summaryrefslogtreecommitdiffstats
path: root/testing/marionette
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /testing/marionette
parentInitial commit. (diff)
downloadfirefox-upstream.tar.xz
firefox-upstream.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/marionette')
-rw-r--r--testing/marionette/.eslintrc.js16
-rw-r--r--testing/marionette/README20
-rw-r--r--testing/marionette/accessibility.js460
-rw-r--r--testing/marionette/action.js1506
-rw-r--r--testing/marionette/actors/MarionetteCommandsChild.jsm546
-rw-r--r--testing/marionette/actors/MarionetteCommandsParent.jsm396
-rw-r--r--testing/marionette/actors/MarionetteEventsChild.jsm74
-rw-r--r--testing/marionette/actors/MarionetteEventsParent.jsm92
-rw-r--r--testing/marionette/actors/MarionetteReftestChild.jsm209
-rw-r--r--testing/marionette/actors/MarionetteReftestParent.jsm44
-rw-r--r--testing/marionette/addon.js136
-rw-r--r--testing/marionette/assert.js464
-rw-r--r--testing/marionette/atom.js223
-rw-r--r--testing/marionette/browser.js532
-rw-r--r--testing/marionette/capabilities.js715
-rw-r--r--testing/marionette/capture.js205
-rw-r--r--testing/marionette/cert.js66
-rw-r--r--testing/marionette/chrome/test.xhtml27
-rw-r--r--testing/marionette/chrome/test2.xhtml20
-rw-r--r--testing/marionette/chrome/test_dialog.dtd7
-rw-r--r--testing/marionette/chrome/test_dialog.properties7
-rw-r--r--testing/marionette/chrome/test_dialog.xhtml37
-rw-r--r--testing/marionette/chrome/test_menupopup.xhtml30
-rw-r--r--testing/marionette/chrome/test_nested_iframe.xhtml9
-rw-r--r--testing/marionette/client/MANIFEST.in2
-rw-r--r--testing/marionette/client/docs/Makefile153
-rw-r--r--testing/marionette/client/docs/advanced/actions.rst21
-rw-r--r--testing/marionette/client/docs/advanced/debug.rst35
-rw-r--r--testing/marionette/client/docs/advanced/findelement.rst87
-rw-r--r--testing/marionette/client/docs/advanced/landing.rst13
-rw-r--r--testing/marionette/client/docs/advanced/stale.rst76
-rw-r--r--testing/marionette/client/docs/basics.rst185
-rw-r--r--testing/marionette/client/docs/conf.py276
-rw-r--r--testing/marionette/client/docs/index.rst16
-rw-r--r--testing/marionette/client/docs/interactive.rst52
-rw-r--r--testing/marionette/client/docs/make.bat190
-rw-r--r--testing/marionette/client/docs/reference.rst66
-rw-r--r--testing/marionette/client/marionette_driver/__init__.py24
-rw-r--r--testing/marionette/client/marionette_driver/addons.py77
-rw-r--r--testing/marionette/client/marionette_driver/by.py27
-rw-r--r--testing/marionette/client/marionette_driver/date_time_value.py51
-rw-r--r--testing/marionette/client/marionette_driver/decorators.py82
-rw-r--r--testing/marionette/client/marionette_driver/errors.py200
-rw-r--r--testing/marionette/client/marionette_driver/expected.py317
-rw-r--r--testing/marionette/client/marionette_driver/geckoinstance.py639
-rw-r--r--testing/marionette/client/marionette_driver/keys.py90
-rw-r--r--testing/marionette/client/marionette_driver/localization.py56
-rw-r--r--testing/marionette/client/marionette_driver/marionette.py1984
-rw-r--r--testing/marionette/client/marionette_driver/timeout.py106
-rw-r--r--testing/marionette/client/marionette_driver/transport.py318
-rw-r--r--testing/marionette/client/marionette_driver/wait.py178
-rw-r--r--testing/marionette/client/requirements.txt3
-rw-r--r--testing/marionette/client/setup.py53
-rw-r--r--testing/marionette/components/marionette.js605
-rw-r--r--testing/marionette/components/marionette.manifest4
-rw-r--r--testing/marionette/components/moz.build11
-rw-r--r--testing/marionette/components/nsIMarionette.idl17
-rw-r--r--testing/marionette/cookie.js296
-rw-r--r--testing/marionette/doc/Building.md50
-rw-r--r--testing/marionette/doc/CodeStyle.md254
-rw-r--r--testing/marionette/doc/Contributing.md78
-rw-r--r--testing/marionette/doc/Debugging.md86
-rw-r--r--testing/marionette/doc/Intro.md82
-rw-r--r--testing/marionette/doc/NewContributors.md90
-rw-r--r--testing/marionette/doc/Patches.md33
-rw-r--r--testing/marionette/doc/Prefs.md80
-rw-r--r--testing/marionette/doc/Protocol.md122
-rw-r--r--testing/marionette/doc/PythonTests.md71
-rw-r--r--testing/marionette/doc/SeleniumAtoms.md84
-rw-r--r--testing/marionette/doc/Taskcluster.md94
-rw-r--r--testing/marionette/doc/Testing.md205
-rw-r--r--testing/marionette/doc/index.rst68
-rw-r--r--testing/marionette/doc/internals/action.rst4
-rw-r--r--testing/marionette/doc/internals/addon.rst7
-rw-r--r--testing/marionette/doc/internals/assert.rst4
-rw-r--r--testing/marionette/doc/internals/browser.rst4
-rw-r--r--testing/marionette/doc/internals/capabilities.rst22
-rw-r--r--testing/marionette/doc/internals/capture.rst7
-rw-r--r--testing/marionette/doc/internals/cert.rst4
-rw-r--r--testing/marionette/doc/internals/cookie.rst4
-rw-r--r--testing/marionette/doc/internals/dom.rst8
-rw-r--r--testing/marionette/doc/internals/driver.rst4
-rw-r--r--testing/marionette/doc/internals/element.rst144
-rw-r--r--testing/marionette/doc/internals/error.rst35
-rw-r--r--testing/marionette/doc/internals/evaluate.rst4
-rw-r--r--testing/marionette/doc/internals/event.rst4
-rw-r--r--testing/marionette/doc/internals/format.rst11
-rw-r--r--testing/marionette/doc/internals/index.rst11
-rw-r--r--testing/marionette/doc/internals/interaction.rst4
-rw-r--r--testing/marionette/doc/internals/listener.rst2
-rw-r--r--testing/marionette/doc/internals/log.rst4
-rw-r--r--testing/marionette/doc/internals/message.rst17
-rw-r--r--testing/marionette/doc/internals/modal.rst4
-rw-r--r--testing/marionette/doc/internals/navigate.rst7
-rw-r--r--testing/marionette/doc/internals/packets.rst22
-rw-r--r--testing/marionette/doc/internals/prefs.rst17
-rw-r--r--testing/marionette/doc/internals/proxy.rst4
-rw-r--r--testing/marionette/doc/internals/reftest.rst4
-rw-r--r--testing/marionette/doc/internals/server.rst12
-rw-r--r--testing/marionette/doc/internals/sync.rst24
-rw-r--r--testing/marionette/dom.js215
-rw-r--r--testing/marionette/driver.js4016
-rw-r--r--testing/marionette/element.js1840
-rw-r--r--testing/marionette/error.js538
-rw-r--r--testing/marionette/evaluate.js629
-rw-r--r--testing/marionette/event.js1138
-rw-r--r--testing/marionette/format.js194
-rw-r--r--testing/marionette/harness/MANIFEST.in4
-rw-r--r--testing/marionette/harness/README.rst30
-rw-r--r--testing/marionette/harness/marionette_harness/__init__.py34
-rw-r--r--testing/marionette/harness/marionette_harness/certificates/test.cert86
-rw-r--r--testing/marionette/harness/marionette_harness/certificates/test.key28
-rw-r--r--testing/marionette/harness/marionette_harness/marionette_test/__init__.py30
-rw-r--r--testing/marionette/harness/marionette_harness/marionette_test/decorators.py215
-rw-r--r--testing/marionette/harness/marionette_harness/marionette_test/testcases.py438
-rw-r--r--testing/marionette/harness/marionette_harness/runner/__init__.py19
-rw-r--r--testing/marionette/harness/marionette_harness/runner/base.py1309
-rwxr-xr-xtesting/marionette/harness/marionette_harness/runner/httpd.py204
-rw-r--r--testing/marionette/harness/marionette_harness/runner/mixins/__init__.py7
-rw-r--r--testing/marionette/harness/marionette_harness/runner/mixins/window_manager.py211
-rwxr-xr-xtesting/marionette/harness/marionette_harness/runner/serve.py243
-rw-r--r--testing/marionette/harness/marionette_harness/runtests.py113
-rw-r--r--testing/marionette/harness/marionette_harness/tests/harness_unit/conftest.py108
-rw-r--r--testing/marionette/harness/marionette_harness/tests/harness_unit/python.ini10
-rw-r--r--testing/marionette/harness/marionette_harness/tests/harness_unit/test_httpd.py94
-rw-r--r--testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_arguments.py82
-rw-r--r--testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_harness.py118
-rw-r--r--testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_runner.py552
-rw-r--r--testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_test_result.py57
-rw-r--r--testing/marionette/harness/marionette_harness/tests/harness_unit/test_serve.py71
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit-tests.ini29
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/data/test.html13
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_accessibility.py273
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_addons.py128
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_capabilities.py249
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_checkbox.py19
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_checkbox_chrome.py33
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_chrome.py33
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_chrome_action.py74
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_chrome_element_css.py33
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_cli_arguments.py68
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_click.py589
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_click_chrome.py35
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_click_scrolling.py169
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_context.py84
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_cookies.py117
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_crash.py216
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_data_driven.py74
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_date_time_value.py35
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_element_rect.py24
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_element_rect_chrome.py30
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_element_retrieval.py503
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_element_state.py177
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_element_state_chrome.py56
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_errors.py107
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_execute_async_script.py242
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_execute_isolate.py48
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_execute_sandboxes.py88
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_execute_script.py502
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_expected.py235
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_expectedfail.py13
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_file_upload.py171
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_findelement_chrome.py116
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_geckoinstance.py27
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_get_current_url_chrome.py41
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_implicit_waits.py28
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_key_actions.py73
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_localization.py73
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_marionette.py111
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_modal_dialogs.py201
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_mouse_action.py199
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_navigation.py931
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_pagesource.py54
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_pagesource_chrome.py26
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_position.py48
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_prefs.py219
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_prefs_enforce.py45
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_profile_management.py253
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_proxy.py164
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_quit_restart.py424
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_reftest.py103
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_rendered_element.py33
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_report.py30
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_run_js_test.py12
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_screen_orientation.py77
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_screenshot.py393
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_select.py220
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_sendkeys_menupopup_chrome.py115
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_session.py55
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_skip_setup.py37
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_switch_frame.py98
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_switch_frame_chrome.py57
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_switch_window_chrome.py111
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_switch_window_content.py213
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_teardown_context_preserved.py23
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_text.py28
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_text_chrome.py37
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_timeouts.py115
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_title.py19
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_title_chrome.py30
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_transport.py114
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_typing.py376
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_unhandled_prompt_behavior.py128
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_visibility.py177
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_wait.py349
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_window_close_chrome.py75
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_window_close_content.py127
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_window_handles_chrome.py255
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_window_handles_content.py142
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_window_management.py141
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_window_maximize.py38
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_window_rect.py317
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_window_status_chrome.py25
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_window_status_content.py94
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/test_window_type_chrome.py28
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/unit-tests.ini106
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/webextension-invalid.xpibin0 -> 295 bytes
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/webextension-signed.xpibin0 -> 4221 bytes
-rw-r--r--testing/marionette/harness/marionette_harness/tests/unit/webextension-unsigned.xpibin0 -> 310 bytes
-rw-r--r--testing/marionette/harness/marionette_harness/www/addons/webextension-signed.xpibin0 -> 4221 bytes
-rw-r--r--testing/marionette/harness/marionette_harness/www/addons/webextension-unsigned.xpibin0 -> 310 bytes
-rw-r--r--testing/marionette/harness/marionette_harness/www/black.pngbin0 -> 150 bytes
-rw-r--r--testing/marionette/harness/marionette_harness/www/bug814037.html56
-rw-r--r--testing/marionette/harness/marionette_harness/www/click_out_of_bounds_overflow.html90
-rw-r--r--testing/marionette/harness/marionette_harness/www/clicks.html57
-rw-r--r--testing/marionette/harness/marionette_harness/www/element_outside_viewport.html41
-rw-r--r--testing/marionette/harness/marionette_harness/www/empty.html12
-rw-r--r--testing/marionette/harness/marionette_harness/www/formPage.html114
-rw-r--r--testing/marionette/harness/marionette_harness/www/frameset.html13
-rw-r--r--testing/marionette/harness/marionette_harness/www/framesetPage2.html7
-rw-r--r--testing/marionette/harness/marionette_harness/www/html5/blue.jpgbin0 -> 92 bytes
-rw-r--r--testing/marionette/harness/marionette_harness/www/html5/boolean_attributes.html2
-rw-r--r--testing/marionette/harness/marionette_harness/www/html5/geolocation.js29
-rw-r--r--testing/marionette/harness/marionette_harness/www/html5/green.jpgbin0 -> 92 bytes
-rw-r--r--testing/marionette/harness/marionette_harness/www/html5/offline.html1
-rw-r--r--testing/marionette/harness/marionette_harness/www/html5/red.jpgbin0 -> 92 bytes
-rw-r--r--testing/marionette/harness/marionette_harness/www/html5/status.html1
-rw-r--r--testing/marionette/harness/marionette_harness/www/html5/test.appcache11
-rw-r--r--testing/marionette/harness/marionette_harness/www/html5/test_html_inputs.html2
-rw-r--r--testing/marionette/harness/marionette_harness/www/html5/yellow.jpgbin0 -> 92 bytes
-rw-r--r--testing/marionette/harness/marionette_harness/www/html5Page.html46
-rw-r--r--testing/marionette/harness/marionette_harness/www/keyboard.html99
-rw-r--r--testing/marionette/harness/marionette_harness/www/layout/test_carets_columns.html31
-rw-r--r--testing/marionette/harness/marionette_harness/www/layout/test_carets_cursor.html31
-rw-r--r--testing/marionette/harness/marionette_harness/www/layout/test_carets_display_none.html10
-rw-r--r--testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe.html15
-rw-r--r--testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe_scroll.html11
-rw-r--r--testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe_scroll_inner.html24
-rw-r--r--testing/marionette/harness/marionette_harness/www/layout/test_carets_longtext.html9
-rw-r--r--testing/marionette/harness/marionette_harness/www/layout/test_carets_multipleline.html18
-rw-r--r--testing/marionette/harness/marionette_harness/www/layout/test_carets_multiplerange.html19
-rw-r--r--testing/marionette/harness/marionette_harness/www/layout/test_carets_selection.html42
-rw-r--r--testing/marionette/harness/marionette_harness/www/layout/test_carets_svg_shapes.html12
-rw-r--r--testing/marionette/harness/marionette_harness/www/navigation_pushstate.html20
-rw-r--r--testing/marionette/harness/marionette_harness/www/navigation_pushstate_target.html13
-rw-r--r--testing/marionette/harness/marionette_harness/www/nestedElements.html9
-rw-r--r--testing/marionette/harness/marionette_harness/www/reftest/mostly-teal-700x700.html21
-rw-r--r--testing/marionette/harness/marionette_harness/www/reftest/teal-700x700.html21
-rw-r--r--testing/marionette/harness/marionette_harness/www/resultPage.html16
-rw-r--r--testing/marionette/harness/marionette_harness/www/serviceworker/install_serviceworker.html11
-rw-r--r--testing/marionette/harness/marionette_harness/www/serviceworker/serviceworker.js0
-rw-r--r--testing/marionette/harness/marionette_harness/www/shim.js297
-rw-r--r--testing/marionette/harness/marionette_harness/www/slow_resource.html13
-rw-r--r--testing/marionette/harness/marionette_harness/www/test.html38
-rw-r--r--testing/marionette/harness/marionette_harness/www/testAction.html96
-rw-r--r--testing/marionette/harness/marionette_harness/www/test_accessibility.html57
-rw-r--r--testing/marionette/harness/marionette_harness/www/test_clearing.html24
-rw-r--r--testing/marionette/harness/marionette_harness/www/test_dynamic.html38
-rw-r--r--testing/marionette/harness/marionette_harness/www/test_iframe.html16
-rw-r--r--testing/marionette/harness/marionette_harness/www/test_inner_iframe.html13
-rw-r--r--testing/marionette/harness/marionette_harness/www/test_nested_iframe.html13
-rw-r--r--testing/marionette/harness/marionette_harness/www/test_oop_1.html14
-rw-r--r--testing/marionette/harness/marionette_harness/www/test_oop_2.html14
-rw-r--r--testing/marionette/harness/marionette_harness/www/test_tab_modal_dialogs.html44
-rw-r--r--testing/marionette/harness/marionette_harness/www/test_windows.html13
-rw-r--r--testing/marionette/harness/marionette_harness/www/visibility.html51
-rw-r--r--testing/marionette/harness/marionette_harness/www/white.pngbin0 -> 150 bytes
-rw-r--r--testing/marionette/harness/marionette_harness/www/windowHandles.html16
-rw-r--r--testing/marionette/harness/marionette_harness/www/xhtmlTest.html79
-rw-r--r--testing/marionette/harness/requirements.txt15
-rw-r--r--testing/marionette/harness/setup.py61
-rw-r--r--testing/marionette/interaction.js771
-rw-r--r--testing/marionette/jar.mn60
-rw-r--r--testing/marionette/l10n.js109
-rw-r--r--testing/marionette/legacyaction.js630
-rw-r--r--testing/marionette/listener.js1069
-rw-r--r--testing/marionette/log.js66
-rw-r--r--testing/marionette/mach_commands.py112
-rw-r--r--testing/marionette/mach_test_package_commands.py75
-rw-r--r--testing/marionette/message.js331
-rw-r--r--testing/marionette/modal.js244
-rw-r--r--testing/marionette/moz.build22
-rw-r--r--testing/marionette/navigate.js414
-rw-r--r--testing/marionette/packets.js429
-rw-r--r--testing/marionette/prefs.js280
-rw-r--r--testing/marionette/print.js129
-rw-r--r--testing/marionette/proxy.js340
-rw-r--r--testing/marionette/reftest-content.js67
-rw-r--r--testing/marionette/reftest.js908
-rw-r--r--testing/marionette/reftest.xhtml6
-rw-r--r--testing/marionette/server.js410
-rw-r--r--testing/marionette/stream-utils.js261
-rw-r--r--testing/marionette/sync.js650
-rw-r--r--testing/marionette/test/README1
-rw-r--r--testing/marionette/test/unit/.eslintrc.js7
-rw-r--r--testing/marionette/test/unit/README16
-rw-r--r--testing/marionette/test/unit/test_action.js712
-rw-r--r--testing/marionette/test/unit/test_actors.js49
-rw-r--r--testing/marionette/test/unit/test_assert.js207
-rw-r--r--testing/marionette/test/unit/test_browser.js25
-rw-r--r--testing/marionette/test/unit/test_capabilities.js609
-rw-r--r--testing/marionette/test/unit/test_cookie.js368
-rw-r--r--testing/marionette/test/unit/test_dom.js275
-rw-r--r--testing/marionette/test/unit/test_element.js609
-rw-r--r--testing/marionette/test/unit/test_error.js477
-rw-r--r--testing/marionette/test/unit/test_evaluate.js342
-rw-r--r--testing/marionette/test/unit/test_format.js118
-rw-r--r--testing/marionette/test/unit/test_message.js277
-rw-r--r--testing/marionette/test/unit/test_modal.js148
-rw-r--r--testing/marionette/test/unit/test_navigate.js88
-rw-r--r--testing/marionette/test/unit/test_prefs.js133
-rw-r--r--testing/marionette/test/unit/test_store.js220
-rw-r--r--testing/marionette/test/unit/test_sync.js521
-rw-r--r--testing/marionette/test/unit/xpcshell.ini24
-rw-r--r--testing/marionette/transport.js537
-rw-r--r--testing/marionette/wm.js7
326 files changed, 53356 insertions, 0 deletions
diff --git a/testing/marionette/.eslintrc.js b/testing/marionette/.eslintrc.js
new file mode 100644
index 0000000000..43fe24f843
--- /dev/null
+++ b/testing/marionette/.eslintrc.js
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// inherits from ../../tools/lint/eslint/eslint-plugin-mozilla/lib/configs/recommended.js
+
+module.exports = {
+ rules: {
+ camelcase: ["error", { properties: "never" }],
+ "no-fallthrough": "error",
+ "no-undef-init": "error",
+ "no-var": "error",
+ },
+};
diff --git a/testing/marionette/README b/testing/marionette/README
new file mode 100644
index 0000000000..6dd268afec
--- /dev/null
+++ b/testing/marionette/README
@@ -0,0 +1,20 @@
+Marionette [ ˌmarɪəˈnɛt] is
+
+ * a puppet worked by strings: the bird bobs up and down like
+ a marionette;
+
+ * a person who is easily manipulated or controlled: many officers
+ dismissed him as the mayor’s marionette;
+
+ * the remote protocol that lets out-of-process programs communicate
+ with, instrument, and control Gecko-based browsers.
+
+Marionette provides interfaces for interacting with both the internal
+JavaScript runtime and UI elements of Gecko-based browsers, such
+as Firefox and Fennec. It can control both the chrome- and content
+documents, giving a high level of control and ability to replicate,
+or emulate, user interaction.
+
+Head on to the Marionette documentation to find out more:
+
+ https://firefox-source-docs.mozilla.org/testing/marionette/marionette/
diff --git a/testing/marionette/accessibility.js b/testing/marionette/accessibility.js
new file mode 100644
index 0000000000..99aa211d74
--- /dev/null
+++ b/testing/marionette/accessibility.js
@@ -0,0 +1,460 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["accessibility"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ error: "chrome://marionette/content/error.js",
+ Log: "chrome://marionette/content/log.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+
+XPCOMUtils.defineLazyGetter(this, "service", () => {
+ try {
+ return Cc["@mozilla.org/accessibilityService;1"].getService(
+ Ci.nsIAccessibilityService
+ );
+ } catch (e) {
+ logger.warn("Accessibility module is not present");
+ return undefined;
+ }
+});
+
+/** @namespace */
+this.accessibility = {
+ get service() {
+ return service;
+ },
+};
+
+/**
+ * Accessible states used to check element"s state from the accessiblity API
+ * perspective.
+ *
+ * Note: if gecko is built with --disable-accessibility, the interfaces
+ * are not defined. This is why we use getters instead to be able to use
+ * these statically.
+ */
+accessibility.State = {
+ get Unavailable() {
+ return Ci.nsIAccessibleStates.STATE_UNAVAILABLE;
+ },
+ get Focusable() {
+ return Ci.nsIAccessibleStates.STATE_FOCUSABLE;
+ },
+ get Selectable() {
+ return Ci.nsIAccessibleStates.STATE_SELECTABLE;
+ },
+ get Selected() {
+ return Ci.nsIAccessibleStates.STATE_SELECTED;
+ },
+};
+
+/**
+ * Accessible object roles that support some action.
+ */
+accessibility.ActionableRoles = new Set([
+ "checkbutton",
+ "check menu item",
+ "check rich option",
+ "combobox",
+ "combobox option",
+ "entry",
+ "key",
+ "link",
+ "listbox option",
+ "listbox rich option",
+ "menuitem",
+ "option",
+ "outlineitem",
+ "pagetab",
+ "pushbutton",
+ "radiobutton",
+ "radio menu item",
+ "rowheader",
+ "slider",
+ "spinbutton",
+ "switch",
+]);
+
+/**
+ * Factory function that constructs a new {@code accessibility.Checks}
+ * object with enforced strictness or not.
+ */
+accessibility.get = function(strict = false) {
+ return new accessibility.Checks(!!strict);
+};
+
+/**
+ * Component responsible for interacting with platform accessibility
+ * API.
+ *
+ * Its methods serve as wrappers for testing content and chrome
+ * accessibility as well as accessibility of user interactions.
+ */
+accessibility.Checks = class {
+ /**
+ * @param {boolean} strict
+ * Flag indicating whether the accessibility issue should be logged
+ * or cause an error to be thrown. Default is to log to stdout.
+ */
+ constructor(strict) {
+ this.strict = strict;
+ }
+
+ /**
+ * Get an accessible object for an element.
+ *
+ * @param {DOMElement|XULElement} element
+ * Element to get the accessible object for.
+ * @param {boolean=} mustHaveAccessible
+ * Flag indicating that the element must have an accessible object.
+ * Defaults to not require this.
+ *
+ * @return {Promise.<nsIAccessible>}
+ * Promise with an accessibility object for the given element.
+ */
+ getAccessible(element, mustHaveAccessible = false) {
+ if (!this.strict) {
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve, reject) => {
+ if (!accessibility.service) {
+ reject();
+ return;
+ }
+
+ // First, check if accessibility is ready.
+ let docAcc = accessibility.service.getAccessibleFor(
+ element.ownerDocument
+ );
+ let state = {};
+ docAcc.getState(state, {});
+ if ((state.value & Ci.nsIAccessibleStates.STATE_BUSY) == 0) {
+ // Accessibility is ready, resolve immediately.
+ let acc = accessibility.service.getAccessibleFor(element);
+ if (mustHaveAccessible && !acc) {
+ reject();
+ } else {
+ resolve(acc);
+ }
+ return;
+ }
+ // Accessibility for the doc is busy, so wait for the state to change.
+ let eventObserver = {
+ observe(subject, topic) {
+ if (topic !== "accessible-event") {
+ return;
+ }
+
+ // If event type does not match expected type, skip the event.
+ let event = subject.QueryInterface(Ci.nsIAccessibleEvent);
+ if (event.eventType !== Ci.nsIAccessibleEvent.EVENT_STATE_CHANGE) {
+ return;
+ }
+
+ // If event's accessible does not match expected accessible,
+ // skip the event.
+ if (event.accessible !== docAcc) {
+ return;
+ }
+
+ Services.obs.removeObserver(this, "accessible-event");
+ let acc = accessibility.service.getAccessibleFor(element);
+ if (mustHaveAccessible && !acc) {
+ reject();
+ } else {
+ resolve(acc);
+ }
+ },
+ };
+ Services.obs.addObserver(eventObserver, "accessible-event");
+ }).catch(() =>
+ this.error("Element does not have an accessible object", element)
+ );
+ }
+
+ /**
+ * Test if the accessible has a role that supports some arbitrary
+ * action.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @return {boolean}
+ * True if an actionable role is found on the accessible, false
+ * otherwise.
+ */
+ isActionableRole(accessible) {
+ return accessibility.ActionableRoles.has(
+ accessibility.service.getStringRole(accessible.role)
+ );
+ }
+
+ /**
+ * Test if an accessible has at least one action that it supports.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @return {boolean}
+ * True if the accessible has at least one supported action,
+ * false otherwise.
+ */
+ hasActionCount(accessible) {
+ return accessible.actionCount > 0;
+ }
+
+ /**
+ * Test if an accessible has a valid name.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @return {boolean}
+ * True if the accessible has a non-empty valid name, or false if
+ * this is not the case.
+ */
+ hasValidName(accessible) {
+ return accessible.name && accessible.name.trim();
+ }
+
+ /**
+ * Test if an accessible has a {@code hidden} attribute.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @return {boolean}
+ * True if the accessible object has a {@code hidden} attribute,
+ * false otherwise.
+ */
+ hasHiddenAttribute(accessible) {
+ let hidden = false;
+ try {
+ hidden = accessible.attributes.getStringProperty("hidden");
+ } catch (e) {}
+ // if the property is missing, error will be thrown
+ return hidden && hidden === "true";
+ }
+
+ /**
+ * Verify if an accessible has a given state.
+ * Test if an accessible has a given state.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object to test.
+ * @param {number} stateToMatch
+ * State to match.
+ *
+ * @return {boolean}
+ * True if |accessible| has |stateToMatch|, false otherwise.
+ */
+ matchState(accessible, stateToMatch) {
+ let state = {};
+ accessible.getState(state, {});
+ return !!(state.value & stateToMatch);
+ }
+
+ /**
+ * Test if an accessible is hidden from the user.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @return {boolean}
+ * True if element is hidden from user, false otherwise.
+ */
+ isHidden(accessible) {
+ if (!accessible) {
+ return true;
+ }
+
+ while (accessible) {
+ if (this.hasHiddenAttribute(accessible)) {
+ return true;
+ }
+ accessible = accessible.parent;
+ }
+ return false;
+ }
+
+ /**
+ * Test if the element's visible state corresponds to its accessibility
+ * API visibility.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ * @param {DOMElement|XULElement} element
+ * Element associated with |accessible|.
+ * @param {boolean} visible
+ * Visibility state of |element|.
+ *
+ * @throws ElementNotAccessibleError
+ * If |element|'s visibility state does not correspond to
+ * |accessible|'s.
+ */
+ assertVisible(accessible, element, visible) {
+ let hiddenAccessibility = this.isHidden(accessible);
+
+ let message;
+ if (visible && hiddenAccessibility) {
+ message =
+ "Element is not currently visible via the accessibility API " +
+ "and may not be manipulated by it";
+ } else if (!visible && !hiddenAccessibility) {
+ message =
+ "Element is currently only visible via the accessibility API " +
+ "and can be manipulated by it";
+ }
+ this.error(message, element);
+ }
+
+ /**
+ * Test if the element's unavailable accessibility state matches the
+ * enabled state.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ * @param {DOMElement|XULElement} element
+ * Element associated with |accessible|.
+ * @param {boolean} enabled
+ * Enabled state of |element|.
+ *
+ * @throws ElementNotAccessibleError
+ * If |element|'s enabled state does not match |accessible|'s.
+ */
+ assertEnabled(accessible, element, enabled) {
+ if (!accessible) {
+ return;
+ }
+
+ let win = element.ownerGlobal;
+ let disabledAccessibility = this.matchState(
+ accessible,
+ accessibility.State.Unavailable
+ );
+ let explorable =
+ win.getComputedStyle(element).getPropertyValue("pointer-events") !==
+ "none";
+
+ let message;
+ if (!explorable && !disabledAccessibility) {
+ message =
+ "Element is enabled but is not explorable via the " +
+ "accessibility API";
+ } else if (enabled && disabledAccessibility) {
+ message = "Element is enabled but disabled via the accessibility API";
+ } else if (!enabled && !disabledAccessibility) {
+ message = "Element is disabled but enabled via the accessibility API";
+ }
+ this.error(message, element);
+ }
+
+ /**
+ * Test if it is possible to activate an element with the accessibility
+ * API.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ * @param {DOMElement|XULElement} element
+ * Element associated with |accessible|.
+ *
+ * @throws ElementNotAccessibleError
+ * If it is impossible to activate |element| with |accessible|.
+ */
+ assertActionable(accessible, element) {
+ if (!accessible) {
+ return;
+ }
+
+ let message;
+ if (!this.hasActionCount(accessible)) {
+ message = "Element does not support any accessible actions";
+ } else if (!this.isActionableRole(accessible)) {
+ message =
+ "Element does not have a correct accessibility role " +
+ "and may not be manipulated via the accessibility API";
+ } else if (!this.hasValidName(accessible)) {
+ message = "Element is missing an accessible name";
+ } else if (!this.matchState(accessible, accessibility.State.Focusable)) {
+ message = "Element is not focusable via the accessibility API";
+ }
+
+ this.error(message, element);
+ }
+
+ /**
+ * Test that an element's selected state corresponds to its
+ * accessibility API selected state.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ * @param {DOMElement|XULElement}
+ * Element associated with |accessible|.
+ * @param {boolean} selected
+ * The |element|s selected state.
+ *
+ * @throws ElementNotAccessibleError
+ * If |element|'s selected state does not correspond to
+ * |accessible|'s.
+ */
+ assertSelected(accessible, element, selected) {
+ if (!accessible) {
+ return;
+ }
+
+ // element is not selectable via the accessibility API
+ if (!this.matchState(accessible, accessibility.State.Selectable)) {
+ return;
+ }
+
+ let selectedAccessibility = this.matchState(
+ accessible,
+ accessibility.State.Selected
+ );
+
+ let message;
+ if (selected && !selectedAccessibility) {
+ message =
+ "Element is selected but not selected via the accessibility API";
+ } else if (!selected && selectedAccessibility) {
+ message =
+ "Element is not selected but selected via the accessibility API";
+ }
+ this.error(message, element);
+ }
+
+ /**
+ * Throw an error if strict accessibility checks are enforced and log
+ * the error to the log.
+ *
+ * @param {string} message
+ * @param {DOMElement|XULElement} element
+ * Element that caused an error.
+ *
+ * @throws ElementNotAccessibleError
+ * If |strict| is true.
+ */
+ error(message, element) {
+ if (!message || !this.strict) {
+ return;
+ }
+ if (element) {
+ let { id, tagName, className } = element;
+ message += `: id: ${id}, tagName: ${tagName}, className: ${className}`;
+ }
+
+ throw new error.ElementNotAccessibleError(message);
+ }
+};
diff --git a/testing/marionette/action.js b/testing/marionette/action.js
new file mode 100644
index 0000000000..1c47803256
--- /dev/null
+++ b/testing/marionette/action.js
@@ -0,0 +1,1506 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint no-dupe-keys:off */
+/* eslint-disable no-restricted-globals */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["action"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ assert: "chrome://marionette/content/assert.js",
+ element: "chrome://marionette/content/element.js",
+ error: "chrome://marionette/content/error.js",
+ event: "chrome://marionette/content/event.js",
+ pprint: "chrome://marionette/content/format.js",
+ Sleep: "chrome://marionette/content/sync.js",
+});
+
+// TODO? With ES 2016 and Symbol you can make a safer approximation
+// to an enum e.g. https://gist.github.com/xmlking/e86e4f15ec32b12c4689
+/**
+ * Implements WebDriver Actions API: a low-level interface for providing
+ * virtualised device input to the web browser.
+ *
+ * @namespace
+ */
+this.action = {
+ Pause: "pause",
+ KeyDown: "keyDown",
+ KeyUp: "keyUp",
+ PointerDown: "pointerDown",
+ PointerUp: "pointerUp",
+ PointerMove: "pointerMove",
+ PointerCancel: "pointerCancel",
+};
+
+const ACTIONS = {
+ none: new Set([action.Pause]),
+ key: new Set([action.Pause, action.KeyDown, action.KeyUp]),
+ pointer: new Set([
+ action.Pause,
+ action.PointerDown,
+ action.PointerUp,
+ action.PointerMove,
+ action.PointerCancel,
+ ]),
+};
+
+/** Map from normalized key value to UI Events modifier key name */
+const MODIFIER_NAME_LOOKUP = {
+ Alt: "alt",
+ Shift: "shift",
+ Control: "ctrl",
+ Meta: "meta",
+};
+
+/** Map from raw key (codepoint) to normalized key value */
+const NORMALIZED_KEY_LOOKUP = {
+ "\uE000": "Unidentified",
+ "\uE001": "Cancel",
+ "\uE002": "Help",
+ "\uE003": "Backspace",
+ "\uE004": "Tab",
+ "\uE005": "Clear",
+ "\uE006": "Enter",
+ "\uE007": "Enter",
+ "\uE008": "Shift",
+ "\uE009": "Control",
+ "\uE00A": "Alt",
+ "\uE00B": "Pause",
+ "\uE00C": "Escape",
+ "\uE00D": " ",
+ "\uE00E": "PageUp",
+ "\uE00F": "PageDown",
+ "\uE010": "End",
+ "\uE011": "Home",
+ "\uE012": "ArrowLeft",
+ "\uE013": "ArrowUp",
+ "\uE014": "ArrowRight",
+ "\uE015": "ArrowDown",
+ "\uE016": "Insert",
+ "\uE017": "Delete",
+ "\uE018": ";",
+ "\uE019": "=",
+ "\uE01A": "0",
+ "\uE01B": "1",
+ "\uE01C": "2",
+ "\uE01D": "3",
+ "\uE01E": "4",
+ "\uE01F": "5",
+ "\uE020": "6",
+ "\uE021": "7",
+ "\uE022": "8",
+ "\uE023": "9",
+ "\uE024": "*",
+ "\uE025": "+",
+ "\uE026": ",",
+ "\uE027": "-",
+ "\uE028": ".",
+ "\uE029": "/",
+ "\uE031": "F1",
+ "\uE032": "F2",
+ "\uE033": "F3",
+ "\uE034": "F4",
+ "\uE035": "F5",
+ "\uE036": "F6",
+ "\uE037": "F7",
+ "\uE038": "F8",
+ "\uE039": "F9",
+ "\uE03A": "F10",
+ "\uE03B": "F11",
+ "\uE03C": "F12",
+ "\uE03D": "Meta",
+ "\uE040": "ZenkakuHankaku",
+ "\uE050": "Shift",
+ "\uE051": "Control",
+ "\uE052": "Alt",
+ "\uE053": "Meta",
+ "\uE054": "PageUp",
+ "\uE055": "PageDown",
+ "\uE056": "End",
+ "\uE057": "Home",
+ "\uE058": "ArrowLeft",
+ "\uE059": "ArrowUp",
+ "\uE05A": "ArrowRight",
+ "\uE05B": "ArrowDown",
+ "\uE05C": "Insert",
+ "\uE05D": "Delete",
+};
+
+/** Map from raw key (codepoint) to key location */
+const KEY_LOCATION_LOOKUP = {
+ "\uE007": 1,
+ "\uE008": 1,
+ "\uE009": 1,
+ "\uE00A": 1,
+ "\uE01A": 3,
+ "\uE01B": 3,
+ "\uE01C": 3,
+ "\uE01D": 3,
+ "\uE01E": 3,
+ "\uE01F": 3,
+ "\uE020": 3,
+ "\uE021": 3,
+ "\uE022": 3,
+ "\uE023": 3,
+ "\uE024": 3,
+ "\uE025": 3,
+ "\uE026": 3,
+ "\uE027": 3,
+ "\uE028": 3,
+ "\uE029": 3,
+ "\uE03D": 1,
+ "\uE050": 2,
+ "\uE051": 2,
+ "\uE052": 2,
+ "\uE053": 2,
+ "\uE054": 3,
+ "\uE055": 3,
+ "\uE056": 3,
+ "\uE057": 3,
+ "\uE058": 3,
+ "\uE059": 3,
+ "\uE05A": 3,
+ "\uE05B": 3,
+ "\uE05C": 3,
+ "\uE05D": 3,
+};
+
+const KEY_CODE_LOOKUP = {
+ "\uE00A": "AltLeft",
+ "\uE052": "AltRight",
+ "\uE015": "ArrowDown",
+ "\uE012": "ArrowLeft",
+ "\uE014": "ArrowRight",
+ "\uE013": "ArrowUp",
+ "`": "Backquote",
+ "~": "Backquote",
+ "\\": "Backslash",
+ "|": "Backslash",
+ "\uE003": "Backspace",
+ "[": "BracketLeft",
+ "{": "BracketLeft",
+ "]": "BracketRight",
+ "}": "BracketRight",
+ ",": "Comma",
+ "<": "Comma",
+ "\uE009": "ControlLeft",
+ "\uE051": "ControlRight",
+ "\uE017": "Delete",
+ ")": "Digit0",
+ "0": "Digit0",
+ "!": "Digit1",
+ "1": "Digit1",
+ "2": "Digit2",
+ "@": "Digit2",
+ "#": "Digit3",
+ "3": "Digit3",
+ $: "Digit4",
+ "4": "Digit4",
+ "%": "Digit5",
+ "5": "Digit5",
+ "6": "Digit6",
+ "^": "Digit6",
+ "&": "Digit7",
+ "7": "Digit7",
+ "*": "Digit8",
+ "8": "Digit8",
+ "(": "Digit9",
+ "9": "Digit9",
+ "\uE010": "End",
+ "\uE006": "Enter",
+ "+": "Equal",
+ "=": "Equal",
+ "\uE00C": "Escape",
+ "\uE031": "F1",
+ "\uE03A": "F10",
+ "\uE03B": "F11",
+ "\uE03C": "F12",
+ "\uE032": "F2",
+ "\uE033": "F3",
+ "\uE034": "F4",
+ "\uE035": "F5",
+ "\uE036": "F6",
+ "\uE037": "F7",
+ "\uE038": "F8",
+ "\uE039": "F9",
+ "\uE002": "Help",
+ "\uE011": "Home",
+ "\uE016": "Insert",
+ "<": "IntlBackslash",
+ ">": "IntlBackslash",
+ A: "KeyA",
+ a: "KeyA",
+ B: "KeyB",
+ b: "KeyB",
+ C: "KeyC",
+ c: "KeyC",
+ D: "KeyD",
+ d: "KeyD",
+ E: "KeyE",
+ e: "KeyE",
+ F: "KeyF",
+ f: "KeyF",
+ G: "KeyG",
+ g: "KeyG",
+ H: "KeyH",
+ h: "KeyH",
+ I: "KeyI",
+ i: "KeyI",
+ J: "KeyJ",
+ j: "KeyJ",
+ K: "KeyK",
+ k: "KeyK",
+ L: "KeyL",
+ l: "KeyL",
+ M: "KeyM",
+ m: "KeyM",
+ N: "KeyN",
+ n: "KeyN",
+ O: "KeyO",
+ o: "KeyO",
+ P: "KeyP",
+ p: "KeyP",
+ Q: "KeyQ",
+ q: "KeyQ",
+ R: "KeyR",
+ r: "KeyR",
+ S: "KeyS",
+ s: "KeyS",
+ T: "KeyT",
+ t: "KeyT",
+ U: "KeyU",
+ u: "KeyU",
+ V: "KeyV",
+ v: "KeyV",
+ W: "KeyW",
+ w: "KeyW",
+ X: "KeyX",
+ x: "KeyX",
+ Y: "KeyY",
+ y: "KeyY",
+ Z: "KeyZ",
+ z: "KeyZ",
+ "-": "Minus",
+ _: "Minus",
+ "\uE01A": "Numpad0",
+ "\uE05C": "Numpad0",
+ "\uE01B": "Numpad1",
+ "\uE056": "Numpad1",
+ "\uE01C": "Numpad2",
+ "\uE05B": "Numpad2",
+ "\uE01D": "Numpad3",
+ "\uE055": "Numpad3",
+ "\uE01E": "Numpad4",
+ "\uE058": "Numpad4",
+ "\uE01F": "Numpad5",
+ "\uE020": "Numpad6",
+ "\uE05A": "Numpad6",
+ "\uE021": "Numpad7",
+ "\uE057": "Numpad7",
+ "\uE022": "Numpad8",
+ "\uE059": "Numpad8",
+ "\uE023": "Numpad9",
+ "\uE054": "Numpad9",
+ "\uE024": "NumpadAdd",
+ "\uE026": "NumpadComma",
+ "\uE028": "NumpadDecimal",
+ "\uE05D": "NumpadDecimal",
+ "\uE029": "NumpadDivide",
+ "\uE007": "NumpadEnter",
+ "\uE024": "NumpadMultiply",
+ "\uE026": "NumpadSubtract",
+ "\uE03D": "OSLeft",
+ "\uE053": "OSRight",
+ "\uE01E": "PageDown",
+ "\uE01F": "PageUp",
+ ".": "Period",
+ ">": "Period",
+ '"': "Quote",
+ "'": "Quote",
+ ":": "Semicolon",
+ ";": "Semicolon",
+ "\uE008": "ShiftLeft",
+ "\uE050": "ShiftRight",
+ "/": "Slash",
+ "?": "Slash",
+ "\uE00D": "Space",
+ " ": "Space",
+ "\uE004": "Tab",
+};
+
+/** Represents possible values for a pointer-move origin. */
+action.PointerOrigin = {
+ Viewport: "viewport",
+ Pointer: "pointer",
+};
+
+/** Flag for WebDriver spec conforming pointer origin calculation. */
+action.specCompatPointerOrigin = true;
+
+/**
+ * Look up a PointerOrigin.
+ *
+ * @param {(string|Element)=} obj
+ * Origin for a <code>pointerMove</code> action. Must be one of
+ * "viewport" (default), "pointer", or a DOM element.
+ *
+ * @return {action.PointerOrigin}
+ * Pointer origin.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not a valid origin.
+ */
+action.PointerOrigin.get = function(obj) {
+ let origin = obj;
+ if (typeof obj == "undefined") {
+ origin = this.Viewport;
+ } else if (typeof obj == "string") {
+ let name = capitalize(obj);
+ assert.in(name, this, pprint`Unknown pointer-move origin: ${obj}`);
+ origin = this[name];
+ } else if (!element.isElement(obj)) {
+ throw new error.InvalidArgumentError(
+ "Expected 'origin' to be undefined, " +
+ '"viewport", "pointer", ' +
+ pprint`or an element, got: ${obj}`
+ );
+ }
+ return origin;
+};
+
+/** Represents possible subtypes for a pointer input source. */
+action.PointerType = {
+ Mouse: "mouse",
+ // TODO For now, only mouse is supported
+ // Pen: "pen",
+ // Touch: "touch",
+};
+
+/**
+ * Look up a PointerType.
+ *
+ * @param {string} str
+ * Name of pointer type.
+ *
+ * @return {string}
+ * A pointer type for processing pointer parameters.
+ *
+ * @throws {InvalidArgumentError}
+ * If <code>str</code> is not a valid pointer type.
+ */
+action.PointerType.get = function(str) {
+ let name = capitalize(str);
+ assert.in(name, this, pprint`Unknown pointerType: ${str}`);
+ return this[name];
+};
+
+/**
+ * Input state associated with current session. This is a map between
+ * input ID and the device state for that input source, with one entry
+ * for each active input source.
+ *
+ * Re-initialized in listener.js.
+ */
+action.inputStateMap = new Map();
+
+/**
+ * List of {@link action.Action} associated with current session. Used to
+ * manage dispatching events when resetting the state of the input sources.
+ * Reset operations are assumed to be idempotent.
+ *
+ * Re-initialized in listener.js
+ */
+action.inputsToCancel = [];
+
+/**
+ * Represents device state for an input source.
+ */
+class InputState {
+ constructor() {
+ this.type = this.constructor.name.toLowerCase();
+ }
+
+ /**
+ * Check equality of this InputState object with another.
+ *
+ * @param {InputState} other
+ * Object representing an input state.
+ *
+ * @return {boolean}
+ * True if <code>this</code> has the same <code>type</code>
+ * as <code>other</code>.
+ */
+ is(other) {
+ if (typeof other == "undefined") {
+ return false;
+ }
+ return this.type === other.type;
+ }
+
+ toString() {
+ return `[object ${this.constructor.name}InputState]`;
+ }
+
+ /**
+ * @param {Object.<string, ?>} obj
+ * Object with property <code>type</code> and optionally
+ * <code>parameters</code> or <code>pointerType</code>,
+ * representing an action sequence or an action item.
+ *
+ * @return {action.InputState}
+ * An {@link InputState} object for the type of the
+ * {@link actionSequence}.
+ *
+ * @throws {InvalidArgumentError}
+ * If {@link actionSequence.type} is not valid.
+ */
+ static fromJSON(obj) {
+ let type = obj.type;
+ assert.in(type, ACTIONS, pprint`Unknown action type: ${type}`);
+ let name = type == "none" ? "Null" : capitalize(type);
+ if (name == "Pointer") {
+ if (
+ !obj.pointerType &&
+ (!obj.parameters || !obj.parameters.pointerType)
+ ) {
+ throw new error.InvalidArgumentError(
+ pprint`Expected obj to have pointerType, got ${obj}`
+ );
+ }
+ let pointerType = obj.pointerType || obj.parameters.pointerType;
+ return new action.InputState[name](pointerType);
+ }
+ return new action.InputState[name]();
+ }
+}
+
+/** Possible kinds of |InputState| for supported input sources. */
+action.InputState = {};
+
+/**
+ * Input state associated with a keyboard-type device.
+ */
+action.InputState.Key = class Key extends InputState {
+ constructor() {
+ super();
+ this.pressed = new Set();
+ this.alt = false;
+ this.shift = false;
+ this.ctrl = false;
+ this.meta = false;
+ }
+
+ /**
+ * Update modifier state according to |key|.
+ *
+ * @param {string} key
+ * Normalized key value of a modifier key.
+ * @param {boolean} value
+ * Value to set the modifier attribute to.
+ *
+ * @throws {InvalidArgumentError}
+ * If |key| is not a modifier.
+ */
+ setModState(key, value) {
+ if (key in MODIFIER_NAME_LOOKUP) {
+ this[MODIFIER_NAME_LOOKUP[key]] = value;
+ } else {
+ throw new error.InvalidArgumentError(
+ "Expected 'key' to be one of " +
+ Object.keys(MODIFIER_NAME_LOOKUP) +
+ pprint`, got ${key}`
+ );
+ }
+ }
+
+ /**
+ * Check whether |key| is pressed.
+ *
+ * @param {string} key
+ * Normalized key value.
+ *
+ * @return {boolean}
+ * True if |key| is in set of pressed keys.
+ */
+ isPressed(key) {
+ return this.pressed.has(key);
+ }
+
+ /**
+ * Add |key| to the set of pressed keys.
+ *
+ * @param {string} key
+ * Normalized key value.
+ *
+ * @return {boolean}
+ * True if |key| is in list of pressed keys.
+ */
+ press(key) {
+ return this.pressed.add(key);
+ }
+
+ /**
+ * Remove |key| from the set of pressed keys.
+ *
+ * @param {string} key
+ * Normalized key value.
+ *
+ * @return {boolean}
+ * True if |key| was present before removal, false otherwise.
+ */
+ release(key) {
+ return this.pressed.delete(key);
+ }
+};
+
+/**
+ * Input state not associated with a specific physical device.
+ */
+action.InputState.Null = class Null extends InputState {
+ constructor() {
+ super();
+ this.type = "none";
+ }
+};
+
+/**
+ * Input state associated with a pointer-type input device.
+ *
+ * @param {string} subtype
+ * Kind of pointing device: mouse, pen, touch.
+ *
+ * @throws {InvalidArgumentError}
+ * If subtype is undefined or an invalid pointer type.
+ */
+action.InputState.Pointer = class Pointer extends InputState {
+ constructor(subtype) {
+ super();
+ this.pressed = new Set();
+ assert.defined(
+ subtype,
+ pprint`Expected subtype to be defined, got ${subtype}`
+ );
+ this.subtype = action.PointerType.get(subtype);
+ this.x = 0;
+ this.y = 0;
+ }
+
+ /**
+ * Check whether |button| is pressed.
+ *
+ * @param {number} button
+ * Positive integer that refers to a mouse button.
+ *
+ * @return {boolean}
+ * True if |button| is in set of pressed buttons.
+ */
+ isPressed(button) {
+ assert.positiveInteger(button);
+ return this.pressed.has(button);
+ }
+
+ /**
+ * Add |button| to the set of pressed keys.
+ *
+ * @param {number} button
+ * Positive integer that refers to a mouse button.
+ *
+ * @return {Set}
+ * Set of pressed buttons.
+ */
+ press(button) {
+ assert.positiveInteger(button);
+ return this.pressed.add(button);
+ }
+
+ /**
+ * Remove |button| from the set of pressed buttons.
+ *
+ * @param {number} button
+ * A positive integer that refers to a mouse button.
+ *
+ * @return {boolean}
+ * True if |button| was present before removals, false otherwise.
+ */
+ release(button) {
+ assert.positiveInteger(button);
+ return this.pressed.delete(button);
+ }
+};
+
+/**
+ * Repesents an action for dispatch. Used in |action.Chain| and
+ * |action.Sequence|.
+ *
+ * @param {string} id
+ * Input source ID.
+ * @param {string} type
+ * Action type: none, key, pointer.
+ * @param {string} subtype
+ * Action subtype: {@link action.Pause}, {@link action.KeyUp},
+ * {@link action.KeyDown}, {@link action.PointerUp},
+ * {@link action.PointerDown}, {@link action.PointerMove}, or
+ * {@link action.PointerCancel}.
+ *
+ * @throws {InvalidArgumentError}
+ * If any parameters are undefined.
+ */
+action.Action = class {
+ constructor(id, type, subtype) {
+ if ([id, type, subtype].includes(undefined)) {
+ throw new error.InvalidArgumentError("Missing id, type or subtype");
+ }
+ for (let attr of [id, type, subtype]) {
+ assert.string(attr, pprint`Expected string, got ${attr}`);
+ }
+ this.id = id;
+ this.type = type;
+ this.subtype = subtype;
+ }
+
+ toString() {
+ return `[action ${this.type}]`;
+ }
+
+ /**
+ * @param {action.Sequence} actionSequence
+ * Object representing sequence of actions from one input source.
+ * @param {action.Action} actionItem
+ * Object representing a single action from |actionSequence|.
+ *
+ * @return {action.Action}
+ * An action that can be dispatched; corresponds to |actionItem|.
+ *
+ * @throws {InvalidArgumentError}
+ * If any <code>actionSequence</code> or <code>actionItem</code>
+ * attributes are invalid.
+ * @throws {UnsupportedOperationError}
+ * If <code>actionItem.type</code> is {@link action.PointerCancel}.
+ */
+ static fromJSON(actionSequence, actionItem) {
+ let type = actionSequence.type;
+ let id = actionSequence.id;
+ let subtypes = ACTIONS[type];
+ if (!subtypes) {
+ throw new error.InvalidArgumentError("Unknown type: " + type);
+ }
+ let subtype = actionItem.type;
+ if (!subtypes.has(subtype)) {
+ throw new error.InvalidArgumentError(
+ `Unknown subtype for ${type} action: ${subtype}`
+ );
+ }
+
+ let item = new action.Action(id, type, subtype);
+ if (type === "pointer") {
+ action.processPointerAction(
+ id,
+ action.PointerParameters.fromJSON(actionSequence.parameters),
+ item
+ );
+ }
+
+ switch (item.subtype) {
+ case action.KeyUp:
+ case action.KeyDown:
+ let key = actionItem.value;
+ // TODO countGraphemes
+ // TODO key.value could be a single code point like "\uE012"
+ // (see rawKey) or "grapheme cluster"
+ assert.string(
+ key,
+ "Expected 'value' to be a string that represents single code point " +
+ pprint`or grapheme cluster, got ${key}`
+ );
+ item.value = key;
+ break;
+
+ case action.PointerDown:
+ case action.PointerUp:
+ assert.positiveInteger(
+ actionItem.button,
+ pprint`Expected 'button' (${actionItem.button}) to be >= 0`
+ );
+ item.button = actionItem.button;
+ break;
+
+ case action.PointerMove:
+ item.duration = actionItem.duration;
+ if (typeof item.duration != "undefined") {
+ assert.positiveInteger(
+ item.duration,
+ pprint`Expected 'duration' (${item.duration}) to be >= 0`
+ );
+ }
+ item.origin = action.PointerOrigin.get(actionItem.origin);
+ item.x = actionItem.x;
+ if (typeof item.x != "undefined") {
+ assert.integer(
+ item.x,
+ pprint`Expected 'x' (${item.x}) to be an Integer`
+ );
+ }
+ item.y = actionItem.y;
+ if (typeof item.y != "undefined") {
+ assert.integer(
+ item.y,
+ pprint`Expected 'y' (${item.y}) to be an Integer`
+ );
+ }
+ break;
+
+ case action.PointerCancel:
+ throw new error.UnsupportedOperationError();
+
+ case action.Pause:
+ item.duration = actionItem.duration;
+ if (typeof item.duration != "undefined") {
+ // eslint-disable-next-line
+ assert.positiveInteger(item.duration,
+ pprint`Expected 'duration' (${item.duration}) to be >= 0`
+ );
+ }
+ break;
+ }
+
+ return item;
+ }
+};
+
+/**
+ * Represents a series of ticks, specifying which actions to perform at
+ * each tick.
+ */
+action.Chain = class extends Array {
+ toString() {
+ return `[chain ${super.toString()}]`;
+ }
+
+ /**
+ * @param {Array.<?>} actions
+ * Array of objects that each represent an action sequence.
+ *
+ * @return {action.Chain}
+ * Transpose of <var>actions</var> such that actions to be performed
+ * in a single tick are grouped together.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>actions</var> is not an Array.
+ */
+ static fromJSON(actions) {
+ assert.array(
+ actions,
+ pprint`Expected 'actions' to be an array, got ${actions}`
+ );
+
+ let actionsByTick = new action.Chain();
+ for (let actionSequence of actions) {
+ // TODO(maja_zf): Check that each actionSequence in actions refers
+ // to a different input ID.
+ let inputSourceActions = action.Sequence.fromJSON(actionSequence);
+ for (let i = 0; i < inputSourceActions.length; i++) {
+ // new tick
+ if (actionsByTick.length < i + 1) {
+ actionsByTick.push([]);
+ }
+ actionsByTick[i].push(inputSourceActions[i]);
+ }
+ }
+ return actionsByTick;
+ }
+};
+
+/**
+ * Represents one input source action sequence; this is essentially an
+ * |Array.<action.Action>|.
+ */
+action.Sequence = class extends Array {
+ toString() {
+ return `[sequence ${super.toString()}]`;
+ }
+
+ /**
+ * @param {Object.<string, ?>} actionSequence
+ * Object that represents a sequence action items for one input source.
+ *
+ * @return {action.Sequence}
+ * Sequence of actions that can be dispatched.
+ *
+ * @throws {InvalidArgumentError}
+ * If <code>actionSequence.id</code> is not a
+ * string or it's aleady mapped to an |action.InputState}
+ * incompatible with <code>actionSequence.type</code>, or if
+ * <code>actionSequence.actions</code> is not an <code>Array</code>.
+ */
+ static fromJSON(actionSequence) {
+ // used here to validate 'type' in addition to InputState type below
+ let inputSourceState = InputState.fromJSON(actionSequence);
+ let id = actionSequence.id;
+ assert.defined(id, "Expected 'id' to be defined");
+ assert.string(id, pprint`Expected 'id' to be a string, got ${id}`);
+ let actionItems = actionSequence.actions;
+ assert.array(
+ actionItems,
+ "Expected 'actionSequence.actions' to be an array, " +
+ pprint`got ${actionSequence.actions}`
+ );
+
+ if (!action.inputStateMap.has(id)) {
+ action.inputStateMap.set(id, inputSourceState);
+ } else if (!action.inputStateMap.get(id).is(inputSourceState)) {
+ throw new error.InvalidArgumentError(
+ `Expected ${id} to be mapped to ${inputSourceState}, ` +
+ `got ${action.inputStateMap.get(id)}`
+ );
+ }
+
+ let actions = new action.Sequence();
+ for (let actionItem of actionItems) {
+ actions.push(action.Action.fromJSON(actionSequence, actionItem));
+ }
+
+ return actions;
+ }
+};
+
+/**
+ * Represents parameters in an action for a pointer input source.
+ *
+ * @param {string=} pointerType
+ * Type of pointing device. If the parameter is undefined, "mouse"
+ * is used.
+ */
+action.PointerParameters = class {
+ constructor(pointerType = "mouse") {
+ this.pointerType = action.PointerType.get(pointerType);
+ }
+
+ toString() {
+ return `[pointerParameters ${this.pointerType}]`;
+ }
+
+ /**
+ * @param {Object.<string, ?>} parametersData
+ * Object that represents pointer parameters.
+ *
+ * @return {action.PointerParameters}
+ * Validated pointer paramters.
+ */
+ static fromJSON(parametersData) {
+ if (typeof parametersData == "undefined") {
+ return new action.PointerParameters();
+ }
+ return new action.PointerParameters(parametersData.pointerType);
+ }
+};
+
+/**
+ * Adds <var>pointerType</var> attribute to Action <var>act</var>.
+ *
+ * Helper function for {@link action.Action.fromJSON}.
+ *
+ * @param {string} id
+ * Input source ID.
+ * @param {action.PointerParams} pointerParams
+ * Input source pointer parameters.
+ * @param {action.Action} act
+ * Action to be updated.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> is already mapped to an
+ * {@link action.InputState} that is not compatible with
+ * <code>act.type</code> or <code>pointerParams.pointerType</code>.
+ */
+action.processPointerAction = function(id, pointerParams, act) {
+ if (
+ action.inputStateMap.has(id) &&
+ action.inputStateMap.get(id).type !== act.type
+ ) {
+ throw new error.InvalidArgumentError(
+ `Expected 'id' ${id} to be mapped to InputState whose type is ` +
+ action.inputStateMap.get(id).type +
+ pprint` , got ${act.type}`
+ );
+ }
+ let pointerType = pointerParams.pointerType;
+ if (
+ action.inputStateMap.has(id) &&
+ action.inputStateMap.get(id).subtype !== pointerType
+ ) {
+ throw new error.InvalidArgumentError(
+ `Expected 'id' ${id} to be mapped to InputState whose subtype is ` +
+ action.inputStateMap.get(id).subtype +
+ pprint` , got ${pointerType}`
+ );
+ }
+ act.pointerType = pointerParams.pointerType;
+};
+
+/** Collect properties associated with KeyboardEvent */
+action.Key = class {
+ constructor(rawKey) {
+ this.key = NORMALIZED_KEY_LOOKUP[rawKey] || rawKey;
+ this.code = KEY_CODE_LOOKUP[rawKey];
+ this.location = KEY_LOCATION_LOOKUP[rawKey] || 0;
+ this.altKey = false;
+ this.shiftKey = false;
+ this.ctrlKey = false;
+ this.metaKey = false;
+ this.repeat = false;
+ this.isComposing = false;
+ // keyCode will be computed by event.sendKeyDown
+ }
+
+ update(inputState) {
+ this.altKey = inputState.alt;
+ this.shiftKey = inputState.shift;
+ this.ctrlKey = inputState.ctrl;
+ this.metaKey = inputState.meta;
+ }
+};
+
+/** Collect properties associated with MouseEvent */
+action.Mouse = class {
+ constructor(type, button = 0) {
+ this.type = type;
+ assert.positiveInteger(button);
+ this.button = button;
+ this.buttons = 0;
+ this.altKey = false;
+ this.shiftKey = false;
+ this.metaKey = false;
+ this.ctrlKey = false;
+ // set modifier properties based on whether any corresponding keys are
+ // pressed on any key input source
+ for (let inputState of action.inputStateMap.values()) {
+ if (inputState.type == "key") {
+ this.altKey = inputState.alt || this.altKey;
+ this.ctrlKey = inputState.ctrl || this.ctrlKey;
+ this.metaKey = inputState.meta || this.metaKey;
+ this.shiftKey = inputState.shift || this.shiftKey;
+ }
+ }
+ }
+
+ update(inputState) {
+ let allButtons = Array.from(inputState.pressed);
+ this.buttons = allButtons.reduce((a, i) => a + Math.pow(2, i), 0);
+ }
+};
+
+/**
+ * Dispatch a chain of actions over |chain.length| ticks.
+ *
+ * This is done by creating a Promise for each tick that resolves once
+ * all the Promises for individual tick-actions are resolved. The next
+ * tick's actions are not dispatched until the Promise for the current
+ * tick is resolved.
+ *
+ * @param {action.Chain} chain
+ * Actions grouped by tick; each element in |chain| is a sequence of
+ * actions for one tick.
+ * @param {WindowProxy} win
+ * Current window global.
+ * @param {boolean=} [specCompatPointerOrigin=true] specCompatPointerOrigin
+ * Flag to turn off the WebDriver spec conforming pointer origin
+ * calculation. It has to be kept until all Selenium bindings can
+ * successfully handle the WebDriver spec conforming Pointer Origin
+ * calculation. See https://bugzilla.mozilla.org/show_bug.cgi?id=1429338.
+ *
+ * @return {Promise}
+ * Promise for dispatching all actions in |chain|.
+ */
+action.dispatch = function(chain, win, specCompatPointerOrigin = true) {
+ action.specCompatPointerOrigin = specCompatPointerOrigin;
+
+ let chainEvents = (async () => {
+ for (let tickActions of chain) {
+ await action.dispatchTickActions(
+ tickActions,
+ action.computeTickDuration(tickActions),
+ win
+ );
+ }
+ })();
+ return chainEvents;
+};
+
+/**
+ * Dispatch sequence of actions for one tick.
+ *
+ * This creates a Promise for one tick that resolves once the Promise
+ * for each tick-action is resolved, which takes at least |tickDuration|
+ * milliseconds. The resolved set of events for each tick is followed by
+ * firing of pending DOM events.
+ *
+ * Note that the tick-actions are dispatched in order, but they may have
+ * different durations and therefore may not end in the same order.
+ *
+ * @param {Array.<action.Action>} tickActions
+ * List of actions for one tick.
+ * @param {number} tickDuration
+ * Duration in milliseconds of this tick.
+ * @param {WindowProxy} win
+ * Current window global.
+ *
+ * @return {Promise}
+ * Promise for dispatching all tick-actions and pending DOM events.
+ */
+action.dispatchTickActions = function(tickActions, tickDuration, win) {
+ let pendingEvents = tickActions.map(toEvents(tickDuration, win));
+ return Promise.all(pendingEvents);
+};
+
+/**
+ * Compute tick duration in milliseconds for a collection of actions.
+ *
+ * @param {Array.<action.Action>} tickActions
+ * List of actions for one tick.
+ *
+ * @return {number}
+ * Longest action duration in |tickActions| if any, or 0.
+ */
+action.computeTickDuration = function(tickActions) {
+ let max = 0;
+ for (let a of tickActions) {
+ let affectsWallClockTime =
+ a.subtype == action.Pause ||
+ (a.type == "pointer" && a.subtype == action.PointerMove);
+ if (affectsWallClockTime && a.duration) {
+ max = Math.max(a.duration, max);
+ }
+ }
+ return max;
+};
+
+/**
+ * Compute viewport coordinates of pointer target based on given origin.
+ *
+ * @param {action.Action} a
+ * Action that specifies pointer origin and x and y coordinates of target.
+ * @param {action.InputState} inputState
+ * Input state that specifies current x and y coordinates of pointer.
+ * @param {Map.<string, number>=} center
+ * Object representing x and y coordinates of an element center-point.
+ * This is only used if |a.origin| is a web element reference.
+ *
+ * @return {Map.<string, number>}
+ * x and y coordinates of pointer destination.
+ */
+action.computePointerDestination = function(a, inputState, center = undefined) {
+ let { x, y } = a;
+ switch (a.origin) {
+ case action.PointerOrigin.Viewport:
+ break;
+ case action.PointerOrigin.Pointer:
+ x += inputState.x;
+ y += inputState.y;
+ break;
+ default:
+ // origin represents web element
+ assert.defined(center);
+ assert.in("x", center);
+ assert.in("y", center);
+ x += center.x;
+ y += center.y;
+ }
+ return { x, y };
+};
+
+/**
+ * Create a closure to use as a map from action definitions to Promise events.
+ *
+ * @param {number} tickDuration
+ * Duration in milliseconds of this tick.
+ * @param {WindowProxy} win
+ * Current window global.
+ *
+ * @return {function(action.Action): Promise}
+ * Function that takes an action and returns a Promise for dispatching
+ * the event that corresponds to that action.
+ */
+function toEvents(tickDuration, win) {
+ return a => {
+ let inputState = action.inputStateMap.get(a.id);
+
+ switch (a.subtype) {
+ case action.KeyUp:
+ return dispatchKeyUp(a, inputState, win);
+
+ case action.KeyDown:
+ return dispatchKeyDown(a, inputState, win);
+
+ case action.PointerDown:
+ return dispatchPointerDown(a, inputState, win);
+
+ case action.PointerUp:
+ return dispatchPointerUp(a, inputState, win);
+
+ case action.PointerMove:
+ return dispatchPointerMove(a, inputState, tickDuration, win);
+
+ case action.PointerCancel:
+ throw new error.UnsupportedOperationError();
+
+ case action.Pause:
+ return dispatchPause(a, tickDuration);
+ }
+
+ return undefined;
+ };
+}
+
+/**
+ * Dispatch a keyDown action equivalent to pressing a key on a keyboard.
+ *
+ * @param {action.Action} a
+ * Action to dispatch.
+ * @param {action.InputState} inputState
+ * Input state for this action's input source.
+ * @param {WindowProxy} win
+ * Current window global.
+ *
+ * @return {Promise}
+ * Promise to dispatch at least a keydown event, and keypress if
+ * appropriate.
+ */
+function dispatchKeyDown(a, inputState, win) {
+ return new Promise(resolve => {
+ let keyEvent = new action.Key(a.value);
+ keyEvent.repeat = inputState.isPressed(keyEvent.key);
+ inputState.press(keyEvent.key);
+ if (keyEvent.key in MODIFIER_NAME_LOOKUP) {
+ inputState.setModState(keyEvent.key, true);
+ }
+
+ // Append a copy of |a| with keyUp subtype
+ action.inputsToCancel.push(Object.assign({}, a, { subtype: action.KeyUp }));
+ keyEvent.update(inputState);
+ event.sendKeyDown(a.value, keyEvent, win);
+
+ resolve();
+ });
+}
+
+/**
+ * Dispatch a keyUp action equivalent to releasing a key on a keyboard.
+ *
+ * @param {action.Action} a
+ * Action to dispatch.
+ * @param {action.InputState} inputState
+ * Input state for this action's input source.
+ * @param {WindowProxy} win
+ * Current window global.
+ *
+ * @return {Promise}
+ * Promise to dispatch a keyup event.
+ */
+function dispatchKeyUp(a, inputState, win) {
+ return new Promise(resolve => {
+ let keyEvent = new action.Key(a.value);
+
+ if (!inputState.isPressed(keyEvent.key)) {
+ resolve();
+ return;
+ }
+
+ if (keyEvent.key in MODIFIER_NAME_LOOKUP) {
+ inputState.setModState(keyEvent.key, false);
+ }
+ inputState.release(keyEvent.key);
+ keyEvent.update(inputState);
+
+ event.sendKeyUp(a.value, keyEvent, win);
+ resolve();
+ });
+}
+
+/**
+ * Dispatch a pointerDown action equivalent to pressing a pointer-device
+ * button.
+ *
+ * @param {action.Action} a
+ * Action to dispatch.
+ * @param {action.InputState} inputState
+ * Input state for this action's input source.
+ * @param {WindowProxy} win
+ * Current window global.
+ *
+ * @return {Promise}
+ * Promise to dispatch at least a pointerdown event.
+ */
+function dispatchPointerDown(a, inputState, win) {
+ return new Promise(resolve => {
+ if (inputState.isPressed(a.button)) {
+ resolve();
+ return;
+ }
+
+ inputState.press(a.button);
+ // Append a copy of |a| with pointerUp subtype
+ let copy = Object.assign({}, a, { subtype: action.PointerUp });
+ action.inputsToCancel.push(copy);
+
+ switch (inputState.subtype) {
+ case action.PointerType.Mouse:
+ let mouseEvent = new action.Mouse("mousedown", a.button);
+ mouseEvent.update(inputState);
+ if (mouseEvent.ctrlKey) {
+ if (Services.appinfo.OS === "Darwin") {
+ mouseEvent.button = 2;
+ event.DoubleClickTracker.resetClick();
+ }
+ } else if (event.DoubleClickTracker.isClicked()) {
+ mouseEvent = Object.assign({}, mouseEvent, { clickCount: 2 });
+ }
+ event.synthesizeMouseAtPoint(
+ inputState.x,
+ inputState.y,
+ mouseEvent,
+ win
+ );
+ if (
+ event.MouseButton.isSecondary(a.button) ||
+ (mouseEvent.ctrlKey && Services.appinfo.OS === "Darwin")
+ ) {
+ let contextMenuEvent = Object.assign({}, mouseEvent, {
+ type: "contextmenu",
+ });
+ event.synthesizeMouseAtPoint(
+ inputState.x,
+ inputState.y,
+ contextMenuEvent,
+ win
+ );
+ }
+ break;
+
+ case action.PointerType.Pen:
+ case action.PointerType.Touch:
+ throw new error.UnsupportedOperationError(
+ "Only 'mouse' pointer type is supported"
+ );
+
+ default:
+ throw new TypeError(`Unknown pointer type: ${inputState.subtype}`);
+ }
+
+ resolve();
+ });
+}
+
+/**
+ * Dispatch a pointerUp action equivalent to releasing a pointer-device
+ * button.
+ *
+ * @param {action.Action} a
+ * Action to dispatch.
+ * @param {action.InputState} inputState
+ * Input state for this action's input source.
+ * @param {WindowProxy} win
+ * Current window global.
+ *
+ * @return {Promise}
+ * Promise to dispatch at least a pointerup event.
+ */
+function dispatchPointerUp(a, inputState, win) {
+ return new Promise(resolve => {
+ if (!inputState.isPressed(a.button)) {
+ resolve();
+ return;
+ }
+
+ inputState.release(a.button);
+
+ switch (inputState.subtype) {
+ case action.PointerType.Mouse:
+ let mouseEvent = new action.Mouse("mouseup", a.button);
+ mouseEvent.update(inputState);
+ if (event.DoubleClickTracker.isClicked()) {
+ mouseEvent = Object.assign({}, mouseEvent, { clickCount: 2 });
+ }
+ event.synthesizeMouseAtPoint(
+ inputState.x,
+ inputState.y,
+ mouseEvent,
+ win
+ );
+ break;
+
+ case action.PointerType.Pen:
+ case action.PointerType.Touch:
+ throw new error.UnsupportedOperationError(
+ "Only 'mouse' pointer type is supported"
+ );
+
+ default:
+ throw new TypeError(`Unknown pointer type: ${inputState.subtype}`);
+ }
+
+ resolve();
+ });
+}
+
+/**
+ * Dispatch a pointerMove action equivalent to moving pointer device
+ * in a line.
+ *
+ * If the action duration is 0, the pointer jumps immediately to the
+ * target coordinates. Otherwise, events are synthesized to mimic a
+ * pointer travelling in a discontinuous, approximately straight line,
+ * with the pointer coordinates being updated around 60 times per second.
+ *
+ * @param {action.Action} a
+ * Action to dispatch.
+ * @param {action.InputState} inputState
+ * Input state for this action's input source.
+ * @param {WindowProxy} win
+ * Current window global.
+ *
+ * @return {Promise}
+ * Promise to dispatch at least one pointermove event, as well as
+ * mousemove events as appropriate.
+ */
+function dispatchPointerMove(a, inputState, tickDuration, win) {
+ const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ // interval between pointermove increments in ms, based on common vsync
+ const fps60 = 17;
+
+ return new Promise((resolve, reject) => {
+ const start = Date.now();
+ const [startX, startY] = [inputState.x, inputState.y];
+
+ let coords = getElementCenter(a.origin, win);
+ let target = action.computePointerDestination(a, inputState, coords);
+ const [targetX, targetY] = [target.x, target.y];
+
+ if (!inViewPort(targetX, targetY, win)) {
+ throw new error.MoveTargetOutOfBoundsError(
+ `(${targetX}, ${targetY}) is out of bounds of viewport ` +
+ `width (${win.innerWidth}) ` +
+ `and height (${win.innerHeight})`
+ );
+ }
+
+ const duration =
+ typeof a.duration == "undefined" ? tickDuration : a.duration;
+ if (duration === 0) {
+ // move pointer to destination in one step
+ performOnePointerMove(inputState, targetX, targetY, win);
+ resolve();
+ return;
+ }
+
+ const distanceX = targetX - startX;
+ const distanceY = targetY - startY;
+ const ONE_SHOT = Ci.nsITimer.TYPE_ONE_SHOT;
+ let intermediatePointerEvents = (async () => {
+ // wait |fps60| ms before performing first incremental pointer move
+ await new Promise(resolveTimer =>
+ timer.initWithCallback(resolveTimer, fps60, ONE_SHOT)
+ );
+
+ let durationRatio = Math.floor(Date.now() - start) / duration;
+ const epsilon = fps60 / duration / 10;
+ while (1 - durationRatio > epsilon) {
+ let x = Math.floor(durationRatio * distanceX + startX);
+ let y = Math.floor(durationRatio * distanceY + startY);
+ performOnePointerMove(inputState, x, y, win);
+ // wait |fps60| ms before performing next pointer move
+ await new Promise(resolveTimer =>
+ timer.initWithCallback(resolveTimer, fps60, ONE_SHOT)
+ );
+
+ durationRatio = Math.floor(Date.now() - start) / duration;
+ }
+ })();
+
+ // perform last pointer move after all incremental moves are resolved and
+ // durationRatio is close enough to 1
+ intermediatePointerEvents
+ .then(() => {
+ performOnePointerMove(inputState, targetX, targetY, win);
+ resolve();
+ })
+ .catch(err => {
+ reject(err);
+ });
+ });
+}
+
+function performOnePointerMove(inputState, targetX, targetY, win) {
+ if (targetX == inputState.x && targetY == inputState.y) {
+ return;
+ }
+
+ switch (inputState.subtype) {
+ case action.PointerType.Mouse:
+ let mouseEvent = new action.Mouse("mousemove");
+ mouseEvent.update(inputState);
+ // TODO both pointermove (if available) and mousemove
+ event.synthesizeMouseAtPoint(targetX, targetY, mouseEvent, win);
+ break;
+
+ case action.PointerType.Pen:
+ case action.PointerType.Touch:
+ throw new error.UnsupportedOperationError(
+ "Only 'mouse' pointer type is supported"
+ );
+
+ default:
+ throw new TypeError(`Unknown pointer type: ${inputState.subtype}`);
+ }
+
+ inputState.x = targetX;
+ inputState.y = targetY;
+}
+
+/**
+ * Dispatch a pause action equivalent waiting for `a.duration`
+ * milliseconds, or a default time interval of `tickDuration`.
+ *
+ * @param {action.Action} a
+ * Action to dispatch.
+ * @param {number} tickDuration
+ * Duration in milliseconds of this tick.
+ *
+ * @return {Promise}
+ * Promise that is resolved after the specified time interval.
+ */
+function dispatchPause(a, tickDuration) {
+ let ms = typeof a.duration == "undefined" ? tickDuration : a.duration;
+ return Sleep(ms);
+}
+
+// helpers
+
+function capitalize(str) {
+ assert.string(str);
+ return str.charAt(0).toUpperCase() + str.slice(1);
+}
+
+function inViewPort(x, y, win) {
+ assert.number(x, `Expected x to be finite number`);
+ assert.number(y, `Expected y to be finite number`);
+ // Viewport includes scrollbars if rendered.
+ return !(x < 0 || y < 0 || x > win.innerWidth || y > win.innerHeight);
+}
+
+function getElementCenter(el, win) {
+ if (element.isElement(el)) {
+ if (action.specCompatPointerOrigin) {
+ return element.getInViewCentrePoint(el.getClientRects()[0], win);
+ }
+ return element.coordinates(el);
+ }
+ return {};
+}
diff --git a/testing/marionette/actors/MarionetteCommandsChild.jsm b/testing/marionette/actors/MarionetteCommandsChild.jsm
new file mode 100644
index 0000000000..9f04fec837
--- /dev/null
+++ b/testing/marionette/actors/MarionetteCommandsChild.jsm
@@ -0,0 +1,546 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-disable no-restricted-globals */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["MarionetteCommandsChild", "clearActionInputState"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ action: "chrome://marionette/content/action.js",
+ atom: "chrome://marionette/content/atom.js",
+ element: "chrome://marionette/content/element.js",
+ error: "chrome://marionette/content/error.js",
+ evaluate: "chrome://marionette/content/evaluate.js",
+ event: "chrome://marionette/content/event.js",
+ interaction: "chrome://marionette/content/interaction.js",
+ legacyaction: "chrome://marionette/content/legacyaction.js",
+ Log: "chrome://marionette/content/log.js",
+ sandbox: "chrome://marionette/content/evaluate.js",
+ Sandboxes: "chrome://marionette/content/evaluate.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+
+let inputStateIsDirty = false;
+
+class MarionetteCommandsChild extends JSWindowActorChild {
+ constructor() {
+ super();
+
+ // sandbox storage and name of the current sandbox
+ this.sandboxes = new Sandboxes(() => this.document.defaultView);
+ }
+
+ get innerWindowId() {
+ return this.manager.innerWindowId;
+ }
+
+ /**
+ * Lazy getter to create a legacyaction Chain instance for touch events.
+ */
+ get legacyactions() {
+ if (!this._legacyactions) {
+ this._legacyactions = new legacyaction.Chain();
+ }
+
+ return this._legacyactions;
+ }
+
+ actorCreated() {
+ logger.trace(
+ `[${this.browsingContext.id}] MarionetteCommands actor created ` +
+ `for window id ${this.innerWindowId}`
+ );
+
+ clearActionInputState();
+ }
+
+ async receiveMessage(msg) {
+ if (!this.contentWindow) {
+ throw new DOMException("Actor is no longer active", "InactiveActor");
+ }
+
+ try {
+ let result;
+
+ const { name, data: serializedData } = msg;
+ const data = evaluate.fromJSON(
+ serializedData,
+ null,
+ this.document.defaultView
+ );
+
+ switch (name) {
+ case "MarionetteCommandsParent:clearElement":
+ this.clearElement(data);
+ break;
+ case "MarionetteCommandsParent:clickElement":
+ result = await this.clickElement(data);
+ break;
+ case "MarionetteCommandsParent:executeScript":
+ result = await this.executeScript(data);
+ break;
+ case "MarionetteCommandsParent:findElement":
+ result = await this.findElement(data);
+ break;
+ case "MarionetteCommandsParent:findElements":
+ result = await this.findElements(data);
+ break;
+ case "MarionetteCommandsParent:getCurrentUrl":
+ result = await this.getCurrentUrl();
+ break;
+ case "MarionetteCommandsParent:getActiveElement":
+ result = await this.getActiveElement();
+ break;
+ case "MarionetteCommandsParent:getElementAttribute":
+ result = await this.getElementAttribute(data);
+ break;
+ case "MarionetteCommandsParent:getElementProperty":
+ result = await this.getElementProperty(data);
+ break;
+ case "MarionetteCommandsParent:getElementRect":
+ result = await this.getElementRect(data);
+ break;
+ case "MarionetteCommandsParent:getElementTagName":
+ result = await this.getElementTagName(data);
+ break;
+ case "MarionetteCommandsParent:getElementText":
+ result = await this.getElementText(data);
+ break;
+ case "MarionetteCommandsParent:getElementValueOfCssProperty":
+ result = await this.getElementValueOfCssProperty(data);
+ break;
+ case "MarionetteCommandsParent:getPageSource":
+ result = await this.getPageSource();
+ break;
+ case "MarionetteCommandsParent:getScreenshotRect":
+ result = await this.getScreenshotRect(data);
+ break;
+ case "MarionetteCommandsParent:isElementDisplayed":
+ result = await this.isElementDisplayed(data);
+ break;
+ case "MarionetteCommandsParent:isElementEnabled":
+ result = await this.isElementEnabled(data);
+ break;
+ case "MarionetteCommandsParent:isElementSelected":
+ result = await this.isElementSelected(data);
+ break;
+ case "MarionetteCommandsParent:performActions":
+ result = await this.performActions(data);
+ break;
+ case "MarionetteCommandsParent:releaseActions":
+ result = await this.releaseActions();
+ break;
+ case "MarionetteCommandsParent:sendKeysToElement":
+ result = await this.sendKeysToElement(data);
+ break;
+ case "MarionetteCommandsParent:singleTap":
+ result = await this.singleTap(data);
+ break;
+ case "MarionetteCommandsParent:switchToFrame":
+ result = await this.switchToFrame(data);
+ break;
+ case "MarionetteCommandsParent:switchToParentFrame":
+ result = await this.switchToParentFrame();
+ break;
+ }
+
+ // The element reference store lives in the parent process. Calling
+ // toJSON() without a second argument here passes element reference ids
+ // of DOM nodes to the parent frame.
+ return { data: evaluate.toJSON(result) };
+ } catch (e) {
+ // Always wrap errors as WebDriverError
+ return { error: error.wrap(e).toJSON() };
+ }
+ }
+
+ // Implementation of WebDriver commands
+
+ /** Clear the text of an element.
+ *
+ * @param {Object} options
+ * @param {Element} options.elem
+ */
+ clearElement(options = {}) {
+ const { elem } = options;
+
+ interaction.clearElement(elem);
+ }
+
+ /**
+ * Click an element.
+ */
+ async clickElement(options = {}) {
+ const { capabilities, elem } = options;
+
+ return interaction.clickElement(
+ elem,
+ capabilities["moz:accessibilityChecks"],
+ capabilities["moz:webdriverClick"]
+ );
+ }
+
+ /**
+ * Executes a JavaScript function.
+ */
+ async executeScript(options = {}) {
+ const { args, opts = {}, script } = options;
+
+ let sb;
+ if (opts.sandboxName) {
+ sb = this.sandboxes.get(opts.sandboxName, opts.newSandbox);
+ } else {
+ sb = sandbox.createMutable(this.document.defaultView);
+ }
+
+ return evaluate.sandbox(sb, script, args, opts);
+ }
+
+ /**
+ * Find an element in the current browsing context's document using the
+ * given search strategy.
+ *
+ * @param {Object} options
+ * @param {Object} options.opts
+ * @param {Element} opts.startNode
+ * @param {string} opts.strategy
+ * @param {string} opts.selector
+ *
+ */
+ async findElement(options = {}) {
+ const { strategy, selector, opts } = options;
+
+ opts.all = false;
+
+ const container = { frame: this.document.defaultView };
+ return element.find(container, strategy, selector, opts);
+ }
+
+ /**
+ * Find elements in the current browsing context's document using the
+ * given search strategy.
+ *
+ * @param {Object} options
+ * @param {Object} options.opts
+ * @param {Element} opts.startNode
+ * @param {string} opts.strategy
+ * @param {string} opts.selector
+ *
+ */
+ async findElements(options = {}) {
+ const { strategy, selector, opts } = options;
+
+ opts.all = true;
+
+ const container = { frame: this.document.defaultView };
+ return element.find(container, strategy, selector, opts);
+ }
+
+ /**
+ * Return the active element in the document.
+ */
+ async getActiveElement() {
+ let elem = this.document.activeElement;
+ if (!elem) {
+ throw new error.NoSuchElementError();
+ }
+
+ return elem;
+ }
+
+ /**
+ * Get the current URL.
+ */
+ async getCurrentUrl() {
+ return this.document.defaultView.location.href;
+ }
+
+ /**
+ * Get the value of an attribute for the given element.
+ */
+ async getElementAttribute(options = {}) {
+ const { name, elem } = options;
+
+ if (element.isBooleanAttribute(elem, name)) {
+ if (elem.hasAttribute(name)) {
+ return "true";
+ }
+ return null;
+ }
+ return elem.getAttribute(name);
+ }
+
+ /**
+ * Get the value of a property for the given element.
+ */
+ async getElementProperty(options = {}) {
+ const { name, elem } = options;
+
+ return typeof elem[name] != "undefined" ? elem[name] : null;
+ }
+
+ /**
+ * Get the position and dimensions of the element.
+ */
+ async getElementRect(options = {}) {
+ const { elem } = options;
+
+ const rect = elem.getBoundingClientRect();
+ return {
+ x: rect.x + this.document.defaultView.pageXOffset,
+ y: rect.y + this.document.defaultView.pageYOffset,
+ width: rect.width,
+ height: rect.height,
+ };
+ }
+
+ /**
+ * Get the tagName for the given element.
+ */
+ async getElementTagName(options = {}) {
+ const { elem } = options;
+
+ return elem.tagName.toLowerCase();
+ }
+
+ /**
+ * Get the text content for the given element.
+ */
+ async getElementText(options = {}) {
+ const { elem } = options;
+
+ return atom.getElementText(elem, this.document.defaultView);
+ }
+
+ /**
+ * Get the value of a css property for the given element.
+ */
+ async getElementValueOfCssProperty(options = {}) {
+ const { name, elem } = options;
+
+ const style = this.document.defaultView.getComputedStyle(elem);
+ return style.getPropertyValue(name);
+ }
+
+ /**
+ * Get the source of the current browsing context's document.
+ */
+ async getPageSource() {
+ return this.document.documentElement.outerHTML;
+ }
+
+ /**
+ * Returns the rect of the element to screenshot.
+ *
+ * Because the screen capture takes place in the parent process the dimensions
+ * for the screenshot have to be determined in the appropriate child process.
+ *
+ * Also it takes care of scrolling an element into view if requested.
+ *
+ * @param {Object} options
+ * @param {Element} options.elem
+ * Optional element to take a screenshot of.
+ * @param {boolean=} options.full
+ * True to take a screenshot of the entire document element.
+ * Defaults to true.
+ * @param {boolean=} options.scroll
+ * When <var>elem</var> is given, scroll it into view.
+ * Defaults to true.
+ *
+ * @return {DOMRect}
+ * The area to take a snapshot from.
+ */
+ async getScreenshotRect(options = {}) {
+ const { elem, full = true, scroll = true } = options;
+ const win = elem
+ ? this.document.defaultView
+ : this.browsingContext.top.window;
+
+ let rect;
+
+ if (elem) {
+ if (scroll) {
+ element.scrollIntoView(elem);
+ }
+ rect = this.getElementRect({ elem });
+ } else if (full) {
+ const docEl = win.document.documentElement;
+ rect = new DOMRect(0, 0, docEl.scrollWidth, docEl.scrollHeight);
+ } else {
+ // viewport
+ rect = new DOMRect(
+ win.pageXOffset,
+ win.pageYOffset,
+ win.innerWidth,
+ win.innerHeight
+ );
+ }
+
+ return rect;
+ }
+
+ /**
+ * Determine the element displayedness of the given web element.
+ */
+ async isElementDisplayed(options = {}) {
+ const { capabilities, elem } = options;
+
+ return interaction.isElementDisplayed(
+ elem,
+ capabilities["moz:accessibilityChecks"]
+ );
+ }
+
+ /**
+ * Check if element is enabled.
+ */
+ async isElementEnabled(options = {}) {
+ const { capabilities, elem } = options;
+
+ return interaction.isElementEnabled(
+ elem,
+ capabilities["moz:accessibilityChecks"]
+ );
+ }
+
+ /**
+ * Determine whether the referenced element is selected or not.
+ */
+ async isElementSelected(options = {}) {
+ const { capabilities, elem } = options;
+
+ return interaction.isElementSelected(
+ elem,
+ capabilities["moz:accessibilityChecks"]
+ );
+ }
+
+ /**
+ * Perform a series of grouped actions at the specified points in time.
+ *
+ * @param {Object} options
+ * @param {Object} options.actions
+ * Array of objects with each representing an action sequence.
+ * @param {Object} options.capabilities
+ * Object with a list of WebDriver session capabilities.
+ */
+ async performActions(options = {}) {
+ const { actions, capabilities } = options;
+
+ await action.dispatch(
+ action.Chain.fromJSON(actions),
+ this.document.defaultView,
+ !capabilities["moz:useNonSpecCompliantPointerOrigin"]
+ );
+ inputStateIsDirty =
+ action.inputsToCancel.length || action.inputStateMap.size;
+ }
+
+ /**
+ * The release actions command is used to release all the keys and pointer
+ * buttons that are currently depressed. This causes events to be fired
+ * as if the state was released by an explicit series of actions. It also
+ * clears all the internal state of the virtual devices.
+ */
+ async releaseActions() {
+ await action.dispatchTickActions(
+ action.inputsToCancel.reverse(),
+ 0,
+ this.document.defaultView
+ );
+ clearActionInputState();
+
+ event.DoubleClickTracker.resetClick();
+ }
+
+ /*
+ * Send key presses to element after focusing on it.
+ */
+ async sendKeysToElement(options = {}) {
+ const { capabilities, elem, text } = options;
+
+ const opts = {
+ strictFileInteractability: capabilities.strictFileInteractability,
+ accessibilityChecks: capabilities["moz:accessibilityChecks"],
+ webdriverClick: capabilities["moz:webdriverClick"],
+ };
+
+ return interaction.sendKeysToElement(elem, text, opts);
+ }
+
+ /**
+ * Perform a single tap.
+ */
+ async singleTap(options = {}) {
+ const { capabilities, elem, x, y } = options;
+ return this.legacyactions.singleTap(elem, x, y, capabilities);
+ }
+
+ /**
+ * Switch to the specified frame.
+ *
+ * @param {Object=} options
+ * @param {(number|Element)=} options.id
+ * If it's a number treat it as the index for all the existing frames.
+ * If it's an Element switch to this specific frame.
+ * If not specified or `null` switch to the top-level browsing context.
+ */
+ async switchToFrame(options = {}) {
+ const { id } = options;
+
+ const childContexts = this.browsingContext.children;
+ let browsingContext;
+
+ if (id == null) {
+ browsingContext = this.browsingContext.top;
+ } else if (typeof id == "number") {
+ if (id < 0 || id >= childContexts.length) {
+ throw new error.NoSuchFrameError(
+ `Unable to locate frame with index: ${id}`
+ );
+ }
+ browsingContext = childContexts[id];
+ } else {
+ const context = childContexts.find(context => {
+ return context.embedderElement === id;
+ });
+ if (!context) {
+ throw new error.NoSuchFrameError(
+ `Unable to locate frame for element: ${id}`
+ );
+ }
+ browsingContext = context;
+ }
+
+ return { browsingContextId: browsingContext.id };
+ }
+
+ /**
+ * Switch to the parent frame.
+ */
+ async switchToParentFrame() {
+ const browsingContext = this.browsingContext.parent || this.browsingContext;
+
+ return { browsingContextId: browsingContext.id };
+ }
+}
+
+/**
+ * Reset Action API input state
+ */
+function clearActionInputState() {
+ // Avoid loading the action module before it is needed by a command
+ if (inputStateIsDirty) {
+ action.inputStateMap.clear();
+ action.inputsToCancel.length = 0;
+ inputStateIsDirty = false;
+ }
+}
diff --git a/testing/marionette/actors/MarionetteCommandsParent.jsm b/testing/marionette/actors/MarionetteCommandsParent.jsm
new file mode 100644
index 0000000000..f7c1990ef5
--- /dev/null
+++ b/testing/marionette/actors/MarionetteCommandsParent.jsm
@@ -0,0 +1,396 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+("use strict");
+
+const EXPORTED_SYMBOLS = [
+ "clearElementIdCache",
+ "getMarionetteCommandsActorProxy",
+ "MarionetteCommandsParent",
+ "registerCommandsActor",
+ "unregisterCommandsActor",
+];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ capture: "chrome://marionette/content/capture.js",
+ element: "chrome://marionette/content/element.js",
+ error: "chrome://marionette/content/error.js",
+ evaluate: "chrome://marionette/content/evaluate.js",
+ Log: "chrome://marionette/content/log.js",
+ modal: "chrome://marionette/content/modal.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+XPCOMUtils.defineLazyGetter(this, "elementIdCache", () => {
+ return new element.ReferenceStore();
+});
+
+class MarionetteCommandsParent extends JSWindowActorParent {
+ actorCreated() {
+ this._resolveDialogOpened = null;
+
+ this.dialogObserver = new modal.DialogObserver();
+ this.dialogObserver.add(this.onDialog.bind(this));
+
+ this.topWindow = this.browsingContext.top.embedderElement?.ownerGlobal;
+ this.topWindow?.addEventListener("TabClose", _onTabClose);
+ }
+
+ dialogOpenedPromise() {
+ return new Promise(resolve => {
+ this._resolveDialogOpened = resolve;
+ });
+ }
+
+ async sendQuery(name, data) {
+ const serializedData = evaluate.toJSON(data, elementIdCache);
+
+ // return early if a dialog is opened
+ const result = await Promise.race([
+ super.sendQuery(name, serializedData),
+ this.dialogOpenedPromise(),
+ ]).finally(() => {
+ this._resolveDialogOpened = null;
+ });
+
+ if ("error" in result) {
+ throw error.WebDriverError.fromJSON(result.error);
+ } else {
+ return evaluate.fromJSON(result.data, elementIdCache);
+ }
+ }
+
+ didDestroy() {
+ this.dialogObserver.remove(this.onDialog);
+ this.dialogObserver.unregister();
+
+ this.topWindow?.removeEventListener("TabClose", _onTabClose);
+ }
+
+ onDialog(action, dialogRef, win) {
+ if (
+ this._resolveDialogOpened &&
+ action == "opened" &&
+ win == this.browsingContext.topChromeWindow
+ ) {
+ this._resolveDialogOpened({ data: null });
+ }
+ }
+
+ // Proxying methods for WebDriver commands
+ // TODO: Maybe using a proxy class instead similar to proxy.js
+
+ clearElement(webEl) {
+ return this.sendQuery("MarionetteCommandsParent:clearElement", {
+ elem: webEl,
+ });
+ }
+
+ clickElement(webEl, capabilities) {
+ return this.sendQuery("MarionetteCommandsParent:clickElement", {
+ elem: webEl,
+ capabilities,
+ });
+ }
+
+ async executeScript(script, args, opts) {
+ return this.sendQuery("MarionetteCommandsParent:executeScript", {
+ script,
+ args,
+ opts,
+ });
+ }
+
+ findElement(strategy, selector, opts) {
+ return this.sendQuery("MarionetteCommandsParent:findElement", {
+ strategy,
+ selector,
+ opts,
+ });
+ }
+
+ findElements(strategy, selector, opts) {
+ return this.sendQuery("MarionetteCommandsParent:findElements", {
+ strategy,
+ selector,
+ opts,
+ });
+ }
+
+ async getActiveElement() {
+ return this.sendQuery("MarionetteCommandsParent:getActiveElement");
+ }
+
+ async getCurrentUrl() {
+ return this.sendQuery("MarionetteCommandsParent:getCurrentUrl");
+ }
+
+ async getElementAttribute(webEl, name) {
+ return this.sendQuery("MarionetteCommandsParent:getElementAttribute", {
+ elem: webEl,
+ name,
+ });
+ }
+
+ async getElementProperty(webEl, name) {
+ return this.sendQuery("MarionetteCommandsParent:getElementProperty", {
+ elem: webEl,
+ name,
+ });
+ }
+
+ async getElementRect(webEl) {
+ return this.sendQuery("MarionetteCommandsParent:getElementRect", {
+ elem: webEl,
+ });
+ }
+
+ async getElementTagName(webEl) {
+ return this.sendQuery("MarionetteCommandsParent:getElementTagName", {
+ elem: webEl,
+ });
+ }
+
+ async getElementText(webEl) {
+ return this.sendQuery("MarionetteCommandsParent:getElementText", {
+ elem: webEl,
+ });
+ }
+
+ async getElementValueOfCssProperty(webEl, name) {
+ return this.sendQuery(
+ "MarionetteCommandsParent:getElementValueOfCssProperty",
+ {
+ elem: webEl,
+ name,
+ }
+ );
+ }
+
+ async getPageSource() {
+ return this.sendQuery("MarionetteCommandsParent:getPageSource");
+ }
+
+ async isElementDisplayed(webEl, capabilities) {
+ return this.sendQuery("MarionetteCommandsParent:isElementDisplayed", {
+ capabilities,
+ elem: webEl,
+ });
+ }
+
+ async isElementEnabled(webEl, capabilities) {
+ return this.sendQuery("MarionetteCommandsParent:isElementEnabled", {
+ capabilities,
+ elem: webEl,
+ });
+ }
+
+ async isElementSelected(webEl, capabilities) {
+ return this.sendQuery("MarionetteCommandsParent:isElementSelected", {
+ capabilities,
+ elem: webEl,
+ });
+ }
+
+ async sendKeysToElement(webEl, text, capabilities) {
+ return this.sendQuery("MarionetteCommandsParent:sendKeysToElement", {
+ capabilities,
+ elem: webEl,
+ text,
+ });
+ }
+
+ async performActions(actions, capabilities) {
+ return this.sendQuery("MarionetteCommandsParent:performActions", {
+ actions,
+ capabilities,
+ });
+ }
+
+ async releaseActions() {
+ return this.sendQuery("MarionetteCommandsParent:releaseActions");
+ }
+
+ async singleTap(webEl, x, y, capabilities) {
+ return this.sendQuery("MarionetteCommandsParent:singleTap", {
+ capabilities,
+ elem: webEl,
+ x,
+ y,
+ });
+ }
+
+ async switchToFrame(id) {
+ const {
+ browsingContextId,
+ } = await this.sendQuery("MarionetteCommandsParent:switchToFrame", { id });
+
+ return {
+ browsingContext: BrowsingContext.get(browsingContextId),
+ };
+ }
+
+ async switchToParentFrame() {
+ const { browsingContextId } = await this.sendQuery(
+ "MarionetteCommandsParent:switchToParentFrame"
+ );
+
+ return {
+ browsingContext: BrowsingContext.get(browsingContextId),
+ };
+ }
+
+ async takeScreenshot(webEl, format, full, scroll) {
+ const rect = await this.sendQuery(
+ "MarionetteCommandsParent:getScreenshotRect",
+ {
+ elem: webEl,
+ full,
+ scroll,
+ }
+ );
+
+ // If no element has been specified use the top-level browsing context.
+ // Otherwise use the browsing context from the currently selected frame.
+ const browsingContext = webEl
+ ? this.browsingContext
+ : this.browsingContext.top;
+
+ let canvas = await capture.canvas(
+ browsingContext.topChromeWindow,
+ browsingContext,
+ rect.x,
+ rect.y,
+ rect.width,
+ rect.height
+ );
+
+ switch (format) {
+ case capture.Format.Hash:
+ return capture.toHash(canvas);
+
+ case capture.Format.Base64:
+ return capture.toBase64(canvas);
+
+ default:
+ throw new TypeError(`Invalid capture format: ${format}`);
+ }
+ }
+}
+
+/**
+ * Clear all the entries from the element id cache.
+ */
+function clearElementIdCache() {
+ elementIdCache.clear();
+}
+
+function _onTabClose(event) {
+ elementIdCache.clear(event.target.linkedBrowser.browsingContext);
+}
+
+/**
+ * Proxy that will dynamically create MarionetteCommands actors for a dynamically
+ * provided browsing context until the method can be fully executed by the
+ * JSWindowActor pair.
+ *
+ * @param {function(): BrowsingContext} browsingContextFn
+ * A function that returns the reference to the browsing context for which
+ * the query should run.
+ */
+function getMarionetteCommandsActorProxy(browsingContextFn) {
+ const MAX_ATTEMPTS = 10;
+
+ /**
+ * Methods which modify the content page cannot be retried safely.
+ * See Bug 1673345.
+ */
+ const NO_RETRY_METHODS = [
+ "clickElement",
+ "executeScript",
+ "performActions",
+ "releaseActions",
+ "sendKeysToElement",
+ "singleTap",
+ ];
+
+ return new Proxy(
+ {},
+ {
+ get(target, methodName) {
+ return async (...args) => {
+ let attempts = 0;
+ while (true) {
+ try {
+ // TODO: Scenarios where the window/tab got closed and
+ // currentWindowGlobal is null will be handled in Bug 1662808.
+ const actor = browsingContextFn().currentWindowGlobal.getActor(
+ "MarionetteCommands"
+ );
+ const result = await actor[methodName](...args);
+ return result;
+ } catch (e) {
+ if (!["AbortError", "InactiveActor"].includes(e.name)) {
+ // Only retry when the JSWindowActor pair gets destroyed, or
+ // gets inactive eg. when the page is moved into bfcache.
+ throw e;
+ }
+
+ if (NO_RETRY_METHODS.includes(methodName)) {
+ return null;
+ }
+
+ if (++attempts > MAX_ATTEMPTS) {
+ const browsingContextId = browsingContextFn()?.id;
+ logger.trace(
+ `[${browsingContextId}] Querying "${methodName} "` +
+ `reached the limit of retry attempts (${MAX_ATTEMPTS})`
+ );
+ throw e;
+ }
+
+ logger.trace(`Retrying "${methodName}", attempt: ${attempts}`);
+ }
+ }
+ };
+ },
+ }
+ );
+}
+
+/**
+ * Register the MarionetteCommands actor that holds all the commands.
+ */
+function registerCommandsActor() {
+ try {
+ ChromeUtils.registerWindowActor("MarionetteCommands", {
+ kind: "JSWindowActor",
+ parent: {
+ moduleURI:
+ "chrome://marionette/content/actors/MarionetteCommandsParent.jsm",
+ },
+ child: {
+ moduleURI:
+ "chrome://marionette/content/actors/MarionetteCommandsChild.jsm",
+ },
+
+ allFrames: true,
+ includeChrome: true,
+ });
+ } catch (e) {
+ if (e.name === "NotSupportedError") {
+ logger.warn(`MarionetteCommands actor is already registered!`);
+ } else {
+ throw e;
+ }
+ }
+}
+
+function unregisterCommandsActor() {
+ ChromeUtils.unregisterWindowActor("MarionetteCommands");
+}
diff --git a/testing/marionette/actors/MarionetteEventsChild.jsm b/testing/marionette/actors/MarionetteEventsChild.jsm
new file mode 100644
index 0000000000..e890c513c9
--- /dev/null
+++ b/testing/marionette/actors/MarionetteEventsChild.jsm
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-disable no-restricted-globals */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["MarionetteEventsChild"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ event: "chrome://marionette/content/event.js",
+ Log: "chrome://marionette/content/log.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+
+class MarionetteEventsChild extends JSWindowActorChild {
+ get innerWindowId() {
+ return this.manager.innerWindowId;
+ }
+
+ actorCreated() {
+ logger.trace(
+ `[${this.browsingContext.id}] MarionetteEvents actor created ` +
+ `for window id ${this.innerWindowId}`
+ );
+ }
+
+ handleEvent({ target, type }) {
+ // Ignore invalid combinations of load events and document's readyState.
+ if (
+ (type === "DOMContentLoaded" && target.readyState != "interactive") ||
+ (type === "pageshow" && target.readyState != "complete")
+ ) {
+ logger.warn(
+ `Ignoring event '${type}' because document has an invalid ` +
+ `readyState of '${target.readyState}'.`
+ );
+ return;
+ }
+
+ switch (type) {
+ case "beforeunload":
+ case "DOMContentLoaded":
+ case "hashchange":
+ case "pagehide":
+ case "pageshow":
+ case "popstate":
+ this.sendAsyncMessage("MarionetteEventsChild:PageLoadEvent", {
+ browsingContext: this.browsingContext,
+ documentURI: target.documentURI,
+ readyState: target.readyState,
+ type,
+ windowId: this.innerWindowId,
+ });
+ break;
+
+ // Listen for click event to indicate one click has happened, so actions
+ // code can send dblclick event
+ case "click":
+ event.DoubleClickTracker.setClick();
+ break;
+ case "dblclick":
+ case "unload":
+ event.DoubleClickTracker.resetClick();
+ break;
+ }
+ }
+}
diff --git a/testing/marionette/actors/MarionetteEventsParent.jsm b/testing/marionette/actors/MarionetteEventsParent.jsm
new file mode 100644
index 0000000000..4db861a8b0
--- /dev/null
+++ b/testing/marionette/actors/MarionetteEventsParent.jsm
@@ -0,0 +1,92 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+("use strict");
+
+const EXPORTED_SYMBOLS = [
+ "EventDispatcher",
+ "MarionetteEventsParent",
+ "registerEventsActor",
+ "unregisterEventsActor",
+];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ EventEmitter: "resource://gre/modules/EventEmitter.jsm",
+ Log: "chrome://marionette/content/log.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+
+// Singleton to allow forwarding events to registered listeners.
+const EventDispatcher = {
+ init() {
+ EventEmitter.decorate(this);
+ },
+};
+EventDispatcher.init();
+
+class MarionetteEventsParent extends JSWindowActorParent {
+ async receiveMessage(msg) {
+ const { name, data } = msg;
+
+ let rv;
+ switch (name) {
+ case "MarionetteEventsChild:PageLoadEvent":
+ EventDispatcher.emit("page-load", data);
+ break;
+ }
+
+ return rv;
+ }
+}
+
+/**
+ * Register Events actors to listen for page load events via EventDispatcher.
+ */
+function registerEventsActor() {
+ try {
+ // Register the JSWindowActor pair for events as used by Marionette
+ ChromeUtils.registerWindowActor("MarionetteEvents", {
+ kind: "JSWindowActor",
+ parent: {
+ moduleURI:
+ "chrome://marionette/content/actors/MarionetteEventsParent.jsm",
+ },
+ child: {
+ moduleURI:
+ "chrome://marionette/content/actors/MarionetteEventsChild.jsm",
+ events: {
+ beforeunload: { capture: true },
+ DOMContentLoaded: { mozSystemGroup: true },
+ hashchange: { mozSystemGroup: true },
+ pagehide: { mozSystemGroup: true },
+ pageshow: { mozSystemGroup: true },
+ // popstate doesn't bubble, as such use capturing phase
+ popstate: { capture: true, mozSystemGroup: true },
+
+ click: {},
+ dblclick: {},
+ unload: { capture: true },
+ },
+ },
+
+ allFrames: true,
+ includeChrome: true,
+ });
+ } catch (e) {
+ if (e.name === "NotSupportedError") {
+ logger.warn(`MarionetteEvents actor is already registered!`);
+ } else {
+ throw e;
+ }
+ }
+}
+
+function unregisterEventsActor() {
+ ChromeUtils.unregisterWindowActor("MarionetteEvents");
+}
diff --git a/testing/marionette/actors/MarionetteReftestChild.jsm b/testing/marionette/actors/MarionetteReftestChild.jsm
new file mode 100644
index 0000000000..dd1743d62c
--- /dev/null
+++ b/testing/marionette/actors/MarionetteReftestChild.jsm
@@ -0,0 +1,209 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["MarionetteReftestChild"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ Log: "chrome://marionette/content/log.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+
+/**
+ * Child JSWindowActor to handle navigation for reftests relying on marionette.
+ */
+class MarionetteReftestChild extends JSWindowActorChild {
+ constructor() {
+ super();
+
+ // This promise will resolve with the URL recorded in the "load" event
+ // handler. This URL will not be impacted by any hash modification that
+ // might be performed by the test script.
+ // The harness should be loaded before loading any test page, so the actors
+ // should be registered before the "load" event is received for a test page.
+ this._loadedURLPromise = new Promise(
+ r => (this._resolveLoadedURLPromise = r)
+ );
+ }
+
+ handleEvent(event) {
+ if (event.type == "load") {
+ const url = event.target.location.href;
+ logger.debug(`Handle load event with URL ${url}`);
+ this._resolveLoadedURLPromise(url);
+ }
+ }
+
+ actorCreated() {
+ logger.trace(
+ `[${this.browsingContext.id}] Reftest actor created ` +
+ `for window id ${this.manager.innerWindowId}`
+ );
+ }
+
+ async receiveMessage(msg) {
+ const { name, data } = msg;
+
+ let result;
+ switch (name) {
+ case "MarionetteReftestParent:reftestWait":
+ result = await this.reftestWait(data);
+ break;
+ }
+ return result;
+ }
+
+ /**
+ * Wait for a reftest page to be ready for screenshots:
+ * - wait for the loadedURL to be available (see handleEvent)
+ * - check if the URL matches the expected URL
+ * - if present, wait for the "reftest-wait" classname to be removed from the
+ * document element
+ *
+ * @param {Object} options
+ * @param {String} options.url
+ * The expected test page URL
+ * @param {Boolean} options.useRemote
+ * True when using e10s
+ * @return {Boolean}
+ * Returns true when the correct page is loaded and ready for
+ * screenshots. Returns false if the page loaded bug does not have the
+ * expected URL.
+ */
+ async reftestWait(options = {}) {
+ const { url, useRemote } = options;
+ const loadedURL = await this._loadedURLPromise;
+ if (loadedURL !== url) {
+ logger.debug(
+ `Window URL does not match the expected URL "${loadedURL}" !== "${url}"`
+ );
+ return false;
+ }
+
+ const documentElement = this.document.documentElement;
+ const hasReftestWait = documentElement.classList.contains("reftest-wait");
+
+ logger.debug("Waiting for event loop to spin");
+ await new Promise(resolve =>
+ this.document.defaultView.setTimeout(resolve, 0)
+ );
+
+ await this.paintComplete(useRemote);
+
+ if (hasReftestWait) {
+ const event = new Event("TestRendered", { bubbles: true });
+ documentElement.dispatchEvent(event);
+ logger.info("Emitted TestRendered event");
+ await this.reftestWaitRemoved();
+ await this.paintComplete(useRemote);
+ }
+ if (
+ this.document.defaultView.innerWidth < documentElement.scrollWidth ||
+ this.document.defaultView.innerHeight < documentElement.scrollHeight
+ ) {
+ logger.warn(
+ `${url} overflows viewport (width: ${documentElement.scrollWidth}, height: ${documentElement.scrollHeight})`
+ );
+ }
+ return true;
+ }
+
+ paintComplete(useRemote) {
+ logger.debug("Waiting for rendering");
+ let windowUtils = this.document.defaultView.windowUtils;
+ return new Promise(resolve => {
+ let maybeResolve = () => {
+ this.flushRendering();
+ if (useRemote) {
+ // Flush display (paint)
+ logger.debug("Force update of layer tree");
+ windowUtils.updateLayerTree();
+ }
+
+ if (windowUtils.isMozAfterPaintPending) {
+ logger.debug("isMozAfterPaintPending: true");
+ this.document.defaultView.addEventListener(
+ "MozAfterPaint",
+ maybeResolve,
+ {
+ once: true,
+ }
+ );
+ } else {
+ // resolve at the start of the next frame in case of leftover paints
+ logger.debug("isMozAfterPaintPending: false");
+ this.document.defaultView.requestAnimationFrame(() => {
+ this.document.defaultView.requestAnimationFrame(resolve);
+ });
+ }
+ };
+ maybeResolve();
+ });
+ }
+
+ reftestWaitRemoved() {
+ logger.debug("Waiting for reftest-wait removal");
+ return new Promise(resolve => {
+ const documentElement = this.document.documentElement;
+ let observer = new this.document.defaultView.MutationObserver(() => {
+ if (!documentElement.classList.contains("reftest-wait")) {
+ observer.disconnect();
+ logger.debug("reftest-wait removed");
+ this.document.defaultView.setTimeout(resolve, 0);
+ }
+ });
+ if (documentElement.classList.contains("reftest-wait")) {
+ observer.observe(documentElement, { attributes: true });
+ } else {
+ this.document.defaultView.setTimeout(resolve, 0);
+ }
+ });
+ }
+
+ flushRendering() {
+ let anyPendingPaintsGeneratedInDescendants = false;
+
+ let windowUtils = this.document.defaultView.windowUtils;
+
+ function flushWindow(win) {
+ let utils = win.windowUtils;
+ let afterPaintWasPending = utils.isMozAfterPaintPending;
+
+ let root = win.document.documentElement;
+ if (root) {
+ try {
+ // Flush pending restyles and reflows for this window (layout)
+ root.getBoundingClientRect();
+ } catch (e) {
+ logger.error("flushWindow failed", e);
+ }
+ }
+
+ if (!afterPaintWasPending && utils.isMozAfterPaintPending) {
+ anyPendingPaintsGeneratedInDescendants = true;
+ }
+
+ for (let i = 0; i < win.frames.length; ++i) {
+ flushWindow(win.frames[i]);
+ }
+ }
+ flushWindow(this.document.defaultView);
+
+ if (
+ anyPendingPaintsGeneratedInDescendants &&
+ !windowUtils.isMozAfterPaintPending
+ ) {
+ logger.error(
+ "Descendant frame generated a MozAfterPaint event, " +
+ "but the root document doesn't have one!"
+ );
+ }
+ }
+}
diff --git a/testing/marionette/actors/MarionetteReftestParent.jsm b/testing/marionette/actors/MarionetteReftestParent.jsm
new file mode 100644
index 0000000000..6a1a2187d8
--- /dev/null
+++ b/testing/marionette/actors/MarionetteReftestParent.jsm
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+("use strict");
+
+const EXPORTED_SYMBOLS = ["MarionetteReftestParent"];
+
+/**
+ * Parent JSWindowActor to handle navigation for reftests relying on marionette.
+ */
+class MarionetteReftestParent extends JSWindowActorParent {
+ /**
+ * Wait for the expected URL to be loaded.
+ *
+ * @param {String} url
+ * The expected url.
+ * @param {Boolean} useRemote
+ * True if tests are running with e10s.
+ * @return {Boolean} true if the page is fully loaded with the expected url,
+ * false otherwise.
+ */
+ async reftestWait(url, useRemote) {
+ try {
+ const isCorrectUrl = await this.sendQuery(
+ "MarionetteReftestParent:reftestWait",
+ {
+ url,
+ useRemote,
+ }
+ );
+ return isCorrectUrl;
+ } catch (e) {
+ if (e.name === "AbortError") {
+ // If the query is aborted, the window global is being destroyed, most
+ // likely because a navigation happened.
+ return false;
+ }
+
+ // Other errors should not be swallowed.
+ throw e;
+ }
+ }
+}
diff --git a/testing/marionette/addon.js b/testing/marionette/addon.js
new file mode 100644
index 0000000000..6702f015a3
--- /dev/null
+++ b/testing/marionette/addon.js
@@ -0,0 +1,136 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["Addon"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.jsm",
+ FileUtils: "resource://gre/modules/FileUtils.jsm",
+
+ error: "chrome://marionette/content/error.js",
+});
+
+// from https://developer.mozilla.org/en-US/Add-ons/Add-on_Manager/AddonManager#AddonInstall_errors
+const ERRORS = {
+ [-1]: "ERROR_NETWORK_FAILURE: A network error occured.",
+ [-2]: "ERROR_INCORECT_HASH: The downloaded file did not match the expected hash.",
+ [-3]: "ERROR_CORRUPT_FILE: The file appears to be corrupt.",
+ [-4]: "ERROR_FILE_ACCESS: There was an error accessing the filesystem.",
+ [-5]: "ERROR_SIGNEDSTATE_REQUIRED: The addon must be signed and isn't.",
+};
+
+async function installAddon(file) {
+ let install = await AddonManager.getInstallForFile(file, null, {
+ source: "internal",
+ });
+
+ if (install.error) {
+ throw new error.UnknownError(ERRORS[install.error]);
+ }
+
+ return install.install().catch(err => {
+ throw new error.UnknownError(ERRORS[install.error]);
+ });
+}
+
+/** Installs addons by path and uninstalls by ID. */
+class Addon {
+ /**
+ * Install a Firefox addon.
+ *
+ * If the addon is restartless, it can be used right away. Otherwise a
+ * restart is required.
+ *
+ * Temporary addons will automatically be uninstalled on shutdown and
+ * do not need to be signed, though they must be restartless.
+ *
+ * @param {string} path
+ * Full path to the extension package archive.
+ * @param {boolean=} temporary
+ * True to install the addon temporarily, false (default) otherwise.
+ *
+ * @return {Promise.<string>}
+ * Addon ID.
+ *
+ * @throws {UnknownError}
+ * If there is a problem installing the addon.
+ */
+ static async install(path, temporary = false) {
+ let addon;
+ let file;
+
+ try {
+ file = new FileUtils.File(path);
+ } catch (e) {
+ throw new error.UnknownError(`Expected absolute path: ${e}`, e);
+ }
+
+ if (!file.exists()) {
+ throw new error.UnknownError(`No such file or directory: ${path}`);
+ }
+
+ try {
+ if (temporary) {
+ addon = await AddonManager.installTemporaryAddon(file);
+ } else {
+ addon = await installAddon(file);
+ }
+ } catch (e) {
+ throw new error.UnknownError(
+ `Could not install add-on: ${path}: ${e.message}`,
+ e
+ );
+ }
+
+ return addon.id;
+ }
+
+ /**
+ * Uninstall a Firefox addon.
+ *
+ * If the addon is restartless it will be uninstalled right away.
+ * Otherwise, Firefox must be restarted for the change to take effect.
+ *
+ * @param {string} id
+ * ID of the addon to uninstall.
+ *
+ * @return {Promise}
+ *
+ * @throws {UnknownError}
+ * If there is a problem uninstalling the addon.
+ */
+ static async uninstall(id) {
+ let candidate = await AddonManager.getAddonByID(id);
+
+ return new Promise(resolve => {
+ let listener = {
+ onOperationCancelled: addon => {
+ if (addon.id === candidate.id) {
+ AddonManager.removeAddonListener(listener);
+ throw new error.UnknownError(
+ `Uninstall of ${candidate.id} has been canceled`
+ );
+ }
+ },
+
+ onUninstalled: addon => {
+ if (addon.id === candidate.id) {
+ AddonManager.removeAddonListener(listener);
+ resolve();
+ }
+ },
+ };
+
+ AddonManager.addAddonListener(listener);
+ candidate.uninstall();
+ });
+ }
+}
+this.Addon = Addon;
diff --git a/testing/marionette/assert.js b/testing/marionette/assert.js
new file mode 100644
index 0000000000..d8776e21ff
--- /dev/null
+++ b/testing/marionette/assert.js
@@ -0,0 +1,464 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["assert"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AppConstants: "resource://gre/modules/AppConstants.jsm",
+
+ browser: "chrome://marionette/content/browser.js",
+ error: "chrome://marionette/content/error.js",
+ evaluate: "chrome://marionette/content/evaluate.js",
+ pprint: "chrome://marionette/content/format.js",
+});
+
+const isFennec = () => AppConstants.platform == "android";
+const isFirefox = () =>
+ Services.appinfo.ID == "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}";
+const isThunderbird = () =>
+ Services.appinfo.ID == "{3550f703-e582-4d05-9a08-453d09bdfdc6}";
+
+/**
+ * Shorthands for common assertions made in Marionette.
+ *
+ * @namespace
+ */
+this.assert = {};
+
+/**
+ * Asserts that an arbitrary object is not acyclic.
+ *
+ * @param {*} obj
+ * Object to test. This assertion is only meaningful if passed
+ * an actual object or array.
+ * @param {Error=} [error=JavaScriptError] error
+ * Error to throw if assertion fails.
+ * @param {string=} message
+ * Custom message to use for `error` if assertion fails.
+ *
+ * @throws {JavaScriptError}
+ * If the object is cyclic.
+ */
+assert.acyclic = function(obj, msg = "", err = error.JavaScriptError) {
+ if (evaluate.isCyclic(obj)) {
+ throw new err(msg || "Cyclic object value");
+ }
+};
+
+/**
+ * Asserts that Marionette has a session.
+ *
+ * @param {GeckoDriver} driver
+ * Marionette driver instance.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @return {string}
+ * Current session's ID.
+ *
+ * @throws {InvalidSessionIDError}
+ * If <var>driver</var> does not have a session ID.
+ */
+assert.session = function(driver, msg = "") {
+ assert.that(
+ sessionID => sessionID,
+ msg,
+ error.InvalidSessionIDError
+ )(driver.sessionID);
+ return driver.sessionID;
+};
+
+/**
+ * Asserts that the current browser is Firefox Desktop.
+ *
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @throws {UnsupportedOperationError}
+ * If current browser is not Firefox.
+ */
+assert.firefox = function(msg = "") {
+ msg = msg || "Only supported in Firefox";
+ assert.that(isFirefox, msg, error.UnsupportedOperationError)();
+};
+
+/**
+ * Asserts that the current browser is Firefox Desktop or Thunderbird.
+ *
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @throws {UnsupportedOperationError}
+ * If current browser is not Firefox or Thunderbird.
+ */
+assert.desktop = function(msg = "") {
+ msg = msg || "Only supported in desktop applications";
+ assert.that(
+ obj => isFirefox(obj) || isThunderbird(obj),
+ msg,
+ error.UnsupportedOperationError
+ )();
+};
+
+/**
+ * Asserts that the current browser is Fennec, or Firefox for Android.
+ *
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @throws {UnsupportedOperationError}
+ * If current browser is not Fennec.
+ */
+assert.fennec = function(msg = "") {
+ msg = msg || "Only supported in Fennec";
+ assert.that(isFennec, msg, error.UnsupportedOperationError)();
+};
+
+/**
+ * Asserts that the current <var>context</var> is content.
+ *
+ * @param {string} context
+ * Context to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @return {string}
+ * <var>context</var> is returned unaltered.
+ *
+ * @throws {UnsupportedOperationError}
+ * If <var>context</var> is not content.
+ */
+assert.content = function(context, msg = "") {
+ msg = msg || "Only supported in content context";
+ assert.that(
+ c => c.toString() == "content",
+ msg,
+ error.UnsupportedOperationError
+ )(context);
+};
+
+/**
+ * Asserts that the {@link CanonicalBrowsingContext} is open.
+ *
+ * @param {CanonicalBrowsingContext} browsingContext
+ * Canonical browsing context to check.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @return {CanonicalBrowsingContext}
+ * <var>browsingContext</var> is returned unaltered.
+ *
+ * @throws {NoSuchWindowError}
+ * If <var>browsingContext</var> is no longer open.
+ */
+assert.open = function(browsingContext, msg = "") {
+ msg = msg || "Browsing context has been discarded";
+ return assert.that(
+ browsingContext => !!browsingContext?.currentWindowGlobal,
+ msg,
+ error.NoSuchWindowError
+ )(browsingContext);
+};
+
+/**
+ * Asserts that there is no current user prompt.
+ *
+ * @param {modal.Dialog} dialog
+ * Reference to current dialogue.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @throws {UnexpectedAlertOpenError}
+ * If there is a user prompt.
+ */
+assert.noUserPrompt = function(dialog, msg = "") {
+ assert.that(
+ d => d === null || typeof d == "undefined",
+ msg,
+ error.UnexpectedAlertOpenError
+ )(dialog);
+};
+
+/**
+ * Asserts that <var>obj</var> is defined.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @return {?}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not defined.
+ */
+assert.defined = function(obj, msg = "") {
+ msg = msg || pprint`Expected ${obj} to be defined`;
+ return assert.that(o => typeof o != "undefined", msg)(obj);
+};
+
+/**
+ * Asserts that <var>obj</var> is a finite number.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @return {number}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not a number.
+ */
+assert.number = function(obj, msg = "") {
+ msg = msg || pprint`Expected ${obj} to be finite number`;
+ return assert.that(Number.isFinite, msg)(obj);
+};
+
+/**
+ * Asserts that <var>obj</var> is a positive number.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @return {number}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not a positive integer.
+ */
+assert.positiveNumber = function(obj, msg = "") {
+ assert.number(obj, msg);
+ msg = msg || pprint`Expected ${obj} to be >= 0`;
+ return assert.that(n => n >= 0, msg)(obj);
+};
+
+/**
+ * Asserts that <var>obj</var> is callable.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @return {Function}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not callable.
+ */
+assert.callable = function(obj, msg = "") {
+ msg = msg || pprint`${obj} is not callable`;
+ return assert.that(o => typeof o == "function", msg)(obj);
+};
+
+/**
+ * Asserts that <var>obj</var> is an unsigned short number.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @return {number}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not an unsigned short.
+ */
+assert.unsignedShort = function(obj, msg = "") {
+ msg = msg || pprint`Expected ${obj} to be >= 0 and < 65536`;
+ return assert.that(n => n >= 0 && n < 65536, msg)(obj);
+};
+
+/**
+ * Asserts that <var>obj</var> is an integer.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @return {number}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not an integer.
+ */
+assert.integer = function(obj, msg = "") {
+ msg = msg || pprint`Expected ${obj} to be an integer`;
+ return assert.that(Number.isSafeInteger, msg)(obj);
+};
+
+/**
+ * Asserts that <var>obj</var> is a positive integer.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @return {number}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not a positive integer.
+ */
+assert.positiveInteger = function(obj, msg = "") {
+ assert.integer(obj, msg);
+ msg = msg || pprint`Expected ${obj} to be >= 0`;
+ return assert.that(n => n >= 0, msg)(obj);
+};
+
+/**
+ * Asserts that <var>obj</var> is a boolean.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @return {boolean}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not a boolean.
+ */
+assert.boolean = function(obj, msg = "") {
+ msg = msg || pprint`Expected ${obj} to be boolean`;
+ return assert.that(b => typeof b == "boolean", msg)(obj);
+};
+
+/**
+ * Asserts that <var>obj</var> is a string.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @return {string}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not a string.
+ */
+assert.string = function(obj, msg = "") {
+ msg = msg || pprint`Expected ${obj} to be a string`;
+ return assert.that(s => typeof s == "string", msg)(obj);
+};
+
+/**
+ * Asserts that <var>obj</var> is an object.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @return {Object}
+ * obj| is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not an object.
+ */
+assert.object = function(obj, msg = "") {
+ msg = msg || pprint`Expected ${obj} to be an object`;
+ return assert.that(o => {
+ // unable to use instanceof because LHS and RHS may come from
+ // different globals
+ let s = Object.prototype.toString.call(o);
+ return s == "[object Object]" || s == "[object nsJSIID]";
+ }, msg)(obj);
+};
+
+/**
+ * Asserts that <var>prop</var> is in <var>obj</var>.
+ *
+ * @param {?} prop
+ * An array element or own property to test if is in <var>obj</var>.
+ * @param {?} obj
+ * An array or an Object that is being tested.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @return {?}
+ * The array element, or the value of <var>obj</var>'s own property
+ * <var>prop</var>.
+ *
+ * @throws {InvalidArgumentError}
+ * If the <var>obj</var> was an array and did not contain <var>prop</var>.
+ * Otherwise if <var>prop</var> is not in <var>obj</var>, or <var>obj</var>
+ * is not an object.
+ */
+assert.in = function(prop, obj, msg = "") {
+ if (Array.isArray(obj)) {
+ assert.that(p => obj.includes(p), msg)(prop);
+ return prop;
+ }
+ assert.object(obj, msg);
+ msg = msg || pprint`Expected ${prop} in ${obj}`;
+ assert.that(p => obj.hasOwnProperty(p), msg)(prop);
+ return obj[prop];
+};
+
+/**
+ * Asserts that <var>obj</var> is an Array.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @return {Object}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not an Array.
+ */
+assert.array = function(obj, msg = "") {
+ msg = msg || pprint`Expected ${obj} to be an Array`;
+ return assert.that(Array.isArray, msg)(obj);
+};
+
+/**
+ * Returns a function that is used to assert the |predicate|.
+ *
+ * @param {function(?): boolean} predicate
+ * Evaluated on calling the return value of this function. If its
+ * return value of the inner function is false, <var>error</var>
+ * is thrown with <var>message</var>.
+ * @param {string=} message
+ * Custom error message.
+ * @param {Error=} error
+ * Custom error type by its class.
+ *
+ * @return {function(?): ?}
+ * Function that takes and returns the passed in value unaltered,
+ * and which may throw <var>error</var> with <var>message</var>
+ * if <var>predicate</var> evaluates to false.
+ */
+assert.that = function(
+ predicate,
+ message = "",
+ err = error.InvalidArgumentError
+) {
+ return obj => {
+ if (!predicate(obj)) {
+ throw new err(message);
+ }
+ return obj;
+ };
+};
diff --git a/testing/marionette/atom.js b/testing/marionette/atom.js
new file mode 100644
index 0000000000..0a17742fb2
--- /dev/null
+++ b/testing/marionette/atom.js
@@ -0,0 +1,223 @@
+// Copyright 2011-2017 Software Freedom Conservancy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+const EXPORTED_SYMBOLS = ["atom"];
+
+/** @namespace */
+this.atom = {};
+
+// https://github.com/SeleniumHQ/selenium/blob/master/javascript/atoms/dom.js#L979
+atom.getElementText = function(element, window){return function(){var g,l=this;function n(a){return void 0!==a}function p(a){return"string"==typeof a}function aa(a){return"number"==typeof a}function ba(a,b){a=a.split(".");var c=l;a[0]in c||!c.execScript||c.execScript("var "+a[0]);for(var d;a.length&&(d=a.shift());)!a.length&&n(b)?c[d]=b:c[d]&&c[d]!==Object.prototype[d]?c=c[d]:c=c[d]={}}
+function ca(a){var b=typeof a;if("object"==b)if(a){if(a instanceof Array)return"array";if(a instanceof Object)return b;var c=Object.prototype.toString.call(a);if("[object Window]"==c)return"object";if("[object Array]"==c||"number"==typeof a.length&&"undefined"!=typeof a.splice&&"undefined"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable("splice"))return"array";if("[object Function]"==c||"undefined"!=typeof a.call&&"undefined"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable("call"))return"function"}else return"null";
+else if("function"==b&&"undefined"==typeof a.call)return"object";return b}function da(a,b,c){return a.call.apply(a.bind,arguments)}function ea(a,b,c){if(!a)throw Error();if(2<arguments.length){var d=Array.prototype.slice.call(arguments,2);return function(){var c=Array.prototype.slice.call(arguments);Array.prototype.unshift.apply(c,d);return a.apply(b,c)}}return function(){return a.apply(b,arguments)}}
+function fa(a,b,c){Function.prototype.bind&&-1!=Function.prototype.bind.toString().indexOf("native code")?fa=da:fa=ea;return fa.apply(null,arguments)}function ha(a,b){var c=Array.prototype.slice.call(arguments,1);return function(){var b=c.slice();b.push.apply(b,arguments);return a.apply(this,b)}}
+function q(a,b){function c(){}c.prototype=b.prototype;a.U=b.prototype;a.prototype=new c;a.prototype.constructor=a;a.S=function(a,c,f){for(var d=Array(arguments.length-2),e=2;e<arguments.length;e++)d[e-2]=arguments[e];return b.prototype[c].apply(a,d)}};function ia(a,b){this.code=a;this.a=u[a]||ja;this.message=b||"";a=this.a.replace(/((?:^|\s+)[a-z])/g,function(a){return a.toUpperCase().replace(/^[\s\xa0]+/g,"")});b=a.length-5;if(0>b||a.indexOf("Error",b)!=b)a+="Error";this.name=a;a=Error(this.message);a.name=this.name;this.stack=a.stack||""}q(ia,Error);var ja="unknown error",u={15:"element not selectable",11:"element not visible"};u[31]=ja;u[30]=ja;u[24]="invalid cookie domain";u[29]="invalid element coordinates";u[12]="invalid element state";
+u[32]="invalid selector";u[51]="invalid selector";u[52]="invalid selector";u[17]="javascript error";u[405]="unsupported operation";u[34]="move target out of bounds";u[27]="no such alert";u[7]="no such element";u[8]="no such frame";u[23]="no such window";u[28]="script timeout";u[33]="session not created";u[10]="stale element reference";u[21]="timeout";u[25]="unable to set cookie";u[26]="unexpected alert open";u[13]=ja;u[9]="unknown command";ia.prototype.toString=function(){return this.name+": "+this.message};var ka={aliceblue:"#f0f8ff",antiquewhite:"#faebd7",aqua:"#00ffff",aquamarine:"#7fffd4",azure:"#f0ffff",beige:"#f5f5dc",bisque:"#ffe4c4",black:"#000000",blanchedalmond:"#ffebcd",blue:"#0000ff",blueviolet:"#8a2be2",brown:"#a52a2a",burlywood:"#deb887",cadetblue:"#5f9ea0",chartreuse:"#7fff00",chocolate:"#d2691e",coral:"#ff7f50",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cyan:"#00ffff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",darkgreen:"#006400",
+darkgrey:"#a9a9a9",darkkhaki:"#bdb76b",darkmagenta:"#8b008b",darkolivegreen:"#556b2f",darkorange:"#ff8c00",darkorchid:"#9932cc",darkred:"#8b0000",darksalmon:"#e9967a",darkseagreen:"#8fbc8f",darkslateblue:"#483d8b",darkslategray:"#2f4f4f",darkslategrey:"#2f4f4f",darkturquoise:"#00ced1",darkviolet:"#9400d3",deeppink:"#ff1493",deepskyblue:"#00bfff",dimgray:"#696969",dimgrey:"#696969",dodgerblue:"#1e90ff",firebrick:"#b22222",floralwhite:"#fffaf0",forestgreen:"#228b22",fuchsia:"#ff00ff",gainsboro:"#dcdcdc",
+ghostwhite:"#f8f8ff",gold:"#ffd700",goldenrod:"#daa520",gray:"#808080",green:"#008000",greenyellow:"#adff2f",grey:"#808080",honeydew:"#f0fff0",hotpink:"#ff69b4",indianred:"#cd5c5c",indigo:"#4b0082",ivory:"#fffff0",khaki:"#f0e68c",lavender:"#e6e6fa",lavenderblush:"#fff0f5",lawngreen:"#7cfc00",lemonchiffon:"#fffacd",lightblue:"#add8e6",lightcoral:"#f08080",lightcyan:"#e0ffff",lightgoldenrodyellow:"#fafad2",lightgray:"#d3d3d3",lightgreen:"#90ee90",lightgrey:"#d3d3d3",lightpink:"#ffb6c1",lightsalmon:"#ffa07a",
+lightseagreen:"#20b2aa",lightskyblue:"#87cefa",lightslategray:"#778899",lightslategrey:"#778899",lightsteelblue:"#b0c4de",lightyellow:"#ffffe0",lime:"#00ff00",limegreen:"#32cd32",linen:"#faf0e6",magenta:"#ff00ff",maroon:"#800000",mediumaquamarine:"#66cdaa",mediumblue:"#0000cd",mediumorchid:"#ba55d3",mediumpurple:"#9370db",mediumseagreen:"#3cb371",mediumslateblue:"#7b68ee",mediumspringgreen:"#00fa9a",mediumturquoise:"#48d1cc",mediumvioletred:"#c71585",midnightblue:"#191970",mintcream:"#f5fffa",mistyrose:"#ffe4e1",
+moccasin:"#ffe4b5",navajowhite:"#ffdead",navy:"#000080",oldlace:"#fdf5e6",olive:"#808000",olivedrab:"#6b8e23",orange:"#ffa500",orangered:"#ff4500",orchid:"#da70d6",palegoldenrod:"#eee8aa",palegreen:"#98fb98",paleturquoise:"#afeeee",palevioletred:"#db7093",papayawhip:"#ffefd5",peachpuff:"#ffdab9",peru:"#cd853f",pink:"#ffc0cb",plum:"#dda0dd",powderblue:"#b0e0e6",purple:"#800080",red:"#ff0000",rosybrown:"#bc8f8f",royalblue:"#4169e1",saddlebrown:"#8b4513",salmon:"#fa8072",sandybrown:"#f4a460",seagreen:"#2e8b57",
+seashell:"#fff5ee",sienna:"#a0522d",silver:"#c0c0c0",skyblue:"#87ceeb",slateblue:"#6a5acd",slategray:"#708090",slategrey:"#708090",snow:"#fffafa",springgreen:"#00ff7f",steelblue:"#4682b4",tan:"#d2b48c",teal:"#008080",thistle:"#d8bfd8",tomato:"#ff6347",turquoise:"#40e0d0",violet:"#ee82ee",wheat:"#f5deb3",white:"#ffffff",whitesmoke:"#f5f5f5",yellow:"#ffff00",yellowgreen:"#9acd32"};function la(a,b){this.width=a;this.height=b}g=la.prototype;g.toString=function(){return"("+this.width+" x "+this.height+")"};g.ceil=function(){this.width=Math.ceil(this.width);this.height=Math.ceil(this.height);return this};g.floor=function(){this.width=Math.floor(this.width);this.height=Math.floor(this.height);return this};g.round=function(){this.width=Math.round(this.width);this.height=Math.round(this.height);return this};g.scale=function(a,b){b=aa(b)?b:a;this.width*=a;this.height*=b;return this};function ma(a){var b=a.length-1;return 0<=b&&a.indexOf(" ",b)==b}function na(a){return String(a).replace(/\-([a-z])/g,function(a,c){return c.toUpperCase()})};
+function v(a,b,c){this.a=a;this.b=b||1;this.f=c||1};function oa(a){this.b=a;this.a=0}function pa(a){a=a.match(qa);for(var b=0;b<a.length;b++)ra.test(a[b])&&a.splice(b,1);return new oa(a)}var qa=/\$?(?:(?![0-9-\.])(?:\*|[\w-\.]+):)?(?![0-9-\.])(?:\*|[\w-\.]+)|\/\/|\.\.|::|\d+(?:\.\d*)?|\.\d+|"[^"]*"|'[^']*'|[!<>]=|\s+|./g,ra=/^\s/;function w(a,b){return a.b[a.a+(b||0)]}oa.prototype.next=function(){return this.b[this.a++]};function sa(a){return a.b.length<=a.a};var ta;a:{var ua=l.navigator;if(ua){var va=ua.userAgent;if(va){ta=va;break a}}ta=""};function x(a,b){this.j=a;this.c=n(b)?b:null;this.b=null;switch(a){case "comment":this.b=8;break;case "text":this.b=3;break;case "processing-instruction":this.b=7;break;case "node":break;default:throw Error("Unexpected argument");}}function wa(a){return"comment"==a||"text"==a||"processing-instruction"==a||"node"==a}x.prototype.a=function(a){return null===this.b||this.b==a.nodeType};x.prototype.f=function(){return this.j};
+x.prototype.toString=function(){var a="Kind Test: "+this.j;null===this.c||(a+=y(this.c));return a};function xa(a,b){this.o=a.toLowerCase();a="*"==this.o?"*":"http://www.w3.org/1999/xhtml";this.b=b?b.toLowerCase():a}xa.prototype.a=function(a){var b=a.nodeType;if(1!=b&&2!=b)return!1;b=n(a.localName)?a.localName:a.nodeName;return"*"!=this.o&&this.o!=b.toLowerCase()?!1:"*"==this.b?!0:this.b==(a.namespaceURI?a.namespaceURI.toLowerCase():"http://www.w3.org/1999/xhtml")};xa.prototype.f=function(){return this.o};
+xa.prototype.toString=function(){return"Name Test: "+("http://www.w3.org/1999/xhtml"==this.b?"":this.b+":")+this.o};function ya(a){switch(a.nodeType){case 1:return ha(za,a);case 9:return ya(a.documentElement);case 11:case 10:case 6:case 12:return Aa;default:return a.parentNode?ya(a.parentNode):Aa}}function Aa(){return null}function za(a,b){if(a.prefix==b)return a.namespaceURI||"http://www.w3.org/1999/xhtml";var c=a.getAttributeNode("xmlns:"+b);return c&&c.specified?c.value||null:a.parentNode&&9!=a.parentNode.nodeType?za(a.parentNode,b):null};function Ba(a,b){if(p(a))return p(b)&&1==b.length?a.indexOf(b,0):-1;for(var c=0;c<a.length;c++)if(c in a&&a[c]===b)return c;return-1}function z(a,b){for(var c=a.length,d=p(a)?a.split(""):a,e=0;e<c;e++)e in d&&b.call(void 0,d[e],e,a)}function A(a,b,c){var d=c;z(a,function(c,f){d=b.call(void 0,d,c,f,a)});return d}function Ca(a,b){for(var c=a.length,d=p(a)?a.split(""):a,e=0;e<c;e++)if(e in d&&b.call(void 0,d[e],e,a))return!0;return!1}
+function Da(a,b){for(var c=a.length,d=p(a)?a.split(""):a,e=0;e<c;e++)if(e in d&&!b.call(void 0,d[e],e,a))return!1;return!0}function Ea(a){return Array.prototype.concat.apply([],arguments)}function Fa(a,b,c){return 2>=arguments.length?Array.prototype.slice.call(a,b):Array.prototype.slice.call(a,b,c)};var Ga="backgroundColor borderTopColor borderRightColor borderBottomColor borderLeftColor color outlineColor".split(" "),Ha=/#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])/,Ia=/^#(?:[0-9a-f]{3}){1,2}$/i,Ja=/^(?:rgba)?\((\d{1,3}),\s?(\d{1,3}),\s?(\d{1,3}),\s?(0|1|0\.\d*)\)$/i,Ka=/^(?:rgb)?\((0|[1-9]\d{0,2}),\s?(0|[1-9]\d{0,2}),\s?(0|[1-9]\d{0,2})\)$/i;function La(a,b){this.x=n(a)?a:0;this.y=n(b)?b:0}g=La.prototype;g.toString=function(){return"("+this.x+", "+this.y+")"};g.ceil=function(){this.x=Math.ceil(this.x);this.y=Math.ceil(this.y);return this};g.floor=function(){this.x=Math.floor(this.x);this.y=Math.floor(this.y);return this};g.round=function(){this.x=Math.round(this.x);this.y=Math.round(this.y);return this};g.scale=function(a,b){b=aa(b)?b:a;this.x*=a;this.y*=b;return this};var Ma=-1!=ta.indexOf("Macintosh"),Na=-1!=ta.indexOf("Windows");function Oa(a,b,c,d){this.c=a;this.a=b;this.b=c;this.f=d}g=Oa.prototype;g.toString=function(){return"("+this.c+"t, "+this.a+"r, "+this.b+"b, "+this.f+"l)"};g.contains=function(a){return this&&a?a instanceof Oa?a.f>=this.f&&a.a<=this.a&&a.c>=this.c&&a.b<=this.b:a.x>=this.f&&a.x<=this.a&&a.y>=this.c&&a.y<=this.b:!1};g.ceil=function(){this.c=Math.ceil(this.c);this.a=Math.ceil(this.a);this.b=Math.ceil(this.b);this.f=Math.ceil(this.f);return this};
+g.floor=function(){this.c=Math.floor(this.c);this.a=Math.floor(this.a);this.b=Math.floor(this.b);this.f=Math.floor(this.f);return this};g.round=function(){this.c=Math.round(this.c);this.a=Math.round(this.a);this.b=Math.round(this.b);this.f=Math.round(this.f);return this};g.scale=function(a,b){b=aa(b)?b:a;this.f*=a;this.a*=a;this.c*=b;this.b*=b;return this};function Pa(a,b){this.w={};this.m=[];this.a=0;var c=arguments.length;if(1<c){if(c%2)throw Error("Uneven number of arguments");for(var d=0;d<c;d+=2)this.set(arguments[d],arguments[d+1])}else if(a){if(a instanceof Pa){d=Qa(a);Ra(a);var e=[];for(c=0;c<a.m.length;c++)e.push(a.w[a.m[c]])}else{var c=[],f=0;for(d in a)c[f++]=d;d=c;c=[];f=0;for(e in a)c[f++]=a[e];e=c}for(c=0;c<d.length;c++)this.set(d[c],e[c])}}function Qa(a){Ra(a);return a.m.concat()}
+function Ra(a){var b,c;if(a.a!=a.m.length){for(b=c=0;c<a.m.length;){var d=a.m[c];Object.prototype.hasOwnProperty.call(a.w,d)&&(a.m[b++]=d);c++}a.m.length=b}if(a.a!=a.m.length){var e={};for(b=c=0;c<a.m.length;)d=a.m[c],Object.prototype.hasOwnProperty.call(e,d)||(a.m[b++]=d,e[d]=1),c++;a.m.length=b}}Pa.prototype.get=function(a,b){return Object.prototype.hasOwnProperty.call(this.w,a)?this.w[a]:b};
+Pa.prototype.set=function(a,b){Object.prototype.hasOwnProperty.call(this.w,a)||(this.a++,this.m.push(a));this.w[a]=b};function B(a,b,c,d){this.a=a;this.b=b;this.width=c;this.height=d}g=B.prototype;g.toString=function(){return"("+this.a+", "+this.b+" - "+this.width+"w x "+this.height+"h)"};g.contains=function(a){return a instanceof La?a.x>=this.a&&a.x<=this.a+this.width&&a.y>=this.b&&a.y<=this.b+this.height:this.a<=a.a&&this.a+this.width>=a.a+a.width&&this.b<=a.b&&this.b+this.height>=a.b+a.height};
+g.ceil=function(){this.a=Math.ceil(this.a);this.b=Math.ceil(this.b);this.width=Math.ceil(this.width);this.height=Math.ceil(this.height);return this};g.floor=function(){this.a=Math.floor(this.a);this.b=Math.floor(this.b);this.width=Math.floor(this.width);this.height=Math.floor(this.height);return this};g.round=function(){this.a=Math.round(this.a);this.b=Math.round(this.b);this.width=Math.round(this.width);this.height=Math.round(this.height);return this};
+g.scale=function(a,b){b=aa(b)?b:a;this.a*=a;this.width*=a;this.b*=b;this.height*=b;return this};var Sa,Ta,Ua=function(){var a=l.Components;if(!a)return!1;try{if(!a.classes)return!1}catch(f){return!1}var b=a.classes,a=a.interfaces,c=b["@mozilla.org/xpcom/version-comparator;1"].getService(a.nsIVersionComparator),b=b["@mozilla.org/xre/app-info;1"].getService(a.nsIXULAppInfo),d=b.platformVersion,e=b.version;Sa=function(a){c.compare(d,""+a)};Ta=function(a){c.compare(e,""+a)};return!0}();function Va(a){for(;a&&1!=a.nodeType;)a=a.previousSibling;return a}function Wa(a,b){if(!a||!b)return!1;if(a.contains&&1==b.nodeType)return a==b||a.contains(b);if("undefined"!=typeof a.compareDocumentPosition)return a==b||!!(a.compareDocumentPosition(b)&16);for(;b&&a!=b;)b=b.parentNode;return b==a}
+function Xa(a,b){if(a==b)return 0;if(a.compareDocumentPosition)return a.compareDocumentPosition(b)&2?1:-1;if("sourceIndex"in a||a.parentNode&&"sourceIndex"in a.parentNode){var c=1==a.nodeType,d=1==b.nodeType;if(c&&d)return a.sourceIndex-b.sourceIndex;var e=a.parentNode,f=b.parentNode;return e==f?Ya(a,b):!c&&Wa(e,b)?-1*Za(a,b):!d&&Wa(f,a)?Za(b,a):(c?a.sourceIndex:e.sourceIndex)-(d?b.sourceIndex:f.sourceIndex)}d=D(a);c=d.createRange();c.selectNode(a);c.collapse(!0);a=d.createRange();a.selectNode(b);
+a.collapse(!0);return c.compareBoundaryPoints(l.Range.START_TO_END,a)}function Za(a,b){var c=a.parentNode;if(c==b)return-1;for(;b.parentNode!=c;)b=b.parentNode;return Ya(b,a)}function Ya(a,b){for(;b=b.previousSibling;)if(b==a)return-1;return 1}function D(a){return 9==a.nodeType?a:a.ownerDocument||a.document}function $a(a,b){a&&(a=a.parentNode);for(var c=0;a;){if(b(a))return a;a=a.parentNode;c++}return null}function ab(a){this.a=a||l.document||document}
+ab.prototype.getElementsByTagName=function(a,b){return(b||this.a).getElementsByTagName(String(a))};ab.prototype.contains=Wa;function E(a){var b=null,c=a.nodeType;1==c&&(b=a.textContent,b=void 0==b||null==b?a.innerText:b,b=void 0==b||null==b?"":b);if("string"!=typeof b)if(9==c||1==c){a=9==c?a.documentElement:a.firstChild;for(var c=0,d=[],b="";a;){do 1!=a.nodeType&&(b+=a.nodeValue),d[c++]=a;while(a=a.firstChild);for(;c&&!(a=d[--c].nextSibling););}}else b=a.nodeValue;return""+b}
+function F(a,b,c){if(null===b)return!0;try{if(!a.getAttribute)return!1}catch(d){return!1}return null==c?!!a.getAttribute(b):a.getAttribute(b,2)==c}function bb(a,b,c,d,e){return cb.call(null,a,b,p(c)?c:null,p(d)?d:null,e||new G)}
+function cb(a,b,c,d,e){b.getElementsByName&&d&&"name"==c?(b=b.getElementsByName(d),z(b,function(b){a.a(b)&&H(e,b)})):b.getElementsByClassName&&d&&"class"==c?(b=b.getElementsByClassName(d),z(b,function(b){b.className==d&&a.a(b)&&H(e,b)})):a instanceof x?db(a,b,c,d,e):b.getElementsByTagName&&(b=b.getElementsByTagName(a.f()),z(b,function(a){F(a,c,d)&&H(e,a)}));return e}function db(a,b,c,d,e){for(b=b.firstChild;b;b=b.nextSibling)F(b,c,d)&&a.a(b)&&H(e,b),db(a,b,c,d,e)};function I(a,b){b&&"string"!==typeof b&&(b=b.toString());return!!a&&1==a.nodeType&&(!b||a.tagName.toUpperCase()==b)};function G(){this.b=this.a=null;this.s=0}function eb(a){this.node=a;this.next=this.a=null}function fb(a,b){if(!a.a)return b;if(!b.a)return a;var c=a.a;b=b.a;for(var d=null,e,f=0;c&&b;)c.node==b.node?(e=c,c=c.next,b=b.next):0<Xa(c.node,b.node)?(e=b,b=b.next):(e=c,c=c.next),(e.a=d)?d.next=e:a.a=e,d=e,f++;for(e=c||b;e;)e.a=d,d=d.next=e,f++,e=e.next;a.b=d;a.s=f;return a}function gb(a,b){b=new eb(b);b.next=a.a;a.b?a.a.a=b:a.a=a.b=b;a.a=b;a.s++}
+function H(a,b){b=new eb(b);b.a=a.b;a.a?a.b.next=b:a.a=a.b=b;a.b=b;a.s++}function hb(a){return(a=a.a)?a.node:null}function ib(a){return(a=hb(a))?E(a):""}G.prototype.iterator=function(a){return new jb(this,!!a)};function jb(a,b){this.f=a;this.b=(this.A=b)?a.b:a.a;this.a=null}jb.prototype.next=function(){var a=this.b;if(a){var b=this.a=a;this.b=this.A?a.a:a.next;return b.node}return null};function J(a){this.l=a;this.b=this.i=!1;this.f=null}function y(a){return"\n "+a.toString().split("\n").join("\n ")}function kb(a,b){a.i=b}function lb(a,b){a.b=b}function K(a,b){a=a.a(b);return a instanceof G?+ib(a):+a}function M(a,b){a=a.a(b);return a instanceof G?ib(a):""+a}function N(a,b){a=a.a(b);return a instanceof G?!!a.s:!!a};function mb(a,b,c){J.call(this,a.l);this.c=a;this.j=b;this.v=c;this.i=b.i||c.i;this.b=b.b||c.b;this.c==nb&&(c.b||c.i||4==c.l||0==c.l||!b.f?b.b||b.i||4==b.l||0==b.l||!c.f||(this.f={name:c.f.name,B:b}):this.f={name:b.f.name,B:c})}q(mb,J);
+function O(a,b,c,d,e){b=b.a(d);c=c.a(d);var f;if(b instanceof G&&c instanceof G){b=b.iterator();for(d=b.next();d;d=b.next())for(e=c.iterator(),f=e.next();f;f=e.next())if(a(E(d),E(f)))return!0;return!1}if(b instanceof G||c instanceof G){b instanceof G?(e=b,d=c):(e=c,d=b);f=e.iterator();for(var h=typeof d,k=f.next();k;k=f.next()){switch(h){case "number":k=+E(k);break;case "boolean":k=!!E(k);break;case "string":k=E(k);break;default:throw Error("Illegal primitive type for comparison.");}if(e==b&&a(k,
+d)||e==c&&a(d,k))return!0}return!1}return e?"boolean"==typeof b||"boolean"==typeof c?a(!!b,!!c):"number"==typeof b||"number"==typeof c?a(+b,+c):a(b,c):a(+b,+c)}mb.prototype.a=function(a){return this.c.u(this.j,this.v,a)};mb.prototype.toString=function(){var a="Binary Expression: "+this.c,a=a+y(this.j);return a+=y(this.v)};function ob(a,b,c,d){this.O=a;this.K=b;this.l=c;this.u=d}ob.prototype.toString=function(){return this.O};var pb={};
+function P(a,b,c,d){if(pb.hasOwnProperty(a))throw Error("Binary operator already created: "+a);a=new ob(a,b,c,d);return pb[a.toString()]=a}P("div",6,1,function(a,b,c){return K(a,c)/K(b,c)});P("mod",6,1,function(a,b,c){return K(a,c)%K(b,c)});P("*",6,1,function(a,b,c){return K(a,c)*K(b,c)});P("+",5,1,function(a,b,c){return K(a,c)+K(b,c)});P("-",5,1,function(a,b,c){return K(a,c)-K(b,c)});P("<",4,2,function(a,b,c){return O(function(a,b){return a<b},a,b,c)});
+P(">",4,2,function(a,b,c){return O(function(a,b){return a>b},a,b,c)});P("<=",4,2,function(a,b,c){return O(function(a,b){return a<=b},a,b,c)});P(">=",4,2,function(a,b,c){return O(function(a,b){return a>=b},a,b,c)});var nb=P("=",3,2,function(a,b,c){return O(function(a,b){return a==b},a,b,c,!0)});P("!=",3,2,function(a,b,c){return O(function(a,b){return a!=b},a,b,c,!0)});P("and",2,2,function(a,b,c){return N(a,c)&&N(b,c)});P("or",1,2,function(a,b,c){return N(a,c)||N(b,c)});function qb(a,b){if(b.a.length&&4!=a.l)throw Error("Primary expression must evaluate to nodeset if filter has predicate(s).");J.call(this,a.l);this.c=a;this.j=b;this.i=a.i;this.b=a.b}q(qb,J);qb.prototype.a=function(a){a=this.c.a(a);return rb(this.j,a)};qb.prototype.toString=function(){var a="Filter:"+y(this.c);return a+=y(this.j)};function sb(a,b){if(b.length<a.J)throw Error("Function "+a.o+" expects at least"+a.J+" arguments, "+b.length+" given");if(null!==a.F&&b.length>a.F)throw Error("Function "+a.o+" expects at most "+a.F+" arguments, "+b.length+" given");a.N&&z(b,function(b,d){if(4!=b.l)throw Error("Argument "+d+" to function "+a.o+" is not of type Nodeset: "+b);});J.call(this,a.l);this.C=a;this.c=b;kb(this,a.i||Ca(b,function(a){return a.i}));lb(this,a.M&&!b.length||a.L&&!!b.length||Ca(b,function(a){return a.b}))}
+q(sb,J);sb.prototype.a=function(a){return this.C.u.apply(null,Ea(a,this.c))};sb.prototype.toString=function(){var a="Function: "+this.C;if(this.c.length)var b=A(this.c,function(a,b){return a+y(b)},"Arguments:"),a=a+y(b);return a};function tb(a,b,c,d,e,f,h,k,r){this.o=a;this.l=b;this.i=c;this.M=d;this.L=e;this.u=f;this.J=h;this.F=n(k)?k:h;this.N=!!r}tb.prototype.toString=function(){return this.o};var ub={};
+function Q(a,b,c,d,e,f,h,k){if(ub.hasOwnProperty(a))throw Error("Function already created: "+a+".");ub[a]=new tb(a,b,c,d,!1,e,f,h,k)}Q("boolean",2,!1,!1,function(a,b){return N(b,a)},1);Q("ceiling",1,!1,!1,function(a,b){return Math.ceil(K(b,a))},1);Q("concat",3,!1,!1,function(a,b){return A(Fa(arguments,1),function(b,d){return b+M(d,a)},"")},2,null);Q("contains",2,!1,!1,function(a,b,c){b=M(b,a);a=M(c,a);return-1!=b.indexOf(a)},2);Q("count",1,!1,!1,function(a,b){return b.a(a).s},1,1,!0);
+Q("false",2,!1,!1,function(){return!1},0);Q("floor",1,!1,!1,function(a,b){return Math.floor(K(b,a))},1);Q("id",4,!1,!1,function(a,b){var c=a.a,d=9==c.nodeType?c:c.ownerDocument;a=M(b,a).split(/\s+/);var e=[];z(a,function(a){a=d.getElementById(a);!a||0<=Ba(e,a)||e.push(a)});e.sort(Xa);var f=new G;z(e,function(a){H(f,a)});return f},1);Q("lang",2,!1,!1,function(){return!1},1);Q("last",1,!0,!1,function(a){if(1!=arguments.length)throw Error("Function last expects ()");return a.f},0);
+Q("local-name",3,!1,!0,function(a,b){return(a=b?hb(b.a(a)):a.a)?a.localName||a.nodeName.toLowerCase():""},0,1,!0);Q("name",3,!1,!0,function(a,b){return(a=b?hb(b.a(a)):a.a)?a.nodeName.toLowerCase():""},0,1,!0);Q("namespace-uri",3,!0,!1,function(){return""},0,1,!0);Q("normalize-space",3,!1,!0,function(a,b){return(b?M(b,a):E(a.a)).replace(/[\s\xa0]+/g," ").replace(/^\s+|\s+$/g,"")},0,1);Q("not",2,!1,!1,function(a,b){return!N(b,a)},1);Q("number",1,!1,!0,function(a,b){return b?K(b,a):+E(a.a)},0,1);
+Q("position",1,!0,!1,function(a){return a.b},0);Q("round",1,!1,!1,function(a,b){return Math.round(K(b,a))},1);Q("starts-with",2,!1,!1,function(a,b,c){b=M(b,a);a=M(c,a);return!b.lastIndexOf(a,0)},2);Q("string",3,!1,!0,function(a,b){return b?M(b,a):E(a.a)},0,1);Q("string-length",1,!1,!0,function(a,b){return(b?M(b,a):E(a.a)).length},0,1);
+Q("substring",3,!1,!1,function(a,b,c,d){c=K(c,a);if(isNaN(c)||Infinity==c||-Infinity==c)return"";d=d?K(d,a):Infinity;if(isNaN(d)||-Infinity===d)return"";c=Math.round(c)-1;var e=Math.max(c,0);a=M(b,a);return Infinity==d?a.substring(e):a.substring(e,c+Math.round(d))},2,3);Q("substring-after",3,!1,!1,function(a,b,c){b=M(b,a);a=M(c,a);c=b.indexOf(a);return-1==c?"":b.substring(c+a.length)},2);
+Q("substring-before",3,!1,!1,function(a,b,c){b=M(b,a);a=M(c,a);a=b.indexOf(a);return-1==a?"":b.substring(0,a)},2);Q("sum",1,!1,!1,function(a,b){a=b.a(a).iterator();b=0;for(var c=a.next();c;c=a.next())b+=+E(c);return b},1,1,!0);Q("translate",3,!1,!1,function(a,b,c,d){b=M(b,a);c=M(c,a);var e=M(d,a);d={};for(var f=0;f<c.length;f++)a=c.charAt(f),a in d||(d[a]=e.charAt(f));c="";for(f=0;f<b.length;f++)a=b.charAt(f),c+=a in d?d[a]:a;return c},3);Q("true",2,!1,!1,function(){return!0},0);function vb(a){J.call(this,3);this.c=a.substring(1,a.length-1)}q(vb,J);vb.prototype.a=function(){return this.c};vb.prototype.toString=function(){return"Literal: "+this.c};function wb(a){J.call(this,1);this.c=a}q(wb,J);wb.prototype.a=function(){return this.c};wb.prototype.toString=function(){return"Number: "+this.c};function xb(a,b){J.call(this,a.l);this.j=a;this.c=b;this.i=a.i;this.b=a.b;1==this.c.length&&(a=this.c[0],a.D||a.c!=yb||(a=a.v,"*"!=a.f()&&(this.f={name:a.f(),B:null})))}q(xb,J);function zb(){J.call(this,4)}q(zb,J);zb.prototype.a=function(a){var b=new G;a=a.a;9==a.nodeType?H(b,a):H(b,a.ownerDocument);return b};zb.prototype.toString=function(){return"Root Helper Expression"};function Ab(){J.call(this,4)}q(Ab,J);Ab.prototype.a=function(a){var b=new G;H(b,a.a);return b};Ab.prototype.toString=function(){return"Context Helper Expression"};
+function Bb(a){return"/"==a||"//"==a}xb.prototype.a=function(a){var b=this.j.a(a);if(!(b instanceof G))throw Error("Filter expression must evaluate to nodeset.");a=this.c;for(var c=0,d=a.length;c<d&&b.s;c++){var e=a[c],f=b.iterator(e.c.A);if(e.i||e.c!=Cb)if(e.i||e.c!=Db){var h=f.next();for(b=e.a(new v(h));h=f.next();)h=e.a(new v(h)),b=fb(b,h)}else h=f.next(),b=e.a(new v(h));else{for(h=f.next();(b=f.next())&&(!h.contains||h.contains(b))&&b.compareDocumentPosition(h)&8;h=b);b=e.a(new v(h))}}return b};
+xb.prototype.toString=function(){var a="Path Expression:"+y(this.j);if(this.c.length){var b=A(this.c,function(a,b){return a+y(b)},"Steps:");a+=y(b)}return a};function Eb(a,b){this.a=a;this.A=!!b}
+function rb(a,b,c){for(c=c||0;c<a.a.length;c++)for(var d=a.a[c],e=b.iterator(),f=b.s,h,k=0;h=e.next();k++){var r=a.A?f-k:k+1;h=d.a(new v(h,r,f));if("number"==typeof h)r=r==h;else if("string"==typeof h||"boolean"==typeof h)r=!!h;else if(h instanceof G)r=0<h.s;else throw Error("Predicate.evaluate returned an unexpected type.");if(!r){r=e;h=r.f;var t=r.a;if(!t)throw Error("Next must be called at least once before remove.");var m=t.a,t=t.next;m?m.next=t:h.a=t;t?t.a=m:h.b=m;h.s--;r.a=null}}return b}
+Eb.prototype.toString=function(){return A(this.a,function(a,b){return a+y(b)},"Predicates:")};function Fb(a){J.call(this,1);this.c=a;this.i=a.i;this.b=a.b}q(Fb,J);Fb.prototype.a=function(a){return-K(this.c,a)};Fb.prototype.toString=function(){return"Unary Expression: -"+y(this.c)};function Gb(a){J.call(this,4);this.c=a;kb(this,Ca(this.c,function(a){return a.i}));lb(this,Ca(this.c,function(a){return a.b}))}q(Gb,J);Gb.prototype.a=function(a){var b=new G;z(this.c,function(c){c=c.a(a);if(!(c instanceof G))throw Error("Path expression must evaluate to NodeSet.");b=fb(b,c)});return b};Gb.prototype.toString=function(){return A(this.c,function(a,b){return a+y(b)},"Union Expression:")};function R(a,b,c,d){J.call(this,4);this.c=a;this.v=b;this.j=c||new Eb([]);this.D=!!d;b=this.j;b=0<b.a.length?b.a[0].f:null;a.R&&b&&(this.f={name:b.name,B:b.B});a:{a=this.j;for(b=0;b<a.a.length;b++)if(c=a.a[b],c.i||1==c.l||0==c.l){a=!0;break a}a=!1}this.i=a}q(R,J);
+R.prototype.a=function(a){var b=a.a,c=this.f,d=null,e=null,f=0;c&&(d=c.name,e=c.B?M(c.B,a):null,f=1);if(this.D)if(this.i||this.c!=Hb)if(b=(new R(Ib,new x("node"))).a(a).iterator(),c=b.next())for(a=this.u(c,d,e,f);c=b.next();)a=fb(a,this.u(c,d,e,f));else a=new G;else a=bb(this.v,b,d,e),a=rb(this.j,a,f);else a=this.u(a.a,d,e,f);return a};R.prototype.u=function(a,b,c,d){a=this.c.C(this.v,a,b,c);return a=rb(this.j,a,d)};
+R.prototype.toString=function(){var a="Step:"+y("Operator: "+(this.D?"//":"/"));this.c.o&&(a+=y("Axis: "+this.c));a+=y(this.v);if(this.j.a.length){var b=A(this.j.a,function(a,b){return a+y(b)},"Predicates:");a+=y(b)}return a};function Jb(a,b,c,d){this.o=a;this.C=b;this.A=c;this.R=d}Jb.prototype.toString=function(){return this.o};var Kb={};function S(a,b,c,d){if(Kb.hasOwnProperty(a))throw Error("Axis already created: "+a);b=new Jb(a,b,c,!!d);return Kb[a]=b}
+S("ancestor",function(a,b){for(var c=new G;b=b.parentNode;)a.a(b)&&gb(c,b);return c},!0);S("ancestor-or-self",function(a,b){var c=new G;do a.a(b)&&gb(c,b);while(b=b.parentNode);return c},!0);
+var yb=S("attribute",function(a,b){var c=new G,d=a.f();if(b=b.attributes)if(a instanceof x&&null===a.b||"*"==d)for(d=0;a=b[d];d++)H(c,a);else(a=b.getNamedItem(d))&&H(c,a);return c},!1),Hb=S("child",function(a,b,c,d,e){c=p(c)?c:null;d=p(d)?d:null;e=e||new G;for(b=b.firstChild;b;b=b.nextSibling)F(b,c,d)&&a.a(b)&&H(e,b);return e},!1,!0);S("descendant",bb,!1,!0);
+var Ib=S("descendant-or-self",function(a,b,c,d){var e=new G;F(b,c,d)&&a.a(b)&&H(e,b);return bb(a,b,c,d,e)},!1,!0),Cb=S("following",function(a,b,c,d){var e=new G;do for(var f=b;f=f.nextSibling;)F(f,c,d)&&a.a(f)&&H(e,f),e=bb(a,f,c,d,e);while(b=b.parentNode);return e},!1,!0);S("following-sibling",function(a,b){for(var c=new G;b=b.nextSibling;)a.a(b)&&H(c,b);return c},!1);S("namespace",function(){return new G},!1);
+var Lb=S("parent",function(a,b){var c=new G;if(9==b.nodeType)return c;if(2==b.nodeType)return H(c,b.ownerElement),c;b=b.parentNode;a.a(b)&&H(c,b);return c},!1),Db=S("preceding",function(a,b,c,d){var e=new G,f=[];do f.unshift(b);while(b=b.parentNode);for(var h=1,k=f.length;h<k;h++){var r=[];for(b=f[h];b=b.previousSibling;)r.unshift(b);for(var t=0,m=r.length;t<m;t++)b=r[t],F(b,c,d)&&a.a(b)&&H(e,b),e=bb(a,b,c,d,e)}return e},!0,!0);
+S("preceding-sibling",function(a,b){for(var c=new G;b=b.previousSibling;)a.a(b)&&gb(c,b);return c},!0);var Mb=S("self",function(a,b){var c=new G;a.a(b)&&H(c,b);return c},!1);function Nb(a,b){this.a=a;this.b=b}function Ob(a){for(var b,c=[];;){U(a,"Missing right hand side of binary expression.");b=Pb(a);var d=a.a.next();if(!d)break;var e=(d=pb[d]||null)&&d.K;if(!e){a.a.a--;break}for(;c.length&&e<=c[c.length-1].K;)b=new mb(c.pop(),c.pop(),b);c.push(b,d)}for(;c.length;)b=new mb(c.pop(),c.pop(),b);return b}function U(a,b){if(sa(a.a))throw Error(b);}function Qb(a,b){a=a.a.next();if(a!=b)throw Error("Bad token, expected: "+b+" got: "+a);}
+function Rb(a){a=a.a.next();if(")"!=a)throw Error("Bad token: "+a);}function Sb(a){a=a.a.next();if(2>a.length)throw Error("Unclosed literal string");return new vb(a)}
+function Tb(a){var b=[];if(Bb(w(a.a))){var c=a.a.next();var d=w(a.a);if("/"==c&&(sa(a.a)||"."!=d&&".."!=d&&"@"!=d&&"*"!=d&&!/(?![0-9])[\w]/.test(d)))return new zb;d=new zb;U(a,"Missing next location step.");c=Ub(a,c);b.push(c)}else{a:{c=w(a.a);d=c.charAt(0);switch(d){case "$":throw Error("Variable reference not allowed in HTML XPath");case "(":a.a.next();c=Ob(a);U(a,'unclosed "("');Qb(a,")");break;case '"':case "'":c=Sb(a);break;default:if(isNaN(+c))if(!wa(c)&&/(?![0-9])[\w]/.test(d)&&"("==w(a.a,
+1)){c=a.a.next();c=ub[c]||null;a.a.next();for(d=[];")"!=w(a.a);){U(a,"Missing function argument list.");d.push(Ob(a));if(","!=w(a.a))break;a.a.next()}U(a,"Unclosed function argument list.");Rb(a);c=new sb(c,d)}else{c=null;break a}else c=new wb(+a.a.next())}"["==w(a.a)&&(d=new Eb(Vb(a)),c=new qb(c,d))}if(c)if(Bb(w(a.a)))d=c;else return c;else c=Ub(a,"/"),d=new Ab,b.push(c)}for(;Bb(w(a.a));)c=a.a.next(),U(a,"Missing next location step."),c=Ub(a,c),b.push(c);return new xb(d,b)}
+function Ub(a,b){if("/"!=b&&"//"!=b)throw Error('Step op should be "/" or "//"');if("."==w(a.a)){var c=new R(Mb,new x("node"));a.a.next();return c}if(".."==w(a.a))return c=new R(Lb,new x("node")),a.a.next(),c;if("@"==w(a.a)){var d=yb;a.a.next();U(a,"Missing attribute name")}else if("::"==w(a.a,1)){if(!/(?![0-9])[\w]/.test(w(a.a).charAt(0)))throw Error("Bad token: "+a.a.next());var e=a.a.next();d=Kb[e]||null;if(!d)throw Error("No axis with name: "+e);a.a.next();U(a,"Missing node name")}else d=Hb;e=
+w(a.a);if(/(?![0-9])[\w\*]/.test(e.charAt(0)))if("("==w(a.a,1)){if(!wa(e))throw Error("Invalid node type: "+e);e=a.a.next();if(!wa(e))throw Error("Invalid type name: "+e);Qb(a,"(");U(a,"Bad nodetype");var f=w(a.a).charAt(0),h=null;if('"'==f||"'"==f)h=Sb(a);U(a,"Bad nodetype");Rb(a);e=new x(e,h)}else if(e=a.a.next(),f=e.indexOf(":"),-1==f)e=new xa(e);else{var h=e.substring(0,f);if("*"==h)var k="*";else if(k=a.b(h),!k)throw Error("Namespace prefix not declared: "+h);e=e.substr(f+1);e=new xa(e,k)}else throw Error("Bad token: "+
+a.a.next());a=new Eb(Vb(a),d.A);return c||new R(d,e,a,"//"==b)}function Vb(a){for(var b=[];"["==w(a.a);){a.a.next();U(a,"Missing predicate expression.");var c=Ob(a);b.push(c);U(a,"Unclosed predicate expression.");Qb(a,"]")}return b}function Pb(a){if("-"==w(a.a))return a.a.next(),new Fb(Pb(a));var b=Tb(a);if("|"!=w(a.a))a=b;else{for(b=[b];"|"==a.a.next();)U(a,"Missing next union location path."),b.push(Tb(a));a.a.a--;a=new Gb(b)}return a};function Wb(a,b){if(!a.length)throw Error("Empty XPath expression.");a=pa(a);if(sa(a))throw Error("Invalid XPath expression.");b?"function"==ca(b)||(b=fa(b.lookupNamespaceURI,b)):b=function(){return null};var c=Ob(new Nb(a,b));if(!sa(a))throw Error("Bad token: "+a.next());this.evaluate=function(a,b){a=c.a(new v(a));return new V(a,b)}}
+function V(a,b){if(!b)if(a instanceof G)b=4;else if("string"==typeof a)b=2;else if("number"==typeof a)b=1;else if("boolean"==typeof a)b=3;else throw Error("Unexpected evaluation result.");if(2!=b&&1!=b&&3!=b&&!(a instanceof G))throw Error("value could not be converted to the specified type");this.resultType=b;switch(b){case 2:this.stringValue=a instanceof G?ib(a):""+a;break;case 1:this.numberValue=a instanceof G?+ib(a):+a;break;case 3:this.booleanValue=a instanceof G?0<a.s:!!a;break;case 4:case 5:case 6:case 7:var c=
+a.iterator();var d=[];for(var e=c.next();e;e=c.next())d.push(e);this.snapshotLength=a.s;this.invalidIteratorState=!1;break;case 8:case 9:this.singleNodeValue=hb(a);break;default:throw Error("Unknown XPathResult type.");}var f=0;this.iterateNext=function(){if(4!=b&&5!=b)throw Error("iterateNext called with wrong result type");return f>=d.length?null:d[f++]};this.snapshotItem=function(a){if(6!=b&&7!=b)throw Error("snapshotItem called with wrong result type");return a>=d.length||0>a?null:d[a]}}
+V.ANY_TYPE=0;V.NUMBER_TYPE=1;V.STRING_TYPE=2;V.BOOLEAN_TYPE=3;V.UNORDERED_NODE_ITERATOR_TYPE=4;V.ORDERED_NODE_ITERATOR_TYPE=5;V.UNORDERED_NODE_SNAPSHOT_TYPE=6;V.ORDERED_NODE_SNAPSHOT_TYPE=7;V.ANY_UNORDERED_NODE_TYPE=8;V.FIRST_ORDERED_NODE_TYPE=9;function Xb(a){this.lookupNamespaceURI=ya(a)}
+ba("wgxpath.install",function(a,b){a=a||l;var c=a.Document&&a.Document.prototype||a.document;if(!c.evaluate||b)a.XPathResult=V,c.evaluate=function(a,b,c,h){return(new Wb(a,c)).evaluate(b,h)},c.createExpression=function(a,b){return new Wb(a,b)},c.createNSResolver=function(a){return new Xb(a)}});var W={};W.G=function(){var a={V:"http://www.w3.org/2000/svg"};return function(b){return a[b]||null}}();
+W.u=function(a,b,c){var d=D(a);if(!d.documentElement)return null;try{for(var e=d.createNSResolver?d.createNSResolver(d.documentElement):W.G,f={},h=d.getElementsByTagName("*"),k=0;k<h.length;++k){var r=h[k],t=r.namespaceURI;if(t&&!f[t]){var m=r.lookupPrefix(t);if(!m)var C=t.match(".*/(\\w+)/?$"),m=C?C[1]:"xhtml";f[t]=m}}var L={},T;for(T in f)L[f[T]]=T;e=function(a){return L[a]||null};try{return d.evaluate(b,a,e,c,null)}catch(ga){if("TypeError"===ga.name)return e=d.createNSResolver?d.createNSResolver(d.documentElement):
+W.G,d.evaluate(b,a,e,c,null);throw ga;}}catch(ga){if("NS_ERROR_ILLEGAL_VALUE"!=ga.name)throw new ia(32,"Unable to locate an element with the xpath expression "+b+" because of the following error:\n"+ga);}};W.H=function(a,b){if(!a||1!=a.nodeType)throw new ia(32,'The result of the xpath expression "'+b+'" is: '+a+". It should be an element.");};
+W.P=function(a,b){var c=function(){var c=W.u(b,a,9);return c?c.singleNodeValue||null:b.selectSingleNode?(c=D(b),c.setProperty&&c.setProperty("SelectionLanguage","XPath"),b.selectSingleNode(a)):null}();null===c||W.H(c,a);return c};
+W.T=function(a,b){var c=function(){var c=W.u(b,a,7);if(c){for(var e=c.snapshotLength,f=[],h=0;h<e;++h)f.push(c.snapshotItem(h));return f}return b.selectNodes?(c=D(b),c.setProperty&&c.setProperty("SelectionLanguage","XPath"),b.selectNodes(a)):[]}();z(c,function(b){W.H(b,a)});return c};var Yb="function"===typeof ShadowRoot;function Zb(a){for(a=a.parentNode;a&&1!=a.nodeType&&9!=a.nodeType&&11!=a.nodeType;)a=a.parentNode;return I(a)?a:null}
+function X(a,b){b=na(b);if("float"==b||"cssFloat"==b||"styleFloat"==b)b="cssFloat";a:{var c=b;var d=D(a);if(d.defaultView&&d.defaultView.getComputedStyle&&(d=d.defaultView.getComputedStyle(a,null))){c=d[c]||d.getPropertyValue(c)||"";break a}c=""}a=c||$b(a,b);if(null===a)a=null;else if(0<=Ba(Ga,b)){b:{var e=a.match(Ja);if(e&&(b=Number(e[1]),c=Number(e[2]),d=Number(e[3]),e=Number(e[4]),0<=b&&255>=b&&0<=c&&255>=c&&0<=d&&255>=d&&0<=e&&1>=e)){b=[b,c,d,e];break b}b=null}if(!b)b:{if(d=a.match(Ka))if(b=Number(d[1]),
+c=Number(d[2]),d=Number(d[3]),0<=b&&255>=b&&0<=c&&255>=c&&0<=d&&255>=d){b=[b,c,d,1];break b}b=null}if(!b)b:{b=a.toLowerCase();c=ka[b.toLowerCase()];if(!c&&(c="#"==b.charAt(0)?b:"#"+b,4==c.length&&(c=c.replace(Ha,"#$1$1$2$2$3$3")),!Ia.test(c))){b=null;break b}b=[parseInt(c.substr(1,2),16),parseInt(c.substr(3,2),16),parseInt(c.substr(5,2),16),1]}a=b?"rgba("+b.join(", ")+")":a}return a}
+function $b(a,b){var c=a.currentStyle||a.style,d=c[b];!n(d)&&"function"==ca(c.getPropertyValue)&&(d=c.getPropertyValue(b));return"inherit"!=d?n(d)?d:null:(a=Zb(a))?$b(a,b):null}
+function ac(a,b,c){function d(a){var b=bc(a);return 0<b.height&&0<b.width?!0:I(a,"PATH")&&(0<b.height||0<b.width)?(a=X(a,"stroke-width"),!!a&&0<parseInt(a,10)):"hidden"!=X(a,"overflow")&&Ca(a.childNodes,function(a){return 3==a.nodeType||I(a)&&d(a)})}function e(a){return cc(a)==Y&&Da(a.childNodes,function(a){return!I(a)||e(a)||!d(a)})}if(!I(a))throw Error("Argument to isShown must be of type Element");if(I(a,"BODY"))return!0;if(I(a,"OPTION")||I(a,"OPTGROUP"))return a=$a(a,function(a){return I(a,"SELECT")}),
+!!a&&ac(a,!0,c);var f=dc(a);if(f)return!!f.I&&0<f.rect.width&&0<f.rect.height&&ac(f.I,b,c);if(I(a,"INPUT")&&"hidden"==a.type.toLowerCase()||I(a,"NOSCRIPT"))return!1;f=X(a,"visibility");return"collapse"!=f&&"hidden"!=f&&c(a)&&(b||ec(a))&&d(a)?!e(a):!1}
+function fc(a){var b=Yb?function(c){if("none"==X(c,"display"))return!1;do{var d=c.parentNode;if(c.getDestinationInsertionPoints){var e=c.getDestinationInsertionPoints();0<e.length&&(d=e[e.length-1])}if(d instanceof ShadowRoot){if(d.host.shadowRoot!=d)return!1;d=d.host}else!d||9!=d.nodeType&&11!=d.nodeType||(d=null)}while(a&&1!=a.nodeType);return!d||b(d)}:function(a){if("none"==X(a,"display"))return!1;a=Zb(a);return!a||b(a)};return ac(a,!1,b)}var Y="hidden";
+function cc(a){function b(a){function b(a){return a==h?!0:!X(a,"display").lastIndexOf("inline",0)||"absolute"==c&&"static"==X(a,"position")?!1:!0}var c=X(a,"position");if("fixed"==c)return t=!0,a==h?null:h;for(a=Zb(a);a&&!b(a);)a=Zb(a);return a}function c(a){var b=a;if("visible"==r)if(a==h&&k)b=k;else if(a==k)return{x:"visible",y:"visible"};b={x:X(b,"overflow-x"),y:X(b,"overflow-y")};a==h&&(b.x="visible"==b.x?"auto":b.x,b.y="visible"==b.y?"auto":b.y);return b}function d(a){if(a==h){var b=(new ab(f)).a;
+a=b.scrollingElement?b.scrollingElement:"CSS1Compat"==b.compatMode?b.documentElement:b.body||b.documentElement;b=b.parentWindow||b.defaultView;a=new La(b.pageXOffset||a.scrollLeft,b.pageYOffset||a.scrollTop)}else a=new La(a.scrollLeft,a.scrollTop);return a}var e=gc(a);var f=D(a),h=f.documentElement,k=f.body,r=X(h,"overflow"),t;for(a=b(a);a;a=b(a)){var m=c(a);if("visible"!=m.x||"visible"!=m.y){var C=bc(a);if(!C.width||!C.height)return Y;var L=e.a<C.a,T=e.b<C.b;if(L&&"hidden"==m.x||T&&"hidden"==m.y)return Y;
+if(L&&"visible"!=m.x||T&&"visible"!=m.y){L=d(a);T=e.b<C.b-L.y;if(e.a<C.a-L.x&&"visible"!=m.x||T&&"visible"!=m.x)return Y;e=cc(a);return e==Y?Y:"scroll"}L=e.f>=C.a+C.width;C=e.c>=C.b+C.height;if(L&&"hidden"==m.x||C&&"hidden"==m.y)return Y;if(L&&"visible"!=m.x||C&&"visible"!=m.y){if(t&&(m=d(a),e.f>=h.scrollWidth-m.x||e.a>=h.scrollHeight-m.y))return Y;e=cc(a);return e==Y?Y:"scroll"}}}return"none"}
+function bc(a){var b=dc(a);if(b)return b.rect;if(I(a,"HTML"))return a=D(a),a=((a?a.parentWindow||a.defaultView:window)||window).document,a="CSS1Compat"==a.compatMode?a.documentElement:a.body,a=new la(a.clientWidth,a.clientHeight),new B(0,0,a.width,a.height);try{var c=a.getBoundingClientRect()}catch(d){return new B(0,0,0,0)}return new B(c.left,c.top,c.right-c.left,c.bottom-c.top)}
+function dc(a){var b=I(a,"MAP");if(!b&&!I(a,"AREA"))return null;var c=b?a:I(a.parentNode,"MAP")?a.parentNode:null,d=null,e=null;c&&c.name&&(d=W.P('/descendant::*[@usemap = "#'+c.name+'"]',D(c)))&&(e=bc(d),b||"default"==a.shape.toLowerCase()||(a=hc(a),b=Math.min(Math.max(a.a,0),e.width),c=Math.min(Math.max(a.b,0),e.height),e=new B(b+e.a,c+e.b,Math.min(a.width,e.width-b),Math.min(a.height,e.height-c))));return{I:d,rect:e||new B(0,0,0,0)}}
+function hc(a){var b=a.shape.toLowerCase();a=a.coords.split(",");if("rect"==b&&4==a.length){var b=a[0],c=a[1];return new B(b,c,a[2]-b,a[3]-c)}if("circle"==b&&3==a.length)return b=a[2],new B(a[0]-b,a[1]-b,2*b,2*b);if("poly"==b&&2<a.length){for(var b=a[0],c=a[1],d=b,e=c,f=2;f+1<a.length;f+=2)b=Math.min(b,a[f]),d=Math.max(d,a[f]),c=Math.min(c,a[f+1]),e=Math.max(e,a[f+1]);return new B(b,c,d-b,e-c)}return new B(0,0,0,0)}function gc(a){a=bc(a);return new Oa(a.b,a.a+a.width,a.b+a.height,a.a)}
+function ic(a){return a.replace(/^[^\S\xa0]+|[^\S\xa0]+$/g,"")}
+function jc(a,b,c){if(I(a,"BR"))b.push("");else{var d=I(a,"TD"),e=X(a,"display"),f=!d&&!(0<=Ba(kc,e)),h=n(a.previousElementSibling)?a.previousElementSibling:Va(a.previousSibling),h=h?X(h,"display"):"",k=X(a,"float")||X(a,"cssFloat")||X(a,"styleFloat");!f||"run-in"==h&&"none"==k||/^[\s\xa0]*$/.test(b[b.length-1]||"")||b.push("");var r=fc(a),t=null,m=null;r&&(t=X(a,"white-space"),m=X(a,"text-transform"));z(a.childNodes,function(a){c(a,b,r,t,m)});a=b[b.length-1]||"";!d&&"table-cell"!=e||!a||ma(a)||(b[b.length-
+1]+=" ");f&&"run-in"!=e&&!/^[\s\xa0]*$/.test(a)&&b.push("")}}function lc(a,b){jc(a,b,function(a,b,e,f,h){3==a.nodeType&&e?mc(a,b,f,h):I(a)&&lc(a,b)})}var kc="inline inline-block inline-table none table-cell table-column table-column-group".split(" ");
+function mc(a,b,c,d){a=a.nodeValue.replace(/[\u200b\u200e\u200f]/g,"");a=a.replace(/(\r\n|\r|\n)/g,"\n");if("normal"==c||"nowrap"==c)a=a.replace(/\n/g," ");a="pre"==c||"pre-wrap"==c?a.replace(/[ \f\t\v\u2028\u2029]/g,"\u00a0"):a.replace(/[\ \f\t\v\u2028\u2029]+/g," ");"capitalize"==d?a=a.replace(/(^|\s)(\S)/g,function(a,b,c){return b+c.toUpperCase()}):"uppercase"==d?a=a.toUpperCase():"lowercase"==d&&(a=a.toLowerCase());c=b.pop()||"";ma(c)&&!a.lastIndexOf(" ",0)&&(a=a.substr(1));b.push(c+a)}
+function ec(a){var b=1,c=X(a,"opacity");c&&(b=Number(c));(a=Zb(a))&&(b*=ec(a));return b}
+function nc(a,b,c,d,e){var f;if(3==a.nodeType&&c)mc(a,b,d,e);else if(I(a))if(I(a,"CONTENT")){for(f=a;f.parentNode;)f=f.parentNode;f instanceof ShadowRoot?z(a.getDistributedNodes(),function(a){nc(a,b,c,d,e)}):oc(a,b)}else if(I(a,"SHADOW")){for(f=a;f.parentNode;)f=f.parentNode;if(f instanceof ShadowRoot&&(a=f))for(a=a.olderShadowRoot;a;)z(a.childNodes,function(a){nc(a,b,c,d,e)}),a=a.olderShadowRoot}else oc(a,b)}
+function oc(a,b){a.shadowRoot&&z(a.shadowRoot.childNodes,function(a){nc(a,b,!0,null,null)});jc(a,b,function(a,b,e,f,h){var c=null;1==a.nodeType?c=a:3==a.nodeType&&(c=a);c&&c.getDestinationInsertionPoints&&0<c.getDestinationInsertionPoints().length||nc(a,b,e,f,h)})};Ua&&Ua&&Ta(3.6);var pc={};function Z(a,b,c){var d=typeof a;("object"==d&&null!=a||"function"==d)&&(a=a.g);a=new qc(a);!b||b in pc&&!c||(pc[b]={key:a,shift:!1},c&&(pc[c]={key:a,shift:!0}));return a}function qc(a){this.code=a}Z(8);Z(9);Z(13);var rc=Z(16),sc=Z(17),tc=Z(18);Z(19);Z(20);Z(27);Z(32," ");Z(33);Z(34);Z(35);Z(36);Z(37);Z(38);Z(39);Z(40);Z(44);Z(45);Z(46);Z(48,"0",")");Z(49,"1","!");Z(50,"2","@");Z(51,"3","#");Z(52,"4","$");Z(53,"5","%");Z(54,"6","^");Z(55,"7","&");Z(56,"8","*");Z(57,"9","(");Z(65,"a","A");
+Z(66,"b","B");Z(67,"c","C");Z(68,"d","D");Z(69,"e","E");Z(70,"f","F");Z(71,"g","G");Z(72,"h","H");Z(73,"i","I");Z(74,"j","J");Z(75,"k","K");Z(76,"l","L");Z(77,"m","M");Z(78,"n","N");Z(79,"o","O");Z(80,"p","P");Z(81,"q","Q");Z(82,"r","R");Z(83,"s","S");Z(84,"t","T");Z(85,"u","U");Z(86,"v","V");Z(87,"w","W");Z(88,"x","X");Z(89,"y","Y");Z(90,"z","Z");var uc=Z(Na?{g:91,h:91}:Ma?{g:224,h:91}:{g:0,h:91});Z(Na?{g:92,h:92}:Ma?{g:224,h:93}:{g:0,h:92});Z(Na?{g:93,h:93}:Ma?{g:0,h:0}:{g:93,h:null});
+Z({g:96,h:96},"0");Z({g:97,h:97},"1");Z({g:98,h:98},"2");Z({g:99,h:99},"3");Z({g:100,h:100},"4");Z({g:101,h:101},"5");Z({g:102,h:102},"6");Z({g:103,h:103},"7");Z({g:104,h:104},"8");Z({g:105,h:105},"9");Z({g:106,h:106},"*");Z({g:107,h:107},"+");Z({g:109,h:109},"-");Z({g:110,h:110},".");Z({g:111,h:111},"/");Z(144);Z(112);Z(113);Z(114);Z(115);Z(116);Z(117);Z(118);Z(119);Z(120);Z(121);Z(122);Z(123);Z({g:107,h:187},"=","+");Z(108,",");Z({g:109,h:189},"-","_");Z(188,",","<");Z(190,".",">");Z(191,"/","?");
+Z(192,"`","~");Z(219,"[","{");Z(220,"\\","|");Z(221,"]","}");Z({g:59,h:186},";",":");Z(222,"'",'"');var vc=new Pa;vc.set(1,rc);vc.set(2,sc);vc.set(4,tc);vc.set(8,uc);(function(a){var b=new Pa;z(Qa(a),function(c){b.set(a.get(c).code,c)});return b})(vc);Ua&&Sa(12);ba("_",function(a){var b=[];Yb?oc(a,b):lc(a,b);a=b;for(var b=a.length,c=Array(b),d=p(a)?a.split(""):a,e=0;e<b;e++)e in d&&(c[e]=ic.call(void 0,d[e],e,a));return ic(c.join("\n")).replace(/\xa0/g," ")});; return this._.apply(null,arguments);}.apply({navigator:typeof window!='undefined'?window.navigator:null,document:typeof window!='undefined'?window.document:null}, arguments);}
+
+// https://github.com/SeleniumHQ/selenium/blob/master/javascript/atoms/dom.js#L189
+atom.isElementEnabled = function(element, window){return function(){var h=this;function k(a){return"string"==typeof a}function aa(a,b){a=a.split(".");var c=h;a[0]in c||!c.execScript||c.execScript("var "+a[0]);for(var d;a.length&&(d=a.shift());)a.length||void 0===b?c[d]&&c[d]!==Object.prototype[d]?c=c[d]:c=c[d]={}:c[d]=b}
+function ba(a){var b=typeof a;if("object"==b)if(a){if(a instanceof Array)return"array";if(a instanceof Object)return b;var c=Object.prototype.toString.call(a);if("[object Window]"==c)return"object";if("[object Array]"==c||"number"==typeof a.length&&"undefined"!=typeof a.splice&&"undefined"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable("splice"))return"array";if("[object Function]"==c||"undefined"!=typeof a.call&&"undefined"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable("call"))return"function"}else return"null";
+else if("function"==b&&"undefined"==typeof a.call)return"object";return b}function ca(a,b,c){return a.call.apply(a.bind,arguments)}function da(a,b,c){if(!a)throw Error();if(2<arguments.length){var d=Array.prototype.slice.call(arguments,2);return function(){var c=Array.prototype.slice.call(arguments);Array.prototype.unshift.apply(c,d);return a.apply(b,c)}}return function(){return a.apply(b,arguments)}}
+function m(a,b,c){Function.prototype.bind&&-1!=Function.prototype.bind.toString().indexOf("native code")?m=ca:m=da;return m.apply(null,arguments)}function ea(a,b){var c=Array.prototype.slice.call(arguments,1);return function(){var b=c.slice();b.push.apply(b,arguments);return a.apply(this,b)}}
+function n(a){var b=p;function c(){}c.prototype=b.prototype;a.K=b.prototype;a.prototype=new c;a.prototype.constructor=a;a.J=function(a,c,f){for(var d=Array(arguments.length-2),e=2;e<arguments.length;e++)d[e-2]=arguments[e];return b.prototype[c].apply(a,d)}};
+function q(a,b,c){this.a=a;this.b=b||1;this.g=c||1};function fa(a){this.b=a;this.a=0}function ga(a){a=a.match(ha);for(var b=0;b<a.length;b++)ia.test(a[b])&&a.splice(b,1);return new fa(a)}var ha=/\$?(?:(?![0-9-\.])(?:\*|[\w-\.]+):)?(?![0-9-\.])(?:\*|[\w-\.]+)|\/\/|\.\.|::|\d+(?:\.\d*)?|\.\d+|"[^"]*"|'[^']*'|[!<>]=|\s+|./g,ia=/^\s/;function r(a,b){return a.b[a.a+(b||0)]}function t(a){return a.b[a.a++]}function u(a){return a.b.length<=a.a};function w(a,b){this.h=a;this.c=void 0!==b?b:null;this.b=null;switch(a){case "comment":this.b=8;break;case "text":this.b=3;break;case "processing-instruction":this.b=7;break;case "node":break;default:throw Error("Unexpected argument");}}function ja(a){return"comment"==a||"text"==a||"processing-instruction"==a||"node"==a}w.prototype.a=function(a){return null===this.b||this.b==a.nodeType};w.prototype.g=function(){return this.h};
+w.prototype.toString=function(){var a="Kind Test: "+this.h;null===this.c||(a+=x(this.c));return a};function y(a,b){this.j=a.toLowerCase();a="*"==this.j?"*":"http://www.w3.org/1999/xhtml";this.b=b?b.toLowerCase():a}y.prototype.a=function(a){var b=a.nodeType;if(1!=b&&2!=b)return!1;b=void 0!==a.localName?a.localName:a.nodeName;return"*"!=this.j&&this.j!=b.toLowerCase()?!1:"*"==this.b?!0:this.b==(a.namespaceURI?a.namespaceURI.toLowerCase():"http://www.w3.org/1999/xhtml")};y.prototype.g=function(){return this.j};
+y.prototype.toString=function(){return"Name Test: "+("http://www.w3.org/1999/xhtml"==this.b?"":this.b+":")+this.j};function ka(a){switch(a.nodeType){case 1:return ea(la,a);case 9:return ka(a.documentElement);case 11:case 10:case 6:case 12:return ma;default:return a.parentNode?ka(a.parentNode):ma}}function ma(){return null}function la(a,b){if(a.prefix==b)return a.namespaceURI||"http://www.w3.org/1999/xhtml";var c=a.getAttributeNode("xmlns:"+b);return c&&c.specified?c.value||null:a.parentNode&&9!=a.parentNode.nodeType?la(a.parentNode,b):null};function z(a,b){for(var c=a.length,d=k(a)?a.split(""):a,e=0;e<c;e++)e in d&&b.call(void 0,d[e],e,a)}function B(a,b,c){var d=c;z(a,function(c,f){d=b.call(void 0,d,c,f,a)});return d}function C(a,b){for(var c=a.length,d=k(a)?a.split(""):a,e=0;e<c;e++)if(e in d&&b.call(void 0,d[e],e,a))return!0;return!1}function na(a){return Array.prototype.concat.apply([],arguments)}function oa(a,b,c){return 2>=arguments.length?Array.prototype.slice.call(a,b):Array.prototype.slice.call(a,b,c)};(function(){var a=h.Components;if(!a)return!1;try{if(!a.classes)return!1}catch(c){return!1}var b=a.classes,a=a.interfaces;b["@mozilla.org/xpcom/version-comparator;1"].getService(a.nsIVersionComparator);b["@mozilla.org/xre/app-info;1"].getService(a.nsIXULAppInfo);return!0})();function pa(a){for(;a&&1!=a.nodeType;)a=a.previousSibling;return a}function qa(a,b){if(!a||!b)return!1;if(a.contains&&1==b.nodeType)return a==b||a.contains(b);if("undefined"!=typeof a.compareDocumentPosition)return a==b||!!(a.compareDocumentPosition(b)&16);for(;b&&a!=b;)b=b.parentNode;return b==a}
+function ra(a,b){if(a==b)return 0;if(a.compareDocumentPosition)return a.compareDocumentPosition(b)&2?1:-1;if("sourceIndex"in a||a.parentNode&&"sourceIndex"in a.parentNode){var c=1==a.nodeType,d=1==b.nodeType;if(c&&d)return a.sourceIndex-b.sourceIndex;var e=a.parentNode,f=b.parentNode;return e==f?sa(a,b):!c&&qa(e,b)?-1*ta(a,b):!d&&qa(f,a)?ta(b,a):(c?a.sourceIndex:e.sourceIndex)-(d?b.sourceIndex:f.sourceIndex)}d=9==a.nodeType?a:a.ownerDocument||a.document;c=d.createRange();c.selectNode(a);c.collapse(!0);
+a=d.createRange();a.selectNode(b);a.collapse(!0);return c.compareBoundaryPoints(h.Range.START_TO_END,a)}function ta(a,b){var c=a.parentNode;if(c==b)return-1;for(;b.parentNode!=c;)b=b.parentNode;return sa(b,a)}function sa(a,b){for(;b=b.previousSibling;)if(b==a)return-1;return 1}function ua(a,b){for(var c=0;a;){if(b(a))return a;a=a.parentNode;c++}return null};function D(a){var b=null,c=a.nodeType;1==c&&(b=a.textContent,b=void 0==b||null==b?a.innerText:b,b=void 0==b||null==b?"":b);if("string"!=typeof b)if(9==c||1==c){a=9==c?a.documentElement:a.firstChild;for(var c=0,d=[],b="";a;){do 1!=a.nodeType&&(b+=a.nodeValue),d[c++]=a;while(a=a.firstChild);for(;c&&!(a=d[--c].nextSibling););}}else b=a.nodeValue;return""+b}
+function E(a,b,c){if(null===b)return!0;try{if(!a.getAttribute)return!1}catch(d){return!1}return null==c?!!a.getAttribute(b):a.getAttribute(b,2)==c}function F(a,b,c,d,e){return va.call(null,a,b,k(c)?c:null,k(d)?d:null,e||new G)}
+function va(a,b,c,d,e){b.getElementsByName&&d&&"name"==c?(b=b.getElementsByName(d),z(b,function(b){a.a(b)&&H(e,b)})):b.getElementsByClassName&&d&&"class"==c?(b=b.getElementsByClassName(d),z(b,function(b){b.className==d&&a.a(b)&&H(e,b)})):a instanceof w?wa(a,b,c,d,e):b.getElementsByTagName&&(b=b.getElementsByTagName(a.g()),z(b,function(a){E(a,c,d)&&H(e,a)}));return e}function wa(a,b,c,d,e){for(b=b.firstChild;b;b=b.nextSibling)E(b,c,d)&&a.a(b)&&H(e,b),wa(a,b,c,d,e)};function I(a,b){b&&"string"!==typeof b&&(b=b.toString());return!!a&&1==a.nodeType&&(!b||a.tagName.toUpperCase()==b)};function G(){this.b=this.a=null;this.l=0}function xa(a){this.node=a;this.a=this.b=null}function ya(a,b){if(!a.a)return b;if(!b.a)return a;var c=a.a;b=b.a;for(var d=null,e,f=0;c&&b;)c.node==b.node?(e=c,c=c.a,b=b.a):0<ra(c.node,b.node)?(e=b,b=b.a):(e=c,c=c.a),(e.b=d)?d.a=e:a.a=e,d=e,f++;for(e=c||b;e;)e.b=d,d=d.a=e,f++,e=e.a;a.b=d;a.l=f;return a}function za(a,b){b=new xa(b);b.a=a.a;a.b?a.a.b=b:a.a=a.b=b;a.a=b;a.l++}function H(a,b){b=new xa(b);b.b=a.b;a.a?a.b.a=b:a.a=a.b=b;a.b=b;a.l++}
+function J(a){return(a=a.a)?a.node:null}function K(a){return(a=J(a))?D(a):""}function L(a,b){return new Aa(a,!!b)}function Aa(a,b){this.g=a;this.b=(this.s=b)?a.b:a.a;this.a=null}function M(a){var b=a.b;if(b){var c=a.a=b;a.b=a.s?b.b:b.a;return c.node}return null};function p(a){this.i=a;this.b=this.f=!1;this.g=null}function x(a){return"\n "+a.toString().split("\n").join("\n ")}function Ba(a,b){a.f=b}function Ca(a,b){a.b=b}function N(a,b){a=a.a(b);return a instanceof G?+K(a):+a}function O(a,b){a=a.a(b);return a instanceof G?K(a):""+a}function Q(a,b){a=a.a(b);return a instanceof G?!!a.l:!!a};function R(a,b,c){p.call(this,a.i);this.c=a;this.h=b;this.o=c;this.f=b.f||c.f;this.b=b.b||c.b;this.c==Da&&(c.b||c.f||4==c.i||0==c.i||!b.g?b.b||b.f||4==b.i||0==b.i||!c.g||(this.g={name:c.g.name,u:b}):this.g={name:b.g.name,u:c})}n(R);
+function S(a,b,c,d,e){b=b.a(d);c=c.a(d);var f;if(b instanceof G&&c instanceof G){b=L(b);for(d=M(b);d;d=M(b))for(e=L(c),f=M(e);f;f=M(e))if(a(D(d),D(f)))return!0;return!1}if(b instanceof G||c instanceof G){b instanceof G?(e=b,d=c):(e=c,d=b);f=L(e);for(var g=typeof d,l=M(f);l;l=M(f)){switch(g){case "number":l=+D(l);break;case "boolean":l=!!D(l);break;case "string":l=D(l);break;default:throw Error("Illegal primitive type for comparison.");}if(e==b&&a(l,d)||e==c&&a(d,l))return!0}return!1}return e?"boolean"==
+typeof b||"boolean"==typeof c?a(!!b,!!c):"number"==typeof b||"number"==typeof c?a(+b,+c):a(b,c):a(+b,+c)}R.prototype.a=function(a){return this.c.m(this.h,this.o,a)};R.prototype.toString=function(){var a="Binary Expression: "+this.c,a=a+x(this.h);return a+=x(this.o)};function Ea(a,b,c,d){this.H=a;this.C=b;this.i=c;this.m=d}Ea.prototype.toString=function(){return this.H};var Fa={};
+function T(a,b,c,d){if(Fa.hasOwnProperty(a))throw Error("Binary operator already created: "+a);a=new Ea(a,b,c,d);return Fa[a.toString()]=a}T("div",6,1,function(a,b,c){return N(a,c)/N(b,c)});T("mod",6,1,function(a,b,c){return N(a,c)%N(b,c)});T("*",6,1,function(a,b,c){return N(a,c)*N(b,c)});T("+",5,1,function(a,b,c){return N(a,c)+N(b,c)});T("-",5,1,function(a,b,c){return N(a,c)-N(b,c)});T("<",4,2,function(a,b,c){return S(function(a,b){return a<b},a,b,c)});
+T(">",4,2,function(a,b,c){return S(function(a,b){return a>b},a,b,c)});T("<=",4,2,function(a,b,c){return S(function(a,b){return a<=b},a,b,c)});T(">=",4,2,function(a,b,c){return S(function(a,b){return a>=b},a,b,c)});var Da=T("=",3,2,function(a,b,c){return S(function(a,b){return a==b},a,b,c,!0)});T("!=",3,2,function(a,b,c){return S(function(a,b){return a!=b},a,b,c,!0)});T("and",2,2,function(a,b,c){return Q(a,c)&&Q(b,c)});T("or",1,2,function(a,b,c){return Q(a,c)||Q(b,c)});function Ga(a,b){if(b.a.length&&4!=a.i)throw Error("Primary expression must evaluate to nodeset if filter has predicate(s).");p.call(this,a.i);this.c=a;this.h=b;this.f=a.f;this.b=a.b}n(Ga);Ga.prototype.a=function(a){a=this.c.a(a);return Ha(this.h,a)};Ga.prototype.toString=function(){var a="Filter:"+x(this.c);return a+=x(this.h)};function Ia(a,b){if(b.length<a.B)throw Error("Function "+a.j+" expects at least"+a.B+" arguments, "+b.length+" given");if(null!==a.A&&b.length>a.A)throw Error("Function "+a.j+" expects at most "+a.A+" arguments, "+b.length+" given");a.G&&z(b,function(b,d){if(4!=b.i)throw Error("Argument "+d+" to function "+a.j+" is not of type Nodeset: "+b);});p.call(this,a.i);this.v=a;this.c=b;Ba(this,a.f||C(b,function(a){return a.f}));Ca(this,a.F&&!b.length||a.D&&!!b.length||C(b,function(a){return a.b}))}n(Ia);
+Ia.prototype.a=function(a){return this.v.m.apply(null,na(a,this.c))};Ia.prototype.toString=function(){var a="Function: "+this.v;if(this.c.length)var b=B(this.c,function(a,b){return a+x(b)},"Arguments:"),a=a+x(b);return a};function Ja(a,b,c,d,e,f,g,l,v){this.j=a;this.i=b;this.f=c;this.F=d;this.D=e;this.m=f;this.B=g;this.A=void 0!==l?l:g;this.G=!!v}Ja.prototype.toString=function(){return this.j};var Ka={};
+function U(a,b,c,d,e,f,g,l){if(Ka.hasOwnProperty(a))throw Error("Function already created: "+a+".");Ka[a]=new Ja(a,b,c,d,!1,e,f,g,l)}U("boolean",2,!1,!1,function(a,b){return Q(b,a)},1);U("ceiling",1,!1,!1,function(a,b){return Math.ceil(N(b,a))},1);U("concat",3,!1,!1,function(a,b){return B(oa(arguments,1),function(b,d){return b+O(d,a)},"")},2,null);U("contains",2,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);return-1!=b.indexOf(a)},2);U("count",1,!1,!1,function(a,b){return b.a(a).l},1,1,!0);
+U("false",2,!1,!1,function(){return!1},0);U("floor",1,!1,!1,function(a,b){return Math.floor(N(b,a))},1);U("id",4,!1,!1,function(a,b){var c=a.a,d=9==c.nodeType?c:c.ownerDocument;a=O(b,a).split(/\s+/);var e=[];z(a,function(a){a=d.getElementById(a);var b;if(!(b=!a)){a:if(k(e))b=k(a)&&1==a.length?e.indexOf(a,0):-1;else{for(b=0;b<e.length;b++)if(b in e&&e[b]===a)break a;b=-1}b=0<=b}b||e.push(a)});e.sort(ra);var f=new G;z(e,function(a){H(f,a)});return f},1);U("lang",2,!1,!1,function(){return!1},1);
+U("last",1,!0,!1,function(a){if(1!=arguments.length)throw Error("Function last expects ()");return a.g},0);U("local-name",3,!1,!0,function(a,b){return(a=b?J(b.a(a)):a.a)?a.localName||a.nodeName.toLowerCase():""},0,1,!0);U("name",3,!1,!0,function(a,b){return(a=b?J(b.a(a)):a.a)?a.nodeName.toLowerCase():""},0,1,!0);U("namespace-uri",3,!0,!1,function(){return""},0,1,!0);U("normalize-space",3,!1,!0,function(a,b){return(b?O(b,a):D(a.a)).replace(/[\s\xa0]+/g," ").replace(/^\s+|\s+$/g,"")},0,1);
+U("not",2,!1,!1,function(a,b){return!Q(b,a)},1);U("number",1,!1,!0,function(a,b){return b?N(b,a):+D(a.a)},0,1);U("position",1,!0,!1,function(a){return a.b},0);U("round",1,!1,!1,function(a,b){return Math.round(N(b,a))},1);U("starts-with",2,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);return!b.lastIndexOf(a,0)},2);U("string",3,!1,!0,function(a,b){return b?O(b,a):D(a.a)},0,1);U("string-length",1,!1,!0,function(a,b){return(b?O(b,a):D(a.a)).length},0,1);
+U("substring",3,!1,!1,function(a,b,c,d){c=N(c,a);if(isNaN(c)||Infinity==c||-Infinity==c)return"";d=d?N(d,a):Infinity;if(isNaN(d)||-Infinity===d)return"";c=Math.round(c)-1;var e=Math.max(c,0);a=O(b,a);return Infinity==d?a.substring(e):a.substring(e,c+Math.round(d))},2,3);U("substring-after",3,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);c=b.indexOf(a);return-1==c?"":b.substring(c+a.length)},2);
+U("substring-before",3,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);a=b.indexOf(a);return-1==a?"":b.substring(0,a)},2);U("sum",1,!1,!1,function(a,b){a=L(b.a(a));b=0;for(var c=M(a);c;c=M(a))b+=+D(c);return b},1,1,!0);U("translate",3,!1,!1,function(a,b,c,d){b=O(b,a);c=O(c,a);var e=O(d,a);d={};for(var f=0;f<c.length;f++)a=c.charAt(f),a in d||(d[a]=e.charAt(f));c="";for(f=0;f<b.length;f++)a=b.charAt(f),c+=a in d?d[a]:a;return c},3);U("true",2,!1,!1,function(){return!0},0);function La(a){p.call(this,3);this.c=a.substring(1,a.length-1)}n(La);La.prototype.a=function(){return this.c};La.prototype.toString=function(){return"Literal: "+this.c};function Ma(a){p.call(this,1);this.c=a}n(Ma);Ma.prototype.a=function(){return this.c};Ma.prototype.toString=function(){return"Number: "+this.c};function Na(a,b){p.call(this,a.i);this.h=a;this.c=b;this.f=a.f;this.b=a.b;1==this.c.length&&(a=this.c[0],a.w||a.c!=Oa||(a=a.o,"*"!=a.g()&&(this.g={name:a.g(),u:null})))}n(Na);function V(){p.call(this,4)}n(V);V.prototype.a=function(a){var b=new G;a=a.a;9==a.nodeType?H(b,a):H(b,a.ownerDocument);return b};V.prototype.toString=function(){return"Root Helper Expression"};function Pa(){p.call(this,4)}n(Pa);Pa.prototype.a=function(a){var b=new G;H(b,a.a);return b};Pa.prototype.toString=function(){return"Context Helper Expression"};
+function Qa(a){return"/"==a||"//"==a}Na.prototype.a=function(a){var b=this.h.a(a);if(!(b instanceof G))throw Error("Filter expression must evaluate to nodeset.");a=this.c;for(var c=0,d=a.length;c<d&&b.l;c++){var e=a[c],f=L(b,e.c.s);if(e.f||e.c!=Ra)if(e.f||e.c!=Sa){var g=M(f);for(b=e.a(new q(g));g=M(f);)g=e.a(new q(g)),b=ya(b,g)}else g=M(f),b=e.a(new q(g));else{for(g=M(f);(b=M(f))&&(!g.contains||g.contains(b))&&b.compareDocumentPosition(g)&8;g=b);b=e.a(new q(g))}}return b};
+Na.prototype.toString=function(){var a="Path Expression:"+x(this.h);if(this.c.length){var b=B(this.c,function(a,b){return a+x(b)},"Steps:");a+=x(b)}return a};function Ta(a,b){this.a=a;this.s=!!b}
+function Ha(a,b,c){for(c=c||0;c<a.a.length;c++)for(var d=a.a[c],e=L(b),f=b.l,g,l=0;g=M(e);l++){var v=a.s?f-l:l+1;g=d.a(new q(g,v,f));if("number"==typeof g)v=v==g;else if("string"==typeof g||"boolean"==typeof g)v=!!g;else if(g instanceof G)v=0<g.l;else throw Error("Predicate.evaluate returned an unexpected type.");if(!v){v=e;g=v.g;var A=v.a;if(!A)throw Error("Next must be called at least once before remove.");var P=A.b,A=A.a;P?P.a=A:g.a=A;A?A.b=P:g.b=P;g.l--;v.a=null}}return b}
+Ta.prototype.toString=function(){return B(this.a,function(a,b){return a+x(b)},"Predicates:")};function Ua(a){p.call(this,1);this.c=a;this.f=a.f;this.b=a.b}n(Ua);Ua.prototype.a=function(a){return-N(this.c,a)};Ua.prototype.toString=function(){return"Unary Expression: -"+x(this.c)};function Va(a){p.call(this,4);this.c=a;Ba(this,C(this.c,function(a){return a.f}));Ca(this,C(this.c,function(a){return a.b}))}n(Va);Va.prototype.a=function(a){var b=new G;z(this.c,function(c){c=c.a(a);if(!(c instanceof G))throw Error("Path expression must evaluate to NodeSet.");b=ya(b,c)});return b};Va.prototype.toString=function(){return B(this.c,function(a,b){return a+x(b)},"Union Expression:")};function W(a,b,c,d){p.call(this,4);this.c=a;this.o=b;this.h=c||new Ta([]);this.w=!!d;b=this.h;b=0<b.a.length?b.a[0].g:null;a.I&&b&&(this.g={name:b.name,u:b.u});a:{a=this.h;for(b=0;b<a.a.length;b++)if(c=a.a[b],c.f||1==c.i||0==c.i){a=!0;break a}a=!1}this.f=a}n(W);
+W.prototype.a=function(a){var b=a.a,c=this.g,d=null,e=null,f=0;c&&(d=c.name,e=c.u?O(c.u,a):null,f=1);if(this.w)if(this.f||this.c!=Wa)if(b=L((new W(Xa,new w("node"))).a(a)),c=M(b))for(a=this.m(c,d,e,f);c=M(b);)a=ya(a,this.m(c,d,e,f));else a=new G;else a=F(this.o,b,d,e),a=Ha(this.h,a,f);else a=this.m(a.a,d,e,f);return a};W.prototype.m=function(a,b,c,d){a=this.c.v(this.o,a,b,c);return a=Ha(this.h,a,d)};
+W.prototype.toString=function(){var a="Step:"+x("Operator: "+(this.w?"//":"/"));this.c.j&&(a+=x("Axis: "+this.c));a+=x(this.o);if(this.h.a.length){var b=B(this.h.a,function(a,b){return a+x(b)},"Predicates:");a+=x(b)}return a};function Ya(a,b,c,d){this.j=a;this.v=b;this.s=c;this.I=d}Ya.prototype.toString=function(){return this.j};var Za={};function X(a,b,c,d){if(Za.hasOwnProperty(a))throw Error("Axis already created: "+a);b=new Ya(a,b,c,!!d);return Za[a]=b}
+X("ancestor",function(a,b){for(var c=new G;b=b.parentNode;)a.a(b)&&za(c,b);return c},!0);X("ancestor-or-self",function(a,b){var c=new G;do a.a(b)&&za(c,b);while(b=b.parentNode);return c},!0);
+var Oa=X("attribute",function(a,b){var c=new G,d=a.g();if(b=b.attributes)if(a instanceof w&&null===a.b||"*"==d)for(d=0;a=b[d];d++)H(c,a);else(a=b.getNamedItem(d))&&H(c,a);return c},!1),Wa=X("child",function(a,b,c,d,e){c=k(c)?c:null;d=k(d)?d:null;e=e||new G;for(b=b.firstChild;b;b=b.nextSibling)E(b,c,d)&&a.a(b)&&H(e,b);return e},!1,!0);X("descendant",F,!1,!0);
+var Xa=X("descendant-or-self",function(a,b,c,d){var e=new G;E(b,c,d)&&a.a(b)&&H(e,b);return F(a,b,c,d,e)},!1,!0),Ra=X("following",function(a,b,c,d){var e=new G;do for(var f=b;f=f.nextSibling;)E(f,c,d)&&a.a(f)&&H(e,f),e=F(a,f,c,d,e);while(b=b.parentNode);return e},!1,!0);X("following-sibling",function(a,b){for(var c=new G;b=b.nextSibling;)a.a(b)&&H(c,b);return c},!1);X("namespace",function(){return new G},!1);
+var $a=X("parent",function(a,b){var c=new G;if(9==b.nodeType)return c;if(2==b.nodeType)return H(c,b.ownerElement),c;b=b.parentNode;a.a(b)&&H(c,b);return c},!1),Sa=X("preceding",function(a,b,c,d){var e=new G,f=[];do f.unshift(b);while(b=b.parentNode);for(var g=1,l=f.length;g<l;g++){var v=[];for(b=f[g];b=b.previousSibling;)v.unshift(b);for(var A=0,P=v.length;A<P;A++)b=v[A],E(b,c,d)&&a.a(b)&&H(e,b),e=F(a,b,c,d,e)}return e},!0,!0);
+X("preceding-sibling",function(a,b){for(var c=new G;b=b.previousSibling;)a.a(b)&&za(c,b);return c},!0);var ab=X("self",function(a,b){var c=new G;a.a(b)&&H(c,b);return c},!1);function bb(a,b){this.a=a;this.b=b}function cb(a){for(var b,c=[];;){Y(a,"Missing right hand side of binary expression.");b=db(a);var d=t(a.a);if(!d)break;var e=(d=Fa[d]||null)&&d.C;if(!e){a.a.a--;break}for(;c.length&&e<=c[c.length-1].C;)b=new R(c.pop(),c.pop(),b);c.push(b,d)}for(;c.length;)b=new R(c.pop(),c.pop(),b);return b}function Y(a,b){if(u(a.a))throw Error(b);}function eb(a,b){a=t(a.a);if(a!=b)throw Error("Bad token, expected: "+b+" got: "+a);}
+function fb(a){a=t(a.a);if(")"!=a)throw Error("Bad token: "+a);}function gb(a){a=t(a.a);if(2>a.length)throw Error("Unclosed literal string");return new La(a)}
+function hb(a){var b=[];if(Qa(r(a.a))){var c=t(a.a);var d=r(a.a);if("/"==c&&(u(a.a)||"."!=d&&".."!=d&&"@"!=d&&"*"!=d&&!/(?![0-9])[\w]/.test(d)))return new V;d=new V;Y(a,"Missing next location step.");c=ib(a,c);b.push(c)}else{a:{c=r(a.a);d=c.charAt(0);switch(d){case "$":throw Error("Variable reference not allowed in HTML XPath");case "(":t(a.a);c=cb(a);Y(a,'unclosed "("');eb(a,")");break;case '"':case "'":c=gb(a);break;default:if(isNaN(+c))if(!ja(c)&&/(?![0-9])[\w]/.test(d)&&"("==r(a.a,1)){c=t(a.a);
+c=Ka[c]||null;t(a.a);for(d=[];")"!=r(a.a);){Y(a,"Missing function argument list.");d.push(cb(a));if(","!=r(a.a))break;t(a.a)}Y(a,"Unclosed function argument list.");fb(a);c=new Ia(c,d)}else{c=null;break a}else c=new Ma(+t(a.a))}"["==r(a.a)&&(d=new Ta(jb(a)),c=new Ga(c,d))}if(c)if(Qa(r(a.a)))d=c;else return c;else c=ib(a,"/"),d=new Pa,b.push(c)}for(;Qa(r(a.a));)c=t(a.a),Y(a,"Missing next location step."),c=ib(a,c),b.push(c);return new Na(d,b)}
+function ib(a,b){if("/"!=b&&"//"!=b)throw Error('Step op should be "/" or "//"');if("."==r(a.a)){var c=new W(ab,new w("node"));t(a.a);return c}if(".."==r(a.a))return c=new W($a,new w("node")),t(a.a),c;if("@"==r(a.a)){var d=Oa;t(a.a);Y(a,"Missing attribute name")}else if("::"==r(a.a,1)){if(!/(?![0-9])[\w]/.test(r(a.a).charAt(0)))throw Error("Bad token: "+t(a.a));var e=t(a.a);d=Za[e]||null;if(!d)throw Error("No axis with name: "+e);t(a.a);Y(a,"Missing node name")}else d=Wa;e=r(a.a);if(/(?![0-9])[\w\*]/.test(e.charAt(0)))if("("==
+r(a.a,1)){if(!ja(e))throw Error("Invalid node type: "+e);e=t(a.a);if(!ja(e))throw Error("Invalid type name: "+e);eb(a,"(");Y(a,"Bad nodetype");var f=r(a.a).charAt(0),g=null;if('"'==f||"'"==f)g=gb(a);Y(a,"Bad nodetype");fb(a);e=new w(e,g)}else if(e=t(a.a),f=e.indexOf(":"),-1==f)e=new y(e);else{var g=e.substring(0,f);if("*"==g)var l="*";else if(l=a.b(g),!l)throw Error("Namespace prefix not declared: "+g);e=e.substr(f+1);e=new y(e,l)}else throw Error("Bad token: "+t(a.a));a=new Ta(jb(a),d.s);return c||
+new W(d,e,a,"//"==b)}function jb(a){for(var b=[];"["==r(a.a);){t(a.a);Y(a,"Missing predicate expression.");var c=cb(a);b.push(c);Y(a,"Unclosed predicate expression.");eb(a,"]")}return b}function db(a){if("-"==r(a.a))return t(a.a),new Ua(db(a));var b=hb(a);if("|"!=r(a.a))a=b;else{for(b=[b];"|"==t(a.a);)Y(a,"Missing next union location path."),b.push(hb(a));a.a.a--;a=new Va(b)}return a};function kb(a,b){if(!a.length)throw Error("Empty XPath expression.");a=ga(a);if(u(a))throw Error("Invalid XPath expression.");b?"function"==ba(b)||(b=m(b.lookupNamespaceURI,b)):b=function(){return null};var c=cb(new bb(a,b));if(!u(a))throw Error("Bad token: "+t(a));this.evaluate=function(a,b){a=c.a(new q(a));return new Z(a,b)}}
+function Z(a,b){if(!b)if(a instanceof G)b=4;else if("string"==typeof a)b=2;else if("number"==typeof a)b=1;else if("boolean"==typeof a)b=3;else throw Error("Unexpected evaluation result.");if(2!=b&&1!=b&&3!=b&&!(a instanceof G))throw Error("value could not be converted to the specified type");this.resultType=b;switch(b){case 2:this.stringValue=a instanceof G?K(a):""+a;break;case 1:this.numberValue=a instanceof G?+K(a):+a;break;case 3:this.booleanValue=a instanceof G?0<a.l:!!a;break;case 4:case 5:case 6:case 7:var c=
+L(a);var d=[];for(var e=M(c);e;e=M(c))d.push(e);this.snapshotLength=a.l;this.invalidIteratorState=!1;break;case 8:case 9:this.singleNodeValue=J(a);break;default:throw Error("Unknown XPathResult type.");}var f=0;this.iterateNext=function(){if(4!=b&&5!=b)throw Error("iterateNext called with wrong result type");return f>=d.length?null:d[f++]};this.snapshotItem=function(a){if(6!=b&&7!=b)throw Error("snapshotItem called with wrong result type");return a>=d.length||0>a?null:d[a]}}Z.ANY_TYPE=0;
+Z.NUMBER_TYPE=1;Z.STRING_TYPE=2;Z.BOOLEAN_TYPE=3;Z.UNORDERED_NODE_ITERATOR_TYPE=4;Z.ORDERED_NODE_ITERATOR_TYPE=5;Z.UNORDERED_NODE_SNAPSHOT_TYPE=6;Z.ORDERED_NODE_SNAPSHOT_TYPE=7;Z.ANY_UNORDERED_NODE_TYPE=8;Z.FIRST_ORDERED_NODE_TYPE=9;function lb(a){this.lookupNamespaceURI=ka(a)}
+aa("wgxpath.install",function(a,b){a=a||h;var c=a.Document&&a.Document.prototype||a.document;if(!c.evaluate||b)a.XPathResult=Z,c.evaluate=function(a,b,c,g){return(new kb(a,c)).evaluate(b,g)},c.createExpression=function(a,b){return new kb(a,b)},c.createNSResolver=function(a){return new lb(a)}});var mb="BUTTON INPUT OPTGROUP OPTION SELECT TEXTAREA".split(" ");function nb(a){return C(mb,function(b){return I(a,b)})?a.disabled?!1:a.parentNode&&1==a.parentNode.nodeType&&I(a,"OPTGROUP")||I(a,"OPTION")?nb(a.parentNode):!ua(a,function(a){var b=a.parentNode;if(b&&I(b,"FIELDSET")&&b.disabled){if(!I(a,"LEGEND"))return!0;for(;a=void 0!==a.previousElementSibling?a.previousElementSibling:pa(a.previousSibling);)if(I(a,"LEGEND"))return!0}return!1}):!0};aa("_",nb);; return this._.apply(null,arguments);}.apply({navigator:typeof window!='undefined'?window.navigator:null,document:typeof window!='undefined'?window.document:null}, arguments);}
+
+// https://github.com/SeleniumHQ/selenium/blob/master/javascript/atoms/dom.js#L435
+atom.isElementDisplayed = function(element, window){return function(){var aa=this;function h(a){return void 0!==a}function l(a){return"string"==typeof a}function ba(a,b){a=a.split(".");var c=aa;a[0]in c||!c.execScript||c.execScript("var "+a[0]);for(var d;a.length&&(d=a.shift());)!a.length&&h(b)?c[d]=b:c[d]&&c[d]!==Object.prototype[d]?c=c[d]:c=c[d]={}}
+function ca(a){var b=typeof a;if("object"==b)if(a){if(a instanceof Array)return"array";if(a instanceof Object)return b;var c=Object.prototype.toString.call(a);if("[object Window]"==c)return"object";if("[object Array]"==c||"number"==typeof a.length&&"undefined"!=typeof a.splice&&"undefined"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable("splice"))return"array";if("[object Function]"==c||"undefined"!=typeof a.call&&"undefined"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable("call"))return"function"}else return"null";
+else if("function"==b&&"undefined"==typeof a.call)return"object";return b}function da(a,b,c){return a.call.apply(a.bind,arguments)}function ea(a,b,c){if(!a)throw Error();if(2<arguments.length){var d=Array.prototype.slice.call(arguments,2);return function(){var c=Array.prototype.slice.call(arguments);Array.prototype.unshift.apply(c,d);return a.apply(b,c)}}return function(){return a.apply(b,arguments)}}
+function fa(a,b,c){Function.prototype.bind&&-1!=Function.prototype.bind.toString().indexOf("native code")?fa=da:fa=ea;return fa.apply(null,arguments)}function ga(a,b){var c=Array.prototype.slice.call(arguments,1);return function(){var b=c.slice();b.push.apply(b,arguments);return a.apply(this,b)}}
+function n(a,b){function c(){}c.prototype=b.prototype;a.L=b.prototype;a.prototype=new c;a.prototype.constructor=a;a.K=function(a,c,f){for(var d=Array(arguments.length-2),e=2;e<arguments.length;e++)d[e-2]=arguments[e];return b.prototype[c].apply(a,d)}};function ha(a,b){this.code=a;this.a=p[a]||ia;this.message=b||"";a=this.a.replace(/((?:^|\s+)[a-z])/g,function(a){return a.toUpperCase().replace(/^[\s\xa0]+/g,"")});b=a.length-5;if(0>b||a.indexOf("Error",b)!=b)a+="Error";this.name=a;a=Error(this.message);a.name=this.name;this.stack=a.stack||""}n(ha,Error);var ia="unknown error",p={15:"element not selectable",11:"element not visible"};p[31]=ia;p[30]=ia;p[24]="invalid cookie domain";p[29]="invalid element coordinates";p[12]="invalid element state";
+p[32]="invalid selector";p[51]="invalid selector";p[52]="invalid selector";p[17]="javascript error";p[405]="unsupported operation";p[34]="move target out of bounds";p[27]="no such alert";p[7]="no such element";p[8]="no such frame";p[23]="no such window";p[28]="script timeout";p[33]="session not created";p[10]="stale element reference";p[21]="timeout";p[25]="unable to set cookie";p[26]="unexpected alert open";p[13]=ia;p[9]="unknown command";ha.prototype.toString=function(){return this.name+": "+this.message};var ja={aliceblue:"#f0f8ff",antiquewhite:"#faebd7",aqua:"#00ffff",aquamarine:"#7fffd4",azure:"#f0ffff",beige:"#f5f5dc",bisque:"#ffe4c4",black:"#000000",blanchedalmond:"#ffebcd",blue:"#0000ff",blueviolet:"#8a2be2",brown:"#a52a2a",burlywood:"#deb887",cadetblue:"#5f9ea0",chartreuse:"#7fff00",chocolate:"#d2691e",coral:"#ff7f50",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cyan:"#00ffff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",darkgreen:"#006400",
+darkgrey:"#a9a9a9",darkkhaki:"#bdb76b",darkmagenta:"#8b008b",darkolivegreen:"#556b2f",darkorange:"#ff8c00",darkorchid:"#9932cc",darkred:"#8b0000",darksalmon:"#e9967a",darkseagreen:"#8fbc8f",darkslateblue:"#483d8b",darkslategray:"#2f4f4f",darkslategrey:"#2f4f4f",darkturquoise:"#00ced1",darkviolet:"#9400d3",deeppink:"#ff1493",deepskyblue:"#00bfff",dimgray:"#696969",dimgrey:"#696969",dodgerblue:"#1e90ff",firebrick:"#b22222",floralwhite:"#fffaf0",forestgreen:"#228b22",fuchsia:"#ff00ff",gainsboro:"#dcdcdc",
+ghostwhite:"#f8f8ff",gold:"#ffd700",goldenrod:"#daa520",gray:"#808080",green:"#008000",greenyellow:"#adff2f",grey:"#808080",honeydew:"#f0fff0",hotpink:"#ff69b4",indianred:"#cd5c5c",indigo:"#4b0082",ivory:"#fffff0",khaki:"#f0e68c",lavender:"#e6e6fa",lavenderblush:"#fff0f5",lawngreen:"#7cfc00",lemonchiffon:"#fffacd",lightblue:"#add8e6",lightcoral:"#f08080",lightcyan:"#e0ffff",lightgoldenrodyellow:"#fafad2",lightgray:"#d3d3d3",lightgreen:"#90ee90",lightgrey:"#d3d3d3",lightpink:"#ffb6c1",lightsalmon:"#ffa07a",
+lightseagreen:"#20b2aa",lightskyblue:"#87cefa",lightslategray:"#778899",lightslategrey:"#778899",lightsteelblue:"#b0c4de",lightyellow:"#ffffe0",lime:"#00ff00",limegreen:"#32cd32",linen:"#faf0e6",magenta:"#ff00ff",maroon:"#800000",mediumaquamarine:"#66cdaa",mediumblue:"#0000cd",mediumorchid:"#ba55d3",mediumpurple:"#9370db",mediumseagreen:"#3cb371",mediumslateblue:"#7b68ee",mediumspringgreen:"#00fa9a",mediumturquoise:"#48d1cc",mediumvioletred:"#c71585",midnightblue:"#191970",mintcream:"#f5fffa",mistyrose:"#ffe4e1",
+moccasin:"#ffe4b5",navajowhite:"#ffdead",navy:"#000080",oldlace:"#fdf5e6",olive:"#808000",olivedrab:"#6b8e23",orange:"#ffa500",orangered:"#ff4500",orchid:"#da70d6",palegoldenrod:"#eee8aa",palegreen:"#98fb98",paleturquoise:"#afeeee",palevioletred:"#db7093",papayawhip:"#ffefd5",peachpuff:"#ffdab9",peru:"#cd853f",pink:"#ffc0cb",plum:"#dda0dd",powderblue:"#b0e0e6",purple:"#800080",red:"#ff0000",rosybrown:"#bc8f8f",royalblue:"#4169e1",saddlebrown:"#8b4513",salmon:"#fa8072",sandybrown:"#f4a460",seagreen:"#2e8b57",
+seashell:"#fff5ee",sienna:"#a0522d",silver:"#c0c0c0",skyblue:"#87ceeb",slateblue:"#6a5acd",slategray:"#708090",slategrey:"#708090",snow:"#fffafa",springgreen:"#00ff7f",steelblue:"#4682b4",tan:"#d2b48c",teal:"#008080",thistle:"#d8bfd8",tomato:"#ff6347",turquoise:"#40e0d0",violet:"#ee82ee",wheat:"#f5deb3",white:"#ffffff",whitesmoke:"#f5f5f5",yellow:"#ffff00",yellowgreen:"#9acd32"};function ka(a,b){this.width=a;this.height=b}ka.prototype.toString=function(){return"("+this.width+" x "+this.height+")"};ka.prototype.ceil=function(){this.width=Math.ceil(this.width);this.height=Math.ceil(this.height);return this};ka.prototype.floor=function(){this.width=Math.floor(this.width);this.height=Math.floor(this.height);return this};ka.prototype.round=function(){this.width=Math.round(this.width);this.height=Math.round(this.height);return this};function la(a){return String(a).replace(/\-([a-z])/g,function(a,c){return c.toUpperCase()})};
+function r(a,b,c){this.a=a;this.b=b||1;this.f=c||1};function ma(a){this.b=a;this.a=0}function na(a){a=a.match(oa);for(var b=0;b<a.length;b++)pa.test(a[b])&&a.splice(b,1);return new ma(a)}var oa=/\$?(?:(?![0-9-\.])(?:\*|[\w-\.]+):)?(?![0-9-\.])(?:\*|[\w-\.]+)|\/\/|\.\.|::|\d+(?:\.\d*)?|\.\d+|"[^"]*"|'[^']*'|[!<>]=|\s+|./g,pa=/^\s/;function t(a,b){return a.b[a.a+(b||0)]}function v(a){return a.b[a.a++]}function qa(a){return a.b.length<=a.a};function w(a,b){this.h=a;this.c=h(b)?b:null;this.b=null;switch(a){case "comment":this.b=8;break;case "text":this.b=3;break;case "processing-instruction":this.b=7;break;case "node":break;default:throw Error("Unexpected argument");}}function ra(a){return"comment"==a||"text"==a||"processing-instruction"==a||"node"==a}w.prototype.a=function(a){return null===this.b||this.b==a.nodeType};w.prototype.f=function(){return this.h};
+w.prototype.toString=function(){var a="Kind Test: "+this.h;null===this.c||(a+=x(this.c));return a};function sa(a,b){this.j=a.toLowerCase();a="*"==this.j?"*":"http://www.w3.org/1999/xhtml";this.b=b?b.toLowerCase():a}sa.prototype.a=function(a){var b=a.nodeType;if(1!=b&&2!=b)return!1;b=h(a.localName)?a.localName:a.nodeName;return"*"!=this.j&&this.j!=b.toLowerCase()?!1:"*"==this.b?!0:this.b==(a.namespaceURI?a.namespaceURI.toLowerCase():"http://www.w3.org/1999/xhtml")};sa.prototype.f=function(){return this.j};
+sa.prototype.toString=function(){return"Name Test: "+("http://www.w3.org/1999/xhtml"==this.b?"":this.b+":")+this.j};function ta(a){switch(a.nodeType){case 1:return ga(ua,a);case 9:return ta(a.documentElement);case 11:case 10:case 6:case 12:return va;default:return a.parentNode?ta(a.parentNode):va}}function va(){return null}function ua(a,b){if(a.prefix==b)return a.namespaceURI||"http://www.w3.org/1999/xhtml";var c=a.getAttributeNode("xmlns:"+b);return c&&c.specified?c.value||null:a.parentNode&&9!=a.parentNode.nodeType?ua(a.parentNode,b):null};function wa(a,b){if(l(a))return l(b)&&1==b.length?a.indexOf(b,0):-1;for(var c=0;c<a.length;c++)if(c in a&&a[c]===b)return c;return-1}function y(a,b){for(var c=a.length,d=l(a)?a.split(""):a,e=0;e<c;e++)e in d&&b.call(void 0,d[e],e,a)}function z(a,b,c){var d=c;y(a,function(c,f){d=b.call(void 0,d,c,f,a)});return d}function xa(a,b){for(var c=a.length,d=l(a)?a.split(""):a,e=0;e<c;e++)if(e in d&&b.call(void 0,d[e],e,a))return!0;return!1}
+function ya(a,b){for(var c=a.length,d=l(a)?a.split(""):a,e=0;e<c;e++)if(e in d&&!b.call(void 0,d[e],e,a))return!1;return!0}function za(a){return Array.prototype.concat.apply([],arguments)}function Aa(a,b,c){return 2>=arguments.length?Array.prototype.slice.call(a,b):Array.prototype.slice.call(a,b,c)};var Ba="backgroundColor borderTopColor borderRightColor borderBottomColor borderLeftColor color outlineColor".split(" "),Ca=/#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])/,Da=/^#(?:[0-9a-f]{3}){1,2}$/i,Ea=/^(?:rgba)?\((\d{1,3}),\s?(\d{1,3}),\s?(\d{1,3}),\s?(0|1|0\.\d*)\)$/i,Fa=/^(?:rgb)?\((0|[1-9]\d{0,2}),\s?(0|[1-9]\d{0,2}),\s?(0|[1-9]\d{0,2})\)$/i;function B(a,b){this.x=h(a)?a:0;this.y=h(b)?b:0}B.prototype.toString=function(){return"("+this.x+", "+this.y+")"};B.prototype.ceil=function(){this.x=Math.ceil(this.x);this.y=Math.ceil(this.y);return this};B.prototype.floor=function(){this.x=Math.floor(this.x);this.y=Math.floor(this.y);return this};B.prototype.round=function(){this.x=Math.round(this.x);this.y=Math.round(this.y);return this};function Ga(a,b,c,d){this.c=a;this.a=b;this.b=c;this.f=d}Ga.prototype.toString=function(){return"("+this.c+"t, "+this.a+"r, "+this.b+"b, "+this.f+"l)"};Ga.prototype.ceil=function(){this.c=Math.ceil(this.c);this.a=Math.ceil(this.a);this.b=Math.ceil(this.b);this.f=Math.ceil(this.f);return this};Ga.prototype.floor=function(){this.c=Math.floor(this.c);this.a=Math.floor(this.a);this.b=Math.floor(this.b);this.f=Math.floor(this.f);return this};
+Ga.prototype.round=function(){this.c=Math.round(this.c);this.a=Math.round(this.a);this.b=Math.round(this.b);this.f=Math.round(this.f);return this};function C(a,b,c,d){this.a=a;this.b=b;this.width=c;this.height=d}C.prototype.toString=function(){return"("+this.a+", "+this.b+" - "+this.width+"w x "+this.height+"h)"};C.prototype.ceil=function(){this.a=Math.ceil(this.a);this.b=Math.ceil(this.b);this.width=Math.ceil(this.width);this.height=Math.ceil(this.height);return this};C.prototype.floor=function(){this.a=Math.floor(this.a);this.b=Math.floor(this.b);this.width=Math.floor(this.width);this.height=Math.floor(this.height);return this};
+C.prototype.round=function(){this.a=Math.round(this.a);this.b=Math.round(this.b);this.width=Math.round(this.width);this.height=Math.round(this.height);return this};(function(){var a=aa.Components;if(!a)return!1;try{if(!a.classes)return!1}catch(c){return!1}var b=a.classes,a=a.interfaces;b["@mozilla.org/xpcom/version-comparator;1"].getService(a.nsIVersionComparator);b["@mozilla.org/xre/app-info;1"].getService(a.nsIXULAppInfo);return!0})();function Ha(a,b){if(!a||!b)return!1;if(a.contains&&1==b.nodeType)return a==b||a.contains(b);if("undefined"!=typeof a.compareDocumentPosition)return a==b||!!(a.compareDocumentPosition(b)&16);for(;b&&a!=b;)b=b.parentNode;return b==a}
+function Ia(a,b){if(a==b)return 0;if(a.compareDocumentPosition)return a.compareDocumentPosition(b)&2?1:-1;if("sourceIndex"in a||a.parentNode&&"sourceIndex"in a.parentNode){var c=1==a.nodeType,d=1==b.nodeType;if(c&&d)return a.sourceIndex-b.sourceIndex;var e=a.parentNode,f=b.parentNode;return e==f?Ja(a,b):!c&&Ha(e,b)?-1*Ka(a,b):!d&&Ha(f,a)?Ka(b,a):(c?a.sourceIndex:e.sourceIndex)-(d?b.sourceIndex:f.sourceIndex)}d=D(a);c=d.createRange();c.selectNode(a);c.collapse(!0);a=d.createRange();a.selectNode(b);
+a.collapse(!0);return c.compareBoundaryPoints(aa.Range.START_TO_END,a)}function Ka(a,b){var c=a.parentNode;if(c==b)return-1;for(;b.parentNode!=c;)b=b.parentNode;return Ja(b,a)}function Ja(a,b){for(;b=b.previousSibling;)if(b==a)return-1;return 1}function D(a){return 9==a.nodeType?a:a.ownerDocument||a.document}function La(a,b){a&&(a=a.parentNode);for(var c=0;a;){if(b(a))return a;a=a.parentNode;c++}return null}function Ma(a){this.a=a||aa.document||document}
+Ma.prototype.getElementsByTagName=function(a,b){return(b||this.a).getElementsByTagName(String(a))};function E(a){var b=null,c=a.nodeType;1==c&&(b=a.textContent,b=void 0==b||null==b?a.innerText:b,b=void 0==b||null==b?"":b);if("string"!=typeof b)if(9==c||1==c){a=9==c?a.documentElement:a.firstChild;for(var c=0,d=[],b="";a;){do 1!=a.nodeType&&(b+=a.nodeValue),d[c++]=a;while(a=a.firstChild);for(;c&&!(a=d[--c].nextSibling););}}else b=a.nodeValue;return""+b}
+function F(a,b,c){if(null===b)return!0;try{if(!a.getAttribute)return!1}catch(d){return!1}return null==c?!!a.getAttribute(b):a.getAttribute(b,2)==c}function Na(a,b,c,d,e){return Oa.call(null,a,b,l(c)?c:null,l(d)?d:null,e||new G)}
+function Oa(a,b,c,d,e){b.getElementsByName&&d&&"name"==c?(b=b.getElementsByName(d),y(b,function(b){a.a(b)&&H(e,b)})):b.getElementsByClassName&&d&&"class"==c?(b=b.getElementsByClassName(d),y(b,function(b){b.className==d&&a.a(b)&&H(e,b)})):a instanceof w?Pa(a,b,c,d,e):b.getElementsByTagName&&(b=b.getElementsByTagName(a.f()),y(b,function(a){F(a,c,d)&&H(e,a)}));return e}function Pa(a,b,c,d,e){for(b=b.firstChild;b;b=b.nextSibling)F(b,c,d)&&a.a(b)&&H(e,b),Pa(a,b,c,d,e)};function J(a,b){b&&"string"!==typeof b&&(b=b.toString());return!!a&&1==a.nodeType&&(!b||a.tagName.toUpperCase()==b)};function G(){this.b=this.a=null;this.l=0}function Qa(a){this.node=a;this.a=this.b=null}function Ra(a,b){if(!a.a)return b;if(!b.a)return a;var c=a.a;b=b.a;for(var d=null,e,f=0;c&&b;)c.node==b.node?(e=c,c=c.a,b=b.a):0<Ia(c.node,b.node)?(e=b,b=b.a):(e=c,c=c.a),(e.b=d)?d.a=e:a.a=e,d=e,f++;for(e=c||b;e;)e.b=d,d=d.a=e,f++,e=e.a;a.b=d;a.l=f;return a}function Sa(a,b){b=new Qa(b);b.a=a.a;a.b?a.a.b=b:a.a=a.b=b;a.a=b;a.l++}function H(a,b){b=new Qa(b);b.b=a.b;a.a?a.b.a=b:a.a=a.b=b;a.b=b;a.l++}
+function Ta(a){return(a=a.a)?a.node:null}function Ua(a){return(a=Ta(a))?E(a):""}function K(a,b){return new Va(a,!!b)}function Va(a,b){this.f=a;this.b=(this.s=b)?a.b:a.a;this.a=null}function M(a){var b=a.b;if(b){var c=a.a=b;a.b=a.s?b.b:b.a;return c.node}return null};function N(a){this.i=a;this.b=this.g=!1;this.f=null}function x(a){return"\n "+a.toString().split("\n").join("\n ")}function Wa(a,b){a.g=b}function Xa(a,b){a.b=b}function O(a,b){a=a.a(b);return a instanceof G?+Ua(a):+a}function P(a,b){a=a.a(b);return a instanceof G?Ua(a):""+a}function Q(a,b){a=a.a(b);return a instanceof G?!!a.l:!!a};function Ya(a,b,c){N.call(this,a.i);this.c=a;this.h=b;this.o=c;this.g=b.g||c.g;this.b=b.b||c.b;this.c==Za&&(c.b||c.g||4==c.i||0==c.i||!b.f?b.b||b.g||4==b.i||0==b.i||!c.f||(this.f={name:c.f.name,u:b}):this.f={name:b.f.name,u:c})}n(Ya,N);
+function R(a,b,c,d,e){b=b.a(d);c=c.a(d);var f;if(b instanceof G&&c instanceof G){b=K(b);for(d=M(b);d;d=M(b))for(e=K(c),f=M(e);f;f=M(e))if(a(E(d),E(f)))return!0;return!1}if(b instanceof G||c instanceof G){b instanceof G?(e=b,d=c):(e=c,d=b);f=K(e);for(var g=typeof d,k=M(f);k;k=M(f)){switch(g){case "number":k=+E(k);break;case "boolean":k=!!E(k);break;case "string":k=E(k);break;default:throw Error("Illegal primitive type for comparison.");}if(e==b&&a(k,d)||e==c&&a(d,k))return!0}return!1}return e?"boolean"==
+typeof b||"boolean"==typeof c?a(!!b,!!c):"number"==typeof b||"number"==typeof c?a(+b,+c):a(b,c):a(+b,+c)}Ya.prototype.a=function(a){return this.c.m(this.h,this.o,a)};Ya.prototype.toString=function(){var a="Binary Expression: "+this.c,a=a+x(this.h);return a+=x(this.o)};function $a(a,b,c,d){this.I=a;this.D=b;this.i=c;this.m=d}$a.prototype.toString=function(){return this.I};var ab={};
+function S(a,b,c,d){if(ab.hasOwnProperty(a))throw Error("Binary operator already created: "+a);a=new $a(a,b,c,d);return ab[a.toString()]=a}S("div",6,1,function(a,b,c){return O(a,c)/O(b,c)});S("mod",6,1,function(a,b,c){return O(a,c)%O(b,c)});S("*",6,1,function(a,b,c){return O(a,c)*O(b,c)});S("+",5,1,function(a,b,c){return O(a,c)+O(b,c)});S("-",5,1,function(a,b,c){return O(a,c)-O(b,c)});S("<",4,2,function(a,b,c){return R(function(a,b){return a<b},a,b,c)});
+S(">",4,2,function(a,b,c){return R(function(a,b){return a>b},a,b,c)});S("<=",4,2,function(a,b,c){return R(function(a,b){return a<=b},a,b,c)});S(">=",4,2,function(a,b,c){return R(function(a,b){return a>=b},a,b,c)});var Za=S("=",3,2,function(a,b,c){return R(function(a,b){return a==b},a,b,c,!0)});S("!=",3,2,function(a,b,c){return R(function(a,b){return a!=b},a,b,c,!0)});S("and",2,2,function(a,b,c){return Q(a,c)&&Q(b,c)});S("or",1,2,function(a,b,c){return Q(a,c)||Q(b,c)});function bb(a,b){if(b.a.length&&4!=a.i)throw Error("Primary expression must evaluate to nodeset if filter has predicate(s).");N.call(this,a.i);this.c=a;this.h=b;this.g=a.g;this.b=a.b}n(bb,N);bb.prototype.a=function(a){a=this.c.a(a);return cb(this.h,a)};bb.prototype.toString=function(){var a="Filter:"+x(this.c);return a+=x(this.h)};function db(a,b){if(b.length<a.C)throw Error("Function "+a.j+" expects at least"+a.C+" arguments, "+b.length+" given");if(null!==a.A&&b.length>a.A)throw Error("Function "+a.j+" expects at most "+a.A+" arguments, "+b.length+" given");a.H&&y(b,function(b,d){if(4!=b.i)throw Error("Argument "+d+" to function "+a.j+" is not of type Nodeset: "+b);});N.call(this,a.i);this.v=a;this.c=b;Wa(this,a.g||xa(b,function(a){return a.g}));Xa(this,a.G&&!b.length||a.F&&!!b.length||xa(b,function(a){return a.b}))}
+n(db,N);db.prototype.a=function(a){return this.v.m.apply(null,za(a,this.c))};db.prototype.toString=function(){var a="Function: "+this.v;if(this.c.length)var b=z(this.c,function(a,b){return a+x(b)},"Arguments:"),a=a+x(b);return a};function eb(a,b,c,d,e,f,g,k,q){this.j=a;this.i=b;this.g=c;this.G=d;this.F=e;this.m=f;this.C=g;this.A=h(k)?k:g;this.H=!!q}eb.prototype.toString=function(){return this.j};var fb={};
+function T(a,b,c,d,e,f,g,k){if(fb.hasOwnProperty(a))throw Error("Function already created: "+a+".");fb[a]=new eb(a,b,c,d,!1,e,f,g,k)}T("boolean",2,!1,!1,function(a,b){return Q(b,a)},1);T("ceiling",1,!1,!1,function(a,b){return Math.ceil(O(b,a))},1);T("concat",3,!1,!1,function(a,b){return z(Aa(arguments,1),function(b,d){return b+P(d,a)},"")},2,null);T("contains",2,!1,!1,function(a,b,c){b=P(b,a);a=P(c,a);return-1!=b.indexOf(a)},2);T("count",1,!1,!1,function(a,b){return b.a(a).l},1,1,!0);
+T("false",2,!1,!1,function(){return!1},0);T("floor",1,!1,!1,function(a,b){return Math.floor(O(b,a))},1);T("id",4,!1,!1,function(a,b){var c=a.a,d=9==c.nodeType?c:c.ownerDocument;a=P(b,a).split(/\s+/);var e=[];y(a,function(a){a=d.getElementById(a);!a||0<=wa(e,a)||e.push(a)});e.sort(Ia);var f=new G;y(e,function(a){H(f,a)});return f},1);T("lang",2,!1,!1,function(){return!1},1);T("last",1,!0,!1,function(a){if(1!=arguments.length)throw Error("Function last expects ()");return a.f},0);
+T("local-name",3,!1,!0,function(a,b){return(a=b?Ta(b.a(a)):a.a)?a.localName||a.nodeName.toLowerCase():""},0,1,!0);T("name",3,!1,!0,function(a,b){return(a=b?Ta(b.a(a)):a.a)?a.nodeName.toLowerCase():""},0,1,!0);T("namespace-uri",3,!0,!1,function(){return""},0,1,!0);T("normalize-space",3,!1,!0,function(a,b){return(b?P(b,a):E(a.a)).replace(/[\s\xa0]+/g," ").replace(/^\s+|\s+$/g,"")},0,1);T("not",2,!1,!1,function(a,b){return!Q(b,a)},1);T("number",1,!1,!0,function(a,b){return b?O(b,a):+E(a.a)},0,1);
+T("position",1,!0,!1,function(a){return a.b},0);T("round",1,!1,!1,function(a,b){return Math.round(O(b,a))},1);T("starts-with",2,!1,!1,function(a,b,c){b=P(b,a);a=P(c,a);return!b.lastIndexOf(a,0)},2);T("string",3,!1,!0,function(a,b){return b?P(b,a):E(a.a)},0,1);T("string-length",1,!1,!0,function(a,b){return(b?P(b,a):E(a.a)).length},0,1);
+T("substring",3,!1,!1,function(a,b,c,d){c=O(c,a);if(isNaN(c)||Infinity==c||-Infinity==c)return"";d=d?O(d,a):Infinity;if(isNaN(d)||-Infinity===d)return"";c=Math.round(c)-1;var e=Math.max(c,0);a=P(b,a);return Infinity==d?a.substring(e):a.substring(e,c+Math.round(d))},2,3);T("substring-after",3,!1,!1,function(a,b,c){b=P(b,a);a=P(c,a);c=b.indexOf(a);return-1==c?"":b.substring(c+a.length)},2);
+T("substring-before",3,!1,!1,function(a,b,c){b=P(b,a);a=P(c,a);a=b.indexOf(a);return-1==a?"":b.substring(0,a)},2);T("sum",1,!1,!1,function(a,b){a=K(b.a(a));b=0;for(var c=M(a);c;c=M(a))b+=+E(c);return b},1,1,!0);T("translate",3,!1,!1,function(a,b,c,d){b=P(b,a);c=P(c,a);var e=P(d,a);d={};for(var f=0;f<c.length;f++)a=c.charAt(f),a in d||(d[a]=e.charAt(f));c="";for(f=0;f<b.length;f++)a=b.charAt(f),c+=a in d?d[a]:a;return c},3);T("true",2,!1,!1,function(){return!0},0);function gb(a){N.call(this,3);this.c=a.substring(1,a.length-1)}n(gb,N);gb.prototype.a=function(){return this.c};gb.prototype.toString=function(){return"Literal: "+this.c};function hb(a){N.call(this,1);this.c=a}n(hb,N);hb.prototype.a=function(){return this.c};hb.prototype.toString=function(){return"Number: "+this.c};function ib(a,b){N.call(this,a.i);this.h=a;this.c=b;this.g=a.g;this.b=a.b;1==this.c.length&&(a=this.c[0],a.w||a.c!=jb||(a=a.o,"*"!=a.f()&&(this.f={name:a.f(),u:null})))}n(ib,N);function kb(){N.call(this,4)}n(kb,N);kb.prototype.a=function(a){var b=new G;a=a.a;9==a.nodeType?H(b,a):H(b,a.ownerDocument);return b};kb.prototype.toString=function(){return"Root Helper Expression"};function lb(){N.call(this,4)}n(lb,N);lb.prototype.a=function(a){var b=new G;H(b,a.a);return b};lb.prototype.toString=function(){return"Context Helper Expression"};
+function mb(a){return"/"==a||"//"==a}ib.prototype.a=function(a){var b=this.h.a(a);if(!(b instanceof G))throw Error("Filter expression must evaluate to nodeset.");a=this.c;for(var c=0,d=a.length;c<d&&b.l;c++){var e=a[c],f=K(b,e.c.s);if(e.g||e.c!=nb)if(e.g||e.c!=ob){var g=M(f);for(b=e.a(new r(g));g=M(f);)g=e.a(new r(g)),b=Ra(b,g)}else g=M(f),b=e.a(new r(g));else{for(g=M(f);(b=M(f))&&(!g.contains||g.contains(b))&&b.compareDocumentPosition(g)&8;g=b);b=e.a(new r(g))}}return b};
+ib.prototype.toString=function(){var a="Path Expression:"+x(this.h);if(this.c.length){var b=z(this.c,function(a,b){return a+x(b)},"Steps:");a+=x(b)}return a};function pb(a,b){this.a=a;this.s=!!b}
+function cb(a,b,c){for(c=c||0;c<a.a.length;c++)for(var d=a.a[c],e=K(b),f=b.l,g,k=0;g=M(e);k++){var q=a.s?f-k:k+1;g=d.a(new r(g,q,f));if("number"==typeof g)q=q==g;else if("string"==typeof g||"boolean"==typeof g)q=!!g;else if(g instanceof G)q=0<g.l;else throw Error("Predicate.evaluate returned an unexpected type.");if(!q){q=e;g=q.f;var u=q.a;if(!u)throw Error("Next must be called at least once before remove.");var m=u.b,u=u.a;m?m.a=u:g.a=u;u?u.b=m:g.b=m;g.l--;q.a=null}}return b}
+pb.prototype.toString=function(){return z(this.a,function(a,b){return a+x(b)},"Predicates:")};function qb(a){N.call(this,1);this.c=a;this.g=a.g;this.b=a.b}n(qb,N);qb.prototype.a=function(a){return-O(this.c,a)};qb.prototype.toString=function(){return"Unary Expression: -"+x(this.c)};function rb(a){N.call(this,4);this.c=a;Wa(this,xa(this.c,function(a){return a.g}));Xa(this,xa(this.c,function(a){return a.b}))}n(rb,N);rb.prototype.a=function(a){var b=new G;y(this.c,function(c){c=c.a(a);if(!(c instanceof G))throw Error("Path expression must evaluate to NodeSet.");b=Ra(b,c)});return b};rb.prototype.toString=function(){return z(this.c,function(a,b){return a+x(b)},"Union Expression:")};function U(a,b,c,d){N.call(this,4);this.c=a;this.o=b;this.h=c||new pb([]);this.w=!!d;b=this.h;b=0<b.a.length?b.a[0].f:null;a.J&&b&&(this.f={name:b.name,u:b.u});a:{a=this.h;for(b=0;b<a.a.length;b++)if(c=a.a[b],c.g||1==c.i||0==c.i){a=!0;break a}a=!1}this.g=a}n(U,N);
+U.prototype.a=function(a){var b=a.a,c=this.f,d=null,e=null,f=0;c&&(d=c.name,e=c.u?P(c.u,a):null,f=1);if(this.w)if(this.g||this.c!=sb)if(b=K((new U(tb,new w("node"))).a(a)),c=M(b))for(a=this.m(c,d,e,f);c=M(b);)a=Ra(a,this.m(c,d,e,f));else a=new G;else a=Na(this.o,b,d,e),a=cb(this.h,a,f);else a=this.m(a.a,d,e,f);return a};U.prototype.m=function(a,b,c,d){a=this.c.v(this.o,a,b,c);return a=cb(this.h,a,d)};
+U.prototype.toString=function(){var a="Step:"+x("Operator: "+(this.w?"//":"/"));this.c.j&&(a+=x("Axis: "+this.c));a+=x(this.o);if(this.h.a.length){var b=z(this.h.a,function(a,b){return a+x(b)},"Predicates:");a+=x(b)}return a};function ub(a,b,c,d){this.j=a;this.v=b;this.s=c;this.J=d}ub.prototype.toString=function(){return this.j};var vb={};function V(a,b,c,d){if(vb.hasOwnProperty(a))throw Error("Axis already created: "+a);b=new ub(a,b,c,!!d);return vb[a]=b}
+V("ancestor",function(a,b){for(var c=new G;b=b.parentNode;)a.a(b)&&Sa(c,b);return c},!0);V("ancestor-or-self",function(a,b){var c=new G;do a.a(b)&&Sa(c,b);while(b=b.parentNode);return c},!0);
+var jb=V("attribute",function(a,b){var c=new G,d=a.f();if(b=b.attributes)if(a instanceof w&&null===a.b||"*"==d)for(d=0;a=b[d];d++)H(c,a);else(a=b.getNamedItem(d))&&H(c,a);return c},!1),sb=V("child",function(a,b,c,d,e){c=l(c)?c:null;d=l(d)?d:null;e=e||new G;for(b=b.firstChild;b;b=b.nextSibling)F(b,c,d)&&a.a(b)&&H(e,b);return e},!1,!0);V("descendant",Na,!1,!0);
+var tb=V("descendant-or-self",function(a,b,c,d){var e=new G;F(b,c,d)&&a.a(b)&&H(e,b);return Na(a,b,c,d,e)},!1,!0),nb=V("following",function(a,b,c,d){var e=new G;do for(var f=b;f=f.nextSibling;)F(f,c,d)&&a.a(f)&&H(e,f),e=Na(a,f,c,d,e);while(b=b.parentNode);return e},!1,!0);V("following-sibling",function(a,b){for(var c=new G;b=b.nextSibling;)a.a(b)&&H(c,b);return c},!1);V("namespace",function(){return new G},!1);
+var wb=V("parent",function(a,b){var c=new G;if(9==b.nodeType)return c;if(2==b.nodeType)return H(c,b.ownerElement),c;b=b.parentNode;a.a(b)&&H(c,b);return c},!1),ob=V("preceding",function(a,b,c,d){var e=new G,f=[];do f.unshift(b);while(b=b.parentNode);for(var g=1,k=f.length;g<k;g++){var q=[];for(b=f[g];b=b.previousSibling;)q.unshift(b);for(var u=0,m=q.length;u<m;u++)b=q[u],F(b,c,d)&&a.a(b)&&H(e,b),e=Na(a,b,c,d,e)}return e},!0,!0);
+V("preceding-sibling",function(a,b){for(var c=new G;b=b.previousSibling;)a.a(b)&&Sa(c,b);return c},!0);var xb=V("self",function(a,b){var c=new G;a.a(b)&&H(c,b);return c},!1);function yb(a,b){this.a=a;this.b=b}function zb(a){for(var b,c=[];;){W(a,"Missing right hand side of binary expression.");b=Ab(a);var d=v(a.a);if(!d)break;var e=(d=ab[d]||null)&&d.D;if(!e){a.a.a--;break}for(;c.length&&e<=c[c.length-1].D;)b=new Ya(c.pop(),c.pop(),b);c.push(b,d)}for(;c.length;)b=new Ya(c.pop(),c.pop(),b);return b}function W(a,b){if(qa(a.a))throw Error(b);}function Bb(a,b){a=v(a.a);if(a!=b)throw Error("Bad token, expected: "+b+" got: "+a);}
+function Cb(a){a=v(a.a);if(")"!=a)throw Error("Bad token: "+a);}function Db(a){a=v(a.a);if(2>a.length)throw Error("Unclosed literal string");return new gb(a)}
+function Eb(a){var b=[];if(mb(t(a.a))){var c=v(a.a);var d=t(a.a);if("/"==c&&(qa(a.a)||"."!=d&&".."!=d&&"@"!=d&&"*"!=d&&!/(?![0-9])[\w]/.test(d)))return new kb;d=new kb;W(a,"Missing next location step.");c=Fb(a,c);b.push(c)}else{a:{c=t(a.a);d=c.charAt(0);switch(d){case "$":throw Error("Variable reference not allowed in HTML XPath");case "(":v(a.a);c=zb(a);W(a,'unclosed "("');Bb(a,")");break;case '"':case "'":c=Db(a);break;default:if(isNaN(+c))if(!ra(c)&&/(?![0-9])[\w]/.test(d)&&"("==t(a.a,1)){c=v(a.a);
+c=fb[c]||null;v(a.a);for(d=[];")"!=t(a.a);){W(a,"Missing function argument list.");d.push(zb(a));if(","!=t(a.a))break;v(a.a)}W(a,"Unclosed function argument list.");Cb(a);c=new db(c,d)}else{c=null;break a}else c=new hb(+v(a.a))}"["==t(a.a)&&(d=new pb(Gb(a)),c=new bb(c,d))}if(c)if(mb(t(a.a)))d=c;else return c;else c=Fb(a,"/"),d=new lb,b.push(c)}for(;mb(t(a.a));)c=v(a.a),W(a,"Missing next location step."),c=Fb(a,c),b.push(c);return new ib(d,b)}
+function Fb(a,b){if("/"!=b&&"//"!=b)throw Error('Step op should be "/" or "//"');if("."==t(a.a)){var c=new U(xb,new w("node"));v(a.a);return c}if(".."==t(a.a))return c=new U(wb,new w("node")),v(a.a),c;if("@"==t(a.a)){var d=jb;v(a.a);W(a,"Missing attribute name")}else if("::"==t(a.a,1)){if(!/(?![0-9])[\w]/.test(t(a.a).charAt(0)))throw Error("Bad token: "+v(a.a));var e=v(a.a);d=vb[e]||null;if(!d)throw Error("No axis with name: "+e);v(a.a);W(a,"Missing node name")}else d=sb;e=t(a.a);if(/(?![0-9])[\w\*]/.test(e.charAt(0)))if("("==
+t(a.a,1)){if(!ra(e))throw Error("Invalid node type: "+e);e=v(a.a);if(!ra(e))throw Error("Invalid type name: "+e);Bb(a,"(");W(a,"Bad nodetype");var f=t(a.a).charAt(0),g=null;if('"'==f||"'"==f)g=Db(a);W(a,"Bad nodetype");Cb(a);e=new w(e,g)}else if(e=v(a.a),f=e.indexOf(":"),-1==f)e=new sa(e);else{var g=e.substring(0,f);if("*"==g)var k="*";else if(k=a.b(g),!k)throw Error("Namespace prefix not declared: "+g);e=e.substr(f+1);e=new sa(e,k)}else throw Error("Bad token: "+v(a.a));a=new pb(Gb(a),d.s);return c||
+new U(d,e,a,"//"==b)}function Gb(a){for(var b=[];"["==t(a.a);){v(a.a);W(a,"Missing predicate expression.");var c=zb(a);b.push(c);W(a,"Unclosed predicate expression.");Bb(a,"]")}return b}function Ab(a){if("-"==t(a.a))return v(a.a),new qb(Ab(a));var b=Eb(a);if("|"!=t(a.a))a=b;else{for(b=[b];"|"==v(a.a);)W(a,"Missing next union location path."),b.push(Eb(a));a.a.a--;a=new rb(b)}return a};function Hb(a,b){if(!a.length)throw Error("Empty XPath expression.");a=na(a);if(qa(a))throw Error("Invalid XPath expression.");b?"function"==ca(b)||(b=fa(b.lookupNamespaceURI,b)):b=function(){return null};var c=zb(new yb(a,b));if(!qa(a))throw Error("Bad token: "+v(a));this.evaluate=function(a,b){a=c.a(new r(a));return new X(a,b)}}
+function X(a,b){if(!b)if(a instanceof G)b=4;else if("string"==typeof a)b=2;else if("number"==typeof a)b=1;else if("boolean"==typeof a)b=3;else throw Error("Unexpected evaluation result.");if(2!=b&&1!=b&&3!=b&&!(a instanceof G))throw Error("value could not be converted to the specified type");this.resultType=b;switch(b){case 2:this.stringValue=a instanceof G?Ua(a):""+a;break;case 1:this.numberValue=a instanceof G?+Ua(a):+a;break;case 3:this.booleanValue=a instanceof G?0<a.l:!!a;break;case 4:case 5:case 6:case 7:var c=
+K(a);var d=[];for(var e=M(c);e;e=M(c))d.push(e);this.snapshotLength=a.l;this.invalidIteratorState=!1;break;case 8:case 9:this.singleNodeValue=Ta(a);break;default:throw Error("Unknown XPathResult type.");}var f=0;this.iterateNext=function(){if(4!=b&&5!=b)throw Error("iterateNext called with wrong result type");return f>=d.length?null:d[f++]};this.snapshotItem=function(a){if(6!=b&&7!=b)throw Error("snapshotItem called with wrong result type");return a>=d.length||0>a?null:d[a]}}X.ANY_TYPE=0;
+X.NUMBER_TYPE=1;X.STRING_TYPE=2;X.BOOLEAN_TYPE=3;X.UNORDERED_NODE_ITERATOR_TYPE=4;X.ORDERED_NODE_ITERATOR_TYPE=5;X.UNORDERED_NODE_SNAPSHOT_TYPE=6;X.ORDERED_NODE_SNAPSHOT_TYPE=7;X.ANY_UNORDERED_NODE_TYPE=8;X.FIRST_ORDERED_NODE_TYPE=9;function Ib(a){this.lookupNamespaceURI=ta(a)}
+ba("wgxpath.install",function(a,b){a=a||aa;var c=a.Document&&a.Document.prototype||a.document;if(!c.evaluate||b)a.XPathResult=X,c.evaluate=function(a,b,c,g){return(new Hb(a,c)).evaluate(b,g)},c.createExpression=function(a,b){return new Hb(a,b)},c.createNSResolver=function(a){return new Ib(a)}});var Jb=function(){var a={M:"http://www.w3.org/2000/svg"};return function(b){return a[b]||null}}();
+function Kb(a,b){var c=D(a);if(!c.documentElement)return null;try{for(var d=c.createNSResolver?c.createNSResolver(c.documentElement):Jb,e={},f=c.getElementsByTagName("*"),g=0;g<f.length;++g){var k=f[g],q=k.namespaceURI;if(q&&!e[q]){var u=k.lookupPrefix(q);if(!u)var m=q.match(".*/(\\w+)/?$"),u=m?m[1]:"xhtml";e[q]=u}}var A={},I;for(I in e)A[e[I]]=I;d=function(a){return A[a]||null};try{return c.evaluate(b,a,d,9,null)}catch(L){if("TypeError"===L.name)return d=c.createNSResolver?c.createNSResolver(c.documentElement):
+Jb,c.evaluate(b,a,d,9,null);throw L;}}catch(L){if("NS_ERROR_ILLEGAL_VALUE"!=L.name)throw new ha(32,"Unable to locate an element with the xpath expression "+b+" because of the following error:\n"+L);}}
+function Lb(a,b){var c=function(){var c=Kb(b,a);return c?c.singleNodeValue||null:b.selectSingleNode?(c=D(b),c.setProperty&&c.setProperty("SelectionLanguage","XPath"),b.selectSingleNode(a)):null}();if(null!==c&&(!c||1!=c.nodeType))throw new ha(32,'The result of the xpath expression "'+a+'" is: '+c+". It should be an element.");return c};var Mb="function"===typeof ShadowRoot;function Nb(a){for(a=a.parentNode;a&&1!=a.nodeType&&9!=a.nodeType&&11!=a.nodeType;)a=a.parentNode;return J(a)?a:null}
+function Y(a,b){b=la(b);if("float"==b||"cssFloat"==b||"styleFloat"==b)b="cssFloat";a:{var c=b;var d=D(a);if(d.defaultView&&d.defaultView.getComputedStyle&&(d=d.defaultView.getComputedStyle(a,null))){c=d[c]||d.getPropertyValue(c)||"";break a}c=""}a=c||Ob(a,b);if(null===a)a=null;else if(0<=wa(Ba,b)){b:{var e=a.match(Ea);if(e&&(b=Number(e[1]),c=Number(e[2]),d=Number(e[3]),e=Number(e[4]),0<=b&&255>=b&&0<=c&&255>=c&&0<=d&&255>=d&&0<=e&&1>=e)){b=[b,c,d,e];break b}b=null}if(!b)b:{if(d=a.match(Fa))if(b=Number(d[1]),
+c=Number(d[2]),d=Number(d[3]),0<=b&&255>=b&&0<=c&&255>=c&&0<=d&&255>=d){b=[b,c,d,1];break b}b=null}if(!b)b:{b=a.toLowerCase();c=ja[b.toLowerCase()];if(!c&&(c="#"==b.charAt(0)?b:"#"+b,4==c.length&&(c=c.replace(Ca,"#$1$1$2$2$3$3")),!Da.test(c))){b=null;break b}b=[parseInt(c.substr(1,2),16),parseInt(c.substr(3,2),16),parseInt(c.substr(5,2),16),1]}a=b?"rgba("+b.join(", ")+")":a}return a}
+function Ob(a,b){var c=a.currentStyle||a.style,d=c[b];!h(d)&&"function"==ca(c.getPropertyValue)&&(d=c.getPropertyValue(b));return"inherit"!=d?h(d)?d:null:(a=Nb(a))?Ob(a,b):null}
+function Pb(a,b,c){function d(a){var b=Qb(a);return 0<b.height&&0<b.width?!0:J(a,"PATH")&&(0<b.height||0<b.width)?(a=Y(a,"stroke-width"),!!a&&0<parseInt(a,10)):"hidden"!=Y(a,"overflow")&&xa(a.childNodes,function(a){return 3==a.nodeType||J(a)&&d(a)})}function e(a){return Rb(a)==Z&&ya(a.childNodes,function(a){return!J(a)||e(a)||!d(a)})}if(!J(a))throw Error("Argument to isShown must be of type Element");if(J(a,"BODY"))return!0;if(J(a,"OPTION")||J(a,"OPTGROUP"))return a=La(a,function(a){return J(a,"SELECT")}),
+!!a&&Pb(a,!0,c);var f=Sb(a);if(f)return!!f.B&&0<f.rect.width&&0<f.rect.height&&Pb(f.B,b,c);if(J(a,"INPUT")&&"hidden"==a.type.toLowerCase()||J(a,"NOSCRIPT"))return!1;f=Y(a,"visibility");return"collapse"!=f&&"hidden"!=f&&c(a)&&(b||Tb(a))&&d(a)?!e(a):!1}var Z="hidden";
+function Rb(a){function b(a){function b(a){return a==g?!0:!Y(a,"display").lastIndexOf("inline",0)||"absolute"==c&&"static"==Y(a,"position")?!1:!0}var c=Y(a,"position");if("fixed"==c)return u=!0,a==g?null:g;for(a=Nb(a);a&&!b(a);)a=Nb(a);return a}function c(a){var b=a;if("visible"==q)if(a==g&&k)b=k;else if(a==k)return{x:"visible",y:"visible"};b={x:Y(b,"overflow-x"),y:Y(b,"overflow-y")};a==g&&(b.x="visible"==b.x?"auto":b.x,b.y="visible"==b.y?"auto":b.y);return b}function d(a){if(a==g){var b=(new Ma(f)).a;
+a=b.scrollingElement?b.scrollingElement:"CSS1Compat"==b.compatMode?b.documentElement:b.body||b.documentElement;b=b.parentWindow||b.defaultView;a=new B(b.pageXOffset||a.scrollLeft,b.pageYOffset||a.scrollTop)}else a=new B(a.scrollLeft,a.scrollTop);return a}var e=Ub(a);var f=D(a),g=f.documentElement,k=f.body,q=Y(g,"overflow"),u;for(a=b(a);a;a=b(a)){var m=c(a);if("visible"!=m.x||"visible"!=m.y){var A=Qb(a);if(!A.width||!A.height)return Z;var I=e.a<A.a,L=e.b<A.b;if(I&&"hidden"==m.x||L&&"hidden"==m.y)return Z;
+if(I&&"visible"!=m.x||L&&"visible"!=m.y){I=d(a);L=e.b<A.b-I.y;if(e.a<A.a-I.x&&"visible"!=m.x||L&&"visible"!=m.x)return Z;e=Rb(a);return e==Z?Z:"scroll"}I=e.f>=A.a+A.width;A=e.c>=A.b+A.height;if(I&&"hidden"==m.x||A&&"hidden"==m.y)return Z;if(I&&"visible"!=m.x||A&&"visible"!=m.y){if(u&&(m=d(a),e.f>=g.scrollWidth-m.x||e.a>=g.scrollHeight-m.y))return Z;e=Rb(a);return e==Z?Z:"scroll"}}}return"none"}
+function Qb(a){var b=Sb(a);if(b)return b.rect;if(J(a,"HTML"))return a=D(a),a=((a?a.parentWindow||a.defaultView:window)||window).document,a="CSS1Compat"==a.compatMode?a.documentElement:a.body,a=new ka(a.clientWidth,a.clientHeight),new C(0,0,a.width,a.height);try{var c=a.getBoundingClientRect()}catch(d){return new C(0,0,0,0)}return new C(c.left,c.top,c.right-c.left,c.bottom-c.top)}
+function Sb(a){var b=J(a,"MAP");if(!b&&!J(a,"AREA"))return null;var c=b?a:J(a.parentNode,"MAP")?a.parentNode:null,d=null,e=null;c&&c.name&&(d=Lb('/descendant::*[@usemap = "#'+c.name+'"]',D(c)))&&(e=Qb(d),b||"default"==a.shape.toLowerCase()||(a=Vb(a),b=Math.min(Math.max(a.a,0),e.width),c=Math.min(Math.max(a.b,0),e.height),e=new C(b+e.a,c+e.b,Math.min(a.width,e.width-b),Math.min(a.height,e.height-c))));return{B:d,rect:e||new C(0,0,0,0)}}
+function Vb(a){var b=a.shape.toLowerCase();a=a.coords.split(",");if("rect"==b&&4==a.length){var b=a[0],c=a[1];return new C(b,c,a[2]-b,a[3]-c)}if("circle"==b&&3==a.length)return b=a[2],new C(a[0]-b,a[1]-b,2*b,2*b);if("poly"==b&&2<a.length){for(var b=a[0],c=a[1],d=b,e=c,f=2;f+1<a.length;f+=2)b=Math.min(b,a[f]),d=Math.max(d,a[f]),c=Math.min(c,a[f+1]),e=Math.max(e,a[f+1]);return new C(b,c,d-b,e-c)}return new C(0,0,0,0)}function Ub(a){a=Qb(a);return new Ga(a.b,a.a+a.width,a.b+a.height,a.a)}
+function Tb(a){var b=1,c=Y(a,"opacity");c&&(b=Number(c));(a=Nb(a))&&(b*=Tb(a));return b};ba("_",function(a,b){var c=Mb?function(b){if("none"==Y(b,"display"))return!1;do{var d=b.parentNode;if(b.getDestinationInsertionPoints){var f=b.getDestinationInsertionPoints();0<f.length&&(d=f[f.length-1])}if(d instanceof ShadowRoot){if(d.host.shadowRoot!=d)return!1;d=d.host}else!d||9!=d.nodeType&&11!=d.nodeType||(d=null)}while(a&&1!=a.nodeType);return!d||c(d)}:function(a){if("none"==Y(a,"display"))return!1;a=Nb(a);return!a||c(a)};return Pb(a,!!b,c)});; return this._.apply(null,arguments);}.apply({navigator:typeof window!='undefined'?window.navigator:null,document:typeof window!='undefined'?window.document:null}, arguments);}
diff --git a/testing/marionette/browser.js b/testing/marionette/browser.js
new file mode 100644
index 0000000000..60b402e395
--- /dev/null
+++ b/testing/marionette/browser.js
@@ -0,0 +1,532 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+/* global frame */
+
+const EXPORTED_SYMBOLS = ["browser", "Context", "WindowState"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ element: "chrome://marionette/content/element.js",
+ error: "chrome://marionette/content/error.js",
+ Log: "chrome://marionette/content/log.js",
+ MessageManagerDestroyedPromise: "chrome://marionette/content/sync.js",
+ waitForEvent: "chrome://marionette/content/sync.js",
+ waitForObserverTopic: "chrome://marionette/content/sync.js",
+ WebElementEventTarget: "chrome://marionette/content/dom.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+
+/** @namespace */
+this.browser = {};
+
+/**
+ * Variations of Marionette contexts.
+ *
+ * Choosing a context through the <tt>Marionette:SetContext</tt>
+ * command directs all subsequent browsing context scoped commands
+ * to that context.
+ */
+class Context {
+ /**
+ * Gets the correct context from a string.
+ *
+ * @param {string} s
+ * Context string serialisation.
+ *
+ * @return {Context}
+ * Context.
+ *
+ * @throws {TypeError}
+ * If <var>s</var> is not a context.
+ */
+ static fromString(s) {
+ switch (s) {
+ case "chrome":
+ return Context.Chrome;
+
+ case "content":
+ return Context.Content;
+
+ default:
+ throw new TypeError(`Unknown context: ${s}`);
+ }
+ }
+}
+Context.Chrome = "chrome";
+Context.Content = "content";
+this.Context = Context;
+
+// GeckoView shim for Desktop's gBrowser
+class MobileTabBrowser {
+ constructor(window) {
+ this.window = window;
+ }
+
+ get tabs() {
+ return [this.window.tab];
+ }
+
+ get selectedTab() {
+ return this.window.tab;
+ }
+
+ set selectedTab(tab) {
+ if (tab != this.selectedTab) {
+ throw new Error("GeckoView only supports a single tab");
+ }
+
+ // Synthesize a custom TabSelect event to indicate that a tab has been
+ // selected even when we don't change it.
+ const event = this.window.CustomEvent("TabSelect", {
+ bubbles: true,
+ cancelable: false,
+ detail: {
+ previousTab: this.selectedTab,
+ },
+ });
+ this.window.document.dispatchEvent(event);
+ }
+
+ get selectedBrowser() {
+ return this.selectedTab.linkedBrowser;
+ }
+}
+
+/**
+ * Get the <code>&lt;xul:browser&gt;</code> for the specified tab.
+ *
+ * @param {Tab} tab
+ * The tab whose browser needs to be returned.
+ *
+ * @return {Browser}
+ * The linked browser for the tab or null if no browser can be found.
+ */
+browser.getBrowserForTab = function(tab) {
+ if (tab && "linkedBrowser" in tab) {
+ return tab.linkedBrowser;
+ }
+
+ return null;
+};
+
+/**
+ * Return the tab browser for the specified chrome window.
+ *
+ * @param {ChromeWindow} win
+ * Window whose <code>tabbrowser</code> needs to be accessed.
+ *
+ * @return {Tab}
+ * Tab browser or null if it's not a browser window.
+ */
+browser.getTabBrowser = function(window) {
+ // GeckoView
+ if (Services.androidBridge) {
+ return new MobileTabBrowser(window);
+ // Firefox
+ } else if ("gBrowser" in window) {
+ return window.gBrowser;
+ // Thunderbird
+ } else if (window.document.getElementById("tabmail")) {
+ return window.document.getElementById("tabmail");
+ }
+
+ return null;
+};
+
+/**
+ * Creates a browsing context wrapper.
+ *
+ * Browsing contexts handle interactions with the browser, according to
+ * the current environment.
+ */
+browser.Context = class {
+ /**
+ * @param {ChromeWindow} win
+ * ChromeWindow that contains the top-level browsing context.
+ * @param {GeckoDriver} driver
+ * Reference to driver instance.
+ */
+ constructor(window, driver) {
+ this.window = window;
+ this.driver = driver;
+
+ // In Firefox this is <xul:tabbrowser> (not <xul:browser>!)
+ // and MobileTabBrowser in GeckoView.
+ this.tabBrowser = browser.getTabBrowser(this.window);
+
+ // Used to set curFrameId upon new session
+ this.newSession = true;
+
+ this.seenEls = new element.Store();
+
+ // A reference to the tab corresponding to the current window handle,
+ // if any. Specifically, this.tab refers to the last tab that Marionette
+ // switched to in this browser window. Note that this may not equal the
+ // currently selected tab. For example, if Marionette switches to tab
+ // A, and then clicks on a button that opens a new tab B in the same
+ // browser window, this.tab will still point to tab A, despite tab B
+ // being the currently selected tab.
+ this.tab = null;
+
+ this.frameRegsPending = 0;
+
+ this.getIdForBrowser = driver.getIdForBrowser.bind(driver);
+ this.updateIdForBrowser = driver.updateIdForBrowser.bind(driver);
+ }
+
+ /**
+ * Returns the content browser for the currently selected tab.
+ * If there is no tab selected, null will be returned.
+ */
+ get contentBrowser() {
+ if (this.tab) {
+ return browser.getBrowserForTab(this.tab);
+ } else if (
+ this.tabBrowser &&
+ this.driver.isReftestBrowser(this.tabBrowser)
+ ) {
+ return this.tabBrowser;
+ }
+
+ return null;
+ }
+
+ get messageManager() {
+ if (this.contentBrowser) {
+ return this.contentBrowser.messageManager;
+ }
+
+ return null;
+ }
+
+ /**
+ * Checks if the browsing context has been discarded.
+ *
+ * The browsing context will have been discarded if the content
+ * browser, represented by the <code>&lt;xul:browser&gt;</code>,
+ * has been detached.
+ *
+ * @return {boolean}
+ * True if browsing context has been discarded, false otherwise.
+ */
+ get closed() {
+ return this.contentBrowser === null;
+ }
+
+ /**
+ * The current frame ID is managed per browser element on desktop in
+ * case the ID needs to be refreshed. The currently selected window is
+ * identified by a tab.
+ */
+ get curFrameId() {
+ let rv = null;
+ if (this.tab || this.driver.isReftestBrowser(this.contentBrowser)) {
+ rv = this.getIdForBrowser(this.contentBrowser);
+ }
+ return rv;
+ }
+
+ /**
+ * Gets the position and dimensions of the top-level browsing context.
+ *
+ * @return {Map.<string, number>}
+ * Object with |x|, |y|, |width|, and |height| properties.
+ */
+ get rect() {
+ return {
+ x: this.window.screenX,
+ y: this.window.screenY,
+ width: this.window.outerWidth,
+ height: this.window.outerHeight,
+ };
+ }
+
+ /**
+ * Retrieves the current tabmodal UI object. According to the browser
+ * associated with the currently selected tab.
+ */
+ getTabModal() {
+ let br = this.contentBrowser;
+ if (!br.hasAttribute("tabmodalPromptShowing")) {
+ return null;
+ }
+
+ // The modal is a direct sibling of the browser element.
+ // See tabbrowser.xml's getTabModalPromptBox.
+ let modalElements = br.parentNode.getElementsByTagName("tabmodalprompt");
+
+ return br.tabModalPromptBox.getPrompt(modalElements[0]);
+ }
+
+ /**
+ * Close the current window.
+ *
+ * @return {Promise}
+ * A promise which is resolved when the current window has been closed.
+ */
+ async closeWindow() {
+ const destroyed = waitForObserverTopic("xul-window-destroyed", {
+ checkFn: () => this.window && this.window.closed,
+ });
+
+ this.window.close();
+
+ return destroyed;
+ }
+
+ /**
+ * Focus the current window.
+ *
+ * @return {Promise}
+ * A promise which is resolved when the current window has been focused.
+ */
+ async focusWindow() {
+ if (Services.focus.activeWindow != this.window) {
+ let activated = waitForEvent(this.window, "activate");
+ let focused = waitForEvent(this.window, "focus", { capture: true });
+
+ this.window.focus();
+
+ await Promise.all([activated, focused]);
+ }
+ }
+
+ /**
+ * Open a new browser window.
+ *
+ * @return {Promise}
+ * A promise resolving to the newly created chrome window.
+ */
+ async openBrowserWindow(focus = false, isPrivate = false) {
+ switch (this.driver.appName) {
+ case "firefox":
+ // Open new browser window, and wait until it is fully loaded.
+ // Also wait for the window to be focused and activated to prevent a
+ // race condition when promptly focusing to the original window again.
+ const win = this.window.OpenBrowserWindow({ private: isPrivate });
+
+ const activated = waitForEvent(win, "activate");
+ const focused = waitForEvent(win, "focus", { capture: true });
+ const startup = waitForObserverTopic(
+ "browser-delayed-startup-finished",
+ {
+ checkFn: subject => subject == win,
+ }
+ );
+
+ win.focus();
+ await Promise.all([activated, focused, startup]);
+
+ // The new window shouldn't get focused. As such set the
+ // focus back to the opening window.
+ if (!focus) {
+ await this.focusWindow();
+ }
+
+ return win;
+
+ default:
+ throw new error.UnsupportedOperationError(
+ `openWindow() not supported in ${this.driver.appName}`
+ );
+ }
+ }
+
+ /**
+ * Close the current tab.
+ *
+ * @return {Promise}
+ * A promise which is resolved when the current tab has been closed.
+ *
+ * @throws UnsupportedOperationError
+ * If tab handling for the current application isn't supported.
+ */
+ closeTab() {
+ // If the current window is not a browser then close it directly. Do the
+ // same if only one remaining tab is open, or no tab selected at all.
+ if (
+ !this.tabBrowser ||
+ !this.tabBrowser.tabs ||
+ this.tabBrowser.tabs.length === 1 ||
+ !this.tab
+ ) {
+ return this.closeWindow();
+ }
+
+ let destroyed = new MessageManagerDestroyedPromise(this.messageManager);
+ let tabClosed;
+
+ switch (this.driver.appName) {
+ case "firefox":
+ tabClosed = waitForEvent(this.tab, "TabClose");
+ this.tabBrowser.removeTab(this.tab);
+ break;
+
+ default:
+ throw new error.UnsupportedOperationError(
+ `closeTab() not supported in ${this.driver.appName}`
+ );
+ }
+
+ return Promise.all([destroyed, tabClosed]);
+ }
+
+ /**
+ * Open a new tab in the currently selected chrome window.
+ */
+ async openTab(focus = false) {
+ let tab = null;
+
+ switch (this.driver.appName) {
+ case "firefox":
+ const opened = waitForEvent(this.window, "TabOpen");
+ this.window.BrowserOpenTab();
+ await opened;
+
+ tab = this.tabBrowser.selectedTab;
+
+ // The new tab is always selected by default. If focus is not wanted,
+ // the previously tab needs to be selected again.
+ if (!focus) {
+ this.tabBrowser.selectedTab = this.tab;
+ }
+
+ break;
+
+ default:
+ throw new error.UnsupportedOperationError(
+ `openTab() not supported in ${this.driver.appName}`
+ );
+ }
+
+ return tab;
+ }
+
+ /**
+ * Set the current tab.
+ *
+ * @param {number=} index
+ * Tab index to switch to. If the parameter is undefined,
+ * the currently selected tab will be used.
+ * @param {ChromeWindow=} window
+ * Switch to this window before selecting the tab.
+ * @param {boolean=} focus
+ * A boolean value which determins whether to focus
+ * the window. Defaults to true.
+ *
+ * @return {Tab}
+ * The selected tab.
+ *
+ * @throws UnsupportedOperationError
+ * If tab handling for the current application isn't supported.
+ */
+ async switchToTab(index, window = undefined, focus = true) {
+ let currentTab = this.tabBrowser.selectedTab;
+
+ if (window) {
+ this.window = window;
+ this.tabBrowser = browser.getTabBrowser(this.window);
+ }
+
+ if (!this.tabBrowser) {
+ return null;
+ }
+
+ if (typeof index == "undefined") {
+ this.tab = this.tabBrowser.selectedTab;
+ } else {
+ this.tab = this.tabBrowser.tabs[index];
+ }
+
+ if (focus && this.tab != currentTab) {
+ const tabSelected = waitForEvent(this.window, "TabSelect");
+ this.tabBrowser.selectedTab = this.tab;
+ await tabSelected;
+ }
+
+ // TODO(ato): Currently tied to curBrowser, but should be moved to
+ // WebElement when introduced by https://bugzil.la/1400256.
+ this.eventObserver = new WebElementEventTarget(this.messageManager);
+
+ return this.tab;
+ }
+
+ /**
+ * Registers a new frame, and sets its current frame id to this frame
+ * if it is not already assigned, and if a) we already have a session
+ * or b) we're starting a new session and it is the right start frame.
+ *
+ * @param {xul:browser} target
+ * The <xul:browser> that was the target of the originating message.
+ */
+ register(target) {
+ if (!this.tabBrowser) {
+ return;
+ }
+
+ // If we're setting up a new session on Firefox, we only process the
+ // registration for this frame if it belongs to the current tab.
+ if (!this.tab) {
+ this.switchToTab();
+ }
+
+ if (target === this.contentBrowser) {
+ // Note that browsing contexts can be swapped during navigation in which
+ // case this id would no longer match the target. See Bug 1680479.
+ const uid = target.browsingContext.id;
+ this.updateIdForBrowser(this.contentBrowser, uid);
+ }
+ }
+};
+
+/**
+ * Marionette representation of the {@link ChromeWindow} window state.
+ *
+ * @enum {string}
+ */
+const WindowState = {
+ Maximized: "maximized",
+ Minimized: "minimized",
+ Normal: "normal",
+ Fullscreen: "fullscreen",
+
+ /**
+ * Converts {@link nsIDOMChromeWindow.windowState} to WindowState.
+ *
+ * @param {number} windowState
+ * Attribute from {@link nsIDOMChromeWindow.windowState}.
+ *
+ * @return {WindowState}
+ * JSON representation.
+ *
+ * @throws {TypeError}
+ * If <var>windowState</var> was unknown.
+ */
+ from(windowState) {
+ switch (windowState) {
+ case 1:
+ return WindowState.Maximized;
+
+ case 2:
+ return WindowState.Minimized;
+
+ case 3:
+ return WindowState.Normal;
+
+ case 4:
+ return WindowState.Fullscreen;
+
+ default:
+ throw new TypeError(`Unknown window state: ${windowState}`);
+ }
+ },
+};
+this.WindowState = WindowState;
diff --git a/testing/marionette/capabilities.js b/testing/marionette/capabilities.js
new file mode 100644
index 0000000000..c7583c1baf
--- /dev/null
+++ b/testing/marionette/capabilities.js
@@ -0,0 +1,715 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "Capabilities",
+ "PageLoadStrategy",
+ "Proxy",
+ "Timeouts",
+ "UnhandledPromptBehavior",
+];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ Preferences: "resource://gre/modules/Preferences.jsm",
+
+ assert: "chrome://marionette/content/assert.js",
+ error: "chrome://marionette/content/error.js",
+ Log: "chrome://marionette/content/log.js",
+ pprint: "chrome://marionette/content/format.js",
+});
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
+
+XPCOMUtils.defineLazyGetter(this, "appinfo", () => {
+ // Enable testing this module, as Services.appinfo.* is not available
+ // in xpcshell tests.
+ const appinfo = { name: "<missing>", version: "<missing>" };
+ try {
+ appinfo.name = Services.appinfo.name.toLowerCase();
+ } catch (e) {}
+ try {
+ appinfo.version = Services.appinfo.version;
+ } catch (e) {}
+
+ return appinfo;
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+
+XPCOMUtils.defineLazyGetter(this, "remoteAgent", () => {
+ // The Remote Agent is currently not available on Android, and all
+ // release channels (bug 1606604),
+ try {
+ return Cc["@mozilla.org/remote/agent;1"].createInstance(Ci.nsIRemoteAgent);
+ } catch (e) {
+ logger.debug("Remote agent not available for this build and platform");
+ return null;
+ }
+});
+
+/** Representation of WebDriver session timeouts. */
+class Timeouts {
+ constructor() {
+ // disabled
+ this.implicit = 0;
+ // five mintues
+ this.pageLoad = 300000;
+ // 30 seconds
+ this.script = 30000;
+ }
+
+ toString() {
+ return "[object Timeouts]";
+ }
+
+ /** Marshals timeout durations to a JSON Object. */
+ toJSON() {
+ return {
+ implicit: this.implicit,
+ pageLoad: this.pageLoad,
+ script: this.script,
+ };
+ }
+
+ static fromJSON(json) {
+ assert.object(
+ json,
+ pprint`Expected "timeouts" to be an object, got ${json}`
+ );
+ let t = new Timeouts();
+
+ for (let [type, ms] of Object.entries(json)) {
+ switch (type) {
+ case "implicit":
+ t.implicit = assert.positiveInteger(
+ ms,
+ pprint`Expected ${type} to be a positive integer, got ${ms}`
+ );
+ break;
+
+ case "script":
+ if (ms !== null) {
+ assert.positiveInteger(
+ ms,
+ pprint`Expected ${type} to be a positive integer, got ${ms}`
+ );
+ }
+ t.script = ms;
+ break;
+
+ case "pageLoad":
+ t.pageLoad = assert.positiveInteger(
+ ms,
+ pprint`Expected ${type} to be a positive integer, got ${ms}`
+ );
+ break;
+
+ default:
+ throw new error.InvalidArgumentError("Unrecognised timeout: " + type);
+ }
+ }
+
+ return t;
+ }
+}
+
+/**
+ * Enum of page loading strategies.
+ *
+ * @enum
+ */
+const PageLoadStrategy = {
+ /** No page load strategy. Navigation will return immediately. */
+ None: "none",
+ /**
+ * Eager, causing navigation to complete when the document reaches
+ * the <code>interactive</code> ready state.
+ */
+ Eager: "eager",
+ /**
+ * Normal, causing navigation to return when the document reaches the
+ * <code>complete</code> ready state.
+ */
+ Normal: "normal",
+};
+
+/** Proxy configuration object representation. */
+class Proxy {
+ /** @class */
+ constructor() {
+ this.proxyType = null;
+ this.ftpProxy = null;
+ this.ftpProxyPort = null;
+ this.httpProxy = null;
+ this.httpProxyPort = null;
+ this.noProxy = null;
+ this.sslProxy = null;
+ this.sslProxyPort = null;
+ this.socksProxy = null;
+ this.socksProxyPort = null;
+ this.socksVersion = null;
+ this.proxyAutoconfigUrl = null;
+ }
+
+ /**
+ * Sets Firefox proxy settings.
+ *
+ * @return {boolean}
+ * True if proxy settings were updated as a result of calling this
+ * function, or false indicating that this function acted as
+ * a no-op.
+ */
+ init() {
+ switch (this.proxyType) {
+ case "autodetect":
+ Preferences.set("network.proxy.type", 4);
+ return true;
+
+ case "direct":
+ Preferences.set("network.proxy.type", 0);
+ return true;
+
+ case "manual":
+ Preferences.set("network.proxy.type", 1);
+
+ if (this.ftpProxy) {
+ Preferences.set("network.proxy.ftp", this.ftpProxy);
+ if (Number.isInteger(this.ftpProxyPort)) {
+ Preferences.set("network.proxy.ftp_port", this.ftpProxyPort);
+ }
+ }
+
+ if (this.httpProxy) {
+ Preferences.set("network.proxy.http", this.httpProxy);
+ if (Number.isInteger(this.httpProxyPort)) {
+ Preferences.set("network.proxy.http_port", this.httpProxyPort);
+ }
+ }
+
+ if (this.sslProxy) {
+ Preferences.set("network.proxy.ssl", this.sslProxy);
+ if (Number.isInteger(this.sslProxyPort)) {
+ Preferences.set("network.proxy.ssl_port", this.sslProxyPort);
+ }
+ }
+
+ if (this.socksProxy) {
+ Preferences.set("network.proxy.socks", this.socksProxy);
+ if (Number.isInteger(this.socksProxyPort)) {
+ Preferences.set("network.proxy.socks_port", this.socksProxyPort);
+ }
+ if (this.socksVersion) {
+ Preferences.set("network.proxy.socks_version", this.socksVersion);
+ }
+ }
+
+ if (this.noProxy) {
+ Preferences.set(
+ "network.proxy.no_proxies_on",
+ this.noProxy.join(", ")
+ );
+ }
+ return true;
+
+ case "pac":
+ Preferences.set("network.proxy.type", 2);
+ Preferences.set(
+ "network.proxy.autoconfig_url",
+ this.proxyAutoconfigUrl
+ );
+ return true;
+
+ case "system":
+ Preferences.set("network.proxy.type", 5);
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * @param {Object.<string, ?>} json
+ * JSON Object to unmarshal.
+ *
+ * @throws {InvalidArgumentError}
+ * When proxy configuration is invalid.
+ */
+ static fromJSON(json) {
+ function stripBracketsFromIpv6Hostname(hostname) {
+ return hostname.includes(":")
+ ? hostname.replace(/[\[\]]/g, "")
+ : hostname;
+ }
+
+ // Parse hostname and optional port from host
+ function fromHost(scheme, host) {
+ assert.string(
+ host,
+ pprint`Expected proxy "host" to be a string, got ${host}`
+ );
+
+ if (host.includes("://")) {
+ throw new error.InvalidArgumentError(`${host} contains a scheme`);
+ }
+
+ let url;
+ try {
+ // To parse the host a scheme has to be added temporarily.
+ // If the returned value for the port is an empty string it
+ // could mean no port or the default port for this scheme was
+ // specified. In such a case parse again with a different
+ // scheme to ensure we filter out the default port.
+ url = new URL("http://" + host);
+ if (url.port == "") {
+ url = new URL("https://" + host);
+ }
+ } catch (e) {
+ throw new error.InvalidArgumentError(e.message);
+ }
+
+ let hostname = stripBracketsFromIpv6Hostname(url.hostname);
+
+ // If the port hasn't been set, use the default port of
+ // the selected scheme (except for socks which doesn't have one).
+ let port = parseInt(url.port);
+ if (!Number.isInteger(port)) {
+ if (scheme === "socks") {
+ port = null;
+ } else {
+ port = Services.io.getProtocolHandler(scheme).defaultPort;
+ }
+ }
+
+ if (
+ url.username != "" ||
+ url.password != "" ||
+ url.pathname != "/" ||
+ url.search != "" ||
+ url.hash != ""
+ ) {
+ throw new error.InvalidArgumentError(
+ `${host} was not of the form host[:port]`
+ );
+ }
+
+ return [hostname, port];
+ }
+
+ let p = new Proxy();
+ if (typeof json == "undefined" || json === null) {
+ return p;
+ }
+
+ assert.object(json, pprint`Expected "proxy" to be an object, got ${json}`);
+
+ assert.in(
+ "proxyType",
+ json,
+ pprint`Expected "proxyType" in "proxy" object, got ${json}`
+ );
+ p.proxyType = assert.string(
+ json.proxyType,
+ pprint`Expected "proxyType" to be a string, got ${json.proxyType}`
+ );
+
+ switch (p.proxyType) {
+ case "autodetect":
+ case "direct":
+ case "system":
+ break;
+
+ case "pac":
+ p.proxyAutoconfigUrl = assert.string(
+ json.proxyAutoconfigUrl,
+ `Expected "proxyAutoconfigUrl" to be a string, ` +
+ pprint`got ${json.proxyAutoconfigUrl}`
+ );
+ break;
+
+ case "manual":
+ if (typeof json.ftpProxy != "undefined") {
+ [p.ftpProxy, p.ftpProxyPort] = fromHost("ftp", json.ftpProxy);
+ }
+ if (typeof json.httpProxy != "undefined") {
+ [p.httpProxy, p.httpProxyPort] = fromHost("http", json.httpProxy);
+ }
+ if (typeof json.sslProxy != "undefined") {
+ [p.sslProxy, p.sslProxyPort] = fromHost("https", json.sslProxy);
+ }
+ if (typeof json.socksProxy != "undefined") {
+ [p.socksProxy, p.socksProxyPort] = fromHost("socks", json.socksProxy);
+ p.socksVersion = assert.positiveInteger(
+ json.socksVersion,
+ pprint`Expected "socksVersion" to be a positive integer, got ${json.socksVersion}`
+ );
+ }
+ if (typeof json.noProxy != "undefined") {
+ let entries = assert.array(
+ json.noProxy,
+ pprint`Expected "noProxy" to be an array, got ${json.noProxy}`
+ );
+ p.noProxy = entries.map(entry => {
+ assert.string(
+ entry,
+ pprint`Expected "noProxy" entry to be a string, got ${entry}`
+ );
+ return stripBracketsFromIpv6Hostname(entry);
+ });
+ }
+ break;
+
+ default:
+ throw new error.InvalidArgumentError(
+ `Invalid type of proxy: ${p.proxyType}`
+ );
+ }
+
+ return p;
+ }
+
+ /**
+ * @return {Object.<string, (number|string)>}
+ * JSON serialisation of proxy object.
+ */
+ toJSON() {
+ function addBracketsToIpv6Hostname(hostname) {
+ return hostname.includes(":") ? `[${hostname}]` : hostname;
+ }
+
+ function toHost(hostname, port) {
+ if (!hostname) {
+ return null;
+ }
+
+ // Add brackets around IPv6 addresses
+ hostname = addBracketsToIpv6Hostname(hostname);
+
+ if (port != null) {
+ return `${hostname}:${port}`;
+ }
+
+ return hostname;
+ }
+
+ let excludes = this.noProxy;
+ if (excludes) {
+ excludes = excludes.map(addBracketsToIpv6Hostname);
+ }
+
+ return marshal({
+ proxyType: this.proxyType,
+ ftpProxy: toHost(this.ftpProxy, this.ftpProxyPort),
+ httpProxy: toHost(this.httpProxy, this.httpProxyPort),
+ noProxy: excludes,
+ sslProxy: toHost(this.sslProxy, this.sslProxyPort),
+ socksProxy: toHost(this.socksProxy, this.socksProxyPort),
+ socksVersion: this.socksVersion,
+ proxyAutoconfigUrl: this.proxyAutoconfigUrl,
+ });
+ }
+
+ toString() {
+ return "[object Proxy]";
+ }
+}
+
+/**
+ * Enum of unhandled prompt behavior.
+ *
+ * @enum
+ */
+const UnhandledPromptBehavior = {
+ /** All simple dialogs encountered should be accepted. */
+ Accept: "accept",
+ /**
+ * All simple dialogs encountered should be accepted, and an error
+ * returned that the dialog was handled.
+ */
+ AcceptAndNotify: "accept and notify",
+ /** All simple dialogs encountered should be dismissed. */
+ Dismiss: "dismiss",
+ /**
+ * All simple dialogs encountered should be dismissed, and an error
+ * returned that the dialog was handled.
+ */
+ DismissAndNotify: "dismiss and notify",
+ /** All simple dialogs encountered should be left to the user to handle. */
+ Ignore: "ignore",
+};
+
+/** WebDriver session capabilities representation. */
+class Capabilities extends Map {
+ /** @class */
+ constructor() {
+ super([
+ // webdriver
+ ["browserName", getWebDriverBrowserName()],
+ ["browserVersion", appinfo.version],
+ ["platformName", getWebDriverPlatformName()],
+ ["platformVersion", Services.sysinfo.getProperty("version")],
+ ["acceptInsecureCerts", false],
+ ["pageLoadStrategy", PageLoadStrategy.Normal],
+ ["proxy", new Proxy()],
+ ["setWindowRect", !Services.androidBridge],
+ ["timeouts", new Timeouts()],
+ ["strictFileInteractability", false],
+ ["unhandledPromptBehavior", UnhandledPromptBehavior.DismissAndNotify],
+
+ // features
+ ["rotatable", appinfo.name == "B2G"],
+
+ // proprietary
+ ["moz:accessibilityChecks", false],
+ ["moz:buildID", Services.appinfo.appBuildID],
+ ["moz:debuggerAddress", remoteAgent?.debuggerAddress || null],
+ [
+ "moz:headless",
+ Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo).isHeadless,
+ ],
+ ["moz:processID", Services.appinfo.processID],
+ ["moz:profile", maybeProfile()],
+ [
+ "moz:shutdownTimeout",
+ Services.prefs.getIntPref("toolkit.asyncshutdown.crash_timeout"),
+ ],
+ ["moz:useNonSpecCompliantPointerOrigin", false],
+ ["moz:webdriverClick", true],
+ ]);
+ }
+
+ /**
+ * @param {string} key
+ * Capability key.
+ * @param {(string|number|boolean)} value
+ * JSON-safe capability value.
+ */
+ set(key, value) {
+ if (key === "timeouts" && !(value instanceof Timeouts)) {
+ throw new TypeError();
+ } else if (key === "proxy" && !(value instanceof Proxy)) {
+ throw new TypeError();
+ }
+
+ return super.set(key, value);
+ }
+
+ toString() {
+ return "[object Capabilities]";
+ }
+
+ /**
+ * JSON serialisation of capabilities object.
+ *
+ * @return {Object.<string, ?>}
+ */
+ toJSON() {
+ let marshalled = marshal(this);
+ marshalled.timeouts = super.get("timeouts");
+ return marshalled;
+ }
+
+ /**
+ * Unmarshal a JSON object representation of WebDriver capabilities.
+ *
+ * @param {Object.<string, *>=} json
+ * WebDriver capabilities.
+ *
+ * @return {Capabilities}
+ * Internal representation of WebDriver capabilities.
+ */
+ static fromJSON(json) {
+ if (typeof json == "undefined" || json === null) {
+ json = {};
+ }
+ assert.object(
+ json,
+ pprint`Expected "capabilities" to be an object, got ${json}"`
+ );
+
+ return Capabilities.match_(json);
+ }
+
+ // Matches capabilities as described by WebDriver.
+ static match_(json = {}) {
+ let matched = new Capabilities();
+
+ for (let [k, v] of Object.entries(json)) {
+ switch (k) {
+ case "acceptInsecureCerts":
+ assert.boolean(v, pprint`Expected ${k} to be a boolean, got ${v}`);
+ break;
+
+ case "pageLoadStrategy":
+ assert.string(v, pprint`Expected ${k} to be a string, got ${v}`);
+ if (!Object.values(PageLoadStrategy).includes(v)) {
+ throw new error.InvalidArgumentError(
+ "Unknown page load strategy: " + v
+ );
+ }
+ break;
+
+ case "proxy":
+ v = Proxy.fromJSON(v);
+ break;
+
+ case "setWindowRect":
+ assert.boolean(v, pprint`Expected ${k} to be boolean, got ${v}`);
+ if (!Services.androidBridge && !v) {
+ throw new error.InvalidArgumentError(
+ "setWindowRect cannot be disabled"
+ );
+ } else if (Services.androidBridge && v) {
+ throw new error.InvalidArgumentError(
+ "setWindowRect is only supported on desktop"
+ );
+ }
+ break;
+
+ case "timeouts":
+ v = Timeouts.fromJSON(v);
+ break;
+
+ case "strictFileInteractability":
+ v = assert.boolean(v);
+ break;
+
+ case "unhandledPromptBehavior":
+ assert.string(v, pprint`Expected ${k} to be a string, got ${v}`);
+ if (!Object.values(UnhandledPromptBehavior).includes(v)) {
+ throw new error.InvalidArgumentError(
+ `Unknown unhandled prompt behavior: ${v}`
+ );
+ }
+ break;
+
+ case "moz:accessibilityChecks":
+ assert.boolean(v, pprint`Expected ${k} to be boolean, got ${v}`);
+ break;
+
+ case "moz:useNonSpecCompliantPointerOrigin":
+ assert.boolean(v, pprint`Expected ${k} to be boolean, got ${v}`);
+ break;
+
+ case "moz:webdriverClick":
+ assert.boolean(v, pprint`Expected ${k} to be boolean, got ${v}`);
+ break;
+
+ // Don't set the value because it's only used to return the address
+ // of the Remote Agent's debugger (HTTP server).
+ case "moz:debuggerAddress":
+ continue;
+ }
+
+ matched.set(k, v);
+ }
+
+ return matched;
+ }
+}
+
+this.Capabilities = Capabilities;
+this.PageLoadStrategy = PageLoadStrategy;
+this.Proxy = Proxy;
+this.Timeouts = Timeouts;
+this.UnhandledPromptBehavior = UnhandledPromptBehavior;
+
+function getWebDriverBrowserName() {
+ // Similar to chromedriver which reports "chrome" as browser name for all
+ // WebView apps, we will report "firefox" for all GeckoView apps.
+ if (Services.androidBridge) {
+ return "firefox";
+ }
+
+ return appinfo.name;
+}
+
+function getWebDriverPlatformName() {
+ let name = Services.sysinfo.getProperty("name");
+
+ if (Services.androidBridge) {
+ return "android";
+ }
+
+ switch (name) {
+ case "Windows_NT":
+ return "windows";
+
+ case "Darwin":
+ return "mac";
+
+ default:
+ return name.toLowerCase();
+ }
+}
+
+// Specialisation of |JSON.stringify| that produces JSON-safe object
+// literals, dropping empty objects and entries which values are undefined
+// or null. Objects are allowed to produce their own JSON representations
+// by implementing a |toJSON| function.
+function marshal(obj) {
+ let rv = Object.create(null);
+
+ function* iter(mapOrObject) {
+ if (mapOrObject instanceof Map) {
+ for (const [k, v] of mapOrObject) {
+ yield [k, v];
+ }
+ } else {
+ for (const k of Object.keys(mapOrObject)) {
+ yield [k, mapOrObject[k]];
+ }
+ }
+ }
+
+ for (let [k, v] of iter(obj)) {
+ // Skip empty values when serialising to JSON.
+ if (typeof v == "undefined" || v === null) {
+ continue;
+ }
+
+ // Recursively marshal objects that are able to produce their own
+ // JSON representation.
+ if (typeof v.toJSON == "function") {
+ v = marshal(v.toJSON());
+
+ // Or do the same for object literals.
+ } else if (isObject(v)) {
+ v = marshal(v);
+ }
+
+ // And finally drop (possibly marshaled) objects which have no
+ // entries.
+ if (!isObjectEmpty(v)) {
+ rv[k] = v;
+ }
+ }
+
+ return rv;
+}
+
+function isObject(obj) {
+ return Object.prototype.toString.call(obj) == "[object Object]";
+}
+
+function isObjectEmpty(obj) {
+ return isObject(obj) && Object.keys(obj).length === 0;
+}
+
+// Services.dirsvc is not accessible from content frame scripts,
+// but we should not panic about that.
+function maybeProfile() {
+ try {
+ return Services.dirsvc.get("ProfD", Ci.nsIFile).path;
+ } catch (e) {
+ return "<protected>";
+ }
+}
diff --git a/testing/marionette/capture.js b/testing/marionette/capture.js
new file mode 100644
index 0000000000..6746baa56c
--- /dev/null
+++ b/testing/marionette/capture.js
@@ -0,0 +1,205 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["capture"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ Log: "chrome://marionette/content/log.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+XPCOMUtils.defineLazyGlobalGetters(this, ["crypto"]);
+
+const CONTEXT_2D = "2d";
+const BG_COLOUR = "rgb(255,255,255)";
+const MAX_CANVAS_DIMENSION = 32767;
+const MAX_CANVAS_AREA = 472907776;
+const PNG_MIME = "image/png";
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * Provides primitives to capture screenshots.
+ *
+ * @namespace
+ */
+this.capture = {};
+
+capture.Format = {
+ Base64: 0,
+ Hash: 1,
+};
+
+/**
+ * Draw a rectangle off the framebuffer.
+ *
+ * @param {DOMWindow} win
+ * The DOM window used for the framebuffer, and providing the interfaces
+ * for creating an HTMLCanvasElement.
+ * @param {number} left
+ * The left, X axis offset of the rectangle.
+ * @param {number} top
+ * The top, Y axis offset of the rectangle.
+ * @param {number} width
+ * The width dimension of the rectangle to paint.
+ * @param {number} height
+ * The height dimension of the rectangle to paint.
+ * @param {HTMLCanvasElement=} canvas
+ * Optional canvas to reuse for the screenshot.
+ * @param {number=} flags
+ * Optional integer representing flags to pass to drawWindow; these
+ * are defined on CanvasRenderingContext2D.
+ * @param {number=} dX
+ * Horizontal offset between the browser window and content area. Defaults to 0.
+ * @param {number=} dY
+ * Vertical offset between the browser window and content area. Defaults to 0.
+ * @param {boolean=} readback
+ * If true, read back a snapshot of the pixel data currently in the
+ * compositor/window. Defaults to false.
+ *
+ * @return {HTMLCanvasElement}
+ * The canvas on which the selection from the window's framebuffer
+ * has been painted on.
+ */
+capture.canvas = async function(
+ win,
+ browsingContext,
+ left,
+ top,
+ width,
+ height,
+ { canvas = null, flags = null, dX = 0, dY = 0, readback = false } = {}
+) {
+ const scale = win.devicePixelRatio;
+
+ let canvasHeight = height * scale;
+ let canvasWidth = width * scale;
+
+ // Cap the screenshot size for width and height at 2^16 pixels,
+ // which is the maximum allowed canvas size. Higher dimensions will
+ // trigger exceptions in Gecko.
+ if (canvasWidth > MAX_CANVAS_DIMENSION) {
+ logger.warn(
+ "Limiting screen capture width to maximum allowed " +
+ MAX_CANVAS_DIMENSION +
+ " pixels"
+ );
+ width = Math.floor(MAX_CANVAS_DIMENSION / scale);
+ canvasWidth = width * scale;
+ }
+
+ if (canvasHeight > MAX_CANVAS_DIMENSION) {
+ logger.warn(
+ "Limiting screen capture height to maximum allowed " +
+ MAX_CANVAS_DIMENSION +
+ " pixels"
+ );
+ height = Math.floor(MAX_CANVAS_DIMENSION / scale);
+ canvasHeight = height * scale;
+ }
+
+ // If the area is larger, reduce the height to keep the full width.
+ if (canvasWidth * canvasHeight > MAX_CANVAS_AREA) {
+ logger.warn(
+ "Limiting screen capture area to maximum allowed " +
+ MAX_CANVAS_AREA +
+ " pixels"
+ );
+ height = Math.floor(MAX_CANVAS_AREA / (canvasWidth * scale));
+ canvasHeight = height * scale;
+ }
+
+ if (canvas === null) {
+ canvas = win.document.createElementNS(XHTML_NS, "canvas");
+ canvas.width = canvasWidth;
+ canvas.height = canvasHeight;
+ }
+
+ const ctx = canvas.getContext(CONTEXT_2D);
+
+ if (readback) {
+ if (flags === null) {
+ flags =
+ ctx.DRAWWINDOW_DRAW_CARET |
+ ctx.DRAWWINDOW_DRAW_VIEW |
+ ctx.DRAWWINDOW_USE_WIDGET_LAYERS;
+ }
+
+ // drawWindow doesn't take scaling into account.
+ ctx.scale(scale, scale);
+ ctx.drawWindow(win, left + dX, top + dY, width, height, BG_COLOUR, flags);
+ } else {
+ let rect = new DOMRect(left, top, width, height);
+ let snapshot = await browsingContext.currentWindowGlobal.drawSnapshot(
+ rect,
+ scale,
+ BG_COLOUR
+ );
+
+ ctx.drawImage(snapshot, 0, 0);
+
+ // Bug 1574935 - Huge dimensions can trigger an OOM because multiple copies
+ // of the bitmap will exist in memory. Force the removal of the snapshot
+ // because it is no longer needed.
+ snapshot.close();
+ }
+
+ return canvas;
+};
+
+/**
+ * Encode the contents of an HTMLCanvasElement to a Base64 encoded string.
+ *
+ * @param {HTMLCanvasElement} canvas
+ * The canvas to encode.
+ *
+ * @return {string}
+ * A Base64 encoded string.
+ */
+capture.toBase64 = function(canvas) {
+ let u = canvas.toDataURL(PNG_MIME);
+ return u.substring(u.indexOf(",") + 1);
+};
+
+/**
+ * Hash the contents of an HTMLCanvasElement to a SHA-256 hex digest.
+ *
+ * @param {HTMLCanvasElement} canvas
+ * The canvas to encode.
+ *
+ * @return {string}
+ * A hex digest of the SHA-256 hash of the base64 encoded string.
+ */
+capture.toHash = function(canvas) {
+ let u = capture.toBase64(canvas);
+ let buffer = new TextEncoder("utf-8").encode(u);
+ return crypto.subtle.digest("SHA-256", buffer).then(hash => hex(hash));
+};
+
+/**
+ * Convert buffer into to hex.
+ *
+ * @param {ArrayBuffer} buffer
+ * The buffer containing the data to convert to hex.
+ *
+ * @return {string}
+ * A hex digest of the input buffer.
+ */
+function hex(buffer) {
+ let hexCodes = [];
+ let view = new DataView(buffer);
+ for (let i = 0; i < view.byteLength; i += 4) {
+ let value = view.getUint32(i);
+ let stringValue = value.toString(16);
+ let padding = "00000000";
+ let paddedValue = (padding + stringValue).slice(-padding.length);
+ hexCodes.push(paddedValue);
+ }
+ return hexCodes.join("");
+}
diff --git a/testing/marionette/cert.js b/testing/marionette/cert.js
new file mode 100644
index 0000000000..2c0ca554a7
--- /dev/null
+++ b/testing/marionette/cert.js
@@ -0,0 +1,66 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["allowAllCerts"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ Preferences: "resource://gre/modules/Preferences.jsm",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "sss",
+ "@mozilla.org/ssservice;1",
+ "nsISiteSecurityService"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "certOverrideService",
+ "@mozilla.org/security/certoverride;1",
+ "nsICertOverrideService"
+);
+
+const CERT_PINNING_ENFORCEMENT_PREF = "security.cert_pinning.enforcement_level";
+const HSTS_PRELOAD_LIST_PREF = "network.stricttransportsecurity.preloadlist";
+
+/** @namespace */
+this.allowAllCerts = {};
+
+/**
+ * Disable all security check and allow all certs.
+ */
+allowAllCerts.enable = function() {
+ // make it possible to register certificate overrides for domains
+ // that use HSTS or HPKP
+ Preferences.set(HSTS_PRELOAD_LIST_PREF, false);
+ Preferences.set(CERT_PINNING_ENFORCEMENT_PREF, 0);
+
+ certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData(
+ true
+ );
+};
+
+/**
+ * Enable all security check.
+ */
+allowAllCerts.disable = function() {
+ certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData(
+ false
+ );
+
+ Preferences.reset(HSTS_PRELOAD_LIST_PREF);
+ Preferences.reset(CERT_PINNING_ENFORCEMENT_PREF);
+
+ // clear collected HSTS and HPKP state
+ // through the site security service
+ sss.clearAll();
+ sss.clearPreloads();
+};
diff --git a/testing/marionette/chrome/test.xhtml b/testing/marionette/chrome/test.xhtml
new file mode 100644
index 0000000000..d878fb13b1
--- /dev/null
+++ b/testing/marionette/chrome/test.xhtml
@@ -0,0 +1,27 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+
+<!DOCTYPE window [
+]>
+<window id="winTest" title="Title Test" windowtype="Test Type"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <dialog id="dia"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+ <vbox id="things">
+ <checkbox id="testBox" label="box" />
+ <input xmlns="http://www.w3.org/1999/xhtml" id="textInput" size="6" value="test" label="input" />
+ <input xmlns="http://www.w3.org/1999/xhtml" id="textInput2" size="6" value="test" label="input" />
+ <input xmlns="http://www.w3.org/1999/xhtml" id="textInput3" class="asdf" size="6" value="test" label="input" />
+ </vbox>
+
+ <iframe id="iframe" name="iframename" src="chrome://marionette/content/test2.xhtml"/>
+ <iframe id="iframe" name="iframename" src="chrome://marionette/content/test_nested_iframe.xhtml"/>
+ <hbox id="testXulBox"/>
+ <browser id='aBrowser' src="chrome://marionette/content/test2.xhtml"/>
+ </dialog>
+</window>
diff --git a/testing/marionette/chrome/test2.xhtml b/testing/marionette/chrome/test2.xhtml
new file mode 100644
index 0000000000..d6b72dab45
--- /dev/null
+++ b/testing/marionette/chrome/test2.xhtml
@@ -0,0 +1,20 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE window [
+]>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+<dialog id="dia">
+
+ <vbox id="things">
+ <checkbox id="testBox" label="box" />
+ <input xmlns="http://www.w3.org/1999/xhtml" id="textInput" size="6" value="test" label="input" />
+ <input xmlns="http://www.w3.org/1999/xhtml" id="textInput2" size="6" value="test" label="input" />
+ <input xmlns="http://www.w3.org/1999/xhtml" id="textInput3" class="asdf" size="6" value="test" label="input" />
+ </vbox>
+
+</dialog>
+</window>
diff --git a/testing/marionette/chrome/test_dialog.dtd b/testing/marionette/chrome/test_dialog.dtd
new file mode 100644
index 0000000000..414cb0ee81
--- /dev/null
+++ b/testing/marionette/chrome/test_dialog.dtd
@@ -0,0 +1,7 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!ENTITY testDialog.title "Test Dialog">
+
+<!ENTITY settings.label "Settings">
diff --git a/testing/marionette/chrome/test_dialog.properties b/testing/marionette/chrome/test_dialog.properties
new file mode 100644
index 0000000000..ade7b6bde3
--- /dev/null
+++ b/testing/marionette/chrome/test_dialog.properties
@@ -0,0 +1,7 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+testDialog.title=Test Dialog
+
+settings.label=Settings
diff --git a/testing/marionette/chrome/test_dialog.xhtml b/testing/marionette/chrome/test_dialog.xhtml
new file mode 100644
index 0000000000..bd2d7bb75b
--- /dev/null
+++ b/testing/marionette/chrome/test_dialog.xhtml
@@ -0,0 +1,37 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE testdialog [
+<!ENTITY % dialogDTD SYSTEM "chrome://marionette/content/test_dialog.dtd" >
+%dialogDTD;
+]>
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ title="&testDialog.title;">
+<dialog id="testDialog"
+ buttons="accept,cancel">
+
+ <vbox flex="1" style="min-width: 300px; min-height: 500px;">
+ <label>&settings.label;</label>
+ <separator class="thin"/>
+ <richlistbox id="test-list" flex="1">
+ <richlistitem id="item-choose" orient="horizontal" selected="true">
+ <label id="choose-label" value="First Entry" flex="1"/>
+ <button id="choose-button" oncommand="" label="Choose..."/>
+ </richlistitem>
+ </richlistbox>
+ <separator class="thin"/>
+ <checkbox id="check-box" label="Test Mode 2" />
+ <hbox align="center">
+ <label id="text-box-label" control="text-box">Name:</label>
+ <input xmlns="http://www.w3.org/1999/xhtml" id="text-box" style="-moz-box-flex: 1;" />
+ </hbox>
+ </vbox>
+
+</dialog>
+</window>
diff --git a/testing/marionette/chrome/test_menupopup.xhtml b/testing/marionette/chrome/test_menupopup.xhtml
new file mode 100644
index 0000000000..5d8902f011
--- /dev/null
+++ b/testing/marionette/chrome/test_menupopup.xhtml
@@ -0,0 +1,30 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+
+<!DOCTYPE window [
+]>
+<window id="test-window" xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <popupset id="options-popupset">
+ <menupopup id="options-menupopup" position="before_end">
+ <menuitem id="option-enabled"
+ type="checkbox"
+ label="enabled"/>
+ <menuitem id="option-hidden"
+ type="checkbox"
+ label="hidden"
+ hidden="true"/>
+ <menuitem id="option-disabled"
+ type="checkbox"
+ label="disabled"
+ disabled="true"/>
+ </menupopup>
+ </popupset>
+ <hbox align="center" style="height: 300px;">
+ <button id="options-button"
+ popup="options-menupopup" label="button"/>
+ </hbox>
+</window>
diff --git a/testing/marionette/chrome/test_nested_iframe.xhtml b/testing/marionette/chrome/test_nested_iframe.xhtml
new file mode 100644
index 0000000000..1d0edcc65b
--- /dev/null
+++ b/testing/marionette/chrome/test_nested_iframe.xhtml
@@ -0,0 +1,9 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE window [
+]>
+
+ <iframe id="iframe" name="iframename" src="test2.xhtml"/>
diff --git a/testing/marionette/client/MANIFEST.in b/testing/marionette/client/MANIFEST.in
new file mode 100644
index 0000000000..cf628b039c
--- /dev/null
+++ b/testing/marionette/client/MANIFEST.in
@@ -0,0 +1,2 @@
+exclude MANIFEST.in
+include requirements.txt
diff --git a/testing/marionette/client/docs/Makefile b/testing/marionette/client/docs/Makefile
new file mode 100644
index 0000000000..f3d89d6d47
--- /dev/null
+++ b/testing/marionette/client/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/MarionettePythonClient.qhcp"
+ @echo "To view the help file:"
+ @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/MarionettePythonClient.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/MarionettePythonClient"
+ @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/MarionettePythonClient"
+ @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/marionette/client/docs/advanced/actions.rst b/testing/marionette/client/docs/advanced/actions.rst
new file mode 100644
index 0000000000..c767bdecdc
--- /dev/null
+++ b/testing/marionette/client/docs/advanced/actions.rst
@@ -0,0 +1,21 @@
+Actions
+=======
+
+.. py:currentmodule:: marionette_driver.marionette
+
+Action Sequences
+----------------
+
+:class:`Actions` are designed as a way to simulate user input like a keyboard
+or a pointer device as closely as possible. For multiple interactions an
+action sequence can be used::
+
+ element = marionette.find_element("id", "input")
+ element.click()
+
+ key_chain = self.marionette.actions.sequence("key", "keyboard1")
+ key_chain.send_keys("fooba").pause(100).key_down("r").perform()
+
+This will simulate entering "fooba" into the input field, waiting for 100ms,
+and pressing the key "r". The pause is optional in this case, but can be useful
+for simulating delays typical to a users behaviour.
diff --git a/testing/marionette/client/docs/advanced/debug.rst b/testing/marionette/client/docs/advanced/debug.rst
new file mode 100644
index 0000000000..895009ef7f
--- /dev/null
+++ b/testing/marionette/client/docs/advanced/debug.rst
@@ -0,0 +1,35 @@
+Debugging
+=========
+
+.. py:currentmodule:: marionette_driver.marionette
+
+Sometimes when working with Marionette you'll run into unexpected behaviour and
+need to do some debugging. This page outlines some of the Marionette methods
+that can be useful to you.
+
+Please note that the best tools for debugging are the `ones that ship with
+Gecko`_. This page doesn't describe how to use those with Marionette. Also see
+a related topic about `using the debugger with Marionette`_ on MDN.
+
+.. _ones that ship with Gecko: https://developer.mozilla.org/en-US/docs/Tools
+.. _using the debugger with Marionette: https://developer.mozilla.org/en-US/docs/Marionette/Debugging
+
+Seeing What's on the Page
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Sometimes it's difficult to tell what is actually on the page that is being
+manipulated. Either because it happens too fast, the window isn't big enough or
+you are manipulating a remote server! There are two methods that can help you
+out. The first is :func:`~Marionette.screenshot`::
+
+ marionette.screenshot() # takes screenshot of entire frame
+ elem = marionette.find_element(By.ID, 'some-div')
+ marionette.screenshot(elem) # takes a screenshot of only the given element
+
+Sometimes you just want to see the DOM layout. You can do this with the
+:attr:`~Marionette.page_source` property. Note that the page source depends on
+the context you are in::
+
+ print(marionette.page_source)
+ marionette.set_context('chrome')
+ print(marionette.page_source)
diff --git a/testing/marionette/client/docs/advanced/findelement.rst b/testing/marionette/client/docs/advanced/findelement.rst
new file mode 100644
index 0000000000..6f61fa5e25
--- /dev/null
+++ b/testing/marionette/client/docs/advanced/findelement.rst
@@ -0,0 +1,87 @@
+Finding Elements
+================
+.. py:currentmodule:: marionette_driver.marionette
+
+One of the most common and yet often most difficult tasks in Marionette is
+finding a DOM element on a webpage or in the chrome UI. Marionette provides
+several different search strategies to use when finding elements. All search
+strategies work with both :func:`~Marionette.find_element` and
+:func:`~Marionette.find_elements`, though some strategies are not implemented
+in chrome scope.
+
+In the event that more than one element is matched by the query,
+:func:`~Marionette.find_element` will only return the first element found. In
+the event that no elements are matched by the query,
+:func:`~Marionette.find_element` will raise `NoSuchElementException` while
+:func:`~Marionette.find_elements` will return an empty list.
+
+Search Strategies
+-----------------
+
+Search strategies are defined in the :class:`By` class::
+
+ from marionette_driver import By
+ print(By.ID)
+
+The strategies are:
+
+* `id` - The easiest way to find an element is to refer to its id directly::
+
+ container = client.find_element(By.ID, 'container')
+
+* `class name` - To find elements belonging to a certain class, use `class name`::
+
+ buttons = client.find_elements(By.CLASS_NAME, 'button')
+
+* `css selector` - It's also possible to find elements using a `css selector`_::
+
+ container_buttons = client.find_elements(By.CSS_SELECTOR, '#container .buttons')
+
+* `name` - Find elements by their name attribute (not implemented in chrome
+ scope)::
+
+ form = client.find_element(By.NAME, 'signup')
+
+* `tag name` - To find all the elements with a given tag, use `tag name`::
+
+ paragraphs = client.find_elements(By.TAG_NAME, 'p')
+
+* `link text` - A convenience strategy for finding link elements by their
+ innerHTML (not implemented in chrome scope)::
+
+ link = client.find_element(By.LINK_TEXT, 'Click me!')
+
+* `partial link text` - Same as `link text` except substrings of the innerHTML
+ are matched (not implemented in chrome scope)::
+
+ link = client.find_element(By.PARTIAL_LINK_TEXT, 'Clic')
+
+* `xpath` - Find elements using an xpath_ query::
+
+ elem = client.find_element(By.XPATH, './/*[@id="foobar"')
+
+.. _css selector: https://developer.mozilla.org/en-US/docs/Web/Guide/CSS/Getting_Started/Selectors
+.. _xpath: https://developer.mozilla.org/en-US/docs/Web/XPath
+
+
+
+Chaining Searches
+-----------------
+
+In addition to the methods on the Marionette object, HTMLElement objects also
+provide :func:`~HTMLElement.find_element` and :func:`~HTMLElement.find_elements`
+methods. The difference is that only child nodes of the element will be searched.
+Consider the following html snippet::
+
+ <div id="content">
+ <span id="main"></span>
+ </div>
+ <div id="footer"></div>
+
+Doing the following will work::
+
+ client.find_element(By.ID, 'container').find_element(By.ID, 'main')
+
+But this will raise a `NoSuchElementException`::
+
+ client.find_element(By.ID, 'container').find_element(By.ID, 'footer')
diff --git a/testing/marionette/client/docs/advanced/landing.rst b/testing/marionette/client/docs/advanced/landing.rst
new file mode 100644
index 0000000000..0a44de63d7
--- /dev/null
+++ b/testing/marionette/client/docs/advanced/landing.rst
@@ -0,0 +1,13 @@
+Advanced Topics
+===============
+
+Here are a collection of articles explaining some of the more complicated
+aspects of Marionette.
+
+.. toctree::
+ :maxdepth: 1
+
+ findelement
+ stale
+ actions
+ debug
diff --git a/testing/marionette/client/docs/advanced/stale.rst b/testing/marionette/client/docs/advanced/stale.rst
new file mode 100644
index 0000000000..885083993c
--- /dev/null
+++ b/testing/marionette/client/docs/advanced/stale.rst
@@ -0,0 +1,76 @@
+Dealing with Stale Elements
+===========================
+.. py:currentmodule:: marionette_driver.marionette
+
+Marionette does not keep a live representation of the DOM saved. All it can do
+is send commands to the Marionette server which queries the DOM on the client's
+behalf. References to elements are also not passed from server to client. A
+unique id is generated for each element that gets referenced and a mapping of
+id to element object is stored on the server. When commands such as
+:func:`~HTMLElement.click` are run, the client sends the element's id along
+with the command. The server looks up the proper DOM element in its reference
+table and executes the command on it.
+
+In practice this means that the DOM can change state and Marionette will never
+know until it sends another query. For example, look at the following HTML::
+
+ <head>
+ <script type=text/javascript>
+ function addDiv() {
+ var div = document.createElement("div");
+ document.getElementById("container").appendChild(div);
+ }
+ </script>
+ </head>
+
+ <body>
+ <div id="container">
+ </div>
+ <input id="button" type=button onclick="addDiv();">
+ </body>
+
+Care needs to be taken as the DOM is being modified after the page has loaded.
+The following code has a race condition::
+
+ button = client.find_element('id', 'button')
+ button.click()
+ assert len(client.find_elements('css selector', '#container div')) > 0
+
+
+Explicit Waiting and Expected Conditions
+----------------------------------------
+.. py:currentmodule:: marionette_driver
+
+To avoid the above scenario, manual synchronisation is needed. Waits are used
+to pause program execution until a given condition is true. This is a useful
+technique to employ when documents load new content or change after
+``Document.readyState``'s value changes to "complete".
+
+The :class:`Wait` helper class provided by Marionette avoids some of the
+caveats of ``time.sleep(n)``. It will return immediately once the provided
+condition evaluates to true.
+
+To avoid the race condition in the above example, one could do::
+
+ from marionette_driver import Wait
+
+ button = client.find_element('id', 'button')
+ button.click()
+
+ def find_divs():
+ return client.find_elements('css selector', '#container div')
+
+ divs = Wait(client).until(find_divs)
+ assert len(divs) > 0
+
+This avoids the race condition. Because finding elements is a common condition
+to wait for, it is built in to Marionette. Instead of the above, you could
+write::
+
+ from marionette_driver import Wait
+
+ button = client.find_element('id', 'button')
+ button.click()
+ assert len(Wait(client).until(expected.elements_present('css selector', '#container div'))) > 0
+
+For a full list of built-in conditions, see :mod:`~marionette_driver.expected`.
diff --git a/testing/marionette/client/docs/basics.rst b/testing/marionette/client/docs/basics.rst
new file mode 100644
index 0000000000..57f10cd69a
--- /dev/null
+++ b/testing/marionette/client/docs/basics.rst
@@ -0,0 +1,185 @@
+.. py:currentmodule:: marionette_driver.marionette
+
+Marionette Python Client
+========================
+
+The Marionette Python client library allows you to remotely control a
+Gecko-based browser or device which is running a Marionette_
+server. This includes Firefox Desktop and Firefox for Android.
+
+The Marionette server is built directly into Gecko and can be started by
+passing in a command line option to Gecko, or by using a Marionette-enabled
+build. The server listens for connections from various clients. Clients can
+then control Gecko by sending commands to the server.
+
+This is the official Python client for Marionette. There also exists a
+`NodeJS client`_ maintained by the Firefox OS automation team.
+
+.. _Marionette: https://developer.mozilla.org/en-US/docs/Marionette
+.. _NodeJS client: https://github.com/mozilla-b2g/gaia/tree/master/tests/jsmarionette
+
+Getting the Client
+------------------
+
+The Python client is officially supported. To install it, first make sure you
+have `pip installed`_ then run:
+
+.. parsed-literal::
+ pip install marionette_driver
+
+It's highly recommended to use virtualenv_ when installing Marionette to avoid
+package conflicts and other general nastiness.
+
+You should now be ready to start using Marionette. The best way to learn is to
+play around with it. Start a `Marionette-enabled instance of Firefox`_, fire up
+a python shell and follow along with the
+:doc:`interactive tutorial <interactive>`!
+
+.. _pip installed: https://pip.pypa.io/en/latest/installing.html
+.. _virtualenv: http://virtualenv.readthedocs.org/en/latest/
+.. _Marionette-enabled instance of Firefox: https://developer.mozilla.org/en-US/docs/Mozilla/QA/Marionette/Builds
+
+Using the Client for Testing
+----------------------------
+
+Please visit the `Marionette Tests`_ section on MDN for information regarding
+testing with Marionette.
+
+.. _Marionette Tests: https://developer.mozilla.org/en/Marionette/Tests
+
+Session Management
+------------------
+A session is a single instance of a Marionette client connected to a Marionette
+server. Before you can start executing commands, you need to start a session
+with :func:`start_session() <Marionette.start_session>`:
+
+.. parsed-literal::
+ from marionette_driver.marionette import Marionette
+
+ client = Marionette('127.0.0.1', port=2828)
+ client.start_session()
+
+This returns a session id and an object listing the capabilities of the
+Marionette server. For example, a server running on Firefox Desktop will
+have some features which a server running from Firefox Android won't.
+It's also possible to access the capabilities using the
+:attr:`~Marionette.session_capabilities` attribute. After finishing with a
+session, you can delete it with :func:`~Marionette.delete_session()`. Note that
+this will also happen automatically when the Marionette object is garbage
+collected.
+
+Context Management
+------------------
+Commands can only be executed in a single window, frame and scope at a time. In
+order to run commands elsewhere, it's necessary to explicitly switch to the
+appropriate context.
+
+Use :func:`~Marionette.switch_to_window` to execute commands in the context of a
+new window:
+
+.. parsed-literal::
+ original_window = client.current_window_handle
+ for handle in client.window_handles:
+ if handle != original_window:
+ client.switch_to_window(handle)
+ print("Switched to window with '{}' loaded.".format(client.get_url()))
+ client.switch_to_window(original_window)
+
+Similarly, use :func:`~Marionette.switch_to_frame` to execute commands in the
+context of a new frame (e.g an <iframe> element):
+
+.. parsed-literal::
+ iframe = client.find_element(By.TAG_NAME, 'iframe')
+ client.switch_to_frame(iframe)
+
+Finally Marionette can switch between `chrome` and `content` scope. Chrome is a
+privileged scope where you can access things like the Firefox UI itself.
+Content scope is where things like webpages live. You can switch between
+`chrome` and `content` using the :func:`~Marionette.set_context` and :func:`~Marionette.using_context` functions:
+
+.. parsed-literal::
+ client.set_context(client.CONTEXT_CONTENT)
+ # content scope
+ with client.using_context(client.CONTEXT_CHROME):
+ #chrome scope
+ ... do stuff ...
+ # content scope restored
+
+
+Navigation
+----------
+
+Use :func:`~Marionette.navigate` to open a new website. It's also possible to
+move through the back/forward cache using :func:`~Marionette.go_forward` and
+:func:`~Marionette.go_back` respectively. To retrieve the currently
+open website, use :func:`~Marionette.get_url`:
+
+.. parsed-literal::
+ url = 'http://mozilla.org'
+ client.navigate(url)
+ client.go_back()
+ client.go_forward()
+ assert client.get_url() == url
+
+
+DOM Elements
+------------
+
+In order to inspect or manipulate actual DOM elements, they must first be found
+using the :func:`~Marionette.find_element` or :func:`~Marionette.find_elements`
+methods:
+
+.. parsed-literal::
+ from marionette_driver.marionette import HTMLElement
+ element = client.find_element(By.ID, 'my-id')
+ assert type(element) == HTMLElement
+ elements = client.find_elements(By.TAG_NAME, 'a')
+ assert type(elements) == list
+
+For a full list of valid search strategies, see :doc:`advanced/findelement`.
+
+Now that an element has been found, it's possible to manipulate it:
+
+.. parsed-literal::
+ element.click()
+ element.send_keys('hello!')
+ print(element.get_attribute('style'))
+
+For the full list of possible commands, see the :class:`HTMLElement`
+reference.
+
+Be warned that a reference to an element object can become stale if it was
+modified or removed from the document. See :doc:`advanced/stale` for tips
+on working around this limitation.
+
+Script Execution
+----------------
+
+Sometimes Marionette's provided APIs just aren't enough and it is necessary to
+run arbitrary javascript. This is accomplished with the
+:func:`~Marionette.execute_script` and :func:`~Marionette.execute_async_script`
+functions. They accomplish what their names suggest, the former executes some
+synchronous JavaScript, while the latter provides a callback mechanism for
+running asynchronous JavaScript:
+
+.. parsed-literal::
+ result = client.execute_script("return arguments[0] + arguments[1];",
+ script_args=[2, 3])
+ assert result == 5
+
+The async method works the same way, except it won't return until the
+`resolve()` function is called:
+
+.. parsed-literal::
+ result = client.execute_async_script("""
+ let [resolve] = arguments;
+ setTimeout(function() {
+ resolve("all done");
+ }, arguments[0]);
+ """, script_args=[1000])
+ assert result == "all done"
+
+Beware that running asynchronous scripts can potentially hang the program
+indefinitely if they are not written properly. It is generally a good idea to
+set a script timeout using :func:`~Marionette.timeout.script` and handling
+`ScriptTimeoutException`.
diff --git a/testing/marionette/client/docs/conf.py b/testing/marionette/client/docs/conf.py
new file mode 100644
index 0000000000..86fc57f20b
--- /dev/null
+++ b/testing/marionette/client/docs/conf.py
@@ -0,0 +1,276 @@
+# -*- coding: utf-8 -*-
+#
+# Marionette Python Client documentation build configuration file, created by
+# sphinx-quickstart on Tue Aug 6 13:54:46 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.
+
+from __future__ import absolute_import
+
+import os
+import sys
+
+# 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('.'))
+
+here = os.path.dirname(os.path.abspath(__file__))
+parent = os.path.dirname(here)
+sys.path.insert(0, parent)
+
+# -- 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"]
+
+# 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 = u"Marionette Python Client"
+copyright = u"2013, Mozilla Automation and Tools and individual contributors"
+
+# 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'
+# The full version, including alpha/beta/rc tags.
+# release = '0'
+
+# 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"
+
+on_rtd = os.environ.get("READTHEDOCS", None) == "True"
+
+if not on_rtd:
+ try:
+ import sphinx_rtd_theme
+
+ html_theme = "sphinx_rtd_theme"
+ html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
+ except ImportError:
+ pass
+
+
+# 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 = False
+
+# 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 = "MarionettePythonClientdoc"
+
+
+# -- 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",
+ "MarionettePythonClient.tex",
+ u"Marionette Python Client Documentation",
+ u"Mozilla Automation and Tools team",
+ "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",
+ "marionettepythonclient",
+ u"Marionette Python Client Documentation",
+ [u"Mozilla Automation and Tools team"],
+ 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",
+ "MarionettePythonClient",
+ "Marionette Python Client Documentation",
+ "Mozilla Automation and Tools team",
+ "MarionettePythonClient",
+ "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/marionette/client/docs/index.rst b/testing/marionette/client/docs/index.rst
new file mode 100644
index 0000000000..b1f266726c
--- /dev/null
+++ b/testing/marionette/client/docs/index.rst
@@ -0,0 +1,16 @@
+.. include:: basics.rst
+
+Indices and tables
+------------------
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
+
+.. toctree::
+ :hidden:
+
+ Getting Started <basics>
+ Interactive Tutorial <interactive>
+ advanced/landing
+ reference
diff --git a/testing/marionette/client/docs/interactive.rst b/testing/marionette/client/docs/interactive.rst
new file mode 100644
index 0000000000..7b2ebe2ec3
--- /dev/null
+++ b/testing/marionette/client/docs/interactive.rst
@@ -0,0 +1,52 @@
+Using the Client Interactively
+==============================
+
+Once you installed the client and have Marionette running, you can fire
+up your favourite interactive python environment and start playing with
+Marionette. Let's use a typical python shell:
+
+.. parsed-literal::
+ python
+
+First, import Marionette:
+
+.. parsed-literal::
+ from marionette_driver.marionette import Marionette
+
+Now create the client for this session. Assuming you're using the default
+port on a Marionette instance running locally:
+
+.. parsed-literal::
+ client = Marionette(host='127.0.0.1', port=2828)
+ client.start_session()
+
+This will return some id representing your session id. Now that you've
+established a connection, let's start doing interesting things:
+
+.. parsed-literal::
+ client.navigate("http://www.mozilla.org")
+
+Now you're at mozilla.org! You can even verify it using the following:
+
+.. parsed-literal::
+ client.get_url()
+
+You can execute Javascript code in the scope of the web page:
+
+.. parsed-literal::
+ client.execute_script("return window.document.title;")
+
+This will you return the title of the web page as set in the head section
+of the HTML document.
+
+Also you can find elements and click on those. Let's say you want to get
+the first link:
+
+.. parsed-literal::
+ from marionette_driver import By
+ first_link = client.find_element(By.TAG_NAME, "a")
+
+first_link now holds a reference to the first link on the page. You can click it:
+
+.. parsed-literal::
+ first_link.click()
diff --git a/testing/marionette/client/docs/make.bat b/testing/marionette/client/docs/make.bat
new file mode 100644
index 0000000000..fb02fc1a8c
--- /dev/null
+++ b/testing/marionette/client/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\MarionettePythonClient.qhcp
+ echo.To view the help file:
+ echo.^> assistant -collectionFile %BUILDDIR%\qthelp\MarionettePythonClient.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/marionette/client/docs/reference.rst b/testing/marionette/client/docs/reference.rst
new file mode 100644
index 0000000000..2765e8b1a4
--- /dev/null
+++ b/testing/marionette/client/docs/reference.rst
@@ -0,0 +1,66 @@
+=============
+API Reference
+=============
+
+Marionette
+----------
+.. py:currentmodule:: marionette_driver.marionette.Marionette
+.. autoclass:: marionette_driver.marionette.Marionette
+ :members:
+
+HTMLElement
+-----------
+.. py:currentmodule:: marionette_driver.marionette.HTMLElement
+.. autoclass:: marionette_driver.marionette.HTMLElement
+ :members:
+
+DateTimeValue
+-------------
+.. py:currentmodule:: marionette_driver.DateTimeValue
+.. autoclass:: marionette_driver.DateTimeValue
+ :members:
+
+Actions
+-------
+.. py:currentmodule:: marionette_driver.marionette.Actions
+.. autoclass:: marionette_driver.marionette.Actions
+ :members:
+
+Alert
+-----
+.. py:currentmodule:: marionette_driver.marionette.Alert
+.. autoclass:: marionette_driver.marionette.Alert
+ :members:
+
+Wait
+----
+.. py:currentmodule:: marionette_driver.Wait
+.. autoclass:: marionette_driver.Wait
+ :members:
+ :special-members:
+.. autoattribute marionette_driver.wait.DEFAULT_TIMEOUT
+.. autoattribute marionette_driver.wait.DEFAULT_INTERVAL
+
+Built-in Conditions
+^^^^^^^^^^^^^^^^^^^
+.. py:currentmodule:: marionette_driver.expected
+.. automodule:: marionette_driver.expected
+ :members:
+
+Timeouts
+--------
+.. py:currentmodule:: marionette_driver.timeout.Timeouts
+.. autoclass:: marionette_driver.timeout.Timeouts
+ :members:
+
+Addons
+------
+.. py:currentmodule:: marionette_driver.addons.Addons
+.. autoclass:: marionette_driver.addons.Addons
+ :members:
+
+Localization
+------------
+.. py:currentmodule:: marionette_driver.localization.L10n
+.. autoclass:: marionette_driver.localization.L10n
+ :members:
diff --git a/testing/marionette/client/marionette_driver/__init__.py b/testing/marionette/client/marionette_driver/__init__.py
new file mode 100644
index 0000000000..0718925e20
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/__init__.py
@@ -0,0 +1,24 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+__version__ = "3.1.0"
+
+from marionette_driver import (
+ addons,
+ by,
+ date_time_value,
+ decorators,
+ errors,
+ expected,
+ geckoinstance,
+ keys,
+ localization,
+ marionette,
+ wait,
+)
+from marionette_driver.by import By
+from marionette_driver.date_time_value import DateTimeValue
+from marionette_driver.wait import Wait
diff --git a/testing/marionette/client/marionette_driver/addons.py b/testing/marionette/client/marionette_driver/addons.py
new file mode 100644
index 0000000000..8dab5086be
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/addons.py
@@ -0,0 +1,77 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+import os
+
+from . import errors
+
+__all__ = ["Addons", "AddonInstallException"]
+
+
+class AddonInstallException(errors.MarionetteException):
+ pass
+
+
+class Addons(object):
+ """An API for installing and inspecting addons during Gecko
+ runtime. This is a partially implemented wrapper around Gecko's
+ `AddonManager API`_.
+
+ For example::
+
+ from marionette_driver.addons import Addons
+ addons = Addons(marionette)
+ addons.install("/path/to/extension.xpi")
+
+ .. _AddonManager API: https://developer.mozilla.org/en-US/Add-ons/Add-on_Manager
+
+ """
+
+ def __init__(self, marionette):
+ self._mn = marionette
+
+ def install(self, path, temp=False):
+ """Install a Firefox addon.
+
+ If the addon is restartless, it can be used right away. Otherwise
+ a restart using :func:`~marionette_driver.marionette.Marionette.restart`
+ will be needed.
+
+ :param path: A file path to the extension to be installed.
+ :param temp: Install a temporary addon. Temporary addons will
+ automatically be uninstalled on shutdown and do not need
+ to be signed, though they must be restartless.
+
+ :returns: The addon ID string of the newly installed addon.
+
+ :raises: :exc:`AddonInstallException`
+
+ """
+ # On windows we can end up with a path with mixed \ and /
+ # which Firefox doesn't like
+ path = path.replace("/", os.path.sep)
+
+ body = {"path": path, "temporary": temp}
+ try:
+ return self._mn._send_message("Addon:Install", body, key="value")
+ except errors.UnknownException as e:
+ raise AddonInstallException(e)
+
+ def uninstall(self, addon_id):
+ """Uninstall a Firefox addon.
+
+ If the addon is restartless, it will be uninstalled right away.
+ Otherwise a restart using :func:`~marionette_driver.marionette.Marionette.restart`
+ will be needed.
+
+ If the call to uninstall is resulting in a `ScriptTimeoutException`,
+ an invalid ID is likely being passed in. Unfortunately due to
+ AddonManager's implementation, it's hard to retrieve this error from
+ Python.
+
+ :param addon_id: The addon ID string to uninstall.
+
+ """
+ self._mn._send_message("Addon:Uninstall", {"id": addon_id})
diff --git a/testing/marionette/client/marionette_driver/by.py b/testing/marionette/client/marionette_driver/by.py
new file mode 100644
index 0000000000..9b0b611ac0
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/by.py
@@ -0,0 +1,27 @@
+# Copyright 2008-2009 WebDriver committers
+# Copyright 2008-2009 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import absolute_import
+
+
+class By(object):
+ ID = "id"
+ XPATH = "xpath"
+ LINK_TEXT = "link text"
+ PARTIAL_LINK_TEXT = "partial link text"
+ NAME = "name"
+ TAG_NAME = "tag name"
+ CLASS_NAME = "class name"
+ CSS_SELECTOR = "css selector"
diff --git a/testing/marionette/client/marionette_driver/date_time_value.py b/testing/marionette/client/marionette_driver/date_time_value.py
new file mode 100644
index 0000000000..fd646db6d8
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/date_time_value.py
@@ -0,0 +1,51 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+
+class DateTimeValue(object):
+ """
+ Interface for setting the value of HTML5 "date" and "time" input elements.
+
+ Simple usage example:
+
+ ::
+
+ element = marionette.find_element(By.ID, "date-test")
+ dt_value = DateTimeValue(element)
+ dt_value.date = datetime(1998, 6, 2)
+
+ """
+
+ def __init__(self, element):
+ self.element = element
+
+ @property
+ def date(self):
+ """
+ Retrieve the element's string value
+ """
+ return self.element.get_attribute("value")
+
+ # As per the W3C "date" element specification
+ # (http://dev.w3.org/html5/markup/input.date.html), this value is formatted
+ # according to RFC 3339: http://tools.ietf.org/html/rfc3339#section-5.6
+ @date.setter
+ def date(self, date_value):
+ self.element.send_keys(date_value.strftime("%Y-%m-%d"))
+
+ @property
+ def time(self):
+ """
+ Retrieve the element's string value
+ """
+ return self.element.get_attribute("value")
+
+ # As per the W3C "time" element specification
+ # (http://dev.w3.org/html5/markup/input.time.html), this value is formatted
+ # according to RFC 3339: http://tools.ietf.org/html/rfc3339#section-5.6
+ @time.setter
+ def time(self, time_value):
+ self.element.send_keys(time_value.strftime("%H:%M:%S"))
diff --git a/testing/marionette/client/marionette_driver/decorators.py b/testing/marionette/client/marionette_driver/decorators.py
new file mode 100644
index 0000000000..223440a8a8
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/decorators.py
@@ -0,0 +1,82 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import, print_function
+
+import socket
+
+from functools import wraps
+
+
+def _find_marionette_in_args(*args, **kwargs):
+ try:
+ m = [a for a in args + tuple(kwargs.values()) if hasattr(a, "session")][0]
+ except IndexError:
+ print("Can only apply decorator to function using a marionette object")
+ raise
+ return m
+
+
+def do_process_check(func):
+ """Decorator which checks the process status after the function has run."""
+
+ @wraps(func)
+ def _(*args, **kwargs):
+ try:
+ return func(*args, **kwargs)
+ except (socket.error, socket.timeout):
+ m = _find_marionette_in_args(*args, **kwargs)
+
+ # In case of socket failures which will also include crashes of the
+ # application, make sure to handle those correctly. In case of an
+ # active shutdown just let it bubble up.
+ if m.is_shutting_down:
+ raise
+
+ m._handle_socket_failure()
+
+ return _
+
+
+def uses_marionette(func):
+ """Decorator which creates a marionette session and deletes it
+ afterwards if one doesn't already exist.
+ """
+
+ @wraps(func)
+ def _(*args, **kwargs):
+ m = _find_marionette_in_args(*args, **kwargs)
+ delete_session = False
+ if not m.session:
+ delete_session = True
+ m.start_session()
+
+ m.set_context(m.CONTEXT_CHROME)
+ ret = func(*args, **kwargs)
+
+ if delete_session:
+ m.delete_session()
+
+ return ret
+
+ return _
+
+
+def using_context(context):
+ """Decorator which allows a function to execute in certain scope
+ using marionette.using_context functionality and returns to old
+ scope once the function exits.
+ :param context: Either 'chrome' or 'content'.
+ """
+
+ def wrap(func):
+ @wraps(func)
+ def inner(*args, **kwargs):
+ m = _find_marionette_in_args(*args, **kwargs)
+ with m.using_context(context):
+ return func(*args, **kwargs)
+
+ return inner
+
+ return wrap
diff --git a/testing/marionette/client/marionette_driver/errors.py b/testing/marionette/client/marionette_driver/errors.py
new file mode 100644
index 0000000000..062eb12883
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/errors.py
@@ -0,0 +1,200 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import traceback
+
+import six
+
+
+@six.python_2_unicode_compatible
+class MarionetteException(Exception):
+
+ """Raised when a generic non-recoverable exception has occured."""
+
+ status = "webdriver error"
+
+ def __init__(self, message=None, cause=None, stacktrace=None):
+ """Construct new MarionetteException instance.
+
+ :param message: An optional exception message.
+
+ :param cause: An optional tuple of three values giving
+ information about the root exception cause. Expected
+ tuple values are (type, value, traceback).
+
+ :param stacktrace: Optional string containing a stacktrace
+ (typically from a failed JavaScript execution) that will
+ be displayed in the exception's string representation.
+
+ """
+ self.cause = cause
+ self.stacktrace = stacktrace
+ self._message = six.text_type(message)
+
+ def __str__(self):
+ # pylint: disable=W1645
+ msg = self.message
+ tb = None
+
+ if self.cause:
+ if type(self.cause) is tuple:
+ msg += u", caused by {0!r}".format(self.cause[0])
+ tb = self.cause[2]
+ else:
+ msg += u", caused by {}".format(self.cause)
+
+ if self.stacktrace:
+ st = u"".join(["\t{}\n".format(x) for x in self.stacktrace.splitlines()])
+ msg += u"\nstacktrace:\n{}".format(st)
+
+ if tb:
+ msg += u": " + u"".join(traceback.format_tb(tb))
+
+ return six.text_type(msg)
+
+ @property
+ def message(self):
+ return self._message
+
+
+class ElementNotSelectableException(MarionetteException):
+ status = "element not selectable"
+
+
+class ElementClickInterceptedException(MarionetteException):
+ status = "element click intercepted"
+
+
+class InsecureCertificateException(MarionetteException):
+ status = "insecure certificate"
+
+
+class InvalidArgumentException(MarionetteException):
+ status = "invalid argument"
+
+
+class InvalidSessionIdException(MarionetteException):
+ status = "invalid session id"
+
+
+class TimeoutException(MarionetteException):
+ status = "timeout"
+
+
+class JavascriptException(MarionetteException):
+ status = "javascript error"
+
+
+class NoSuchElementException(MarionetteException):
+ status = "no such element"
+
+
+class NoSuchWindowException(MarionetteException):
+ status = "no such window"
+
+
+class StaleElementException(MarionetteException):
+ status = "stale element reference"
+
+
+class ScriptTimeoutException(MarionetteException):
+ status = "script timeout"
+
+
+class ElementNotVisibleException(MarionetteException):
+ """Deprecated. Will be removed with the release of Firefox 54."""
+
+ status = "element not visible"
+
+ def __init__(
+ self,
+ message="Element is not currently visible and may not be manipulated",
+ stacktrace=None,
+ cause=None,
+ ):
+ super(ElementNotVisibleException, self).__init__(
+ message, cause=cause, stacktrace=stacktrace
+ )
+
+
+class ElementNotAccessibleException(MarionetteException):
+ status = "element not accessible"
+
+
+class ElementNotInteractableException(MarionetteException):
+ status = "element not interactable"
+
+
+class NoSuchFrameException(MarionetteException):
+ status = "no such frame"
+
+
+class InvalidElementStateException(MarionetteException):
+ status = "invalid element state"
+
+
+class NoAlertPresentException(MarionetteException):
+ status = "no such alert"
+
+
+class InvalidCookieDomainException(MarionetteException):
+ status = "invalid cookie domain"
+
+
+class UnableToSetCookieException(MarionetteException):
+ status = "unable to set cookie"
+
+
+class InvalidElementCoordinates(MarionetteException):
+ status = "invalid element coordinates"
+
+
+class InvalidSelectorException(MarionetteException):
+ status = "invalid selector"
+
+
+class MoveTargetOutOfBoundsException(MarionetteException):
+ status = "move target out of bounds"
+
+
+class SessionNotCreatedException(MarionetteException):
+ status = "session not created"
+
+
+class UnexpectedAlertOpen(MarionetteException):
+ status = "unexpected alert open"
+
+
+class UnknownCommandException(MarionetteException):
+ status = "unknown command"
+
+
+class UnknownException(MarionetteException):
+ status = "unknown error"
+
+
+class UnsupportedOperationException(MarionetteException):
+ status = "unsupported operation"
+
+
+class UnresponsiveInstanceException(Exception):
+ pass
+
+
+es_ = [
+ e
+ for e in locals().values()
+ if type(e) == type and issubclass(e, MarionetteException)
+]
+by_string = {e.status: e for e in es_}
+
+
+def lookup(identifier):
+ """Finds error exception class by associated Selenium JSON wire
+ protocol number code, or W3C WebDriver protocol string.
+
+ """
+ return by_string.get(identifier, MarionetteException)
diff --git a/testing/marionette/client/marionette_driver/expected.py b/testing/marionette/client/marionette_driver/expected.py
new file mode 100644
index 0000000000..1605b2adad
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/expected.py
@@ -0,0 +1,317 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import types
+
+from . import errors
+from .marionette import HTMLElement
+
+"""This file provides a set of expected conditions for common use
+cases when writing Marionette tests.
+
+The conditions rely on explicit waits that retries conditions a number
+of times until they are either successfully met, or they time out.
+
+"""
+
+
+class element_present(object):
+ """Checks that a web element is present in the DOM of the current
+ context. This does not necessarily mean that the element is
+ visible.
+
+ You can select which element to be checked for presence by
+ supplying a locator::
+
+ el = Wait(marionette).until(expected.element_present(By.ID, "foo"))
+
+ Or by using a function/lambda returning an element::
+
+ el = Wait(marionette).until(
+ expected.element_present(lambda m: m.find_element(By.ID, "foo")))
+
+ :param args: locator or function returning web element
+ :returns: the web element once it is located, or False
+
+ """
+
+ def __init__(self, *args):
+ if len(args) == 1 and isinstance(args[0], types.FunctionType):
+ self.locator = args[0]
+ else:
+ self.locator = lambda m: m.find_element(*args)
+
+ def __call__(self, marionette):
+ return _find(marionette, self.locator)
+
+
+class element_not_present(element_present):
+ """Checks that a web element is not present in the DOM of the current
+ context.
+
+ You can select which element to be checked for lack of presence by
+ supplying a locator::
+
+ r = Wait(marionette).until(expected.element_not_present(By.ID, "foo"))
+
+ Or by using a function/lambda returning an element::
+
+ r = Wait(marionette).until(
+ expected.element_present(lambda m: m.find_element(By.ID, "foo")))
+
+ :param args: locator or function returning web element
+ :returns: True if element is not present, or False if it is present
+
+ """
+
+ def __init__(self, *args):
+ super(element_not_present, self).__init__(*args)
+
+ def __call__(self, marionette):
+ return not super(element_not_present, self).__call__(marionette)
+
+
+class element_stale(object):
+ """Check that the given element is no longer attached to DOM of the
+ current context.
+
+ This can be useful for waiting until an element is no longer
+ present.
+
+ Sample usage::
+
+ el = marionette.find_element(By.ID, "foo")
+ # ...
+ Wait(marionette).until(expected.element_stale(el))
+
+ :param element: the element to wait for
+ :returns: False if the element is still attached to the DOM, True
+ otherwise
+
+ """
+
+ def __init__(self, element):
+ self.el = element
+
+ def __call__(self, marionette):
+ try:
+ # Calling any method forces a staleness check
+ self.el.is_enabled()
+ return False
+ except (errors.StaleElementException, errors.NoSuchElementException):
+ # StaleElementException is raised when the element is gone, and
+ # NoSuchElementException is raised after a process swap.
+ return True
+
+
+class elements_present(object):
+ """Checks that web elements are present in the DOM of the current
+ context. This does not necessarily mean that the elements are
+ visible.
+
+ You can select which elements to be checked for presence by
+ supplying a locator::
+
+ els = Wait(marionette).until(expected.elements_present(By.TAG_NAME, "a"))
+
+ Or by using a function/lambda returning a list of elements::
+
+ els = Wait(marionette).until(
+ expected.elements_present(lambda m: m.find_elements(By.TAG_NAME, "a")))
+
+ :param args: locator or function returning a list of web elements
+ :returns: list of web elements once they are located, or False
+
+ """
+
+ def __init__(self, *args):
+ if len(args) == 1 and isinstance(args[0], types.FunctionType):
+ self.locator = args[0]
+ else:
+ self.locator = lambda m: m.find_elements(*args)
+
+ def __call__(self, marionette):
+ return _find(marionette, self.locator)
+
+
+class elements_not_present(elements_present):
+ """Checks that web elements are not present in the DOM of the
+ current context.
+
+ You can select which elements to be checked for not being present
+ by supplying a locator::
+
+ r = Wait(marionette).until(expected.elements_not_present(By.TAG_NAME, "a"))
+
+ Or by using a function/lambda returning a list of elements::
+
+ r = Wait(marionette).until(
+ expected.elements_not_present(lambda m: m.find_elements(By.TAG_NAME, "a")))
+
+ :param args: locator or function returning a list of web elements
+ :returns: True if elements are missing, False if one or more are
+ present
+
+ """
+
+ def __init__(self, *args):
+ super(elements_not_present, self).__init__(*args)
+
+ def __call__(self, marionette):
+ return not super(elements_not_present, self).__call__(marionette)
+
+
+class element_displayed(object):
+ """An expectation for checking that an element is visible.
+
+ Visibility means that the element is not only displayed, but also
+ has a height and width that is greater than 0 pixels.
+
+ Stale elements, meaning elements that have been detached from the
+ DOM of the current context are treated as not being displayed,
+ meaning this expectation is not analogous to the behaviour of
+ calling :func:`~marionette_driver.marionette.HTMLElement.is_displayed`
+ on an :class:`~marionette_driver.marionette.HTMLElement`.
+
+ You can select which element to be checked for visibility by
+ supplying a locator::
+
+ displayed = Wait(marionette).until(expected.element_displayed(By.ID, "foo"))
+
+ Or by supplying an element::
+
+ el = marionette.find_element(By.ID, "foo")
+ displayed = Wait(marionette).until(expected.element_displayed(el))
+
+ :param args: locator or web element
+ :returns: True if element is displayed, False if hidden
+
+ """
+
+ def __init__(self, *args):
+ self.el = None
+ if len(args) == 1 and isinstance(args[0], HTMLElement):
+ self.el = args[0]
+ else:
+ self.locator = lambda m: m.find_element(*args)
+
+ def __call__(self, marionette):
+ if self.el is None:
+ self.el = _find(marionette, self.locator)
+ if not self.el:
+ return False
+ try:
+ return self.el.is_displayed()
+ except errors.StaleElementException:
+ return False
+
+
+class element_not_displayed(element_displayed):
+ """An expectation for checking that an element is not visible.
+
+ Visibility means that the element is not only displayed, but also
+ has a height and width that is greater than 0 pixels.
+
+ Stale elements, meaning elements that have been detached fom the
+ DOM of the current context are treated as not being displayed,
+ meaning this expectation is not analogous to the behaviour of
+ calling :func:`~marionette_driver.marionette.HTMLElement.is_displayed`
+ on an :class:`~marionette_driver.marionette.HTMLElement`.
+
+ You can select which element to be checked for visibility by
+ supplying a locator::
+
+ hidden = Wait(marionette).until(expected.element_not_displayed(By.ID, "foo"))
+
+ Or by supplying an element::
+
+ el = marionette.find_element(By.ID, "foo")
+ hidden = Wait(marionette).until(expected.element_not_displayed(el))
+
+ :param args: locator or web element
+ :returns: True if element is hidden, False if displayed
+
+ """
+
+ def __init__(self, *args):
+ super(element_not_displayed, self).__init__(*args)
+
+ def __call__(self, marionette):
+ return not super(element_not_displayed, self).__call__(marionette)
+
+
+class element_selected(object):
+ """An expectation for checking that the given element is selected.
+
+ :param element: the element to be selected
+ :returns: True if element is selected, False otherwise
+
+ """
+
+ def __init__(self, element):
+ self.el = element
+
+ def __call__(self, marionette):
+ return self.el.is_selected()
+
+
+class element_not_selected(element_selected):
+ """An expectation for checking that the given element is not
+ selected.
+
+ :param element: the element to not be selected
+ :returns: True if element is not selected, False if selected
+
+ """
+
+ def __init__(self, element):
+ super(element_not_selected, self).__init__(element)
+
+ def __call__(self, marionette):
+ return not super(element_not_selected, self).__call__(marionette)
+
+
+class element_enabled(object):
+ """An expectation for checking that the given element is enabled.
+
+ :param element: the element to check if enabled
+ :returns: True if element is enabled, False otherwise
+
+ """
+
+ def __init__(self, element):
+ self.el = element
+
+ def __call__(self, marionette):
+ return self.el.is_enabled()
+
+
+class element_not_enabled(element_enabled):
+ """An expectation for checking that the given element is disabled.
+
+ :param element: the element to check if disabled
+ :returns: True if element is disabled, False if enabled
+
+ """
+
+ def __init__(self, element):
+ super(element_not_enabled, self).__init__(element)
+
+ def __call__(self, marionette):
+ return not super(element_not_enabled, self).__call__(marionette)
+
+
+def _find(marionette, func):
+ el = None
+
+ try:
+ el = func(marionette)
+ except errors.NoSuchElementException:
+ pass
+
+ if el is None:
+ return False
+ return el
diff --git a/testing/marionette/client/marionette_driver/geckoinstance.py b/testing/marionette/client/marionette_driver/geckoinstance.py
new file mode 100644
index 0000000000..9ff56428e0
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/geckoinstance.py
@@ -0,0 +1,639 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/
+
+# ALL CHANGES TO THIS FILE MUST HAVE REVIEW FROM A MARIONETTE PEER!
+#
+# The Marionette Python client is used out-of-tree with various builds of
+# Firefox. Removing a preference from this file will cause regressions,
+# so please be careful and get review from a Testing :: Marionette peer
+# before you make any changes to this file.
+
+from __future__ import absolute_import
+
+import codecs
+import os
+import sys
+import tempfile
+import time
+import traceback
+
+from copy import deepcopy
+
+import mozversion
+
+from mozprofile import Profile
+from mozrunner import Runner, FennecEmulatorRunner
+import six
+from six import reraise
+
+from . import errors
+
+
+class GeckoInstance(object):
+ required_prefs = {
+ # Make sure Shield doesn't hit the network.
+ "app.normandy.api_url": "",
+ # Increase the APZ content response timeout in tests to 1 minute.
+ # This is to accommodate the fact that test environments tends to be slower
+ # than production environments (with the b2g emulator being the slowest of them
+ # all), resulting in the production timeout value sometimes being exceeded
+ # and causing false-positive test failures. See bug 1176798, bug 1177018,
+ # bug 1210465.
+ "apz.content_response_timeout": 60000,
+ # Defensively disable data reporting systems
+ "datareporting.healthreport.documentServerURI": "http://%(server)s/dummy/healthreport/",
+ "datareporting.healthreport.logging.consoleEnabled": False,
+ "datareporting.healthreport.service.enabled": False,
+ "datareporting.healthreport.service.firstRun": False,
+ "datareporting.healthreport.uploadEnabled": False,
+ # Do not show datareporting policy notifications which can interfere with tests
+ "datareporting.policy.dataSubmissionEnabled": False,
+ "datareporting.policy.dataSubmissionPolicyBypassNotification": True,
+ # Automatically unload beforeunload alerts
+ "dom.disable_beforeunload": True,
+ # Disable the ProcessHangMonitor
+ "dom.ipc.reportProcessHangs": False,
+ # No slow script dialogs
+ "dom.max_chrome_script_run_time": 0,
+ "dom.max_script_run_time": 0,
+ # DOM Push
+ "dom.push.connection.enabled": False,
+ # Only load extensions from the application and user profile
+ # AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION
+ "extensions.autoDisableScopes": 0,
+ "extensions.enabledScopes": 5,
+ # Disable metadata caching for installed add-ons by default
+ "extensions.getAddons.cache.enabled": False,
+ # Disable intalling any distribution add-ons
+ "extensions.installDistroAddons": False,
+ # Turn off extension updates so they don't bother tests
+ "extensions.update.enabled": False,
+ "extensions.update.notifyUser": False,
+ # Make sure opening about:addons won"t hit the network
+ "extensions.getAddons.discovery.api_url": "data:, ",
+ # Allow the application to have focus even it runs in the background
+ "focusmanager.testmode": True,
+ # Disable useragent updates
+ "general.useragent.updates.enabled": False,
+ # Always use network provider for geolocation tests
+ # so we bypass the OSX dialog raised by the corelocation provider
+ "geo.provider.testing": True,
+ # Do not scan Wifi
+ "geo.wifi.scan": False,
+ # Disable idle-daily notifications to avoid expensive operations
+ # that may cause unexpected test timeouts.
+ "idle.lastDailyNotification": -1,
+ "javascript.options.showInConsole": True,
+ # (deprecated and can be removed when Firefox 60 ships)
+ "marionette.defaultPrefs.enabled": True,
+ # Disable recommended automation prefs in CI
+ "marionette.prefs.recommended": False,
+ # Disable download and usage of OpenH264, and Widevine plugins
+ "media.gmp-manager.updateEnabled": False,
+ # Disable the GFX sanity window
+ "media.sanity-test.disabled": True,
+ "media.volume_scale": "0.01",
+ # Do not prompt for temporary redirects
+ "network.http.prompt-temp-redirect": False,
+ # Do not automatically switch between offline and online
+ "network.manage-offline-status": False,
+ # Make sure SNTP requests don't hit the network
+ "network.sntp.pools": "%(server)s",
+ # Privacy and Tracking Protection
+ "privacy.trackingprotection.enabled": False,
+ # Don't do network connections for mitm priming
+ "security.certerrors.mitm.priming.enabled": False,
+ # Tests don't wait for the notification button security delay
+ "security.notification_enable_delay": 0,
+ # Ensure blocklist updates don't hit the network
+ "services.settings.server": "http://%(server)s/dummy/blocklist/",
+ # Disable password capture, so that tests that include forms aren"t
+ # influenced by the presence of the persistent doorhanger notification
+ "signon.rememberSignons": False,
+ # Prevent starting into safe mode after application crashes
+ "toolkit.startup.max_resumed_crashes": -1,
+ # Enabling the support for File object creation in the content process.
+ "dom.file.createInChild": True,
+ }
+
+ def __init__(
+ self,
+ host=None,
+ port=None,
+ bin=None,
+ profile=None,
+ addons=None,
+ app_args=None,
+ symbols_path=None,
+ gecko_log=None,
+ prefs=None,
+ workspace=None,
+ verbose=0,
+ headless=False,
+ enable_webrender=False,
+ ):
+ self.runner_class = Runner
+ self.app_args = app_args or []
+ self.runner = None
+ self.symbols_path = symbols_path
+ self.binary = bin
+
+ self.marionette_host = host
+ self.marionette_port = port
+ self.addons = addons
+ self.prefs = prefs
+ self.required_prefs = deepcopy(self.required_prefs)
+ if prefs:
+ self.required_prefs.update(prefs)
+
+ self._gecko_log_option = gecko_log
+ self._gecko_log = None
+ self.verbose = verbose
+ self.headless = headless
+ self.enable_webrender = enable_webrender
+
+ # keep track of errors to decide whether instance is unresponsive
+ self.unresponsive_count = 0
+
+ # Alternative to default temporary directory
+ self.workspace = workspace
+
+ # Don't use the 'profile' property here, because sub-classes could add
+ # further preferences and data, which would not be included in the new
+ # profile
+ self._profile = profile
+
+ @property
+ def gecko_log(self):
+ if self._gecko_log:
+ return self._gecko_log
+
+ path = self._gecko_log_option
+ if path != "-":
+ if path is None:
+ path = "gecko.log"
+ elif os.path.isdir(path):
+ fname = "gecko-{}.log".format(time.time())
+ path = os.path.join(path, fname)
+
+ path = os.path.realpath(path)
+ if os.access(path, os.F_OK):
+ os.remove(path)
+
+ self._gecko_log = path
+ return self._gecko_log
+
+ @property
+ def profile(self):
+ return self._profile
+
+ @profile.setter
+ def profile(self, value):
+ self._update_profile(value)
+
+ def _update_profile(self, profile=None, profile_name=None):
+ """Check if the profile has to be created, or replaced.
+
+ :param profile: A Profile instance to be used.
+ :param name: Profile name to be used in the path.
+ """
+ if self.runner and self.runner.is_running():
+ raise errors.MarionetteException(
+ "The current profile can only be updated "
+ "when the instance is not running"
+ )
+
+ if isinstance(profile, Profile):
+ # Only replace the profile if it is not the current one
+ if hasattr(self, "_profile") and profile is self._profile:
+ return
+
+ else:
+ profile_args = self.profile_args
+ profile_path = profile
+
+ # If a path to a profile is given then clone it
+ if isinstance(profile_path, six.string_types):
+ profile_args["path_from"] = profile_path
+ profile_args["path_to"] = tempfile.mkdtemp(
+ suffix=u".{}".format(
+ profile_name or os.path.basename(profile_path)
+ ),
+ dir=self.workspace,
+ )
+ # The target must not exist yet
+ os.rmdir(profile_args["path_to"])
+
+ profile = Profile.clone(**profile_args)
+
+ # Otherwise create a new profile
+ else:
+ profile_args["profile"] = tempfile.mkdtemp(
+ suffix=u".{}".format(profile_name or "mozrunner"),
+ dir=self.workspace,
+ )
+ profile = Profile(**profile_args)
+ profile.create_new = True
+
+ if isinstance(self.profile, Profile):
+ self.profile.cleanup()
+
+ self._profile = profile
+
+ def switch_profile(self, profile_name=None, clone_from=None):
+ """Switch the profile by using the given name, and optionally clone it.
+
+ Compared to :attr:`profile` this method allows to switch the profile
+ by giving control over the profile name as used for the new profile. It
+ also always creates a new blank profile, or as clone of an existent one.
+
+ :param profile_name: Optional, name of the profile, which will be used
+ as part of the profile path (folder name containing the profile).
+ :clone_from: Optional, if specified the new profile will be cloned
+ based on the given profile. This argument can be an instance of
+ ``mozprofile.Profile``, or the path of the profile.
+ """
+ if isinstance(clone_from, Profile):
+ clone_from = clone_from.profile
+
+ self._update_profile(clone_from, profile_name=profile_name)
+
+ @property
+ def profile_args(self):
+ args = {"preferences": deepcopy(self.required_prefs)}
+ args["preferences"]["marionette.port"] = self.marionette_port
+ args["preferences"]["marionette.defaultPrefs.port"] = self.marionette_port
+
+ if self.prefs:
+ args["preferences"].update(self.prefs)
+
+ if self.verbose:
+ level = "Trace" if self.verbose >= 2 else "Debug"
+ args["preferences"]["marionette.log.level"] = level
+ args["preferences"]["marionette.logging"] = level
+
+ if "-jsdebugger" in self.app_args:
+ args["preferences"].update(
+ {
+ "devtools.browsertoolbox.panel": "jsdebugger",
+ "devtools.debugger.remote-enabled": True,
+ "devtools.chrome.enabled": True,
+ "devtools.debugger.prompt-connection": False,
+ "marionette.debugging.clicktostart": True,
+ }
+ )
+
+ if self.addons:
+ args["addons"] = self.addons
+
+ return args
+
+ @classmethod
+ def create(cls, app=None, *args, **kwargs):
+ try:
+ if not app and kwargs["bin"] is not None:
+ app_id = mozversion.get_version(binary=kwargs["bin"])["application_id"]
+ app = app_ids[app_id]
+
+ instance_class = apps[app]
+ except (IOError, KeyError):
+ exc, val, tb = sys.exc_info()
+ msg = 'Application "{0}" unknown (should be one of {1})'.format(
+ app, list(apps.keys())
+ )
+ reraise(NotImplementedError, NotImplementedError(msg), tb)
+
+ return instance_class(*args, **kwargs)
+
+ def start(self):
+ self._update_profile(self.profile)
+ self.runner = self.runner_class(**self._get_runner_args())
+ self.runner.start()
+
+ def _get_runner_args(self):
+ process_args = {
+ "processOutputLine": [NullOutput()],
+ "universal_newlines": True,
+ }
+
+ if self.gecko_log == "-":
+ if six.PY2:
+ process_args["stream"] = codecs.getwriter("utf-8")(sys.stdout)
+ else:
+ process_args["stream"] = codecs.getwriter("utf-8")(sys.stdout.buffer)
+ else:
+ process_args["logfile"] = self.gecko_log
+
+ env = os.environ.copy()
+
+ if self.headless:
+ env["MOZ_HEADLESS"] = "1"
+ env["DISPLAY"] = "77" # Set a fake display.
+
+ if self.enable_webrender:
+ env["MOZ_WEBRENDER"] = "1"
+ env["MOZ_ACCELERATED"] = "1"
+ else:
+ env["MOZ_WEBRENDER"] = "0"
+
+ # environment variables needed for crashreporting
+ # https://developer.mozilla.org/docs/Environment_variables_affecting_crash_reporting
+ env.update(
+ {
+ "MOZ_CRASHREPORTER": "1",
+ "MOZ_CRASHREPORTER_NO_REPORT": "1",
+ "MOZ_CRASHREPORTER_SHUTDOWN": "1",
+ }
+ )
+
+ return {
+ "binary": self.binary,
+ "profile": self.profile,
+ "cmdargs": ["-no-remote", "-marionette"] + self.app_args,
+ "env": env,
+ "symbols_path": self.symbols_path,
+ "process_args": process_args,
+ }
+
+ def close(self, clean=False):
+ """
+ Close the managed Gecko process.
+
+ Depending on self.runner_class, setting `clean` to True may also kill
+ the emulator process in which this instance is running.
+
+ :param clean: If True, also perform runner cleanup.
+ """
+ if self.runner:
+ self.runner.stop()
+ if clean:
+ self.runner.cleanup()
+
+ if clean:
+ if isinstance(self.profile, Profile):
+ self.profile.cleanup()
+ self.profile = None
+
+ def restart(self, prefs=None, clean=True):
+ """
+ Close then start the managed Gecko process.
+
+ :param prefs: Dictionary of preference names and values.
+ :param clean: If True, reset the profile before starting.
+ """
+ if prefs:
+ self.prefs = prefs
+ else:
+ self.prefs = None
+
+ self.close(clean=clean)
+ self.start()
+
+
+class FennecInstance(GeckoInstance):
+ fennec_prefs = {
+ # Enable output for dump() and chrome console API
+ "browser.dom.window.dump.enabled": True,
+ "devtools.console.stdout.chrome": True,
+ # Disable safebrowsing components
+ "browser.safebrowsing.blockedURIs.enabled": False,
+ "browser.safebrowsing.downloads.enabled": False,
+ "browser.safebrowsing.passwords.enabled": False,
+ "browser.safebrowsing.malware.enabled": False,
+ "browser.safebrowsing.phishing.enabled": False,
+ # Do not restore the last open set of tabs if the browser has crashed
+ "browser.sessionstore.resume_from_crash": False,
+ # Disable e10s by default
+ "browser.tabs.remote.autostart": False,
+ # Do not allow background tabs to be zombified, otherwise for tests that
+ # open additional tabs, the test harness tab itself might get unloaded
+ "browser.tabs.disableBackgroundZombification": True,
+ }
+
+ def __init__(
+ self,
+ emulator_binary=None,
+ avd_home=None,
+ avd=None,
+ adb_path=None,
+ serial=None,
+ connect_to_running_emulator=False,
+ package_name=None,
+ env=None,
+ *args,
+ **kwargs
+ ):
+ required_prefs = deepcopy(FennecInstance.fennec_prefs)
+ required_prefs.update(kwargs.get("prefs", {}))
+
+ super(FennecInstance, self).__init__(*args, **kwargs)
+ self.required_prefs.update(required_prefs)
+
+ self.runner_class = FennecEmulatorRunner
+ # runner args
+ self._package_name = package_name
+ self.emulator_binary = emulator_binary
+ self.avd_home = avd_home
+ self.adb_path = adb_path
+ self.avd = avd
+ self.env = env
+ self.serial = serial
+ self.connect_to_running_emulator = connect_to_running_emulator
+
+ @property
+ def package_name(self):
+ """
+ Name of app to run on emulator.
+
+ Note that FennecInstance does not use self.binary
+ """
+ if self._package_name is None:
+ self._package_name = "org.mozilla.fennec"
+ user = os.getenv("USER")
+ if user:
+ self._package_name += "_" + user
+ return self._package_name
+
+ def start(self):
+ self._update_profile(self.profile)
+ self.runner = self.runner_class(**self._get_runner_args())
+ try:
+ if self.connect_to_running_emulator:
+ self.runner.device.connect()
+ self.runner.start()
+ except Exception:
+ exc_cls, exc, tb = sys.exc_info()
+ reraise(
+ exc_cls,
+ exc_cls("Error possibly due to runner or device args: {}".format(exc)),
+ tb,
+ )
+
+ # forward marionette port
+ self.runner.device.device.forward(
+ local="tcp:{}".format(self.marionette_port),
+ remote="tcp:{}".format(self.marionette_port),
+ )
+
+ def _get_runner_args(self):
+ process_args = {
+ "processOutputLine": [NullOutput()],
+ "universal_newlines": True,
+ }
+
+ env = {} if self.env is None else self.env.copy()
+ if self.enable_webrender:
+ env["MOZ_WEBRENDER"] = "1"
+ else:
+ env["MOZ_WEBRENDER"] = "0"
+
+ runner_args = {
+ "app": self.package_name,
+ "avd_home": self.avd_home,
+ "adb_path": self.adb_path,
+ "binary": self.emulator_binary,
+ "env": env,
+ "profile": self.profile,
+ "cmdargs": ["-marionette"] + self.app_args,
+ "symbols_path": self.symbols_path,
+ "process_args": process_args,
+ "logdir": self.workspace or os.getcwd(),
+ "serial": self.serial,
+ }
+ if self.avd:
+ runner_args["avd"] = self.avd
+
+ return runner_args
+
+ def close(self, clean=False):
+ """
+ Close the managed Gecko process.
+
+ If `clean` is True and the Fennec instance is running in an
+ emulator managed by mozrunner, this will stop the emulator.
+
+ :param clean: If True, also perform runner cleanup.
+ """
+ super(FennecInstance, self).close(clean)
+ if clean and self.runner and self.runner.device.connected:
+ try:
+ self.runner.device.device.remove_forwards(
+ "tcp:{}".format(self.marionette_port)
+ )
+ self.unresponsive_count = 0
+ except Exception:
+ self.unresponsive_count += 1
+ traceback.print_exception(*sys.exc_info())
+
+
+class DesktopInstance(GeckoInstance):
+ desktop_prefs = {
+ # Disable Firefox old build background check
+ "app.update.checkInstallTime": False,
+ # Disable automatically upgrading Firefox
+ #
+ # Note: Possible update tests could reset or flip the value to allow
+ # updates to be downloaded and applied.
+ "app.update.disabledForTesting": True,
+ # !!! For backward compatibility up to Firefox 64. Only remove
+ # when this Firefox version is no longer supported by the client !!!
+ "app.update.auto": False,
+ # Don't show the content blocking introduction panel
+ # We use a larger number than the default 22 to have some buffer
+ # This can be removed once Firefox 69 and 68 ESR and are no longer supported.
+ "browser.contentblocking.introCount": 99,
+ # Enable output for dump() and chrome console API
+ "browser.dom.window.dump.enabled": True,
+ "devtools.console.stdout.chrome": True,
+ # Indicate that the download panel has been shown once so that whichever
+ # download test runs first doesn"t show the popup inconsistently
+ "browser.download.panel.shown": True,
+ # Do not show the EULA notification which can interfer with tests
+ "browser.EULA.override": True,
+ # Always display a blank page
+ "browser.newtabpage.enabled": False,
+ # Background thumbnails in particular cause grief, and disabling thumbnails
+ # in general can"t hurt - we re-enable them when tests need them
+ "browser.pagethumbnails.capturing_disabled": True,
+ # Disable safebrowsing components
+ "browser.safebrowsing.blockedURIs.enabled": False,
+ "browser.safebrowsing.downloads.enabled": False,
+ "browser.safebrowsing.passwords.enabled": False,
+ "browser.safebrowsing.malware.enabled": False,
+ "browser.safebrowsing.phishing.enabled": False,
+ # Disable updates to search engines
+ "browser.search.update": False,
+ # Do not restore the last open set of tabs if the browser has crashed
+ "browser.sessionstore.resume_from_crash": False,
+ # Don't check for the default web browser during startup
+ "browser.shell.checkDefaultBrowser": False,
+ # Needed for branded builds to prevent opening a second tab on startup
+ "browser.startup.homepage_override.mstone": "ignore",
+ # Start with a blank page by default
+ "browser.startup.page": 0,
+ # Don't unload tabs when available memory is running low
+ "browser.tabs.unloadOnLowMemory": False,
+ # Do not warn when closing all open tabs
+ "browser.tabs.warnOnClose": False,
+ # Do not warn when closing all other open tabs
+ "browser.tabs.warnOnCloseOtherTabs": False,
+ # Do not warn when multiple tabs will be opened
+ "browser.tabs.warnOnOpen": False,
+ # Don't show the Bookmarks Toolbar on any tab (the above pref that
+ # disables the New Tab Page ends up showing the toolbar on about:blank).
+ "browser.toolbars.bookmarks.visibility": "never",
+ # Disable the UI tour
+ "browser.uitour.enabled": False,
+ # Turn off search suggestions in the location bar so as not to trigger network
+ # connections.
+ "browser.urlbar.suggest.searches": False,
+ # Don't warn when exiting the browser
+ "browser.warnOnQuit": False,
+ # Only allow the old modal dialogs. This should be removed when there is
+ # support for the new modal UI (see Bug 1686741).
+ "prompts.contentPromptSubDialog": False,
+ # Disable first-run welcome page
+ "startup.homepage_welcome_url": "about:blank",
+ "startup.homepage_welcome_url.additional": "",
+ }
+
+ def __init__(self, *args, **kwargs):
+ required_prefs = deepcopy(DesktopInstance.desktop_prefs)
+ required_prefs.update(kwargs.get("prefs", {}))
+
+ super(DesktopInstance, self).__init__(*args, **kwargs)
+ self.required_prefs.update(required_prefs)
+
+
+class ThunderbirdInstance(GeckoInstance):
+ def __init__(self, *args, **kwargs):
+ super(ThunderbirdInstance, self).__init__(*args, **kwargs)
+ try:
+ # Copied alongside in the test archive
+ from .thunderbirdinstance import thunderbird_prefs
+ except ImportError:
+ try:
+ # Coming from source tree through virtualenv
+ from thunderbirdinstance import thunderbird_prefs
+ except ImportError:
+ thunderbird_prefs = {}
+ self.required_prefs.update(thunderbird_prefs)
+
+
+class NullOutput(object):
+ def __call__(self, line):
+ pass
+
+
+apps = {
+ "fennec": FennecInstance,
+ "fxdesktop": DesktopInstance,
+ "thunderbird": ThunderbirdInstance,
+}
+
+app_ids = {
+ "{aa3c5121-dab2-40e2-81ca-7ea25febc110}": "fennec",
+ "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}": "fxdesktop",
+ "{3550f703-e582-4d05-9a08-453d09bdfdc6}": "thunderbird",
+}
diff --git a/testing/marionette/client/marionette_driver/keys.py b/testing/marionette/client/marionette_driver/keys.py
new file mode 100644
index 0000000000..205e9556e2
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/keys.py
@@ -0,0 +1,90 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# copyright 2008-2009 WebDriver committers
+# Copyright 2008-2009 Google Inc.
+#
+# Licensed under the Apache License Version 2.0 = uthe "License")
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http //www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing software
+# distributed under the License is distributed on an "AS IS" BASIS
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import absolute_import
+
+
+class Keys(object):
+
+ NULL = u"\ue000"
+ CANCEL = u"\ue001" # ^break
+ HELP = u"\ue002"
+ BACK_SPACE = u"\ue003"
+ TAB = u"\ue004"
+ CLEAR = u"\ue005"
+ RETURN = u"\ue006"
+ ENTER = u"\ue007"
+ SHIFT = u"\ue008"
+ LEFT_SHIFT = u"\ue008" # alias
+ CONTROL = u"\ue009"
+ LEFT_CONTROL = u"\ue009" # alias
+ ALT = u"\ue00a"
+ LEFT_ALT = u"\ue00a" # alias
+ PAUSE = u"\ue00b"
+ ESCAPE = u"\ue00c"
+ SPACE = u"\ue00d"
+ PAGE_UP = u"\ue00e"
+ PAGE_DOWN = u"\ue00f"
+ END = u"\ue010"
+ HOME = u"\ue011"
+ LEFT = u"\ue012"
+ ARROW_LEFT = u"\ue012" # alias
+ UP = u"\ue013"
+ ARROW_UP = u"\ue013" # alias
+ RIGHT = u"\ue014"
+ ARROW_RIGHT = u"\ue014" # alias
+ DOWN = u"\ue015"
+ ARROW_DOWN = u"\ue015" # alias
+ INSERT = u"\ue016"
+ DELETE = u"\ue017"
+ SEMICOLON = u"\ue018"
+ EQUALS = u"\ue019"
+
+ NUMPAD0 = u"\ue01a" # numbe pad keys
+ NUMPAD1 = u"\ue01b"
+ NUMPAD2 = u"\ue01c"
+ NUMPAD3 = u"\ue01d"
+ NUMPAD4 = u"\ue01e"
+ NUMPAD5 = u"\ue01f"
+ NUMPAD6 = u"\ue020"
+ NUMPAD7 = u"\ue021"
+ NUMPAD8 = u"\ue022"
+ NUMPAD9 = u"\ue023"
+ MULTIPLY = u"\ue024"
+ ADD = u"\ue025"
+ SEPARATOR = u"\ue026"
+ SUBTRACT = u"\ue027"
+ DECIMAL = u"\ue028"
+ DIVIDE = u"\ue029"
+
+ F1 = u"\ue031" # function keys
+ F2 = u"\ue032"
+ F3 = u"\ue033"
+ F4 = u"\ue034"
+ F5 = u"\ue035"
+ F6 = u"\ue036"
+ F7 = u"\ue037"
+ F8 = u"\ue038"
+ F9 = u"\ue039"
+ F10 = u"\ue03a"
+ F11 = u"\ue03b"
+ F12 = u"\ue03c"
+
+ META = u"\ue03d"
+ COMMAND = u"\ue03d"
diff --git a/testing/marionette/client/marionette_driver/localization.py b/testing/marionette/client/marionette_driver/localization.py
new file mode 100644
index 0000000000..5270ff5ffb
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/localization.py
@@ -0,0 +1,56 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+
+class L10n(object):
+ """An API which allows Marionette to handle localized content.
+
+ The `localization`_ of UI elements in Gecko based applications is done via
+ entities and properties. For static values entities are used, which are located
+ in .dtd files. Whereby for dynamically updated content the values come from
+ .property files. Both types of elements can be identifed via a unique id,
+ and the translated content retrieved.
+
+ For example::
+
+ from marionette_driver.localization import L10n
+ l10n = L10n(marionette)
+
+ l10n.localize_entity(["chrome://branding/locale/brand.dtd"], "brandShortName")
+ l10n.localize_property(["chrome://global/locale/findbar.properties"], "FastFind"))
+
+ .. _localization: https://mzl.la/2eUMjyF
+ """
+
+ def __init__(self, marionette):
+ self._marionette = marionette
+
+ def localize_entity(self, dtd_urls, entity_id):
+ """Retrieve the localized string for the specified entity id.
+
+ :param dtd_urls: List of .dtd URLs which will be used to search for the entity.
+ :param entity_id: ID of the entity to retrieve the localized string for.
+
+ :returns: The localized string for the requested entity.
+ :raises: :exc:`NoSuchElementException`
+ """
+ body = {"urls": dtd_urls, "id": entity_id}
+ return self._marionette._send_message("L10n:LocalizeEntity", body, key="value")
+
+ def localize_property(self, properties_urls, property_id):
+ """Retrieve the localized string for the specified property id.
+
+ :param properties_urls: List of .properties URLs which will be used to
+ search for the property.
+ :param property_id: ID of the property to retrieve the localized string for.
+
+ :returns: The localized string for the requested property.
+ :raises: :exc:`NoSuchElementException`
+ """
+ body = {"urls": properties_urls, "id": property_id}
+ return self._marionette._send_message(
+ "L10n:LocalizeProperty", body, key="value"
+ )
diff --git a/testing/marionette/client/marionette_driver/marionette.py b/testing/marionette/client/marionette_driver/marionette.py
new file mode 100644
index 0000000000..ce3f67f19d
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/marionette.py
@@ -0,0 +1,1984 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import, division
+
+import base64
+import datetime
+import json
+import os
+import socket
+import sys
+import time
+import traceback
+
+from contextlib import contextmanager
+
+import six
+from six import reraise
+
+from . import errors
+from . import transport
+from .decorators import do_process_check
+from .geckoinstance import GeckoInstance
+from .keys import Keys
+from .timeout import Timeouts
+
+CHROME_ELEMENT_KEY = "chromeelement-9fc5-4b51-a3c8-01716eedeb04"
+FRAME_KEY = "frame-075b-4da1-b6ba-e579c2d3230a"
+WEB_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf"
+WINDOW_KEY = "window-fcc6-11e5-b4f8-330a88ab9d7f"
+
+
+class MouseButton(object):
+ """Enum-like class for mouse button constants."""
+
+ LEFT = 0
+ MIDDLE = 1
+ RIGHT = 2
+
+
+class ActionSequence(object):
+ r"""API for creating and performing action sequences.
+
+ Each action method adds one or more actions to a queue. When perform()
+ is called, the queued actions fire in order.
+
+ May be chained together as in::
+
+ ActionSequence(self.marionette, "key", id) \
+ .key_down("a") \
+ .key_up("a") \
+ .perform()
+ """
+
+ def __init__(self, marionette, action_type, input_id, pointer_params=None):
+ self.marionette = marionette
+ self._actions = []
+ self._id = input_id
+ self._pointer_params = pointer_params
+ self._type = action_type
+
+ @property
+ def dict(self):
+ d = {
+ "type": self._type,
+ "id": self._id,
+ "actions": self._actions,
+ }
+ if self._pointer_params is not None:
+ d["parameters"] = self._pointer_params
+ return d
+
+ def perform(self):
+ """Perform all queued actions."""
+ self.marionette.actions.perform([self.dict])
+
+ def _key_action(self, subtype, value):
+ self._actions.append({"type": subtype, "value": value})
+
+ def _pointer_action(self, subtype, button):
+ self._actions.append({"type": subtype, "button": button})
+
+ def pause(self, duration):
+ self._actions.append({"type": "pause", "duration": duration})
+ return self
+
+ def pointer_move(self, x, y, duration=None, origin=None):
+ """Queue a pointerMove action.
+
+ :param x: Destination x-axis coordinate of pointer in CSS pixels.
+ :param y: Destination y-axis coordinate of pointer in CSS pixels.
+ :param duration: Number of milliseconds over which to distribute the
+ move. If None, remote end defaults to 0.
+ :param origin: Origin of coordinates, either "viewport", "pointer" or
+ an Element. If None, remote end defaults to "viewport".
+ """
+ action = {"type": "pointerMove", "x": x, "y": y}
+ if duration is not None:
+ action["duration"] = duration
+ if origin is not None:
+ if isinstance(origin, HTMLElement):
+ action["origin"] = {origin.kind: origin.id}
+ else:
+ action["origin"] = origin
+ self._actions.append(action)
+ return self
+
+ def pointer_up(self, button=MouseButton.LEFT):
+ """Queue a pointerUp action for `button`.
+
+ :param button: Pointer button to perform action with.
+ Default: 0, which represents main device button.
+ """
+ self._pointer_action("pointerUp", button)
+ return self
+
+ def pointer_down(self, button=MouseButton.LEFT):
+ """Queue a pointerDown action for `button`.
+
+ :param button: Pointer button to perform action with.
+ Default: 0, which represents main device button.
+ """
+ self._pointer_action("pointerDown", button)
+ return self
+
+ def click(self, element=None, button=MouseButton.LEFT):
+ """Queue a click with the specified button.
+
+ If an element is given, move the pointer to that element first,
+ otherwise click current pointer coordinates.
+
+ :param element: Optional element to click.
+ :param button: Integer representing pointer button to perform action
+ with. Default: 0, which represents main device button.
+ """
+ if element:
+ self.pointer_move(0, 0, origin=element)
+ return self.pointer_down(button).pointer_up(button)
+
+ def key_down(self, value):
+ """Queue a keyDown action for `value`.
+
+ :param value: Single character to perform key action with.
+ """
+ self._key_action("keyDown", value)
+ return self
+
+ def key_up(self, value):
+ """Queue a keyUp action for `value`.
+
+ :param value: Single character to perform key action with.
+ """
+ self._key_action("keyUp", value)
+ return self
+
+ def send_keys(self, keys):
+ """Queue a keyDown and keyUp action for each character in `keys`.
+
+ :param keys: String of keys to perform key actions with.
+ """
+ for c in keys:
+ self.key_down(c)
+ self.key_up(c)
+ return self
+
+
+class Actions(object):
+ def __init__(self, marionette):
+ self.marionette = marionette
+
+ def perform(self, actions=None):
+ """Perform actions by tick from each action sequence in `actions`.
+
+ :param actions: List of input source action sequences. A single action
+ sequence may be created with the help of
+ ``ActionSequence.dict``.
+ """
+ body = {"actions": [] if actions is None else actions}
+ return self.marionette._send_message("WebDriver:PerformActions", body)
+
+ def release(self):
+ return self.marionette._send_message("WebDriver:ReleaseActions")
+
+ def sequence(self, *args, **kwargs):
+ """Return an empty ActionSequence of the designated type.
+
+ See ActionSequence for parameter list.
+ """
+ return ActionSequence(self.marionette, *args, **kwargs)
+
+
+class HTMLElement(object):
+ """Represents a DOM Element."""
+
+ identifiers = (CHROME_ELEMENT_KEY, FRAME_KEY, WINDOW_KEY, WEB_ELEMENT_KEY)
+
+ def __init__(self, marionette, id, kind=WEB_ELEMENT_KEY):
+ self.marionette = marionette
+ assert id is not None
+ self.id = id
+ self.kind = kind
+
+ def __str__(self):
+ return self.id
+
+ def __eq__(self, other_element):
+ return self.id == other_element.id
+
+ def __hash__(self):
+ # pylint --py3k: W1641
+ return hash(self.id)
+
+ def find_element(self, method, target):
+ """Returns an ``HTMLElement`` instance that matches the specified
+ method and target, relative to the current element.
+
+ For more details on this function, see the
+ :func:`~marionette_driver.marionette.Marionette.find_element` method
+ in the Marionette class.
+ """
+ return self.marionette.find_element(method, target, self.id)
+
+ def find_elements(self, method, target):
+ """Returns a list of all ``HTMLElement`` instances that match the
+ specified method and target in the current context.
+
+ For more details on this function, see the
+ :func:`~marionette_driver.marionette.Marionette.find_elements` method
+ in the Marionette class.
+ """
+ return self.marionette.find_elements(method, target, self.id)
+
+ def get_attribute(self, name):
+ """Returns the requested attribute, or None if no attribute
+ is set.
+ """
+ body = {"id": self.id, "name": name}
+ return self.marionette._send_message(
+ "WebDriver:GetElementAttribute", body, key="value"
+ )
+
+ def get_property(self, name):
+ """Returns the requested property, or None if the property is
+ not set.
+ """
+ try:
+ body = {"id": self.id, "name": name}
+ return self.marionette._send_message(
+ "WebDriver:GetElementProperty", body, key="value"
+ )
+ except errors.UnknownCommandException:
+ # Keep backward compatibility for code which uses get_attribute() to
+ # also retrieve element properties.
+ # Remove when Firefox 55 is stable.
+ return self.get_attribute(name)
+
+ def click(self):
+ """Simulates a click on the element."""
+ self.marionette._send_message("WebDriver:ElementClick", {"id": self.id})
+
+ def tap(self, x=None, y=None):
+ """Simulates a set of tap events on the element.
+
+ :param x: X coordinate of tap event. If not given, default to
+ the centre of the element.
+ :param y: Y coordinate of tap event. If not given, default to
+ the centre of the element.
+ """
+ body = {"id": self.id, "x": x, "y": y}
+ self.marionette._send_message("Marionette:SingleTap", body)
+
+ @property
+ def text(self):
+ """Returns the visible text of the element, and its child elements."""
+ body = {"id": self.id}
+ return self.marionette._send_message(
+ "WebDriver:GetElementText", body, key="value"
+ )
+
+ def send_keys(self, *strings):
+ """Sends the string via synthesized keypresses to the element.
+ If an array is passed in like `marionette.send_keys(Keys.SHIFT, "a")` it
+ will be joined into a string.
+ If an integer is passed in like `marionette.send_keys(1234)` it will be
+ coerced into a string.
+ """
+ keys = Marionette.convert_keys(*strings)
+ self.marionette._send_message(
+ "WebDriver:ElementSendKeys", {"id": self.id, "text": keys}
+ )
+
+ def clear(self):
+ """Clears the input of the element."""
+ self.marionette._send_message("WebDriver:ElementClear", {"id": self.id})
+
+ def is_selected(self):
+ """Returns True if the element is selected."""
+ body = {"id": self.id}
+ return self.marionette._send_message(
+ "WebDriver:IsElementSelected", body, key="value"
+ )
+
+ def is_enabled(self):
+ """This command will return False if all the following criteria
+ are met otherwise return True:
+
+ * A form control is disabled.
+ * A ``HTMLElement`` has a disabled boolean attribute.
+ """
+ body = {"id": self.id}
+ return self.marionette._send_message(
+ "WebDriver:IsElementEnabled", body, key="value"
+ )
+
+ def is_displayed(self):
+ """Returns True if the element is displayed, False otherwise."""
+ body = {"id": self.id}
+ return self.marionette._send_message(
+ "WebDriver:IsElementDisplayed", body, key="value"
+ )
+
+ @property
+ def tag_name(self):
+ """The tag name of the element."""
+ body = {"id": self.id}
+ return self.marionette._send_message(
+ "WebDriver:GetElementTagName", body, key="value"
+ )
+
+ @property
+ def rect(self):
+ """Gets the element's bounding rectangle.
+
+ This will return a dictionary with the following:
+
+ * x and y represent the top left coordinates of the ``HTMLElement``
+ relative to top left corner of the document.
+ * height and the width will contain the height and the width
+ of the DOMRect of the ``HTMLElement``.
+ """
+ return self.marionette._send_message(
+ "WebDriver:GetElementRect", {"id": self.id}
+ )
+
+ def value_of_css_property(self, property_name):
+ """Gets the value of the specified CSS property name.
+
+ :param property_name: Property name to get the value of.
+ """
+ body = {"id": self.id, "propertyName": property_name}
+ return self.marionette._send_message(
+ "WebDriver:GetElementCSSValue", body, key="value"
+ )
+
+ @classmethod
+ def _from_json(cls, json, marionette):
+ if isinstance(json, dict):
+ if WEB_ELEMENT_KEY in json:
+ return cls(marionette, json[WEB_ELEMENT_KEY], WEB_ELEMENT_KEY)
+ elif CHROME_ELEMENT_KEY in json:
+ return cls(marionette, json[CHROME_ELEMENT_KEY], CHROME_ELEMENT_KEY)
+ elif FRAME_KEY in json:
+ return cls(marionette, json[FRAME_KEY], FRAME_KEY)
+ elif WINDOW_KEY in json:
+ return cls(marionette, json[WINDOW_KEY], WINDOW_KEY)
+ raise ValueError("Unrecognised web element")
+
+
+class Alert(object):
+ """A class for interacting with alerts.
+
+ ::
+
+ Alert(marionette).accept()
+ Alert(marionette).dismiss()
+ """
+
+ def __init__(self, marionette):
+ self.marionette = marionette
+
+ def accept(self):
+ """Accept a currently displayed modal dialog."""
+ self.marionette._send_message("WebDriver:AcceptAlert")
+
+ def dismiss(self):
+ """Dismiss a currently displayed modal dialog."""
+ self.marionette._send_message("WebDriver:DismissAlert")
+
+ @property
+ def text(self):
+ """Return the currently displayed text in a tab modal."""
+ return self.marionette._send_message("WebDriver:GetAlertText", key="value")
+
+ def send_keys(self, *string):
+ """Send keys to the currently displayed text input area in an open
+ tab modal dialog."""
+ self.marionette._send_message(
+ "WebDriver:SendAlertText", {"text": Marionette.convert_keys(*string)}
+ )
+
+
+class Marionette(object):
+ """Represents a Marionette connection to a browser or device."""
+
+ CONTEXT_CHROME = "chrome" # non-browser content: windows, dialogs, etc.
+ CONTEXT_CONTENT = "content" # browser content: iframes, divs, etc.
+ DEFAULT_STARTUP_TIMEOUT = 120
+ DEFAULT_SHUTDOWN_TIMEOUT = (
+ 70 # By default Firefox will kill hanging threads after 60s
+ )
+
+ # Bug 1336953 - Until we can remove the socket timeout parameter it has to be
+ # set a default value which is larger than the longest timeout as defined by the
+ # WebDriver spec. In that case its 300s for page load. Also add another minute
+ # so that slow builds have enough time to send the timeout error to the client.
+ DEFAULT_SOCKET_TIMEOUT = 360
+
+ def __init__(
+ self,
+ host="127.0.0.1",
+ port=2828,
+ app=None,
+ bin=None,
+ baseurl=None,
+ socket_timeout=None,
+ startup_timeout=None,
+ **instance_args
+ ):
+ """Construct a holder for the Marionette connection.
+
+ Remember to call ``start_session`` in order to initiate the
+ connection and start a Marionette session.
+
+ :param host: Host where the Marionette server listens.
+ Defaults to 127.0.0.1.
+ :param port: Port where the Marionette server listens.
+ Defaults to port 2828.
+ :param baseurl: Where to look for files served from Marionette's
+ www directory.
+ :param socket_timeout: Timeout for Marionette socket operations.
+ :param startup_timeout: Seconds to wait for a connection with
+ binary.
+ :param bin: Path to browser binary. If any truthy value is given
+ this will attempt to start a Gecko instance with the specified
+ `app`.
+ :param app: Type of ``instance_class`` to use for managing app
+ instance. See ``marionette_driver.geckoinstance``.
+ :param instance_args: Arguments to pass to ``instance_class``.
+
+ """
+ self.host = "127.0.0.1" # host
+ if int(port) == 0:
+ port = Marionette.check_port_available(port)
+ self.port = self.local_port = int(port)
+ self.bin = bin
+ self.client = None
+ self.instance = None
+ self.session = None
+ self.session_id = None
+ self.process_id = None
+ self.profile = None
+ self.window = None
+ self.chrome_window = None
+ self.baseurl = baseurl
+ self._test_name = None
+ self.crashed = 0
+ self.is_shutting_down = False
+
+ if socket_timeout is None:
+ self.socket_timeout = self.DEFAULT_SOCKET_TIMEOUT
+ else:
+ self.socket_timeout = float(socket_timeout)
+
+ if startup_timeout is None:
+ self.startup_timeout = self.DEFAULT_STARTUP_TIMEOUT
+ else:
+ self.startup_timeout = int(startup_timeout)
+
+ self.shutdown_timeout = self.DEFAULT_SHUTDOWN_TIMEOUT
+
+ if self.bin:
+ self.instance = GeckoInstance.create(
+ app, host=self.host, port=self.port, bin=self.bin, **instance_args
+ )
+ self.start_binary(self.startup_timeout)
+
+ self.actions = Actions(self)
+ self.timeout = Timeouts(self)
+
+ @property
+ def profile_path(self):
+ if self.instance and self.instance.profile:
+ return self.instance.profile.profile
+
+ def start_binary(self, timeout):
+ try:
+ self.check_port_available(self.port, host=self.host)
+ except socket.error:
+ _, value, tb = sys.exc_info()
+ msg = "Port {}:{} is unavailable ({})".format(self.host, self.port, value)
+ reraise(IOError, IOError(msg), tb)
+
+ try:
+ self.instance.start()
+ self.raise_for_port(timeout=timeout)
+ except socket.timeout:
+ # Something went wrong with starting up Marionette server. Given
+ # that the process will not quit itself, force a shutdown immediately.
+ self.cleanup()
+
+ msg = (
+ "Process killed after {}s because no connection to Marionette "
+ "server could be established. Check gecko.log for errors"
+ )
+ reraise(IOError, IOError(msg.format(timeout)), sys.exc_info()[2])
+
+ def cleanup(self):
+ if self.session is not None:
+ try:
+ self.delete_session()
+ except (errors.MarionetteException, IOError):
+ # These exceptions get thrown if the Marionette server
+ # hit an exception/died or the connection died. We can
+ # do no further server-side cleanup in this case.
+ pass
+ if self.instance:
+ # stop application and, if applicable, stop emulator
+ self.instance.close(clean=True)
+ if self.instance.unresponsive_count >= 3:
+ raise errors.UnresponsiveInstanceException(
+ "Application clean-up has failed >2 consecutive times."
+ )
+
+ def __del__(self):
+ self.cleanup()
+
+ @staticmethod
+ def check_port_available(port, host=""):
+ """Check if "host:port" is available.
+
+ Raise socket.error if port is not available.
+ """
+ port = int(port)
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ s.bind((host, port))
+ port = s.getsockname()[1]
+ finally:
+ s.close()
+ return port
+
+ def raise_for_port(self, timeout=None, check_process_status=True):
+ """Raise socket.timeout if no connection can be established.
+
+ :param timeout: Optional timeout in seconds for the server to be ready.
+ :param check_process_status: Optional, if `True` the process will be
+ continuously checked if it has exited, and the connection
+ attempt will be aborted.
+ """
+ if timeout is None:
+ timeout = self.startup_timeout
+
+ runner = None
+ if self.instance is not None:
+ runner = self.instance.runner
+
+ poll_interval = 0.1
+ starttime = datetime.datetime.now()
+ timeout_time = starttime + datetime.timedelta(seconds=timeout)
+
+ client = transport.TcpTransport(self.host, self.port, 0.5)
+
+ connected = False
+ while datetime.datetime.now() < timeout_time:
+ # If the instance we want to connect to is not running return immediately
+ if check_process_status and runner is not None and not runner.is_running():
+ break
+
+ try:
+ client.connect()
+ return True
+ except socket.error:
+ pass
+ finally:
+ client.close()
+
+ time.sleep(poll_interval)
+
+ if not connected:
+ # There might have been a startup crash of the application
+ if runner is not None and self.check_for_crash() > 0:
+ raise IOError("Process crashed (Exit code: {})".format(runner.wait(0)))
+
+ raise socket.timeout(
+ "Timed out waiting for connection on {0}:{1}!".format(
+ self.host, self.port
+ )
+ )
+
+ @do_process_check
+ def _send_message(self, name, params=None, key=None):
+ """Send a blocking message to the server.
+
+ Marionette provides an asynchronous, non-blocking interface and
+ this attempts to paper over this by providing a synchronous API
+ to the user.
+
+ :param name: Requested command key.
+ :param params: Optional dictionary of key/value arguments.
+ :param key: Optional key to extract from response.
+
+ :returns: Full response from the server, or if `key` is given,
+ the value of said key in the response.
+ """
+ if not self.session_id and name != "WebDriver:NewSession":
+ raise errors.InvalidSessionIdException("Please start a session")
+
+ try:
+ msg = self.client.request(name, params)
+
+ except IOError:
+ self.delete_session(send_request=False)
+ raise
+
+ res, err = msg.result, msg.error
+ if err:
+ self._handle_error(err)
+
+ if key is not None:
+ return self._unwrap_response(res.get(key))
+ else:
+ return self._unwrap_response(res)
+
+ def _unwrap_response(self, value):
+ if isinstance(value, dict) and any(
+ k in value.keys() for k in HTMLElement.identifiers
+ ):
+ return HTMLElement._from_json(value, self)
+ elif isinstance(value, list):
+ return list(self._unwrap_response(item) for item in value)
+ else:
+ return value
+
+ def _handle_error(self, obj):
+ error = obj["error"]
+ message = obj["message"]
+ stacktrace = obj["stacktrace"]
+
+ raise errors.lookup(error)(message, stacktrace=stacktrace)
+
+ def check_for_crash(self):
+ """Check if the process crashed.
+
+ :returns: True, if a crash happened since the method has been called the last time.
+ """
+ crash_count = 0
+
+ if self.instance:
+ name = self.test_name or "marionette.py"
+ crash_count = self.instance.runner.check_for_crashes(test_name=name)
+ self.crashed = self.crashed + crash_count
+
+ return crash_count > 0
+
+ def _handle_socket_failure(self):
+ """Handle socket failures for the currently connected application.
+
+ If the application crashed then clean-up internal states, or in case of a content
+ crash also kill the process. If there are other reasons for a socket failure,
+ wait for the process to shutdown itself, or force kill it.
+
+ Please note that the method expects an exception to be handled on the current stack
+ frame, and is only called via the `@do_process_check` decorator.
+
+ """
+ exc_cls, exc, tb = sys.exc_info()
+
+ # If the application hasn't been launched by Marionette no further action can be done.
+ # In such cases we simply re-throw the exception.
+ if not self.instance:
+ reraise(exc_cls, exc, tb)
+
+ else:
+ # Somehow the socket disconnected. Give the application some time to shutdown
+ # itself before killing the process.
+ returncode = self.instance.runner.wait(timeout=self.shutdown_timeout)
+
+ if returncode is None:
+ message = (
+ "Process killed because the connection to Marionette server is "
+ "lost. Check gecko.log for errors"
+ )
+ # This will force-close the application without sending any other message.
+ self.cleanup()
+ else:
+ # If Firefox quit itself check if there was a crash
+ crash_count = self.check_for_crash()
+
+ if crash_count > 0:
+ if returncode == 0:
+ message = "Content process crashed"
+ else:
+ message = "Process crashed (Exit code: {returncode})"
+ else:
+ message = (
+ "Process has been unexpectedly closed (Exit code: {returncode})"
+ )
+
+ self.delete_session(send_request=False)
+
+ message += " (Reason: {reason})"
+
+ reraise(
+ IOError, IOError(message.format(returncode=returncode, reason=exc)), tb
+ )
+
+ @staticmethod
+ def convert_keys(*string):
+ typing = []
+ for val in string:
+ if isinstance(val, Keys):
+ typing.append(val)
+ elif isinstance(val, int):
+ val = str(val)
+ for i in range(len(val)):
+ typing.append(val[i])
+ else:
+ for i in range(len(val)):
+ typing.append(val[i])
+ return "".join(typing)
+
+ def clear_pref(self, pref):
+ """Clear the user-defined value from the specified preference.
+
+ :param pref: Name of the preference.
+ """
+ with self.using_context(self.CONTEXT_CHROME):
+ self.execute_script(
+ """
+ Components.utils.import("resource://gre/modules/Preferences.jsm");
+ Preferences.reset(arguments[0]);
+ """,
+ script_args=(pref,),
+ )
+
+ def get_pref(self, pref, default_branch=False, value_type="unspecified"):
+ """Get the value of the specified preference.
+
+ :param pref: Name of the preference.
+ :param default_branch: Optional, if `True` the preference value will be read
+ from the default branch. Otherwise the user-defined
+ value if set is returned. Defaults to `False`.
+ :param value_type: Optional, XPCOM interface of the pref's complex value.
+ Possible values are: `nsIFile` and
+ `nsIPrefLocalizedString`.
+
+ Usage example::
+
+ marionette.get_pref("browser.tabs.warnOnClose")
+
+ """
+ with self.using_context(self.CONTEXT_CHROME):
+ pref_value = self.execute_script(
+ """
+ Components.utils.import("resource://gre/modules/Preferences.jsm");
+
+ let pref = arguments[0];
+ let defaultBranch = arguments[1];
+ let valueType = arguments[2];
+
+ prefs = new Preferences({defaultBranch: defaultBranch});
+ return prefs.get(pref, null, Components.interfaces[valueType]);
+ """,
+ script_args=(pref, default_branch, value_type),
+ )
+ return pref_value
+
+ def set_pref(self, pref, value, default_branch=False):
+ """Set the value of the specified preference.
+
+ :param pref: Name of the preference.
+ :param value: The value to set the preference to. If the value is None,
+ reset the preference to its default value. If no default
+ value exists, the preference will cease to exist.
+ :param default_branch: Optional, if `True` the preference value will
+ be written to the default branch, and will remain until
+ the application gets restarted. Otherwise a user-defined
+ value is set. Defaults to `False`.
+
+ Usage example::
+
+ marionette.set_pref("browser.tabs.warnOnClose", True)
+
+ """
+ with self.using_context(self.CONTEXT_CHROME):
+ if value is None:
+ self.clear_pref(pref)
+ return
+
+ self.execute_script(
+ """
+ Components.utils.import("resource://gre/modules/Preferences.jsm");
+
+ let pref = arguments[0];
+ let value = arguments[1];
+ let defaultBranch = arguments[2];
+
+ prefs = new Preferences({defaultBranch: defaultBranch});
+ prefs.set(pref, value);
+ """,
+ script_args=(pref, value, default_branch),
+ )
+
+ def set_prefs(self, prefs, default_branch=False):
+ """Set the value of a list of preferences.
+
+ :param prefs: A dict containing one or more preferences and their values
+ to be set. See :func:`set_pref` for further details.
+ :param default_branch: Optional, if `True` the preference value will
+ be written to the default branch, and will remain until
+ the application gets restarted. Otherwise a user-defined
+ value is set. Defaults to `False`.
+
+ Usage example::
+
+ marionette.set_prefs({"browser.tabs.warnOnClose": True})
+
+ """
+ for pref, value in prefs.items():
+ self.set_pref(pref, value, default_branch=default_branch)
+
+ @contextmanager
+ def using_prefs(self, prefs, default_branch=False):
+ """Set preferences for code executed in a `with` block, and restores them on exit.
+
+ :param prefs: A dict containing one or more preferences and their values
+ to be set. See :func:`set_prefs` for further details.
+ :param default_branch: Optional, if `True` the preference value will
+ be written to the default branch, and will remain until
+ the application gets restarted. Otherwise a user-defined
+ value is set. Defaults to `False`.
+
+ Usage example::
+
+ with marionette.using_prefs({"browser.tabs.warnOnClose": True}):
+ # ... do stuff ...
+
+ """
+ original_prefs = {p: self.get_pref(p) for p in prefs}
+ self.set_prefs(prefs, default_branch=default_branch)
+
+ try:
+ yield
+ finally:
+ self.set_prefs(original_prefs, default_branch=default_branch)
+
+ @do_process_check
+ def enforce_gecko_prefs(self, prefs):
+ """Checks if the running instance has the given prefs. If not,
+ it will kill the currently running instance, and spawn a new
+ instance with the requested preferences.
+
+ :param prefs: A dictionary whose keys are preference names.
+ """
+ if not self.instance:
+ raise errors.MarionetteException(
+ "enforce_gecko_prefs() can only be called "
+ "on Gecko instances launched by Marionette"
+ )
+ pref_exists = True
+ with self.using_context(self.CONTEXT_CHROME):
+ for pref, value in six.iteritems(prefs):
+ if type(value) is not str:
+ value = json.dumps(value)
+ pref_exists = self.execute_script(
+ """
+ let prefInterface = Components.classes["@mozilla.org/preferences-service;1"]
+ .getService(Components.interfaces.nsIPrefBranch);
+ let pref = '{0}';
+ let value = '{1}';
+ let type = prefInterface.getPrefType(pref);
+ switch(type) {{
+ case prefInterface.PREF_STRING:
+ return value == prefInterface.getCharPref(pref).toString();
+ case prefInterface.PREF_BOOL:
+ return value == prefInterface.getBoolPref(pref).toString();
+ case prefInterface.PREF_INT:
+ return value == prefInterface.getIntPref(pref).toString();
+ case prefInterface.PREF_INVALID:
+ return false;
+ }}
+ """.format(
+ pref, value
+ )
+ )
+ if not pref_exists:
+ break
+
+ if not pref_exists:
+ context = self._send_message("Marionette:GetContext", key="value")
+ self.delete_session()
+ self.instance.restart(prefs)
+ self.raise_for_port()
+ self.start_session()
+
+ # Restore the context as used before the restart
+ self.set_context(context)
+
+ def _request_in_app_shutdown(self, *shutdown_flags):
+ """Attempt to quit the currently running instance from inside the
+ application.
+
+ Duplicate entries in `shutdown_flags` are removed, and
+ `"eForceQuit"` is added if no other `*Quit` flags are given.
+ This provides backwards compatible behaviour with earlier
+ Firefoxen.
+
+ This method effectively calls `Services.startup.quit` in Gecko.
+ Possible flag values are listed at http://mzl.la/1X0JZsC.
+
+ :param shutdown_flags: Optional additional quit masks to include.
+ Duplicates are removed, and `"eForceQuit"` is added if no
+ flags ending with `"Quit"` are present.
+
+ :throws InvalidArgumentException: If there are multiple
+ `shutdown_flags` ending with `"Quit"`.
+
+ :returns: The cause of shutdown.
+ """
+
+ # The vast majority of this function was implemented inside
+ # the quit command as part of bug 1337743, and can be
+ # removed from here in Firefox 55 at the earliest.
+
+ # remove duplicates
+ flags = set(shutdown_flags)
+
+ # add eForceQuit if there are no *Quits
+ if not any(flag.endswith("Quit") for flag in flags):
+ flags = flags | set(("eForceQuit",))
+
+ # Trigger a quit-application-requested observer notification
+ # so that components can safely shutdown before quitting the
+ # application.
+ with self.using_context("chrome"):
+ canceled = self.execute_script(
+ """
+ Components.utils.import("resource://gre/modules/Services.jsm");
+ let cancelQuit = Components.classes["@mozilla.org/supports-PRBool;1"]
+ .createInstance(Components.interfaces.nsISupportsPRBool);
+ Services.obs.notifyObservers(cancelQuit, "quit-application-requested", null);
+ return cancelQuit.data;
+ """
+ )
+ if canceled:
+ raise errors.MarionetteException(
+ "Something cancelled the quit application request"
+ )
+
+ body = None
+ if len(flags) > 0:
+ body = {"flags": list(flags)}
+
+ return self._send_message("Marionette:Quit", body, key="cause")
+
+ @do_process_check
+ def quit(self, clean=False, in_app=False, callback=None):
+ """Terminate the currently running instance.
+
+ This command will delete the active marionette session. It also allows
+ manipulation of eg. the profile data while the application is not running.
+ To start the application again, :func:`start_session` has to be called.
+
+ :param clean: If False the same profile will be used after the next start of
+ the application. Note that the in app initiated restart always
+ maintains the same profile.
+ :param in_app: If True, marionette will cause a quit from within the
+ browser. Otherwise the browser will be quit immediately
+ by killing the process.
+ :param callback: If provided and `in_app` is True, the callback will
+ be used to trigger the shutdown.
+ """
+ if not self.instance:
+ raise errors.MarionetteException(
+ "quit() can only be called " "on Gecko instances launched by Marionette"
+ )
+
+ cause = None
+ if in_app:
+ if callback is not None and not callable(callback):
+ raise ValueError(
+ "Specified callback '{}' is not callable".format(callback)
+ )
+
+ # Block Marionette from accepting new connections
+ self._send_message("Marionette:AcceptConnections", {"value": False})
+
+ try:
+ self.is_shutting_down = True
+ if callback is not None:
+ callback()
+ else:
+ cause = self._request_in_app_shutdown()
+
+ except IOError:
+ # A possible IOError should be ignored at this point, given that
+ # quit() could have been called inside of `using_context`,
+ # which wants to reset the context but fails sending the message.
+ pass
+
+ returncode = self.instance.runner.wait(timeout=self.shutdown_timeout)
+ if returncode is None:
+ # The process did not shutdown itself, so force-closing it.
+ self.cleanup()
+
+ message = "Process still running {}s after quit request"
+ raise IOError(message.format(self.shutdown_timeout))
+
+ self.is_shutting_down = False
+ self.delete_session(send_request=False)
+
+ else:
+ self.delete_session(send_request=False)
+ self.instance.close(clean=clean)
+
+ if cause not in (None, "shutdown"):
+ raise errors.MarionetteException(
+ "Unexpected shutdown reason '{}' for "
+ "quitting the process.".format(cause)
+ )
+
+ @do_process_check
+ def restart(self, clean=False, in_app=False, callback=None):
+ """
+ This will terminate the currently running instance, and spawn a new instance
+ with the same profile and then reuse the session id when creating a session again.
+
+ :param clean: If False the same profile will be used after the restart. Note
+ that the in app initiated restart always maintains the same
+ profile.
+ :param in_app: If True, marionette will cause a restart from within the
+ browser. Otherwise the browser will be restarted immediately
+ by killing the process.
+ :param callback: If provided and `in_app` is True, the callback will be
+ used to trigger the restart.
+ """
+ if not self.instance:
+ raise errors.MarionetteException(
+ "restart() can only be called "
+ "on Gecko instances launched by Marionette"
+ )
+ context = self._send_message("Marionette:GetContext", key="value")
+
+ cause = None
+ if in_app:
+ if clean:
+ raise ValueError(
+ "An in_app restart cannot be triggered with the clean flag set"
+ )
+
+ if callback is not None and not callable(callback):
+ raise ValueError(
+ "Specified callback '{}' is not callable".format(callback)
+ )
+
+ # Block Marionette from accepting new connections
+ self._send_message("Marionette:AcceptConnections", {"value": False})
+
+ try:
+ self.is_shutting_down = True
+ if callback is not None:
+ callback()
+ else:
+ cause = self._request_in_app_shutdown("eRestart")
+
+ except IOError:
+ # A possible IOError should be ignored at this point, given that
+ # restart() could have been called inside of `using_context`,
+ # which wants to reset the context but fails sending the message.
+ pass
+
+ timeout_restart = self.shutdown_timeout + self.startup_timeout
+ try:
+ # Wait for a new Marionette connection to appear while the
+ # process restarts itself.
+ self.raise_for_port(timeout=timeout_restart, check_process_status=False)
+ except socket.timeout:
+ exc_cls, _, tb = sys.exc_info()
+
+ if self.instance.runner.returncode is None:
+ # The process is still running, which means the shutdown
+ # request was not correct or the application ignored it.
+ # Allow Marionette to accept connections again.
+ self._send_message("Marionette:AcceptConnections", {"value": True})
+
+ message = "Process still running {}s after restart request"
+ reraise(exc_cls, exc_cls(message.format(timeout_restart)), tb)
+
+ else:
+ # The process shutdown but didn't start again.
+ self.cleanup()
+ msg = "Process unexpectedly quit without restarting (exit code: {})"
+ reraise(
+ exc_cls,
+ exc_cls(msg.format(self.instance.runner.returncode)),
+ tb,
+ )
+
+ finally:
+ self.is_shutting_down = False
+
+ self.delete_session(send_request=False)
+
+ else:
+ self.delete_session()
+ self.instance.restart(clean=clean)
+ self.raise_for_port(timeout=self.DEFAULT_STARTUP_TIMEOUT)
+
+ if cause not in (None, "restart"):
+ raise errors.MarionetteException(
+ "Unexpected shutdown reason '{}' for "
+ "restarting the process".format(cause)
+ )
+
+ self.start_session()
+ # Restore the context as used before the restart
+ self.set_context(context)
+
+ if in_app and self.process_id:
+ # In some cases Firefox restarts itself by spawning into a new process group.
+ # As long as mozprocess cannot track that behavior (bug 1284864) we assist by
+ # informing about the new process id.
+ self.instance.runner.process_handler.check_for_detached(self.process_id)
+
+ def absolute_url(self, relative_url):
+ """
+ Returns an absolute url for files served from Marionette's www directory.
+
+ :param relative_url: The url of a static file, relative to Marionette's www directory.
+ """
+ return "{0}{1}".format(self.baseurl, relative_url)
+
+ @do_process_check
+ def start_session(self, capabilities=None, timeout=None):
+ """Create a new WebDriver session.
+ This method must be called before performing any other action.
+
+ :param capabilities: An optional dictionary of
+ Marionette-recognised capabilities. It does not
+ accept a WebDriver conforming capabilities dictionary
+ (including alwaysMatch, firstMatch, desiredCapabilities,
+ or requriedCapabilities), and only recognises extension
+ capabilities that are specific to Marionette.
+ :param timeout: Optional timeout in seconds for the server to be ready.
+ :returns: A dictionary of the capabilities offered.
+ """
+ if capabilities is None:
+ capabilities = {"strictFileInteractability": True}
+
+ if timeout is None:
+ timeout = self.startup_timeout
+
+ self.crashed = 0
+
+ if self.instance:
+ returncode = self.instance.runner.returncode
+ # We're managing a binary which has terminated. Start it again
+ # and implicitely wait for the Marionette server to be ready.
+ if returncode is not None:
+ self.start_binary(timeout)
+
+ else:
+ # In the case when Marionette doesn't manage the binary wait until
+ # its server component has been started.
+ self.raise_for_port(timeout=timeout)
+
+ self.client = transport.TcpTransport(self.host, self.port, self.socket_timeout)
+ self.protocol, _ = self.client.connect()
+
+ try:
+ resp = self._send_message("WebDriver:NewSession", capabilities)
+ except errors.UnknownException:
+ # Force closing the managed process when the session cannot be
+ # created due to global JavaScript errors.
+ exc_type, value, tb = sys.exc_info()
+ if self.instance and self.instance.runner.is_running():
+ self.instance.close()
+ reraise(exc_type, exc_type(value.message), tb)
+
+ self.session_id = resp["sessionId"]
+ self.session = resp["capabilities"]
+ # fallback to processId can be removed in Firefox 55
+ self.process_id = self.session.get(
+ "moz:processID", self.session.get("processId")
+ )
+ self.profile = self.session.get("moz:profile")
+
+ timeout = self.session.get("moz:shutdownTimeout")
+ if timeout is not None:
+ # pylint --py3k W1619
+ self.shutdown_timeout = timeout / 1000 + 10
+
+ return self.session
+
+ @property
+ def test_name(self):
+ return self._test_name
+
+ @test_name.setter
+ def test_name(self, test_name):
+ self._test_name = test_name
+
+ def delete_session(self, send_request=True):
+ """Close the current session and disconnect from the server.
+
+ :param send_request: Optional, if `True` a request to close the session on
+ the server side will be sent. Use `False` in case of eg. in_app restart()
+ or quit(), which trigger a deletion themselves. Defaults to `True`.
+ """
+ try:
+ if send_request:
+ try:
+ self._send_message("WebDriver:DeleteSession")
+ except errors.InvalidSessionIdException:
+ pass
+ finally:
+ self.process_id = None
+ self.profile = None
+ self.session = None
+ self.session_id = None
+ self.window = None
+
+ if self.client is not None:
+ self.client.close()
+
+ @property
+ def session_capabilities(self):
+ """A JSON dictionary representing the capabilities of the
+ current session.
+
+ """
+ return self.session
+
+ @property
+ def current_window_handle(self):
+ """Get the current window's handle.
+
+ Returns an opaque server-assigned identifier to this window
+ that uniquely identifies it within this Marionette instance.
+ This can be used to switch to this window at a later point.
+
+ :returns: unique window handle
+ :rtype: string
+ """
+ self.window = self._send_message("WebDriver:GetWindowHandle", key="value")
+ return self.window
+
+ @property
+ def current_chrome_window_handle(self):
+ """Get the current chrome window's handle. Corresponds to
+ a chrome window that may itself contain tabs identified by
+ window_handles.
+
+ Returns an opaque server-assigned identifier to this window
+ that uniquely identifies it within this Marionette instance.
+ This can be used to switch to this window at a later point.
+
+ :returns: unique window handle
+ :rtype: string
+ """
+ self.chrome_window = self._send_message(
+ "WebDriver:GetChromeWindowHandle", key="value"
+ )
+
+ return self.chrome_window
+
+ def set_window_rect(self, x=None, y=None, height=None, width=None):
+ """Set the position and size of the current window.
+
+ The supplied width and height values refer to the window outerWidth
+ and outerHeight values, which include scroll bars, title bars, etc.
+
+ An error will be returned if the requested window size would result
+ in the window being in the maximised state.
+
+ :param x: x coordinate for the top left of the window
+ :param y: y coordinate for the top left of the window
+ :param width: The width to resize the window to.
+ :param height: The height to resize the window to.
+ """
+ if (x is None and y is None) and (height is None and width is None):
+ raise errors.InvalidArgumentException(
+ "x and y or height and width need values"
+ )
+
+ body = {"x": x, "y": y, "height": height, "width": width}
+ return self._send_message("WebDriver:SetWindowRect", body)
+
+ @property
+ def window_rect(self):
+ return self._send_message("WebDriver:GetWindowRect")
+
+ @property
+ def title(self):
+ """Current title of the active window."""
+ return self._send_message("WebDriver:GetTitle", key="value")
+
+ @property
+ def window_handles(self):
+ """Get list of windows in the current context.
+
+ If called in the content context it will return a list of
+ references to all available browser windows. Called in the
+ chrome context, it will list all available windows, not just
+ browser windows (e.g. not just navigator.browser).
+
+ Each window handle is assigned by the server, and the list of
+ strings returned does not have a guaranteed ordering.
+
+ :returns: Unordered list of unique window handles as strings
+ """
+ return self._send_message("WebDriver:GetWindowHandles")
+
+ @property
+ def chrome_window_handles(self):
+ """Get a list of currently open chrome windows.
+
+ Each window handle is assigned by the server, and the list of
+ strings returned does not have a guaranteed ordering.
+
+ :returns: Unordered list of unique chrome window handles as strings
+ """
+ return self._send_message("WebDriver:GetChromeWindowHandles")
+
+ @property
+ def page_source(self):
+ """A string representation of the DOM."""
+ return self._send_message("WebDriver:GetPageSource", key="value")
+
+ def open(self, type=None, focus=False, private=False):
+ """Open a new window, or tab based on the specified context type.
+
+ If no context type is given the application will choose the best
+ option based on tab and window support.
+
+ :param type: Type of window to be opened. Can be one of "tab" or "window"
+ :param focus: If true, the opened window will be focused
+ :param private: If true, open a private window
+
+ :returns: Dict with new window handle, and type of opened window
+ """
+ body = {"type": type, "focus": focus, "private": private}
+ return self._send_message("WebDriver:NewWindow", body)
+
+ def close(self):
+ """Close the current window, ending the session if it's the last
+ window currently open.
+
+ :returns: Unordered list of remaining unique window handles as strings
+ """
+ return self._send_message("WebDriver:CloseWindow")
+
+ def close_chrome_window(self):
+ """Close the currently selected chrome window, ending the session
+ if it's the last window open.
+
+ :returns: Unordered list of remaining unique chrome window handles as strings
+ """
+ return self._send_message("WebDriver:CloseChromeWindow")
+
+ def set_context(self, context):
+ """Sets the context that Marionette commands are running in.
+
+ :param context: Context, may be one of the class properties
+ `CONTEXT_CHROME` or `CONTEXT_CONTENT`.
+
+ Usage example::
+
+ marionette.set_context(marionette.CONTEXT_CHROME)
+ """
+ if context not in [self.CONTEXT_CHROME, self.CONTEXT_CONTENT]:
+ raise ValueError("Unknown context: {}".format(context))
+
+ self._send_message("Marionette:SetContext", {"value": context})
+
+ @contextmanager
+ def using_context(self, context):
+ """Sets the context that Marionette commands are running in using
+ a `with` statement. The state of the context on the server is
+ saved before entering the block, and restored upon exiting it.
+
+ :param context: Context, may be one of the class properties
+ `CONTEXT_CHROME` or `CONTEXT_CONTENT`.
+
+ Usage example::
+
+ with marionette.using_context(marionette.CONTEXT_CHROME):
+ # chrome scope
+ ... do stuff ...
+ """
+ scope = self._send_message("Marionette:GetContext", key="value")
+ self.set_context(context)
+ try:
+ yield
+ finally:
+ self.set_context(scope)
+
+ def switch_to_alert(self):
+ """Returns an :class:`~marionette_driver.marionette.Alert` object for
+ interacting with a currently displayed alert.
+
+ ::
+
+ alert = self.marionette.switch_to_alert()
+ text = alert.text
+ alert.accept()
+ """
+ return Alert(self)
+
+ def switch_to_window(self, handle, focus=True):
+ """Switch to the specified window; subsequent commands will be
+ directed at the new window.
+
+ :param handle: The id of the window to switch to.
+
+ :param focus: A boolean value which determins whether to focus
+ the window that we just switched to.
+ """
+ self._send_message(
+ "WebDriver:SwitchToWindow", {"handle": handle, "focus": focus}
+ )
+ self.window = handle
+
+ def switch_to_default_content(self):
+ """Switch the current context to page's default content."""
+ return self.switch_to_frame()
+
+ def switch_to_parent_frame(self):
+ """
+ Switch to the Parent Frame
+ """
+ self._send_message("WebDriver:SwitchToParentFrame")
+
+ def switch_to_frame(self, frame=None):
+ """Switch the current context to the specified frame. Subsequent
+ commands will operate in the context of the specified frame,
+ if applicable.
+
+ :param frame: A reference to the frame to switch to. This can
+ be an :class:`~marionette_driver.marionette.HTMLElement`,
+ or an integer index. If you call ``switch_to_frame`` without an
+ argument, it will switch to the top-level frame.
+ """
+ body = {}
+ if isinstance(frame, HTMLElement):
+ body["element"] = frame.id
+ elif frame is not None:
+ body["id"] = frame
+
+ self._send_message("WebDriver:SwitchToFrame", body)
+
+ def get_url(self):
+ """Get a string representing the current URL.
+
+ On Desktop this returns a string representation of the URL of
+ the current top level browsing context. This is equivalent to
+ document.location.href.
+
+ When in the context of the chrome, this returns the canonical
+ URL of the current resource.
+
+ :returns: string representation of URL
+ """
+ return self._send_message("WebDriver:GetCurrentURL", key="value")
+
+ def get_window_type(self):
+ """Gets the windowtype attribute of the window Marionette is
+ currently acting on.
+
+ This command only makes sense in a chrome context. You might use this
+ method to distinguish a browser window from an editor window.
+ """
+ try:
+ return self._send_message("Marionette:GetWindowType", key="value")
+ except errors.UnknownCommandException:
+ return self._send_message("getWindowType", key="value")
+
+ def navigate(self, url):
+ """Navigate to given `url`.
+
+ Navigates the current top-level browsing context's content
+ frame to the given URL and waits for the document to load or
+ the session's page timeout duration to elapse before returning.
+
+ The command will return with a failure if there is an error
+ loading the document or the URL is blocked. This can occur if
+ it fails to reach the host, the URL is malformed, the page is
+ restricted (about:* pages), or if there is a certificate issue
+ to name some examples.
+
+ The document is considered successfully loaded when the
+ `DOMContentLoaded` event on the frame element associated with the
+ `window` triggers and `document.readyState` is "complete".
+
+ In chrome context it will change the current `window`'s location
+ to the supplied URL and wait until `document.readyState` equals
+ "complete" or the page timeout duration has elapsed.
+
+ :param url: The URL to navigate to.
+ """
+ self._send_message("WebDriver:Navigate", {"url": url})
+
+ def go_back(self):
+ """Causes the browser to perform a back navigation."""
+ self._send_message("WebDriver:Back")
+
+ def go_forward(self):
+ """Causes the browser to perform a forward navigation."""
+ self._send_message("WebDriver:Forward")
+
+ def refresh(self):
+ """Causes the browser to perform to refresh the current page."""
+ self._send_message("WebDriver:Refresh")
+
+ def _to_json(self, args):
+ if isinstance(args, list) or isinstance(args, tuple):
+ wrapped = []
+ for arg in args:
+ wrapped.append(self._to_json(arg))
+ elif isinstance(args, dict):
+ wrapped = {}
+ for arg in args:
+ wrapped[arg] = self._to_json(args[arg])
+ elif type(args) == HTMLElement:
+ wrapped = {WEB_ELEMENT_KEY: args.id, CHROME_ELEMENT_KEY: args.id}
+ elif (
+ isinstance(args, bool)
+ or isinstance(args, six.string_types)
+ or isinstance(args, int)
+ or isinstance(args, float)
+ or args is None
+ ):
+ wrapped = args
+ return wrapped
+
+ def _from_json(self, value):
+ if isinstance(value, list):
+ unwrapped = []
+ for item in value:
+ unwrapped.append(self._from_json(item))
+ return unwrapped
+ elif isinstance(value, dict):
+ unwrapped = {}
+ for key in value:
+ if key in HTMLElement.identifiers:
+ return HTMLElement._from_json(value[key], self)
+ else:
+ unwrapped[key] = self._from_json(value[key])
+ return unwrapped
+ else:
+ return value
+
+ def execute_script(
+ self,
+ script,
+ script_args=(),
+ new_sandbox=True,
+ sandbox="default",
+ script_timeout=None,
+ ):
+ """Executes a synchronous JavaScript script, and returns the
+ result (or None if the script does return a value).
+
+ The script is executed in the context set by the most recent
+ :func:`set_context` call, or to the CONTEXT_CONTENT context if
+ :func:`set_context` has not been called.
+
+ :param script: A string containing the JavaScript to execute.
+ :param script_args: An interable of arguments to pass to the script.
+ :param new_sandbox: If False, preserve global variables from
+ the last execute_*script call. This is True by default, in which
+ case no globals are preserved.
+ :param sandbox: A tag referring to the sandbox you wish to use;
+ if you specify a new tag, a new sandbox will be created.
+ If you use the special tag `system`, the sandbox will
+ be created using the system principal which has elevated
+ privileges.
+ :param script_timeout: Timeout in milliseconds, overriding
+ the session's default script timeout.
+
+ Simple usage example:
+
+ ::
+
+ result = marionette.execute_script("return 1;")
+ assert result == 1
+
+ You can use the `script_args` parameter to pass arguments to the
+ script:
+
+ ::
+
+ result = marionette.execute_script("return arguments[0] + arguments[1];",
+ script_args=(2, 3,))
+ assert result == 5
+ some_element = marionette.find_element(By.ID, "someElement")
+ sid = marionette.execute_script("return arguments[0].id;", script_args=(some_element,))
+ assert some_element.get_attribute("id") == sid
+
+ Scripts wishing to access non-standard properties of the window
+ object must use window.wrappedJSObject:
+
+ ::
+
+ result = marionette.execute_script('''
+ window.wrappedJSObject.test1 = "foo";
+ window.wrappedJSObject.test2 = "bar";
+ return window.wrappedJSObject.test1 + window.wrappedJSObject.test2;
+ ''')
+ assert result == "foobar"
+
+ Global variables set by individual scripts do not persist between
+ script calls by default. If you wish to persist data between
+ script calls, you can set `new_sandbox` to False on your next call,
+ and add any new variables to a new 'global' object like this:
+
+ ::
+
+ marionette.execute_script("global.test1 = 'foo';")
+ result = self.marionette.execute_script("return global.test1;", new_sandbox=False)
+ assert result == "foo"
+
+ """
+ original_timeout = None
+ if script_timeout is not None:
+ original_timeout = self.timeout.script
+ self.timeout.script = script_timeout / 1000.0
+
+ try:
+ args = self._to_json(script_args)
+ stack = traceback.extract_stack()
+ frame = stack[-2:-1][0] # grab the second-to-last frame
+ filename = (
+ frame[0] if sys.platform == "win32" else os.path.relpath(frame[0])
+ )
+ body = {
+ "script": script.strip(),
+ "args": args,
+ "newSandbox": new_sandbox,
+ "sandbox": sandbox,
+ "line": int(frame[1]),
+ "filename": filename,
+ }
+ rv = self._send_message("WebDriver:ExecuteScript", body, key="value")
+
+ finally:
+ if script_timeout is not None:
+ self.timeout.script = original_timeout
+
+ return self._from_json(rv)
+
+ def execute_async_script(
+ self,
+ script,
+ script_args=(),
+ new_sandbox=True,
+ sandbox="default",
+ script_timeout=None,
+ ):
+ """Executes an asynchronous JavaScript script, and returns the
+ result (or None if the script does return a value).
+
+ The script is executed in the context set by the most recent
+ :func:`set_context` call, or to the CONTEXT_CONTENT context if
+ :func:`set_context` has not been called.
+
+ :param script: A string containing the JavaScript to execute.
+ :param script_args: An interable of arguments to pass to the script.
+ :param new_sandbox: If False, preserve global variables from
+ the last execute_*script call. This is True by default,
+ in which case no globals are preserved.
+ :param sandbox: A tag referring to the sandbox you wish to use; if
+ you specify a new tag, a new sandbox will be created. If you
+ use the special tag `system`, the sandbox will be created
+ using the system principal which has elevated privileges.
+ :param script_timeout: Timeout in milliseconds, overriding
+ the session's default script timeout.
+
+ Usage example:
+
+ ::
+
+ marionette.timeout.script = 10
+ result = self.marionette.execute_async_script('''
+ // this script waits 5 seconds, and then returns the number 1
+ let [resolve] = arguments;
+ setTimeout(function() {
+ resolve(1);
+ }, 5000);
+ ''')
+ assert result == 1
+ """
+ original_timeout = None
+ if script_timeout is not None:
+ original_timeout = self.timeout.script
+ self.timeout.script = script_timeout / 1000.0
+
+ try:
+ args = self._to_json(script_args)
+ stack = traceback.extract_stack()
+ frame = stack[-2:-1][0] # grab the second-to-last frame
+ filename = (
+ frame[0] if sys.platform == "win32" else os.path.relpath(frame[0])
+ )
+ body = {
+ "script": script.strip(),
+ "args": args,
+ "newSandbox": new_sandbox,
+ "sandbox": sandbox,
+ "scriptTimeout": script_timeout,
+ "line": int(frame[1]),
+ "filename": filename,
+ }
+ rv = self._send_message("WebDriver:ExecuteAsyncScript", body, key="value")
+
+ finally:
+ if script_timeout is not None:
+ self.timeout.script = original_timeout
+
+ return self._from_json(rv)
+
+ def find_element(self, method, target, id=None):
+ """Returns an :class:`~marionette_driver.marionette.HTMLElement`
+ instance that matches the specified method and target in the current
+ context.
+
+ An :class:`~marionette_driver.marionette.HTMLElement` instance may be
+ used to call other methods on the element, such as
+ :func:`~marionette_driver.marionette.HTMLElement.click`. If no element
+ is immediately found, the attempt to locate an element will be repeated
+ for up to the amount of time set by
+ :attr:`marionette_driver.timeout.Timeouts.implicit`. If multiple
+ elements match the given criteria, only the first is returned. If no
+ element matches, a ``NoSuchElementException`` will be raised.
+
+ :param method: The method to use to locate the element; one of:
+ "id", "name", "class name", "tag name", "css selector",
+ "link text", "partial link text" and "xpath".
+ Note that the "name", "link text" and "partial link test"
+ methods are not supported in the chrome DOM.
+ :param target: The target of the search. For example, if method =
+ "tag", target might equal "div". If method = "id", target would
+ be an element id.
+ :param id: If specified, search for elements only inside the element
+ with the specified id.
+ """
+ body = {"value": target, "using": method}
+ if id:
+ body["element"] = id
+
+ return self._send_message("WebDriver:FindElement", body, key="value")
+
+ def find_elements(self, method, target, id=None):
+ """Returns a list of all
+ :class:`~marionette_driver.marionette.HTMLElement` instances that match
+ the specified method and target in the current context.
+
+ An :class:`~marionette_driver.marionette.HTMLElement` instance may be
+ used to call other methods on the element, such as
+ :func:`~marionette_driver.marionette.HTMLElement.click`. If no element
+ is immediately found, the attempt to locate an element will be repeated
+ for up to the amount of time set by
+ :attr:`marionette_driver.timeout.Timeouts.implicit`.
+
+ :param method: The method to use to locate the elements; one
+ of: "id", "name", "class name", "tag name", "css selector",
+ "link text", "partial link text" and "xpath".
+ Note that the "name", "link text" and "partial link test"
+ methods are not supported in the chrome DOM.
+ :param target: The target of the search. For example, if method =
+ "tag", target might equal "div". If method = "id", target would be
+ an element id.
+ :param id: If specified, search for elements only inside the element
+ with the specified id.
+ """
+ body = {"value": target, "using": method}
+ if id:
+ body["element"] = id
+
+ return self._send_message("WebDriver:FindElements", body)
+
+ def get_active_element(self):
+ el_or_ref = self._send_message("WebDriver:GetActiveElement", key="value")
+ return el_or_ref
+
+ def add_cookie(self, cookie):
+ """Adds a cookie to your current session.
+
+ :param cookie: A dictionary object, with required keys - "name"
+ and "value"; optional keys - "path", "domain", "secure",
+ "expiry".
+
+ Usage example:
+
+ ::
+
+ driver.add_cookie({"name": "foo", "value": "bar"})
+ driver.add_cookie({"name": "foo", "value": "bar", "path": "/"})
+ driver.add_cookie({"name": "foo", "value": "bar", "path": "/",
+ "secure": True})
+ """
+ self._send_message("WebDriver:AddCookie", {"cookie": cookie})
+
+ def delete_all_cookies(self):
+ """Delete all cookies in the scope of the current session.
+
+ Usage example:
+
+ ::
+
+ driver.delete_all_cookies()
+ """
+ self._send_message("WebDriver:DeleteAllCookies")
+
+ def delete_cookie(self, name):
+ """Delete a cookie by its name.
+
+ :param name: Name of cookie to delete.
+
+ Usage example:
+
+ ::
+
+ driver.delete_cookie("foo")
+ """
+ self._send_message("WebDriver:DeleteCookie", {"name": name})
+
+ def get_cookie(self, name):
+ """Get a single cookie by name. Returns the cookie if found,
+ None if not.
+
+ :param name: Name of cookie to get.
+ """
+ cookies = self.get_cookies()
+ for cookie in cookies:
+ if cookie["name"] == name:
+ return cookie
+ return None
+
+ def get_cookies(self):
+ """Get all the cookies for the current domain.
+
+ This is the equivalent of calling `document.cookie` and
+ parsing the result.
+
+ :returns: A list of cookies for the current domain.
+ """
+ return self._send_message("WebDriver:GetCookies")
+
+ def save_screenshot(self, fh, element=None, full=True, scroll=True):
+ """Takes a screenhot of a web element or the current frame and
+ saves it in the filehandle.
+
+ It is a wrapper around screenshot()
+ :param fh: The filehandle to save the screenshot at.
+
+ The rest of the parameters are defined like in screenshot()
+ """
+ data = self.screenshot(element, "binary", full, scroll)
+ fh.write(data)
+
+ def screenshot(self, element=None, format="base64", full=True, scroll=True):
+ """Takes a screenshot of a web element or the current frame.
+
+ The screen capture is returned as a lossless PNG image encoded
+ as a base 64 string by default. If the `element` argument is defined the
+ capture area will be limited to the bounding box of that
+ element. Otherwise, the capture area will be the bounding box
+ of the current frame.
+
+ :param element: The element to take a screenshot of. If None, will
+ take a screenshot of the current frame.
+
+ :param format: if "base64" (the default), returns the screenshot
+ as a base64-string. If "binary", the data is decoded and
+ returned as raw binary. If "hash", the data is hashed using
+ the SHA-256 algorithm and the result is returned as a hex digest.
+
+ :param full: If True (the default), the capture area will be the
+ complete frame. Else only the viewport is captured. Only applies
+ when `element` is None.
+
+ :param scroll: When `element` is provided, scroll to it before
+ taking the screenshot (default). Otherwise, avoid scrolling
+ `element` into view.
+ """
+
+ if element:
+ element = element.id
+
+ body = {"id": element, "full": full, "hash": False, "scroll": scroll}
+ if format == "hash":
+ body["hash"] = True
+
+ data = self._send_message("WebDriver:TakeScreenshot", body, key="value")
+
+ if format == "base64" or format == "hash":
+ return data
+ elif format == "binary":
+ return base64.b64decode(data.encode("ascii"))
+ else:
+ raise ValueError(
+ "format parameter must be either 'base64'"
+ " or 'binary', not {0}".format(repr(format))
+ )
+
+ @property
+ def orientation(self):
+ """Get the current browser orientation.
+
+ Will return one of the valid primary orientation values
+ portrait-primary, landscape-primary, portrait-secondary, or
+ landscape-secondary.
+ """
+ try:
+ return self._send_message("Marionette:GetScreenOrientation", key="value")
+ except errors.UnknownCommandException:
+ return self._send_message("getScreenOrientation", key="value")
+
+ def set_orientation(self, orientation):
+ """Set the current browser orientation.
+
+ The supplied orientation should be given as one of the valid
+ orientation values. If the orientation is unknown, an error
+ will be raised.
+
+ Valid orientations are "portrait" and "landscape", which fall
+ back to "portrait-primary" and "landscape-primary"
+ respectively, and "portrait-secondary" as well as
+ "landscape-secondary".
+
+ :param orientation: The orientation to lock the screen in.
+ """
+ body = {"orientation": orientation}
+ try:
+ self._send_message("Marionette:SetScreenOrientation", body)
+ except errors.UnknownCommandException:
+ self._send_message("setScreenOrientation", body)
+
+ def minimize_window(self):
+ """Iconify the browser window currently receiving commands.
+ The action should be equivalent to the user pressing the minimize
+ button in the OS window.
+
+ Note that this command is not available on Fennec. It may also
+ not be available in certain window managers.
+
+ :returns Window rect.
+ """
+ return self._send_message("WebDriver:MinimizeWindow")
+
+ def maximize_window(self):
+ """Resize the browser window currently receiving commands.
+ The action should be equivalent to the user pressing the maximize
+ button in the OS window.
+
+
+ Note that this command is not available on Fennec. It may also
+ not be available in certain window managers.
+
+ :returns: Window rect.
+ """
+ return self._send_message("WebDriver:MaximizeWindow")
+
+ def fullscreen(self):
+ """Synchronously sets the user agent window to full screen as
+ if the user had done "View > Enter Full Screen", or restores
+ it if it is already in full screen.
+
+ :returns: Window rect.
+ """
+ return self._send_message("WebDriver:FullscreenWindow")
diff --git a/testing/marionette/client/marionette_driver/timeout.py b/testing/marionette/client/marionette_driver/timeout.py
new file mode 100644
index 0000000000..99a9746082
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/timeout.py
@@ -0,0 +1,106 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from . import errors
+
+
+DEFAULT_SCRIPT_TIMEOUT = 30
+DEFAULT_PAGE_LOAD_TIMEOUT = 300
+DEFAULT_IMPLICIT_WAIT_TIMEOUT = 0
+
+
+class Timeouts(object):
+ """Manage timeout settings in the Marionette session.
+
+ Usage::
+
+ marionette = Marionette(...)
+ marionette.start_session()
+ marionette.timeout.page_load = 10
+ marionette.timeout.page_load
+ # => 10
+
+ """
+
+ def __init__(self, marionette):
+ self._marionette = marionette
+
+ def _set(self, name, sec):
+ ms = sec * 1000
+ self._marionette._send_message("WebDriver:SetTimeouts", {name: ms})
+
+ def _get(self, name):
+ ts = self._marionette._send_message("WebDriver:GetTimeouts")
+ if name not in ts:
+ raise KeyError()
+ ms = ts[name]
+ return ms / 1000.0
+
+ @property
+ def script(self):
+ """Get the session's script timeout. This specifies the time
+ to wait for injected scripts to finished before interrupting
+ them. It is by default 30 seconds.
+
+ """
+ return self._get("script")
+
+ @script.setter
+ def script(self, sec):
+ """Set the session's script timeout. This specifies the time
+ to wait for injected scripts to finish before interrupting them.
+
+ """
+ self._set("script", sec)
+
+ @property
+ def page_load(self):
+ """Get the session's page load timeout. This specifies the time
+ to wait for the page loading to complete. It is by default 5
+ minutes (or 300 seconds).
+
+ """
+ # remove fallback when Firefox 56 is stable
+ try:
+ return self._get("pageLoad")
+ except KeyError:
+ return self._get("page load")
+
+ @page_load.setter
+ def page_load(self, sec):
+ """Set the session's page load timeout. This specifies the time
+ to wait for the page loading to complete.
+
+ """
+ # remove fallback when Firefox 56 is stable
+ try:
+ self._set("pageLoad", sec)
+ except errors.InvalidArgumentException:
+ return self._set("page load", sec)
+
+ @property
+ def implicit(self):
+ """Get the session's implicit wait timeout. This specifies the
+ time to wait for the implicit element location strategy when
+ retrieving elements. It is by default disabled (0 seconds).
+
+ """
+ return self._get("implicit")
+
+ @implicit.setter
+ def implicit(self, sec):
+ """Set the session's implicit wait timeout. This specifies the
+ time to wait for the implicit element location strategy when
+ retrieving elements.
+
+ """
+ self._set("implicit", sec)
+
+ def reset(self):
+ """Resets timeouts to their default values."""
+ self.script = DEFAULT_SCRIPT_TIMEOUT
+ self.page_load = DEFAULT_PAGE_LOAD_TIMEOUT
+ self.implicit = DEFAULT_IMPLICIT_WAIT_TIMEOUT
diff --git a/testing/marionette/client/marionette_driver/transport.py b/testing/marionette/client/marionette_driver/transport.py
new file mode 100644
index 0000000000..1cdc777d9d
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/transport.py
@@ -0,0 +1,318 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import json
+import socket
+import sys
+import time
+
+import six
+
+
+class SocketTimeout(object):
+ def __init__(self, socket, timeout):
+ self.sock = socket
+ self.timeout = timeout
+ self.old_timeout = None
+
+ def __enter__(self):
+ self.old_timeout = self.sock.gettimeout()
+ self.sock.settimeout(self.timeout)
+
+ def __exit__(self, *args, **kwargs):
+ self.sock.settimeout(self.old_timeout)
+
+
+class Message(object):
+ def __init__(self, msgid):
+ self.id = msgid
+
+ def __eq__(self, other):
+ return self.id == other.id
+
+ def __ne__(self, other):
+ return not self.__eq__(other)
+
+ def __hash__(self):
+ # pylint --py3k: W1641
+ return hash(self.id)
+
+
+class Command(Message):
+ TYPE = 0
+
+ def __init__(self, msgid, name, params):
+ Message.__init__(self, msgid)
+ self.name = name
+ self.params = params
+
+ def __str__(self):
+ return "<Command id={0}, name={1}, params={2}>".format(
+ self.id, self.name, self.params
+ )
+
+ def to_msg(self):
+ msg = [Command.TYPE, self.id, self.name, self.params]
+ return json.dumps(msg)
+
+ @staticmethod
+ def from_msg(payload):
+ data = json.loads(payload)
+ assert data[0] == Command.TYPE
+ cmd = Command(data[1], data[2], data[3])
+ return cmd
+
+
+class Response(Message):
+ TYPE = 1
+
+ def __init__(self, msgid, error, result):
+ Message.__init__(self, msgid)
+ self.error = error
+ self.result = result
+
+ def __str__(self):
+ return "<Response id={0}, error={1}, result={2}>".format(
+ self.id, self.error, self.result
+ )
+
+ def to_msg(self):
+ msg = [Response.TYPE, self.id, self.error, self.result]
+ return json.dumps(msg)
+
+ @staticmethod
+ def from_msg(payload):
+ data = json.loads(payload)
+ assert data[0] == Response.TYPE
+ return Response(data[1], data[2], data[3])
+
+
+class TcpTransport(object):
+ """Socket client that communciates with Marionette via TCP.
+
+ It speaks the protocol of the remote debugger in Gecko, in which
+ messages are always preceded by the message length and a colon, e.g.:
+
+ 7:MESSAGE
+
+ On top of this protocol it uses a Marionette message format, that
+ depending on the protocol level offered by the remote server, varies.
+ Supported protocol levels are `min_protocol_level` and above.
+ """
+
+ max_packet_length = 4096
+ min_protocol_level = 3
+
+ def __init__(self, host, port, socket_timeout=60.0):
+ """If `socket_timeout` is `0` or `0.0`, non-blocking socket mode
+ will be used. Setting it to `1` or `None` disables timeouts on
+ socket operations altogether.
+ """
+ self._sock = None
+
+ self.host = host
+ self.port = port
+ self.socket_timeout = socket_timeout
+
+ self.protocol = self.min_protocol_level
+ self.application_type = None
+ self.last_id = 0
+ self.expected_response = None
+
+ @property
+ def socket_timeout(self):
+ return self._socket_timeout
+
+ @socket_timeout.setter
+ def socket_timeout(self, value):
+ self._socket_timeout = value
+
+ if self._sock:
+ self._sock.settimeout(value)
+
+ def _unmarshal(self, packet):
+ msg = None
+
+ # protocol 3 and above
+ if self.protocol >= 3:
+ if six.PY3:
+ typ = int(chr(packet[1]))
+ else:
+ typ = int(packet[1])
+ if typ == Command.TYPE:
+ msg = Command.from_msg(packet)
+ elif typ == Response.TYPE:
+ msg = Response.from_msg(packet)
+
+ return msg
+
+ def receive(self, unmarshal=True):
+ """Wait for the next complete response from the remote.
+
+ :param unmarshal: Default is to deserialise the packet and
+ return a ``Message`` type. Setting this to false will return
+ the raw packet.
+ """
+ now = time.time()
+ data = b""
+ bytes_to_recv = 10
+
+ while self.socket_timeout is None or (time.time() - now < self.socket_timeout):
+ try:
+ chunk = self._sock.recv(bytes_to_recv)
+ data += chunk
+ except socket.timeout:
+ pass
+ else:
+ if not chunk:
+ raise socket.error("No data received over socket")
+
+ sep = data.find(b":")
+ if sep > -1:
+ length = data[0:sep]
+ remaining = data[sep + 1 :]
+
+ if len(remaining) == int(length):
+ if unmarshal:
+ msg = self._unmarshal(remaining)
+ self.last_id = msg.id
+
+ # keep reading incoming responses until
+ # we receive the user's expected response
+ if isinstance(msg, Response) and msg != self.expected_response:
+ return self.receive(unmarshal)
+
+ return msg
+
+ else:
+ return remaining
+
+ bytes_to_recv = int(length) - len(remaining)
+
+ raise socket.timeout(
+ "Connection timed out after {}s".format(self.socket_timeout)
+ )
+
+ def connect(self):
+ """Connect to the server and process the hello message we expect
+ to receive in response.
+
+ Returns a tuple of the protocol level and the application type.
+ """
+ try:
+ self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self._sock.settimeout(self.socket_timeout)
+
+ self._sock.connect((self.host, self.port))
+ except Exception:
+ # Unset so that the next attempt to send will cause
+ # another connection attempt.
+ self._sock = None
+ raise
+
+ try:
+ with SocketTimeout(self._sock, 60.0):
+ # first packet is always a JSON Object
+ # which we can use to tell which protocol level we are at
+ raw = self.receive(unmarshal=False)
+ except socket.timeout:
+ exc_cls, exc, tb = sys.exc_info()
+ msg = "Connection attempt failed because no data has been received over the socket: {}"
+ six.reraise(exc_cls, exc_cls(msg.format(exc)), tb)
+
+ hello = json.loads(raw)
+ application_type = hello.get("applicationType")
+ protocol = hello.get("marionetteProtocol")
+
+ if application_type != "gecko":
+ raise ValueError(
+ "Application type '{}' is not supported".format(application_type)
+ )
+
+ if not isinstance(protocol, int) or protocol < self.min_protocol_level:
+ msg = "Earliest supported protocol level is '{}' but got '{}'"
+ raise ValueError(msg.format(self.min_protocol_level, protocol))
+
+ self.application_type = application_type
+ self.protocol = protocol
+
+ return (self.protocol, self.application_type)
+
+ def send(self, obj):
+ """Send message to the remote server. Allowed input is a
+ ``Message`` instance or a JSON serialisable object.
+ """
+ if not self._sock:
+ self.connect()
+
+ if isinstance(obj, Message):
+ data = obj.to_msg()
+ if isinstance(obj, Command):
+ self.expected_response = obj
+ else:
+ data = json.dumps(obj)
+ data = six.ensure_binary(data)
+ payload = six.ensure_binary(str(len(data))) + b":" + data
+
+ totalsent = 0
+ while totalsent < len(payload):
+ sent = self._sock.send(payload[totalsent:])
+ if sent == 0:
+ raise IOError(
+ "Socket error after sending {0} of {1} bytes".format(
+ totalsent, len(payload)
+ )
+ )
+ else:
+ totalsent += sent
+
+ def respond(self, obj):
+ """Send a response to a command. This can be an arbitrary JSON
+ serialisable object or an ``Exception``.
+ """
+ res, err = None, None
+ if isinstance(obj, Exception):
+ err = obj
+ else:
+ res = obj
+ msg = Response(self.last_id, err, res)
+ self.send(msg)
+ return self.receive()
+
+ def request(self, name, params):
+ """Sends a message to the remote server and waits for a response
+ to come back.
+ """
+ self.last_id = self.last_id + 1
+ cmd = Command(self.last_id, name, params)
+ self.send(cmd)
+ return self.receive()
+
+ def close(self):
+ """Close the socket.
+
+ First forces the socket to not send data anymore, and then explicitly
+ close it to free up its resources.
+
+ See: https://docs.python.org/2/howto/sockets.html#disconnecting
+ """
+ if self._sock:
+ try:
+ self._sock.shutdown(socket.SHUT_RDWR)
+ except IOError as exc:
+ # If the socket is already closed, don't care about:
+ # Errno 57: Socket not connected
+ # Errno 107: Transport endpoint is not connected
+ if exc.errno not in (57, 107):
+ raise
+
+ if self._sock:
+ # Guard against unclean shutdown.
+ self._sock.close()
+ self._sock = None
+
+ def __del__(self):
+ self.close()
diff --git a/testing/marionette/client/marionette_driver/wait.py b/testing/marionette/client/marionette_driver/wait.py
new file mode 100644
index 0000000000..caa9cb1f86
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/wait.py
@@ -0,0 +1,178 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import collections
+import sys
+import time
+
+from . import errors
+
+
+DEFAULT_TIMEOUT = 5
+DEFAULT_INTERVAL = 0.1
+
+
+class Wait(object):
+
+ """An explicit conditional utility class for waiting until a condition
+ evaluates to true or not null.
+
+ This will repeatedly evaluate a condition in anticipation for a
+ truthy return value, or its timeout to expire, or its waiting
+ predicate to become true.
+
+ A `Wait` instance defines the maximum amount of time to wait for a
+ condition, as well as the frequency with which to check the
+ condition. Furthermore, the user may configure the wait to ignore
+ specific types of exceptions whilst waiting, such as
+ `errors.NoSuchElementException` when searching for an element on
+ the page.
+
+ """
+
+ def __init__(
+ self,
+ marionette,
+ timeout=None,
+ interval=None,
+ ignored_exceptions=None,
+ clock=None,
+ ):
+ """Configure the Wait instance to have a custom timeout, interval, and
+ list of ignored exceptions. Optionally a different time
+ implementation than the one provided by the standard library
+ (time) can also be provided.
+
+ Sample usage::
+
+ # Wait 30 seconds for window to open, checking for its presence once
+ # every 5 seconds.
+ wait = Wait(marionette, timeout=30, interval=5,
+ ignored_exceptions=errors.NoSuchWindowException)
+ window = wait.until(lambda m: m.switch_to_window(42))
+
+ :param marionette: The input value to be provided to
+ conditions, usually a Marionette instance.
+
+ :param timeout: How long to wait for the evaluated condition
+ to become true. The default timeout is
+ `wait.DEFAULT_TIMEOUT`.
+
+ :param interval: How often the condition should be evaluated.
+ In reality the interval may be greater as the cost of
+ evaluating the condition function. If that is not the case the
+ interval for the next condition function call is shortend to keep
+ the original interval sequence as best as possible.
+ The default polling interval is `wait.DEFAULT_INTERVAL`.
+
+ :param ignored_exceptions: Ignore specific types of exceptions
+ whilst waiting for the condition. Any exceptions not
+ whitelisted will be allowed to propagate, terminating the
+ wait.
+
+ :param clock: Allows overriding the use of the runtime's
+ default time library. See `wait.SystemClock` for
+ implementation details.
+
+ """
+
+ self.marionette = marionette
+ self.timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
+ self.interval = interval if interval is not None else DEFAULT_INTERVAL
+ self.clock = clock or SystemClock()
+ self.end = self.clock.now + self.timeout
+
+ exceptions = []
+ if ignored_exceptions is not None:
+ if isinstance(ignored_exceptions, collections.Iterable):
+ exceptions.extend(iter(ignored_exceptions))
+ else:
+ exceptions.append(ignored_exceptions)
+ self.exceptions = tuple(set(exceptions))
+
+ def until(self, condition, is_true=None, message=""):
+ """Repeatedly runs condition until its return value evaluates to true,
+ or its timeout expires or the predicate evaluates to true.
+
+ This will poll at the given interval until the given timeout
+ is reached, or the predicate or conditions returns true. A
+ condition that returns null or does not evaluate to true will
+ fully elapse its timeout before raising an
+ `errors.TimeoutException`.
+
+ If an exception is raised in the condition function and it's
+ not ignored, this function will raise immediately. If the
+ exception is ignored, it will continue polling for the
+ condition until it returns successfully or a
+ `TimeoutException` is raised.
+
+ :param condition: A callable function whose return value will
+ be returned by this function if it evaluates to true.
+
+ :param is_true: An optional predicate that will terminate and
+ return when it evaluates to False. It should be a
+ function that will be passed clock and an end time. The
+ default predicate will terminate a wait when the clock
+ elapses the timeout.
+
+ :param message: An optional message to include in the
+ exception's message if this function times out.
+
+ """
+
+ rv = None
+ last_exc = None
+ until = is_true or until_pred
+ start = self.clock.now
+
+ while not until(self.clock, self.end):
+ try:
+ next = self.clock.now + self.interval
+ rv = condition(self.marionette)
+ except (KeyboardInterrupt, SystemExit):
+ raise
+ except self.exceptions:
+ last_exc = sys.exc_info()
+
+ # Re-adjust the interval depending on how long the callback
+ # took to evaluate the condition
+ interval_new = max(next - self.clock.now, 0)
+
+ if not rv:
+ self.clock.sleep(interval_new)
+ continue
+
+ if rv is not None:
+ return rv
+
+ self.clock.sleep(interval_new)
+
+ if message:
+ message = " with message: {}".format(message)
+
+ raise errors.TimeoutException(
+ # pylint: disable=W1633
+ "Timed out after {0:.1f} seconds{1}".format(
+ float(round((self.clock.now - start), 1)), message if message else ""
+ ),
+ cause=last_exc,
+ )
+
+
+def until_pred(clock, end):
+ return clock.now >= end
+
+
+class SystemClock(object):
+ def __init__(self):
+ self._time = time
+
+ def sleep(self, duration):
+ self._time.sleep(duration)
+
+ @property
+ def now(self):
+ return self._time.time()
diff --git a/testing/marionette/client/requirements.txt b/testing/marionette/client/requirements.txt
new file mode 100644
index 0000000000..220531c4f5
--- /dev/null
+++ b/testing/marionette/client/requirements.txt
@@ -0,0 +1,3 @@
+mozrunner >= 7.4.0
+mozversion >= 2.1.0
+six
diff --git a/testing/marionette/client/setup.py b/testing/marionette/client/setup.py
new file mode 100644
index 0000000000..7c8fddeba6
--- /dev/null
+++ b/testing/marionette/client/setup.py
@@ -0,0 +1,53 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import os
+import re
+from setuptools import setup, find_packages
+
+THIS_DIR = os.path.dirname(os.path.realpath(__name__))
+
+
+def read(*parts):
+ with open(os.path.join(THIS_DIR, *parts)) as f:
+ return f.read()
+
+
+def get_version():
+ return re.findall(
+ '__version__ = "([\d\.]+)"', read("marionette_driver", "__init__.py"), re.M
+ )[0]
+
+
+setup(
+ name="marionette_driver",
+ version=get_version(),
+ description="Marionette Driver",
+ long_description="See https://firefox-source-docs.mozilla.org/python/marionette_driver.html",
+ # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers
+ classifiers=[
+ "Development Status :: 5 - Production/Stable",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)",
+ "Operating System :: MacOS :: MacOS X",
+ "Operating System :: Microsoft :: Windows",
+ "Operating System :: POSIX",
+ "Topic :: Software Development :: Quality Assurance",
+ "Topic :: Software Development :: Testing",
+ "Topic :: Utilities",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 2.7",
+ ],
+ keywords="mozilla",
+ author="Auto-tools",
+ author_email="tools-marionette@lists.mozilla.org",
+ url="https://wiki.mozilla.org/Auto-tools/Projects/Marionette",
+ license="MPL",
+ packages=find_packages(),
+ include_package_data=True,
+ zip_safe=False,
+ install_requires=read("requirements.txt").splitlines(),
+)
diff --git a/testing/marionette/components/marionette.js b/testing/marionette/components/marionette.js
new file mode 100644
index 0000000000..6e5591725a
--- /dev/null
+++ b/testing/marionette/components/marionette.js
@@ -0,0 +1,605 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { ComponentUtils } = ChromeUtils.import(
+ "resource://gre/modules/ComponentUtils.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+const { EnvironmentPrefs, MarionettePrefs } = ChromeUtils.import(
+ "chrome://marionette/content/prefs.js",
+ null
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ Log: "chrome://marionette/content/log.js",
+ Preferences: "resource://gre/modules/Preferences.jsm",
+ TCPListener: "chrome://marionette/content/server.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "env",
+ "@mozilla.org/process/environment;1",
+ "nsIEnvironment"
+);
+
+const XMLURI_PARSE_ERROR =
+ "http://www.mozilla.org/newlayout/xml/parsererror.xml";
+
+const NOTIFY_LISTENING = "marionette-listening";
+
+// Complements -marionette flag for starting the Marionette server.
+// We also set this if Marionette is running in order to start the server
+// again after a Firefox restart.
+const ENV_ENABLED = "MOZ_MARIONETTE";
+
+// Besides starting based on existing prefs in a profile and a command
+// line flag, we also support inheriting prefs out of an env var, and to
+// start Marionette that way.
+//
+// This allows marionette prefs to persist when we do a restart into
+// a different profile in order to test things like Firefox refresh.
+// The environment variable itself, if present, is interpreted as a
+// JSON structure, with the keys mapping to preference names in the
+// "marionette." branch, and the values to the values of those prefs. So
+// something like {"port": 4444} would result in the marionette.port
+// pref being set to 4444.
+const ENV_PRESERVE_PREFS = "MOZ_MARIONETTE_PREF_STATE_ACROSS_RESTARTS";
+
+// ALL CHANGES TO THIS LIST MUST HAVE REVIEW FROM A MARIONETTE PEER!
+//
+// Marionette sets preferences recommended for automation when it starts,
+// unless marionette.prefs.recommended has been set to false.
+//
+// All prefs as added here have immediate effect, and don't require a restart
+// nor have to be set in the profile before the application starts. If such a
+// latter preference has to be added, it needs to be done for the client like
+// Marionette client (geckoinstance.py), or geckodriver (prefs.rs).
+//
+// Note: Clients do not always use the latest version of the application. As
+// such backward compatibility has to be ensured at least for the last three
+// releases.
+const RECOMMENDED_PREFS = new Map([
+ // Make sure Shield doesn't hit the network.
+ ["app.normandy.api_url", ""],
+
+ // Disable automatically upgrading Firefox
+ //
+ // Note: This preference should have already been set by the client when
+ // creating the profile. But if not and to absolutely make sure that updates
+ // of Firefox aren't downloaded and applied, enforce its presence.
+ ["app.update.disabledForTesting", true],
+
+ // Increase the APZ content response timeout in tests to 1 minute.
+ // This is to accommodate the fact that test environments tends to be
+ // slower than production environments (with the b2g emulator being
+ // the slowest of them all), resulting in the production timeout value
+ // sometimes being exceeded and causing false-positive test failures.
+ //
+ // (bug 1176798, bug 1177018, bug 1210465)
+ ["apz.content_response_timeout", 60000],
+
+ // Don't show the content blocking introduction panel.
+ // We use a larger number than the default 22 to have some buffer
+ // This can be removed once Firefox 69 and 68 ESR and are no longer supported.
+ ["browser.contentblocking.introCount", 99],
+
+ // Indicate that the download panel has been shown once so that
+ // whichever download test runs first doesn't show the popup
+ // inconsistently.
+ ["browser.download.panel.shown", true],
+
+ // Always display a blank page
+ ["browser.newtabpage.enabled", false],
+
+ // Background thumbnails in particular cause grief, and disabling
+ // thumbnails in general cannot hurt
+ ["browser.pagethumbnails.capturing_disabled", true],
+
+ // Disable safebrowsing components.
+ //
+ // These should also be set in the profile prior to starting Firefox,
+ // as it is picked up at runtime.
+ ["browser.safebrowsing.blockedURIs.enabled", false],
+ ["browser.safebrowsing.downloads.enabled", false],
+ ["browser.safebrowsing.passwords.enabled", false],
+ ["browser.safebrowsing.malware.enabled", false],
+ ["browser.safebrowsing.phishing.enabled", false],
+
+ // Disable updates to search engines.
+ //
+ // Should be set in profile.
+ ["browser.search.update", false],
+
+ // Do not restore the last open set of tabs if the browser has crashed
+ ["browser.sessionstore.resume_from_crash", false],
+
+ // Don't check for the default web browser during startup.
+ //
+ // These should also be set in the profile prior to starting Firefox,
+ // as it is picked up at runtime.
+ ["browser.shell.checkDefaultBrowser", false],
+
+ // Do not redirect user when a milstone upgrade of Firefox is detected
+ ["browser.startup.homepage_override.mstone", "ignore"],
+
+ // Do not close the window when the last tab gets closed
+ ["browser.tabs.closeWindowWithLastTab", false],
+
+ // Do not allow background tabs to be zombified on Android, otherwise for
+ // tests that open additional tabs, the test harness tab itself might get
+ // unloaded
+ ["browser.tabs.disableBackgroundZombification", false],
+
+ // Bug 1557457: Disable because modal dialogs might not appear in Firefox
+ ["browser.tabs.remote.separatePrivilegedContentProcess", false],
+
+ // Don't unload tabs when available memory is running low
+ ["browser.tabs.unloadOnLowMemory", false],
+
+ // Do not warn when closing all open tabs
+ ["browser.tabs.warnOnClose", false],
+
+ // Do not warn when closing all other open tabs
+ ["browser.tabs.warnOnCloseOtherTabs", false],
+
+ // Do not warn when multiple tabs will be opened
+ ["browser.tabs.warnOnOpen", false],
+
+ // Don't show the Bookmarks Toolbar on any tab (the above pref that
+ // disables the New Tab Page ends up showing the toolbar on about:blank).
+ ["browser.toolbars.bookmarks.visibility", "never"],
+
+ // Disable first run splash page on Windows 10
+ ["browser.usedOnWindows10.introURL", ""],
+
+ // Disable the UI tour.
+ //
+ // Should be set in profile.
+ ["browser.uitour.enabled", false],
+
+ // Turn off search suggestions in the location bar so as not to trigger
+ // network connections.
+ ["browser.urlbar.suggest.searches", false],
+
+ // Do not warn on quitting Firefox
+ ["browser.warnOnQuit", false],
+
+ // Do not show datareporting policy notifications which can
+ // interfere with tests
+ [
+ "datareporting.healthreport.documentServerURI",
+ "http://%(server)s/dummy/healthreport/",
+ ],
+ ["datareporting.healthreport.logging.consoleEnabled", false],
+ ["datareporting.healthreport.service.enabled", false],
+ ["datareporting.healthreport.service.firstRun", false],
+ ["datareporting.healthreport.uploadEnabled", false],
+ ["datareporting.policy.dataSubmissionEnabled", false],
+ ["datareporting.policy.dataSubmissionPolicyAccepted", false],
+ ["datareporting.policy.dataSubmissionPolicyBypassNotification", true],
+
+ // Automatically unload beforeunload alerts
+ ["dom.disable_beforeunload", true],
+
+ // Disable popup-blocker
+ ["dom.disable_open_during_load", false],
+
+ // Enabling the support for File object creation in the content process
+ ["dom.file.createInChild", true],
+
+ // Disable the ProcessHangMonitor
+ ["dom.ipc.reportProcessHangs", false],
+
+ // Disable slow script dialogues
+ ["dom.max_chrome_script_run_time", 0],
+ ["dom.max_script_run_time", 0],
+
+ // DOM Push
+ ["dom.push.connection.enabled", false],
+
+ // Only load extensions from the application and user profile
+ // AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION
+ //
+ // Should be set in profile.
+ ["extensions.autoDisableScopes", 0],
+ ["extensions.enabledScopes", 5],
+
+ // Disable metadata caching for installed add-ons by default
+ ["extensions.getAddons.cache.enabled", false],
+
+ // Disable installing any distribution extensions or add-ons.
+ // Should be set in profile.
+ ["extensions.installDistroAddons", false],
+
+ // Turn off extension updates so they do not bother tests
+ ["extensions.update.enabled", false],
+ ["extensions.update.notifyUser", false],
+
+ // Make sure opening about:addons will not hit the network
+ ["extensions.getAddons.discovery.api_url", "data:, "],
+
+ // Allow the application to have focus even it runs in the background
+ ["focusmanager.testmode", true],
+
+ // Disable useragent updates
+ ["general.useragent.updates.enabled", false],
+
+ // Always use network provider for geolocation tests so we bypass the
+ // macOS dialog raised by the corelocation provider
+ ["geo.provider.testing", true],
+
+ // Do not scan Wifi
+ ["geo.wifi.scan", false],
+
+ // Show chrome errors and warnings in the error console
+ ["javascript.options.showInConsole", true],
+
+ // Do not prompt with long usernames or passwords in URLs
+ ["network.http.phishy-userpass-length", 255],
+
+ // Do not prompt for temporary redirects
+ ["network.http.prompt-temp-redirect", false],
+
+ // Do not automatically switch between offline and online
+ ["network.manage-offline-status", false],
+
+ // Make sure SNTP requests do not hit the network
+ ["network.sntp.pools", "%(server)s"],
+
+ // Privacy and Tracking Protection
+ ["privacy.trackingprotection.enabled", false],
+
+ // Only allow the old modal dialogs. This should be removed when there is
+ // support for the new modal UI (see Bug 1686741).
+ ["prompts.contentPromptSubDialog", false],
+
+ // Don't do network connections for mitm priming
+ ["security.certerrors.mitm.priming.enabled", false],
+
+ // Local documents have access to all other local documents,
+ // including directory listings
+ ["security.fileuri.strict_origin_policy", false],
+
+ // Tests do not wait for the notification button security delay
+ ["security.notification_enable_delay", 0],
+
+ // Ensure blocklist updates do not hit the network
+ ["services.settings.server", "http://%(server)s/dummy/blocklist/"],
+
+ // Do not automatically fill sign-in forms with known usernames and
+ // passwords
+ ["signon.autofillForms", false],
+
+ // Disable password capture, so that tests that include forms are not
+ // influenced by the presence of the persistent doorhanger notification
+ ["signon.rememberSignons", false],
+
+ // Disable first-run welcome page
+ ["startup.homepage_welcome_url", "about:blank"],
+ ["startup.homepage_welcome_url.additional", ""],
+
+ // Prevent starting into safe mode after application crashes
+ ["toolkit.startup.max_resumed_crashes", -1],
+]);
+
+const isRemote =
+ Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT;
+
+class MarionetteParentProcess {
+ constructor() {
+ this.server = null;
+
+ // holds reference to ChromeWindow
+ // used to run GFX sanity tests on Windows
+ this.gfxWindow = null;
+
+ // indicates that all pending window checks have been completed
+ // and that we are ready to start the Marionette server
+ this.finalUIStartup = false;
+
+ this.alteredPrefs = new Set();
+
+ if (env.exists(ENV_ENABLED)) {
+ this.enabled = true;
+ } else {
+ // TODO: Don't read the preference anymore (bug 1632821)
+ this.enabled = MarionettePrefs.enabled;
+ }
+
+ if (this.enabled) {
+ logger.trace(`Marionette enabled`);
+ }
+
+ Services.ppmm.addMessageListener("Marionette:IsRunning", this);
+ }
+
+ get enabled() {
+ return !!this._enabled;
+ }
+
+ set enabled(value) {
+ if (value) {
+ // Only update the preference when Marionette is going to be enabled
+ MarionettePrefs.enabled = value;
+ }
+
+ this._enabled = value;
+ }
+
+ get running() {
+ return !!this.server && this.server.alive;
+ }
+
+ receiveMessage({ name }) {
+ switch (name) {
+ case "Marionette:IsRunning":
+ return this.running;
+
+ default:
+ logger.warn("Unknown IPC message to parent process: " + name);
+ return null;
+ }
+ }
+
+ observe(subject, topic) {
+ if (this.enabled) {
+ logger.trace(`Received observer notification ${topic}`);
+ }
+
+ switch (topic) {
+ case "profile-after-change":
+ Services.obs.addObserver(this, "command-line-startup");
+ break;
+
+ // In safe mode the command line handlers are getting parsed after the
+ // safe mode dialog has been closed. To allow Marionette to start
+ // earlier, use the CLI startup observer notification for
+ // special-cased handlers, which gets fired before the dialog appears.
+ case "command-line-startup":
+ Services.obs.removeObserver(this, topic);
+
+ if (!this.enabled && subject.handleFlag("marionette", false)) {
+ logger.trace(`Marionette enabled`);
+ this.enabled = true;
+ }
+
+ if (this.enabled) {
+ Services.obs.addObserver(this, "toplevel-window-ready");
+ Services.obs.addObserver(this, "marionette-startup-requested");
+
+ // Only set preferences to preserve in a new profile
+ // when Marionette is enabled.
+ for (let [pref, value] of EnvironmentPrefs.from(ENV_PRESERVE_PREFS)) {
+ Preferences.set(pref, value);
+ }
+
+ // We want to suppress the modal dialog that's shown
+ // when starting up in safe-mode to enable testing.
+ if (Services.appinfo.inSafeMode) {
+ Services.obs.addObserver(this, "domwindowopened");
+ }
+ }
+
+ break;
+
+ case "domwindowclosed":
+ if (this.gfxWindow === null || subject === this.gfxWindow) {
+ Services.obs.removeObserver(this, topic);
+ Services.obs.removeObserver(this, "toplevel-window-ready");
+
+ Services.obs.addObserver(this, "xpcom-will-shutdown");
+
+ this.finalUIStartup = true;
+ this.init();
+ }
+ break;
+
+ case "domwindowopened":
+ Services.obs.removeObserver(this, topic);
+ this.suppressSafeModeDialog(subject);
+ break;
+
+ case "toplevel-window-ready":
+ subject.addEventListener(
+ "load",
+ ev => {
+ if (ev.target.documentElement.namespaceURI == XMLURI_PARSE_ERROR) {
+ Services.obs.removeObserver(this, topic);
+
+ let parserError = ev.target.querySelector("parsererror");
+ logger.fatal(parserError.textContent);
+ this.uninit();
+ Services.startup.quit(Ci.nsIAppStartup.eForceQuit);
+ }
+ },
+ { once: true }
+ );
+ break;
+
+ case "marionette-startup-requested":
+ Services.obs.removeObserver(this, topic);
+
+ // When Firefox starts on Windows, an additional GFX sanity test
+ // window may appear off-screen. Marionette should wait for it
+ // to close.
+ for (let win of Services.wm.getEnumerator(null)) {
+ if (
+ win.document.documentURI ==
+ "chrome://gfxsanity/content/sanityparent.html"
+ ) {
+ this.gfxWindow = win;
+ break;
+ }
+ }
+
+ if (this.gfxWindow) {
+ logger.trace(
+ "GFX sanity window detected, waiting until it has been closed..."
+ );
+ Services.obs.addObserver(this, "domwindowclosed");
+ } else {
+ Services.obs.removeObserver(this, "toplevel-window-ready");
+
+ Services.obs.addObserver(this, "xpcom-will-shutdown");
+
+ this.finalUIStartup = true;
+ this.init();
+ }
+
+ break;
+
+ case "xpcom-will-shutdown":
+ Services.obs.removeObserver(this, "xpcom-will-shutdown");
+ this.uninit();
+ break;
+ }
+ }
+
+ suppressSafeModeDialog(win) {
+ win.addEventListener(
+ "load",
+ () => {
+ let dialog = win.document.getElementById("safeModeDialog");
+ if (dialog) {
+ // accept the dialog to start in safe-mode
+ logger.trace("Safe mode detected, supressing dialog");
+ win.setTimeout(() => {
+ dialog.getButton("accept").click();
+ });
+ }
+ },
+ { once: true }
+ );
+ }
+
+ init(quit = true) {
+ if (this.running || !this.enabled || !this.finalUIStartup) {
+ logger.debug(
+ `Init aborted (running=${this.running}, ` +
+ `enabled=${this.enabled}, finalUIStartup=${this.finalUIStartup})`
+ );
+ return;
+ }
+
+ logger.trace(
+ `Waiting until startup recorder finished recording startup scripts...`
+ );
+ Services.tm.idleDispatchToMainThread(async () => {
+ let startupRecorder = Promise.resolve();
+ if ("@mozilla.org/test/startuprecorder;1" in Cc) {
+ startupRecorder = Cc["@mozilla.org/test/startuprecorder;1"].getService()
+ .wrappedJSObject.done;
+ }
+ await startupRecorder;
+ logger.trace(`All scripts recorded.`);
+
+ if (MarionettePrefs.recommendedPrefs) {
+ for (let [k, v] of RECOMMENDED_PREFS) {
+ if (!Preferences.isSet(k)) {
+ logger.debug(`Setting recommended pref ${k} to ${v}`);
+ Preferences.set(k, v);
+ this.alteredPrefs.add(k);
+ }
+ }
+ }
+
+ try {
+ this.server = new TCPListener(MarionettePrefs.port);
+ this.server.start();
+ } catch (e) {
+ logger.fatal("Remote protocol server failed to start", e);
+ this.uninit();
+ if (quit) {
+ Services.startup.quit(Ci.nsIAppStartup.eForceQuit);
+ }
+ return;
+ }
+
+ env.set(ENV_ENABLED, "1");
+ Services.obs.notifyObservers(this, NOTIFY_LISTENING, true);
+ logger.debug("Marionette is listening");
+ });
+ }
+
+ uninit() {
+ for (let k of this.alteredPrefs) {
+ logger.debug(`Resetting recommended pref ${k}`);
+ Preferences.reset(k);
+ }
+ this.alteredPrefs.clear();
+
+ if (this.running) {
+ this.server.stop();
+ Services.obs.notifyObservers(this, NOTIFY_LISTENING);
+ logger.debug("Marionette stopped listening");
+ }
+ }
+
+ get QueryInterface() {
+ return ChromeUtils.generateQI([
+ "nsICommandLineHandler",
+ "nsIMarionette",
+ "nsIObserver",
+ ]);
+ }
+}
+
+class MarionetteContentProcess {
+ get running() {
+ let reply = Services.cpmm.sendSyncMessage("Marionette:IsRunning");
+ if (reply.length == 0) {
+ logger.warn("No reply from parent process");
+ return false;
+ }
+ return reply[0];
+ }
+
+ get QueryInterface() {
+ return ChromeUtils.generateQI(["nsIMarionette"]);
+ }
+}
+
+const MarionetteFactory = {
+ instance_: null,
+
+ createInstance(outer, iid) {
+ if (outer) {
+ throw Components.Exception("", Cr.NS_ERROR_NO_AGGREGATION);
+ }
+
+ if (!this.instance_) {
+ if (isRemote) {
+ this.instance_ = new MarionetteContentProcess();
+ } else {
+ this.instance_ = new MarionetteParentProcess();
+ }
+ }
+
+ return this.instance_.QueryInterface(iid);
+ },
+};
+
+function Marionette() {}
+
+Marionette.prototype = {
+ classDescription: "Marionette component",
+ classID: Components.ID("{786a1369-dca5-4adc-8486-33d23c88010a}"),
+ contractID: "@mozilla.org/remote/marionette;1",
+
+ /* eslint-disable-next-line camelcase */
+ _xpcom_factory: MarionetteFactory,
+
+ helpInfo: " --marionette Enable remote control server.\n",
+};
+
+this.NSGetFactory = ComponentUtils.generateNSGetFactory([Marionette]);
diff --git a/testing/marionette/components/marionette.manifest b/testing/marionette/components/marionette.manifest
new file mode 100644
index 0000000000..5558796ba1
--- /dev/null
+++ b/testing/marionette/components/marionette.manifest
@@ -0,0 +1,4 @@
+component {786a1369-dca5-4adc-8486-33d23c88010a} marionette.js
+contract @mozilla.org/remote/marionette;1 {786a1369-dca5-4adc-8486-33d23c88010a}
+category command-line-handler b-marionette @mozilla.org/remote/marionette;1
+category profile-after-change Marionette @mozilla.org/remote/marionette;1
diff --git a/testing/marionette/components/moz.build b/testing/marionette/components/moz.build
new file mode 100644
index 0000000000..1771ec39d6
--- /dev/null
+++ b/testing/marionette/components/moz.build
@@ -0,0 +1,11 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_COMPONENTS += [
+ "marionette.js",
+ "marionette.manifest",
+]
+
+XPIDL_MODULE = "remote"
+XPIDL_SOURCES += ["nsIMarionette.idl"]
diff --git a/testing/marionette/components/nsIMarionette.idl b/testing/marionette/components/nsIMarionette.idl
new file mode 100644
index 0000000000..a889b32bad
--- /dev/null
+++ b/testing/marionette/components/nsIMarionette.idl
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+%{C++
+#define NS_MARIONETTE_CONTRACTID "@mozilla.org/remote/marionette;1"
+%}
+
+/** Interface for accessing the Marionette server instance. */
+[scriptable, uuid(13fa7d76-f976-4711-a00c-29ac9c1881e1)]
+interface nsIMarionette : nsISupports
+{
+ /** Indicates whether the remote protocol is enabled. */
+ readonly attribute boolean running;
+};
diff --git a/testing/marionette/cookie.js b/testing/marionette/cookie.js
new file mode 100644
index 0000000000..82dbde3d00
--- /dev/null
+++ b/testing/marionette/cookie.js
@@ -0,0 +1,296 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["cookie"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ assert: "chrome://marionette/content/assert.js",
+ error: "chrome://marionette/content/error.js",
+ pprint: "chrome://marionette/content/format.js",
+});
+
+const IPV4_PORT_EXPR = /:\d+$/;
+
+const SAMESITE_MAP = new Map([
+ ["None", Ci.nsICookie.SAMESITE_NONE],
+ ["Lax", Ci.nsICookie.SAMESITE_LAX],
+ ["Strict", Ci.nsICookie.SAMESITE_STRICT],
+]);
+
+/** @namespace */
+this.cookie = {
+ manager: Services.cookies,
+};
+
+/**
+ * @name Cookie
+ *
+ * @return {Object.<string, (number|boolean|string)>
+ */
+
+/**
+ * Unmarshal a JSON Object to a cookie representation.
+ *
+ * Effectively this will run validation checks on ``json``, which
+ * will produce the errors expected by WebDriver if the input is
+ * not valid.
+ *
+ * @param {Object.<string, (number|boolean|string)>} json
+ * Cookie to be deserialised. ``name`` and ``value`` are required
+ * fields which must be strings. The ``path`` and ``domain`` fields
+ * are optional, but must be a string if provided. The ``secure``,
+ * and ``httpOnly`` are similarly optional, but must be booleans.
+ * Likewise, the ``expiry`` field is optional but must be
+ * unsigned integer.
+ *
+ * @return {Cookie}
+ * Valid cookie object.
+ *
+ * @throws {InvalidArgumentError}
+ * If any of the properties are invalid.
+ */
+cookie.fromJSON = function(json) {
+ let newCookie = {};
+
+ assert.object(json, pprint`Expected cookie object, got ${json}`);
+
+ newCookie.name = assert.string(json.name, "Cookie name must be string");
+ newCookie.value = assert.string(json.value, "Cookie value must be string");
+
+ if (typeof json.path != "undefined") {
+ newCookie.path = assert.string(json.path, "Cookie path must be string");
+ }
+ if (typeof json.domain != "undefined") {
+ newCookie.domain = assert.string(
+ json.domain,
+ "Cookie domain must be string"
+ );
+ }
+ if (typeof json.secure != "undefined") {
+ newCookie.secure = assert.boolean(
+ json.secure,
+ "Cookie secure flag must be boolean"
+ );
+ }
+ if (typeof json.httpOnly != "undefined") {
+ newCookie.httpOnly = assert.boolean(
+ json.httpOnly,
+ "Cookie httpOnly flag must be boolean"
+ );
+ }
+ if (typeof json.expiry != "undefined") {
+ newCookie.expiry = assert.positiveInteger(
+ json.expiry,
+ "Cookie expiry must be a positive integer"
+ );
+ }
+ if (typeof json.sameSite != "undefined") {
+ newCookie.sameSite = assert.in(
+ json.sameSite,
+ Array.from(SAMESITE_MAP.keys()),
+ "Cookie SameSite flag must be one of None, Lax, or Strict"
+ );
+ }
+
+ return newCookie;
+};
+
+/**
+ * Insert cookie to the cookie store.
+ *
+ * @param {Cookie} newCookie
+ * Cookie to add.
+ * @param {string=} restrictToHost
+ * Perform test that ``newCookie``'s domain matches this.
+ * @param {string=} protocol
+ * The protocol of the caller. It can be `ftp:`, `http:` or `https:`.
+ *
+ * @throws {TypeError}
+ * If ``name``, ``value``, or ``domain`` are not present and
+ * of the correct type.
+ * @throws {InvalidCookieDomainError}
+ * If ``restrictToHost`` is set and ``newCookie``'s domain does
+ * not match.
+ * @throws {UnableToSetCookieError}
+ * If an error occurred while trying to save the cookie.
+ */
+cookie.add = function(
+ newCookie,
+ { restrictToHost = null, protocol = null } = {}
+) {
+ assert.string(newCookie.name, "Cookie name must be string");
+ assert.string(newCookie.value, "Cookie value must be string");
+
+ if (typeof newCookie.path == "undefined") {
+ newCookie.path = "/";
+ }
+
+ let hostOnly = false;
+ if (typeof newCookie.domain == "undefined") {
+ hostOnly = true;
+ newCookie.domain = restrictToHost;
+ }
+ assert.string(newCookie.domain, "Cookie domain must be string");
+ if (newCookie.domain.substring(0, 1) === ".") {
+ newCookie.domain = newCookie.domain.substring(1);
+ }
+
+ if (typeof newCookie.secure == "undefined") {
+ newCookie.secure = false;
+ }
+ if (typeof newCookie.httpOnly == "undefined") {
+ newCookie.httpOnly = false;
+ }
+ if (typeof newCookie.expiry == "undefined") {
+ // The XPCOM interface requires the expiry field even for session cookies.
+ newCookie.expiry = Number.MAX_SAFE_INTEGER;
+ newCookie.session = true;
+ } else {
+ newCookie.session = false;
+ }
+ newCookie.sameSite = SAMESITE_MAP.get(newCookie.sameSite || "None");
+
+ let isIpAddress = false;
+ try {
+ Services.eTLD.getPublicSuffixFromHost(newCookie.domain);
+ } catch (e) {
+ switch (e.result) {
+ case Cr.NS_ERROR_HOST_IS_IP_ADDRESS:
+ isIpAddress = true;
+ break;
+ default:
+ throw new error.InvalidCookieDomainError(newCookie.domain);
+ }
+ }
+
+ if (!hostOnly && !isIpAddress) {
+ // only store this as a domain cookie if the domain was specified in the
+ // request and it wasn't an IP address.
+ newCookie.domain = "." + newCookie.domain;
+ }
+
+ if (restrictToHost) {
+ if (
+ !restrictToHost.endsWith(newCookie.domain) &&
+ "." + restrictToHost !== newCookie.domain &&
+ restrictToHost !== newCookie.domain
+ ) {
+ throw new error.InvalidCookieDomainError(
+ `Cookies may only be set ` +
+ `for the current domain (${restrictToHost})`
+ );
+ }
+ }
+
+ let schemeType = Ci.nsICookie.SCHEME_UNSET;
+ switch (protocol) {
+ case "http:":
+ schemeType = Ci.nsICookie.SCHEME_HTTP;
+ break;
+ case "https:":
+ schemeType = Ci.nsICookie.SCHEME_HTTPS;
+ break;
+ default:
+ // ftp: or any other protocol is supported by the cookie service.
+ break;
+ }
+
+ // remove port from domain, if present.
+ // unfortunately this catches IPv6 addresses by mistake
+ // TODO: Bug 814416
+ newCookie.domain = newCookie.domain.replace(IPV4_PORT_EXPR, "");
+
+ try {
+ cookie.manager.add(
+ newCookie.domain,
+ newCookie.path,
+ newCookie.name,
+ newCookie.value,
+ newCookie.secure,
+ newCookie.httpOnly,
+ newCookie.session,
+ newCookie.expiry,
+ {} /* origin attributes */,
+ newCookie.sameSite,
+ schemeType
+ );
+ } catch (e) {
+ throw new error.UnableToSetCookieError(e);
+ }
+};
+
+/**
+ * Remove cookie from the cookie store.
+ *
+ * @param {Cookie} toDelete
+ * Cookie to remove.
+ */
+cookie.remove = function(toDelete) {
+ cookie.manager.remove(
+ toDelete.domain,
+ toDelete.name,
+ toDelete.path,
+ {} /* originAttributes */
+ );
+};
+
+/**
+ * Iterates over the cookies for the current ``host``. You may
+ * optionally filter for specific paths on that ``host`` by specifying
+ * a path in ``currentPath``.
+ *
+ * @param {string} host
+ * Hostname to retrieve cookies for.
+ * @param {string=} [currentPath="/"] currentPath
+ * Optionally filter the cookies for ``host`` for the specific path.
+ * Defaults to ``/``, meaning all cookies for ``host`` are included.
+ *
+ * @return {Iterable.<Cookie>}
+ * Iterator.
+ */
+cookie.iter = function*(host, currentPath = "/") {
+ assert.string(host, "host must be string");
+ assert.string(currentPath, "currentPath must be string");
+
+ const isForCurrentPath = path => currentPath.includes(path);
+
+ let cookies = cookie.manager.getCookiesFromHost(host, {});
+ for (let cookie of cookies) {
+ // take the hostname and progressively shorten
+ let hostname = host;
+ do {
+ if (
+ (cookie.host == "." + hostname || cookie.host == hostname) &&
+ isForCurrentPath(cookie.path)
+ ) {
+ let data = {
+ name: cookie.name,
+ value: cookie.value,
+ path: cookie.path,
+ domain: cookie.host,
+ secure: cookie.isSecure,
+ httpOnly: cookie.isHttpOnly,
+ };
+
+ if (!cookie.isSession) {
+ data.expiry = cookie.expiry;
+ }
+
+ data.sameSite = [...SAMESITE_MAP].find(
+ ([, value]) => cookie.sameSite === value
+ )[0];
+
+ yield data;
+ }
+ hostname = hostname.replace(/^.*?\./, "");
+ } while (hostname.includes("."));
+ }
+};
diff --git a/testing/marionette/doc/Building.md b/testing/marionette/doc/Building.md
new file mode 100644
index 0000000000..e2b84c17fa
--- /dev/null
+++ b/testing/marionette/doc/Building.md
@@ -0,0 +1,50 @@
+Building
+========
+
+Marionette is built in to Firefox and ships in the official
+Firefox binary. As Marionette is written in [XPCOM] flavoured
+JavaScript, you may choose to rely on so called [artifact builds],
+which will download pre-compiled Firefox blobs to your computer.
+This means you don’t have to compile Firefox locally, but does
+come at the cost of having a good internet connection. To enable
+[artifact builds] you may choose ‘Firefox for Desktop Artifact
+Mode’ when bootstrapping.
+
+Once you have a clone of [mozilla-unified], you can set up your
+development environment by running this command and following the
+on-screen instructions:
+
+ % ./mach bootstrap
+
+When you're getting asked to choose the version of Firefox you want to build,
+you may want to consider choosing "Firefox for Desktop Artifact Mode". This
+significantly reduces the time it takes to build Firefox on your machine
+(from 30+ minutes to just 1-2 minutes) if you have a fast internet connection.
+
+To perform a regular build, simply do:
+
+ % ./mach build
+
+You can clean out the objdir using this command:
+
+ % ./mach clobber
+
+Occasionally a clean build will be required after you fetch the
+latest changes from mozilla-central. You will find that the the
+build will error when this is the case. To automatically do clean
+builds when this happens you may optionally add this line to the
+_mozconfig_ file in your top source directory:
+
+ mk_add_options AUTOCLOBBER=1
+
+If you compile Firefox frequently you will also want to enable
+[ccache] and [sccache] if you develop on a macOS or Linux system:
+
+ mk_add_options 'export RUSTC_WRAPPER=sccache'
+ mk_add_options 'export CCACHE_CPP2=yes'
+ ac_add_options --with-ccache
+
+[mozilla-unified]: https://mozilla-version-control-tools.readthedocs.io/en/latest/hgmozilla/unifiedrepo.html
+[artifact builds]: https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/Build_Instructions/Artifact_builds
+[ccache]: https://ccache.samba.org/
+[sccache]: https://github.com/mozilla/sccache
diff --git a/testing/marionette/doc/CodeStyle.md b/testing/marionette/doc/CodeStyle.md
new file mode 100644
index 0000000000..bc332fb17a
--- /dev/null
+++ b/testing/marionette/doc/CodeStyle.md
@@ -0,0 +1,254 @@
+Style guide
+===========
+
+Like other projects, we also have some guidelines to keep to the code.
+For the overall Marionette project, a few rough rules are:
+
+ * Make your code readable and sensible, and don’t try to be
+ clever. Prefer simple and easy solutions over more convoluted
+ and foreign syntax.
+
+ * Fixing style violations whilst working on a real change as a
+ preparatory clean-up step is good, but otherwise avoid useless
+ code churn for the sake of conforming to the style guide.
+
+ * Code is mutable and not written in stone. Nothing that
+ is checked in is sacred and we encourage change to make
+ testing/marionette a pleasant ecosystem to work in.
+
+
+JavaScript
+----------
+
+Marionette is written in [XPCOM] flavoured JavaScript and ships
+as part of Firefox. We have access to all the latest ECMAScript
+features currently in development, usually before it ships in the
+wild and we try to make use of new features when appropriate,
+especially when they move us off legacy internal replacements
+(such as Promise.jsm and Task.jsm).
+
+One of the peculiarities of working on JavaScript code that ships as
+part of a runtime platform is, that unlike in a regular web document,
+we share a single global state with the rest of Firefox. This means
+we have to be responsible and not leak resources unnecessarily.
+
+JS code in Gecko is organised into _modules_ carrying _.js_ or _.jsm_
+file extensions. Depending on the area of Gecko you’re working on,
+you may find they have different techniques for exporting symbols,
+varying indentation and code style, as well as varying linting
+requirements.
+
+To export symbols to other Marionette modules, remember to assign
+your exported symbols to the shared global `this`:
+
+ const EXPORTED_SYMBOLS = ["PollPromise", "TimedPromise"];
+
+When importing symbols in Marionette code, try to be specific about
+what you need:
+
+ const {TimedPromise} = Cu.import("chrome://marionette/content/sync.js", {});
+
+The [linter] will complain if you import a named symbol that is
+not in use. If however you _need_ to import every symbol, you can:
+
+ const wait = {};
+ Cu.import("chrome://marionette/content/sync.js", wait);
+
+ wait.sleep(42);
+ await wait.TimedPromise(…);
+
+We prefer object assignment shorthands when redefining names,
+for example when you use functionality from the `Components` global:
+
+ const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+When using symbols by their own name, the assignment name can be
+omitted:
+
+ const {TYPE_ONE_SHOT, TYPE_REPEATING_SLACK} = Ci.nsITimer;
+
+In addition to the default [Mozilla eslint rules], we have [our
+own specialisations] that are stricter and enforce more security.
+A few notable examples are that we disallow fallthrough `case`
+statements unless they are explicitly grouped together:
+
+ switch (x) {
+ case "foo":
+ doSomething();
+
+ case "bar": // <-- disallowed!
+ doSomethingElse();
+ break;
+
+ case "baz":
+ case "bah": // <-- allowed (-:
+ doCrazyThings();
+ }
+
+We disallow the use of `var`, for which we always prefer `let` and
+`const` as replacements. Do be aware that `const` does not mean
+that the variable is immutable: just that it cannot be reassigned.
+We require all lines to end with semicolons, disallow construction
+of plain `new Object()`, require variable names to be camel-cased,
+and complain about unused variables.
+
+For purely aesthetic reasons we indent our code with two spaces,
+which includes switch-statement `case`s, and limit the maximum
+line length to 78 columns. When you need to wrap a statement to
+the next line, the second line is indented with four spaces, like this:
+
+ throw new TypeError(
+ "Expected an element or WindowProxy, " +
+ pprint`got: ${el}`);
+
+This is not normally something you have to think to deeply about as
+it is enforced by the [linter]. The linter also has an automatic
+mode that fixes and formats certain classes of style violations.
+
+If you find yourself struggling to fit a long statement on one line,
+this is usually an indication that it is too long and should be
+split into multiple lines. This is also a helpful tip to make the
+code easier to read. Assigning transitive values to descriptive
+variable names can serve as self-documentation:
+
+ let location = event.target.documentURI || event.target.location.href;
+ log.debug(`Received DOM event ${event.type} for ${location}`);
+
+On the topic of variable naming the opinions are as many as programmers
+writing code, but it is often helpful to keep the input and output
+arguments to functions descriptive (longer), and let transitive
+internal values to be described more succinctly:
+
+ /** Prettifies instance of Error and its stacktrace to a string. */
+ function stringify(error) {
+ try {
+ let s = error.toString();
+ if ("stack" in error) {
+ s += "\n" + error.stack;
+ }
+ return s;
+ } catch (e) {
+ return "<unprintable error>";
+ }
+ }
+
+When we can, we try to extract the relevant object properties in
+the arguments to an event handler or a function:
+
+ const responseListener = ({name, target, json, data}) => { … };
+
+Instead of:
+
+ const responseListener = msg => {
+ let name = msg.name;
+ let target = msg.target;
+ let json = msg.json;
+ let data = msg.data;
+ …
+ };
+
+All source files should have `"use strict";` as the first directive
+so that the file is parsed in [strict mode].
+
+Every source code file that ships as part of the Firefox bundle
+must also have a [copying header], such as this:
+
+ /* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+New xpcshell test files _should not_ have a license header as all
+new Mozilla tests should be in the [public domain] so that they can
+easily be shared with other browser vendors. We want to re-license
+existing tests covered by the [MPL] so that they can be shared.
+We very much welcome your help in doing version control archeology
+to make this happen!
+
+The practical details of working on the Marionette code is outlined
+in [CONTRIBUTING.md], but generally you do not have to re-build
+Firefox when changing code. Any change to testing/marionette/*.js
+will be picked up on restarting Firefox. The only notable exception
+is testing/marionette/components/marionette.js, which does require
+a re-build.
+
+[XPCOM]: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM
+[strict mode]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode
+[our own specialisations]: https://searchfox.org/mozilla-central/source/testing/marionette/.eslintrc.js
+[linter]: #linting
+[copying header]: https://www.mozilla.org/en-US/MPL/headers/
+[public domain]: https://creativecommons.org/publicdomain/zero/1.0/
+[MPL]: https://www.mozilla.org/en-US/MPL/2.0/
+[CONTRIBUTING.md]: ../CONTRIBUTING.md
+
+
+Python
+------
+
+TODO
+
+
+Documentation
+-------------
+
+We keep our documentation in-tree under [testing/marionette/doc]
+and [testing/geckodriver/doc]. Updates and minor changes to
+documentation should ideally not be scrutinised to the same degree
+as code changes to encourage frequent updates so that the documentation
+does not go stale. To that end, documentation changes with `r=me`
+from module peers are permitted.
+
+Use fmt(1) or an equivalent editor specific mechanism (such as Meta-Q
+in Emacs) to format paragraphs at a maximum width of 75 columns
+with a goal of roughly 65. This is equivalent to `fmt -w 75 -g 65`,
+which happens to be the default on BSD and macOS.
+
+We endeavour to document all _public APIs_ of the Marionette component.
+These include public functions—or command implementations—on
+the `GeckoDriver` class, as well as all exported symbols from
+other modules. Documentation for non-exported symbols is not required.
+
+The API documentation can be regenerated to [testing/marionette/doc/api]
+so:
+
+The API documentation uses [jsdoc] and is generated to <https://firefox-source-docs.mozilla.org/testing/marionette/marionette/internals> on Taskcluster. You may also build the documentation locally:
+
+ % ./mach doc
+
+[Mozilla eslint rules]: https://searchfox.org/mozilla-central/source/.eslintrc.js
+[testing/geckodriver/doc]: https://searchfox.org/mozilla-central/source/testing/geckodriver/doc
+[testing/marionette/doc]: https://searchfox.org/mozilla-central/source/testing/marionette/doc
+[jsdoc]: http://usejsdoc.org/
+
+
+Linting
+-------
+
+Marionette consists mostly of JavaScript (server) and Python (client,
+harness, test runner) code. We lint our code with [mozlint],
+which harmonises the output from [eslint] and [flake8].
+
+To run the linter with a sensible output:
+
+ % ./mach lint -funix testing/marionette
+
+For certain classes of style violations the eslint linter has
+an automatic mode for fixing and formatting your code. This is
+particularly useful to keep to whitespace and indentation rules:
+
+ % ./mach eslint --fix testing/marionette
+
+The linter is also run as a try job (shorthand `ES`) which means
+any style violations will automatically block a patch from landing
+(if using Autoland) or cause your changeset to be backed out (if
+landing directly on mozilla-inbound).
+
+If you use git(1) you can [enable automatic linting] before you push
+to a remote through a pre-push (or pre-commit) hook. This will
+run the linters on the changed files before a push and abort if
+there are any problems. This is convenient for avoiding a try run
+failing due to a stupid linting issue.
+
+[mozlint]: https://firefox-source-docs.mozilla.org/tools/lint/usage.html
+[eslint]: https://eslint.org/
+[flake8]: http://flake8.pycqa.org/en/latest/
+[enable automatic linting]: https://firefox-source-docs.mozilla.org/tools/lint/usage.html#using-a-vcs-hook
diff --git a/testing/marionette/doc/Contributing.md b/testing/marionette/doc/Contributing.md
new file mode 100644
index 0000000000..e325b1a952
--- /dev/null
+++ b/testing/marionette/doc/Contributing.md
@@ -0,0 +1,78 @@
+Contributing
+============
+
+If you are new to open source or to Mozilla, you might like this
+[tutorial for new Marionette contributors](NewContributors.html).
+
+We are delighted that you want to help improve Marionette!
+‘Marionette’ means different a few different things, depending
+on who you talk to, but the overall scope of the project involves
+these components:
+
+ * [_Marionette_] is a Firefox remote protocol to communicate with,
+ instrument, and control Gecko-based browsers such as Firefox
+ and Fennec. It is built in to Firefox and written in [XPCOM]
+ flavoured JavaScript.
+
+ It serves as the backend for the geckodriver WebDriver implementation,
+ and is used in the context of Firefox UI tests, reftesting,
+ Web Platform Tests, test harness bootstrapping, and in many
+ other far-reaching places where browser instrumentation is required.
+
+ * [_geckodriver_] provides the HTTP API described by the [WebDriver
+ protocol] to communicate with Gecko-based browsers such as
+ Firefox and Fennec. It is a standalone executable written in
+ Rust, and can be used with compatible W3C WebDriver clients.
+
+ * [_webdriver_] is a Rust crate providing interfaces, traits
+ and types, errors, type- and bounds checks, and JSON marshaling
+ for correctly parsing and emitting the [WebDriver protocol].
+
+By participating in this project, you agree to abide by the Mozilla
+[Community Participation Guidelines]. Here are some guidelines
+for contributing high-quality and actionable bugs and code.
+
+[_Marionette_]: ./index.html
+[_geckodriver_]: ../../geckodriver/geckodriver
+[_webdriver_]: https://searchfox.org/mozilla-central/source/testing/webdriver/README.md
+[WebDriver protocol]: https://w3c.github.io/webdriver/webdriver-spec.html#protocol
+[XPCOM]: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Guide
+[Community Participation Guidelines]: https://www.mozilla.org/en-US/about/governance/policies/participation/
+
+
+Writing code
+------------
+
+Because there are many moving parts involved remote controlling
+a web browser, it can be challenging to a new contributor to know
+where to start. Please don’t hesitate to [ask questions]!
+
+The canonical source code repository is [mozilla-central]. Bugs are
+filed in the `Testing :: Marionette` component on Bugzilla. We also
+have a curated set of [good first bugs] you may consider attempting first.
+
+We have collected a lot of good advice for working on Marionette
+code in our [code style document], which we highly recommend you read.
+
+[ask questions]: ./index.html#communication
+[reach out to us]: ./index.html#communication
+[mozilla-central]: https://searchfox.org/mozilla-central/source/testing/marionette/
+[good first bugs]: https://codetribute.mozilla.org/projects/automation
+[code style document]: CodeStyle.html
+
+
+Next steps
+----------
+
+ * [Building](Building.html)
+ * [Debugging](Debugging.html)
+ * [Testing](Testing.html)
+ * [Patching](Patches.html)
+
+
+Other resources
+---------------
+
+ * [Code style](CodeStyle.html)
+ * [Internals](internals/)
+ * [New Contributor Tutorial](NewContributors.html)
diff --git a/testing/marionette/doc/Debugging.md b/testing/marionette/doc/Debugging.md
new file mode 100644
index 0000000000..8a5e1fa580
--- /dev/null
+++ b/testing/marionette/doc/Debugging.md
@@ -0,0 +1,86 @@
+Debugging
+=========
+
+Redirecting the Gecko output
+----------------------------
+
+The most common way to debug Marionette, as well as chrome code in
+general, is to use `dump()` to print a string to stdout. In Firefox,
+this log output normally ends up in the gecko.log file in your current
+working directory. With Fennec it can be inspected using `adb logcat`.
+
+`mach marionette-test` takes a `--gecko-log` option which lets
+you redirect this output stream. This is convenient if you want to
+“merge” the test harness output with the stdout from the browser.
+Per Unix conventions you can use `-` (dash) to have Firefox write
+its log to stdout instead of file:
+
+ % ./mach marionette-test --gecko-log -
+
+It is common to use this in conjunction with an option to increase
+the Marionette log level:
+
+ % ./mach test --gecko-log - -vv TEST
+
+A single `-v` enables debug logging, and a double `-vv` enables
+trace logging.
+
+This debugging technique can be particularly effective when combined
+with using [pdb] in the Python client or the JS remote debugger
+that is described below.
+
+[pdb]: https://docs.python.org/2/library/pdb.html
+
+
+JavaScript debugger
+-------------------
+
+You can attach the [Browser Toolbox] JavaScript debugger to the
+Marionette server using the `--jsdebugger` flag. This enables you
+to introspect and set breakpoints in Gecko chrome code, which is a
+more powerful debugging technique than using `dump()` or `console.log()`.
+
+To automatically open the JS debugger for `Mn` tests:
+
+ % ./mach marionette-test --jsdebugger
+
+It will prompt you when to start to allow you time to set your
+breakpoints. It will also prompt you between each test.
+
+You can also use the `debugger;` statement anywhere in chrome code
+to add a breakpoint. In this example, a breakpoint will be added
+whenever the `WebDriver:GetPageSource` command is called:
+
+ GeckoDriver.prototype.getPageSource = async function() {
+ debugger;
+ …
+ }
+
+To not be prompted at the start of the test run or between tests,
+you can set the `marionette.debugging.clicktostart` preference to
+false this way:
+
+ % ./mach marionette-test --pref 'marionette.debugging.clicktostart:false' --jsdebugger
+
+For reference, below is the list of preferences that enables the
+chrome debugger for Marionette. These are all set implicitly when
+`--jsdebugger` is passed to mach. In non-official builds, which
+are the default when built using `./mach build`, you will find that
+the chrome debugger won’t prompt for connection and will allow
+remote connections.
+
+ * `devtools.debugger.prompt-connection` → true
+
+ Controls the remote connection prompt. Note that this will
+ automatically expose your Firefox instance to the network.
+
+ * `devtools.chrome.enabled` → true
+
+ Enables debugging of chrome code.
+
+ * `devtools.debugger.remote-enabled` → true
+
+ Allows a remote debugger to connect, which is necessary for
+ debugging chrome code.
+
+[Browser Toolbox]: https://developer.mozilla.org/en-US/docs/Tools/Browser_Toolbox
diff --git a/testing/marionette/doc/Intro.md b/testing/marionette/doc/Intro.md
new file mode 100644
index 0000000000..4e52e11276
--- /dev/null
+++ b/testing/marionette/doc/Intro.md
@@ -0,0 +1,82 @@
+Introduction to Marionette
+==========================
+
+Marionette is an automation driver for Mozilla's Gecko engine.
+It can remotely control either the UI or the internal JavaScript of
+a Gecko platform, such as Firefox. It can control both the chrome
+(i.e. menus and functions) or the content (the webpage loaded inside
+the browsing context), giving a high level of control and ability
+to replicate user actions. In addition to performing actions on the
+browser, Marionette can also read the properties and attributes of
+the DOM.
+
+If this sounds similar to [Selenium/WebDriver] then you're
+correct! Marionette shares much of the same ethos and API as
+Selenium/WebDriver, with additional commands to interact with
+Gecko's chrome interface. Its goal is to replicate what Selenium
+does for web content: to enable the tester to have the ability to
+send commands to remotely control a user agent.
+
+[Selenium/WebDriver]: https://dvcs.w3.org/hg/webdriver/raw-file/tip/webdriver-spec.html
+
+
+How does it work?
+-----------------
+
+Marionette consists of two parts: a server which takes requests and
+executes them in Gecko, and a client. The client sends commands to
+the server and the server executes the command inside the browser.
+
+
+When would I use it?
+--------------------
+
+If you want to perform UI tests with browser chrome or content,
+Marionette is the tool you're looking for! You can use it to
+control either web content, or Firefox itself.
+
+A test engineer would typically import the Marionette client package
+into their test framework, import the classes and use the class
+functions and methods to control the browser. After controlling
+the browser, Marionette can be used to return information about
+the state of the browser which can then be used to validate that
+the action was performed correctly.
+
+
+Using Marionette
+----------------
+
+Marionette combines a gecko component (the Marionette server) with an
+outside component (the Marionette client), which drives the tests.
+The Marionette server ships with Firefox, and to use it you will
+need to download a Marionette client or use the in-tree client.
+
+ * [Download and setup the Python client for Marionette][1]
+ * [Run Tests with Python][2] – How to run tests using the
+ Python client
+ * You might want to experiment with [using Marionette interactively
+ at a Python command prompt][2]
+ * Start [writing and running][3] tests
+ * Tips on [debugging][4] Marionette code
+ * [Get a Build][5] – Instructions on how to get a Marionette-enabled
+ build of Firefox
+ * [Download and setup the Marionette JS client][6]
+ * [Protocol definition][7]
+
+[1]: ../../python/marionette_driver.html
+[2]: ../../python/marionette_driver.html
+[3]: ./PythonTests.html
+[4]: ./Debugging.html
+[5]: https://developer.mozilla.org/en-US/docs/Marionette/Builds
+[6]: https://github.com/mozilla-b2g/marionette_js_client
+[7]: ./Protocol.html
+
+
+Bugs
+----
+
+Please file any bugs you may find in the `Testing :: Marionette`
+component in Bugzilla. You can view a [list of current bugs]
+to see if your problem is already being addressed.
+
+[list of current bugs]: https://bugzilla.mozilla.org/buglist.cgi?product=Testing&component=Marionette&resolution=---&list_id=1844713
diff --git a/testing/marionette/doc/NewContributors.md b/testing/marionette/doc/NewContributors.md
new file mode 100644
index 0000000000..5411b32862
--- /dev/null
+++ b/testing/marionette/doc/NewContributors.md
@@ -0,0 +1,90 @@
+New contributors
+================
+
+This page is aimed at people who are new to Mozilla and want to contribute
+to Mozilla source code related to Marionette Python tests, WebDriver
+spec tests and related test harnesses and tools. Mozilla has both
+git and Mercurial repositories, but this guide only describes Mercurial.
+
+If you run into issues or have doubts, check out the [Resources](#resources)
+section below and **don't hesitate to ask questions**. :) The goal of these
+steps is to make sure you have the basics of your development environment
+working. Once you do, we can get you started with working on an
+actual bug, yay!
+
+
+Accounts, communication
+-----------------------
+
+ 1. Set up a [Bugzilla] account (and, if you like, a [Mozillians] profile).
+ Please include your Matrix nickname in both of these accounts so we can work
+ with you more easily. For example, Eve Smith would set the Bugzilla name
+ to "Eve Smith (:esmith)", where "esmith" is the Matrix nick.
+
+ 2. For a direct communication with us it will be beneficial to setup [Matrix].
+ Make sure to also register your nickname as described in the linked document.
+
+ 3. Join our #interop channel, and introduce yourself to the team. :jgraham,
+ :maja_zf, and :whimboo are all familiar with Marionette.
+ We're nice, I promise, but we might not answer right away due to different
+ time zones, time off, etc. So please be patient.
+
+ 4. When you want to ask a question on Matrix, just go ahead an ask it even if
+ no one appears to be around/responding.
+ Provide lots of detail so that we have a better chance of helping you.
+ If you don't get an answer right away, check again in a few hours --
+ someone may have answered you in the mean time.
+
+ 5. If you're having trouble reaching us over Matrix, you are welcome to send an
+ email to our [mailing list](index.html#communication) instead. It's a good
+ idea to include your Matrix nick in your email message.
+
+[Matrix]: https://chat.mozilla.org
+[Bugzilla]: https://bugzilla.mozilla.org/
+[Mozillians]: https://mozillians.org/
+[logbot]: https://mozilla.logbot.info/ateam/
+
+Getting the code, running tests
+-------------------------------
+
+Follow the documentation on [Contributing](Contributing.html) to get a sense of
+our projects, and which is of most interest for you. You will also learn how to
+get the Firefox source code, build your custom Firefox build, and how to run the
+tests.
+
+
+Work on bugs and get code review
+--------------------------------
+
+Once you are familiar with the code of the test harnesses, and the tests you might
+want to start with your first contribution. The necessary steps to submit and verify
+your patches are laid out in [Patches.md](Patches.html).
+
+
+Resources
+---------
+
+ * Search Mozilla's code repositories with [searchfox].
+
+ * Another [guide for new contributors]. It has not been updated in a long
+ time but it's a good general resource if you ever get stuck on something.
+ The most relevant sections to you are about Bugzilla, Mercurial, Python and the
+ Development Process.
+
+ * [Mercurial for Mozillians]
+
+ * More general resources are available in this little [guide] :maja_zf wrote
+ in 2015 to help a student get started with open source contributions.
+
+ * Textbook about general open source practices: [Practical Open Source Software Exploration]
+
+ * If you'd rather use git instead of hg, see [git workflow for
+ Gecko development] and/or [this blog post by :ato].
+
+[searchfox]: https://searchfox.org/mozilla-central/source/testing/marionette/
+[guide for new contributors]: https://ateam-bootcamp.readthedocs.org/en/latest/guide/index.html#new-contributor-guide
+[Mercurial for Mozillians]: https://mozilla-version-control-tools.readthedocs.org/en/latest/hgmozilla/index.html
+[guide]: https://gist.github.com/mjzffr/d2adef328a416081f543
+[Practical Open Source Software Exploration]: https://quaid.fedorapeople.org/TOS/Practical_Open_Source_Software_Exploration/html/index.html
+[git workflow for Gecko development]: https://github.com/glandium/git-cinnabar/wiki/Mozilla:-A-git-workflow-for-Gecko-development
+[this blog post by :ato]: https://sny.no/2016/03/geckogit
diff --git a/testing/marionette/doc/Patches.md b/testing/marionette/doc/Patches.md
new file mode 100644
index 0000000000..7e5ece9fd3
--- /dev/null
+++ b/testing/marionette/doc/Patches.md
@@ -0,0 +1,33 @@
+Submitting patches
+==================
+
+You can submit patches by using [Phabricator]. Walk through its documentation
+in how to set it up, and uploading patches for review. Don't worry about which
+person to select for reviewing your code. It will be done automatically.
+
+Please also make sure to follow the [commit creation guidelines].
+
+Once you have contributed a couple of patches, we are happy to
+sponsor you in [becoming a Mozilla committer]. When you have been
+granted commit access level 1 you will have permission to use the
+[Firefox CI] to trigger your own “try runs” to test your changes.
+
+This is a good try syntax to use when testing Marionette changes:
+
+ -b do -p linux,linux64,macosx64,win64,android-api-16 -u marionette,marionette-headless,xpcshell,web-platform-tests,firefox-ui-functional -t none
+
+You can also use the `marionette` [try preset]:
+
+ mach try --preset marionette
+
+This preset will schedule Marionette-related tests on various platforms. You can
+reduce the number of tasks by filtering on platforms (e.g. linux) or build type
+(e.g. opt):
+
+ mach try --preset marionette -xq "'linux 'opt"
+
+[Phabricator]: https://moz-conduit.readthedocs.io/en/latest/phabricator-user.html
+[commit creation guidelines]: https://mozilla-version-control-tools.readthedocs.io/en/latest/devguide/contributing.html?highlight=phabricator#submitting-patches-for-review
+[becoming a Mozilla committer]: https://www.mozilla.org/en-US/about/governance/policies/commit/
+[Firefox CI]: https://treeherder.mozilla.org/
+[try preset]: https://firefox-source-docs.mozilla.org/tools/try/presets.html
diff --git a/testing/marionette/doc/Prefs.md b/testing/marionette/doc/Prefs.md
new file mode 100644
index 0000000000..2918a25c9e
--- /dev/null
+++ b/testing/marionette/doc/Prefs.md
@@ -0,0 +1,80 @@
+Preferences
+===========
+
+There are a couple of preferences associated with the Gecko remote
+protocol:
+
+
+`marionette.enabled`
+--------------------
+
+Starts and stops the Marionette server. This will cause a TCP
+server to bind to the port defined by `marionette.port`.
+
+If Gecko has not been started with the `-marionette` flag or the
+`MOZ_MARIONETTE` environment variable, changing this preference
+will have no effect. For Marionette to be enabled, either one of
+these options _must_ be given to Firefox or Fennec for Marionette
+to start.
+
+
+`marionette.debugging.clicktostart`
+-----------------------------------
+
+Delay server startup until a modal dialogue has been clicked to
+allow time for user to set breakpoints in the [Browser Toolbox].
+
+[Browser Toolbox]: https://developer.mozilla.org/en-US/docs/Tools/Browser_Toolbox
+
+
+`marionette.log.level`
+----------------------
+
+Sets the verbosity level of the Marionette logger repository. Note
+that this preference does not control the verbosity of other loggers
+used in Firefox or Fennec.
+
+The available levels are, in descending order of severity, `Trace`,
+`debug`, `config`, `info`, `warn`, `error`, and `fatal`. The value
+is treated case-insensitively.
+
+
+`marionette.log.truncate`
+-------------------------
+
+Certain log messages that are known to be long, such as wire protocol
+dumps, are truncated. This preference causes them not to be truncated.
+
+
+`marionette.port`
+-----------------
+
+Defines the port on which the Marionette server will listen. Defaults
+to port 2828.
+
+This can be set to 0 to have the system atomically allocate a free
+port, which can be useful when running multiple Marionette servers
+on the same system. The effective port is written to the user
+preference file when the server has started and is also logged to
+stdout.
+
+
+`marionette.prefs.recommended`
+------------------------------
+
+By default Marionette attempts to set a range of preferences deemed
+suitable in automation when it starts. These include the likes of
+disabling auto-updates, Telemetry, and first-run UX.
+
+The user preference file takes presedence over the recommended
+preferences, meaning any user-defined preference value will not be
+overridden.
+
+
+`marionette.contentListener`
+----------------------------
+
+Used internally in Marionette for determining whether content scripts
+can safely be reused. Should not be tweaked manually.
+
+This preference is scheduled for removal.
diff --git a/testing/marionette/doc/Protocol.md b/testing/marionette/doc/Protocol.md
new file mode 100644
index 0000000000..d9c9508409
--- /dev/null
+++ b/testing/marionette/doc/Protocol.md
@@ -0,0 +1,122 @@
+Protocol
+========
+
+Marionette provides an asynchronous, parallel pipelining user-facing
+interface. Message sequencing limits chances of payload race
+conditions and provides a uniform way in which payloads are serialised.
+
+Clients that deliver a blocking WebDriver interface are still
+expected to not send further command requests before the response
+from the last command has come back, but if they still happen to do
+so because of programming error, no harm will be done. This guards
+against [mixing up responses].
+
+Schematic flow of messages:
+
+ client server
+ | |
+ msgid=1 |----------->|
+ | command |
+ | |
+ msgid=2 |<-----------|
+ | command |
+ | |
+ msgid=2 |----------->|
+ | response |
+ | |
+ msgid=1 |<-----------|
+ | response |
+ | |
+
+The protocol consists of a [command] message and the corresponding
+[response] message. A [response] message must always be sent in
+reply to a [command] message.
+
+This means that the server implementation does not need to send
+the reply precisely in the order of the received commands: if it
+receives multiple messages, the server may even reply in random order.
+It is therefore strongly advised that clients take this into account
+when imlpementing the client end of this wire protocol.
+
+This is required for pipelining messages. On the server side,
+some functions are fast, and some less so. If the server must
+reply in order, the slow functions delay the other replies even if
+its execution is already completed.
+
+[mixing up responses]: https://bugzil.la/1207125
+
+
+Command
+-------
+
+The request, or command message, is a four element JSON Array as shown
+below, that may originate from either the client- or server remote ends:
+
+ [type, message ID, command, parameters]
+
+ * _type_ must be 0 (integer). This indicates that the message
+ is a [command].
+
+ * _message ID_ is a 32-bit unsigned integer. This number is
+ used as a sequencing number that uniquely identifies a pair of
+ [command] and [response] messages. The other remote part will
+ reply with a corresponding [response] with the same message ID.
+
+ * _command_ is a string identifying the RPC method or command
+ to execute.
+
+ * _parameters_ is an arbitrary JSON serialisable object.
+
+
+Response
+--------
+
+The response message is also a four element array as shown below,
+and must always be sent after receiving a [command]:
+
+ [type, message ID, error, result]
+
+ * _type_ must be 1 (integer). This indicates that the message is a
+ [response].
+
+ * _message ID_ is a 32-bit unsigned integer. This corresponds
+ to the [command]’s message ID.
+
+ * _error_ is null if the command executed correctly. If the
+ error occurred on the server-side, then this is an [error] object.
+
+ * _result_ is the result object from executing the [command], iff
+ it executed correctly. If an error occurred on the server-side,
+ this field is null.
+
+The structure of the result field can vary, but is documented
+individually for each command.
+
+
+Error object
+------------
+
+An error object is a serialisation of JavaScript error types,
+and it is structured like this:
+
+ {
+ "error": "invalid session id",
+ "message": "No active session with ID 1234",
+ "stacktrace": ""
+ }
+
+All the fields of the error object are required, so the stacktrace and
+message fields may be empty strings. The error field is guaranteed
+to be one of the JSON error codes as laid out by the [WebDriver standard].
+
+
+Clients
+-------
+
+Clients may be implemented in any language that is capable of writing
+and receiving data over TCP socket. A [reference client] is provided.
+Clients may be implemented both synchronously and asynchronously,
+although the latter is impossible in protocol levels 2 and earlier
+due to the lack of message sequencing.
+
+[reference client]: https://searchfox.org/mozilla-central/source/testing/marionette/client/
diff --git a/testing/marionette/doc/PythonTests.md b/testing/marionette/doc/PythonTests.md
new file mode 100644
index 0000000000..02d2da4d29
--- /dev/null
+++ b/testing/marionette/doc/PythonTests.md
@@ -0,0 +1,71 @@
+Mn Python tests
+===============
+
+_Marionette_ is the codename of a [remote protocol] built in to
+Firefox as well as the name of a functional test framework for
+automating user interface tests.
+
+The in-tree test framework supports tests written in Python, using
+Python’s [unittest] library. Test cases are written as a subclass
+of `MarionetteTestCase`, with child tests belonging to instance
+methods that have a name starting with `test_`.
+
+You can additionally define [`setUp`] and [`tearDown`] instance
+methods to execute code before and after child tests, and
+[`setUpClass`]/[`tearDownClass`] for the parent test. When you use
+these, it is important to remember calling the [`MarionetteTestCase`]
+superclass’ own `setUp`/`tearDown` methods since they handle
+setup/cleanup of the session.
+
+The test structure is illustrated here:
+
+ from marionette_test import MarionetteTestCase
+
+ class TestSomething(MarionetteTestCase):
+ def setUp(self):
+ # code to execute before any tests are run
+ MarionetteTestCase.setUp(self)
+
+ def test_foo(self):
+ # run test for 'foo'
+
+ def test_bar(self):
+ # run test for 'bar'
+
+ def tearDown(self):
+ # code to execute after all tests are run
+ MarionetteTestCase.tearDown(self)
+
+[remote protocol]: Protocol.html
+[unittest]: https://docs.python.org/2.7/library/unittest.html
+[`setUp`]: https://docs.python.org/2.7/library/unittest.html#unittest.TestCase.setUp
+[`setUpClass`]: https://docs.python.org/2.7/library/unittest.html#unittest.TestCase.setUpClass
+[`tearDown`]: https://docs.python.org/2.7/library/unittest.html#unittest.TestCase.tearDown
+[`tearDownClass`]: https://docs.python.org/2.7/library/unittest.html#unittest.TestCase.tearDownClass
+
+
+Test assertions
+---------------
+
+Assertions are provided courtesy of [unittest]. For example:
+
+ from marionette_test import MarionetteTestCase
+
+ class TestSomething(MarionetteTestCase):
+ def test_foo(self):
+ self.assertEqual(9, 3 * 3, '3 x 3 should be 9')
+ self.assertTrue(type(2) == int, '2 should be an integer')
+
+
+The API
+-------
+
+The full API documentation is found [here], but the key objects are:
+
+ * `MarionetteTestCase`: a subclass for `unittest.TestCase`
+ used as a base class for all tests to run.
+
+ * [`Marionette`]: client that speaks to Firefox.
+
+[here]: ../../../python/marionette_driver.html
+[`Marionette`]: ../../../python/marionette_driver.html#marionette_driver.marionette.Marionette
diff --git a/testing/marionette/doc/SeleniumAtoms.md b/testing/marionette/doc/SeleniumAtoms.md
new file mode 100644
index 0000000000..9f5a73f36c
--- /dev/null
+++ b/testing/marionette/doc/SeleniumAtoms.md
@@ -0,0 +1,84 @@
+Selenium atoms
+==============
+
+Marionette uses a small list of [Selenium atoms] to interact with
+web elements. Initially those have been added to ensure a better
+reliability due to a wider usage inside the Selenium project. But
+by adding full support for the [WebDriver specification] they will
+be removed step by step.
+
+Currently the following atoms are in use:
+
+- `getElementText`
+- `isDisplayed`
+
+To use one of those atoms Javascript modules will have to import
+[atom.js].
+
+[Selenium atoms]: https://github.com/SeleniumHQ/selenium/tree/master/javascript/webdriver/atoms
+[WebDriver specification]: https://w3c.github.io/webdriver/webdriver-spec.html
+[atom.js]: https://searchfox.org/mozilla-central/source/testing/marionette/atom.js
+
+
+Update required Selenium atoms
+------------------------------
+
+In regular intervals the atoms, which are still in use, have to
+be updated. Therefore they have to be exported from the Selenium
+repository first, and then updated in [atom.js].
+
+
+### Export Selenium Atoms
+
+The canonical GitHub repository for Selenium is
+
+ https://github.com/SeleniumHQ/selenium.git
+
+so make sure to have a local copy of it. For the cloning process
+it is recommended to specify the `--depth=1` argument, so only the
+last changeset is getting downloaded (which itself will already be
+more than 100 MB). Once the clone is ready the export of the atoms
+can be triggered by running the following commands:
+
+ % cd selenium
+ % ./go
+ % python buck-out/crazy-fun/%changeset%/buck.pex build --show-output %atom%
+
+Hereby `%changeset%` corresponds to the currently used version of
+buck, and `%atom%` to the atom to export. The following targets
+for exporting are available:
+
+ - `//javascript/webdriver/atoms:clear-element-firefox`
+ - `//javascript/webdriver/atoms:get-text-firefox`
+ - `//javascript/webdriver/atoms:is-displayed-firefox`
+ - `//javascript/webdriver/atoms:is-enabled-firefox`
+ - `//javascript/webdriver/atoms:is-selected-firefox`
+
+For each of the exported atoms a file can now be found in the folder
+`buck-out/gen/javascript/webdriver/atoms/`. They contain all the
+code including dependencies for the atom wrapped into a single function.
+
+
+### Update atom.js
+
+To update the atoms for Marionette the `atoms.js` file has to be edited. For
+each atom to be updated the steps as laid out below have to be performed:
+
+1. Open the Javascript file of the exported atom. See above for
+ its location.
+
+2. Remove the contained license header, which can be found somewhere
+ in the middle of the file.
+
+3. Update the parameters of the wrapper function (at the very top)
+ so that those are equal with the used parameters in `atom.js`.
+
+4. Copy the whole content of the file, and replace the existing
+ code for the atom in `atom.js`.
+
+
+### Test the changes
+
+To ensure that the update of the atoms doesn't cause a regression
+a try build should be run including Marionette unit tests, Firefox
+ui tests, and all the web-platform-tests.
diff --git a/testing/marionette/doc/Taskcluster.md b/testing/marionette/doc/Taskcluster.md
new file mode 100644
index 0000000000..b8490d4bad
--- /dev/null
+++ b/testing/marionette/doc/Taskcluster.md
@@ -0,0 +1,94 @@
+Testing with one-click loaners
+==============================
+
+[Taskcluster] is the task execution framework that supports Mozilla's
+continuous integration and release processes.
+
+Build and test jobs (like Marionette) are executed across all supported
+platforms, and job results are pushed to [Treeherder] for observation.
+
+The best way to debug issues for intermittent test failures of
+Marionette tests for Firefox and Fennec (Android) is to use a
+one-click loaner as provided by Taskcluster. Such a loaner creates
+an interactive task you can interact with via a shell and VNC.
+
+To create an interactive task for a Marionette job which is shown
+as failed on Treeherder, follow the Taskcluster documentation for
+[Debugging a task].
+
+Please note that you need special permissions to actually request
+such a loaner.
+
+When the task has been created the shell needs to be opened.
+Once that has been done a wizard will automatically launch and
+provide some options. Best here is to choose the second option,
+which will run all the setup steps, installs the Firefox or Fennec
+binary, and then exits.
+
+[Taskcluster]: https://docs.taskcluster.net/
+[Treeherder]: https://treeherder.mozilla.org
+[Debugging a task]: https://docs.taskcluster.net/tutorial/debug-task#content
+
+
+Setting up the Marionette environment
+-------------------------------------
+
+Best here is to use a virtual environment, which has all the
+necessary packages installed. If no modifications to any Python
+package will be done, the already created environment by the
+wizard can be used:
+
+ % cd /builds/worker/workspace/build
+ % source venv/bin/activate
+
+Otherwise a new virtual environment needs to be created and
+populated with the mozbase and marionette packages installed:
+
+ % cd /builds/worker/workspace/build && rm -r venv
+ % virtualenv venv && source venv/bin/activate
+ % cd tests/mozbase && ./setup_development.py
+ % cd ../marionette/client && python setup.py develop
+ % cd ../harness && python setup.py develop
+ % cd ../../../
+
+
+Running Marionette tests
+------------------------
+
+### Firefox
+
+To run the Marionette tests execute the `runtests.py` script. For all
+the required options as best search in the log file of the failing job
+the interactive task has been created from. Then copy the complete
+command and run it inside the already sourced virtual environment:
+
+ % /builds/worker/workspace/build/venv/bin/python -u /builds/worker/workspace/build/tests/marionette/harness/marionette_harness/runtests.py --gecko-log=- -vv --binary=/builds/worker/workspace/build/application/firefox/firefox --address=127.0.0.1:2828 --symbols-path=https://queue.taskcluster.net/v1/task/GSuwee61Qyibujtxq4UV3A/artifacts/public/build/target.crashreporter-symbols.zip /builds/worker/workspace/build/tests/marionette/tests/testing/marionette/harness/marionette_harness/tests/unit-tests.ini
+
+
+#### Fennec
+
+The Marionette tests for Fennec are executed by using an Android
+emulator which runs on the host platform. As such some extra setup
+steps compared to Firefox on desktop are required.
+
+The following lines set necessary environment variables before
+starting the emulator in the background, and to let Marionette
+know of various Android SDK tools.
+
+ % export ADB_PATH=/builds/worker/workspace/build/android-sdk-linux/platform-tools/adb
+ % export ANDROID_AVD_HOME=/builds/worker/workspace/build/.android/avd/
+ % /builds/worker/workspace/build/android-sdk-linux/tools/emulator -avd test-1 -show-kernel -debug init,console,gles,memcheck,adbserver,adbclient,adb,avd_config,socket &
+
+The actual call to `runtests.py` is different per test job because
+those are using chunks on Android. As best search for the command
+and its options in the log file of the failing job the interactive
+task has been created from. Then copy the complete command and run
+it inside the already sourced virtual environment.
+
+Here an example for chunk 1 which runs all the tests in the current
+chunk with some options for logs removed:
+
+ % /builds/worker/workspace/build/venv/bin/python -u /builds/worker/workspace/build/tests/marionette/harness/marionette_harness/runtests.py --emulator --app=fennec --package=org.mozilla.fennec_aurora --address=127.0.0.1:2828 /builds/worker/workspace/build/tests/marionette/tests/testing/marionette/harness/marionette_harness/tests/unit-tests.ini --gecko-log=- --symbols-path=/builds/worker/workspace/build/symbols --startup-timeout=300 --this-chunk 1 --total-chunks 10
+
+To execute a specific test only simply replace `unit-tests.ini`
+with its name.
diff --git a/testing/marionette/doc/Testing.md b/testing/marionette/doc/Testing.md
new file mode 100644
index 0000000000..1b9db0d8f3
--- /dev/null
+++ b/testing/marionette/doc/Testing.md
@@ -0,0 +1,205 @@
+Testing
+=======
+
+We verify and test Marionette in a couple of different ways, using
+a combination of unit tests and functional tests. There are three
+distinct components that we test:
+
+ - the Marionette **server**, using a combination of xpcshell
+ unit tests and functional tests written in Python spread across
+ Marionette- and WPT tests;
+
+ - the Python **client** is tested with the same body of functional
+ Marionette tests;
+
+ - and the **harness** that backs the Marionette, or `Mn` job on
+ try, tests is verified using separate mock-styled unit tests.
+
+All these tests can be run by using [mach].
+
+[mach]: https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/mach
+
+xpcshell unit tests
+-------------------
+
+Marionette has a set of [xpcshell] unit tests located in
+_testing/marionette/test/unit. These can be run this way:
+
+ % ./mach test testing/marionette/test/unit
+
+Because tests are run in parallel and xpcshell itself is quite
+chatty, it can sometimes be useful to run the tests sequentially:
+
+ % ./mach test --sequential testing/marionette/test/unit/test_error.js
+
+These unit tests run as part of the `X` jobs on Treeherder.
+
+[xpcshell]: https://developer.mozilla.org/en-US/docs/Mozilla/QA/Writing_xpcshell-based_unit_tests
+
+
+Marionette functional tests
+---------------------------
+
+We also have a set of [functional tests] that make use of the Marionette
+Python client. These start a Firefox process and tests the Marionette
+protocol input and output, and will appear as `Mn` on Treeherder.
+The following command will run all tests locally:
+
+ % ./mach marionette-test
+
+But you can also run individual tests:
+
+ % ./mach marionette-test testing/marionette/harness/marionette_harness/tests/unit/test_navigation.py
+
+In case you want to run the tests with another binary like [Firefox Nightly]:
+
+ % ./mach marionette-test --binary /path/to/nightly/firefox TEST
+
+When working on Marionette it is often useful to surface the stdout
+from Gecko, which can be achieved using the `--gecko-log` option.
+See <Debugging.html> for usage instructions, but the gist is that
+you can redirect all Gecko output to stdout:
+
+ % ./mach marionette-test --gecko-log - TEST
+
+Our functional integration tests pop up Firefox windows sporadically,
+and a helpful tip is to suppress the window can be to use Firefox’
+[headless mode]:
+
+ % ./mach marionette-test -z TEST
+
+`-z` is an alias for the `--headless` flag and equivalent to setting
+the `MOZ_HEADLESS` output variable. In addition to `MOZ_HEADLESS`
+there is also `MOZ_HEADLESS_WIDTH` and `MOZ_HEADLESS_HEIGHT` for
+controlling the dimensions of the no-op virtual display. This is
+similar to using Xvfb(1) which you may know from the X windowing system,
+but has the additional benefit of also working on macOS and Windows.
+
+[functional tests]: PythonTests.html
+[Firefox Nightly]: https://nightly.mozilla.org/
+
+
+### Android
+
+Prerequisites:
+
+* You have [built Fennec](https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/Build_Instructions/Simple_Firefox_for_Android_build).
+* You can run an Android [emulator](https://wiki.mozilla.org/Mobile/Fennec/Android/Testing#Running_tests_on_the_Android_emulator),
+ which means you have the AVD you need.
+
+When running tests on Fennec, you can have Marionette runner take care of
+starting Fennec and an emulator, as shown below.
+
+ % ./mach marionette-test --emulator --app fennec
+ --avd-home /path/to/.mozbuild/android-device/avd
+ --emulator-binary /path/to/.mozbuild/android-sdk/emulator/emulator
+ --avd=mozemulator-x86
+
+For Fennec tests, if the appropriate `emulator` command is in your `PATH`, you may omit the `--emulator-binary` argument. See `./mach marionette-test -h`
+for additional options.
+
+Alternately, you can start an emulator yourself and have the Marionette runner
+start Fennec for you:
+
+ % ./mach marionette-test --emulator --app='fennec' --address=127.0.0.1:2828
+
+To connect to an already-running Fennec in an already running emulator or on a device, you will need to enable Marionette manually by setting the browser preference
+`marionette.enabled` set to true in the Fennec profile.
+
+Make sure port 2828 is forwarded:
+
+ % adb forward tcp:2828 tcp:2828
+
+If Fennec is already started:
+
+ % ./mach marionette-test --app='fennec' --address=127.0.0.1:2828
+
+If Fennec is not already started on the emulator/device, add the `--emulator`
+option. Marionette Test Runner will take care of forwarding the port and
+starting Fennec with the correct prefs. (You may need to run
+`adb forward --remove-all` to allow the runner to start.)
+
+ % ./mach marionette-test --emulator --app='fennec' --address=127.0.0.1:2828 --startup-timeout=300
+
+If you need to troubleshoot the Marionette connection, the most basic check is
+to start Fennec, make sure the `marionette.enabled` browser preference is
+true and port 2828 is forwarded, then see if you get any response from
+Marionette when you connect manually:
+
+ % telnet 127.0.0.1:2828
+
+You should see output like `{"applicationType":"gecko","marionetteProtocol":3}`
+
+[headless mode]: https://developer.mozilla.org/en-US/Firefox/Headless_mode
+[geckodriver]: /testing/geckodriver/geckodriver
+
+
+WPT functional tests
+--------------------
+
+Marionette is also indirectly tested through [geckodriver] with WPT
+(`Wd` on Treeherder). To run them:
+
+ % ./mach wpt testing/web-platform/tests/webdriver
+
+WPT tests conformance to the [WebDriver] standard and uses
+[geckodriver]. Together with the Marionette remote protocol in
+Gecko, they make up Mozilla’s WebDriver implementation.
+
+This command supports a `--webdriver-arg='-vv'` argument that
+enables more detailed logging, as well as `--jsdebugger` for opening
+the Browser Toolbox.
+
+A particularly useful trick is to combine this with the [headless
+mode] for Firefox we learned about earlier:
+
+ % MOZ_HEADLESS=1 ./mach wpt --webdriver-arg='-vv' testing/web-platform/tests/webdriver
+
+[WebDriver]: https://w3c.github.io/webdriver/webdriver-spec.html
+
+
+Harness tests
+-------------
+
+The Marionette harness Python package has a set of mock-styled unit
+tests that uses the [pytest] framework. The following command will
+run all tests:
+
+ % ./mach python-test testing/marionette
+
+To run a specific test specify the full path to the module:
+
+ % ./mach python-test testing/marionette/harness/marionette_harness/tests/harness_unit/test_serve.py
+
+[pytest]: https://docs.pytest.org/en/latest/
+
+
+One-click loaners
+-----------------
+
+Additionally, for debugging hard-to-reproduce test failures in CI,
+one-click loaners from <Taskcluster.html> can be particularly useful.
+
+
+Out-of-tree testing
+-------------------
+
+All the above examples show tests running _in-tree_, with a local
+checkout of _central_ and a local build of Firefox. It is also
+possibly to run the Marionette tests _without_ a local build and
+with a downloaded test archive from <Taskcluster.html>.
+
+If you want to run tests from a downloaded test archive, you will
+need to download the `target.common.tests.tar.gz` artifact attached to
+Treeherder [build jobs] `B` for your system. Extract the archive
+and set up the Python Marionette client and harness by executing
+the following command in a virtual environment:
+
+ % pip install -r config/marionette_requirements.txt
+
+The tests can then be found under
+_marionette/tests/testing/marionette/harness/marionette_harness/tests_
+and can be executed with the command `marionette`. It supports
+the same options as described above for `mach`.
+
+[build jobs]: https://treeherder.mozilla.org/#/jobs?repo=mozilla-central&filter-searchStr=build
diff --git a/testing/marionette/doc/index.rst b/testing/marionette/doc/index.rst
new file mode 100644
index 0000000000..3309928f6d
--- /dev/null
+++ b/testing/marionette/doc/index.rst
@@ -0,0 +1,68 @@
+==========
+Marionette
+==========
+
+Marionette is a remote `protocol`_ that lets out-of-process programs
+communicate with, instrument, and control Gecko-based browsers.
+
+It provides interfaces for interacting with both the internal JavaScript
+runtime and UI elements of Gecko-based browsers, such as Firefox
+and Fennec. It can control both the chrome- and content documents,
+giving a high level of control and ability to emulate user interaction.
+
+Within the central tree, Marionette is used in most TaskCluster
+test jobs to instrument Gecko. It can additionally be used to
+write different kinds of functional tests:
+
+ * The `Marionette Python client`_ is used in the `Mn` job, which
+ is generally what you want to use for interacting with web documents
+
+Outside the tree, Marionette is used by `geckodriver`_ to implement
+`WebDriver`_.
+
+Marionette supports to various degrees all the Gecko based applications,
+including Firefox, Thunderbird, Fennec, and Fenix.
+
+.. _protocol: Protocol.html
+.. _Marionette Python client: /python/marionette_driver.html
+.. _geckodriver: /testing/geckodriver/
+.. _WebDriver: https://w3c.github.io/webdriver/
+
+Some further documentation can be found here:
+
+.. toctree::
+ :maxdepth: 1
+
+ Intro.md
+ Building.md
+ PythonTests.md
+ Protocol.md
+ Contributing.md
+ NewContributors.md
+ Patches.md
+ Debugging.md
+ Testing.md
+ Taskcluster.md
+ CodeStyle.md
+ SeleniumAtoms.md
+ Prefs.md
+ internals/index
+
+
+Bugs
+====
+
+Bugs are tracked in the `Testing :: Marionette` component.
+
+
+Communication
+=============
+
+The mailing list for Marionette discussion is
+tools-marionette@lists.mozilla.org (`subscribe`_, `archive`_).
+
+If you prefer real-time chat, ask your questions
+on `#interop:mozilla.org <https://chat.mozilla.org/#/room/#interop:mozilla.org>`__.
+
+.. _subscribe: https://lists.mozilla.org/listinfo/tools-marionette
+.. _archive: https://lists.mozilla.org/pipermail/tools-marionette/
diff --git a/testing/marionette/doc/internals/action.rst b/testing/marionette/doc/internals/action.rst
new file mode 100644
index 0000000000..1f466ee2d8
--- /dev/null
+++ b/testing/marionette/doc/internals/action.rst
@@ -0,0 +1,4 @@
+action module
+=============
+.. js:autoclass:: action
+ :members:
diff --git a/testing/marionette/doc/internals/addon.rst b/testing/marionette/doc/internals/addon.rst
new file mode 100644
index 0000000000..21a3997807
--- /dev/null
+++ b/testing/marionette/doc/internals/addon.rst
@@ -0,0 +1,7 @@
+addon module
+============
+
+Addon
+-----
+.. js:autoclass:: Addon
+ :members:
diff --git a/testing/marionette/doc/internals/assert.rst b/testing/marionette/doc/internals/assert.rst
new file mode 100644
index 0000000000..ec143f6679
--- /dev/null
+++ b/testing/marionette/doc/internals/assert.rst
@@ -0,0 +1,4 @@
+assert module
+=============
+.. js:autoclass:: assert
+ :members:
diff --git a/testing/marionette/doc/internals/browser.rst b/testing/marionette/doc/internals/browser.rst
new file mode 100644
index 0000000000..ed3839b5c9
--- /dev/null
+++ b/testing/marionette/doc/internals/browser.rst
@@ -0,0 +1,4 @@
+browser module
+==============
+.. js:autoclass:: event
+ :members:
diff --git a/testing/marionette/doc/internals/capabilities.rst b/testing/marionette/doc/internals/capabilities.rst
new file mode 100644
index 0000000000..2ce5d3b19c
--- /dev/null
+++ b/testing/marionette/doc/internals/capabilities.rst
@@ -0,0 +1,22 @@
+capabilities module
+===================
+
+Timeouts
+--------
+.. js:autoclass:: Timeouts
+ :members:
+
+PageLoadStrategy
+----------------
+.. js:autoclass:: PageLoadStrategy
+ :members:
+
+Proxy
+-----
+.. js:autoclass:: Proxy
+ :members:
+
+Capabilities
+------------
+.. js:autoclass:: Capabilities
+ :members:
diff --git a/testing/marionette/doc/internals/capture.rst b/testing/marionette/doc/internals/capture.rst
new file mode 100644
index 0000000000..a3abd6b152
--- /dev/null
+++ b/testing/marionette/doc/internals/capture.rst
@@ -0,0 +1,7 @@
+capture module
+==============
+
+capture.canvas
+--------------
+.. js:autoclass:: capture.canvas
+ :members:
diff --git a/testing/marionette/doc/internals/cert.rst b/testing/marionette/doc/internals/cert.rst
new file mode 100644
index 0000000000..33e00d7d1e
--- /dev/null
+++ b/testing/marionette/doc/internals/cert.rst
@@ -0,0 +1,4 @@
+cert module
+===========
+.. js:autoclass:: allowAllCerts
+ :members:
diff --git a/testing/marionette/doc/internals/cookie.rst b/testing/marionette/doc/internals/cookie.rst
new file mode 100644
index 0000000000..502c7f4c2b
--- /dev/null
+++ b/testing/marionette/doc/internals/cookie.rst
@@ -0,0 +1,4 @@
+cookie module
+=============
+.. js:autoclass:: cookie
+ :members:
diff --git a/testing/marionette/doc/internals/dom.rst b/testing/marionette/doc/internals/dom.rst
new file mode 100644
index 0000000000..cc09042fb1
--- /dev/null
+++ b/testing/marionette/doc/internals/dom.rst
@@ -0,0 +1,8 @@
+dom module
+==========
+
+.. js:autoclass:: ContentEventObserverService
+ :members:
+
+.. js:autoclass:: WebElementEventTarget
+ :members:
diff --git a/testing/marionette/doc/internals/driver.rst b/testing/marionette/doc/internals/driver.rst
new file mode 100644
index 0000000000..2181395c1f
--- /dev/null
+++ b/testing/marionette/doc/internals/driver.rst
@@ -0,0 +1,4 @@
+driver module
+=============
+.. js:autoclass:: driver
+ :members:
diff --git a/testing/marionette/doc/internals/element.rst b/testing/marionette/doc/internals/element.rst
new file mode 100644
index 0000000000..5fabfc2c34
--- /dev/null
+++ b/testing/marionette/doc/internals/element.rst
@@ -0,0 +1,144 @@
+element module
+==============
+
+element.Store
+-------------
+.. js:autoclass:: element.Store
+ :members:
+
+element.find
+------------
+.. js:autofunction:: element.find
+
+element.findByXPath
+-------------------
+.. js:autofunction:: element.findByXPath
+
+element.findByXPathAll
+----------------------
+.. js:autofunction:: element.findByXPathAll
+
+element.findByLinkText
+----------------------
+.. js:autofunction:: element.findByLinkText
+
+element.findByPartialLinkText
+-----------------------------
+.. js:autofunction:: element.findByPartialLinkText
+
+element.findClosest
+-------------------
+.. js:autofunction:: element.findClosest
+
+element.isCollection
+--------------------
+.. js:autofunction:: element.isCollection
+
+element.isStale
+---------------
+.. js:autofunction:: element.isStale
+
+element.isSelected
+------------------
+.. js:autofunction:: element.isSelected
+
+element.isReadOnly
+------------------
+.. js:autofunction:: element.isReadOnly
+
+element.isDisabled
+------------------
+.. js:autofunction:: element.isDisabled
+
+element.isMutableFormControl
+----------------------------
+.. js:autofunction:: element.isMutableFormControl
+
+element.isEditingHost
+---------------------
+.. js:autofunction:: element.isEditingHost
+
+element.isEditable
+------------------
+.. js:autofunction:: element.isEditable
+
+element.coordinates
+-------------------
+.. js:autofunction:: element.coordinates
+
+element.inViewport
+------------------
+.. js:autofunction:: element.inViewport
+
+element.getContainer
+---------------------
+.. js:autofunction:: element.getContainer
+
+element.isInView
+----------------
+.. js:autofunction:: element.isInView
+
+element.isVisible
+-----------------
+.. js:autofunction:: element.isVisible
+
+element.isObscured
+------------------
+.. js:autofunction:: element.isObscured
+
+element.getInViewCentrePoint
+----------------------------
+.. js:autofunction:: element.getInViewCentrePoint
+
+element.getPointerInteractablePaintTree
+---------------------------------------
+.. js:autofunction:: element.getPointerInteractablePaintTree
+
+element.scrollIntoView
+----------------------
+.. js:autofunction:: element.scrollIntoView
+
+element.isElement
+-----------------
+.. js:autofunction:: element.isElement
+
+element.isDOMElement
+--------------------
+.. js:autofunction:: element.isDOMElement
+
+element.isXULElement
+--------------------
+.. js:autofunction:: element.isXULElement
+
+element.isDOMWindow
+--------------------
+.. js:autofunction:: element.isDOMWindow
+
+element.isBooleanAttribute
+--------------------------
+.. js:autofunction:: element.isBooleanAttribute
+
+ChromeWebElement
+----------------
+.. js:autoclass:: ChromeWebElement
+ :members:
+
+ContentWebElement
+-----------------
+.. js:autoclass:: ContentWebElement
+ :members:
+
+ContentWebFrame
+---------------
+.. js:autoclass:: ContentWebFrame
+ :members:
+
+ContentWebWindow
+----------------
+.. js:autoclass:: ContentWebWindow
+ :members:
+
+WebElement
+----------
+.. js:autoclass:: WebElement
+ :members:
diff --git a/testing/marionette/doc/internals/error.rst b/testing/marionette/doc/internals/error.rst
new file mode 100644
index 0000000000..fb012af737
--- /dev/null
+++ b/testing/marionette/doc/internals/error.rst
@@ -0,0 +1,35 @@
+error module
+============
+
+.. js:autofunction:: error.isError
+.. js:autofunction:: error.isWebDriverError
+.. js:autofunction:: error.wrap
+.. js:autofunction:: error.report
+.. js:autofunction:: error.stringify
+.. js:autofunction:: stack
+
+.. js:autoclass:: ElementClickInterceptedError
+.. js:autoclass:: ElementNotAccessibleError
+.. js:autoclass:: ElementNotInteractableError
+.. js:autoclass:: InsecureCertificateError
+.. js:autoclass:: InvalidArgumentError
+.. js:autoclass:: InvalidCookieDomainError
+.. js:autoclass:: InvalidElementStateError
+.. js:autoclass:: InvalidSelectorError
+.. js:autoclass:: InvalidSessionIDError
+.. js:autoclass:: JavaScriptError
+.. js:autoclass:: MoveTargetOutOfBoundsError
+.. js:autoclass:: NoSuchAlertError
+.. js:autoclass:: NoSuchElementError
+.. js:autoclass:: NoSuchFrameError
+.. js:autoclass:: NoSuchWindowError
+.. js:autoclass:: ScriptTimeoutError
+.. js:autoclass:: SessionNotCreatedError
+.. js:autoclass:: StaleElementReferenceError
+.. js:autoclass:: TimeoutError
+.. js:autoclass:: UnableToSetCookieError
+.. js:autoclass:: UnexpectedAlertOpenError
+.. js:autoclass:: UnknownCommandError
+.. js:autoclass:: UnknownError
+.. js:autoclass:: UnsupportedOperationError
+.. js:autoclass:: WebDriverError
diff --git a/testing/marionette/doc/internals/evaluate.rst b/testing/marionette/doc/internals/evaluate.rst
new file mode 100644
index 0000000000..8cbf4448d6
--- /dev/null
+++ b/testing/marionette/doc/internals/evaluate.rst
@@ -0,0 +1,4 @@
+evaluate module
+===============
+.. js:autoclass:: evaluate
+ :members:
diff --git a/testing/marionette/doc/internals/event.rst b/testing/marionette/doc/internals/event.rst
new file mode 100644
index 0000000000..296d1b4bdb
--- /dev/null
+++ b/testing/marionette/doc/internals/event.rst
@@ -0,0 +1,4 @@
+event module
+============
+.. js:autoclass:: event
+ :members:
diff --git a/testing/marionette/doc/internals/format.rst b/testing/marionette/doc/internals/format.rst
new file mode 100644
index 0000000000..0d90c6375b
--- /dev/null
+++ b/testing/marionette/doc/internals/format.rst
@@ -0,0 +1,11 @@
+format module
+=============
+
+
+pprint
+------
+.. js:autofunction:: pprint
+
+truncate
+--------
+.. js:autofunction:: truncate
diff --git a/testing/marionette/doc/internals/index.rst b/testing/marionette/doc/internals/index.rst
new file mode 100644
index 0000000000..a52012e0bf
--- /dev/null
+++ b/testing/marionette/doc/internals/index.rst
@@ -0,0 +1,11 @@
+=========
+Internals
+=========
+
+This is an overview of all documented internals in the Marionette server:
+
+.. toctree::
+ :glob:
+ :maxdepth: 1
+
+ *
diff --git a/testing/marionette/doc/internals/interaction.rst b/testing/marionette/doc/internals/interaction.rst
new file mode 100644
index 0000000000..298fd5a0f0
--- /dev/null
+++ b/testing/marionette/doc/internals/interaction.rst
@@ -0,0 +1,4 @@
+interaction module
+==================
+.. js:autoclass:: interaction
+ :members:
diff --git a/testing/marionette/doc/internals/listener.rst b/testing/marionette/doc/internals/listener.rst
new file mode 100644
index 0000000000..4e6b49d0d3
--- /dev/null
+++ b/testing/marionette/doc/internals/listener.rst
@@ -0,0 +1,2 @@
+listener module
+===============
diff --git a/testing/marionette/doc/internals/log.rst b/testing/marionette/doc/internals/log.rst
new file mode 100644
index 0000000000..bf8dad1369
--- /dev/null
+++ b/testing/marionette/doc/internals/log.rst
@@ -0,0 +1,4 @@
+log module
+==========
+.. js:autoclass:: Log
+ :members:
diff --git a/testing/marionette/doc/internals/message.rst b/testing/marionette/doc/internals/message.rst
new file mode 100644
index 0000000000..c44005a5d7
--- /dev/null
+++ b/testing/marionette/doc/internals/message.rst
@@ -0,0 +1,17 @@
+message module
+==============
+
+Command
+-------
+.. js:autoclass:: Command
+ :members:
+
+Message
+-------
+.. js:autoclass:: Message
+ :members:
+
+Response
+--------
+.. js:autoclass:: Response
+ :members:
diff --git a/testing/marionette/doc/internals/modal.rst b/testing/marionette/doc/internals/modal.rst
new file mode 100644
index 0000000000..5a9b30ba50
--- /dev/null
+++ b/testing/marionette/doc/internals/modal.rst
@@ -0,0 +1,4 @@
+modal module
+============
+.. js:autoclass:: modal
+ :members:
diff --git a/testing/marionette/doc/internals/navigate.rst b/testing/marionette/doc/internals/navigate.rst
new file mode 100644
index 0000000000..4141c79d3c
--- /dev/null
+++ b/testing/marionette/doc/internals/navigate.rst
@@ -0,0 +1,7 @@
+navigate module
+===============
+
+isLoadEventExpected
+-------------------
+.. js:autoclass:: navigate.isLoadEventExpected
+ :members:
diff --git a/testing/marionette/doc/internals/packets.rst b/testing/marionette/doc/internals/packets.rst
new file mode 100644
index 0000000000..5bc76d48af
--- /dev/null
+++ b/testing/marionette/doc/internals/packets.rst
@@ -0,0 +1,22 @@
+packets module
+==============
+
+RawPacket
+---------
+.. js:autoclass:: RawPacket
+ :members:
+
+Packet
+------
+.. js:autoclass:: Packet
+ :members:
+
+JSONPacket
+----------
+.. js:autoclass:: JSONPacket
+ :members:
+
+BulkPacket
+----------
+.. js:autoclass:: BulkPacket
+ :members:
diff --git a/testing/marionette/doc/internals/prefs.rst b/testing/marionette/doc/internals/prefs.rst
new file mode 100644
index 0000000000..d54383b70f
--- /dev/null
+++ b/testing/marionette/doc/internals/prefs.rst
@@ -0,0 +1,17 @@
+prefs module
+============
+
+Branch
+------
+.. js:autoclass:: Branch
+ :members:
+
+EnvironmentPrefs
+----------------
+.. js:autoclass:: EnvironmentPrefs
+ :members:
+
+MarionetteBranch
+----------------
+.. js:autoclass:: MarionetteBranch
+ :members:
diff --git a/testing/marionette/doc/internals/proxy.rst b/testing/marionette/doc/internals/proxy.rst
new file mode 100644
index 0000000000..fe7a775c62
--- /dev/null
+++ b/testing/marionette/doc/internals/proxy.rst
@@ -0,0 +1,4 @@
+proxy module
+============
+.. js:autoclass:: proxy
+ :members:
diff --git a/testing/marionette/doc/internals/reftest.rst b/testing/marionette/doc/internals/reftest.rst
new file mode 100644
index 0000000000..85e811850c
--- /dev/null
+++ b/testing/marionette/doc/internals/reftest.rst
@@ -0,0 +1,4 @@
+reftest module
+==============
+.. js:autoclass:: reftest
+ :members:
diff --git a/testing/marionette/doc/internals/server.rst b/testing/marionette/doc/internals/server.rst
new file mode 100644
index 0000000000..8b2f1c391b
--- /dev/null
+++ b/testing/marionette/doc/internals/server.rst
@@ -0,0 +1,12 @@
+server module
+=============
+
+TCPConnection
+-------------
+.. js:autoclass:: TCPConnection
+ :members:
+
+TCPListener
+-----------
+.. js:autoclass:: TCPListener
+ :members:
diff --git a/testing/marionette/doc/internals/sync.rst b/testing/marionette/doc/internals/sync.rst
new file mode 100644
index 0000000000..3ec1ac3f31
--- /dev/null
+++ b/testing/marionette/doc/internals/sync.rst
@@ -0,0 +1,24 @@
+sync module
+===========
+
+Provides an assortment of synchronisation primitives.
+
+.. js:autofunction:: executeSoon
+
+.. js:autoclass:: MessageManagerDestroyedPromise
+ :members:
+
+.. js:autoclass:: PollPromise
+ :members:
+
+.. js:autoclass:: Sleep
+ :members:
+
+.. js:autoclass:: TimedPromise
+ :members:
+
+.. js:autofunction:: waitForEvent
+
+.. js:autofunction:: waitForMessage
+
+.. js:autofunction:: waitForObserverTopic
diff --git a/testing/marionette/dom.js b/testing/marionette/dom.js
new file mode 100644
index 0000000000..ff90e35a1d
--- /dev/null
+++ b/testing/marionette/dom.js
@@ -0,0 +1,215 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "ContentEventObserverService",
+ "WebElementEventTarget",
+];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ Log: "chrome://marionette/content/log.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+
+/**
+ * The ``EventTarget`` for web elements can be used to observe DOM
+ * events in the content document.
+ *
+ * A caveat of the current implementation is that it is only possible
+ * to listen for top-level ``window`` global events.
+ *
+ * It needs to be backed by a :js:class:`ContentEventObserverService`
+ * in a content frame script.
+ *
+ * Usage::
+ *
+ * let observer = new WebElementEventTarget(messageManager);
+ * await new Promise(resolve => {
+ * observer.addEventListener("visibilitychange", resolve, {once: true});
+ * chromeWindow.minimize();
+ * });
+ */
+class WebElementEventTarget {
+ /**
+ * @param {function(): nsIMessageListenerManager} messageManagerFn
+ * Message manager to the current browser.
+ */
+ constructor(messageManager) {
+ this.mm = messageManager;
+ this.listeners = {};
+ this.mm.addMessageListener("Marionette:DOM:OnEvent", this);
+ }
+
+ /**
+ * Register an event handler of a specific event type from the content
+ * frame.
+ *
+ * @param {string} type
+ * Event type to listen for.
+ * @param {EventListener} listener
+ * Object which receives a notification (a ``BareEvent``)
+ * when an event of the specified type occurs. This must be
+ * an object implementing the ``EventListener`` interface,
+ * or a JavaScript function.
+ * @param {boolean=} once
+ * Indicates that the ``listener`` should be invoked at
+ * most once after being added. If true, the ``listener``
+ * would automatically be removed when invoked.
+ */
+ addEventListener(type, listener, { once = false } = {}) {
+ if (!(type in this.listeners)) {
+ this.listeners[type] = [];
+ }
+
+ if (!this.listeners[type].includes(listener)) {
+ listener.once = once;
+ this.listeners[type].push(listener);
+ }
+
+ this.mm.sendAsyncMessage("Marionette:DOM:AddEventListener", { type });
+ }
+
+ /**
+ * Removes an event listener.
+ *
+ * @param {string} type
+ * Type of event to cease listening for.
+ * @param {EventListener} listener
+ * Event handler to remove from the event target.
+ */
+ removeEventListener(type, listener) {
+ if (!(type in this.listeners)) {
+ return;
+ }
+
+ let stack = this.listeners[type];
+ for (let i = stack.length - 1; i >= 0; --i) {
+ if (stack[i] === listener) {
+ stack.splice(i, 1);
+ if (stack.length == 0) {
+ this.mm.sendAsyncMessage("Marionette:DOM:RemoveEventListener", {
+ type,
+ });
+ }
+ return;
+ }
+ }
+ }
+
+ dispatchEvent(event) {
+ if (!(event.type in this.listeners)) {
+ return;
+ }
+
+ event.target = this;
+
+ let stack = this.listeners[event.type].slice(0);
+ stack.forEach(listener => {
+ if (typeof listener.handleEvent == "function") {
+ listener.handleEvent(event);
+ } else {
+ listener(event);
+ }
+
+ if (listener.once) {
+ this.removeEventListener(event.type, listener);
+ }
+ });
+ }
+
+ receiveMessage({ name, data }) {
+ if (name != "Marionette:DOM:OnEvent") {
+ return;
+ }
+
+ let ev = {
+ type: data.type,
+ };
+ this.dispatchEvent(ev);
+ }
+}
+this.WebElementEventTarget = WebElementEventTarget;
+
+/**
+ * Provides the frame script backend for the
+ * :js:class:`WebElementEventTarget`.
+ *
+ * This service receives requests for new DOM events to listen for and
+ * to cease listening for, and despatches IPC messages to the browser
+ * when they fire.
+ */
+class ContentEventObserverService {
+ /**
+ * @param {WindowProxy} windowGlobal
+ * Window.
+ * @param {nsIMessageSender.sendAsyncMessage} sendAsyncMessage
+ * Function for sending an async message to the parent browser.
+ */
+ constructor(windowGlobal, sendAsyncMessage) {
+ this.window = windowGlobal;
+ this.sendAsyncMessage = sendAsyncMessage;
+ this.events = new Set();
+ }
+
+ /**
+ * Observe a new DOM event.
+ *
+ * When the DOM event of ``type`` fires, a message is passed to
+ * the parent browser's event observer.
+ *
+ * If event type is already being observed, only a single message
+ * is sent. E.g. multiple registration for events will only ever emit
+ * a maximum of one message.
+ *
+ * @param {string} type
+ * DOM event to listen for.
+ */
+ add(type) {
+ if (this.events.has(type)) {
+ return;
+ }
+ this.window.addEventListener(type, this);
+ this.events.add(type);
+ }
+
+ /**
+ * Ceases observing a DOM event.
+ *
+ * @param {string} type
+ * DOM event to stop listening for.
+ */
+ remove(type) {
+ if (!this.events.has(type)) {
+ return;
+ }
+ this.window.removeEventListener(type, this);
+ this.events.delete(type);
+ }
+
+ /** Ceases observing all previously registered DOM events. */
+ clear() {
+ for (let ev of this) {
+ this.remove(ev);
+ }
+ }
+
+ *[Symbol.iterator]() {
+ for (let ev of this.events) {
+ yield ev;
+ }
+ }
+
+ handleEvent({ type, target }) {
+ logger.trace(`Received DOM event ${type}`);
+ this.sendAsyncMessage("Marionette:DOM:OnEvent", { type });
+ }
+}
+this.ContentEventObserverService = ContentEventObserverService;
diff --git a/testing/marionette/driver.js b/testing/marionette/driver.js
new file mode 100644
index 0000000000..5589d30b5f
--- /dev/null
+++ b/testing/marionette/driver.js
@@ -0,0 +1,4016 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+/* global XPCNativeWrapper */
+
+const EXPORTED_SYMBOLS = ["GeckoDriver"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ OS: "resource://gre/modules/osfile.jsm",
+
+ accessibility: "chrome://marionette/content/accessibility.js",
+ Addon: "chrome://marionette/content/addon.js",
+ allowAllCerts: "chrome://marionette/content/cert.js",
+ assert: "chrome://marionette/content/assert.js",
+ atom: "chrome://marionette/content/atom.js",
+ browser: "chrome://marionette/content/browser.js",
+ Capabilities: "chrome://marionette/content/capabilities.js",
+ capture: "chrome://marionette/content/capture.js",
+ ChromeWebElement: "chrome://marionette/content/element.js",
+ clearElementIdCache:
+ "chrome://marionette/content/actors/MarionetteCommandsParent.jsm",
+ clearActionInputState:
+ "chrome://marionette/content/actors/MarionetteCommandsChild.jsm",
+ Context: "chrome://marionette/content/browser.js",
+ cookie: "chrome://marionette/content/cookie.js",
+ DebounceCallback: "chrome://marionette/content/sync.js",
+ element: "chrome://marionette/content/element.js",
+ error: "chrome://marionette/content/error.js",
+ evaluate: "chrome://marionette/content/evaluate.js",
+ getMarionetteCommandsActorProxy:
+ "chrome://marionette/content/actors/MarionetteCommandsParent.jsm",
+ IdlePromise: "chrome://marionette/content/sync.js",
+ interaction: "chrome://marionette/content/interaction.js",
+ l10n: "chrome://marionette/content/l10n.js",
+ legacyaction: "chrome://marionette/content/legacyaction.js",
+ Log: "chrome://marionette/content/log.js",
+ MarionettePrefs: "chrome://marionette/content/prefs.js",
+ modal: "chrome://marionette/content/modal.js",
+ navigate: "chrome://marionette/content/navigate.js",
+ PollPromise: "chrome://marionette/content/sync.js",
+ pprint: "chrome://marionette/content/format.js",
+ print: "chrome://marionette/content/print.js",
+ proxy: "chrome://marionette/content/proxy.js",
+ reftest: "chrome://marionette/content/reftest.js",
+ registerCommandsActor:
+ "chrome://marionette/content/actors/MarionetteCommandsParent.jsm",
+ registerEventsActor:
+ "chrome://marionette/content/actors/MarionetteEventsParent.jsm",
+ Sandboxes: "chrome://marionette/content/evaluate.js",
+ TimedPromise: "chrome://marionette/content/sync.js",
+ Timeouts: "chrome://marionette/content/capabilities.js",
+ UnhandledPromptBehavior: "chrome://marionette/content/capabilities.js",
+ unregisterCommandsActor:
+ "chrome://marionette/content/actors/MarionetteCommandsParent.jsm",
+ unregisterEventsActor:
+ "chrome://marionette/content/actors/MarionetteEventsParent.jsm",
+ waitForEvent: "chrome://marionette/content/sync.js",
+ waitForLoadEvent: "chrome://marionette/content/sync.js",
+ waitForObserverTopic: "chrome://marionette/content/sync.js",
+ WebElement: "chrome://marionette/content/element.js",
+ WebElementEventTarget: "chrome://marionette/content/dom.js",
+ WindowState: "chrome://marionette/content/browser.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
+
+const APP_ID_FIREFOX = "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}";
+const APP_ID_THUNDERBIRD = "{3550f703-e582-4d05-9a08-453d09bdfdc6}";
+
+const FRAME_SCRIPT = "chrome://marionette/content/listener.js";
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+const SUPPORTED_STRATEGIES = new Set([
+ element.Strategy.ClassName,
+ element.Strategy.Selector,
+ element.Strategy.ID,
+ element.Strategy.Name,
+ element.Strategy.LinkText,
+ element.Strategy.PartialLinkText,
+ element.Strategy.TagName,
+ element.Strategy.XPath,
+]);
+
+// Timeout used to abort fullscreen, maximize, and minimize
+// commands if no window manager is present.
+const TIMEOUT_NO_WINDOW_MANAGER = 5000;
+
+const globalMessageManager = Services.mm;
+
+/**
+ * The Marionette WebDriver services provides a standard conforming
+ * implementation of the W3C WebDriver specification.
+ *
+ * @see {@link https://w3c.github.io/webdriver/webdriver-spec.html}
+ * @namespace driver
+ */
+
+/**
+ * Implements (parts of) the W3C WebDriver protocol. GeckoDriver lives
+ * in chrome space and mediates calls to the message listener of the current
+ * browsing context's content frame message listener via ListenerProxy.
+ *
+ * Throughout this prototype, functions with the argument <var>cmd</var>'s
+ * documentation refers to the contents of the <code>cmd.parameter</code>
+ * object.
+ *
+ * @class GeckoDriver
+ *
+ * @param {MarionetteServer} server
+ * The instance of Marionette server.
+ */
+this.GeckoDriver = function(server) {
+ this.appId = Services.appinfo.ID;
+ this.appName = Services.appinfo.name.toLowerCase();
+ this._server = server;
+
+ this.sessionID = null;
+ this.browsers = {};
+
+ // Maps permanentKey to browsing context id: WeakMap.<Object, number>
+ this._browserIds = new WeakMap();
+
+ // points to current browser
+ this.curBrowser = null;
+ // top-most chrome window
+ this.mainFrame = null;
+
+ // current browsing contexts for chrome and content
+ this.chromeBrowsingContext = null;
+ this.contentBrowsingContext = null;
+
+ // Use content context by default
+ this.context = Context.Content;
+
+ this.sandboxes = new Sandboxes(() => this.getCurrentWindow());
+ this.legacyactions = new legacyaction.Chain();
+
+ this.capabilities = new Capabilities();
+
+ this.mm = globalMessageManager;
+ if (!MarionettePrefs.useActors) {
+ this.listener = proxy.toListener(
+ this.sendAsync.bind(this),
+ () => this.curBrowser
+ );
+ }
+
+ // used for modal dialogs or tab modal alerts
+ this.dialog = null;
+ this.dialogObserver = null;
+};
+
+Object.defineProperty(GeckoDriver.prototype, "a11yChecks", {
+ get() {
+ return this.capabilities.get("moz:accessibilityChecks");
+ },
+});
+
+/**
+ * The current context decides if commands are executed in chrome- or
+ * content space.
+ */
+Object.defineProperty(GeckoDriver.prototype, "context", {
+ get() {
+ return this._context;
+ },
+
+ set(context) {
+ this._context = Context.fromString(context);
+ },
+});
+
+/**
+ * Returns the current URL of the ChromeWindow or content browser,
+ * depending on context.
+ *
+ * @return {URL}
+ * Read-only property containing the currently loaded URL.
+ */
+Object.defineProperty(GeckoDriver.prototype, "currentURL", {
+ get() {
+ const browsingContext = this.getBrowsingContext({ top: true });
+ return new URL(browsingContext.currentWindowGlobal.documentURI.spec);
+ },
+});
+
+/**
+ * Returns the title of the ChromeWindow or content browser,
+ * depending on context.
+ *
+ * @return {string}
+ * Read-only property containing the title of the loaded URL.
+ */
+Object.defineProperty(GeckoDriver.prototype, "title", {
+ get() {
+ const browsingContext = this.getBrowsingContext({ top: true });
+ return browsingContext.currentWindowGlobal.documentTitle;
+ },
+});
+
+Object.defineProperty(GeckoDriver.prototype, "proxy", {
+ get() {
+ return this.capabilities.get("proxy");
+ },
+});
+
+Object.defineProperty(GeckoDriver.prototype, "secureTLS", {
+ get() {
+ return !this.capabilities.get("acceptInsecureCerts");
+ },
+});
+
+Object.defineProperty(GeckoDriver.prototype, "timeouts", {
+ get() {
+ return this.capabilities.get("timeouts");
+ },
+
+ set(newTimeouts) {
+ this.capabilities.set("timeouts", newTimeouts);
+ },
+});
+
+Object.defineProperty(GeckoDriver.prototype, "windows", {
+ get() {
+ return Services.wm.getEnumerator(null);
+ },
+});
+
+Object.defineProperty(GeckoDriver.prototype, "windowType", {
+ get() {
+ return this.curBrowser.window.document.documentElement.getAttribute(
+ "windowtype"
+ );
+ },
+});
+
+Object.defineProperty(GeckoDriver.prototype, "windowHandles", {
+ get() {
+ let hs = [];
+
+ for (let win of this.windows) {
+ let tabBrowser = browser.getTabBrowser(win);
+
+ // Only return handles for browser windows
+ if (tabBrowser && tabBrowser.tabs) {
+ for (let tab of tabBrowser.tabs) {
+ let winId = this.getIdForBrowser(browser.getBrowserForTab(tab));
+ if (winId !== null) {
+ hs.push(winId);
+ }
+ }
+ }
+ }
+
+ return hs;
+ },
+});
+
+Object.defineProperty(GeckoDriver.prototype, "chromeWindowHandles", {
+ get() {
+ let hs = [];
+
+ for (let win of this.windows) {
+ hs.push(getWindowId(win));
+ }
+
+ return hs;
+ },
+});
+
+GeckoDriver.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+]);
+
+GeckoDriver.prototype.init = function() {
+ if (MarionettePrefs.useActors) {
+ // When using JSWindowActors, we are not relying on framescript events
+ return;
+ }
+
+ this.mm.addMessageListener("Marionette:ListenersAttached", this);
+ this.mm.addMessageListener("Marionette:Register", this);
+ this.mm.addMessageListener("Marionette:switchedToFrame", this);
+ this.mm.addMessageListener("Marionette:NavigationEvent", this);
+ this.mm.addMessageListener("Marionette:Unloaded", this, true);
+};
+
+GeckoDriver.prototype.uninit = function() {
+ if (MarionettePrefs.useActors) {
+ return;
+ }
+
+ this.mm.removeMessageListener("Marionette:ListenersAttached", this);
+ this.mm.removeMessageListener("Marionette:Register", this);
+ this.mm.removeMessageListener("Marionette:switchedToFrame", this);
+ this.mm.removeMessageListener("Marionette:NavigationEvent", this);
+ this.mm.removeMessageListener("Marionette:Unloaded", this);
+};
+
+/**
+ * Callback used to observe the creation of new modal or tab modal dialogs
+ * during the session's lifetime.
+ */
+GeckoDriver.prototype.handleModalDialog = function(action, dialog, win) {
+ // Only care about modals of the currently selected window.
+ if (win !== this.curBrowser.window) {
+ return;
+ }
+
+ if (action === modal.ACTION_OPENED) {
+ this.dialog = new modal.Dialog(() => this.curBrowser, dialog);
+ } else if (action === modal.ACTION_CLOSED) {
+ this.dialog = null;
+ }
+};
+
+/**
+ * Get the current visible URL.
+ *
+ * Can be removed once WindowGlobal supports visibleURL (bug 1664881).
+ */
+GeckoDriver.prototype._getCurrentURL = async function() {
+ let url;
+
+ if (MarionettePrefs.useActors) {
+ url = await this.getActor({ top: true }).getCurrentUrl();
+ return new URL(url);
+ }
+
+ switch (this.context) {
+ case Context.Chrome:
+ const browsingContext = this.getBrowsingContext({ top: true });
+ url = browsingContext.window.location.href;
+ break;
+ case Context.Content:
+ url = await this.listener.getCurrentUrl();
+ break;
+ }
+
+ return new URL(url);
+};
+
+/**
+ * Helper method to send async messages to the content listener.
+ * Correct usage is to pass in the name of a function in listener.js,
+ * a serialisable object, and optionally the current command's ID
+ * when not using the modern dispatching technique.
+ *
+ * @param {string} name
+ * Suffix of the target message handler <tt>Marionette:SUFFIX</tt>.
+ * @param {Object=} data
+ * Data that must be serialisable using {@link evaluate.toJSON}.
+ * @param {number=} commandID
+ * Optional command ID to ensure synchronisity.
+ *
+ * @throws {JavaScriptError}
+ * If <var>data</var> could not be marshaled.
+ * @throws {NoSuchWindowError}
+ * If there is no current target frame.
+ */
+GeckoDriver.prototype.sendAsync = function(name, data, commandID) {
+ let payload = evaluate.toJSON(data, this.seenEls);
+
+ if (payload === null) {
+ payload = {};
+ }
+
+ // TODO(ato): When proxy.AsyncMessageChannel
+ // is used for all chrome <-> content communication
+ // this can be removed.
+ if (commandID) {
+ payload.commandID = commandID;
+ }
+
+ if (this.curBrowser.curFrameId) {
+ let target = `Marionette:${name}`;
+ this.curBrowser.messageManager.sendAsyncMessage(target, payload);
+ } else {
+ throw new error.NoSuchWindowError(
+ "No such content frame; perhaps the listener was not registered?"
+ );
+ }
+};
+
+/**
+ * Get the current "MarionetteCommands" parent actor.
+ *
+ * @param {Object} options
+ * @param {boolean=} options.top
+ * If set to true use the window's top-level browsing context for the actor,
+ * otherwise the one from the currently selected frame. Defaults to false.
+ *
+ * @returns {MarionetteCommandsParent}
+ * The parent actor.
+ */
+GeckoDriver.prototype.getActor = function(options = {}) {
+ return getMarionetteCommandsActorProxy(() =>
+ this.getBrowsingContext(options)
+ );
+};
+
+/**
+ * Get the selected BrowsingContext for the current context.
+ *
+ * @param {Object} options
+ * @param {Context=} options.context
+ * Context (content or chrome) for which to retrieve the browsing context.
+ * Defaults to the current one.
+ * @param {boolean=} options.parent
+ * If set to true return the window's parent browsing context,
+ * otherwise the one from the currently selected frame. Defaults to false.
+ * @param {boolean=} options.top
+ * If set to true return the window's top-level browsing context,
+ * otherwise the one from the currently selected frame. Defaults to false.
+ *
+ * @return {BrowsingContext}
+ * The browsing context.
+ */
+GeckoDriver.prototype.getBrowsingContext = function(options = {}) {
+ const { context = this.context, parent = false, top = false } = options;
+
+ let browsingContext = null;
+ if (context === Context.Chrome) {
+ browsingContext = this.chromeBrowsingContext;
+ } else {
+ browsingContext = this.contentBrowsingContext;
+ }
+
+ if (browsingContext && parent) {
+ browsingContext = browsingContext.parent;
+ }
+
+ if (browsingContext && top) {
+ browsingContext = browsingContext.top;
+ }
+
+ return browsingContext;
+};
+
+/**
+ * Get the currently selected window.
+ *
+ * It will return the outer {@link ChromeWindow} previously selected by
+ * window handle through {@link #switchToWindow}, or the first window that
+ * was registered.
+ *
+ * @param {Object} options
+ * @param {Context=} options.context
+ * Optional name of the context to use for finding the window.
+ * It will be required if a command always needs a specific context,
+ * whether which context is currently set. Defaults to the current
+ * context.
+ *
+ * @return {ChromeWindow}
+ * The current top-level browsing context.
+ */
+GeckoDriver.prototype.getCurrentWindow = function(options = {}) {
+ const { context = this.context } = options;
+
+ let win = null;
+ switch (context) {
+ case Context.Chrome:
+ if (this.curBrowser) {
+ win = this.curBrowser.window;
+ }
+ break;
+
+ case Context.Content:
+ if (this.curBrowser && this.curBrowser.contentBrowser) {
+ win = this.curBrowser.window;
+ }
+ break;
+ }
+
+ return win;
+};
+
+GeckoDriver.prototype.isReftestBrowser = function(element) {
+ return (
+ this._reftest &&
+ element &&
+ element.tagName === "xul:browser" &&
+ element.parentElement &&
+ element.parentElement.id === "reftest"
+ );
+};
+
+GeckoDriver.prototype.addFrameCloseListener = function(action) {
+ let win = this.getCurrentWindow();
+ this.mozBrowserClose = e => {
+ if (e.target.id == this.oopFrameId) {
+ win.removeEventListener("mozbrowserclose", this.mozBrowserClose, true);
+ throw new error.NoSuchWindowError(
+ "The window closed during action: " + action
+ );
+ }
+ };
+ win.addEventListener("mozbrowserclose", this.mozBrowserClose, true);
+};
+
+/**
+ * Create a new browsing context for window and add to known browsers.
+ *
+ * @param {ChromeWindow} win
+ * Window for which we will create a browsing context.
+ *
+ * @return {string}
+ * Returns the unique server-assigned ID of the window.
+ */
+GeckoDriver.prototype.addBrowser = function(win) {
+ let context = new browser.Context(win, this);
+ let winId = getWindowId(win);
+
+ this.browsers[winId] = context;
+ this.curBrowser = this.browsers[winId];
+};
+
+/**
+ * Registers a new browser, win, with Marionette.
+ *
+ * If we have not seen the browser content window before, the listener
+ * frame script will be loaded into it. If isNewSession is true, we will
+ * switch focus to the start frame when it registers.
+ *
+ * @param {ChromeWindow} win
+ * Window whose browser we need to access.
+ * @param {boolean=} [false] isNewSession
+ * True if this is the first time we're talking to this browser.
+ */
+GeckoDriver.prototype.startBrowser = function(window, isNewSession = false) {
+ this.mainFrame = window;
+
+ this.addBrowser(window);
+ this.whenBrowserStarted(window, isNewSession);
+};
+
+/**
+ * Callback invoked after a new session has been started in a browser.
+ * Loads the Marionette frame script into the browser if needed.
+ *
+ * @param {ChromeWindow} window
+ * Window whose browser we need to access.
+ * @param {boolean} isNewSession
+ * True if this is the first time we're talking to this browser.
+ */
+GeckoDriver.prototype.whenBrowserStarted = function(window, isNewSession) {
+ // Do not load the framescript when actors are used.
+ if (MarionettePrefs.useActors) {
+ return;
+ }
+
+ let mm = window.messageManager;
+ if (mm) {
+ if (!isNewSession) {
+ // Loading the frame script corresponds to a situation we need to
+ // return to the server. If the messageManager is a message broadcaster
+ // with no children, we don't have a hope of coming back from this
+ // call, so send the ack here. Otherwise, make a note of how many
+ // child scripts will be loaded so we known when it's safe to return.
+ // Child managers may not have child scripts yet (e.g. socialapi),
+ // only count child managers that have children, but only count the top
+ // level children as they are the ones that we expect a response from.
+ if (mm.childCount !== 0) {
+ this.curBrowser.frameRegsPending = 0;
+ for (let i = 0; i < mm.childCount; i++) {
+ if (mm.getChildAt(i).childCount !== 0) {
+ this.curBrowser.frameRegsPending += 1;
+ }
+ }
+ }
+ }
+
+ if (!MarionettePrefs.contentListener || !isNewSession) {
+ // load listener into the remote frame
+ // and any applicable new frames
+ // opened after this call
+ mm.loadFrameScript(FRAME_SCRIPT, true);
+ MarionettePrefs.contentListener = true;
+ }
+ } else {
+ logger.error("Unable to load content frame script");
+ }
+};
+
+/**
+ * Recursively get all labeled text.
+ *
+ * @param {Element} el
+ * The parent element.
+ * @param {Array.<string>} lines
+ * Array that holds the text lines.
+ */
+GeckoDriver.prototype.getVisibleText = function(el, lines) {
+ try {
+ if (atom.isElementDisplayed(el, this.getCurrentWindow())) {
+ if (el.value) {
+ lines.push(el.value);
+ }
+ for (let child in el.childNodes) {
+ this.getVisibleText(el.childNodes[child], lines);
+ }
+ }
+ } catch (e) {
+ if (el.nodeName == "#text") {
+ lines.push(el.textContent);
+ }
+ }
+};
+
+/**
+ * Handles registration of new content listener browsers. Depending on
+ * their type they are either accepted or ignored.
+ *
+ * @param {xul:browser} browserElement
+ */
+GeckoDriver.prototype.registerBrowser = function(browserElement) {
+ // We want to ignore frames that are XUL browsers that aren't in the "main"
+ // tabbrowser, but accept things on Fennec (which doesn't have a
+ // xul:tabbrowser), and accept HTML iframes (because tests depend on it),
+ // as well as XUL frames. Ideally this should be cleaned up and we should
+ // keep track of browsers a different way.
+ if (
+ this.appId != APP_ID_FIREFOX ||
+ browserElement.namespaceURI != XUL_NS ||
+ browserElement.nodeName != "browser" ||
+ browserElement.getTabBrowser()
+ ) {
+ this.curBrowser.register(browserElement);
+ }
+};
+
+GeckoDriver.prototype.registerPromise = function() {
+ const li = "Marionette:Register";
+
+ return new Promise(resolve => {
+ let cb = ({ json, target }) => {
+ this.registerBrowser(target);
+
+ if (this.curBrowser.frameRegsPending > 0) {
+ this.curBrowser.frameRegsPending--;
+ }
+
+ if (this.curBrowser.frameRegsPending === 0) {
+ this.mm.removeMessageListener(li, cb);
+ resolve();
+ }
+
+ return { frameId: json.frameId };
+ };
+ this.mm.addMessageListener(li, cb);
+ });
+};
+
+GeckoDriver.prototype.listeningPromise = function() {
+ const li = "Marionette:ListenersAttached";
+
+ return new Promise(resolve => {
+ let cb = msg => {
+ if (msg.json.frameId === this.curBrowser.curFrameId) {
+ this.mm.removeMessageListener(li, cb);
+ resolve(msg.json.frameId);
+ }
+ };
+ this.mm.addMessageListener(li, cb);
+ });
+};
+
+/**
+ * Create a new WebDriver session.
+ *
+ * It is expected that the caller performs the necessary checks on
+ * the requested capabilities to be WebDriver conforming. The WebDriver
+ * service offered by Marionette does not match or negotiate capabilities
+ * beyond type- and bounds checks.
+ *
+ * <h3>Capabilities</h3>
+ *
+ * <dl>
+ * <dt><code>pageLoadStrategy</code> (string)
+ * <dd>The page load strategy to use for the current session. Must be
+ * one of "<tt>none</tt>", "<tt>eager</tt>", and "<tt>normal</tt>".
+ *
+ * <dt><code>acceptInsecureCerts</code> (boolean)
+ * <dd>Indicates whether untrusted and self-signed TLS certificates
+ * are implicitly trusted on navigation for the duration of the session.
+ *
+ * <dt><code>timeouts</code> (Timeouts object)
+ * <dd>Describes the timeouts imposed on certian session operations.
+ *
+ * <dt><code>proxy</code> (Proxy object)
+ * <dd>Defines the proxy configuration.
+ *
+ * <dt><code>moz:accessibilityChecks</code> (boolean)
+ * <dd>Run a11y checks when clicking elements.
+ *
+ * <dt><code>moz:useNonSpecCompliantPointerOrigin</code> (boolean)
+ * <dd>Use the not WebDriver conforming calculation of the pointer origin
+ * when the origin is an element, and the element center point is used.
+ *
+ * <dt><code>moz:webdriverClick</code> (boolean)
+ * <dd>Use a WebDriver conforming <i>WebDriver::ElementClick</i>.
+ * </dl>
+ *
+ * <h4>Timeouts object</h4>
+ *
+ * <dl>
+ * <dt><code>script</code> (number)
+ * <dd>Determines when to interrupt a script that is being evaluates.
+ *
+ * <dt><code>pageLoad</code> (number)
+ * <dd>Provides the timeout limit used to interrupt navigation of the
+ * browsing context.
+ *
+ * <dt><code>implicit</code> (number)
+ * <dd>Gives the timeout of when to abort when locating an element.
+ * </dl>
+ *
+ * <h4>Proxy object</h4>
+ *
+ * <dl>
+ * <dt><code>proxyType</code> (string)
+ * <dd>Indicates the type of proxy configuration. Must be one
+ * of "<tt>pac</tt>", "<tt>direct</tt>", "<tt>autodetect</tt>",
+ * "<tt>system</tt>", or "<tt>manual</tt>".
+ *
+ * <dt><code>proxyAutoconfigUrl</code> (string)
+ * <dd>Defines the URL for a proxy auto-config file if
+ * <code>proxyType</code> is equal to "<tt>pac</tt>".
+ *
+ * <dt><code>ftpProxy</code> (string)
+ * <dd>Defines the proxy host for FTP traffic when the
+ * <code>proxyType</code> is "<tt>manual</tt>".
+ *
+ * <dt><code>httpProxy</code> (string)
+ * <dd>Defines the proxy host for HTTP traffic when the
+ * <code>proxyType</code> is "<tt>manual</tt>".
+ *
+ * <dt><code>noProxy</code> (string)
+ * <dd>Lists the adress for which the proxy should be bypassed when
+ * the <code>proxyType</code> is "<tt>manual</tt>". Must be a JSON
+ * List containing any number of any of domains, IPv4 addresses, or IPv6
+ * addresses.
+ *
+ * <dt><code>sslProxy</code> (string)
+ * <dd>Defines the proxy host for encrypted TLS traffic when the
+ * <code>proxyType</code> is "<tt>manual</tt>".
+ *
+ * <dt><code>socksProxy</code> (string)
+ * <dd>Defines the proxy host for a SOCKS proxy traffic when the
+ * <code>proxyType</code> is "<tt>manual</tt>".
+ *
+ * <dt><code>socksVersion</code> (string)
+ * <dd>Defines the SOCKS proxy version when the <code>proxyType</code> is
+ * "<tt>manual</tt>". It must be any integer between 0 and 255
+ * inclusive.
+ * </dl>
+ *
+ * <h3>Example</h3>
+ *
+ * Input:
+ *
+ * <pre><code>
+ * {"capabilities": {"acceptInsecureCerts": true}}
+ * </code></pre>
+ *
+ * @param {string=} sessionId
+ * Normally a unique ID is given to a new session, however this can
+ * be overriden by providing this field.
+ * @param {Object.<string, *>=} capabilities
+ * JSON Object containing any of the recognised capabilities listed
+ * above.
+ *
+ * @return {Object}
+ * Session ID and capabilities offered by the WebDriver service.
+ *
+ * @throws {SessionNotCreatedError}
+ * If, for whatever reason, a session could not be created.
+ */
+GeckoDriver.prototype.newSession = async function(cmd) {
+ if (this.sessionID) {
+ throw new error.SessionNotCreatedError("Maximum number of active sessions");
+ }
+ this.sessionID = WebElement.generateUUID();
+
+ try {
+ this.capabilities = Capabilities.fromJSON(cmd.parameters);
+
+ if (!this.secureTLS) {
+ logger.warn("TLS certificate errors will be ignored for this session");
+ allowAllCerts.enable();
+ }
+
+ if (this.proxy.init()) {
+ logger.info("Proxy settings initialised: " + JSON.stringify(this.proxy));
+ }
+ } catch (e) {
+ throw new error.SessionNotCreatedError(e);
+ }
+
+ // If we are testing accessibility with marionette, start a11y service in
+ // chrome first. This will ensure that we do not have any content-only
+ // services hanging around.
+ if (this.a11yChecks && accessibility.service) {
+ logger.info("Preemptively starting accessibility service in Chrome");
+ }
+
+ let waitForWindow = function() {
+ let windowTypes;
+ switch (this.appId) {
+ case APP_ID_THUNDERBIRD:
+ windowTypes = ["mail:3pane"];
+ break;
+ default:
+ // We assume that an app either has GeckoView windows, or
+ // Firefox/Fennec windows, but not both.
+ windowTypes = ["navigator:browser", "navigator:geckoview"];
+ break;
+ }
+ let win;
+ for (let windowType of windowTypes) {
+ win = Services.wm.getMostRecentWindow(windowType);
+ if (win) {
+ break;
+ }
+ }
+ if (!win) {
+ // if the window isn't even created, just poll wait for it
+ let checkTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ checkTimer.initWithCallback(
+ waitForWindow.bind(this),
+ 100,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ } else if (win.document.readyState != "complete") {
+ // otherwise, wait for it to be fully loaded before proceeding
+ let listener = ev => {
+ // ensure that we proceed, on the top level document load event
+ // (not an iframe one...)
+ if (ev.target != win.document) {
+ return;
+ }
+ win.removeEventListener("load", listener);
+ waitForWindow.call(this);
+ };
+ win.addEventListener("load", listener, true);
+ } else {
+ if (MarionettePrefs.clickToStart) {
+ Services.prompt.alert(
+ win,
+ "",
+ "Click to start execution of marionette tests"
+ );
+ }
+ this.startBrowser(win, true);
+ }
+ };
+
+ let registerBrowsers;
+ let browserListening;
+
+ if (MarionettePrefs.useActors) {
+ registerCommandsActor();
+ registerEventsActor();
+ } else {
+ registerBrowsers = this.registerPromise();
+ browserListening = this.listeningPromise();
+ }
+
+ if (!MarionettePrefs.contentListener) {
+ waitForWindow.call(this);
+ } else if (this.appId != APP_ID_FIREFOX && this.curBrowser === null) {
+ // if there is a content listener, then we just wake it up
+ let win = this.getCurrentWindow();
+ this.addBrowser(win);
+ this.whenBrowserStarted(win, false);
+ } else {
+ throw new error.WebDriverError("Session already running");
+ }
+
+ if (MarionettePrefs.useActors) {
+ for (let win of this.windows) {
+ const tabBrowser = browser.getTabBrowser(win);
+
+ if (tabBrowser) {
+ for (const tab of tabBrowser.tabs) {
+ const contentBrowser = browser.getBrowserForTab(tab);
+ this.registerBrowser(contentBrowser);
+ }
+ }
+ }
+ } else {
+ await registerBrowsers;
+ await browserListening;
+ }
+
+ if (this.mainFrame) {
+ this.chromeBrowsingContext = this.mainFrame.browsingContext;
+ this.mainFrame.focus();
+ }
+
+ if (this.curBrowser.tab) {
+ this.contentBrowsingContext = this.curBrowser.contentBrowser.browsingContext;
+ this.curBrowser.contentBrowser.focus();
+ }
+
+ // Setup observer for modal dialogs
+ this.dialogObserver = new modal.DialogObserver(this);
+ this.dialogObserver.add(this.handleModalDialog.bind(this));
+
+ Services.obs.addObserver(this, "browsing-context-attached");
+
+ // Check if there is already an open dialog for the selected browser window.
+ this.dialog = modal.findModalDialogs(this.curBrowser);
+
+ return {
+ sessionId: this.sessionID,
+ capabilities: this.capabilities,
+ };
+};
+
+GeckoDriver.prototype.observe = function(subject, topic, data) {
+ switch (topic) {
+ case "browsing-context-attached":
+ // For cross-group navigations the complete browsing context tree of a tab
+ // gets replaced. An indication for that is when the newly attached
+ // browsing context has the same browserId as the currently selected
+ // content browsing context, and doesn't have a parent.
+ //
+ // Also the current content browsing context gets only updated when it's
+ // the top-level one to not automatically switch away from the currently
+ // selected frame.
+ if (
+ subject.browserId == this.contentBrowsingContext?.browserId &&
+ !subject.parent &&
+ !this.contentBrowsingContext?.parent
+ ) {
+ logger.trace(
+ "Remoteness change detected. Set new top-level browsing context " +
+ `to ${subject.id}`
+ );
+ this.contentBrowsingContext = subject;
+ if (MarionettePrefs.useActors) {
+ // When using the framescript, the new browsing context created after
+ // a remoteness change will self-register. With JSWindowActors, we
+ // manually update the stored browsing context id.
+ // Switching to browserId instead of browsingContext.id would make
+ // this call unnecessary. See Bug 1681973.
+ this.updateIdForBrowser(this.curBrowser.contentBrowser, subject.id);
+ }
+ }
+ break;
+ }
+};
+
+/**
+ * Send the current session's capabilities to the client.
+ *
+ * Capabilities informs the client of which WebDriver features are
+ * supported by Firefox and Marionette. They are immutable for the
+ * length of the session.
+ *
+ * The return value is an immutable map of string keys
+ * ("capabilities") to values, which may be of types boolean,
+ * numerical or string.
+ */
+GeckoDriver.prototype.getSessionCapabilities = function() {
+ return { capabilities: this.capabilities };
+};
+
+/**
+ * Sets the context of the subsequent commands.
+ *
+ * All subsequent requests to commands that in some way involve
+ * interaction with a browsing context will target the chosen browsing
+ * context.
+ *
+ * @param {string} value
+ * Name of the context to be switched to. Must be one of "chrome" or
+ * "content".
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>value</var> is not a string.
+ * @throws {WebDriverError}
+ * If <var>value</var> is not a valid browsing context.
+ */
+GeckoDriver.prototype.setContext = function(cmd) {
+ let value = assert.string(cmd.parameters.value);
+
+ this.context = value;
+};
+
+/**
+ * Gets the context type that is Marionette's current target for
+ * browsing context scoped commands.
+ *
+ * You may choose a context through the {@link #setContext} command.
+ *
+ * The default browsing context is {@link Context.Content}.
+ *
+ * @return {Context}
+ * Current context.
+ */
+GeckoDriver.prototype.getContext = function() {
+ return this.context;
+};
+
+/**
+ * Executes a JavaScript function in the context of the current browsing
+ * context, if in content space, or in chrome space otherwise, and returns
+ * the return value of the function.
+ *
+ * It is important to note that if the <var>sandboxName</var> parameter
+ * is left undefined, the script will be evaluated in a mutable sandbox,
+ * causing any change it makes on the global state of the document to have
+ * lasting side-effects.
+ *
+ * @param {string} script
+ * Script to evaluate as a function body.
+ * @param {Array.<(string|boolean|number|object|WebElement)>} args
+ * Arguments exposed to the script in <code>arguments</code>.
+ * The array items must be serialisable to the WebDriver protocol.
+ * @param {string=} sandbox
+ * Name of the sandbox to evaluate the script in. The sandbox is
+ * cached for later re-use on the same Window object if
+ * <var>newSandbox</var> is false. If he parameter is undefined,
+ * the script is evaluated in a mutable sandbox. If the parameter
+ * is "system", it will be evaluted in a sandbox with elevated system
+ * privileges, equivalent to chrome space.
+ * @param {boolean=} newSandbox
+ * Forces the script to be evaluated in a fresh sandbox. Note that if
+ * it is undefined, the script will normally be evaluted in a fresh
+ * sandbox.
+ * @param {string=} filename
+ * Filename of the client's program where this script is evaluated.
+ * @param {number=} line
+ * Line in the client's program where this script is evaluated.
+ *
+ * @return {(string|boolean|number|object|WebElement)}
+ * Return value from the script, or null which signifies either the
+ * JavaScript notion of null or undefined.
+ *
+ * @throws {JavaScriptError}
+ * If an {@link Error} was thrown whilst evaluating the script.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {ScriptTimeoutError}
+ * If the script was interrupted due to reaching the session's
+ * script timeout.
+ */
+GeckoDriver.prototype.executeScript = async function(cmd) {
+ let { script, args } = cmd.parameters;
+ let opts = {
+ script: cmd.parameters.script,
+ args: cmd.parameters.args,
+ sandboxName: cmd.parameters.sandbox,
+ newSandbox: cmd.parameters.newSandbox,
+ file: cmd.parameters.filename,
+ line: cmd.parameters.line,
+ };
+
+ return { value: await this.execute_(script, args, opts) };
+};
+
+/**
+ * Executes a JavaScript function in the context of the current browsing
+ * context, if in content space, or in chrome space otherwise, and returns
+ * the object passed to the callback.
+ *
+ * The callback is always the last argument to the <var>arguments</var>
+ * list passed to the function scope of the script. It can be retrieved
+ * as such:
+ *
+ * <pre><code>
+ * let callback = arguments[arguments.length - 1];
+ * callback("foo");
+ * // "foo" is returned
+ * </code></pre>
+ *
+ * It is important to note that if the <var>sandboxName</var> parameter
+ * is left undefined, the script will be evaluated in a mutable sandbox,
+ * causing any change it makes on the global state of the document to have
+ * lasting side-effects.
+ *
+ * @param {string} script
+ * Script to evaluate as a function body.
+ * @param {Array.<(string|boolean|number|object|WebElement)>} args
+ * Arguments exposed to the script in <code>arguments</code>.
+ * The array items must be serialisable to the WebDriver protocol.
+ * @param {string=} sandbox
+ * Name of the sandbox to evaluate the script in. The sandbox is
+ * cached for later re-use on the same Window object if
+ * <var>newSandbox</var> is false. If the parameter is undefined,
+ * the script is evaluated in a mutable sandbox. If the parameter
+ * is "system", it will be evaluted in a sandbox with elevated system
+ * privileges, equivalent to chrome space.
+ * @param {boolean=} newSandbox
+ * Forces the script to be evaluated in a fresh sandbox. Note that if
+ * it is undefined, the script will normally be evaluted in a fresh
+ * sandbox.
+ * @param {string=} filename
+ * Filename of the client's program where this script is evaluated.
+ * @param {number=} line
+ * Line in the client's program where this script is evaluated.
+ *
+ * @return {(string|boolean|number|object|WebElement)}
+ * Return value from the script, or null which signifies either the
+ * JavaScript notion of null or undefined.
+ *
+ * @throws {JavaScriptError}
+ * If an Error was thrown whilst evaluating the script.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {ScriptTimeoutError}
+ * If the script was interrupted due to reaching the session's
+ * script timeout.
+ */
+GeckoDriver.prototype.executeAsyncScript = async function(cmd) {
+ let { script, args } = cmd.parameters;
+ let opts = {
+ script: cmd.parameters.script,
+ args: cmd.parameters.args,
+ sandboxName: cmd.parameters.sandbox,
+ newSandbox: cmd.parameters.newSandbox,
+ file: cmd.parameters.filename,
+ line: cmd.parameters.line,
+ async: true,
+ };
+
+ return { value: await this.execute_(script, args, opts) };
+};
+
+GeckoDriver.prototype.execute_ = async function(
+ script,
+ args = [],
+ {
+ sandboxName = null,
+ newSandbox = false,
+ file = "",
+ line = 0,
+ async = false,
+ } = {}
+) {
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ assert.string(script, pprint`Expected "script" to be a string: ${script}`);
+ assert.array(args, pprint`Expected script args to be an array: ${args}`);
+ if (sandboxName !== null) {
+ assert.string(
+ sandboxName,
+ pprint`Expected sandbox name to be a string: ${sandboxName}`
+ );
+ }
+ assert.boolean(
+ newSandbox,
+ pprint`Expected newSandbox to be boolean: ${newSandbox}`
+ );
+ assert.string(file, pprint`Expected file to be a string: ${file}`);
+ assert.number(line, pprint`Expected line to be a number: ${line}`);
+
+ let opts = {
+ timeout: this.timeouts.script,
+ sandboxName,
+ newSandbox,
+ file,
+ line,
+ async,
+ };
+
+ if (MarionettePrefs.useActors) {
+ return this.getActor().executeScript(script, args, opts);
+ }
+
+ let res, els;
+
+ switch (this.context) {
+ case Context.Chrome:
+ let sb = this.sandboxes.get(sandboxName, newSandbox);
+ let wargs = evaluate.fromJSON(args, this.curBrowser.seenEls, sb.window);
+ res = await evaluate.sandbox(sb, script, wargs, opts);
+ els = this.curBrowser.seenEls;
+ break;
+
+ case Context.Content:
+ // evaluate in content with lasting side-effects
+ opts.useSandbox = !!sandboxName;
+ res = await this.listener.executeScript(script, args, opts);
+ break;
+
+ default:
+ throw new TypeError(`Unknown context: ${this.context}`);
+ }
+
+ return evaluate.toJSON(res, els);
+};
+
+/**
+ * Navigate to given URL.
+ *
+ * Navigates the current browsing context to the given URL and waits for
+ * the document to load or the session's page timeout duration to elapse
+ * before returning.
+ *
+ * The command will return with a failure if there is an error loading
+ * the document or the URL is blocked. This can occur if it fails to
+ * reach host, the URL is malformed, or if there is a certificate issue
+ * to name some examples.
+ *
+ * The document is considered successfully loaded when the
+ * DOMContentLoaded event on the frame element associated with the
+ * current window triggers and document.readyState is "complete".
+ *
+ * In chrome context it will change the current window's location to
+ * the supplied URL and wait until document.readyState equals "complete"
+ * or the page timeout duration has elapsed.
+ *
+ * @param {string} url
+ * URL to navigate to.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in current context.
+ */
+GeckoDriver.prototype.navigateTo = async function(cmd) {
+ assert.content(this.context);
+ const browsingContext = assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ let validURL;
+ try {
+ validURL = new URL(cmd.parameters.url);
+ } catch (e) {
+ throw new error.InvalidArgumentError(`Malformed URL: ${e.message}`);
+ }
+
+ // Switch to the top-level browsing context before navigating
+ if (MarionettePrefs.useActors) {
+ this.contentBrowsingContext = browsingContext;
+ } else {
+ await this.listener.switchToFrame();
+ }
+
+ const loadEventExpected = navigate.isLoadEventExpected(
+ await this._getCurrentURL(),
+ {
+ future: validURL,
+ }
+ );
+
+ await navigate.waitForNavigationCompleted(
+ this,
+ () => {
+ navigate.navigateTo(browsingContext, validURL);
+ },
+ { loadEventExpected }
+ );
+
+ this.curBrowser.contentBrowser.focus();
+};
+
+/**
+ * Get a string representing the current URL.
+ *
+ * On Desktop this returns a string representation of the URL of the
+ * current top level browsing context. This is equivalent to
+ * document.location.href.
+ *
+ * When in the context of the chrome, this returns the canonical URL
+ * of the current resource.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.getCurrentUrl = async function() {
+ assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ const url = await this._getCurrentURL();
+ return url.href;
+};
+
+/**
+ * Gets the current title of the window.
+ *
+ * @return {string}
+ * Document title of the top-level browsing context.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.getTitle = async function() {
+ assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ return this.title;
+};
+
+/**
+ * Gets the current type of the window.
+ *
+ * @return {string}
+ * Type of window
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.getWindowType = function() {
+ assert.open(this.getBrowsingContext({ top: true }));
+
+ return this.windowType;
+};
+
+/**
+ * Gets the page source of the content document.
+ *
+ * @return {string}
+ * String serialisation of the DOM of the current browsing context's
+ * active document.
+ *
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.getPageSource = async function() {
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ if (MarionettePrefs.useActors) {
+ return this.getActor().getPageSource();
+ }
+
+ switch (this.context) {
+ case Context.Chrome:
+ const win = this.getCurrentWindow();
+ const s = new win.XMLSerializer();
+ return s.serializeToString(win.document);
+
+ case Context.Content:
+ return this.listener.getPageSource();
+
+ default:
+ throw new TypeError(`Unknown context: ${this.context}`);
+ }
+};
+
+/**
+ * Cause the browser to traverse one step backward in the joint history
+ * of the current browsing context.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in current context.
+ */
+GeckoDriver.prototype.goBack = async function() {
+ assert.content(this.context);
+ const browsingContext = assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ // If there is no history, just return
+ if (!browsingContext.embedderElement?.canGoBack) {
+ return;
+ }
+
+ await navigate.waitForNavigationCompleted(this, () => {
+ browsingContext.goBack();
+ });
+};
+
+/**
+ * Cause the browser to traverse one step forward in the joint history
+ * of the current browsing context.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in current context.
+ */
+GeckoDriver.prototype.goForward = async function() {
+ assert.content(this.context);
+ const browsingContext = assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ // If there is no history, just return
+ if (!browsingContext.embedderElement?.canGoForward) {
+ return;
+ }
+
+ await navigate.waitForNavigationCompleted(this, () => {
+ browsingContext.goForward();
+ });
+};
+
+/**
+ * Causes the browser to reload the page in current top-level browsing
+ * context.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in current context.
+ */
+GeckoDriver.prototype.refresh = async function() {
+ assert.content(this.context);
+ const browsingContext = assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ // Switch to the top-level browsing context before navigating
+ if (MarionettePrefs.useActors) {
+ this.contentBrowsingContext = browsingContext;
+ } else {
+ await this.listener.switchToFrame();
+ }
+
+ await navigate.waitForNavigationCompleted(this, () => {
+ navigate.refresh(browsingContext);
+ });
+};
+
+/**
+ * Forces an update for the given browser's id.
+ */
+GeckoDriver.prototype.updateIdForBrowser = function(browser, newId) {
+ this._browserIds.set(browser.permanentKey, newId);
+};
+
+/**
+ * Retrieves a listener id for the given xul browser element. In case
+ * the browser is not known, an attempt is made to retrieve the id from
+ * a CPOW, and null is returned if this fails.
+ */
+GeckoDriver.prototype.getIdForBrowser = function(browser) {
+ if (browser === null) {
+ return null;
+ }
+
+ let permKey = browser.permanentKey;
+ if (this._browserIds.has(permKey)) {
+ return this._browserIds.get(permKey);
+ }
+
+ let winId = browser.browsingContext.id;
+ if (winId) {
+ this._browserIds.set(permKey, winId);
+ return winId;
+ }
+ return null;
+};
+
+/**
+ * Get the current window's handle. On desktop this typically corresponds
+ * to the currently selected tab.
+ *
+ * Return an opaque server-assigned identifier to this window that
+ * uniquely identifies it within this Marionette instance. This can
+ * be used to switch to this window at a later point.
+ *
+ * @return {string}
+ * Unique window handle.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.getWindowHandle = function() {
+ const browsingContext = assert.open(
+ this.getBrowsingContext({
+ context: Context.Content,
+ top: true,
+ })
+ );
+
+ return browsingContext.id.toString();
+};
+
+/**
+ * Get a list of top-level browsing contexts. On desktop this typically
+ * corresponds to the set of open tabs for browser windows, or the window
+ * itself for non-browser chrome windows.
+ *
+ * Each window handle is assigned by the server and is guaranteed unique,
+ * however the return array does not have a specified ordering.
+ *
+ * @return {Array.<string>}
+ * Unique window handles.
+ */
+GeckoDriver.prototype.getWindowHandles = function() {
+ return this.windowHandles.map(String);
+};
+
+/**
+ * Get the current window's handle. This corresponds to a window that
+ * may itself contain tabs.
+ *
+ * Return an opaque server-assigned identifier to this window that
+ * uniquely identifies it within this Marionette instance. This can
+ * be used to switch to this window at a later point.
+ *
+ * @return {string}
+ * Unique window handle.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnknownError}
+ * Internal browsing context reference not found
+ */
+GeckoDriver.prototype.getChromeWindowHandle = function() {
+ const browsingContext = assert.open(
+ this.getBrowsingContext({
+ context: Context.Chrome,
+ top: true,
+ })
+ );
+
+ return browsingContext.id.toString();
+};
+
+/**
+ * Returns identifiers for each open chrome window for tests interested in
+ * managing a set of chrome windows and tabs separately.
+ *
+ * @return {Array.<string>}
+ * Unique window handles.
+ */
+GeckoDriver.prototype.getChromeWindowHandles = function() {
+ return this.chromeWindowHandles.map(String);
+};
+
+/**
+ * Get the current position and size of the browser window currently in focus.
+ *
+ * Will return the current browser window size in pixels. Refers to
+ * window outerWidth and outerHeight values, which include scroll bars,
+ * title bars, etc.
+ *
+ * @return {Object.<string, number>}
+ * Object with |x| and |y| coordinates, and |width| and |height|
+ * of browser window.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.getWindowRect = async function() {
+ assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ return this.curBrowser.rect;
+};
+
+/**
+ * Set the window position and size of the browser on the operating
+ * system window manager.
+ *
+ * The supplied `width` and `height` values refer to the window `outerWidth`
+ * and `outerHeight` values, which include browser chrome and OS-level
+ * window borders.
+ *
+ * @param {number} x
+ * X coordinate of the top/left of the window that it will be
+ * moved to.
+ * @param {number} y
+ * Y coordinate of the top/left of the window that it will be
+ * moved to.
+ * @param {number} width
+ * Width to resize the window to.
+ * @param {number} height
+ * Height to resize the window to.
+ *
+ * @return {Object.<string, number>}
+ * Object with `x` and `y` coordinates and `width` and `height`
+ * dimensions.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not applicable to application.
+ */
+GeckoDriver.prototype.setWindowRect = async function(cmd) {
+ assert.firefox();
+ assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ let { x, y, width, height } = cmd.parameters;
+
+ const win = this.getCurrentWindow();
+ switch (WindowState.from(win.windowState)) {
+ case WindowState.Fullscreen:
+ await exitFullscreen(win);
+ break;
+
+ case WindowState.Maximized:
+ case WindowState.Minimized:
+ await restoreWindow(win);
+ break;
+ }
+
+ if (width != null && height != null) {
+ assert.positiveInteger(height);
+ assert.positiveInteger(width);
+
+ if (win.outerWidth != width || win.outerHeight != height) {
+ win.resizeTo(width, height);
+ await new IdlePromise(win);
+ }
+ }
+
+ if (x != null && y != null) {
+ assert.integer(x);
+ assert.integer(y);
+
+ if (win.screenX != x || win.screenY != y) {
+ win.moveTo(x, y);
+ await new IdlePromise(win);
+ }
+ }
+
+ return this.curBrowser.rect;
+};
+
+/**
+ * Switch current top-level browsing context by name or server-assigned
+ * ID. Searches for windows by name, then ID. Content windows take
+ * precedence.
+ *
+ * @param {string} handle
+ * Handle of the window to switch to.
+ * @param {boolean=} focus
+ * A boolean value which determines whether to focus
+ * the window. Defaults to true.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.switchToWindow = async function(cmd) {
+ const { focus = true, handle } = cmd.parameters;
+
+ assert.string(
+ handle,
+ pprint`Expected "handle" to be a string, got ${handle}`
+ );
+ assert.boolean(focus, pprint`Expected "focus" to be a boolean, got ${focus}`);
+
+ const id = parseInt(handle);
+ const found = this.findWindow(this.windows, (win, winId) => id == winId);
+
+ let selected = false;
+ if (found) {
+ try {
+ await this.setWindowHandle(found, focus);
+ selected = true;
+ } catch (e) {
+ logger.error(e);
+ }
+ }
+
+ if (!selected) {
+ throw new error.NoSuchWindowError(`Unable to locate window: ${handle}`);
+ }
+};
+
+/**
+ * Find a specific window according to some filter function.
+ *
+ * @param {Iterable.<Window>} winIterable
+ * Iterable that emits Window objects.
+ * @param {function(Window, number): boolean} filter
+ * A callback function taking two arguments; the window and
+ * the outerId of the window, and returning a boolean indicating
+ * whether the window is the target.
+ *
+ * @return {Object}
+ * A window handle object containing the window and some
+ * associated metadata.
+ */
+GeckoDriver.prototype.findWindow = function(winIterable, filter) {
+ for (const win of winIterable) {
+ const browsingContext = win.docShell.browsingContext;
+ const tabBrowser = browser.getTabBrowser(win);
+
+ // In case the wanted window is a chrome window, we are done.
+ if (filter(win, browsingContext.id)) {
+ return { win, id: browsingContext.id, hasTabBrowser: !!tabBrowser };
+
+ // Otherwise check if the chrome window has a tab browser, and that it
+ // contains a tab with the wanted window handle.
+ } else if (tabBrowser && tabBrowser.tabs) {
+ for (let i = 0; i < tabBrowser.tabs.length; ++i) {
+ let contentBrowser = browser.getBrowserForTab(tabBrowser.tabs[i]);
+ let contentWindowId = this.getIdForBrowser(contentBrowser);
+
+ if (filter(win, contentWindowId)) {
+ return {
+ win,
+ id: browsingContext.id,
+ hasTabBrowser: true,
+ tabIndex: i,
+ };
+ }
+ }
+ }
+ }
+
+ return null;
+};
+
+/**
+ * Switch the marionette window to a given window. If the browser in
+ * the window is unregistered, register that browser and wait for
+ * the registration is complete. If |focus| is true then set the focus
+ * on the window.
+ *
+ * @param {Object} winProperties
+ * Object containing window properties such as returned from
+ * GeckoDriver#findWindow
+ * @param {boolean=} focus
+ * A boolean value which determines whether to focus the window.
+ * Defaults to true.
+ */
+GeckoDriver.prototype.setWindowHandle = async function(
+ winProperties,
+ focus = true
+) {
+ if (!(winProperties.id in this.browsers)) {
+ // Initialise Marionette if the current chrome window has not been seen
+ // before. Also register the initial tab, if one exists.
+ let registerBrowsers, browserListening;
+ if (!MarionettePrefs.useActors && winProperties.hasTabBrowser) {
+ registerBrowsers = this.registerPromise();
+ browserListening = this.listeningPromise();
+ }
+
+ this.startBrowser(winProperties.win, false /* isNewSession */);
+
+ this.chromeBrowsingContext = this.mainFrame.browsingContext;
+
+ if (!winProperties.hasTabBrowser) {
+ this.contentBrowsingContext = null;
+ } else if (MarionettePrefs.useActors) {
+ const tabBrowser = browser.getTabBrowser(winProperties.win);
+
+ // For chrome windows such as a reftest window, `getTabBrowser` is not
+ // a tabbrowser, it is the content browser which should be used here.
+ const contentBrowser = tabBrowser.tabs
+ ? tabBrowser.selectedBrowser
+ : tabBrowser;
+
+ this.contentBrowsingContext = contentBrowser.browsingContext;
+ this.registerBrowser(contentBrowser);
+ } else {
+ await registerBrowsers;
+ const id = await browserListening;
+ this.contentBrowsingContext = BrowsingContext.get(id);
+ }
+ } else {
+ // Otherwise switch to the known chrome window
+ this.curBrowser = this.browsers[winProperties.id];
+ this.mainFrame = this.curBrowser.window;
+
+ // Activate the tab if it's a content window.
+ let tab = null;
+ if (winProperties.hasTabBrowser) {
+ tab = await this.curBrowser.switchToTab(
+ winProperties.tabIndex,
+ winProperties.win,
+ focus
+ );
+ }
+
+ this.chromeBrowsingContext = this.mainFrame.browsingContext;
+ this.contentBrowsingContext = tab?.linkedBrowser.browsingContext;
+ }
+
+ if (focus) {
+ await this.curBrowser.focusWindow();
+ }
+};
+
+/**
+ * Set the current browsing context for future commands to the parent
+ * of the current browsing context.
+ *
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.switchToParentFrame = async function() {
+ let browsingContext = this.getBrowsingContext();
+ if (browsingContext && !browsingContext.parent) {
+ return;
+ }
+
+ browsingContext = assert.open(browsingContext?.parent);
+
+ if (MarionettePrefs.useActors) {
+ this.contentBrowsingContext = browsingContext;
+ return;
+ }
+
+ await this.listener.switchToParentFrame();
+};
+
+/**
+ * Switch to a given frame within the current window.
+ *
+ * @param {(string|Object)=} element
+ * A web element reference of the frame or its element id.
+ * @param {number=} id
+ * The index of the frame to switch to.
+ * If both element and id are not defined, switch to top-level frame.
+ *
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.switchToFrame = async function(cmd) {
+ const { element: el, id } = cmd.parameters;
+
+ if (typeof id == "number") {
+ assert.unsignedShort(id, `Expected id to be unsigned short, got ${id}`);
+ }
+
+ const top = id == null && el == null;
+ assert.open(this.getBrowsingContext({ top }));
+ await this._handleUserPrompts();
+
+ // Bug 1495063: Elements should be passed as WebElement reference
+ let byFrame;
+ if (typeof el == "string") {
+ byFrame = WebElement.fromUUID(el, this.context);
+ } else if (el) {
+ byFrame = WebElement.fromJSON(el);
+ }
+
+ if (MarionettePrefs.useActors) {
+ const { browsingContext } = await this.getActor({ top }).switchToFrame(
+ byFrame || id
+ );
+
+ this.contentBrowsingContext = browsingContext;
+ return;
+ }
+
+ const checkLoad = function(win) {
+ const otherErrorsExpr = /about:.+(error)|(blocked)\?/;
+
+ return new PollPromise(resolve => {
+ if (win.document.readyState == "complete") {
+ resolve();
+ } else if (win.document.readyState == "interactive") {
+ let documentURI = win.document.documentURI;
+ if (documentURI.startsWith("about:certerror")) {
+ throw new error.InsecureCertificateError();
+ } else if (otherErrorsExpr.exec(documentURI)) {
+ throw new error.UnknownError("Reached error page: " + documentURI);
+ }
+ }
+ });
+ };
+
+ if (this.context == Context.Chrome) {
+ const childContexts = this.getBrowsingContext().children;
+
+ let browsingContext;
+ if (id == null && !byFrame) {
+ browsingContext = this.getBrowsingContext({ top: true });
+ } else if (typeof id == "number") {
+ if (id < 0 || id >= childContexts.length) {
+ throw new error.NoSuchFrameError(
+ `Unable to locate frame with index: ${id}`
+ );
+ }
+ browsingContext = childContexts[id];
+ } else {
+ const wantedFrame = this.curBrowser.seenEls.get(byFrame);
+ const context = childContexts.find(context => {
+ return context.embedderElement === wantedFrame;
+ });
+ if (!context) {
+ throw new error.NoSuchFrameError(
+ `Unable to locate frame for element: ${byFrame}`
+ );
+ }
+ browsingContext = context;
+ }
+
+ this.contentBrowsingContext = browsingContext;
+
+ const frameWindow = browsingContext.window;
+ await checkLoad(frameWindow);
+ } else if (this.context == Context.Content) {
+ cmd.commandID = cmd.id;
+ await this.listener.switchToFrame(cmd.parameters);
+ }
+};
+
+GeckoDriver.prototype.getTimeouts = function() {
+ return this.timeouts;
+};
+
+/**
+ * Set timeout for page loading, searching, and scripts.
+ *
+ * @param {Object.<string, number>}
+ * Dictionary of timeout types and their new value, where all timeout
+ * types are optional.
+ *
+ * @throws {InvalidArgumentError}
+ * If timeout type key is unknown, or the value provided with it is
+ * not an integer.
+ */
+GeckoDriver.prototype.setTimeouts = function(cmd) {
+ // merge with existing timeouts
+ let merged = Object.assign(this.timeouts.toJSON(), cmd.parameters);
+ this.timeouts = Timeouts.fromJSON(merged);
+};
+
+/** Single tap. */
+GeckoDriver.prototype.singleTap = async function(cmd) {
+ assert.open(this.getBrowsingContext());
+
+ let { id, x, y } = cmd.parameters;
+ let webEl = WebElement.fromUUID(id, this.context);
+
+ if (MarionettePrefs.useActors) {
+ await this.getActor().singleTap(webEl, x, y, this.capabilities);
+ return;
+ }
+
+ switch (this.context) {
+ case Context.Chrome:
+ throw new error.UnsupportedOperationError(
+ "Command 'singleTap' is not yet available in chrome context"
+ );
+
+ case Context.Content:
+ await this.listener.singleTap(webEl, x, y, this.capabilities);
+ break;
+ }
+};
+
+/**
+ * Perform a series of grouped actions at the specified points in time.
+ *
+ * @param {Array.<?>} actions
+ * Array of objects that each represent an action sequence.
+ *
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not yet available in current context.
+ */
+GeckoDriver.prototype.performActions = async function(cmd) {
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ const actions = cmd.parameters.actions;
+
+ if (MarionettePrefs.useActors) {
+ await this.getActor().performActions(actions, this.capabilities);
+ return;
+ }
+
+ assert.content(
+ this.context,
+ "Command 'performActions' is not yet available in chrome context"
+ );
+
+ await this.listener.performActions({ actions }, this.capabilities);
+};
+
+/**
+ * Release all the keys and pointer buttons that are currently depressed.
+ *
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in current context.
+ */
+GeckoDriver.prototype.releaseActions = async function() {
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ if (MarionettePrefs.useActors) {
+ await this.getActor().releaseActions();
+ return;
+ }
+
+ assert.content(
+ this.context,
+ "Command 'releaseActions' is not yet available in chrome context"
+ );
+ await this.listener.releaseActions();
+};
+
+/**
+ * Find an element using the indicated search strategy.
+ *
+ * @param {string} using
+ * Indicates which search method to use.
+ * @param {string} value
+ * Value the client is looking for.
+ *
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.findElement = async function(cmd) {
+ const { element: el, using, value } = cmd.parameters;
+
+ if (!SUPPORTED_STRATEGIES.has(using)) {
+ throw new error.InvalidSelectorError(`Strategy not supported: ${using}`);
+ }
+
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let startNode;
+ if (typeof el != "undefined") {
+ startNode = WebElement.fromUUID(el, this.context);
+ }
+
+ let opts = {
+ startNode,
+ timeout: this.timeouts.implicit,
+ all: false,
+ };
+
+ if (MarionettePrefs.useActors) {
+ return this.getActor().findElement(using, value, opts);
+ }
+
+ switch (this.context) {
+ case Context.Chrome:
+ let container = { frame: this.getCurrentWindow() };
+ if (opts.startNode) {
+ opts.startNode = this.curBrowser.seenEls.get(opts.startNode);
+ }
+ let el = await element.find(container, using, value, opts);
+ return this.curBrowser.seenEls.add(el);
+
+ case Context.Content:
+ return this.listener.findElementContent(using, value, opts);
+
+ default:
+ throw new TypeError(`Unknown context: ${this.context}`);
+ }
+};
+
+/**
+ * Find elements using the indicated search strategy.
+ *
+ * @param {string} using
+ * Indicates which search method to use.
+ * @param {string} value
+ * Value the client is looking for.
+ *
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ */
+GeckoDriver.prototype.findElements = async function(cmd) {
+ const { element: el, using, value } = cmd.parameters;
+
+ if (!SUPPORTED_STRATEGIES.has(using)) {
+ throw new error.InvalidSelectorError(`Strategy not supported: ${using}`);
+ }
+
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let startNode;
+ if (typeof el != "undefined") {
+ startNode = WebElement.fromUUID(el, this.context);
+ }
+
+ let opts = {
+ startNode,
+ timeout: this.timeouts.implicit,
+ all: true,
+ };
+
+ if (MarionettePrefs.useActors) {
+ return this.getActor().findElements(using, value, opts);
+ }
+
+ switch (this.context) {
+ case Context.Chrome:
+ let container = { frame: this.getCurrentWindow() };
+ if (startNode) {
+ opts.startNode = this.curBrowser.seenEls.get(opts.startNode);
+ }
+ let els = await element.find(container, using, value, opts);
+ return this.curBrowser.seenEls.addAll(els);
+
+ case Context.Content:
+ return this.listener.findElementsContent(using, value, opts);
+
+ default:
+ throw new TypeError(`Unknown context: ${this.context}`);
+ }
+};
+
+/**
+ * Return the active element in the document.
+ *
+ * @return {WebElement}
+ * Active element of the current browsing context's document
+ * element, if the document element is non-null.
+ *
+ * @throws {NoSuchElementError}
+ * If the document does not have an active element, i.e. if
+ * its document element has been deleted.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in current context.
+ */
+GeckoDriver.prototype.getActiveElement = async function() {
+ assert.content(this.context);
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ if (MarionettePrefs.useActors) {
+ return this.getActor().getActiveElement();
+ }
+ return this.listener.getActiveElement();
+};
+
+/**
+ * Send click event to element.
+ *
+ * @param {string} id
+ * Reference ID to the element that will be clicked.
+ *
+ * @throws {InvalidArgumentError}
+ * If element <var>id</var> is not a string.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.clickElement = async function(cmd) {
+ const browsingContext = assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = assert.string(cmd.parameters.id);
+ let webEl = WebElement.fromUUID(id, this.context);
+
+ if (MarionettePrefs.useActors) {
+ const actor = this.getActor();
+
+ const loadEventExpected = navigate.isLoadEventExpected(
+ await this._getCurrentURL(),
+ {
+ browsingContext,
+ target: await actor.getElementAttribute(webEl, "target"),
+ }
+ );
+
+ await navigate.waitForNavigationCompleted(
+ this,
+ () => actor.clickElement(webEl, this.capabilities),
+ {
+ loadEventExpected,
+ // The click might trigger a navigation, so don't count on it.
+ requireBeforeUnload: false,
+ }
+ );
+ return;
+ }
+
+ switch (this.context) {
+ case Context.Chrome:
+ let el = this.curBrowser.seenEls.get(webEl);
+ await interaction.clickElement(el, this.a11yChecks);
+ break;
+
+ case Context.Content:
+ const loadEventExpected = navigate.isLoadEventExpected(
+ await this._getCurrentURL(),
+ {
+ browsingContext,
+ target: this.listener.getElementAttribute(webEl, "target"),
+ }
+ );
+
+ await navigate.waitForNavigationCompleted(
+ this,
+ () => this.listener.clickElement(webEl, this.capabilities),
+ {
+ loadEventExpected,
+ // The click might trigger a navigation, so don't count on it.
+ requireBeforeUnload: false,
+ }
+ );
+ break;
+
+ default:
+ throw new TypeError(`Unknown context: ${this.context}`);
+ }
+};
+
+/**
+ * Get a given attribute of an element.
+ *
+ * @param {string} id
+ * Web element reference ID to the element that will be inspected.
+ * @param {string} name
+ * Name of the attribute which value to retrieve.
+ *
+ * @return {string}
+ * Value of the attribute.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> or <var>name</var> are not strings.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.getElementAttribute = async function(cmd) {
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ const id = assert.string(cmd.parameters.id);
+ const name = assert.string(cmd.parameters.name);
+ const webEl = WebElement.fromUUID(id, this.context);
+
+ if (MarionettePrefs.useActors) {
+ return this.getActor().getElementAttribute(webEl, name);
+ }
+
+ switch (this.context) {
+ case Context.Chrome:
+ let el = this.curBrowser.seenEls.get(webEl);
+ return el.getAttribute(name);
+
+ case Context.Content:
+ return this.listener.getElementAttribute(webEl, name);
+
+ default:
+ throw new TypeError(`Unknown context: ${this.context}`);
+ }
+};
+
+/**
+ * Returns the value of a property associated with given element.
+ *
+ * @param {string} id
+ * Web element reference ID to the element that will be inspected.
+ * @param {string} name
+ * Name of the property which value to retrieve.
+ *
+ * @return {string}
+ * Value of the property.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> or <var>name</var> are not strings.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.getElementProperty = async function(cmd) {
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ const id = assert.string(cmd.parameters.id);
+ const name = assert.string(cmd.parameters.name);
+ const webEl = WebElement.fromUUID(id, this.context);
+
+ if (MarionettePrefs.useActors) {
+ return this.getActor().getElementProperty(webEl, name);
+ }
+
+ switch (this.context) {
+ case Context.Chrome:
+ let el = this.curBrowser.seenEls.get(webEl);
+ return evaluate.toJSON(el[name], this.curBrowser.seenEls);
+
+ case Context.Content:
+ return this.listener.getElementProperty(webEl, name);
+
+ default:
+ throw new TypeError(`Unknown context: ${this.context}`);
+ }
+};
+
+/**
+ * Get the text of an element, if any. Includes the text of all child
+ * elements.
+ *
+ * @param {string} id
+ * Reference ID to the element that will be inspected.
+ *
+ * @return {string}
+ * Element's text "as rendered".
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> is not a string.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.getElementText = async function(cmd) {
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = assert.string(cmd.parameters.id);
+ let webEl = WebElement.fromUUID(id, this.context);
+
+ if (MarionettePrefs.useActors) {
+ return this.getActor().getElementText(webEl);
+ }
+
+ switch (this.context) {
+ case Context.Chrome:
+ // for chrome, we look at text nodes, and any node with a "label" field
+ let el = this.curBrowser.seenEls.get(webEl);
+ let lines = [];
+ this.getVisibleText(el, lines);
+ return lines.join("\n");
+
+ case Context.Content:
+ return this.listener.getElementText(webEl);
+
+ default:
+ throw new TypeError(`Unknown context: ${this.context}`);
+ }
+};
+
+/**
+ * Get the tag name of the element.
+ *
+ * @param {string} id
+ * Reference ID to the element that will be inspected.
+ *
+ * @return {string}
+ * Local tag name of element.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> is not a string.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.getElementTagName = async function(cmd) {
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = assert.string(cmd.parameters.id);
+ let webEl = WebElement.fromUUID(id, this.context);
+
+ if (MarionettePrefs.useActors) {
+ return this.getActor().getElementTagName(webEl);
+ }
+
+ switch (this.context) {
+ case Context.Chrome:
+ let el = this.curBrowser.seenEls.get(webEl);
+ return el.tagName.toLowerCase();
+
+ case Context.Content:
+ return this.listener.getElementTagName(webEl);
+
+ default:
+ throw new TypeError(`Unknown context: ${this.context}`);
+ }
+};
+
+/**
+ * Check if element is displayed.
+ *
+ * @param {string} id
+ * Reference ID to the element that will be inspected.
+ *
+ * @return {boolean}
+ * True if displayed, false otherwise.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> is not a string.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.isElementDisplayed = async function(cmd) {
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = assert.string(cmd.parameters.id);
+ let webEl = WebElement.fromUUID(id, this.context);
+
+ if (MarionettePrefs.useActors) {
+ return this.getActor().isElementDisplayed(webEl, this.capabilities);
+ }
+
+ switch (this.context) {
+ case Context.Chrome:
+ let el = this.curBrowser.seenEls.get(webEl);
+ return interaction.isElementDisplayed(el, this.a11yChecks);
+
+ case Context.Content:
+ return this.listener.isElementDisplayed(webEl, this.capabilities);
+
+ default:
+ throw new TypeError(`Unknown context: ${this.context}`);
+ }
+};
+
+/**
+ * Return the property of the computed style of an element.
+ *
+ * @param {string} id
+ * Reference ID to the element that will be checked.
+ * @param {string} propertyName
+ * CSS rule that is being requested.
+ *
+ * @return {string}
+ * Value of |propertyName|.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> or <var>propertyName</var> are not strings.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.getElementValueOfCssProperty = async function(cmd) {
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = assert.string(cmd.parameters.id);
+ let prop = assert.string(cmd.parameters.propertyName);
+ let webEl = WebElement.fromUUID(id, this.context);
+
+ if (MarionettePrefs.useActors) {
+ return this.getActor().getElementValueOfCssProperty(webEl, prop);
+ }
+
+ switch (this.context) {
+ case Context.Chrome:
+ const win = this.getCurrentWindow();
+ const el = this.curBrowser.seenEls.get(webEl);
+ const style = win.document.defaultView.getComputedStyle(el);
+ return style.getPropertyValue(prop);
+
+ case Context.Content:
+ return this.listener.getElementValueOfCssProperty(webEl, prop);
+
+ default:
+ throw new TypeError(`Unknown context: ${this.context}`);
+ }
+};
+
+/**
+ * Check if element is enabled.
+ *
+ * @param {string} id
+ * Reference ID to the element that will be checked.
+ *
+ * @return {boolean}
+ * True if enabled, false if disabled.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> is not a string.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.isElementEnabled = async function(cmd) {
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = assert.string(cmd.parameters.id);
+ let webEl = WebElement.fromUUID(id, this.context);
+
+ if (MarionettePrefs.useActors) {
+ return this.getActor().isElementEnabled(webEl, this.capabilities);
+ }
+
+ switch (this.context) {
+ case Context.Chrome:
+ // Selenium atom doesn't quite work here
+ let el = this.curBrowser.seenEls.get(webEl);
+ return interaction.isElementEnabled(el, this.a11yChecks);
+
+ case Context.Content:
+ return this.listener.isElementEnabled(webEl, this.capabilities);
+
+ default:
+ throw new TypeError(`Unknown context: ${this.context}`);
+ }
+};
+
+/**
+ * Check if element is selected.
+ *
+ * @param {string} id
+ * Reference ID to the element that will be checked.
+ *
+ * @return {boolean}
+ * True if selected, false if unselected.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> is not a string.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.isElementSelected = async function(cmd) {
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = assert.string(cmd.parameters.id);
+ let webEl = WebElement.fromUUID(id, this.context);
+
+ if (MarionettePrefs.useActors) {
+ return this.getActor().isElementSelected(webEl, this.capabilities);
+ }
+
+ switch (this.context) {
+ case Context.Chrome:
+ // Selenium atom doesn't quite work here
+ let el = this.curBrowser.seenEls.get(webEl);
+ return interaction.isElementSelected(el, this.a11yChecks);
+
+ case Context.Content:
+ return this.listener.isElementSelected(webEl, this.capabilities);
+
+ default:
+ throw new TypeError(`Unknown context: ${this.context}`);
+ }
+};
+
+/**
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> is not a string.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.getElementRect = async function(cmd) {
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = assert.string(cmd.parameters.id);
+ let webEl = WebElement.fromUUID(id, this.context);
+
+ if (MarionettePrefs.useActors) {
+ return this.getActor().getElementRect(webEl);
+ }
+
+ switch (this.context) {
+ case Context.Chrome:
+ const win = this.getCurrentWindow();
+ const el = this.curBrowser.seenEls.get(webEl);
+ const rect = el.getBoundingClientRect();
+ return {
+ x: rect.x + win.pageXOffset,
+ y: rect.y + win.pageYOffset,
+ width: rect.width,
+ height: rect.height,
+ };
+
+ case Context.Content:
+ return this.listener.getElementRect(webEl);
+
+ default:
+ throw new TypeError(`Unknown context: ${this.context}`);
+ }
+};
+
+/**
+ * Send key presses to element after focusing on it.
+ *
+ * @param {string} id
+ * Reference ID to the element that will be checked.
+ * @param {string} text
+ * Value to send to the element.
+ *
+ * @throws {InvalidArgumentError}
+ * If `id` or `text` are not strings.
+ * @throws {NoSuchElementError}
+ * If element represented by reference `id` is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.sendKeysToElement = async function(cmd) {
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = assert.string(cmd.parameters.id);
+ let text = assert.string(cmd.parameters.text);
+ let webEl = WebElement.fromUUID(id, this.context);
+
+ if (MarionettePrefs.useActors) {
+ await this.getActor().sendKeysToElement(webEl, text, this.capabilities);
+ return;
+ }
+
+ switch (this.context) {
+ case Context.Chrome:
+ let el = this.curBrowser.seenEls.get(webEl);
+ await interaction.sendKeysToElement(el, text, {
+ accessibilityChecks: this.a11yChecks,
+ });
+ break;
+
+ case Context.Content:
+ await this.listener.sendKeysToElement(webEl, text, this.capabilities);
+ break;
+
+ default:
+ throw new TypeError(`Unknown context: ${this.context}`);
+ }
+};
+
+/**
+ * Clear the text of an element.
+ *
+ * @param {string} id
+ * Reference ID to the element that will be cleared.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> is not a string.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.clearElement = async function(cmd) {
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = assert.string(cmd.parameters.id);
+ let webEl = WebElement.fromUUID(id, this.context);
+
+ if (MarionettePrefs.useActors) {
+ await this.getActor().clearElement(webEl);
+ return;
+ }
+
+ switch (this.context) {
+ case Context.Chrome:
+ // the selenium atom doesn't work here
+ let el = this.curBrowser.seenEls.get(webEl);
+ if (el.nodeName == "input" && el.type == "text") {
+ el.value = "";
+ } else if (el.nodeName == "checkbox") {
+ el.checked = false;
+ }
+ break;
+
+ case Context.Content:
+ await this.listener.clearElement(webEl);
+ break;
+
+ default:
+ throw new TypeError(`Unknown context: ${this.context}`);
+ }
+};
+
+/**
+ * Add a single cookie to the cookie store associated with the active
+ * document's address.
+ *
+ * @param {Map.<string, (string|number|boolean)> cookie
+ * Cookie object.
+ *
+ * @throws {InvalidCookieDomainError}
+ * If <var>cookie</var> is for a different domain than the active
+ * document's host.
+ * @throws {NoSuchWindowError}
+ * Bbrowsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in current context.
+ */
+GeckoDriver.prototype.addCookie = async function(cmd) {
+ assert.content(this.context);
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let { protocol, hostname } = await this._getCurrentURL();
+
+ const networkSchemes = ["ftp:", "http:", "https:"];
+ if (!networkSchemes.includes(protocol)) {
+ throw new error.InvalidCookieDomainError("Document is cookie-averse");
+ }
+
+ let newCookie = cookie.fromJSON(cmd.parameters.cookie);
+
+ cookie.add(newCookie, { restrictToHost: hostname, protocol });
+};
+
+/**
+ * Get all the cookies for the current domain.
+ *
+ * This is the equivalent of calling <code>document.cookie</code> and
+ * parsing the result.
+ *
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in current context.
+ */
+GeckoDriver.prototype.getCookies = async function() {
+ assert.content(this.context);
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let { hostname, pathname } = await this._getCurrentURL();
+ return [...cookie.iter(hostname, pathname)];
+};
+
+/**
+ * Delete all cookies that are visible to a document.
+ *
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in current context.
+ */
+GeckoDriver.prototype.deleteAllCookies = async function() {
+ assert.content(this.context);
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let { hostname, pathname } = await this._getCurrentURL();
+ for (let toDelete of cookie.iter(hostname, pathname)) {
+ cookie.remove(toDelete);
+ }
+};
+
+/**
+ * Delete a cookie by name.
+ *
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in current context.
+ */
+GeckoDriver.prototype.deleteCookie = async function(cmd) {
+ assert.content(this.context);
+ assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let { hostname, pathname } = await this._getCurrentURL();
+ let name = assert.string(cmd.parameters.name);
+ for (let c of cookie.iter(hostname, pathname)) {
+ if (c.name === name) {
+ cookie.remove(c);
+ }
+ }
+};
+
+/**
+ * Open a new top-level browsing context.
+ *
+ * @param {string=} type
+ * Optional type of the new top-level browsing context. Can be one of
+ * `tab` or `window`. Defaults to `tab`.
+ * @param {boolean=} focus
+ * Optional flag if the new top-level browsing context should be opened
+ * in foreground (focused) or background (not focused). Defaults to false.
+ * @param {boolean=} private
+ * Optional flag, which gets only evaluated for type `window`. True if the
+ * new top-level browsing context should be a private window.
+ * Defaults to false.
+ *
+ * @return {Object.<string, string>}
+ * Handle and type of the new browsing context.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.newWindow = async function(cmd) {
+ assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ let focus = false;
+ if (typeof cmd.parameters.focus != "undefined") {
+ focus = assert.boolean(
+ cmd.parameters.focus,
+ pprint`Expected "focus" to be a boolean, got ${cmd.parameters.focus}`
+ );
+ }
+
+ let isPrivate = false;
+ if (typeof cmd.parameters.private != "undefined") {
+ isPrivate = assert.boolean(
+ cmd.parameters.private,
+ pprint`Expected "private" to be a boolean, got ${cmd.parameters.private}`
+ );
+ }
+
+ let type;
+ if (typeof cmd.parameters.type != "undefined") {
+ type = assert.string(
+ cmd.parameters.type,
+ pprint`Expected "type" to be a string, got ${cmd.parameters.type}`
+ );
+ }
+
+ // If an invalid or no type has been specified default to a tab.
+ if (typeof type == "undefined" || !["tab", "window"].includes(type)) {
+ type = "tab";
+ }
+
+ let contentBrowser;
+
+ let onBrowserContentLoaded;
+ if (MarionettePrefs.useActors) {
+ // Actors need the new window to be loaded to safely execute queries.
+ // Wait until a load event is dispatched for the new browsing context.
+ onBrowserContentLoaded = waitForLoadEvent(
+ "pageshow",
+ () => contentBrowser?.browsingContext
+ );
+ }
+
+ switch (type) {
+ case "window":
+ let win = await this.curBrowser.openBrowserWindow(focus, isPrivate);
+ contentBrowser = browser.getTabBrowser(win).selectedBrowser;
+ break;
+
+ default:
+ // To not fail if a new type gets added in the future, make opening
+ // a new tab the default action.
+ let tab = await this.curBrowser.openTab(focus);
+ contentBrowser = browser.getBrowserForTab(tab);
+ }
+
+ await onBrowserContentLoaded;
+
+ // Even with the framescript registered, the browser might not be known to
+ // the parent process yet. Wait until it is available.
+ // TODO: Fix by using `Browser:Init` or equivalent on bug 1311041
+ let windowId = await new PollPromise((resolve, reject) => {
+ let id = this.getIdForBrowser(contentBrowser);
+ this.windowHandles.includes(id) ? resolve(id) : reject();
+ });
+
+ return { handle: windowId.toString(), type };
+};
+
+/**
+ * Close the currently selected tab/window.
+ *
+ * With multiple open tabs present the currently selected tab will
+ * be closed. Otherwise the window itself will be closed. If it is the
+ * last window currently open, the window will not be closed to prevent
+ * a shutdown of the application. Instead the returned list of window
+ * handles is empty.
+ *
+ * @return {Array.<string>}
+ * Unique window handles of remaining windows.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.close = async function() {
+ assert.open(this.getBrowsingContext({ context: Context.Content, top: true }));
+ await this._handleUserPrompts();
+
+ let nwins = 0;
+
+ for (let win of this.windows) {
+ // For browser windows count the tabs. Otherwise take the window itself.
+ let tabbrowser = browser.getTabBrowser(win);
+ if (tabbrowser && tabbrowser.tabs) {
+ nwins += tabbrowser.tabs.length;
+ } else {
+ nwins += 1;
+ }
+ }
+
+ // If there is only one window left, do not close it. Instead return
+ // a faked empty array of window handles. This will instruct geckodriver
+ // to terminate the application.
+ if (nwins === 1) {
+ return [];
+ }
+
+ await this.curBrowser.closeTab();
+ this.contentBrowsingContext = null;
+
+ return this.windowHandles.map(String);
+};
+
+/**
+ * Close the currently selected chrome window.
+ *
+ * If it is the last window currently open, the chrome window will not be
+ * closed to prevent a shutdown of the application. Instead the returned
+ * list of chrome window handles is empty.
+ *
+ * @return {Array.<string>}
+ * Unique chrome window handles of remaining chrome windows.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.closeChromeWindow = async function() {
+ assert.firefox();
+ assert.open(this.getBrowsingContext({ context: Context.Chrome, top: true }));
+
+ let nwins = 0;
+
+ // eslint-disable-next-line
+ for (let _ of this.windows) {
+ nwins++;
+ }
+
+ // If there is only one window left, do not close it. Instead return
+ // a faked empty array of window handles. This will instruct geckodriver
+ // to terminate the application.
+ if (nwins == 1) {
+ return [];
+ }
+
+ await this.curBrowser.closeWindow();
+ this.chromeBrowsingContext = null;
+ this.contentBrowsingContext = null;
+
+ return this.chromeWindowHandles.map(String);
+};
+
+/** Delete Marionette session. */
+GeckoDriver.prototype.deleteSession = function() {
+ if (MarionettePrefs.useActors) {
+ clearActionInputState();
+ clearElementIdCache();
+
+ unregisterCommandsActor();
+ unregisterEventsActor();
+ } else if (this.curBrowser !== null) {
+ // frame scripts can be safely reused
+ MarionettePrefs.contentListener = false;
+
+ globalMessageManager.broadcastAsyncMessage("Marionette:Session:Delete");
+ globalMessageManager.broadcastAsyncMessage("Marionette:Deregister");
+
+ for (let win of this.windows) {
+ if (win.messageManager) {
+ win.messageManager.removeDelayedFrameScript(FRAME_SCRIPT);
+ } else {
+ logger.error(
+ `Could not remove listener from page ${win.location.href}`
+ );
+ }
+ }
+ }
+
+ // reset to the top-most frame, and clear browsing context references
+ this.mainFrame = null;
+ this.chromeBrowsingContext = null;
+ this.contentBrowsingContext = null;
+
+ if (this.dialogObserver) {
+ this.dialogObserver.cleanup();
+ this.dialogObserver = null;
+ }
+
+ try {
+ Services.obs.removeObserver(this, "browsing-context-attached");
+ } catch (e) {}
+
+ this.sandboxes.clear();
+ allowAllCerts.disable();
+
+ this.sessionID = null;
+ this.capabilities = new Capabilities();
+};
+
+/**
+ * Takes a screenshot of a web element, current frame, or viewport.
+ *
+ * The screen capture is returned as a lossless PNG image encoded as
+ * a base 64 string.
+ *
+ * If called in the content context, the |id| argument is not null and
+ * refers to a present and visible web element's ID, the capture area will
+ * be limited to the bounding box of that element. Otherwise, the capture
+ * area will be the bounding box of the current frame.
+ *
+ * If called in the chrome context, the screenshot will always represent
+ * the entire viewport.
+ *
+ * @param {string=} id
+ * Optional web element reference to take a screenshot of.
+ * If undefined, a screenshot will be taken of the document element.
+ * @param {boolean=} full
+ * True to take a screenshot of the entire document element. Is only
+ * considered if <var>id</var> is not defined. Defaults to true.
+ * @param {boolean=} hash
+ * True if the user requests a hash of the image data. Defaults to false.
+ * @param {boolean=} scroll
+ * Scroll to element if |id| is provided. Defaults to true.
+ *
+ * @return {string}
+ * If <var>hash</var> is false, PNG image encoded as Base64 encoded
+ * string. If <var>hash</var> is true, hex digest of the SHA-256
+ * hash of the Base64 encoded string.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.takeScreenshot = async function(cmd) {
+ assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ let { id, full, hash, scroll } = cmd.parameters;
+ let format = hash ? capture.Format.Hash : capture.Format.Base64;
+
+ full = typeof full == "undefined" ? true : full;
+ scroll = typeof scroll == "undefined" ? true : scroll;
+
+ let webEl = id ? WebElement.fromUUID(id, this.context) : null;
+
+ // Only consider full screenshot if no element has been specified
+ full = webEl ? false : full;
+
+ if (MarionettePrefs.useActors) {
+ return this.getActor().takeScreenshot(webEl, format, full, scroll);
+ }
+
+ const win = this.getCurrentWindow();
+
+ let rect;
+ switch (this.context) {
+ case Context.Chrome:
+ if (id) {
+ let el = this.curBrowser.seenEls.get(webEl, win);
+ rect = el.getBoundingClientRect();
+ } else if (full) {
+ const docEl = win.document.documentElement;
+ rect = new DOMRect(0, 0, docEl.scrollWidth, docEl.scrollHeight);
+ } else {
+ // viewport
+ rect = new win.DOMRect(
+ win.pageXOffset,
+ win.pageYOffset,
+ win.innerWidth,
+ win.innerHeight
+ );
+ }
+ break;
+
+ case Context.Content:
+ rect = await this.listener.getScreenshotRect({ el: webEl, full, scroll });
+ break;
+ }
+
+ // If no element has been specified use the top-level browsing context.
+ // Otherwise use the browsing context from the currently selected frame.
+ const browsingContext = this.getBrowsingContext({ top: !webEl });
+
+ let canvas = await capture.canvas(
+ win,
+ browsingContext,
+ rect.x,
+ rect.y,
+ rect.width,
+ rect.height
+ );
+
+ switch (format) {
+ case capture.Format.Hash:
+ return capture.toHash(canvas);
+
+ case capture.Format.Base64:
+ return capture.toBase64(canvas);
+ }
+
+ throw new TypeError(`Unknown context: ${this.context}`);
+};
+
+/**
+ * Get the current browser orientation.
+ *
+ * Will return one of the valid primary orientation values
+ * portrait-primary, landscape-primary, portrait-secondary, or
+ * landscape-secondary.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.getScreenOrientation = function() {
+ assert.fennec();
+ assert.open(this.getBrowsingContext({ top: true }));
+
+ const win = this.getCurrentWindow();
+
+ return win.screen.mozOrientation;
+};
+
+/**
+ * Set the current browser orientation.
+ *
+ * The supplied orientation should be given as one of the valid
+ * orientation values. If the orientation is unknown, an error will
+ * be raised.
+ *
+ * Valid orientations are "portrait" and "landscape", which fall
+ * back to "portrait-primary" and "landscape-primary" respectively,
+ * and "portrait-secondary" as well as "landscape-secondary".
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.setScreenOrientation = function(cmd) {
+ assert.fennec();
+ assert.open(this.getBrowsingContext({ top: true }));
+
+ const ors = [
+ "portrait",
+ "landscape",
+ "portrait-primary",
+ "landscape-primary",
+ "portrait-secondary",
+ "landscape-secondary",
+ ];
+
+ let or = String(cmd.parameters.orientation);
+ assert.string(or);
+ let mozOr = or.toLowerCase();
+ if (!ors.includes(mozOr)) {
+ throw new error.InvalidArgumentError(`Unknown screen orientation: ${or}`);
+ }
+
+ const win = this.getCurrentWindow();
+ if (!win.screen.mozLockOrientation(mozOr)) {
+ throw new error.WebDriverError(`Unable to set screen orientation: ${or}`);
+ }
+};
+
+/**
+ * Synchronously minimizes the user agent window as if the user pressed
+ * the minimize button.
+ *
+ * No action is taken if the window is already minimized.
+ *
+ * Not supported on Fennec.
+ *
+ * @return {Object.<string, number>}
+ * Window rect and window state.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available for current application.
+ */
+GeckoDriver.prototype.minimizeWindow = async function() {
+ assert.firefox();
+ assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ const win = this.getCurrentWindow();
+ switch (WindowState.from(win.windowState)) {
+ case WindowState.Fullscreen:
+ await exitFullscreen(win);
+ break;
+
+ case WindowState.Maximized:
+ await restoreWindow(win);
+ break;
+ }
+
+ if (WindowState.from(win.windowState) != WindowState.Minimized) {
+ let cb;
+ let observer = new WebElementEventTarget(this.curBrowser.messageManager);
+ // Use a timed promise to abort if no window manager is present
+ await new TimedPromise(
+ resolve => {
+ cb = new DebounceCallback(resolve);
+ observer.addEventListener("visibilitychange", cb);
+ win.minimize();
+ },
+ { throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER }
+ );
+ observer.removeEventListener("visibilitychange", cb);
+ await new IdlePromise(win);
+ }
+
+ return this.curBrowser.rect;
+};
+
+/**
+ * Synchronously maximizes the user agent window as if the user pressed
+ * the maximize button.
+ *
+ * No action is taken if the window is already maximized.
+ *
+ * Not supported on Fennec.
+ *
+ * @return {Object.<string, number>}
+ * Window rect.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available for current application.
+ */
+GeckoDriver.prototype.maximizeWindow = async function() {
+ assert.firefox();
+ assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ const win = this.getCurrentWindow();
+ switch (WindowState.from(win.windowState)) {
+ case WindowState.Fullscreen:
+ await exitFullscreen(win);
+ break;
+
+ case WindowState.Minimized:
+ await restoreWindow(win);
+ break;
+ }
+
+ if (WindowState.from(win.windowState) != WindowState.Maximized) {
+ let cb;
+ // Use a timed promise to abort if no window manager is present
+ await new TimedPromise(
+ resolve => {
+ cb = new DebounceCallback(resolve);
+ win.addEventListener("sizemodechange", cb);
+ win.maximize();
+ },
+ { throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER }
+ );
+ win.removeEventListener("sizemodechange", cb);
+ await new IdlePromise(win);
+ }
+
+ return this.curBrowser.rect;
+};
+
+/**
+ * Synchronously sets the user agent window to full screen as if the user
+ * had done "View > Enter Full Screen".
+ *
+ * No action is taken if the window is already in full screen mode.
+ *
+ * Not supported on Fennec.
+ *
+ * @return {Map.<string, number>}
+ * Window rect.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available for current application.
+ */
+GeckoDriver.prototype.fullscreenWindow = async function() {
+ assert.firefox();
+ assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ const win = this.getCurrentWindow();
+ switch (WindowState.from(win.windowState)) {
+ case WindowState.Maximized:
+ case WindowState.Minimized:
+ await restoreWindow(win);
+ break;
+ }
+
+ if (WindowState.from(win.windowState) != WindowState.Fullscreen) {
+ let cb;
+ // Use a timed promise to abort if no window manager is present
+ await new TimedPromise(
+ resolve => {
+ cb = new DebounceCallback(resolve);
+ win.addEventListener("sizemodechange", cb);
+ win.fullScreen = true;
+ },
+ { throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER }
+ );
+ win.removeEventListener("sizemodechange", cb);
+ }
+ await new IdlePromise(win);
+
+ return this.curBrowser.rect;
+};
+
+/**
+ * Dismisses a currently displayed tab modal, or returns no such alert if
+ * no modal is displayed.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.dismissDialog = async function() {
+ assert.open(this.getBrowsingContext({ top: true }));
+ this._checkIfAlertIsPresent();
+
+ const win = this.getCurrentWindow();
+ const dialogClosed = waitForEvent(win, "DOMModalDialogClosed");
+
+ const { button0, button1 } = this.dialog.ui;
+ (button1 ? button1 : button0).click();
+
+ await dialogClosed;
+ await new IdlePromise(win);
+};
+
+/**
+ * Accepts a currently displayed tab modal, or returns no such alert if
+ * no modal is displayed.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.acceptDialog = async function() {
+ assert.open(this.getBrowsingContext({ top: true }));
+ this._checkIfAlertIsPresent();
+
+ const win = this.getCurrentWindow();
+ const dialogClosed = waitForEvent(win, "DOMModalDialogClosed");
+
+ const { button0 } = this.dialog.ui;
+ button0.click();
+
+ await dialogClosed;
+ await new IdlePromise(win);
+};
+
+/**
+ * Returns the message shown in a currently displayed modal, or returns
+ * a no such alert error if no modal is currently displayed.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.getTextFromDialog = function() {
+ assert.open(this.getBrowsingContext({ top: true }));
+ this._checkIfAlertIsPresent();
+
+ return this.dialog.ui.infoBody.textContent;
+};
+
+/**
+ * Set the user prompt's value field.
+ *
+ * Sends keys to the input field of a currently displayed modal, or
+ * returns a no such alert error if no modal is currently displayed. If
+ * a tab modal is currently displayed but has no means for text input,
+ * an element not visible error is returned.
+ *
+ * @param {string} text
+ * Input to the user prompt's value field.
+ *
+ * @throws {ElementNotInteractableError}
+ * If the current user prompt is an alert or confirm.
+ * @throws {NoSuchAlertError}
+ * If there is no current user prompt.
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnsupportedOperationError}
+ * If the current user prompt is something other than an alert,
+ * confirm, or a prompt.
+ */
+GeckoDriver.prototype.sendKeysToDialog = async function(cmd) {
+ assert.open(this.getBrowsingContext({ top: true }));
+ this._checkIfAlertIsPresent();
+
+ let text = assert.string(cmd.parameters.text);
+ let promptType = this.dialog.args.promptType;
+
+ switch (promptType) {
+ case "alert":
+ case "confirm":
+ throw new error.ElementNotInteractableError(
+ `User prompt of type ${promptType} is not interactable`
+ );
+ case "prompt":
+ break;
+ default:
+ await this.dismissDialog();
+ throw new error.UnsupportedOperationError(
+ `User prompt of type ${promptType} is not supported`
+ );
+ }
+
+ // see toolkit/components/prompts/content/commonDialog.js
+ let { loginTextbox } = this.dialog.ui;
+ loginTextbox.value = text;
+};
+
+GeckoDriver.prototype._checkIfAlertIsPresent = function() {
+ if (!this.dialog || !this.dialog.ui) {
+ throw new error.NoSuchAlertError();
+ }
+};
+
+GeckoDriver.prototype._handleUserPrompts = async function() {
+ if (!this.dialog || !this.dialog.ui) {
+ return;
+ }
+
+ let { textContent } = this.dialog.ui.infoBody;
+
+ let behavior = this.capabilities.get("unhandledPromptBehavior");
+ switch (behavior) {
+ case UnhandledPromptBehavior.Accept:
+ await this.acceptDialog();
+ break;
+
+ case UnhandledPromptBehavior.AcceptAndNotify:
+ await this.acceptDialog();
+ throw new error.UnexpectedAlertOpenError(
+ `Accepted user prompt dialog: ${textContent}`
+ );
+
+ case UnhandledPromptBehavior.Dismiss:
+ await this.dismissDialog();
+ break;
+
+ case UnhandledPromptBehavior.DismissAndNotify:
+ await this.dismissDialog();
+ throw new error.UnexpectedAlertOpenError(
+ `Dismissed user prompt dialog: ${textContent}`
+ );
+
+ case UnhandledPromptBehavior.Ignore:
+ throw new error.UnexpectedAlertOpenError(
+ "Encountered unhandled user prompt dialog"
+ );
+
+ default:
+ throw new TypeError(`Unknown unhandledPromptBehavior "${behavior}"`);
+ }
+};
+
+/**
+ * Enables or disables accepting new socket connections.
+ *
+ * By calling this method with `false` the server will not accept any
+ * further connections, but existing connections will not be forcible
+ * closed. Use `true` to re-enable accepting connections.
+ *
+ * Please note that when closing the connection via the client you can
+ * end-up in a non-recoverable state if it hasn't been enabled before.
+ *
+ * This method is used for custom in application shutdowns via
+ * marionette.quit() or marionette.restart(), like File -> Quit.
+ *
+ * @param {boolean} state
+ * True if the server should accept new socket connections.
+ */
+GeckoDriver.prototype.acceptConnections = function(cmd) {
+ assert.boolean(cmd.parameters.value);
+ this._server.acceptConnections = cmd.parameters.value;
+};
+
+/**
+ * Quits the application with the provided flags.
+ *
+ * Marionette will stop accepting new connections before ending the
+ * current session, and finally attempting to quit the application.
+ *
+ * Optional {@link nsIAppStartup} flags may be provided as
+ * an array of masks, and these will be combined by ORing
+ * them with a bitmask. The available masks are defined in
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIAppStartup.
+ *
+ * Crucially, only one of the *Quit flags can be specified. The |eRestart|
+ * flag may be bit-wise combined with one of the *Quit flags to cause
+ * the application to restart after it quits.
+ *
+ * @param {Array.<string>=} flags
+ * Constant name of masks to pass to |Services.startup.quit|.
+ * If empty or undefined, |nsIAppStartup.eAttemptQuit| is used.
+ *
+ * @return {string}
+ * Explaining the reason why the application quit. This can be
+ * in response to a normal shutdown or restart, yielding "shutdown"
+ * or "restart", respectively.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>flags</var> contains unknown or incompatible flags,
+ * for example multiple Quit flags.
+ */
+GeckoDriver.prototype.quit = async function(cmd) {
+ const quits = ["eConsiderQuit", "eAttemptQuit", "eForceQuit"];
+
+ let flags = [];
+ if (typeof cmd.parameters.flags != "undefined") {
+ flags = assert.array(cmd.parameters.flags);
+ }
+
+ let quitSeen;
+ let mode = 0;
+ if (flags.length > 0) {
+ for (let k of flags) {
+ assert.in(k, Ci.nsIAppStartup);
+
+ if (quits.includes(k)) {
+ if (quitSeen) {
+ throw new error.InvalidArgumentError(
+ `${k} cannot be combined with ${quitSeen}`
+ );
+ }
+ quitSeen = k;
+ }
+
+ mode |= Ci.nsIAppStartup[k];
+ }
+ } else {
+ mode = Ci.nsIAppStartup.eAttemptQuit;
+ }
+
+ this._server.acceptConnections = false;
+ this.deleteSession();
+
+ // delay response until the application is about to quit
+ let quitApplication = waitForObserverTopic("quit-application");
+ Services.startup.quit(mode);
+
+ return { cause: (await quitApplication).data };
+};
+
+GeckoDriver.prototype.installAddon = function(cmd) {
+ assert.desktop();
+
+ let path = cmd.parameters.path;
+ let temp = cmd.parameters.temporary || false;
+ if (
+ typeof path == "undefined" ||
+ typeof path != "string" ||
+ typeof temp != "boolean"
+ ) {
+ throw new error.InvalidArgumentError();
+ }
+
+ return Addon.install(path, temp);
+};
+
+GeckoDriver.prototype.uninstallAddon = function(cmd) {
+ assert.firefox();
+
+ let id = cmd.parameters.id;
+ if (typeof id == "undefined" || typeof id != "string") {
+ throw new error.InvalidArgumentError();
+ }
+
+ return Addon.uninstall(id);
+};
+
+/** Receives all messages from content messageManager. */
+/* eslint-disable consistent-return */
+GeckoDriver.prototype.receiveMessage = function(message) {
+ switch (message.name) {
+ case "Marionette:switchedToFrame":
+ this.contentBrowsingContext = BrowsingContext.get(
+ message.json.browsingContextId
+ );
+ break;
+
+ case "Marionette:Register":
+ this.registerBrowser(message.target);
+ return { frameId: message.json.frameId };
+
+ case "Marionette:ListenersAttached":
+ if (message.json.frameId === this.curBrowser.curFrameId) {
+ const browsingContext = BrowsingContext.get(message.json.frameId);
+
+ // If the framescript for the current content browsing context
+ // has been re-attached due to a remoteness change (the browserId is
+ // always persistent) then track the new browsing context.
+ if (
+ browsingContext.browserId == this.contentBrowsingContext?.browserId
+ ) {
+ logger.trace(
+ "Detected remoteness change. New browsing context: " +
+ browsingContext.id
+ );
+ this.contentBrowsingContext = browsingContext;
+ }
+ }
+ break;
+ }
+};
+/* eslint-enable consistent-return */
+
+/**
+ * Retrieve the localized string for the specified entity id.
+ *
+ * Example:
+ * localizeEntity(["chrome://branding/locale/brand.dtd"], "brandShortName")
+ *
+ * @param {Array.<string>} urls
+ * Array of .dtd URLs.
+ * @param {string} id
+ * The ID of the entity to retrieve the localized string for.
+ *
+ * @return {string}
+ * The localized string for the requested entity.
+ */
+GeckoDriver.prototype.localizeEntity = function(cmd) {
+ let { urls, id } = cmd.parameters;
+
+ if (!Array.isArray(urls)) {
+ throw new error.InvalidArgumentError(
+ "Value of `urls` should be of type 'Array'"
+ );
+ }
+ if (typeof id != "string") {
+ throw new error.InvalidArgumentError(
+ "Value of `id` should be of type 'string'"
+ );
+ }
+
+ return l10n.localizeEntity(urls, id);
+};
+
+/**
+ * Retrieve the localized string for the specified property id.
+ *
+ * Example:
+ *
+ * localizeProperty(
+ * ["chrome://global/locale/findbar.properties"], "FastFind");
+ *
+ * @param {Array.<string>} urls
+ * Array of .properties URLs.
+ * @param {string} id
+ * The ID of the property to retrieve the localized string for.
+ *
+ * @return {string}
+ * The localized string for the requested property.
+ */
+GeckoDriver.prototype.localizeProperty = function(cmd) {
+ let { urls, id } = cmd.parameters;
+
+ if (!Array.isArray(urls)) {
+ throw new error.InvalidArgumentError(
+ "Value of `urls` should be of type 'Array'"
+ );
+ }
+ if (typeof id != "string") {
+ throw new error.InvalidArgumentError(
+ "Value of `id` should be of type 'string'"
+ );
+ }
+
+ return l10n.localizeProperty(urls, id);
+};
+
+/**
+ * Initialize the reftest mode
+ */
+GeckoDriver.prototype.setupReftest = async function(cmd) {
+ if (this._reftest) {
+ throw new error.UnsupportedOperationError(
+ "Called reftest:setup with a reftest session already active"
+ );
+ }
+
+ let {
+ urlCount = {},
+ screenshot = "unexpected",
+ isPrint = false,
+ } = cmd.parameters;
+ if (!["always", "fail", "unexpected"].includes(screenshot)) {
+ throw new error.InvalidArgumentError(
+ "Value of `screenshot` should be 'always', 'fail' or 'unexpected'"
+ );
+ }
+
+ this._reftest = new reftest.Runner(this);
+ this._reftest.setup(urlCount, screenshot, isPrint);
+};
+
+/** Run a reftest. */
+GeckoDriver.prototype.runReftest = async function(cmd) {
+ let {
+ test,
+ references,
+ expected,
+ timeout,
+ width,
+ height,
+ pageRanges,
+ } = cmd.parameters;
+
+ if (!this._reftest) {
+ throw new error.UnsupportedOperationError(
+ "Called reftest:run before reftest:start"
+ );
+ }
+
+ assert.string(test);
+ assert.string(expected);
+ assert.array(references);
+
+ return {
+ value: await this._reftest.run(
+ test,
+ references,
+ expected,
+ timeout,
+ pageRanges,
+ width,
+ height
+ ),
+ };
+};
+
+/**
+ * End a reftest run.
+ *
+ * Closes the reftest window (without changing the current window handle),
+ * and removes cached canvases.
+ */
+GeckoDriver.prototype.teardownReftest = function() {
+ if (!this._reftest) {
+ throw new error.UnsupportedOperationError(
+ "Called reftest:teardown before reftest:start"
+ );
+ }
+
+ this._reftest.teardown();
+ this._reftest = null;
+};
+
+/**
+ * Print page as PDF.
+ *
+ * @param {boolean=} landscape
+ * Paper orientation. Defaults to false.
+ * @param {number=} margin.bottom
+ * Bottom margin in cm. Defaults to 1cm (~0.4 inches).
+ * @param {number=} margin.left
+ * Left margin in cm. Defaults to 1cm (~0.4 inches).
+ * @param {number=} margin.right
+ * Right margin in cm. Defaults to 1cm (~0.4 inches).
+ * @param {number=} margin.top
+ * Top margin in cm. Defaults to 1cm (~0.4 inches).
+ * @param {string=} pageRanges (not supported)
+ * Paper ranges to print, e.g., '1-5, 8, 11-13'.
+ * Defaults to the empty string, which means print all pages.
+ * @param {number=} page.height
+ * Paper height in cm. Defaults to US letter height (11 inches / 27.94cm)
+ * @param {number=} page.width
+ * Paper width in cm. Defaults to US letter width (8.5 inches / 21.59cm)
+ * @param {boolean=} shrinkToFit
+ * Whether or not to override page size as defined by CSS.
+ * Defaults to true, in which case the content will be scaled
+ * to fit the paper size.
+ * @param {boolean=} printBackground
+ * Print background graphics. Defaults to false.
+ * @param {number=} scale
+ * Scale of the webpage rendering. Defaults to 1.
+ *
+ * @return {string}
+ * Base64 encoded PDF representing printed document
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.print = async function(cmd) {
+ assert.content(this.context);
+ assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ const settings = print.addDefaultSettings(cmd.parameters);
+ for (let prop of ["top", "bottom", "left", "right"]) {
+ assert.positiveNumber(
+ settings.margin[prop],
+ pprint`margin.${prop} is not a positive number`
+ );
+ }
+ for (let prop of ["width", "height"]) {
+ assert.positiveNumber(
+ settings.page[prop],
+ pprint`page.${prop} is not a positive number`
+ );
+ }
+ assert.positiveNumber(
+ settings.scale,
+ `scale ${settings.scale} is not a positive number`
+ );
+ assert.that(
+ s => s >= print.minScaleValue && settings.scale <= print.maxScaleValue,
+ `scale ${settings.scale} is outside the range ${print.minScaleValue}-${print.maxScaleValue}`
+ )(settings.scale);
+ assert.boolean(settings.shrinkToFit);
+ assert.boolean(settings.landscape);
+ assert.boolean(settings.printBackground);
+
+ const linkedBrowser = this.curBrowser.tab.linkedBrowser;
+ const filePath = await print.printToFile(
+ linkedBrowser,
+ linkedBrowser.outerWindowID,
+ settings
+ );
+
+ // return all data as a base64 encoded string
+ let bytes;
+ const file = await OS.File.open(filePath);
+ try {
+ bytes = await file.read();
+ } finally {
+ file.close();
+ await OS.File.remove(filePath);
+ }
+
+ // Each UCS2 character has an upper byte of 0 and a lower byte matching
+ // the binary data
+ return {
+ value: btoa(String.fromCharCode.apply(null, bytes)),
+ };
+};
+
+GeckoDriver.prototype.commands = {
+ // Marionette service
+ "Marionette:AcceptConnections": GeckoDriver.prototype.acceptConnections,
+ "Marionette:GetContext": GeckoDriver.prototype.getContext,
+ "Marionette:GetScreenOrientation": GeckoDriver.prototype.getScreenOrientation,
+ "Marionette:GetWindowType": GeckoDriver.prototype.getWindowType,
+ "Marionette:Quit": GeckoDriver.prototype.quit,
+ "Marionette:SetContext": GeckoDriver.prototype.setContext,
+ "Marionette:SetScreenOrientation": GeckoDriver.prototype.setScreenOrientation,
+ "Marionette:SingleTap": GeckoDriver.prototype.singleTap,
+
+ // Addon service
+ "Addon:Install": GeckoDriver.prototype.installAddon,
+ "Addon:Uninstall": GeckoDriver.prototype.uninstallAddon,
+
+ // L10n service
+ "L10n:LocalizeEntity": GeckoDriver.prototype.localizeEntity,
+ "L10n:LocalizeProperty": GeckoDriver.prototype.localizeProperty,
+
+ // Reftest service
+ "reftest:setup": GeckoDriver.prototype.setupReftest,
+ "reftest:run": GeckoDriver.prototype.runReftest,
+ "reftest:teardown": GeckoDriver.prototype.teardownReftest,
+
+ // WebDriver service
+ "WebDriver:AcceptAlert": GeckoDriver.prototype.acceptDialog,
+ "WebDriver:AcceptDialog": GeckoDriver.prototype.acceptDialog, // deprecated, but used in geckodriver (see also bug 1495063)
+ "WebDriver:AddCookie": GeckoDriver.prototype.addCookie,
+ "WebDriver:Back": GeckoDriver.prototype.goBack,
+ "WebDriver:CloseChromeWindow": GeckoDriver.prototype.closeChromeWindow,
+ "WebDriver:CloseWindow": GeckoDriver.prototype.close,
+ "WebDriver:DeleteAllCookies": GeckoDriver.prototype.deleteAllCookies,
+ "WebDriver:DeleteCookie": GeckoDriver.prototype.deleteCookie,
+ "WebDriver:DeleteSession": GeckoDriver.prototype.deleteSession,
+ "WebDriver:DismissAlert": GeckoDriver.prototype.dismissDialog,
+ "WebDriver:ElementClear": GeckoDriver.prototype.clearElement,
+ "WebDriver:ElementClick": GeckoDriver.prototype.clickElement,
+ "WebDriver:ElementSendKeys": GeckoDriver.prototype.sendKeysToElement,
+ "WebDriver:ExecuteAsyncScript": GeckoDriver.prototype.executeAsyncScript,
+ "WebDriver:ExecuteScript": GeckoDriver.prototype.executeScript,
+ "WebDriver:FindElement": GeckoDriver.prototype.findElement,
+ "WebDriver:FindElements": GeckoDriver.prototype.findElements,
+ "WebDriver:Forward": GeckoDriver.prototype.goForward,
+ "WebDriver:FullscreenWindow": GeckoDriver.prototype.fullscreenWindow,
+ "WebDriver:GetActiveElement": GeckoDriver.prototype.getActiveElement,
+ "WebDriver:GetAlertText": GeckoDriver.prototype.getTextFromDialog,
+ "WebDriver:GetCapabilities": GeckoDriver.prototype.getSessionCapabilities,
+ "WebDriver:GetChromeWindowHandle":
+ GeckoDriver.prototype.getChromeWindowHandle,
+ "WebDriver:GetChromeWindowHandles":
+ GeckoDriver.prototype.getChromeWindowHandles,
+ "WebDriver:GetCookies": GeckoDriver.prototype.getCookies,
+ "WebDriver:GetCurrentChromeWindowHandle":
+ GeckoDriver.prototype.getChromeWindowHandle,
+ "WebDriver:GetCurrentURL": GeckoDriver.prototype.getCurrentUrl,
+ "WebDriver:GetElementAttribute": GeckoDriver.prototype.getElementAttribute,
+ "WebDriver:GetElementCSSValue":
+ GeckoDriver.prototype.getElementValueOfCssProperty,
+ "WebDriver:GetElementProperty": GeckoDriver.prototype.getElementProperty,
+ "WebDriver:GetElementRect": GeckoDriver.prototype.getElementRect,
+ "WebDriver:GetElementTagName": GeckoDriver.prototype.getElementTagName,
+ "WebDriver:GetElementText": GeckoDriver.prototype.getElementText,
+ "WebDriver:GetPageSource": GeckoDriver.prototype.getPageSource,
+ "WebDriver:GetTimeouts": GeckoDriver.prototype.getTimeouts,
+ "WebDriver:GetTitle": GeckoDriver.prototype.getTitle,
+ "WebDriver:GetWindowHandle": GeckoDriver.prototype.getWindowHandle,
+ "WebDriver:GetWindowHandles": GeckoDriver.prototype.getWindowHandles,
+ "WebDriver:GetWindowRect": GeckoDriver.prototype.getWindowRect,
+ "WebDriver:IsElementDisplayed": GeckoDriver.prototype.isElementDisplayed,
+ "WebDriver:IsElementEnabled": GeckoDriver.prototype.isElementEnabled,
+ "WebDriver:IsElementSelected": GeckoDriver.prototype.isElementSelected,
+ "WebDriver:MinimizeWindow": GeckoDriver.prototype.minimizeWindow,
+ "WebDriver:MaximizeWindow": GeckoDriver.prototype.maximizeWindow,
+ "WebDriver:Navigate": GeckoDriver.prototype.navigateTo,
+ "WebDriver:NewSession": GeckoDriver.prototype.newSession,
+ "WebDriver:NewWindow": GeckoDriver.prototype.newWindow,
+ "WebDriver:PerformActions": GeckoDriver.prototype.performActions,
+ "WebDriver:Print": GeckoDriver.prototype.print,
+ "WebDriver:Refresh": GeckoDriver.prototype.refresh,
+ "WebDriver:ReleaseActions": GeckoDriver.prototype.releaseActions,
+ "WebDriver:SendAlertText": GeckoDriver.prototype.sendKeysToDialog,
+ "WebDriver:SetTimeouts": GeckoDriver.prototype.setTimeouts,
+ "WebDriver:SetWindowRect": GeckoDriver.prototype.setWindowRect,
+ "WebDriver:SwitchToFrame": GeckoDriver.prototype.switchToFrame,
+ "WebDriver:SwitchToParentFrame": GeckoDriver.prototype.switchToParentFrame,
+ "WebDriver:SwitchToWindow": GeckoDriver.prototype.switchToWindow,
+ "WebDriver:TakeScreenshot": GeckoDriver.prototype.takeScreenshot,
+};
+
+function getWindowId(win) {
+ return win.docShell.browsingContext.id;
+}
+
+async function exitFullscreen(win) {
+ let cb;
+ // Use a timed promise to abort if no window manager is present
+ await new TimedPromise(
+ resolve => {
+ cb = new DebounceCallback(resolve);
+ win.addEventListener("sizemodechange", cb);
+ win.fullScreen = false;
+ },
+ { throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER }
+ );
+ win.removeEventListener("sizemodechange", cb);
+}
+
+async function restoreWindow(win) {
+ win.restore();
+ // Use a poll promise to abort if no window manager is present
+ await new PollPromise(
+ (resolve, reject) => {
+ if (WindowState.from(win.windowState) == WindowState.Normal) {
+ resolve();
+ } else {
+ reject();
+ }
+ },
+ { timeout: TIMEOUT_NO_WINDOW_MANAGER }
+ );
+}
diff --git a/testing/marionette/element.js b/testing/marionette/element.js
new file mode 100644
index 0000000000..0a22beb929
--- /dev/null
+++ b/testing/marionette/element.js
@@ -0,0 +1,1840 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+/* global XPCNativeWrapper */
+
+const EXPORTED_SYMBOLS = [
+ "ChromeWebElement",
+ "ContentWebElement",
+ "ContentWebFrame",
+ "ContentWebWindow",
+ "element",
+ "WebElement",
+];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ContentDOMReference: "resource://gre/modules/ContentDOMReference.jsm",
+
+ assert: "chrome://marionette/content/assert.js",
+ atom: "chrome://marionette/content/atom.js",
+ error: "chrome://marionette/content/error.js",
+ PollPromise: "chrome://marionette/content/sync.js",
+ pprint: "chrome://marionette/content/format.js",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "uuidGen",
+ "@mozilla.org/uuid-generator;1",
+ "nsIUUIDGenerator"
+);
+
+const ORDERED_NODE_ITERATOR_TYPE = 5;
+const FIRST_ORDERED_NODE_TYPE = 9;
+
+const ELEMENT_NODE = 1;
+const DOCUMENT_NODE = 9;
+
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+/** XUL elements that support checked property. */
+const XUL_CHECKED_ELS = new Set(["button", "checkbox", "toolbarbutton"]);
+
+/** XUL elements that support selected property. */
+const XUL_SELECTED_ELS = new Set([
+ "menu",
+ "menuitem",
+ "menuseparator",
+ "radio",
+ "richlistitem",
+ "tab",
+]);
+
+/**
+ * This module provides shared functionality for dealing with DOM-
+ * and web elements in Marionette.
+ *
+ * A web element is an abstraction used to identify an element when it
+ * is transported across the protocol, between remote- and local ends.
+ *
+ * Each element has an associated web element reference (a UUID) that
+ * uniquely identifies the the element across all browsing contexts. The
+ * web element reference for every element representing the same element
+ * is the same.
+ *
+ * The {@link element.Store} provides a mapping between web element
+ * references and DOM elements for each browsing context. It also provides
+ * functionality for looking up and retrieving elements.
+ *
+ * @namespace
+ */
+this.element = {};
+
+element.Strategy = {
+ ClassName: "class name",
+ Selector: "css selector",
+ ID: "id",
+ Name: "name",
+ LinkText: "link text",
+ PartialLinkText: "partial link text",
+ TagName: "tag name",
+ XPath: "xpath",
+};
+
+/**
+ * Stores known/seen elements and their associated web element
+ * references.
+ *
+ * Elements are added by calling {@link #add()} or {@link addAll()},
+ * and may be queried by their web element reference using {@link get()}.
+ *
+ * @class
+ * @memberof element
+ */
+element.Store = class {
+ constructor() {
+ this.els = {};
+ }
+
+ clear() {
+ this.els = {};
+ }
+
+ /**
+ * Make a collection of elements seen.
+ *
+ * The order of the returned web element references is guaranteed to
+ * match that of the collection passed in.
+ *
+ * @param {NodeList} els
+ * Sequence of elements to add to set of seen elements.
+ *
+ * @return {Array.<WebElement>}
+ * List of the web element references associated with each element
+ * from <var>els</var>.
+ */
+ addAll(els) {
+ let add = this.add.bind(this);
+ return [...els].map(add);
+ }
+
+ /**
+ * Make an element seen.
+ *
+ * @param {(Element|WindowProxy|XULElement)} el
+ * Element to add to set of seen elements.
+ *
+ * @return {WebElement}
+ * Web element reference associated with element.
+ *
+ * @throws {TypeError}
+ * If <var>el</var> is not an {@link Element} or a {@link XULElement}.
+ */
+ add(el) {
+ const isDOMElement = element.isDOMElement(el);
+ const isDOMWindow = element.isDOMWindow(el);
+ const isXULElement = element.isXULElement(el);
+ const context = element.isInXULDocument(el) ? "chrome" : "content";
+
+ if (!(isDOMElement || isDOMWindow || isXULElement)) {
+ throw new TypeError(
+ "Expected an element or WindowProxy, " + pprint`got: ${el}`
+ );
+ }
+
+ for (let i in this.els) {
+ let foundEl;
+ try {
+ foundEl = this.els[i].get();
+ } catch (e) {}
+
+ if (foundEl) {
+ if (new XPCNativeWrapper(foundEl) == new XPCNativeWrapper(el)) {
+ return WebElement.fromUUID(i, context);
+ }
+
+ // cleanup reference to gc'd element
+ } else {
+ delete this.els[i];
+ }
+ }
+
+ let webEl = WebElement.from(el);
+ this.els[webEl.uuid] = Cu.getWeakReference(el);
+ return webEl;
+ }
+
+ /**
+ * Determine if the provided web element reference has been seen
+ * before/is in the element store.
+ *
+ * Unlike when getting the element, a staleness check is not
+ * performed.
+ *
+ * @param {WebElement} webEl
+ * Element's associated web element reference.
+ *
+ * @return {boolean}
+ * True if element is in the store, false otherwise.
+ *
+ * @throws {TypeError}
+ * If <var>webEl</var> is not a {@link WebElement}.
+ */
+ has(webEl) {
+ if (!(webEl instanceof WebElement)) {
+ throw new TypeError(pprint`Expected web element, got: ${webEl}`);
+ }
+ return Object.keys(this.els).includes(webEl.uuid);
+ }
+
+ /**
+ * Retrieve a DOM {@link Element} or a {@link XULElement} by its
+ * unique {@link WebElement} reference.
+ *
+ * @param {WebElement} webEl
+ * Web element reference to find the associated {@link Element}
+ * of.
+ * @param {WindowProxy} win
+ * Current window global, which may differ from the associated
+ * window global of <var>el</var>.
+ *
+ * @returns {(Element|XULElement)}
+ * Element associated with reference.
+ *
+ * @throws {TypeError}
+ * If <var>webEl</var> is not a {@link WebElement}.
+ * @throws {NoSuchElementError}
+ * If the web element reference <var>uuid</var> has not been
+ * seen before.
+ * @throws {StaleElementReferenceError}
+ * If the element has gone stale, indicating it is no longer
+ * attached to the DOM, or its node document is no longer the
+ * active document.
+ */
+ get(webEl, win) {
+ if (!(webEl instanceof WebElement)) {
+ throw new TypeError(pprint`Expected web element, got: ${webEl}`);
+ }
+ if (!this.has(webEl)) {
+ throw new error.NoSuchElementError(
+ "Web element reference not seen before: " + webEl.uuid
+ );
+ }
+
+ let el;
+ let ref = this.els[webEl.uuid];
+ try {
+ el = ref.get();
+ } catch (e) {
+ delete this.els[webEl.uuid];
+ }
+
+ if (element.isStale(el, win)) {
+ throw new error.StaleElementReferenceError(
+ pprint`The element reference of ${el || webEl.uuid} is stale; ` +
+ "either the element is no longer attached to the DOM, " +
+ "it is not in the current frame context, " +
+ "or the document has been refreshed"
+ );
+ }
+
+ return el;
+ }
+};
+
+/**
+ * Stores known/seen web element references and their associated
+ * ContentDOMReference ElementIdentifiers.
+ *
+ * The ContentDOMReference ElementIdentifier is augmented with a WebElement
+ * reference, so in Marionette's IPC it looks like the following example:
+ *
+ * { browsingContextId: 9,
+ * id: 0.123,
+ * webElRef: {element-6066-11e4-a52e-4f735466cecf: <uuid>} }
+ *
+ * For use in parent process in conjunction with ContentDOMReference in content.
+ * Implements all `element.Store` methods for duck typing.
+ *
+ * @class
+ * @memberof element
+ */
+element.ReferenceStore = class {
+ constructor() {
+ // uuid -> { id, browsingContextId, webElRef }
+ this.refs = new Map();
+ // id -> webElRef
+ this.domRefs = new Map();
+ }
+
+ clear(browsingContext) {
+ if (!browsingContext) {
+ this.refs.clear();
+ this.domRefs.clear();
+ return;
+ }
+ for (const context of browsingContext.getAllBrowsingContextsInSubtree()) {
+ for (const [uuid, elId] of this.refs) {
+ if (elId.browsingContextId == context.id) {
+ this.refs.delete(uuid);
+ this.domRefs.delete(elId.id);
+ }
+ }
+ }
+ }
+
+ /**
+ * Make a collection of elements seen.
+ *
+ * The order of the returned web element references is guaranteed to
+ * match that of the collection passed in.
+ *
+ * @param {Array.<ElementIdentifer>} elIds
+ * Sequence of ids to add to set of seen elements.
+ *
+ * @return {Array.<WebElement>}
+ * List of the web element references associated with each element
+ * from <var>els</var>.
+ */
+ addAll(elIds) {
+ return [...elIds].map(elId => this.add(elId));
+ }
+
+ /**
+ * Make an element seen.
+ *
+ * @param {ElementIdentifier} elId
+ * {id, browsingContextId} to add to set of seen elements.
+ *
+ * @return {WebElement}
+ * Web element reference associated with element.
+ *
+ */
+ add(elId) {
+ if (!elId.id || !elId.browsingContextId) {
+ throw new TypeError(pprint`Expected ElementIdentifier, got: ${elId}`);
+ }
+ if (this.domRefs.has(elId.id)) {
+ return WebElement.fromJSON(this.domRefs.get(elId.id));
+ }
+ const webEl = WebElement.fromJSON(elId.webElRef);
+ this.refs.set(webEl.uuid, elId);
+ this.domRefs.set(elId.id, elId.webElRef);
+ return webEl;
+ }
+
+ /**
+ * Determine if the provided web element reference is in the store.
+ *
+ * Unlike when getting the element, a staleness check is not
+ * performed.
+ *
+ * @param {WebElement} webEl
+ * Element's associated web element reference.
+ *
+ * @return {boolean}
+ * True if element is in the store, false otherwise.
+ *
+ * @throws {TypeError}
+ * If <var>webEl</var> is not a {@link WebElement}.
+ */
+ has(webEl) {
+ if (!(webEl instanceof WebElement)) {
+ throw new TypeError(pprint`Expected web element, got: ${webEl}`);
+ }
+ return this.refs.has(webEl.uuid);
+ }
+
+ /**
+ * Retrieve a DOM {@link Element} or a {@link XULElement} by its
+ * unique {@link WebElement} reference.
+ *
+ * @param {WebElement} webEl
+ * Web element reference to find the associated {@link Element}
+ * of.
+ * @returns {ElementIdentifier}
+ * ContentDOMReference identifier
+ *
+ * @throws {TypeError}
+ * If <var>webEl</var> is not a {@link WebElement}.
+ * @throws {NoSuchElementError}
+ * If the web element reference <var>uuid</var> has not been
+ * seen before.
+ */
+ get(webEl) {
+ if (!(webEl instanceof WebElement)) {
+ throw new TypeError(pprint`Expected web element, got: ${webEl}`);
+ }
+ const elId = this.refs.get(webEl.uuid);
+ if (!elId) {
+ throw new error.NoSuchElementError(
+ "Web element reference not seen before: " + webEl.uuid
+ );
+ }
+
+ return elId;
+ }
+};
+
+/**
+ * Find a single element or a collection of elements starting at the
+ * document root or a given node.
+ *
+ * If |timeout| is above 0, an implicit search technique is used.
+ * This will wait for the duration of <var>timeout</var> for the
+ * element to appear in the DOM.
+ *
+ * See the {@link element.Strategy} enum for a full list of supported
+ * search strategies that can be passed to <var>strategy</var>.
+ *
+ * Available flags for <var>opts</var>:
+ *
+ * <dl>
+ * <dt><code>all</code>
+ * <dd>
+ * If true, a multi-element search selector is used and a sequence
+ * of elements will be returned. Otherwise a single element.
+ *
+ * <dt><code>timeout</code>
+ * <dd>
+ * Duration to wait before timing out the search. If <code>all</code>
+ * is false, a {@link NoSuchElementError} is thrown if unable to
+ * find the element within the timeout duration.
+ *
+ * <dt><code>startNode</code>
+ * <dd>Element to use as the root of the search.
+ *
+ * @param {Object.<string, WindowProxy>} container
+ * Window object.
+ * @param {string} strategy
+ * Search strategy whereby to locate the element(s).
+ * @param {string} selector
+ * Selector search pattern. The selector must be compatible with
+ * the chosen search <var>strategy</var>.
+ * @param {Object.<string, ?>} opts
+ * Options.
+ *
+ * @return {Promise.<(Element|Array.<Element>)>}
+ * Single element or a sequence of elements.
+ *
+ * @throws InvalidSelectorError
+ * If <var>strategy</var> is unknown.
+ * @throws InvalidSelectorError
+ * If <var>selector</var> is malformed.
+ * @throws NoSuchElementError
+ * If a single element is requested, this error will throw if the
+ * element is not found.
+ */
+element.find = function(container, strategy, selector, opts = {}) {
+ let all = !!opts.all;
+ let timeout = opts.timeout || 0;
+ let startNode = opts.startNode;
+
+ let searchFn;
+ if (opts.all) {
+ searchFn = findElements.bind(this);
+ } else {
+ searchFn = findElement.bind(this);
+ }
+
+ return new Promise((resolve, reject) => {
+ let findElements = new PollPromise(
+ (resolve, reject) => {
+ let res = find_(container, strategy, selector, searchFn, {
+ all,
+ startNode,
+ });
+ if (res.length > 0) {
+ resolve(Array.from(res));
+ } else {
+ reject([]);
+ }
+ },
+ { timeout }
+ );
+
+ findElements.then(foundEls => {
+ // the following code ought to be moved into findElement
+ // and findElements when bug 1254486 is addressed
+ if (!opts.all && (!foundEls || foundEls.length == 0)) {
+ let msg = `Unable to locate element: ${selector}`;
+ reject(new error.NoSuchElementError(msg));
+ }
+
+ if (opts.all) {
+ resolve(foundEls);
+ }
+ resolve(foundEls[0]);
+ }, reject);
+ });
+};
+
+function find_(
+ container,
+ strategy,
+ selector,
+ searchFn,
+ { startNode = null, all = false } = {}
+) {
+ let rootNode = container.frame.document;
+
+ if (!startNode) {
+ startNode = rootNode;
+ }
+
+ let res;
+ try {
+ res = searchFn(strategy, selector, rootNode, startNode);
+ } catch (e) {
+ throw new error.InvalidSelectorError(
+ `Given ${strategy} expression "${selector}" is invalid: ${e}`
+ );
+ }
+
+ if (res) {
+ if (all) {
+ return res;
+ }
+ return [res];
+ }
+ return [];
+}
+
+/**
+ * Find a single element by XPath expression.
+ *
+ * @param {HTMLDocument} document
+ * Document root.
+ * @param {Element} startNode
+ * Where in the DOM hiearchy to begin searching.
+ * @param {string} expression
+ * XPath search expression.
+ *
+ * @return {Node}
+ * First element matching <var>expression</var>.
+ */
+element.findByXPath = function(document, startNode, expression) {
+ let iter = document.evaluate(
+ expression,
+ startNode,
+ null,
+ FIRST_ORDERED_NODE_TYPE,
+ null
+ );
+ return iter.singleNodeValue;
+};
+
+/**
+ * Find elements by XPath expression.
+ *
+ * @param {HTMLDocument} document
+ * Document root.
+ * @param {Element} startNode
+ * Where in the DOM hierarchy to begin searching.
+ * @param {string} expression
+ * XPath search expression.
+ *
+ * @return {Iterable.<Node>}
+ * Iterator over elements matching <var>expression</var>.
+ */
+element.findByXPathAll = function*(document, startNode, expression) {
+ let iter = document.evaluate(
+ expression,
+ startNode,
+ null,
+ ORDERED_NODE_ITERATOR_TYPE,
+ null
+ );
+ let el = iter.iterateNext();
+ while (el) {
+ yield el;
+ el = iter.iterateNext();
+ }
+};
+
+/**
+ * Find all hyperlinks descendant of <var>startNode</var> which
+ * link text is <var>linkText</var>.
+ *
+ * @param {Element} startNode
+ * Where in the DOM hierarchy to begin searching.
+ * @param {string} linkText
+ * Link text to search for.
+ *
+ * @return {Iterable.<HTMLAnchorElement>}
+ * Sequence of link elements which text is <var>s</var>.
+ */
+element.findByLinkText = function(startNode, linkText) {
+ return filterLinks(
+ startNode,
+ link => atom.getElementText(link).trim() === linkText
+ );
+};
+
+/**
+ * Find all hyperlinks descendant of <var>startNode</var> which
+ * link text contains <var>linkText</var>.
+ *
+ * @param {Element} startNode
+ * Where in the DOM hierachy to begin searching.
+ * @param {string} linkText
+ * Link text to search for.
+ *
+ * @return {Iterable.<HTMLAnchorElement>}
+ * Iterator of link elements which text containins
+ * <var>linkText</var>.
+ */
+element.findByPartialLinkText = function(startNode, linkText) {
+ return filterLinks(startNode, link =>
+ atom.getElementText(link).includes(linkText)
+ );
+};
+
+/**
+ * Filters all hyperlinks that are descendant of <var>startNode</var>
+ * by <var>predicate</var>.
+ *
+ * @param {Element} startNode
+ * Where in the DOM hierarchy to begin searching.
+ * @param {function(HTMLAnchorElement): boolean} predicate
+ * Function that determines if given link should be included in
+ * return value or filtered away.
+ *
+ * @return {Iterable.<HTMLAnchorElement>}
+ * Iterator of link elements matching <var>predicate</var>.
+ */
+function* filterLinks(startNode, predicate) {
+ for (let link of startNode.getElementsByTagName("a")) {
+ if (predicate(link)) {
+ yield link;
+ }
+ }
+}
+
+/**
+ * Finds a single element.
+ *
+ * @param {element.Strategy} strategy
+ * Selector strategy to use.
+ * @param {string} selector
+ * Selector expression.
+ * @param {HTMLDocument} document
+ * Document root.
+ * @param {Element=} startNode
+ * Optional node from which to start searching.
+ *
+ * @return {Element}
+ * Found elements.
+ *
+ * @throws {InvalidSelectorError}
+ * If strategy <var>using</var> is not recognised.
+ * @throws {Error}
+ * If selector expression <var>selector</var> is malformed.
+ */
+function findElement(strategy, selector, document, startNode = undefined) {
+ switch (strategy) {
+ case element.Strategy.ID: {
+ if (startNode.getElementById) {
+ return startNode.getElementById(selector);
+ }
+ let expr = `.//*[@id="${selector}"]`;
+ return element.findByXPath(document, startNode, expr);
+ }
+
+ case element.Strategy.Name: {
+ if (startNode.getElementsByName) {
+ return startNode.getElementsByName(selector)[0];
+ }
+ let expr = `.//*[@name="${selector}"]`;
+ return element.findByXPath(document, startNode, expr);
+ }
+
+ case element.Strategy.ClassName:
+ return startNode.getElementsByClassName(selector)[0];
+
+ case element.Strategy.TagName:
+ return startNode.getElementsByTagName(selector)[0];
+
+ case element.Strategy.XPath:
+ return element.findByXPath(document, startNode, selector);
+
+ case element.Strategy.LinkText:
+ for (let link of startNode.getElementsByTagName("a")) {
+ if (atom.getElementText(link).trim() === selector) {
+ return link;
+ }
+ }
+ return undefined;
+
+ case element.Strategy.PartialLinkText:
+ for (let link of startNode.getElementsByTagName("a")) {
+ if (atom.getElementText(link).includes(selector)) {
+ return link;
+ }
+ }
+ return undefined;
+
+ case element.Strategy.Selector:
+ try {
+ return startNode.querySelector(selector);
+ } catch (e) {
+ throw new error.InvalidSelectorError(`${e.message}: "${selector}"`);
+ }
+ }
+
+ throw new error.InvalidSelectorError(`No such strategy: ${strategy}`);
+}
+
+/**
+ * Find multiple elements.
+ *
+ * @param {element.Strategy} strategy
+ * Selector strategy to use.
+ * @param {string} selector
+ * Selector expression.
+ * @param {HTMLDocument} document
+ * Document root.
+ * @param {Element=} startNode
+ * Optional node from which to start searching.
+ *
+ * @return {Array.<Element>}
+ * Found elements.
+ *
+ * @throws {InvalidSelectorError}
+ * If strategy <var>strategy</var> is not recognised.
+ * @throws {Error}
+ * If selector expression <var>selector</var> is malformed.
+ */
+function findElements(strategy, selector, document, startNode = undefined) {
+ switch (strategy) {
+ case element.Strategy.ID:
+ selector = `.//*[@id="${selector}"]`;
+
+ // fall through
+ case element.Strategy.XPath:
+ return [...element.findByXPathAll(document, startNode, selector)];
+
+ case element.Strategy.Name:
+ if (startNode.getElementsByName) {
+ return startNode.getElementsByName(selector);
+ }
+ return [
+ ...element.findByXPathAll(
+ document,
+ startNode,
+ `.//*[@name="${selector}"]`
+ ),
+ ];
+
+ case element.Strategy.ClassName:
+ return startNode.getElementsByClassName(selector);
+
+ case element.Strategy.TagName:
+ return startNode.getElementsByTagName(selector);
+
+ case element.Strategy.LinkText:
+ return [...element.findByLinkText(startNode, selector)];
+
+ case element.Strategy.PartialLinkText:
+ return [...element.findByPartialLinkText(startNode, selector)];
+
+ case element.Strategy.Selector:
+ return startNode.querySelectorAll(selector);
+
+ default:
+ throw new error.InvalidSelectorError(`No such strategy: ${strategy}`);
+ }
+}
+
+/**
+ * Finds the closest parent node of <var>startNode</var> by CSS a
+ * <var>selector</var> expression.
+ *
+ * @param {Node} startNode
+ * Cyce through <var>startNode</var>'s parent nodes in tree-order
+ * and return the first match to <var>selector</var>.
+ * @param {string} selector
+ * CSS selector expression.
+ *
+ * @return {Node=}
+ * First match to <var>selector</var>, or null if no match was found.
+ */
+element.findClosest = function(startNode, selector) {
+ let node = startNode;
+ while (node.parentNode && node.parentNode.nodeType == ELEMENT_NODE) {
+ node = node.parentNode;
+ if (node.matches(selector)) {
+ return node;
+ }
+ }
+ return null;
+};
+
+/**
+ * Wrapper around ContentDOMReference.get with additional steps specific to
+ * Marionette.
+ *
+ * @param {Element} el
+ * The DOM element to generate the identifier for.
+ *
+ * @return {object} The ContentDOMReference ElementIdentifier for the DOM
+ * element augmented with a Marionette WebElement reference.
+ */
+element.getElementId = function(el) {
+ const id = ContentDOMReference.get(el);
+ const webEl = WebElement.from(el);
+ id.webElRef = webEl.toJSON();
+ return id;
+};
+
+/**
+ * Wrapper around ContentDOMReference.resolve with additional error handling
+ * specific to Marionette.
+ *
+ * @param {ElementIdentifier} id
+ * The identifier generated via ContentDOMReference.get for a DOM element.
+ *
+ * @param {WindowProxy} win
+ * Current window, which may differ from the associated
+ * window of <var>el</var>.
+ *
+ * @return {Element} The DOM element that the identifier was generated for, or
+ * null if the element does not still exist.
+ *
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> doesn't exist
+ * in the current browsing context.
+ * @throws {StaleElementReferenceError}
+ * If the element has gone stale, indicating it is no longer
+ * attached to the DOM, or its node document is no longer the
+ * active document.
+ */
+element.resolveElement = function(id, win) {
+ // Don't allow elements whose browsing context differs from the current one.
+ if (id.browsingContextId != win?.browsingContext.id) {
+ throw new error.NoSuchElementError(
+ `Web element reference not seen before: ${JSON.stringify(id.webElRef)}`
+ );
+ }
+
+ const el = ContentDOMReference.resolve(id);
+
+ if (element.isStale(el, win)) {
+ throw new error.StaleElementReferenceError(
+ pprint`The element reference of ${el || JSON.stringify(id.webElRef)} ` +
+ "is stale; either the element is no longer attached to the DOM, " +
+ "it is not in the current frame context, " +
+ "or the document has been refreshed"
+ );
+ }
+ return el;
+};
+
+/**
+ * Determines if <var>obj<var> is an HTML or JS collection.
+ *
+ * @param {*} seq
+ * Type to determine.
+ *
+ * @return {boolean}
+ * True if <var>seq</va> is collection.
+ */
+element.isCollection = function(seq) {
+ switch (Object.prototype.toString.call(seq)) {
+ case "[object Arguments]":
+ case "[object Array]":
+ case "[object FileList]":
+ case "[object HTMLAllCollection]":
+ case "[object HTMLCollection]":
+ case "[object HTMLFormControlsCollection]":
+ case "[object HTMLOptionsCollection]":
+ case "[object NodeList]":
+ return true;
+
+ default:
+ return false;
+ }
+};
+
+/**
+ * Determines if <var>el</var> is stale.
+ *
+ * A stale element is an element no longer attached to the DOM or which
+ * node document is not the active document of the current browsing
+ * context.
+ *
+ * The currently selected browsing context, specified through
+ * <var>win<var>, is a WebDriver concept defining the target
+ * against which commands will run. As the current browsing context
+ * may differ from <var>el</var>'s associated context, an element is
+ * considered stale even if it is connected to a living (not discarded)
+ * browsing context such as an <tt>&lt;iframe&gt;</tt>.
+ *
+ * @param {Element=} el
+ * DOM element to check for staleness. If null, which may be
+ * the case if the element has been unwrapped from a weak
+ * reference, it is always considered stale.
+ * @param {WindowProxy=} win
+ * Current window global, which may differ from the associated
+ * window global of <var>el</var>. When retrieving XUL
+ * elements, this is optional.
+ *
+ * @return {boolean}
+ * True if <var>el</var> is stale, false otherwise.
+ */
+element.isStale = function(el, win = undefined) {
+ if (typeof win == "undefined") {
+ win = el.ownerGlobal;
+ }
+ if (el === null || !el.ownerGlobal || el.ownerDocument !== win.document) {
+ return true;
+ }
+
+ return !el.isConnected;
+};
+
+/**
+ * Determine if <var>el</var> is selected or not.
+ *
+ * This operation only makes sense on
+ * <tt>&lt;input type=checkbox&gt;</tt>,
+ * <tt>&lt;input type=radio&gt;</tt>,
+ * and <tt>&gt;option&gt;</tt> elements.
+ *
+ * @param {(DOMElement|XULElement)} el
+ * Element to test if selected.
+ *
+ * @return {boolean}
+ * True if element is selected, false otherwise.
+ */
+element.isSelected = function(el) {
+ if (!el) {
+ return false;
+ }
+
+ if (element.isXULElement(el)) {
+ if (XUL_CHECKED_ELS.has(el.tagName)) {
+ return el.checked;
+ } else if (XUL_SELECTED_ELS.has(el.tagName)) {
+ return el.selected;
+ }
+ } else if (element.isDOMElement(el)) {
+ if (el.localName == "input" && ["checkbox", "radio"].includes(el.type)) {
+ return el.checked;
+ } else if (el.localName == "option") {
+ return el.selected;
+ }
+ }
+
+ return false;
+};
+
+/**
+ * An element is considered read only if it is an
+ * <code>&lt;input&gt;</code> or <code>&lt;textarea&gt;</code>
+ * element whose <code>readOnly</code> content IDL attribute is set.
+ *
+ * @param {Element} el
+ * Element to test is read only.
+ *
+ * @return {boolean}
+ * True if element is read only.
+ */
+element.isReadOnly = function(el) {
+ return (
+ element.isDOMElement(el) &&
+ ["input", "textarea"].includes(el.localName) &&
+ el.readOnly
+ );
+};
+
+/**
+ * An element is considered disabled if it is a an element
+ * that can be disabled, or it belongs to a container group which
+ * <code>disabled</code> content IDL attribute affects it.
+ *
+ * @param {Element} el
+ * Element to test for disabledness.
+ *
+ * @return {boolean}
+ * True if element, or its container group, is disabled.
+ */
+element.isDisabled = function(el) {
+ if (!element.isDOMElement(el)) {
+ return false;
+ }
+
+ switch (el.localName) {
+ case "option":
+ case "optgroup":
+ if (el.disabled) {
+ return true;
+ }
+ let parent = element.findClosest(el, "optgroup,select");
+ return element.isDisabled(parent);
+
+ case "button":
+ case "input":
+ case "select":
+ case "textarea":
+ return el.disabled;
+
+ default:
+ return false;
+ }
+};
+
+/**
+ * Denotes elements that can be used for typing and clearing.
+ *
+ * Elements that are considered WebDriver-editable are non-readonly
+ * and non-disabled <code>&lt;input&gt;</code> elements in the Text,
+ * Search, URL, Telephone, Email, Password, Date, Month, Date and
+ * Time Local, Number, Range, Color, and File Upload states, and
+ * <code>&lt;textarea&gt;</code> elements.
+ *
+ * @param {Element} el
+ * Element to test.
+ *
+ * @return {boolean}
+ * True if editable, false otherwise.
+ */
+element.isMutableFormControl = function(el) {
+ if (!element.isDOMElement(el)) {
+ return false;
+ }
+ if (element.isReadOnly(el) || element.isDisabled(el)) {
+ return false;
+ }
+
+ if (el.localName == "textarea") {
+ return true;
+ }
+
+ if (el.localName != "input") {
+ return false;
+ }
+
+ switch (el.type) {
+ case "color":
+ case "date":
+ case "datetime-local":
+ case "email":
+ case "file":
+ case "month":
+ case "number":
+ case "password":
+ case "range":
+ case "search":
+ case "tel":
+ case "text":
+ case "time":
+ case "url":
+ case "week":
+ return true;
+
+ default:
+ return false;
+ }
+};
+
+/**
+ * An editing host is a node that is either an HTML element with a
+ * <code>contenteditable</code> attribute, or the HTML element child
+ * of a document whose <code>designMode</code> is enabled.
+ *
+ * @param {Element} el
+ * Element to determine if is an editing host.
+ *
+ * @return {boolean}
+ * True if editing host, false otherwise.
+ */
+element.isEditingHost = function(el) {
+ return (
+ element.isDOMElement(el) &&
+ (el.isContentEditable || el.ownerDocument.designMode == "on")
+ );
+};
+
+/**
+ * Determines if an element is editable according to WebDriver.
+ *
+ * An element is considered editable if it is not read-only or
+ * disabled, and one of the following conditions are met:
+ *
+ * <ul>
+ * <li>It is a <code>&lt;textarea&gt;</code> element.
+ *
+ * <li>It is an <code>&lt;input&gt;</code> element that is not of
+ * the <code>checkbox</code>, <code>radio</code>, <code>hidden</code>,
+ * <code>submit</code>, <code>button</code>, or <code>image</code> types.
+ *
+ * <li>It is content-editable.
+ *
+ * <li>It belongs to a document in design mode.
+ * </ul>
+ *
+ * @param {Element}
+ * Element to test if editable.
+ *
+ * @return {boolean}
+ * True if editable, false otherwise.
+ */
+element.isEditable = function(el) {
+ if (!element.isDOMElement(el)) {
+ return false;
+ }
+
+ if (element.isReadOnly(el) || element.isDisabled(el)) {
+ return false;
+ }
+
+ return element.isMutableFormControl(el) || element.isEditingHost(el);
+};
+
+/**
+ * This function generates a pair of coordinates relative to the viewport
+ * given a target element and coordinates relative to that element's
+ * top-left corner.
+ *
+ * @param {Node} node
+ * Target node.
+ * @param {number=} xOffset
+ * Horizontal offset relative to target's top-left corner.
+ * Defaults to the centre of the target's bounding box.
+ * @param {number=} yOffset
+ * Vertical offset relative to target's top-left corner. Defaults to
+ * the centre of the target's bounding box.
+ *
+ * @return {Object.<string, number>}
+ * X- and Y coordinates.
+ *
+ * @throws TypeError
+ * If <var>xOffset</var> or <var>yOffset</var> are not numbers.
+ */
+element.coordinates = function(node, xOffset = undefined, yOffset = undefined) {
+ let box = node.getBoundingClientRect();
+
+ if (typeof xOffset == "undefined" || xOffset === null) {
+ xOffset = box.width / 2.0;
+ }
+ if (typeof yOffset == "undefined" || yOffset === null) {
+ yOffset = box.height / 2.0;
+ }
+
+ if (typeof yOffset != "number" || typeof xOffset != "number") {
+ throw new TypeError("Offset must be a number");
+ }
+
+ return {
+ x: box.left + xOffset,
+ y: box.top + yOffset,
+ };
+};
+
+/**
+ * This function returns true if the node is in the viewport.
+ *
+ * @param {Element} el
+ * Target element.
+ * @param {number=} x
+ * Horizontal offset relative to target. Defaults to the centre of
+ * the target's bounding box.
+ * @param {number=} y
+ * Vertical offset relative to target. Defaults to the centre of
+ * the target's bounding box.
+ *
+ * @return {boolean}
+ * True if if <var>el</var> is in viewport, false otherwise.
+ */
+element.inViewport = function(el, x = undefined, y = undefined) {
+ let win = el.ownerGlobal;
+ let c = element.coordinates(el, x, y);
+ let vp = {
+ top: win.pageYOffset,
+ left: win.pageXOffset,
+ bottom: win.pageYOffset + win.innerHeight,
+ right: win.pageXOffset + win.innerWidth,
+ };
+
+ return (
+ vp.left <= c.x + win.pageXOffset &&
+ c.x + win.pageXOffset <= vp.right &&
+ vp.top <= c.y + win.pageYOffset &&
+ c.y + win.pageYOffset <= vp.bottom
+ );
+};
+
+/**
+ * Gets the element's container element.
+ *
+ * An element container is defined by the WebDriver
+ * specification to be an <tt>&lt;option&gt;</tt> element in a
+ * <a href="https://html.spec.whatwg.org/#concept-element-contexts">valid
+ * element context</a>, meaning that it has an ancestral element
+ * that is either <tt>&lt;datalist&gt;</tt> or <tt>&lt;select&gt;</tt>.
+ *
+ * If the element does not have a valid context, its container element
+ * is itself.
+ *
+ * @param {Element} el
+ * Element to get the container of.
+ *
+ * @return {Element}
+ * Container element of <var>el</var>.
+ */
+element.getContainer = function(el) {
+ // Does <option> or <optgroup> have a valid context,
+ // meaning is it a child of <datalist> or <select>?
+ if (["option", "optgroup"].includes(el.localName)) {
+ return element.findClosest(el, "datalist,select") || el;
+ }
+
+ return el;
+};
+
+/**
+ * An element is in view if it is a member of its own pointer-interactable
+ * paint tree.
+ *
+ * This means an element is considered to be in view, but not necessarily
+ * pointer-interactable, if it is found somewhere in the
+ * <code>elementsFromPoint</code> list at <var>el</var>'s in-view
+ * centre coordinates.
+ *
+ * Before running the check, we change <var>el</var>'s pointerEvents
+ * style property to "auto", since elements without pointer events
+ * enabled do not turn up in the paint tree we get from
+ * document.elementsFromPoint. This is a specialisation that is only
+ * relevant when checking if the element is in view.
+ *
+ * @param {Element} el
+ * Element to check if is in view.
+ *
+ * @return {boolean}
+ * True if <var>el</var> is inside the viewport, or false otherwise.
+ */
+element.isInView = function(el) {
+ let originalPointerEvents = el.style.pointerEvents;
+
+ try {
+ el.style.pointerEvents = "auto";
+ const tree = element.getPointerInteractablePaintTree(el);
+
+ // Bug 1413493 - <tr> is not part of the returned paint tree yet. As
+ // workaround check the visibility based on the first contained cell.
+ if (el.localName === "tr" && el.cells && el.cells.length > 0) {
+ return tree.includes(el.cells[0]);
+ }
+
+ return tree.includes(el);
+ } finally {
+ el.style.pointerEvents = originalPointerEvents;
+ }
+};
+
+/**
+ * This function throws the visibility of the element error if the element is
+ * not displayed or the given coordinates are not within the viewport.
+ *
+ * @param {Element} el
+ * Element to check if visible.
+ * @param {number=} x
+ * Horizontal offset relative to target. Defaults to the centre of
+ * the target's bounding box.
+ * @param {number=} y
+ * Vertical offset relative to target. Defaults to the centre of
+ * the target's bounding box.
+ *
+ * @return {boolean}
+ * True if visible, false otherwise.
+ */
+element.isVisible = function(el, x = undefined, y = undefined) {
+ let win = el.ownerGlobal;
+
+ if (!atom.isElementDisplayed(el, win)) {
+ return false;
+ }
+
+ if (el.tagName.toLowerCase() == "body") {
+ return true;
+ }
+
+ if (!element.inViewport(el, x, y)) {
+ element.scrollIntoView(el);
+ if (!element.inViewport(el)) {
+ return false;
+ }
+ }
+ return true;
+};
+
+/**
+ * A pointer-interactable element is defined to be the first
+ * non-transparent element, defined by the paint order found at the centre
+ * point of its rectangle that is inside the viewport, excluding the size
+ * of any rendered scrollbars.
+ *
+ * An element is obscured if the pointer-interactable paint tree at its
+ * centre point is empty, or the first element in this tree is not an
+ * inclusive descendant of itself.
+ *
+ * @param {DOMElement} el
+ * Element determine if is pointer-interactable.
+ *
+ * @return {boolean}
+ * True if element is obscured, false otherwise.
+ */
+element.isObscured = function(el) {
+ let tree = element.getPointerInteractablePaintTree(el);
+ return !el.contains(tree[0]);
+};
+
+// TODO(ato): Only used by deprecated action API
+// https://bugzil.la/1354578
+/**
+ * Calculates the in-view centre point of an element's client rect.
+ *
+ * The portion of an element that is said to be _in view_, is the
+ * intersection of two squares: the first square being the initial
+ * viewport, and the second a DOM element. From this square we
+ * calculate the in-view _centre point_ and convert it into CSS pixels.
+ *
+ * Although Gecko's system internals allow click points to be
+ * given in floating point precision, the DOM operates in CSS pixels.
+ * When the in-view centre point is later used to retrieve a coordinate's
+ * paint tree, we need to ensure to operate in the same language.
+ *
+ * As a word of warning, there appears to be inconsistencies between
+ * how `DOMElement.elementsFromPoint` and `DOMWindowUtils.sendMouseEvent`
+ * internally rounds (ceils/floors) coordinates.
+ *
+ * @param {DOMRect} rect
+ * Element off a DOMRect sequence produced by calling
+ * `getClientRects` on an {@link Element}.
+ * @param {WindowProxy} win
+ * Current window global.
+ *
+ * @return {Map.<string, number>}
+ * X and Y coordinates that denotes the in-view centre point of
+ * `rect`.
+ */
+element.getInViewCentrePoint = function(rect, win) {
+ const { floor, max, min } = Math;
+
+ // calculate the intersection of the rect that is inside the viewport
+ let visible = {
+ left: max(0, min(rect.x, rect.x + rect.width)),
+ right: min(win.innerWidth, max(rect.x, rect.x + rect.width)),
+ top: max(0, min(rect.y, rect.y + rect.height)),
+ bottom: min(win.innerHeight, max(rect.y, rect.y + rect.height)),
+ };
+
+ // arrive at the centre point of the visible rectangle
+ let x = (visible.left + visible.right) / 2.0;
+ let y = (visible.top + visible.bottom) / 2.0;
+
+ // convert to CSS pixels, as centre point can be float
+ x = floor(x);
+ y = floor(y);
+
+ return { x, y };
+};
+
+/**
+ * Produces a pointer-interactable elements tree from a given element.
+ *
+ * The tree is defined by the paint order found at the centre point of
+ * the element's rectangle that is inside the viewport, excluding the size
+ * of any rendered scrollbars.
+ *
+ * @param {DOMElement} el
+ * Element to determine if is pointer-interactable.
+ *
+ * @return {Array.<DOMElement>}
+ * Sequence of elements in paint order.
+ */
+element.getPointerInteractablePaintTree = function(el) {
+ const doc = el.ownerDocument;
+ const win = doc.defaultView;
+ const rootNode = el.getRootNode();
+
+ // pointer-interactable elements tree, step 1
+ if (!el.isConnected) {
+ return [];
+ }
+
+ // steps 2-3
+ let rects = el.getClientRects();
+ if (rects.length == 0) {
+ return [];
+ }
+
+ // step 4
+ let centre = element.getInViewCentrePoint(rects[0], win);
+
+ // step 5
+ return rootNode.elementsFromPoint(centre.x, centre.y);
+};
+
+// TODO(ato): Not implemented.
+// In fact, it's not defined in the spec.
+element.isKeyboardInteractable = () => true;
+
+/**
+ * Attempts to scroll into view |el|.
+ *
+ * @param {DOMElement} el
+ * Element to scroll into view.
+ */
+element.scrollIntoView = function(el) {
+ if (el.scrollIntoView) {
+ el.scrollIntoView({ block: "end", inline: "nearest", behavior: "instant" });
+ }
+};
+
+/**
+ * Ascertains whether <var>node</var> is a DOM-, SVG-, or XUL element.
+ *
+ * @param {*} node
+ * Element thought to be an <code>Element</code> or
+ * <code>XULElement</code>.
+ *
+ * @return {boolean}
+ * True if <var>node</var> is an element, false otherwise.
+ */
+element.isElement = function(node) {
+ return element.isDOMElement(node) || element.isXULElement(node);
+};
+
+/**
+ * Ascertains whether <var>node</var> is a DOM element.
+ *
+ * @param {*} node
+ * Element thought to be an <code>Element</code>.
+ *
+ * @return {boolean}
+ * True if <var>node</var> is a DOM element, false otherwise.
+ */
+element.isDOMElement = function(node) {
+ return (
+ typeof node == "object" &&
+ node !== null &&
+ "nodeType" in node &&
+ [ELEMENT_NODE, DOCUMENT_NODE].includes(node.nodeType) &&
+ !element.isXULElement(node)
+ );
+};
+
+/**
+ * Ascertains whether <var>node</var> is a XUL element.
+ *
+ * @param {*} node
+ * Element to check
+ *
+ * @return {boolean}
+ * True if <var>node</var> is a XULElement,
+ * false otherwise.
+ */
+element.isXULElement = function(node) {
+ return (
+ typeof node == "object" &&
+ node !== null &&
+ "nodeType" in node &&
+ node.nodeType === node.ELEMENT_NODE &&
+ node.namespaceURI === XUL_NS
+ );
+};
+
+/**
+ * Ascertains whether <var>node</var> is in a XUL document.
+ *
+ * @param {*} node
+ * Element to check
+ *
+ * @return {boolean}
+ * True if <var>node</var> is in a XUL document,
+ * false otherwise.
+ */
+element.isInXULDocument = function(node) {
+ return (
+ typeof node == "object" &&
+ node !== null &&
+ "ownerDocument" in node &&
+ node.ownerDocument.documentElement.namespaceURI === XUL_NS
+ );
+};
+
+/**
+ * Ascertains whether <var>node</var> is a <code>WindowProxy</code>.
+ *
+ * @param {*} node
+ * Node thought to be a <code>WindowProxy</code>.
+ *
+ * @return {boolean}
+ * True if <var>node</var> is a DOM window.
+ */
+element.isDOMWindow = function(node) {
+ // TODO(ato): This should use Object.prototype.toString.call(node)
+ // but it's not clear how to write a good xpcshell test for that,
+ // seeing as we stub out a WindowProxy.
+ return (
+ typeof node == "object" &&
+ node !== null &&
+ typeof node.toString == "function" &&
+ node.toString() == "[object Window]" &&
+ node.self === node
+ );
+};
+
+const boolEls = {
+ audio: ["autoplay", "controls", "loop", "muted"],
+ button: ["autofocus", "disabled", "formnovalidate"],
+ details: ["open"],
+ dialog: ["open"],
+ fieldset: ["disabled"],
+ form: ["novalidate"],
+ iframe: ["allowfullscreen"],
+ img: ["ismap"],
+ input: [
+ "autofocus",
+ "checked",
+ "disabled",
+ "formnovalidate",
+ "multiple",
+ "readonly",
+ "required",
+ ],
+ keygen: ["autofocus", "disabled"],
+ menuitem: ["checked", "default", "disabled"],
+ ol: ["reversed"],
+ optgroup: ["disabled"],
+ option: ["disabled", "selected"],
+ script: ["async", "defer"],
+ select: ["autofocus", "disabled", "multiple", "required"],
+ textarea: ["autofocus", "disabled", "readonly", "required"],
+ track: ["default"],
+ video: ["autoplay", "controls", "loop", "muted"],
+};
+
+/**
+ * Tests if the attribute is a boolean attribute on element.
+ *
+ * @param {DOMElement} el
+ * Element to test if <var>attr</var> is a boolean attribute on.
+ * @param {string} attr
+ * Attribute to test is a boolean attribute.
+ *
+ * @return {boolean}
+ * True if the attribute is boolean, false otherwise.
+ */
+element.isBooleanAttribute = function(el, attr) {
+ if (!element.isDOMElement(el)) {
+ return false;
+ }
+
+ // global boolean attributes that apply to all HTML elements,
+ // except for custom elements
+ const customElement = !el.localName.includes("-");
+ if ((attr == "hidden" || attr == "itemscope") && customElement) {
+ return true;
+ }
+
+ if (!boolEls.hasOwnProperty(el.localName)) {
+ return false;
+ }
+ return boolEls[el.localName].includes(attr);
+};
+
+/**
+ * A web element is an abstraction used to identify an element when
+ * it is transported via the protocol, between remote- and local ends.
+ *
+ * In Marionette this abstraction can represent DOM elements,
+ * WindowProxies, and XUL elements.
+ */
+class WebElement {
+ /**
+ * @param {string} uuid
+ * Identifier that must be unique across all browsing contexts
+ * for the contract to be upheld.
+ */
+ constructor(uuid) {
+ this.uuid = assert.string(uuid);
+ }
+
+ /**
+ * Performs an equality check between this web element and
+ * <var>other</var>.
+ *
+ * @param {WebElement} other
+ * Web element to compare with this.
+ *
+ * @return {boolean}
+ * True if this and <var>other</var> are the same. False
+ * otherwise.
+ */
+ is(other) {
+ return other instanceof WebElement && this.uuid === other.uuid;
+ }
+
+ toString() {
+ return `[object ${this.constructor.name} uuid=${this.uuid}]`;
+ }
+
+ /**
+ * Returns a new {@link WebElement} reference for a DOM element,
+ * <code>WindowProxy</code>, or XUL element.
+ *
+ * @param {(Element|WindowProxy|XULElement)} node
+ * Node to construct a web element reference for.
+ *
+ * @return {(ContentWebElement|ChromeWebElement)}
+ * Web element reference for <var>el</var>.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>node</var> is neither a <code>WindowProxy</code>,
+ * DOM element, or a XUL element.
+ */
+ static from(node) {
+ const uuid = WebElement.generateUUID();
+
+ if (element.isElement(node)) {
+ if (element.isInXULDocument(node)) {
+ // If the node is in a XUL document, we are in "chrome" context.
+ return new ChromeWebElement(uuid);
+ }
+ return new ContentWebElement(uuid);
+ } else if (element.isDOMWindow(node)) {
+ if (node.parent === node) {
+ return new ContentWebWindow(uuid);
+ }
+ return new ContentWebFrame(uuid);
+ }
+
+ throw new error.InvalidArgumentError(
+ "Expected DOM window/element " + pprint`or XUL element, got: ${node}`
+ );
+ }
+
+ /**
+ * Unmarshals a JSON Object to one of {@link ContentWebElement},
+ * {@link ContentWebWindow}, {@link ContentWebFrame}, or
+ * {@link ChromeWebElement}.
+ *
+ * @param {Object.<string, string>} json
+ * Web element reference, which is supposed to be a JSON Object
+ * where the key is one of the {@link WebElement} concrete
+ * classes' UUID identifiers.
+ *
+ * @return {WebElement}
+ * Representation of the web element.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>json</var> is not a web element reference.
+ */
+ static fromJSON(json) {
+ assert.object(json);
+ if (json instanceof WebElement) {
+ return json;
+ }
+ let keys = Object.keys(json);
+
+ for (let key of keys) {
+ switch (key) {
+ case ContentWebElement.Identifier:
+ return ContentWebElement.fromJSON(json);
+
+ case ContentWebWindow.Identifier:
+ return ContentWebWindow.fromJSON(json);
+
+ case ContentWebFrame.Identifier:
+ return ContentWebFrame.fromJSON(json);
+
+ case ChromeWebElement.Identifier:
+ return ChromeWebElement.fromJSON(json);
+ }
+ }
+
+ throw new error.InvalidArgumentError(
+ pprint`Expected web element reference, got: ${json}`
+ );
+ }
+
+ /**
+ * Constructs a {@link ContentWebElement} or {@link ChromeWebElement}
+ * from a a string <var>uuid</var>.
+ *
+ * This whole function is a workaround for the fact that clients
+ * to Marionette occasionally pass <code>{id: <uuid>}</code> JSON
+ * Objects instead of web element representations. For that reason
+ * we need the <var>context</var> argument to determine what kind of
+ * {@link WebElement} to return.
+ *
+ * @param {string} uuid
+ * UUID to be associated with the web element.
+ * @param {Context} context
+ * Context, which is used to determine if the returned type
+ * should be a content web element or a chrome web element.
+ *
+ * @return {WebElement}
+ * One of {@link ContentWebElement} or {@link ChromeWebElement},
+ * based on <var>context</var>.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>uuid</var> is not a string or <var>context</var>
+ * is an invalid context.
+ */
+ static fromUUID(uuid, context) {
+ assert.string(uuid);
+
+ switch (context) {
+ case "chrome":
+ return new ChromeWebElement(uuid);
+
+ case "content":
+ return new ContentWebElement(uuid);
+
+ default:
+ throw new error.InvalidArgumentError("Unknown context: " + context);
+ }
+ }
+
+ /**
+ * Checks if <var>ref<var> is a {@link WebElement} reference,
+ * i.e. if it has {@link ContentWebElement.Identifier}, or
+ * {@link ChromeWebElement.Identifier} as properties.
+ *
+ * @param {Object.<string, string>} obj
+ * Object that represents a reference to a {@link WebElement}.
+ * @return {boolean}
+ * True if <var>obj</var> is a {@link WebElement}, false otherwise.
+ */
+ static isReference(obj) {
+ if (Object.prototype.toString.call(obj) != "[object Object]") {
+ return false;
+ }
+
+ if (
+ ContentWebElement.Identifier in obj ||
+ ContentWebWindow.Identifier in obj ||
+ ContentWebFrame.Identifier in obj ||
+ ChromeWebElement.Identifier in obj
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Generates a unique identifier.
+ *
+ * @return {string}
+ * UUID.
+ */
+ static generateUUID() {
+ let uuid = uuidGen.generateUUID().toString();
+ return uuid.substring(1, uuid.length - 1);
+ }
+}
+this.WebElement = WebElement;
+
+/**
+ * DOM elements are represented as web elements when they are
+ * transported over the wire protocol.
+ */
+class ContentWebElement extends WebElement {
+ toJSON() {
+ return { [ContentWebElement.Identifier]: this.uuid };
+ }
+
+ static fromJSON(json) {
+ const { Identifier } = ContentWebElement;
+
+ if (!(Identifier in json)) {
+ throw new error.InvalidArgumentError(
+ pprint`Expected web element reference, got: ${json}`
+ );
+ }
+
+ let uuid = json[Identifier];
+ return new ContentWebElement(uuid);
+ }
+}
+ContentWebElement.Identifier = "element-6066-11e4-a52e-4f735466cecf";
+this.ContentWebElement = ContentWebElement;
+
+/**
+ * Top-level browsing contexts, such as <code>WindowProxy</code>
+ * whose <code>opener</code> is null, are represented as web windows
+ * over the wire protocol.
+ */
+class ContentWebWindow extends WebElement {
+ toJSON() {
+ return { [ContentWebWindow.Identifier]: this.uuid };
+ }
+
+ static fromJSON(json) {
+ if (!(ContentWebWindow.Identifier in json)) {
+ throw new error.InvalidArgumentError(
+ pprint`Expected web window reference, got: ${json}`
+ );
+ }
+ let uuid = json[ContentWebWindow.Identifier];
+ return new ContentWebWindow(uuid);
+ }
+}
+ContentWebWindow.Identifier = "window-fcc6-11e5-b4f8-330a88ab9d7f";
+this.ContentWebWindow = ContentWebWindow;
+
+/**
+ * Nested browsing contexts, such as the <code>WindowProxy</code>
+ * associated with <tt>&lt;frame&gt;</tt> and <tt>&lt;iframe&gt;</tt>,
+ * are represented as web frames over the wire protocol.
+ */
+class ContentWebFrame extends WebElement {
+ toJSON() {
+ return { [ContentWebFrame.Identifier]: this.uuid };
+ }
+
+ static fromJSON(json) {
+ if (!(ContentWebFrame.Identifier in json)) {
+ throw new error.InvalidArgumentError(
+ pprint`Expected web frame reference, got: ${json}`
+ );
+ }
+ let uuid = json[ContentWebFrame.Identifier];
+ return new ContentWebFrame(uuid);
+ }
+}
+ContentWebFrame.Identifier = "frame-075b-4da1-b6ba-e579c2d3230a";
+this.ContentWebFrame = ContentWebFrame;
+
+/**
+ * XUL elements in chrome space are represented as chrome web elements
+ * over the wire protocol.
+ */
+class ChromeWebElement extends WebElement {
+ toJSON() {
+ return { [ChromeWebElement.Identifier]: this.uuid };
+ }
+
+ static fromJSON(json) {
+ if (!(ChromeWebElement.Identifier in json)) {
+ throw new error.InvalidArgumentError(
+ "Expected chrome element reference " +
+ pprint`for XUL element, got: ${json}`
+ );
+ }
+ let uuid = json[ChromeWebElement.Identifier];
+ return new ChromeWebElement(uuid);
+ }
+}
+ChromeWebElement.Identifier = "chromeelement-9fc5-4b51-a3c8-01716eedeb04";
+this.ChromeWebElement = ChromeWebElement;
diff --git a/testing/marionette/error.js b/testing/marionette/error.js
new file mode 100644
index 0000000000..d8c8263934
--- /dev/null
+++ b/testing/marionette/error.js
@@ -0,0 +1,538 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["error"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ pprint: "chrome://marionette/content/format.js",
+});
+
+const ERRORS = new Set([
+ "ElementClickInterceptedError",
+ "ElementNotAccessibleError",
+ "ElementNotInteractableError",
+ "InsecureCertificateError",
+ "InvalidArgumentError",
+ "InvalidCookieDomainError",
+ "InvalidElementStateError",
+ "InvalidSelectorError",
+ "InvalidSessionIDError",
+ "JavaScriptError",
+ "MoveTargetOutOfBoundsError",
+ "NoSuchAlertError",
+ "NoSuchElementError",
+ "NoSuchFrameError",
+ "NoSuchWindowError",
+ "ScriptTimeoutError",
+ "SessionNotCreatedError",
+ "StaleElementReferenceError",
+ "TimeoutError",
+ "UnableToSetCookieError",
+ "UnexpectedAlertOpenError",
+ "UnknownCommandError",
+ "UnknownError",
+ "UnsupportedOperationError",
+ "WebDriverError",
+]);
+
+const BUILTIN_ERRORS = new Set([
+ "Error",
+ "EvalError",
+ "InternalError",
+ "RangeError",
+ "ReferenceError",
+ "SyntaxError",
+ "TypeError",
+ "URIError",
+]);
+
+/** @namespace */
+this.error = {
+ /**
+ * Check if ``val`` is an instance of the ``Error`` prototype.
+ *
+ * Because error objects may originate from different globals, comparing
+ * the prototype of the left hand side with the prototype property from
+ * the right hand side, which is what ``instanceof`` does, will not work.
+ * If the LHS and RHS come from different globals, this check will always
+ * fail because the two objects will not have the same identity.
+ *
+ * Therefore it is not safe to use ``instanceof`` in any multi-global
+ * situation, e.g. in content across multiple ``Window`` objects or anywhere
+ * in chrome scope.
+ *
+ * This function also contains a special check if ``val`` is an XPCOM
+ * ``nsIException`` because they are special snowflakes and may indeed
+ * cause Firefox to crash if used with ``instanceof``.
+ *
+ * @param {*} val
+ * Any value that should be undergo the test for errorness.
+ * @return {boolean}
+ * True if error, false otherwise.
+ */
+ isError(val) {
+ if (val === null || typeof val != "object") {
+ return false;
+ } else if (val instanceof Ci.nsIException) {
+ return true;
+ }
+
+ // DOMRectList errors on string comparison
+ try {
+ let proto = Object.getPrototypeOf(val);
+ return BUILTIN_ERRORS.has(proto.toString());
+ } catch (e) {
+ return false;
+ }
+ },
+
+ /**
+ * Checks if ``obj`` is an object in the :js:class:`WebDriverError`
+ * prototypal chain.
+ *
+ * @param {*} obj
+ * Arbitrary object to test.
+ *
+ * @return {boolean}
+ * True if ``obj`` is of the WebDriverError prototype chain,
+ * false otherwise.
+ */
+ isWebDriverError(obj) {
+ return error.isError(obj) && "name" in obj && ERRORS.has(obj.name);
+ },
+
+ /**
+ * Ensures error instance is a :js:class:`WebDriverError`.
+ *
+ * If the given error is already in the WebDriverError prototype
+ * chain, ``err`` is returned unmodified. If it is not, it is wrapped
+ * in :js:class:`UnknownError`.
+ *
+ * @param {Error} err
+ * Error to conditionally turn into a WebDriverError.
+ *
+ * @return {WebDriverError}
+ * If ``err`` is a WebDriverError, it is returned unmodified.
+ * Otherwise an UnknownError type is returned.
+ */
+ wrap(err) {
+ if (error.isWebDriverError(err)) {
+ return err;
+ }
+ return new UnknownError(err);
+ },
+
+ /**
+ * Unhandled error reporter. Dumps the error and its stacktrace to console,
+ * and reports error to the Browser Console.
+ */
+ report(err) {
+ let msg = "Marionette threw an error: " + error.stringify(err);
+ dump(msg + "\n");
+ if (Cu.reportError) {
+ Cu.reportError(msg);
+ }
+ },
+
+ /**
+ * Prettifies an instance of Error and its stacktrace to a string.
+ */
+ stringify(err) {
+ try {
+ let s = err.toString();
+ if ("stack" in err) {
+ s += "\n" + err.stack;
+ }
+ return s;
+ } catch (e) {
+ return "<unprintable error>";
+ }
+ },
+
+ /** Create a stacktrace to the current line in the program. */
+ stack() {
+ let trace = new Error().stack;
+ let sa = trace.split("\n");
+ sa = sa.slice(1);
+ let rv = "stacktrace:\n" + sa.join("\n");
+ return rv.trimEnd();
+ },
+};
+
+/**
+ * WebDriverError is the prototypal parent of all WebDriver errors.
+ * It should not be used directly, as it does not correspond to a real
+ * error in the specification.
+ */
+class WebDriverError extends Error {
+ /**
+ * @param {(string|Error)=} x
+ * Optional string describing error situation or Error instance
+ * to propagate.
+ */
+ constructor(x) {
+ super(x);
+ this.name = this.constructor.name;
+ this.status = "webdriver error";
+
+ // Error's ctor does not preserve x' stack
+ if (error.isError(x)) {
+ this.stack = x.stack;
+ }
+ }
+
+ /**
+ * @return {Object.<string, string>}
+ * JSON serialisation of error prototype.
+ */
+ toJSON() {
+ return {
+ error: this.status,
+ message: this.message || "",
+ stacktrace: this.stack || "",
+ };
+ }
+
+ /**
+ * Unmarshals a JSON error representation to the appropriate Marionette
+ * error type.
+ *
+ * @param {Object.<string, string>} json
+ * Error object.
+ *
+ * @return {Error}
+ * Error prototype.
+ */
+ static fromJSON(json) {
+ if (typeof json.error == "undefined") {
+ let s = JSON.stringify(json);
+ throw new TypeError("Undeserialisable error type: " + s);
+ }
+ if (!STATUSES.has(json.error)) {
+ throw new TypeError("Not of WebDriverError descent: " + json.error);
+ }
+
+ let cls = STATUSES.get(json.error);
+ let err = new cls();
+ if ("message" in json) {
+ err.message = json.message;
+ }
+ if ("stacktrace" in json) {
+ err.stack = json.stacktrace;
+ }
+ return err;
+ }
+}
+
+/** The Gecko a11y API indicates that the element is not accessible. */
+class ElementNotAccessibleError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "element not accessible";
+ }
+}
+
+/**
+ * An element click could not be completed because the element receiving
+ * the events is obscuring the element that was requested clicked.
+ *
+ * @param {Element=} obscuredEl
+ * Element obscuring the element receiving the click. Providing this
+ * is not required, but will produce a nicer error message.
+ * @param {Map.<string, number>} coords
+ * Original click location. Providing this is not required, but
+ * will produce a nicer error message.
+ */
+class ElementClickInterceptedError extends WebDriverError {
+ constructor(obscuredEl = undefined, coords = undefined) {
+ let msg = "";
+ if (obscuredEl && coords) {
+ const doc = obscuredEl.ownerDocument;
+ const overlayingEl = doc.elementFromPoint(coords.x, coords.y);
+
+ switch (obscuredEl.style.pointerEvents) {
+ case "none":
+ msg =
+ pprint`Element ${obscuredEl} is not clickable ` +
+ `at point (${coords.x},${coords.y}) ` +
+ `because it does not have pointer events enabled, ` +
+ pprint`and element ${overlayingEl} ` +
+ `would receive the click instead`;
+ break;
+
+ default:
+ msg =
+ pprint`Element ${obscuredEl} is not clickable ` +
+ `at point (${coords.x},${coords.y}) ` +
+ pprint`because another element ${overlayingEl} ` +
+ `obscures it`;
+ break;
+ }
+ }
+
+ super(msg);
+ this.status = "element click intercepted";
+ }
+}
+
+/**
+ * A command could not be completed because the element is not pointer-
+ * or keyboard interactable.
+ */
+class ElementNotInteractableError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "element not interactable";
+ }
+}
+
+/**
+ * Navigation caused the user agent to hit a certificate warning, which
+ * is usually the result of an expired or invalid TLS certificate.
+ */
+class InsecureCertificateError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "insecure certificate";
+ }
+}
+
+/** The arguments passed to a command are either invalid or malformed. */
+class InvalidArgumentError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "invalid argument";
+ }
+}
+
+/**
+ * An illegal attempt was made to set a cookie under a different
+ * domain than the current page.
+ */
+class InvalidCookieDomainError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "invalid cookie domain";
+ }
+}
+
+/**
+ * A command could not be completed because the element is in an
+ * invalid state, e.g. attempting to clear an element that isn't both
+ * editable and resettable.
+ */
+class InvalidElementStateError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "invalid element state";
+ }
+}
+
+/** Argument was an invalid selector. */
+class InvalidSelectorError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "invalid selector";
+ }
+}
+
+/**
+ * Occurs if the given session ID is not in the list of active sessions,
+ * meaning the session either does not exist or that it's not active.
+ */
+class InvalidSessionIDError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "invalid session id";
+ }
+}
+
+/** An error occurred whilst executing JavaScript supplied by the user. */
+class JavaScriptError extends WebDriverError {
+ constructor(x) {
+ super(x);
+ this.status = "javascript error";
+ }
+}
+
+/**
+ * The target for mouse interaction is not in the browser's viewport
+ * and cannot be brought into that viewport.
+ */
+class MoveTargetOutOfBoundsError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "move target out of bounds";
+ }
+}
+
+/**
+ * An attempt was made to operate on a modal dialog when one was
+ * not open.
+ */
+class NoSuchAlertError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "no such alert";
+ }
+}
+
+/**
+ * An element could not be located on the page using the given
+ * search parameters.
+ */
+class NoSuchElementError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "no such element";
+ }
+}
+
+/**
+ * A command to switch to a frame could not be satisfied because
+ * the frame could not be found.
+ */
+class NoSuchFrameError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "no such frame";
+ }
+}
+
+/**
+ * A command to switch to a window could not be satisfied because
+ * the window could not be found.
+ */
+class NoSuchWindowError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "no such window";
+ }
+}
+
+/** A script did not complete before its timeout expired. */
+class ScriptTimeoutError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "script timeout";
+ }
+}
+
+/** A new session could not be created. */
+class SessionNotCreatedError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "session not created";
+ }
+}
+
+/**
+ * A command failed because the referenced element is no longer
+ * attached to the DOM.
+ */
+class StaleElementReferenceError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "stale element reference";
+ }
+}
+
+/** An operation did not complete before its timeout expired. */
+class TimeoutError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "timeout";
+ }
+}
+
+/** A command to set a cookie's value could not be satisfied. */
+class UnableToSetCookieError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "unable to set cookie";
+ }
+}
+
+/** A modal dialog was open, blocking this operation. */
+class UnexpectedAlertOpenError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "unexpected alert open";
+ }
+}
+
+/**
+ * A command could not be executed because the remote end is not
+ * aware of it.
+ */
+class UnknownCommandError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "unknown command";
+ }
+}
+
+/**
+ * An unknown error occurred in the remote end while processing
+ * the command.
+ */
+class UnknownError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "unknown error";
+ }
+}
+
+/**
+ * Indicates that a command that should have executed properly
+ * cannot be supported for some reason.
+ */
+class UnsupportedOperationError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "unsupported operation";
+ }
+}
+
+const STATUSES = new Map([
+ ["element click intercepted", ElementClickInterceptedError],
+ ["element not accessible", ElementNotAccessibleError],
+ ["element not interactable", ElementNotInteractableError],
+ ["insecure certificate", InsecureCertificateError],
+ ["invalid argument", InvalidArgumentError],
+ ["invalid cookie domain", InvalidCookieDomainError],
+ ["invalid element state", InvalidElementStateError],
+ ["invalid selector", InvalidSelectorError],
+ ["invalid session id", InvalidSessionIDError],
+ ["javascript error", JavaScriptError],
+ ["move target out of bounds", MoveTargetOutOfBoundsError],
+ ["no such alert", NoSuchAlertError],
+ ["no such element", NoSuchElementError],
+ ["no such frame", NoSuchFrameError],
+ ["no such window", NoSuchWindowError],
+ ["script timeout", ScriptTimeoutError],
+ ["session not created", SessionNotCreatedError],
+ ["stale element reference", StaleElementReferenceError],
+ ["timeout", TimeoutError],
+ ["unable to set cookie", UnableToSetCookieError],
+ ["unexpected alert open", UnexpectedAlertOpenError],
+ ["unknown command", UnknownCommandError],
+ ["unknown error", UnknownError],
+ ["unsupported operation", UnsupportedOperationError],
+ ["webdriver error", WebDriverError],
+]);
+
+// Errors must be expored on the local this scope so that the
+// EXPORTED_SYMBOLS and the Cu.import("foo", {}) machinery sees them.
+// We could assign each error definition directly to |this|, but
+// because they are Error prototypes this would mess up their names.
+for (let cls of STATUSES.values()) {
+ error[cls.name] = cls;
+}
diff --git a/testing/marionette/evaluate.js b/testing/marionette/evaluate.js
new file mode 100644
index 0000000000..5dd22d4010
--- /dev/null
+++ b/testing/marionette/evaluate.js
@@ -0,0 +1,629 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["evaluate", "sandbox", "Sandboxes"];
+
+const { clearTimeout, setTimeout } = ChromeUtils.import(
+ "resource://gre/modules/Timer.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ assert: "chrome://marionette/content/assert.js",
+ element: "chrome://marionette/content/element.js",
+ error: "chrome://marionette/content/error.js",
+ Log: "chrome://marionette/content/log.js",
+ WebElement: "chrome://marionette/content/element.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+
+const ARGUMENTS = "__webDriverArguments";
+const CALLBACK = "__webDriverCallback";
+const COMPLETE = "__webDriverComplete";
+const DEFAULT_TIMEOUT = 10000; // ms
+const FINISH = "finish";
+
+/** @namespace */
+this.evaluate = {};
+
+/**
+ * Evaluate a script in given sandbox.
+ *
+ * The the provided `script` will be wrapped in an anonymous function
+ * with the `args` argument applied.
+ *
+ * The arguments provided by the `args<` argument are exposed
+ * through the `arguments` object available in the script context,
+ * and if the script is executed asynchronously with the `async`
+ * option, an additional last argument that is synonymous to the
+ * name `resolve` is appended, and can be accessed
+ * through `arguments[arguments.length - 1]`.
+ *
+ * The `timeout` option specifies the duration for how long the
+ * script should be allowed to run before it is interrupted and aborted.
+ * An interrupted script will cause a {@link ScriptTimeoutError} to occur.
+ *
+ * The `async` option indicates that the script will not return
+ * until the `resolve` callback is invoked,
+ * which is analogous to the last argument of the `arguments` object.
+ *
+ * The `file` option is used in error messages to provide information
+ * on the origin script file in the local end.
+ *
+ * The `line` option is used in error messages, along with `filename`,
+ * to provide the line number in the origin script file on the local end.
+ *
+ * @param {nsISandbox} sb
+ * Sandbox the script will be evaluted in.
+ * @param {string} script
+ * Script to evaluate.
+ * @param {Array.<?>=} args
+ * A sequence of arguments to call the script with.
+ * @param {boolean=} [async=false] async
+ * Indicates if the script should return immediately or wait for
+ * the callback to be invoked before returning.
+ * @param {string=} [file="dummy file"] file
+ * File location of the program in the client.
+ * @param {number=} [line=0] line
+ * Line number of th eprogram in the client.
+ * @param {number=} [timeout=DEFAULT_TIMEOUT] timeout
+ * Duration in milliseconds before interrupting the script.
+ *
+ * @return {Promise}
+ * A promise that when resolved will give you the return value from
+ * the script. Note that the return value requires serialisation before
+ * it can be sent to the client.
+ *
+ * @throws {JavaScriptError}
+ * If an {@link Error} was thrown whilst evaluating the script.
+ * @throws {ScriptTimeoutError}
+ * If the script was interrupted due to script timeout.
+ */
+evaluate.sandbox = function(
+ sb,
+ script,
+ args = [],
+ {
+ async = false,
+ file = "dummy file",
+ line = 0,
+ timeout = DEFAULT_TIMEOUT,
+ } = {}
+) {
+ let unloadHandler;
+ let marionetteSandbox = sandbox.create(sb.window);
+
+ // timeout handler
+ let scriptTimeoutID, timeoutPromise;
+ if (timeout !== null) {
+ timeoutPromise = new Promise((resolve, reject) => {
+ scriptTimeoutID = setTimeout(() => {
+ reject(new error.ScriptTimeoutError(`Timed out after ${timeout} ms`));
+ }, timeout);
+ });
+ }
+
+ let promise = new Promise((resolve, reject) => {
+ let src = "";
+ sb[COMPLETE] = resolve;
+ sb[ARGUMENTS] = sandbox.cloneInto(args, sb);
+
+ // callback function made private
+ // so that introspection is possible
+ // on the arguments object
+ if (async) {
+ sb[CALLBACK] = sb[COMPLETE];
+ src += `${ARGUMENTS}.push(rv => ${CALLBACK}(rv));`;
+ }
+
+ src += `(function() {
+ ${script}
+ }).apply(null, ${ARGUMENTS})`;
+
+ unloadHandler = sandbox.cloneInto(
+ () => reject(new error.JavaScriptError("Document was unloaded")),
+ marionetteSandbox
+ );
+ marionetteSandbox.window.addEventListener("unload", unloadHandler);
+
+ let promises = [
+ Cu.evalInSandbox(
+ src,
+ sb,
+ "1.8",
+ file,
+ line,
+ /* enforceFilenameRestrictions */ false
+ ),
+ timeoutPromise,
+ ];
+
+ // Wait for the immediate result of calling evalInSandbox, or a timeout.
+ // Only resolve the promise if the scriptPromise was resolved and is not
+ // async, because the latter has to call resolve() itself.
+ Promise.race(promises).then(
+ value => {
+ if (!async) {
+ resolve(value);
+ }
+ },
+ err => {
+ reject(err);
+ }
+ );
+ });
+
+ // This block is mainly for async scripts, which escape the inner promise
+ // when calling resolve() on their own. The timeout promise will be re-used
+ // to break out after the initially setup timeout.
+ return Promise.race([promise, timeoutPromise])
+ .catch(err => {
+ // Only raise valid errors for both the sync and async scripts.
+ if (err instanceof error.ScriptTimeoutError) {
+ throw err;
+ }
+ throw new error.JavaScriptError(err);
+ })
+ .finally(() => {
+ clearTimeout(scriptTimeoutID);
+ marionetteSandbox.window.removeEventListener("unload", unloadHandler);
+ });
+};
+
+/**
+ * Convert any web elements in arbitrary objects to DOM elements by
+ * looking them up in the seen element store. For ElementIdentifiers a new
+ * entry in the seen element reference store gets added when running in the
+ * parent process, otherwise ContentDOMReference is used to retrieve the DOM
+ * node.
+ *
+ * @param {Object} obj
+ * Arbitrary object containing web elements or ElementIdentifiers.
+ * @param {(element.Store|element.ReferenceStore)=} seenEls
+ * Known element store to look up web elements from. If `seenEls` is an
+ * instance of `element.ReferenceStore`, return WebElement. If `seenEls`
+ * is an instance of `element.Store`, return Element. If `seenEls` is
+ * `undefined` the Element from the ContentDOMReference cache is returned
+ * when executed in the child process, in the parent process the WebElement
+ * is passed-through.
+ * @param {WindowProxy=} win
+ * Current browsing context, if `seenEls` is provided.
+ *
+ * @return {Object}
+ * Same object as provided by `obj` with the web elements
+ * replaced by DOM elements.
+ *
+ * @throws {NoSuchElementError}
+ * If `seenEls` is an `element.Store` and the web element reference has not
+ * been seen before.
+ * @throws {StaleElementReferenceError}
+ * If `seenEls` is an `element.ReferenceStore` or `element.Store` and the
+ * element has gone stale, indicating it is no longer attached to the DOM,
+ * or its node document is no longer the active document.
+ */
+evaluate.fromJSON = function(obj, seenEls = undefined, win = undefined) {
+ switch (typeof obj) {
+ case "boolean":
+ case "number":
+ case "string":
+ default:
+ return obj;
+
+ case "object":
+ if (obj === null) {
+ return obj;
+
+ // arrays
+ } else if (Array.isArray(obj)) {
+ return obj.map(e => evaluate.fromJSON(e, seenEls, win));
+
+ // ElementIdentifier and ReferenceStore (used by JSWindowActor)
+ } else if (WebElement.isReference(obj.webElRef)) {
+ if (seenEls instanceof element.ReferenceStore) {
+ // Parent: Store web element reference in the cache
+ return seenEls.add(obj);
+ } else if (!seenEls) {
+ // Child: Resolve ElementIdentifier by using ContentDOMReference
+ return element.resolveElement(obj, win);
+ }
+ throw new TypeError("seenEls is not an instance of ReferenceStore");
+
+ // WebElement and Store (used by framescript)
+ } else if (WebElement.isReference(obj)) {
+ const webEl = WebElement.fromJSON(obj);
+ if (seenEls instanceof element.Store) {
+ // Child: Get web element from the store
+ return seenEls.get(webEl, win);
+ } else if (!seenEls) {
+ // Parent: No conversion. Just return the web element
+ return webEl;
+ }
+ throw new TypeError("seenEls is not an instance of Store");
+ }
+
+ // arbitrary objects
+ let rv = {};
+ for (let prop in obj) {
+ rv[prop] = evaluate.fromJSON(obj[prop], seenEls, win);
+ }
+ return rv;
+ }
+};
+
+/**
+ * Marshal arbitrary objects to JSON-safe primitives that can be
+ * transported over the Marionette protocol or across processes.
+ *
+ * The marshaling rules are as follows:
+ *
+ * - Primitives are returned as is.
+ *
+ * - Collections, such as `Array<`, `NodeList`, `HTMLCollection`
+ * et al. are expanded to arrays and then recursed.
+ *
+ * - Elements that are not known web elements are added to the `seenEls` element
+ * store, or the ContentDOMReference registry. Once known, the elements'
+ * associated web element representation is returned.
+ *
+ * - WebElements are transformed to the corresponding ElementIdentifier
+ * for use in the content process, if an `element.ReferenceStore` is provided.
+ *
+ * - Objects with custom JSON representations, i.e. if they have
+ * a callable `toJSON` function, are returned verbatim. This means
+ * their internal integrity _are not_ checked. Be careful.
+ *
+ * - Other arbitrary objects are first tested for cyclic references
+ * and then recursed into.
+ *
+ * @param {Object} obj
+ * Object to be marshaled.
+ *
+ * @param {(element.Store|element.ReferenceStore)=} seenEls
+ * Element store to use for lookup of web element references.
+ *
+ * @return {Object}
+ * Same object as provided by `obj` with the elements
+ * replaced by web elements.
+ *
+ * @throws {JavaScriptError}
+ * If an object contains cyclic references.
+ */
+evaluate.toJSON = function(obj, seenEls) {
+ const t = Object.prototype.toString.call(obj);
+
+ // null
+ if (t == "[object Undefined]" || t == "[object Null]") {
+ return null;
+
+ // primitives
+ } else if (
+ t == "[object Boolean]" ||
+ t == "[object Number]" ||
+ t == "[object String]"
+ ) {
+ return obj;
+
+ // Array, NodeList, HTMLCollection, et al.
+ } else if (element.isCollection(obj)) {
+ assert.acyclic(obj);
+ return [...obj].map(el => evaluate.toJSON(el, seenEls));
+
+ // WebElement
+ } else if (WebElement.isReference(obj)) {
+ // Parent: Convert to ElementIdentifier for use in child actor
+ if (seenEls instanceof element.ReferenceStore) {
+ return seenEls.get(WebElement.fromJSON(obj));
+ }
+
+ return obj;
+
+ // ElementIdentifier
+ } else if (WebElement.isReference(obj.webElRef)) {
+ // Parent: Pass-through ElementIdentifiers to the child
+ if (seenEls instanceof element.ReferenceStore) {
+ return obj;
+ }
+
+ // Parent: Otherwise return the web element
+ return WebElement.fromJSON(obj.webElRef);
+
+ // Element (HTMLElement, SVGElement, XULElement, et al.)
+ } else if (element.isElement(obj)) {
+ // Parent
+ if (seenEls instanceof element.ReferenceStore) {
+ throw new TypeError(`ReferenceStore can't be used with Element`);
+
+ // Child: Add element to the Store, return as WebElement
+ } else if (seenEls instanceof element.Store) {
+ return seenEls.add(obj);
+ }
+
+ // If no storage has been specified assume we are in a child process.
+ // Evaluation of code will take place in mutable sandboxes, which are
+ // created to waive xrays by default. As such DOM nodes have to be unwaived
+ // before accessing the ownerGlobal is possible, which is needed by
+ // ContentDOMReference.
+ return element.getElementId(Cu.unwaiveXrays(obj));
+
+ // custom JSON representation
+ } else if (typeof obj.toJSON == "function") {
+ let unsafeJSON = obj.toJSON();
+ return evaluate.toJSON(unsafeJSON, seenEls);
+ }
+
+ // arbitrary objects + files
+ let rv = {};
+ for (let prop in obj) {
+ assert.acyclic(obj[prop]);
+
+ try {
+ rv[prop] = evaluate.toJSON(obj[prop], seenEls);
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_NOT_IMPLEMENTED) {
+ logger.debug(`Skipping ${prop}: ${e.message}`);
+ } else {
+ throw e;
+ }
+ }
+ }
+ return rv;
+};
+
+/**
+ * Tests if an arbitrary object is cyclic.
+ *
+ * Element prototypes are by definition acyclic, even when they
+ * contain cyclic references. This is because `evaluate.toJSON`
+ * ensures they are marshaled as web elements.
+ *
+ * @param {*} value
+ * Object to test for cyclical references.
+ *
+ * @return {boolean}
+ * True if object is cyclic, false otherwise.
+ */
+evaluate.isCyclic = function(value, stack = []) {
+ let t = Object.prototype.toString.call(value);
+
+ // null
+ if (t == "[object Undefined]" || t == "[object Null]") {
+ return false;
+
+ // primitives
+ } else if (
+ t == "[object Boolean]" ||
+ t == "[object Number]" ||
+ t == "[object String]"
+ ) {
+ return false;
+
+ // HTMLElement, SVGElement, XULElement, et al.
+ } else if (element.isElement(value)) {
+ return false;
+
+ // Array, NodeList, HTMLCollection, et al.
+ } else if (element.isCollection(value)) {
+ if (stack.includes(value)) {
+ return true;
+ }
+ stack.push(value);
+
+ for (let i = 0; i < value.length; i++) {
+ if (evaluate.isCyclic(value[i], stack)) {
+ return true;
+ }
+ }
+
+ stack.pop();
+ return false;
+ }
+
+ // arbitrary objects
+ if (stack.includes(value)) {
+ return true;
+ }
+ stack.push(value);
+
+ for (let prop in value) {
+ if (evaluate.isCyclic(value[prop], stack)) {
+ return true;
+ }
+ }
+
+ stack.pop();
+ return false;
+};
+
+/**
+ * `Cu.isDeadWrapper` does not return true for a dead sandbox that
+ * was assosciated with and extension popup. This provides a way to
+ * still test for a dead object.
+ *
+ * @param {Object} obj
+ * A potentially dead object.
+ * @param {string} prop
+ * Name of a property on the object.
+ *
+ * @returns {boolean}
+ * True if <var>obj</var> is dead, false otherwise.
+ */
+evaluate.isDead = function(obj, prop) {
+ try {
+ obj[prop];
+ } catch (e) {
+ if (e.message.includes("dead object")) {
+ return true;
+ }
+ throw e;
+ }
+ return false;
+};
+
+this.sandbox = {};
+
+/**
+ * Provides a safe way to take an object defined in a privileged scope and
+ * create a structured clone of it in a less-privileged scope. It returns
+ * a reference to the clone.
+ *
+ * Unlike for {@link Components.utils.cloneInto}, `obj` may contain
+ * functions and DOM elements.
+ */
+sandbox.cloneInto = function(obj, sb) {
+ return Cu.cloneInto(obj, sb, { cloneFunctions: true, wrapReflectors: true });
+};
+
+/**
+ * Augment given sandbox by an adapter that has an `exports` map
+ * property, or a normal map, of function names and function references.
+ *
+ * @param {Sandbox} sb
+ * The sandbox to augment.
+ * @param {Object} adapter
+ * Object that holds an `exports` property, or a map, of function
+ * names and function references.
+ *
+ * @return {Sandbox}
+ * The augmented sandbox.
+ */
+sandbox.augment = function(sb, adapter) {
+ function* entries(obj) {
+ for (let key of Object.keys(obj)) {
+ yield [key, obj[key]];
+ }
+ }
+
+ let funcs = adapter.exports || entries(adapter);
+ for (let [name, func] of funcs) {
+ sb[name] = func;
+ }
+
+ return sb;
+};
+
+/**
+ * Creates a sandbox.
+ *
+ * @param {Window} win
+ * The DOM Window object.
+ * @param {nsIPrincipal=} principal
+ * An optional, custom principal to prefer over the Window. Useful if
+ * you need elevated security permissions.
+ *
+ * @return {Sandbox}
+ * The created sandbox.
+ */
+sandbox.create = function(win, principal = null, opts = {}) {
+ let p = principal || win;
+ opts = Object.assign(
+ {
+ sameZoneAs: win,
+ sandboxPrototype: win,
+ wantComponents: true,
+ wantXrays: true,
+ wantGlobalProperties: ["ChromeUtils"],
+ },
+ opts
+ );
+ return new Cu.Sandbox(p, opts);
+};
+
+/**
+ * Creates a mutable sandbox, where changes to the global scope
+ * will have lasting side-effects.
+ *
+ * @param {Window} win
+ * The DOM Window object.
+ *
+ * @return {Sandbox}
+ * The created sandbox.
+ */
+sandbox.createMutable = function(win) {
+ let opts = {
+ wantComponents: false,
+ wantXrays: false,
+ };
+ // Note: We waive Xrays here to match potentially-accidental old behavior.
+ return Cu.waiveXrays(sandbox.create(win, null, opts));
+};
+
+sandbox.createSystemPrincipal = function(win) {
+ let principal = Cc["@mozilla.org/systemprincipal;1"].createInstance(
+ Ci.nsIPrincipal
+ );
+ return sandbox.create(win, principal);
+};
+
+sandbox.createSimpleTest = function(win, harness) {
+ let sb = sandbox.create(win);
+ sb = sandbox.augment(sb, harness);
+ sb[FINISH] = () => sb[COMPLETE](harness.generate_results());
+ return sb;
+};
+
+/**
+ * Sandbox storage. When the user requests a sandbox by a specific name,
+ * if one exists in the storage this will be used as long as its window
+ * reference is still valid.
+ *
+ * @memberof evaluate
+ */
+this.Sandboxes = class {
+ /**
+ * @param {function(): Window} windowFn
+ * A function that returns the references to the current Window
+ * object.
+ */
+ constructor(windowFn) {
+ this.windowFn_ = windowFn;
+ this.boxes_ = new Map();
+ }
+
+ get window_() {
+ return this.windowFn_();
+ }
+
+ /**
+ * Factory function for getting a sandbox by name, or failing that,
+ * creating a new one.
+ *
+ * If the sandbox' window does not match the provided window, a new one
+ * will be created.
+ *
+ * @param {string} name
+ * The name of the sandbox to get or create.
+ * @param {boolean=} [fresh=false] fresh
+ * Remove old sandbox by name first, if it exists.
+ *
+ * @return {Sandbox}
+ * A used or fresh sandbox.
+ */
+ get(name = "default", fresh = false) {
+ let sb = this.boxes_.get(name);
+ if (sb) {
+ if (fresh || evaluate.isDead(sb, "window") || sb.window != this.window_) {
+ this.boxes_.delete(name);
+ return this.get(name, false);
+ }
+ } else {
+ if (name == "system") {
+ sb = sandbox.createSystemPrincipal(this.window_);
+ } else {
+ sb = sandbox.create(this.window_);
+ }
+ this.boxes_.set(name, sb);
+ }
+ return sb;
+ }
+
+ /** Clears cache of sandboxes. */
+ clear() {
+ this.boxes_.clear();
+ }
+};
diff --git a/testing/marionette/event.js b/testing/marionette/event.js
new file mode 100644
index 0000000000..b7f6565b1f
--- /dev/null
+++ b/testing/marionette/event.js
@@ -0,0 +1,1138 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+/* global content, is */
+/* eslint-disable no-restricted-globals */
+
+const EXPORTED_SYMBOLS = ["event"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ element: "chrome://marionette/content/element.js",
+});
+
+/** Provides functionality for creating and sending DOM events. */
+this.event = {};
+
+XPCOMUtils.defineLazyGetter(this, "dblclickTimer", () => {
+ return Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+});
+
+// Max interval between two clicks that should result in a dblclick (in ms)
+const DBLCLICK_INTERVAL = 640;
+
+// TODO(ato): Document!
+let seenEvent = false;
+
+event.MouseEvents = {
+ click: 0,
+ dblclick: 1,
+ mousedown: 2,
+ mouseup: 3,
+ mouseover: 4,
+ mouseout: 5,
+};
+
+event.Modifiers = {
+ shiftKey: 0,
+ ctrlKey: 1,
+ altKey: 2,
+ metaKey: 3,
+};
+
+event.MouseButton = {
+ isPrimary(button) {
+ return button === 0;
+ },
+ isAuxiliary(button) {
+ return button === 1;
+ },
+ isSecondary(button) {
+ return button === 2;
+ },
+};
+
+event.DoubleClickTracker = {
+ firstClick: false,
+ isClicked() {
+ return event.DoubleClickTracker.firstClick;
+ },
+ setClick() {
+ if (!event.DoubleClickTracker.firstClick) {
+ event.DoubleClickTracker.firstClick = true;
+ event.DoubleClickTracker.startTimer();
+ }
+ },
+ resetClick() {
+ event.DoubleClickTracker.firstClick = false;
+ event.DoubleClickTracker.cancelTimer();
+ },
+ startTimer() {
+ dblclickTimer.initWithCallback(
+ event.DoubleClickTracker.resetClick,
+ DBLCLICK_INTERVAL,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ },
+ cancelTimer() {
+ dblclickTimer.cancel();
+ },
+};
+
+// TODO(ato): Unexpose this when action.Chain#emitMouseEvent
+// no longer emits its own events
+event.parseModifiers_ = function(modifiers) {
+ let mval = 0;
+ if (modifiers.shiftKey) {
+ mval |= Ci.nsIDOMWindowUtils.MODIFIER_SHIFT;
+ }
+ if (modifiers.ctrlKey) {
+ mval |= Ci.nsIDOMWindowUtils.MODIFIER_CONTROL;
+ }
+ if (modifiers.altKey) {
+ mval |= Ci.nsIDOMWindowUtils.MODIFIER_ALT;
+ }
+ if (modifiers.metaKey) {
+ mval |= Ci.nsIDOMWindowUtils.MODIFIER_META;
+ }
+ if (modifiers.accelKey) {
+ if (Services.appinfo.OS === "Darwin") {
+ mval |= Ci.nsIDOMWindowUtils.MODIFIER_META;
+ } else {
+ mval |= Ci.nsIDOMWindowUtils.MODIFIER_CONTROL;
+ }
+ }
+ return mval;
+};
+
+/**
+ * Synthesise a mouse event on a target.
+ *
+ * The actual client point is determined by taking the aTarget's client
+ * box and offseting it by offsetX and offsetY. This allows mouse clicks
+ * to be simulated by calling this method.
+ *
+ * If the type is specified, an mouse event of that type is
+ * fired. Otherwise, a mousedown followed by a mouse up is performed.
+ *
+ * @param {Element} element
+ * Element to click.
+ * @param {number} offsetX
+ * Horizontal offset to click from the target's bounding box.
+ * @param {number} offsetY
+ * Vertical offset to click from the target's bounding box.
+ * @param {Object.<string, ?>} opts
+ * Object which may contain the properties "shiftKey", "ctrlKey",
+ * "altKey", "metaKey", "accessKey", "clickCount", "button", and
+ * "type".
+ * @param {Window} win
+ * Window object.
+ */
+event.synthesizeMouse = function(element, offsetX, offsetY, opts, win) {
+ let rect = element.getBoundingClientRect();
+ event.synthesizeMouseAtPoint(
+ rect.left + offsetX,
+ rect.top + offsetY,
+ opts,
+ win
+ );
+};
+
+/*
+ * Synthesize a mouse event at a particular point in a window.
+ *
+ * If the type of the event is specified, a mouse event of that type is
+ * fired. Otherwise, a mousedown followed by a mouse up is performed.
+ *
+ * @param {number} left
+ * CSS pixels from the left document margin.
+ * @param {number} top
+ * CSS pixels from the top document margin.
+ * @param {Object.<string, ?>} opts
+ * Object which may contain the properties "shiftKey", "ctrlKey",
+ * "altKey", "metaKey", "accessKey", "clickCount", "button", and
+ * "type".
+ * @param {Window} win
+ * Window object.
+ */
+event.synthesizeMouseAtPoint = function(left, top, opts, win) {
+ let domutils = win.windowUtils;
+
+ let button = opts.button || 0;
+ let clickCount = opts.clickCount || 1;
+ let modifiers = event.parseModifiers_(opts);
+ let pressure = "pressure" in opts ? opts.pressure : 0;
+ let inputSource =
+ "inputSource" in opts ? opts.inputSource : win.MouseEvent.MOZ_SOURCE_MOUSE;
+ let isDOMEventSynthesized =
+ "isSynthesized" in opts ? opts.isSynthesized : true;
+ let isWidgetEventSynthesized;
+ if ("isWidgetEventSynthesized" in opts) {
+ isWidgetEventSynthesized = opts.isWidgetEventSynthesized;
+ } else {
+ isWidgetEventSynthesized = false;
+ }
+ let buttons;
+ if ("buttons" in opts) {
+ buttons = opts.buttons;
+ } else {
+ buttons = domutils.MOUSE_BUTTONS_NOT_SPECIFIED;
+ }
+
+ if ("type" in opts && opts.type) {
+ domutils.sendMouseEvent(
+ opts.type,
+ left,
+ top,
+ button,
+ clickCount,
+ modifiers,
+ false,
+ pressure,
+ inputSource,
+ isDOMEventSynthesized,
+ isWidgetEventSynthesized,
+ buttons
+ );
+ } else {
+ domutils.sendMouseEvent(
+ "mousedown",
+ left,
+ top,
+ button,
+ clickCount,
+ modifiers,
+ false,
+ pressure,
+ inputSource,
+ isDOMEventSynthesized,
+ isWidgetEventSynthesized,
+ buttons
+ );
+ domutils.sendMouseEvent(
+ "mouseup",
+ left,
+ top,
+ button,
+ clickCount,
+ modifiers,
+ false,
+ pressure,
+ inputSource,
+ isDOMEventSynthesized,
+ isWidgetEventSynthesized,
+ buttons
+ );
+ }
+};
+
+/* eslint-disable */
+function computeKeyCodeFromChar_(char, win) {
+ if (char.length != 1) {
+ return 0;
+ }
+
+ let KeyboardEvent = getKeyboardEvent_(win);
+
+ if (char in VIRTUAL_KEYCODE_LOOKUP) {
+ return KeyboardEvent["DOM_" + VIRTUAL_KEYCODE_LOOKUP[char]];
+ }
+
+ if (char >= "a" && char <= "z") {
+ return KeyboardEvent.DOM_VK_A + char.charCodeAt(0) - "a".charCodeAt(0);
+ }
+ if (char >= "A" && char <= "Z") {
+ return KeyboardEvent.DOM_VK_A + char.charCodeAt(0) - "A".charCodeAt(0);
+ }
+ if (char >= "0" && char <= "9") {
+ return KeyboardEvent.DOM_VK_0 + char.charCodeAt(0) - "0".charCodeAt(0);
+ }
+
+ // returns US keyboard layout's keycode
+ switch (char) {
+ case "~":
+ case "`":
+ return KeyboardEvent.DOM_VK_BACK_QUOTE;
+
+ case "!":
+ return KeyboardEvent.DOM_VK_1;
+
+ case "@":
+ return KeyboardEvent.DOM_VK_2;
+
+ case "#":
+ return KeyboardEvent.DOM_VK_3;
+
+ case "$":
+ return KeyboardEvent.DOM_VK_4;
+
+ case "%":
+ return KeyboardEvent.DOM_VK_5;
+
+ case "^":
+ return KeyboardEvent.DOM_VK_6;
+
+ case "&":
+ return KeyboardEvent.DOM_VK_7;
+
+ case "*":
+ return KeyboardEvent.DOM_VK_8;
+
+ case "(":
+ return KeyboardEvent.DOM_VK_9;
+
+ case ")":
+ return KeyboardEvent.DOM_VK_0;
+
+ case "-":
+ case "_":
+ return KeyboardEvent.DOM_VK_SUBTRACT;
+
+ case "+":
+ case "=":
+ return KeyboardEvent.DOM_VK_EQUALS;
+
+ case "{":
+ case "[":
+ return KeyboardEvent.DOM_VK_OPEN_BRACKET;
+
+ case "}":
+ case "]":
+ return KeyboardEvent.DOM_VK_CLOSE_BRACKET;
+
+ case "|":
+ case "\\":
+ return KeyboardEvent.DOM_VK_BACK_SLASH;
+
+ case ":":
+ case ";":
+ return KeyboardEvent.DOM_VK_SEMICOLON;
+
+ case "'":
+ case "\"":
+ return KeyboardEvent.DOM_VK_QUOTE;
+
+ case "<":
+ case ",":
+ return KeyboardEvent.DOM_VK_COMMA;
+
+ case ">":
+ case ".":
+ return KeyboardEvent.DOM_VK_PERIOD;
+
+ case "?":
+ case "/":
+ return KeyboardEvent.DOM_VK_SLASH;
+
+ case "\n":
+ return KeyboardEvent.DOM_VK_RETURN;
+
+ case " ":
+ return KeyboardEvent.DOM_VK_SPACE;
+
+ default:
+ return 0;
+ }
+}
+/* eslint-enable */
+/* eslint-disable no-restricted-globals */
+
+/**
+ * Returns true if the given key should cause keypress event when widget
+ * handles the native key event. Otherwise, false.
+ *
+ * The key code should be one of consts of KeyboardEvent.DOM_VK_*,
+ * or a key name begins with "VK_", or a character.
+ */
+event.isKeypressFiredKey = function(key, win) {
+ let KeyboardEvent = getKeyboardEvent_(win);
+
+ if (typeof key == "string") {
+ if (key.indexOf("VK_") === 0) {
+ key = KeyboardEvent["DOM_" + key];
+ if (!key) {
+ throw new TypeError("Unknown key: " + key);
+ }
+
+ // if key generates a character, it must cause a keypress event
+ } else {
+ return true;
+ }
+ }
+
+ switch (key) {
+ case KeyboardEvent.DOM_VK_SHIFT:
+ case KeyboardEvent.DOM_VK_CONTROL:
+ case KeyboardEvent.DOM_VK_ALT:
+ case KeyboardEvent.DOM_VK_CAPS_LOCK:
+ case KeyboardEvent.DOM_VK_NUM_LOCK:
+ case KeyboardEvent.DOM_VK_SCROLL_LOCK:
+ case KeyboardEvent.DOM_VK_META:
+ return false;
+
+ default:
+ return true;
+ }
+};
+
+/**
+ * Synthesise a key event.
+ *
+ * It is targeted at whatever would be targeted by an actual keypress
+ * by the user, typically the focused element.
+ *
+ * @param {string} key
+ * Key to synthesise. Should either be a character or a key code
+ * starting with "VK_" such as VK_RETURN, or a normalized key value.
+ * @param {Object.<string, ?>} event
+ * Object which may contain the properties shiftKey, ctrlKey, altKey,
+ * metaKey, accessKey, type. If the type is specified (keydown or keyup),
+ * a key event of that type is fired. Otherwise, a keydown, a keypress,
+ * and then a keyup event are fired in sequence.
+ * @param {Window} win
+ * Window object.
+ *
+ * @throws {TypeError}
+ * If unknown key.
+ */
+event.synthesizeKey = function(key, event, win) {
+ let TIP = getTIP_(win);
+ if (!TIP) {
+ return;
+ }
+ let KeyboardEvent = getKeyboardEvent_(win);
+ let modifiers = emulateToActivateModifiers_(TIP, event, win);
+ let keyEventDict = createKeyboardEventDictionary_(key, event, win);
+ let keyEvent = new KeyboardEvent("", keyEventDict.dictionary);
+ let dispatchKeydown =
+ !("type" in event) || event.type === "keydown" || !event.type;
+ let dispatchKeyup =
+ !("type" in event) || event.type === "keyup" || !event.type;
+
+ try {
+ if (dispatchKeydown) {
+ TIP.keydown(keyEvent, keyEventDict.flags);
+ if ("repeat" in event && event.repeat > 1) {
+ keyEventDict.dictionary.repeat = true;
+ let repeatedKeyEvent = new KeyboardEvent("", keyEventDict.dictionary);
+ for (let i = 1; i < event.repeat; i++) {
+ TIP.keydown(repeatedKeyEvent, keyEventDict.flags);
+ }
+ }
+ }
+ if (dispatchKeyup) {
+ TIP.keyup(keyEvent, keyEventDict.flags);
+ }
+ } finally {
+ emulateToInactivateModifiers_(TIP, modifiers, win);
+ }
+};
+
+const TIPMap = new WeakMap();
+
+function getTIP_(win, callback) {
+ let tip;
+
+ if (TIPMap.has(win)) {
+ tip = TIPMap.get(win);
+ } else {
+ tip = Cc["@mozilla.org/text-input-processor;1"].createInstance(
+ Ci.nsITextInputProcessor
+ );
+ TIPMap.set(win, tip);
+ }
+ if (!tip.beginInputTransactionForTests(win, callback)) {
+ tip = null;
+ TIPMap.delete(win);
+ }
+ return tip;
+}
+
+function getKeyboardEvent_(win) {
+ if (typeof KeyboardEvent != "undefined") {
+ try {
+ // See if the object can be instantiated; sometimes this yields
+ // 'TypeError: can't access dead object' or 'KeyboardEvent is not
+ // a constructor'.
+ new KeyboardEvent("", {});
+ return KeyboardEvent;
+ } catch (ex) {}
+ }
+ if (typeof content != "undefined" && "KeyboardEvent" in content) {
+ return content.KeyboardEvent;
+ }
+ return win.KeyboardEvent;
+}
+
+function createKeyboardEventDictionary_(key, keyEvent, win) {
+ let result = { dictionary: null, flags: 0 };
+ let keyCodeIsDefined = "keyCode" in keyEvent && keyEvent.keyCode != undefined;
+ let keyCode =
+ keyCodeIsDefined && keyEvent.keyCode >= 0 && keyEvent.keyCode <= 255
+ ? keyEvent.keyCode
+ : 0;
+ let keyName = "Unidentified";
+
+ let printable = false;
+
+ if (key.indexOf("KEY_") == 0) {
+ keyName = key.substr("KEY_".length);
+ result.flags |= Ci.nsITextInputProcessor.KEY_NON_PRINTABLE_KEY;
+ } else if (key.indexOf("VK_") == 0) {
+ keyCode = getKeyboardEvent_(win)["DOM_" + key];
+ if (!keyCode) {
+ throw new Error("Unknown key: " + key);
+ }
+ keyName = guessKeyNameFromKeyCode_(keyCode, win);
+ if (!isPrintable(keyCode, win)) {
+ result.flags |= Ci.nsITextInputProcessor.KEY_NON_PRINTABLE_KEY;
+ }
+ } else if (key != "") {
+ keyName = key;
+ if (!keyCodeIsDefined) {
+ keyCode = computeKeyCodeFromChar_(key.charAt(0), win);
+ }
+ if (!keyCode) {
+ result.flags |= Ci.nsITextInputProcessor.KEY_KEEP_KEYCODE_ZERO;
+ }
+ // only force printable if "raw character" and event key match, like "a"
+ if (!("key" in keyEvent && key != keyEvent.key)) {
+ result.flags |= Ci.nsITextInputProcessor.KEY_FORCE_PRINTABLE_KEY;
+ printable = true;
+ }
+ }
+
+ let locationIsDefined = "location" in keyEvent;
+ if (locationIsDefined && keyEvent.location === 0) {
+ result.flags |= Ci.nsITextInputProcessor.KEY_KEEP_KEY_LOCATION_STANDARD;
+ }
+
+ let resultKey = "key" in keyEvent ? keyEvent.key : keyName;
+ if (printable && keyEvent.shiftKey) {
+ resultKey = resultKey.toUpperCase();
+ }
+
+ result.dictionary = {
+ key: resultKey,
+ code: "code" in keyEvent ? keyEvent.code : "",
+ location: locationIsDefined ? keyEvent.location : 0,
+ repeat: "repeat" in keyEvent ? keyEvent.repeat === true : false,
+ keyCode,
+ };
+
+ return result;
+}
+
+function emulateToActivateModifiers_(TIP, keyEvent, win) {
+ if (!keyEvent) {
+ return null;
+ }
+ let KeyboardEvent = getKeyboardEvent_(win);
+
+ let modifiers = {
+ normal: [
+ { key: "Alt", attr: "altKey" },
+ { key: "AltGraph", attr: "altGraphKey" },
+ { key: "Control", attr: "ctrlKey" },
+ { key: "Fn", attr: "fnKey" },
+ { key: "Meta", attr: "metaKey" },
+ { key: "OS", attr: "osKey" },
+ { key: "Shift", attr: "shiftKey" },
+ { key: "Symbol", attr: "symbolKey" },
+ {
+ key: Services.appinfo.OS === "Darwin" ? "Meta" : "Control",
+ attr: "accelKey",
+ },
+ ],
+ lockable: [
+ { key: "CapsLock", attr: "capsLockKey" },
+ { key: "FnLock", attr: "fnLockKey" },
+ { key: "NumLock", attr: "numLockKey" },
+ { key: "ScrollLock", attr: "scrollLockKey" },
+ { key: "SymbolLock", attr: "symbolLockKey" },
+ ],
+ };
+
+ for (let i = 0; i < modifiers.normal.length; i++) {
+ if (!keyEvent[modifiers.normal[i].attr]) {
+ continue;
+ }
+ if (TIP.getModifierState(modifiers.normal[i].key)) {
+ continue; // already activated.
+ }
+ let event = new KeyboardEvent("", { key: modifiers.normal[i].key });
+ TIP.keydown(
+ event,
+ TIP.KEY_NON_PRINTABLE_KEY | TIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT
+ );
+ modifiers.normal[i].activated = true;
+ }
+
+ for (let j = 0; j < modifiers.lockable.length; j++) {
+ if (!keyEvent[modifiers.lockable[j].attr]) {
+ continue;
+ }
+ if (TIP.getModifierState(modifiers.lockable[j].key)) {
+ continue; // already activated.
+ }
+ let event = new KeyboardEvent("", { key: modifiers.lockable[j].key });
+ TIP.keydown(
+ event,
+ TIP.KEY_NON_PRINTABLE_KEY | TIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT
+ );
+ TIP.keyup(
+ event,
+ TIP.KEY_NON_PRINTABLE_KEY | TIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT
+ );
+ modifiers.lockable[j].activated = true;
+ }
+
+ return modifiers;
+}
+
+function emulateToInactivateModifiers_(TIP, modifiers, win) {
+ if (!modifiers) {
+ return;
+ }
+ let KeyboardEvent = getKeyboardEvent_(win);
+ for (let i = 0; i < modifiers.normal.length; i++) {
+ if (!modifiers.normal[i].activated) {
+ continue;
+ }
+ let event = new KeyboardEvent("", { key: modifiers.normal[i].key });
+ TIP.keyup(
+ event,
+ TIP.KEY_NON_PRINTABLE_KEY | TIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT
+ );
+ }
+ for (let j = 0; j < modifiers.lockable.length; j++) {
+ if (!modifiers.lockable[j].activated) {
+ continue;
+ }
+ if (!TIP.getModifierState(modifiers.lockable[j].key)) {
+ continue; // who already inactivated this?
+ }
+ let event = new KeyboardEvent("", { key: modifiers.lockable[j].key });
+ TIP.keydown(
+ event,
+ TIP.KEY_NON_PRINTABLE_KEY | TIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT
+ );
+ TIP.keyup(
+ event,
+ TIP.KEY_NON_PRINTABLE_KEY | TIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT
+ );
+ }
+}
+
+/* eslint-disable */
+function guessKeyNameFromKeyCode_(aKeyCode, win) {
+ let KeyboardEvent = getKeyboardEvent_(win);
+ switch (aKeyCode) {
+ case KeyboardEvent.DOM_VK_CANCEL:
+ return "Cancel";
+ case KeyboardEvent.DOM_VK_HELP:
+ return "Help";
+ case KeyboardEvent.DOM_VK_BACK_SPACE:
+ return "Backspace";
+ case KeyboardEvent.DOM_VK_TAB:
+ return "Tab";
+ case KeyboardEvent.DOM_VK_CLEAR:
+ return "Clear";
+ case KeyboardEvent.DOM_VK_RETURN:
+ return "Enter";
+ case KeyboardEvent.DOM_VK_SHIFT:
+ return "Shift";
+ case KeyboardEvent.DOM_VK_CONTROL:
+ return "Control";
+ case KeyboardEvent.DOM_VK_ALT:
+ return "Alt";
+ case KeyboardEvent.DOM_VK_PAUSE:
+ return "Pause";
+ case KeyboardEvent.DOM_VK_EISU:
+ return "Eisu";
+ case KeyboardEvent.DOM_VK_ESCAPE:
+ return "Escape";
+ case KeyboardEvent.DOM_VK_CONVERT:
+ return "Convert";
+ case KeyboardEvent.DOM_VK_NONCONVERT:
+ return "NonConvert";
+ case KeyboardEvent.DOM_VK_ACCEPT:
+ return "Accept";
+ case KeyboardEvent.DOM_VK_MODECHANGE:
+ return "ModeChange";
+ case KeyboardEvent.DOM_VK_PAGE_UP:
+ return "PageUp";
+ case KeyboardEvent.DOM_VK_PAGE_DOWN:
+ return "PageDown";
+ case KeyboardEvent.DOM_VK_END:
+ return "End";
+ case KeyboardEvent.DOM_VK_HOME:
+ return "Home";
+ case KeyboardEvent.DOM_VK_LEFT:
+ return "ArrowLeft";
+ case KeyboardEvent.DOM_VK_UP:
+ return "ArrowUp";
+ case KeyboardEvent.DOM_VK_RIGHT:
+ return "ArrowRight";
+ case KeyboardEvent.DOM_VK_DOWN:
+ return "ArrowDown";
+ case KeyboardEvent.DOM_VK_SELECT:
+ return "Select";
+ case KeyboardEvent.DOM_VK_PRINT:
+ return "Print";
+ case KeyboardEvent.DOM_VK_EXECUTE:
+ return "Execute";
+ case KeyboardEvent.DOM_VK_PRINTSCREEN:
+ return "PrintScreen";
+ case KeyboardEvent.DOM_VK_INSERT:
+ return "Insert";
+ case KeyboardEvent.DOM_VK_DELETE:
+ return "Delete";
+ case KeyboardEvent.DOM_VK_WIN:
+ return "OS";
+ case KeyboardEvent.DOM_VK_CONTEXT_MENU:
+ return "ContextMenu";
+ case KeyboardEvent.DOM_VK_SLEEP:
+ return "Standby";
+ case KeyboardEvent.DOM_VK_F1:
+ return "F1";
+ case KeyboardEvent.DOM_VK_F2:
+ return "F2";
+ case KeyboardEvent.DOM_VK_F3:
+ return "F3";
+ case KeyboardEvent.DOM_VK_F4:
+ return "F4";
+ case KeyboardEvent.DOM_VK_F5:
+ return "F5";
+ case KeyboardEvent.DOM_VK_F6:
+ return "F6";
+ case KeyboardEvent.DOM_VK_F7:
+ return "F7";
+ case KeyboardEvent.DOM_VK_F8:
+ return "F8";
+ case KeyboardEvent.DOM_VK_F9:
+ return "F9";
+ case KeyboardEvent.DOM_VK_F10:
+ return "F10";
+ case KeyboardEvent.DOM_VK_F11:
+ return "F11";
+ case KeyboardEvent.DOM_VK_F12:
+ return "F12";
+ case KeyboardEvent.DOM_VK_F13:
+ return "F13";
+ case KeyboardEvent.DOM_VK_F14:
+ return "F14";
+ case KeyboardEvent.DOM_VK_F15:
+ return "F15";
+ case KeyboardEvent.DOM_VK_F16:
+ return "F16";
+ case KeyboardEvent.DOM_VK_F17:
+ return "F17";
+ case KeyboardEvent.DOM_VK_F18:
+ return "F18";
+ case KeyboardEvent.DOM_VK_F19:
+ return "F19";
+ case KeyboardEvent.DOM_VK_F20:
+ return "F20";
+ case KeyboardEvent.DOM_VK_F21:
+ return "F21";
+ case KeyboardEvent.DOM_VK_F22:
+ return "F22";
+ case KeyboardEvent.DOM_VK_F23:
+ return "F23";
+ case KeyboardEvent.DOM_VK_F24:
+ return "F24";
+ case KeyboardEvent.DOM_VK_NUM_LOCK:
+ return "NumLock";
+ case KeyboardEvent.DOM_VK_SCROLL_LOCK:
+ return "ScrollLock";
+ case KeyboardEvent.DOM_VK_VOLUME_MUTE:
+ return "AudioVolumeMute";
+ case KeyboardEvent.DOM_VK_VOLUME_DOWN:
+ return "AudioVolumeDown";
+ case KeyboardEvent.DOM_VK_VOLUME_UP:
+ return "AudioVolumeUp";
+ case KeyboardEvent.DOM_VK_META:
+ return "Meta";
+ case KeyboardEvent.DOM_VK_ALTGR:
+ return "AltGraph";
+ case KeyboardEvent.DOM_VK_ATTN:
+ return "Attn";
+ case KeyboardEvent.DOM_VK_CRSEL:
+ return "CrSel";
+ case KeyboardEvent.DOM_VK_EXSEL:
+ return "ExSel";
+ case KeyboardEvent.DOM_VK_EREOF:
+ return "EraseEof";
+ case KeyboardEvent.DOM_VK_PLAY:
+ return "Play";
+ default:
+ return "Unidentified";
+ }
+}
+/* eslint-enable */
+
+/**
+ * Indicate that an event with an original target and type is expected
+ * to be fired, or not expected to be fired.
+ */
+/* eslint-disable */
+function expectEvent_(expectedTarget, expectedEvent, testName) {
+ if (!expectedTarget || !expectedEvent) {
+ return null;
+ }
+
+ seenEvent = false;
+
+ let type;
+ if (expectedEvent.charAt(0) == "!") {
+ type = expectedEvent.substring(1);
+ } else {
+ type = expectedEvent;
+ }
+
+ let handler = ev => {
+ let pass = (!seenEvent && ev.originalTarget == expectedTarget && ev.type == type);
+ is(pass, true, `${testName} ${type} event target ${seenEvent ? "twice" : ""}`);
+ seenEvent = true;
+ };
+
+ expectedTarget.addEventListener(type, handler);
+ return handler;
+}
+/* eslint-enable */
+/* eslint-disable no-restricted-globals */
+
+/**
+ * Check if the event was fired or not. The provided event handler will
+ * be removed.
+ */
+function checkExpectedEvent_(
+ expectedTarget,
+ expectedEvent,
+ eventHandler,
+ testName
+) {
+ if (eventHandler) {
+ let expectEvent = expectedEvent.charAt(0) != "!";
+ let type = expectEvent;
+ if (!type) {
+ type = expectedEvent.substring(1);
+ }
+ expectedTarget.removeEventListener(type, eventHandler);
+
+ let desc = `${type} event`;
+ if (!expectEvent) {
+ desc += " not";
+ }
+ is(seenEvent, expectEvent, `${testName} ${desc} fired`);
+ }
+
+ seenEvent = false;
+}
+
+/**
+ * Similar to event.synthesizeMouse except that a test is performed to
+ * see if an event is fired at the right target as a result.
+ *
+ * To test that an event is not fired, use an expected type preceded by
+ * an exclamation mark, such as "!select". This might be used to test that
+ * a click on a disabled element doesn't fire certain events for instance.
+ *
+ * @param {Element} target
+ * Synthesise the mouse event on this target.
+ * @param {number} offsetX
+ * Horizontal offset from the target's bounding box.
+ * @param {number} offsetY
+ * Vertical offset from the target's bounding box.
+ * @param {Object.<string, ?>} ev
+ * Object which may contain the properties shiftKey, ctrlKey, altKey,
+ * metaKey, accessKey, type.
+ * @param {Element} expectedTarget
+ * Expected originalTarget of the event.
+ * @param {DOMEvent} expectedEvent
+ * Expected type of the event, such as "select".
+ * @param {string} testName
+ * Test name when outputing results.
+ * @param {Window} win
+ * Window object.
+ */
+event.synthesizeMouseExpectEvent = function(
+ target,
+ offsetX,
+ offsetY,
+ ev,
+ expectedTarget,
+ expectedEvent,
+ testName,
+ win
+) {
+ let eventHandler = expectEvent_(expectedTarget, expectedEvent, testName);
+ event.synthesizeMouse(target, offsetX, offsetY, ev, win);
+ checkExpectedEvent_(expectedTarget, expectedEvent, eventHandler, testName);
+};
+
+const MODIFIER_KEYCODES_LOOKUP = {
+ VK_SHIFT: "shiftKey",
+ VK_CONTROL: "ctrlKey",
+ VK_ALT: "altKey",
+ VK_META: "metaKey",
+};
+
+const VIRTUAL_KEYCODE_LOOKUP = {
+ "\uE001": "VK_CANCEL",
+ "\uE002": "VK_HELP",
+ "\uE003": "VK_BACK_SPACE",
+ "\uE004": "VK_TAB",
+ "\uE005": "VK_CLEAR",
+ "\uE006": "VK_RETURN",
+ "\uE007": "VK_RETURN",
+ "\uE008": "VK_SHIFT",
+ "\uE009": "VK_CONTROL",
+ "\uE00A": "VK_ALT",
+ "\uE00B": "VK_PAUSE",
+ "\uE00C": "VK_ESCAPE",
+ "\uE00D": "VK_SPACE", // printable
+ "\uE00E": "VK_PAGE_UP",
+ "\uE00F": "VK_PAGE_DOWN",
+ "\uE010": "VK_END",
+ "\uE011": "VK_HOME",
+ "\uE012": "VK_LEFT",
+ "\uE013": "VK_UP",
+ "\uE014": "VK_RIGHT",
+ "\uE015": "VK_DOWN",
+ "\uE016": "VK_INSERT",
+ "\uE017": "VK_DELETE",
+ "\uE018": "VK_SEMICOLON",
+ "\uE019": "VK_EQUALS",
+ "\uE01A": "VK_NUMPAD0",
+ "\uE01B": "VK_NUMPAD1",
+ "\uE01C": "VK_NUMPAD2",
+ "\uE01D": "VK_NUMPAD3",
+ "\uE01E": "VK_NUMPAD4",
+ "\uE01F": "VK_NUMPAD5",
+ "\uE020": "VK_NUMPAD6",
+ "\uE021": "VK_NUMPAD7",
+ "\uE022": "VK_NUMPAD8",
+ "\uE023": "VK_NUMPAD9",
+ "\uE024": "VK_MULTIPLY",
+ "\uE025": "VK_ADD",
+ "\uE026": "VK_SEPARATOR",
+ "\uE027": "VK_SUBTRACT",
+ "\uE028": "VK_DECIMAL",
+ "\uE029": "VK_DIVIDE",
+ "\uE031": "VK_F1",
+ "\uE032": "VK_F2",
+ "\uE033": "VK_F3",
+ "\uE034": "VK_F4",
+ "\uE035": "VK_F5",
+ "\uE036": "VK_F6",
+ "\uE037": "VK_F7",
+ "\uE038": "VK_F8",
+ "\uE039": "VK_F9",
+ "\uE03A": "VK_F10",
+ "\uE03B": "VK_F11",
+ "\uE03C": "VK_F12",
+ "\uE03D": "VK_META",
+ "\uE050": "VK_SHIFT",
+ "\uE051": "VK_CONTROL",
+ "\uE052": "VK_ALT",
+ "\uE053": "VK_META",
+ "\uE054": "VK_PAGE_UP",
+ "\uE055": "VK_PAGE_DOWN",
+ "\uE056": "VK_END",
+ "\uE057": "VK_HOME",
+ "\uE058": "VK_LEFT",
+ "\uE059": "VK_UP",
+ "\uE05A": "VK_RIGHT",
+ "\uE05B": "VK_DOWN",
+ "\uE05C": "VK_INSERT",
+ "\uE05D": "VK_DELETE",
+};
+
+function getKeyCode(c) {
+ if (c in VIRTUAL_KEYCODE_LOOKUP) {
+ return VIRTUAL_KEYCODE_LOOKUP[c];
+ }
+ return c;
+}
+
+function isPrintable(c, win) {
+ let KeyboardEvent = getKeyboardEvent_(win);
+ let NON_PRINT_KEYS = [
+ KeyboardEvent.DOM_VK_CANCEL,
+ KeyboardEvent.DOM_VK_HELP,
+ KeyboardEvent.DOM_VK_BACK_SPACE,
+ KeyboardEvent.DOM_VK_TAB,
+ KeyboardEvent.DOM_VK_CLEAR,
+ KeyboardEvent.DOM_VK_SHIFT,
+ KeyboardEvent.DOM_VK_CONTROL,
+ KeyboardEvent.DOM_VK_ALT,
+ KeyboardEvent.DOM_VK_PAUSE,
+ KeyboardEvent.DOM_VK_EISU,
+ KeyboardEvent.DOM_VK_ESCAPE,
+ KeyboardEvent.DOM_VK_CONVERT,
+ KeyboardEvent.DOM_VK_NONCONVERT,
+ KeyboardEvent.DOM_VK_ACCEPT,
+ KeyboardEvent.DOM_VK_MODECHANGE,
+ KeyboardEvent.DOM_VK_PAGE_UP,
+ KeyboardEvent.DOM_VK_PAGE_DOWN,
+ KeyboardEvent.DOM_VK_END,
+ KeyboardEvent.DOM_VK_HOME,
+ KeyboardEvent.DOM_VK_LEFT,
+ KeyboardEvent.DOM_VK_UP,
+ KeyboardEvent.DOM_VK_RIGHT,
+ KeyboardEvent.DOM_VK_DOWN,
+ KeyboardEvent.DOM_VK_SELECT,
+ KeyboardEvent.DOM_VK_PRINT,
+ KeyboardEvent.DOM_VK_EXECUTE,
+ KeyboardEvent.DOM_VK_PRINTSCREEN,
+ KeyboardEvent.DOM_VK_INSERT,
+ KeyboardEvent.DOM_VK_DELETE,
+ KeyboardEvent.DOM_VK_WIN,
+ KeyboardEvent.DOM_VK_CONTEXT_MENU,
+ KeyboardEvent.DOM_VK_SLEEP,
+ KeyboardEvent.DOM_VK_F1,
+ KeyboardEvent.DOM_VK_F2,
+ KeyboardEvent.DOM_VK_F3,
+ KeyboardEvent.DOM_VK_F4,
+ KeyboardEvent.DOM_VK_F5,
+ KeyboardEvent.DOM_VK_F6,
+ KeyboardEvent.DOM_VK_F7,
+ KeyboardEvent.DOM_VK_F8,
+ KeyboardEvent.DOM_VK_F9,
+ KeyboardEvent.DOM_VK_F10,
+ KeyboardEvent.DOM_VK_F11,
+ KeyboardEvent.DOM_VK_F12,
+ KeyboardEvent.DOM_VK_F13,
+ KeyboardEvent.DOM_VK_F14,
+ KeyboardEvent.DOM_VK_F15,
+ KeyboardEvent.DOM_VK_F16,
+ KeyboardEvent.DOM_VK_F17,
+ KeyboardEvent.DOM_VK_F18,
+ KeyboardEvent.DOM_VK_F19,
+ KeyboardEvent.DOM_VK_F20,
+ KeyboardEvent.DOM_VK_F21,
+ KeyboardEvent.DOM_VK_F22,
+ KeyboardEvent.DOM_VK_F23,
+ KeyboardEvent.DOM_VK_F24,
+ KeyboardEvent.DOM_VK_NUM_LOCK,
+ KeyboardEvent.DOM_VK_SCROLL_LOCK,
+ KeyboardEvent.DOM_VK_VOLUME_MUTE,
+ KeyboardEvent.DOM_VK_VOLUME_DOWN,
+ KeyboardEvent.DOM_VK_VOLUME_UP,
+ KeyboardEvent.DOM_VK_META,
+ KeyboardEvent.DOM_VK_ALTGR,
+ KeyboardEvent.DOM_VK_ATTN,
+ KeyboardEvent.DOM_VK_CRSEL,
+ KeyboardEvent.DOM_VK_EXSEL,
+ KeyboardEvent.DOM_VK_EREOF,
+ KeyboardEvent.DOM_VK_PLAY,
+ KeyboardEvent.DOM_VK_RETURN,
+ ];
+ return !NON_PRINT_KEYS.includes(c);
+}
+
+event.sendKeyDown = function(keyToSend, modifiers, win) {
+ modifiers.type = "keydown";
+ event.sendSingleKey(keyToSend, modifiers, win);
+ delete modifiers.type;
+};
+
+event.sendKeyUp = function(keyToSend, modifiers, win) {
+ modifiers.type = "keyup";
+ event.sendSingleKey(keyToSend, modifiers, win);
+ delete modifiers.type;
+};
+
+/**
+ * Synthesize a key event for a single key.
+ *
+ * @param {string} keyToSend
+ * Code point or normalized key value
+ * @param {Object.<string, boolean>} modifiers
+ * Object with properties used in KeyboardEvent (shiftkey, repeat, ...)
+ * as well as, the event |type| such as keydown. All properties
+ * are optional.
+ * @param {Window} win
+ * Window object.
+ */
+event.sendSingleKey = function(keyToSend, modifiers, win) {
+ let keyCode = getKeyCode(keyToSend);
+ if (keyCode in MODIFIER_KEYCODES_LOOKUP) {
+ // For |sendKeysToElement| and legacy actions we assume that if
+ // |keyToSend| is a raw code point (like "\uE009") then |modifiers| does
+ // not already have correct value for corresponding |modName| attribute
+ // (like ctrlKey), so that value needs to be flipped.
+ let modName = MODIFIER_KEYCODES_LOOKUP[keyCode];
+ modifiers[modName] = !modifiers[modName];
+ }
+ event.synthesizeKey(keyCode, modifiers, win);
+};
+
+/**
+ * @param {string} keyString
+ * @param {Element} element
+ * @param {Window} win
+ */
+event.sendKeysToElement = function(keyString, el, win) {
+ // make Object.<modifier, false> map
+ let modifiers = Object.create(event.Modifiers);
+ for (let modifier in event.Modifiers) {
+ modifiers[modifier] = false;
+ }
+
+ for (let i = 0; i < keyString.length; i++) {
+ let c = keyString.charAt(i);
+ event.sendSingleKey(c, modifiers, win);
+ }
+};
+
+event.sendEvent = function(eventType, el, modifiers = {}, opts = {}) {
+ opts.canBubble = opts.canBubble || true;
+
+ let doc = el.ownerDocument || el.document;
+ let ev = doc.createEvent("Event");
+
+ ev.shiftKey = modifiers.shift;
+ ev.metaKey = modifiers.meta;
+ ev.altKey = modifiers.alt;
+ ev.ctrlKey = modifiers.ctrl;
+
+ ev.initEvent(eventType, opts.canBubble, true);
+ el.dispatchEvent(ev);
+};
+
+event.mouseover = function(el, modifiers = {}, opts = {}) {
+ return event.sendEvent("mouseover", el, modifiers, opts);
+};
+
+event.mousemove = function(el, modifiers = {}, opts = {}) {
+ return event.sendEvent("mousemove", el, modifiers, opts);
+};
+
+event.mousedown = function(el, modifiers = {}, opts = {}) {
+ return event.sendEvent("mousedown", el, modifiers, opts);
+};
+
+event.mouseup = function(el, modifiers = {}, opts = {}) {
+ return event.sendEvent("mouseup", el, modifiers, opts);
+};
+
+event.click = function(el, modifiers = {}, opts = {}) {
+ return event.sendEvent("click", el, modifiers, opts);
+};
+
+event.change = function(el, modifiers = {}, opts = {}) {
+ return event.sendEvent("change", el, modifiers, opts);
+};
+
+event.input = function(el, modifiers = {}, opts = {}) {
+ return event.sendEvent("input", el, modifiers, opts);
+};
diff --git a/testing/marionette/format.js b/testing/marionette/format.js
new file mode 100644
index 0000000000..51f2d4005d
--- /dev/null
+++ b/testing/marionette/format.js
@@ -0,0 +1,194 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["pprint", "truncate"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ Log: "chrome://marionette/content/log.js",
+ MarionettePrefs: "chrome://marionette/content/prefs.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+
+const ELEMENT_NODE = 1;
+const MAX_STRING_LENGTH = 250;
+
+/**
+ * Pretty-print values passed to template strings.
+ *
+ * Usage::
+ *
+ * const { pprint } = Cu.import(
+ * "chrome://marionette/content/format.js", {}
+ * );
+ *
+ * let bool = {value: true};
+ * pprint`Expected boolean, got ${bool}`;
+ * => 'Expected boolean, got [object Object] {"value": true}'
+ *
+ * let htmlElement = document.querySelector("input#foo");
+ * pprint`Expected element ${htmlElement}`;
+ * => 'Expected element <input id="foo" class="bar baz" type="input">'
+ *
+ * pprint`Current window: ${window}`;
+ * => '[object Window https://www.mozilla.org/]'
+ */
+function pprint(ss, ...values) {
+ function pretty(val) {
+ let proto = Object.prototype.toString.call(val);
+ if (
+ typeof val == "object" &&
+ val !== null &&
+ "nodeType" in val &&
+ val.nodeType === ELEMENT_NODE
+ ) {
+ return prettyElement(val);
+ } else if (["[object Window]", "[object ChromeWindow]"].includes(proto)) {
+ return prettyWindowGlobal(val);
+ } else if (proto == "[object Attr]") {
+ return prettyAttr(val);
+ }
+ return prettyObject(val);
+ }
+
+ function prettyElement(el) {
+ let attrs = ["id", "class", "href", "name", "src", "type"];
+
+ let idents = "";
+ for (let attr of attrs) {
+ if (el.hasAttribute(attr)) {
+ idents += ` ${attr}="${el.getAttribute(attr)}"`;
+ }
+ }
+
+ return `<${el.localName}${idents}>`;
+ }
+
+ function prettyWindowGlobal(win) {
+ let proto = Object.prototype.toString.call(win);
+ return `[${proto.substring(1, proto.length - 1)} ${win.location}]`;
+ }
+
+ function prettyAttr(obj) {
+ return `[object Attr ${obj.name}="${obj.value}"]`;
+ }
+
+ function prettyObject(obj) {
+ let proto = Object.prototype.toString.call(obj);
+ let s = "";
+ try {
+ s = JSON.stringify(obj);
+ } catch (e) {
+ if (e instanceof TypeError) {
+ s = `<${e.message}>`;
+ } else {
+ throw e;
+ }
+ }
+ return `${proto} ${s}`;
+ }
+
+ let res = [];
+ for (let i = 0; i < ss.length; i++) {
+ res.push(ss[i]);
+ if (i < values.length) {
+ let s;
+ try {
+ s = pretty(values[i]);
+ } catch (e) {
+ logger.warn("Problem pretty printing:", e);
+ s = typeof values[i];
+ }
+ res.push(s);
+ }
+ }
+ return res.join("");
+}
+this.pprint = pprint;
+
+/**
+ * Template literal that truncates string values in arbitrary objects.
+ *
+ * Given any object, the template will walk the object and truncate
+ * any strings it comes across to a reasonable limit. This is suitable
+ * when you have arbitrary data and data integrity is not important.
+ *
+ * The strings are truncated in the middle so that the beginning and
+ * the end is preserved. This will make a long, truncated string look
+ * like "X <...> Y", where X and Y are half the number of characters
+ * of the maximum string length from either side of the string.
+ *
+ * If the `marionette.log.truncate` preference is false, this
+ * function acts as a no-op.
+ *
+ * Usage::
+ *
+ * truncate`Hello ${"x".repeat(260)}!`;
+ * // Hello xxx ... xxx!
+ *
+ * Functions named `toJSON` or `toString` on objects will be called.
+ */
+function truncate(strings, ...values) {
+ function walk(obj) {
+ const typ = Object.prototype.toString.call(obj);
+
+ switch (typ) {
+ case "[object Undefined]":
+ case "[object Null]":
+ case "[object Boolean]":
+ case "[object Number]":
+ return obj;
+
+ case "[object String]":
+ if (MarionettePrefs.truncateLog) {
+ if (obj.length > MAX_STRING_LENGTH) {
+ let s1 = obj.substring(0, MAX_STRING_LENGTH / 2);
+ let s2 = obj.substring(obj.length - MAX_STRING_LENGTH / 2);
+ return `${s1} ... ${s2}`;
+ }
+ }
+ return obj;
+
+ case "[object Array]":
+ return obj.map(walk);
+
+ // arbitrary object
+ default:
+ if (
+ Object.getOwnPropertyNames(obj).includes("toString") &&
+ typeof obj.toString == "function"
+ ) {
+ return walk(obj.toString());
+ }
+
+ let rv = {};
+ for (let prop in obj) {
+ rv[prop] = walk(obj[prop]);
+ }
+ return rv;
+ }
+ }
+
+ let res = [];
+ for (let i = 0; i < strings.length; ++i) {
+ res.push(strings[i]);
+ if (i < values.length) {
+ let obj = walk(values[i]);
+ let t = Object.prototype.toString.call(obj);
+ if (t == "[object Array]" || t == "[object Object]") {
+ res.push(JSON.stringify(obj));
+ } else {
+ res.push(obj);
+ }
+ }
+ }
+ return res.join("");
+}
+this.truncate = truncate;
diff --git a/testing/marionette/harness/MANIFEST.in b/testing/marionette/harness/MANIFEST.in
new file mode 100644
index 0000000000..ce2d97cd30
--- /dev/null
+++ b/testing/marionette/harness/MANIFEST.in
@@ -0,0 +1,4 @@
+exclude MANIFEST.in
+include requirements.txt
+recursive-include marionette_harness/certificates *
+recursive-include marionette_harness/www *
diff --git a/testing/marionette/harness/README.rst b/testing/marionette/harness/README.rst
new file mode 100644
index 0000000000..3f8865603e
--- /dev/null
+++ b/testing/marionette/harness/README.rst
@@ -0,0 +1,30 @@
+marionette-harness
+==================
+
+Marionette is an automation driver for Mozilla's Gecko engine. It can remotely
+control either the UI or the internal JavaScript of a Gecko platform, such as
+Firefox. It can control both the chrome (i.e. menus and functions) or the
+content (the webpage loaded inside the browsing context), giving a high level
+of control and ability to replicate user actions. In addition to performing
+actions on the browser, Marionette can also read the properties and attributes
+of the DOM.
+
+The marionette_harness package contains the test runner for Marionette, and
+allows you to run automated tests written in Python for Gecko based
+applications. Therefore it offers the necessary testcase classes, which are
+based on the unittest framework.
+
+For more information and the repository please checkout:
+
+- home and docs: https://developer.mozilla.org/en-US/docs/Mozilla/QA/Marionette
+
+
+Example
+-------
+
+The following command will run the tests as specified via a manifest file, or
+test path, or test folder in Firefox:
+
+ marionette --binary %path_to_firefox% [manifest_file | test_file | test_folder]
+
+To get an overview about all possible option run `marionette --help`.
diff --git a/testing/marionette/harness/marionette_harness/__init__.py b/testing/marionette/harness/marionette_harness/__init__.py
new file mode 100644
index 0000000000..dc80408329
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/__init__.py
@@ -0,0 +1,34 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+__version__ = "5.0.2"
+
+from .marionette_test import (
+ CommonTestCase,
+ expectedFailure,
+ MarionetteTestCase,
+ parameterized,
+ run_if_manage_instance,
+ skip,
+ skip_if_chrome,
+ skip_if_desktop,
+ skip_if_framescript,
+ SkipTest,
+ skip_unless_browser_pref,
+ skip_unless_protocol,
+)
+from .runner import (
+ BaseMarionetteArguments,
+ BaseMarionetteTestRunner,
+ Marionette,
+ MarionetteTest,
+ MarionetteTestResult,
+ MarionetteTextTestRunner,
+ TestManifest,
+ TestResult,
+ TestResultCollection,
+ WindowManagerMixin,
+)
diff --git a/testing/marionette/harness/marionette_harness/certificates/test.cert b/testing/marionette/harness/marionette_harness/certificates/test.cert
new file mode 100644
index 0000000000..3fd1cba2b7
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/certificates/test.cert
@@ -0,0 +1,86 @@
+Certificate:
+ Data:
+ Version: 3 (0x2)
+ Serial Number: 2 (0x2)
+ Signature Algorithm: sha256WithRSAEncryption
+ Issuer: CN=web-platform-tests
+ Validity
+ Not Before: Dec 22 12:09:16 2014 GMT
+ Not After : Dec 21 12:09:16 2024 GMT
+ Subject: CN=web-platform.test
+ Subject Public Key Info:
+ Public Key Algorithm: rsaEncryption
+ Public-Key: (2048 bit)
+ Modulus:
+ 00:b3:84:d6:8b:01:59:18:85:d1:dc:32:df:38:f7:
+ 90:85:1b:3e:a5:5e:81:3e:2f:fc:3a:5f:7f:77:ef:
+ 23:bb:3a:88:27:0f:be:25:46:cd:63:7d:cb:95:d8:
+ a5:50:10:d2:a2:d2:b7:97:d1:0d:6c:fb:f9:05:e8:
+ 6f:a8:4b:bd:95:67:9e:7b:94:58:a9:6d:93:fd:e0:
+ 12:c5:cd:b4:8a:64:52:31:5f:0e:e3:89:84:71:da:
+ 98:dd:4b:ec:02:25:a5:7d:35:fe:63:da:b3:ac:ec:
+ a5:46:0f:0d:64:23:5c:6d:f3:ec:cc:28:63:23:c0:
+ 4b:9a:ec:8f:c1:ee:b1:a2:3e:72:4d:70:b5:09:c1:
+ eb:b4:10:55:3c:8b:ea:1b:94:7e:4b:74:e6:f4:9f:
+ 4f:a6:45:30:b5:f0:b8:b4:d1:59:50:65:0a:86:53:
+ ea:4c:9f:9e:f4:58:6c:31:f5:17:3a:6f:57:8b:cb:
+ 5f:f0:28:0b:45:92:8d:30:20:49:ff:52:e6:2c:cb:
+ 18:9a:d7:e6:ee:3e:4f:34:35:15:13:c5:02:da:c5:
+ 5f:be:fb:5b:ce:8d:bf:b5:35:76:3c:7c:e6:9c:3b:
+ 26:87:4d:8d:80:e6:16:c6:27:f2:50:49:b6:72:74:
+ 43:49:49:44:38:bb:78:43:23:ee:16:3e:d9:62:e6:
+ a5:d7
+ Exponent: 65537 (0x10001)
+ X509v3 extensions:
+ X509v3 Basic Constraints:
+ CA:FALSE
+ X509v3 Subject Key Identifier:
+ 2D:98:A3:99:39:1C:FE:E9:9A:6D:17:94:D2:3A:96:EE:C8:9E:04:22
+ X509v3 Authority Key Identifier:
+ keyid:6A:AB:53:64:92:36:87:23:34:B3:1D:6F:85:4B:F5:DF:5A:5C:74:8F
+
+ X509v3 Key Usage:
+ Digital Signature, Non Repudiation, Key Encipherment
+ X509v3 Extended Key Usage:
+ TLS Web Server Authentication
+ X509v3 Subject Alternative Name:
+ DNS:web-platform.test, DNS:www.web-platform.test, DNS:xn--n8j6ds53lwwkrqhv28a.web-platform.test, DNS:xn--lve-6lad.web-platform.test, DNS:www2.web-platform.test, DNS:www1.web-platform.test
+ Signature Algorithm: sha256WithRSAEncryption
+ 33:db:f7:f0:f6:92:16:4f:2d:42:bc:b8:aa:e6:ab:5e:f9:b9:
+ b0:48:ae:b5:8d:cc:02:7b:e9:6f:4e:75:f7:17:a0:5e:7b:87:
+ 06:49:48:83:c5:bb:ca:95:07:37:0e:5d:e3:97:de:9e:0c:a4:
+ 82:30:11:81:49:5d:50:29:72:92:a5:ca:17:b1:7c:f1:32:11:
+ 17:57:e6:59:c1:ac:e3:3b:26:d2:94:97:50:6a:b9:54:88:84:
+ 9b:6f:b1:06:f5:80:04:22:10:14:b1:f5:97:25:fc:66:d6:69:
+ a3:36:08:85:23:ff:8e:3c:2b:e0:6d:e7:61:f1:00:8f:61:3d:
+ b0:87:ad:72:21:f6:f0:cc:4f:c9:20:bf:83:11:0f:21:f4:b8:
+ c0:dd:9c:51:d7:bb:27:32:ec:ab:a4:62:14:28:32:da:f2:87:
+ 80:68:9c:ea:ac:eb:f5:7f:f5:de:f4:c0:39:91:c8:76:a4:ee:
+ d0:a8:50:db:c1:4b:f9:c4:3d:d9:e8:8e:b6:3f:c0:96:79:12:
+ d8:fa:4d:0a:b3:36:76:aa:4e:b2:82:2f:a2:d4:0d:db:fd:64:
+ 77:6f:6e:e9:94:7f:0f:c8:3a:3c:96:3d:cd:4d:6c:ba:66:95:
+ f7:b4:9d:a4:94:9f:97:b3:9a:0d:dc:18:8c:11:0b:56:65:8e:
+ 46:4c:e6:5e
+-----BEGIN CERTIFICATE-----
+MIID2jCCAsKgAwIBAgIBAjANBgkqhkiG9w0BAQsFADAdMRswGQYDVQQDDBJ3ZWIt
+cGxhdGZvcm0tdGVzdHMwHhcNMTQxMjIyMTIwOTE2WhcNMjQxMjIxMTIwOTE2WjAc
+MRowGAYDVQQDExF3ZWItcGxhdGZvcm0udGVzdDCCASIwDQYJKoZIhvcNAQEBBQAD
+ggEPADCCAQoCggEBALOE1osBWRiF0dwy3zj3kIUbPqVegT4v/Dpff3fvI7s6iCcP
+viVGzWN9y5XYpVAQ0qLSt5fRDWz7+QXob6hLvZVnnnuUWKltk/3gEsXNtIpkUjFf
+DuOJhHHamN1L7AIlpX01/mPas6zspUYPDWQjXG3z7MwoYyPAS5rsj8HusaI+ck1w
+tQnB67QQVTyL6huUfkt05vSfT6ZFMLXwuLTRWVBlCoZT6kyfnvRYbDH1FzpvV4vL
+X/AoC0WSjTAgSf9S5izLGJrX5u4+TzQ1FRPFAtrFX777W86Nv7U1djx85pw7JodN
+jYDmFsYn8lBJtnJ0Q0lJRDi7eEMj7hY+2WLmpdcCAwEAAaOCASQwggEgMAkGA1Ud
+EwQCMAAwHQYDVR0OBBYEFC2Yo5k5HP7pmm0XlNI6lu7IngQiMB8GA1UdIwQYMBaA
+FGqrU2SSNocjNLMdb4VL9d9aXHSPMAsGA1UdDwQEAwIF4DATBgNVHSUEDDAKBggr
+BgEFBQcDATCBsAYDVR0RBIGoMIGlghF3ZWItcGxhdGZvcm0udGVzdIIVd3d3Lndl
+Yi1wbGF0Zm9ybS50ZXN0gil4bi0tbjhqNmRzNTNsd3drcnFodjI4YS53ZWItcGxh
+dGZvcm0udGVzdIIeeG4tLWx2ZS02bGFkLndlYi1wbGF0Zm9ybS50ZXN0ghZ3d3cy
+LndlYi1wbGF0Zm9ybS50ZXN0ghZ3d3cxLndlYi1wbGF0Zm9ybS50ZXN0MA0GCSqG
+SIb3DQEBCwUAA4IBAQAz2/fw9pIWTy1CvLiq5qte+bmwSK61jcwCe+lvTnX3F6Be
+e4cGSUiDxbvKlQc3Dl3jl96eDKSCMBGBSV1QKXKSpcoXsXzxMhEXV+ZZwazjOybS
+lJdQarlUiISbb7EG9YAEIhAUsfWXJfxm1mmjNgiFI/+OPCvgbedh8QCPYT2wh61y
+IfbwzE/JIL+DEQ8h9LjA3ZxR17snMuyrpGIUKDLa8oeAaJzqrOv1f/Xe9MA5kch2
+pO7QqFDbwUv5xD3Z6I62P8CWeRLY+k0KszZ2qk6ygi+i1A3b/WR3b27plH8PyDo8
+lj3NTWy6ZpX3tJ2klJ+Xs5oN3BiMEQtWZY5GTOZe
+-----END CERTIFICATE----- \ No newline at end of file
diff --git a/testing/marionette/harness/marionette_harness/certificates/test.key b/testing/marionette/harness/marionette_harness/certificates/test.key
new file mode 100644
index 0000000000..194a49ec42
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/certificates/test.key
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCzhNaLAVkYhdHc
+Mt8495CFGz6lXoE+L/w6X3937yO7OognD74lRs1jfcuV2KVQENKi0reX0Q1s+/kF
+6G+oS72VZ557lFipbZP94BLFzbSKZFIxXw7jiYRx2pjdS+wCJaV9Nf5j2rOs7KVG
+Dw1kI1xt8+zMKGMjwEua7I/B7rGiPnJNcLUJweu0EFU8i+oblH5LdOb0n0+mRTC1
+8Li00VlQZQqGU+pMn570WGwx9Rc6b1eLy1/wKAtFko0wIEn/UuYsyxia1+buPk80
+NRUTxQLaxV+++1vOjb+1NXY8fOacOyaHTY2A5hbGJ/JQSbZydENJSUQ4u3hDI+4W
+Ptli5qXXAgMBAAECggEBAIcwDQSnIjo2ZECHytQykpG6X6XXEksLhc1Lp0lhPC49
+uNR5pX6a4AcBb3PLr0opMQZO2tUoKA0ff3t0e8loKD+/xXhY0Z/dlioEOP7elwv0
+2nS1mhe9spCuxpk4GGXRhdtR8t2tj8s0do3YvgPgITXoEDX6YBZHNGhZpzSrFPgQ
+/c3eGCVmzWYuLFfdj5OPQ9bwTaY4JSvDLZT0/WTgiica7VySwfz3HP1fFqNykTiK
+ACQREvtxfk5Ym2nT6oni7CM2zOEJL9SXicXI5HO4bERH0ZYh//F3g6mwGiFXUJPd
+NKgaTM1oT9kRGkUaEYsRWrddwR8d5mXLvBuTJbgIsSECgYEA1+2uJSYRW1OqbhYP
+ms59YQHSs3VjpJpnCV2zNa2Wixs57KS2cOH7B6KrQCogJFLtgCDVLtyoErfVkD7E
+FivTgYr1pVCRppJddQzXik31uOINOBVffr7/09g3GcRN+ubHPZPq3K+dD6gHa3Aj
+0nH1EjEEV0QpSTQFn87OF2mc9wcCgYEA1NVqMbbzd+9Xft5FXuSbX6E+S02dOGat
+SgpnkTM80rjqa6eHdQzqk3JqyteHPgdi1vdYRlSPOj/X+6tySY0Ej9sRnYOfddA2
+kpiDiVkmiqVolyJPY69Utj+E3TzJ1vhCQuYknJmB7zP9tDcTxMeq0l/NaWvGshEK
+yC4UTQog1rECgYASOFILfGzWgfbNlzr12xqlRtwanHst9oFfPvLSQrWDQ2bd2wAy
+Aj+GY2mD3oobxouX1i1m6OOdwLlalJFDNauBMNKNgoDnx03vhIfjebSURy7KXrNS
+JJe9rm7n07KoyzRgs8yLlp3wJkOKA0pihY8iW9R78JpzPNqEo5SsURMXnQKBgBlV
+gfuC9H4tPjP6zzUZbyk1701VYsaI6k2q6WMOP0ox+q1v1p7nN7DvaKjWeOG4TVqb
+PKW6gQYE/XeWk9cPcyCQigs+1KdYbnaKsvWRaBYO1GFREzQhdarv6qfPCZOOH40J
+Cgid+Sp4/NULzU2aGspJ3xCSZKdjge4MFhyJfRkxAoGBAJlwqY4nue0MBLGNpqcs
+WwDtSasHvegKAcxGBKL5oWPbLBk7hk+hdqc8f6YqCkCNqv/ooBspL15ESItL+6yT
+zt0YkK4oH9tmLDb+rvqZ7ZdXbWSwKITMoCyyHUtT6OKt/RtA0Vdy9LPnP27oSO/C
+dk8Qf7KgKZLWo0ZNkvw38tEC
+-----END PRIVATE KEY----- \ No newline at end of file
diff --git a/testing/marionette/harness/marionette_harness/marionette_test/__init__.py b/testing/marionette/harness/marionette_harness/marionette_test/__init__.py
new file mode 100644
index 0000000000..6e3c42c4d6
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/marionette_test/__init__.py
@@ -0,0 +1,30 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+__version__ = "3.1.0"
+
+from unittest.case import (
+ expectedFailure,
+ skip,
+ SkipTest,
+)
+
+from .decorators import (
+ parameterized,
+ run_if_manage_instance,
+ skip_if_chrome,
+ skip_if_desktop,
+ skip_if_framescript,
+ skip_unless_browser_pref,
+ skip_unless_protocol,
+ with_parameters,
+)
+
+from .testcases import (
+ CommonTestCase,
+ MarionetteTestCase,
+ MetaParameterized,
+)
diff --git a/testing/marionette/harness/marionette_harness/marionette_test/decorators.py b/testing/marionette/harness/marionette_harness/marionette_test/decorators.py
new file mode 100644
index 0000000000..8896364c0d
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/marionette_test/decorators.py
@@ -0,0 +1,215 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import functools
+import types
+
+from unittest.case import SkipTest
+
+
+def parameterized(func_suffix, *args, **kwargs):
+ r"""Decorator which generates methods given a base method and some data.
+
+ **func_suffix** is used as a suffix for the new created method and must be
+ unique given a base method. if **func_suffix** countains characters that
+ are not allowed in normal python function name, these characters will be
+ replaced with "_".
+
+ This decorator can be used more than once on a single base method. The class
+ must have a metaclass of :class:`MetaParameterized`.
+
+ Example::
+
+ # This example will generate two methods:
+ #
+ # - MyTestCase.test_it_1
+ # - MyTestCase.test_it_2
+ #
+ class MyTestCase(MarionetteTestCase):
+ @parameterized("1", 5, named='name')
+ @parameterized("2", 6, named='name2')
+ def test_it(self, value, named=None):
+ print value, named
+
+ :param func_suffix: will be used as a suffix for the new method
+ :param \*args: arguments to pass to the new method
+ :param \*\*kwargs: named arguments to pass to the new method
+ """
+
+ def wrapped(func):
+ if not hasattr(func, "metaparameters"):
+ func.metaparameters = []
+ func.metaparameters.append((func_suffix, args, kwargs))
+ return func
+
+ return wrapped
+
+
+def run_if_manage_instance(reason):
+ """Decorator which runs a test if Marionette manages the application instance."""
+
+ def decorator(test_item):
+ if not isinstance(test_item, types.FunctionType):
+ raise Exception("Decorator only supported for functions")
+
+ @functools.wraps(test_item)
+ def skip_wrapper(self, *args, **kwargs):
+ if self.marionette.instance is None:
+ raise SkipTest(reason)
+ return test_item(self, *args, **kwargs)
+
+ return skip_wrapper
+
+ return decorator
+
+
+def skip_if_chrome(reason):
+ """Decorator which skips a test if chrome context is active."""
+
+ def decorator(test_item):
+ if not isinstance(test_item, types.FunctionType):
+ raise Exception("Decorator only supported for functions")
+
+ @functools.wraps(test_item)
+ def skip_wrapper(self, *args, **kwargs):
+ if self.marionette._send_message("getContext", key="value") == "chrome":
+ raise SkipTest(reason)
+ return test_item(self, *args, **kwargs)
+
+ return skip_wrapper
+
+ return decorator
+
+
+def skip_if_desktop(reason):
+ """Decorator which skips a test if run on desktop."""
+
+ def decorator(test_item):
+ if not isinstance(test_item, types.FunctionType):
+ raise Exception("Decorator only supported for functions")
+
+ @functools.wraps(test_item)
+ def skip_wrapper(self, *args, **kwargs):
+ if self.marionette.session_capabilities.get("browserName") == "firefox":
+ raise SkipTest(reason)
+ return test_item(self, *args, **kwargs)
+
+ return skip_wrapper
+
+ return decorator
+
+
+def skip_if_framescript(reason):
+ """Decorator which skips a test if the framescript implementation is used."""
+
+ def decorator(test_item):
+ if not isinstance(test_item, types.FunctionType):
+ raise Exception("Decorator only supported for functions")
+
+ @functools.wraps(test_item)
+ def skip_wrapper(self, *args, **kwargs):
+ if self.marionette.get_pref("marionette.actors.enabled") is False:
+ raise SkipTest(reason)
+ return test_item(self, *args, **kwargs)
+
+ return skip_wrapper
+
+ return decorator
+
+
+def skip_unless_browser_pref(reason, pref, predicate=bool):
+ """Decorator which skips a test based on the value of a browser preference.
+
+ :param reason: Message describing why the test need to be skipped.
+ :param pref: the preference name
+ :param predicate: a function that should return false to skip the test.
+ The function takes one parameter, the preference value.
+ Defaults to the python built-in bool function.
+
+ Note that the preference must exist, else a failure is raised.
+
+ Example: ::
+
+ class TestSomething(MarionetteTestCase):
+ @skip_unless_browser_pref("Sessionstore needs to be enabled for crashes",
+ "browser.sessionstore.resume_from_crash",
+ lambda value: value is True,
+ )
+ def test_foo(self):
+ pass # test implementation here
+
+ """
+
+ def decorator(test_item):
+ if not isinstance(test_item, types.FunctionType):
+ raise Exception("Decorator only supported for functions")
+ if not callable(predicate):
+ raise ValueError("predicate must be callable")
+
+ @functools.wraps(test_item)
+ def skip_wrapper(self, *args, **kwargs):
+ value = self.marionette.get_pref(pref)
+ if value is None:
+ self.fail("No such browser preference: {0!r}".format(pref))
+ if not predicate(value):
+ raise SkipTest(reason)
+ return test_item(self, *args, **kwargs)
+
+ return skip_wrapper
+
+ return decorator
+
+
+def skip_unless_protocol(reason, predicate):
+ """Decorator which skips a test if the predicate does not match the current protocol level."""
+
+ def decorator(test_item):
+ if not isinstance(test_item, types.FunctionType):
+ raise Exception("Decorator only supported for functions")
+ if not callable(predicate):
+ raise ValueError("predicate must be callable")
+
+ @functools.wraps(test_item)
+ def skip_wrapper(self, *args, **kwargs):
+ level = self.marionette.client.protocol
+ if not predicate(level):
+ raise SkipTest(reason)
+ return test_item(self, *args, **kwargs)
+
+ return skip_wrapper
+
+ return decorator
+
+
+def with_parameters(parameters):
+ """Decorator which generates methods given a base method and some data.
+
+ Acts like :func:`parameterized`, but define all methods in one call.
+
+ Example::
+
+ # This example will generate two methods:
+ #
+ # - MyTestCase.test_it_1
+ # - MyTestCase.test_it_2
+ #
+
+ DATA = [("1", [5], {'named':'name'}), ("2", [6], {'named':'name2'})]
+
+ class MyTestCase(MarionetteTestCase):
+ @with_parameters(DATA)
+ def test_it(self, value, named=None):
+ print value, named
+
+ :param parameters: list of tuples (**func_suffix**, **args**, **kwargs**)
+ defining parameters like in :func:`todo`.
+ """
+
+ def wrapped(func):
+ func.metaparameters = parameters
+ return func
+
+ return wrapped
diff --git a/testing/marionette/harness/marionette_harness/marionette_test/testcases.py b/testing/marionette/harness/marionette_harness/marionette_test/testcases.py
new file mode 100644
index 0000000000..0aa1c6f745
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/marionette_test/testcases.py
@@ -0,0 +1,438 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import imp
+import os
+import re
+import sys
+import time
+import unittest
+import warnings
+import weakref
+
+from unittest.case import SkipTest
+
+import six
+
+from marionette_driver.errors import TimeoutException, UnresponsiveInstanceException
+from mozlog import get_default_logger
+
+
+# ExpectedFailure and UnexpectedSuccess are adapted from the Python 2
+# private classes _ExpectedFailure and _UnexpectedSuccess in
+# unittest/case.py which are no longer available in Python 3.
+class ExpectedFailure(Exception):
+ """
+ Raise this when a test is expected to fail.
+
+ This is an implementation detail.
+ """
+
+ def __init__(self, exc_info):
+ super(ExpectedFailure, self).__init__()
+ self.exc_info = exc_info
+
+
+class UnexpectedSuccess(Exception):
+ """
+ The test was supposed to fail, but it didn't!
+ """
+
+ pass
+
+
+try:
+ # Since these errors can be thrown during execution under Python 2
+ # we must support them until Marionette completes its transition
+ # from Python 2 to Python 3.
+ from unittest.case import (
+ _ExpectedFailure,
+ _UnexpectedSuccess,
+ )
+except ImportError:
+ _ExpectedFailure = ExpectedFailure
+ _UnexpectedSuccess = UnexpectedSuccess
+
+
+def _wraps_parameterized(func, func_suffix, args, kwargs):
+ """Internal: Decorator used in class MetaParameterized."""
+
+ def wrapper(self):
+ return func(self, *args, **kwargs)
+
+ wrapper.__name__ = func.__name__ + "_" + str(func_suffix)
+ wrapper.__doc__ = "[{0}] {1}".format(func_suffix, func.__doc__)
+ return wrapper
+
+
+class MetaParameterized(type):
+ """
+ A metaclass that allow a class to use decorators.
+
+ It can be used like :func:`parameterized`
+ or :func:`with_parameters` to generate new methods.
+ """
+
+ RE_ESCAPE_BAD_CHARS = re.compile(r"[\.\(\) -/]")
+
+ def __new__(cls, name, bases, attrs):
+ for k, v in list(attrs.items()):
+ if callable(v) and hasattr(v, "metaparameters"):
+ for func_suffix, args, kwargs in v.metaparameters:
+ func_suffix = cls.RE_ESCAPE_BAD_CHARS.sub("_", func_suffix)
+ wrapper = _wraps_parameterized(v, func_suffix, args, kwargs)
+ if wrapper.__name__ in attrs:
+ raise KeyError(
+ "{0} is already a defined method on {1}".format(
+ wrapper.__name__, name
+ )
+ )
+ attrs[wrapper.__name__] = wrapper
+ del attrs[k]
+
+ return type.__new__(cls, name, bases, attrs)
+
+
+@six.add_metaclass(MetaParameterized)
+class CommonTestCase(unittest.TestCase):
+
+ match_re = None
+ failureException = AssertionError
+ pydebugger = None
+
+ def __init__(self, methodName, marionette_weakref, fixtures, **kwargs):
+ super(CommonTestCase, self).__init__(methodName)
+ self.methodName = methodName
+
+ self._marionette_weakref = marionette_weakref
+ self.fixtures = fixtures
+
+ self.duration = 0
+ self.start_time = 0
+ self.expected = kwargs.pop("expected", "pass")
+ self.logger = get_default_logger()
+
+ def _enter_pm(self):
+ if self.pydebugger:
+ self.pydebugger.post_mortem(sys.exc_info()[2])
+
+ def _addSkip(self, result, reason):
+ addSkip = getattr(result, "addSkip", None)
+ if addSkip is not None:
+ addSkip(self, reason)
+ else:
+ warnings.warn(
+ "TestResult has no addSkip method, skips not reported",
+ RuntimeWarning,
+ 2,
+ )
+ result.addSuccess(self)
+
+ def assertRaisesRegxp(
+ self, expected_exception, expected_regexp, callable_obj=None, *args, **kwargs
+ ):
+ return six.assertRaisesRegex(
+ self,
+ expected_exception,
+ expected_regexp,
+ callable_obj=None,
+ *args,
+ **kwargs
+ )
+
+ def run(self, result=None):
+ # Bug 967566 suggests refactoring run, which would hopefully
+ # mean getting rid of this inner function, which only sits
+ # here to reduce code duplication:
+ def expected_failure(result, exc_info):
+ addExpectedFailure = getattr(result, "addExpectedFailure", None)
+ if addExpectedFailure is not None:
+ addExpectedFailure(self, exc_info)
+ else:
+ warnings.warn(
+ "TestResult has no addExpectedFailure method, "
+ "reporting as passes",
+ RuntimeWarning,
+ )
+ result.addSuccess(self)
+
+ self.start_time = time.time()
+ orig_result = result
+ if result is None:
+ result = self.defaultTestResult()
+ startTestRun = getattr(result, "startTestRun", None)
+ if startTestRun is not None:
+ startTestRun()
+
+ result.startTest(self)
+
+ testMethod = getattr(self, self._testMethodName)
+ if getattr(self.__class__, "__unittest_skip__", False) or getattr(
+ testMethod, "__unittest_skip__", False
+ ):
+ # If the class or method was skipped.
+ try:
+ skip_why = getattr(
+ self.__class__, "__unittest_skip_why__", ""
+ ) or getattr(testMethod, "__unittest_skip_why__", "")
+ self._addSkip(result, skip_why)
+ finally:
+ result.stopTest(self)
+ self.stop_time = time.time()
+ return
+ try:
+ success = False
+ try:
+ if self.expected == "fail":
+ try:
+ self.setUp()
+ except Exception:
+ raise _ExpectedFailure(sys.exc_info())
+ else:
+ self.setUp()
+ except SkipTest as e:
+ self._addSkip(result, str(e))
+ except (KeyboardInterrupt, UnresponsiveInstanceException):
+ raise
+ except _ExpectedFailure as e:
+ expected_failure(result, e.exc_info)
+ except Exception:
+ self._enter_pm()
+ result.addError(self, sys.exc_info())
+ else:
+ try:
+ if self.expected == "fail":
+ try:
+ testMethod()
+ except Exception:
+ raise _ExpectedFailure(sys.exc_info())
+ raise _UnexpectedSuccess
+ else:
+ testMethod()
+ except self.failureException:
+ self._enter_pm()
+ result.addFailure(self, sys.exc_info())
+ except (KeyboardInterrupt, UnresponsiveInstanceException):
+ raise
+ except _ExpectedFailure as e:
+ expected_failure(result, e.exc_info)
+ except _UnexpectedSuccess:
+ addUnexpectedSuccess = getattr(result, "addUnexpectedSuccess", None)
+ if addUnexpectedSuccess is not None:
+ addUnexpectedSuccess(self)
+ else:
+ warnings.warn(
+ "TestResult has no addUnexpectedSuccess method, "
+ "reporting as failures",
+ RuntimeWarning,
+ )
+ result.addFailure(self, sys.exc_info())
+ except SkipTest as e:
+ self._addSkip(result, str(e))
+ except Exception:
+ self._enter_pm()
+ result.addError(self, sys.exc_info())
+ else:
+ success = True
+ try:
+ if self.expected == "fail":
+ try:
+ self.tearDown()
+ except Exception:
+ raise _ExpectedFailure(sys.exc_info())
+ else:
+ self.tearDown()
+ except (KeyboardInterrupt, UnresponsiveInstanceException):
+ raise
+ except _ExpectedFailure as e:
+ expected_failure(result, e.exc_info)
+ except Exception:
+ self._enter_pm()
+ result.addError(self, sys.exc_info())
+ success = False
+ # Here we could handle doCleanups() instead of calling cleanTest directly
+ self.cleanTest()
+
+ if success:
+ result.addSuccess(self)
+
+ finally:
+ result.stopTest(self)
+ if orig_result is None:
+ stopTestRun = getattr(result, "stopTestRun", None)
+ if stopTestRun is not None:
+ stopTestRun()
+
+ @classmethod
+ def match(cls, filename):
+ """Determine if the specified filename should be handled by this test class.
+
+ This is done by looking for a match for the filename using cls.match_re.
+ """
+ if not cls.match_re:
+ return False
+ m = cls.match_re.match(filename)
+ return m is not None
+
+ @classmethod
+ def add_tests_to_suite(
+ cls,
+ mod_name,
+ filepath,
+ suite,
+ testloader,
+ marionette,
+ fixtures,
+ testvars,
+ **kwargs
+ ):
+ """Add all the tests in the specified file to the specified suite."""
+ raise NotImplementedError
+
+ @property
+ def test_name(self):
+ rel_path = None
+ if os.path.exists(self.filepath):
+ rel_path = self._fix_test_path(self.filepath)
+
+ return "{0} {1}.{2}".format(
+ rel_path, self.__class__.__name__, self._testMethodName
+ )
+
+ def id(self):
+ # TBPL starring requires that the "test name" field of a failure message
+ # not differ over time. The test name to be used is passed to
+ # mozlog via the test id, so this is overriden to maintain
+ # consistency.
+ return self.test_name
+
+ def setUp(self):
+ # Convert the marionette weakref to an object, just for the
+ # duration of the test; this is deleted in tearDown() to prevent
+ # a persistent circular reference which in turn would prevent
+ # proper garbage collection.
+ self.start_time = time.time()
+ self.marionette = self._marionette_weakref()
+ if self.marionette.session is None:
+ self.marionette.start_session()
+ self.marionette.timeout.reset()
+
+ super(CommonTestCase, self).setUp()
+
+ def cleanTest(self):
+ self._delete_session()
+
+ def _delete_session(self):
+ if hasattr(self, "start_time"):
+ self.duration = time.time() - self.start_time
+ if self.marionette.session is not None:
+ try:
+ self.marionette.delete_session()
+ except IOError:
+ # Gecko has crashed?
+ pass
+ self.marionette = None
+
+ def _fix_test_path(self, path):
+ """Normalize a logged test path from the test package."""
+ test_path_prefixes = [
+ "tests{}".format(os.path.sep),
+ ]
+
+ path = os.path.relpath(path)
+ for prefix in test_path_prefixes:
+ if path.startswith(prefix):
+ path = path[len(prefix) :]
+ break
+ path = path.replace("\\", "/")
+
+ return path
+
+
+class MarionetteTestCase(CommonTestCase):
+
+ match_re = re.compile(r"test_(.*)\.py$")
+
+ def __init__(
+ self, marionette_weakref, fixtures, methodName="runTest", filepath="", **kwargs
+ ):
+ self.filepath = filepath
+ self.testvars = kwargs.pop("testvars", None)
+
+ super(MarionetteTestCase, self).__init__(
+ methodName,
+ marionette_weakref=marionette_weakref,
+ fixtures=fixtures,
+ **kwargs
+ )
+
+ @classmethod
+ def add_tests_to_suite(
+ cls,
+ mod_name,
+ filepath,
+ suite,
+ testloader,
+ marionette,
+ fixtures,
+ testvars,
+ **kwargs
+ ):
+ # since we use imp.load_source to load test modules, if a module
+ # is loaded with the same name as another one the module would just be
+ # reloaded.
+ #
+ # We may end up by finding too many test in a module then since
+ # reload() only update the module dict (so old keys are still there!)
+ # see https://docs.python.org/2/library/functions.html#reload
+ #
+ # we get rid of that by removing the module from sys.modules,
+ # so we ensure that it will be fully loaded by the
+ # imp.load_source call.
+ if mod_name in sys.modules:
+ del sys.modules[mod_name]
+
+ test_mod = imp.load_source(mod_name, filepath)
+
+ for name in dir(test_mod):
+ obj = getattr(test_mod, name)
+ if isinstance(obj, six.class_types) and issubclass(obj, unittest.TestCase):
+ testnames = testloader.getTestCaseNames(obj)
+ for testname in testnames:
+ suite.addTest(
+ obj(
+ weakref.ref(marionette),
+ fixtures,
+ methodName=testname,
+ filepath=filepath,
+ testvars=testvars,
+ **kwargs
+ )
+ )
+
+ def setUp(self):
+ super(MarionetteTestCase, self).setUp()
+ self.marionette.test_name = self.test_name
+
+ def tearDown(self):
+ # In the case no session is active (eg. the application was quit), start
+ # a new session for clean-up steps.
+ if not self.marionette.session:
+ self.marionette.start_session()
+
+ self.marionette.test_name = None
+
+ super(MarionetteTestCase, self).tearDown()
+
+ def wait_for_condition(self, method, timeout=30):
+ timeout = float(timeout) + time.time()
+ while time.time() < timeout:
+ value = method(self.marionette)
+ if value:
+ return value
+ time.sleep(0.5)
+ else:
+ raise TimeoutException("wait_for_condition timed out")
diff --git a/testing/marionette/harness/marionette_harness/runner/__init__.py b/testing/marionette/harness/marionette_harness/runner/__init__.py
new file mode 100644
index 0000000000..bf5dfd9977
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/runner/__init__.py
@@ -0,0 +1,19 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from .base import (
+ BaseMarionetteArguments,
+ BaseMarionetteTestRunner,
+ Marionette,
+ MarionetteTest,
+ MarionetteTestResult,
+ MarionetteTextTestRunner,
+ TestManifest,
+ TestResult,
+ TestResultCollection,
+)
+
+from .mixins import WindowManagerMixin
diff --git a/testing/marionette/harness/marionette_harness/runner/base.py b/testing/marionette/harness/marionette_harness/runner/base.py
new file mode 100644
index 0000000000..926aa12c35
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/runner/base.py
@@ -0,0 +1,1309 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import json
+import os
+import random
+import re
+import socket
+import sys
+import time
+import traceback
+import unittest
+
+from argparse import ArgumentParser
+from collections import defaultdict
+from copy import deepcopy
+
+import six
+
+import mozinfo
+import moznetwork
+import mozprofile
+import mozversion
+
+from manifestparser import TestManifest
+from manifestparser.filters import tags
+from marionette_driver.marionette import Marionette
+from moztest.adapters.unit import StructuredTestResult, StructuredTestRunner
+from moztest.results import TestResult, TestResultCollection, relevant_line
+
+from six import reraise, MAXSIZE
+
+from . import serve
+
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+
+def update_mozinfo(path=None):
+ """Walk up directories to find mozinfo.json and update the info."""
+ path = path or here
+ dirs = set()
+ while path != os.path.expanduser("~"):
+ if path in dirs:
+ break
+ dirs.add(path)
+ path = os.path.split(path)[0]
+
+ return mozinfo.find_and_update_from_json(*dirs)
+
+
+class MarionetteTest(TestResult):
+ @property
+ def test_name(self):
+ if self.test_class is not None:
+ return "{0}.py {1}.{2}".format(
+ self.test_class.split(".")[0], self.test_class, self.name
+ )
+ else:
+ return self.name
+
+
+class MarionetteTestResult(StructuredTestResult, TestResultCollection):
+
+ resultClass = MarionetteTest
+
+ def __init__(self, *args, **kwargs):
+ self.marionette = kwargs.pop("marionette")
+ TestResultCollection.__init__(self, "MarionetteTest")
+ self.passed = 0
+ self.testsRun = 0
+ self.result_modifiers = [] # used by mixins to modify the result
+ StructuredTestResult.__init__(self, *args, **kwargs)
+
+ @property
+ def skipped(self):
+ return [t for t in self if t.result == "SKIPPED"]
+
+ @skipped.setter
+ def skipped(self, value):
+ pass
+
+ @property
+ def expectedFailures(self):
+ return [t for t in self if t.result == "KNOWN-FAIL"]
+
+ @expectedFailures.setter
+ def expectedFailures(self, value):
+ pass
+
+ @property
+ def unexpectedSuccesses(self):
+ return [t for t in self if t.result == "UNEXPECTED-PASS"]
+
+ @unexpectedSuccesses.setter
+ def unexpectedSuccesses(self, value):
+ pass
+
+ @property
+ def tests_passed(self):
+ return [t for t in self if t.result == "PASS"]
+
+ @property
+ def errors(self):
+ return [t for t in self if t.result == "ERROR"]
+
+ @errors.setter
+ def errors(self, value):
+ pass
+
+ @property
+ def failures(self):
+ return [t for t in self if t.result == "UNEXPECTED-FAIL"]
+
+ @failures.setter
+ def failures(self, value):
+ pass
+
+ @property
+ def duration(self):
+ if self.stop_time:
+ return self.stop_time - self.start_time
+ else:
+ return 0
+
+ def add_test_result(
+ self,
+ test,
+ result_expected="PASS",
+ result_actual="PASS",
+ output="",
+ context=None,
+ **kwargs
+ ):
+ def get_class(test):
+ return test.__class__.__module__ + "." + test.__class__.__name__
+
+ name = str(test).split()[0]
+ test_class = get_class(test)
+ if hasattr(test, "jsFile"):
+ name = os.path.basename(test.jsFile)
+ test_class = None
+
+ t = self.resultClass(
+ name=name,
+ test_class=test_class,
+ time_start=test.start_time,
+ result_expected=result_expected,
+ context=context,
+ **kwargs
+ )
+ # call any registered result modifiers
+ for modifier in self.result_modifiers:
+ result_expected, result_actual, output, context = modifier(
+ t, result_expected, result_actual, output, context
+ )
+ t.finish(
+ result_actual,
+ time_end=time.time() if test.start_time else 0,
+ reason=relevant_line(output),
+ output=output,
+ )
+ self.append(t)
+
+ def addError(self, test, err):
+ self.add_test_result(
+ test, output=self._exc_info_to_string(err, test), result_actual="ERROR"
+ )
+ super(MarionetteTestResult, self).addError(test, err)
+
+ def addFailure(self, test, err):
+ self.add_test_result(
+ test,
+ output=self._exc_info_to_string(err, test),
+ result_actual="UNEXPECTED-FAIL",
+ )
+ super(MarionetteTestResult, self).addFailure(test, err)
+
+ def addSuccess(self, test):
+ self.passed += 1
+ self.add_test_result(test, result_actual="PASS")
+ super(MarionetteTestResult, self).addSuccess(test)
+
+ def addExpectedFailure(self, test, err):
+ """Called when an expected failure/error occured."""
+ self.add_test_result(
+ test, output=self._exc_info_to_string(err, test), result_actual="KNOWN-FAIL"
+ )
+ super(MarionetteTestResult, self).addExpectedFailure(test, err)
+
+ def addUnexpectedSuccess(self, test):
+ """Called when a test was expected to fail, but succeed."""
+ self.add_test_result(test, result_actual="UNEXPECTED-PASS")
+ super(MarionetteTestResult, self).addUnexpectedSuccess(test)
+
+ def addSkip(self, test, reason):
+ self.add_test_result(test, output=reason, result_actual="SKIPPED")
+ super(MarionetteTestResult, self).addSkip(test, reason)
+
+ def getInfo(self, test):
+ return test.test_name
+
+ def getDescription(self, test):
+ doc_first_line = test.shortDescription()
+ if self.descriptions and doc_first_line:
+ return "\n".join((str(test), doc_first_line))
+ else:
+ desc = str(test)
+ return desc
+
+ def printLogs(self, test):
+ for testcase in test._tests:
+ if hasattr(testcase, "loglines") and testcase.loglines:
+ # Don't dump loglines to the console if they only contain
+ # TEST-START and TEST-END.
+ skip_log = True
+ for line in testcase.loglines:
+ str_line = " ".join(line)
+ if "TEST-END" not in str_line and "TEST-START" not in str_line:
+ skip_log = False
+ break
+ if skip_log:
+ return
+ self.logger.info("START LOG:")
+ for line in testcase.loglines:
+ self.logger.info(" ".join(line).encode("ascii", "replace"))
+ self.logger.info("END LOG:")
+
+ def stopTest(self, *args, **kwargs):
+ unittest._TextTestResult.stopTest(self, *args, **kwargs)
+ if self.marionette.check_for_crash():
+ # this tells unittest.TestSuite not to continue running tests
+ self.shouldStop = True
+ test = next((a for a in args if isinstance(a, unittest.TestCase)), None)
+ if test:
+ self.addError(test, sys.exc_info())
+
+
+class MarionetteTextTestRunner(StructuredTestRunner):
+
+ resultclass = MarionetteTestResult
+
+ def __init__(self, **kwargs):
+ self.marionette = kwargs.pop("marionette")
+ self.capabilities = kwargs.pop("capabilities")
+
+ StructuredTestRunner.__init__(self, **kwargs)
+
+ def _makeResult(self):
+ return self.resultclass(
+ self.stream,
+ self.descriptions,
+ self.verbosity,
+ marionette=self.marionette,
+ logger=self.logger,
+ result_callbacks=self.result_callbacks,
+ )
+
+ def run(self, test):
+ result = super(MarionetteTextTestRunner, self).run(test)
+ result.printLogs(test)
+ return result
+
+
+class BaseMarionetteArguments(ArgumentParser):
+ def __init__(self, **kwargs):
+ ArgumentParser.__init__(self, **kwargs)
+
+ def dir_path(path):
+ path = os.path.abspath(os.path.expanduser(path))
+ if not os.access(path, os.F_OK):
+ os.makedirs(path)
+ return path
+
+ self.argument_containers = []
+ self.add_argument(
+ "tests",
+ nargs="*",
+ default=[],
+ help="Tests to run. "
+ "One or more paths to test files (Python or JS), "
+ "manifest files (.ini) or directories. "
+ "When a directory is specified, "
+ "all test files in the directory will be run.",
+ )
+ self.add_argument(
+ "--binary",
+ help="path to gecko executable to launch before running the test",
+ )
+ self.add_argument(
+ "--address", help="host:port of running Gecko instance to connect to"
+ )
+ self.add_argument(
+ "--emulator",
+ action="store_true",
+ help="If no --address is given, then the harness will launch an "
+ "emulator. (See Remote options group.) "
+ "If --address is given, then the harness assumes you are "
+ "running an emulator already, and will launch gecko app "
+ "on that emulator.",
+ )
+ self.add_argument(
+ "--app", help="application to use. see marionette_driver.geckoinstance"
+ )
+ self.add_argument(
+ "--app-arg",
+ dest="app_args",
+ action="append",
+ default=[],
+ help="specify a command line argument to be passed onto the application",
+ )
+ self.add_argument(
+ "--profile",
+ help="profile to use when launching the gecko process. If not passed, "
+ "then a profile will be constructed and used",
+ type=dir_path,
+ )
+ self.add_argument(
+ "--setpref",
+ action="append",
+ metavar="PREF=VALUE",
+ dest="prefs_args",
+ help="set a browser preference; repeat for multiple preferences.",
+ )
+ self.add_argument(
+ "--preferences",
+ action="append",
+ dest="prefs_files",
+ help="read preferences from a JSON or INI file. For INI, use "
+ "'file.ini:section' to specify a particular section.",
+ )
+ self.add_argument(
+ "--addon",
+ action="append",
+ dest="addons",
+ help="addon to install; repeat for multiple addons.",
+ )
+ self.add_argument(
+ "--repeat", type=int, help="number of times to repeat the test(s)"
+ )
+ self.add_argument(
+ "--run-until-failure",
+ action="store_true",
+ help="Run tests repeatedly and stop on the first time a test fails. "
+ "Default cap is 30 runs, which can be overwritten "
+ "with the --repeat parameter.",
+ )
+ self.add_argument(
+ "--testvars",
+ action="append",
+ help="path to a json file with any test data required",
+ )
+ self.add_argument(
+ "--symbols-path",
+ help="absolute path to directory containing breakpad symbols, or the "
+ "url of a zip file containing symbols",
+ )
+ self.add_argument(
+ "--socket-timeout",
+ type=float,
+ default=Marionette.DEFAULT_SOCKET_TIMEOUT,
+ help="Set the global timeout for marionette socket operations."
+ " Default: %(default)ss.",
+ )
+ self.add_argument(
+ "--startup-timeout",
+ type=int,
+ default=Marionette.DEFAULT_STARTUP_TIMEOUT,
+ help="the max number of seconds to wait for a Marionette connection "
+ "after launching a binary. Default: %(default)ss.",
+ )
+ self.add_argument(
+ "--shuffle",
+ action="store_true",
+ default=False,
+ help="run tests in a random order",
+ )
+ self.add_argument(
+ "--shuffle-seed",
+ type=int,
+ default=random.randint(0, MAXSIZE),
+ help="Use given seed to shuffle tests",
+ )
+ self.add_argument(
+ "--total-chunks",
+ type=int,
+ help="how many chunks to split the tests up into",
+ )
+ self.add_argument("--this-chunk", type=int, help="which chunk to run")
+ self.add_argument(
+ "--server-root",
+ help="url to a webserver or path to a document root from which content "
+ "resources are served (default: {}).".format(
+ os.path.join(os.path.dirname(here), "www")
+ ),
+ )
+ self.add_argument(
+ "--gecko-log",
+ help="Define the path to store log file. If the path is"
+ " a directory, the real log file will be created"
+ " given the format gecko-(timestamp).log. If it is"
+ " a file, if will be used directly. '-' may be passed"
+ " to write to stdout. Default: './gecko.log'",
+ )
+ self.add_argument(
+ "--logger-name",
+ default="Marionette-based Tests",
+ help="Define the name to associate with the logger used",
+ )
+ self.add_argument(
+ "--jsdebugger",
+ action="store_true",
+ default=False,
+ help="Enable the jsdebugger for marionette javascript.",
+ )
+ self.add_argument(
+ "--pydebugger",
+ help="Enable python post-mortem debugger when a test fails."
+ " Pass in the debugger you want to use, eg pdb or ipdb.",
+ )
+ self.add_argument(
+ "--disable-actors",
+ action="store_true",
+ dest="disable_actors",
+ default=False,
+ help="Disable the usage of JSWindowActors in Marionette.",
+ )
+ self.add_argument(
+ "--enable-fission",
+ action="store_true",
+ dest="enable_fission",
+ default=False,
+ help="Enable Fission (site isolation) in Gecko.",
+ )
+ self.add_argument(
+ "--enable-webrender",
+ action="store_true",
+ dest="enable_webrender",
+ default=False,
+ help="Enable the WebRender compositor in Gecko.",
+ )
+ self.add_argument(
+ "-z",
+ "--headless",
+ action="store_true",
+ dest="headless",
+ default=os.environ.get("MOZ_HEADLESS", False),
+ help="Run tests in headless mode.",
+ )
+ self.add_argument(
+ "--tag",
+ action="append",
+ dest="test_tags",
+ default=None,
+ help="Filter out tests that don't have the given tag. Can be "
+ "used multiple times in which case the test must contain "
+ "at least one of the given tags.",
+ )
+ self.add_argument(
+ "--workspace",
+ action="store",
+ default=None,
+ help="Path to directory for Marionette output. "
+ "(Default: .) (Default profile dest: TMP)",
+ type=dir_path,
+ )
+ self.add_argument(
+ "-v",
+ "--verbose",
+ action="count",
+ help="Increase verbosity to include debug messages with -v, "
+ "and trace messages with -vv.",
+ )
+ self.register_argument_container(RemoteMarionetteArguments())
+
+ def register_argument_container(self, container):
+ group = self.add_argument_group(container.name)
+
+ for cli, kwargs in container.args:
+ group.add_argument(*cli, **kwargs)
+
+ self.argument_containers.append(container)
+
+ def parse_known_args(self, args=None, namespace=None):
+ args, remainder = ArgumentParser.parse_known_args(self, args, namespace)
+ for container in self.argument_containers:
+ if hasattr(container, "parse_args_handler"):
+ container.parse_args_handler(args)
+ return (args, remainder)
+
+ def _get_preferences(self, prefs_files, prefs_args):
+ """Return user defined profile preferences as a dict."""
+ # object that will hold the preferences
+ prefs = mozprofile.prefs.Preferences()
+
+ # add preferences files
+ if prefs_files:
+ for prefs_file in prefs_files:
+ prefs.add_file(prefs_file)
+
+ separator = "="
+ cli_prefs = []
+ if prefs_args:
+ misformatted = []
+ for pref in prefs_args:
+ if separator not in pref:
+ misformatted.append(pref)
+ else:
+ cli_prefs.append(pref.split(separator, 1))
+ if misformatted:
+ self._print_message(
+ "Warning: Ignoring preferences not in key{}value format: {}\n".format(
+ separator, ", ".join(misformatted)
+ )
+ )
+ # string preferences
+ prefs.add(cli_prefs, cast=True)
+
+ return dict(prefs())
+
+ def verify_usage(self, args):
+ if not args.tests:
+ self.error(
+ "You must specify one or more test files, manifests, or directories."
+ )
+
+ missing_tests = [path for path in args.tests if not os.path.exists(path)]
+ if missing_tests:
+ self.error(
+ "Test file(s) not found: " + " ".join([path for path in missing_tests])
+ )
+
+ if not args.address and not args.binary and not args.emulator:
+ self.error("You must specify --binary, or --address, or --emulator")
+
+ if args.repeat is not None and args.repeat < 0:
+ self.error("The value of --repeat has to be equal or greater than 0.")
+
+ if args.total_chunks is not None and args.this_chunk is None:
+ self.error("You must specify which chunk to run.")
+
+ if args.this_chunk is not None and args.total_chunks is None:
+ self.error("You must specify how many chunks to split the tests into.")
+
+ if args.total_chunks is not None:
+ if not 1 < args.total_chunks:
+ self.error("Total chunks must be greater than 1.")
+ if not 1 <= args.this_chunk <= args.total_chunks:
+ self.error(
+ "Chunk to run must be between 1 and {}.".format(args.total_chunks)
+ )
+
+ if args.jsdebugger:
+ args.app_args.append("-jsdebugger")
+ args.socket_timeout = None
+
+ args.prefs = self._get_preferences(args.prefs_files, args.prefs_args)
+
+ for container in self.argument_containers:
+ if hasattr(container, "verify_usage_handler"):
+ container.verify_usage_handler(args)
+
+ return args
+
+
+class RemoteMarionetteArguments(object):
+ name = "Remote (Emulator/Device)"
+ args = [
+ [
+ ["--emulator-binary"],
+ {
+ "help": "Path to emulator binary. By default mozrunner uses `which emulator`",
+ "dest": "emulator_bin",
+ },
+ ],
+ [
+ ["--adb"],
+ {
+ "help": "Path to the adb. By default mozrunner uses `which adb`",
+ "dest": "adb_path",
+ },
+ ],
+ [
+ ["--avd"],
+ {
+ "help": (
+ "Name of an AVD available in your environment."
+ "See mozrunner.FennecEmulatorRunner"
+ ),
+ },
+ ],
+ [
+ ["--avd-home"],
+ {
+ "help": "Path to avd parent directory",
+ },
+ ],
+ [
+ ["--device"],
+ {
+ "help": (
+ "Serial ID to connect to as seen in `adb devices`,"
+ "e.g emulator-5444"
+ ),
+ "dest": "device_serial",
+ },
+ ],
+ [
+ ["--package"],
+ {
+ "help": "Name of Android package, e.g. org.mozilla.fennec",
+ "dest": "package_name",
+ },
+ ],
+ ]
+
+
+class Fixtures(object):
+ def where_is(self, uri, on="http"):
+ return serve.where_is(uri, on)
+
+
+class BaseMarionetteTestRunner(object):
+
+ textrunnerclass = MarionetteTextTestRunner
+ driverclass = Marionette
+
+ def __init__(
+ self,
+ address=None,
+ app=None,
+ app_args=None,
+ binary=None,
+ profile=None,
+ logger=None,
+ logdir=None,
+ repeat=None,
+ run_until_failure=None,
+ testvars=None,
+ symbols_path=None,
+ shuffle=False,
+ shuffle_seed=random.randint(0, MAXSIZE),
+ this_chunk=1,
+ total_chunks=1,
+ server_root=None,
+ gecko_log=None,
+ result_callbacks=None,
+ prefs=None,
+ test_tags=None,
+ socket_timeout=None,
+ startup_timeout=None,
+ addons=None,
+ workspace=None,
+ verbose=0,
+ emulator=False,
+ headless=False,
+ disable_actors=False,
+ enable_fission=False,
+ enable_webrender=False,
+ **kwargs
+ ):
+ self._appName = None
+ self._capabilities = None
+ self._filename_pattern = None
+ self._version_info = {}
+
+ self.fixture_servers = {}
+ self.fixtures = Fixtures()
+ self.extra_kwargs = kwargs
+ self.test_kwargs = deepcopy(kwargs)
+ self.address = address
+ self.app = app
+ self.app_args = app_args or []
+ self.bin = binary
+ self.emulator = emulator
+ self.profile = profile
+ self.addons = addons
+ self.logger = logger
+ self.marionette = None
+ self.logdir = logdir
+ self.repeat = repeat or 0
+ self.run_until_failure = run_until_failure or False
+ self.symbols_path = symbols_path
+ self.socket_timeout = socket_timeout
+ self.startup_timeout = startup_timeout
+ self.shuffle = shuffle
+ self.shuffle_seed = shuffle_seed
+ self.server_root = server_root
+ self.this_chunk = this_chunk
+ self.total_chunks = total_chunks
+ self.mixin_run_tests = []
+ self.manifest_skipped_tests = []
+ self.tests = []
+ self.result_callbacks = result_callbacks or []
+ self.prefs = prefs or {}
+ self.test_tags = test_tags
+ self.workspace = workspace
+ # If no workspace is set, default location for gecko.log is .
+ # and default location for profile is TMP
+ self.workspace_path = workspace or os.getcwd()
+ self.verbose = verbose
+ self.headless = headless
+ self.enable_webrender = enable_webrender
+
+ self.disable_actors = disable_actors
+ if self.disable_actors:
+ self.prefs.update(
+ {
+ "marionette.actors.enabled": False,
+ }
+ )
+
+ self.enable_fission = enable_fission
+ if self.enable_fission:
+ self.prefs.update(
+ {
+ "fission.autostart": True,
+ }
+ )
+
+ # If no repeat has been set, default to 30 extra runs
+ if self.run_until_failure and repeat is None:
+ self.repeat = 30
+
+ def gather_debug(test, status):
+ # No screenshots and page source for skipped tests
+ if status == "SKIP":
+ return
+
+ rv = {}
+ marionette = test._marionette_weakref()
+
+ # In the event we're gathering debug without starting a session,
+ # skip marionette commands
+ if marionette.session is not None:
+ try:
+ with marionette.using_context(marionette.CONTEXT_CHROME):
+ rv["screenshot"] = marionette.screenshot()
+ with marionette.using_context(marionette.CONTEXT_CONTENT):
+ rv["source"] = marionette.page_source
+ except Exception as exc:
+ self.logger.warning(
+ "Failed to gather test failure debug: {}".format(exc)
+ )
+ return rv
+
+ self.result_callbacks.append(gather_debug)
+
+ # testvars are set up in self.testvars property
+ self._testvars = None
+ self.testvars_paths = testvars
+
+ self.test_handlers = []
+
+ self.reset_test_stats()
+
+ self.logger.info(
+ "Using workspace for temporary data: " '"{}"'.format(self.workspace_path)
+ )
+
+ if not gecko_log:
+ self.gecko_log = os.path.join(self.workspace_path or "", "gecko.log")
+ else:
+ self.gecko_log = gecko_log
+
+ self.results = []
+
+ @property
+ def filename_pattern(self):
+ if self._filename_pattern is None:
+ self._filename_pattern = re.compile("^test(((_.+?)+?\.((py))))$")
+
+ return self._filename_pattern
+
+ @property
+ def testvars(self):
+ if self._testvars is not None:
+ return self._testvars
+
+ self._testvars = {}
+
+ def update(d, u):
+ """Update a dictionary that may contain nested dictionaries."""
+ for k, v in six.iteritems(u):
+ o = d.get(k, {})
+ if isinstance(v, dict) and isinstance(o, dict):
+ d[k] = update(d.get(k, {}), v)
+ else:
+ d[k] = u[k]
+ return d
+
+ json_testvars = self._load_testvars()
+ for j in json_testvars:
+ self._testvars = update(self._testvars, j)
+ return self._testvars
+
+ def _load_testvars(self):
+ data = []
+ if self.testvars_paths is not None:
+ for path in list(self.testvars_paths):
+ path = os.path.abspath(os.path.expanduser(path))
+ if not os.path.exists(path):
+ raise IOError("--testvars file {} does not exist".format(path))
+ try:
+ with open(path) as f:
+ data.append(json.loads(f.read()))
+ except ValueError as e:
+ msg = "JSON file ({0}) is not properly formatted: {1}"
+ reraise(
+ ValueError,
+ ValueError(msg.format(os.path.abspath(path), e)),
+ sys.exc_info()[2],
+ )
+ return data
+
+ @property
+ def capabilities(self):
+ if self._capabilities:
+ return self._capabilities
+
+ self.marionette.start_session()
+ self._capabilities = self.marionette.session_capabilities
+ self.marionette.delete_session()
+ return self._capabilities
+
+ @property
+ def appName(self):
+ if self._appName:
+ return self._appName
+
+ self._appName = self.capabilities.get("browserName")
+ return self._appName
+
+ @property
+ def bin(self):
+ return self._bin
+
+ @bin.setter
+ def bin(self, path):
+ """Set binary and reset parts of runner accordingly.
+ Intended use: to change binary between calls to run_tests
+ """
+ self._bin = path
+ self.tests = []
+ self.cleanup()
+
+ @property
+ def version_info(self):
+ if not self._version_info:
+ try:
+ # TODO: Get version_info in Fennec case
+ self._version_info = mozversion.get_version(binary=self.bin)
+ except Exception:
+ self.logger.warning(
+ "Failed to retrieve version information for {}".format(self.bin)
+ )
+ return self._version_info
+
+ def reset_test_stats(self):
+ self.passed = 0
+ self.failed = 0
+ self.crashed = 0
+ self.unexpected_successes = 0
+ self.todo = 0
+ self.skipped = 0
+ self.failures = []
+
+ def _build_kwargs(self):
+ if self.logdir and not os.access(self.logdir, os.F_OK):
+ os.mkdir(self.logdir)
+
+ kwargs = {
+ "socket_timeout": self.socket_timeout,
+ "prefs": self.prefs,
+ "startup_timeout": self.startup_timeout,
+ "verbose": self.verbose,
+ "symbols_path": self.symbols_path,
+ "enable_webrender": self.enable_webrender,
+ }
+ if self.bin or self.emulator:
+ kwargs.update(
+ {
+ "host": "127.0.0.1",
+ "port": 2828,
+ "app": self.app,
+ "app_args": self.app_args,
+ "profile": self.profile,
+ "addons": self.addons,
+ "gecko_log": self.gecko_log,
+ # ensure Marionette class takes care of starting gecko instance
+ "bin": True,
+ }
+ )
+
+ if self.bin:
+ kwargs.update(
+ {
+ "bin": self.bin,
+ }
+ )
+
+ if self.emulator:
+ kwargs.update(
+ {
+ "avd_home": self.extra_kwargs.get("avd_home"),
+ "adb_path": self.extra_kwargs.get("adb_path"),
+ "emulator_binary": self.extra_kwargs.get("emulator_bin"),
+ "avd": self.extra_kwargs.get("avd"),
+ "package_name": self.extra_kwargs.get("package_name"),
+ }
+ )
+
+ if self.address:
+ host, port = self.address.split(":")
+ kwargs.update(
+ {
+ "host": host,
+ "port": int(port),
+ }
+ )
+ if self.emulator:
+ kwargs.update(
+ {
+ "connect_to_running_emulator": True,
+ }
+ )
+ if not self.bin and not self.emulator:
+ try:
+ # Establish a socket connection so we can vertify the data come back
+ connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ connection.connect((host, int(port)))
+ connection.close()
+ except Exception as e:
+ exc_cls, _, tb = sys.exc_info()
+ msg = "Connection attempt to {0}:{1} failed with error: {2}"
+ reraise(exc_cls, exc_cls(msg.format(host, port, e)), tb)
+ if self.workspace:
+ kwargs["workspace"] = self.workspace_path
+ if self.headless:
+ kwargs["headless"] = True
+
+ return kwargs
+
+ def record_crash(self):
+ crash = True
+ try:
+ crash = self.marionette.check_for_crash()
+ self.crashed += int(crash)
+ except Exception:
+ traceback.print_exc()
+ return crash
+
+ def _initialize_test_run(self, tests):
+ assert len(tests) > 0
+ assert len(self.test_handlers) > 0
+ self.reset_test_stats()
+
+ def _add_tests(self, tests):
+ for test in tests:
+ self.add_test(test)
+
+ invalid_tests = [
+ t["filepath"]
+ for t in self.tests
+ if not self._is_filename_valid(t["filepath"])
+ ]
+ if invalid_tests:
+ raise Exception(
+ "Test file names must be of the form "
+ "'test_something.py'."
+ " Invalid test names:\n {}".format("\n ".join(invalid_tests))
+ )
+
+ def _is_filename_valid(self, filename):
+ filename = os.path.basename(filename)
+ return self.filename_pattern.match(filename)
+
+ def _fix_test_path(self, path):
+ """Normalize a logged test path from the test package."""
+ test_path_prefixes = [
+ "tests{}".format(os.path.sep),
+ ]
+
+ path = os.path.relpath(path)
+ for prefix in test_path_prefixes:
+ if path.startswith(prefix):
+ path = path[len(prefix) :]
+ break
+ path = path.replace("\\", "/")
+
+ return path
+
+ def _log_skipped_tests(self):
+ for test in self.manifest_skipped_tests:
+ rel_path = None
+ if os.path.exists(test["path"]):
+ rel_path = self._fix_test_path(test["path"])
+
+ self.logger.test_start(rel_path)
+ self.logger.test_end(rel_path, "SKIP", message=test["disabled"])
+ self.todo += 1
+
+ def run_tests(self, tests):
+ start_time = time.time()
+ self._initialize_test_run(tests)
+
+ if self.marionette is None:
+ self.marionette = self.driverclass(**self._build_kwargs())
+ self.logger.info("Profile path is %s" % self.marionette.profile_path)
+
+ if len(self.fixture_servers) == 0 or any(
+ not server.is_alive for _, server in self.fixture_servers
+ ):
+ self.logger.info("Starting fixture servers")
+ self.fixture_servers = self.start_fixture_servers()
+ for url in serve.iter_url(self.fixture_servers):
+ self.logger.info("Fixture server listening on %s" % url)
+
+ # backwards compatibility
+ self.marionette.baseurl = serve.where_is("/")
+
+ self._add_tests(tests)
+
+ device_info = None
+ if self.marionette.instance and self.emulator:
+ try:
+ device_info = self.marionette.instance.runner.device.device.get_info()
+ except Exception:
+ self.logger.warning("Could not get device info", exc_info=True)
+
+ tests_by_group = defaultdict(list)
+ for test in self.tests:
+ group = self._fix_test_path(test["group"])
+ filepath = self._fix_test_path(test["filepath"])
+ tests_by_group[group].append(filepath)
+
+ self.logger.suite_start(
+ tests_by_group,
+ name="marionette-test",
+ version_info=self.version_info,
+ device_info=device_info,
+ )
+
+ if self.shuffle:
+ self.logger.info("Using shuffle seed: %d" % self.shuffle_seed)
+
+ self._log_skipped_tests()
+
+ interrupted = None
+ try:
+ repeat_index = 0
+ while repeat_index <= self.repeat:
+ if repeat_index > 0:
+ self.logger.info("\nREPEAT {}\n-------".format(repeat_index))
+ self.run_test_sets()
+ if self.run_until_failure and self.failed > 0:
+ break
+
+ repeat_index += 1
+
+ except KeyboardInterrupt:
+ # in case of KeyboardInterrupt during the test execution
+ # we want to display current test results.
+ # so we keep the exception to raise it later.
+ interrupted = sys.exc_info()
+ except Exception:
+ # For any other exception we return immediately and have to
+ # cleanup running processes
+ self.cleanup()
+ raise
+
+ try:
+ self._print_summary(tests)
+ self.record_crash()
+ self.elapsedtime = time.time() - start_time
+
+ for run_tests in self.mixin_run_tests:
+ run_tests(tests)
+
+ self.logger.suite_end()
+ except Exception:
+ # raise only the exception if we were not interrupted
+ if not interrupted:
+ raise
+ finally:
+ self.cleanup()
+
+ # reraise previous interruption now
+ if interrupted:
+ reraise(interrupted[0], interrupted[1], interrupted[2])
+
+ def _print_summary(self, tests):
+ self.logger.info("\nSUMMARY\n-------")
+ self.logger.info("passed: {}".format(self.passed))
+ if self.unexpected_successes == 0:
+ self.logger.info("failed: {}".format(self.failed))
+ else:
+ self.logger.info(
+ "failed: {0} (unexpected sucesses: {1})".format(
+ self.failed, self.unexpected_successes
+ )
+ )
+ if self.skipped == 0:
+ self.logger.info("todo: {}".format(self.todo))
+ else:
+ self.logger.info("todo: {0} (skipped: {1})".format(self.todo, self.skipped))
+
+ if self.failed > 0:
+ self.logger.info("\nFAILED TESTS\n-------")
+ for failed_test in self.failures:
+ self.logger.info("{}".format(failed_test[0]))
+
+ def start_fixture_servers(self):
+ root = self.server_root or os.path.join(os.path.dirname(here), "www")
+ if self.appName == "fennec":
+ return serve.start(root, host=moznetwork.get_ip())
+ else:
+ return serve.start(root)
+
+ def add_test(self, test, expected="pass", group="default"):
+ filepath = os.path.abspath(test)
+
+ if os.path.isdir(filepath):
+ for root, dirs, files in os.walk(filepath):
+ for filename in files:
+ if filename.endswith(".ini"):
+ msg_tmpl = (
+ "Ignoring manifest '{0}'; running all tests in '{1}'."
+ " See --help for details."
+ )
+ relpath = os.path.relpath(
+ os.path.join(root, filename), filepath
+ )
+ self.logger.warning(msg_tmpl.format(relpath, filepath))
+ elif self._is_filename_valid(filename):
+ test_file = os.path.join(root, filename)
+ self.add_test(test_file)
+ return
+
+ file_ext = os.path.splitext(os.path.split(filepath)[-1])[1]
+
+ if file_ext == ".ini":
+ group = filepath
+
+ manifest = TestManifest()
+ manifest.read(filepath)
+
+ json_path = update_mozinfo(filepath)
+ mozinfo.update(
+ {
+ "appname": self.appName,
+ "manage_instance": self.marionette.instance is not None,
+ "headless": self.headless,
+ "actors": not self.disable_actors,
+ "webrender": self.enable_webrender,
+ }
+ )
+ self.logger.info("mozinfo updated from: {}".format(json_path))
+ self.logger.info("mozinfo is: {}".format(mozinfo.info))
+
+ filters = []
+ if self.test_tags:
+ filters.append(tags(self.test_tags))
+
+ manifest_tests = manifest.active_tests(
+ exists=False, disabled=True, filters=filters, **mozinfo.info
+ )
+ if len(manifest_tests) == 0:
+ self.logger.error(
+ "No tests to run using specified "
+ "combination of filters: {}".format(manifest.fmt_filters())
+ )
+
+ target_tests = []
+ for test in manifest_tests:
+ if test.get("disabled"):
+ self.manifest_skipped_tests.append(test)
+ else:
+ target_tests.append(test)
+
+ for i in target_tests:
+ if not os.path.exists(i["path"]):
+ raise IOError("test file: {} does not exist".format(i["path"]))
+
+ self.add_test(i["path"], i["expected"], group=group)
+ return
+
+ self.tests.append({"filepath": filepath, "expected": expected, "group": group})
+
+ def run_test(self, filepath, expected):
+ testloader = unittest.TestLoader()
+ suite = unittest.TestSuite()
+ self.test_kwargs["expected"] = expected
+ mod_name = os.path.splitext(os.path.split(filepath)[-1])[0]
+ for handler in self.test_handlers:
+ if handler.match(os.path.basename(filepath)):
+ handler.add_tests_to_suite(
+ mod_name,
+ filepath,
+ suite,
+ testloader,
+ self.marionette,
+ self.fixtures,
+ self.testvars,
+ **self.test_kwargs
+ )
+ break
+
+ if suite.countTestCases():
+ runner = self.textrunnerclass(
+ logger=self.logger,
+ marionette=self.marionette,
+ capabilities=self.capabilities,
+ result_callbacks=self.result_callbacks,
+ )
+
+ results = runner.run(suite)
+ self.results.append(results)
+
+ self.failed += len(results.failures) + len(results.errors)
+ if hasattr(results, "skipped"):
+ self.skipped += len(results.skipped)
+ self.todo += len(results.skipped)
+ self.passed += results.passed
+ for failure in results.failures + results.errors:
+ self.failures.append(
+ (results.getInfo(failure), failure.output, "TEST-UNEXPECTED-FAIL")
+ )
+ if hasattr(results, "unexpectedSuccesses"):
+ self.failed += len(results.unexpectedSuccesses)
+ self.unexpected_successes += len(results.unexpectedSuccesses)
+ for failure in results.unexpectedSuccesses:
+ self.failures.append(
+ (
+ results.getInfo(failure),
+ failure.output,
+ "TEST-UNEXPECTED-PASS",
+ )
+ )
+ if hasattr(results, "expectedFailures"):
+ self.todo += len(results.expectedFailures)
+
+ self.mixin_run_tests = []
+ for result in self.results:
+ result.result_modifiers = []
+
+ def run_test_set(self, tests):
+ if self.shuffle:
+ random.seed(self.shuffle_seed)
+ random.shuffle(tests)
+
+ for test in tests:
+ self.run_test(test["filepath"], test["expected"])
+ if self.record_crash():
+ break
+
+ def run_test_sets(self):
+ if len(self.tests) < 1:
+ raise Exception("There are no tests to run.")
+ elif self.total_chunks is not None and self.total_chunks > len(self.tests):
+ raise ValueError(
+ "Total number of chunks must be between 1 and {}.".format(
+ len(self.tests)
+ )
+ )
+ if self.total_chunks is not None and self.total_chunks > 1:
+ chunks = [[] for i in range(self.total_chunks)]
+ for i, test in enumerate(self.tests):
+ target_chunk = i % self.total_chunks
+ chunks[target_chunk].append(test)
+
+ self.logger.info(
+ "Running chunk {0} of {1} ({2} tests selected from a "
+ "total of {3})".format(
+ self.this_chunk,
+ self.total_chunks,
+ len(chunks[self.this_chunk - 1]),
+ len(self.tests),
+ )
+ )
+ self.tests = chunks[self.this_chunk - 1]
+
+ self.run_test_set(self.tests)
+
+ def cleanup(self):
+ for proc in serve.iter_proc(self.fixture_servers):
+ proc.stop()
+ proc.kill()
+ self.fixture_servers = {}
+
+ if hasattr(self, "marionette") and self.marionette:
+ if self.marionette.instance is not None:
+ if self.marionette.instance.runner.is_running():
+ # Force a clean shutdown of the application process first if
+ # it is still running. If that fails, kill the process.
+ # Therefore a new session needs to be started.
+ self.marionette.start_session()
+ self.marionette.quit(in_app=True)
+
+ self.marionette.instance.close(clean=True)
+ self.marionette.instance = None
+
+ self.marionette.cleanup()
+ self.marionette = None
+
+ __del__ = cleanup
diff --git a/testing/marionette/harness/marionette_harness/runner/httpd.py b/testing/marionette/harness/marionette_harness/runner/httpd.py
new file mode 100755
index 0000000000..aab83a23e7
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/runner/httpd.py
@@ -0,0 +1,204 @@
+#!/usr/bin/env python
+
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+"""Specialisation of wptserver.server.WebTestHttpd for testing
+Marionette.
+
+"""
+
+from __future__ import absolute_import, print_function
+
+import argparse
+import os
+import select
+import sys
+import time
+
+from wptserve import handlers, request, routes as default_routes, server
+
+from six.moves.urllib import parse as urlparse
+
+
+root = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
+default_doc_root = os.path.join(root, "www")
+default_ssl_cert = os.path.join(root, "certificates", "test.cert")
+default_ssl_key = os.path.join(root, "certificates", "test.key")
+
+
+@handlers.handler
+def http_auth_handler(req, response):
+ # Allow the test to specify the username and password
+ params = dict(urlparse.parse_qsl(req.url_parts.query))
+ username = params.get("username", "guest")
+ password = params.get("password", "guest")
+
+ auth = request.Authentication(req.headers)
+ content = """<!doctype html>
+<title>HTTP Authentication</title>
+<p id="status">{}</p>"""
+
+ if auth.username == username and auth.password == password:
+ response.status = 200
+ response.content = content.format("success")
+
+ else:
+ response.status = 401
+ response.headers.set("WWW-Authenticate", 'Basic realm="secret"')
+ response.content = content.format("restricted")
+
+
+@handlers.handler
+def upload_handler(request, response):
+ return 200, [], [request.headers.get("Content-Type")] or []
+
+
+@handlers.handler
+def slow_loading_handler(request, response):
+ # Allow the test specify the delay for delivering the content
+ params = dict(urlparse.parse_qsl(request.url_parts.query))
+ delay = int(params.get("delay", 5))
+ time.sleep(delay)
+
+ # Do not allow the page to be cached to circumvent the bfcache of the browser
+ response.headers.set("Cache-Control", "no-cache, no-store")
+ response.content = """<!doctype html>
+<meta charset="UTF-8">
+<title>Slow page loading</title>
+
+<p>Delay: <span id="delay">{}</span></p>
+""".format(
+ delay
+ )
+
+
+class NotAliveError(Exception):
+ """Occurs when attempting to run a function that requires the HTTPD
+ to have been started, and it has not.
+
+ """
+
+ pass
+
+
+class FixtureServer(object):
+ def __init__(
+ self,
+ doc_root,
+ url="http://127.0.0.1:0",
+ use_ssl=False,
+ ssl_cert=None,
+ ssl_key=None,
+ ):
+ if not os.path.isdir(doc_root):
+ raise ValueError("Server root is not a directory: %s" % doc_root)
+
+ url = urlparse.urlparse(url)
+ if url.scheme is None:
+ raise ValueError("Server scheme not provided")
+
+ scheme, host, port = url.scheme, url.hostname, url.port
+ if host is None:
+ host = "127.0.0.1"
+ if port is None:
+ port = 0
+
+ routes = [
+ ("POST", "/file_upload", upload_handler),
+ ("GET", "/http_auth", http_auth_handler),
+ ("GET", "/slow", slow_loading_handler),
+ ]
+ routes.extend(default_routes.routes)
+
+ self._httpd = server.WebTestHttpd(
+ host=host,
+ port=port,
+ bind_address=True,
+ doc_root=doc_root,
+ routes=routes,
+ use_ssl=True if scheme == "https" else False,
+ certificate=ssl_cert,
+ key_file=ssl_key,
+ )
+
+ def start(self, block=False):
+ if self.is_alive:
+ return
+ self._httpd.start(block=block)
+
+ def wait(self):
+ if not self.is_alive:
+ return
+ try:
+ select.select([], [], [])
+ except KeyboardInterrupt:
+ self.stop()
+
+ def stop(self):
+ if not self.is_alive:
+ return
+ self._httpd.stop()
+
+ def get_url(self, path):
+ if not self.is_alive:
+ raise NotAliveError()
+ return self._httpd.get_url(path)
+
+ @property
+ def doc_root(self):
+ return self._httpd.router.doc_root
+
+ @property
+ def router(self):
+ return self._httpd.router
+
+ @property
+ def routes(self):
+ return self._httpd.router.routes
+
+ @property
+ def is_alive(self):
+ return self._httpd.started
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(
+ description="Specialised HTTP server for testing Marionette."
+ )
+ parser.add_argument(
+ "url",
+ help="""
+service address including scheme, hostname, port, and prefix for document root,
+e.g. \"https://0.0.0.0:0/base/\"""",
+ )
+ parser.add_argument(
+ "-r",
+ dest="doc_root",
+ default=default_doc_root,
+ help="path to document root (default %(default)s)",
+ )
+ parser.add_argument(
+ "-c",
+ dest="ssl_cert",
+ default=default_ssl_cert,
+ help="path to SSL certificate (default %(default)s)",
+ )
+ parser.add_argument(
+ "-k",
+ dest="ssl_key",
+ default=default_ssl_key,
+ help="path to SSL certificate key (default %(default)s)",
+ )
+ args = parser.parse_args()
+
+ httpd = FixtureServer(
+ args.doc_root, args.url, ssl_cert=args.ssl_cert, ssl_key=args.ssl_key
+ )
+ httpd.start()
+ print(
+ "{0}: started fixture server on {1}".format(sys.argv[0], httpd.get_url("/")),
+ file=sys.stderr,
+ )
+ httpd.wait()
diff --git a/testing/marionette/harness/marionette_harness/runner/mixins/__init__.py b/testing/marionette/harness/marionette_harness/runner/mixins/__init__.py
new file mode 100644
index 0000000000..d6a97bb742
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/runner/mixins/__init__.py
@@ -0,0 +1,7 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from .window_manager import WindowManagerMixin
diff --git a/testing/marionette/harness/marionette_harness/runner/mixins/window_manager.py b/testing/marionette/harness/marionette_harness/runner/mixins/window_manager.py
new file mode 100644
index 0000000000..d90ce122ce
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/runner/mixins/window_manager.py
@@ -0,0 +1,211 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import sys
+
+from marionette_driver import Wait
+from six import reraise
+
+
+class WindowManagerMixin(object):
+ def setUp(self):
+ super(WindowManagerMixin, self).setUp()
+
+ self.start_window = self.marionette.current_chrome_window_handle
+ self.start_windows = self.marionette.chrome_window_handles
+
+ self.start_tab = self.marionette.current_window_handle
+ self.start_tabs = self.marionette.window_handles
+
+ def tearDown(self):
+ if len(self.marionette.chrome_window_handles) > len(self.start_windows):
+ raise Exception("Not all windows as opened by the test have been closed")
+
+ if len(self.marionette.window_handles) > len(self.start_tabs):
+ raise Exception("Not all tabs as opened by the test have been closed")
+
+ super(WindowManagerMixin, self).tearDown()
+
+ def close_all_tabs(self):
+ current_window_handles = self.marionette.window_handles
+
+ # If the start tab is not present anymore, use the next one of the list
+ if self.start_tab not in current_window_handles:
+ self.start_tab = current_window_handles[0]
+
+ current_window_handles.remove(self.start_tab)
+ for handle in current_window_handles:
+ self.marionette.switch_to_window(handle)
+ self.marionette.close()
+
+ self.marionette.switch_to_window(self.start_tab)
+
+ def close_all_windows(self):
+ current_chrome_window_handles = self.marionette.chrome_window_handles
+
+ # If the start window is not present anymore, use the next one of the list
+ if self.start_window not in current_chrome_window_handles:
+ self.start_window = current_chrome_window_handles[0]
+ current_chrome_window_handles.remove(self.start_window)
+
+ for handle in current_chrome_window_handles:
+ self.marionette.switch_to_window(handle)
+ self.marionette.close_chrome_window()
+
+ self.marionette.switch_to_window(self.start_window)
+
+ def open_tab(self, callback=None, focus=False):
+ current_tabs = self.marionette.window_handles
+
+ try:
+ if callable(callback):
+ callback()
+ else:
+ result = self.marionette.open(type="tab", focus=focus)
+ if result["type"] != "tab":
+ raise Exception(
+ "Newly opened browsing context is of type {} and not tab.".format(
+ result["type"]
+ )
+ )
+ except Exception:
+ exc_cls, exc, tb = sys.exc_info()
+ reraise(
+ exc_cls,
+ exc_cls("Failed to trigger opening a new tab: {}".format(exc)),
+ tb,
+ )
+ else:
+ Wait(self.marionette).until(
+ lambda mn: len(mn.window_handles) == len(current_tabs) + 1,
+ message="No new tab has been opened",
+ )
+
+ [new_tab] = list(set(self.marionette.window_handles) - set(current_tabs))
+
+ return new_tab
+
+ def open_window(self, callback=None, focus=False, private=False):
+ current_windows = self.marionette.chrome_window_handles
+ current_tabs = self.marionette.window_handles
+
+ def loaded(handle):
+ with self.marionette.using_context("chrome"):
+ return self.marionette.execute_script(
+ """
+ Components.utils.import("resource://gre/modules/Services.jsm");
+
+ const win = BrowsingContext.get(Number(arguments[0])).window;
+ return win.document.readyState == "complete";
+ """,
+ script_args=[handle],
+ )
+
+ try:
+ if callable(callback):
+ callback(focus)
+ else:
+ result = self.marionette.open(
+ type="window", focus=focus, private=private
+ )
+ if result["type"] != "window":
+ raise Exception(
+ "Newly opened browsing context is of type {} and not window.".format(
+ result["type"]
+ )
+ )
+ except Exception:
+ exc_cls, exc, tb = sys.exc_info()
+ reraise(
+ exc_cls,
+ exc_cls("Failed to trigger opening a new window: {}".format(exc)),
+ tb,
+ )
+ else:
+ Wait(self.marionette).until(
+ lambda mn: len(mn.chrome_window_handles) == len(current_windows) + 1,
+ message="No new window has been opened",
+ )
+
+ [new_window] = list(
+ set(self.marionette.chrome_window_handles) - set(current_windows)
+ )
+
+ # Before continuing ensure the window has been completed loading
+ Wait(self.marionette).until(
+ lambda _: loaded(new_window),
+ message="Window with handle '{}'' did not finish loading".format(
+ new_window
+ ),
+ )
+
+ # Bug 1507771 - Return the correct handle based on the currently selected context
+ # as long as "WebDriver:NewWindow" is not handled separtely in chrome context
+ context = self.marionette._send_message(
+ "Marionette:GetContext", key="value"
+ )
+ if context == "chrome":
+ return new_window
+ elif context == "content":
+ [new_tab] = list(
+ set(self.marionette.window_handles) - set(current_tabs)
+ )
+ return new_tab
+
+ def open_chrome_window(self, url, focus=False):
+ """Open a new chrome window with the specified chrome URL.
+
+ Can be replaced with "WebDriver:NewWindow" once the command
+ supports opening generic chrome windows beside browsers (bug 1507771).
+ """
+
+ def open_with_js(focus):
+ with self.marionette.using_context("chrome"):
+ self.marionette.execute_async_script(
+ """
+ let [url, focus, resolve] = arguments;
+
+ function waitForEvent(target, type, args) {
+ return new Promise(resolve => {
+ let params = Object.assign({once: true}, args);
+ target.addEventListener(type, event => {
+ dump(`** Received DOM event ${event.type} for ${event.target}\n`);
+ resolve();
+ }, params);
+ });
+ }
+
+ function waitForFocus(win) {
+ return Promise.all([
+ waitForEvent(win, "activate"),
+ waitForEvent(win, "focus", {capture: true}),
+ ]);
+ }
+
+ (async function() {
+ // Open a window, wait for it to receive focus
+ let win = window.openDialog(url, null, "chrome,centerscreen");
+ let focused = waitForFocus(win);
+
+ win.focus();
+ await focused;
+
+ // The new window shouldn't get focused. As such set the
+ // focus back to the opening window.
+ if (!focus && Services.focus.activeWindow != window) {
+ let focused = waitForFocus(window);
+ window.focus();
+ await focused;
+ }
+
+ resolve(win.docShell.browsingContext.id);
+ })();
+ """,
+ script_args=(url, focus),
+ )
+
+ with self.marionette.using_context("chrome"):
+ return self.open_window(callback=open_with_js, focus=focus)
diff --git a/testing/marionette/harness/marionette_harness/runner/serve.py b/testing/marionette/harness/marionette_harness/runner/serve.py
new file mode 100755
index 0000000000..654dc08859
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/runner/serve.py
@@ -0,0 +1,243 @@
+#!/usr/bin/env python
+
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+"""Spawns necessary HTTP servers for testing Marionette in child
+processes.
+
+"""
+
+from __future__ import absolute_import, print_function
+
+import argparse
+import multiprocessing
+import os
+import sys
+
+from collections import defaultdict
+
+from six import iteritems
+
+from . import httpd
+
+
+__all__ = [
+ "default_doc_root",
+ "iter_proc",
+ "iter_url",
+ "registered_servers",
+ "servers",
+ "start",
+ "where_is",
+]
+here = os.path.abspath(os.path.dirname(__file__))
+
+
+class BlockingChannel(object):
+ def __init__(self, channel):
+ self.chan = channel
+ self.lock = multiprocessing.Lock()
+
+ def call(self, func, args=()):
+ self.send((func, args))
+ return self.recv()
+
+ def send(self, *args):
+ try:
+ self.lock.acquire()
+ self.chan.send(args)
+ finally:
+ self.lock.release()
+
+ def recv(self):
+ try:
+ self.lock.acquire()
+ payload = self.chan.recv()
+ if isinstance(payload, tuple) and len(payload) == 1:
+ return payload[0]
+ return payload
+ except KeyboardInterrupt:
+ return ("stop", ())
+ finally:
+ self.lock.release()
+
+
+class ServerProxy(multiprocessing.Process, BlockingChannel):
+ def __init__(self, channel, init_func, *init_args, **init_kwargs):
+ multiprocessing.Process.__init__(self)
+ BlockingChannel.__init__(self, channel)
+ self.init_func = init_func
+ self.init_args = init_args
+ self.init_kwargs = init_kwargs
+
+ def run(self):
+ try:
+ server = self.init_func(*self.init_args, **self.init_kwargs)
+ server.start(block=False)
+ self.send(("ok", ()))
+
+ while True:
+ # ["func", ("arg", ...)]
+ # ["prop", ()]
+ sattr, fargs = self.recv()
+ attr = getattr(server, sattr)
+
+ # apply fargs to attr if it is a function
+ if callable(attr):
+ rv = attr(*fargs)
+
+ # otherwise attr is a property
+ else:
+ rv = attr
+
+ self.send(rv)
+
+ if sattr == "stop":
+ return
+
+ except Exception as e:
+ self.send(("stop", e))
+
+ except KeyboardInterrupt:
+ server.stop()
+
+
+class ServerProc(BlockingChannel):
+ def __init__(self, init_func):
+ self._init_func = init_func
+ self.proc = None
+
+ parent_chan, self.child_chan = multiprocessing.Pipe()
+ BlockingChannel.__init__(self, parent_chan)
+
+ def start(self, doc_root, ssl_config, **kwargs):
+ self.proc = ServerProxy(
+ self.child_chan, self._init_func, doc_root, ssl_config, **kwargs
+ )
+ self.proc.daemon = True
+ self.proc.start()
+
+ res, exc = self.recv()
+ if res == "stop":
+ raise exc
+
+ def get_url(self, url):
+ return self.call("get_url", (url,))
+
+ @property
+ def doc_root(self):
+ return self.call("doc_root", ())
+
+ def stop(self):
+ self.call("stop")
+ if not self.is_alive:
+ return
+ self.proc.join()
+
+ def kill(self):
+ if not self.is_alive:
+ return
+ self.proc.terminate()
+ self.proc.join(0)
+
+ @property
+ def is_alive(self):
+ if self.proc is not None:
+ return self.proc.is_alive()
+ return False
+
+
+def http_server(doc_root, ssl_config, host="127.0.0.1", **kwargs):
+ return httpd.FixtureServer(doc_root, url="http://{}:0/".format(host), **kwargs)
+
+
+def https_server(doc_root, ssl_config, host="127.0.0.1", **kwargs):
+ return httpd.FixtureServer(
+ doc_root,
+ url="https://{}:0/".format(host),
+ ssl_key=ssl_config["key_path"],
+ ssl_cert=ssl_config["cert_path"],
+ **kwargs
+ )
+
+
+def start_servers(doc_root, ssl_config, **kwargs):
+ servers = defaultdict()
+ for schema, builder_fn in registered_servers:
+ proc = ServerProc(builder_fn)
+ proc.start(doc_root, ssl_config, **kwargs)
+ servers[schema] = (proc.get_url("/"), proc)
+ return servers
+
+
+def start(doc_root=None, **kwargs):
+ """Start all relevant test servers.
+
+ If no `doc_root` is given the default
+ testing/marionette/harness/marionette_harness/www directory will be used.
+
+ Additional keyword arguments can be given which will be passed on
+ to the individual ``FixtureServer``'s in httpd.py.
+
+ """
+ doc_root = doc_root or default_doc_root
+ ssl_config = {
+ "cert_path": httpd.default_ssl_cert,
+ "key_path": httpd.default_ssl_key,
+ }
+
+ global servers
+ servers = start_servers(doc_root, ssl_config, **kwargs)
+ return servers
+
+
+def where_is(uri, on="http"):
+ """Returns the full URL, including scheme, hostname, and port, for
+ a fixture resource from the server associated with the ``on`` key.
+ It will by default look for the resource in the "http" server.
+
+ """
+ return servers.get(on)[1].get_url(uri)
+
+
+def iter_proc(servers):
+ for _, (_, proc) in iteritems(servers):
+ yield proc
+
+
+def iter_url(servers):
+ for _, (url, _) in iteritems(servers):
+ yield url
+
+
+default_doc_root = os.path.join(os.path.dirname(here), "www")
+registered_servers = [("http", http_server), ("https", https_server)]
+servers = defaultdict()
+
+
+def main(args):
+ global servers
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "-r", dest="doc_root", help="Path to document root. Overrides default."
+ )
+ args = parser.parse_args()
+
+ servers = start(args.doc_root)
+ for url in iter_url(servers):
+ print("{}: listening on {}".format(sys.argv[0], url), file=sys.stderr)
+
+ try:
+ while any(proc.is_alive for proc in iter_proc(servers)):
+ for proc in iter_proc(servers):
+ proc.proc.join(1)
+ except KeyboardInterrupt:
+ for proc in iter_proc(servers):
+ proc.kill()
+
+
+if __name__ == "__main__":
+ main(sys.argv[1:])
diff --git a/testing/marionette/harness/marionette_harness/runtests.py b/testing/marionette/harness/marionette_harness/runtests.py
new file mode 100644
index 0000000000..8ddc7fa9d3
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/runtests.py
@@ -0,0 +1,113 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import sys
+
+import mozlog
+
+from marionette_driver import __version__ as driver_version
+
+from marionette_harness import (
+ __version__,
+ BaseMarionetteTestRunner,
+ BaseMarionetteArguments,
+ MarionetteTestCase,
+)
+
+
+class MarionetteTestRunner(BaseMarionetteTestRunner):
+ def __init__(self, **kwargs):
+ BaseMarionetteTestRunner.__init__(self, **kwargs)
+ self.test_handlers = [MarionetteTestCase]
+
+
+class MarionetteArguments(BaseMarionetteArguments):
+ pass
+
+
+class MarionetteHarness(object):
+ def __init__(
+ self,
+ runner_class=MarionetteTestRunner,
+ parser_class=MarionetteArguments,
+ testcase_class=MarionetteTestCase,
+ args=None,
+ ):
+ self._runner_class = runner_class
+ self._parser_class = parser_class
+ self._testcase_class = testcase_class
+ self.args = args or self.parse_args()
+
+ def parse_args(self, logger_defaults=None):
+ parser = self._parser_class(
+ usage="%(prog)s [options] test_file_or_dir <test_file_or_dir> ..."
+ )
+ parser.add_argument(
+ "--version",
+ action="version",
+ help="Show version information.",
+ version="%(prog)s {version}"
+ " (using marionette-driver: {driver_version}, ".format(
+ version=__version__, driver_version=driver_version
+ ),
+ )
+ mozlog.commandline.add_logging_group(parser)
+ args = parser.parse_args()
+ parser.verify_usage(args)
+
+ logger = mozlog.commandline.setup_logging(
+ args.logger_name, args, logger_defaults or {"tbpl": sys.stdout}
+ )
+
+ args.logger = logger
+ return vars(args)
+
+ def process_args(self):
+ if self.args.get("pydebugger"):
+ self._testcase_class.pydebugger = __import__(self.args["pydebugger"])
+
+ def run(self):
+ self.process_args()
+ tests = self.args.pop("tests")
+ runner = self._runner_class(**self.args)
+ try:
+ runner.run_tests(tests)
+ finally:
+ runner.cleanup()
+ return runner.failed + runner.crashed
+
+
+def cli(
+ runner_class=MarionetteTestRunner,
+ parser_class=MarionetteArguments,
+ harness_class=MarionetteHarness,
+ testcase_class=MarionetteTestCase,
+ args=None,
+):
+ """
+ Call the harness to parse args and run tests.
+
+ The following exit codes are expected:
+ - Test failures: 10
+ - Harness/other failures: 1
+ - Success: 0
+ """
+ logger = mozlog.commandline.setup_logging("Marionette test runner", {})
+ try:
+ harness_instance = harness_class(
+ runner_class, parser_class, testcase_class, args=args
+ )
+ failed = harness_instance.run()
+ if failed > 0:
+ sys.exit(10)
+ except Exception as e:
+ logger.error(str(e), exc_info=True)
+ sys.exit(1)
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ cli()
diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/conftest.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/conftest.py
new file mode 100644
index 0000000000..f708fce6db
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/conftest.py
@@ -0,0 +1,108 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import sys
+
+import pytest
+
+PY2 = sys.version_info.major == 2
+
+if PY2:
+ from mock import Mock, MagicMock
+else:
+ from unittest.mock import Mock, MagicMock
+
+from marionette_driver.marionette import Marionette
+
+from marionette_harness.runner.httpd import FixtureServer
+
+
+@pytest.fixture(scope="module")
+def logger():
+ """
+ Fake logger to help with mocking out other runner-related classes.
+ """
+ import mozlog
+
+ return Mock(spec=mozlog.structuredlog.StructuredLogger)
+
+
+@pytest.fixture
+def mach_parsed_kwargs(logger):
+ """
+ Parsed and verified dictionary used during simplest
+ call to mach marionette-test
+ """
+ return {
+ "adb_path": None,
+ "addons": None,
+ "address": None,
+ "app": None,
+ "app_args": [],
+ "avd": None,
+ "avd_home": None,
+ "binary": u"/path/to/firefox",
+ "browsermob_port": None,
+ "browsermob_script": None,
+ "device_serial": None,
+ "emulator": False,
+ "emulator_bin": None,
+ "gecko_log": None,
+ "jsdebugger": False,
+ "log_errorsummary": None,
+ "log_html": None,
+ "log_mach": None,
+ "log_mach_buffer": None,
+ "log_mach_level": None,
+ "log_mach_verbose": None,
+ "log_raw": None,
+ "log_raw_level": None,
+ "log_tbpl": None,
+ "log_tbpl_buffer": None,
+ "log_tbpl_compact": None,
+ "log_tbpl_level": None,
+ "log_unittest": None,
+ "log_xunit": None,
+ "logger_name": "Marionette-based Tests",
+ "prefs": {},
+ "prefs_args": None,
+ "prefs_files": None,
+ "profile": None,
+ "pydebugger": None,
+ "repeat": None,
+ "run_until_failure": None,
+ "server_root": None,
+ "shuffle": False,
+ "shuffle_seed": 2276870381009474531,
+ "socket_timeout": 60.0,
+ "startup_timeout": 60,
+ "symbols_path": None,
+ "test_tags": None,
+ "tests": [u"/path/to/unit-tests.ini"],
+ "testvars": None,
+ "this_chunk": None,
+ "timeout": None,
+ "total_chunks": None,
+ "verbose": None,
+ "workspace": None,
+ "logger": logger,
+ }
+
+
+@pytest.fixture
+def mock_httpd(request):
+ """ Mock httpd instance """
+ httpd = MagicMock(spec=FixtureServer)
+ return httpd
+
+
+@pytest.fixture
+def mock_marionette(request):
+ """ Mock marionette instance """
+ marionette = MagicMock(spec=Marionette())
+ if "has_crashed" in request.funcargnames:
+ marionette.check_for_crash.return_value = request.getfuncargvalue("has_crashed")
+ return marionette
diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/python.ini b/testing/marionette/harness/marionette_harness/tests/harness_unit/python.ini
new file mode 100644
index 0000000000..47bbcb1693
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/python.ini
@@ -0,0 +1,10 @@
+[DEFAULT]
+subsuite = marionette-harness
+skip-if = python == 3
+
+[test_httpd.py]
+[test_marionette_arguments.py]
+[test_marionette_harness.py]
+[test_marionette_runner.py]
+[test_marionette_test_result.py]
+[test_serve.py]
diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/test_httpd.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_httpd.py
new file mode 100644
index 0000000000..1f027e8d46
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_httpd.py
@@ -0,0 +1,94 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import json
+import os
+import types
+
+import six
+from six.moves.urllib_request import urlopen
+
+import mozunit
+import pytest
+
+from wptserve.handlers import json_handler
+
+from marionette_harness.runner import httpd
+
+here = os.path.abspath(os.path.dirname(__file__))
+parent = os.path.dirname(here)
+default_doc_root = os.path.join(os.path.dirname(parent), "www")
+
+
+@pytest.yield_fixture
+def server():
+ server = httpd.FixtureServer(default_doc_root)
+ yield server
+ server.stop()
+
+
+def test_ctor():
+ with pytest.raises(ValueError):
+ httpd.FixtureServer("foo")
+ httpd.FixtureServer(default_doc_root)
+
+
+def test_start_stop(server):
+ server.start()
+ server.stop()
+
+
+def test_get_url(server):
+ server.start()
+ url = server.get_url("/")
+ assert isinstance(url, six.string_types)
+ assert "http://" in url
+
+ server.stop()
+ with pytest.raises(httpd.NotAliveError):
+ server.get_url("/")
+
+
+def test_doc_root(server):
+ server.start()
+ assert isinstance(server.doc_root, six.string_types)
+ server.stop()
+ assert isinstance(server.doc_root, six.string_types)
+
+
+def test_router(server):
+ assert server.router is not None
+
+
+def test_routes(server):
+ assert server.routes is not None
+
+
+def test_is_alive(server):
+ assert server.is_alive == False
+ server.start()
+ assert server.is_alive == True
+
+
+def test_handler(server):
+ counter = 0
+
+ @json_handler
+ def handler(request, response):
+ return {"count": counter}
+
+ route = ("GET", "/httpd/test_handler", handler)
+ server.router.register(*route)
+ server.start()
+
+ url = server.get_url("/httpd/test_handler")
+ body = urlopen(url).read()
+ res = json.loads(body)
+ assert res["count"] == counter
+
+
+if __name__ == "__main__":
+ mozunit.main("-p", "no:terminalreporter", "--log-tbpl=-", "--capture", "no")
diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_arguments.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_arguments.py
new file mode 100644
index 0000000000..59ae36b8d8
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_arguments.py
@@ -0,0 +1,82 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import mozunit
+import pytest
+
+from marionette_harness.runtests import MarionetteArguments, MarionetteTestRunner
+
+
+@pytest.mark.parametrize("socket_timeout", ["A", "10", "1B-", "1C2", "44.35"])
+def test_parse_arg_socket_timeout(socket_timeout):
+ argv = ["marionette", "--socket-timeout", socket_timeout]
+ parser = MarionetteArguments()
+
+ def _is_float_convertible(value):
+ try:
+ float(value)
+ return True
+ except:
+ return False
+
+ if not _is_float_convertible(socket_timeout):
+ with pytest.raises(SystemExit) as ex:
+ parser.parse_args(args=argv)
+ assert ex.value.code == 2
+ else:
+ args = parser.parse_args(args=argv)
+ assert hasattr(args, "socket_timeout") and args.socket_timeout == float(
+ socket_timeout
+ )
+
+
+@pytest.mark.parametrize(
+ "arg_name, arg_dest, arg_value, expected_value",
+ [
+ ("app-arg", "app_args", "samplevalue", ["samplevalue"]),
+ ("symbols-path", "symbols_path", "samplevalue", "samplevalue"),
+ ("gecko-log", "gecko_log", "samplevalue", "samplevalue"),
+ ("app", "app", "samplevalue", "samplevalue"),
+ ],
+)
+def test_parsing_optional_arguments(
+ mach_parsed_kwargs, arg_name, arg_dest, arg_value, expected_value
+):
+ parser = MarionetteArguments()
+ parsed_args = parser.parse_args(["--" + arg_name, arg_value])
+ result = vars(parsed_args)
+ assert result.get(arg_dest) == expected_value
+ mach_parsed_kwargs[arg_dest] = result[arg_dest]
+ runner = MarionetteTestRunner(**mach_parsed_kwargs)
+ built_kwargs = runner._build_kwargs()
+ assert built_kwargs[arg_dest] == expected_value
+
+
+@pytest.mark.parametrize(
+ "arg_name, arg_dest, arg_value, expected_value",
+ [
+ ("adb", "adb_path", "samplevalue", "samplevalue"),
+ ("avd", "avd", "samplevalue", "samplevalue"),
+ ("avd-home", "avd_home", "samplevalue", "samplevalue"),
+ ("package", "package_name", "samplevalue", "samplevalue"),
+ ],
+)
+def test_parse_opt_args_emulator(
+ mach_parsed_kwargs, arg_name, arg_dest, arg_value, expected_value
+):
+ parser = MarionetteArguments()
+ parsed_args = parser.parse_args(["--" + arg_name, arg_value])
+ result = vars(parsed_args)
+ assert result.get(arg_dest) == expected_value
+ mach_parsed_kwargs[arg_dest] = result[arg_dest]
+ mach_parsed_kwargs["emulator"] = True
+ runner = MarionetteTestRunner(**mach_parsed_kwargs)
+ built_kwargs = runner._build_kwargs()
+ assert built_kwargs[arg_dest] == expected_value
+
+
+if __name__ == "__main__":
+ mozunit.main("-p", "no:terminalreporter", "--log-tbpl=-", "--capture", "no")
diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_harness.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_harness.py
new file mode 100644
index 0000000000..c48a7e5122
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_harness.py
@@ -0,0 +1,118 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import sys
+
+import mozunit
+import pytest
+
+PY2 = sys.version_info.major == 2
+
+if PY2:
+ from mock import Mock, patch, sentinel
+else:
+ from unittest.mock import Mock, patch, sentinel
+
+import marionette_harness.marionette_test as marionette_test
+
+from marionette_harness.runtests import MarionetteTestRunner, MarionetteHarness, cli
+
+
+@pytest.fixture
+def harness_class(request):
+ """
+ Mock based on MarionetteHarness whose run method just returns a number of
+ failures according to the supplied test parameter
+ """
+ if "num_fails_crashed" in request.funcargnames:
+ num_fails_crashed = request.getfuncargvalue("num_fails_crashed")
+ else:
+ num_fails_crashed = (0, 0)
+ harness_cls = Mock(spec=MarionetteHarness)
+ harness = harness_cls.return_value
+ if num_fails_crashed is None:
+ harness.run.side_effect = Exception
+ else:
+ harness.run.return_value = sum(num_fails_crashed)
+ return harness_cls
+
+
+@pytest.fixture
+def runner_class(request):
+ """
+ Mock based on MarionetteTestRunner, wherein the runner.failed,
+ runner.crashed attributes are provided by a test parameter
+ """
+ if "num_fails_crashed" in request.funcargnames:
+ failures, crashed = request.getfuncargvalue("num_fails_crashed")
+ else:
+ failures = 0
+ crashed = 0
+ mock_runner_class = Mock(spec=MarionetteTestRunner)
+ runner = mock_runner_class.return_value
+ runner.failed = failures
+ runner.crashed = crashed
+ return mock_runner_class
+
+
+@pytest.mark.parametrize(
+ "num_fails_crashed,exit_code",
+ [((0, 0), 0), ((1, 0), 10), ((0, 1), 10), (None, 1)],
+)
+def test_cli_exit_code(num_fails_crashed, exit_code, harness_class):
+ with pytest.raises(SystemExit) as err:
+ cli(harness_class=harness_class)
+ assert err.value.code == exit_code
+
+
+@pytest.mark.parametrize("num_fails_crashed", [(0, 0), (1, 0), (1, 1)])
+def test_call_harness_with_parsed_args_yields_num_failures(
+ mach_parsed_kwargs, runner_class, num_fails_crashed
+):
+ with patch(
+ "marionette_harness.runtests.MarionetteHarness.parse_args"
+ ) as parse_args:
+ failed_or_crashed = MarionetteHarness(
+ runner_class, args=mach_parsed_kwargs
+ ).run()
+ parse_args.assert_not_called()
+ assert failed_or_crashed == sum(num_fails_crashed)
+
+
+def test_call_harness_with_no_args_yields_num_failures(runner_class):
+ with patch(
+ "marionette_harness.runtests.MarionetteHarness.parse_args",
+ return_value={"tests": []},
+ ) as parse_args:
+ failed_or_crashed = MarionetteHarness(runner_class).run()
+ assert parse_args.call_count == 1
+ assert failed_or_crashed == 0
+
+
+def test_args_passed_to_runner_class(mach_parsed_kwargs, runner_class):
+ arg_list = list(mach_parsed_kwargs.keys())
+ arg_list.remove("tests")
+ mach_parsed_kwargs.update([(a, getattr(sentinel, a)) for a in arg_list])
+ harness = MarionetteHarness(runner_class, args=mach_parsed_kwargs)
+ harness.process_args = Mock()
+ harness.run()
+ for arg in arg_list:
+ assert harness._runner_class.call_args[1][arg] is getattr(sentinel, arg)
+
+
+def test_harness_sets_up_default_test_handlers(mach_parsed_kwargs):
+ """
+ If the necessary TestCase is not in test_handlers,
+ tests are omitted silently
+ """
+ harness = MarionetteHarness(args=mach_parsed_kwargs)
+ mach_parsed_kwargs.pop("tests")
+ runner = harness._runner_class(**mach_parsed_kwargs)
+ assert marionette_test.MarionetteTestCase in runner.test_handlers
+
+
+if __name__ == "__main__":
+ mozunit.main("-p", "no:terminalreporter", "--log-tbpl=-", "--capture", "no")
diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_runner.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_runner.py
new file mode 100644
index 0000000000..fcffb38dba
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_runner.py
@@ -0,0 +1,552 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import os
+import sys
+
+import manifestparser
+import mozinfo
+import mozunit
+import pytest
+
+PY2 = sys.version_info.major == 2
+
+if PY2:
+ from mock import Mock, patch, mock_open, sentinel, DEFAULT
+else:
+ from unittest.mock import Mock, patch, mock_open, sentinel, DEFAULT
+
+from marionette_harness.runtests import MarionetteTestRunner
+
+
+@pytest.fixture
+def runner(mach_parsed_kwargs):
+ """
+ MarionetteTestRunner instance initialized with default options.
+ """
+ return MarionetteTestRunner(**mach_parsed_kwargs)
+
+
+@pytest.fixture
+def mock_runner(runner, mock_marionette, monkeypatch):
+ """
+ MarionetteTestRunner instance with mocked-out
+ self.marionette and other properties,
+ to enable testing runner.run_tests().
+ """
+ runner.driverclass = Mock(return_value=mock_marionette)
+ for attr in ["run_test", "_capabilities"]:
+ setattr(runner, attr, Mock())
+ runner._appName = "fake_app"
+ monkeypatch.setattr("marionette_harness.runner.base.mozversion", Mock())
+ return runner
+
+
+@pytest.fixture
+def build_kwargs_using(mach_parsed_kwargs):
+ """Helper function for test_build_kwargs_* functions"""
+
+ def kwarg_builder(new_items, return_socket=False):
+ mach_parsed_kwargs.update(new_items)
+ runner = MarionetteTestRunner(**mach_parsed_kwargs)
+ with patch("marionette_harness.runner.base.socket") as socket:
+ built_kwargs = runner._build_kwargs()
+ if return_socket:
+ return built_kwargs, socket
+ return built_kwargs
+
+ return kwarg_builder
+
+
+@pytest.fixture
+def expected_driver_args(runner):
+ """Helper fixture for tests of _build_kwargs
+ with binary/emulator.
+ Provides a dictionary of certain arguments
+ related to binary/emulator settings
+ which we expect to be passed to the
+ driverclass constructor. Expected values can
+ be updated in tests as needed.
+ Provides convenience methods for comparing the
+ expected arguments to the argument dictionary
+ created by _build_kwargs."""
+
+ class ExpectedDict(dict):
+ def assert_matches(self, actual):
+ for (k, v) in self.items():
+ assert actual[k] == v
+
+ def assert_keys_not_in(self, actual):
+ for k in self.keys():
+ assert k not in actual
+
+ expected = ExpectedDict(host=None, port=None, bin=None)
+ for attr in ["app", "app_args", "profile", "addons", "gecko_log"]:
+ expected[attr] = getattr(runner, attr)
+ return expected
+
+
+class ManifestFixture:
+ def __init__(
+ self,
+ name="mock_manifest",
+ tests=[{"path": u"test_something.py", "expected": "pass"}],
+ ):
+ self.filepath = "/path/to/fake/manifest.ini"
+ self.n_disabled = len([t for t in tests if "disabled" in t])
+ self.n_enabled = len(tests) - self.n_disabled
+ mock_manifest = Mock(
+ spec=manifestparser.TestManifest, active_tests=Mock(return_value=tests)
+ )
+ self.manifest_class = Mock(return_value=mock_manifest)
+ self.__repr__ = lambda: "<ManifestFixture {}>".format(name)
+
+
+@pytest.fixture
+def manifest():
+ return ManifestFixture()
+
+
+@pytest.fixture(params=["enabled", "disabled", "enabled_disabled", "empty"])
+def manifest_with_tests(request):
+ """
+ Fixture for the contents of mock_manifest, where a manifest
+ can include enabled tests, disabled tests, both, or neither (empty)
+ """
+ included = []
+ if "enabled" in request.param:
+ included += [
+ (u"test_expected_pass.py", "pass"),
+ (u"test_expected_fail.py", "fail"),
+ ]
+ if "disabled" in request.param:
+ included += [
+ (u"test_pass_disabled.py", "pass", "skip-if: true"),
+ (u"test_fail_disabled.py", "fail", "skip-if: true"),
+ ]
+ keys = ("path", "expected", "disabled")
+ active_tests = [dict(list(zip(keys, values))) for values in included]
+
+ return ManifestFixture(request.param, active_tests)
+
+
+def test_args_passed_to_driverclass(mock_runner):
+ built_kwargs = {"arg1": "value1", "arg2": "value2"}
+ mock_runner._build_kwargs = Mock(return_value=built_kwargs)
+ with pytest.raises(IOError):
+ mock_runner.run_tests(["fake_tests.ini"])
+ assert mock_runner.driverclass.call_args[1] == built_kwargs
+
+
+def test_build_kwargs_basic_args(build_kwargs_using):
+ """Test the functionality of runner._build_kwargs:
+ make sure that basic arguments (those which should
+ always be included, irrespective of the runner's settings)
+ get passed to the call to runner.driverclass"""
+
+ basic_args = [
+ "socket_timeout",
+ "prefs",
+ "startup_timeout",
+ "verbose",
+ "symbols_path",
+ ]
+ args_dict = {a: getattr(sentinel, a) for a in basic_args}
+ # Mock an update method to work with calls to MarionetteTestRunner()
+ args_dict["prefs"].update = Mock(return_value={})
+ built_kwargs = build_kwargs_using([(a, getattr(sentinel, a)) for a in basic_args])
+ for arg in basic_args:
+ assert built_kwargs[arg] is getattr(sentinel, arg)
+
+
+@pytest.mark.parametrize("workspace", ["path/to/workspace", None])
+def test_build_kwargs_with_workspace(build_kwargs_using, workspace):
+ built_kwargs = build_kwargs_using({"workspace": workspace})
+ if workspace:
+ assert built_kwargs["workspace"] == workspace
+ else:
+ assert "workspace" not in built_kwargs
+
+
+@pytest.mark.parametrize("address", ["host:123", None])
+def test_build_kwargs_with_address(build_kwargs_using, address):
+ built_kwargs, socket = build_kwargs_using(
+ {"address": address, "binary": None, "emulator": None}, return_socket=True
+ )
+ assert "connect_to_running_emulator" not in built_kwargs
+ if address is not None:
+ host, port = address.split(":")
+ assert built_kwargs["host"] == host and built_kwargs["port"] == int(port)
+ socket.socket().connect.assert_called_with((host, int(port)))
+ assert socket.socket().close.called
+ else:
+ assert not socket.socket.called
+
+
+@pytest.mark.parametrize("address", ["host:123", None])
+@pytest.mark.parametrize("binary", ["path/to/bin", None])
+def test_build_kwargs_with_binary_or_address(
+ expected_driver_args, build_kwargs_using, binary, address
+):
+ built_kwargs = build_kwargs_using(
+ {"binary": binary, "address": address, "emulator": None}
+ )
+ if binary:
+ expected_driver_args["bin"] = binary
+ if address:
+ host, port = address.split(":")
+ expected_driver_args.update({"host": host, "port": int(port)})
+ else:
+ expected_driver_args.update({"host": "127.0.0.1", "port": 2828})
+ expected_driver_args.assert_matches(built_kwargs)
+ elif address is None:
+ expected_driver_args.assert_keys_not_in(built_kwargs)
+
+
+@pytest.mark.parametrize("address", ["host:123", None])
+@pytest.mark.parametrize("emulator", [True, False, None])
+def test_build_kwargs_with_emulator_or_address(
+ expected_driver_args, build_kwargs_using, emulator, address
+):
+ emulator_props = [
+ (a, getattr(sentinel, a)) for a in ["avd_home", "adb_path", "emulator_bin"]
+ ]
+ built_kwargs = build_kwargs_using(
+ [("emulator", emulator), ("address", address), ("binary", None)]
+ + emulator_props
+ )
+ if emulator:
+ expected_driver_args.update(emulator_props)
+ expected_driver_args["emulator_binary"] = expected_driver_args.pop(
+ "emulator_bin"
+ )
+ expected_driver_args["bin"] = True
+ if address:
+ expected_driver_args["connect_to_running_emulator"] = True
+ host, port = address.split(":")
+ expected_driver_args.update({"host": host, "port": int(port)})
+ else:
+ expected_driver_args.update({"host": "127.0.0.1", "port": 2828})
+ assert "connect_to_running_emulator" not in built_kwargs
+ expected_driver_args.assert_matches(built_kwargs)
+ elif not address:
+ expected_driver_args.assert_keys_not_in(built_kwargs)
+
+
+def test_parsing_testvars(mach_parsed_kwargs):
+ mach_parsed_kwargs.pop("tests")
+ testvars_json_loads = [
+ {"wifi": {"ssid": "blah", "keyManagement": "WPA-PSK", "psk": "foo"}},
+ {"wifi": {"PEAP": "bar"}, "device": {"stuff": "buzz"}},
+ ]
+ expected_dict = {
+ "wifi": {
+ "ssid": "blah",
+ "keyManagement": "WPA-PSK",
+ "psk": "foo",
+ "PEAP": "bar",
+ },
+ "device": {"stuff": "buzz"},
+ }
+ with patch(
+ "marionette_harness.runtests.MarionetteTestRunner._load_testvars",
+ return_value=testvars_json_loads,
+ ) as load:
+ runner = MarionetteTestRunner(**mach_parsed_kwargs)
+ assert runner.testvars == expected_dict
+ assert load.call_count == 1
+
+
+def test_load_testvars_throws_expected_errors(mach_parsed_kwargs):
+ mach_parsed_kwargs["testvars"] = ["some_bad_path.json"]
+ runner = MarionetteTestRunner(**mach_parsed_kwargs)
+ with pytest.raises(IOError) as io_exc:
+ runner._load_testvars()
+ assert "does not exist" in str(io_exc.value)
+ with patch("os.path.exists", return_value=True):
+ with patch("__builtin__.open", mock_open(read_data="[not {valid JSON]")):
+ with pytest.raises(Exception) as json_exc:
+ runner._load_testvars()
+ assert "not properly formatted" in str(json_exc.value)
+
+
+def _check_crash_counts(has_crashed, runner, mock_marionette):
+ if has_crashed:
+ assert mock_marionette.check_for_crash.call_count == 1
+ assert runner.crashed == 1
+ else:
+ assert runner.crashed == 0
+
+
+@pytest.mark.parametrize("has_crashed", [True, False])
+def test_increment_crash_count_in_run_test_set(runner, has_crashed, mock_marionette):
+ fake_tests = [{"filepath": i, "expected": "pass"} for i in "abc"]
+
+ with patch.multiple(runner, run_test=DEFAULT, marionette=mock_marionette):
+ runner.run_test_set(fake_tests)
+ if not has_crashed:
+ assert runner.marionette.check_for_crash.call_count == len(fake_tests)
+ _check_crash_counts(has_crashed, runner, runner.marionette)
+
+
+@pytest.mark.parametrize("has_crashed", [True, False])
+def test_record_crash(runner, has_crashed, mock_marionette):
+ with patch.object(runner, "marionette", mock_marionette):
+ assert runner.record_crash() == has_crashed
+ _check_crash_counts(has_crashed, runner, runner.marionette)
+
+
+def test_add_test_module(runner):
+ tests = ["test_something.py", "testSomething.js", "bad_test.py"]
+ assert len(runner.tests) == 0
+ for test in tests:
+ with patch("os.path.abspath", return_value=test) as abspath:
+ runner.add_test(test)
+ assert abspath.called
+ expected = {"filepath": test, "expected": "pass", "group": "default"}
+ assert expected in runner.tests
+ # add_test doesn't validate module names; 'bad_test.py' gets through
+ assert len(runner.tests) == 3
+
+
+def test_add_test_directory(runner):
+ test_dir = "path/to/tests"
+ dir_contents = [
+ (test_dir, ("subdir",), ("test_a.py", "bad_test_a.py")),
+ (test_dir + "/subdir", (), ("test_b.py", "bad_test_b.py")),
+ ]
+ tests = list(dir_contents[0][2] + dir_contents[1][2])
+ assert len(runner.tests) == 0
+ # Need to use side effect to make isdir return True for test_dir and False for tests
+ with patch("os.path.isdir", side_effect=[True] + [False for t in tests]) as isdir:
+ with patch("os.walk", return_value=dir_contents) as walk:
+ runner.add_test(test_dir)
+ assert isdir.called and walk.called
+ for test in runner.tests:
+ assert os.path.normpath(test_dir) in test["filepath"]
+ assert len(runner.tests) == 2
+
+
+@pytest.mark.parametrize("test_files_exist", [True, False])
+def test_add_test_manifest(
+ mock_runner, manifest_with_tests, monkeypatch, test_files_exist
+):
+ monkeypatch.setattr(
+ "marionette_harness.runner.base.TestManifest",
+ manifest_with_tests.manifest_class,
+ )
+ mock_runner.marionette = mock_runner.driverclass()
+ with patch(
+ "marionette_harness.runner.base.os.path.exists", return_value=test_files_exist
+ ):
+ if test_files_exist or manifest_with_tests.n_enabled == 0:
+ mock_runner.add_test(manifest_with_tests.filepath)
+ assert len(mock_runner.tests) == manifest_with_tests.n_enabled
+ assert (
+ len(mock_runner.manifest_skipped_tests)
+ == manifest_with_tests.n_disabled
+ )
+ for test in mock_runner.tests:
+ assert test["filepath"].endswith(test["expected"] + ".py")
+ else:
+ pytest.raises(IOError, "mock_runner.add_test(manifest_with_tests.filepath)")
+ assert manifest_with_tests.manifest_class().read.called
+ assert manifest_with_tests.manifest_class().active_tests.called
+
+
+def get_kwargs_passed_to_manifest(mock_runner, manifest, monkeypatch, **kwargs):
+ """Helper function for test_manifest_* tests.
+ Returns the kwargs passed to the call to manifest.active_tests."""
+ monkeypatch.setattr(
+ "marionette_harness.runner.base.TestManifest", manifest.manifest_class
+ )
+ monkeypatch.setitem(mozinfo.info, "mozinfo_key", "mozinfo_val")
+ for attr in kwargs:
+ setattr(mock_runner, attr, kwargs[attr])
+ mock_runner.marionette = mock_runner.driverclass()
+ with patch("marionette_harness.runner.base.os.path.exists", return_value=True):
+ mock_runner.add_test(manifest.filepath)
+ call_args, call_kwargs = manifest.manifest_class().active_tests.call_args
+ return call_kwargs
+
+
+def test_manifest_basic_args(mock_runner, manifest, monkeypatch):
+ kwargs = get_kwargs_passed_to_manifest(mock_runner, manifest, monkeypatch)
+ assert kwargs["exists"] is False
+ assert kwargs["disabled"] is True
+ assert kwargs["appname"] == "fake_app"
+ assert kwargs["actors"] is True
+ assert "mozinfo_key" in kwargs and kwargs["mozinfo_key"] == "mozinfo_val"
+
+
+def test_manifest_actors_disabled(mock_runner, manifest, monkeypatch):
+ kwargs = get_kwargs_passed_to_manifest(
+ mock_runner, manifest, monkeypatch, disable_actors=True
+ )
+ assert kwargs["actors"] is False
+
+
+@pytest.mark.parametrize("test_tags", (None, ["tag", "tag2"]))
+def test_manifest_with_test_tags(mock_runner, manifest, monkeypatch, test_tags):
+ kwargs = get_kwargs_passed_to_manifest(
+ mock_runner, manifest, monkeypatch, test_tags=test_tags
+ )
+ if test_tags is None:
+ assert kwargs["filters"] == []
+ else:
+ assert len(kwargs["filters"]) == 1 and kwargs["filters"][0].tags == test_tags
+
+
+def test_cleanup_with_manifest(mock_runner, manifest_with_tests, monkeypatch):
+ monkeypatch.setattr(
+ "marionette_harness.runner.base.TestManifest",
+ manifest_with_tests.manifest_class,
+ )
+ if manifest_with_tests.n_enabled > 0:
+ context = patch(
+ "marionette_harness.runner.base.os.path.exists", return_value=True
+ )
+ else:
+ context = pytest.raises(Exception)
+ with context:
+ mock_runner.run_tests([manifest_with_tests.filepath])
+ assert mock_runner.marionette is None
+ assert mock_runner.fixture_servers == {}
+
+
+def test_reset_test_stats(mock_runner):
+ def reset_successful(runner):
+ stats = [
+ "passed",
+ "failed",
+ "unexpected_successes",
+ "todo",
+ "skipped",
+ "failures",
+ ]
+ return all([((s in vars(runner)) and (not vars(runner)[s])) for s in stats])
+
+ assert reset_successful(mock_runner)
+ mock_runner.passed = 1
+ mock_runner.failed = 1
+ mock_runner.failures.append(["TEST-UNEXPECTED-FAIL"])
+ assert not reset_successful(mock_runner)
+ mock_runner.run_tests([u"test_fake_thing.py"])
+ assert reset_successful(mock_runner)
+
+
+def test_initialize_test_run(mock_runner):
+ tests = [u"test_fake_thing.py"]
+ mock_runner.reset_test_stats = Mock()
+ mock_runner.run_tests(tests)
+ assert mock_runner.reset_test_stats.called
+ with pytest.raises(AssertionError) as test_exc:
+ mock_runner.run_tests([])
+ assert "len(tests)" in str(test_exc.traceback[-1].statement)
+ with pytest.raises(AssertionError) as hndl_exc:
+ mock_runner.test_handlers = []
+ mock_runner.run_tests(tests)
+ assert "test_handlers" in str(hndl_exc.traceback[-1].statement)
+ assert mock_runner.reset_test_stats.call_count == 1
+
+
+def test_add_tests(mock_runner):
+ assert len(mock_runner.tests) == 0
+ fake_tests = ["test_" + i + ".py" for i in "abc"]
+ mock_runner.run_tests(fake_tests)
+ assert len(mock_runner.tests) == 3
+ for (test_name, added_test) in zip(fake_tests, mock_runner.tests):
+ assert added_test["filepath"].endswith(test_name)
+
+
+def test_repeat(mock_runner):
+ def update_result(test, expected):
+ mock_runner.failed += 1
+
+ fake_tests = ["test_1.py"]
+ mock_runner.repeat = 4
+ mock_runner.run_test = Mock(side_effect=update_result)
+ mock_runner.run_tests(fake_tests)
+
+ assert mock_runner.failed == 5
+ assert mock_runner.passed == 0
+ assert mock_runner.todo == 0
+
+
+def test_run_until_failure(mock_runner):
+ def update_result(test, expected):
+ mock_runner.failed += 1
+
+ fake_tests = ["test_1.py"]
+ mock_runner.run_until_failure = True
+ mock_runner.repeat = 4
+ mock_runner.run_test = Mock(side_effect=update_result)
+ mock_runner.run_tests(fake_tests)
+
+ assert mock_runner.failed == 1
+ assert mock_runner.passed == 0
+ assert mock_runner.todo == 0
+
+
+def test_catch_invalid_test_names(runner):
+ good_tests = [u"test_ok.py", u"test_is_ok.py"]
+ bad_tests = [
+ u"bad_test.py",
+ u"testbad.py",
+ u"_test_bad.py",
+ u"test_bad.notpy",
+ u"test_bad",
+ u"test.py",
+ u"test_.py",
+ ]
+ with pytest.raises(Exception) as exc:
+ runner._add_tests(good_tests + bad_tests)
+ msg = str(exc.value)
+ assert "Test file names must be of the form" in msg
+ for bad_name in bad_tests:
+ assert bad_name in msg
+ for good_name in good_tests:
+ assert good_name not in msg
+
+
+@pytest.mark.parametrize("repeat", (None, 0, 42, -1))
+def test_option_repeat(mach_parsed_kwargs, repeat):
+ if repeat is not None:
+ mach_parsed_kwargs["repeat"] = repeat
+ runner = MarionetteTestRunner(**mach_parsed_kwargs)
+
+ if repeat is None:
+ assert runner.repeat == 0
+ else:
+ assert runner.repeat == repeat
+
+
+@pytest.mark.parametrize("repeat", (None, 42))
+@pytest.mark.parametrize("run_until_failure", (None, True))
+def test_option_run_until_failure(mach_parsed_kwargs, repeat, run_until_failure):
+ if run_until_failure is not None:
+ mach_parsed_kwargs["run_until_failure"] = run_until_failure
+ if repeat is not None:
+ mach_parsed_kwargs["repeat"] = repeat
+ runner = MarionetteTestRunner(**mach_parsed_kwargs)
+
+ if run_until_failure is None:
+ assert runner.run_until_failure is False
+ if repeat is None:
+ assert runner.repeat == 0
+ else:
+ assert runner.repeat == repeat
+
+ else:
+ assert runner.run_until_failure == run_until_failure
+ if repeat is None:
+ assert runner.repeat == 30
+ else:
+ assert runner.repeat == repeat
+
+
+if __name__ == "__main__":
+ mozunit.main("-p", "no:terminalreporter", "--log-tbpl=-", "--capture", "no")
diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_test_result.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_test_result.py
new file mode 100644
index 0000000000..42e11f6f19
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_test_result.py
@@ -0,0 +1,57 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import mozunit
+import pytest
+
+from marionette_harness import MarionetteTestResult
+
+
+@pytest.fixture
+def empty_marionette_testcase():
+ """ Testable MarionetteTestCase class """
+ from marionette_harness import MarionetteTestCase
+
+ class EmptyTestCase(MarionetteTestCase):
+ def test_nothing(self):
+ pass
+
+ return EmptyTestCase
+
+
+@pytest.fixture
+def empty_marionette_test(mock_marionette, empty_marionette_testcase):
+ return empty_marionette_testcase(
+ lambda: mock_marionette, lambda: mock_httpd, "test_nothing"
+ )
+
+
+@pytest.mark.parametrize("has_crashed", [True, False])
+def test_crash_is_recorded_as_error(empty_marionette_test, logger, has_crashed):
+ """ Number of errors is incremented by stopTest iff has_crashed is true """
+ # collect results from the empty test
+ result = MarionetteTestResult(
+ marionette=empty_marionette_test._marionette_weakref(),
+ logger=logger,
+ verbosity=1,
+ stream=None,
+ descriptions=None,
+ )
+ result.startTest(empty_marionette_test)
+ assert len(result.errors) == 0
+ assert len(result.failures) == 0
+ assert result.testsRun == 1
+ assert result.shouldStop is False
+ result.stopTest(empty_marionette_test)
+ assert result.shouldStop == has_crashed
+ if has_crashed:
+ assert len(result.errors) == 1
+ else:
+ assert len(result.errors) == 0
+
+
+if __name__ == "__main__":
+ mozunit.main("-p", "no:terminalreporter", "--log-tbpl=-", "--capture", "no")
diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/test_serve.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_serve.py
new file mode 100644
index 0000000000..68fbc51bae
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_serve.py
@@ -0,0 +1,71 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import types
+
+import six
+
+import mozunit
+import pytest
+
+from marionette_harness.runner import serve
+from marionette_harness.runner.serve import iter_proc, iter_url
+
+
+def teardown_function(func):
+ for server in [s for s in iter_proc(serve.servers) if s.is_alive]:
+ server.stop()
+ server.kill()
+
+
+def test_registered_servers():
+ # [(name, factory), ...]
+ assert serve.registered_servers[0][0] == "http"
+ assert serve.registered_servers[1][0] == "https"
+
+
+def test_globals():
+ assert serve.default_doc_root is not None
+ assert serve.registered_servers is not None
+ assert serve.servers is not None
+
+
+def test_start():
+ serve.start()
+ assert len(serve.servers) == 2
+ assert "http" in serve.servers
+ assert "https" in serve.servers
+ for url in iter_url(serve.servers):
+ assert isinstance(url, six.string_types)
+
+
+def test_start_with_custom_root(tmpdir_factory):
+ tdir = tmpdir_factory.mktemp("foo")
+ serve.start(str(tdir))
+ for server in iter_proc(serve.servers):
+ assert server.doc_root == tdir
+
+
+def test_iter_proc():
+ serve.start()
+ for server in iter_proc(serve.servers):
+ server.stop()
+
+
+def test_iter_url():
+ serve.start()
+ for url in iter_url(serve.servers):
+ assert isinstance(url, six.string_types)
+
+
+def test_where_is():
+ serve.start()
+ assert serve.where_is("/") == serve.servers["http"][1].get_url("/")
+ assert serve.where_is("/", on="https") == serve.servers["https"][1].get_url("/")
+
+
+if __name__ == "__main__":
+ mozunit.main("-p", "no:terminalreporter", "--log-tbpl=-", "--capture", "no")
diff --git a/testing/marionette/harness/marionette_harness/tests/unit-tests.ini b/testing/marionette/harness/marionette_harness/tests/unit-tests.ini
new file mode 100644
index 0000000000..a776385b33
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit-tests.ini
@@ -0,0 +1,29 @@
+# marionette unit tests
+[include:unit/unit-tests.ini]
+
+# layout tests
+[include:../../../../../layout/base/tests/marionette/manifest.ini]
+
+# migration tests
+[include:../../../../../browser/components/migration/tests/marionette/manifest.ini]
+
+# xpconnect tests
+[include:../../../../../js/xpconnect/tests/marionette/manifest.ini]
+
+# xre tests
+[include:../../../../../toolkit/xre/test/marionette/marionette.ini]
+
+# searchservice tests
+[include:../../../../../browser/components/search/test/marionette/manifest.ini]
+
+# autoconfig tests
+[include:../../../../../extensions/pref/autoconfig/test/marionette/manifest.ini]
+
+# cleardata tests
+[include:../../../../../toolkit/components/cleardata/tests/marionette/manifest.ini]
+
+# webextensions tests
+[include:../../../../../toolkit/components/extensions/test/marionette/manifest.ini]
+
+# workers tests
+[include:../../../../../dom/workers/test/marionette/manifest.ini]
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/data/test.html b/testing/marionette/harness/marionette_harness/tests/unit/data/test.html
new file mode 100644
index 0000000000..8334cf0a2e
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/data/test.html
@@ -0,0 +1,13 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html>
+<head>
+<title>Marionette Test</title>
+</head>
+<body>
+ <p id="file-url">Loaded via file://</p>
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_accessibility.py b/testing/marionette/harness/marionette_harness/tests/unit/test_accessibility.py
new file mode 100644
index 0000000000..2edad1f402
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_accessibility.py
@@ -0,0 +1,273 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import sys
+import unittest
+
+from marionette_driver.by import By
+from marionette_driver.errors import (
+ ElementNotAccessibleException,
+ ElementNotInteractableException,
+ ElementClickInterceptedException,
+)
+
+from marionette_harness import MarionetteTestCase
+
+
+class TestAccessibility(MarionetteTestCase):
+ def setUp(self):
+ super(TestAccessibility, self).setUp()
+ with self.marionette.using_context("chrome"):
+ self.marionette.set_pref("dom.ipc.processCount", 1)
+
+ def tearDown(self):
+ with self.marionette.using_context("chrome"):
+ self.marionette.clear_pref("dom.ipc.processCount")
+
+ # Elements that are accessible with and without the accessibliity API
+ valid_elementIDs = [
+ # Button1 is an accessible button with a valid accessible name
+ # computed from subtree
+ "button1",
+ # Button2 is an accessible button with a valid accessible name
+ # computed from aria-label
+ "button2",
+ # Button13 is an accessible button that is implemented via role="button"
+ # and is explorable using tabindex="0"
+ "button13",
+ # button17 is an accessible button that overrides parent's
+ # pointer-events:none; property with its own pointer-events:all;
+ "button17",
+ ]
+
+ # Elements that are not accessible with the accessibility API
+ invalid_elementIDs = [
+ # Button3 does not have an accessible object
+ "button3",
+ # Button4 does not support any accessible actions
+ "button4",
+ # Button5 does not have a correct accessibility role and may not be
+ # manipulated via the accessibility API
+ "button5",
+ # Button6 is missing an accessible name
+ "button6",
+ # Button7 is not currently visible via the accessibility API and may
+ # not be manipulated by it
+ "button7",
+ # Button8 is not currently visible via the accessibility API and may
+ # not be manipulated by it (in hidden subtree)
+ "button8",
+ # Button14 is accessible button but is not explorable because of lack
+ # of tabindex that would make it focusable.
+ "button14",
+ ]
+
+ # Elements that are either accessible to accessibility API or not accessible
+ # at all
+ falsy_elements = [
+ # Element is only visible to the accessibility API and may be
+ # manipulated by it
+ "button9",
+ # Element is not currently visible
+ "button10",
+ ]
+
+ displayed_elementIDs = ["button1", "button2", "button4", "button5", "button6"]
+
+ displayed_but_have_no_accessible_elementIDs = [
+ # Button3 does not have an accessible object
+ "button3",
+ # Button 7 is hidden with aria-hidden set to true
+ "button7",
+ # Button 8 is inside an element with aria-hidden set to true
+ "button8",
+ "no_accessible_but_displayed",
+ ]
+
+ disabled_elementIDs = ["button11", "no_accessible_but_disabled"]
+
+ # Elements that are enabled but otherwise disabled or not explorable
+ # via the accessibility API
+ aria_disabled_elementIDs = ["button12"]
+
+ # pointer-events: "none", which will return
+ # ElementClickInterceptedException if clicked
+ # when Marionette switches
+ # to using WebDriver conforming interaction
+ pointer_events_none_elementIDs = ["button15", "button16"]
+
+ # Elements that are reporting selected state
+ valid_option_elementIDs = ["option1", "option2"]
+
+ def run_element_test(self, ids, testFn):
+ for id in ids:
+ element = self.marionette.find_element(By.ID, id)
+ testFn(element)
+
+ def setup_accessibility(self, enable_a11y_checks=True, navigate=True):
+ self.marionette.delete_session()
+ self.marionette.start_session({"moz:accessibilityChecks": enable_a11y_checks})
+ self.assertEqual(
+ self.marionette.session_capabilities["moz:accessibilityChecks"],
+ enable_a11y_checks,
+ )
+
+ # Navigate to test_accessibility.html
+ if navigate:
+ test_accessibility = self.marionette.absolute_url("test_accessibility.html")
+ self.marionette.navigate(test_accessibility)
+
+ def test_valid_single_tap(self):
+ self.setup_accessibility()
+ # No exception should be raised
+ self.run_element_test(self.valid_elementIDs, lambda button: button.tap())
+
+ def test_single_tap_raises_element_not_accessible(self):
+ self.setup_accessibility()
+ self.run_element_test(
+ self.invalid_elementIDs,
+ lambda button: self.assertRaises(ElementNotAccessibleException, button.tap),
+ )
+ self.run_element_test(
+ self.falsy_elements,
+ lambda button: self.assertRaises(
+ ElementNotInteractableException, button.tap
+ ),
+ )
+
+ def test_single_tap_raises_no_exceptions(self):
+ self.setup_accessibility(False, True)
+ # No exception should be raised
+ self.run_element_test(self.invalid_elementIDs, lambda button: button.tap())
+ # Elements are invisible
+ self.run_element_test(
+ self.falsy_elements,
+ lambda button: self.assertRaises(
+ ElementNotInteractableException, button.tap
+ ),
+ )
+
+ def test_valid_click(self):
+ self.setup_accessibility()
+ # No exception should be raised
+ self.run_element_test(self.valid_elementIDs, lambda button: button.click())
+
+ def test_click_raises_element_not_accessible(self):
+ self.setup_accessibility()
+ self.run_element_test(
+ self.invalid_elementIDs,
+ lambda button: self.assertRaises(
+ ElementNotAccessibleException, button.click
+ ),
+ )
+ self.run_element_test(
+ self.falsy_elements,
+ lambda button: self.assertRaises(
+ ElementNotInteractableException, button.click
+ ),
+ )
+
+ def test_click_raises_no_exceptions(self):
+ self.setup_accessibility(False, True)
+ # No exception should be raised
+ self.run_element_test(self.invalid_elementIDs, lambda button: button.click())
+ # Elements are invisible
+ self.run_element_test(
+ self.falsy_elements,
+ lambda button: self.assertRaises(
+ ElementNotInteractableException, button.click
+ ),
+ )
+
+ def test_element_visible_but_not_visible_to_accessbility(self):
+ self.setup_accessibility()
+ # Elements are displayed but hidden from accessibility API
+ self.run_element_test(
+ self.displayed_but_have_no_accessible_elementIDs,
+ lambda element: self.assertRaises(
+ ElementNotAccessibleException, element.is_displayed
+ ),
+ )
+
+ def test_element_is_visible_to_accessibility(self):
+ self.setup_accessibility()
+ # No exception should be raised
+ self.run_element_test(
+ self.displayed_elementIDs, lambda element: element.is_displayed()
+ )
+
+ def test_element_is_not_enabled_to_accessbility(self):
+ self.setup_accessibility()
+ # Buttons are enabled but disabled/not-explorable via the accessibility API
+ self.run_element_test(
+ self.aria_disabled_elementIDs,
+ lambda element: self.assertRaises(
+ ElementNotAccessibleException, element.is_enabled
+ ),
+ )
+ self.run_element_test(
+ self.pointer_events_none_elementIDs,
+ lambda element: self.assertRaises(
+ ElementNotAccessibleException, element.is_enabled
+ ),
+ )
+
+ # Buttons are enabled but disabled/not-explorable via
+ # the accessibility API and thus are not clickable via the
+ # accessibility API.
+ self.run_element_test(
+ self.aria_disabled_elementIDs,
+ lambda element: self.assertRaises(
+ ElementNotAccessibleException, element.click
+ ),
+ )
+ # To be removed with bug 1405967
+ if not self.marionette.session_capabilities["moz:webdriverClick"]:
+ self.run_element_test(
+ self.pointer_events_none_elementIDs,
+ lambda element: self.assertRaises(
+ ElementNotAccessibleException, element.click
+ ),
+ )
+
+ self.setup_accessibility(False, False)
+ self.run_element_test(
+ self.aria_disabled_elementIDs, lambda element: element.is_enabled()
+ )
+ self.run_element_test(
+ self.pointer_events_none_elementIDs, lambda element: element.is_enabled()
+ )
+ self.run_element_test(
+ self.aria_disabled_elementIDs, lambda element: element.click()
+ )
+ # To be removed with bug 1405967
+ if not self.marionette.session_capabilities["moz:webdriverClick"]:
+ self.run_element_test(
+ self.pointer_events_none_elementIDs, lambda element: element.click()
+ )
+
+ def test_element_is_enabled_to_accessibility(self):
+ self.setup_accessibility()
+ # No exception should be raised
+ self.run_element_test(
+ self.disabled_elementIDs, lambda element: element.is_enabled()
+ )
+
+ def test_send_keys_raises_no_exception(self):
+ self.setup_accessibility()
+ # Sending keys to valid input should not raise any exceptions
+ self.run_element_test(["input1"], lambda element: element.send_keys("a"))
+
+ def test_is_selected_raises_no_exception(self):
+ self.setup_accessibility()
+ # No exception should be raised for valid options
+ self.run_element_test(
+ self.valid_option_elementIDs, lambda element: element.is_selected()
+ )
+ # No exception should be raised for non-selectable elements
+ self.run_element_test(
+ self.valid_elementIDs, lambda element: element.is_selected()
+ )
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_addons.py b/testing/marionette/harness/marionette_harness/tests/unit/test_addons.py
new file mode 100644
index 0000000000..63f0cde9ed
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_addons.py
@@ -0,0 +1,128 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import os
+import sys
+from unittest import skipIf
+
+from marionette_driver.addons import Addons, AddonInstallException
+from marionette_harness import MarionetteTestCase
+
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+
+class TestAddons(MarionetteTestCase):
+ def setUp(self):
+ super(TestAddons, self).setUp()
+
+ self.addons = Addons(self.marionette)
+ self.preinstalled_addons = self.all_addon_ids
+
+ def tearDown(self):
+ self.reset_addons()
+
+ super(TestAddons, self).tearDown()
+
+ @property
+ def all_addon_ids(self):
+ with self.marionette.using_context("chrome"):
+ addons = self.marionette.execute_async_script(
+ """
+ let [resolve] = arguments;
+ Components.utils.import("resource://gre/modules/AddonManager.jsm");
+
+ AddonManager.getAllAddons().then(function(addons) {
+ let ids = addons.map(x => x.id);
+ resolve(ids);
+ });
+ """
+ )
+
+ return set(addons)
+
+ def reset_addons(self):
+ with self.marionette.using_context("chrome"):
+ for addon in self.all_addon_ids - self.preinstalled_addons:
+ addon_id = self.marionette.execute_async_script(
+ """
+ let [resolve] = arguments;
+ Components.utils.import("resource://gre/modules/AddonManager.jsm");
+
+ return new Promise(await resolve => {
+ let addon = await AddonManager.getAddonByID(arguments[0]);
+ addon.uninstall();
+ resolve(addon.id);
+ });
+ """,
+ script_args=(addon,),
+ )
+ self.assertEqual(
+ addon_id, addon, msg="Failed to uninstall {}".format(addon)
+ )
+
+ def test_temporary_install_and_remove_unsigned_addon(self):
+ addon_path = os.path.join(here, "webextension-unsigned.xpi")
+
+ addon_id = self.addons.install(addon_path, temp=True)
+ self.assertIn(addon_id, self.all_addon_ids)
+ self.assertEqual(addon_id, "{d3e7c1f1-2e35-4a49-89fe-9f46eb8abf0a}")
+
+ self.addons.uninstall(addon_id)
+ self.assertNotIn(addon_id, self.all_addon_ids)
+
+ def test_temporary_install_invalid_addon(self):
+ addon_path = os.path.join(here, "webextension-invalid.xpi")
+
+ with self.assertRaises(AddonInstallException):
+ self.addons.install(addon_path, temp=True)
+ self.assertNotIn("{d3e7c1f1-2e35-4a49-89fe-9f46eb8abf0a}", self.all_addon_ids)
+
+ def test_install_and_remove_signed_addon(self):
+ addon_path = os.path.join(here, "webextension-signed.xpi")
+
+ addon_id = self.addons.install(addon_path)
+ self.assertIn(addon_id, self.all_addon_ids)
+ self.assertEqual(addon_id, "{d3e7c1f1-2e35-4a49-89fe-9f46eb8abf0a}")
+
+ self.addons.uninstall(addon_id)
+ self.assertNotIn(addon_id, self.all_addon_ids)
+
+ def test_install_invalid_addon(self):
+ addon_path = os.path.join(here, "webextension-invalid.xpi")
+
+ with self.assertRaises(AddonInstallException):
+ self.addons.install(addon_path)
+ self.assertNotIn("{d3e7c1f1-2e35-4a49-89fe-9f46eb8abf0a}", self.all_addon_ids)
+
+ def test_install_unsigned_addon_fails(self):
+ addon_path = os.path.join(here, "webextension-unsigned.xpi")
+
+ with self.assertRaises(AddonInstallException):
+ self.addons.install(addon_path)
+
+ def test_install_nonexistent_addon(self):
+ addon_path = os.path.join(here, "does-not-exist.xpi")
+
+ with self.assertRaises(AddonInstallException):
+ self.addons.install(addon_path)
+
+ def test_install_with_relative_path(self):
+ with self.assertRaises(AddonInstallException):
+ self.addons.install("webextension.xpi")
+
+ @skipIf(sys.platform != "win32", "Only makes sense on Windows")
+ def test_install_mixed_separator_windows(self):
+ # Ensure the base path has only \
+ addon_path = here.replace("/", "\\")
+ addon_path += "/webextension-signed.xpi"
+
+ addon_id = self.addons.install(addon_path, temp=True)
+ self.assertIn(addon_id, self.all_addon_ids)
+ self.assertEqual(addon_id, "{d3e7c1f1-2e35-4a49-89fe-9f46eb8abf0a}")
+
+ self.addons.uninstall(addon_id)
+ self.assertNotIn(addon_id, self.all_addon_ids)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_capabilities.py b/testing/marionette/harness/marionette_harness/tests/unit/test_capabilities.py
new file mode 100644
index 0000000000..d186e4af35
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_capabilities.py
@@ -0,0 +1,249 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import, print_function
+
+import os
+
+from marionette_driver.errors import SessionNotCreatedException
+from marionette_harness import MarionetteTestCase
+
+
+class TestCapabilities(MarionetteTestCase):
+ def setUp(self):
+ super(TestCapabilities, self).setUp()
+ self.caps = self.marionette.session_capabilities
+ with self.marionette.using_context("chrome"):
+ self.appinfo = self.marionette.execute_script(
+ """
+ return {
+ name: Services.appinfo.name,
+ version: Services.appinfo.version,
+ processID: Services.appinfo.processID,
+ buildID: Services.appinfo.appBuildID,
+ }
+ """
+ )
+ self.os_name = self.marionette.execute_script(
+ """
+ let name = Services.sysinfo.getProperty("name");
+ switch (name) {
+ case "Windows_NT":
+ return "windows";
+ case "Darwin":
+ return "mac";
+ default:
+ return name.toLowerCase();
+ }
+ """
+ )
+ self.os_version = self.marionette.execute_script(
+ "return Services.sysinfo.getProperty('version')"
+ )
+
+ def test_mandated_capabilities(self):
+ self.assertIn("browserName", self.caps)
+ self.assertIn("browserVersion", self.caps)
+ self.assertIn("platformName", self.caps)
+ self.assertIn("platformVersion", self.caps)
+ self.assertIn("acceptInsecureCerts", self.caps)
+ self.assertIn("setWindowRect", self.caps)
+ self.assertIn("timeouts", self.caps)
+ self.assertIn("strictFileInteractability", self.caps)
+
+ self.assertEqual(self.caps["browserName"], self.appinfo["name"].lower())
+ self.assertEqual(self.caps["browserVersion"], self.appinfo["version"])
+ self.assertEqual(self.caps["platformName"], self.os_name)
+ self.assertEqual(self.caps["platformVersion"], self.os_version)
+ self.assertFalse(self.caps["acceptInsecureCerts"])
+ if self.appinfo["name"] == "Firefox":
+ self.assertTrue(self.caps["setWindowRect"])
+ else:
+ self.assertFalse(self.caps["setWindowRect"])
+ self.assertDictEqual(
+ self.caps["timeouts"], {"implicit": 0, "pageLoad": 300000, "script": 30000}
+ )
+ self.assertTrue(self.caps["strictFileInteractability"])
+
+ def test_supported_features(self):
+ self.assertIn("rotatable", self.caps)
+
+ def test_additional_capabilities(self):
+ self.assertIn("moz:processID", self.caps)
+ self.assertEqual(self.caps["moz:processID"], self.appinfo["processID"])
+ self.assertEqual(self.marionette.process_id, self.appinfo["processID"])
+
+ self.assertIn("moz:profile", self.caps)
+ if self.marionette.instance is not None:
+ if self.caps["browserName"] == "fennec":
+ current_profile = (
+ self.marionette.instance.runner.device.app_ctx.remote_profile
+ )
+ else:
+ current_profile = self.marionette.profile_path
+ # Bug 1438461 - mozprofile uses lower-case letters even on case-sensitive filesystems
+ # Bug 1533221 - paths may differ due to file system links or aliases
+ self.assertEqual(
+ os.path.basename(self.caps["moz:profile"]).lower(),
+ os.path.basename(current_profile).lower(),
+ )
+
+ self.assertIn("moz:accessibilityChecks", self.caps)
+ self.assertFalse(self.caps["moz:accessibilityChecks"])
+
+ self.assertIn("moz:buildID", self.caps)
+ self.assertEqual(self.caps["moz:buildID"], self.appinfo["buildID"])
+
+ self.assertNotIn("moz:debuggerAddress", self.caps)
+
+ self.assertIn("moz:useNonSpecCompliantPointerOrigin", self.caps)
+ self.assertFalse(self.caps["moz:useNonSpecCompliantPointerOrigin"])
+
+ self.assertIn("moz:webdriverClick", self.caps)
+ self.assertTrue(self.caps["moz:webdriverClick"])
+
+ def test_disable_webdriver_click(self):
+ self.marionette.delete_session()
+ self.marionette.start_session({"moz:webdriverClick": False})
+ caps = self.marionette.session_capabilities
+ self.assertFalse(caps["moz:webdriverClick"])
+
+ def test_use_non_spec_compliant_pointer_origin(self):
+ self.marionette.delete_session()
+ self.marionette.start_session({"moz:useNonSpecCompliantPointerOrigin": True})
+ caps = self.marionette.session_capabilities
+ self.assertTrue(caps["moz:useNonSpecCompliantPointerOrigin"])
+
+ def test_we_get_valid_uuid4_when_creating_a_session(self):
+ self.assertNotIn(
+ "{",
+ self.marionette.session_id,
+ "Session ID has {{}} in it: {}".format(self.marionette.session_id),
+ )
+
+
+class TestCapabilityMatching(MarionetteTestCase):
+ def setUp(self):
+ MarionetteTestCase.setUp(self)
+ self.browser_name = self.marionette.session_capabilities["browserName"]
+ self.delete_session()
+
+ def delete_session(self):
+ if self.marionette.session is not None:
+ self.marionette.delete_session()
+
+ def test_accept_insecure_certs(self):
+ for value in ["", 42, {}, []]:
+ print(" type {}".format(type(value)))
+ with self.assertRaises(SessionNotCreatedException):
+ self.marionette.start_session({"acceptInsecureCerts": value})
+
+ self.delete_session()
+ self.marionette.start_session({"acceptInsecureCerts": True})
+ self.assertTrue(self.marionette.session_capabilities["acceptInsecureCerts"])
+
+ def test_page_load_strategy(self):
+ for strategy in ["none", "eager", "normal"]:
+ print("valid strategy {}".format(strategy))
+ self.delete_session()
+ self.marionette.start_session({"pageLoadStrategy": strategy})
+ self.assertEqual(
+ self.marionette.session_capabilities["pageLoadStrategy"], strategy
+ )
+
+ for value in ["", "EAGER", True, 42, {}, [], None]:
+ print("invalid strategy {}".format(value))
+ with self.assertRaisesRegexp(
+ SessionNotCreatedException, "InvalidArgumentError"
+ ):
+ self.marionette.start_session({"pageLoadStrategy": value})
+
+ def test_set_window_rect(self):
+ if self.browser_name == "firefox":
+ self.marionette.start_session({"setWindowRect": True})
+ self.delete_session()
+ with self.assertRaisesRegexp(
+ SessionNotCreatedException, "InvalidArgumentError"
+ ):
+ self.marionette.start_session({"setWindowRect": False})
+ else:
+ self.marionette.start_session({"setWindowRect": False})
+ self.delete_session()
+ with self.assertRaisesRegexp(
+ SessionNotCreatedException, "InvalidArgumentError"
+ ):
+ self.marionette.start_session({"setWindowRect": True})
+
+ def test_timeouts(self):
+ for value in ["", 2.5, {}, []]:
+ print(" type {}".format(type(value)))
+ with self.assertRaises(SessionNotCreatedException):
+ self.marionette.start_session({"timeouts": {"pageLoad": value}})
+
+ self.delete_session()
+
+ timeouts = {"implicit": 0, "pageLoad": 2.0, "script": 2 ** 53 - 1}
+ self.marionette.start_session({"timeouts": timeouts})
+ self.assertIn("timeouts", self.marionette.session_capabilities)
+ self.assertDictEqual(self.marionette.session_capabilities["timeouts"], timeouts)
+ self.assertDictEqual(
+ self.marionette._send_message("WebDriver:GetTimeouts"), timeouts
+ )
+
+ def test_strict_file_interactability(self):
+ for value in ["", 2.5, {}, []]:
+ print(" type {}".format(type(value)))
+ with self.assertRaises(SessionNotCreatedException):
+ self.marionette.start_session({"strictFileInteractability": value})
+
+ self.delete_session()
+
+ self.marionette.start_session({"strictFileInteractability": True})
+ self.assertIn("strictFileInteractability", self.marionette.session_capabilities)
+ self.assertTrue(
+ self.marionette.session_capabilities["strictFileInteractability"]
+ )
+
+ self.delete_session()
+
+ self.marionette.start_session({"strictFileInteractability": False})
+ self.assertIn("strictFileInteractability", self.marionette.session_capabilities)
+ self.assertFalse(
+ self.marionette.session_capabilities["strictFileInteractability"]
+ )
+
+ def test_unhandled_prompt_behavior(self):
+ behaviors = [
+ "accept",
+ "accept and notify",
+ "dismiss",
+ "dismiss and notify",
+ "ignore",
+ ]
+
+ for behavior in behaviors:
+ print("valid unhandled prompt behavior {}".format(behavior))
+ self.delete_session()
+ self.marionette.start_session({"unhandledPromptBehavior": behavior})
+ self.assertEqual(
+ self.marionette.session_capabilities["unhandledPromptBehavior"],
+ behavior,
+ )
+
+ # Default value
+ self.delete_session()
+ self.marionette.start_session()
+ self.assertEqual(
+ self.marionette.session_capabilities["unhandledPromptBehavior"],
+ "dismiss and notify",
+ )
+
+ # Invalid values
+ self.delete_session()
+ for behavior in [None, "", "ACCEPT", True, 42, {}, []]:
+ print("invalid unhandled prompt behavior {}".format(behavior))
+ with self.assertRaisesRegexp(
+ SessionNotCreatedException, "InvalidArgumentError"
+ ):
+ self.marionette.start_session({"unhandledPromptBehavior": behavior})
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_checkbox.py b/testing/marionette/harness/marionette_harness/tests/unit/test_checkbox.py
new file mode 100644
index 0000000000..d01fe4bc3a
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_checkbox.py
@@ -0,0 +1,19 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver.by import By
+
+from marionette_harness import MarionetteTestCase
+
+
+class TestCheckbox(MarionetteTestCase):
+ def test_selected(self):
+ test_html = self.marionette.absolute_url("test.html")
+ self.marionette.navigate(test_html)
+ box = self.marionette.find_element(By.NAME, "myCheckBox")
+ self.assertFalse(box.is_selected())
+ box.click()
+ self.assertTrue(box.is_selected())
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_checkbox_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_checkbox_chrome.py
new file mode 100644
index 0000000000..30b1c0fe6d
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_checkbox_chrome.py
@@ -0,0 +1,33 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver.by import By
+
+from marionette_harness import MarionetteTestCase, WindowManagerMixin
+
+
+class TestSelectedChrome(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestSelectedChrome, self).setUp()
+
+ self.marionette.set_context("chrome")
+
+ new_window = self.open_chrome_window("chrome://marionette/content/test.xhtml")
+ self.marionette.switch_to_window(new_window)
+
+ def tearDown(self):
+ try:
+ self.close_all_windows()
+ finally:
+ super(TestSelectedChrome, self).tearDown()
+
+ def test_selected(self):
+ box = self.marionette.find_element(By.ID, "testBox")
+ self.assertFalse(box.is_selected())
+ self.assertFalse(
+ self.marionette.execute_script("arguments[0].checked = true;", [box])
+ )
+ self.assertTrue(box.is_selected())
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_chrome.py
new file mode 100644
index 0000000000..3efe769f0a
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_chrome.py
@@ -0,0 +1,33 @@
+from __future__ import absolute_import
+
+from marionette_harness import MarionetteTestCase, WindowManagerMixin
+
+
+class ChromeTests(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(ChromeTests, self).setUp()
+
+ def tearDown(self):
+ self.close_all_windows()
+ super(ChromeTests, self).tearDown()
+
+ def test_hang_until_timeout(self):
+ with self.marionette.using_context("chrome"):
+ new_window = self.open_window()
+ self.marionette.switch_to_window(new_window)
+
+ try:
+ try:
+ # Raise an exception type which should not be thrown by Marionette
+ # while running this test. Otherwise it would mask eg. IOError as
+ # thrown for a socket timeout.
+ raise NotImplementedError(
+ "Exception should not cause a hang when "
+ "closing the chrome window in content "
+ "context"
+ )
+ finally:
+ self.marionette.close_chrome_window()
+ self.marionette.switch_to_window(self.start_window)
+ except NotImplementedError:
+ pass
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_chrome_action.py b/testing/marionette/harness/marionette_harness/tests/unit/test_chrome_action.py
new file mode 100644
index 0000000000..038194f4da
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_chrome_action.py
@@ -0,0 +1,74 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver import By, errors
+from marionette_driver.keys import Keys
+
+from marionette_harness import MarionetteTestCase, WindowManagerMixin
+
+
+class TestPointerActions(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestPointerActions, self).setUp()
+
+ self.actors_enabled = self.marionette.get_pref("marionette.actors.enabled")
+ if self.actors_enabled is None:
+ self.actors_enabled = True
+
+ self.mouse_chain = self.marionette.actions.sequence(
+ "pointer", "pointer_id", {"pointerType": "mouse"}
+ )
+ self.key_chain = self.marionette.actions.sequence("key", "keyboard_id")
+
+ if self.marionette.session_capabilities["platformName"] == "mac":
+ self.mod_key = Keys.META
+ else:
+ self.mod_key = Keys.CONTROL
+
+ self.marionette.set_context("chrome")
+
+ self.win = self.open_chrome_window("chrome://marionette/content/test.xhtml")
+ self.marionette.switch_to_window(self.win)
+
+ def tearDown(self):
+ if self.actors_enabled:
+ self.marionette.actions.release()
+ self.close_all_windows()
+
+ super(TestPointerActions, self).tearDown()
+
+ def test_click_action(self):
+ box = self.marionette.find_element(By.ID, "testBox")
+ box.get_property("localName")
+ if self.actors_enabled:
+ self.assertFalse(
+ self.marionette.execute_script(
+ "return document.getElementById('testBox').checked"
+ )
+ )
+ self.mouse_chain.click(element=box).perform()
+ self.assertTrue(
+ self.marionette.execute_script(
+ "return document.getElementById('testBox').checked"
+ )
+ )
+ else:
+ with self.assertRaises(errors.UnsupportedOperationException):
+ self.mouse_chain.click(element=box).perform()
+
+ def test_key_action(self):
+ self.marionette.find_element(By.ID, "textInput").click()
+ if self.actors_enabled:
+ self.key_chain.send_keys("x").perform()
+ self.assertEqual(
+ self.marionette.execute_script(
+ "return document.getElementById('textInput').value"
+ ),
+ "testx",
+ )
+ else:
+ with self.assertRaises(errors.UnsupportedOperationException):
+ self.key_chain.send_keys("x").perform()
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_chrome_element_css.py b/testing/marionette/harness/marionette_harness/tests/unit/test_chrome_element_css.py
new file mode 100644
index 0000000000..a160c62fc9
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_chrome_element_css.py
@@ -0,0 +1,33 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver.by import By
+
+from marionette_harness import MarionetteTestCase
+
+
+class TestChromeElementCSS(MarionetteTestCase):
+ def get_element_computed_style(self, element, property):
+ return self.marionette.execute_script(
+ """
+ const [el, prop] = arguments;
+ const elStyle = window.getComputedStyle(el);
+ return elStyle[prop];""",
+ script_args=(element, property),
+ sandbox=None,
+ )
+
+ def test_we_can_get_css_value_on_chrome_element(self):
+ with self.marionette.using_context("chrome"):
+ identity_icon = self.marionette.find_element(By.ID, "identity-icon")
+ favicon_image = identity_icon.value_of_css_property("list-style-image")
+ self.assertIn("chrome://", favicon_image)
+ identity_box = self.marionette.find_element(By.ID, "identity-box")
+ expected_bg_colour = self.get_element_computed_style(
+ identity_box, "backgroundColor"
+ )
+ actual_bg_colour = identity_box.value_of_css_property("background-color")
+ self.assertEqual(expected_bg_colour, actual_bg_colour)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_cli_arguments.py b/testing/marionette/harness/marionette_harness/tests/unit/test_cli_arguments.py
new file mode 100644
index 0000000000..6997eba01a
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_cli_arguments.py
@@ -0,0 +1,68 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import copy
+
+import requests
+
+from marionette_harness import MarionetteTestCase
+
+
+class TestCommandLineArguments(MarionetteTestCase):
+ def setUp(self):
+ super(TestCommandLineArguments, self).setUp()
+
+ self.orig_arguments = copy.copy(self.marionette.instance.app_args)
+
+ def tearDown(self):
+ self.marionette.instance.app_args = self.orig_arguments
+ self.marionette.quit(clean=True)
+
+ super(TestCommandLineArguments, self).tearDown()
+
+ def test_remote_agent_enabled(self):
+ debugger_address = self.marionette.session_capabilities.get(
+ "moz:debuggerAddress"
+ )
+ self.assertIsNone(debugger_address)
+
+ self.marionette.instance.app_args.append("-remote-debugging-port")
+
+ self.marionette.quit()
+ self.marionette.start_session()
+
+ debugger_address = self.marionette.session_capabilities.get(
+ "moz:debuggerAddress"
+ )
+
+ self.assertEqual(debugger_address, "localhost:9222")
+ result = requests.get(url="http://{}/json/version".format(debugger_address))
+ self.assertTrue(result.ok)
+
+ def test_start_in_safe_mode(self):
+ self.marionette.instance.app_args.append("-safe-mode")
+
+ self.marionette.quit()
+ self.marionette.start_session()
+
+ with self.marionette.using_context("chrome"):
+ safe_mode = self.marionette.execute_script(
+ """
+ Cu.import("resource://gre/modules/Services.jsm");
+
+ return Services.appinfo.inSafeMode;
+ """
+ )
+ self.assertTrue(safe_mode, "Safe Mode has not been enabled")
+
+ def test_startup_timeout(self):
+ try:
+ self.marionette.quit()
+ with self.assertRaisesRegexp(IOError, "Process killed after 0s"):
+ # Use a small enough timeout which should always cause an IOError
+ self.marionette.start_session(timeout=0)
+ finally:
+ self.marionette.start_session()
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_click.py b/testing/marionette/harness/marionette_harness/tests/unit/test_click.py
new file mode 100644
index 0000000000..bbe8172bab
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_click.py
@@ -0,0 +1,589 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from six.moves.urllib.parse import quote
+
+from marionette_driver import By, errors
+from marionette_driver.marionette import Alert
+
+from marionette_harness import (
+ MarionetteTestCase,
+ WindowManagerMixin,
+)
+
+
+def inline(doc):
+ return "data:text/html;charset=utf-8,{}".format(quote(doc))
+
+
+# The <a> element in the following HTML is not interactable because it
+# is hidden by an overlay when scrolled into the top of the viewport.
+# It should be interactable when scrolled in at the bottom of the
+# viewport.
+fixed_overlay = inline(
+ """
+<style>
+* { margin: 0; padding: 0; }
+body { height: 300vh }
+div, a { display: block }
+div {
+ background-color: pink;
+ position: fixed;
+ width: 100%;
+ height: 40px;
+ top: 0;
+}
+a {
+ margin-top: 1000px;
+}
+</style>
+
+<div>overlay</div>
+<a href=#>link</a>
+
+<script>
+window.clicked = false;
+
+let link = document.querySelector("a");
+link.addEventListener("click", () => window.clicked = true);
+</script>
+"""
+)
+
+
+obscured_overlay = inline(
+ """
+<style>
+* { margin: 0; padding: 0; }
+body { height: 100vh }
+#overlay {
+ background-color: pink;
+ position: absolute;
+ width: 100%;
+ height: 100%;
+}
+</style>
+
+<div id=overlay></div>
+<a id=obscured href=#>link</a>
+
+<script>
+window.clicked = false;
+
+let link = document.querySelector("#obscured");
+link.addEventListener("click", () => window.clicked = true);
+</script>
+"""
+)
+
+
+class ClickBaseTestCase(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(ClickBaseTestCase, self).setUp()
+
+ # Always use a blank new tab for an empty history
+ self.new_tab = self.open_tab()
+ self.marionette.switch_to_window(self.new_tab)
+
+ def tearDown(self):
+ self.close_all_tabs()
+
+ def test_click(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <button>click me</button>
+ <script>
+ window.clicks = 0;
+ let button = document.querySelector("button");
+ button.addEventListener("click", () => window.clicks++);
+ </script>
+ """
+ )
+ )
+ button = self.marionette.find_element(By.TAG_NAME, "button")
+ button.click()
+ self.assertEqual(
+ 1, self.marionette.execute_script("return window.clicks", sandbox=None)
+ )
+
+ def test_click_number_link(self):
+ test_html = self.marionette.absolute_url("clicks.html")
+ self.marionette.navigate(test_html)
+ self.marionette.find_element(By.LINK_TEXT, "333333").click()
+ self.marionette.find_element(By.ID, "testDiv")
+ self.assertEqual(self.marionette.title, "Marionette Test")
+
+ def test_clicking_an_element_that_is_not_displayed_raises(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <p hidden>foo</p>
+ """
+ )
+ )
+
+ with self.assertRaises(errors.ElementNotInteractableException):
+ self.marionette.find_element(By.TAG_NAME, "p").click()
+
+ def test_clicking_on_a_multiline_link(self):
+ test_html = self.marionette.absolute_url("clicks.html")
+ self.marionette.navigate(test_html)
+ self.marionette.find_element(By.ID, "overflowLink").click()
+ self.marionette.find_element(By.ID, "testDiv")
+ self.assertEqual(self.marionette.title, "Marionette Test")
+
+ def test_click_mathml(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <math><mtext id="target">click me</mtext></math>
+ <script>
+ window.clicks = 0;
+ let mtext = document.getElementById("target");
+ mtext.addEventListener("click", () => window.clicks++);
+ </script>
+ """
+ )
+ )
+ mtext = self.marionette.find_element(By.ID, "target")
+ mtext.click()
+ self.assertEqual(
+ 1, self.marionette.execute_script("return window.clicks", sandbox=None)
+ )
+
+ def test_scroll_into_view_near_end(self):
+ self.marionette.navigate(fixed_overlay)
+ link = self.marionette.find_element(By.TAG_NAME, "a")
+ link.click()
+ self.assertTrue(
+ self.marionette.execute_script("return window.clicked", sandbox=None)
+ )
+
+ def test_inclusive_descendant(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <select multiple>
+ <option>first
+ <option>second
+ <option>third
+ </select>"""
+ )
+ )
+ select = self.marionette.find_element(By.TAG_NAME, "select")
+
+ # This tests that the pointer-interactability test does not
+ # cause an ElementClickInterceptedException.
+ #
+ # At a <select multiple>'s in-view centre point, you might
+ # find a fully rendered <option>. Marionette should test that
+ # the paint tree at this point _contains_ <option>, not that the
+ # first element of the paint tree is _equal_ to <select>.
+ select.click()
+
+ # Bug 1413821 - Click does not select an option on Android
+ if self.marionette.session_capabilities["browserName"] != "fennec":
+ self.assertNotEqual(select.get_property("selectedIndex"), -1)
+
+ def test_container_is_select(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <select>
+ <option>foo</option>
+ </select>"""
+ )
+ )
+ option = self.marionette.find_element(By.TAG_NAME, "option")
+ option.click()
+ self.assertTrue(option.get_property("selected"))
+
+ def test_container_is_button(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <button onclick="window.clicked = true;">
+ <span><em>foo</em></span>
+ </button>"""
+ )
+ )
+ span = self.marionette.find_element(By.TAG_NAME, "span")
+ span.click()
+ self.assertTrue(
+ self.marionette.execute_script("return window.clicked", sandbox=None)
+ )
+
+ def test_container_element_outside_view(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <select style="margin-top: 100vh">
+ <option>foo</option>
+ </select>"""
+ )
+ )
+ option = self.marionette.find_element(By.TAG_NAME, "option")
+ option.click()
+ self.assertTrue(option.get_property("selected"))
+
+ def test_table_tr(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <table>
+ <tr><td onclick="window.clicked = true;">
+ foo
+ </td></tr>
+ </table>"""
+ )
+ )
+ tr = self.marionette.find_element(By.TAG_NAME, "tr")
+ tr.click()
+ self.assertTrue(
+ self.marionette.execute_script("return window.clicked", sandbox=None)
+ )
+
+
+class TestLegacyClick(ClickBaseTestCase):
+ """Uses legacy Selenium element displayedness checks."""
+
+ def setUp(self):
+ super(TestLegacyClick, self).setUp()
+
+ self.marionette.delete_session()
+ self.marionette.start_session({"moz:webdriverClick": False})
+
+
+class TestClick(ClickBaseTestCase):
+ """Uses WebDriver specification compatible element interactability checks."""
+
+ def setUp(self):
+ super(TestClick, self).setUp()
+
+ self.marionette.delete_session()
+ self.marionette.start_session({"moz:webdriverClick": True})
+
+ def test_click_element_obscured_by_absolute_positioned_element(self):
+ self.marionette.navigate(obscured_overlay)
+ overlay = self.marionette.find_element(By.ID, "overlay")
+ obscured = self.marionette.find_element(By.ID, "obscured")
+
+ overlay.click()
+ with self.assertRaises(errors.ElementClickInterceptedException):
+ obscured.click()
+
+ def test_centre_outside_viewport_vertically(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <style>
+ * { margin: 0; padding: 0; }
+ div {
+ display: block;
+ position: absolute;
+ background-color: blue;
+ width: 200px;
+ height: 200px;
+
+ /* move centre point off viewport vertically */
+ top: -105px;
+ }
+ </style>
+
+ <div onclick="window.clicked = true;"></div>"""
+ )
+ )
+
+ self.marionette.find_element(By.TAG_NAME, "div").click()
+ self.assertTrue(
+ self.marionette.execute_script("return window.clicked", sandbox=None)
+ )
+
+ def test_centre_outside_viewport_horizontally(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <style>
+ * { margin: 0; padding: 0; }
+ div {
+ display: block;
+ position: absolute;
+ background-color: blue;
+ width: 200px;
+ height: 200px;
+
+ /* move centre point off viewport horizontally */
+ left: -105px;
+ }
+ </style>
+
+ <div onclick="window.clicked = true;"></div>"""
+ )
+ )
+
+ self.marionette.find_element(By.TAG_NAME, "div").click()
+ self.assertTrue(
+ self.marionette.execute_script("return window.clicked", sandbox=None)
+ )
+
+ def test_centre_outside_viewport(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <style>
+ * { margin: 0; padding: 0; }
+ div {
+ display: block;
+ position: absolute;
+ background-color: blue;
+ width: 200px;
+ height: 200px;
+
+ /* move centre point off viewport */
+ left: -105px;
+ top: -105px;
+ }
+ </style>
+
+ <div onclick="window.clicked = true;"></div>"""
+ )
+ )
+
+ self.marionette.find_element(By.TAG_NAME, "div").click()
+ self.assertTrue(
+ self.marionette.execute_script("return window.clicked", sandbox=None)
+ )
+
+ def test_css_transforms(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <style>
+ * { margin: 0; padding: 0; }
+ div {
+ display: block;
+ background-color: blue;
+ width: 200px;
+ height: 200px;
+
+ transform: translateX(-105px);
+ }
+ </style>
+
+ <div onclick="window.clicked = true;"></div>"""
+ )
+ )
+
+ self.marionette.find_element(By.TAG_NAME, "div").click()
+ self.assertTrue(
+ self.marionette.execute_script("return window.clicked", sandbox=None)
+ )
+
+ def test_input_file(self):
+ self.marionette.navigate(inline("<input type=file>"))
+ with self.assertRaises(errors.InvalidArgumentException):
+ self.marionette.find_element(By.TAG_NAME, "input").click()
+
+ def test_obscured_element(self):
+ self.marionette.navigate(obscured_overlay)
+ overlay = self.marionette.find_element(By.ID, "overlay")
+ obscured = self.marionette.find_element(By.ID, "obscured")
+
+ overlay.click()
+ with self.assertRaises(errors.ElementClickInterceptedException):
+ obscured.click()
+ self.assertFalse(
+ self.marionette.execute_script("return window.clicked", sandbox=None)
+ )
+
+ def test_pointer_events_none(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <button style="pointer-events: none">click me</button>
+ <script>
+ window.clicked = false;
+ let button = document.querySelector("button");
+ button.addEventListener("click", () => window.clicked = true);
+ </script>
+ """
+ )
+ )
+ button = self.marionette.find_element(By.TAG_NAME, "button")
+ self.assertEqual("none", button.value_of_css_property("pointer-events"))
+
+ with self.assertRaisesRegexp(
+ errors.ElementClickInterceptedException,
+ "does not have pointer events enabled",
+ ):
+ button.click()
+ self.assertFalse(
+ self.marionette.execute_script("return window.clicked", sandbox=None)
+ )
+
+ def test_prevent_default(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <button>click me</button>
+ <script>
+ let button = document.querySelector("button");
+ button.addEventListener("click", event => event.preventDefault());
+ </script>
+ """
+ )
+ )
+ button = self.marionette.find_element(By.TAG_NAME, "button")
+ # should not time out
+ button.click()
+
+ def test_stop_propagation(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <button>click me</button>
+ <script>
+ let button = document.querySelector("button");
+ button.addEventListener("click", event => event.stopPropagation());
+ </script>
+ """
+ )
+ )
+ button = self.marionette.find_element(By.TAG_NAME, "button")
+ # should not time out
+ button.click()
+
+ def test_stop_immediate_propagation(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <button>click me</button>
+ <script>
+ let button = document.querySelector("button");
+ button.addEventListener("click", event => event.stopImmediatePropagation());
+ </script>
+ """
+ )
+ )
+ button = self.marionette.find_element(By.TAG_NAME, "button")
+ # should not time out
+ button.click()
+
+
+class TestClickNavigation(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestClickNavigation, self).setUp()
+
+ # Always use a blank new tab for an empty history
+ self.new_tab = self.open_tab()
+ self.marionette.switch_to_window(self.new_tab)
+
+ self.test_page = self.marionette.absolute_url("clicks.html")
+ self.marionette.navigate(self.test_page)
+
+ def tearDown(self):
+ self.close_all_tabs()
+
+ def close_notification(self):
+ try:
+ with self.marionette.using_context("chrome"):
+ elem = self.marionette.find_element(
+ By.CSS_SELECTOR,
+ "#notification-popup popupnotification .popup-notification-closebutton",
+ )
+ elem.click()
+ except errors.NoSuchElementException:
+ pass
+
+ def test_click_link_page_load(self):
+ self.marionette.find_element(By.LINK_TEXT, "333333").click()
+ self.assertNotEqual(self.marionette.get_url(), self.test_page)
+ self.assertEqual(self.marionette.title, "Marionette Test")
+
+ def test_click_link_page_load_dismissed_beforeunload_prompt(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <input type="text"></input>
+ <a href="{}">Click</a>
+ <script>
+ window.addEventListener("beforeunload", function (event) {{
+ event.preventDefault();
+ }});
+ </script>
+ """.format(
+ self.marionette.absolute_url("clicks.html")
+ )
+ )
+ )
+ self.marionette.find_element(By.TAG_NAME, "input").send_keys("foo")
+ self.marionette.find_element(By.TAG_NAME, "a").click()
+
+ # navigation auto-dismisses beforeunload prompt
+ with self.assertRaises(errors.NoAlertPresentException):
+ Alert(self.marionette).text
+
+ def test_click_link_anchor(self):
+ self.marionette.find_element(By.ID, "anchor").click()
+ self.assertEqual(self.marionette.get_url(), "{}#".format(self.test_page))
+
+ def test_click_link_install_addon(self):
+ try:
+ self.marionette.find_element(By.ID, "install-addon").click()
+ self.assertEqual(self.marionette.get_url(), self.test_page)
+ finally:
+ self.close_notification()
+
+ def test_click_no_link(self):
+ self.marionette.find_element(By.ID, "links").click()
+ self.assertEqual(self.marionette.get_url(), self.test_page)
+
+ def test_click_option_navigate(self):
+ self.marionette.find_element(By.ID, "option").click()
+ self.marionette.find_element(By.ID, "delay")
+
+ def test_click_remoteness_change(self):
+ self.marionette.navigate("about:robots")
+ self.marionette.navigate(self.test_page)
+ self.marionette.find_element(By.ID, "anchor")
+
+ self.marionette.navigate("about:robots")
+ with self.assertRaises(errors.NoSuchElementException):
+ self.marionette.find_element(By.ID, "anchor")
+
+ self.marionette.go_back()
+ self.marionette.find_element(By.ID, "anchor")
+
+ self.marionette.find_element(By.ID, "history-back").click()
+ with self.assertRaises(errors.NoSuchElementException):
+ self.marionette.find_element(By.ID, "anchor")
+
+
+class TestClickCloseContext(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestClickCloseContext, self).setUp()
+
+ self.test_page = self.marionette.absolute_url("clicks.html")
+
+ def tearDown(self):
+ self.close_all_tabs()
+
+ super(TestClickCloseContext, self).tearDown()
+
+ def test_click_close_tab(self):
+ new_tab = self.open_tab()
+ self.marionette.switch_to_window(new_tab)
+
+ self.marionette.navigate(self.test_page)
+ self.marionette.find_element(By.ID, "close-window").click()
+
+ def test_click_close_window(self):
+ new_tab = self.open_window()
+ self.marionette.switch_to_window(new_tab)
+
+ self.marionette.navigate(self.test_page)
+ self.marionette.find_element(By.ID, "close-window").click()
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_click_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_click_chrome.py
new file mode 100644
index 0000000000..d97d2a3893
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_click_chrome.py
@@ -0,0 +1,35 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver.by import By
+
+from marionette_harness import MarionetteTestCase, WindowManagerMixin
+
+
+class TestClickChrome(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestClickChrome, self).setUp()
+
+ self.marionette.set_context("chrome")
+
+ def tearDown(self):
+ self.close_all_windows()
+
+ super(TestClickChrome, self).tearDown()
+
+ def test_click(self):
+ win = self.open_chrome_window("chrome://marionette/content/test.xhtml")
+ self.marionette.switch_to_window(win)
+
+ def checked():
+ return self.marionette.execute_script(
+ "return arguments[0].checked", script_args=[box]
+ )
+
+ box = self.marionette.find_element(By.ID, "testBox")
+ self.assertFalse(checked())
+ box.click()
+ self.assertTrue(checked())
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_click_scrolling.py b/testing/marionette/harness/marionette_harness/tests/unit/test_click_scrolling.py
new file mode 100644
index 0000000000..227afd2499
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_click_scrolling.py
@@ -0,0 +1,169 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from six.moves.urllib.parse import quote
+
+from marionette_driver.by import By
+from marionette_driver.errors import MoveTargetOutOfBoundsException
+
+from marionette_harness import MarionetteTestCase
+
+
+def inline(doc):
+ return "data:text/html;charset=utf-8,{}".format(quote(doc))
+
+
+class TestClickScrolling(MarionetteTestCase):
+ def test_clicking_on_anchor_scrolls_page(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <a href="#content">Link to content</a>
+ <div id="content" style="margin-top: 205vh;">Text</div>
+ """
+ )
+ )
+
+ # Focusing on to click, but not actually following,
+ # the link will scroll it in to view, which is a few
+ # pixels further than 0
+ self.marionette.find_element(By.CSS_SELECTOR, "a").click()
+
+ y_offset = self.marionette.execute_script(
+ """
+ var pageY;
+ if (typeof(window.pageYOffset) == 'number') {
+ pageY = window.pageYOffset;
+ } else {
+ pageY = document.documentElement.scrollTop;
+ }
+ return pageY;
+ """
+ )
+
+ self.assertGreater(y_offset, 300)
+
+ def test_should_scroll_to_click_on_an_element_hidden_by_overflow(self):
+ test_html = self.marionette.absolute_url("click_out_of_bounds_overflow.html")
+ self.marionette.navigate(test_html)
+
+ link = self.marionette.find_element(By.ID, "link")
+ try:
+ link.click()
+ except MoveTargetOutOfBoundsException:
+ self.fail("Should not be out of bounds")
+
+ def test_should_not_scroll_elements_if_click_point_is_in_view(self):
+ test_html = self.marionette.absolute_url("element_outside_viewport.html")
+
+ for s in ["top", "right", "bottom", "left"]:
+ for p in ["50", "30"]:
+ self.marionette.navigate(test_html)
+ scroll = self.marionette.execute_script(
+ "return [window.scrollX, window.scrollY];"
+ )
+ self.marionette.find_element(By.ID, "{0}-{1}".format(s, p)).click()
+ self.assertEqual(
+ scroll,
+ self.marionette.execute_script(
+ "return [window.scrollX, window.scrollY];"
+ ),
+ )
+
+ def test_do_not_scroll_again_if_element_is_already_in_view(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <div style="height: 200vh;">
+ <button id="button1" style="margin-top: 105vh">Button1</button>
+ <button id="button2" style="position: relative; top: 5em">Button2</button>
+ </div>
+ """
+ )
+ )
+ button1 = self.marionette.find_element(By.ID, "button1")
+ button2 = self.marionette.find_element(By.ID, "button2")
+
+ button2.click()
+ scroll_top = self.marionette.execute_script("return document.body.scrollTop;")
+ button1.click()
+
+ self.assertEqual(
+ scroll_top,
+ self.marionette.execute_script("return document.body.scrollTop;"),
+ )
+
+ def test_scroll_radio_button_into_view(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <input type="radio" id="radio" style="margin-top: 105vh;">
+ """
+ )
+ )
+ self.marionette.find_element(By.ID, "radio").click()
+
+ def test_overflow_scroll_do_not_scroll_elements_which_are_visible(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <ul style='overflow: scroll; height: 8em; line-height: 3em'>
+ <li></li>
+ <li id="desired">Text</li>
+ <li></li>
+ <li></li>
+ </ul>
+ """
+ )
+ )
+
+ list_el = self.marionette.find_element(By.TAG_NAME, "ul")
+ expected_y_offset = self.marionette.execute_script(
+ "return arguments[0].scrollTop;", script_args=(list_el,)
+ )
+
+ item = list_el.find_element(By.ID, "desired")
+ item.click()
+
+ y_offset = self.marionette.execute_script(
+ "return arguments[0].scrollTop;", script_args=(list_el,)
+ )
+ self.assertEqual(expected_y_offset, y_offset)
+
+ def test_overflow_scroll_click_on_hidden_element(self):
+ self.marionette.navigate(
+ inline(
+ """
+ Result: <span id="result"></span>
+ <ul style='overflow: scroll; width: 150px; height: 8em; line-height: 4em'
+ onclick="document.getElementById('result').innerText = event.target.id;">
+ <li>line1</li>
+ <li>line2</li>
+ <li>line3</li>
+ <li id="line4">line4</li>
+ </ul>
+ """
+ )
+ )
+
+ self.marionette.find_element(By.ID, "line4").click()
+ self.assertEqual("line4", self.marionette.find_element(By.ID, "result").text)
+
+ def test_overflow_scroll_vertically_for_click_point_outside_of_viewport(self):
+ self.marionette.navigate(
+ inline(
+ """
+ Result: <span id="result"></span>
+ <div style='overflow: scroll; width: 100px; height: 100px; background-color: yellow;'>
+ <div id="inner" style="width: 100px; height: 300px; background-color: green;"
+ onclick="document.getElementById('result').innerText = event.type" ></div>
+ </div>
+ """
+ )
+ )
+
+ self.marionette.find_element(By.ID, "inner").click()
+ self.assertEqual("click", self.marionette.find_element(By.ID, "result").text)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_context.py b/testing/marionette/harness/marionette_harness/tests/unit/test_context.py
new file mode 100644
index 0000000000..f463e17f42
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_context.py
@@ -0,0 +1,84 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver.decorators import using_context
+from marionette_driver.errors import MarionetteException
+from marionette_harness import MarionetteTestCase
+
+
+class ContextTestCase(MarionetteTestCase):
+ def setUp(self):
+ super(ContextTestCase, self).setUp()
+
+ # shortcuts to improve readability of these tests
+ self.chrome = self.marionette.CONTEXT_CHROME
+ self.content = self.marionette.CONTEXT_CONTENT
+
+ self.assertEqual(self.get_context(), self.content)
+
+ test_url = self.marionette.absolute_url("empty.html")
+ self.marionette.navigate(test_url)
+
+ def get_context(self):
+ return self.marionette._send_message("Marionette:GetContext", key="value")
+
+
+class TestSetContext(ContextTestCase):
+ def test_switch_context(self):
+ self.marionette.set_context(self.chrome)
+ self.assertEqual(self.get_context(), self.chrome)
+
+ self.marionette.set_context(self.content)
+ self.assertEqual(self.get_context(), self.content)
+
+ def test_invalid_context(self):
+ with self.assertRaises(ValueError):
+ self.marionette.set_context("foobar")
+
+
+class TestUsingContext(ContextTestCase):
+ def test_set_different_context_using_with_block(self):
+ with self.marionette.using_context(self.chrome):
+ self.assertEqual(self.get_context(), self.chrome)
+ self.assertEquals(self.get_context(), self.content)
+
+ def test_set_same_context_using_with_block(self):
+ with self.marionette.using_context(self.content):
+ self.assertEquals(self.get_context(), self.content)
+ self.assertEquals(self.get_context(), self.content)
+
+ def test_nested_with_blocks(self):
+ with self.marionette.using_context(self.chrome):
+ self.assertEquals(self.get_context(), self.chrome)
+ with self.marionette.using_context(self.content):
+ self.assertEquals(self.get_context(), self.content)
+ self.assertEquals(self.get_context(), self.chrome)
+ self.assertEquals(self.get_context(), self.content)
+
+ def test_set_scope_while_in_with_block(self):
+ with self.marionette.using_context(self.chrome):
+ self.assertEquals(self.get_context(), self.chrome)
+ self.marionette.set_context(self.content)
+ self.assertEquals(self.get_context(), self.content)
+ self.assertEquals(self.get_context(), self.content)
+
+ def test_exception_raised_while_in_with_block_is_propagated(self):
+ with self.assertRaises(MarionetteException):
+ with self.marionette.using_context(self.chrome):
+ raise MarionetteException
+ self.assertEquals(self.get_context(), self.content)
+
+ def test_with_using_context_decorator(self):
+ @using_context("content")
+ def inner_content(m):
+ self.assertEquals(self.get_context(), "content")
+
+ @using_context("chrome")
+ def inner_chrome(m):
+ self.assertEquals(self.get_context(), "chrome")
+
+ inner_content(self.marionette)
+ inner_chrome(self.marionette)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_cookies.py b/testing/marionette/harness/marionette_harness/tests/unit/test_cookies.py
new file mode 100644
index 0000000000..551a572490
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_cookies.py
@@ -0,0 +1,117 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import, print_function
+
+import calendar
+import random
+import time
+
+from marionette_driver.errors import UnsupportedOperationException
+from marionette_harness import MarionetteTestCase
+
+
+class CookieTest(MarionetteTestCase):
+ def setUp(self):
+ MarionetteTestCase.setUp(self)
+ test_url = self.marionette.absolute_url("test.html")
+ self.marionette.navigate(test_url)
+ self.COOKIE_A = {"name": "foo", "value": "bar", "path": "/", "secure": False}
+
+ def tearDown(self):
+ self.marionette.delete_all_cookies()
+ MarionetteTestCase.tearDown(self)
+
+ def test_add_cookie(self):
+ self.marionette.add_cookie(self.COOKIE_A)
+ cookie_returned = str(self.marionette.execute_script("return document.cookie"))
+ self.assertTrue(self.COOKIE_A["name"] in cookie_returned)
+
+ def test_adding_a_cookie_that_expired_in_the_past(self):
+ cookie = self.COOKIE_A.copy()
+ cookie["expiry"] = calendar.timegm(time.gmtime()) - (60 * 60 * 24)
+ self.marionette.add_cookie(cookie)
+ cookies = self.marionette.get_cookies()
+ self.assertEquals(0, len(cookies))
+
+ def test_chrome_error(self):
+ with self.marionette.using_context("chrome"):
+ self.assertRaises(
+ UnsupportedOperationException, self.marionette.add_cookie, self.COOKIE_A
+ )
+ self.assertRaises(
+ UnsupportedOperationException,
+ self.marionette.delete_cookie,
+ self.COOKIE_A,
+ )
+ self.assertRaises(
+ UnsupportedOperationException, self.marionette.delete_all_cookies
+ )
+ self.assertRaises(
+ UnsupportedOperationException, self.marionette.get_cookies
+ )
+
+ def test_delete_all_cookie(self):
+ self.marionette.add_cookie(self.COOKIE_A)
+ cookie_returned = str(self.marionette.execute_script("return document.cookie"))
+ print(cookie_returned)
+ self.assertTrue(self.COOKIE_A["name"] in cookie_returned)
+ self.marionette.delete_all_cookies()
+ self.assertFalse(self.marionette.get_cookies())
+
+ def test_delete_cookie(self):
+ self.marionette.add_cookie(self.COOKIE_A)
+ cookie_returned = str(self.marionette.execute_script("return document.cookie"))
+ self.assertTrue(self.COOKIE_A["name"] in cookie_returned)
+ self.marionette.delete_cookie("foo")
+ cookie_returned = str(self.marionette.execute_script("return document.cookie"))
+ self.assertFalse(self.COOKIE_A["name"] in cookie_returned)
+
+ def test_should_get_cookie_by_name(self):
+ key = "key_{}".format(int(random.random() * 10000000))
+ self.marionette.execute_script(
+ "document.cookie = arguments[0] + '=set';", [key]
+ )
+
+ cookie = self.marionette.get_cookie(key)
+ self.assertEquals("set", cookie["value"])
+
+ def test_get_all_cookies(self):
+ key1 = "key_{}".format(int(random.random() * 10000000))
+ key2 = "key_{}".format(int(random.random() * 10000000))
+
+ cookies = self.marionette.get_cookies()
+ count = len(cookies)
+
+ one = {"name": key1, "value": "value"}
+ two = {"name": key2, "value": "value"}
+
+ self.marionette.add_cookie(one)
+ self.marionette.add_cookie(two)
+
+ test_url = self.marionette.absolute_url("test.html")
+ self.marionette.navigate(test_url)
+ cookies = self.marionette.get_cookies()
+ self.assertEquals(count + 2, len(cookies))
+
+ def test_should_not_delete_cookies_with_a_similar_name(self):
+ cookieOneName = "fish"
+ cookie1 = {"name": cookieOneName, "value": "cod"}
+ cookie2 = {"name": cookieOneName + "x", "value": "earth"}
+ self.marionette.add_cookie(cookie1)
+ self.marionette.add_cookie(cookie2)
+
+ self.marionette.delete_cookie(cookieOneName)
+ cookies = self.marionette.get_cookies()
+
+ self.assertFalse(cookie1["name"] == cookies[0]["name"], msg=str(cookies))
+ self.assertEquals(cookie2["name"], cookies[0]["name"], msg=str(cookies))
+
+ def test_we_get_required_elements_when_available(self):
+ self.marionette.add_cookie(self.COOKIE_A)
+ cookies = self.marionette.get_cookies()
+
+ self.assertIn("name", cookies[0], "name not available")
+ self.assertIn("value", cookies[0], "value not available")
+ self.assertIn("httpOnly", cookies[0], "httpOnly not available")
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_crash.py b/testing/marionette/harness/marionette_harness/tests/unit/test_crash.py
new file mode 100644
index 0000000000..65e4b87a1c
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_crash.py
@@ -0,0 +1,216 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import, print_function
+
+import glob
+import os
+import shutil
+import sys
+import unittest
+from io import StringIO
+
+import six
+
+from marionette_driver import Wait
+from marionette_driver.errors import (
+ InvalidSessionIdException,
+ NoSuchWindowException,
+ TimeoutException,
+)
+
+from marionette_harness import MarionetteTestCase, expectedFailure
+
+# Import runner module to monkey patch mozcrash module
+from mozrunner.base import runner
+
+
+class MockMozCrash(object):
+ """Mock object to replace original mozcrash methods."""
+
+ def __init__(self, marionette):
+ self.marionette = marionette
+
+ with self.marionette.using_context("chrome"):
+ self.crash_reporter_enabled = self.marionette.execute_script(
+ """
+ Cu.import("resource://gre/modules/AppConstants.jsm");
+ return AppConstants.MOZ_CRASHREPORTER;
+ """
+ )
+
+ def check_for_crashes(self, dump_directory, *args, **kwargs):
+ if self.crash_reporter_enabled:
+ # Workaround until bug 1376795 has been fixed
+ # Wait at maximum 5s for the minidump files being created
+ # minidump_files = glob.glob('{}/*.dmp'.format(dump_directory))
+ try:
+ minidump_files = Wait(None, timeout=5).until(
+ lambda _: glob.glob("{}/*.dmp".format(dump_directory))
+ )
+ except TimeoutException:
+ minidump_files = []
+
+ if os.path.isdir(dump_directory):
+ shutil.rmtree(dump_directory)
+
+ return len(minidump_files)
+ else:
+ return len(minidump_files) == 0
+
+ def log_crashes(self, logger, dump_directory, *args, **kwargs):
+ return self.check_for_crashes(dump_directory, *args, **kwargs)
+
+
+class BaseCrashTestCase(MarionetteTestCase):
+
+ # Reduce the timeout for faster processing of the tests
+ socket_timeout = 10
+
+ def setUp(self):
+ super(BaseCrashTestCase, self).setUp()
+
+ # Monkey patch mozcrash to avoid crash info output only for our triggered crashes.
+ mozcrash_mock = MockMozCrash(self.marionette)
+ if not mozcrash_mock.crash_reporter_enabled:
+ self.skipTest("Crash reporter disabled")
+ return
+
+ self.mozcrash = runner.mozcrash
+ runner.mozcrash = mozcrash_mock
+
+ self.crash_count = self.marionette.crashed
+ self.pid = self.marionette.process_id
+
+ def tearDown(self):
+ # Replace mockup with original mozcrash instance
+ runner.mozcrash = self.mozcrash
+
+ self.marionette.crashed = self.crash_count
+
+ super(BaseCrashTestCase, self).tearDown()
+
+ def crash(self, parent=True):
+ socket_timeout = self.marionette.client.socket_timeout
+ self.marionette.client.socket_timeout = self.socket_timeout
+
+ self.marionette.set_context("content")
+ try:
+ self.marionette.navigate(
+ "about:crash{}".format("parent" if parent else "content")
+ )
+ finally:
+ self.marionette.client.socket_timeout = socket_timeout
+
+
+class TestCrash(BaseCrashTestCase):
+ def setUp(self):
+ if os.environ.get("MOZ_AUTOMATION"):
+ # Capture stdout, otherwise the Gecko output causes mozharness to fail
+ # the task due to "A content process has crashed" appearing in the log.
+ # To view stdout for debugging, use `print(self.new_out.getvalue())`
+ print(
+ "Suppressing GECKO output. To view, add `print(self.new_out.getvalue())` "
+ "to the end of this test."
+ )
+ self.new_out, self.new_err = StringIO(), StringIO()
+ self.old_out, self.old_err = sys.stdout, sys.stderr
+ sys.stdout, sys.stderr = self.new_out, self.new_err
+
+ super(TestCrash, self).setUp()
+
+ def tearDown(self):
+ super(TestCrash, self).tearDown()
+
+ if os.environ.get("MOZ_AUTOMATION"):
+ sys.stdout, sys.stderr = self.old_out, self.old_err
+
+ def test_crash_chrome_process(self):
+ self.assertRaisesRegexp(IOError, "Process crashed", self.crash, parent=True)
+
+ # A crash results in a non zero exit code
+ self.assertNotIn(self.marionette.instance.runner.returncode, (None, 0))
+
+ self.assertEqual(self.marionette.crashed, 1)
+ self.assertIsNone(self.marionette.session)
+ with self.assertRaisesRegexp(
+ InvalidSessionIdException, "Please start a session"
+ ):
+ self.marionette.get_url()
+
+ self.marionette.start_session()
+ self.assertNotEqual(self.marionette.process_id, self.pid)
+
+ self.marionette.get_url()
+
+ def test_crash_content_process(self):
+ # For a content process crash and MOZ_CRASHREPORTER_SHUTDOWN set the top
+ # browsing context will be gone first. As such the raised NoSuchWindowException
+ # has to be ignored. To check for the IOError, further commands have to
+ # be executed until the process is gone.
+ with self.assertRaisesRegexp(IOError, "Content process crashed"):
+ self.crash(parent=False)
+ Wait(
+ self.marionette,
+ timeout=self.socket_timeout,
+ ignored_exceptions=NoSuchWindowException,
+ ).until(
+ lambda _: self.marionette.get_url(),
+ message="Expected IOError exception for content crash not raised.",
+ )
+
+ # In the case of a content crash Firefox will be closed and its
+ # returncode will report 0 (this will change with 1370520).
+ self.assertEqual(self.marionette.instance.runner.returncode, 0)
+
+ self.assertEqual(self.marionette.crashed, 1)
+ self.assertIsNone(self.marionette.session)
+ with self.assertRaisesRegexp(
+ InvalidSessionIdException, "Please start a session"
+ ):
+ self.marionette.get_url()
+
+ self.marionette.start_session()
+ self.assertNotEqual(self.marionette.process_id, self.pid)
+ self.marionette.get_url()
+
+ @unittest.expectedFailure
+ @unittest.skipIf(six.PY3, "Bug 1641226 - Not supported in Python3.")
+ def test_unexpected_crash(self):
+ self.crash(parent=True)
+
+
+class TestCrashInSetUp(BaseCrashTestCase):
+ def setUp(self):
+ super(TestCrashInSetUp, self).setUp()
+
+ self.assertRaisesRegexp(IOError, "Process crashed", self.crash, parent=True)
+
+ # A crash results in a non zero exit code
+ self.assertNotIn(self.marionette.instance.runner.returncode, (None, 0))
+
+ self.assertEqual(self.marionette.crashed, 1)
+ self.assertIsNone(self.marionette.session)
+
+ def test_crash_in_setup(self):
+ self.marionette.start_session()
+ self.assertNotEqual(self.marionette.process_id, self.pid)
+
+
+class TestCrashInTearDown(BaseCrashTestCase):
+ def tearDown(self):
+ try:
+ self.assertRaisesRegexp(IOError, "Process crashed", self.crash, parent=True)
+
+ # A crash results in a non zero exit code
+ self.assertNotIn(self.marionette.instance.runner.returncode, (None, 0))
+
+ self.assertEqual(self.marionette.crashed, 1)
+ self.assertIsNone(self.marionette.session)
+
+ finally:
+ super(TestCrashInTearDown, self).tearDown()
+
+ def test_crash_in_teardown(self):
+ pass
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_data_driven.py b/testing/marionette/harness/marionette_harness/tests/unit/test_data_driven.py
new file mode 100644
index 0000000000..4d2a882cae
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_data_driven.py
@@ -0,0 +1,74 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import six
+
+from marionette_harness.marionette_test import (
+ parameterized,
+ with_parameters,
+ MetaParameterized,
+ MarionetteTestCase,
+)
+
+
+@six.add_metaclass(MetaParameterized)
+class Parameterizable(object):
+ pass
+
+
+class TestDataDriven(MarionetteTestCase):
+ def test_parameterized(self):
+ class Test(Parameterizable):
+ def __init__(self):
+ self.parameters = []
+
+ @parameterized("1", "thing", named=43)
+ @parameterized("2", "thing2")
+ def test(self, thing, named=None):
+ self.parameters.append((thing, named))
+
+ self.assertFalse(hasattr(Test, "test"))
+ self.assertTrue(hasattr(Test, "test_1"))
+ self.assertTrue(hasattr(Test, "test_2"))
+
+ test = Test()
+ test.test_1()
+ test.test_2()
+
+ self.assertEquals(test.parameters, [("thing", 43), ("thing2", None)])
+
+ def test_with_parameters(self):
+ DATA = [("1", ("thing",), {"named": 43}), ("2", ("thing2",), {"named": None})]
+
+ class Test(Parameterizable):
+ def __init__(self):
+ self.parameters = []
+
+ @with_parameters(DATA)
+ def test(self, thing, named=None):
+ self.parameters.append((thing, named))
+
+ self.assertFalse(hasattr(Test, "test"))
+ self.assertTrue(hasattr(Test, "test_1"))
+ self.assertTrue(hasattr(Test, "test_2"))
+
+ test = Test()
+ test.test_1()
+ test.test_2()
+
+ self.assertEquals(test.parameters, [("thing", 43), ("thing2", None)])
+
+ def test_parameterized_same_name_raises_error(self):
+ with self.assertRaises(KeyError):
+
+ class Test(Parameterizable):
+ @parameterized("1", "thing", named=43)
+ @parameterized("1", "thing2")
+ def test(self, thing, named=None):
+ pass
+
+ def test_marionette_test_case_is_parameterizable(self):
+ self.assertTrue(isinstance(MarionetteTestCase, MetaParameterized))
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_date_time_value.py b/testing/marionette/harness/marionette_harness/tests/unit/test_date_time_value.py
new file mode 100644
index 0000000000..6f81c25b1d
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_date_time_value.py
@@ -0,0 +1,35 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from datetime import datetime
+
+from six.moves.urllib.parse import quote
+
+from marionette_driver.by import By
+from marionette_driver.date_time_value import DateTimeValue
+from marionette_harness import MarionetteTestCase
+
+
+def inline(doc):
+ return "data:text/html;charset=utf-8,{}".format(quote(doc))
+
+
+class TestDateTime(MarionetteTestCase):
+ def test_set_date(self):
+ self.marionette.navigate(inline("<input id='date-test' type='date'/>"))
+
+ element = self.marionette.find_element(By.ID, "date-test")
+ dt_value = DateTimeValue(element)
+ dt_value.date = datetime(1998, 6, 2)
+ self.assertEqual("1998-06-02", element.get_property("value"))
+
+ def test_set_time(self):
+ self.marionette.navigate(inline("<input id='time-test' type='time'/>"))
+
+ element = self.marionette.find_element(By.ID, "time-test")
+ dt_value = DateTimeValue(element)
+ dt_value.time = datetime(1998, 11, 19, 9, 8, 7)
+ self.assertEqual("09:08:07", element.get_property("value"))
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_element_rect.py b/testing/marionette/harness/marionette_harness/tests/unit/test_element_rect.py
new file mode 100644
index 0000000000..cfcbd25c16
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_element_rect.py
@@ -0,0 +1,24 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from six.moves.urllib.parse import quote
+
+from marionette_driver.by import By
+from marionette_harness import MarionetteTestCase
+
+
+def inline(doc):
+ return "data:text/html;charset=utf-8,{}".format(quote(doc))
+
+
+class TestElementSize(MarionetteTestCase):
+ def test_payload(self):
+ self.marionette.navigate(inline("""<a href="#">link</a>"""))
+ rect = self.marionette.find_element(By.LINK_TEXT, "link").rect
+ self.assertTrue(rect["x"] > 0)
+ self.assertTrue(rect["y"] > 0)
+ self.assertTrue(rect["width"] > 0)
+ self.assertTrue(rect["height"] > 0)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_element_rect_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_element_rect_chrome.py
new file mode 100644
index 0000000000..edb82405f2
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_element_rect_chrome.py
@@ -0,0 +1,30 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver.by import By
+
+from marionette_harness import MarionetteTestCase, WindowManagerMixin
+
+
+class TestElementSizeChrome(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestElementSizeChrome, self).setUp()
+
+ self.marionette.set_context("chrome")
+
+ new_window = self.open_chrome_window("chrome://marionette/content/test2.xhtml")
+ self.marionette.switch_to_window(new_window)
+
+ def tearDown(self):
+ self.close_all_windows()
+ super(TestElementSizeChrome, self).tearDown()
+
+ def test_payload(self):
+ rect = self.marionette.find_element(By.ID, "textInput").rect
+ self.assertTrue(rect["x"] > 0)
+ self.assertTrue(rect["y"] > 0)
+ self.assertTrue(rect["width"] > 0)
+ self.assertTrue(rect["height"] > 0)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_element_retrieval.py b/testing/marionette/harness/marionette_harness/tests/unit/test_element_retrieval.py
new file mode 100644
index 0000000000..5e893984f1
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_element_retrieval.py
@@ -0,0 +1,503 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import re
+
+from six.moves.urllib.parse import quote
+
+from marionette_driver.by import By
+from marionette_driver.errors import NoSuchElementException, InvalidSelectorException
+from marionette_driver.marionette import HTMLElement
+
+from marionette_harness import MarionetteTestCase, skip
+
+
+def inline(doc, doctype="html"):
+ if doctype == "html":
+ return "data:text/html;charset=utf-8,{}".format(quote(doc))
+ elif doctype == "xhtml":
+ return "data:application/xhtml+xml,{}".format(
+ quote(
+ r"""<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
+ <head>
+ <title>XHTML might be the future</title>
+ </head>
+
+ <body>
+ {}
+ </body>
+</html>""".format(
+ doc
+ )
+ )
+ )
+
+
+id_html = inline("<p id=foo></p>", doctype="html")
+id_xhtml = inline('<p id="foo"></p>', doctype="xhtml")
+parent_child_html = inline("<div id=parent><p id=child></p></div>", doctype="html")
+parent_child_xhtml = inline(
+ '<div id="parent"><p id="child"></p></div>', doctype="xhtml"
+)
+children_html = inline("<div><p>foo <p>bar</div>", doctype="html")
+children_xhtml = inline("<div><p>foo</p> <p>bar</p></div>", doctype="xhtml")
+class_html = inline("<p class='foo bar'>", doctype="html")
+class_xhtml = inline('<p class="foo bar"></p>', doctype="xhtml")
+name_html = inline("<p name=foo>", doctype="html")
+name_xhtml = inline('<p name="foo"></p>', doctype="xhtml")
+
+
+class TestFindElementHTML(MarionetteTestCase):
+ def setUp(self):
+ MarionetteTestCase.setUp(self)
+ self.marionette.timeout.implicit = 0
+
+ def test_id(self):
+ self.marionette.navigate(id_html)
+ expected = self.marionette.execute_script("return document.querySelector('p')")
+ found = self.marionette.find_element(By.ID, "foo")
+ self.assertIsInstance(found, HTMLElement)
+ self.assertEqual(expected, found)
+
+ def test_child_element(self):
+ self.marionette.navigate(parent_child_html)
+ parent = self.marionette.find_element(By.ID, "parent")
+ child = self.marionette.find_element(By.ID, "child")
+ found = parent.find_element(By.TAG_NAME, "p")
+ self.assertEqual(found.tag_name, "p")
+ self.assertIsInstance(found, HTMLElement)
+ self.assertEqual(child, found)
+
+ def test_tag_name(self):
+ self.marionette.navigate(children_html)
+ el = self.marionette.execute_script("return document.querySelector('p')")
+ found = self.marionette.find_element(By.TAG_NAME, "p")
+ self.assertIsInstance(found, HTMLElement)
+ self.assertEqual(el, found)
+
+ def test_class_name(self):
+ self.marionette.navigate(class_html)
+ el = self.marionette.execute_script("return document.querySelector('.foo')")
+ found = self.marionette.find_element(By.CLASS_NAME, "foo")
+ self.assertIsInstance(found, HTMLElement)
+ self.assertEqual(el, found)
+
+ def test_by_name(self):
+ self.marionette.navigate(name_html)
+ el = self.marionette.execute_script(
+ "return document.querySelector('[name=foo]')"
+ )
+ found = self.marionette.find_element(By.NAME, "foo")
+ self.assertIsInstance(found, HTMLElement)
+ self.assertEqual(el, found)
+
+ def test_css_selector(self):
+ self.marionette.navigate(children_html)
+ el = self.marionette.execute_script("return document.querySelector('p')")
+ found = self.marionette.find_element(By.CSS_SELECTOR, "p")
+ self.assertIsInstance(found, HTMLElement)
+ self.assertEqual(el, found)
+
+ def test_invalid_css_selector_should_throw(self):
+ with self.assertRaises(InvalidSelectorException):
+ self.marionette.find_element(By.CSS_SELECTOR, "#")
+
+ def test_xpath(self):
+ self.marionette.navigate(id_html)
+ el = self.marionette.execute_script("return document.querySelector('#foo')")
+ found = self.marionette.find_element(By.XPATH, "id('foo')")
+ self.assertIsInstance(found, HTMLElement)
+ self.assertEqual(el, found)
+
+ def test_not_found(self):
+ self.marionette.timeout.implicit = 0
+ self.assertRaises(
+ NoSuchElementException,
+ self.marionette.find_element,
+ By.CLASS_NAME,
+ "cheese",
+ )
+ self.assertRaises(
+ NoSuchElementException,
+ self.marionette.find_element,
+ By.CSS_SELECTOR,
+ "cheese",
+ )
+ self.assertRaises(
+ NoSuchElementException, self.marionette.find_element, By.ID, "cheese"
+ )
+ self.assertRaises(
+ NoSuchElementException, self.marionette.find_element, By.LINK_TEXT, "cheese"
+ )
+ self.assertRaises(
+ NoSuchElementException, self.marionette.find_element, By.NAME, "cheese"
+ )
+ self.assertRaises(
+ NoSuchElementException,
+ self.marionette.find_element,
+ By.PARTIAL_LINK_TEXT,
+ "cheese",
+ )
+ self.assertRaises(
+ NoSuchElementException, self.marionette.find_element, By.TAG_NAME, "cheese"
+ )
+ self.assertRaises(
+ NoSuchElementException, self.marionette.find_element, By.XPATH, "cheese"
+ )
+
+ def test_not_found_implicit_wait(self):
+ self.marionette.timeout.implicit = 0.5
+ self.assertRaises(
+ NoSuchElementException,
+ self.marionette.find_element,
+ By.CLASS_NAME,
+ "cheese",
+ )
+ self.assertRaises(
+ NoSuchElementException,
+ self.marionette.find_element,
+ By.CSS_SELECTOR,
+ "cheese",
+ )
+ self.assertRaises(
+ NoSuchElementException, self.marionette.find_element, By.ID, "cheese"
+ )
+ self.assertRaises(
+ NoSuchElementException, self.marionette.find_element, By.LINK_TEXT, "cheese"
+ )
+ self.assertRaises(
+ NoSuchElementException, self.marionette.find_element, By.NAME, "cheese"
+ )
+ self.assertRaises(
+ NoSuchElementException,
+ self.marionette.find_element,
+ By.PARTIAL_LINK_TEXT,
+ "cheese",
+ )
+ self.assertRaises(
+ NoSuchElementException, self.marionette.find_element, By.TAG_NAME, "cheese"
+ )
+ self.assertRaises(
+ NoSuchElementException, self.marionette.find_element, By.XPATH, "cheese"
+ )
+
+ def test_not_found_from_element(self):
+ self.marionette.timeout.implicit = 0
+ self.marionette.navigate(id_html)
+ el = self.marionette.find_element(By.ID, "foo")
+ self.assertRaises(
+ NoSuchElementException, el.find_element, By.CLASS_NAME, "cheese"
+ )
+ self.assertRaises(
+ NoSuchElementException, el.find_element, By.CSS_SELECTOR, "cheese"
+ )
+ self.assertRaises(NoSuchElementException, el.find_element, By.ID, "cheese")
+ self.assertRaises(
+ NoSuchElementException, el.find_element, By.LINK_TEXT, "cheese"
+ )
+ self.assertRaises(NoSuchElementException, el.find_element, By.NAME, "cheese")
+ self.assertRaises(
+ NoSuchElementException, el.find_element, By.PARTIAL_LINK_TEXT, "cheese"
+ )
+ self.assertRaises(
+ NoSuchElementException, el.find_element, By.TAG_NAME, "cheese"
+ )
+ self.assertRaises(NoSuchElementException, el.find_element, By.XPATH, "cheese")
+
+ def test_not_found_implicit_wait_from_element(self):
+ self.marionette.timeout.implicit = 0.5
+ self.marionette.navigate(id_html)
+ el = self.marionette.find_element(By.ID, "foo")
+ self.assertRaises(
+ NoSuchElementException, el.find_element, By.CLASS_NAME, "cheese"
+ )
+ self.assertRaises(
+ NoSuchElementException, el.find_element, By.CSS_SELECTOR, "cheese"
+ )
+ self.assertRaises(NoSuchElementException, el.find_element, By.ID, "cheese")
+ self.assertRaises(
+ NoSuchElementException, el.find_element, By.LINK_TEXT, "cheese"
+ )
+ self.assertRaises(NoSuchElementException, el.find_element, By.NAME, "cheese")
+ self.assertRaises(
+ NoSuchElementException, el.find_element, By.PARTIAL_LINK_TEXT, "cheese"
+ )
+ self.assertRaises(
+ NoSuchElementException, el.find_element, By.TAG_NAME, "cheese"
+ )
+ self.assertRaises(NoSuchElementException, el.find_element, By.XPATH, "cheese")
+
+ def test_css_selector_scope_doesnt_start_at_rootnode(self):
+ self.marionette.navigate(parent_child_html)
+ el = self.marionette.find_element(By.ID, "child")
+ parent = self.marionette.find_element(By.ID, "parent")
+ found = parent.find_element(By.CSS_SELECTOR, "p")
+ self.assertEqual(el, found)
+
+ def test_unknown_selector(self):
+ with self.assertRaises(InvalidSelectorException):
+ self.marionette.find_elements("foo", "bar")
+
+ def test_element_id_is_valid_uuid(self):
+ self.marionette.navigate(id_html)
+ el = self.marionette.find_element(By.TAG_NAME, "p")
+ uuid_regex = re.compile(
+ "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
+ )
+ self.assertIsNotNone(
+ re.search(uuid_regex, el.id),
+ "UUID for the WebElement is not valid. ID is {}".format(el.id),
+ )
+
+ def test_invalid_xpath_selector(self):
+ with self.assertRaises(InvalidSelectorException):
+ self.marionette.find_element(By.XPATH, "count(//input)")
+ with self.assertRaises(InvalidSelectorException):
+ parent = self.marionette.execute_script("return document.documentElement")
+ parent.find_element(By.XPATH, "count(//input)")
+
+ def test_invalid_css_selector(self):
+ with self.assertRaises(InvalidSelectorException):
+ self.marionette.find_element(By.CSS_SELECTOR, "")
+ with self.assertRaises(InvalidSelectorException):
+ parent = self.marionette.execute_script("return document.documentElement")
+ parent.find_element(By.CSS_SELECTOR, "")
+
+ def test_finding_active_element_returns_element(self):
+ self.marionette.navigate(id_html)
+ active = self.marionette.execute_script("return document.activeElement")
+ self.assertEqual(active, self.marionette.get_active_element())
+
+
+class TestFindElementXHTML(MarionetteTestCase):
+ def setUp(self):
+ MarionetteTestCase.setUp(self)
+ self.marionette.timeout.implicit = 0
+
+ def test_id(self):
+ self.marionette.navigate(id_xhtml)
+ expected = self.marionette.execute_script("return document.querySelector('p')")
+ found = self.marionette.find_element(By.ID, "foo")
+ self.assertIsInstance(found, HTMLElement)
+ self.assertEqual(expected, found)
+
+ def test_child_element(self):
+ self.marionette.navigate(parent_child_xhtml)
+ parent = self.marionette.find_element(By.ID, "parent")
+ child = self.marionette.find_element(By.ID, "child")
+ found = parent.find_element(By.TAG_NAME, "p")
+ self.assertEqual(found.tag_name, "p")
+ self.assertIsInstance(found, HTMLElement)
+ self.assertEqual(child, found)
+
+ def test_tag_name(self):
+ self.marionette.navigate(children_xhtml)
+ el = self.marionette.execute_script("return document.querySelector('p')")
+ found = self.marionette.find_element(By.TAG_NAME, "p")
+ self.assertIsInstance(found, HTMLElement)
+ self.assertEqual(el, found)
+
+ def test_class_name(self):
+ self.marionette.navigate(class_xhtml)
+ el = self.marionette.execute_script("return document.querySelector('.foo')")
+ found = self.marionette.find_element(By.CLASS_NAME, "foo")
+ self.assertIsInstance(found, HTMLElement)
+ self.assertEqual(el, found)
+
+ def test_by_name(self):
+ self.marionette.navigate(name_xhtml)
+ el = self.marionette.execute_script(
+ "return document.querySelector('[name=foo]')"
+ )
+ found = self.marionette.find_element(By.NAME, "foo")
+ self.assertIsInstance(found, HTMLElement)
+ self.assertEqual(el, found)
+
+ def test_css_selector(self):
+ self.marionette.navigate(children_xhtml)
+ el = self.marionette.execute_script("return document.querySelector('p')")
+ found = self.marionette.find_element(By.CSS_SELECTOR, "p")
+ self.assertIsInstance(found, HTMLElement)
+ self.assertEqual(el, found)
+
+ def test_xpath(self):
+ self.marionette.navigate(id_xhtml)
+ el = self.marionette.execute_script("return document.querySelector('#foo')")
+ found = self.marionette.find_element(By.XPATH, "id('foo')")
+ self.assertIsInstance(found, HTMLElement)
+ self.assertEqual(el, found)
+
+ def test_css_selector_scope_does_not_start_at_rootnode(self):
+ self.marionette.navigate(parent_child_xhtml)
+ el = self.marionette.find_element(By.ID, "child")
+ parent = self.marionette.find_element(By.ID, "parent")
+ found = parent.find_element(By.CSS_SELECTOR, "p")
+ self.assertEqual(el, found)
+
+ def test_active_element(self):
+ self.marionette.navigate(id_xhtml)
+ active = self.marionette.execute_script("return document.activeElement")
+ self.assertEqual(active, self.marionette.get_active_element())
+
+
+class TestFindElementsHTML(MarionetteTestCase):
+ def setUp(self):
+ MarionetteTestCase.setUp(self)
+ self.marionette.timeout.implicit = 0
+
+ def assertItemsIsInstance(self, items, typ):
+ for item in items:
+ self.assertIsInstance(item, typ)
+
+ def test_child_elements(self):
+ self.marionette.navigate(children_html)
+ parent = self.marionette.find_element(By.TAG_NAME, "div")
+ children = self.marionette.find_elements(By.TAG_NAME, "p")
+ found = parent.find_elements(By.TAG_NAME, "p")
+ self.assertItemsIsInstance(found, HTMLElement)
+ self.assertSequenceEqual(found, children)
+
+ def test_tag_name(self):
+ self.marionette.navigate(children_html)
+ els = self.marionette.execute_script("return document.querySelectorAll('p')")
+ found = self.marionette.find_elements(By.TAG_NAME, "p")
+ self.assertItemsIsInstance(found, HTMLElement)
+ self.assertSequenceEqual(els, found)
+
+ def test_class_name(self):
+ self.marionette.navigate(class_html)
+ els = self.marionette.execute_script("return document.querySelectorAll('.foo')")
+ found = self.marionette.find_elements(By.CLASS_NAME, "foo")
+ self.assertItemsIsInstance(found, HTMLElement)
+ self.assertSequenceEqual(els, found)
+
+ def test_by_name(self):
+ self.marionette.navigate(name_html)
+ els = self.marionette.execute_script(
+ "return document.querySelectorAll('[name=foo]')"
+ )
+ found = self.marionette.find_elements(By.NAME, "foo")
+ self.assertItemsIsInstance(found, HTMLElement)
+ self.assertSequenceEqual(els, found)
+
+ def test_css_selector(self):
+ self.marionette.navigate(children_html)
+ els = self.marionette.execute_script("return document.querySelectorAll('p')")
+ found = self.marionette.find_elements(By.CSS_SELECTOR, "p")
+ self.assertItemsIsInstance(found, HTMLElement)
+ self.assertSequenceEqual(els, found)
+
+ def test_invalid_css_selector_should_throw(self):
+ with self.assertRaises(InvalidSelectorException):
+ self.marionette.find_elements(By.CSS_SELECTOR, "#")
+
+ def test_xpath(self):
+ self.marionette.navigate(children_html)
+ els = self.marionette.execute_script("return document.querySelectorAll('p')")
+ found = self.marionette.find_elements(By.XPATH, ".//p")
+ self.assertItemsIsInstance(found, HTMLElement)
+ self.assertSequenceEqual(els, found)
+
+ def test_css_selector_scope_doesnt_start_at_rootnode(self):
+ self.marionette.navigate(parent_child_html)
+ els = self.marionette.find_elements(By.ID, "child")
+ parent = self.marionette.find_element(By.ID, "parent")
+ found = parent.find_elements(By.CSS_SELECTOR, "p")
+ self.assertSequenceEqual(els, found)
+
+ def test_unknown_selector(self):
+ with self.assertRaises(InvalidSelectorException):
+ self.marionette.find_element("foo", "bar")
+
+ def test_element_id_is_valid_uuid(self):
+ self.marionette.navigate(id_html)
+ els = self.marionette.find_elements(By.TAG_NAME, "p")
+ uuid_regex = re.compile(
+ "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
+ )
+ self.assertIsNotNone(
+ re.search(uuid_regex, els[0].id),
+ "UUID for the WebElement is not valid. ID is {}".format(els[0].id),
+ )
+
+ def test_invalid_xpath_selector(self):
+ with self.assertRaises(InvalidSelectorException):
+ self.marionette.find_elements(By.XPATH, "count(//input)")
+ with self.assertRaises(InvalidSelectorException):
+ parent = self.marionette.execute_script("return document.documentElement")
+ parent.find_elements(By.XPATH, "count(//input)")
+
+ def test_invalid_css_selector(self):
+ with self.assertRaises(InvalidSelectorException):
+ self.marionette.find_elements(By.CSS_SELECTOR, "")
+ with self.assertRaises(InvalidSelectorException):
+ parent = self.marionette.execute_script("return document.documentElement")
+ parent.find_elements(By.CSS_SELECTOR, "")
+
+
+class TestFindElementsXHTML(MarionetteTestCase):
+ def setUp(self):
+ MarionetteTestCase.setUp(self)
+ self.marionette.timeout.implicit = 0
+
+ def assertItemsIsInstance(self, items, typ):
+ for item in items:
+ self.assertIsInstance(item, typ)
+
+ def test_child_elements(self):
+ self.marionette.navigate(children_xhtml)
+ parent = self.marionette.find_element(By.TAG_NAME, "div")
+ children = self.marionette.find_elements(By.TAG_NAME, "p")
+ found = parent.find_elements(By.TAG_NAME, "p")
+ self.assertItemsIsInstance(found, HTMLElement)
+ self.assertSequenceEqual(found, children)
+
+ def test_tag_name(self):
+ self.marionette.navigate(children_xhtml)
+ els = self.marionette.execute_script("return document.querySelectorAll('p')")
+ found = self.marionette.find_elements(By.TAG_NAME, "p")
+ self.assertItemsIsInstance(found, HTMLElement)
+ self.assertSequenceEqual(els, found)
+
+ def test_class_name(self):
+ self.marionette.navigate(class_xhtml)
+ els = self.marionette.execute_script("return document.querySelectorAll('.foo')")
+ found = self.marionette.find_elements(By.CLASS_NAME, "foo")
+ self.assertItemsIsInstance(found, HTMLElement)
+ self.assertSequenceEqual(els, found)
+
+ def test_by_name(self):
+ self.marionette.navigate(name_xhtml)
+ els = self.marionette.execute_script(
+ "return document.querySelectorAll('[name=foo]')"
+ )
+ found = self.marionette.find_elements(By.NAME, "foo")
+ self.assertItemsIsInstance(found, HTMLElement)
+ self.assertSequenceEqual(els, found)
+
+ def test_css_selector(self):
+ self.marionette.navigate(children_xhtml)
+ els = self.marionette.execute_script("return document.querySelectorAll('p')")
+ found = self.marionette.find_elements(By.CSS_SELECTOR, "p")
+ self.assertItemsIsInstance(found, HTMLElement)
+ self.assertSequenceEqual(els, found)
+
+ @skip("XHTML namespace not yet supported")
+ def test_xpath(self):
+ self.marionette.navigate(children_xhtml)
+ els = self.marionette.execute_script("return document.querySelectorAll('p')")
+ found = self.marionette.find_elements(By.XPATH, "//xhtml:p")
+ self.assertItemsIsInstance(found, HTMLElement)
+ self.assertSequenceEqual(els, found)
+
+ def test_css_selector_scope_doesnt_start_at_rootnode(self):
+ self.marionette.navigate(parent_child_xhtml)
+ els = self.marionette.find_elements(By.ID, "child")
+ parent = self.marionette.find_element(By.ID, "parent")
+ found = parent.find_elements(By.CSS_SELECTOR, "p")
+ self.assertSequenceEqual(els, found)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_element_state.py b/testing/marionette/harness/marionette_harness/tests/unit/test_element_state.py
new file mode 100644
index 0000000000..231f70c78d
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_element_state.py
@@ -0,0 +1,177 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import, print_function
+
+import types
+
+import six
+from six.moves.urllib.parse import quote
+
+from marionette_driver.by import By
+from marionette_harness import MarionetteTestCase
+
+
+boolean_attributes = {
+ "audio": ["autoplay", "controls", "loop", "muted"],
+ "button": ["autofocus", "disabled", "formnovalidate"],
+ "details": ["open"],
+ "dialog": ["open"],
+ "fieldset": ["disabled"],
+ "form": ["novalidate"],
+ "iframe": ["allowfullscreen"],
+ "img": ["ismap"],
+ "input": [
+ "autofocus",
+ "checked",
+ "disabled",
+ "formnovalidate",
+ "multiple",
+ "readonly",
+ "required",
+ ],
+ "menuitem": ["checked", "default", "disabled"],
+ "ol": ["reversed"],
+ "optgroup": ["disabled"],
+ "option": ["disabled", "selected"],
+ "script": ["async", "defer"],
+ "select": ["autofocus", "disabled", "multiple", "required"],
+ "textarea": ["autofocus", "disabled", "readonly", "required"],
+ "track": ["default"],
+ "video": ["autoplay", "controls", "loop", "muted"],
+}
+
+
+def inline(doc, doctype="html"):
+ if doctype == "html":
+ return "data:text/html;charset=utf-8,{}".format(quote(doc))
+ elif doctype == "xhtml":
+ return "data:application/xhtml+xml,{}".format(
+ quote(
+ r"""<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
+ <head>
+ <title>XHTML might be the future</title>
+ </head>
+
+ <body>
+ {}
+ </body>
+</html>""".format(
+ doc
+ )
+ )
+ )
+
+
+attribute = inline("<input foo=bar>")
+input = inline("<input>")
+disabled = inline("<input disabled=baz>")
+check = inline("<input type=checkbox>")
+
+
+class TestIsElementEnabled(MarionetteTestCase):
+ def test_is_enabled(self):
+ test_html = self.marionette.absolute_url("test.html")
+ self.marionette.navigate(test_html)
+ l = self.marionette.find_element(By.NAME, "myCheckBox")
+ self.assertTrue(l.is_enabled())
+ self.marionette.execute_script("arguments[0].disabled = true;", [l])
+ self.assertFalse(l.is_enabled())
+
+
+class TestIsElementDisplayed(MarionetteTestCase):
+ def test_is_displayed(self):
+ test_html = self.marionette.absolute_url("test.html")
+ self.marionette.navigate(test_html)
+ l = self.marionette.find_element(By.NAME, "myCheckBox")
+ self.assertTrue(l.is_displayed())
+ self.marionette.execute_script("arguments[0].hidden = true;", [l])
+ self.assertFalse(l.is_displayed())
+
+
+class TestGetElementAttribute(MarionetteTestCase):
+ def test_normal_attribute(self):
+ self.marionette.navigate(inline("<p style=foo>"))
+ el = self.marionette.find_element(By.TAG_NAME, "p")
+ attr = el.get_attribute("style")
+ self.assertIsInstance(attr, six.string_types)
+ self.assertEqual("foo", attr)
+
+ def test_boolean_attributes(self):
+ for tag, attrs in six.iteritems(boolean_attributes):
+ for attr in attrs:
+ print("testing boolean attribute <{0} {1}>".format(tag, attr))
+ doc = inline("<{0} {1}>".format(tag, attr))
+ self.marionette.navigate(doc)
+ el = self.marionette.find_element(By.TAG_NAME, tag)
+ res = el.get_attribute(attr)
+ self.assertIsInstance(res, six.string_types)
+ self.assertEqual("true", res)
+
+ def test_global_boolean_attributes(self):
+ self.marionette.navigate(inline("<p hidden>foo"))
+ el = self.marionette.find_element(By.TAG_NAME, "p")
+ attr = el.get_attribute("hidden")
+ self.assertIsInstance(attr, six.string_types)
+ self.assertEqual("true", attr)
+
+ self.marionette.navigate(inline("<p>foo"))
+ el = self.marionette.find_element(By.TAG_NAME, "p")
+ attr = el.get_attribute("hidden")
+ self.assertIsNone(attr)
+
+ self.marionette.navigate(inline("<p itemscope>foo"))
+ el = self.marionette.find_element(By.TAG_NAME, "p")
+ attr = el.get_attribute("itemscope")
+ self.assertIsInstance(attr, six.string_types)
+ self.assertEqual("true", attr)
+
+ self.marionette.navigate(inline("<p>foo"))
+ el = self.marionette.find_element(By.TAG_NAME, "p")
+ attr = el.get_attribute("itemscope")
+ self.assertIsNone(attr)
+
+ # TODO(ato): Test for custom elements
+
+ def test_xhtml(self):
+ doc = inline('<p hidden="true">foo</p>', doctype="xhtml")
+ self.marionette.navigate(doc)
+ el = self.marionette.find_element(By.TAG_NAME, "p")
+ attr = el.get_attribute("hidden")
+ self.assertIsInstance(attr, six.string_types)
+ self.assertEqual("true", attr)
+
+
+class TestGetElementProperty(MarionetteTestCase):
+ def test_get(self):
+ self.marionette.navigate(disabled)
+ el = self.marionette.find_element(By.TAG_NAME, "input")
+ prop = el.get_property("disabled")
+ self.assertIsInstance(prop, bool)
+ self.assertTrue(prop)
+
+ def test_missing_property_returns_default(self):
+ self.marionette.navigate(input)
+ el = self.marionette.find_element(By.TAG_NAME, "input")
+ prop = el.get_property("checked")
+ self.assertIsInstance(prop, bool)
+ self.assertFalse(prop)
+
+ def test_attribute_not_returned(self):
+ self.marionette.navigate(attribute)
+ el = self.marionette.find_element(By.TAG_NAME, "input")
+ self.assertEqual(el.get_property("foo"), None)
+
+ def test_manipulated_element(self):
+ self.marionette.navigate(check)
+ el = self.marionette.find_element(By.TAG_NAME, "input")
+ self.assertEqual(el.get_property("checked"), False)
+
+ el.click()
+ self.assertEqual(el.get_property("checked"), True)
+
+ el.click()
+ self.assertEqual(el.get_property("checked"), False)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_element_state_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_element_state_chrome.py
new file mode 100644
index 0000000000..af7246980b
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_element_state_chrome.py
@@ -0,0 +1,56 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver.by import By
+
+from marionette_harness import MarionetteTestCase, skip, WindowManagerMixin
+
+
+class TestElementState(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestElementState, self).setUp()
+
+ self.marionette.set_context("chrome")
+
+ self.win = self.open_chrome_window("chrome://marionette/content/test.xhtml")
+ self.marionette.switch_to_window(self.win)
+
+ def tearDown(self):
+ self.close_all_windows()
+
+ super(TestElementState, self).tearDown()
+
+ def test_is_displayed(self):
+ l = self.marionette.find_element(By.ID, "textInput")
+ self.assertTrue(l.is_displayed())
+ self.marionette.execute_script("arguments[0].hidden = true;", [l])
+ self.assertFalse(l.is_displayed())
+ self.marionette.execute_script("arguments[0].hidden = false;", [l])
+
+ def test_enabled(self):
+ l = self.marionette.find_element(By.ID, "textInput")
+ self.assertTrue(l.is_enabled())
+ self.marionette.execute_script("arguments[0].disabled = true;", [l])
+ self.assertFalse(l.is_enabled())
+ self.marionette.execute_script("arguments[0].disabled = false;", [l])
+
+ def test_can_get_element_rect(self):
+ l = self.marionette.find_element(By.ID, "textInput")
+ rect = l.rect
+ self.assertTrue(rect["x"] > 0)
+ self.assertTrue(rect["y"] > 0)
+
+ def test_get_attribute(self):
+ el = self.marionette.execute_script(
+ "return window.document.getElementById('textInput');"
+ )
+ self.assertEqual(el.get_attribute("id"), "textInput")
+
+ def test_get_property(self):
+ el = self.marionette.execute_script(
+ "return window.document.getElementById('textInput');"
+ )
+ self.assertEqual(el.get_property("id"), "textInput")
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_errors.py b/testing/marionette/harness/marionette_harness/tests/unit/test_errors.py
new file mode 100644
index 0000000000..0ebca29948
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_errors.py
@@ -0,0 +1,107 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import sys
+
+import six
+
+from marionette_driver import errors
+
+from marionette_harness import marionette_test
+
+
+def fake_cause():
+ try:
+ raise ValueError("bar")
+ except ValueError:
+ return sys.exc_info()
+
+
+message = "foo"
+unicode_message = u"\u201Cfoo"
+cause = fake_cause()
+stacktrace = "first\nsecond"
+
+
+class TestErrors(marionette_test.MarionetteTestCase):
+ def test_defaults(self):
+ exc = errors.MarionetteException()
+ self.assertEquals(str(exc), "None")
+ self.assertIsNone(exc.cause)
+ self.assertIsNone(exc.stacktrace)
+
+ def test_construction(self):
+ exc = errors.MarionetteException(
+ message=message, cause=cause, stacktrace=stacktrace
+ )
+ self.assertEquals(exc.message, message)
+ self.assertEquals(exc.cause, cause)
+ self.assertEquals(exc.stacktrace, stacktrace)
+
+ def test_str_message(self):
+ exc = errors.MarionetteException(
+ message=message, cause=cause, stacktrace=stacktrace
+ )
+ r = str(exc)
+ self.assertIn(message, r)
+ self.assertIn(", caused by {0!r}".format(cause[0]), r)
+ self.assertIn("\nstacktrace:\n\tfirst\n\tsecond", r)
+
+ def test_unicode_message(self):
+ exc = errors.MarionetteException(
+ message=unicode_message, cause=cause, stacktrace=stacktrace
+ )
+ r = six.text_type(exc)
+ self.assertIn(unicode_message, r)
+ self.assertIn(", caused by {0!r}".format(cause[0]), r)
+ self.assertIn("\nstacktrace:\n\tfirst\n\tsecond", r)
+
+ def test_unicode_message_as_str(self):
+ exc = errors.MarionetteException(
+ message=unicode_message, cause=cause, stacktrace=stacktrace
+ )
+ r = str(exc)
+ self.assertIn(six.ensure_str(unicode_message, encoding="utf-8"), r)
+ self.assertIn(", caused by {0!r}".format(cause[0]), r)
+ self.assertIn("\nstacktrace:\n\tfirst\n\tsecond", r)
+
+ def test_cause_string(self):
+ exc = errors.MarionetteException(cause="foo")
+ self.assertEqual(exc.cause, "foo")
+ r = str(exc)
+ self.assertIn(", caused by foo", r)
+
+ def test_cause_tuple(self):
+ exc = errors.MarionetteException(cause=cause)
+ self.assertEqual(exc.cause, cause)
+ r = str(exc)
+ self.assertIn(", caused by {0!r}".format(cause[0]), r)
+
+
+class TestLookup(marionette_test.MarionetteTestCase):
+ def test_by_unknown_number(self):
+ self.assertEqual(errors.MarionetteException, errors.lookup(123456))
+
+ def test_by_known_string(self):
+ self.assertEqual(
+ errors.NoSuchElementException, errors.lookup("no such element")
+ )
+
+ def test_by_unknown_string(self):
+ self.assertEqual(errors.MarionetteException, errors.lookup("barbera"))
+
+ def test_by_known_unicode_string(self):
+ self.assertEqual(
+ errors.NoSuchElementException, errors.lookup(u"no such element")
+ )
+
+
+class TestAllErrors(marionette_test.MarionetteTestCase):
+ def test_properties(self):
+ for exc in errors.es_:
+ self.assertTrue(
+ hasattr(exc, "status"), "expected exception to have attribute `status'"
+ )
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_execute_async_script.py b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_async_script.py
new file mode 100644
index 0000000000..b16f349c99
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_async_script.py
@@ -0,0 +1,242 @@
+from __future__ import absolute_import
+
+import os
+
+from marionette_driver.errors import (
+ JavascriptException,
+ NoAlertPresentException,
+ ScriptTimeoutException,
+)
+from marionette_driver.marionette import Alert
+from marionette_driver.wait import Wait
+
+from marionette_harness import MarionetteTestCase
+
+
+class TestExecuteAsyncContent(MarionetteTestCase):
+ def setUp(self):
+ super(TestExecuteAsyncContent, self).setUp()
+ self.marionette.timeout.script = 1
+
+ def tearDown(self):
+ if self.alert_present():
+ alert = self.marionette.switch_to_alert()
+ alert.dismiss()
+ self.wait_for_alert_closed()
+
+ def alert_present(self):
+ try:
+ Alert(self.marionette).text
+ return True
+ except NoAlertPresentException:
+ return False
+
+ def wait_for_alert_closed(self, timeout=None):
+ Wait(self.marionette, timeout=timeout).until(lambda _: not self.alert_present())
+
+ def test_execute_async_simple(self):
+ self.assertEqual(
+ 1, self.marionette.execute_async_script("arguments[arguments.length-1](1);")
+ )
+
+ def test_execute_async_ours(self):
+ self.assertEqual(1, self.marionette.execute_async_script("arguments[0](1);"))
+
+ def test_script_timeout_error(self):
+ with self.assertRaisesRegexp(ScriptTimeoutException, "Timed out after 100 ms"):
+ self.marionette.execute_async_script("var x = 1;", script_timeout=100)
+
+ def test_script_timeout_reset_after_timeout_error(self):
+ script_timeout = self.marionette.timeout.script
+ with self.assertRaises(ScriptTimeoutException):
+ self.marionette.execute_async_script("var x = 1;", script_timeout=100)
+ self.assertEqual(self.marionette.timeout.script, script_timeout)
+
+ def test_script_timeout_no_timeout_error(self):
+ self.assertTrue(
+ self.marionette.execute_async_script(
+ """
+ var callback = arguments[arguments.length - 1];
+ setTimeout(function() { callback(true); }, 500);
+ """,
+ script_timeout=1000,
+ )
+ )
+
+ def test_no_timeout(self):
+ self.marionette.timeout.script = 10
+ self.assertTrue(
+ self.marionette.execute_async_script(
+ """
+ var callback = arguments[arguments.length - 1];
+ setTimeout(function() { callback(true); }, 500);
+ """
+ )
+ )
+
+ def test_execute_async_unload(self):
+ self.marionette.timeout.script = 5
+ unload = """
+ window.location.href = "about:blank";
+ """
+ self.assertRaises(
+ JavascriptException, self.marionette.execute_async_script, unload
+ )
+
+ def test_check_window(self):
+ self.assertTrue(
+ self.marionette.execute_async_script(
+ "arguments[0](window != null && window != undefined);"
+ )
+ )
+
+ def test_same_context(self):
+ var1 = "testing"
+ self.assertEqual(
+ self.marionette.execute_script(
+ """
+ this.testvar = '{}';
+ return this.testvar;
+ """.format(
+ var1
+ )
+ ),
+ var1,
+ )
+ self.assertEqual(
+ self.marionette.execute_async_script(
+ "arguments[0](this.testvar);", new_sandbox=False
+ ),
+ var1,
+ )
+
+ def test_execute_no_return(self):
+ self.assertEqual(self.marionette.execute_async_script("arguments[0]()"), None)
+
+ def test_execute_js_exception(self):
+ try:
+ self.marionette.execute_async_script(
+ """
+ let a = 1;
+ foo(bar);
+ """
+ )
+ self.fail()
+ except JavascriptException as e:
+ self.assertIsNotNone(e.stacktrace)
+ self.assertIn(
+ os.path.relpath(__file__.replace(".pyc", ".py")), e.stacktrace
+ )
+
+ def test_execute_async_js_exception(self):
+ try:
+ self.marionette.execute_async_script(
+ """
+ let [resolve] = arguments;
+ resolve(foo());
+ """
+ )
+ self.fail()
+ except JavascriptException as e:
+ self.assertIsNotNone(e.stacktrace)
+ self.assertIn(
+ os.path.relpath(__file__.replace(".pyc", ".py")), e.stacktrace
+ )
+
+ def test_script_finished(self):
+ self.assertTrue(
+ self.marionette.execute_async_script(
+ """
+ arguments[0](true);
+ """
+ )
+ )
+
+ def test_execute_permission(self):
+ self.assertRaises(
+ JavascriptException,
+ self.marionette.execute_async_script,
+ """
+let prefs = Components.classes["@mozilla.org/preferences-service;1"]
+ .getService(Components.interfaces.nsIPrefBranch);
+arguments[0](4);
+""",
+ )
+
+ def test_sandbox_reuse(self):
+ # Sandboxes between `execute_script()` invocations are shared.
+ self.marionette.execute_async_script(
+ "this.foobar = [23, 42];" "arguments[0]();"
+ )
+ self.assertEqual(
+ self.marionette.execute_async_script(
+ "arguments[0](this.foobar);", new_sandbox=False
+ ),
+ [23, 42],
+ )
+
+ def test_sandbox_refresh_arguments(self):
+ self.marionette.execute_async_script(
+ "this.foobar = [arguments[0], arguments[1]];"
+ "let resolve = "
+ "arguments[arguments.length - 1];"
+ "resolve();",
+ script_args=[23, 42],
+ )
+ self.assertEqual(
+ self.marionette.execute_async_script(
+ "arguments[0](this.foobar);", new_sandbox=False
+ ),
+ [23, 42],
+ )
+
+ # Functions defined in higher privilege scopes, such as the privileged
+ # content frame script listener.js runs in, cannot be accessed from
+ # content. This tests that it is possible to introspect the objects on
+ # `arguments` without getting permission defined errors. This is made
+ # possible because the last argument is always the callback/complete
+ # function.
+ #
+ # See bug 1290966.
+ def test_introspection_of_arguments(self):
+ self.marionette.execute_async_script(
+ "arguments[0].cheese; __webDriverCallback();", script_args=[], sandbox=None
+ )
+
+ def test_return_value_on_alert(self):
+ res = self.marionette.execute_async_script("alert()")
+ self.assertIsNone(res)
+
+
+class TestExecuteAsyncChrome(TestExecuteAsyncContent):
+ def setUp(self):
+ super(TestExecuteAsyncChrome, self).setUp()
+ self.marionette.set_context("chrome")
+
+ def test_execute_async_unload(self):
+ pass
+
+ def test_execute_permission(self):
+ self.assertEqual(
+ 5,
+ self.marionette.execute_async_script(
+ """
+ var c = Components.classes;
+ arguments[0](5);
+ """
+ ),
+ )
+
+ def test_execute_async_js_exception(self):
+ # Javascript exceptions are not propagated in chrome code
+ self.marionette.timeout.script = 0.2
+ with self.assertRaises(ScriptTimeoutException):
+ self.marionette.execute_async_script(
+ """
+ var callback = arguments[arguments.length - 1];
+ setTimeout(function() { callback(foo()); }, 50);
+ """
+ )
+
+ def test_return_value_on_alert(self):
+ pass
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_execute_isolate.py b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_isolate.py
new file mode 100644
index 0000000000..7db93e4eb4
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_isolate.py
@@ -0,0 +1,48 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver.errors import ScriptTimeoutException
+
+from marionette_harness import MarionetteTestCase
+
+
+class TestExecuteIsolationContent(MarionetteTestCase):
+ def setUp(self):
+ super(TestExecuteIsolationContent, self).setUp()
+ self.content = True
+
+ def test_execute_async_isolate(self):
+ # Results from one execute call that has timed out should not
+ # contaminate a future call.
+ multiplier = "*3" if self.content else "*1"
+ self.marionette.timeout.script = 0.5
+ self.assertRaises(
+ ScriptTimeoutException,
+ self.marionette.execute_async_script,
+ (
+ "setTimeout(function() {{ arguments[0](5{}); }}, 3000);".format(
+ multiplier
+ )
+ ),
+ )
+
+ self.marionette.timeout.script = 6
+ result = self.marionette.execute_async_script(
+ """
+ let [resolve] = arguments;
+ setTimeout(function() {{ resolve(10{}); }}, 5000);
+ """.format(
+ multiplier
+ )
+ )
+ self.assertEqual(result, 30 if self.content else 10)
+
+
+class TestExecuteIsolationChrome(TestExecuteIsolationContent):
+ def setUp(self):
+ super(TestExecuteIsolationChrome, self).setUp()
+ self.marionette.set_context("chrome")
+ self.content = False
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_execute_sandboxes.py b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_sandboxes.py
new file mode 100644
index 0000000000..d3b93f8e83
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_sandboxes.py
@@ -0,0 +1,88 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver.errors import JavascriptException
+
+from marionette_harness import MarionetteTestCase
+
+
+class TestExecuteSandboxes(MarionetteTestCase):
+ def setUp(self):
+ super(TestExecuteSandboxes, self).setUp()
+
+ def test_execute_system_sandbox(self):
+ # Test that "system" sandbox has elevated privileges in execute_script
+ result = self.marionette.execute_script(
+ "return Components.interfaces.nsIPermissionManager.ALLOW_ACTION",
+ sandbox="system",
+ )
+ self.assertEqual(result, 1)
+
+ def test_execute_async_system_sandbox(self):
+ # Test that "system" sandbox has elevated privileges in
+ # execute_async_script.
+ result = self.marionette.execute_async_script(
+ """
+ let result = Ci.nsIPermissionManager.ALLOW_ACTION;
+ arguments[0](result);
+ """,
+ sandbox="system",
+ )
+ self.assertEqual(result, 1)
+
+ def test_execute_switch_sandboxes(self):
+ # Test that sandboxes are retained when switching between them
+ # for execute_script.
+ self.marionette.execute_script("foo = 1", sandbox="1")
+ self.marionette.execute_script("foo = 2", sandbox="2")
+ foo = self.marionette.execute_script(
+ "return foo", sandbox="1", new_sandbox=False
+ )
+ self.assertEqual(foo, 1)
+ foo = self.marionette.execute_script(
+ "return foo", sandbox="2", new_sandbox=False
+ )
+ self.assertEqual(foo, 2)
+
+ def test_execute_new_sandbox(self):
+ # test that clearing a sandbox does not affect other sandboxes
+ self.marionette.execute_script("foo = 1", sandbox="1")
+ self.marionette.execute_script("foo = 2", sandbox="2")
+
+ # deprecate sandbox 1 by asking explicitly for a fresh one
+ with self.assertRaises(JavascriptException):
+ self.marionette.execute_script(
+ """
+ return foo
+ """,
+ sandbox="1",
+ new_sandbox=True,
+ )
+
+ foo = self.marionette.execute_script(
+ "return foo", sandbox="2", new_sandbox=False
+ )
+ self.assertEqual(foo, 2)
+
+ def test_execute_async_switch_sandboxes(self):
+ # Test that sandboxes are retained when switching between them
+ # for execute_async_script.
+ self.marionette.execute_async_script("foo = 1; arguments[0]();", sandbox="1")
+ self.marionette.execute_async_script("foo = 2; arguments[0]();", sandbox="2")
+ foo = self.marionette.execute_async_script(
+ "arguments[0](foo);", sandbox="1", new_sandbox=False
+ )
+ self.assertEqual(foo, 1)
+ foo = self.marionette.execute_async_script(
+ "arguments[0](foo);", sandbox="2", new_sandbox=False
+ )
+ self.assertEqual(foo, 2)
+
+
+class TestExecuteSandboxesChrome(TestExecuteSandboxes):
+ def setUp(self):
+ super(TestExecuteSandboxesChrome, self).setUp()
+ self.marionette.set_context("chrome")
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_execute_script.py b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_script.py
new file mode 100644
index 0000000000..e7e354881d
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_script.py
@@ -0,0 +1,502 @@
+from __future__ import absolute_import
+
+import os
+
+from six.moves.urllib.parse import quote
+
+from marionette_driver import By, errors
+from marionette_driver.marionette import Alert, HTMLElement
+from marionette_driver.wait import Wait
+
+from marionette_harness import MarionetteTestCase, WindowManagerMixin
+
+
+def inline(doc):
+ return "data:text/html;charset=utf-8,{}".format(quote(doc))
+
+
+elements = inline("<p>foo</p> <p>bar</p>")
+
+globals = set(
+ [
+ "atob",
+ "Audio",
+ "btoa",
+ "document",
+ "navigator",
+ "URL",
+ "window",
+ ]
+)
+
+
+class TestExecuteContent(MarionetteTestCase):
+ def alert_present(self):
+ try:
+ Alert(self.marionette).text
+ return True
+ except errors.NoAlertPresentException:
+ return False
+
+ def wait_for_alert_closed(self, timeout=None):
+ Wait(self.marionette, timeout=timeout).until(lambda _: not self.alert_present())
+
+ def tearDown(self):
+ if self.alert_present():
+ alert = self.marionette.switch_to_alert()
+ alert.dismiss()
+ self.wait_for_alert_closed()
+
+ def assert_is_defined(self, property, sandbox="default"):
+ self.assertTrue(
+ self.marionette.execute_script(
+ "return typeof arguments[0] != 'undefined'", [property], sandbox=sandbox
+ ),
+ "property {} is undefined".format(property),
+ )
+
+ def assert_is_web_element(self, element):
+ self.assertIsInstance(element, HTMLElement)
+
+ def test_return_number(self):
+ self.assertEqual(1, self.marionette.execute_script("return 1"))
+ self.assertEqual(1.5, self.marionette.execute_script("return 1.5"))
+
+ def test_return_boolean(self):
+ self.assertTrue(self.marionette.execute_script("return true"))
+
+ def test_return_string(self):
+ self.assertEqual("foo", self.marionette.execute_script("return 'foo'"))
+
+ def test_return_array(self):
+ self.assertEqual([1, 2], self.marionette.execute_script("return [1, 2]"))
+ self.assertEqual(
+ [1.25, 1.75], self.marionette.execute_script("return [1.25, 1.75]")
+ )
+ self.assertEqual(
+ [True, False], self.marionette.execute_script("return [true, false]")
+ )
+ self.assertEqual(
+ ["foo", "bar"], self.marionette.execute_script("return ['foo', 'bar']")
+ )
+ self.assertEqual(
+ [1, 1.5, True, "foo"],
+ self.marionette.execute_script("return [1, 1.5, true, 'foo']"),
+ )
+ self.assertEqual([1, [2]], self.marionette.execute_script("return [1, [2]]"))
+
+ def test_return_object(self):
+ self.assertEqual({"foo": 1}, self.marionette.execute_script("return {foo: 1}"))
+ self.assertEqual(
+ {"foo": 1.5}, self.marionette.execute_script("return {foo: 1.5}")
+ )
+ self.assertEqual(
+ {"foo": True}, self.marionette.execute_script("return {foo: true}")
+ )
+ self.assertEqual(
+ {"foo": "bar"}, self.marionette.execute_script("return {foo: 'bar'}")
+ )
+ self.assertEqual(
+ {"foo": [1, 2]}, self.marionette.execute_script("return {foo: [1, 2]}")
+ )
+ self.assertEqual(
+ {"foo": {"bar": [1, 2]}},
+ self.marionette.execute_script("return {foo: {bar: [1, 2]}}"),
+ )
+
+ def test_no_return_value(self):
+ self.assertIsNone(self.marionette.execute_script("true"))
+
+ def test_argument_null(self):
+ self.assertIsNone(
+ self.marionette.execute_script(
+ "return arguments[0]", script_args=(None,), sandbox="default"
+ )
+ )
+ self.assertIsNone(
+ self.marionette.execute_script(
+ "return arguments[0]", script_args=(None,), sandbox="system"
+ )
+ )
+ self.assertIsNone(
+ self.marionette.execute_script(
+ "return arguments[0]", script_args=(None,), sandbox=None
+ )
+ )
+
+ def test_argument_number(self):
+ self.assertEqual(1, self.marionette.execute_script("return arguments[0]", (1,)))
+ self.assertEqual(
+ 1.5, self.marionette.execute_script("return arguments[0]", (1.5,))
+ )
+
+ def test_argument_boolean(self):
+ self.assertTrue(self.marionette.execute_script("return arguments[0]", (True,)))
+
+ def test_argument_string(self):
+ self.assertEqual(
+ "foo", self.marionette.execute_script("return arguments[0]", ("foo",))
+ )
+
+ def test_argument_array(self):
+ self.assertEqual(
+ [1, 2], self.marionette.execute_script("return arguments[0]", ([1, 2],))
+ )
+
+ def test_argument_object(self):
+ self.assertEqual(
+ {"foo": 1},
+ self.marionette.execute_script("return arguments[0]", ({"foo": 1},)),
+ )
+
+ def test_default_sandbox_globals(self):
+ for property in globals:
+ self.assert_is_defined(property, sandbox="default")
+
+ self.assert_is_defined("Components")
+ self.assert_is_defined("window.wrappedJSObject")
+
+ def test_system_globals(self):
+ for property in globals:
+ self.assert_is_defined(property, sandbox="system")
+
+ self.assert_is_defined("Components", sandbox="system")
+ self.assert_is_defined("window.wrappedJSObject", sandbox="system")
+
+ def test_mutable_sandbox_globals(self):
+ for property in globals:
+ self.assert_is_defined(property, sandbox=None)
+
+ # Components is there, but will be removed soon
+ self.assert_is_defined("Components", sandbox=None)
+ # wrappedJSObject is always there in sandboxes
+ self.assert_is_defined("window.wrappedJSObject", sandbox=None)
+
+ def test_exception(self):
+ self.assertRaises(
+ errors.JavascriptException, self.marionette.execute_script, "return foo"
+ )
+
+ def test_stacktrace(self):
+ with self.assertRaises(errors.JavascriptException) as cm:
+ self.marionette.execute_script("return b")
+
+ # by default execute_script pass the name of the python file
+ self.assertIn(
+ os.path.relpath(__file__.replace(".pyc", ".py")), cm.exception.stacktrace
+ )
+ self.assertIn("b is not defined", str(cm.exception))
+
+ def test_permission(self):
+ for sandbox in ["default", None]:
+ with self.assertRaises(errors.JavascriptException):
+ self.marionette.execute_script(
+ "Components.classes['@mozilla.org/preferences-service;1']"
+ )
+
+ def test_return_web_element(self):
+ self.marionette.navigate(elements)
+ expected = self.marionette.find_element(By.TAG_NAME, "p")
+ actual = self.marionette.execute_script("return document.querySelector('p')")
+ self.assertEqual(expected, actual)
+
+ def test_return_web_element_array(self):
+ self.marionette.navigate(elements)
+ expected = self.marionette.find_elements(By.TAG_NAME, "p")
+ actual = self.marionette.execute_script(
+ """
+ let els = document.querySelectorAll('p')
+ return [els[0], els[1]]"""
+ )
+ self.assertEqual(expected, actual)
+
+ # Bug 938228 identifies a problem with unmarshaling NodeList
+ # objects from the DOM. document.querySelectorAll returns this
+ # construct.
+ def test_return_web_element_nodelist(self):
+ self.marionette.navigate(elements)
+ expected = self.marionette.find_elements(By.TAG_NAME, "p")
+ actual = self.marionette.execute_script("return document.querySelectorAll('p')")
+ self.assertEqual(expected, actual)
+
+ def test_sandbox_reuse(self):
+ # Sandboxes between `execute_script()` invocations are shared.
+ self.marionette.execute_script("this.foobar = [23, 42];")
+ self.assertEqual(
+ self.marionette.execute_script("return this.foobar;", new_sandbox=False),
+ [23, 42],
+ )
+
+ def test_sandbox_refresh_arguments(self):
+ self.marionette.execute_script(
+ "this.foobar = [arguments[0], arguments[1]]", [23, 42]
+ )
+ self.assertEqual(
+ self.marionette.execute_script("return this.foobar", new_sandbox=False),
+ [23, 42],
+ )
+
+ def test_mutable_sandbox_wrappedjsobject(self):
+ self.assert_is_defined("window.wrappedJSObject")
+ with self.assertRaises(errors.JavascriptException):
+ self.marionette.execute_script(
+ "window.wrappedJSObject.foo = 1", sandbox=None
+ )
+
+ def test_default_sandbox_wrappedjsobject(self):
+ self.assert_is_defined("window.wrappedJSObject", sandbox="default")
+
+ try:
+ self.marionette.execute_script(
+ "window.wrappedJSObject.foo = 4", sandbox="default"
+ )
+ self.assertEqual(
+ self.marionette.execute_script(
+ "return window.wrappedJSObject.foo", sandbox="default"
+ ),
+ 4,
+ )
+ finally:
+ self.marionette.execute_script(
+ "delete window.wrappedJSObject.foo", sandbox="default"
+ )
+
+ def test_system_sandbox_wrappedjsobject(self):
+ self.assert_is_defined("window.wrappedJSObject", sandbox="system")
+
+ self.marionette.execute_script(
+ "window.wrappedJSObject.foo = 4", sandbox="system"
+ )
+ self.assertEqual(
+ self.marionette.execute_script(
+ "return window.wrappedJSObject.foo", sandbox="system"
+ ),
+ 4,
+ )
+
+ def test_system_dead_object(self):
+ self.assert_is_defined("window.wrappedJSObject", sandbox="system")
+
+ self.marionette.execute_script(
+ "window.wrappedJSObject.foo = function() { return 'yo' }", sandbox="system"
+ )
+ self.marionette.execute_script(
+ "dump(window.wrappedJSObject.foo)", sandbox="system"
+ )
+
+ self.marionette.execute_script(
+ "window.wrappedJSObject.foo = function() { return 'yolo' }",
+ sandbox="system",
+ )
+ typ = self.marionette.execute_script(
+ "return typeof window.wrappedJSObject.foo", sandbox="system"
+ )
+ self.assertEqual("function", typ)
+ obj = self.marionette.execute_script(
+ "return window.wrappedJSObject.foo.toString()", sandbox="system"
+ )
+ self.assertIn("yolo", obj)
+
+ def test_lasting_side_effects(self):
+ def send(script):
+ return self.marionette._send_message(
+ "WebDriver:ExecuteScript", {"script": script}, key="value"
+ )
+
+ send("window.foo = 1")
+ foo = send("return window.foo")
+ self.assertEqual(1, foo)
+
+ for property in globals:
+ exists = send("return typeof {} != 'undefined'".format(property))
+ self.assertTrue(exists, "property {} is undefined".format(property))
+
+ self.assertTrue(
+ send(
+ """
+ return (typeof Components == 'undefined') ||
+ (typeof Components.utils == 'undefined')
+ """
+ )
+ )
+ self.assertTrue(send("return typeof window.wrappedJSObject == 'undefined'"))
+
+ def test_no_callback(self):
+ self.assertTrue(
+ self.marionette.execute_script("return typeof arguments[0] == 'undefined'")
+ )
+
+ def test_window_set_timeout_is_not_cancelled(self):
+ def content_timeout_triggered(mn):
+ return mn.execute_script("return window.n", sandbox=None) > 0
+
+ # subsequent call to execute_script after this
+ # should not cancel the setTimeout event
+ self.marionette.navigate(
+ inline(
+ """
+ <script>
+ window.n = 0;
+ setTimeout(() => ++window.n, 4000);
+ </script>"""
+ )
+ )
+
+ # as debug builds are inherently slow,
+ # we need to assert the event did not already fire
+ self.assertEqual(
+ 0,
+ self.marionette.execute_script("return window.n", sandbox=None),
+ "setTimeout already fired",
+ )
+
+ # if event was cancelled, this will time out
+ Wait(self.marionette, timeout=8).until(
+ content_timeout_triggered,
+ message="Scheduled setTimeout event was cancelled by call to execute_script",
+ )
+
+ def test_access_chrome_objects_in_event_listeners(self):
+ # sandbox.window.addEventListener/removeEventListener
+ # is used by Marionette for installing the unloadHandler which
+ # is used to return an error when a document is unloaded during
+ # script execution.
+ #
+ # Certain web frameworks, notably Angular, override
+ # window.addEventListener/removeEventListener and introspects
+ # objects passed to them. If these objects originates from chrome
+ # without having been cloned, a permission denied error is thrown
+ # as part of the security precautions put in place by the sandbox.
+
+ # addEventListener is called when script is injected
+ self.marionette.navigate(
+ inline(
+ """
+ <script>
+ window.addEventListener = (event, listener) => listener.toString();
+ </script>
+ """
+ )
+ )
+ self.marionette.execute_script("", sandbox=None)
+
+ # removeEventListener is called when sandbox is unloaded
+ self.marionette.navigate(
+ inline(
+ """
+ <script>
+ window.removeEventListener = (event, listener) => listener.toString();
+ </script>
+ """
+ )
+ )
+ self.marionette.execute_script("", sandbox=None)
+
+ def test_access_global_objects_from_chrome(self):
+ # test inspection of arguments
+ self.marionette.execute_script("__webDriverArguments.toString()")
+
+ def test_toJSON(self):
+ foo = self.marionette.execute_script(
+ """
+ return {
+ toJSON () {
+ return "foo";
+ }
+ }
+ """,
+ sandbox=None,
+ )
+ self.assertEqual("foo", foo)
+
+ def test_unsafe_toJSON(self):
+ el = self.marionette.execute_script(
+ """
+ return {
+ toJSON () {
+ return document.documentElement;
+ }
+ }
+ """,
+ sandbox=None,
+ )
+ self.assert_is_web_element(el)
+ self.assertEqual(el, self.marionette.find_element(By.CSS_SELECTOR, ":root"))
+ self.assertEqual(el.get_property("localName"), "html")
+
+ def test_comment_in_last_line(self):
+ self.marionette.execute_script(" // comment ")
+
+ def test_return_value_on_alert(self):
+ res = self.marionette.execute_script("alert()")
+ self.assertIsNone(res)
+
+
+class TestExecuteChrome(WindowManagerMixin, TestExecuteContent):
+ def setUp(self):
+ super(TestExecuteChrome, self).setUp()
+
+ self.marionette.set_context("chrome")
+
+ def tearDown(self):
+ super(TestExecuteChrome, self).tearDown()
+
+ def test_permission(self):
+ self.marionette.execute_script(
+ "Components.classes['@mozilla.org/preferences-service;1']"
+ )
+
+ def test_unmarshal_element_collection(self):
+ try:
+ win = self.open_chrome_window("chrome://marionette/content/test.xhtml")
+ self.marionette.switch_to_window(win)
+
+ expected = self.marionette.find_elements(By.TAG_NAME, "input")
+ actual = self.marionette.execute_script(
+ "return document.querySelectorAll('input')"
+ )
+ self.assertTrue(len(expected) > 0)
+ self.assertEqual(expected, actual)
+
+ finally:
+ self.close_all_windows()
+
+ def test_async_script_timeout(self):
+ with self.assertRaises(errors.ScriptTimeoutException):
+ self.marionette.execute_async_script(
+ """
+ var cb = arguments[arguments.length - 1];
+ setTimeout(function() { cb() }, 2500);
+ """,
+ script_timeout=100,
+ )
+
+ def test_lasting_side_effects(self):
+ pass
+
+ def test_return_web_element(self):
+ pass
+
+ def test_return_web_element_array(self):
+ pass
+
+ def test_return_web_element_nodelist(self):
+ pass
+
+ def test_window_set_timeout_is_not_cancelled(self):
+ pass
+
+ def test_mutable_sandbox_wrappedjsobject(self):
+ pass
+
+ def test_default_sandbox_wrappedjsobject(self):
+ pass
+
+ def test_system_sandbox_wrappedjsobject(self):
+ pass
+
+ def test_access_chrome_objects_in_event_listeners(self):
+ pass
+
+ def test_return_value_on_alert(self):
+ pass
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_expected.py b/testing/marionette/harness/marionette_harness/tests/unit/test_expected.py
new file mode 100644
index 0000000000..7ea0e68f9b
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_expected.py
@@ -0,0 +1,235 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from six.moves.urllib.parse import quote
+
+from marionette_driver import expected
+from marionette_driver.by import By
+
+from marionette_harness import marionette_test
+
+
+def inline(doc):
+ return "data:text/html;charset=utf-8,{}".format(quote(doc))
+
+
+static_element = inline("""<p>foo</p>""")
+static_elements = static_element + static_element
+
+remove_element_by_tag_name = """var el = document.getElementsByTagName('{}')[0];
+ document.getElementsByTagName("body")[0].remove(el);"""
+
+hidden_element = inline("<p style='display: none'>hidden</p>")
+
+selected_element = inline("<option selected>selected</option>")
+unselected_element = inline("<option>unselected</option>")
+
+enabled_element = inline("<input>")
+disabled_element = inline("<input disabled>")
+
+
+def no_such_element(marionette):
+ return marionette.find_element(By.ID, "nosuchelement")
+
+
+def no_such_elements(marionette):
+ return marionette.find_elements(By.ID, "nosuchelement")
+
+
+def p(marionette):
+ return marionette.find_element(By.TAG_NAME, "p")
+
+
+def ps(marionette):
+ return marionette.find_elements(By.TAG_NAME, "p")
+
+
+class TestExpected(marionette_test.MarionetteTestCase):
+ def test_element_present_func(self):
+ self.marionette.navigate(static_element)
+ el = expected.element_present(p)(self.marionette)
+ self.assertIsNotNone(el)
+
+ def test_element_present_locator(self):
+ self.marionette.navigate(static_element)
+ el = expected.element_present(By.TAG_NAME, "p")(self.marionette)
+ self.assertIsNotNone(el)
+
+ def test_element_present_not_present(self):
+ r = expected.element_present(no_such_element)(self.marionette)
+ self.assertIsInstance(r, bool)
+ self.assertFalse(r)
+
+ def test_element_not_present_func(self):
+ r = expected.element_not_present(no_such_element)(self.marionette)
+ self.assertIsInstance(r, bool)
+ self.assertTrue(r)
+
+ def test_element_not_present_locator(self):
+ r = expected.element_not_present(By.ID, "nosuchelement")(self.marionette)
+ self.assertIsInstance(r, bool)
+ self.assertTrue(r)
+
+ def test_element_not_present_is_present(self):
+ self.marionette.navigate(static_element)
+ r = expected.element_not_present(p)(self.marionette)
+ self.assertIsInstance(r, bool)
+ self.assertFalse(r)
+
+ def test_element_stale(self):
+ self.marionette.navigate(static_element)
+ el = self.marionette.find_element(By.TAG_NAME, "p")
+ self.assertIsNotNone(el)
+ self.marionette.execute_script(remove_element_by_tag_name.format("p"))
+ r = expected.element_stale(el)(self.marionette)
+ self.assertTrue(r)
+
+ def test_element_stale_is_not_stale(self):
+ self.marionette.navigate(static_element)
+ el = self.marionette.find_element(By.TAG_NAME, "p")
+ r = expected.element_stale(el)(self.marionette)
+ self.assertFalse(r)
+
+ def test_elements_present_func(self):
+ self.marionette.navigate(static_elements)
+ els = expected.elements_present(ps)(self.marionette)
+ self.assertEqual(len(els), 2)
+
+ def test_elements_present_locator(self):
+ self.marionette.navigate(static_elements)
+ els = expected.elements_present(By.TAG_NAME, "p")(self.marionette)
+ self.assertEqual(len(els), 2)
+
+ def test_elements_present_not_present(self):
+ r = expected.elements_present(no_such_elements)(self.marionette)
+ self.assertEqual(r, [])
+
+ def test_elements_not_present_func(self):
+ r = expected.element_not_present(no_such_elements)(self.marionette)
+ self.assertIsInstance(r, bool)
+ self.assertTrue(r)
+
+ def test_elements_not_present_locator(self):
+ r = expected.element_not_present(By.ID, "nosuchelement")(self.marionette)
+ self.assertIsInstance(r, bool)
+ self.assertTrue(r)
+
+ def test_elements_not_present_is_present(self):
+ self.marionette.navigate(static_elements)
+ r = expected.elements_not_present(ps)(self.marionette)
+ self.assertIsInstance(r, bool)
+ self.assertFalse(r)
+
+ def test_element_displayed(self):
+ self.marionette.navigate(static_element)
+ el = self.marionette.find_element(By.TAG_NAME, "p")
+ visible = expected.element_displayed(el)(self.marionette)
+ self.assertTrue(visible)
+
+ def test_element_displayed_locator(self):
+ self.marionette.navigate(static_element)
+ visible = expected.element_displayed(By.TAG_NAME, "p")(self.marionette)
+ self.assertTrue(visible)
+
+ def test_element_displayed_when_hidden(self):
+ self.marionette.navigate(hidden_element)
+ el = self.marionette.find_element(By.TAG_NAME, "p")
+ visible = expected.element_displayed(el)(self.marionette)
+ self.assertFalse(visible)
+
+ def test_element_displayed_when_hidden_locator(self):
+ self.marionette.navigate(hidden_element)
+ visible = expected.element_displayed(By.TAG_NAME, "p")(self.marionette)
+ self.assertFalse(visible)
+
+ def test_element_displayed_when_not_present(self):
+ self.marionette.navigate("about:blank")
+ visible = expected.element_displayed(By.TAG_NAME, "p")(self.marionette)
+ self.assertFalse(visible)
+
+ def test_element_displayed_when_stale_element(self):
+ self.marionette.navigate(static_element)
+ el = self.marionette.find_element(By.TAG_NAME, "p")
+ self.marionette.navigate("about:blank")
+ missing = expected.element_displayed(el)(self.marionette)
+ self.assertFalse(missing)
+
+ def test_element_not_displayed(self):
+ self.marionette.navigate(hidden_element)
+ el = self.marionette.find_element(By.TAG_NAME, "p")
+ hidden = expected.element_not_displayed(el)(self.marionette)
+ self.assertTrue(hidden)
+
+ def test_element_not_displayed_locator(self):
+ self.marionette.navigate(hidden_element)
+ hidden = expected.element_not_displayed(By.TAG_NAME, "p")(self.marionette)
+ self.assertTrue(hidden)
+
+ def test_element_not_displayed_when_visible(self):
+ self.marionette.navigate(static_element)
+ el = self.marionette.find_element(By.TAG_NAME, "p")
+ hidden = expected.element_not_displayed(el)(self.marionette)
+ self.assertFalse(hidden)
+
+ def test_element_not_displayed_when_visible_locator(self):
+ self.marionette.navigate(static_element)
+ hidden = expected.element_not_displayed(By.TAG_NAME, "p")(self.marionette)
+ self.assertFalse(hidden)
+
+ def test_element_not_displayed_when_stale_element(self):
+ self.marionette.navigate(static_element)
+ el = self.marionette.find_element(By.TAG_NAME, "p")
+ self.marionette.navigate("about:blank")
+ missing = expected.element_not_displayed(el)(self.marionette)
+ self.assertTrue(missing)
+
+ def test_element_selected(self):
+ self.marionette.navigate(selected_element)
+ el = self.marionette.find_element(By.TAG_NAME, "option")
+ selected = expected.element_selected(el)(self.marionette)
+ self.assertTrue(selected)
+
+ def test_element_selected_when_not_selected(self):
+ self.marionette.navigate(unselected_element)
+ el = self.marionette.find_element(By.TAG_NAME, "option")
+ unselected = expected.element_selected(el)(self.marionette)
+ self.assertFalse(unselected)
+
+ def test_element_not_selected(self):
+ self.marionette.navigate(unselected_element)
+ el = self.marionette.find_element(By.TAG_NAME, "option")
+ unselected = expected.element_not_selected(el)(self.marionette)
+ self.assertTrue(unselected)
+
+ def test_element_not_selected_when_selected(self):
+ self.marionette.navigate(selected_element)
+ el = self.marionette.find_element(By.TAG_NAME, "option")
+ selected = expected.element_not_selected(el)(self.marionette)
+ self.assertFalse(selected)
+
+ def test_element_enabled(self):
+ self.marionette.navigate(enabled_element)
+ el = self.marionette.find_element(By.TAG_NAME, "input")
+ enabled = expected.element_enabled(el)(self.marionette)
+ self.assertTrue(enabled)
+
+ def test_element_enabled_when_disabled(self):
+ self.marionette.navigate(disabled_element)
+ el = self.marionette.find_element(By.TAG_NAME, "input")
+ disabled = expected.element_enabled(el)(self.marionette)
+ self.assertFalse(disabled)
+
+ def test_element_not_enabled(self):
+ self.marionette.navigate(disabled_element)
+ el = self.marionette.find_element(By.TAG_NAME, "input")
+ disabled = expected.element_not_enabled(el)(self.marionette)
+ self.assertTrue(disabled)
+
+ def test_element_not_enabled_when_enabled(self):
+ self.marionette.navigate(enabled_element)
+ el = self.marionette.find_element(By.TAG_NAME, "input")
+ enabled = expected.element_not_enabled(el)(self.marionette)
+ self.assertFalse(enabled)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_expectedfail.py b/testing/marionette/harness/marionette_harness/tests/unit/test_expectedfail.py
new file mode 100644
index 0000000000..e970bc870a
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_expectedfail.py
@@ -0,0 +1,13 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_harness import MarionetteTestCase
+
+
+class TestFail(MarionetteTestCase):
+ def test_fails(self):
+ # this test is supposed to fail!
+ self.assertEquals(True, False)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_file_upload.py b/testing/marionette/harness/marionette_harness/tests/unit/test_file_upload.py
new file mode 100644
index 0000000000..692393af4b
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_file_upload.py
@@ -0,0 +1,171 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import contextlib
+
+from tempfile import NamedTemporaryFile as tempfile
+
+import six
+from six.moves.urllib.parse import quote
+
+from marionette_driver import By, errors, expected
+from marionette_driver.wait import Wait
+from marionette_harness import MarionetteTestCase, skip
+
+
+single = "data:text/html,{}".format(quote("<input type=file>"))
+multiple = "data:text/html,{}".format(quote("<input type=file multiple>"))
+upload = lambda url: "data:text/html,{}".format(
+ quote(
+ """
+ <form action='{}' method=post enctype='multipart/form-data'>
+ <input type=file>
+ <input type=submit>
+ </form>""".format(
+ url
+ )
+ )
+)
+
+
+class TestFileUpload(MarionetteTestCase):
+ def test_sets_one_file(self):
+ self.marionette.navigate(single)
+ input = self.input
+
+ exp = None
+ with tempfile() as f:
+ input.send_keys(f.name)
+ exp = [f.name]
+
+ files = self.get_file_names(input)
+ self.assertEqual(len(files), 1)
+ self.assertFileNamesEqual(files, exp)
+
+ def test_sets_multiple_files(self):
+ self.marionette.navigate(multiple)
+ input = self.input
+
+ exp = None
+ with tempfile() as a, tempfile() as b:
+ input.send_keys(a.name)
+ input.send_keys(b.name)
+ exp = [a.name, b.name]
+
+ files = self.get_file_names(input)
+ self.assertEqual(len(files), 2)
+ self.assertFileNamesEqual(files, exp)
+
+ def test_sets_multiple_indentical_files(self):
+ self.marionette.navigate(multiple)
+ input = self.input
+
+ exp = []
+ with tempfile() as f:
+ input.send_keys(f.name)
+ input.send_keys(f.name)
+ exp = f.name
+
+ files = self.get_file_names(input)
+ self.assertEqual(len(files), 2)
+ self.assertFileNamesEqual(files, exp)
+
+ def test_clear_file(self):
+ self.marionette.navigate(single)
+ input = self.input
+
+ with tempfile() as f:
+ input.send_keys(f.name)
+
+ self.assertEqual(len(self.get_files(input)), 1)
+ input.clear()
+ self.assertEqual(len(self.get_files(input)), 0)
+
+ def test_clear_files(self):
+ self.marionette.navigate(multiple)
+ input = self.input
+
+ with tempfile() as a, tempfile() as b:
+ input.send_keys(a.name)
+ input.send_keys(b.name)
+
+ self.assertEqual(len(self.get_files(input)), 2)
+ input.clear()
+ self.assertEqual(len(self.get_files(input)), 0)
+
+ def test_illegal_file(self):
+ self.marionette.navigate(single)
+ with self.assertRaisesRegexp(errors.MarionetteException, "File not found"):
+ self.input.send_keys("rochefort")
+
+ def test_upload(self):
+ self.marionette.navigate(upload(self.marionette.absolute_url("file_upload")))
+ url = self.marionette.get_url()
+
+ with tempfile() as f:
+ f.write(six.ensure_binary("camembert"))
+ f.flush()
+ self.input.send_keys(f.name)
+ self.submit.click()
+
+ Wait(self.marionette, timeout=self.marionette.timeout.page_load).until(
+ lambda m: m.get_url() != url,
+ message="URL didn't change after submitting a file upload",
+ )
+ self.assertIn("multipart/form-data", self.body.text)
+
+ def test_change_event(self):
+ self.marionette.navigate(single)
+ self.marionette.execute_script(
+ """
+ window.changeEvs = [];
+ let el = arguments[arguments.length - 1];
+ el.addEventListener("change", ev => window.changeEvs.push(ev));
+ console.log(window.changeEvs.length);
+ """,
+ script_args=(self.input,),
+ sandbox=None,
+ )
+
+ with tempfile() as f:
+ self.input.send_keys(f.name)
+
+ nevs = self.marionette.execute_script(
+ "return window.changeEvs.length", sandbox=None
+ )
+ self.assertEqual(1, nevs)
+
+ def find_inputs(self):
+ return self.marionette.find_elements(By.TAG_NAME, "input")
+
+ @property
+ def input(self):
+ return self.find_inputs()[0]
+
+ @property
+ def submit(self):
+ return self.find_inputs()[1]
+
+ @property
+ def body(self):
+ return Wait(self.marionette).until(
+ expected.element_present(By.TAG_NAME, "body")
+ )
+
+ def get_file_names(self, el):
+ fl = self.get_files(el)
+ return [f["name"] for f in fl]
+
+ def get_files(self, el):
+ return self.marionette.execute_script(
+ "return arguments[0].files", script_args=[el]
+ )
+
+ def assertFileNamesEqual(self, act, exp):
+ # File array returned from browser doesn't contain full path names,
+ # this cuts off the path of the expected files.
+ filenames = [f.rsplit("/", 0)[-1] for f in act]
+ self.assertListEqual(filenames, act)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_findelement_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_findelement_chrome.py
new file mode 100644
index 0000000000..63b1661365
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_findelement_chrome.py
@@ -0,0 +1,116 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver.by import By
+from marionette_driver.errors import NoSuchElementException
+from marionette_driver.marionette import HTMLElement
+
+from marionette_harness import MarionetteTestCase, WindowManagerMixin
+
+
+class TestElementsChrome(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestElementsChrome, self).setUp()
+
+ self.marionette.set_context("chrome")
+
+ win = self.open_chrome_window("chrome://marionette/content/test.xhtml")
+ self.marionette.switch_to_window(win)
+
+ def tearDown(self):
+ self.close_all_windows()
+
+ super(TestElementsChrome, self).tearDown()
+
+ def test_id(self):
+ el = self.marionette.execute_script(
+ "return window.document.getElementById('textInput');"
+ )
+ found_el = self.marionette.find_element(By.ID, "textInput")
+ self.assertEqual(HTMLElement, type(found_el))
+ self.assertEqual(el, found_el)
+
+ def test_that_we_can_find_elements_from_css_selectors(self):
+ el = self.marionette.execute_script(
+ "return window.document.getElementById('textInput');"
+ )
+ found_el = self.marionette.find_element(By.CSS_SELECTOR, "#textInput")
+ self.assertEqual(HTMLElement, type(found_el))
+ self.assertEqual(el, found_el)
+
+ def test_child_element(self):
+ el = self.marionette.find_element(By.ID, "textInput")
+ parent = self.marionette.find_element(By.ID, "things")
+ found_el = parent.find_element(By.TAG_NAME, "input")
+ self.assertEqual(HTMLElement, type(found_el))
+ self.assertEqual(el, found_el)
+
+ def test_child_elements(self):
+ el = self.marionette.find_element(By.ID, "textInput3")
+ parent = self.marionette.find_element(By.ID, "things")
+ found_els = parent.find_elements(By.TAG_NAME, "input")
+ self.assertTrue(el.id in [found_el.id for found_el in found_els])
+
+ def test_tag_name(self):
+ el = self.marionette.execute_script(
+ "return window.document.getElementsByTagName('vbox')[0];"
+ )
+ found_el = self.marionette.find_element(By.TAG_NAME, "vbox")
+ self.assertEquals("vbox", found_el.tag_name)
+ self.assertEqual(HTMLElement, type(found_el))
+ self.assertEqual(el, found_el)
+
+ def test_class_name(self):
+ el = self.marionette.execute_script(
+ "return window.document.getElementsByClassName('asdf')[0];"
+ )
+ found_el = self.marionette.find_element(By.CLASS_NAME, "asdf")
+ self.assertEqual(HTMLElement, type(found_el))
+ self.assertEqual(el, found_el)
+
+ def test_xpath(self):
+ el = self.marionette.execute_script(
+ "return window.document.getElementById('testBox');"
+ )
+ found_el = self.marionette.find_element(By.XPATH, "id('testBox')")
+ self.assertEqual(HTMLElement, type(found_el))
+ self.assertEqual(el, found_el)
+
+ def test_not_found(self):
+ self.marionette.timeout.implicit = 1
+ self.assertRaises(
+ NoSuchElementException,
+ self.marionette.find_element,
+ By.ID,
+ "I'm not on the page",
+ )
+ self.marionette.timeout.implicit = 0
+ self.assertRaises(
+ NoSuchElementException,
+ self.marionette.find_element,
+ By.ID,
+ "I'm not on the page",
+ )
+
+ def test_timeout(self):
+ self.assertRaises(
+ NoSuchElementException, self.marionette.find_element, By.ID, "myid"
+ )
+ self.marionette.timeout.implicit = 4
+ self.marionette.execute_script(
+ """
+ window.setTimeout(function () {
+ var b = window.document.createXULElement('button');
+ b.id = 'myid';
+ document.getElementById('things').appendChild(b);
+ }, 1000); """
+ )
+ self.assertEqual(HTMLElement, type(self.marionette.find_element(By.ID, "myid")))
+ self.marionette.execute_script(
+ """
+ var elem = window.document.getElementById('things');
+ elem.removeChild(window.document.getElementById('myid')); """
+ )
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_geckoinstance.py b/testing/marionette/harness/marionette_harness/tests/unit/test_geckoinstance.py
new file mode 100644
index 0000000000..97b2dd06c2
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_geckoinstance.py
@@ -0,0 +1,27 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver.geckoinstance import apps, GeckoInstance
+
+from marionette_harness import MarionetteTestCase
+
+
+class TestGeckoInstance(MarionetteTestCase):
+ def test_create(self):
+ """Test that the correct gecko instance is determined."""
+ for app in apps:
+ # If app has been specified we directly return the appropriate instance class
+ self.assertEqual(type(GeckoInstance.create(app=app, bin="n/a")), apps[app])
+
+ # Unknown applications and binaries should fail
+ self.assertRaises(
+ NotImplementedError,
+ GeckoInstance.create,
+ app="n/a",
+ bin=self.marionette.bin,
+ )
+ self.assertRaises(NotImplementedError, GeckoInstance.create, bin="n/a")
+ self.assertRaises(NotImplementedError, GeckoInstance.create, bin=None)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_get_current_url_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_get_current_url_chrome.py
new file mode 100644
index 0000000000..67889df7e4
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_get_current_url_chrome.py
@@ -0,0 +1,41 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver.errors import NoSuchWindowException
+
+from marionette_harness import MarionetteTestCase, WindowManagerMixin
+
+
+class TestGetCurrentUrlChrome(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestGetCurrentUrlChrome, self).setUp()
+ self.marionette.set_context("chrome")
+
+ def tearDown(self):
+ self.close_all_windows()
+ super(TestGetCurrentUrlChrome, self).tearDown()
+
+ def test_browser_window(self):
+ url = self.marionette.absolute_url("test.html")
+
+ with self.marionette.using_context("content"):
+ self.marionette.navigate(url)
+ self.assertEqual(self.marionette.get_url(), url)
+
+ chrome_url = self.marionette.execute_script("return window.location.href;")
+ self.assertEqual(self.marionette.get_url(), chrome_url)
+
+ def test_no_browser_window(self):
+ win = self.open_chrome_window("chrome://marionette/content/test.xhtml")
+ self.marionette.switch_to_window(win)
+
+ chrome_url = self.marionette.execute_script("return window.location.href;")
+ self.assertEqual(self.marionette.get_url(), chrome_url)
+
+ # With no tabbrowser available an exception will be thrown
+ with self.assertRaises(NoSuchWindowException):
+ with self.marionette.using_context("content"):
+ self.marionette.get_url()
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_implicit_waits.py b/testing/marionette/harness/marionette_harness/tests/unit/test_implicit_waits.py
new file mode 100644
index 0000000000..41863a9d83
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_implicit_waits.py
@@ -0,0 +1,28 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver.by import By
+from marionette_driver.errors import NoSuchElementException
+
+from marionette_harness import MarionetteTestCase
+
+
+class TestImplicitWaits(MarionetteTestCase):
+ def test_implicitly_wait_for_single_element(self):
+ test_html = self.marionette.absolute_url("test_dynamic.html")
+ self.marionette.navigate(test_html)
+ add = self.marionette.find_element(By.ID, "adder")
+ self.marionette.timeout.implicit = 30
+ add.click()
+ # all is well if this does not throw
+ self.marionette.find_element(By.ID, "box0")
+
+ def test_implicit_wait_reaches_timeout(self):
+ test_html = self.marionette.absolute_url("test_dynamic.html")
+ self.marionette.navigate(test_html)
+ self.marionette.timeout.implicit = 3
+ with self.assertRaises(NoSuchElementException):
+ self.marionette.find_element(By.ID, "box0")
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_key_actions.py b/testing/marionette/harness/marionette_harness/tests/unit/test_key_actions.py
new file mode 100644
index 0000000000..8481e9d251
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_key_actions.py
@@ -0,0 +1,73 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from six.moves.urllib.parse import quote
+
+from marionette_driver.by import By
+from marionette_driver.keys import Keys
+from marionette_harness import MarionetteTestCase, WindowManagerMixin
+
+
+def inline(doc):
+ return "data:text/html;charset=utf-8,{}".format(quote(doc))
+
+
+class TestKeyActions(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestKeyActions, self).setUp()
+ self.key_chain = self.marionette.actions.sequence("key", "keyboard_id")
+
+ if self.marionette.session_capabilities["platformName"] == "mac":
+ self.mod_key = Keys.META
+ else:
+ self.mod_key = Keys.CONTROL
+
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+ self.reporter_element = self.marionette.find_element(By.ID, "keyReporter")
+ self.reporter_element.click()
+
+ def tearDown(self):
+ self.marionette.actions.release()
+
+ super(TestKeyActions, self).tearDown()
+
+ @property
+ def key_reporter_value(self):
+ return self.reporter_element.get_property("value")
+
+ def test_basic_input(self):
+ self.key_chain.key_down("a").key_down("b").key_down("c").perform()
+ self.assertEqual(self.key_reporter_value, "abc")
+
+ def test_upcase_input(self):
+ self.key_chain.key_down(Keys.SHIFT).key_down("a").key_up(Keys.SHIFT).key_down(
+ "b"
+ ).key_down("c").perform()
+ self.assertEqual(self.key_reporter_value, "Abc")
+
+ def test_replace_input(self):
+ self.key_chain.key_down("a").key_down("b").key_down("c").perform()
+ self.assertEqual(self.key_reporter_value, "abc")
+
+ self.key_chain.key_down(self.mod_key).key_down("a").key_up(
+ self.mod_key
+ ).key_down("x").perform()
+ self.assertEqual(self.key_reporter_value, "x")
+
+ def test_clear_input(self):
+ self.key_chain.key_down("a").key_down("b").key_down("c").perform()
+ self.assertEqual(self.key_reporter_value, "abc")
+
+ self.key_chain.key_down(self.mod_key).key_down("a").key_down("x").perform()
+ self.assertEqual(self.key_reporter_value, "")
+
+ def test_input_with_wait(self):
+ self.key_chain.key_down("a").key_down("b").key_down("c").perform()
+ self.key_chain.key_down(self.mod_key).key_down("a").pause(250).key_down(
+ "x"
+ ).perform()
+ self.assertEqual(self.key_reporter_value, "")
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_localization.py b/testing/marionette/harness/marionette_harness/tests/unit/test_localization.py
new file mode 100644
index 0000000000..e1e112d21e
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_localization.py
@@ -0,0 +1,73 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver import By
+from marionette_driver.errors import (
+ InvalidArgumentException,
+ NoSuchElementException,
+ UnknownException,
+)
+from marionette_driver.localization import L10n
+
+from marionette_harness import MarionetteTestCase
+
+
+class TestL10n(MarionetteTestCase):
+ def setUp(self):
+ super(TestL10n, self).setUp()
+
+ self.l10n = L10n(self.marionette)
+
+ def test_localize_entity(self):
+ dtds = ["chrome://marionette/content/test_dialog.dtd"]
+ value = self.l10n.localize_entity(dtds, "testDialog.title")
+
+ self.assertEqual(value, "Test Dialog")
+
+ def test_localize_entity_invalid_arguments(self):
+ dtds = ["chrome://marionette/content/test_dialog.dtd"]
+
+ self.assertRaises(
+ NoSuchElementException, self.l10n.localize_entity, dtds, "notExistent"
+ )
+ self.assertRaises(
+ InvalidArgumentException, self.l10n.localize_entity, dtds[0], "notExistent"
+ )
+ self.assertRaises(
+ InvalidArgumentException, self.l10n.localize_entity, dtds, True
+ )
+
+ def test_localize_property(self):
+ properties = ["chrome://marionette/content/test_dialog.properties"]
+
+ value = self.l10n.localize_property(properties, "testDialog.title")
+ self.assertEqual(value, "Test Dialog")
+
+ self.assertRaises(
+ NoSuchElementException,
+ self.l10n.localize_property,
+ properties,
+ "notExistent",
+ )
+
+ def test_localize_property_invalid_arguments(self):
+ properties = ["chrome://global/locale/filepicker.properties"]
+
+ self.assertRaises(
+ NoSuchElementException,
+ self.l10n.localize_property,
+ properties,
+ "notExistent",
+ )
+ self.assertRaises(
+ InvalidArgumentException,
+ self.l10n.localize_property,
+ properties[0],
+ "notExistent",
+ )
+ self.assertRaises(
+ InvalidArgumentException, self.l10n.localize_property, properties, True
+ )
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_marionette.py b/testing/marionette/harness/marionette_harness/tests/unit/test_marionette.py
new file mode 100644
index 0000000000..c3d43bbe7b
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_marionette.py
@@ -0,0 +1,111 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import socket
+import time
+
+from marionette_driver import errors
+from marionette_driver.marionette import Marionette
+from marionette_harness import MarionetteTestCase, run_if_manage_instance
+
+
+class TestMarionette(MarionetteTestCase):
+ def test_correct_test_name(self):
+ """Test that the correct test name gets set."""
+ expected_test_name = "{module}.py {cls}.{func}".format(
+ module=__name__,
+ cls=self.__class__.__name__,
+ func=self.test_correct_test_name.__name__,
+ )
+
+ self.assertIn(expected_test_name, self.marionette.test_name)
+
+ @run_if_manage_instance("Only runnable if Marionette manages the instance")
+ def test_raise_for_port_non_existing_process(self):
+ """Test that raise_for_port doesn't run into a timeout if instance is not running."""
+ self.marionette.quit()
+ self.assertIsNotNone(self.marionette.instance.runner.returncode)
+ start_time = time.time()
+ self.assertRaises(socket.timeout, self.marionette.raise_for_port, timeout=5)
+ self.assertLess(time.time() - start_time, 5)
+
+ def test_disable_enable_new_connections(self):
+ # Do not re-create socket if it already exists
+ self.marionette._send_message("Marionette:AcceptConnections", {"value": True})
+
+ try:
+ # Disabling new connections does not affect existing ones...
+ self.marionette._send_message(
+ "Marionette:AcceptConnections", {"value": False}
+ )
+ self.assertEqual(1, self.marionette.execute_script("return 1"))
+
+ # but only new connection attempts
+ marionette = Marionette(
+ host=self.marionette.host, port=self.marionette.port
+ )
+ self.assertRaises(socket.timeout, marionette.raise_for_port, timeout=1.0)
+
+ self.marionette._send_message(
+ "Marionette:AcceptConnections", {"value": True}
+ )
+ marionette.raise_for_port(timeout=10.0)
+
+ finally:
+ self.marionette._send_message(
+ "Marionette:AcceptConnections", {"value": True}
+ )
+
+ def test_client_socket_uses_expected_socket_timeout(self):
+ current_socket_timeout = self.marionette.socket_timeout
+
+ self.assertEqual(current_socket_timeout, self.marionette.client.socket_timeout)
+ self.assertEqual(
+ current_socket_timeout, self.marionette.client._sock.gettimeout()
+ )
+
+ def test_application_update_disabled(self):
+ # Updates of the application should always be disabled by default
+ with self.marionette.using_context("chrome"):
+ update_allowed = self.marionette.execute_script(
+ """
+ let aus = Cc['@mozilla.org/updates/update-service;1']
+ .getService(Ci.nsIApplicationUpdateService);
+ return aus.canCheckForUpdates;
+ """
+ )
+
+ self.assertFalse(update_allowed)
+
+
+class TestContext(MarionetteTestCase):
+ def setUp(self):
+ MarionetteTestCase.setUp(self)
+ self.marionette.set_context(self.marionette.CONTEXT_CONTENT)
+
+ def get_context(self):
+ return self.marionette._send_message("Marionette:GetContext", key="value")
+
+ def set_context(self, value):
+ return self.marionette._send_message("Marionette:SetContext", {"value": value})
+
+ def test_set_context(self):
+ self.assertEqual(self.set_context("content"), {"value": None})
+ self.assertEqual(self.set_context("chrome"), {"value": None})
+
+ for typ in [True, 42, [], {}, None]:
+ with self.assertRaises(errors.InvalidArgumentException):
+ self.set_context(typ)
+
+ with self.assertRaises(errors.MarionetteException):
+ self.set_context("foo")
+
+ def test_get_context(self):
+ self.assertEqual(self.get_context(), "content")
+ self.set_context("chrome")
+ self.assertEqual(self.get_context(), "chrome")
+ self.set_context("content")
+ self.assertEqual(self.get_context(), "content")
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_modal_dialogs.py b/testing/marionette/harness/marionette_harness/tests/unit/test_modal_dialogs.py
new file mode 100644
index 0000000000..2177214ca0
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_modal_dialogs.py
@@ -0,0 +1,201 @@
+from __future__ import absolute_import
+
+from marionette_driver.by import By
+from marionette_driver.expected import element_present
+from marionette_driver import errors
+from marionette_driver.marionette import Alert
+from marionette_driver.wait import Wait
+
+from marionette_harness import MarionetteTestCase, parameterized, WindowManagerMixin
+
+
+class BaseAlertTestCase(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(BaseAlertTestCase, self).setUp()
+ self.new_tab = self.open_tab()
+ self.marionette.switch_to_window(self.new_tab)
+
+ def tearDown(self):
+ self.close_all_tabs()
+ super(BaseAlertTestCase, self).tearDown()
+
+ @property
+ def alert_present(self):
+ try:
+ Alert(self.marionette).text
+ return True
+ except errors.NoAlertPresentException:
+ return False
+
+ def wait_for_alert(self, timeout=None):
+ Wait(self.marionette, timeout=timeout).until(lambda _: self.alert_present)
+
+
+class TestTabModalAlerts(BaseAlertTestCase):
+ def setUp(self):
+ super(TestTabModalAlerts, self).setUp()
+
+ self.test_page = self.marionette.absolute_url("test_tab_modal_dialogs.html")
+ self.marionette.navigate(self.test_page)
+
+ def tearDown(self):
+ # Ensure to close all possible remaining tab modal dialogs
+ try:
+ while True:
+ alert = self.marionette.switch_to_alert()
+ alert.dismiss()
+ except errors.NoAlertPresentException:
+ pass
+
+ super(TestTabModalAlerts, self).tearDown()
+
+ def test_no_alert_raises(self):
+ with self.assertRaises(errors.NoAlertPresentException):
+ Alert(self.marionette).accept()
+ with self.assertRaises(errors.NoAlertPresentException):
+ Alert(self.marionette).dismiss()
+
+ def test_alert_opened_before_session_starts(self):
+ self.marionette.find_element(By.ID, "tab-modal-alert").click()
+ self.wait_for_alert()
+
+ # Restart the session to ensure we still find the formerly left-open dialog.
+ self.marionette.delete_session()
+ self.marionette.start_session()
+
+ alert = self.marionette.switch_to_alert()
+ alert.dismiss()
+
+ @parameterized("alert", "alert", "undefined")
+ @parameterized("confirm", "confirm", "true")
+ @parameterized("prompt", "prompt", "")
+ def test_accept(self, value, result):
+ self.marionette.find_element(By.ID, "tab-modal-{}".format(value)).click()
+ self.wait_for_alert()
+ alert = self.marionette.switch_to_alert()
+ alert.accept()
+ self.assertEqual(self.marionette.find_element(By.ID, "text").text, result)
+
+ @parameterized("alert", "alert", "undefined")
+ @parameterized("confirm", "confirm", "false")
+ @parameterized("prompt", "prompt", "null")
+ def test_dismiss(self, value, result):
+ self.marionette.find_element(By.ID, "tab-modal-{}".format(value)).click()
+ self.wait_for_alert()
+ alert = self.marionette.switch_to_alert()
+ alert.dismiss()
+ self.assertEqual(self.marionette.find_element(By.ID, "text").text, result)
+
+ @parameterized("alert", "alert", "Marionette alert")
+ @parameterized("confirm", "confirm", "Marionette confirm")
+ @parameterized("prompt", "prompt", "Marionette prompt")
+ def test_text(self, value, text):
+ with self.assertRaises(errors.NoAlertPresentException):
+ alert = self.marionette.switch_to_alert()
+ alert.text
+ self.marionette.find_element(By.ID, "tab-modal-{}".format(value)).click()
+ self.wait_for_alert()
+ alert = self.marionette.switch_to_alert()
+ self.assertEqual(alert.text, text)
+ alert.accept()
+
+ @parameterized("alert", "alert")
+ @parameterized("confirm", "confirm")
+ def test_set_text_throws(self, value):
+ with self.assertRaises(errors.NoAlertPresentException):
+ Alert(self.marionette).send_keys("Foo")
+ self.marionette.find_element(By.ID, "tab-modal-{}".format(value)).click()
+ self.wait_for_alert()
+ alert = self.marionette.switch_to_alert()
+ with self.assertRaises(errors.ElementNotInteractableException):
+ alert.send_keys("Foo")
+ alert.accept()
+
+ def test_set_text_accept(self):
+ self.marionette.find_element(By.ID, "tab-modal-prompt").click()
+ self.wait_for_alert()
+ alert = self.marionette.switch_to_alert()
+ alert.send_keys("Foo bar")
+ alert.accept()
+ self.assertEqual(self.marionette.find_element(By.ID, "text").text, "Foo bar")
+
+ def test_set_text_dismiss(self):
+ self.marionette.find_element(By.ID, "tab-modal-prompt").click()
+ self.wait_for_alert()
+ alert = self.marionette.switch_to_alert()
+ alert.send_keys("Some text!")
+ alert.dismiss()
+ self.assertEqual(self.marionette.find_element(By.ID, "text").text, "null")
+
+ def test_unrelated_command_when_alert_present(self):
+ self.marionette.find_element(By.ID, "tab-modal-alert").click()
+ self.wait_for_alert()
+ with self.assertRaises(errors.UnexpectedAlertOpen):
+ self.marionette.find_element(By.ID, "text")
+
+ def test_modal_is_dismissed_after_unexpected_alert(self):
+ self.marionette.find_element(By.ID, "tab-modal-alert").click()
+ self.wait_for_alert()
+ with self.assertRaises(errors.UnexpectedAlertOpen):
+ self.marionette.find_element(By.ID, "text")
+
+ assert not self.alert_present
+
+ def test_handle_two_dialogs(self):
+ self.marionette.find_element(By.ID, "open-two-dialogs").click()
+
+ alert1 = self.marionette.switch_to_alert()
+ alert1.send_keys("foo")
+ alert1.accept()
+
+ alert2 = self.marionette.switch_to_alert()
+ alert2.send_keys("bar")
+ alert2.accept()
+
+ self.assertEqual(self.marionette.find_element(By.ID, "text1").text, "foo")
+ self.assertEqual(self.marionette.find_element(By.ID, "text2").text, "bar")
+
+
+class TestModalAlerts(BaseAlertTestCase):
+ def setUp(self):
+ super(TestModalAlerts, self).setUp()
+ self.marionette.set_pref(
+ "network.auth.non-web-content-triggered-resources-http-auth-allow",
+ True,
+ )
+
+ def tearDown(self):
+ self.marionette.clear_pref(
+ "network.auth.non-web-content-triggered-resources-http-auth-allow"
+ )
+ super(TestModalAlerts, self).tearDown()
+
+ def test_http_auth_dismiss(self):
+ self.marionette.navigate(self.marionette.absolute_url("http_auth"))
+ self.wait_for_alert(timeout=self.marionette.timeout.page_load)
+ alert = self.marionette.switch_to_alert()
+ alert.dismiss()
+
+ status = Wait(self.marionette, timeout=self.marionette.timeout.page_load).until(
+ element_present(By.ID, "status")
+ )
+ self.assertEqual(status.text, "restricted")
+
+ def test_http_auth_send_keys(self):
+ self.marionette.navigate(self.marionette.absolute_url("http_auth"))
+ self.wait_for_alert(timeout=self.marionette.timeout.page_load)
+
+ alert = self.marionette.switch_to_alert()
+ with self.assertRaises(errors.UnsupportedOperationException):
+ alert.send_keys("foo")
+
+ def test_alert_opened_before_session_starts(self):
+ self.marionette.navigate(self.marionette.absolute_url("http_auth"))
+ self.wait_for_alert(timeout=self.marionette.timeout.page_load)
+
+ # Restart the session to ensure we still find the formerly left-open dialog.
+ self.marionette.delete_session()
+ self.marionette.start_session()
+
+ alert = self.marionette.switch_to_alert()
+ alert.dismiss()
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_mouse_action.py b/testing/marionette/harness/marionette_harness/tests/unit/test_mouse_action.py
new file mode 100644
index 0000000000..a27965964a
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_mouse_action.py
@@ -0,0 +1,199 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import, division
+
+from six.moves.urllib.parse import quote
+
+from marionette_driver import By, errors, Wait
+from marionette_driver.keys import Keys
+
+from marionette_harness import MarionetteTestCase
+
+
+def inline(doc):
+ return "data:text/html;charset=utf-8,{}".format(quote(doc))
+
+
+class BaseMouseAction(MarionetteTestCase):
+ def setUp(self):
+ super(BaseMouseAction, self).setUp()
+ self.mouse_chain = self.marionette.actions.sequence(
+ "pointer", "pointer_id", {"pointerType": "mouse"}
+ )
+
+ if self.marionette.session_capabilities["platformName"] == "mac":
+ self.mod_key = Keys.META
+ else:
+ self.mod_key = Keys.CONTROL
+
+ def tearDown(self):
+ self.marionette.actions.release()
+
+ super(BaseMouseAction, self).tearDown()
+
+ @property
+ def click_position(self):
+ return self.marionette.execute_script(
+ """
+ if (window.click_x && window.click_y) {
+ return {x: window.click_x, y: window.click_y};
+ }
+ """,
+ sandbox=None,
+ )
+
+ def get_element_center_point(self, elem):
+ # pylint --py3k W1619
+ return {
+ "x": elem.rect["x"] + elem.rect["width"] / 2,
+ "y": elem.rect["y"] + elem.rect["height"] / 2,
+ }
+
+
+class TestPointerActions(BaseMouseAction):
+ def test_click_action(self):
+ test_html = self.marionette.absolute_url("test.html")
+ self.marionette.navigate(test_html)
+ link = self.marionette.find_element(By.ID, "mozLink")
+ self.mouse_chain.click(element=link).perform()
+ self.assertEqual(
+ "Clicked",
+ self.marionette.execute_script(
+ "return document.getElementById('mozLink').innerHTML"
+ ),
+ )
+
+ def test_clicking_element_out_of_view(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <div style="position:relative;top:200vh;">foo</div>
+ """
+ )
+ )
+ el = self.marionette.find_element(By.TAG_NAME, "div")
+ with self.assertRaises(errors.MoveTargetOutOfBoundsException):
+ self.mouse_chain.click(element=el).perform()
+
+ def test_double_click_action(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <script>window.eventCount = 0;</script>
+ <button onclick="window.eventCount++">foobar</button>
+ """
+ )
+ )
+
+ el = self.marionette.find_element(By.CSS_SELECTOR, "button")
+ self.mouse_chain.click(el).pause(100).click(el).perform()
+
+ event_count = self.marionette.execute_script(
+ "return window.eventCount", sandbox=None
+ )
+ self.assertEqual(event_count, 2)
+
+ def test_context_click_action(self):
+ test_html = self.marionette.absolute_url("clicks.html")
+ self.marionette.navigate(test_html)
+ click_el = self.marionette.find_element(By.ID, "normal")
+
+ def context_menu_state():
+ with self.marionette.using_context("chrome"):
+ cm_el = self.marionette.find_element(By.ID, "contentAreaContextMenu")
+ return cm_el.get_property("state")
+
+ self.assertEqual("closed", context_menu_state())
+ self.mouse_chain.click(element=click_el, button=2).perform()
+ Wait(self.marionette).until(
+ lambda _: context_menu_state() == "open",
+ message="Context menu did not open",
+ )
+ with self.marionette.using_context("chrome"):
+ self.marionette.find_element(By.ID, "main-window").send_keys(Keys.ESCAPE)
+ Wait(self.marionette).until(
+ lambda _: context_menu_state() == "closed",
+ message="Context menu did not close",
+ )
+
+ def test_middle_click_action(self):
+ test_html = self.marionette.absolute_url("clicks.html")
+ self.marionette.navigate(test_html)
+
+ self.marionette.find_element(By.ID, "addbuttonlistener").click()
+
+ el = self.marionette.find_element(By.ID, "showbutton")
+ self.mouse_chain.click(element=el, button=1).perform()
+
+ Wait(self.marionette).until(
+ lambda _: el.get_property("innerHTML") == "1",
+ message="Middle-click hasn't been fired",
+ )
+
+
+class TestNonSpecCompliantPointerOrigin(BaseMouseAction):
+ def setUp(self):
+ super(TestNonSpecCompliantPointerOrigin, self).setUp()
+
+ self.marionette.delete_session()
+ self.marionette.start_session({"moz:useNonSpecCompliantPointerOrigin": True})
+
+ def tearDown(self):
+ self.marionette.delete_session()
+ self.marionette.start_session()
+
+ super(TestNonSpecCompliantPointerOrigin, self).tearDown()
+
+ def test_click_element_smaller_than_viewport(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <div id="div" style="width: 10vw; height: 10vh; background: green;"
+ onclick="window.click_x = event.clientX; window.click_y = event.clientY" />
+ """
+ )
+ )
+ elem = self.marionette.find_element(By.ID, "div")
+ elem_center_point = self.get_element_center_point(elem)
+
+ self.mouse_chain.click(element=elem).perform()
+ click_position = Wait(self.marionette).until(
+ lambda _: self.click_position, message="No click event has been detected"
+ )
+ self.assertAlmostEqual(click_position["x"], elem_center_point["x"], delta=1)
+ self.assertAlmostEqual(click_position["y"], elem_center_point["y"], delta=1)
+
+ def test_click_element_larger_than_viewport_with_center_point_inside(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <div id="div" style="width: 150vw; height: 150vh; background: green;"
+ onclick="window.click_x = event.clientX; window.click_y = event.clientY" />
+ """
+ )
+ )
+ elem = self.marionette.find_element(By.ID, "div")
+ elem_center_point = self.get_element_center_point(elem)
+
+ self.mouse_chain.click(element=elem).perform()
+ click_position = Wait(self.marionette).until(
+ lambda _: self.click_position, message="No click event has been detected"
+ )
+ self.assertAlmostEqual(click_position["x"], elem_center_point["x"], delta=1)
+ self.assertAlmostEqual(click_position["y"], elem_center_point["y"], delta=1)
+
+ def test_click_element_larger_than_viewport_with_center_point_outside(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <div id="div" style="width: 300vw; height: 300vh; background: green;"
+ onclick="window.click_x = event.clientX; window.click_y = event.clientY" />
+ """
+ )
+ )
+ elem = self.marionette.find_element(By.ID, "div")
+
+ with self.assertRaises(errors.MoveTargetOutOfBoundsException):
+ self.mouse_chain.click(element=elem).perform()
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_navigation.py b/testing/marionette/harness/marionette_harness/tests/unit/test_navigation.py
new file mode 100644
index 0000000000..09bee9e6b3
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_navigation.py
@@ -0,0 +1,931 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import, print_function
+
+import contextlib
+import os
+
+from six.moves.urllib.parse import quote
+
+from marionette_driver import By, errors, expected, Wait
+from marionette_driver.keys import Keys
+from marionette_driver.marionette import Alert
+from marionette_harness import (
+ MarionetteTestCase,
+ run_if_manage_instance,
+ skip_if_framescript,
+ skip_unless_browser_pref,
+ WindowManagerMixin,
+)
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+
+BLACK_PIXEL = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" # noqa
+RED_PIXEL = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/TQBcNTh/AAAAAXRSTlPM0jRW/QAAAApJREFUeJxjYgAAAAYAAzY3fKgAAAAASUVORK5CYII=" # noqa
+
+
+def inline(doc):
+ return "data:text/html;charset=utf-8,%s" % quote(doc)
+
+
+def inline_image(data):
+ return "data:image/png;base64,%s" % data
+
+
+class BaseNavigationTestCase(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(BaseNavigationTestCase, self).setUp()
+
+ file_path = os.path.join(here, "data", "test.html").replace("\\", "/")
+
+ self.test_page_file_url = "file:///{}".format(file_path)
+ self.test_page_frameset = self.marionette.absolute_url("frameset.html")
+ self.test_page_insecure = self.fixtures.where_is("test.html", on="https")
+ self.test_page_not_remote = "about:robots"
+ self.test_page_push_state = self.marionette.absolute_url(
+ "navigation_pushstate.html"
+ )
+ self.test_page_remote = self.marionette.absolute_url("test.html")
+ self.test_page_slow_resource = self.marionette.absolute_url(
+ "slow_resource.html"
+ )
+
+ if self.marionette.session_capabilities["platformName"] == "mac":
+ self.mod_key = Keys.META
+ else:
+ self.mod_key = Keys.CONTROL
+
+ # Always use a blank new tab for an empty history
+ self.new_tab = self.open_tab()
+ self.marionette.switch_to_window(self.new_tab)
+ Wait(self.marionette, timeout=self.marionette.timeout.page_load).until(
+ lambda _: self.history_length == 1,
+ message="The newly opened tab doesn't have a browser history length of 1",
+ )
+
+ def tearDown(self):
+ self.marionette.timeout.reset()
+
+ self.close_all_tabs()
+
+ super(BaseNavigationTestCase, self).tearDown()
+
+ @property
+ def history_length(self):
+ return self.marionette.execute_script("return window.history.length;")
+
+ @property
+ def is_remote_tab(self):
+ with self.marionette.using_context("chrome"):
+ # TODO: DO NOT USE MOST RECENT WINDOW BUT CURRENT ONE
+ return self.marionette.execute_script(
+ """
+ Components.utils.import("resource://gre/modules/AppConstants.jsm");
+
+ let win = null;
+
+ if (AppConstants.MOZ_APP_NAME == "fennec") {
+ Components.utils.import("resource://gre/modules/Services.jsm");
+ win = Services.wm.getMostRecentWindow("navigator:browser");
+ } else {
+ Components.utils.import("resource:///modules/BrowserWindowTracker.jsm");
+ win = BrowserWindowTracker.getTopWindow();
+ }
+
+ let tabBrowser = null;
+
+ // Fennec
+ if (win.BrowserApp) {
+ tabBrowser = win.BrowserApp.selectedBrowser;
+
+ // Firefox
+ } else if (win.gBrowser) {
+ tabBrowser = win.gBrowser.selectedBrowser;
+
+ } else {
+ return null;
+ }
+
+ return tabBrowser.isRemoteBrowser;
+ """
+ )
+
+ @property
+ def ready_state(self):
+ return self.marionette.execute_script(
+ "return window.document.readyState;", sandbox=None
+ )
+
+
+class TestNavigate(BaseNavigationTestCase):
+ def test_set_location_through_execute_script(self):
+ # To avoid unexpected remoteness changes and a hang in any non-navigation
+ # command (bug 1519354) when navigating via the location bar, already
+ # pre-load a page which causes a remoteness change.
+ self.marionette.navigate(self.test_page_push_state)
+
+ self.marionette.execute_script(
+ "window.location.href = arguments[0];",
+ script_args=(self.test_page_remote,),
+ sandbox=None,
+ )
+
+ Wait(self.marionette, timeout=self.marionette.timeout.page_load).until(
+ expected.element_present(*(By.ID, "testh1")),
+ message="Target element 'testh1' has not been found",
+ )
+
+ self.assertEqual(self.test_page_remote, self.marionette.get_url())
+
+ def test_navigate_chrome_unsupported_error(self):
+ with self.marionette.using_context("chrome"):
+ self.assertRaises(
+ errors.UnsupportedOperationException,
+ self.marionette.navigate,
+ "about:blank",
+ )
+ self.assertRaises(
+ errors.UnsupportedOperationException, self.marionette.go_back
+ )
+ self.assertRaises(
+ errors.UnsupportedOperationException, self.marionette.go_forward
+ )
+ self.assertRaises(
+ errors.UnsupportedOperationException, self.marionette.refresh
+ )
+
+ def test_get_current_url_returns_top_level_browsing_context_url(self):
+ page_iframe = self.marionette.absolute_url("test_iframe.html")
+
+ self.marionette.navigate(page_iframe)
+ self.assertEqual(page_iframe, self.marionette.get_url())
+ frame = self.marionette.find_element(By.CSS_SELECTOR, "#test_iframe")
+ self.marionette.switch_to_frame(frame)
+ self.assertEqual(page_iframe, self.marionette.get_url())
+
+ def test_get_current_url(self):
+ self.marionette.navigate(self.test_page_remote)
+ self.assertEqual(self.test_page_remote, self.marionette.get_url())
+ self.marionette.navigate("about:blank")
+ self.assertEqual("about:blank", self.marionette.get_url())
+
+ def test_navigate_in_child_frame_changes_to_top(self):
+ self.marionette.navigate(self.test_page_frameset)
+ frame = self.marionette.find_element(By.NAME, "third")
+ self.marionette.switch_to_frame(frame)
+ self.assertRaises(
+ errors.NoSuchElementException,
+ self.marionette.find_element,
+ By.NAME,
+ "third",
+ )
+
+ self.marionette.navigate(self.test_page_frameset)
+ self.marionette.find_element(By.NAME, "third")
+
+ def test_invalid_url(self):
+ with self.assertRaises(errors.MarionetteException):
+ self.marionette.navigate("foo")
+ with self.assertRaises(errors.MarionetteException):
+ self.marionette.navigate("thisprotocoldoesnotexist://")
+
+ def test_find_element_state_complete(self):
+ self.marionette.navigate(self.test_page_remote)
+ self.assertEqual("complete", self.ready_state)
+ self.assertTrue(self.marionette.find_element(By.ID, "mozLink"))
+
+ def test_navigate_timeout_error_no_remoteness_change(self):
+ is_remote_before_timeout = self.is_remote_tab
+ self.marionette.timeout.page_load = 0.5
+ with self.assertRaises(errors.TimeoutException):
+ self.marionette.navigate(self.marionette.absolute_url("slow"))
+ self.assertEqual(self.is_remote_tab, is_remote_before_timeout)
+
+ def test_navigate_timeout_error_remoteness_change(self):
+ self.assertTrue(self.is_remote_tab)
+ self.marionette.navigate("about:robots")
+ self.assertFalse(self.is_remote_tab)
+
+ self.marionette.timeout.page_load = 0.5
+ with self.assertRaises(errors.TimeoutException):
+ self.marionette.navigate(self.marionette.absolute_url("slow"))
+
+ def test_navigate_to_same_image_document_twice(self):
+ self.marionette.navigate(self.fixtures.where_is("black.png"))
+ self.assertIn("black.png", self.marionette.title)
+ self.marionette.navigate(self.fixtures.where_is("black.png"))
+ self.assertIn("black.png", self.marionette.title)
+
+ def test_navigate_hash_change(self):
+ doc = inline("<p id=foo>")
+ self.marionette.navigate(doc)
+ self.marionette.execute_script("window.visited = true", sandbox=None)
+ self.marionette.navigate("{}#foo".format(doc))
+ self.assertTrue(
+ self.marionette.execute_script("return window.visited", sandbox=None)
+ )
+
+ def test_navigate_hash_argument_identical(self):
+ test_page = "{}#foo".format(inline("<p id=foo>"))
+
+ self.marionette.navigate(test_page)
+ self.marionette.find_element(By.ID, "foo")
+ self.marionette.navigate(test_page)
+ self.marionette.find_element(By.ID, "foo")
+
+ def test_navigate_hash_argument_differnt(self):
+ test_page = "{}#Foo".format(inline("<p id=foo>"))
+
+ self.marionette.navigate(test_page)
+ self.marionette.find_element(By.ID, "foo")
+ self.marionette.navigate(test_page.lower())
+ self.marionette.find_element(By.ID, "foo")
+
+ def test_navigate_history_pushstate(self):
+ target_page = self.marionette.absolute_url("navigation_pushstate_target.html")
+
+ self.marionette.navigate(self.test_page_push_state)
+ self.marionette.find_element(By.ID, "forward").click()
+
+ # By using pushState() the URL is updated but the target page is not loaded
+ # and as such the element is not displayed
+ self.assertEqual(self.marionette.get_url(), target_page)
+ with self.assertRaises(errors.NoSuchElementException):
+ self.marionette.find_element(By.ID, "target")
+
+ self.marionette.go_back()
+ self.assertEqual(self.marionette.get_url(), self.test_page_push_state)
+
+ # The target page still gets not loaded
+ self.marionette.go_forward()
+ self.assertEqual(self.marionette.get_url(), target_page)
+ with self.assertRaises(errors.NoSuchElementException):
+ self.marionette.find_element(By.ID, "target")
+
+ # Navigating to a different page, and returning to the injected
+ # page, it will be loaded.
+ self.marionette.navigate(self.test_page_remote)
+ self.assertEqual(self.marionette.get_url(), self.test_page_remote)
+
+ self.marionette.go_back()
+ self.assertEqual(self.marionette.get_url(), target_page)
+ self.marionette.find_element(By.ID, "target")
+
+ self.marionette.go_back()
+ self.assertEqual(self.marionette.get_url(), self.test_page_push_state)
+
+ def test_navigate_file_url(self):
+ self.marionette.navigate(self.test_page_file_url)
+ self.marionette.find_element(By.ID, "file-url")
+ self.marionette.navigate(self.test_page_remote)
+
+ def test_navigate_file_url_remoteness_change(self):
+ self.marionette.navigate("about:robots")
+ self.assertFalse(self.is_remote_tab)
+
+ self.marionette.navigate(self.test_page_file_url)
+ self.assertTrue(self.is_remote_tab)
+ self.marionette.find_element(By.ID, "file-url")
+
+ self.marionette.navigate("about:robots")
+ self.assertFalse(self.is_remote_tab)
+
+ def test_about_blank_for_new_docshell(self):
+ self.assertEqual(self.marionette.get_url(), "about:blank")
+
+ self.marionette.navigate("about:blank")
+
+ def test_about_newtab(self):
+ with self.marionette.using_prefs({"browser.newtabpage.enabled": True}):
+ self.marionette.navigate("about:newtab")
+
+ self.marionette.navigate(self.test_page_remote)
+ self.marionette.find_element(By.ID, "testDiv")
+
+ @run_if_manage_instance("Only runnable if Marionette manages the instance")
+ def test_focus_after_navigation(self):
+ self.marionette.restart()
+
+ self.marionette.navigate(inline("<input autofocus>"))
+ focus_el = self.marionette.find_element(By.CSS_SELECTOR, ":focus")
+ self.assertEqual(self.marionette.get_active_element(), focus_el)
+
+ def test_no_hang_when_navigating_after_closing_original_tab(self):
+ # Close the start tab
+ self.marionette.switch_to_window(self.start_tab)
+ self.marionette.close()
+
+ self.marionette.switch_to_window(self.new_tab)
+ self.marionette.navigate(self.test_page_remote)
+
+ def test_type_to_non_remote_tab(self):
+ self.marionette.navigate(self.test_page_not_remote)
+ self.assertFalse(self.is_remote_tab)
+
+ with self.marionette.using_context("chrome"):
+ urlbar = self.marionette.find_element(By.ID, "urlbar-input")
+ urlbar.send_keys(self.mod_key + "a")
+ urlbar.send_keys(self.mod_key + "x")
+ urlbar.send_keys("about:support" + Keys.ENTER)
+
+ Wait(self.marionette, timeout=self.marionette.timeout.page_load).until(
+ lambda mn: mn.get_url() == "about:support",
+ message="'about:support' hasn't been loaded",
+ )
+ self.assertFalse(self.is_remote_tab)
+
+ def test_type_to_remote_tab(self):
+ self.assertTrue(self.is_remote_tab)
+
+ with self.marionette.using_context("chrome"):
+ urlbar = self.marionette.find_element(By.ID, "urlbar-input")
+ urlbar.send_keys(self.mod_key + "a")
+ urlbar.send_keys(self.mod_key + "x")
+ urlbar.send_keys(self.test_page_remote + Keys.ENTER)
+
+ Wait(self.marionette, timeout=self.marionette.timeout.page_load).until(
+ lambda mn: mn.get_url() == self.test_page_remote,
+ message="'{}' hasn't been loaded".format(self.test_page_remote),
+ )
+ self.assertTrue(self.is_remote_tab)
+
+
+class TestBackForwardNavigation(BaseNavigationTestCase):
+ def run_bfcache_test(self, test_pages):
+ # Helper method to run simple back and forward testcases.
+
+ def check_page_status(page, expected_history_length):
+ if "alert_text" in page:
+ if page["alert_text"] is None:
+ # navigation auto-dismisses beforeunload prompt
+ with self.assertRaises(errors.NoAlertPresentException):
+ Alert(self.marionette).text
+ else:
+ self.assertEqual(Alert(self.marionette).text, page["alert_text"])
+
+ self.assertEqual(self.marionette.get_url(), page["url"])
+ self.assertEqual(self.history_length, expected_history_length)
+
+ if "is_remote" in page:
+ self.assertEqual(
+ page["is_remote"],
+ self.is_remote_tab,
+ "'{}' doesn't match expected remoteness state: {}".format(
+ page["url"], page["is_remote"]
+ ),
+ )
+
+ if "callback" in page and callable(page["callback"]):
+ page["callback"]()
+
+ for index, page in enumerate(test_pages):
+ if "error" in page:
+ with self.assertRaises(page["error"]):
+ self.marionette.navigate(page["url"])
+ else:
+ self.marionette.navigate(page["url"])
+
+ check_page_status(page, index + 1)
+
+ # Now going back in history for all test pages by backward iterating
+ # through the list (-1) and skipping the first entry at the end (-2).
+ for page in test_pages[-2::-1]:
+ if "error" in page:
+ with self.assertRaises(page["error"]):
+ self.marionette.go_back()
+ else:
+ self.marionette.go_back()
+
+ check_page_status(page, len(test_pages))
+
+ # Now going forward in history by skipping the first entry.
+ for page in test_pages[1::]:
+ if "error" in page:
+ with self.assertRaises(page["error"]):
+ self.marionette.go_forward()
+ else:
+ self.marionette.go_forward()
+
+ check_page_status(page, len(test_pages))
+
+ def test_no_history_items(self):
+ # Both methods should not raise a failure if no navigation is possible
+ self.marionette.go_back()
+ self.marionette.go_forward()
+
+ @skip_unless_browser_pref(
+ "Bug 1656208 - Always turn on session history in the parent for fission",
+ "fission.autostart",
+ lambda value: value is False,
+ )
+ def test_dismissed_beforeunload_prompt(self):
+ url_beforeunload = inline(
+ """
+ <input type="text">
+ <script>
+ window.addEventListener("beforeunload", function (event) {
+ event.preventDefault();
+ });
+ </script>
+ """
+ )
+
+ def modify_page():
+ self.marionette.find_element(By.TAG_NAME, "input").send_keys("foo")
+
+ test_pages = [
+ {"url": inline("<p>foobar</p>"), "alert_text": None},
+ {"url": url_beforeunload, "callback": modify_page},
+ {"url": inline("<p>foobar</p>"), "alert_text": None},
+ ]
+
+ self.run_bfcache_test(test_pages)
+
+ @skip_unless_browser_pref(
+ "Bug 1656208 - Always turn on session history in the parent for fission",
+ "fission.autostart",
+ lambda value: value is False,
+ )
+ def test_data_urls(self):
+ test_pages = [
+ {"url": inline("<p>foobar</p>")},
+ {"url": self.test_page_remote},
+ {"url": inline("<p>foobar</p>")},
+ ]
+ self.run_bfcache_test(test_pages)
+
+ @skip_unless_browser_pref(
+ "Bug 1656208 - Always turn on session history in the parent for fission",
+ "fission.autostart",
+ lambda value: value is False,
+ )
+ def test_same_document_hash_change(self):
+ test_pages = [
+ {"url": "{}#23".format(self.test_page_remote)},
+ {"url": self.test_page_remote},
+ {"url": "{}#42".format(self.test_page_remote)},
+ ]
+ self.run_bfcache_test(test_pages)
+
+ @skip_unless_browser_pref(
+ "Bug 1656208 - Always turn on session history in the parent for fission",
+ "fission.autostart",
+ lambda value: value is False,
+ )
+ def test_file_url(self):
+ test_pages = [
+ {"url": self.test_page_remote},
+ {"url": self.test_page_file_url},
+ {"url": self.test_page_remote},
+ ]
+ self.run_bfcache_test(test_pages)
+
+ @skip_unless_browser_pref(
+ "Bug 1656208 - Always turn on session history in the parent for fission",
+ "fission.autostart",
+ lambda value: value is False,
+ )
+ def test_frameset(self):
+ test_pages = [
+ {"url": self.marionette.absolute_url("frameset.html")},
+ {"url": self.test_page_remote},
+ {"url": self.marionette.absolute_url("frameset.html")},
+ ]
+ self.run_bfcache_test(test_pages)
+
+ @skip_unless_browser_pref(
+ "Bug 1656208 - Always turn on session history in the parent for fission",
+ "fission.autostart",
+ lambda value: value is False,
+ )
+ def test_frameset_after_navigating_in_frame(self):
+ test_element_locator = (By.ID, "email")
+
+ self.marionette.navigate(self.test_page_remote)
+ self.assertEqual(self.marionette.get_url(), self.test_page_remote)
+ self.assertEqual(self.history_length, 1)
+ page = self.marionette.absolute_url("frameset.html")
+ self.marionette.navigate(page)
+ self.assertEqual(self.marionette.get_url(), page)
+ self.assertEqual(self.history_length, 2)
+ frame = self.marionette.find_element(By.ID, "fifth")
+ self.marionette.switch_to_frame(frame)
+ link = self.marionette.find_element(By.ID, "linkId")
+ link.click()
+
+ # We cannot use get_url() to wait until the target page has been loaded,
+ # because it will return the URL of the top browsing context and doesn't
+ # wait for the page load to be complete.
+ Wait(self.marionette, timeout=self.marionette.timeout.page_load).until(
+ expected.element_present(*test_element_locator),
+ message="Target element 'email' has not been found",
+ )
+ self.assertEqual(self.history_length, 3)
+
+ # Go back to the frame the click navigated away from
+ self.marionette.go_back()
+ self.assertEqual(self.marionette.get_url(), page)
+ with self.assertRaises(errors.NoSuchElementException):
+ self.marionette.find_element(*test_element_locator)
+
+ # Go back to the non-frameset page
+ self.marionette.switch_to_parent_frame()
+ self.marionette.go_back()
+ self.assertEqual(self.marionette.get_url(), self.test_page_remote)
+
+ # Go forward to the frameset page
+ self.marionette.go_forward()
+ self.assertEqual(self.marionette.get_url(), page)
+
+ # Go forward to the frame the click navigated to
+ # TODO: See above for automatic browser context switches. Hard to do here
+ frame = self.marionette.find_element(By.ID, "fifth")
+ self.marionette.switch_to_frame(frame)
+ self.marionette.go_forward()
+ self.marionette.find_element(*test_element_locator)
+ self.assertEqual(self.marionette.get_url(), page)
+
+ @skip_unless_browser_pref(
+ "Bug 1656208 - Always turn on session history in the parent for fission",
+ "fission.autostart",
+ lambda value: value is False,
+ )
+ def test_image_to_html_to_image(self):
+ test_pages = [
+ {"url": self.marionette.absolute_url("black.png")},
+ {"url": self.test_page_remote},
+ {"url": self.marionette.absolute_url("white.png")},
+ ]
+ self.run_bfcache_test(test_pages)
+
+ @skip_unless_browser_pref(
+ "Bug 1656208 - Always turn on session history in the parent for fission",
+ "fission.autostart",
+ lambda value: value is False,
+ )
+ def test_image_to_image(self):
+ test_pages = [
+ {"url": self.marionette.absolute_url("black.png")},
+ {"url": self.marionette.absolute_url("white.png")},
+ {"url": inline_image(RED_PIXEL)},
+ {"url": inline_image(BLACK_PIXEL)},
+ {"url": self.marionette.absolute_url("black.png")},
+ ]
+ self.run_bfcache_test(test_pages)
+
+ @skip_unless_browser_pref(
+ "Bug 1656208 - Always turn on session history in the parent for fission",
+ "fission.autostart",
+ lambda value: value is False,
+ )
+ def test_remoteness_change(self):
+ test_pages = [
+ {"url": "about:robots", "is_remote": False},
+ {"url": self.test_page_remote, "is_remote": True},
+ {"url": "about:robots", "is_remote": False},
+ ]
+ self.run_bfcache_test(test_pages)
+
+ @skip_unless_browser_pref(
+ "Bug 1656208 - Always turn on session history in the parent for fission",
+ "fission.autostart",
+ lambda value: value is False,
+ )
+ def test_non_remote_about_pages(self):
+ test_pages = [
+ {"url": "about:preferences", "is_remote": False},
+ {"url": "about:robots", "is_remote": False},
+ {"url": "about:support", "is_remote": False},
+ ]
+ self.run_bfcache_test(test_pages)
+
+ @skip_unless_browser_pref(
+ "Bug 1656208 - Always turn on session history in the parent for fission",
+ "fission.autostart",
+ lambda value: value is False,
+ )
+ def test_navigate_to_requested_about_page_after_error_page(self):
+ test_pages = [
+ {"url": "about:neterror"},
+ {"url": self.test_page_remote},
+ {"url": "about:blocked"},
+ ]
+ self.run_bfcache_test(test_pages)
+
+ @skip_unless_browser_pref(
+ "Bug 1656208 - Always turn on session history in the parent for fission",
+ "fission.autostart",
+ lambda value: value is False,
+ )
+ def test_timeout_error(self):
+ urls = [
+ self.marionette.absolute_url("slow?delay=3"),
+ self.test_page_remote,
+ self.marionette.absolute_url("slow?delay=4"),
+ ]
+
+ # First, load all pages completely to get them added to the cache
+ for index, url in enumerate(urls):
+ self.marionette.navigate(url)
+ self.assertEqual(url, self.marionette.get_url())
+ self.assertEqual(self.history_length, index + 1)
+
+ self.marionette.go_back()
+ self.assertEqual(urls[1], self.marionette.get_url())
+
+ # Force triggering a timeout error
+ self.marionette.timeout.page_load = 0.5
+ with self.assertRaises(errors.TimeoutException):
+ self.marionette.go_back()
+ self.marionette.timeout.reset()
+
+ delay = Wait(self.marionette, timeout=self.marionette.timeout.page_load).until(
+ expected.element_present(By.ID, "delay"),
+ message="Target element 'delay' has not been found after timeout in 'back'",
+ )
+ self.assertEqual(delay.text, "3")
+
+ self.marionette.go_forward()
+ self.assertEqual(urls[1], self.marionette.get_url())
+
+ # Force triggering a timeout error
+ self.marionette.timeout.page_load = 0.5
+ with self.assertRaises(errors.TimeoutException):
+ self.marionette.go_forward()
+ self.marionette.timeout.reset()
+
+ delay = Wait(self.marionette, timeout=self.marionette.timeout.page_load).until(
+ expected.element_present(By.ID, "delay"),
+ message="Target element 'delay' has not been found after timeout in 'forward'",
+ )
+ self.assertEqual(delay.text, "4")
+
+ @skip_unless_browser_pref(
+ "Bug 1656208 - Always turn on session history in the parent for fission",
+ "fission.autostart",
+ lambda value: value is False,
+ )
+ def test_certificate_error(self):
+ test_pages = [
+ {
+ "url": self.test_page_insecure,
+ "error": errors.InsecureCertificateException,
+ },
+ {"url": self.test_page_remote},
+ {
+ "url": self.test_page_insecure,
+ "error": errors.InsecureCertificateException,
+ },
+ ]
+ self.run_bfcache_test(test_pages)
+
+
+class TestRefresh(BaseNavigationTestCase):
+ def test_basic(self):
+ self.marionette.navigate(self.test_page_remote)
+ self.assertEqual(self.test_page_remote, self.marionette.get_url())
+
+ self.marionette.execute_script(
+ """
+ let elem = window.document.createElement('div');
+ elem.id = 'someDiv';
+ window.document.body.appendChild(elem);
+ """
+ )
+ self.marionette.find_element(By.ID, "someDiv")
+
+ self.marionette.refresh()
+ self.assertEqual(self.test_page_remote, self.marionette.get_url())
+ with self.assertRaises(errors.NoSuchElementException):
+ self.marionette.find_element(By.ID, "someDiv")
+
+ def test_refresh_in_child_frame_navigates_to_top(self):
+ self.marionette.navigate(self.test_page_frameset)
+ self.assertEqual(self.test_page_frameset, self.marionette.get_url())
+
+ frame = self.marionette.find_element(By.NAME, "third")
+ self.marionette.switch_to_frame(frame)
+ self.assertRaises(
+ errors.NoSuchElementException,
+ self.marionette.find_element,
+ By.NAME,
+ "third",
+ )
+
+ self.marionette.refresh()
+ self.marionette.find_element(By.NAME, "third")
+
+ def test_file_url(self):
+ self.marionette.navigate(self.test_page_file_url)
+ self.assertEqual(self.test_page_file_url, self.marionette.get_url())
+
+ self.marionette.refresh()
+ self.assertEqual(self.test_page_file_url, self.marionette.get_url())
+
+ def test_dismissed_beforeunload_prompt(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <input type="text">
+ <script>
+ window.addEventListener("beforeunload", function (event) {
+ event.preventDefault();
+ });
+ </script>
+ """
+ )
+ )
+ self.marionette.find_element(By.TAG_NAME, "input").send_keys("foo")
+ self.marionette.refresh()
+
+ # navigation auto-dismisses beforeunload prompt
+ with self.assertRaises(errors.NoAlertPresentException):
+ Alert(self.marionette).text
+
+ def test_image(self):
+ image = self.marionette.absolute_url("black.png")
+
+ self.marionette.navigate(image)
+ self.assertEqual(image, self.marionette.get_url())
+
+ self.marionette.refresh()
+ self.assertEqual(image, self.marionette.get_url())
+
+ def test_history_pushstate(self):
+ target_page = self.marionette.absolute_url("navigation_pushstate_target.html")
+
+ self.marionette.navigate(self.test_page_push_state)
+ self.marionette.find_element(By.ID, "forward").click()
+
+ # By using pushState() the URL is updated but the target page is not loaded
+ # and as such the element is not displayed
+ self.assertEqual(self.marionette.get_url(), target_page)
+ with self.assertRaises(errors.NoSuchElementException):
+ self.marionette.find_element(By.ID, "target")
+
+ # Refreshing the target page will trigger a full page load.
+ self.marionette.refresh()
+ self.assertEqual(self.marionette.get_url(), target_page)
+ self.marionette.find_element(By.ID, "target")
+
+ self.marionette.go_back()
+ self.assertEqual(self.marionette.get_url(), self.test_page_push_state)
+
+ def test_timeout_error(self):
+ slow_page = self.marionette.absolute_url("slow?delay=3")
+
+ self.marionette.navigate(slow_page)
+ self.assertEqual(slow_page, self.marionette.get_url())
+
+ self.marionette.timeout.page_load = 0.5
+ with self.assertRaises(errors.TimeoutException):
+ self.marionette.refresh()
+ self.assertEqual(slow_page, self.marionette.get_url())
+
+ def test_insecure_error(self):
+ with self.assertRaises(errors.InsecureCertificateException):
+ self.marionette.navigate(self.test_page_insecure)
+ self.assertEqual(self.marionette.get_url(), self.test_page_insecure)
+
+ with self.assertRaises(errors.InsecureCertificateException):
+ self.marionette.refresh()
+
+
+class TestTLSNavigation(BaseNavigationTestCase):
+ insecure_tls = {"acceptInsecureCerts": True}
+ secure_tls = {"acceptInsecureCerts": False}
+
+ def setUp(self):
+ super(TestTLSNavigation, self).setUp()
+
+ self.test_page_insecure = self.fixtures.where_is("test.html", on="https")
+
+ self.marionette.delete_session()
+ self.capabilities = self.marionette.start_session(self.insecure_tls)
+
+ def tearDown(self):
+ try:
+ self.marionette.delete_session()
+ self.marionette.start_session()
+ except:
+ pass
+
+ super(TestTLSNavigation, self).tearDown()
+
+ @contextlib.contextmanager
+ def safe_session(self):
+ try:
+ self.capabilities = self.marionette.start_session(self.secure_tls)
+ self.assertFalse(self.capabilities["acceptInsecureCerts"])
+ # Always use a blank new tab for an empty history
+ self.new_tab = self.open_tab()
+ self.marionette.switch_to_window(self.new_tab)
+ Wait(self.marionette, timeout=self.marionette.timeout.page_load).until(
+ lambda _: self.history_length == 1,
+ message="The newly opened tab doesn't have a browser history length of 1",
+ )
+ yield self.marionette
+ finally:
+ self.close_all_tabs()
+ self.marionette.delete_session()
+
+ @contextlib.contextmanager
+ def unsafe_session(self):
+ try:
+ self.capabilities = self.marionette.start_session(self.insecure_tls)
+ self.assertTrue(self.capabilities["acceptInsecureCerts"])
+ # Always use a blank new tab for an empty history
+ self.new_tab = self.open_tab()
+ self.marionette.switch_to_window(self.new_tab)
+ Wait(self.marionette, timeout=self.marionette.timeout.page_load).until(
+ lambda _: self.history_length == 1,
+ message="The newly opened tab doesn't have a browser history length of 1",
+ )
+ yield self.marionette
+ finally:
+ self.close_all_tabs()
+ self.marionette.delete_session()
+
+ def test_navigate_by_command(self):
+ self.marionette.navigate(self.test_page_insecure)
+ self.assertIn("https", self.marionette.get_url())
+
+ def test_navigate_by_click(self):
+ link_url = self.test_page_insecure
+ self.marionette.navigate(
+ inline("<a href=%s>https is the future</a>" % link_url)
+ )
+ self.marionette.find_element(By.TAG_NAME, "a").click()
+ self.assertIn("https", self.marionette.get_url())
+
+ def test_deactivation(self):
+ invalid_cert_url = self.test_page_insecure
+
+ print("with safe session")
+ with self.safe_session() as session:
+ with self.assertRaises(errors.InsecureCertificateException):
+ session.navigate(invalid_cert_url)
+
+ print("with unsafe session")
+ with self.unsafe_session() as session:
+ session.navigate(invalid_cert_url)
+
+ print("with safe session again")
+ with self.safe_session() as session:
+ with self.assertRaises(errors.InsecureCertificateException):
+ session.navigate(invalid_cert_url)
+
+
+class TestPageLoadStrategy(BaseNavigationTestCase):
+ def tearDown(self):
+ self.marionette.delete_session()
+ self.marionette.start_session()
+
+ super(TestPageLoadStrategy, self).tearDown()
+
+ @skip_if_framescript("Bug 1675173: Won't be fixed for framescript mode")
+ def test_none(self):
+ self.marionette.delete_session()
+ self.marionette.start_session({"pageLoadStrategy": "none"})
+
+ self.marionette.navigate(self.test_page_slow_resource)
+ Wait(self.marionette, timeout=self.marionette.timeout.page_load).until(
+ lambda _: self.marionette.get_url() == self.test_page_slow_resource,
+ message="Target page has not been loaded",
+ )
+ self.marionette.find_element(By.ID, "slow")
+
+ def test_eager(self):
+ self.marionette.delete_session()
+ self.marionette.start_session({"pageLoadStrategy": "eager"})
+
+ self.marionette.navigate(self.test_page_slow_resource)
+ self.assertEqual("interactive", self.ready_state)
+ self.assertEqual(self.test_page_slow_resource, self.marionette.get_url())
+ self.marionette.find_element(By.ID, "slow")
+
+ def test_normal(self):
+ self.marionette.delete_session()
+ self.marionette.start_session({"pageLoadStrategy": "normal"})
+
+ self.marionette.navigate(self.test_page_slow_resource)
+ self.assertEqual(self.test_page_slow_resource, self.marionette.get_url())
+ self.assertEqual("complete", self.ready_state)
+ self.marionette.find_element(By.ID, "slow")
+
+ def test_strategy_after_remoteness_change(self):
+ """Bug 1378191 - Reset of capabilities after listener reload."""
+ self.marionette.delete_session()
+ self.marionette.start_session({"pageLoadStrategy": "eager"})
+
+ # Trigger a remoteness change which will reload the listener script
+ self.assertTrue(
+ self.is_remote_tab, "Initial tab doesn't have remoteness flag set"
+ )
+ self.marionette.navigate("about:robots")
+ self.assertFalse(self.is_remote_tab, "Tab has remoteness flag set")
+ self.marionette.navigate(self.test_page_slow_resource)
+ self.assertEqual("interactive", self.ready_state)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_pagesource.py b/testing/marionette/harness/marionette_harness/tests/unit/test_pagesource.py
new file mode 100644
index 0000000000..352d4c4fea
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_pagesource.py
@@ -0,0 +1,54 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from six.moves.urllib.parse import quote
+
+from marionette_harness import MarionetteTestCase
+
+
+def inline(doc, mime=None, charset=None):
+ mime = "html" if mime is None else mime
+ charset = "utf-8" if (charset is None) else charset
+ return "data:text/{};charset={},{}".format(mime, charset, quote(doc))
+
+
+class TestPageSource(MarionetteTestCase):
+ def testShouldReturnTheSourceOfAPage(self):
+ test_html = inline("<body><p> Check the PageSource</body>")
+ self.marionette.navigate(test_html)
+ source = self.marionette.page_source
+ from_web_api = self.marionette.execute_script(
+ "return document.documentElement.outerHTML"
+ )
+ self.assertTrue("<html" in source)
+ self.assertTrue("PageSource" in source)
+ self.assertEqual(source, from_web_api)
+
+ def testShouldReturnTheSourceOfAPageWhenThereAreUnicodeChars(self):
+ test_html = inline(
+ '<head><meta http-equiv="pragma" content="no-cache"/></head><body><!-- the \u00ab section[id^="wifi-"] \u00bb selector.--></body>'
+ )
+ self.marionette.navigate(test_html)
+ # if we don't throw on the next line we are good!
+ source = self.marionette.page_source
+ from_web_api = self.marionette.execute_script(
+ "return document.documentElement.outerHTML"
+ )
+ self.assertEqual(source, from_web_api)
+
+ def testShouldReturnAXMLDocumentSource(self):
+ test_xml = inline("<xml><foo><bar>baz</bar></foo></xml>", "xml")
+ self.marionette.navigate(test_xml)
+ source = self.marionette.page_source
+ from_web_api = self.marionette.execute_script(
+ "return document.documentElement.outerHTML"
+ )
+ import re
+
+ self.assertEqual(
+ re.sub("\s", "", source), "<xml><foo><bar>baz</bar></foo></xml>"
+ )
+ self.assertEqual(source, from_web_api)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_pagesource_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_pagesource_chrome.py
new file mode 100644
index 0000000000..a8a8af14af
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_pagesource_chrome.py
@@ -0,0 +1,26 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_harness import MarionetteTestCase, WindowManagerMixin
+
+
+class TestPageSourceChrome(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestPageSourceChrome, self).setUp()
+ self.marionette.set_context("chrome")
+
+ new_window = self.open_chrome_window("chrome://marionette/content/test.xhtml")
+ self.marionette.switch_to_window(new_window)
+
+ def tearDown(self):
+ self.close_all_windows()
+ super(TestPageSourceChrome, self).tearDown()
+
+ def testShouldReturnXULDetails(self):
+ source = self.marionette.page_source
+ self.assertTrue(
+ '<input xmlns="http://www.w3.org/1999/xhtml" id="textInput"' in source
+ )
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_position.py b/testing/marionette/harness/marionette_harness/tests/unit/test_position.py
new file mode 100644
index 0000000000..6be86d0381
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_position.py
@@ -0,0 +1,48 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from six.moves.urllib.parse import quote
+
+from marionette_driver.by import By
+
+from marionette_harness import MarionetteTestCase
+
+
+def inline(doc):
+ return "data:text/html;charset=utf-8,{}".format(quote(doc))
+
+
+class TestPosition(MarionetteTestCase):
+ def test_should_get_element_position_back(self):
+ doc = """
+ <head>
+ <title>Rectangles</title>
+ <style>
+ div {
+ position: absolute;
+ margin: 0;
+ border: 0;
+ padding: 0;
+ }
+ #r {
+ background-color: red;
+ left: 11px;
+ top: 10px;
+ width: 48.666666667px;
+ height: 49.333333333px;
+ }
+ </style>
+ </head>
+ <body>
+ <div id="r">r</div>
+ </body>
+ """
+ self.marionette.navigate(inline(doc))
+
+ r2 = self.marionette.find_element(By.ID, "r")
+ location = r2.rect
+ self.assertEqual(11, location["x"])
+ self.assertEqual(10, location["y"])
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_prefs.py b/testing/marionette/harness/marionette_harness/tests/unit/test_prefs.py
new file mode 100644
index 0000000000..b4d7ac3963
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_prefs.py
@@ -0,0 +1,219 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import six
+
+from marionette_driver import geckoinstance
+from marionette_driver.errors import JavascriptException
+
+from marionette_harness import (
+ MarionetteTestCase,
+ run_if_manage_instance,
+)
+
+
+class TestPreferences(MarionetteTestCase):
+ prefs = {
+ "bool": "marionette.test.bool",
+ "int": "marionette.test.int",
+ "string": "marionette.test.string",
+ }
+
+ def tearDown(self):
+ for pref in self.prefs.values():
+ self.marionette.clear_pref(pref)
+
+ super(TestPreferences, self).tearDown()
+
+ def test_gecko_instance_preferences(self):
+ required_prefs = geckoinstance.GeckoInstance.required_prefs
+
+ for key, value in six.iteritems(required_prefs):
+ self.assertEqual(
+ self.marionette.get_pref(key),
+ value,
+ "Preference {} hasn't been set to {}".format(key, repr(value)),
+ )
+
+ def test_desktop_instance_preferences(self):
+ required_prefs = geckoinstance.DesktopInstance.desktop_prefs
+
+ for key, value in six.iteritems(required_prefs):
+ if key in ["browser.tabs.remote.autostart"]:
+ return
+
+ self.assertEqual(
+ self.marionette.get_pref(key),
+ value,
+ "Preference {} hasn't been set to {}".format(key, value),
+ )
+
+ def test_clear_pref(self):
+ self.assertIsNone(self.marionette.get_pref(self.prefs["bool"]))
+
+ self.marionette.set_pref(self.prefs["bool"], True)
+ self.assertTrue(self.marionette.get_pref(self.prefs["bool"]))
+
+ self.marionette.clear_pref(self.prefs["bool"])
+ self.assertIsNone(self.marionette.get_pref(self.prefs["bool"]))
+
+ def test_get_and_set_pref(self):
+ # By default none of the preferences are set
+ self.assertIsNone(self.marionette.get_pref(self.prefs["bool"]))
+ self.assertIsNone(self.marionette.get_pref(self.prefs["int"]))
+ self.assertIsNone(self.marionette.get_pref(self.prefs["string"]))
+
+ # Test boolean values
+ self.marionette.set_pref(self.prefs["bool"], True)
+ value = self.marionette.get_pref(self.prefs["bool"])
+ self.assertTrue(value)
+ self.assertEqual(type(value), bool)
+
+ # Test int values
+ self.marionette.set_pref(self.prefs["int"], 42)
+ value = self.marionette.get_pref(self.prefs["int"])
+ self.assertEqual(value, 42)
+ self.assertEqual(type(value), int)
+
+ # Test string values
+ self.marionette.set_pref(self.prefs["string"], "abc")
+ value = self.marionette.get_pref(self.prefs["string"])
+ self.assertEqual(value, "abc")
+ self.assertTrue(isinstance(value, six.string_types))
+
+ # Test reset value
+ self.marionette.set_pref(self.prefs["string"], None)
+ self.assertIsNone(self.marionette.get_pref(self.prefs["string"]))
+
+ def test_get_set_pref_default_branch(self):
+ pref_default = "marionette.test.pref_default1"
+ self.assertIsNone(self.marionette.get_pref(self.prefs["string"]))
+
+ self.marionette.set_pref(pref_default, "default_value", default_branch=True)
+ self.assertEqual(self.marionette.get_pref(pref_default), "default_value")
+ self.assertEqual(
+ self.marionette.get_pref(pref_default, default_branch=True), "default_value"
+ )
+
+ self.marionette.set_pref(pref_default, "user_value")
+ self.assertEqual(self.marionette.get_pref(pref_default), "user_value")
+ self.assertEqual(
+ self.marionette.get_pref(pref_default, default_branch=True), "default_value"
+ )
+
+ self.marionette.clear_pref(pref_default)
+ self.assertEqual(self.marionette.get_pref(pref_default), "default_value")
+
+ def test_get_pref_value_type(self):
+ # Without a given value type the properties URL will be returned only
+ pref_complex = "browser.menu.showCharacterEncoding"
+ properties_file = "chrome://browser/locale/browser.properties"
+ self.assertEqual(
+ self.marionette.get_pref(pref_complex, default_branch=True), properties_file
+ )
+
+ # Otherwise the property named like the pref will be translated
+ value = self.marionette.get_pref(
+ pref_complex, default_branch=True, value_type="nsIPrefLocalizedString"
+ )
+ self.assertNotEqual(value, properties_file)
+
+ def test_set_prefs(self):
+ # By default none of the preferences are set
+ self.assertIsNone(self.marionette.get_pref(self.prefs["bool"]))
+ self.assertIsNone(self.marionette.get_pref(self.prefs["int"]))
+ self.assertIsNone(self.marionette.get_pref(self.prefs["string"]))
+
+ # Set a value on the default branch first
+ pref_default = "marionette.test.pref_default2"
+ self.assertIsNone(self.marionette.get_pref(pref_default))
+ self.marionette.set_prefs({pref_default: "default_value"}, default_branch=True)
+
+ # Set user values
+ prefs = {
+ self.prefs["bool"]: True,
+ self.prefs["int"]: 42,
+ self.prefs["string"]: "abc",
+ pref_default: "user_value",
+ }
+ self.marionette.set_prefs(prefs)
+
+ self.assertTrue(self.marionette.get_pref(self.prefs["bool"]))
+ self.assertEqual(self.marionette.get_pref(self.prefs["int"]), 42)
+ self.assertEqual(self.marionette.get_pref(self.prefs["string"]), "abc")
+ self.assertEqual(self.marionette.get_pref(pref_default), "user_value")
+ self.assertEqual(
+ self.marionette.get_pref(pref_default, default_branch=True), "default_value"
+ )
+
+ def test_using_prefs(self):
+ # Test that multiple preferences can be set with "using_prefs", and that
+ # they are set correctly and unset correctly after leaving the context
+ # manager.
+ pref_not_existent = "marionette.test.not_existent1"
+ pref_default = "marionette.test.pref_default3"
+
+ self.marionette.set_prefs(
+ {
+ self.prefs["string"]: "abc",
+ self.prefs["int"]: 42,
+ self.prefs["bool"]: False,
+ }
+ )
+ self.assertFalse(self.marionette.get_pref(self.prefs["bool"]))
+ self.assertEqual(self.marionette.get_pref(self.prefs["int"]), 42)
+ self.assertEqual(self.marionette.get_pref(self.prefs["string"]), "abc")
+ self.assertIsNone(self.marionette.get_pref(pref_not_existent))
+
+ with self.marionette.using_prefs(
+ {
+ self.prefs["bool"]: True,
+ self.prefs["int"]: 24,
+ self.prefs["string"]: "def",
+ pref_not_existent: "existent",
+ }
+ ):
+
+ self.assertTrue(self.marionette.get_pref(self.prefs["bool"]), True)
+ self.assertEquals(self.marionette.get_pref(self.prefs["int"]), 24)
+ self.assertEquals(self.marionette.get_pref(self.prefs["string"]), "def")
+ self.assertEquals(self.marionette.get_pref(pref_not_existent), "existent")
+
+ self.assertFalse(self.marionette.get_pref(self.prefs["bool"]))
+ self.assertEqual(self.marionette.get_pref(self.prefs["int"]), 42)
+ self.assertEqual(self.marionette.get_pref(self.prefs["string"]), "abc")
+ self.assertIsNone(self.marionette.get_pref(pref_not_existent))
+
+ # Using context with default branch
+ self.marionette.set_pref(pref_default, "default_value", default_branch=True)
+ self.assertEqual(
+ self.marionette.get_pref(pref_default, default_branch=True), "default_value"
+ )
+
+ with self.marionette.using_prefs(
+ {pref_default: "new_value"}, default_branch=True
+ ):
+ self.assertEqual(
+ self.marionette.get_pref(pref_default, default_branch=True), "new_value"
+ )
+
+ self.assertEqual(
+ self.marionette.get_pref(pref_default, default_branch=True), "default_value"
+ )
+
+ def test_using_prefs_exception(self):
+ # Test that throwing an exception inside the context manager doesn"t
+ # prevent the preferences from being restored at context manager exit.
+ self.marionette.set_pref(self.prefs["string"], "abc")
+
+ try:
+ with self.marionette.using_prefs({self.prefs["string"]: "def"}):
+ self.assertEquals(self.marionette.get_pref(self.prefs["string"]), "def")
+ self.marionette.execute_script("return foo.bar.baz;")
+ except JavascriptException:
+ pass
+
+ self.assertEquals(self.marionette.get_pref(self.prefs["string"]), "abc")
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_prefs_enforce.py b/testing/marionette/harness/marionette_harness/tests/unit/test_prefs_enforce.py
new file mode 100644
index 0000000000..6673e27d73
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_prefs_enforce.py
@@ -0,0 +1,45 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import six
+
+from marionette_harness import MarionetteTestCase
+
+
+class TestEnforcePreferences(MarionetteTestCase):
+ def setUp(self):
+ super(TestEnforcePreferences, self).setUp()
+
+ self.marionette.enforce_gecko_prefs(
+ {
+ "marionette.test.bool": True,
+ "marionette.test.int": 3,
+ "marionette.test.string": "testing",
+ }
+ )
+ self.marionette.set_context("chrome")
+
+ def tearDown(self):
+ self.marionette.quit(clean=True)
+
+ super(TestEnforcePreferences, self).tearDown()
+
+ def test_preferences_are_set(self):
+ self.assertTrue(self.marionette.get_pref("marionette.test.bool"))
+ self.assertEqual(self.marionette.get_pref("marionette.test.string"), "testing")
+ self.assertEqual(self.marionette.get_pref("marionette.test.int"), 3)
+
+ def test_change_preference(self):
+ self.assertTrue(self.marionette.get_pref("marionette.test.bool"))
+
+ self.marionette.enforce_gecko_prefs({"marionette.test.bool": False})
+
+ self.assertFalse(self.marionette.get_pref("marionette.test.bool"))
+
+ def test_restart_with_clean_profile(self):
+ self.marionette.restart(clean=True)
+
+ self.assertEqual(self.marionette.get_pref("marionette.test.bool"), None)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_profile_management.py b/testing/marionette/harness/marionette_harness/tests/unit/test_profile_management.py
new file mode 100644
index 0000000000..7bb93d8dc4
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_profile_management.py
@@ -0,0 +1,253 @@
+# coding=UTF-8
+
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import os
+import shutil
+import tempfile
+
+import mozprofile
+
+from marionette_driver import errors
+from marionette_harness import MarionetteTestCase
+
+
+class BaseProfileManagement(MarionetteTestCase):
+ def setUp(self):
+ super(BaseProfileManagement, self).setUp()
+
+ self.orig_profile_path = self.profile_path
+
+ def tearDown(self):
+ shutil.rmtree(self.orig_profile_path, ignore_errors=True)
+
+ self.marionette.profile = None
+
+ super(BaseProfileManagement, self).tearDown()
+
+ @property
+ def profile(self):
+ return self.marionette.instance.profile
+
+ @property
+ def profile_path(self):
+ return self.marionette.instance.profile.profile
+
+
+class WorkspaceProfileManagement(BaseProfileManagement):
+ def setUp(self):
+ super(WorkspaceProfileManagement, self).setUp()
+
+ # Set a new workspace for the instance, which will be used
+ # the next time a new profile is requested by a test.
+ self.workspace = tempfile.mkdtemp()
+ self.marionette.instance.workspace = self.workspace
+
+ def tearDown(self):
+ self.marionette.instance.workspace = None
+
+ shutil.rmtree(self.workspace, ignore_errors=True)
+
+ super(WorkspaceProfileManagement, self).tearDown()
+
+
+class ExternalProfileMixin(object):
+ def setUp(self):
+ super(ExternalProfileMixin, self).setUp()
+
+ # Create external profile
+ tmp_dir = tempfile.mkdtemp(suffix="external")
+ shutil.rmtree(tmp_dir, ignore_errors=True)
+
+ self.external_profile = mozprofile.Profile(profile=tmp_dir)
+ # Prevent profile from being removed during cleanup
+ self.external_profile.create_new = False
+
+ def tearDown(self):
+ shutil.rmtree(self.external_profile.profile, ignore_errors=True)
+
+ super(ExternalProfileMixin, self).tearDown()
+
+
+class TestQuitRestartWithoutWorkspace(BaseProfileManagement):
+ def test_quit_keeps_same_profile(self):
+ self.marionette.quit()
+ self.marionette.start_session()
+
+ self.assertEqual(self.profile_path, self.orig_profile_path)
+ self.assertTrue(os.path.exists(self.orig_profile_path))
+
+ def test_quit_clean_creates_new_profile(self):
+ self.marionette.quit(clean=True)
+ self.marionette.start_session()
+
+ self.assertNotEqual(self.profile_path, self.orig_profile_path)
+ self.assertFalse(os.path.exists(self.orig_profile_path))
+
+ def test_restart_keeps_same_profile(self):
+ self.marionette.restart()
+
+ self.assertEqual(self.profile_path, self.orig_profile_path)
+ self.assertTrue(os.path.exists(self.orig_profile_path))
+
+ def test_restart_clean_creates_new_profile(self):
+ self.marionette.restart(clean=True)
+
+ self.assertNotEqual(self.profile_path, self.orig_profile_path)
+ self.assertFalse(os.path.exists(self.orig_profile_path))
+
+
+class TestQuitRestartWithWorkspace(WorkspaceProfileManagement):
+ def test_quit_keeps_same_profile(self):
+ self.marionette.quit()
+ self.marionette.start_session()
+
+ self.assertEqual(self.profile_path, self.orig_profile_path)
+ self.assertNotIn(self.workspace, self.profile_path)
+ self.assertTrue(os.path.exists(self.orig_profile_path))
+
+ def test_quit_clean_creates_new_profile(self):
+ self.marionette.quit(clean=True)
+ self.marionette.start_session()
+
+ self.assertNotEqual(self.profile_path, self.orig_profile_path)
+ self.assertIn(self.workspace, self.profile_path)
+ self.assertFalse(os.path.exists(self.orig_profile_path))
+
+ def test_restart_keeps_same_profile(self):
+ self.marionette.restart()
+
+ self.assertEqual(self.profile_path, self.orig_profile_path)
+ self.assertNotIn(self.workspace, self.profile_path)
+ self.assertTrue(os.path.exists(self.orig_profile_path))
+
+ def test_restart_clean_creates_new_profile(self):
+ self.marionette.restart(clean=True)
+
+ self.assertNotEqual(self.profile_path, self.orig_profile_path)
+ self.assertIn(self.workspace, self.profile_path)
+ self.assertFalse(os.path.exists(self.orig_profile_path))
+
+
+class TestSwitchProfileFailures(BaseProfileManagement):
+ def test_raise_for_switching_profile_while_instance_is_running(self):
+ with self.assertRaisesRegexp(
+ errors.MarionetteException, "instance is not running"
+ ):
+ self.marionette.instance.switch_profile()
+
+
+class TestSwitchProfileWithoutWorkspace(ExternalProfileMixin, BaseProfileManagement):
+ def setUp(self):
+ super(TestSwitchProfileWithoutWorkspace, self).setUp()
+
+ self.marionette.quit()
+
+ def test_do_not_call_cleanup_of_profile_for_path_only(self):
+ # If a path to a profile has been given (eg. via the --profile command
+ # line argument) and the profile hasn't been created yet, switching the
+ # profile should not try to call `cleanup()` on a string.
+ self.marionette.instance._profile = self.external_profile.profile
+ self.marionette.instance.switch_profile()
+
+ def test_new_random_profile_name(self):
+ self.marionette.instance.switch_profile()
+ self.marionette.start_session()
+
+ self.assertNotEqual(self.profile_path, self.orig_profile_path)
+ self.assertFalse(os.path.exists(self.orig_profile_path))
+
+ def test_new_named_profile(self):
+ self.marionette.instance.switch_profile("foobar")
+ self.marionette.start_session()
+
+ self.assertNotEqual(self.profile_path, self.orig_profile_path)
+ self.assertIn("foobar", self.profile_path)
+ self.assertFalse(os.path.exists(self.orig_profile_path))
+
+ def test_new_named_profile_unicode(self):
+ """Test using unicode string with 1-4 bytes encoding works."""
+ self.marionette.instance.switch_profile(u"$¢€🍪")
+ self.marionette.start_session()
+
+ self.assertNotEqual(self.profile_path, self.orig_profile_path)
+ self.assertIn(u"$¢€🍪", self.profile_path)
+ self.assertFalse(os.path.exists(self.orig_profile_path))
+
+ def test_new_named_profile_unicode_escape_characters(self):
+ """Test using escaped unicode string with 1-4 bytes encoding works."""
+ self.marionette.instance.switch_profile(u"\u0024\u00A2\u20AC\u1F36A")
+ self.marionette.start_session()
+
+ self.assertNotEqual(self.profile_path, self.orig_profile_path)
+ self.assertIn(u"\u0024\u00A2\u20AC\u1F36A", self.profile_path)
+ self.assertFalse(os.path.exists(self.orig_profile_path))
+
+ def test_clone_existing_profile(self):
+ self.marionette.instance.switch_profile(clone_from=self.external_profile)
+ self.marionette.start_session()
+
+ self.assertIn(
+ os.path.basename(self.external_profile.profile), self.profile_path
+ )
+ self.assertTrue(os.path.exists(self.external_profile.profile))
+
+ def test_replace_with_current_profile(self):
+ self.marionette.instance.profile = self.profile
+ self.marionette.start_session()
+
+ self.assertEqual(self.profile_path, self.orig_profile_path)
+ self.assertTrue(os.path.exists(self.orig_profile_path))
+
+ def test_replace_with_external_profile(self):
+ self.marionette.instance.profile = self.external_profile
+ self.marionette.start_session()
+
+ self.assertEqual(self.profile_path, self.external_profile.profile)
+ self.assertFalse(os.path.exists(self.orig_profile_path))
+
+ # Set a new profile and ensure the external profile has not been deleted
+ self.marionette.quit()
+ self.marionette.instance.profile = None
+
+ self.assertNotEqual(self.profile_path, self.external_profile.profile)
+ self.assertTrue(os.path.exists(self.external_profile.profile))
+
+
+class TestSwitchProfileWithWorkspace(ExternalProfileMixin, WorkspaceProfileManagement):
+ def setUp(self):
+ super(TestSwitchProfileWithWorkspace, self).setUp()
+
+ self.marionette.quit()
+
+ def test_new_random_profile_name(self):
+ self.marionette.instance.switch_profile()
+ self.marionette.start_session()
+
+ self.assertNotEqual(self.profile_path, self.orig_profile_path)
+ self.assertIn(self.workspace, self.profile_path)
+ self.assertFalse(os.path.exists(self.orig_profile_path))
+
+ def test_new_named_profile(self):
+ self.marionette.instance.switch_profile("foobar")
+ self.marionette.start_session()
+
+ self.assertNotEqual(self.profile_path, self.orig_profile_path)
+ self.assertIn("foobar", self.profile_path)
+ self.assertIn(self.workspace, self.profile_path)
+ self.assertFalse(os.path.exists(self.orig_profile_path))
+
+ def test_clone_existing_profile(self):
+ self.marionette.instance.switch_profile(clone_from=self.external_profile)
+ self.marionette.start_session()
+
+ self.assertNotEqual(self.profile_path, self.orig_profile_path)
+ self.assertIn(self.workspace, self.profile_path)
+ self.assertIn(
+ os.path.basename(self.external_profile.profile), self.profile_path
+ )
+ self.assertTrue(os.path.exists(self.external_profile.profile))
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_proxy.py b/testing/marionette/harness/marionette_harness/tests/unit/test_proxy.py
new file mode 100644
index 0000000000..3ba3a37d5d
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_proxy.py
@@ -0,0 +1,164 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver import errors
+
+from marionette_harness import MarionetteTestCase
+
+
+class TestProxyCapabilities(MarionetteTestCase):
+ def setUp(self):
+ super(TestProxyCapabilities, self).setUp()
+
+ self.marionette.delete_session()
+
+ def tearDown(self):
+ if not self.marionette.session:
+ self.marionette.start_session()
+
+ with self.marionette.using_context("chrome"):
+ self.marionette.execute_script(
+ """
+ Cu.import("resource://gre/modules/Preferences.jsm");
+ Preferences.resetBranch("network.proxy");
+ """
+ )
+
+ super(TestProxyCapabilities, self).tearDown()
+
+ def test_proxy_object_none_by_default(self):
+ self.marionette.start_session()
+ self.assertNotIn("proxy", self.marionette.session_capabilities)
+
+ def test_proxy_object_in_returned_capabilities(self):
+ capabilities = {"proxy": {"proxyType": "system"}}
+
+ self.marionette.start_session(capabilities)
+ self.assertEqual(
+ self.marionette.session_capabilities["proxy"], capabilities["proxy"]
+ )
+
+ def test_proxy_type_autodetect(self):
+ capabilities = {"proxy": {"proxyType": "autodetect"}}
+
+ self.marionette.start_session(capabilities)
+ self.assertEqual(
+ self.marionette.session_capabilities["proxy"], capabilities["proxy"]
+ )
+
+ def test_proxy_type_direct(self):
+ capabilities = {"proxy": {"proxyType": "direct"}}
+
+ self.marionette.start_session(capabilities)
+ self.assertEqual(
+ self.marionette.session_capabilities["proxy"], capabilities["proxy"]
+ )
+
+ def test_proxy_type_manual_without_port(self):
+ proxy_hostname = "marionette.test"
+ capabilities = {
+ "proxy": {
+ "proxyType": "manual",
+ "ftpProxy": "{}:21".format(proxy_hostname),
+ "httpProxy": "{}:80".format(proxy_hostname),
+ "sslProxy": "{}:443".format(proxy_hostname),
+ "socksProxy": proxy_hostname,
+ "socksVersion": 4,
+ }
+ }
+
+ self.marionette.start_session(capabilities)
+ self.assertEqual(
+ self.marionette.session_capabilities["proxy"], capabilities["proxy"]
+ )
+
+ def test_proxy_type_manual_socks_requires_version(self):
+ proxy_port = 4444
+ proxy_hostname = "marionette.test"
+ proxy_host = "{}:{}".format(proxy_hostname, proxy_port)
+ capabilities = {
+ "proxy": {
+ "proxyType": "manual",
+ "socksProxy": proxy_host,
+ }
+ }
+
+ with self.assertRaises(errors.SessionNotCreatedException):
+ self.marionette.start_session(capabilities)
+
+ def test_proxy_type_manual_no_proxy_on(self):
+ capabilities = {
+ "proxy": {
+ "proxyType": "manual",
+ "noProxy": ["foo", "bar"],
+ }
+ }
+
+ self.marionette.start_session(capabilities)
+ self.assertEqual(
+ self.marionette.session_capabilities["proxy"], capabilities["proxy"]
+ )
+
+ def test_proxy_type_manual_invalid_no_proxy_on(self):
+ capabilities = {
+ "proxy": {
+ "proxyType": "manual",
+ "noProxy": "foo, bar",
+ }
+ }
+
+ with self.assertRaises(errors.SessionNotCreatedException):
+ self.marionette.start_session(capabilities)
+
+ def test_proxy_type_pac(self):
+ pac_url = "http://marionette.test"
+ capabilities = {"proxy": {"proxyType": "pac", "proxyAutoconfigUrl": pac_url}}
+
+ self.marionette.start_session(capabilities)
+ self.assertEqual(
+ self.marionette.session_capabilities["proxy"], capabilities["proxy"]
+ )
+
+ def test_proxy_type_system(self):
+ capabilities = {"proxy": {"proxyType": "system"}}
+
+ self.marionette.start_session(capabilities)
+ self.assertEqual(
+ self.marionette.session_capabilities["proxy"], capabilities["proxy"]
+ )
+
+ def test_invalid_proxy_object(self):
+ capabilities = {"proxy": "I really should be a dictionary"}
+
+ with self.assertRaises(errors.SessionNotCreatedException):
+ self.marionette.start_session(capabilities)
+
+ def test_missing_proxy_type(self):
+ with self.assertRaises(errors.SessionNotCreatedException):
+ self.marionette.start_session({"proxy": {"proxyAutoconfigUrl": "foobar"}})
+
+ def test_invalid_proxy_type(self):
+ capabilities = {"proxy": {"proxyType": "NOPROXY"}}
+
+ with self.assertRaises(errors.SessionNotCreatedException):
+ self.marionette.start_session(capabilities)
+
+ def test_invalid_autoconfig_url_for_pac(self):
+ with self.assertRaises(errors.SessionNotCreatedException):
+ self.marionette.start_session({"proxy": {"proxyType": "pac"}})
+
+ with self.assertRaises(errors.SessionNotCreatedException):
+ self.marionette.start_session(
+ {"proxy": {"proxyType": "pac", "proxyAutoconfigUrl": None}}
+ )
+
+ def test_missing_socks_version_for_manual(self):
+ capabilities = {
+ "proxy": {"proxyType": "manual", "socksProxy": "marionette.test"}
+ }
+
+ with self.assertRaises(errors.SessionNotCreatedException):
+ self.marionette.start_session(capabilities)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_quit_restart.py b/testing/marionette/harness/marionette_harness/tests/unit/test_quit_restart.py
new file mode 100644
index 0000000000..8812520c85
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_quit_restart.py
@@ -0,0 +1,424 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import, print_function
+
+import sys
+import unittest
+
+from six.moves.urllib.parse import quote
+
+from marionette_driver import errors
+from marionette_driver.by import By
+from marionette_harness import MarionetteTestCase
+
+
+def inline(doc):
+ return "data:text/html;charset=utf-8,{}".format(quote(doc))
+
+
+class TestServerQuitApplication(MarionetteTestCase):
+ def tearDown(self):
+ if self.marionette.session is None:
+ self.marionette.start_session()
+
+ def quit(self, flags=None):
+ body = None
+ if flags is not None:
+ body = {"flags": list(flags)}
+
+ try:
+ resp = self.marionette._send_message("Marionette:Quit", body)
+ finally:
+ self.marionette.session_id = None
+ self.marionette.session = None
+ self.marionette.process_id = None
+ self.marionette.profile = None
+ self.marionette.window = None
+
+ self.assertIn("cause", resp)
+
+ self.marionette.client.close()
+ self.marionette.instance.runner.wait()
+
+ return resp["cause"]
+
+ def test_types(self):
+ for typ in [42, True, "foo", []]:
+ print("testing type {}".format(type(typ)))
+ with self.assertRaises(errors.InvalidArgumentException):
+ self.marionette._send_message("Marionette:Quit", typ)
+
+ with self.assertRaises(errors.InvalidArgumentException):
+ self.quit("foo")
+
+ def test_undefined_default(self):
+ cause = self.quit()
+ self.assertEqual("shutdown", cause)
+
+ def test_empty_default(self):
+ cause = self.quit(())
+ self.assertEqual("shutdown", cause)
+
+ def test_incompatible_flags(self):
+ with self.assertRaises(errors.InvalidArgumentException):
+ self.quit(("eAttemptQuit", "eForceQuit"))
+
+ def test_attempt_quit(self):
+ cause = self.quit(("eAttemptQuit",))
+ self.assertEqual("shutdown", cause)
+
+ def test_force_quit(self):
+ cause = self.quit(("eForceQuit",))
+ self.assertEqual("shutdown", cause)
+
+
+class TestQuitRestart(MarionetteTestCase):
+ def setUp(self):
+ MarionetteTestCase.setUp(self)
+
+ self.pid = self.marionette.process_id
+ self.profile = self.marionette.profile
+ self.session_id = self.marionette.session_id
+
+ # Use a preference to check that the restart was successful. If its
+ # value has not been forced, a restart will cause a reset of it.
+ self.assertNotEqual(
+ self.marionette.get_pref("startup.homepage_welcome_url"), "about:about"
+ )
+ self.marionette.set_pref("startup.homepage_welcome_url", "about:about")
+
+ def tearDown(self):
+ # Ensure to restart a session if none exist for clean-up
+ if self.marionette.session is None:
+ self.marionette.start_session()
+
+ self.marionette.clear_pref("startup.homepage_welcome_url")
+
+ MarionetteTestCase.tearDown(self)
+
+ @property
+ def is_safe_mode(self):
+ with self.marionette.using_context("chrome"):
+ return self.marionette.execute_script(
+ """
+ Cu.import("resource://gre/modules/Services.jsm");
+ return Services.appinfo.inSafeMode;
+ """
+ )
+
+ def shutdown(self, restart=False):
+ self.marionette.set_context("chrome")
+ self.marionette.execute_script(
+ """
+ Components.utils.import("resource://gre/modules/Services.jsm");
+ let flags = Ci.nsIAppStartup.eAttemptQuit;
+ if (arguments[0]) {
+ flags |= Ci.nsIAppStartup.eRestart;
+ }
+ Services.startup.quit(flags);
+ """,
+ script_args=(restart,),
+ )
+
+ def test_force_restart(self):
+ self.marionette.restart()
+ self.assertEqual(self.marionette.profile, self.profile)
+ self.assertNotEqual(self.marionette.session_id, self.session_id)
+
+ # A forced restart will cause a new process id
+ self.assertNotEqual(self.marionette.process_id, self.pid)
+ self.assertNotEqual(
+ self.marionette.get_pref("startup.homepage_welcome_url"), "about:about"
+ )
+
+ def test_force_clean_restart(self):
+ self.marionette.restart(clean=True)
+ self.assertNotEqual(self.marionette.profile, self.profile)
+ self.assertNotEqual(self.marionette.session_id, self.session_id)
+ # A forced restart will cause a new process id
+ self.assertNotEqual(self.marionette.process_id, self.pid)
+ self.assertNotEqual(
+ self.marionette.get_pref("startup.homepage_welcome_url"), "about:about"
+ )
+
+ def test_force_quit(self):
+ self.marionette.quit()
+
+ self.assertEqual(self.marionette.session, None)
+ with self.assertRaisesRegexp(
+ errors.InvalidSessionIdException, "Please start a session"
+ ):
+ self.marionette.get_url()
+
+ def test_force_clean_quit(self):
+ self.marionette.quit(clean=True)
+
+ self.assertEqual(self.marionette.session, None)
+ with self.assertRaisesRegexp(
+ errors.InvalidSessionIdException, "Please start a session"
+ ):
+ self.marionette.get_url()
+
+ self.marionette.start_session()
+ self.assertNotEqual(self.marionette.profile, self.profile)
+ self.assertNotEqual(self.marionette.session_id, self.session_id)
+ self.assertNotEqual(
+ self.marionette.get_pref("startup.homepage_welcome_url"), "about:about"
+ )
+
+ def test_no_in_app_clean_restart(self):
+ # Test that in_app and clean cannot be used in combination
+ with self.assertRaisesRegexp(
+ ValueError, "cannot be triggered with the clean flag set"
+ ):
+ self.marionette.restart(in_app=True, clean=True)
+
+ def test_in_app_restart(self):
+ self.marionette.restart(in_app=True)
+
+ self.assertEqual(self.marionette.profile, self.profile)
+ self.assertNotEqual(self.marionette.session_id, self.session_id)
+
+ # An in-app restart will keep the same process id only on Linux
+ if self.marionette.session_capabilities["platformName"] == "linux":
+ self.assertEqual(self.marionette.process_id, self.pid)
+ else:
+ self.assertNotEqual(self.marionette.process_id, self.pid)
+
+ self.assertNotEqual(
+ self.marionette.get_pref("startup.homepage_welcome_url"), "about:about"
+ )
+
+ def test_in_app_restart_with_callback(self):
+ self.marionette.restart(
+ in_app=True, callback=lambda: self.shutdown(restart=True)
+ )
+
+ self.assertEqual(self.marionette.profile, self.profile)
+ self.assertNotEqual(self.marionette.session_id, self.session_id)
+
+ # An in-app restart will keep the same process id only on Linux
+ if self.marionette.session_capabilities["platformName"] == "linux":
+ self.assertEqual(self.marionette.process_id, self.pid)
+ else:
+ self.assertNotEqual(self.marionette.process_id, self.pid)
+
+ self.assertNotEqual(
+ self.marionette.get_pref("startup.homepage_welcome_url"), "about:about"
+ )
+
+ def test_in_app_restart_with_callback_not_callable(self):
+ with self.assertRaisesRegexp(ValueError, "is not callable"):
+ self.marionette.restart(in_app=True, callback=4)
+
+ @unittest.skipIf(sys.platform.startswith("win"), "Bug 1493796")
+ def test_in_app_restart_with_callback_but_process_quit(self):
+ try:
+ timeout_shutdown = self.marionette.shutdown_timeout
+ timeout_startup = self.marionette.startup_timeout
+ self.marionette.shutdown_timeout = 5
+ self.marionette.startup_timeout = 0
+
+ with self.assertRaisesRegexp(
+ IOError, "Process unexpectedly quit without restarting"
+ ):
+ self.marionette.restart(
+ in_app=True, callback=lambda: self.shutdown(restart=False)
+ )
+ finally:
+ self.marionette.shutdown_timeout = timeout_shutdown
+ self.marionette.startup_timeout = timeout_startup
+
+ @unittest.skipIf(sys.platform.startswith("win"), "Bug 1493796")
+ def test_in_app_restart_with_callback_missing_shutdown(self):
+ try:
+ timeout_shutdown = self.marionette.shutdown_timeout
+ timeout_startup = self.marionette.startup_timeout
+ self.marionette.shutdown_timeout = 5
+ self.marionette.startup_timeout = 0
+
+ with self.assertRaisesRegexp(
+ IOError, "the connection to Marionette server is lost"
+ ):
+ self.marionette.restart(in_app=True, callback=lambda: False)
+ finally:
+ self.marionette.shutdown_timeout = timeout_shutdown
+ self.marionette.startup_timeout = timeout_startup
+
+ def test_in_app_restart_safe_mode(self):
+ def restart_in_safe_mode():
+ with self.marionette.using_context("chrome"):
+ self.marionette.execute_script(
+ """
+ Components.utils.import("resource://gre/modules/Services.jsm");
+
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"]
+ .createInstance(Ci.nsISupportsPRBool);
+ Services.obs.notifyObservers(cancelQuit,
+ "quit-application-requested", null);
+
+ if (!cancelQuit.data) {
+ Services.startup.restartInSafeMode(Ci.nsIAppStartup.eAttemptQuit);
+ }
+ """
+ )
+
+ try:
+ self.assertFalse(self.is_safe_mode, "Safe Mode is unexpectedly enabled")
+ self.marionette.restart(in_app=True, callback=restart_in_safe_mode)
+ self.assertTrue(self.is_safe_mode, "Safe Mode is not enabled")
+ finally:
+ if self.marionette.session is None:
+ self.marionette.start_session()
+ self.marionette.quit(clean=True)
+
+ def test_in_app_quit(self):
+ self.marionette.quit(in_app=True)
+
+ self.assertEqual(self.marionette.session, None)
+ with self.assertRaisesRegexp(
+ errors.InvalidSessionIdException, "Please start a session"
+ ):
+ self.marionette.get_url()
+
+ self.marionette.start_session()
+ self.assertEqual(self.marionette.profile, self.profile)
+ self.assertNotEqual(self.marionette.session_id, self.session_id)
+ self.assertNotEqual(
+ self.marionette.get_pref("startup.homepage_welcome_url"), "about:about"
+ )
+
+ def test_in_app_quit_with_callback(self):
+ self.marionette.quit(in_app=True, callback=self.shutdown)
+ self.assertEqual(self.marionette.session, None)
+ with self.assertRaisesRegexp(
+ errors.InvalidSessionIdException, "Please start a session"
+ ):
+ self.marionette.get_url()
+
+ self.marionette.start_session()
+ self.assertEqual(self.marionette.profile, self.profile)
+ self.assertNotEqual(self.marionette.session_id, self.session_id)
+ self.assertNotEqual(
+ self.marionette.get_pref("startup.homepage_welcome_url"), "about:about"
+ )
+
+ def test_in_app_quit_with_callback_missing_shutdown(self):
+ try:
+ timeout = self.marionette.shutdown_timeout
+ self.marionette.shutdown_timeout = 5
+
+ with self.assertRaisesRegexp(IOError, "Process still running"):
+ self.marionette.quit(in_app=True, callback=lambda: False)
+ finally:
+ self.marionette.shutdown_timeout = timeout
+
+ def test_in_app_quit_with_callback_not_callable(self):
+ with self.assertRaisesRegexp(ValueError, "is not callable"):
+ self.marionette.restart(in_app=True, callback=4)
+
+ def test_in_app_quit_with_dismissed_beforeunload_prompt(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <input type="text">
+ <script>
+ window.addEventListener("beforeunload", function (event) {
+ event.preventDefault();
+ });
+ </script>
+ """
+ )
+ )
+
+ self.marionette.find_element(By.TAG_NAME, "input").send_keys("foo")
+ self.marionette.quit(in_app=True)
+ self.marionette.start_session()
+
+ def test_reset_context_after_quit_by_set_context(self):
+ # Check that we are in content context which is used by default in
+ # Marionette
+ self.assertNotIn(
+ "chrome://",
+ self.marionette.get_url(),
+ "Context does not default to content",
+ )
+
+ self.marionette.set_context("chrome")
+ self.marionette.quit(in_app=True)
+ self.assertEqual(self.marionette.session, None)
+ self.marionette.start_session()
+ self.assertNotIn(
+ "chrome://",
+ self.marionette.get_url(),
+ "Not in content context after quit with using_context",
+ )
+
+ def test_reset_context_after_quit_by_using_context(self):
+ # Check that we are in content context which is used by default in
+ # Marionette
+ self.assertNotIn(
+ "chrome://",
+ self.marionette.get_url(),
+ "Context does not default to content",
+ )
+
+ with self.marionette.using_context("chrome"):
+ self.marionette.quit(in_app=True)
+ self.assertEqual(self.marionette.session, None)
+ self.marionette.start_session()
+ self.assertNotIn(
+ "chrome://",
+ self.marionette.get_url(),
+ "Not in content context after quit with using_context",
+ )
+
+ def test_keep_context_after_restart_by_set_context(self):
+ # Check that we are in content context which is used by default in
+ # Marionette
+ self.assertNotIn(
+ "chrome://", self.marionette.get_url(), "Context doesn't default to content"
+ )
+
+ # restart while we are in chrome context
+ self.marionette.set_context("chrome")
+ self.marionette.restart(in_app=True)
+
+ # An in-app restart will keep the same process id only on Linux
+ if self.marionette.session_capabilities["platformName"] == "linux":
+ self.assertEqual(self.marionette.process_id, self.pid)
+ else:
+ self.assertNotEqual(self.marionette.process_id, self.pid)
+
+ self.assertIn(
+ "chrome://",
+ self.marionette.get_url(),
+ "Not in chrome context after a restart with set_context",
+ )
+
+ def test_keep_context_after_restart_by_using_context(self):
+ # Check that we are in content context which is used by default in
+ # Marionette
+ self.assertNotIn(
+ "chrome://",
+ self.marionette.get_url(),
+ "Context does not default to content",
+ )
+
+ # restart while we are in chrome context
+ with self.marionette.using_context("chrome"):
+ self.marionette.restart(in_app=True)
+
+ # An in-app restart will keep the same process id only on Linux
+ if self.marionette.session_capabilities["platformName"] == "linux":
+ self.assertEqual(self.marionette.process_id, self.pid)
+ else:
+ self.assertNotEqual(self.marionette.process_id, self.pid)
+
+ self.assertIn(
+ "chrome://",
+ self.marionette.get_url(),
+ "Not in chrome context after a restart with using_context",
+ )
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_reftest.py b/testing/marionette/harness/marionette_harness/tests/unit/test_reftest.py
new file mode 100644
index 0000000000..f3468e11e6
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_reftest.py
@@ -0,0 +1,103 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import, print_function
+
+from marionette_driver.errors import UnsupportedOperationException
+from marionette_harness import MarionetteTestCase, skip
+
+
+class TestReftest(MarionetteTestCase):
+ def setUp(self):
+ super(TestReftest, self).setUp()
+
+ self.original_window = self.marionette.current_window_handle
+
+ self.marionette.set_pref("dom.send_after_paint_to_content", True)
+
+ def tearDown(self):
+ try:
+ # make sure we've teared down any reftest context
+ self.marionette._send_message("reftest:teardown", {})
+ except UnsupportedOperationException:
+ # this will throw if we aren't currently in a reftest context
+ pass
+
+ self.marionette.switch_to_window(self.original_window)
+
+ self.marionette.clear_pref("dom.send_after_paint_to_content")
+
+ super(TestReftest, self).tearDown()
+
+ @skip("Bug 1648444 - Unexpected page unload when refreshing about:blank")
+ def test_basic(self):
+ self.marionette._send_message("reftest:setup", {"screenshot": "unexpected"})
+ rv = self.marionette._send_message(
+ "reftest:run",
+ {
+ "test": "about:blank",
+ "references": [["about:blank", [], "=="]],
+ "expected": "PASS",
+ "timeout": 10 * 1000,
+ },
+ )
+ self.marionette._send_message("reftest:teardown", {})
+ expected = {
+ u"value": {
+ u"extra": {},
+ u"message": u"Testing about:blank == about:blank\n",
+ u"stack": None,
+ u"status": u"PASS",
+ }
+ }
+ self.assertEqual(expected, rv)
+
+ def test_url_comparison(self):
+ test_page = self.fixtures.where_is("test.html")
+ test_page_2 = self.fixtures.where_is("foo/../test.html")
+
+ self.marionette._send_message("reftest:setup", {"screenshot": "unexpected"})
+ rv = self.marionette._send_message(
+ "reftest:run",
+ {
+ "test": test_page,
+ "references": [[test_page_2, [], "=="]],
+ "expected": "PASS",
+ "timeout": 10 * 1000,
+ },
+ )
+ self.marionette._send_message("reftest:teardown", {})
+ self.assertEqual(u"PASS", rv[u"value"][u"status"])
+
+ def test_cache_multiple_sizes(self):
+ teal = self.fixtures.where_is("reftest/teal-700x700.html")
+ mostly_teal = self.fixtures.where_is("reftest/mostly-teal-700x700.html")
+
+ self.marionette._send_message("reftest:setup", {"screenshot": "unexpected"})
+ rv = self.marionette._send_message(
+ "reftest:run",
+ {
+ "test": teal,
+ "references": [[mostly_teal, [], "=="]],
+ "expected": "PASS",
+ "timeout": 10 * 1000,
+ "width": 600,
+ "height": 600,
+ },
+ )
+ self.assertEqual(u"PASS", rv[u"value"][u"status"])
+
+ rv = self.marionette._send_message(
+ "reftest:run",
+ {
+ "test": teal,
+ "references": [[mostly_teal, [], "=="]],
+ "expected": "PASS",
+ "timeout": 10 * 1000,
+ "width": 700,
+ "height": 700,
+ },
+ )
+ self.assertEqual(u"FAIL", rv[u"value"][u"status"])
+ self.marionette._send_message("reftest:teardown", {})
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_rendered_element.py b/testing/marionette/harness/marionette_harness/tests/unit/test_rendered_element.py
new file mode 100644
index 0000000000..47e7283cc6
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_rendered_element.py
@@ -0,0 +1,33 @@
+from __future__ import absolute_import
+
+from six.moves.urllib.parse import quote
+
+from marionette_driver.by import By
+from marionette_harness import MarionetteTestCase
+
+
+def inline(doc):
+ return "data:text/html;charset=utf-8,{}".format(quote(doc))
+
+
+class RenderedElementTests(MarionetteTestCase):
+ def test_get_computed_style_value_from_element(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <div style="color: green;" id="parent">
+ <p id="green">This should be green</p>
+ <p id="red" style="color: red;">But this is red</p>
+ </div>
+ """
+ )
+ )
+
+ parent = self.marionette.find_element(By.ID, "parent")
+ self.assertEqual("rgb(0, 128, 0)", parent.value_of_css_property("color"))
+
+ green = self.marionette.find_element(By.ID, "green")
+ self.assertEqual("rgb(0, 128, 0)", green.value_of_css_property("color"))
+
+ red = self.marionette.find_element(By.ID, "red")
+ self.assertEqual("rgb(255, 0, 0)", red.value_of_css_property("color"))
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_report.py b/testing/marionette/harness/marionette_harness/tests/unit/test_report.py
new file mode 100644
index 0000000000..5ce6c5c263
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_report.py
@@ -0,0 +1,30 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_harness import MarionetteTestCase, expectedFailure, skip
+
+
+class TestReport(MarionetteTestCase):
+ def test_pass(self):
+ assert True
+
+ def test_fail(self):
+ assert False
+
+ @skip("Skip Message")
+ def test_skip(self):
+ assert False
+
+ @expectedFailure
+ def test_expected_fail(self):
+ assert False
+
+ @expectedFailure
+ def test_unexpected_pass(self):
+ assert True
+
+ def test_error(self):
+ raise Exception()
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_run_js_test.py b/testing/marionette/harness/marionette_harness/tests/unit/test_run_js_test.py
new file mode 100644
index 0000000000..fdaeffe62f
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_run_js_test.py
@@ -0,0 +1,12 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from __future__ import absolute_import
+
+from marionette_harness import MarionetteTestCase
+
+
+class TestRunJSTest(MarionetteTestCase):
+ def test_basic(self):
+ self.run_js_test("test_simpletest_pass.js")
+ self.run_js_test("test_simpletest_fail.js")
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_screen_orientation.py b/testing/marionette/harness/marionette_harness/tests/unit/test_screen_orientation.py
new file mode 100644
index 0000000000..7c65d0c84d
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_screen_orientation.py
@@ -0,0 +1,77 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver import errors
+from marionette_driver.wait import Wait
+from marionette_harness import (
+ MarionetteTestCase,
+ parameterized,
+ skip_if_desktop,
+)
+
+
+default_orientation = "portrait-primary"
+unknown_orientation = "Unknown screen orientation: {}"
+
+
+class TestScreenOrientation(MarionetteTestCase):
+ def setUp(self):
+ MarionetteTestCase.setUp(self)
+
+ def tearDown(self):
+ MarionetteTestCase.tearDown(self)
+
+ def wait_for_orientation(self, orientation, timeout=None):
+ Wait(self.marionette, timeout=timeout).until(
+ lambda _: self.marionette.orientation == orientation
+ )
+
+ @skip_if_desktop("Not supported in Firefox")
+ @parameterized("landscape-primary", "landscape-primary")
+ @parameterized("landscape-secondary", "landscape-secondary")
+ @parameterized("portrait-primary", "portrait-primary")
+ # @parameterized("portrait-secondary", "portrait-secondary") # Bug 1533084
+ def test_set_orientation(self, orientation):
+ self.marionette.set_orientation(orientation)
+ self.wait_for_orientation(orientation)
+
+ @skip_if_desktop("Not supported in Firefox")
+ def test_set_orientation_to_shorthand_portrait(self):
+ # Set orientation to something other than portrait-primary first,
+ # since the default is portrait-primary.
+ self.marionette.set_orientation("landscape-primary")
+ self.wait_for_orientation("landscape-primary")
+
+ self.marionette.set_orientation("portrait")
+ self.wait_for_orientation("portrait-primary")
+
+ @skip_if_desktop("Not supported in Firefox")
+ def test_set_orientation_to_shorthand_landscape(self):
+ self.marionette.set_orientation("landscape")
+ self.wait_for_orientation("landscape-primary")
+
+ @skip_if_desktop("Not supported in Firefox")
+ def test_set_orientation_with_mixed_casing(self):
+ self.marionette.set_orientation("lAnDsCaPe")
+ self.wait_for_orientation("landscape-primary")
+
+ @skip_if_desktop("Not supported in Firefox")
+ def test_set_invalid_orientation(self):
+ with self.assertRaisesRegexp(
+ errors.MarionetteException, unknown_orientation.format("cheese")
+ ):
+ self.marionette.set_orientation("cheese")
+
+ @skip_if_desktop("Not supported in Firefox")
+ def test_set_null_orientation(self):
+ with self.assertRaisesRegexp(
+ errors.MarionetteException, unknown_orientation.format("null")
+ ):
+ self.marionette.set_orientation(None)
+
+ def test_unsupported_operation_on_desktop(self):
+ with self.assertRaises(errors.UnsupportedOperationException):
+ self.marionette.set_orientation("landscape-primary")
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_screenshot.py b/testing/marionette/harness/marionette_harness/tests/unit/test_screenshot.py
new file mode 100644
index 0000000000..f69ad9fd7a
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_screenshot.py
@@ -0,0 +1,393 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import base64
+import hashlib
+import imghdr
+import struct
+import tempfile
+import unittest
+
+import six
+from six.moves.urllib.parse import quote
+
+import mozinfo
+
+from marionette_driver import By
+from marionette_driver.errors import NoSuchWindowException
+from marionette_harness import (
+ MarionetteTestCase,
+ skip,
+ WindowManagerMixin,
+)
+
+
+def decodebytes(s):
+ if six.PY3:
+ return base64.decodebytes(six.ensure_binary(s))
+ return base64.decodestring(s)
+
+
+def inline(doc, mime="text/html;charset=utf-8"):
+ return "data:{0},{1}".format(mime, quote(doc))
+
+
+box = inline(
+ "<body><div id='box'><p id='green' style='width: 50px; height: 50px; "
+ "background: silver;'></p></div></body>"
+)
+input = inline("<body><input id='text-input'></input></body>")
+long = inline("<body style='height: 300vh'><p style='margin-top: 100vh'>foo</p></body>")
+short = inline("<body style='height: 10vh'></body>")
+svg = inline(
+ """
+ <svg xmlns="http://www.w3.org/2000/svg" height="20" width="20">
+ <rect height="20" width="20"/>
+ </svg>""",
+ mime="image/svg+xml",
+)
+
+
+class ScreenCaptureTestCase(MarionetteTestCase):
+ def setUp(self):
+ super(ScreenCaptureTestCase, self).setUp()
+
+ self.maxDiff = None
+
+ self._device_pixel_ratio = None
+
+ # Ensure that each screenshot test runs on a blank page to avoid left
+ # over elements or focus which could interfer with taking screenshots
+ self.marionette.navigate("about:blank")
+
+ @property
+ def device_pixel_ratio(self):
+ if self._device_pixel_ratio is None:
+ self._device_pixel_ratio = self.marionette.execute_script(
+ """
+ return window.devicePixelRatio
+ """
+ )
+ return self._device_pixel_ratio
+
+ @property
+ def document_element(self):
+ return self.marionette.find_element(By.CSS_SELECTOR, ":root")
+
+ @property
+ def page_y_offset(self):
+ return self.marionette.execute_script("return window.pageYOffset")
+
+ @property
+ def viewport_dimensions(self):
+ return self.marionette.execute_script(
+ "return [window.innerWidth, window.innerHeight];"
+ )
+
+ def assert_png(self, screenshot):
+ """Test that screenshot is a Base64 encoded PNG file."""
+ if six.PY3 and not isinstance(screenshot, bytes):
+ screenshot = bytes(screenshot, encoding="utf-8")
+ image = decodebytes(screenshot)
+ self.assertEqual(imghdr.what("", image), "png")
+
+ def assert_formats(self, element=None):
+ if element is None:
+ element = self.document_element
+
+ screenshot_default = self.marionette.screenshot(element=element)
+ if six.PY3 and not isinstance(screenshot_default, bytes):
+ screenshot_default = bytes(screenshot_default, encoding="utf-8")
+ screenshot_image = self.marionette.screenshot(element=element, format="base64")
+ if six.PY3 and not isinstance(screenshot_image, bytes):
+ screenshot_image = bytes(screenshot_image, encoding="utf-8")
+ binary1 = self.marionette.screenshot(element=element, format="binary")
+ binary2 = self.marionette.screenshot(element=element, format="binary")
+ hash1 = self.marionette.screenshot(element=element, format="hash")
+ hash2 = self.marionette.screenshot(element=element, format="hash")
+
+ # Valid data should have been returned
+ self.assert_png(screenshot_image)
+ self.assertEqual(imghdr.what("", binary1), "png")
+ self.assertEqual(screenshot_image, base64.b64encode(binary1))
+ self.assertEqual(hash1, hashlib.sha256(screenshot_image).hexdigest())
+
+ # Different formats produce different data
+ self.assertNotEqual(screenshot_image, binary1)
+ self.assertNotEqual(screenshot_image, hash1)
+ self.assertNotEqual(binary1, hash1)
+
+ # A second capture should be identical
+ self.assertEqual(screenshot_image, screenshot_default)
+ self.assertEqual(binary1, binary2)
+ self.assertEqual(hash1, hash2)
+
+ def get_element_dimensions(self, element):
+ rect = element.rect
+ return rect["width"], rect["height"]
+
+ def get_image_dimensions(self, screenshot):
+ if six.PY3 and not isinstance(screenshot, bytes):
+ screenshot = bytes(screenshot, encoding="utf-8")
+ self.assert_png(screenshot)
+ image = decodebytes(screenshot)
+ width, height = struct.unpack(">LL", image[16:24])
+ return int(width), int(height)
+
+ def scale(self, rect):
+ return (
+ int(rect[0] * self.device_pixel_ratio),
+ int(rect[1] * self.device_pixel_ratio),
+ )
+
+
+class TestScreenCaptureChrome(WindowManagerMixin, ScreenCaptureTestCase):
+ def setUp(self):
+ super(TestScreenCaptureChrome, self).setUp()
+ self.marionette.set_context("chrome")
+
+ def tearDown(self):
+ self.close_all_windows()
+ super(TestScreenCaptureChrome, self).tearDown()
+
+ @property
+ def window_dimensions(self):
+ return tuple(
+ self.marionette.execute_script(
+ """
+ let el = document.documentElement;
+ let rect = el.getBoundingClientRect();
+ return [rect.width, rect.height];
+ """
+ )
+ )
+
+ def open_dialog(self):
+ return self.open_chrome_window("chrome://marionette/content/test_dialog.xhtml")
+
+ def test_capture_different_context(self):
+ """Check that screenshots in content and chrome are different."""
+ with self.marionette.using_context("content"):
+ screenshot_content = self.marionette.screenshot()
+ screenshot_chrome = self.marionette.screenshot()
+ self.assertNotEqual(screenshot_content, screenshot_chrome)
+
+ def test_capture_element(self):
+ dialog = self.open_dialog()
+ self.marionette.switch_to_window(dialog)
+
+ # Ensure we only capture the element
+ el = self.marionette.find_element(By.ID, "test-list")
+ screenshot_element = self.marionette.screenshot(element=el)
+ self.assertEqual(
+ self.scale(self.get_element_dimensions(el)),
+ self.get_image_dimensions(screenshot_element),
+ )
+
+ # Ensure we do not capture the full window
+ screenshot_dialog = self.marionette.screenshot()
+ self.assertNotEqual(screenshot_dialog, screenshot_element)
+
+ self.marionette.close_chrome_window()
+ self.marionette.switch_to_window(self.start_window)
+
+ def test_capture_full_area(self):
+ dialog = self.open_dialog()
+ self.marionette.switch_to_window(dialog)
+
+ root_dimensions = self.scale(self.get_element_dimensions(self.document_element))
+
+ # self.marionette.set_window_rect(width=100, height=100)
+ # A full capture is not the outer dimensions of the window,
+ # but instead the bounding box of the window's root node (documentElement).
+ screenshot_full = self.marionette.screenshot()
+ screenshot_root = self.marionette.screenshot(element=self.document_element)
+
+ self.marionette.close_chrome_window()
+ self.marionette.switch_to_window(self.start_window)
+
+ self.assert_png(screenshot_full)
+ self.assert_png(screenshot_root)
+ self.assertEqual(root_dimensions, self.get_image_dimensions(screenshot_full))
+ self.assertEqual(screenshot_root, screenshot_full)
+
+ def test_capture_window_already_closed(self):
+ dialog = self.open_dialog()
+ self.marionette.switch_to_window(dialog)
+ self.marionette.close_chrome_window()
+
+ self.assertRaises(NoSuchWindowException, self.marionette.screenshot)
+ self.marionette.switch_to_window(self.start_window)
+
+ def test_formats(self):
+ dialog = self.open_dialog()
+ self.marionette.switch_to_window(dialog)
+
+ self.assert_formats()
+
+ self.marionette.close_chrome_window()
+ self.marionette.switch_to_window(self.start_window)
+
+ def test_format_unknown(self):
+ with self.assertRaises(ValueError):
+ self.marionette.screenshot(format="cheese")
+
+
+class TestScreenCaptureContent(WindowManagerMixin, ScreenCaptureTestCase):
+ def setUp(self):
+ super(TestScreenCaptureContent, self).setUp()
+ self.marionette.set_context("content")
+
+ def tearDown(self):
+ self.close_all_tabs()
+ super(TestScreenCaptureContent, self).tearDown()
+
+ @property
+ def scroll_dimensions(self):
+ return tuple(
+ self.marionette.execute_script(
+ """
+ return [
+ document.documentElement.scrollWidth,
+ document.documentElement.scrollHeight
+ ];
+ """
+ )
+ )
+
+ def test_capture_tab_already_closed(self):
+ new_tab = self.open_tab()
+ self.marionette.switch_to_window(new_tab)
+ self.marionette.close()
+
+ self.assertRaises(NoSuchWindowException, self.marionette.screenshot)
+ self.marionette.switch_to_window(self.start_tab)
+
+ @unittest.skipIf(mozinfo.info["bits"] == 32, "Bug 1582973 - Risk for OOM on 32bit")
+ def test_capture_vertical_bounds(self):
+ self.marionette.navigate(inline("<body style='margin-top: 32768px'>foo"))
+ screenshot = self.marionette.screenshot()
+ self.assert_png(screenshot)
+
+ @unittest.skipIf(mozinfo.info["bits"] == 32, "Bug 1582973 - Risk for OOM on 32bit")
+ def test_capture_horizontal_bounds(self):
+ self.marionette.navigate(inline("<body style='margin-left: 32768px'>foo"))
+ screenshot = self.marionette.screenshot()
+ self.assert_png(screenshot)
+
+ @unittest.skipIf(mozinfo.info["bits"] == 32, "Bug 1582973 - Risk for OOM on 32bit")
+ def test_capture_area_bounds(self):
+ self.marionette.navigate(
+ inline("<body style='margin-right: 21747px; margin-top: 21747px'>foo")
+ )
+ screenshot = self.marionette.screenshot()
+ self.assert_png(screenshot)
+
+ def test_capture_element(self):
+ self.marionette.navigate(box)
+ el = self.marionette.find_element(By.TAG_NAME, "div")
+ screenshot = self.marionette.screenshot(element=el)
+ self.assert_png(screenshot)
+ self.assertEqual(
+ self.scale(self.get_element_dimensions(el)),
+ self.get_image_dimensions(screenshot),
+ )
+
+ @skip("Bug 1213875")
+ def test_capture_element_scrolled_into_view(self):
+ self.marionette.navigate(long)
+ el = self.marionette.find_element(By.TAG_NAME, "p")
+ screenshot = self.marionette.screenshot(element=el)
+ self.assert_png(screenshot)
+ self.assertEqual(
+ self.scale(self.get_element_dimensions(el)),
+ self.get_image_dimensions(screenshot),
+ )
+ self.assertGreater(self.page_y_offset, 0)
+
+ def test_capture_full_html_document_element(self):
+ self.marionette.navigate(long)
+ screenshot = self.marionette.screenshot()
+ self.assert_png(screenshot)
+ self.assertEqual(
+ self.scale(self.scroll_dimensions), self.get_image_dimensions(screenshot)
+ )
+
+ def test_capture_full_svg_document_element(self):
+ self.marionette.navigate(svg)
+ screenshot = self.marionette.screenshot()
+ self.assert_png(screenshot)
+ self.assertEqual(
+ self.scale(self.scroll_dimensions), self.get_image_dimensions(screenshot)
+ )
+
+ def test_capture_viewport(self):
+ url = self.marionette.absolute_url("clicks.html")
+ self.marionette.navigate(short)
+ self.marionette.navigate(url)
+ screenshot = self.marionette.screenshot(full=False)
+ self.assert_png(screenshot)
+ self.assertEqual(
+ self.scale(self.viewport_dimensions), self.get_image_dimensions(screenshot)
+ )
+
+ def test_capture_viewport_after_scroll(self):
+ self.marionette.navigate(long)
+ before = self.marionette.screenshot()
+ el = self.marionette.find_element(By.TAG_NAME, "p")
+ self.marionette.execute_script(
+ "arguments[0].scrollIntoView()", script_args=[el]
+ )
+ after = self.marionette.screenshot(full=False)
+ self.assertNotEqual(before, after)
+ self.assertGreater(self.page_y_offset, 0)
+
+ def test_formats(self):
+ self.marionette.navigate(box)
+
+ # Use a smaller region to speed up the test
+ element = self.marionette.find_element(By.TAG_NAME, "div")
+ self.assert_formats(element=element)
+
+ def test_format_unknown(self):
+ with self.assertRaises(ValueError):
+ self.marionette.screenshot(format="cheese")
+
+ def test_save_screenshot(self):
+ expected = self.marionette.screenshot(format="binary")
+ with tempfile.TemporaryFile("w+b") as fh:
+ self.marionette.save_screenshot(fh)
+ fh.flush()
+ fh.seek(0)
+ content = fh.read()
+ self.assertEqual(expected, content)
+
+ def test_scroll_default(self):
+ self.marionette.navigate(long)
+ before = self.page_y_offset
+ el = self.marionette.find_element(By.TAG_NAME, "p")
+ self.marionette.screenshot(element=el, format="hash")
+ self.assertNotEqual(before, self.page_y_offset)
+
+ def test_scroll(self):
+ self.marionette.navigate(long)
+ before = self.page_y_offset
+ el = self.marionette.find_element(By.TAG_NAME, "p")
+ self.marionette.screenshot(element=el, format="hash", scroll=True)
+ self.assertNotEqual(before, self.page_y_offset)
+
+ def test_scroll_off(self):
+ self.marionette.navigate(long)
+ el = self.marionette.find_element(By.TAG_NAME, "p")
+ before = self.page_y_offset
+ self.marionette.screenshot(element=el, format="hash", scroll=False)
+ self.assertEqual(before, self.page_y_offset)
+
+ def test_scroll_no_element(self):
+ self.marionette.navigate(long)
+ before = self.page_y_offset
+ self.marionette.screenshot(format="hash", scroll=True)
+ self.assertEqual(before, self.page_y_offset)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_select.py b/testing/marionette/harness/marionette_harness/tests/unit/test_select.py
new file mode 100644
index 0000000000..7a84e23d1b
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_select.py
@@ -0,0 +1,220 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from six.moves.urllib.parse import quote
+
+from marionette_driver.by import By
+
+from marionette_harness import MarionetteTestCase
+
+
+def inline(doc):
+ return "data:text/html;charset=utf-8,{}".format(quote(doc))
+
+
+class SelectTestCase(MarionetteTestCase):
+ def assertSelected(self, option_element):
+ self.assertTrue(option_element.is_selected(), "<option> element not selected")
+ self.assertTrue(
+ self.marionette.execute_script(
+ "return arguments[0].selected",
+ script_args=[option_element],
+ sandbox=None,
+ ),
+ "<option> selected attribute not updated",
+ )
+
+ def assertNotSelected(self, option_element):
+ self.assertFalse(option_element.is_selected(), "<option> is selected")
+ self.assertFalse(
+ self.marionette.execute_script(
+ "return arguments[0].selected",
+ script_args=[option_element],
+ sandbox=None,
+ ),
+ "<option> selected attribute not updated",
+ )
+
+
+class TestSelect(SelectTestCase):
+ def test_single(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <select>
+ <option>first
+ <option>second
+ </select>"""
+ )
+ )
+ select = self.marionette.find_element(By.TAG_NAME, "select")
+ options = self.marionette.find_elements(By.TAG_NAME, "option")
+
+ self.assertSelected(options[0])
+ options[1].click()
+ self.assertSelected(options[1])
+
+ def test_deselect_others(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <select>
+ <option>first
+ <option>second
+ <option>third
+ </select>"""
+ )
+ )
+ select = self.marionette.find_element(By.TAG_NAME, "select")
+ options = self.marionette.find_elements(By.TAG_NAME, "option")
+
+ options[0].click()
+ self.assertSelected(options[0])
+ options[1].click()
+ self.assertSelected(options[1])
+ options[2].click()
+ self.assertSelected(options[2])
+ options[0].click()
+ self.assertSelected(options[0])
+
+ def test_select_self(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <select>
+ <option>first
+ <option>second
+ </select>"""
+ )
+ )
+ select = self.marionette.find_element(By.TAG_NAME, "select")
+ options = self.marionette.find_elements(By.TAG_NAME, "option")
+ self.assertSelected(options[0])
+ self.assertNotSelected(options[1])
+
+ options[1].click()
+ self.assertSelected(options[1])
+ options[1].click()
+ self.assertSelected(options[1])
+
+ def test_out_of_view(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <select>
+ <option>1
+ <option>2
+ <option>3
+ <option>4
+ <option>5
+ <option>6
+ <option>7
+ <option>8
+ <option>9
+ <option>10
+ <option>11
+ <option>12
+ <option>13
+ <option>14
+ <option>15
+ <option>16
+ <option>17
+ <option>18
+ <option>19
+ <option>20
+ </select>"""
+ )
+ )
+ select = self.marionette.find_element(By.TAG_NAME, "select")
+ options = self.marionette.find_elements(By.TAG_NAME, "option")
+
+ options[14].click()
+ self.assertSelected(options[14])
+
+
+class TestSelectMultiple(SelectTestCase):
+ def test_single(self):
+ self.marionette.navigate(inline("<select multiple> <option>first </select>"))
+ option = self.marionette.find_element(By.TAG_NAME, "option")
+ option.click()
+ self.assertSelected(option)
+
+ def test_multiple(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <select multiple>
+ <option>first
+ <option>second
+ <option>third
+ </select>"""
+ )
+ )
+ select = self.marionette.find_element(By.TAG_NAME, "select")
+ options = select.find_elements(By.TAG_NAME, "option")
+
+ options[1].click()
+ self.assertSelected(options[1])
+
+ options[2].click()
+ self.assertSelected(options[2])
+ self.assertSelected(options[1])
+
+ def test_deselect_selected(self):
+ self.marionette.navigate(inline("<select multiple> <option>first </select>"))
+ option = self.marionette.find_element(By.TAG_NAME, "option")
+ option.click()
+ self.assertSelected(option)
+ option.click()
+ self.assertNotSelected(option)
+
+ def test_deselect_preselected(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <select multiple>
+ <option selected>first
+ </select>"""
+ )
+ )
+ option = self.marionette.find_element(By.TAG_NAME, "option")
+ self.assertSelected(option)
+ option.click()
+ self.assertNotSelected(option)
+
+ def test_out_of_view(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <select multiple>
+ <option>1
+ <option>2
+ <option>3
+ <option>4
+ <option>5
+ <option>6
+ <option>7
+ <option>8
+ <option>9
+ <option>10
+ <option>11
+ <option>12
+ <option>13
+ <option>14
+ <option>15
+ <option>16
+ <option>17
+ <option>18
+ <option>19
+ <option>20
+ </select>"""
+ )
+ )
+ select = self.marionette.find_element(By.TAG_NAME, "select")
+ options = self.marionette.find_elements(By.TAG_NAME, "option")
+
+ options[-1].click()
+ self.assertSelected(options[-1])
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_sendkeys_menupopup_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_sendkeys_menupopup_chrome.py
new file mode 100644
index 0000000000..6f24cc44d1
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_sendkeys_menupopup_chrome.py
@@ -0,0 +1,115 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import, division
+
+from six.moves.urllib.parse import quote
+
+from marionette_driver import By, errors, Wait
+from marionette_driver.keys import Keys
+
+from marionette_harness import (
+ MarionetteTestCase,
+ skip_if_framescript,
+ WindowManagerMixin,
+)
+
+
+class TestSendkeysMenupopup(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestSendkeysMenupopup, self).setUp()
+
+ self.marionette.set_context("chrome")
+ new_window = self.open_chrome_window(
+ "chrome://marionette/content/test_menupopup.xhtml"
+ )
+ self.marionette.switch_to_window(new_window)
+
+ self.click_el = self.marionette.find_element(By.ID, "options-button")
+ self.disabled_menuitem_el = self.marionette.find_element(
+ By.ID, "option-disabled"
+ )
+ self.hidden_menuitem_el = self.marionette.find_element(By.ID, "option-hidden")
+ self.menuitem_el = self.marionette.find_element(By.ID, "option-enabled")
+ self.menupopup_el = self.marionette.find_element(By.ID, "options-menupopup")
+ self.testwindow_el = self.marionette.find_element(By.ID, "test-window")
+
+ def context_menu_state(self):
+ return self.menupopup_el.get_property("state")
+
+ def open_context_menu(self):
+ def attempt_open_context_menu():
+ self.assertEqual(self.context_menu_state(), "closed")
+ self.click_el.click()
+ Wait(self.marionette).until(
+ lambda _: self.context_menu_state() == "open",
+ message="Context menu did not open",
+ )
+
+ try:
+ attempt_open_context_menu()
+ except errors.TimeoutException:
+ # If the first attempt timed out, try a second time.
+ # On Linux, the test will intermittently fail if we click too
+ # early on the button. Retrying fixes the issue. See Bug 1686769.
+ attempt_open_context_menu()
+
+ def wait_for_context_menu_closed(self):
+ Wait(self.marionette).until(
+ lambda _: self.context_menu_state() == "closed",
+ message="Context menu did not close",
+ )
+
+ def tearDown(self):
+ try:
+ self.close_all_windows()
+ finally:
+ super(TestSendkeysMenupopup, self).tearDown()
+
+ def test_sendkeys_menuitem(self):
+ # Try closing the context menu by sending ESCAPE to a visible context menu item.
+ self.open_context_menu()
+
+ self.menuitem_el.send_keys(Keys.ESCAPE)
+ self.wait_for_context_menu_closed()
+
+ def test_sendkeys_menupopup(self):
+ # Try closing the context menu by sending ESCAPE to the context menu.
+ self.open_context_menu()
+
+ self.menupopup_el.send_keys(Keys.ESCAPE)
+ self.wait_for_context_menu_closed()
+
+ def test_sendkeys_window(self):
+ # Try closing the context menu by sending ESCAPE to the main window.
+ self.open_context_menu()
+
+ self.testwindow_el.send_keys(Keys.ESCAPE)
+ self.wait_for_context_menu_closed()
+
+ @skip_if_framescript(
+ "Bug 1675173: Interactability is only checked with actors enabled"
+ )
+ def test_sendkeys_closed_menu(self):
+ # send_keys should throw for the menupopup if the contextmenu is closed.
+ with self.assertRaises(errors.ElementNotInteractableException):
+ self.menupopup_el.send_keys(Keys.ESCAPE)
+
+ # send_keys should throw for the menuitem if the contextmenu is closed.
+ with self.assertRaises(errors.ElementNotInteractableException):
+ self.menuitem_el.send_keys(Keys.ESCAPE)
+
+ @skip_if_framescript(
+ "Bug 1675173: Interactability is only checked with actors enabled"
+ )
+ def test_sendkeys_hidden_disabled_menuitem(self):
+ self.open_context_menu()
+
+ # send_keys should throw for a disabled menuitem in an opened contextmenu.
+ with self.assertRaises(errors.ElementNotInteractableException):
+ self.disabled_menuitem_el.send_keys(Keys.ESCAPE)
+
+ # send_keys should throw for a hidden menuitem in an opened contextmenu.
+ with self.assertRaises(errors.ElementNotInteractableException):
+ self.hidden_menuitem_el.send_keys(Keys.ESCAPE)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_session.py b/testing/marionette/harness/marionette_harness/tests/unit/test_session.py
new file mode 100644
index 0000000000..86757662a1
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_session.py
@@ -0,0 +1,55 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import six
+
+from marionette_driver import errors
+
+from marionette_harness import MarionetteTestCase
+
+
+class TestSession(MarionetteTestCase):
+ def setUp(self):
+ super(TestSession, self).setUp()
+
+ self.marionette.delete_session()
+
+ def test_new_session_returns_capabilities(self):
+ # Sends newSession
+ caps = self.marionette.start_session()
+
+ # Check that session was created. This implies the server
+ # sent us the sessionId and status fields.
+ self.assertIsNotNone(self.marionette.session)
+
+ # Required capabilities mandated by WebDriver spec
+ self.assertIn("browserName", caps)
+ self.assertIn("browserVersion", caps)
+ self.assertIn("platformName", caps)
+ self.assertIn("platformVersion", caps)
+
+ # Optional capabilities we want Marionette to support
+ self.assertIn("rotatable", caps)
+
+ def test_get_session_id(self):
+ # Sends newSession
+ self.marionette.start_session()
+
+ self.assertTrue(self.marionette.session_id is not None)
+ self.assertTrue(isinstance(self.marionette.session_id, six.text_type))
+
+ def test_session_already_started(self):
+ self.marionette.start_session()
+ self.assertTrue(isinstance(self.marionette.session_id, six.text_type))
+ with self.assertRaises(errors.SessionNotCreatedException):
+ self.marionette._send_message("WebDriver:NewSession", {})
+
+ def test_no_session(self):
+ with self.assertRaises(errors.InvalidSessionIdException):
+ self.marionette.get_url()
+
+ self.marionette.start_session()
+ self.marionette.get_url()
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_skip_setup.py b/testing/marionette/harness/marionette_harness/tests/unit/test_skip_setup.py
new file mode 100644
index 0000000000..66b75fdd70
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_skip_setup.py
@@ -0,0 +1,37 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_harness import MarionetteTestCase, SkipTest
+
+
+class TestSetUpSkipped(MarionetteTestCase):
+
+ testVar = {"test": "SkipTest"}
+
+ def setUp(self):
+ MarionetteTestCase.setUp(self)
+ try:
+ self.testVar["email"]
+ except KeyError:
+ raise SkipTest("email key not present in dict, skip ...")
+
+ def test_assert(self):
+ assert True
+
+
+class TestSetUpNotSkipped(MarionetteTestCase):
+
+ testVar = {"test": "SkipTest"}
+
+ def setUp(self):
+ try:
+ self.testVar["test"]
+ except KeyError:
+ raise SkipTest("email key not present in dict, skip ...")
+ MarionetteTestCase.setUp(self)
+
+ def test_assert(self):
+ assert True
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_switch_frame.py b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_frame.py
new file mode 100644
index 0000000000..878423deea
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_frame.py
@@ -0,0 +1,98 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver.by import By
+from marionette_driver.errors import InvalidArgumentException, NoSuchFrameException
+
+from marionette_harness import MarionetteTestCase
+
+
+class TestSwitchFrame(MarionetteTestCase):
+ def setUp(self):
+ super(TestSwitchFrame, self).setUp()
+
+ test_html = self.marionette.absolute_url("frameset.html")
+ self.marionette.navigate(test_html)
+
+ def test_exceptions(self):
+ frame = self.marionette.find_element(By.CSS_SELECTOR, ":root")
+ with self.assertRaises(NoSuchFrameException):
+ self.marionette.switch_to_frame(frame)
+
+ with self.assertRaises(InvalidArgumentException):
+ self.marionette.switch_to_frame(-1)
+
+ def test_by_frame_element(self):
+ frame = self.marionette.find_element(By.NAME, "third")
+ self.marionette.switch_to_frame(frame)
+
+ element = self.marionette.find_element(By.ID, "email")
+ self.assertEquals(element.get_attribute("type"), "email")
+
+ def test_by_index(self):
+ self.marionette.switch_to_frame(2)
+
+ element = self.marionette.find_element(By.ID, "email")
+ self.assertEquals(element.get_attribute("type"), "email")
+
+ def test_back_to_top_frame(self):
+ frame1 = self.marionette.find_element(By.ID, "sixth")
+ self.marionette.switch_to_frame(frame1)
+ self.marionette.switch_to_frame(0)
+
+ self.marionette.find_element(By.ID, "testDiv")
+
+ self.marionette.switch_to_frame()
+ frame = self.marionette.find_element(By.ID, "sixth")
+ self.assertEquals(frame, frame1)
+
+
+class TestSwitchParentFrame(MarionetteTestCase):
+ def test_iframe(self):
+ frame_html = self.marionette.absolute_url("test_iframe.html")
+ self.marionette.navigate(frame_html)
+
+ frame = self.marionette.find_element(By.ID, "test_iframe")
+ self.marionette.switch_to_frame(frame)
+ self.marionette.find_element(By.ID, "testDiv")
+
+ self.marionette.switch_to_parent_frame()
+
+ self.marionette.find_element(By.ID, "test_iframe")
+
+ def test_frameset(self):
+ frame_html = self.marionette.absolute_url("frameset.html")
+ self.marionette.navigate(frame_html)
+ frame = self.marionette.find_element(By.NAME, "third")
+ self.marionette.switch_to_frame(frame)
+
+ # If we don't find the following element we aren't on the right page
+ self.marionette.find_element(By.ID, "checky")
+
+ self.marionette.switch_to_parent_frame()
+ self.marionette.find_element(By.NAME, "third")
+
+ def test_from_default_context_is_a_noop(self):
+ formpage = self.marionette.absolute_url("formPage.html")
+ self.marionette.navigate(formpage)
+ self.marionette.find_element(By.ID, "checky")
+
+ self.marionette.switch_to_parent_frame()
+ self.marionette.find_element(By.ID, "checky")
+
+ def test_from_second_level(self):
+ frame_html = self.marionette.absolute_url("frameset.html")
+ self.marionette.navigate(frame_html)
+ frame = self.marionette.find_element(By.NAME, "fourth")
+ self.marionette.switch_to_frame(frame)
+
+ second_level = self.marionette.find_element(By.NAME, "child1")
+ self.marionette.switch_to_frame(second_level)
+ self.marionette.find_element(By.NAME, "myCheckBox")
+
+ self.marionette.switch_to_parent_frame()
+
+ second_level = self.marionette.find_element(By.NAME, "child1")
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_switch_frame_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_frame_chrome.py
new file mode 100644
index 0000000000..e7c842f01d
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_frame_chrome.py
@@ -0,0 +1,57 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver import By
+from marionette_driver.errors import JavascriptException
+
+from marionette_harness import MarionetteTestCase, WindowManagerMixin
+
+
+class TestSwitchFrameChrome(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestSwitchFrameChrome, self).setUp()
+ self.marionette.set_context("chrome")
+
+ new_window = self.open_chrome_window("chrome://marionette/content/test.xhtml")
+ self.marionette.switch_to_window(new_window)
+ self.assertNotEqual(
+ self.start_window, self.marionette.current_chrome_window_handle
+ )
+
+ def tearDown(self):
+ self.close_all_windows()
+ super(TestSwitchFrameChrome, self).tearDown()
+
+ def test_switch_simple(self):
+ self.assertIn(
+ "test.xhtml", self.marionette.get_url(), "Initial navigation has failed"
+ )
+ self.marionette.switch_to_frame(0)
+ self.assertIn(
+ "test.xhtml", self.marionette.get_url(), "Switching by index failed"
+ )
+ self.marionette.find_element(By.ID, "testBox")
+ self.marionette.switch_to_frame()
+ self.assertIn(
+ "test.xhtml", self.marionette.get_url(), "Switching by null failed"
+ )
+ iframe = self.marionette.find_element(By.ID, "iframe")
+ self.marionette.switch_to_frame(iframe)
+ self.assertIn(
+ "test.xhtml", self.marionette.get_url(), "Switching by element failed"
+ )
+ self.marionette.find_element(By.ID, "testBox")
+
+ def test_stack_trace(self):
+ self.assertIn(
+ "test.xhtml", self.marionette.get_url(), "Initial navigation has failed"
+ )
+ self.marionette.switch_to_frame(0)
+ self.marionette.find_element(By.ID, "testBox")
+ try:
+ self.marionette.execute_async_script("foo();")
+ except JavascriptException as e:
+ self.assertIn("foo", str(e))
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_switch_window_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_window_chrome.py
new file mode 100644
index 0000000000..6e9405d332
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_window_chrome.py
@@ -0,0 +1,111 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import os
+import sys
+
+from unittest import skipIf
+
+# add this directory to the path
+sys.path.append(os.path.dirname(__file__))
+
+from test_switch_window_content import TestSwitchToWindowContent
+
+
+class TestSwitchWindowChrome(TestSwitchToWindowContent):
+ def setUp(self):
+ super(TestSwitchWindowChrome, self).setUp()
+
+ self.marionette.set_context("chrome")
+
+ def tearDown(self):
+ self.close_all_windows()
+
+ super(TestSwitchWindowChrome, self).tearDown()
+
+ @skipIf(
+ sys.platform.startswith("linux"),
+ "Bug 1511970 - New window isn't moved to the background on Linux",
+ )
+ def test_switch_tabs_for_new_background_window_without_focus_change(self):
+ # Open an additional tab in the original window so we can better check
+ # the selected index in thew new window to be opened.
+ second_tab = self.open_tab(focus=True)
+ self.marionette.switch_to_window(second_tab, focus=True)
+ second_tab_index = self.get_selected_tab_index()
+ self.assertNotEqual(second_tab_index, self.selected_tab_index)
+
+ # Open a new background window, but we are interested in the tab
+ with self.marionette.using_context("content"):
+ tab_in_new_window = self.open_window()
+ self.assertEqual(self.marionette.current_window_handle, second_tab)
+ self.assertEqual(
+ self.marionette.current_chrome_window_handle, self.start_window
+ )
+ self.assertEqual(self.get_selected_tab_index(), second_tab_index)
+
+ # Switch to the tab in the new window but don't focus it
+ self.marionette.switch_to_window(tab_in_new_window, focus=False)
+ self.assertEqual(self.marionette.current_window_handle, tab_in_new_window)
+ self.assertNotEqual(
+ self.marionette.current_chrome_window_handle, self.start_window
+ )
+ self.assertEqual(self.get_selected_tab_index(), second_tab_index)
+
+ def test_switch_tabs_for_new_foreground_window_with_focus_change(self):
+ # Open an addition tab in the original window so we can better check
+ # the selected index in thew new window to be opened.
+ second_tab = self.open_tab()
+ self.marionette.switch_to_window(second_tab, focus=True)
+ second_tab_index = self.get_selected_tab_index()
+ self.assertNotEqual(second_tab_index, self.selected_tab_index)
+
+ # Opens a new window, but we are interested in the tab
+ with self.marionette.using_context("content"):
+ tab_in_new_window = self.open_window(focus=True)
+ self.assertEqual(self.marionette.current_window_handle, second_tab)
+ self.assertEqual(
+ self.marionette.current_chrome_window_handle, self.start_window
+ )
+ self.assertNotEqual(self.get_selected_tab_index(), second_tab_index)
+
+ self.marionette.switch_to_window(tab_in_new_window)
+ self.assertEqual(self.marionette.current_window_handle, tab_in_new_window)
+ self.assertNotEqual(
+ self.marionette.current_chrome_window_handle, self.start_window
+ )
+ self.assertNotEqual(self.get_selected_tab_index(), second_tab_index)
+
+ self.marionette.switch_to_window(second_tab, focus=True)
+ self.assertEqual(self.marionette.current_window_handle, second_tab)
+ self.assertEqual(
+ self.marionette.current_chrome_window_handle, self.start_window
+ )
+ # Bug 1335085 - The focus doesn't change even as requested so.
+ # self.assertEqual(self.get_selected_tab_index(), second_tab_index)
+
+ def test_switch_tabs_for_new_foreground_window_without_focus_change(self):
+ # Open an addition tab in the original window so we can better check
+ # the selected index in thew new window to be opened.
+ second_tab = self.open_tab()
+ self.marionette.switch_to_window(second_tab, focus=True)
+ second_tab_index = self.get_selected_tab_index()
+ self.assertNotEqual(second_tab_index, self.selected_tab_index)
+
+ self.open_window(focus=True)
+ self.assertEqual(self.marionette.current_window_handle, second_tab)
+ self.assertEqual(
+ self.marionette.current_chrome_window_handle, self.start_window
+ )
+ self.assertNotEqual(self.get_selected_tab_index(), second_tab_index)
+
+ # Switch to the second tab in the first window, but don't focus it.
+ self.marionette.switch_to_window(second_tab, focus=False)
+ self.assertEqual(self.marionette.current_window_handle, second_tab)
+ self.assertEqual(
+ self.marionette.current_chrome_window_handle, self.start_window
+ )
+ self.assertNotEqual(self.get_selected_tab_index(), second_tab_index)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_switch_window_content.py b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_window_content.py
new file mode 100644
index 0000000000..653d302084
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_window_content.py
@@ -0,0 +1,213 @@
+# This Source Code Form is subject to the terms of the Mozilla ublic
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import sys
+from unittest import skipIf
+
+from marionette_driver import By
+from marionette_driver.keys import Keys
+
+from marionette_harness import (
+ MarionetteTestCase,
+ WindowManagerMixin,
+)
+
+
+class TestSwitchToWindowContent(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestSwitchToWindowContent, self).setUp()
+
+ if self.marionette.session_capabilities["platformName"] == "mac":
+ self.mod_key = Keys.META
+ else:
+ self.mod_key = Keys.CONTROL
+
+ self.selected_tab_index = self.get_selected_tab_index()
+
+ def tearDown(self):
+ self.close_all_tabs()
+
+ super(TestSwitchToWindowContent, self).tearDown()
+
+ def get_selected_tab_index(self):
+ with self.marionette.using_context("chrome"):
+ return self.marionette.execute_script(
+ """
+ Components.utils.import("resource://gre/modules/AppConstants.jsm");
+
+ let win = null;
+
+ if (AppConstants.MOZ_APP_NAME == "fennec") {
+ Components.utils.import("resource://gre/modules/Services.jsm");
+ win = Services.wm.getMostRecentWindow("navigator:browser");
+ } else {
+ Components.utils.import("resource:///modules/BrowserWindowTracker.jsm");
+ win = BrowserWindowTracker.getTopWindow();
+ }
+
+ let tabBrowser = null;
+
+ // Fennec
+ if (win.BrowserApp) {
+ tabBrowser = win.BrowserApp;
+
+ // Firefox
+ } else if (win.gBrowser) {
+ tabBrowser = win.gBrowser;
+
+ } else {
+ return null;
+ }
+
+ for (let i = 0; i < tabBrowser.tabs.length; i++) {
+ if (tabBrowser.tabs[i] == tabBrowser.selectedTab) {
+ return i;
+ }
+ }
+ """
+ )
+
+ def test_switch_tabs_with_focus_change(self):
+ new_tab = self.open_tab(focus=True)
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+ self.assertNotEqual(self.get_selected_tab_index(), self.selected_tab_index)
+
+ # Switch to new tab first because it is already selected
+ self.marionette.switch_to_window(new_tab)
+ self.assertEqual(self.marionette.current_window_handle, new_tab)
+ self.assertNotEqual(self.get_selected_tab_index(), self.selected_tab_index)
+
+ # Switch to original tab by explicitely setting the focus
+ self.marionette.switch_to_window(self.start_tab, focus=True)
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+ self.assertEqual(self.get_selected_tab_index(), self.selected_tab_index)
+
+ self.marionette.switch_to_window(new_tab)
+ self.marionette.close()
+
+ self.marionette.switch_to_window(self.start_tab)
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+ self.assertEqual(self.get_selected_tab_index(), self.selected_tab_index)
+
+ @skipIf(
+ sys.platform.startswith("linux"),
+ "Bug 1557232 - Original window sometimes doesn't receive focus",
+ )
+ def test_switch_tabs_in_different_windows_with_focus_change(self):
+ new_tab1 = self.open_tab(focus=True)
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+ self.assertEqual(self.get_selected_tab_index(), 1)
+
+ # Switch to new tab first which is already selected
+ self.marionette.switch_to_window(new_tab1)
+ self.assertEqual(self.marionette.current_window_handle, new_tab1)
+ self.assertEqual(self.get_selected_tab_index(), 1)
+
+ # Open a new browser window with a single focused tab already focused
+ with self.marionette.using_context("content"):
+ new_tab2 = self.open_window(focus=True)
+ self.assertEqual(self.marionette.current_window_handle, new_tab1)
+ self.assertEqual(self.get_selected_tab_index(), 0)
+
+ # Switch to that tab
+ self.marionette.switch_to_window(new_tab2)
+ self.assertEqual(self.marionette.current_window_handle, new_tab2)
+ self.assertEqual(self.get_selected_tab_index(), 0)
+
+ # Switch back to the 2nd tab of the original window and setting the focus
+ self.marionette.switch_to_window(new_tab1, focus=True)
+ self.assertEqual(self.marionette.current_window_handle, new_tab1)
+ self.assertEqual(self.get_selected_tab_index(), 1)
+
+ self.marionette.switch_to_window(new_tab2)
+ self.marionette.close()
+
+ self.marionette.switch_to_window(new_tab1)
+ self.marionette.close()
+
+ self.marionette.switch_to_window(self.start_tab)
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+ self.assertEqual(self.get_selected_tab_index(), self.selected_tab_index)
+
+ def test_switch_tabs_without_focus_change(self):
+ new_tab = self.open_tab(focus=True)
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+ self.assertNotEqual(self.get_selected_tab_index(), self.selected_tab_index)
+
+ # Switch to new tab first because it is already selected
+ self.marionette.switch_to_window(new_tab)
+ self.assertEqual(self.marionette.current_window_handle, new_tab)
+
+ # Switch to original tab by explicitely not setting the focus
+ self.marionette.switch_to_window(self.start_tab, focus=False)
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+ self.assertNotEqual(self.get_selected_tab_index(), self.selected_tab_index)
+
+ self.marionette.switch_to_window(new_tab)
+ self.marionette.close()
+
+ self.marionette.switch_to_window(self.start_tab)
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+ self.assertEqual(self.get_selected_tab_index(), self.selected_tab_index)
+
+ def test_switch_from_content_to_chrome_window_should_not_change_selected_tab(self):
+ new_tab = self.open_tab(focus=True)
+
+ self.marionette.switch_to_window(new_tab)
+ self.assertEqual(self.marionette.current_window_handle, new_tab)
+ new_tab_index = self.get_selected_tab_index()
+
+ self.marionette.switch_to_window(self.start_window)
+ self.assertEqual(self.marionette.current_window_handle, new_tab)
+ self.assertEqual(self.get_selected_tab_index(), new_tab_index)
+
+ def test_switch_to_new_private_browsing_tab(self):
+ # Test that tabs (browsers) are correctly registered for a newly opened
+ # private browsing window/tab. This has to also happen without explicitely
+ # switching to the tab itself before using any commands in content scope.
+ #
+ # Note: Not sure why this only affects private browsing windows only.
+ new_tab = self.open_tab(focus=True)
+ self.marionette.switch_to_window(new_tab)
+
+ def open_private_browsing_window_firefox():
+ with self.marionette.using_context("content"):
+ self.marionette.find_element(By.ID, "startPrivateBrowsing").click()
+
+ def open_private_browsing_tab_fennec():
+ with self.marionette.using_context("content"):
+ self.marionette.find_element(By.ID, "newPrivateTabLink").click()
+
+ with self.marionette.using_context("content"):
+ self.marionette.navigate("about:privatebrowsing")
+ if self.marionette.session_capabilities["browserName"] == "fennec":
+ new_pb_tab = self.open_tab(open_private_browsing_tab_fennec)
+ else:
+ new_pb_tab = self.open_tab(open_private_browsing_window_firefox)
+
+ self.marionette.switch_to_window(new_pb_tab)
+ self.assertEqual(self.marionette.current_window_handle, new_pb_tab)
+
+ self.marionette.execute_script(" return true; ")
+
+ def test_switch_to_window_after_remoteness_change(self):
+ # Test that after a remoteness change (and a browsing context swap)
+ # marionette can still switch to tabs correctly.
+ with self.marionette.using_context("content"):
+ # about:robots runs in a different process and will trigger a
+ # remoteness change with or without fission.
+ self.marionette.navigate("about:robots")
+
+ about_robots_tab = self.marionette.current_window_handle
+
+ # Open a new tab and switch to it before trying to switch back to the
+ # initial tab.
+ tab2 = self.open_tab(focus=True)
+ self.marionette.switch_to_window(tab2)
+ self.marionette.close()
+
+ self.marionette.switch_to_window(about_robots_tab)
+ self.assertEqual(self.marionette.current_window_handle, about_robots_tab)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_teardown_context_preserved.py b/testing/marionette/harness/marionette_harness/tests/unit/test_teardown_context_preserved.py
new file mode 100644
index 0000000000..21a66452e4
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_teardown_context_preserved.py
@@ -0,0 +1,23 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_harness import MarionetteTestCase, SkipTest
+
+
+class TestTearDownContext(MarionetteTestCase):
+ def setUp(self):
+ MarionetteTestCase.setUp(self)
+ self.marionette.set_context(self.marionette.CONTEXT_CHROME)
+
+ def tearDown(self):
+ self.assertEqual(self.get_context(), self.marionette.CONTEXT_CHROME)
+ MarionetteTestCase.tearDown(self)
+
+ def get_context(self):
+ return self.marionette._send_message("Marionette:GetContext", key="value")
+
+ def test_skipped_teardown_ok(self):
+ raise SkipTest("This should leave our teardown method in chrome context")
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_text.py b/testing/marionette/harness/marionette_harness/tests/unit/test_text.py
new file mode 100644
index 0000000000..a3adb09496
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_text.py
@@ -0,0 +1,28 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver.by import By
+from marionette_harness import MarionetteTestCase
+
+
+class TestText(MarionetteTestCase):
+ def test_get_text(self):
+ test_html = self.marionette.absolute_url("test.html")
+ self.marionette.navigate(test_html)
+ l = self.marionette.find_element(By.ID, "mozLink")
+ self.assertEqual("Click me!", l.text)
+
+ def test_clear_text(self):
+ test_html = self.marionette.absolute_url("test.html")
+ self.marionette.navigate(test_html)
+ l = self.marionette.find_element(By.NAME, "myInput")
+ self.assertEqual(
+ "asdf", self.marionette.execute_script("return arguments[0].value;", [l])
+ )
+ l.clear()
+ self.assertEqual(
+ "", self.marionette.execute_script("return arguments[0].value;", [l])
+ )
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_text_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_text_chrome.py
new file mode 100644
index 0000000000..bcf42b9fa0
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_text_chrome.py
@@ -0,0 +1,37 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver.by import By
+from marionette_harness import MarionetteTestCase, WindowManagerMixin
+
+
+class TestTextChrome(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestTextChrome, self).setUp()
+ win = self.open_chrome_window("chrome://marionette/content/test.xhtml")
+ self.marionette.switch_to_window(win)
+
+ self.marionette.set_context("chrome")
+
+ def tearDown(self):
+ self.close_all_windows()
+
+ super(TestTextChrome, self).tearDown()
+
+ def test_get_text(self):
+ elem = self.marionette.find_element(By.ID, "testBox")
+ self.assertEqual(elem.text, "box")
+
+ def test_clear_text(self):
+ input = self.marionette.find_element(By.ID, "textInput3")
+ self.assertEqual(
+ "test",
+ self.marionette.execute_script("return arguments[0].value;", [input]),
+ )
+ input.clear()
+ self.assertEqual(
+ "", self.marionette.execute_script("return arguments[0].value;", [input])
+ )
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_timeouts.py b/testing/marionette/harness/marionette_harness/tests/unit/test_timeouts.py
new file mode 100644
index 0000000000..a67c9e756f
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_timeouts.py
@@ -0,0 +1,115 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver.by import By
+from marionette_driver.errors import (
+ MarionetteException,
+ NoSuchElementException,
+ ScriptTimeoutException,
+)
+from marionette_driver.marionette import HTMLElement
+
+from marionette_harness import MarionetteTestCase, run_if_manage_instance
+
+
+class TestTimeouts(MarionetteTestCase):
+ def tearDown(self):
+ self.marionette.timeout.reset()
+ MarionetteTestCase.tearDown(self)
+
+ def test_get_timeout_fraction(self):
+ self.marionette.timeout.script = 0.5
+ self.assertEqual(self.marionette.timeout.script, 0.5)
+
+ def test_page_timeout_notdefinetimeout_pass(self):
+ test_html = self.marionette.absolute_url("test.html")
+ self.marionette.navigate(test_html)
+
+ def test_page_timeout_fail(self):
+ self.marionette.timeout.page_load = 0
+ test_html = self.marionette.absolute_url("slow")
+ with self.assertRaises(MarionetteException):
+ self.marionette.navigate(test_html)
+
+ def test_page_timeout_pass(self):
+ self.marionette.timeout.page_load = 60
+ test_html = self.marionette.absolute_url("test.html")
+ self.marionette.navigate(test_html)
+
+ def test_search_timeout_notfound_settimeout(self):
+ test_html = self.marionette.absolute_url("test.html")
+ self.marionette.navigate(test_html)
+ self.marionette.timeout.implicit = 1
+ with self.assertRaises(NoSuchElementException):
+ self.marionette.find_element(By.ID, "I'm not on the page")
+ self.marionette.timeout.implicit = 0
+ with self.assertRaises(NoSuchElementException):
+ self.marionette.find_element(By.ID, "I'm not on the page")
+
+ def test_search_timeout_found_settimeout(self):
+ test_html = self.marionette.absolute_url("test.html")
+ self.marionette.navigate(test_html)
+ button = self.marionette.find_element(By.ID, "createDivButton")
+ button.click()
+ self.marionette.timeout.implicit = 8
+ self.assertEqual(
+ HTMLElement, type(self.marionette.find_element(By.ID, "newDiv"))
+ )
+
+ def test_search_timeout_found(self):
+ test_html = self.marionette.absolute_url("test.html")
+ self.marionette.navigate(test_html)
+ button = self.marionette.find_element(By.ID, "createDivButton")
+ button.click()
+ self.assertRaises(
+ NoSuchElementException, self.marionette.find_element, By.ID, "newDiv"
+ )
+
+ @run_if_manage_instance("Only runnable if Marionette manages the instance")
+ def test_reset_timeout(self):
+ timeouts = [
+ getattr(self.marionette.timeout, f)
+ for f in (
+ "implicit",
+ "page_load",
+ "script",
+ )
+ ]
+
+ def do_check(callback):
+ for timeout in timeouts:
+ timeout = 10000
+ self.assertEqual(timeout, 10000)
+ callback()
+ for timeout in timeouts:
+ self.assertNotEqual(timeout, 10000)
+
+ def callback_quit():
+ self.marionette.quit()
+ self.marionette.start_session()
+
+ do_check(self.marionette.restart)
+ do_check(callback_quit)
+
+ def test_execute_async_timeout_settimeout(self):
+ test_html = self.marionette.absolute_url("test.html")
+ self.marionette.navigate(test_html)
+ self.marionette.timeout.script = 1
+ with self.assertRaises(ScriptTimeoutException):
+ self.marionette.execute_async_script("var x = 1;")
+
+ def test_no_timeout_settimeout(self):
+ test_html = self.marionette.absolute_url("test.html")
+ self.marionette.navigate(test_html)
+ self.marionette.timeout.script = 1
+ self.assertTrue(
+ self.marionette.execute_async_script(
+ """
+ var callback = arguments[arguments.length - 1];
+ setTimeout(function() { callback(true); }, 500);
+ """
+ )
+ )
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_title.py b/testing/marionette/harness/marionette_harness/tests/unit/test_title.py
new file mode 100644
index 0000000000..8e1f2941b6
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_title.py
@@ -0,0 +1,19 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from six.moves.urllib.parse import quote
+
+from marionette_harness import MarionetteTestCase
+
+
+def inline(doc):
+ return "data:text/html;charset=utf-8,{}".format(quote(doc))
+
+
+class TestTitle(MarionetteTestCase):
+ def test_basic(self):
+ self.marionette.navigate(inline("<title>foo</title>"))
+ self.assertEqual(self.marionette.title, "foo")
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_title_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_title_chrome.py
new file mode 100644
index 0000000000..93ecefe511
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_title_chrome.py
@@ -0,0 +1,30 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_harness import MarionetteTestCase, WindowManagerMixin
+
+
+class TestTitleChrome(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestTitleChrome, self).setUp()
+
+ self.marionette.set_context("chrome")
+
+ def tearDown(self):
+ self.close_all_windows()
+
+ super(TestTitleChrome, self).tearDown()
+
+ def test_get_chrome_title(self):
+ win = self.open_chrome_window("chrome://marionette/content/test.xhtml")
+ self.marionette.switch_to_window(win)
+
+ expected_title = self.marionette.execute_script(
+ """
+ return window.document.documentElement.getAttribute('title');
+ """
+ )
+ self.assertEqual(self.marionette.title, expected_title)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_transport.py b/testing/marionette/harness/marionette_harness/tests/unit/test_transport.py
new file mode 100644
index 0000000000..4b4d2665f3
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_transport.py
@@ -0,0 +1,114 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import json
+
+from marionette_driver.transport import Command, Response
+
+from marionette_harness import MarionetteTestCase
+
+
+get_current_url = ("getCurrentUrl", None)
+execute_script = ("executeScript", {"script": "return 42"})
+
+
+class TestMessageSequencing(MarionetteTestCase):
+ @property
+ def last_id(self):
+ return self.marionette.client.last_id
+
+ @last_id.setter
+ def last_id(self, new_id):
+ self.marionette.client.last_id = new_id
+
+ def send(self, name, params):
+ self.last_id = self.last_id + 1
+ cmd = Command(self.last_id, name, params)
+ self.marionette.client.send(cmd)
+ return self.last_id
+
+
+class MessageTestCase(MarionetteTestCase):
+ def assert_attr(self, obj, attr):
+ self.assertTrue(
+ hasattr(obj, attr), "object does not have attribute {}".format(attr)
+ )
+
+
+class TestCommand(MessageTestCase):
+ def create(self, msgid="msgid", name="name", params="params"):
+ return Command(msgid, name, params)
+
+ def test_initialise(self):
+ cmd = self.create()
+ self.assert_attr(cmd, "id")
+ self.assert_attr(cmd, "name")
+ self.assert_attr(cmd, "params")
+ self.assertEqual("msgid", cmd.id)
+ self.assertEqual("name", cmd.name)
+ self.assertEqual("params", cmd.params)
+
+ def test_stringify(self):
+ cmd = self.create()
+ string = str(cmd)
+ self.assertIn("Command", string)
+ self.assertIn("id=msgid", string)
+ self.assertIn("name=name", string)
+ self.assertIn("params=params", string)
+
+ def test_to_msg(self):
+ cmd = self.create()
+ msg = json.loads(cmd.to_msg())
+ self.assertEquals(msg[0], Command.TYPE)
+ self.assertEquals(msg[1], "msgid")
+ self.assertEquals(msg[2], "name")
+ self.assertEquals(msg[3], "params")
+
+ def test_from_msg(self):
+ msg = [Command.TYPE, "msgid", "name", "params"]
+ payload = json.dumps(msg)
+ cmd = Command.from_msg(payload)
+ self.assertEquals(msg[1], cmd.id)
+ self.assertEquals(msg[2], cmd.name)
+ self.assertEquals(msg[3], cmd.params)
+
+
+class TestResponse(MessageTestCase):
+ def create(self, msgid="msgid", error="error", result="result"):
+ return Response(msgid, error, result)
+
+ def test_initialise(self):
+ resp = self.create()
+ self.assert_attr(resp, "id")
+ self.assert_attr(resp, "error")
+ self.assert_attr(resp, "result")
+ self.assertEqual("msgid", resp.id)
+ self.assertEqual("error", resp.error)
+ self.assertEqual("result", resp.result)
+
+ def test_stringify(self):
+ resp = self.create()
+ string = str(resp)
+ self.assertIn("Response", string)
+ self.assertIn("id=msgid", string)
+ self.assertIn("error=error", string)
+ self.assertIn("result=result", string)
+
+ def test_to_msg(self):
+ resp = self.create()
+ msg = json.loads(resp.to_msg())
+ self.assertEquals(msg[0], Response.TYPE)
+ self.assertEquals(msg[1], "msgid")
+ self.assertEquals(msg[2], "error")
+ self.assertEquals(msg[3], "result")
+
+ def test_from_msg(self):
+ msg = [Response.TYPE, "msgid", "error", "result"]
+ payload = json.dumps(msg)
+ resp = Response.from_msg(payload)
+ self.assertEquals(msg[1], resp.id)
+ self.assertEquals(msg[2], resp.error)
+ self.assertEquals(msg[3], resp.result)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_typing.py b/testing/marionette/harness/marionette_harness/tests/unit/test_typing.py
new file mode 100644
index 0000000000..0ddd7a167d
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_typing.py
@@ -0,0 +1,376 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from six.moves.urllib.parse import quote
+
+from marionette_driver.by import By
+from marionette_driver.errors import ElementNotInteractableException
+from marionette_driver.keys import Keys
+
+from marionette_harness import MarionetteTestCase, skip
+
+
+def inline(doc):
+ return "data:text/html;charset=utf-8,{}".format(quote(doc))
+
+
+class TypingTestCase(MarionetteTestCase):
+ def setUp(self):
+ super(TypingTestCase, self).setUp()
+
+ if self.marionette.session_capabilities["platformName"] == "mac":
+ self.mod_key = Keys.META
+ else:
+ self.mod_key = Keys.CONTROL
+
+
+class TestTypingChrome(TypingTestCase):
+ def setUp(self):
+ super(TestTypingChrome, self).setUp()
+ self.marionette.set_context("chrome")
+
+ def test_cut_and_paste_shortcuts(self):
+ with self.marionette.using_context("content"):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ key_reporter = self.marionette.find_element(By.ID, "keyReporter")
+ self.assertEqual("", key_reporter.get_property("value"))
+ key_reporter.send_keys("zyxwvutsr")
+ self.assertEqual("zyxwvutsr", key_reporter.get_property("value"))
+
+ # select all and cut
+ key_reporter.send_keys(self.mod_key, "a")
+ key_reporter.send_keys(self.mod_key, "x")
+ self.assertEqual("", key_reporter.get_property("value"))
+
+ url_bar = self.marionette.find_element(By.ID, "urlbar-input")
+
+ # Clear contents first
+ url_bar.send_keys(self.mod_key, "a")
+ url_bar.send_keys(Keys.BACK_SPACE)
+ self.assertEqual("", url_bar.get_property("value"))
+
+ url_bar.send_keys(self.mod_key, "v")
+ self.assertEqual("zyxwvutsr", url_bar.get_property("value"))
+
+
+class TestTypingContent(TypingTestCase):
+ def test_should_fire_key_press_events(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+ key_reporter = self.marionette.find_element(By.ID, "keyReporter")
+ key_reporter.send_keys("a")
+ result = self.marionette.find_element(By.ID, "result")
+ self.assertTrue("press:" in result.text)
+
+ def test_should_fire_key_down_events(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+ key_reporter = self.marionette.find_element(By.ID, "keyReporter")
+ key_reporter.send_keys("I")
+ result = self.marionette.find_element(By.ID, "result")
+ self.assertTrue("down" in result.text)
+
+ def test_should_fire_key_up_events(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ key_reporter = self.marionette.find_element(By.ID, "keyReporter")
+ key_reporter.send_keys("a")
+ result = self.marionette.find_element(By.ID, "result")
+ self.assertTrue("up:" in result.text)
+
+ def test_should_type_lowercase_characters(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ key_reporter = self.marionette.find_element(By.ID, "keyReporter")
+ key_reporter.send_keys("abc def")
+ self.assertEqual("abc def", key_reporter.get_property("value"))
+
+ def test_should_type_uppercase_characters(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ key_reporter = self.marionette.find_element(By.ID, "keyReporter")
+ key_reporter.send_keys("ABC DEF")
+ self.assertEqual("ABC DEF", key_reporter.get_property("value"))
+
+ def test_cut_and_paste_shortcuts(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ key_reporter = self.marionette.find_element(By.ID, "keyReporter")
+ self.assertEqual("", key_reporter.get_property("value"))
+ key_reporter.send_keys("zyxwvutsr")
+ self.assertEqual("zyxwvutsr", key_reporter.get_property("value"))
+
+ # select all and cut
+ key_reporter.send_keys(self.mod_key, "a")
+ key_reporter.send_keys(self.mod_key, "x")
+ self.assertEqual("", key_reporter.get_property("value"))
+
+ key_reporter.send_keys(self.mod_key, "v")
+ self.assertEqual("zyxwvutsr", key_reporter.get_property("value"))
+
+ def test_should_type_a_quote_characters(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ key_reporter = self.marionette.find_element(By.ID, "keyReporter")
+ key_reporter.send_keys('"')
+ self.assertEqual('"', key_reporter.get_property("value"))
+
+ def test_should_type_an_at_character(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ key_reporter = self.marionette.find_element(By.ID, "keyReporter")
+ key_reporter.send_keys("@")
+ self.assertEqual("@", key_reporter.get_property("value"))
+
+ def test_should_type_a_mix_of_upper_and_lower_case_character(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ key_reporter = self.marionette.find_element(By.ID, "keyReporter")
+ key_reporter.send_keys("me@eXample.com")
+ self.assertEqual("me@eXample.com", key_reporter.get_property("value"))
+
+ def test_arrow_keys_are_not_printable(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ key_reporter = self.marionette.find_element(By.ID, "keyReporter")
+ key_reporter.send_keys(Keys.ARROW_LEFT)
+ self.assertEqual("", key_reporter.get_property("value"))
+
+ def test_will_simulate_a_key_up_when_entering_text_into_input_elements(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ element = self.marionette.find_element(By.ID, "keyUp")
+ element.send_keys("I like cheese")
+ result = self.marionette.find_element(By.ID, "result")
+ self.assertEqual(result.text, "I like cheese")
+
+ def test_will_simulate_a_key_down_when_entering_text_into_input_elements(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ element = self.marionette.find_element(By.ID, "keyDown")
+ element.send_keys("I like cheese")
+ result = self.marionette.find_element(By.ID, "result")
+ # Because the key down gets the result before the input element is
+ # filled, we're a letter short here
+ self.assertEqual(result.text, "I like chees")
+
+ def test_will_simulate_a_key_press_when_entering_text_into_input_elements(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ element = self.marionette.find_element(By.ID, "keyPress")
+ element.send_keys("I like cheese")
+ result = self.marionette.find_element(By.ID, "result")
+ # Because the key down gets the result before the input element is
+ # filled, we're a letter short here
+ self.assertEqual(result.text, "I like chees")
+
+ def test_will_simulate_a_keyup_when_entering_text_into_textareas(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ element = self.marionette.find_element(By.ID, "keyUpArea")
+ element.send_keys("I like cheese")
+ result = self.marionette.find_element(By.ID, "result")
+ self.assertEqual("I like cheese", result.text)
+
+ def test_will_simulate_a_keydown_when_entering_text_into_textareas(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ element = self.marionette.find_element(By.ID, "keyDownArea")
+ element.send_keys("I like cheese")
+ result = self.marionette.find_element(By.ID, "result")
+ # Because the key down gets the result before the input element is
+ # filled, we're a letter short here
+ self.assertEqual(result.text, "I like chees")
+
+ def test_will_simulate_a_keypress_when_entering_text_into_textareas(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ element = self.marionette.find_element(By.ID, "keyPressArea")
+ element.send_keys("I like cheese")
+ result = self.marionette.find_element(By.ID, "result")
+ # Because the key down gets the result before the input element is
+ # filled, we're a letter short here
+ self.assertEqual(result.text, "I like chees")
+
+ def test_should_report_key_code_of_arrow_keys_up_down_events(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ result = self.marionette.find_element(By.ID, "result")
+ element = self.marionette.find_element(By.ID, "keyReporter")
+
+ element.send_keys(Keys.ARROW_DOWN)
+
+ self.assertIn("down: 40", result.text.strip())
+ self.assertIn("up: 40", result.text.strip())
+
+ element.send_keys(Keys.ARROW_UP)
+ self.assertIn("down: 38", result.text.strip())
+ self.assertIn("up: 38", result.text.strip())
+
+ element.send_keys(Keys.ARROW_LEFT)
+ self.assertIn("down: 37", result.text.strip())
+ self.assertIn("up: 37", result.text.strip())
+
+ element.send_keys(Keys.ARROW_RIGHT)
+ self.assertIn("down: 39", result.text.strip())
+ self.assertIn("up: 39", result.text.strip())
+
+ # And leave no rubbish/printable keys in the "keyReporter"
+ self.assertEqual("", element.get_property("value"))
+
+ @skip("Reenable in Bug 1068728")
+ def test_numeric_shift_keys(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ result = self.marionette.find_element(By.ID, "result")
+ element = self.marionette.find_element(By.ID, "keyReporter")
+ numeric_shifts_etc = '~!@#$%^&*()_+{}:i"<>?|END~'
+ element.send_keys(numeric_shifts_etc)
+ self.assertEqual(numeric_shifts_etc, element.get_property("value"))
+ self.assertIn(" up: 16", result.text.strip())
+
+ def test_numeric_non_shift_keys(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+ element = self.marionette.find_element(By.ID, "keyReporter")
+ numeric_line_chars_non_shifted = "`1234567890-=[]\\,.'/42"
+ element.send_keys(numeric_line_chars_non_shifted)
+ self.assertEqual(numeric_line_chars_non_shifted, element.get_property("value"))
+
+ def test_lowercase_alpha_keys(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ element = self.marionette.find_element(By.ID, "keyReporter")
+ lower_alphas = "abcdefghijklmnopqrstuvwxyz"
+ element.send_keys(lower_alphas)
+ self.assertEqual(lower_alphas, element.get_property("value"))
+
+ @skip("Reenable in Bug 1068735")
+ def test_uppercase_alpha_keys(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ result = self.marionette.find_element(By.ID, "result")
+ element = self.marionette.find_element(By.ID, "keyReporter")
+ upper_alphas = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ element.send_keys(upper_alphas)
+ self.assertEqual(upper_alphas, element.get_property("value"))
+ self.assertIn(" up: 16", result.text.strip())
+
+ @skip("Reenable in Bug 1068726")
+ def test_all_printable_keys(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ result = self.marionette.find_element(By.ID, "result")
+ element = self.marionette.find_element(By.ID, "keyReporter")
+ all_printable = (
+ "!\"#$%&'()*+,-./0123456789:<=>?@ "
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ [\\]^_`"
+ "abcdefghijklmnopqrstuvwxyz{|}~"
+ )
+ element.send_keys(all_printable)
+
+ self.assertTrue(all_printable, element.get_property("value"))
+ self.assertIn(" up: 16", result.text.strip())
+
+ @skip("Reenable in Bug 1068733")
+ def test_special_space_keys(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ element = self.marionette.find_element(By.ID, "keyReporter")
+ element.send_keys("abcd" + Keys.SPACE + "fgh" + Keys.SPACE + "ij")
+ self.assertEqual("abcd fgh ij", element.get_property("value"))
+
+ def test_should_type_an_integer(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ element = self.marionette.find_element(By.ID, "keyReporter")
+ element.send_keys(1234)
+ self.assertEqual("1234", element.get_property("value"))
+
+ def test_should_send_keys_to_elements_without_the_value_attribute(self):
+ test_html = self.marionette.absolute_url("keyboard.html")
+ self.marionette.navigate(test_html)
+
+ # If we don't get an error below we are good
+ self.marionette.find_element(By.TAG_NAME, "body").send_keys("foo")
+
+ def test_appends_to_input_text(self):
+ self.marionette.navigate(inline("<input>"))
+ el = self.marionette.find_element(By.TAG_NAME, "input")
+ el.send_keys("foo")
+ el.send_keys("bar")
+ self.assertEqual("foobar", el.get_property("value"))
+
+ def test_appends_to_textarea(self):
+ self.marionette.navigate(inline("<textarea></textarea>"))
+ textarea = self.marionette.find_element(By.TAG_NAME, "textarea")
+ textarea.send_keys("foo")
+ textarea.send_keys("bar")
+ self.assertEqual("foobar", textarea.get_property("value"))
+
+ def test_send_keys_to_type_input(self):
+ test_html = self.marionette.absolute_url("html5/test_html_inputs.html")
+ self.marionette.navigate(test_html)
+
+ num_input = self.marionette.find_element(By.ID, "number")
+ self.assertEqual(
+ "", self.marionette.execute_script("return arguments[0].value", [num_input])
+ )
+ num_input.send_keys("1234")
+ self.assertEqual(
+ "1234",
+ self.marionette.execute_script("return arguments[0].value", [num_input]),
+ )
+
+ def test_insert_keys(self):
+ l = self.marionette.find_element(By.ID, "change")
+ l.send_keys("abde")
+ self.assertEqual(
+ "abde", self.marionette.execute_script("return arguments[0].value;", [l])
+ )
+
+ # Set caret position to the middle of the input text.
+ self.marionette.execute_script(
+ """var el = arguments[0];
+ el.selectionStart = el.selectionEnd = el.value.length / 2;""",
+ script_args=[l],
+ )
+
+ l.send_keys("c")
+ self.assertEqual(
+ "abcde", self.marionette.execute_script("return arguments[0].value;", [l])
+ )
+
+
+class TestTypingContentLegacy(TestTypingContent):
+ def setUp(self):
+ super(TestTypingContent, self).setUp()
+
+ self.marionette.delete_session()
+ self.marionette.start_session({"moz:webdriverClick": False})
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_unhandled_prompt_behavior.py b/testing/marionette/harness/marionette_harness/tests/unit/test_unhandled_prompt_behavior.py
new file mode 100644
index 0000000000..5c9f7cc200
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_unhandled_prompt_behavior.py
@@ -0,0 +1,128 @@
+from __future__ import absolute_import
+
+from marionette_driver import errors
+from marionette_driver.marionette import Alert
+from marionette_driver.wait import Wait
+from marionette_harness import MarionetteTestCase, parameterized
+
+
+class TestUnhandledPromptBehavior(MarionetteTestCase):
+ def setUp(self):
+ super(TestUnhandledPromptBehavior, self).setUp()
+
+ self.marionette.delete_session()
+
+ def tearDown(self):
+ # Ensure to close a possible remaining tab modal dialog
+ try:
+ alert = self.marionette.switch_to_alert()
+ alert.dismiss()
+
+ Wait(self.marionette).until(lambda _: not self.alert_present)
+ except errors.NoAlertPresentException:
+ pass
+
+ super(TestUnhandledPromptBehavior, self).tearDown()
+
+ @property
+ def alert_present(self):
+ try:
+ Alert(self.marionette).text
+ return True
+ except errors.NoAlertPresentException:
+ return False
+
+ def perform_user_prompt_check(
+ self,
+ prompt_type,
+ text,
+ expected_result,
+ expected_close=True,
+ expected_notify=True,
+ ):
+ if prompt_type not in ["alert", "confirm", "prompt"]:
+ raise TypeError("Invalid dialog type: {}".format(prompt_type))
+
+ # No need to call resolve() because opening a prompt stops the script
+ self.marionette.execute_async_script(
+ """
+ window.return_value = null;
+ window.return_value = window[arguments[0]](arguments[1]);
+ """,
+ script_args=(prompt_type, text),
+ )
+
+ if expected_notify:
+ with self.assertRaises(errors.UnexpectedAlertOpen):
+ self.marionette.title
+ # Bug 1469752 - WebDriverError misses optional data property
+ # self.assertEqual(ex.data.text, text)
+ else:
+ self.marionette.title
+
+ self.assertEqual(self.alert_present, not expected_close)
+
+ # Close an expected left-over user prompt
+ if not expected_close:
+ alert = self.marionette.switch_to_alert()
+ alert.dismiss()
+
+ else:
+ prompt_result = self.marionette.execute_script(
+ "return window.return_value", new_sandbox=False
+ )
+ self.assertEqual(prompt_result, expected_result)
+
+ @parameterized("alert", "alert", None)
+ @parameterized("confirm", "confirm", True)
+ @parameterized("prompt", "prompt", "")
+ def test_accept(self, prompt_type, result):
+ self.marionette.start_session({"unhandledPromptBehavior": "accept"})
+ self.perform_user_prompt_check(
+ prompt_type, "foo {}".format(prompt_type), result, expected_notify=False
+ )
+
+ @parameterized("alert", "alert", None)
+ @parameterized("confirm", "confirm", True)
+ @parameterized("prompt", "prompt", "")
+ def test_accept_and_notify(self, prompt_type, result):
+ self.marionette.start_session({"unhandledPromptBehavior": "accept and notify"})
+ self.perform_user_prompt_check(
+ prompt_type, "foo {}".format(prompt_type), result
+ )
+
+ @parameterized("alert", "alert", None)
+ @parameterized("confirm", "confirm", False)
+ @parameterized("prompt", "prompt", None)
+ def test_dismiss(self, prompt_type, result):
+ self.marionette.start_session({"unhandledPromptBehavior": "dismiss"})
+ self.perform_user_prompt_check(
+ prompt_type, "foo {}".format(prompt_type), result, expected_notify=False
+ )
+
+ @parameterized("alert", "alert", None)
+ @parameterized("confirm", "confirm", False)
+ @parameterized("prompt", "prompt", None)
+ def test_dismiss_and_notify(self, prompt_type, result):
+ self.marionette.start_session({"unhandledPromptBehavior": "dismiss and notify"})
+ self.perform_user_prompt_check(
+ prompt_type, "foo {}".format(prompt_type), result
+ )
+
+ @parameterized("alert", "alert", None)
+ @parameterized("confirm", "confirm", None)
+ @parameterized("prompt", "prompt", None)
+ def test_ignore(self, prompt_type, result):
+ self.marionette.start_session({"unhandledPromptBehavior": "ignore"})
+ self.perform_user_prompt_check(
+ prompt_type, "foo {}".format(prompt_type), result, expected_close=False
+ )
+
+ @parameterized("alert", "alert", None)
+ @parameterized("confirm", "confirm", False)
+ @parameterized("prompt", "prompt", None)
+ def test_default(self, prompt_type, result):
+ self.marionette.start_session({})
+ self.perform_user_prompt_check(
+ prompt_type, "foo {}".format(prompt_type), result
+ )
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_visibility.py b/testing/marionette/harness/marionette_harness/tests/unit/test_visibility.py
new file mode 100644
index 0000000000..fbe45d618b
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_visibility.py
@@ -0,0 +1,177 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from six.moves.urllib.parse import quote
+
+from marionette_driver.by import By
+
+from marionette_harness import MarionetteTestCase
+
+
+def inline(doc):
+ return "data:text/html;charset=utf-8,{}".format(quote(doc))
+
+
+def element_direction_doc(direction):
+ return inline(
+ """
+ <meta name="viewport" content="initial-scale=1,width=device-width">
+ <style>
+ .element{{
+ position: absolute;
+ {}: -50px;
+ background_color: red;
+ width: 100px;
+ height: 100px;
+ }}
+ </style>
+ <div class='element'></div>""".format(
+ direction
+ )
+ )
+
+
+class TestVisibility(MarionetteTestCase):
+ def testShouldAllowTheUserToTellIfAnElementIsDisplayedOrNot(self):
+ test_html = self.marionette.absolute_url("visibility.html")
+ self.marionette.navigate(test_html)
+
+ self.assertTrue(self.marionette.find_element(By.ID, "displayed").is_displayed())
+ self.assertFalse(self.marionette.find_element(By.ID, "none").is_displayed())
+ self.assertFalse(
+ self.marionette.find_element(By.ID, "suppressedParagraph").is_displayed()
+ )
+ self.assertFalse(self.marionette.find_element(By.ID, "hidden").is_displayed())
+
+ def testVisibilityShouldTakeIntoAccountParentVisibility(self):
+ test_html = self.marionette.absolute_url("visibility.html")
+ self.marionette.navigate(test_html)
+
+ childDiv = self.marionette.find_element(By.ID, "hiddenchild")
+ hiddenLink = self.marionette.find_element(By.ID, "hiddenlink")
+
+ self.assertFalse(childDiv.is_displayed())
+ self.assertFalse(hiddenLink.is_displayed())
+
+ def testShouldCountElementsAsVisibleIfStylePropertyHasBeenSet(self):
+ test_html = self.marionette.absolute_url("visibility.html")
+ self.marionette.navigate(test_html)
+ shown = self.marionette.find_element(By.ID, "visibleSubElement")
+ self.assertTrue(shown.is_displayed())
+
+ def testShouldModifyTheVisibilityOfAnElementDynamically(self):
+ test_html = self.marionette.absolute_url("visibility.html")
+ self.marionette.navigate(test_html)
+ element = self.marionette.find_element(By.ID, "hideMe")
+ self.assertTrue(element.is_displayed())
+ element.click()
+ self.assertFalse(element.is_displayed())
+
+ def testHiddenInputElementsAreNeverVisible(self):
+ test_html = self.marionette.absolute_url("visibility.html")
+ self.marionette.navigate(test_html)
+
+ shown = self.marionette.find_element(By.NAME, "hidden")
+
+ self.assertFalse(shown.is_displayed())
+
+ def test_elements_not_displayed_with_negative_transform(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <div id="y" style="transform: translateY(-200%);">hidden</div>
+ <div id="x" style="transform: translateX(-200%);">hidden</div>
+ """
+ )
+ )
+
+ element_x = self.marionette.find_element(By.ID, "x")
+ self.assertFalse(element_x.is_displayed())
+ element_y = self.marionette.find_element(By.ID, "y")
+ self.assertFalse(element_y.is_displayed())
+
+ def test_elements_not_displayed_with_parents_having_negative_transform(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <div style="transform: translateY(-200%);"><p id="y">hidden</p></div>
+ <div style="transform: translateX(-200%);"><p id="x">hidden</p></div>
+ """
+ )
+ )
+
+ element_x = self.marionette.find_element(By.ID, "x")
+ self.assertFalse(element_x.is_displayed())
+ element_y = self.marionette.find_element(By.ID, "y")
+ self.assertFalse(element_y.is_displayed())
+
+ def test_element_displayed_with_zero_transform(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <div style="transform: translate(0px, 0px);">not hidden</div>
+ """
+ )
+ )
+ element = self.marionette.find_element(By.TAG_NAME, "div")
+ self.assertTrue(element.is_displayed())
+
+ def test_element_displayed_with_negative_transform_but_in_viewport(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <div style="margin-top: 1em; transform: translateY(-75%);">not hidden</div>
+ """
+ )
+ )
+ element = self.marionette.find_element(By.TAG_NAME, "div")
+ self.assertTrue(element.is_displayed())
+
+ def testShouldSayElementIsInvisibleWhenOverflowXIsHiddenAndOutOfViewport(self):
+ test_html = self.marionette.absolute_url("bug814037.html")
+ self.marionette.navigate(test_html)
+ overflow_x = self.marionette.find_element(By.ID, "assertMe2")
+ self.assertFalse(overflow_x.is_displayed())
+
+ def testShouldShowElementNotVisibleWithHiddenAttribute(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <p hidden>foo</p>
+ """
+ )
+ )
+ singleHidden = self.marionette.find_element(By.TAG_NAME, "p")
+ self.assertFalse(singleHidden.is_displayed())
+
+ def testShouldShowElementNotVisibleWhenParentElementHasHiddenAttribute(self):
+ self.marionette.navigate(
+ inline(
+ """
+ <div hidden>
+ <p>foo</p>
+ </div>
+ """
+ )
+ )
+ child = self.marionette.find_element(By.TAG_NAME, "p")
+ self.assertFalse(child.is_displayed())
+
+ def testShouldClickOnELementPartiallyOffLeft(self):
+ test_html = self.marionette.navigate(element_direction_doc("left"))
+ self.marionette.find_element(By.CSS_SELECTOR, ".element").click()
+
+ def testShouldClickOnELementPartiallyOffRight(self):
+ test_html = self.marionette.navigate(element_direction_doc("right"))
+ self.marionette.find_element(By.CSS_SELECTOR, ".element").click()
+
+ def testShouldClickOnELementPartiallyOffTop(self):
+ test_html = self.marionette.navigate(element_direction_doc("top"))
+ self.marionette.find_element(By.CSS_SELECTOR, ".element").click()
+
+ def testShouldClickOnELementPartiallyOffBottom(self):
+ test_html = self.marionette.navigate(element_direction_doc("bottom"))
+ self.marionette.find_element(By.CSS_SELECTOR, ".element").click()
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_wait.py b/testing/marionette/harness/marionette_harness/tests/unit/test_wait.py
new file mode 100644
index 0000000000..fc8312b9dc
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_wait.py
@@ -0,0 +1,349 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import sys
+import time
+
+import six
+
+from marionette_driver import errors, wait
+from marionette_driver.wait import Wait
+
+from marionette_harness import MarionetteTestCase
+
+
+class TickingClock(object):
+ def __init__(self, incr=1):
+ self.ticks = 0
+ self.increment = incr
+
+ def sleep(self, dur=None):
+ dur = dur if dur is not None else self.increment
+ self.ticks += dur
+
+ @property
+ def now(self):
+ return self.ticks
+
+
+class SequenceClock(object):
+ def __init__(self, times):
+ self.times = times
+ self.i = 0
+
+ @property
+ def now(self):
+ if len(self.times) > self.i:
+ self.i += 1
+ return self.times[self.i - 1]
+
+ def sleep(self, dur):
+ pass
+
+
+class MockMarionette(object):
+ def __init__(self):
+ self.waited = 0
+
+ def exception(self, e=None, wait=1):
+ self.wait()
+ if self.waited == wait:
+ if e is None:
+ e = Exception
+ raise e
+
+ def true(self, wait=1):
+ self.wait()
+ if self.waited == wait:
+ return True
+ return None
+
+ def false(self, wait=1):
+ self.wait()
+ return False
+
+ def none(self, wait=1):
+ self.wait()
+ return None
+
+ def value(self, value, wait=1):
+ self.wait()
+ if self.waited == wait:
+ return value
+ return None
+
+ def wait(self):
+ self.waited += 1
+
+
+def at_third_attempt(clock, end):
+ return clock.now == 2
+
+
+def now(clock, end):
+ return True
+
+
+class SystemClockTest(MarionetteTestCase):
+ def setUp(self):
+ super(SystemClockTest, self).setUp()
+ self.clock = wait.SystemClock()
+
+ def test_construction_initializes_time(self):
+ self.assertEqual(self.clock._time, time)
+
+ def test_sleep(self):
+ start = time.time()
+ self.clock.sleep(0.1)
+ end = time.time() - start
+ self.assertGreater(end, 0)
+
+ def test_time_now(self):
+ self.assertIsNotNone(self.clock.now)
+
+
+class FormalWaitTest(MarionetteTestCase):
+ def setUp(self):
+ super(FormalWaitTest, self).setUp()
+ self.m = MockMarionette()
+ self.m.timeout = 123
+
+ def test_construction_with_custom_timeout(self):
+ wt = Wait(self.m, timeout=42)
+ self.assertEqual(wt.timeout, 42)
+
+ def test_construction_with_custom_interval(self):
+ wt = Wait(self.m, interval=42)
+ self.assertEqual(wt.interval, 42)
+
+ def test_construction_with_custom_clock(self):
+ c = TickingClock(1)
+ wt = Wait(self.m, clock=c)
+ self.assertEqual(wt.clock, c)
+
+ def test_construction_with_custom_exception(self):
+ wt = Wait(self.m, ignored_exceptions=Exception)
+ self.assertIn(Exception, wt.exceptions)
+ self.assertEqual(len(wt.exceptions), 1)
+
+ def test_construction_with_custom_exception_list(self):
+ exc = [Exception, ValueError]
+ wt = Wait(self.m, ignored_exceptions=exc)
+ for e in exc:
+ self.assertIn(e, wt.exceptions)
+ self.assertEqual(len(wt.exceptions), len(exc))
+
+ def test_construction_with_custom_exception_tuple(self):
+ exc = (Exception, ValueError)
+ wt = Wait(self.m, ignored_exceptions=exc)
+ for e in exc:
+ self.assertIn(e, wt.exceptions)
+ self.assertEqual(len(wt.exceptions), len(exc))
+
+ def test_duplicate_exceptions(self):
+ wt = Wait(self.m, ignored_exceptions=[Exception, Exception])
+ self.assertIn(Exception, wt.exceptions)
+ self.assertEqual(len(wt.exceptions), 1)
+
+ def test_default_timeout(self):
+ self.assertEqual(wait.DEFAULT_TIMEOUT, 5)
+
+ def test_default_interval(self):
+ self.assertEqual(wait.DEFAULT_INTERVAL, 0.1)
+
+ def test_end_property(self):
+ wt = Wait(self.m)
+ self.assertIsNotNone(wt.end)
+
+ def test_marionette_property(self):
+ wt = Wait(self.m)
+ self.assertEqual(wt.marionette, self.m)
+
+ def test_clock_property(self):
+ wt = Wait(self.m)
+ self.assertIsInstance(wt.clock, wait.SystemClock)
+
+ def test_timeout_uses_default_if_marionette_timeout_is_none(self):
+ self.m.timeout = None
+ wt = Wait(self.m)
+ self.assertEqual(wt.timeout, wait.DEFAULT_TIMEOUT)
+
+
+class PredicatesTest(MarionetteTestCase):
+ def test_until(self):
+ c = wait.SystemClock()
+ self.assertFalse(wait.until_pred(c, six.MAXSIZE))
+ self.assertTrue(wait.until_pred(c, 0))
+
+
+class WaitUntilTest(MarionetteTestCase):
+ def setUp(self):
+ super(WaitUntilTest, self).setUp()
+
+ self.m = MockMarionette()
+ self.clock = TickingClock()
+ self.wt = Wait(self.m, timeout=10, interval=1, clock=self.clock)
+
+ def test_true(self):
+ r = self.wt.until(lambda x: x.true())
+ self.assertTrue(r)
+ self.assertEqual(self.clock.ticks, 0)
+
+ def test_true_within_timeout(self):
+ r = self.wt.until(lambda x: x.true(wait=5))
+ self.assertTrue(r)
+ self.assertEqual(self.clock.ticks, 4)
+
+ def test_timeout(self):
+ with self.assertRaises(errors.TimeoutException):
+ r = self.wt.until(lambda x: x.true(wait=15))
+ self.assertEqual(self.clock.ticks, 10)
+
+ def test_exception_raises_immediately(self):
+ with self.assertRaises(TypeError):
+ self.wt.until(lambda x: x.exception(e=TypeError))
+ self.assertEqual(self.clock.ticks, 0)
+
+ def test_ignored_exception(self):
+ self.wt.exceptions = (TypeError,)
+ with self.assertRaises(errors.TimeoutException):
+ self.wt.until(lambda x: x.exception(e=TypeError))
+
+ def test_ignored_exception_wrapped_in_timeoutexception(self):
+ self.wt.exceptions = (TypeError,)
+
+ exc = None
+ try:
+ self.wt.until(lambda x: x.exception(e=TypeError))
+ except Exception as e:
+ exc = e
+
+ s = str(exc)
+ self.assertIsNotNone(exc)
+ self.assertIsInstance(exc, errors.TimeoutException)
+ self.assertIn(", caused by {0!r}".format(TypeError), s)
+ self.assertIn("self.wt.until(lambda x: x.exception(e=TypeError))", s)
+
+ def test_ignored_exception_after_timeout_is_not_raised(self):
+ with self.assertRaises(errors.TimeoutException):
+ r = self.wt.until(lambda x: x.exception(wait=15))
+ self.assertEqual(self.clock.ticks, 10)
+
+ def test_keyboard_interrupt(self):
+ with self.assertRaises(KeyboardInterrupt):
+ self.wt.until(lambda x: x.exception(e=KeyboardInterrupt))
+
+ def test_system_exit(self):
+ with self.assertRaises(SystemExit):
+ self.wt.until(lambda x: x.exception(SystemExit))
+
+ def test_true_condition_returns_immediately(self):
+ r = self.wt.until(lambda x: x.true())
+ self.assertIsInstance(r, bool)
+ self.assertTrue(r)
+ self.assertEqual(self.clock.ticks, 0)
+
+ def test_value(self):
+ r = self.wt.until(lambda x: "foo")
+ self.assertEqual(r, "foo")
+ self.assertEqual(self.clock.ticks, 0)
+
+ def test_custom_predicate(self):
+ r = self.wt.until(lambda x: x.true(wait=2), is_true=at_third_attempt)
+ self.assertTrue(r)
+ self.assertEqual(self.clock.ticks, 1)
+
+ def test_custom_predicate_times_out(self):
+ with self.assertRaises(errors.TimeoutException):
+ self.wt.until(lambda x: x.true(wait=4), is_true=at_third_attempt)
+
+ self.assertEqual(self.clock.ticks, 2)
+
+ def test_timeout_elapsed_duration(self):
+ with self.assertRaisesRegexp(
+ errors.TimeoutException, "Timed out after 2.0 seconds"
+ ):
+ self.wt.until(lambda x: x.true(wait=4), is_true=at_third_attempt)
+
+ def test_timeout_elapsed_rounding(self):
+ wt = Wait(self.m, clock=SequenceClock([1, 0.01, 1]), timeout=0)
+ with self.assertRaisesRegexp(
+ errors.TimeoutException, "Timed out after 1.0 seconds"
+ ):
+ wt.until(lambda x: x.true(), is_true=now)
+
+ def test_timeout_elapsed_interval_by_delayed_condition_return(self):
+ def callback(mn):
+ self.clock.sleep(11)
+ return mn.false()
+
+ with self.assertRaisesRegexp(
+ errors.TimeoutException, "Timed out after 11.0 seconds"
+ ):
+ self.wt.until(callback)
+ # With a delayed conditional return > timeout, only 1 iteration is
+ # possible
+ self.assertEqual(self.m.waited, 1)
+
+ def test_timeout_with_delayed_condition_return(self):
+ def callback(mn):
+ self.clock.sleep(0.5)
+ return mn.false()
+
+ with self.assertRaisesRegexp(
+ errors.TimeoutException, "Timed out after 10.0 seconds"
+ ):
+ self.wt.until(callback)
+ # With a delayed conditional return < interval, 10 iterations should be
+ # possible
+ self.assertEqual(self.m.waited, 10)
+
+ def test_timeout_interval_shorter_than_delayed_condition_return(self):
+ def callback(mn):
+ self.clock.sleep(2)
+ return mn.false()
+
+ with self.assertRaisesRegexp(
+ errors.TimeoutException, "Timed out after 10.0 seconds"
+ ):
+ self.wt.until(callback)
+ # With a delayed return of the conditional which takes twice that long than the interval,
+ # half of the iterations should be possible
+ self.assertEqual(self.m.waited, 5)
+
+ def test_message(self):
+ self.wt.exceptions = (TypeError,)
+ exc = None
+ try:
+ self.wt.until(lambda x: x.exception(e=TypeError), message="hooba")
+ except errors.TimeoutException as e:
+ exc = e
+
+ result = str(exc)
+ self.assertIn("seconds with message: hooba, caused by", result)
+
+ def test_no_message(self):
+ self.wt.exceptions = (TypeError,)
+ exc = None
+ try:
+ self.wt.until(lambda x: x.exception(e=TypeError), message="")
+ except errors.TimeoutException as e:
+ exc = e
+
+ result = str(exc)
+ self.assertIn("seconds, caused by", result)
+
+ def test_message_has_none_as_its_value(self):
+ self.wt.exceptions = (TypeError,)
+ exc = None
+ try:
+ self.wt.until(False, None, None)
+ except errors.TimeoutException as e:
+ exc = e
+
+ result = str(exc)
+ self.assertNotIn("with message:", result)
+ self.assertNotIn("secondsNone", result)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_close_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_close_chrome.py
new file mode 100644
index 0000000000..d38e456737
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_close_chrome.py
@@ -0,0 +1,75 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_harness import MarionetteTestCase, WindowManagerMixin
+
+
+class TestCloseWindow(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestCloseWindow, self).setUp()
+
+ self.marionette.set_context("chrome")
+
+ def tearDown(self):
+ self.close_all_windows()
+ self.close_all_tabs()
+
+ super(TestCloseWindow, self).tearDown()
+
+ def test_close_chrome_window_for_browser_window(self):
+ new_window = self.open_window()
+ self.marionette.switch_to_window(new_window)
+
+ self.assertNotIn(new_window, self.marionette.window_handles)
+ chrome_window_handles = self.marionette.close_chrome_window()
+ self.assertNotIn(new_window, chrome_window_handles)
+ self.assertListEqual(self.start_windows, chrome_window_handles)
+ self.assertNotIn(new_window, self.marionette.window_handles)
+
+ def test_close_chrome_window_for_non_browser_window(self):
+ win = self.open_chrome_window("chrome://marionette/content/test.xhtml")
+ self.marionette.switch_to_window(win)
+
+ self.assertIn(win, self.marionette.chrome_window_handles)
+ self.assertNotIn(win, self.marionette.window_handles)
+ chrome_window_handles = self.marionette.close_chrome_window()
+ self.assertNotIn(win, chrome_window_handles)
+ self.assertListEqual(self.start_windows, chrome_window_handles)
+ self.assertNotIn(win, self.marionette.chrome_window_handles)
+
+ def test_close_chrome_window_for_last_open_window(self):
+ self.close_all_windows()
+
+ self.assertListEqual([], self.marionette.close_chrome_window())
+ self.assertListEqual([self.start_tab], self.marionette.window_handles)
+ self.assertListEqual([self.start_window], self.marionette.chrome_window_handles)
+ self.assertIsNotNone(self.marionette.session)
+
+ def test_close_window_for_browser_tab(self):
+ new_tab = self.open_tab()
+ self.marionette.switch_to_window(new_tab)
+
+ window_handles = self.marionette.close()
+ self.assertNotIn(new_tab, window_handles)
+ self.assertListEqual(self.start_tabs, window_handles)
+
+ def test_close_window_for_browser_window_with_single_tab(self):
+ new_window = self.open_window()
+ self.marionette.switch_to_window(new_window)
+
+ self.assertEqual(len(self.start_tabs) + 1, len(self.marionette.window_handles))
+ window_handles = self.marionette.close()
+ self.assertNotIn(new_window, window_handles)
+ self.assertListEqual(self.start_tabs, window_handles)
+ self.assertListEqual(self.start_windows, self.marionette.chrome_window_handles)
+
+ def test_close_window_for_last_open_tab(self):
+ self.close_all_tabs()
+
+ self.assertListEqual([], self.marionette.close())
+ self.assertListEqual([self.start_tab], self.marionette.window_handles)
+ self.assertListEqual([self.start_window], self.marionette.chrome_window_handles)
+ self.assertIsNotNone(self.marionette.session)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_close_content.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_close_content.py
new file mode 100644
index 0000000000..cca97f39e6
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_close_content.py
@@ -0,0 +1,127 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from six.moves.urllib.parse import quote
+
+from marionette_driver.by import By
+from marionette_harness import MarionetteTestCase, WindowManagerMixin
+
+
+def inline(doc):
+ return "data:text/html;charset=utf-8,{}".format(quote(doc))
+
+
+class TestCloseWindow(WindowManagerMixin, MarionetteTestCase):
+ def tearDown(self):
+ self.close_all_windows()
+ self.close_all_tabs()
+
+ super(TestCloseWindow, self).tearDown()
+
+ def test_close_chrome_window_for_browser_window(self):
+ with self.marionette.using_context("chrome"):
+ new_window = self.open_window()
+ self.marionette.switch_to_window(new_window)
+
+ self.assertIn(new_window, self.marionette.chrome_window_handles)
+ chrome_window_handles = self.marionette.close_chrome_window()
+ self.assertNotIn(new_window, chrome_window_handles)
+ self.assertListEqual(self.start_windows, chrome_window_handles)
+ self.assertNotIn(new_window, self.marionette.window_handles)
+
+ def test_close_chrome_window_for_non_browser_window(self):
+ new_window = self.open_chrome_window("chrome://marionette/content/test.xhtml")
+ self.marionette.switch_to_window(new_window)
+
+ self.assertIn(new_window, self.marionette.chrome_window_handles)
+ self.assertNotIn(new_window, self.marionette.window_handles)
+ chrome_window_handles = self.marionette.close_chrome_window()
+ self.assertNotIn(new_window, chrome_window_handles)
+ self.assertListEqual(self.start_windows, chrome_window_handles)
+ self.assertNotIn(new_window, self.marionette.window_handles)
+
+ def test_close_chrome_window_for_last_open_window(self):
+ self.close_all_windows()
+
+ self.assertListEqual([], self.marionette.close_chrome_window())
+ self.assertListEqual([self.start_tab], self.marionette.window_handles)
+ self.assertListEqual([self.start_window], self.marionette.chrome_window_handles)
+ self.assertIsNotNone(self.marionette.session)
+
+ def test_close_window_for_browser_tab(self):
+ new_tab = self.open_tab()
+ self.marionette.switch_to_window(new_tab)
+
+ window_handles = self.marionette.close()
+ self.assertNotIn(new_tab, window_handles)
+ self.assertListEqual(self.start_tabs, window_handles)
+
+ def test_close_window_with_dismissed_beforeunload_prompt(self):
+ new_tab = self.open_tab()
+ self.marionette.switch_to_window(new_tab)
+
+ self.marionette.navigate(
+ inline(
+ """
+ <input type="text">
+ <script>
+ window.addEventListener("beforeunload", function (event) {
+ event.preventDefault();
+ });
+ </script>
+ """
+ )
+ )
+
+ self.marionette.find_element(By.TAG_NAME, "input").send_keys("foo")
+ self.marionette.close()
+
+ def test_close_window_for_browser_window_with_single_tab(self):
+ new_tab = self.open_window()
+ self.marionette.switch_to_window(new_tab)
+
+ self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1)
+ window_handles = self.marionette.close()
+ self.assertNotIn(new_tab, window_handles)
+ self.assertListEqual(self.start_tabs, window_handles)
+ self.assertListEqual(self.start_windows, self.marionette.chrome_window_handles)
+
+ def test_close_window_for_last_open_tab(self):
+ self.close_all_tabs()
+
+ self.assertListEqual([], self.marionette.close())
+ self.assertListEqual([self.start_tab], self.marionette.window_handles)
+ self.assertListEqual([self.start_window], self.marionette.chrome_window_handles)
+ self.assertIsNotNone(self.marionette.session)
+
+ def test_close_browserless_tab(self):
+ self.close_all_tabs()
+
+ test_page = self.marionette.absolute_url("windowHandles.html")
+ new_tab = self.open_tab()
+ self.marionette.switch_to_window(new_tab)
+ self.marionette.navigate(test_page)
+ self.marionette.switch_to_window(self.start_tab)
+
+ with self.marionette.using_context("chrome"):
+ self.marionette.execute_async_script(
+ """
+ Components.utils.import("resource:///modules/BrowserWindowTracker.jsm");
+
+ let win = BrowserWindowTracker.getTopWindow();
+ win.addEventListener("TabBrowserDiscarded", ev => {
+ arguments[0](true);
+ }, { once: true});
+ win.gBrowser.discardBrowser(win.gBrowser.tabs[1]);
+ """
+ )
+
+ window_handles = self.marionette.window_handles
+ window_handles.remove(self.start_tab)
+ self.assertEqual(1, len(window_handles))
+ self.marionette.switch_to_window(window_handles[0], focus=False)
+ self.marionette.close()
+ self.assertListEqual([self.start_tab], self.marionette.window_handles)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_handles_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_handles_chrome.py
new file mode 100644
index 0000000000..bb5ab6091d
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_handles_chrome.py
@@ -0,0 +1,255 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import six
+
+from marionette_driver import errors
+from marionette_harness import MarionetteTestCase, WindowManagerMixin
+
+
+class TestWindowHandles(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestWindowHandles, self).setUp()
+
+ self.chrome_dialog = "chrome://marionette/content/test_dialog.xhtml"
+
+ self.marionette.set_context("chrome")
+
+ def tearDown(self):
+ self.close_all_windows()
+ self.close_all_tabs()
+
+ super(TestWindowHandles, self).tearDown()
+
+ def assert_window_handles(self):
+ try:
+ self.assertIsInstance(
+ self.marionette.current_chrome_window_handle, six.string_types
+ )
+ self.assertIsInstance(
+ self.marionette.current_window_handle, six.string_types
+ )
+ except errors.NoSuchWindowException:
+ pass
+
+ for handle in self.marionette.chrome_window_handles:
+ self.assertIsInstance(handle, six.string_types)
+
+ for handle in self.marionette.window_handles:
+ self.assertIsInstance(handle, six.string_types)
+
+ def test_chrome_window_handles_with_scopes(self):
+ new_browser = self.open_window()
+ self.assert_window_handles()
+ self.assertEqual(
+ len(self.marionette.chrome_window_handles), len(self.start_windows) + 1
+ )
+ self.assertIn(new_browser, self.marionette.chrome_window_handles)
+ self.assertEqual(
+ self.marionette.current_chrome_window_handle, self.start_window
+ )
+
+ new_dialog = self.open_chrome_window(self.chrome_dialog)
+ self.assert_window_handles()
+ self.assertEqual(
+ len(self.marionette.chrome_window_handles), len(self.start_windows) + 2
+ )
+ self.assertIn(new_dialog, self.marionette.chrome_window_handles)
+ self.assertEqual(
+ self.marionette.current_chrome_window_handle, self.start_window
+ )
+
+ chrome_window_handles_in_chrome_scope = self.marionette.chrome_window_handles
+ window_handles_in_chrome_scope = self.marionette.window_handles
+
+ with self.marionette.using_context("content"):
+ self.assertEqual(
+ self.marionette.chrome_window_handles,
+ chrome_window_handles_in_chrome_scope,
+ )
+ self.assertEqual(
+ self.marionette.window_handles, window_handles_in_chrome_scope
+ )
+
+ def test_chrome_window_handles_after_opening_new_chrome_window(self):
+ new_window = self.open_chrome_window(self.chrome_dialog)
+ self.assert_window_handles()
+ self.assertEqual(
+ len(self.marionette.chrome_window_handles), len(self.start_windows) + 1
+ )
+ self.assertIn(new_window, self.marionette.chrome_window_handles)
+ self.assertEqual(
+ self.marionette.current_chrome_window_handle, self.start_window
+ )
+
+ # Check that the new chrome window has the correct URL loaded
+ self.marionette.switch_to_window(new_window)
+ self.assert_window_handles()
+ self.assertEqual(self.marionette.current_chrome_window_handle, new_window)
+ self.assertEqual(self.marionette.get_url(), self.chrome_dialog)
+
+ # Close the chrome window, and carry on in our original window.
+ self.marionette.close_chrome_window()
+ self.assert_window_handles()
+ self.assertEqual(
+ len(self.marionette.chrome_window_handles), len(self.start_windows)
+ )
+ self.assertNotIn(new_window, self.marionette.chrome_window_handles)
+
+ self.marionette.switch_to_window(self.start_window)
+ self.assert_window_handles()
+ self.assertEqual(
+ self.marionette.current_chrome_window_handle, self.start_window
+ )
+
+ def test_chrome_window_handles_after_opening_new_window(self):
+ new_window = self.open_window()
+ self.assert_window_handles()
+ self.assertEqual(
+ len(self.marionette.chrome_window_handles), len(self.start_windows) + 1
+ )
+ self.assertIn(new_window, self.marionette.chrome_window_handles)
+ self.assertEqual(
+ self.marionette.current_chrome_window_handle, self.start_window
+ )
+
+ self.marionette.switch_to_window(new_window)
+ self.assert_window_handles()
+ self.assertEqual(self.marionette.current_chrome_window_handle, new_window)
+
+ # Close the opened window and carry on in our original window.
+ self.marionette.close()
+ self.assert_window_handles()
+ self.assertEqual(
+ len(self.marionette.chrome_window_handles), len(self.start_windows)
+ )
+ self.assertNotIn(new_window, self.marionette.chrome_window_handles)
+
+ self.marionette.switch_to_window(self.start_window)
+ self.assert_window_handles()
+ self.assertEqual(
+ self.marionette.current_chrome_window_handle, self.start_window
+ )
+
+ def test_chrome_window_handles_after_session_created(self):
+ new_window = self.open_chrome_window(self.chrome_dialog)
+ self.assert_window_handles()
+ self.assertEqual(
+ len(self.marionette.chrome_window_handles), len(self.start_windows) + 1
+ )
+ self.assertIn(new_window, self.marionette.chrome_window_handles)
+ self.assertEqual(
+ self.marionette.current_chrome_window_handle, self.start_window
+ )
+
+ chrome_window_handles = self.marionette.chrome_window_handles
+
+ self.marionette.delete_session()
+ self.marionette.start_session()
+
+ self.assert_window_handles()
+ self.assertEqual(chrome_window_handles, self.marionette.chrome_window_handles)
+
+ self.marionette.switch_to_window(new_window)
+
+ def test_window_handles_after_opening_new_tab(self):
+ with self.marionette.using_context("content"):
+ new_tab = self.open_tab()
+ self.assert_window_handles()
+ self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1)
+ self.assertIn(new_tab, self.marionette.window_handles)
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+
+ self.marionette.switch_to_window(new_tab)
+ self.assert_window_handles()
+ self.assertEqual(self.marionette.current_window_handle, new_tab)
+
+ self.marionette.switch_to_window(self.start_tab)
+ self.assert_window_handles()
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+
+ self.marionette.switch_to_window(new_tab)
+ self.marionette.close()
+ self.assert_window_handles()
+ self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs))
+ self.assertNotIn(new_tab, self.marionette.window_handles)
+
+ self.marionette.switch_to_window(self.start_tab)
+ self.assert_window_handles()
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+
+ def test_window_handles_after_opening_new_foreground_tab(self):
+ with self.marionette.using_context("content"):
+ new_tab = self.open_tab(focus=True)
+ self.assert_window_handles()
+ self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1)
+ self.assertIn(new_tab, self.marionette.window_handles)
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+
+ # We still have the default tab set as our window handle. This
+ # get_url command should be sent immediately, and not be forever-queued.
+ with self.marionette.using_context("content"):
+ self.marionette.get_url()
+
+ self.marionette.switch_to_window(new_tab)
+ self.assert_window_handles()
+ self.assertEqual(self.marionette.current_window_handle, new_tab)
+
+ self.marionette.close()
+ self.assert_window_handles()
+ self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs))
+ self.assertNotIn(new_tab, self.marionette.window_handles)
+
+ self.marionette.switch_to_window(self.start_tab)
+ self.assert_window_handles()
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+
+ def test_window_handles_after_opening_new_chrome_window(self):
+ new_window = self.open_chrome_window(self.chrome_dialog)
+ self.assert_window_handles()
+ self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs))
+ self.assertNotIn(new_window, self.marionette.window_handles)
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+
+ self.marionette.switch_to_window(new_window)
+ self.assert_window_handles()
+ self.assertEqual(self.marionette.get_url(), self.chrome_dialog)
+
+ # Check that the opened dialog is not accessible via window handles
+ with self.assertRaises(errors.NoSuchWindowException):
+ self.marionette.current_window_handle
+ with self.assertRaises(errors.NoSuchWindowException):
+ self.marionette.close()
+
+ # Close the dialog and carry on in our original tab.
+ self.marionette.close_chrome_window()
+ self.assert_window_handles()
+ self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs))
+
+ self.marionette.switch_to_window(self.start_tab)
+ self.assert_window_handles()
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+
+ def test_window_handles_after_closing_original_tab(self):
+ with self.marionette.using_context("content"):
+ new_tab = self.open_tab()
+ self.assert_window_handles()
+ self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1)
+ self.assertIn(new_tab, self.marionette.window_handles)
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+
+ self.marionette.close()
+ self.assert_window_handles()
+ self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs))
+ self.assertIn(new_tab, self.marionette.window_handles)
+
+ self.marionette.switch_to_window(new_tab)
+ self.assert_window_handles()
+ self.assertEqual(self.marionette.current_window_handle, new_tab)
+
+ def test_window_handles_after_closing_last_window(self):
+ self.close_all_windows()
+ self.assertEqual(self.marionette.close_chrome_window(), [])
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_handles_content.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_handles_content.py
new file mode 100644
index 0000000000..08f0641131
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_handles_content.py
@@ -0,0 +1,142 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import six
+from six.moves.urllib.parse import quote
+
+from marionette_driver import errors
+from marionette_harness import MarionetteTestCase, WindowManagerMixin
+
+
+def inline(doc):
+ return "data:text/html;charset=utf-8,{}".format(quote(doc))
+
+
+class TestWindowHandles(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestWindowHandles, self).setUp()
+
+ self.chrome_dialog = "chrome://marionette/content/test_dialog.xhtml"
+
+ def tearDown(self):
+ self.close_all_windows()
+ self.close_all_tabs()
+
+ super(TestWindowHandles, self).tearDown()
+
+ def assert_window_handles(self):
+ try:
+ self.assertIsInstance(
+ self.marionette.current_window_handle, six.string_types
+ )
+ except errors.NoSuchWindowException:
+ pass
+
+ for handle in self.marionette.window_handles:
+ self.assertIsInstance(handle, six.string_types)
+
+ def test_window_handles_after_opening_new_tab(self):
+ new_tab = self.open_tab()
+ self.assert_window_handles()
+ self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1)
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+
+ self.marionette.switch_to_window(new_tab)
+ self.assert_window_handles()
+ self.assertEqual(self.marionette.current_window_handle, new_tab)
+
+ self.marionette.switch_to_window(self.start_tab)
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+
+ self.marionette.switch_to_window(new_tab)
+ self.marionette.close()
+ self.assert_window_handles()
+ self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs))
+
+ self.marionette.switch_to_window(self.start_tab)
+ self.assert_window_handles()
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+
+ def test_window_handles_after_opening_new_browser_window(self):
+ new_tab = self.open_window()
+ self.assert_window_handles()
+ self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1)
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+
+ self.marionette.switch_to_window(new_tab)
+ self.assert_window_handles()
+ self.assertEqual(self.marionette.current_window_handle, new_tab)
+
+ # Close the opened window and carry on in our original tab.
+ self.marionette.close()
+ self.assert_window_handles()
+ self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs))
+
+ self.marionette.switch_to_window(self.start_tab)
+ self.assert_window_handles()
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+
+ def test_window_handles_after_opening_new_non_browser_window(self):
+ new_window = self.open_chrome_window(self.chrome_dialog)
+ self.assert_window_handles()
+ self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs))
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+ self.assertNotIn(new_window, self.marionette.window_handles)
+
+ self.marionette.switch_to_window(new_window)
+ self.assert_window_handles()
+
+ # Check that the opened window is not accessible via window handles
+ with self.assertRaises(errors.NoSuchWindowException):
+ self.marionette.current_window_handle
+ with self.assertRaises(errors.NoSuchWindowException):
+ self.marionette.close()
+
+ # Close the opened window and carry on in our original tab.
+ self.marionette.close_chrome_window()
+ self.assert_window_handles()
+ self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs))
+
+ self.marionette.switch_to_window(self.start_tab)
+ self.assert_window_handles()
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+
+ def test_window_handles_after_session_created(self):
+ new_window = self.open_chrome_window(self.chrome_dialog)
+ self.assert_window_handles()
+ self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs))
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+ self.assertNotIn(new_window, self.marionette.window_handles)
+
+ window_handles = self.marionette.window_handles
+
+ self.marionette.delete_session()
+ self.marionette.start_session()
+
+ self.assert_window_handles()
+ self.assertEqual(window_handles, self.marionette.window_handles)
+
+ self.marionette.switch_to_window(new_window)
+
+ def test_window_handles_after_closing_original_tab(self):
+ new_tab = self.open_tab()
+ self.assert_window_handles()
+ self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1)
+ self.assertEqual(self.marionette.current_window_handle, self.start_tab)
+ self.assertIn(new_tab, self.marionette.window_handles)
+
+ self.marionette.close()
+ self.assert_window_handles()
+ self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs))
+ self.assertNotIn(self.start_tab, self.marionette.window_handles)
+
+ self.marionette.switch_to_window(new_tab)
+ self.assert_window_handles()
+ self.assertEqual(self.marionette.current_window_handle, new_tab)
+
+ def test_window_handles_after_closing_last_tab(self):
+ self.close_all_tabs()
+ self.assertEqual(self.marionette.close(), [])
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_management.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_management.py
new file mode 100644
index 0000000000..7bd98a4a72
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_management.py
@@ -0,0 +1,141 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_driver import By
+from marionette_driver.errors import NoSuchWindowException
+
+from marionette_harness import MarionetteTestCase, WindowManagerMixin
+
+
+class TestNoSuchWindowContent(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestNoSuchWindowContent, self).setUp()
+
+ def tearDown(self):
+ self.close_all_tabs()
+ super(TestNoSuchWindowContent, self).tearDown()
+
+ def test_closed_chrome_window(self):
+ with self.marionette.using_context("chrome"):
+ new_window = self.open_window()
+ self.marionette.switch_to_window(new_window)
+ self.marionette.close_chrome_window()
+
+ # When closing a browser window both handles are not available
+ for context in ("chrome", "content"):
+ with self.marionette.using_context(context):
+ with self.assertRaises(NoSuchWindowException):
+ self.marionette.current_chrome_window_handle
+ with self.assertRaises(NoSuchWindowException):
+ self.marionette.current_window_handle
+
+ self.marionette.switch_to_window(self.start_window)
+
+ with self.assertRaises(NoSuchWindowException):
+ self.marionette.switch_to_window(new_window)
+
+ def test_closed_chrome_window_while_in_frame(self):
+ new_window = self.open_chrome_window("chrome://marionette/content/test.xhtml")
+ self.marionette.switch_to_window(new_window)
+ with self.marionette.using_context("chrome"):
+ self.marionette.switch_to_frame(0)
+ self.marionette.close_chrome_window()
+
+ with self.assertRaises(NoSuchWindowException):
+ self.marionette.current_window_handle
+ with self.assertRaises(NoSuchWindowException):
+ self.marionette.current_chrome_window_handle
+
+ self.marionette.switch_to_window(self.start_window)
+
+ with self.assertRaises(NoSuchWindowException):
+ self.marionette.switch_to_window(new_window)
+
+ def test_closed_tab(self):
+ new_tab = self.open_tab()
+ self.marionette.switch_to_window(new_tab)
+ self.marionette.close()
+
+ # Check that only the content window is not available in both contexts
+ for context in ("chrome", "content"):
+ with self.marionette.using_context(context):
+ with self.assertRaises(NoSuchWindowException):
+ self.marionette.current_window_handle
+ self.marionette.current_chrome_window_handle
+
+ self.marionette.switch_to_window(self.start_tab)
+
+ with self.assertRaises(NoSuchWindowException):
+ self.marionette.switch_to_window(new_tab)
+
+ def test_closed_tab_while_in_frame(self):
+ new_tab = self.open_tab()
+ self.marionette.switch_to_window(new_tab)
+
+ with self.marionette.using_context("content"):
+ self.marionette.navigate(self.marionette.absolute_url("test_iframe.html"))
+ frame = self.marionette.find_element(By.ID, "test_iframe")
+ self.marionette.switch_to_frame(frame)
+ self.marionette.close()
+
+ with self.assertRaises(NoSuchWindowException):
+ self.marionette.current_window_handle
+ self.marionette.current_chrome_window_handle
+
+ self.marionette.switch_to_window(self.start_tab)
+
+ with self.assertRaises(NoSuchWindowException):
+ self.marionette.switch_to_window(new_tab)
+
+
+class TestNoSuchWindowChrome(TestNoSuchWindowContent):
+ def setUp(self):
+ super(TestNoSuchWindowChrome, self).setUp()
+ self.marionette.set_context("chrome")
+
+ def tearDown(self):
+ self.close_all_windows()
+ super(TestNoSuchWindowChrome, self).tearDown()
+
+
+class TestSwitchWindow(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestSwitchWindow, self).setUp()
+ self.marionette.set_context("chrome")
+
+ def tearDown(self):
+ self.close_all_windows()
+ super(TestSwitchWindow, self).tearDown()
+
+ def test_switch_window_after_open_and_close(self):
+ with self.marionette.using_context("chrome"):
+ new_window = self.open_window()
+ self.assertEqual(
+ len(self.marionette.chrome_window_handles), len(self.start_windows) + 1
+ )
+ self.assertIn(new_window, self.marionette.chrome_window_handles)
+ self.assertEqual(
+ self.marionette.current_chrome_window_handle, self.start_window
+ )
+
+ # switch to the new chrome window and close it
+ self.marionette.switch_to_window(new_window)
+ self.assertEqual(self.marionette.current_chrome_window_handle, new_window)
+ self.assertNotEqual(
+ self.marionette.current_chrome_window_handle, self.start_window
+ )
+
+ self.marionette.close_chrome_window()
+ self.assertEqual(
+ len(self.marionette.chrome_window_handles), len(self.start_windows)
+ )
+ self.assertNotIn(new_window, self.marionette.chrome_window_handles)
+
+ # switch back to the original chrome window
+ self.marionette.switch_to_window(self.start_window)
+ self.assertEqual(
+ self.marionette.current_chrome_window_handle, self.start_window
+ )
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_maximize.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_maximize.py
new file mode 100644
index 0000000000..7def35b932
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_maximize.py
@@ -0,0 +1,38 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_harness import MarionetteTestCase
+
+
+class TestWindowMaximize(MarionetteTestCase):
+ def setUp(self):
+ MarionetteTestCase.setUp(self)
+ self.max = self.marionette.execute_script(
+ """
+ return {
+ width: window.screen.availWidth,
+ height: window.screen.availHeight,
+ }""",
+ sandbox=None,
+ )
+
+ # ensure window is not maximized
+ self.marionette.set_window_rect(
+ width=self.max["width"] - 100, height=self.max["height"] - 100
+ )
+ actual = self.marionette.window_rect
+ self.assertNotEqual(actual["width"], self.max["width"])
+ self.assertNotEqual(actual["height"], self.max["height"])
+
+ self.original_size = actual
+
+ def tearDown(self):
+ self.marionette.set_window_rect(
+ width=self.original_size["width"], height=self.original_size["height"]
+ )
+
+ def test_maximize(self):
+ self.marionette.maximize_window()
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_rect.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_rect.py
new file mode 100644
index 0000000000..844b9ec14a
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_rect.py
@@ -0,0 +1,317 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import, print_function
+
+from marionette_driver.errors import InvalidArgumentException
+from marionette_harness import MarionetteTestCase
+
+
+class TestWindowRect(MarionetteTestCase):
+ def setUp(self):
+ super(TestWindowRect, self).setUp()
+
+ self.original_rect = self.marionette.window_rect
+
+ self.max = self.marionette.execute_script(
+ """
+ return {
+ width: window.screen.availWidth,
+ height: window.screen.availHeight,
+ }""",
+ sandbox=None,
+ )
+
+ # WebDriver spec says a resize cannot result in window being
+ # maximised, an error is returned if that is the case; therefore if
+ # the window is maximised at the start of this test, returning to
+ # the original size via set_window_rect size will result in error;
+ # so reset to original size minus 1 pixel width
+ start_size = {
+ "height": self.original_rect["height"],
+ "width": self.original_rect["width"],
+ }
+ if (
+ start_size["width"] == self.max["width"]
+ and start_size["height"] == self.max["height"]
+ ):
+ start_size["width"] -= 10
+ start_size["height"] -= 10
+ self.marionette.set_window_rect(
+ height=start_size["height"], width=start_size["width"]
+ )
+
+ def tearDown(self):
+ x, y = self.original_rect["x"], self.original_rect["y"]
+ height, width = self.original_rect["height"], self.original_rect["width"]
+
+ self.marionette.set_window_rect(x=x, y=y, height=height, width=width)
+
+ is_fullscreen = self.marionette.execute_script(
+ "return document.fullscreenElement;", sandbox=None
+ )
+ if is_fullscreen:
+ self.marionette.fullscreen()
+
+ super(TestWindowRect, self).tearDown()
+
+ def test_get_types(self):
+ rect = self.marionette.window_rect
+ self.assertIn("x", rect)
+ self.assertIn("y", rect)
+ self.assertIn("height", rect)
+ self.assertIn("width", rect)
+ self.assertIsInstance(rect["x"], int)
+ self.assertIsInstance(rect["y"], int)
+ self.assertIsInstance(rect["height"], int)
+ self.assertIsInstance(rect["width"], int)
+
+ def test_set_types(self):
+ invalid_rects = (
+ ["a", "b", "h", "w"],
+ [1.2, 3.4, 4.5, 5.6],
+ [True, False, True, False],
+ [[], [], [], []],
+ [{}, {}, {}, {}],
+ )
+ for x, y, h, w in invalid_rects:
+ print("testing invalid type position ({},{})".format(x, y))
+ with self.assertRaises(InvalidArgumentException):
+ self.marionette.set_window_rect(x=x, y=y, height=h, width=w)
+
+ def test_setting_window_rect_with_nulls_errors(self):
+ with self.assertRaises(InvalidArgumentException):
+ self.marionette.set_window_rect(height=None, width=None, x=None, y=None)
+
+ def test_set_position(self):
+ old_position = self.marionette.window_rect
+ wanted_position = {"x": old_position["x"] + 10, "y": old_position["y"] + 10}
+
+ new_position = self.marionette.set_window_rect(
+ x=wanted_position["x"], y=wanted_position["y"]
+ )
+ expected_position = self.marionette.window_rect
+
+ self.assertEqual(new_position["x"], wanted_position["x"])
+ self.assertEqual(new_position["y"], wanted_position["y"])
+ self.assertEqual(new_position["x"], expected_position["x"])
+ self.assertEqual(new_position["y"], expected_position["y"])
+
+ def test_set_size(self):
+ old_size = self.marionette.window_rect
+ wanted_size = {
+ "height": old_size["height"] - 50,
+ "width": old_size["width"] - 50,
+ }
+
+ new_size = self.marionette.set_window_rect(
+ height=wanted_size["height"], width=wanted_size["width"]
+ )
+ expected_size = self.marionette.window_rect
+
+ self.assertEqual(
+ new_size["width"],
+ wanted_size["width"],
+ "New width is {0} but should be {1}".format(
+ new_size["width"], wanted_size["width"]
+ ),
+ )
+ self.assertEqual(
+ new_size["height"],
+ wanted_size["height"],
+ "New height is {0} but should be {1}".format(
+ new_size["height"], wanted_size["height"]
+ ),
+ )
+ self.assertEqual(
+ new_size["width"],
+ expected_size["width"],
+ "New width is {0} but should be {1}".format(
+ new_size["width"], expected_size["width"]
+ ),
+ )
+ self.assertEqual(
+ new_size["height"],
+ expected_size["height"],
+ "New height is {0} but should be {1}".format(
+ new_size["height"], expected_size["height"]
+ ),
+ )
+
+ def test_set_position_and_size(self):
+ old_rect = self.marionette.window_rect
+ wanted_rect = {
+ "x": old_rect["x"] + 10,
+ "y": old_rect["y"] + 10,
+ "width": old_rect["width"] - 50,
+ "height": old_rect["height"] - 50,
+ }
+
+ new_rect = self.marionette.set_window_rect(
+ x=wanted_rect["x"],
+ y=wanted_rect["y"],
+ width=wanted_rect["width"],
+ height=wanted_rect["height"],
+ )
+ expected_rect = self.marionette.window_rect
+
+ self.assertEqual(new_rect["x"], wanted_rect["x"])
+ self.assertEqual(new_rect["y"], wanted_rect["y"])
+ self.assertEqual(
+ new_rect["width"],
+ wanted_rect["width"],
+ "New width is {0} but should be {1}".format(
+ new_rect["width"], wanted_rect["width"]
+ ),
+ )
+ self.assertEqual(
+ new_rect["height"],
+ wanted_rect["height"],
+ "New height is {0} but should be {1}".format(
+ new_rect["height"], wanted_rect["height"]
+ ),
+ )
+ self.assertEqual(new_rect["x"], expected_rect["x"])
+ self.assertEqual(new_rect["y"], expected_rect["y"])
+ self.assertEqual(
+ new_rect["width"],
+ expected_rect["width"],
+ "New width is {0} but should be {1}".format(
+ new_rect["width"], expected_rect["width"]
+ ),
+ )
+ self.assertEqual(
+ new_rect["height"],
+ expected_rect["height"],
+ "New height is {0} but should be {1}".format(
+ new_rect["height"], expected_rect["height"]
+ ),
+ )
+
+ def test_move_to_current_position(self):
+ old_position = self.marionette.window_rect
+ new_position = self.marionette.set_window_rect(
+ x=old_position["x"], y=old_position["y"]
+ )
+
+ self.assertEqual(new_position["x"], old_position["x"])
+ self.assertEqual(new_position["y"], old_position["y"])
+
+ def test_move_to_current_size(self):
+ old_size = self.marionette.window_rect
+ new_size = self.marionette.set_window_rect(
+ height=old_size["height"], width=old_size["width"]
+ )
+
+ self.assertEqual(new_size["height"], old_size["height"])
+ self.assertEqual(new_size["width"], old_size["width"])
+
+ def test_move_to_current_position_and_size(self):
+ old_position_and_size = self.marionette.window_rect
+ new_position_and_size = self.marionette.set_window_rect(
+ x=old_position_and_size["x"],
+ y=old_position_and_size["y"],
+ height=old_position_and_size["height"],
+ width=old_position_and_size["width"],
+ )
+
+ self.assertEqual(new_position_and_size["x"], old_position_and_size["x"])
+ self.assertEqual(new_position_and_size["y"], old_position_and_size["y"])
+ self.assertEqual(new_position_and_size["width"], old_position_and_size["width"])
+ self.assertEqual(
+ new_position_and_size["height"], old_position_and_size["height"]
+ )
+
+ def test_move_to_negative_coordinates(self):
+ old_position = self.marionette.window_rect
+ print("Current position: {}".format(old_position["x"], old_position["y"]))
+ new_position = self.marionette.set_window_rect(x=-8, y=-8)
+ print(
+ "Position after requesting move to negative coordinates: {}, {}".format(
+ new_position["x"], new_position["y"]
+ )
+ )
+
+ # Different systems will report more or less than (-8,-8)
+ # depending on the characteristics of the window manager, since
+ # the screenX/screenY position measures the chrome boundaries,
+ # including any WM decorations.
+ #
+ # This makes this hard to reliably test across different
+ # environments. Generally we are happy when calling
+ # marionette.set_window_position with negative coordinates does
+ # not throw.
+ #
+ # Because we have to cater to an unknown set of environments,
+ # the following assertions are the most common denominator that
+ # make this test pass, irregardless of system characteristics.
+
+ os = self.marionette.session_capabilities["platformName"]
+
+ # Regardless of platform, headless always supports being positioned
+ # off-screen.
+ if self.marionette.session_capabilities["moz:headless"]:
+ self.assertEqual(-8, new_position["x"])
+ self.assertEqual(-8, new_position["y"])
+
+ # Certain WMs prohibit windows from being moved off-screen,
+ # but we don't have this information. It should be safe to
+ # assume a window can be moved to (0,0) or less.
+ elif os == "linux":
+ # certain WMs prohibit windows from being moved off-screen
+ self.assertLessEqual(new_position["x"], 0)
+ self.assertLessEqual(new_position["y"], 0)
+
+ # On macOS, windows can only be moved off the screen on the
+ # horizontal axis. The system menu bar also blocks windows from
+ # being moved to (0,0).
+ elif os == "mac":
+ self.assertEqual(-8, new_position["x"])
+ self.assertEqual(23, new_position["y"])
+
+ # It turns out that Windows is the only platform on which the
+ # window can be reliably positioned off-screen.
+ elif os == "windows":
+ self.assertEqual(-8, new_position["x"])
+ self.assertEqual(-8, new_position["y"])
+
+ def test_resize_larger_than_screen(self):
+ new_size = self.marionette.set_window_rect(
+ width=self.max["width"] * 2, height=self.max["height"] * 2
+ )
+ actual_size = self.marionette.window_rect
+
+ # in X the window size may be greater than the bounds of the screen
+ self.assertGreaterEqual(new_size["width"], self.max["width"])
+ self.assertGreaterEqual(new_size["height"], self.max["height"])
+ self.assertEqual(actual_size["width"], new_size["width"])
+ self.assertEqual(actual_size["height"], new_size["height"])
+
+ def test_resize_to_available_screen_size(self):
+ expected_size = self.marionette.set_window_rect(
+ width=self.max["width"], height=self.max["height"]
+ )
+ result_size = self.marionette.window_rect
+
+ self.assertGreaterEqual(expected_size["width"], self.max["width"])
+ self.assertGreaterEqual(expected_size["height"], self.max["height"])
+ self.assertEqual(result_size["width"], expected_size["width"])
+ self.assertEqual(result_size["height"], expected_size["height"])
+
+ def test_resize_while_fullscreen(self):
+ self.marionette.fullscreen()
+ expected_size = self.marionette.set_window_rect(
+ width=self.max["width"] - 100, height=self.max["height"] - 100
+ )
+ result_size = self.marionette.window_rect
+
+ self.assertTrue(
+ self.marionette.execute_script(
+ "return window.fullscreenElement == null", sandbox=None
+ )
+ )
+ self.assertEqual(self.max["width"] - 100, expected_size["width"])
+ self.assertEqual(self.max["height"] - 100, expected_size["height"])
+ self.assertEqual(result_size["width"], expected_size["width"])
+ self.assertEqual(result_size["height"], expected_size["height"])
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_status_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_status_chrome.py
new file mode 100644
index 0000000000..17df67ff0b
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_status_chrome.py
@@ -0,0 +1,25 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import os
+import sys
+
+# add this directory to the path
+sys.path.append(os.path.dirname(__file__))
+
+from test_window_status_content import TestNoSuchWindowContent
+
+
+class TestNoSuchWindowChrome(TestNoSuchWindowContent):
+ def setUp(self):
+ super(TestNoSuchWindowChrome, self).setUp()
+
+ self.marionette.set_context("chrome")
+
+ def tearDown(self):
+ self.close_all_windows()
+
+ super(TestNoSuchWindowChrome, self).tearDown()
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_status_content.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_status_content.py
new file mode 100644
index 0000000000..45f4fe4ccc
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_status_content.py
@@ -0,0 +1,94 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import, print_function
+
+from marionette_driver import By
+from marionette_driver.errors import NoSuchWindowException
+
+from marionette_harness import MarionetteTestCase, WindowManagerMixin
+
+
+class TestNoSuchWindowContent(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestNoSuchWindowContent, self).setUp()
+
+ def tearDown(self):
+ self.close_all_windows()
+ super(TestNoSuchWindowContent, self).tearDown()
+
+ def test_closed_chrome_window(self):
+ with self.marionette.using_context("chrome"):
+ new_window = self.open_window()
+ self.marionette.switch_to_window(new_window)
+ self.marionette.close_chrome_window()
+
+ # When closing a browser window both handles are not available
+ for context in ("chrome", "content"):
+ print("Testing handles with context {}".format(context))
+ with self.marionette.using_context(context):
+ with self.assertRaises(NoSuchWindowException):
+ self.marionette.current_chrome_window_handle
+ with self.assertRaises(NoSuchWindowException):
+ self.marionette.current_window_handle
+
+ self.marionette.switch_to_window(self.start_window)
+
+ with self.assertRaises(NoSuchWindowException):
+ self.marionette.switch_to_window(new_window)
+
+ def test_closed_chrome_window_while_in_frame(self):
+ new_window = self.open_chrome_window("chrome://marionette/content/test.xhtml")
+ self.marionette.switch_to_window(new_window)
+
+ with self.marionette.using_context("chrome"):
+ self.marionette.switch_to_frame(0)
+ self.marionette.close_chrome_window()
+
+ with self.assertRaises(NoSuchWindowException):
+ self.marionette.current_window_handle
+ with self.assertRaises(NoSuchWindowException):
+ self.marionette.current_chrome_window_handle
+
+ self.marionette.switch_to_window(self.start_window)
+
+ with self.assertRaises(NoSuchWindowException):
+ self.marionette.switch_to_window(new_window)
+
+ def test_closed_tab(self):
+ new_tab = self.open_tab(focus=True)
+ self.marionette.switch_to_window(new_tab)
+ self.marionette.close()
+
+ # Check that only the content window is not available in both contexts
+ for context in ("chrome", "content"):
+ with self.marionette.using_context(context):
+ with self.assertRaises(NoSuchWindowException):
+ self.marionette.current_window_handle
+ self.marionette.current_chrome_window_handle
+
+ self.marionette.switch_to_window(self.start_tab)
+
+ with self.assertRaises(NoSuchWindowException):
+ self.marionette.switch_to_window(new_tab)
+
+ def test_closed_tab_while_in_frame(self):
+ new_tab = self.open_tab()
+ self.marionette.switch_to_window(new_tab)
+
+ with self.marionette.using_context("content"):
+ self.marionette.navigate(self.marionette.absolute_url("test_iframe.html"))
+ frame = self.marionette.find_element(By.ID, "test_iframe")
+ self.marionette.switch_to_frame(frame)
+
+ self.marionette.close()
+
+ with self.assertRaises(NoSuchWindowException):
+ self.marionette.current_window_handle
+ self.marionette.current_chrome_window_handle
+
+ self.marionette.switch_to_window(self.start_tab)
+
+ with self.assertRaises(NoSuchWindowException):
+ self.marionette.switch_to_window(new_tab)
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_type_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_type_chrome.py
new file mode 100644
index 0000000000..3320af910f
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_type_chrome.py
@@ -0,0 +1,28 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+from marionette_harness import MarionetteTestCase, WindowManagerMixin
+
+
+class TestWindowTypeChrome(WindowManagerMixin, MarionetteTestCase):
+ def setUp(self):
+ super(TestWindowTypeChrome, self).setUp()
+
+ self.marionette.set_context("chrome")
+
+ def tearDown(self):
+ self.close_all_windows()
+
+ super(TestWindowTypeChrome, self).tearDown()
+
+ def test_get_window_type(self):
+ win = self.open_chrome_window("chrome://marionette/content/test.xhtml")
+ self.marionette.switch_to_window(win)
+
+ window_type = self.marionette.execute_script(
+ "return window.document.documentElement.getAttribute('windowtype');"
+ )
+ self.assertEqual(window_type, self.marionette.get_window_type())
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/unit-tests.ini b/testing/marionette/harness/marionette_harness/tests/unit/unit-tests.ini
new file mode 100644
index 0000000000..b2c31b02aa
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/unit-tests.ini
@@ -0,0 +1,106 @@
+[test_marionette.py]
+[test_transport.py]
+[test_cli_arguments.py]
+skip-if = manage_instance == false
+[test_geckoinstance.py]
+[test_data_driven.py]
+[test_session.py]
+[test_capabilities.py]
+[test_proxy.py]
+[test_accessibility.py]
+[test_expectedfail.py]
+expected = fail
+[test_skip_setup.py]
+[test_click.py]
+[test_click_chrome.py]
+[test_checkbox.py]
+[test_checkbox_chrome.py]
+[test_element_rect.py]
+[test_element_rect_chrome.py]
+[test_position.py]
+[test_rendered_element.py]
+[test_chrome_element_css.py]
+[test_element_state.py]
+[test_element_state_chrome.py]
+[test_text.py]
+[test_text_chrome.py]
+
+[test_typing.py]
+
+[test_execute_async_script.py]
+[test_execute_script.py]
+[test_element_retrieval.py]
+[test_findelement_chrome.py]
+
+[test_get_current_url_chrome.py]
+[test_navigation.py]
+[test_timeouts.py]
+
+[test_switch_frame.py]
+[test_switch_frame_chrome.py]
+[test_switch_window_chrome.py]
+[test_switch_window_content.py]
+
+[test_pagesource.py]
+[test_pagesource_chrome.py]
+
+[test_visibility.py]
+[test_window_handles_chrome.py]
+[test_window_handles_content.py]
+[test_window_close_chrome.py]
+[test_window_close_content.py]
+[test_window_rect.py]
+
+[test_window_maximize.py]
+[test_window_status_content.py]
+[test_window_status_chrome.py]
+
+[test_screenshot.py]
+[test_cookies.py]
+[test_title.py]
+[test_title_chrome.py]
+[test_window_type_chrome.py]
+[test_implicit_waits.py]
+[test_wait.py]
+[test_expected.py]
+[test_date_time_value.py]
+[test_screen_orientation.py]
+[test_errors.py]
+
+[test_execute_isolate.py]
+[test_click_scrolling.py]
+[test_profile_management.py]
+skip-if = manage_instance == false || (debug && ((os == 'mac') || (os == 'linux'))) # Bug 1450355
+[test_quit_restart.py]
+skip-if = manage_instance == false
+[test_context.py]
+
+[test_modal_dialogs.py]
+[test_unhandled_prompt_behavior.py]
+
+[test_key_actions.py]
+[test_mouse_action.py]
+[test_chrome_action.py]
+
+[test_teardown_context_preserved.py]
+[test_file_upload.py]
+skip-if = os == "win" # http://bugs.python.org/issue14574
+
+[test_execute_sandboxes.py]
+[test_prefs.py]
+[test_prefs_enforce.py]
+skip-if = manage_instance == false
+
+[test_chrome.py]
+
+[test_addons.py]
+
+[test_select.py]
+[test_crash.py]
+skip-if = asan || manage_instance == false
+[test_localization.py]
+
+[test_reftest.py]
+skip-if = (os == 'mac' && webrender) # bug 1674411
+
+[test_sendkeys_menupopup_chrome.py]
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/webextension-invalid.xpi b/testing/marionette/harness/marionette_harness/tests/unit/webextension-invalid.xpi
new file mode 100644
index 0000000000..bd1177462e
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/webextension-invalid.xpi
Binary files differ
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/webextension-signed.xpi b/testing/marionette/harness/marionette_harness/tests/unit/webextension-signed.xpi
new file mode 100644
index 0000000000..5363911af1
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/webextension-signed.xpi
Binary files differ
diff --git a/testing/marionette/harness/marionette_harness/tests/unit/webextension-unsigned.xpi b/testing/marionette/harness/marionette_harness/tests/unit/webextension-unsigned.xpi
new file mode 100644
index 0000000000..cf0fad63b5
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/tests/unit/webextension-unsigned.xpi
Binary files differ
diff --git a/testing/marionette/harness/marionette_harness/www/addons/webextension-signed.xpi b/testing/marionette/harness/marionette_harness/www/addons/webextension-signed.xpi
new file mode 100644
index 0000000000..5363911af1
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/addons/webextension-signed.xpi
Binary files differ
diff --git a/testing/marionette/harness/marionette_harness/www/addons/webextension-unsigned.xpi b/testing/marionette/harness/marionette_harness/www/addons/webextension-unsigned.xpi
new file mode 100644
index 0000000000..cf0fad63b5
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/addons/webextension-unsigned.xpi
Binary files differ
diff --git a/testing/marionette/harness/marionette_harness/www/black.png b/testing/marionette/harness/marionette_harness/www/black.png
new file mode 100644
index 0000000000..b62a3a7bc8
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/black.png
Binary files differ
diff --git a/testing/marionette/harness/marionette_harness/www/bug814037.html b/testing/marionette/harness/marionette_harness/www/bug814037.html
new file mode 100644
index 0000000000..47c2968163
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/bug814037.html
@@ -0,0 +1,56 @@
+<html>
+<head>
+<meta name="viewport" content="minimum-scale=1,width=device-width">
+<style>
+body {
+ width: 100%;
+ margin: 0px;
+ transition: transform 300ms ease;
+ overflow-x: hidden;
+}
+
+body.section1 {
+ transform: translateX(0%);
+}
+
+body.section2 {
+ transform: translateX(-100%);
+}
+
+section {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+}
+
+#section1 {
+ left: 0px;
+}
+
+#section2 {
+ left: 100%;
+}
+.mypossie {
+ position:absolute;
+ left: -1000px;
+}
+</style>
+
+</head>
+ <body class="section1">
+ <section id="section1">
+ <div id="assertMe1">
+ <p>Section 1</p>
+ </div>
+ <button id="b1" onclick="var sect = document.getElementsByTagName('body')[0]; sect.classList.add('section2'); sect.classList.remove('section1');">Show section 2</button>
+ </section>
+
+ <section id="section2">
+ <div id="assertMe2">
+ <p>Section 2</p>
+ </div>
+ <button id="b2" onclick="var sect = document.getElementsByTagName('body')[0]; sect.classList.add('section1'); sect.classList.remove('section2'); ">Show section 1</button>
+ </section>
+ <section class='mypossie'>out in left field!</section>
+ </body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/click_out_of_bounds_overflow.html b/testing/marionette/harness/marionette_harness/www/click_out_of_bounds_overflow.html
new file mode 100644
index 0000000000..f0bee9b469
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/click_out_of_bounds_overflow.html
@@ -0,0 +1,90 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html><head>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+<body>
+<div style="height: 100px; overflow: auto;">
+ <table>
+ <tbody>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td>data</td></tr>
+ <tr><td><a href="#clicked" id="link">click me</a></td></tr>
+ </tbody>
+ </table>
+</div>
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/clicks.html b/testing/marionette/harness/marionette_harness/www/clicks.html
new file mode 100644
index 0000000000..96e9f55171
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/clicks.html
@@ -0,0 +1,57 @@
+<html>
+<head>
+ <!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+ <title>Testing Clicks</title>
+
+ <script>
+ function addMousedownListener() {
+ let el = document.getElementById('showbutton');
+
+ el.addEventListener('mousedown', function (evt) {
+ evt.target.innerText = evt.button;
+ });
+ }
+ </script>
+</head>
+
+<body>
+<h1>Testing Clicks</h1>
+
+<div>
+ <p id="links">Links:</p>
+ <ul>
+ <li><a href="test.html">333333</a>
+ <li><a href="test.html" id="normal">Normal</a>
+ <li><a href="#" id="anchor">I go to an anchor</a>
+ <li><a href="addons/webextension-unsigned.xpi" id="install-addon">Install Add-on</a>
+ </ul>
+</div>
+
+<div>
+ <p id="js-links">Javascript links:</p>
+ <ul>
+ <li>Navigate in history:
+ <a href="javascript:history.back();" id="history-back">Back</a>
+ <a href="javascript:history.forward();" id="history-forward">Forward</a>
+ <li><a href="javascript:window.open('test.html', '_blank')" id="new-window">Open a window</a>
+ <li><a href="javascript:window.close();" id="close-window">Close tab/window</a>
+ <li><a id="addbuttonlistener" href="javascript:addMousedownListener();">Click</a> to
+ add an event listener for: <span style="color: red;" id="showbutton">button click</span>
+ </ul>
+</div>
+
+<div>
+ <p id="special">Special:</p>
+ <select id="option" onclick="window.location = '/slow?delay=1'">
+ <option>Click to navigate</option>
+ </select>
+
+ <p style="background-color: rgb(0, 255, 0); width: 5em;">
+ <a id="overflowLink" href="test.html">looooooooooong short looooooooooong</a>
+ </p>
+</div>
+
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/element_outside_viewport.html b/testing/marionette/harness/marionette_harness/www/element_outside_viewport.html
new file mode 100644
index 0000000000..69b66b8759
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/element_outside_viewport.html
@@ -0,0 +1,41 @@
+<!DOCTYPE html>
+<style>
+ div {
+ position: absolute;
+ width: 100px;
+ height: 100px;
+ }
+ .top { background-color: red; }
+ #top-70 { left: 80px; top: 0; }
+ #top-50 { left: 190px; top: 20px; }
+ #top-30 { left: 300px; top: 40px; }
+
+ .right { background-color: black; }
+ #right-70 { top: 80px; right: -140px;}
+ #right-50 { top: 190px; right: -120px;}
+ #right-30 { top: 300px; right: -100px;}
+
+ .bottom { background-color: blue; }
+ #bottom-70 { right: -50px; bottom: -140px; }
+ #bottom-50 { right: 60px; bottom: -120px; }
+ #bottom-30 { right: 170px; bottom: -100px; }
+
+ .left { background-color: green; }
+ #left-70 { bottom: -50px; left: 0; }
+ #left-50 { bottom: 60px; left: 20px; }
+ #left-30 { bottom: 170px; left: 40px; }
+</style>
+<body onload="window.scrollTo(70, 70);">
+ <div id="top-70" class="top"></div>
+ <div id="top-50" class="top"></div>
+ <div id="top-30" class="top"></div>
+ <div id="right-70" class="right"></div>
+ <div id="right-50" class="right"></div>
+ <div id="right-30" class="right"></div>
+ <div id="bottom-70" class="bottom"></div>
+ <div id="bottom-50" class="bottom"></div>
+ <div id="bottom-30" class="bottom"></div>
+ <div id="left-70" class="left"></div>
+ <div id="left-50" class="left"></div>
+ <div id="left-30" class="left"></div>
+</body>
diff --git a/testing/marionette/harness/marionette_harness/www/empty.html b/testing/marionette/harness/marionette_harness/www/empty.html
new file mode 100644
index 0000000000..646edf9a72
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/empty.html
@@ -0,0 +1,12 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html>
+<head>
+<title>Marionette Test</title>
+</head>
+<body>
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/formPage.html b/testing/marionette/harness/marionette_harness/www/formPage.html
new file mode 100644
index 0000000000..43fde32431
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/formPage.html
@@ -0,0 +1,114 @@
+<html>
+<head>
+ <title>We Leave From Here</title>
+
+ <script type="text/javascript">
+ function changePage() {
+ let newLocation = '/common/page/3';
+ window.location = newLocation;
+ }
+ </script>
+</head>
+<body>
+There should be a form here:
+
+<form method="get" action="resultPage.html" name="login">
+ <input type="email" id="email"/>
+ <input type="submit" id="submitButton" value="Hello there"/>
+</form>
+
+<form method="get" action="resultPage.html" name="optional" style="display: block">
+ Here's a checkbox:
+ <input type="checkbox" id="checky" name="checky" value="furrfu"/>
+ <input type="checkbox" id="checkedchecky" name="checkedchecky" checked="checked" />
+ <input type="checkbox" id="disabledchecky" disabled="disabled" name="disabledchecky" />
+ <input type="checkbox" id="randomly_disabled_checky" disabled="somerandomstring" checked="checked" name="randomlydisabledchecky" />
+ <br/>
+ <select name="selectomatic">
+ <option selected="selected" id="non_multi_option" value="one">One</option>
+ <option value="two">Two</option>
+ <option value="four">Four</option>
+ <option value="still learning how to count, apparently">Still learning how to count, apparently</option>
+ </select>
+
+ <select name="multi" id="multi" multiple="multiple">
+ <option selected="selected" value="eggs">Eggs</option>
+ <option value="ham">Ham</option>
+ <option selected="selected" value="sausages">Sausages</option>
+ <option value="onion gravy">Onion gravy</option>
+ </select>
+
+ <select name="no-select" disabled="disabled">
+ <option value="foo">Foo</option>
+ </select>
+
+ <select name="select_empty_multiple" multiple>
+ <option id="multi_1" value="select_1">select_1</option>
+ <option id="multi_2" value="select_2">select_2</option>
+ <option id="multi_3" value="select_3">select_3</option>
+ <option id="multi_4" value="select_4">select_4</option>
+ </select>
+
+ <select name="multi_true" multiple="true">
+ <option id="multi_true_1" value="select_1">select_1</option>
+ <option id="multi_true_2" value="select_2">select_2</option>
+ </select>
+
+ <select name="multi_false" multiple="false">
+ <option id="multi_false_1" value="select_1">select_1</option>
+ <option id="multi_false_2" value="select_2">select_2</option>
+ </select>
+
+ <select id="invisi_select" style="opacity:0;">
+ <option selected value="apples">Apples</option>
+ <option value="oranges">Oranges</option>
+ </select>
+
+ <select name="select-default">
+ <option>One</option>
+ <option>Two</option>
+ <option>Four</option>
+ <option>Still learning how to count, apparently</option>
+ </select>
+
+ <select name="select_with_spaces">
+ <option>One</option>
+ <option> Two </option>
+ <option>
+ Four
+ </option>
+ <option>
+ Still learning how to count,
+ apparently
+ </option>
+ </select>
+
+ <select>
+ <option id="blankOption"></option>
+ <option id="optionEmptyValueSet" value="">nothing</option>
+ </select>
+
+ <br/>
+
+ <input type="radio" id="cheese" name="snack" value="cheese"/>Cheese<br/>
+ <input type="radio" id="peas" name="snack" value="peas"/>Peas<br/>
+ <input type="radio" id="cheese_and_peas" name="snack" value="cheese and peas" checked/>Cheese and peas<br/>
+ <input type="radio" id="nothing" name="snack" value="nowt" disabled="disabled"/>Not a sausage<br/>
+ <input type="radio" id="randomly_disabled_nothing" name="snack" value="funny nowt" disabled="somedisablingstring"/>Not another sausage
+
+ <input type="hidden" name="hidden" value="fromage" />
+
+ <p id="cheeseLiker">I like cheese</p>
+ <input type="submit" value="Click!"/>
+
+ <input type="radio" id="lone_disabled_selected_radio" name="not_a_snack" value="cumberland" checked="checked" disabled="disabled" />Cumberland sausage
+</form>
+
+<form method="get" action="formPage.html">
+ <p>
+ <label for="checkbox-with-label" id="label-for-checkbox-with-label">Label</label><input type="checkbox" id="checkbox-with-label" />
+ </p>
+</form>
+<input id="vsearchGadget" name="SearchableText" type="text" size="18" value="" title="Hvad søger du?" accesskey="4" class="inputLabel" />
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/frameset.html b/testing/marionette/harness/marionette_harness/www/frameset.html
new file mode 100644
index 0000000000..e91472c952
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/frameset.html
@@ -0,0 +1,13 @@
+<html>
+ <head>
+ <title>Unique title</title>
+ </head>
+<frameset cols="*, *, *, *, *, *, *">
+ <frame name="first" src="page/1"/>
+ <frame name="second" src="page/2?title=Fish"/>
+ <frame name="third" src="formPage.html"/>
+ <frame name="fourth" src="framesetPage2.html"/>
+ <frame id="fifth" src="xhtmlTest.html"/>
+ <frame id="sixth" src="test_iframe.html"/>
+ <frame id="sixth.iframe1" src="page/3"/>
+</frameset>
diff --git a/testing/marionette/harness/marionette_harness/www/framesetPage2.html b/testing/marionette/harness/marionette_harness/www/framesetPage2.html
new file mode 100644
index 0000000000..5190ceb6ce
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/framesetPage2.html
@@ -0,0 +1,7 @@
+<html>
+<head></head>
+<frameset cols="*, *">
+ <frame name="child1" src="test.html"/>
+ <frame name="child2" src="test.html"/>
+</frameset>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/html5/blue.jpg b/testing/marionette/harness/marionette_harness/www/html5/blue.jpg
new file mode 100644
index 0000000000..8ea27c42fa
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/html5/blue.jpg
Binary files differ
diff --git a/testing/marionette/harness/marionette_harness/www/html5/boolean_attributes.html b/testing/marionette/harness/marionette_harness/www/html5/boolean_attributes.html
new file mode 100644
index 0000000000..431e575aef
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/html5/boolean_attributes.html
@@ -0,0 +1,2 @@
+<!DOCTYPE html>
+<input id='disabled' disabled>
diff --git a/testing/marionette/harness/marionette_harness/www/html5/geolocation.js b/testing/marionette/harness/marionette_harness/www/html5/geolocation.js
new file mode 100644
index 0000000000..4fb4a4747b
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/html5/geolocation.js
@@ -0,0 +1,29 @@
+/* eslint-disable no-unsanitized/property */
+
+function success(position) {
+ let message = document.getElementById("status");
+ message.innerHTML =
+ "<img src='http://maps.google.com/maps/api/staticmap?center=" +
+ position.coords.latitude +
+ "," +
+ position.coords.longitude +
+ "&size=300x200&maptype=roadmap&zoom=12&&markers=size:mid|color:red|" +
+ position.coords.latitude +
+ "," +
+ position.coords.longitude +
+ "&sensor=false' />";
+ message.innerHTML += "<p>Longitude: " + position.coords.longitude + "</p>";
+ message.innerHTML += "<p>Latitude: " + position.coords.latitude + "</p>";
+ message.innerHTML += "<p>Altitude: " + position.coords.altitude + "</p>";
+}
+
+function error(msg) {
+ let message = document.getElementById("status");
+ message.innerHTML = "Failed to get geolocation.";
+}
+
+if (navigator.geolocation) {
+ navigator.geolocation.getCurrentPosition(success, error);
+} else {
+ error("Geolocation is not supported.");
+}
diff --git a/testing/marionette/harness/marionette_harness/www/html5/green.jpg b/testing/marionette/harness/marionette_harness/www/html5/green.jpg
new file mode 100644
index 0000000000..6a0d3bea47
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/html5/green.jpg
Binary files differ
diff --git a/testing/marionette/harness/marionette_harness/www/html5/offline.html b/testing/marionette/harness/marionette_harness/www/html5/offline.html
new file mode 100644
index 0000000000..c24178b5f5
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/html5/offline.html
@@ -0,0 +1 @@
+<html><head><title>Offline</title></head><body></body></html>
diff --git a/testing/marionette/harness/marionette_harness/www/html5/red.jpg b/testing/marionette/harness/marionette_harness/www/html5/red.jpg
new file mode 100644
index 0000000000..f296e27195
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/html5/red.jpg
Binary files differ
diff --git a/testing/marionette/harness/marionette_harness/www/html5/status.html b/testing/marionette/harness/marionette_harness/www/html5/status.html
new file mode 100644
index 0000000000..394116a522
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/html5/status.html
@@ -0,0 +1 @@
+<html><head><title>Online</title></head><body></body></html>
diff --git a/testing/marionette/harness/marionette_harness/www/html5/test.appcache b/testing/marionette/harness/marionette_harness/www/html5/test.appcache
new file mode 100644
index 0000000000..3bc4e00257
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/html5/test.appcache
@@ -0,0 +1,11 @@
+CACHE MANIFEST
+
+CACHE:
+# Additional items to cache.
+yellow.jpg
+red.jpg
+blue.jpg
+green.jpg
+
+FALLBACK:
+status.html offline.html
diff --git a/testing/marionette/harness/marionette_harness/www/html5/test_html_inputs.html b/testing/marionette/harness/marionette_harness/www/html5/test_html_inputs.html
new file mode 100644
index 0000000000..a170ced1ab
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/html5/test_html_inputs.html
@@ -0,0 +1,2 @@
+<!DOCTYPE html>
+<input id='number' type=number>
diff --git a/testing/marionette/harness/marionette_harness/www/html5/yellow.jpg b/testing/marionette/harness/marionette_harness/www/html5/yellow.jpg
new file mode 100644
index 0000000000..7c609b3712
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/html5/yellow.jpg
Binary files differ
diff --git a/testing/marionette/harness/marionette_harness/www/html5Page.html b/testing/marionette/harness/marionette_harness/www/html5Page.html
new file mode 100644
index 0000000000..fbd943d792
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/html5Page.html
@@ -0,0 +1,46 @@
+<html manifest="html5/test.appcache">
+<!--
+Copyright 2011 Software Freedom Conservancy.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+
+<head>
+<title>HTML5</title>
+</head>
+<body>
+
+<h3>Geolocation Test</h3>
+<div id="status">Location unknown</div>
+<script language="javascript" type="text/javascript" src="html5/geolocation.js"></script>
+
+<h3>Application Cache Test</h3>
+<div id="images">
+ <p>Current network status: <span id="state"></span></p>
+ <script>
+ const state = document.getElementById('state')
+ setInterval(function () {
+ state.className = navigator.onLine ? 'online' : 'offline';
+ // eslint-disable-next-line no-unsanitized/property
+ state.innerHTML = navigator.onLine ? 'online' : 'offline';
+ }, 250);
+ </script>
+ <img id="red" src="html5/red.jpg">
+ <img id="blue" src="html5/blue.jpg">
+ <img id="green" src="html5/green.jpg">
+ <img id="yellow" src="html5/yellow.jpg">
+</div>
+
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/keyboard.html b/testing/marionette/harness/marionette_harness/www/keyboard.html
new file mode 100644
index 0000000000..e711b31e05
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/keyboard.html
@@ -0,0 +1,99 @@
+<?xml version="1.0"?>
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
+ <!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<head>
+ <title>Testing Javascript</title>
+ <meta name="viewport" content="user-scalable=no">
+ <script type="text/javascript">
+ const seen = {};
+
+ function updateResult(event) {
+ document.getElementById('result').innerText = event.target.value;
+ }
+
+ function displayMessage(message) {
+ document.getElementById('result').innerText = message;
+ }
+
+ function appendMessage(message) {
+ document.getElementById('result').innerText += " " + message + " ";
+ }
+ </script>
+</head>
+
+<body>
+<h1>Type Stuff</h1>
+
+<div id="resultContainer">
+ Result: <p id="result"></p>
+</div>
+
+<div>
+ <form action="#">
+ <p>
+ <label>keyDown: <input type="text" id="keyDown" onkeydown="updateResult(event)"/></label>
+ <label>keyPress: <input type="text" id="keyPress" onkeypress="updateResult(event)"/></label>
+ <label>keyUp: <input type="text" id="keyUp" onkeyup="updateResult(event)"/></label>
+ <label>change: <input type="text" id="change" onchange="updateResult(event)"/></label>
+ </p>
+ <p>
+ <label>change:
+ <input type="checkbox" id="checkbox" value="checkbox thing" onchange="updateResult(event)"/>
+ </label>
+ </p>
+ <p>
+ <label>keyDown:
+ <textarea id="keyDownArea" onkeydown="updateResult(event)" rows="2" cols="15"></textarea>
+ </label>
+ <label>keyPress:
+ <textarea id="keyPressArea" onkeypress="updateResult(event)" rows="2" cols="15"></textarea>
+ </label>
+ <label>keyUp:
+ <textarea id="keyUpArea" onkeyup="updateResult(event)" rows="2" cols="15"></textarea>
+ </label>
+ </p>
+ <p>
+ <select id="selector" onchange="updateResult(event)">
+ <option value="foo">Foo</option>
+ <option value="bar">Bar</option>
+ </select>
+ </p>
+ </form>
+</div>
+
+<div id="formageddon">
+ <form action="#">
+ Key Up: <input type="text" id="keyUp" onkeyup="javascript:updateContent(this)"/><br/>
+ Key Down: <input type="text" id="keyDown" onkeydown="javascript:updateContent(this)"/><br/>
+ Key Press: <input type="text" id="keyPress" onkeypress="javascript:updateContent(this)"/><br/>
+ Change: <input type="text" id="change" onkeypress="javascript:displayMessage('change')"/><br/>
+ <textarea id="keyDownArea" onkeydown="javascript:updateContent(this)" rows="2" cols="15"></textarea>
+ <textarea id="keyPressArea" onkeypress="javascript:updateContent(this)" rows="2" cols="15"></textarea>
+ <textarea id="keyUpArea" onkeyup="javascript:updateContent(this)" rows="2" cols="15"></textarea>
+ <select id="selector" onchange="javascript:updateContent(this)">
+ <option value="foo">Foo</option>
+ <option value="bar">Bar</option>
+ </select>
+ <input type="checkbox" id="checkbox" value="checkbox thing" onchange="javascript:updateContent(this)"/>
+ <input id="clickField" type="text" onclick="document.getElementById('clickField').value='Clicked';" value="Hello"/>
+ <input id="doubleClickField" type="text" onclick="document.getElementById('doubleClickField').value='Clicked';" ondblclick="document.getElementById('doubleClickField').value='DoubleClicked';" oncontextmenu="document.getElementById('doubleClickField').value='ContextClicked'; return false;" value="DoubleHello"/>
+ <input id="clearMe" value="Something" onchange="displayMessage('Cleared')"/>
+ <input type="text" id="notDisplayed" style="display: none">
+ </form>
+</div>
+
+<div>
+ <form>
+ <input type="text" id="keyReporter" size="80"
+ onkeyup="appendMessage('up: ' + event.keyCode)"
+ onkeypress="appendMessage('press: ' + event.keyCode)"
+ onkeydown="displayMessage(''); appendMessage('down: ' + event.keyCode)" />
+ <input name="suppress" onkeydown="if (event.preventDefault) event.preventDefault(); event.returnValue = false; return false;" onkeypress="appendMessage('press');"/>
+ </form>
+</div>
+
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_columns.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_columns.html
new file mode 100644
index 0000000000..bc414cfc45
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_columns.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<html>
+ <head>
+ <meta charset="UTF-8">
+ <style>
+ #columns {
+ column-count: 2;
+ -webkit-column-count: 2;
+ column-rule: 1px solid lightgray;
+ -webkit-column-rule: 1px solid lightgray;
+ border: 1px solid lightblue;
+ width: 450px;
+ }
+ </style>
+ </head>
+ <body>
+ <div id="columns">
+ <div id="columns-inner" style="border: 1px solid red;" contenteditable="true">
+ <p id="before-image-1">Before image 1</p>
+ <p><img width="100px" height="30px" src="data:image/gif;base64,R0lGODlhAQABAIABAAD/AP///ywAAAAAAQABAAACAkQBADs="></p>
+ <p>After image 1</p>
+ <p>Before image 2</p>
+ <p><img width="100px" height="30px" src="data:image/gif;base64,R0lGODlhAQABAIABAAD/AP///ywAAAAAAQABAAACAkQBADs="></p>
+ <p>After image 2</p>
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_cursor.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_cursor.html
new file mode 100644
index 0000000000..fdbd6fe7a8
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_cursor.html
@@ -0,0 +1,31 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html id="html">
+ <head>
+ <title>Marionette tests for AccessibleCaret in cursor mode</title>
+ <style>
+ .block {
+ width: 10em;
+ height: 6em;
+ word-wrap: break-word;
+ overflow: auto;
+ }
+ </style>
+ </head>
+ <body>
+ <div>
+ <input id="input" value="ABCDEFGHI">
+ <input id="input-padding" style="padding: 1em;" value="ABCDEFGHI">
+ </div>
+ <br>
+ <div>
+ <textarea name="textarea" id="textarea" rows="4" cols="6">ABCDEFGHI</textarea>
+ <textarea id="textarea-one-line" rows="3">ABCDEFGHI</textarea>
+ </div>
+ <br>
+ <div class="block" contenteditable="true" id="contenteditable">ABCDEFGHI</div>
+ </body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_display_none.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_display_none.html
new file mode 100644
index 0000000000..766f320011
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_display_none.html
@@ -0,0 +1,10 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html id="html" style="display: none">
+ <body>
+ <div id="content">ABC DEF GHI</div>
+ </body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe.html
new file mode 100644
index 0000000000..175d3c3d5c
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe.html
@@ -0,0 +1,15 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html id="html">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+ <title>Marionette tests for AccessibleCaret in selection mode (iframe)</title>
+ </head>
+ <body>
+ <iframe id="frame" src="test_carets_longtext.html" style="width: 10em; height: 8em;"></iframe>
+ <input id="input" value="ABC DEF GHI">
+ </body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe_scroll.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe_scroll.html
new file mode 100644
index 0000000000..5f4b00e5bd
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe_scroll.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html>
+ <title>Bug 1657256: Test select word, scroll up, and drag AccessibleCaret.</title>
+ <style>
+ :root {
+ font: 16px/1.25 monospace;
+ }
+ </style>
+
+ <iframe id="iframe" src="test_carets_iframe_scroll_inner.html" style="width: 6em; height: 8em;"></iframe>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe_scroll_inner.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe_scroll_inner.html
new file mode 100644
index 0000000000..1087227007
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe_scroll_inner.html
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<html>
+ <style>
+ :root {
+ font: 16px/1.25 monospace;
+ }
+ </style>
+
+ <body id="bd">
+ AAAAAA
+ BBBBBB
+ CCCCCC
+ <span id="content">DDDDDD</span>
+ <span id="content2">EEEEEE</span>
+ FFFFFF
+ GGGGGG
+ HHHHHH
+ IIIIII
+ JJJJJJ
+ KKKKKK
+ LLLLLL
+ MMMMMM
+ </body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_longtext.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_longtext.html
new file mode 100644
index 0000000000..7e2495509b
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_longtext.html
@@ -0,0 +1,9 @@
+<html>
+ <head>
+ <title>Bug 1094072: Orientation change test for AccessibleCaret positions</title>
+ </head>
+ <body id="bd">
+ <h3 id="longtext">long long text for orientation change test long long text for orientation change test long long text for orientation change test long long text for orientation change test</h3>
+ <div contenteditable="true" id="bottomtext">bottom text</div>
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_multipleline.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_multipleline.html
new file mode 100644
index 0000000000..fbbefbebcb
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_multipleline.html
@@ -0,0 +1,18 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html id="html">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+ <title>Bug 1019441: Marionette tests for AccessibleCaret (multiple lines)</title>
+ </head>
+ <body>
+ <div><textarea id="textarea2" style="width: 10em; height: 6em; overflow: auto;">First Line&#13;&#10;&#13;&#10;Second Line&#13;&#10;&#13;&#10;Third Line</textarea></div>
+ <br>
+ <div style="width: 10em; height: 6em; overflow: auto;" id="contenteditable2" contenteditable="true">First Line<br><br>Second Line<br><br>Third Line</div>
+ <br>
+ <div style="width: 10em; height: 6em; overflow: auto;" id="content2">First Line<br><br>Second Line<br><br>Third Line</div>
+ </body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_multiplerange.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_multiplerange.html
new file mode 100644
index 0000000000..9b9bbe9e9f
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_multiplerange.html
@@ -0,0 +1,19 @@
+<html>
+<style>
+h4 {
+ user-select: none;
+}
+</style>
+<body id=bd>
+<h3 id=sel1>user can select this 1</h3>
+<h3 id=sel2>user can select this 2</h3>
+<h3 id=sel3>user can select this 3</h3>
+<h4 id=nonsel1>user cannot select this 1</h4>
+<h4 id=nonsel2>user cannot select this 2</h4>
+<h3 id=sel4>user can select this 4</h3>
+<h3 id=sel5>user can select this 5</h3>
+<h4 id=nonsel3>user cannot select this 3</h4>
+<h3 id=sel6>user can select this 6</h3>
+<h3 id=sel7>user can select this 7</h3>
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_selection.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_selection.html
new file mode 100644
index 0000000000..bd36f45b23
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_selection.html
@@ -0,0 +1,42 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html id="html">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+ <title>Marionette tests for AccessibleCaret in selection mode</title>
+ <style>
+ .block {
+ width: 10em;
+ height: 4em;
+ word-wrap: break-word;
+ overflow: auto;
+ }
+ </style>
+ </head>
+ <body>
+ <div>
+ <input id="input" value="ABC DEF GHI">
+ <input id="input-padding" style="padding: 1em;" value="ABC DEF GHI">
+
+ <!-- To successfully select 'B's when 'A' is selected, use sufficient
+ spaces between 'A's and 'B's to avoid the second caret covers 'B's. -->
+ <input size="16" id="input-size" value="AAAAAAAA BBBBBBBB">
+ </div>
+ <br>
+ <div>
+ <textarea id="textarea" rows="4" cols="8">ABC DEF GHI JKL MNO PQR</textarea>
+ <textarea id="textarea-one-line" rows="4" cols="12">ABC DEF GHI</textarea>
+ </div>
+ <br>
+ <div><textarea dir="rtl" id="textarea-rtl" rows="8" cols="8">موزيلا فيرفكس موزيلا فيرفكس</textarea></div>
+ <br>
+ <div class="block" contenteditable="true" id="contenteditable">ABC DEF GHI</div>
+ <br>
+ <div class="block" id="content">ABC DEF GHI</div>
+ <br>
+ <div style="user-select: none;" id="non-selectable">Non-selectable</div>
+ </body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_svg_shapes.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_svg_shapes.html
new file mode 100644
index 0000000000..ea3ad4ecf6
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_svg_shapes.html
@@ -0,0 +1,12 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!DOCTYPE html>
+<html>
+ <body>
+ <svg xmlns="http://www.w3.org/2000/svg" id="svg-element" width="200" height="200">
+ <rect id="rect" x="100" y="100" width="20" height="20"></rect>
+ </svg>
+ <p id="text">ABC DEF GHI</p>
+ </body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/navigation_pushstate.html b/testing/marionette/harness/marionette_harness/www/navigation_pushstate.html
new file mode 100644
index 0000000000..fbde792d8c
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/navigation_pushstate.html
@@ -0,0 +1,20 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Navigation by manipulating the browser history</title>
+ <script type="text/javascript">
+ function forward() {
+ let stateObj = { foo: "bar" };
+ history.pushState(stateObj, "", "navigation_pushstate_target.html");
+ }
+ </script>
+</head>
+
+<body>
+ <p>Navigate <a onclick="javascript:forward();" id="forward">forward</a></p>
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/navigation_pushstate_target.html b/testing/marionette/harness/marionette_harness/www/navigation_pushstate_target.html
new file mode 100644
index 0000000000..153d0a657f
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/navigation_pushstate_target.html
@@ -0,0 +1,13 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html>
+<head>
+</head>
+
+<body>
+ <p id="target">Pushstate target</p>
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/nestedElements.html b/testing/marionette/harness/marionette_harness/www/nestedElements.html
new file mode 100644
index 0000000000..618bf3231b
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/nestedElements.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<a href="1.html">hello world</a>
+<a href="1.html">hello world</a><a href="1.html">hello world</a>
+<div name="div1">
+ <a href="2.html" name="link1">hello world</a>
+ <a href="2.html" name="link2">hello world</a>
+</div>
+
+<a href="1.html">hello world</a><a href="1.html">hello world</a><a href="1.html">hello world</a>
diff --git a/testing/marionette/harness/marionette_harness/www/reftest/mostly-teal-700x700.html b/testing/marionette/harness/marionette_harness/www/reftest/mostly-teal-700x700.html
new file mode 100644
index 0000000000..a5aa12d0d2
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/reftest/mostly-teal-700x700.html
@@ -0,0 +1,21 @@
+<!doctype html>
+<style>
+ * {
+ margin: 0;
+ padding: 0;
+ }
+
+ html {
+ background: teal;
+ }
+
+ div {
+ position: absolute;
+ top: 600px;
+ left: 600px;
+ height: 100px;
+ width: 100px;
+ background: orange;
+ }
+</style>
+<div></div>
diff --git a/testing/marionette/harness/marionette_harness/www/reftest/teal-700x700.html b/testing/marionette/harness/marionette_harness/www/reftest/teal-700x700.html
new file mode 100644
index 0000000000..e441e88e6d
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/reftest/teal-700x700.html
@@ -0,0 +1,21 @@
+<!doctype html>
+<style>
+ * {
+ margin: 0;
+ padding: 0;
+ }
+
+ html {
+ background: teal;
+ }
+
+ div {
+ position: absolute;
+ top: 600px;
+ left: 600px;
+ height: 100px;
+ width: 100px;
+ background: transparent;
+ }
+</style>
+<div></div>
diff --git a/testing/marionette/harness/marionette_harness/www/resultPage.html b/testing/marionette/harness/marionette_harness/www/resultPage.html
new file mode 100644
index 0000000000..6e2fea9a14
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/resultPage.html
@@ -0,0 +1,16 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<html>
+<head>
+ <title>We Arrive Here</title>
+</head>
+<body>
+
+
+<div>
+ <input type='text' id='email'/>
+</div>
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/serviceworker/install_serviceworker.html b/testing/marionette/harness/marionette_harness/www/serviceworker/install_serviceworker.html
new file mode 100644
index 0000000000..572a05c2bc
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/serviceworker/install_serviceworker.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Install Service Worker</title>
+ </head>
+ <body>
+ <script>
+ navigator.serviceWorker.register("serviceworker.js");
+ </script>
+ </body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/serviceworker/serviceworker.js b/testing/marionette/harness/marionette_harness/www/serviceworker/serviceworker.js
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/serviceworker/serviceworker.js
diff --git a/testing/marionette/harness/marionette_harness/www/shim.js b/testing/marionette/harness/marionette_harness/www/shim.js
new file mode 100644
index 0000000000..c304333088
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/shim.js
@@ -0,0 +1,297 @@
+/**
+ * mouse_event_shim.js: generate mouse events from touch events.
+ *
+ * This library listens for touch events and generates mousedown, mousemove
+ * mouseup, and click events to match them. It captures and dicards any
+ * real mouse events (non-synthetic events with isTrusted true) that are
+ * send by gecko so that there are not duplicates.
+ *
+ * This library does emit mouseover/mouseout and mouseenter/mouseleave
+ * events. You can turn them off by setting MouseEventShim.trackMouseMoves to
+ * false. This means that mousemove events will always have the same target
+ * as the mousedown even that began the series. You can also call
+ * MouseEventShim.setCapture() from a mousedown event handler to prevent
+ * mouse tracking until the next mouseup event.
+ *
+ * This library does not support multi-touch but should be sufficient
+ * to do drags based on mousedown/mousemove/mouseup events.
+ *
+ * This library does not emit dblclick events or contextmenu events
+ */
+
+"use strict";
+
+(function() {
+ // Make sure we don't run more than once
+ if (MouseEventShim) {
+ return;
+ }
+
+ // Bail if we're not on running on a platform that sends touch
+ // events. We don't need the shim code for mouse events.
+ try {
+ document.createEvent("TouchEvent");
+ } catch (e) {
+ return;
+ }
+
+ let starttouch; // The Touch object that we started with
+ let target; // The element the touch is currently over
+ let emitclick; // Will we be sending a click event after mouseup?
+
+ // Use capturing listeners to discard all mouse events from gecko
+ window.addEventListener("mousedown", discardEvent, true);
+ window.addEventListener("mouseup", discardEvent, true);
+ window.addEventListener("mousemove", discardEvent, true);
+ window.addEventListener("click", discardEvent, true);
+
+ function discardEvent(e) {
+ if (e.isTrusted) {
+ e.stopImmediatePropagation(); // so it goes no further
+ if (e.type === "click") {
+ e.preventDefault();
+ } // so it doesn't trigger a change event
+ }
+ }
+
+ // Listen for touch events that bubble up to the window.
+ // If other code has called stopPropagation on the touch events
+ // then we'll never see them. Also, we'll honor the defaultPrevented
+ // state of the event and will not generate synthetic mouse events
+ window.addEventListener("touchstart", handleTouchStart);
+ window.addEventListener("touchmove", handleTouchMove);
+ window.addEventListener("touchend", handleTouchEnd);
+ window.addEventListener("touchcancel", handleTouchEnd); // Same as touchend
+
+ function handleTouchStart(e) {
+ // If we're already handling a touch, ignore this one
+ if (starttouch) {
+ return;
+ }
+
+ // Ignore any event that has already been prevented
+ if (e.defaultPrevented) {
+ return;
+ }
+
+ // Sometimes an unknown gecko bug causes us to get a touchstart event
+ // for an iframe target that we can't use because it is cross origin.
+ // Don't start handling a touch in that case
+ try {
+ e.changedTouches[0].target.ownerDocument;
+ } catch (e) {
+ // Ignore the event if we can't see the properties of the target
+ return;
+ }
+
+ // If there is more than one simultaneous touch, ignore all but the first
+ starttouch = e.changedTouches[0];
+ target = starttouch.target;
+ emitclick = true;
+
+ // Move to the position of the touch
+ emitEvent("mousemove", target, starttouch);
+
+ // Now send a synthetic mousedown
+ let result = emitEvent("mousedown", target, starttouch);
+
+ // If the mousedown was prevented, pass that on to the touch event.
+ // And remember not to send a click event
+ if (!result) {
+ e.preventDefault();
+ emitclick = false;
+ }
+ }
+
+ function handleTouchEnd(e) {
+ if (!starttouch) {
+ return;
+ }
+
+ // End a MouseEventShim.setCapture() call
+ if (MouseEventShim.capturing) {
+ MouseEventShim.capturing = false;
+ MouseEventShim.captureTarget = null;
+ }
+
+ for (let i = 0; i < e.changedTouches.length; i++) {
+ let touch = e.changedTouches[i];
+ // If the ended touch does not have the same id, skip it
+ if (touch.identifier !== starttouch.identifier) {
+ continue;
+ }
+
+ emitEvent("mouseup", target, touch);
+
+ // If target is still the same element we started and the touch did not
+ // move more than the threshold and if the user did not prevent
+ // the mousedown, then send a click event, too.
+ if (emitclick) {
+ emitEvent("click", starttouch.target, touch);
+ }
+
+ starttouch = null;
+ return;
+ }
+ }
+
+ function handleTouchMove(e) {
+ if (!starttouch) {
+ return;
+ }
+
+ for (let i = 0; i < e.changedTouches.length; i++) {
+ let touch = e.changedTouches[i];
+ // If the ended touch does not have the same id, skip it
+ if (touch.identifier !== starttouch.identifier) {
+ continue;
+ }
+
+ // Don't send a mousemove if the touchmove was prevented
+ if (e.defaultPrevented) {
+ return;
+ }
+
+ // See if we've moved too much to emit a click event
+ let dx = Math.abs(touch.screenX - starttouch.screenX);
+ let dy = Math.abs(touch.screenY - starttouch.screenY);
+ if (
+ dx > MouseEventShim.dragThresholdX ||
+ dy > MouseEventShim.dragThresholdY
+ ) {
+ emitclick = false;
+ }
+
+ let tracking =
+ MouseEventShim.trackMouseMoves && !MouseEventShim.capturing;
+
+ let oldtarget;
+ let newtarget;
+ if (tracking) {
+ // If the touch point moves, then the element it is over
+ // may have changed as well. Note that calling elementFromPoint()
+ // forces a layout if one is needed.
+ // XXX: how expensive is it to do this on each touchmove?
+ // Can we listen for (non-standard) touchleave events instead?
+ oldtarget = target;
+ newtarget = document.elementFromPoint(touch.clientX, touch.clientY);
+ if (newtarget === null) {
+ // this can happen as the touch is moving off of the screen, e.g.
+ newtarget = oldtarget;
+ }
+ if (newtarget !== oldtarget) {
+ leave(oldtarget, newtarget, touch); // mouseout, mouseleave
+ target = newtarget;
+ }
+ } else if (MouseEventShim.captureTarget) {
+ target = MouseEventShim.captureTarget;
+ }
+
+ emitEvent("mousemove", target, touch);
+
+ if (tracking && newtarget !== oldtarget) {
+ enter(newtarget, oldtarget, touch); // mouseover, mouseenter
+ }
+ }
+ }
+
+ // Return true if element a contains element b
+ function contains(a, b) {
+ return (a.compareDocumentPosition(b) & 16) !== 0;
+ }
+
+ // A touch has left oldtarget and entered newtarget
+ // Send out all the events that are required
+ function leave(oldtarget, newtarget, touch) {
+ emitEvent("mouseout", oldtarget, touch, newtarget);
+
+ // If the touch has actually left oldtarget (and has not just moved
+ // into a child of oldtarget) send a mouseleave event. mouseleave
+ // events don't bubble, so we have to repeat this up the hierarchy.
+ for (let e = oldtarget; !contains(e, newtarget); e = e.parentNode) {
+ emitEvent("mouseleave", e, touch, newtarget);
+ }
+ }
+
+ // A touch has entered newtarget from oldtarget
+ // Send out all the events that are required.
+ function enter(newtarget, oldtarget, touch) {
+ emitEvent("mouseover", newtarget, touch, oldtarget);
+
+ // Emit non-bubbling mouseenter events if the touch actually entered
+ // newtarget and wasn't already in some child of it
+ for (let e = newtarget; !contains(e, oldtarget); e = e.parentNode) {
+ emitEvent("mouseenter", e, touch, oldtarget);
+ }
+ }
+
+ function emitEvent(type, target, touch, relatedTarget) {
+ let synthetic = document.createEvent("MouseEvents");
+ let bubbles = type !== "mouseenter" && type !== "mouseleave";
+ let count =
+ type === "mousedown" || type === "mouseup" || type === "click" ? 1 : 0;
+
+ synthetic.initMouseEvent(
+ type,
+ bubbles, // canBubble
+ true, // cancelable
+ window,
+ count, // detail: click count
+ touch.screenX,
+ touch.screenY,
+ touch.clientX,
+ touch.clientY,
+ false, // ctrlKey: we don't have one
+ false, // altKey: we don't have one
+ false, // shiftKey: we don't have one
+ false, // metaKey: we don't have one
+ 0, // we're simulating the left button
+ relatedTarget || null
+ );
+
+ try {
+ return target.dispatchEvent(synthetic);
+ } catch (e) {
+ console.warn("Exception calling dispatchEvent", type, e);
+ return true;
+ }
+ }
+})();
+
+const MouseEventShim = {
+ // It is a known gecko bug that synthetic events have timestamps measured
+ // in microseconds while regular events have timestamps measured in
+ // milliseconds. This utility function returns a the timestamp converted
+ // to milliseconds, if necessary.
+ getEventTimestamp(e) {
+ if (e.isTrusted) {
+ // XXX: Are real events always trusted?
+ return e.timeStamp;
+ }
+ return e.timeStamp / 1000;
+ },
+
+ // Set this to false if you don't care about mouseover/out events
+ // and don't want the target of mousemove events to follow the touch
+ trackMouseMoves: true,
+
+ // Call this function from a mousedown event handler if you want to guarantee
+ // that the mousemove and mouseup events will go to the same element
+ // as the mousedown even if they leave the bounds of the element. This is
+ // like setting trackMouseMoves to false for just one drag. It is a
+ // substitute for event.target.setCapture(true)
+ setCapture(target) {
+ this.capturing = true; // Will be set back to false on mouseup
+ if (target) {
+ this.captureTarget = target;
+ }
+ },
+
+ capturing: false,
+
+ // Keep these in sync with ui.dragThresholdX and ui.dragThresholdY prefs.
+ // If a touch ever moves more than this many pixels from its starting point
+ // then we will not synthesize a click event when the touch ends.
+ dragThresholdX: 25,
+ dragThresholdY: 25,
+};
diff --git a/testing/marionette/harness/marionette_harness/www/slow_resource.html b/testing/marionette/harness/marionette_harness/www/slow_resource.html
new file mode 100644
index 0000000000..b87d9f4b86
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/slow_resource.html
@@ -0,0 +1,13 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html>
+<head>
+<title>Slow loading resource</title>
+</head>
+<body>
+ <img src="/slow?delay=4" id="slow" />
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/test.html b/testing/marionette/harness/marionette_harness/www/test.html
new file mode 100644
index 0000000000..70e42c2d06
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/test.html
@@ -0,0 +1,38 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html>
+<head>
+<title>Marionette Test</title>
+</head>
+<body>
+ <h1 id="testh1">Test Page</h1>
+ <script type="text/javascript">
+ window.ready = true;
+ function addDelayedElement() {
+ setTimeout(createDiv, 2000);
+ function createDiv() {
+ let newDiv = document.createElement("div");
+ newDiv.id = "newDiv";
+ let newContent = document.createTextNode("I am a newly created div!");
+ newDiv.appendChild(newContent);
+ document.body.appendChild(newDiv);
+ }
+ }
+ function clicked() {
+ let link = document.getElementById("mozLink");
+ link.innerHTML = "Clicked";
+ }
+ </script>
+ <a href="#" id="mozLink" class="linkClass" onclick="clicked()">Click me!</a>
+ <div id="testDiv">
+ <a href="#" id="divLink" class="linkClass" onclick="clicked()">Div click me!</a>
+ <a href="#" id="divLink2" class="linkClass" onclick="clicked()">Div click me!</a>
+ </div>
+ <input name="myInput" type="text" value="asdf"/>
+ <input name="myCheckBox" type="checkbox" />
+ <input id="createDivButton" type="button" value="create a div" onclick="addDelayedElement()" />
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/testAction.html b/testing/marionette/harness/marionette_harness/www/testAction.html
new file mode 100644
index 0000000000..404ce9809a
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/testAction.html
@@ -0,0 +1,96 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+
+<html>
+<meta charset="UTF-8">
+<head>
+<title>Marionette Test</title>
+</head>
+<body>
+ <h1 id="testh1">Test Page</h1>
+ <button id="button1" style="position:absolute;left:0px;top:55px;" type="button" allowevents=true>button1</button>
+ <button id="button2" style="position:absolute;left:0px;top:355px;" type="button" allowevents=true>button2</button>
+ <button id="button3" style="position:absolute;left:0px;top:455px;" type="button" allowevents=true>button3</button>
+ <button id="button4" style="position:absolute;left:100px;top:455px;" type="button" allowevents=true>button4</button>
+ <button id="buttonScroll" style="position:absolute;left:100px;top:855px;" type="button" allowevents=true>buttonScroll</button>
+ <h2 id="hidden" style="visibility: hidden" class="linkClass">Hidden</h2>
+ <button id="buttonFlick" style="position:absolute;left:0px;top:255px;" type="button" allowevents=true>buttonFlick</button>
+ <script type="text/javascript">
+ let button3Timer = null;
+ let button4Timer = null;
+ //appends passed in text to the innerHTML of the event's target
+ function appendText(text) {
+ return function(evt) {
+ let element;
+ if (evt.type.includes("touch")) {
+ if (evt.type == "touchstart") {
+ element = evt.target;
+ }
+ else {
+ //since the target of touchstart is the target of all subsequent events, then
+ //changedTouches holds the current coordinates of this touch event, so we
+ //use these coordinates to find the element under the touch event
+ let touches = evt.changedTouches;
+ let x = touches[0].clientX;
+ let y = touches[0].clientY;
+ element = document.elementFromPoint(x,y);
+ }
+ }
+ //handle mouse events or contextmenu
+ else {
+ element = evt.target;
+ }
+ // eslint-disable-next-line no-unsanitized/property
+ element.innerHTML += text;
+ };
+ };
+ //use this function outside of attachListeners when you want to test sendMouseOnlyEvents on a target
+ function attachMouseListeners(element) {
+ element.addEventListener("contextmenu", appendText("-contextmenu"));
+ element.addEventListener("mousedown", appendText("-mousedown"));
+ element.addEventListener("mousemove", appendText("-mousemove"));
+ element.addEventListener("mouseup", appendText("-mouseup"));
+ element.addEventListener("click", appendText("-click"));
+ };
+ function attachListeners(id) {
+ let element = document.getElementById(id);
+ element.addEventListener("touchstart", appendText("-touchstart"));
+ element.addEventListener("touchmove", appendText("-touchmove"));
+ element.addEventListener("touchend", appendText("-touchend"));
+ element.addEventListener("touchcancel", appendText("-touchcancel"));
+ attachMouseListeners(element);
+ };
+ //for tracking time on an element
+ function addTimers(id, timer) {
+ let element = document.getElementById(id);
+ element.addEventListener("touchstart", function(evt) { timer = (new Date()).getTime();});
+ // eslint-disable-next-line no-unsanitized/property
+ element.addEventListener("touchend", function(evt) { timer = (new Date()).getTime() - timer; evt.target.innerHTML += "-" + timer;});
+ }
+ attachListeners("button1");
+ attachListeners("button2");
+ attachListeners("button3");
+ attachListeners("button4");
+ attachListeners("buttonScroll");
+ addTimers("button3");
+ addTimers("button4");
+ const buttonFlick = document.getElementById("buttonFlick");
+ attachMouseListeners(buttonFlick);
+ function createDelayed() {
+ let newButton = document.createElement("button");
+ newButton.id = "delayed";
+ newButton.setAttribute("style", "position:absolute;left:220px;top:455px;");
+ let content = document.createTextNode("delayed");
+ newButton.appendChild(content);
+ document.body.appendChild(newButton);
+ newButton.addEventListener("mousemove", appendText("-mousemove"));
+ newButton.addEventListener("mouseup", appendText("-mouseup"));
+ newButton.addEventListener("click", appendText("-click"));
+ };
+ window.setTimeout(createDelayed, 5000);
+ </script>
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/test_accessibility.html b/testing/marionette/harness/marionette_harness/www/test_accessibility.html
new file mode 100644
index 0000000000..8cc9fd6493
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/test_accessibility.html
@@ -0,0 +1,57 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+
+<html>
+<meta charset="UTF-8">
+<head>
+<title>Marionette Test</title>
+</head>
+<body>
+ <button id="button1">button1</button>
+ <button id="button2" aria-label="button2"></button>
+ <span id="button3">I am a bad button with no accessible</span>
+ <h1 id="button4">I am a bad button that is actually a header</h1>
+ <h1 id="button5">
+ I am a bad button that is actually an actionable header with a listener
+ </h1>
+ <button id="button6"></button>
+ <button id="button7" aria-hidden="true">button7</button>
+ <div aria-hidden="true">
+ <button id="button8">button8</button>
+ </div>
+ <button id="button9" style="position:absolute;left:-100px;top:-455px;">
+ button9
+ </button>
+ <button id="button10" style="visibility:hidden;">
+ button10
+ </button>
+ <span id="no_accessible_but_displayed">I have no accessible object</span>
+ <button id="button11" disabled>button11</button>
+ <button id="button12" aria-disabled="true">button12</button>
+ <span id="no_accessible_but_disabled" disabled>I have no accessible object</span>
+ <span id="button13" tabindex="0" role="button" aria-label="Span button">Span button</span>
+ <span id="button14" role="button" aria-label="Span button">Unexplorable Span button</span>
+ <button id="button15" style="pointer-events:none;">button15</button>
+ <div style="pointer-events:none;">
+ <button id="button16">button16</button>
+ </div>
+ <div style="pointer-events:none;">
+ <button style="pointer-events:all;" id="button17">button17</button>
+ </div>
+ <input id="input1" title="My Input 1" name="myInput1" type="text" value="asdf"/>
+ <select>
+ <option id="option1" value="val1">Val1</option>
+ <option id="option2" value="val2" selected>Val2</option>
+ </select>
+ <script>
+ 'use strict';
+ document.getElementById('button5').addEventListener('click', function() {
+ // A pseudo button that has a listener but is missing button semantics.
+ return true;
+ });
+ </script>
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/test_clearing.html b/testing/marionette/harness/marionette_harness/www/test_clearing.html
new file mode 100644
index 0000000000..2aa3c6a21f
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/test_clearing.html
@@ -0,0 +1,24 @@
+<html>
+ <!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+ <body>
+ <input id="writableTextInput" type="text" value="Test"/>
+
+ <input id="readOnlyTextInput" type="text" readonly value="Test"/>
+
+ <input id="textInputnotenabled" type="text" disabled="true" value="Test"/>
+
+ <textarea id="writableTextArea" rows="2" cols="20">
+ This is a sample text area which is supposed to be cleared
+ </textarea>
+
+ <textarea id="textAreaReadOnly" readonly rows="5" cols="20">
+ text area which is not supposed to be cleared</textarea>
+
+ <textarea rows="5" id="textAreaNotenabled" disabled="true" cols="20">
+ text area which is not supposed to be cleared</textarea>
+
+ <div id="content-editable" contentEditable="true">This is a contentEditable area</div>
+ </body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/test_dynamic.html b/testing/marionette/harness/marionette_harness/www/test_dynamic.html
new file mode 100644
index 0000000000..504e7e74ba
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/test_dynamic.html
@@ -0,0 +1,38 @@
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
+ "http://www.w3.org/TR/html4/loose.dtd">
+<html>
+ <head>
+ <title></title>
+ <script type="text/javascript">
+ let next = 0;
+
+ function addMore() {
+ let box = document.createElement('DIV');
+ box.id = 'box' + next++;
+ box.className = 'redbox';
+ box.style.width = '150px';
+ box.style.height = '150px';
+ box.style.backgroundColor = 'red';
+ box.style.border = '1px solid black';
+ box.style.margin = '5px';
+ window.setTimeout(function() {
+ document.body.appendChild(box);
+ }, 1000);
+ }
+
+ function reveal() {
+ let elem = document.getElementById('revealed');
+ window.setTimeout(function() {
+ elem.style.display = '';
+ }, 1000);
+ }
+ </script>
+ </head>
+ <body>
+ <input id="adder" type="button" value="Add a box!" onclick="addMore()"/>
+
+ <input id="reveal" type="button" value="Reveal a new input" onclick="reveal();" />
+
+ <input id="revealed" style="display:none;" />
+ </body>
+ </html>
diff --git a/testing/marionette/harness/marionette_harness/www/test_iframe.html b/testing/marionette/harness/marionette_harness/www/test_iframe.html
new file mode 100644
index 0000000000..b323ace679
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/test_iframe.html
@@ -0,0 +1,16 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!doctype html>
+<html>
+<head>
+<title>Marionette IFrame Test</title>
+</head>
+<body>
+ <h1 id="iframe_page_heading">This is the heading</h1>
+
+ <iframe src="test.html" id="test_iframe"></iframe>
+ <iframe src="test.html" id="test_iframe" name="test_iframe_name"></iframe>
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/test_inner_iframe.html b/testing/marionette/harness/marionette_harness/www/test_inner_iframe.html
new file mode 100644
index 0000000000..8c9810d0bb
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/test_inner_iframe.html
@@ -0,0 +1,13 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!doctype html>
+<html>
+<head>
+<title>Inner Iframe</title>
+</head>
+<body>
+ <iframe src="test.html" id="inner_frame"></iframe>
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/test_nested_iframe.html b/testing/marionette/harness/marionette_harness/www/test_nested_iframe.html
new file mode 100644
index 0000000000..49ac1b0ba5
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/test_nested_iframe.html
@@ -0,0 +1,13 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!doctype html>
+<html>
+<head>
+<title>Marionette IFrame Test</title>
+</head>
+<body>
+ <iframe src="test_inner_iframe.html" id="test_iframe"></iframe>
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/test_oop_1.html b/testing/marionette/harness/marionette_harness/www/test_oop_1.html
new file mode 100644
index 0000000000..29add714cd
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/test_oop_1.html
@@ -0,0 +1,14 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html>
+<head>
+<title>OOP Test Frame 1</title>
+</head>
+<body>
+ <h1 id="testh1">OOP Test Frame 1</h1>
+ Hello!
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/test_oop_2.html b/testing/marionette/harness/marionette_harness/www/test_oop_2.html
new file mode 100644
index 0000000000..6e5a4962fb
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/test_oop_2.html
@@ -0,0 +1,14 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html>
+<head>
+<title>OOP Test Frame 2</title>
+</head>
+<body>
+ <h1 id="testh1">OOP Test Frame 2</h1>
+ Hello!
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/test_tab_modal_dialogs.html b/testing/marionette/harness/marionette_harness/www/test_tab_modal_dialogs.html
new file mode 100644
index 0000000000..b61bda5608
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/test_tab_modal_dialogs.html
@@ -0,0 +1,44 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Dialog Test</title>
+ <script type="text/javascript">
+ function setInnerText(id, value) {
+ // eslint-disable-next-line no-unsanitized/property
+ document.getElementById(id).innerHTML = "<p>" + value + "</p>";
+ }
+
+ function handleAlert () {
+ setInnerText("text", alert("Marionette alert"));
+ }
+
+ function handleConfirm () {
+ setInnerText("text", confirm("Marionette confirm"));
+ }
+
+ function handlePrompt () {
+ setInnerText("text", prompt("Marionette prompt"));
+ }
+
+ function handleTwoDialogs() {
+ setInnerText("text1", prompt("First"));
+ setInnerText("text2", prompt("Second"));
+ }
+ </script>
+</head>
+<body>
+ <a href="#" id="tab-modal-alert" onclick="handleAlert()">Open an alert dialog.</a>
+ <a href="#" id="tab-modal-confirm" onclick="handleConfirm()">Open a confirm dialog.</a>
+ <a href="#" id="tab-modal-prompt" onclick="handlePrompt()">Open a prompt dialog.</a>
+ <a href="#" id="open-two-dialogs" onclick="handleTwoDialogs()">Open two prompts.</a>
+ <a href="#" id="click-handler" onclick="document.getElementById('text').innerHTML='result';">Make text appear.</a>
+
+ <div id="text"></div>
+ <div id="text1"></div>
+ <div id="text2"></div>
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/test_windows.html b/testing/marionette/harness/marionette_harness/www/test_windows.html
new file mode 100644
index 0000000000..f3759990c0
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/test_windows.html
@@ -0,0 +1,13 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
+<head>
+ <title>XHTML Test Page</title>
+</head>
+<body>
+ <p><a href="resultPage.html" onClick='javascript:window.open("resultPage.html",null, "menubar=0,location=1,resizable=1,scrollbars=1,status=0,width=700,height=375");' name="windowOne">Open new window</a></p>
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/visibility.html b/testing/marionette/harness/marionette_harness/www/visibility.html
new file mode 100644
index 0000000000..2296f3cd46
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/visibility.html
@@ -0,0 +1,51 @@
+<?xml version="1.0"?>
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
+ <!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<head>
+ <title>Testing Visibility</title>
+</head>
+
+<body>
+
+<div>
+ <span id="hideMe" onclick="this.style.display = 'none';">Click to hide me.</span>
+</div>
+
+<div id="zero" style="width:0;height:0">
+ <div>
+ <img src="map.png">
+ </div>
+</div>
+
+<p id="suppressedParagraph" style="display: none">A paragraph suppressed using CSS display=none</p>
+
+<div>
+ <p id="displayed">Displayed</p>
+
+ <form action="#"><input type="hidden" name="hidden" /> </form>
+
+ <p id="none" style="display: none;">Display set to none</p>
+
+ <p id="hidden" style="visibility: hidden;">Hidden</p>
+
+ <div id="hiddenparent" style="height: 2em; display: none;">
+ <div id="hiddenchild">
+ <a href="#" id="hiddenlink">ok</a>
+ </div>
+ </div>
+
+ <div style="visibility: hidden;">
+ <span>
+ <input id="unclickable" />
+ <input type="checkbox" id="untogglable" checked="checked" />Check box you can't see
+ </span>
+ </div>
+
+ <p id="outer" style="visibility: hidden">A <b id="visibleSubElement" style="visibility: visible">sub-element that is explicitly visible</b> using CSS visibility=visible</p>
+</div>
+
+<input type='text' id='notDisplayed' style='display:none'>
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/white.png b/testing/marionette/harness/marionette_harness/www/white.png
new file mode 100644
index 0000000000..8a68c11548
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/white.png
Binary files differ
diff --git a/testing/marionette/harness/marionette_harness/www/windowHandles.html b/testing/marionette/harness/marionette_harness/www/windowHandles.html
new file mode 100644
index 0000000000..bcd0b08dc3
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/windowHandles.html
@@ -0,0 +1,16 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html>
+<head>
+<title>Marionette New Tab Link</title>
+</head>
+<body>
+ <a href="empty.html" id="new-tab" target="_blank">New Tab</a>
+ <a href="about:blank" id="new-blank-tab" target="_blank">New blank Tab</a>
+
+ <a href="" id="new-window" onClick='javascript:window.open("empty.html", null, "location=1,toolbar=1");'>New Window</a>
+</body>
+</html>
diff --git a/testing/marionette/harness/marionette_harness/www/xhtmlTest.html b/testing/marionette/harness/marionette_harness/www/xhtmlTest.html
new file mode 100644
index 0000000000..30940c709e
--- /dev/null
+++ b/testing/marionette/harness/marionette_harness/www/xhtmlTest.html
@@ -0,0 +1,79 @@
+<?xml version="1.0"?>
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
+ <!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<head>
+ <title>XHTML Test Page</title>
+</head>
+<body>
+<div class="navigation">
+ <p><a href="resultPage.html" target="result" name="windowOne">Open new window</a></p>
+ <p><a href="iframes.html" target="_blank" name="windowTwo">Create a new anonymous window</a></p>
+ <p><a href="test_iframe.html" name="sameWindow">Open page with iframes in same window</a></p>
+ <p><a href="test.html" target="result" name="windowThree">Open a window with a close button</a></p>
+</div>
+
+<a name="notext"><b></b></a>
+
+<div class="content">
+ <h1 class="header">XHTML Might Be The Future</h1>
+
+ <p>If you'd like to go elsewhere then <a href="resultPage.html">click me</a>.</p>
+
+ <p>Alternatively, <a href="resultPage.html" id="linkId">this goes to the same place</a>.</p>
+
+ <form name="someForm">
+ <input id="username" type="text" value="change"/>
+ </form>
+
+ This link has the same text as another link: <a href="resultPage.html">click me</a>.
+</div>
+
+<div class="extraDiv">Another div starts here.<p/>
+ <h2 class="nameA nameBnoise nameC">An H2 title</h2>
+ <p class="nameC">Some more text</p>
+</div>
+
+<div>
+ <a id="id1" href="#">Foo</a>
+ <ul id="id2" />
+ <span id="id3"/>
+</div>
+
+<div>
+ <table id="table" ></table>
+</div>
+
+<span id="amazing">
+<div>
+ <div>
+ <div>
+ <span/>
+ <a>I have width</a>
+ </div>
+ </div>
+</div>
+</span>
+
+<a name="text" />
+<p id="spaces"> </p>
+<p id="empty"></p>
+<a href="foo" id="linkWithEqualsSign">Link=equalssign</a>
+
+<p class=" spaceAround ">Spaced out</p>
+
+<span id="my_span">
+ <div>first_div</div>
+ <div>second_div</div>
+ <span>first_span</span>
+ <span>second_span</span>
+</span>
+
+<div id="parent">I'm a parent
+ <div id="child">I'm a child</div>
+</div>
+
+<div id="only-exists-on-xhtmltest">Woo woo</div>
+</body>
+</html>
diff --git a/testing/marionette/harness/requirements.txt b/testing/marionette/harness/requirements.txt
new file mode 100644
index 0000000000..aa307b196d
--- /dev/null
+++ b/testing/marionette/harness/requirements.txt
@@ -0,0 +1,15 @@
+browsermob-proxy >= 0.8.0
+manifestparser >= 1.1
+marionette-driver >= 3.0.0
+mozcrash >= 2.0
+mozdevice >= 4.0.0,<5
+mozinfo >= 1.0.0
+mozlog >= 6.0
+moznetwork >= 0.27
+mozprocess >= 1.0.0
+mozprofile >= 2.2.0
+mozrunner >= 7.4.0
+moztest >= 0.8
+mozversion >= 2.1.0
+six
+wptserve >= 2.0.0
diff --git a/testing/marionette/harness/setup.py b/testing/marionette/harness/setup.py
new file mode 100644
index 0000000000..c3fc1595a0
--- /dev/null
+++ b/testing/marionette/harness/setup.py
@@ -0,0 +1,61 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import os
+import re
+
+from setuptools import find_packages, setup
+
+
+THIS_DIR = os.path.dirname(os.path.realpath(__name__))
+
+
+def read(*parts):
+ with open(os.path.join(THIS_DIR, *parts)) as f:
+ return f.read()
+
+
+def get_version():
+ return re.findall(
+ '__version__ = "([\d\.]+)"', read("marionette_harness", "__init__.py"), re.M
+ )[0]
+
+
+setup(
+ name="marionette-harness",
+ version=get_version(),
+ description="Marionette test automation harness",
+ long_description=open("README.rst").read(),
+ # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers
+ classifiers=[
+ "Development Status :: 5 - Production/Stable",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)",
+ "Operating System :: MacOS :: MacOS X",
+ "Operating System :: Microsoft :: Windows",
+ "Operating System :: POSIX",
+ "Topic :: Software Development :: Quality Assurance",
+ "Topic :: Software Development :: Testing",
+ "Topic :: Utilities",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 2.7",
+ ],
+ keywords="mozilla",
+ author="Auto-tools",
+ author_email="tools-marionette@lists.mozilla.org",
+ url="https://wiki.mozilla.org/Auto-tools/Projects/Marionette",
+ license="Mozilla Public License 2.0 (MPL 2.0)",
+ packages=find_packages(),
+ # Needed to include package data as specified in MANIFEST.in
+ include_package_data=True,
+ install_requires=read("requirements.txt").splitlines(),
+ zip_safe=False,
+ entry_points="""
+ # -*- Entry points: -*-
+ [console_scripts]
+ marionette = marionette_harness.runtests:cli
+ """,
+)
diff --git a/testing/marionette/interaction.js b/testing/marionette/interaction.js
new file mode 100644
index 0000000000..519d2bb1c6
--- /dev/null
+++ b/testing/marionette/interaction.js
@@ -0,0 +1,771 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-disable no-restricted-globals */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["interaction"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ Preferences: "resource://gre/modules/Preferences.jsm",
+
+ accessibility: "chrome://marionette/content/accessibility.js",
+ atom: "chrome://marionette/content/atom.js",
+ element: "chrome://marionette/content/element.js",
+ error: "chrome://marionette/content/error.js",
+ event: "chrome://marionette/content/event.js",
+ Log: "chrome://marionette/content/log.js",
+ pprint: "chrome://marionette/content/format.js",
+ TimedPromise: "chrome://marionette/content/sync.js",
+});
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["File"]);
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+
+/** XUL elements that support disabled attribute. */
+const DISABLED_ATTRIBUTE_SUPPORTED_XUL = new Set([
+ "ARROWSCROLLBOX",
+ "BUTTON",
+ "CHECKBOX",
+ "COMMAND",
+ "DESCRIPTION",
+ "KEY",
+ "KEYSET",
+ "LABEL",
+ "MENU",
+ "MENUITEM",
+ "MENULIST",
+ "MENUSEPARATOR",
+ "RADIO",
+ "RADIOGROUP",
+ "RICHLISTBOX",
+ "RICHLISTITEM",
+ "TAB",
+ "TABS",
+ "TOOLBARBUTTON",
+ "TREE",
+]);
+
+/**
+ * Common form controls that user can change the value property
+ * interactively.
+ */
+const COMMON_FORM_CONTROLS = new Set(["input", "textarea", "select"]);
+
+/**
+ * Input elements that do not fire <tt>input</tt> and <tt>change</tt>
+ * events when value property changes.
+ */
+const INPUT_TYPES_NO_EVENT = new Set([
+ "checkbox",
+ "radio",
+ "file",
+ "hidden",
+ "image",
+ "reset",
+ "button",
+ "submit",
+]);
+
+/** @namespace */
+this.interaction = {};
+
+/**
+ * Interact with an element by clicking it.
+ *
+ * The element is scrolled into view before visibility- or interactability
+ * checks are performed.
+ *
+ * Selenium-style visibility checks will be performed
+ * if <var>specCompat</var> is false (default). Otherwise
+ * pointer-interactability checks will be performed. If either of these
+ * fail an {@link ElementNotInteractableError} is thrown.
+ *
+ * If <var>strict</var> is enabled (defaults to disabled), further
+ * accessibility checks will be performed, and these may result in an
+ * {@link ElementNotAccessibleError} being returned.
+ *
+ * When <var>el</var> is not enabled, an {@link InvalidElementStateError}
+ * is returned.
+ *
+ * @param {(DOMElement|XULElement)} el
+ * Element to click.
+ * @param {boolean=} [strict=false] strict
+ * Enforce strict accessibility tests.
+ * @param {boolean=} [specCompat=false] specCompat
+ * Use WebDriver specification compatible interactability definition.
+ *
+ * @throws {ElementNotInteractableError}
+ * If either Selenium-style visibility check or
+ * pointer-interactability check fails.
+ * @throws {ElementClickInterceptedError}
+ * If <var>el</var> is obscured by another element and a click would
+ * not hit, in <var>specCompat</var> mode.
+ * @throws {ElementNotAccessibleError}
+ * If <var>strict</var> is true and element is not accessible.
+ * @throws {InvalidElementStateError}
+ * If <var>el</var> is not enabled.
+ */
+interaction.clickElement = async function(
+ el,
+ strict = false,
+ specCompat = false
+) {
+ const a11y = accessibility.get(strict);
+ if (element.isXULElement(el)) {
+ await chromeClick(el, a11y);
+ } else if (specCompat) {
+ await webdriverClickElement(el, a11y);
+ } else {
+ logger.trace(`Using non spec-compatible element click`);
+ await seleniumClickElement(el, a11y);
+ }
+};
+
+async function webdriverClickElement(el, a11y) {
+ const win = getWindow(el);
+
+ // step 3
+ if (el.localName == "input" && el.type == "file") {
+ throw new error.InvalidArgumentError(
+ "Cannot click <input type=file> elements"
+ );
+ }
+
+ let containerEl = element.getContainer(el);
+
+ // step 4
+ if (!element.isInView(containerEl)) {
+ element.scrollIntoView(containerEl);
+ }
+
+ // step 5
+ // TODO(ato): wait for containerEl to be in view
+
+ // step 6
+ // if we cannot bring the container element into the viewport
+ // there is no point in checking if it is pointer-interactable
+ if (!element.isInView(containerEl)) {
+ throw new error.ElementNotInteractableError(
+ pprint`Element ${el} could not be scrolled into view`
+ );
+ }
+
+ // step 7
+ let rects = containerEl.getClientRects();
+ let clickPoint = element.getInViewCentrePoint(rects[0], win);
+
+ if (element.isObscured(containerEl)) {
+ throw new error.ElementClickInterceptedError(containerEl, clickPoint);
+ }
+
+ let acc = await a11y.getAccessible(el, true);
+ a11y.assertVisible(acc, el, true);
+ a11y.assertEnabled(acc, el, true);
+ a11y.assertActionable(acc, el);
+
+ // step 8
+ if (el.localName == "option") {
+ interaction.selectOption(el);
+ } else {
+ // step 9
+ let clicked = interaction.flushEventLoop(containerEl);
+
+ // Synthesize a pointerMove action.
+ event.synthesizeMouseAtPoint(
+ clickPoint.x,
+ clickPoint.y,
+ {
+ type: "mousemove",
+ // Remove buttons attribute with https://bugzilla.mozilla.org/show_bug.cgi?id=1686361
+ buttons: 0,
+ },
+ win
+ );
+
+ // Synthesize a pointerDown + pointerUp action.
+ event.synthesizeMouseAtPoint(clickPoint.x, clickPoint.y, {}, win);
+
+ await clicked;
+ }
+
+ // step 10
+ // if the click causes navigation, the post-navigation checks are
+ // handled by the load listener in listener.js
+}
+
+async function chromeClick(el, a11y) {
+ if (!atom.isElementEnabled(el)) {
+ throw new error.InvalidElementStateError("Element is not enabled");
+ }
+
+ let acc = await a11y.getAccessible(el, true);
+ a11y.assertVisible(acc, el, true);
+ a11y.assertEnabled(acc, el, true);
+ a11y.assertActionable(acc, el);
+
+ if (el.localName == "option") {
+ interaction.selectOption(el);
+ } else {
+ el.click();
+ }
+}
+
+async function seleniumClickElement(el, a11y) {
+ let win = getWindow(el);
+
+ let visibilityCheckEl = el;
+ if (el.localName == "option") {
+ visibilityCheckEl = element.getContainer(el);
+ }
+
+ if (!element.isVisible(visibilityCheckEl)) {
+ throw new error.ElementNotInteractableError();
+ }
+
+ if (!atom.isElementEnabled(el)) {
+ throw new error.InvalidElementStateError("Element is not enabled");
+ }
+
+ let acc = await a11y.getAccessible(el, true);
+ a11y.assertVisible(acc, el, true);
+ a11y.assertEnabled(acc, el, true);
+ a11y.assertActionable(acc, el);
+
+ if (el.localName == "option") {
+ interaction.selectOption(el);
+ } else {
+ let rects = el.getClientRects();
+ let centre = element.getInViewCentrePoint(rects[0], win);
+ let opts = {};
+ event.synthesizeMouseAtPoint(centre.x, centre.y, opts, win);
+ }
+}
+
+/**
+ * Select <tt>&lt;option&gt;</tt> element in a <tt>&lt;select&gt;</tt>
+ * list.
+ *
+ * Because the dropdown list of select elements are implemented using
+ * native widget technology, our trusted synthesised events are not able
+ * to reach them. Dropdowns are instead handled mimicking DOM events,
+ * which for obvious reasons is not ideal, but at the current point in
+ * time considered to be good enough.
+ *
+ * @param {HTMLOptionElement} option
+ * Option element to select.
+ *
+ * @throws {TypeError}
+ * If <var>el</var> is a XUL element or not an <tt>&lt;option&gt;</tt>
+ * element.
+ * @throws {Error}
+ * If unable to find <var>el</var>'s parent <tt>&lt;select&gt;</tt>
+ * element.
+ */
+interaction.selectOption = function(el) {
+ if (element.isXULElement(el)) {
+ throw new TypeError("XUL dropdowns not supported");
+ }
+ if (el.localName != "option") {
+ throw new TypeError(pprint`Expected <option> element, got ${el}`);
+ }
+
+ let containerEl = element.getContainer(el);
+
+ event.mouseover(containerEl);
+ event.mousemove(containerEl);
+ event.mousedown(containerEl);
+ containerEl.focus();
+
+ if (!el.disabled) {
+ // Clicking <option> in <select> should not be deselected if selected.
+ // However, clicking one in a <select multiple> should toggle
+ // selectedness the way holding down Control works.
+ if (containerEl.multiple) {
+ el.selected = !el.selected;
+ } else if (!el.selected) {
+ el.selected = true;
+ }
+ event.input(containerEl);
+ event.change(containerEl);
+ }
+
+ event.mouseup(containerEl);
+ event.click(containerEl);
+ containerEl.blur();
+};
+
+/**
+ * Clears the form control or the editable element, if required.
+ *
+ * Before clearing the element, it will attempt to scroll it into
+ * view if it is not already in the viewport. An error is raised
+ * if the element cannot be brought into view.
+ *
+ * If the element is a submittable form control and it is empty
+ * (it has no value or it has no files associated with it, in the
+ * case it is a <code>&lt;input type=file&gt;</code> element) or
+ * it is an editing host and its <code>innerHTML</code> content IDL
+ * attribute is empty, this function acts as a no-op.
+ *
+ * @param {Element} el
+ * Element to clear.
+ *
+ * @throws {InvalidElementStateError}
+ * If element is disabled, read-only, non-editable, not a submittable
+ * element or not an editing host, or cannot be scrolled into view.
+ */
+interaction.clearElement = function(el) {
+ if (element.isDisabled(el)) {
+ throw new error.InvalidElementStateError(
+ pprint`Element is disabled: ${el}`
+ );
+ }
+ if (element.isReadOnly(el)) {
+ throw new error.InvalidElementStateError(
+ pprint`Element is read-only: ${el}`
+ );
+ }
+ if (!element.isEditable(el)) {
+ throw new error.InvalidElementStateError(
+ pprint`Unable to clear element that cannot be edited: ${el}`
+ );
+ }
+
+ if (!element.isInView(el)) {
+ element.scrollIntoView(el);
+ }
+ if (!element.isInView(el)) {
+ throw new error.ElementNotInteractableError(
+ pprint`Element ${el} could not be scrolled into view`
+ );
+ }
+
+ if (element.isEditingHost(el)) {
+ clearContentEditableElement(el);
+ } else {
+ clearResettableElement(el);
+ }
+};
+
+function clearContentEditableElement(el) {
+ if (el.innerHTML === "") {
+ return;
+ }
+ el.focus();
+ el.innerHTML = "";
+ event.change(el);
+ el.blur();
+}
+
+function clearResettableElement(el) {
+ if (!element.isMutableFormControl(el)) {
+ throw new error.InvalidElementStateError(
+ pprint`Not an editable form control: ${el}`
+ );
+ }
+
+ let isEmpty;
+ switch (el.type) {
+ case "file":
+ isEmpty = el.files.length == 0;
+ break;
+
+ default:
+ isEmpty = el.value === "";
+ break;
+ }
+
+ if (el.validity.valid && isEmpty) {
+ return;
+ }
+
+ el.focus();
+ el.value = "";
+ event.change(el);
+ el.blur();
+}
+
+/**
+ * Waits until the event loop has spun enough times to process the
+ * DOM events generated by clicking an element, or until the document
+ * is unloaded.
+ *
+ * @param {Element} el
+ * Element that is expected to receive the click.
+ *
+ * @return {Promise}
+ * Promise is resolved once <var>el</var> has been clicked
+ * (its <code>click</code> event fires), the document is unloaded,
+ * or a 500 ms timeout is reached.
+ */
+interaction.flushEventLoop = async function(el) {
+ const win = el.ownerGlobal;
+ let unloadEv, clickEv;
+
+ let spinEventLoop = resolve => {
+ unloadEv = resolve;
+ clickEv = event => {
+ logger.trace(`Received DOM event click for ${event.target}`);
+ if (win.closed) {
+ resolve();
+ } else {
+ win.setTimeout(resolve, 0);
+ }
+ };
+
+ win.addEventListener("unload", unloadEv, { mozSystemGroup: true });
+ el.addEventListener("click", clickEv, { mozSystemGroup: true });
+ };
+ let removeListeners = () => {
+ // only one event fires
+ win.removeEventListener("unload", unloadEv);
+ el.removeEventListener("click", clickEv);
+ };
+
+ return new TimedPromise(spinEventLoop, { timeout: 500, throws: null }).then(
+ removeListeners
+ );
+};
+
+/**
+ * If <var>el<var> is a textual form control and no previous
+ * selection state exists, move the caret to the end of the form control.
+ *
+ * The element has to be a <code>&lt;input type=text&gt;</code>
+ * or <code>&lt;textarea&gt;</code> element for the cursor to move
+ * be moved.
+ *
+ * @param {Element} el
+ * Element to potential move the caret in.
+ */
+interaction.moveCaretToEnd = function(el) {
+ if (!element.isDOMElement(el)) {
+ return;
+ }
+
+ let isTextarea = el.localName == "textarea";
+ let isInputText = el.localName == "input" && el.type == "text";
+
+ if (isTextarea || isInputText) {
+ if (el.selectionEnd == 0) {
+ let len = el.value.length;
+ el.setSelectionRange(len, len);
+ }
+ }
+};
+
+/**
+ * Performs checks if <var>el</var> is keyboard-interactable.
+ *
+ * To decide if an element is keyboard-interactable various properties,
+ * and computed CSS styles have to be evaluated. Whereby it has to be taken
+ * into account that the element can be part of a container (eg. option),
+ * and as such the container has to be checked instead.
+ *
+ * @param {Element} el
+ * Element to check.
+ *
+ * @return {boolean}
+ * True if element is keyboard-interactable, false otherwise.
+ */
+interaction.isKeyboardInteractable = function(el) {
+ const win = getWindow(el);
+
+ // body and document element are always keyboard-interactable
+ if (el.localName === "body" || el === win.document.documentElement) {
+ return true;
+ }
+
+ // context menu popups do not take the focus from the document.
+ const menuPopup = el.closest("menupopup");
+ if (menuPopup) {
+ if (menuPopup.state !== "open") {
+ // closed menupopups are not keyboard interactable.
+ return false;
+ }
+
+ const menuItem = el.closest("menuitem");
+ if (menuItem) {
+ // hidden or disabled menu items are not keyboard interactable.
+ return !menuItem.disabled && !menuItem.hidden;
+ }
+
+ return true;
+ }
+
+ el.focus();
+ return el === win.document.activeElement;
+};
+
+/**
+ * Updates an `<input type=file>`'s file list with given `paths`.
+ *
+ * Hereby will the file list be appended with `paths` if the
+ * element allows multiple files. Otherwise the list will be
+ * replaced.
+ *
+ * @param {HTMLInputElement} el
+ * An `input type=file` element.
+ * @param {Array.<string>} paths
+ * List of full paths to any of the files to be uploaded.
+ *
+ * @throws {InvalidArgumentError}
+ * If `path` doesn't exist.
+ */
+interaction.uploadFiles = async function(el, paths) {
+ let files = [];
+
+ if (el.hasAttribute("multiple")) {
+ // for multiple file uploads new files will be appended
+ files = Array.prototype.slice.call(el.files);
+ } else if (paths.length > 1) {
+ throw new error.InvalidArgumentError(
+ pprint`Element ${el} doesn't accept multiple files`
+ );
+ }
+
+ for (let path of paths) {
+ let file;
+
+ try {
+ file = await File.createFromFileName(path);
+ } catch (e) {
+ throw new error.InvalidArgumentError("File not found: " + path);
+ }
+
+ files.push(file);
+ }
+
+ el.mozSetFileArray(files);
+};
+
+/**
+ * Sets a form element's value.
+ *
+ * @param {DOMElement} el
+ * An form element, e.g. input, textarea, etc.
+ * @param {string} value
+ * The value to be set.
+ *
+ * @throws {TypeError}
+ * If <var>el</var> is not an supported form element.
+ */
+interaction.setFormControlValue = function(el, value) {
+ if (!COMMON_FORM_CONTROLS.has(el.localName)) {
+ throw new TypeError("This function is for form elements only");
+ }
+
+ el.value = value;
+
+ if (INPUT_TYPES_NO_EVENT.has(el.type)) {
+ return;
+ }
+
+ event.input(el);
+ event.change(el);
+};
+
+/**
+ * Send keys to element.
+ *
+ * @param {DOMElement|XULElement} el
+ * Element to send key events to.
+ * @param {Array.<string>} value
+ * Sequence of keystrokes to send to the element.
+ * @param {boolean=} strictFileInteractability
+ * Run interactability checks on `<input type=file>` elements.
+ * @param {boolean=} accessibilityChecks
+ * Enforce strict accessibility tests.
+ * @param {boolean=} webdriverClick
+ * Use WebDriver specification compatible interactability definition.
+ */
+interaction.sendKeysToElement = async function(
+ el,
+ value,
+ {
+ strictFileInteractability = false,
+ accessibilityChecks = false,
+ webdriverClick = false,
+ } = {}
+) {
+ const a11y = accessibility.get(accessibilityChecks);
+
+ if (webdriverClick) {
+ await webdriverSendKeysToElement(
+ el,
+ value,
+ a11y,
+ strictFileInteractability
+ );
+ } else {
+ await legacySendKeysToElement(el, value, a11y);
+ }
+};
+
+async function webdriverSendKeysToElement(
+ el,
+ value,
+ a11y,
+ strictFileInteractability
+) {
+ const win = getWindow(el);
+
+ if (el.type != "file" || strictFileInteractability) {
+ let containerEl = element.getContainer(el);
+
+ // TODO: Wait for element to be keyboard-interactible
+ if (!interaction.isKeyboardInteractable(containerEl)) {
+ throw new error.ElementNotInteractableError(
+ pprint`Element ${el} is not reachable by keyboard`
+ );
+ }
+ }
+
+ let acc = await a11y.getAccessible(el, true);
+ a11y.assertActionable(acc, el);
+
+ el.focus();
+ interaction.moveCaretToEnd(el);
+
+ if (el.type == "file") {
+ let paths = value.split("\n");
+ await interaction.uploadFiles(el, paths);
+
+ event.input(el);
+ event.change(el);
+ } else if (el.type == "date" || el.type == "time") {
+ interaction.setFormControlValue(el, value);
+ } else {
+ event.sendKeysToElement(value, el, win);
+ }
+}
+
+async function legacySendKeysToElement(el, value, a11y) {
+ const win = getWindow(el);
+
+ if (el.type == "file") {
+ el.focus();
+ await interaction.uploadFiles(el, [value]);
+
+ event.input(el);
+ event.change(el);
+ } else if (el.type == "date" || el.type == "time") {
+ interaction.setFormControlValue(el, value);
+ } else {
+ let visibilityCheckEl = el;
+ if (el.localName == "option") {
+ visibilityCheckEl = element.getContainer(el);
+ }
+
+ if (!element.isVisible(visibilityCheckEl)) {
+ throw new error.ElementNotInteractableError("Element is not visible");
+ }
+
+ let acc = await a11y.getAccessible(el, true);
+ a11y.assertActionable(acc, el);
+
+ interaction.moveCaretToEnd(el);
+ el.focus();
+ event.sendKeysToElement(value, el, win);
+ }
+}
+
+/**
+ * Determine the element displayedness of an element.
+ *
+ * @param {DOMElement|XULElement} el
+ * Element to determine displayedness of.
+ * @param {boolean=} [strict=false] strict
+ * Enforce strict accessibility tests.
+ *
+ * @return {boolean}
+ * True if element is displayed, false otherwise.
+ */
+interaction.isElementDisplayed = function(el, strict = false) {
+ let win = getWindow(el);
+ let displayed = atom.isElementDisplayed(el, win);
+
+ let a11y = accessibility.get(strict);
+ return a11y.getAccessible(el).then(acc => {
+ a11y.assertVisible(acc, el, displayed);
+ return displayed;
+ });
+};
+
+/**
+ * Check if element is enabled.
+ *
+ * @param {DOMElement|XULElement} el
+ * Element to test if is enabled.
+ *
+ * @return {boolean}
+ * True if enabled, false otherwise.
+ */
+interaction.isElementEnabled = function(el, strict = false) {
+ let enabled = true;
+ let win = getWindow(el);
+
+ if (element.isXULElement(el)) {
+ // check if XUL element supports disabled attribute
+ if (DISABLED_ATTRIBUTE_SUPPORTED_XUL.has(el.tagName.toUpperCase())) {
+ if (
+ el.hasAttribute("disabled") &&
+ el.getAttribute("disabled") === "true"
+ ) {
+ enabled = false;
+ }
+ }
+ } else if (
+ ["application/xml", "text/xml"].includes(win.document.contentType)
+ ) {
+ enabled = false;
+ } else {
+ enabled = atom.isElementEnabled(el, { frame: win });
+ }
+
+ let a11y = accessibility.get(strict);
+ return a11y.getAccessible(el).then(acc => {
+ a11y.assertEnabled(acc, el, enabled);
+ return enabled;
+ });
+};
+
+/**
+ * Determines if the referenced element is selected or not, with
+ * an additional accessibility check if <var>strict</var> is true.
+ *
+ * This operation only makes sense on input elements of the checkbox-
+ * and radio button states, and option elements.
+ *
+ * @param {(DOMElement|XULElement)} el
+ * Element to test if is selected.
+ * @param {boolean=} [strict=false] strict
+ * Enforce strict accessibility tests.
+ *
+ * @return {boolean}
+ * True if element is selected, false otherwise.
+ *
+ * @throws {ElementNotAccessibleError}
+ * If <var>el</var> is not accessible when <var>strict</var> is true.
+ */
+interaction.isElementSelected = function(el, strict = false) {
+ let selected = element.isSelected(el);
+
+ let a11y = accessibility.get(strict);
+ return a11y.getAccessible(el).then(acc => {
+ a11y.assertSelected(acc, el, selected);
+ return selected;
+ });
+};
+
+function getWindow(el) {
+ return el.ownerDocument.defaultView; // eslint-disable-line
+}
diff --git a/testing/marionette/jar.mn b/testing/marionette/jar.mn
new file mode 100644
index 0000000000..d2f4145e04
--- /dev/null
+++ b/testing/marionette/jar.mn
@@ -0,0 +1,60 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+marionette.jar:
+% content marionette %content/
+ content/accessibility.js (accessibility.js)
+ content/action.js (action.js)
+ content/actors/MarionetteCommandsChild.jsm (actors/MarionetteCommandsChild.jsm)
+ content/actors/MarionetteCommandsParent.jsm (actors/MarionetteCommandsParent.jsm)
+ content/actors/MarionetteEventsChild.jsm (actors/MarionetteEventsChild.jsm)
+ content/actors/MarionetteEventsParent.jsm (actors/MarionetteEventsParent.jsm)
+ content/actors/MarionetteReftestChild.jsm (actors/MarionetteReftestChild.jsm)
+ content/actors/MarionetteReftestParent.jsm (actors/MarionetteReftestParent.jsm)
+ content/addon.js (addon.js)
+ content/assert.js (assert.js)
+ content/atom.js (atom.js)
+ content/browser.js (browser.js)
+ content/capabilities.js (capabilities.js)
+ content/capture.js (capture.js)
+ content/cert.js (cert.js)
+ content/cookie.js (cookie.js)
+ content/dom.js (dom.js)
+ content/driver.js (driver.js)
+ content/element.js (element.js)
+ content/error.js (error.js)
+ content/evaluate.js (evaluate.js)
+ content/event.js (event.js)
+ content/format.js (format.js)
+ content/interaction.js (interaction.js)
+ content/l10n.js (l10n.js)
+ content/legacyaction.js (legacyaction.js)
+ content/listener.js (listener.js)
+ content/log.js (log.js)
+ content/message.js (message.js)
+ content/modal.js (modal.js)
+ content/navigate.js (navigate.js)
+ content/packets.js (packets.js)
+ content/prefs.js (prefs.js)
+ content/print.js (print.js)
+ content/proxy.js (proxy.js)
+ content/reftest.js (reftest.js)
+ content/reftest.xhtml (reftest.xhtml)
+ content/reftest-content.js (reftest-content.js)
+ content/server.js (server.js)
+ content/stream-utils.js (stream-utils.js)
+ content/sync.js (sync.js)
+ content/transport.js (transport.js)
+#ifdef ENABLE_TESTS
+ content/test.xhtml (chrome/test.xhtml)
+ content/test2.xhtml (chrome/test2.xhtml)
+ content/test_dialog.dtd (chrome/test_dialog.dtd)
+ content/test_dialog.properties (chrome/test_dialog.properties)
+ content/test_dialog.xhtml (chrome/test_dialog.xhtml)
+ content/test_menupopup.xhtml (chrome/test_menupopup.xhtml)
+ content/test_nested_iframe.xhtml (chrome/test_nested_iframe.xhtml)
+#ifdef MOZ_CODE_COVERAGE
+ content/PerTestCoverageUtils.jsm (../../tools/code-coverage/PerTestCoverageUtils.jsm)
+#endif
+#endif
diff --git a/testing/marionette/l10n.js b/testing/marionette/l10n.js
new file mode 100644
index 0000000000..0d3a5960d2
--- /dev/null
+++ b/testing/marionette/l10n.js
@@ -0,0 +1,109 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["l10n"];
+
+/**
+ * An API which allows Marionette to handle localized content.
+ *
+ * The localization (https://mzl.la/2eUMjyF) of UI elements in Gecko
+ * based applications is done via entities and properties. For static
+ * values entities are used, which are located in .dtd files. Whereby for
+ * dynamically updated content the values come from .property files. Both
+ * types of elements can be identifed via a unique id, and the translated
+ * content retrieved.
+ */
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ error: "chrome://marionette/content/error.js",
+});
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["DOMParser"]);
+XPCOMUtils.defineLazyGetter(this, "domParser", () => {
+ const parser = new DOMParser();
+ parser.forceEnableDTD();
+ return parser;
+});
+
+/** @namespace */
+this.l10n = {};
+
+/**
+ * Retrieve the localized string for the specified entity id.
+ *
+ * Example:
+ * localizeEntity(["chrome://branding/locale/brand.dtd"], "brandShortName")
+ *
+ * @param {Array.<string>} urls
+ * Array of .dtd URLs.
+ * @param {string} id
+ * The ID of the entity to retrieve the localized string for.
+ *
+ * @return {string}
+ * The localized string for the requested entity.
+ */
+l10n.localizeEntity = function(urls, id) {
+ // Build a string which contains all possible entity locations
+ let locations = [];
+ urls.forEach((url, index) => {
+ locations.push(`<!ENTITY % dtd_${index} SYSTEM "${url}">%dtd_${index};`);
+ });
+
+ // Use the DOM parser to resolve the entity and extract its real value
+ let header = `<?xml version="1.0"?><!DOCTYPE elem [${locations.join("")}]>`;
+ let elem = `<elem id="elementID">&${id};</elem>`;
+ let doc = domParser.parseFromString(header + elem, "text/xml");
+ let element = doc.querySelector("elem[id='elementID']");
+
+ if (element === null) {
+ throw new error.NoSuchElementError(
+ `Entity with id='${id}' hasn't been found`
+ );
+ }
+
+ return element.textContent;
+};
+
+/**
+ * Retrieve the localized string for the specified property id.
+ *
+ * Example:
+ *
+ * localizeProperty(
+ * ["chrome://global/locale/findbar.properties"], "FastFind");
+ *
+ * @param {Array.<string>} urls
+ * Array of .properties URLs.
+ * @param {string} id
+ * The ID of the property to retrieve the localized string for.
+ *
+ * @return {string}
+ * The localized string for the requested property.
+ */
+l10n.localizeProperty = function(urls, id) {
+ let property = null;
+
+ for (let url of urls) {
+ let bundle = Services.strings.createBundle(url);
+ try {
+ property = bundle.GetStringFromName(id);
+ break;
+ } catch (e) {}
+ }
+
+ if (property === null) {
+ throw new error.NoSuchElementError(
+ `Property with ID '${id}' hasn't been found`
+ );
+ }
+
+ return property;
+};
diff --git a/testing/marionette/legacyaction.js b/testing/marionette/legacyaction.js
new file mode 100644
index 0000000000..908c5929a7
--- /dev/null
+++ b/testing/marionette/legacyaction.js
@@ -0,0 +1,630 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-disable no-restricted-globals */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["legacyaction"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ Preferences: "resource://gre/modules/Preferences.jsm",
+
+ accessibility: "chrome://marionette/content/accessibility.js",
+ element: "chrome://marionette/content/element.js",
+ error: "chrome://marionette/content/error.js",
+ evaluate: "chrome://marionette/content/evaluate.js",
+ event: "chrome://marionette/content/event.js",
+ Log: "chrome://marionette/content/log.js",
+ WebElement: "chrome://marionette/content/element.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+
+const CONTEXT_MENU_DELAY_PREF = "ui.click_hold_context_menus.delay";
+const DEFAULT_CONTEXT_MENU_DELAY = 750; // ms
+
+/* global action */
+/** @namespace */
+this.legacyaction = this.action = {};
+
+/**
+ * Functionality for (single finger) action chains.
+ */
+action.Chain = function() {
+ // for assigning unique ids to all touches
+ this.nextTouchId = 1000;
+ // keep track of active Touches
+ this.touchIds = {};
+ // last touch for each fingerId
+ this.lastCoordinates = null;
+ this.isTap = false;
+ this.scrolling = false;
+ // whether to send mouse event
+ this.mouseEventsOnly = false;
+ this.checkTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+
+ // determines if we create touch events
+ this.inputSource = null;
+};
+
+/**
+ * Create a touch based event.
+ *
+ * @param {Element} elem
+ * The Element on which the touch event should be created.
+ * @param {Number} x
+ * x coordinate relative to the viewport.
+ * @param {Number} y
+ * y coordinate relative to the viewport.
+ * @param {Number} touchId
+ * Touch event id used by legacyactions.
+ */
+action.Chain.prototype.createATouch = function(elem, x, y, touchId) {
+ const doc = elem.ownerDocument;
+ const win = doc.defaultView;
+ const [
+ clientX,
+ clientY,
+ pageX,
+ pageY,
+ screenX,
+ screenY,
+ ] = this.getCoordinateInfo(elem, x, y);
+ const atouch = doc.createTouch(
+ win,
+ elem,
+ touchId,
+ pageX,
+ pageY,
+ screenX,
+ screenY,
+ clientX,
+ clientY
+ );
+ return atouch;
+};
+
+action.Chain.prototype.dispatchActions = function(
+ args,
+ touchId,
+ container,
+ seenEls
+) {
+ this.seenEls = seenEls;
+ this.container = container;
+ let commandArray = evaluate.fromJSON(args, seenEls, container.frame);
+
+ if (touchId == null) {
+ touchId = this.nextTouchId++;
+ }
+
+ if (!container.frame.document.createTouch) {
+ this.mouseEventsOnly = true;
+ }
+
+ let keyModifiers = {
+ shiftKey: false,
+ ctrlKey: false,
+ altKey: false,
+ metaKey: false,
+ };
+
+ return new Promise(resolve => {
+ this.actions(commandArray, touchId, 0, keyModifiers, resolve);
+ }).catch(this.resetValues.bind(this));
+};
+
+/**
+ * This function emit mouse event.
+ *
+ * @param {Document} doc
+ * Current document.
+ * @param {string} type
+ * Type of event to dispatch.
+ * @param {number} clickCount
+ * Number of clicks, button notes the mouse button.
+ * @param {number} elClientX
+ * X coordinate of the mouse relative to the viewport.
+ * @param {number} elClientY
+ * Y coordinate of the mouse relative to the viewport.
+ * @param {Object} modifiers
+ * An object of modifier keys present.
+ */
+action.Chain.prototype.emitMouseEvent = function(
+ doc,
+ type,
+ elClientX,
+ elClientY,
+ button,
+ clickCount,
+ modifiers
+) {
+ logger.debug(
+ `Emitting ${type} mouse event ` +
+ `at coordinates (${elClientX}, ${elClientY}) ` +
+ `relative to the viewport, ` +
+ `button: ${button}, ` +
+ `clickCount: ${clickCount}`
+ );
+
+ let win = doc.defaultView;
+ let domUtils = win.windowUtils;
+
+ let mods;
+ if (typeof modifiers != "undefined") {
+ mods = event.parseModifiers_(modifiers);
+ } else {
+ mods = 0;
+ }
+
+ domUtils.sendMouseEvent(
+ type,
+ elClientX,
+ elClientY,
+ button || 0,
+ clickCount || 1,
+ mods,
+ false,
+ 0,
+ this.inputSource
+ );
+};
+
+action.Chain.prototype.emitTouchEvent = function(doc, type, touch) {
+ logger.info(
+ `Emitting Touch event of type ${type} ` +
+ `to element with id: ${touch.target.id} ` +
+ `and tag name: ${touch.target.tagName} ` +
+ `at coordinates (${touch.clientX}), ` +
+ `${touch.clientY}) relative to the viewport`
+ );
+
+ const win = doc.defaultView;
+ if (win.docShell.asyncPanZoomEnabled && this.scrolling) {
+ logger.debug(
+ `Cannot emit touch event with asyncPanZoomEnabled and legacyactions.scrolling`
+ );
+ return;
+ }
+
+ // we get here if we're not in asyncPacZoomEnabled land, or if we're
+ // the main process
+ win.windowUtils.sendTouchEvent(
+ type,
+ [touch.identifier],
+ [touch.clientX],
+ [touch.clientY],
+ [touch.radiusX],
+ [touch.radiusY],
+ [touch.rotationAngle],
+ [touch.force],
+ 0
+ );
+};
+
+/**
+ * Reset any persisted values after a command completes.
+ */
+action.Chain.prototype.resetValues = function() {
+ this.container = null;
+ this.seenEls = null;
+ this.mouseEventsOnly = false;
+};
+
+/**
+ * Function that performs a single tap.
+ */
+action.Chain.prototype.singleTap = async function(
+ el,
+ corx,
+ cory,
+ capabilities
+) {
+ const doc = el.ownerDocument;
+ // after this block, the element will be scrolled into view
+ let visible = element.isVisible(el, corx, cory);
+ if (!visible) {
+ throw new error.ElementNotInteractableError(
+ "Element is not currently visible and may not be manipulated"
+ );
+ }
+
+ let a11y = accessibility.get(capabilities["moz:accessibilityChecks"]);
+ let acc = await a11y.getAccessible(el, true);
+ a11y.assertVisible(acc, el, visible);
+ a11y.assertActionable(acc, el);
+ if (!doc.createTouch) {
+ this.mouseEventsOnly = true;
+ }
+ let c = element.coordinates(el, corx, cory);
+ if (!this.mouseEventsOnly) {
+ let touchId = this.nextTouchId++;
+ let touch = this.createATouch(el, c.x, c.y, touchId);
+ this.emitTouchEvent(doc, "touchstart", touch);
+ this.emitTouchEvent(doc, "touchend", touch);
+ }
+ this.mouseTap(doc, c.x, c.y);
+};
+
+/**
+ * Emit events for each action in the provided chain.
+ *
+ * To emit touch events for each finger, one might send a [["press", id],
+ * ["wait", 5], ["release"]] chain.
+ *
+ * @param {Array.<Array<?>>} chain
+ * A multi-dimensional array of actions.
+ * @param {Object.<string, number>} touchId
+ * Represents the finger ID.
+ * @param {number} i
+ * Keeps track of the current action of the chain.
+ * @param {Object.<string, boolean>} keyModifiers
+ * Keeps track of keyDown/keyUp pairs through an action chain.
+ * @param {function(?)} cb
+ * Called on success.
+ *
+ * @return {Object.<string, number>}
+ * Last finger ID, or an empty object.
+ */
+action.Chain.prototype.actions = function(chain, touchId, i, keyModifiers, cb) {
+ if (i == chain.length) {
+ cb(touchId || null);
+ this.resetValues();
+ return;
+ }
+
+ let pack = chain[i];
+ let command = pack[0];
+ let webEl;
+ let el;
+ let c;
+ i++;
+
+ if (!["press", "wait", "keyDown", "keyUp", "click"].includes(command)) {
+ // if mouseEventsOnly, then touchIds isn't used
+ if (!(touchId in this.touchIds) && !this.mouseEventsOnly) {
+ this.resetValues();
+ throw new error.WebDriverError("Element has not been pressed");
+ }
+ }
+
+ switch (command) {
+ case "keyDown":
+ event.sendKeyDown(pack[1], keyModifiers, this.container.frame);
+ this.actions(chain, touchId, i, keyModifiers, cb);
+ break;
+
+ case "keyUp":
+ event.sendKeyUp(pack[1], keyModifiers, this.container.frame);
+ this.actions(chain, touchId, i, keyModifiers, cb);
+ break;
+
+ case "click":
+ webEl = WebElement.fromUUID(pack[1], "content");
+ el = this.seenEls.get(webEl);
+ let button = pack[2];
+ let clickCount = pack[3];
+ c = element.coordinates(el);
+ this.mouseTap(
+ el.ownerDocument,
+ c.x,
+ c.y,
+ button,
+ clickCount,
+ keyModifiers
+ );
+ if (button == 2) {
+ this.emitMouseEvent(
+ el.ownerDocument,
+ "contextmenu",
+ c.x,
+ c.y,
+ button,
+ clickCount,
+ keyModifiers
+ );
+ }
+ this.actions(chain, touchId, i, keyModifiers, cb);
+ break;
+
+ case "press":
+ if (this.lastCoordinates) {
+ this.generateEvents(
+ "cancel",
+ this.lastCoordinates[0],
+ this.lastCoordinates[1],
+ touchId,
+ null,
+ keyModifiers
+ );
+ this.resetValues();
+ throw new error.WebDriverError(
+ "Invalid Command: press cannot follow an active touch event"
+ );
+ }
+
+ // look ahead to check if we're scrolling,
+ // needed for APZ touch dispatching
+ if (i != chain.length && chain[i][0].includes("move")) {
+ this.scrolling = true;
+ }
+ webEl = WebElement.fromUUID(pack[1], "content");
+ el = this.seenEls.get(webEl);
+ c = element.coordinates(el, pack[2], pack[3]);
+ touchId = this.generateEvents("press", c.x, c.y, null, el, keyModifiers);
+ this.actions(chain, touchId, i, keyModifiers, cb);
+ break;
+
+ case "release":
+ this.generateEvents(
+ "release",
+ this.lastCoordinates[0],
+ this.lastCoordinates[1],
+ touchId,
+ null,
+ keyModifiers
+ );
+ this.actions(chain, null, i, keyModifiers, cb);
+ this.scrolling = false;
+ break;
+
+ case "move":
+ webEl = WebElement.fromUUID(pack[1], "content");
+ el = this.seenEls.get(webEl);
+ c = element.coordinates(el);
+ this.generateEvents("move", c.x, c.y, touchId, null, keyModifiers);
+ this.actions(chain, touchId, i, keyModifiers, cb);
+ break;
+
+ case "moveByOffset":
+ this.generateEvents(
+ "move",
+ this.lastCoordinates[0] + pack[1],
+ this.lastCoordinates[1] + pack[2],
+ touchId,
+ null,
+ keyModifiers
+ );
+ this.actions(chain, touchId, i, keyModifiers, cb);
+ break;
+
+ case "wait":
+ if (pack[1] != null) {
+ let time = pack[1] * 1000;
+
+ // standard waiting time to fire contextmenu
+ let standard = Preferences.get(
+ CONTEXT_MENU_DELAY_PREF,
+ DEFAULT_CONTEXT_MENU_DELAY
+ );
+
+ if (time >= standard && this.isTap) {
+ chain.splice(i, 0, ["longPress"], ["wait", (time - standard) / 1000]);
+ time = standard;
+ }
+ this.checkTimer.initWithCallback(
+ () => this.actions(chain, touchId, i, keyModifiers, cb),
+ time,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ } else {
+ this.actions(chain, touchId, i, keyModifiers, cb);
+ }
+ break;
+
+ case "cancel":
+ this.generateEvents(
+ "cancel",
+ this.lastCoordinates[0],
+ this.lastCoordinates[1],
+ touchId,
+ null,
+ keyModifiers
+ );
+ this.actions(chain, touchId, i, keyModifiers, cb);
+ this.scrolling = false;
+ break;
+
+ case "longPress":
+ this.generateEvents(
+ "contextmenu",
+ this.lastCoordinates[0],
+ this.lastCoordinates[1],
+ touchId,
+ null,
+ keyModifiers
+ );
+ this.actions(chain, touchId, i, keyModifiers, cb);
+ break;
+ }
+};
+
+/**
+ * Given an element and a pair of coordinates, returns an array of the
+ * form [clientX, clientY, pageX, pageY, screenX, screenY].
+ */
+action.Chain.prototype.getCoordinateInfo = function(el, corx, cory) {
+ let win = el.ownerGlobal;
+ return [
+ corx, // clientX
+ cory, // clientY
+ corx + win.pageXOffset, // pageX
+ cory + win.pageYOffset, // pageY
+ corx + win.mozInnerScreenX, // screenX
+ cory + win.mozInnerScreenY, // screenY
+ ];
+};
+
+/**
+ * @param {number} x
+ * X coordinate of the location to generate the event that is relative
+ * to the viewport.
+ * @param {number} y
+ * Y coordinate of the location to generate the event that is relative
+ * to the viewport.
+ */
+action.Chain.prototype.generateEvents = function(
+ type,
+ x,
+ y,
+ touchId,
+ target,
+ keyModifiers
+) {
+ this.lastCoordinates = [x, y];
+ let doc = this.container.frame.document;
+
+ switch (type) {
+ case "tap":
+ if (this.mouseEventsOnly) {
+ let touch = this.createATouch(target, x, y, touchId);
+ this.mouseTap(
+ touch.target.ownerDocument,
+ touch.clientX,
+ touch.clientY,
+ null,
+ null,
+ keyModifiers
+ );
+ } else {
+ touchId = this.nextTouchId++;
+ let touch = this.createATouch(target, x, y, touchId);
+ this.emitTouchEvent(doc, "touchstart", touch);
+ this.emitTouchEvent(doc, "touchend", touch);
+ this.mouseTap(
+ touch.target.ownerDocument,
+ touch.clientX,
+ touch.clientY,
+ null,
+ null,
+ keyModifiers
+ );
+ }
+ this.lastCoordinates = null;
+ break;
+
+ case "press":
+ this.isTap = true;
+ if (this.mouseEventsOnly) {
+ this.emitMouseEvent(doc, "mousemove", x, y, null, null, keyModifiers);
+ this.emitMouseEvent(doc, "mousedown", x, y, null, null, keyModifiers);
+ } else {
+ touchId = this.nextTouchId++;
+ let touch = this.createATouch(target, x, y, touchId);
+ this.emitTouchEvent(doc, "touchstart", touch);
+ this.touchIds[touchId] = touch;
+ return touchId;
+ }
+ break;
+
+ case "release":
+ if (this.mouseEventsOnly) {
+ let [x, y] = this.lastCoordinates;
+ this.emitMouseEvent(doc, "mouseup", x, y, null, null, keyModifiers);
+ } else {
+ let touch = this.touchIds[touchId];
+ let [x, y] = this.lastCoordinates;
+
+ touch = this.createATouch(touch.target, x, y, touchId);
+ this.emitTouchEvent(doc, "touchend", touch);
+
+ if (this.isTap) {
+ this.mouseTap(
+ touch.target.ownerDocument,
+ touch.clientX,
+ touch.clientY,
+ null,
+ null,
+ keyModifiers
+ );
+ }
+ delete this.touchIds[touchId];
+ }
+
+ this.isTap = false;
+ this.lastCoordinates = null;
+ break;
+
+ case "cancel":
+ this.isTap = false;
+ if (this.mouseEventsOnly) {
+ let [x, y] = this.lastCoordinates;
+ this.emitMouseEvent(doc, "mouseup", x, y, null, null, keyModifiers);
+ } else {
+ this.emitTouchEvent(doc, "touchcancel", this.touchIds[touchId]);
+ delete this.touchIds[touchId];
+ }
+ this.lastCoordinates = null;
+ break;
+
+ case "move":
+ this.isTap = false;
+ if (this.mouseEventsOnly) {
+ this.emitMouseEvent(doc, "mousemove", x, y, null, null, keyModifiers);
+ } else {
+ let touch = this.createATouch(
+ this.touchIds[touchId].target,
+ x,
+ y,
+ touchId
+ );
+ this.touchIds[touchId] = touch;
+ this.emitTouchEvent(doc, "touchmove", touch);
+ }
+ break;
+
+ case "contextmenu":
+ this.isTap = false;
+ let event = this.container.frame.document.createEvent("MouseEvents");
+ if (this.mouseEventsOnly) {
+ target = doc.elementFromPoint(
+ this.lastCoordinates[0],
+ this.lastCoordinates[1]
+ );
+ } else {
+ target = this.touchIds[touchId].target;
+ }
+
+ let [clientX, clientY, , , screenX, screenY] = this.getCoordinateInfo(
+ target,
+ x,
+ y
+ );
+
+ event.initMouseEvent(
+ "contextmenu",
+ true,
+ true,
+ target.ownerGlobal,
+ 1,
+ screenX,
+ screenY,
+ clientX,
+ clientY,
+ false,
+ false,
+ false,
+ false,
+ 0,
+ null
+ );
+ target.dispatchEvent(event);
+ break;
+
+ default:
+ throw new error.WebDriverError("Unknown event type: " + type);
+ }
+ return null;
+};
+
+action.Chain.prototype.mouseTap = function(doc, x, y, button, count, mod) {
+ this.emitMouseEvent(doc, "mousemove", x, y, button, count, mod);
+ this.emitMouseEvent(doc, "mousedown", x, y, button, count, mod);
+ this.emitMouseEvent(doc, "mouseup", x, y, button, count, mod);
+};
diff --git a/testing/marionette/listener.js b/testing/marionette/listener.js
new file mode 100644
index 0000000000..49c6d3a3e3
--- /dev/null
+++ b/testing/marionette/listener.js
@@ -0,0 +1,1069 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-env mozilla/frame-script */
+/* global XPCNativeWrapper */
+/* eslint-disable no-restricted-globals */
+
+"use strict";
+
+const winUtil = content.windowUtils;
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ OS: "resource://gre/modules/osfile.jsm",
+
+ accessibility: "chrome://marionette/content/accessibility.js",
+ action: "chrome://marionette/content/action.js",
+ atom: "chrome://marionette/content/atom.js",
+ ContentEventObserverService: "chrome://marionette/content/dom.js",
+ element: "chrome://marionette/content/element.js",
+ error: "chrome://marionette/content/error.js",
+ evaluate: "chrome://marionette/content/evaluate.js",
+ event: "chrome://marionette/content/event.js",
+ interaction: "chrome://marionette/content/interaction.js",
+ legacyaction: "chrome://marionette/content/legacyaction.js",
+ Log: "chrome://marionette/content/log.js",
+ MarionettePrefs: "chrome://marionette/content/prefs.js",
+ pprint: "chrome://marionette/content/format.js",
+ proxy: "chrome://marionette/content/proxy.js",
+ sandbox: "chrome://marionette/content/evaluate.js",
+ Sandboxes: "chrome://marionette/content/evaluate.js",
+ truncate: "chrome://marionette/content/format.js",
+ WebElement: "chrome://marionette/content/element.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.getWithPrefix(contentId));
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
+
+const contentFrameMessageManager = this;
+const contentId = content.docShell.browsingContext.id;
+
+const curContainer = {
+ _frame: null,
+ _parentFrame: null,
+
+ get frame() {
+ return this._frame;
+ },
+
+ set frame(frame) {
+ this._frame = frame;
+ this._parentFrame = frame.parent;
+ this.id = frame.browsingContext.id;
+ },
+
+ get parentFrame() {
+ return this._parentFrame;
+ },
+};
+
+// Listen for click event to indicate one click has happened, so actions
+// code can send dblclick event
+addEventListener("click", event.DoubleClickTracker.setClick);
+addEventListener("dblclick", event.DoubleClickTracker.resetClick);
+addEventListener("unload", event.DoubleClickTracker.resetClick, true);
+
+const seenEls = new element.Store();
+
+let legacyactions = new legacyaction.Chain();
+
+// last touch for each fingerId
+let multiLast = {};
+
+// sandbox storage and name of the current sandbox
+const sandboxes = new Sandboxes(() => curContainer.frame);
+
+const eventObservers = new ContentEventObserverService(
+ content,
+ sendAsyncMessage.bind(this)
+);
+
+// Eventually we will not have a closure for every single command,
+// but use a generic dispatch for all listener commands.
+//
+// Worth nothing that this shares many characteristics with
+// server.TCPConnection#execute. Perhaps this could be generalised
+// at the point.
+function dispatch(fn) {
+ if (typeof fn != "function") {
+ throw new TypeError("Provided dispatch handler is not a function");
+ }
+
+ return msg => {
+ const id = msg.json.commandID;
+
+ let req = new Promise(resolve => {
+ const args = evaluate.fromJSON(msg.json, seenEls, curContainer.frame);
+
+ let rv;
+ if (typeof args == "undefined" || args instanceof Array) {
+ rv = fn.apply(null, args);
+ } else {
+ rv = fn(args);
+ }
+ resolve(rv);
+ });
+
+ req
+ .then(
+ rv => sendResponse(rv, id),
+ err => sendError(err, id)
+ )
+ .catch(err => sendError(err, id));
+ };
+}
+
+let clickElementFn = dispatch(clickElement);
+let getActiveElementFn = dispatch(getActiveElement);
+let getBrowsingContextIdFn = dispatch(getBrowsingContextId);
+let getCurrentUrlFn = dispatch(getCurrentUrl);
+let getElementAttributeFn = dispatch(getElementAttribute);
+let getElementPropertyFn = dispatch(getElementProperty);
+let getElementTextFn = dispatch(getElementText);
+let getElementTagNameFn = dispatch(getElementTagName);
+let getElementRectFn = dispatch(getElementRect);
+let getPageSourceFn = dispatch(getPageSource);
+let getScreenshotRectFn = dispatch(getScreenshotRect);
+let isElementEnabledFn = dispatch(isElementEnabled);
+let findElementContentFn = dispatch(findElementContent);
+let findElementsContentFn = dispatch(findElementsContent);
+let isElementSelectedFn = dispatch(isElementSelected);
+let clearElementFn = dispatch(clearElement);
+let isElementDisplayedFn = dispatch(isElementDisplayed);
+let getElementValueOfCssPropertyFn = dispatch(getElementValueOfCssProperty);
+let singleTapFn = dispatch(singleTap);
+let performActionsFn = dispatch(performActions);
+let releaseActionsFn = dispatch(releaseActions);
+let actionChainFn = dispatch(actionChain);
+let multiActionFn = dispatch(multiAction);
+let executeScriptFn = dispatch(executeScript);
+let sendKeysToElementFn = dispatch(sendKeysToElement);
+
+function startListeners() {
+ eventDispatcher.enable();
+
+ addMessageListener("Marionette:actionChain", actionChainFn);
+ addMessageListener("Marionette:clearElement", clearElementFn);
+ addMessageListener("Marionette:clickElement", clickElementFn);
+ addMessageListener("Marionette:Deregister", deregister);
+ addMessageListener("Marionette:DOM:AddEventListener", domAddEventListener);
+ addMessageListener(
+ "Marionette:DOM:RemoveEventListener",
+ domRemoveEventListener
+ );
+ addMessageListener("Marionette:executeScript", executeScriptFn);
+ addMessageListener("Marionette:findElementContent", findElementContentFn);
+ addMessageListener("Marionette:findElementsContent", findElementsContentFn);
+ addMessageListener("Marionette:getActiveElement", getActiveElementFn);
+ addMessageListener("Marionette:getBrowsingContextId", getBrowsingContextIdFn);
+ addMessageListener("Marionette:getCurrentUrl", getCurrentUrlFn);
+ addMessageListener("Marionette:getElementAttribute", getElementAttributeFn);
+ addMessageListener("Marionette:getElementProperty", getElementPropertyFn);
+ addMessageListener("Marionette:getElementRect", getElementRectFn);
+ addMessageListener("Marionette:getElementTagName", getElementTagNameFn);
+ addMessageListener("Marionette:getElementText", getElementTextFn);
+ addMessageListener(
+ "Marionette:getElementValueOfCssProperty",
+ getElementValueOfCssPropertyFn
+ );
+ addMessageListener("Marionette:getPageSource", getPageSourceFn);
+ addMessageListener("Marionette:getScreenshotRect", getScreenshotRectFn);
+ addMessageListener("Marionette:isElementDisplayed", isElementDisplayedFn);
+ addMessageListener("Marionette:isElementEnabled", isElementEnabledFn);
+ addMessageListener("Marionette:isElementSelected", isElementSelectedFn);
+ addMessageListener("Marionette:multiAction", multiActionFn);
+ addMessageListener("Marionette:performActions", performActionsFn);
+ addMessageListener("Marionette:releaseActions", releaseActionsFn);
+ addMessageListener("Marionette:sendKeysToElement", sendKeysToElementFn);
+ addMessageListener("Marionette:Session:Delete", deleteSession);
+ addMessageListener("Marionette:singleTap", singleTapFn);
+ addMessageListener("Marionette:switchToFrame", switchToFrame);
+ addMessageListener("Marionette:switchToParentFrame", switchToParentFrame);
+}
+
+function deregister() {
+ eventDispatcher.disable();
+
+ removeMessageListener("Marionette:actionChain", actionChainFn);
+ removeMessageListener("Marionette:clearElement", clearElementFn);
+ removeMessageListener("Marionette:clickElement", clickElementFn);
+ removeMessageListener("Marionette:Deregister", deregister);
+ removeMessageListener("Marionette:executeScript", executeScriptFn);
+ removeMessageListener("Marionette:findElementContent", findElementContentFn);
+ removeMessageListener(
+ "Marionette:findElementsContent",
+ findElementsContentFn
+ );
+ removeMessageListener("Marionette:getActiveElement", getActiveElementFn);
+ removeMessageListener(
+ "Marionette:getBrowsingContextId",
+ getBrowsingContextIdFn
+ );
+ removeMessageListener("Marionette:getCurrentUrl", getCurrentUrlFn);
+ removeMessageListener(
+ "Marionette:getElementAttribute",
+ getElementAttributeFn
+ );
+ removeMessageListener("Marionette:getElementProperty", getElementPropertyFn);
+ removeMessageListener("Marionette:getElementRect", getElementRectFn);
+ removeMessageListener("Marionette:getElementTagName", getElementTagNameFn);
+ removeMessageListener("Marionette:getElementText", getElementTextFn);
+ removeMessageListener(
+ "Marionette:getElementValueOfCssProperty",
+ getElementValueOfCssPropertyFn
+ );
+ removeMessageListener("Marionette:getPageSource", getPageSourceFn);
+ removeMessageListener("Marionette:getScreenshotRect", getScreenshotRectFn);
+ removeMessageListener("Marionette:isElementDisplayed", isElementDisplayedFn);
+ removeMessageListener("Marionette:isElementEnabled", isElementEnabledFn);
+ removeMessageListener("Marionette:isElementSelected", isElementSelectedFn);
+ removeMessageListener("Marionette:multiAction", multiActionFn);
+ removeMessageListener("Marionette:performActions", performActionsFn);
+ removeMessageListener("Marionette:releaseActions", releaseActionsFn);
+ removeMessageListener("Marionette:sendKeysToElement", sendKeysToElementFn);
+ removeMessageListener("Marionette:Session:Delete", deleteSession);
+ removeMessageListener("Marionette:singleTap", singleTapFn);
+ removeMessageListener("Marionette:switchToFrame", switchToFrame);
+ removeMessageListener("Marionette:switchToParentFrame", switchToParentFrame);
+}
+
+function deleteSession() {
+ seenEls.clear();
+
+ // reset container frame to the top-most frame
+ curContainer.frame = content;
+ curContainer.frame.focus();
+
+ legacyactions.touchIds = {};
+}
+
+/**
+ * Send asynchronous reply to chrome.
+ *
+ * @param {UUID} uuid
+ * Unique identifier of the request.
+ * @param {AsyncContentSender.ResponseType} type
+ * Type of response.
+ * @param {*} [Object] data
+ * JSON serialisable object to accompany the message. Defaults to
+ * an empty dictionary.
+ */
+let sendToServer = (uuid, data = undefined) => {
+ let channel = new proxy.AsyncMessageChannel(sendAsyncMessage.bind(this));
+ channel.reply(uuid, data);
+};
+
+/**
+ * Send asynchronous reply with value to chrome.
+ *
+ * @param {Object} obj
+ * JSON serialisable object of arbitrary type and complexity.
+ * @param {UUID} uuid
+ * Unique identifier of the request.
+ */
+function sendResponse(obj, uuid) {
+ let payload = evaluate.toJSON(obj, seenEls);
+ sendToServer(uuid, payload);
+}
+
+/**
+ * Send asynchronous reply to chrome.
+ *
+ * @param {UUID} uuid
+ * Unique identifier of the request.
+ */
+function sendOk(uuid) {
+ sendToServer(uuid);
+}
+
+/**
+ * Send asynchronous error reply to chrome.
+ *
+ * @param {Error} err
+ * Error to notify chrome of.
+ * @param {UUID} uuid
+ * Unique identifier of the request.
+ */
+function sendError(err, uuid) {
+ sendToServer(uuid, err);
+}
+
+async function executeScript(script, args, opts = {}) {
+ let sb;
+
+ if (opts.useSandbox) {
+ sb = sandboxes.get(opts.sandboxName, opts.newSandbox);
+ } else {
+ sb = sandbox.createMutable(curContainer.frame);
+ }
+
+ return evaluate.sandbox(sb, script, args, opts);
+}
+
+/**
+ * Function that performs a single tap.
+ */
+async function singleTap(el, corx, cory, capabilities) {
+ return legacyactions.singleTap(el, corx, cory, capabilities);
+}
+
+/**
+ * Perform a series of grouped actions at the specified points in time.
+ *
+ * @param {Object} msg
+ * Object with an |actions| attribute that is an Array of objects
+ * each of which represents an action sequence.
+ * @param {Object} capabilities
+ * Object with a list of WebDriver session capabilities.
+ */
+async function performActions(msg, capabilities) {
+ let chain = action.Chain.fromJSON(msg.actions);
+ await action.dispatch(
+ chain,
+ curContainer.frame,
+ !capabilities["moz:useNonSpecCompliantPointerOrigin"]
+ );
+}
+
+/**
+ * The release actions command is used to release all the keys and pointer
+ * buttons that are currently depressed. This causes events to be fired
+ * as if the state was released by an explicit series of actions. It also
+ * clears all the internal state of the virtual devices.
+ */
+async function releaseActions() {
+ await action.dispatchTickActions(
+ action.inputsToCancel.reverse(),
+ 0,
+ curContainer.frame
+ );
+ action.inputsToCancel.length = 0;
+ action.inputStateMap.clear();
+
+ event.DoubleClickTracker.resetClick();
+}
+
+/**
+ * Start action chain on one finger.
+ */
+function actionChain(chain, touchId) {
+ return legacyactions.dispatchActions(chain, touchId, curContainer, seenEls);
+}
+
+function emitMultiEvents(type, touch, touches) {
+ let target = touch.target;
+ let doc = target.ownerDocument;
+ let win = doc.defaultView;
+ // touches that are in the same document
+ let documentTouches = doc.createTouchList(
+ touches.filter(function(t) {
+ return t.target.ownerDocument === doc && type != "touchcancel";
+ })
+ );
+ // touches on the same target
+ let targetTouches = doc.createTouchList(
+ touches.filter(function(t) {
+ return (
+ t.target === target && (type != "touchcancel" || type != "touchend")
+ );
+ })
+ );
+ // Create changed touches
+ let changedTouches = doc.createTouchList(touch);
+ // Create the event object
+ let event = doc.createEvent("TouchEvent");
+ event.initTouchEvent(
+ type,
+ true,
+ true,
+ win,
+ 0,
+ false,
+ false,
+ false,
+ false,
+ documentTouches,
+ targetTouches,
+ changedTouches
+ );
+ target.dispatchEvent(event);
+}
+
+function setDispatch(batches, touches, batchIndex = 0) {
+ // check if all the sets have been fired
+ if (batchIndex >= batches.length) {
+ multiLast = {};
+ return;
+ }
+
+ // a set of actions need to be done
+ let batch = batches[batchIndex];
+ // each action for some finger
+ let pack;
+ // the touch id for the finger (pack)
+ let touchId;
+ // command for the finger
+ let command;
+ // touch that will be created for the finger
+ let el;
+ let touch;
+ let lastTouch;
+ let touchIndex;
+ let waitTime = 0;
+ let maxTime = 0;
+ let c;
+
+ // loop through the batch
+ batchIndex++;
+ for (let i = 0; i < batch.length; i++) {
+ pack = batch[i];
+ touchId = pack[0];
+ command = pack[1];
+
+ switch (command) {
+ case "press":
+ el = seenEls.get(pack[2], curContainer.frame);
+ c = element.coordinates(el, pack[3], pack[4]);
+ touch = legacyactions.createATouch(el, c.x, c.y, touchId);
+ multiLast[touchId] = touch;
+ touches.push(touch);
+ emitMultiEvents("touchstart", touch, touches);
+ break;
+
+ case "release":
+ touch = multiLast[touchId];
+ // the index of the previous touch for the finger may change in
+ // the touches array
+ touchIndex = touches.indexOf(touch);
+ touches.splice(touchIndex, 1);
+ emitMultiEvents("touchend", touch, touches);
+ break;
+
+ case "move":
+ el = seenEls.get(pack[2], curContainer.frame);
+ c = element.coordinates(el);
+ touch = legacyactions.createATouch(
+ multiLast[touchId].target,
+ c.x,
+ c.y,
+ touchId
+ );
+ touchIndex = touches.indexOf(lastTouch);
+ touches[touchIndex] = touch;
+ multiLast[touchId] = touch;
+ emitMultiEvents("touchmove", touch, touches);
+ break;
+
+ case "moveByOffset":
+ el = multiLast[touchId].target;
+ lastTouch = multiLast[touchId];
+ touchIndex = touches.indexOf(lastTouch);
+ let doc = el.ownerDocument;
+ let win = doc.defaultView;
+ // since x and y are relative to the last touch, therefore,
+ // it's relative to the position of the last touch
+ let clientX = lastTouch.clientX + pack[2];
+ let clientY = lastTouch.clientY + pack[3];
+ let pageX = clientX + win.pageXOffset;
+ let pageY = clientY + win.pageYOffset;
+ let screenX = clientX + win.mozInnerScreenX;
+ let screenY = clientY + win.mozInnerScreenY;
+ touch = doc.createTouch(
+ win,
+ el,
+ touchId,
+ pageX,
+ pageY,
+ screenX,
+ screenY,
+ clientX,
+ clientY
+ );
+ touches[touchIndex] = touch;
+ multiLast[touchId] = touch;
+ emitMultiEvents("touchmove", touch, touches);
+ break;
+
+ case "wait":
+ if (typeof pack[2] != "undefined") {
+ waitTime = pack[2] * 1000;
+ if (waitTime > maxTime) {
+ maxTime = waitTime;
+ }
+ }
+ break;
+ }
+ }
+
+ if (maxTime != 0) {
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.initWithCallback(
+ function() {
+ setDispatch(batches, touches, batchIndex);
+ },
+ maxTime,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ } else {
+ setDispatch(batches, touches, batchIndex);
+ }
+}
+
+/**
+ * Start multi-action.
+ *
+ * @param {Number} maxLen
+ * Longest action chain for one finger.
+ */
+function multiAction(args, maxLen) {
+ // unwrap the original nested array
+ let commandArray = evaluate.fromJSON(args, seenEls, curContainer.frame);
+ let concurrentEvent = [];
+ let temp;
+ for (let i = 0; i < maxLen; i++) {
+ let row = [];
+ for (let j = 0; j < commandArray.length; j++) {
+ if (typeof commandArray[j][i] != "undefined") {
+ // add finger id to the front of each action,
+ // i.e. [finger_id, action, element]
+ temp = commandArray[j][i];
+ temp.unshift(j);
+ row.push(temp);
+ }
+ }
+ concurrentEvent.push(row);
+ }
+
+ // Now concurrent event is made of sets where each set contain a list
+ // of actions that need to be fired.
+ //
+ // But note that each action belongs to a different finger
+ // pendingTouches keeps track of current touches that's on the screen.
+ let pendingTouches = [];
+ setDispatch(concurrentEvent, pendingTouches);
+}
+
+/**
+ * Get source of the current browsing context's DOM.
+ */
+function getPageSource() {
+ return curContainer.frame.document.documentElement.outerHTML;
+}
+
+/**
+ * Find an element in the current browsing context's document using the
+ * given search strategy.
+ */
+async function findElementContent(strategy, selector, opts = {}) {
+ opts.all = false;
+ let el = await element.find(curContainer, strategy, selector, opts);
+ return seenEls.add(el);
+}
+
+/**
+ * Find elements in the current browsing context's document using the
+ * given search strategy.
+ */
+async function findElementsContent(strategy, selector, opts = {}) {
+ opts.all = true;
+ let els = await element.find(curContainer, strategy, selector, opts);
+ let webEls = seenEls.addAll(els);
+ return webEls;
+}
+
+/**
+ * Return the active element in the document.
+ *
+ * @return {WebElement}
+ * Active element of the current browsing context's document
+ * element, if the document element is non-null.
+ *
+ * @throws {NoSuchElementError}
+ * If the document does not have an active element, i.e. if
+ * its document element has been deleted.
+ */
+function getActiveElement() {
+ let el = curContainer.frame.document.activeElement;
+ if (!el) {
+ throw new error.NoSuchElementError();
+ }
+ return evaluate.toJSON(el, seenEls);
+}
+
+/**
+ * Return the current browsing context id.
+ *
+ * @param {boolean=} topContext
+ * If set to true use the window's top-level browsing context,
+ * otherwise the one from the currently selected frame. Defaults to false.
+ *
+ * @return {number}
+ * Id of the browsing context.
+ */
+function getBrowsingContextId(topContext = false) {
+ const bc = curContainer.frame.docShell.browsingContext;
+
+ return topContext ? bc.top.id : bc.id;
+}
+
+/**
+ * Return the current visible URL.
+ *
+ * @return {string}
+ * Current visible URL.
+ */
+function getCurrentUrl() {
+ return content.location.href;
+}
+
+/**
+ * Send click event to element.
+ *
+ * @param {WebElement} el
+ * Element to click.
+ * @param {Object} capabilities
+ * Object with a list of WebDriver session capabilities.
+ */
+function clickElement(el, capabilities) {
+ return interaction.clickElement(
+ el,
+ capabilities["moz:accessibilityChecks"],
+ capabilities["moz:webdriverClick"]
+ );
+}
+
+function getElementAttribute(el, name) {
+ if (element.isBooleanAttribute(el, name)) {
+ if (el.hasAttribute(name)) {
+ return "true";
+ }
+ return null;
+ }
+ return el.getAttribute(name);
+}
+
+function getElementProperty(el, name) {
+ return typeof el[name] != "undefined" ? el[name] : null;
+}
+
+/**
+ * Get the text of this element. This includes text from child
+ * elements.
+ */
+function getElementText(el) {
+ return atom.getElementText(el, curContainer.frame);
+}
+
+/**
+ * Get the tag name of an element.
+ *
+ * @param {WebElement} id
+ * Reference to web element.
+ *
+ * @return {string}
+ * Tag name of element.
+ */
+function getElementTagName(el) {
+ return el.tagName.toLowerCase();
+}
+
+/**
+ * Determine the element displayedness of the given web element.
+ *
+ * Also performs additional accessibility checks if enabled by session
+ * capability.
+ */
+function isElementDisplayed(el, capabilities) {
+ return interaction.isElementDisplayed(
+ el,
+ capabilities["moz:accessibilityChecks"]
+ );
+}
+
+/**
+ * Retrieves the computed value of the given CSS property of the given
+ * web element.
+ */
+function getElementValueOfCssProperty(el, prop) {
+ let st = curContainer.frame.document.defaultView.getComputedStyle(el);
+ return st.getPropertyValue(prop);
+}
+
+/**
+ * Get the position and dimensions of the element.
+ *
+ * @return {Object.<string, number>}
+ * The x, y, width, and height properties of the element.
+ */
+function getElementRect(el) {
+ let clientRect = el.getBoundingClientRect();
+ return {
+ x: clientRect.x + curContainer.frame.pageXOffset,
+ y: clientRect.y + curContainer.frame.pageYOffset,
+ width: clientRect.width,
+ height: clientRect.height,
+ };
+}
+
+function isElementEnabled(el, capabilities) {
+ return interaction.isElementEnabled(
+ el,
+ capabilities["moz:accessibilityChecks"]
+ );
+}
+
+/**
+ * Determines if the referenced element is selected or not.
+ *
+ * This operation only makes sense on input elements of the Checkbox-
+ * and Radio Button states, or option elements.
+ */
+function isElementSelected(el, capabilities) {
+ return interaction.isElementSelected(
+ el,
+ capabilities["moz:accessibilityChecks"]
+ );
+}
+
+async function sendKeysToElement(el, val, capabilities) {
+ let opts = {
+ strictFileInteractability: capabilities.strictFileInteractability,
+ accessibilityChecks: capabilities["moz:accessibilityChecks"],
+ webdriverClick: capabilities["moz:webdriverClick"],
+ };
+ await interaction.sendKeysToElement(el, val, opts);
+}
+
+/** Clear the text of an element. */
+function clearElement(el) {
+ interaction.clearElement(el);
+}
+
+/**
+ * Switch to the parent frame of the current frame. If the frame is the
+ * top most is the current frame then no action will happen.
+ */
+function switchToParentFrame(msg) {
+ curContainer.frame = curContainer.parentFrame;
+
+ sendSyncMessage("Marionette:switchedToFrame", {
+ browsingContextId: curContainer.id,
+ });
+
+ sendOk(msg.json.commandID);
+}
+
+/**
+ * Switch to the specified frame.
+ *
+ * @param {(string|Object)=} element
+ * A web element reference of the frame or its element id.
+ * @param {number=} id
+ * The index of the frame to switch to.
+ * If both element and id are not defined, switch to top-level frame.
+ */
+function switchToFrame({ json }) {
+ let { commandID, element, id } = json;
+
+ let foundFrame;
+ let wantedFrame = null;
+
+ // check if curContainer.frame reference is dead
+ let frames = [];
+ try {
+ frames = curContainer.frame.frames;
+ } catch (e) {
+ // dead comparment, redirect to top frame
+ id = null;
+ element = null;
+ }
+
+ // switch to top-level frame
+ if (id == null && !element) {
+ curContainer.frame = content;
+ sendSyncMessage("Marionette:switchedToFrame", {
+ browsingContextId: curContainer.id,
+ });
+
+ sendOk(commandID);
+ return;
+ }
+
+ let webEl;
+ if (typeof element != "undefined") {
+ webEl = WebElement.fromUUID(element, "content");
+ }
+
+ if (webEl) {
+ if (!seenEls.has(webEl)) {
+ let err = new error.NoSuchElementError(
+ `Unable to locate element: ${webEl}`
+ );
+ sendError(err, commandID);
+ return;
+ }
+
+ try {
+ wantedFrame = seenEls.get(webEl, curContainer.frame);
+ } catch (e) {
+ sendError(e, commandID);
+ return;
+ }
+
+ if (frames.length > 0) {
+ // use XPCNativeWrapper to compare elements; see bug 834266
+ let wrappedWanted = new XPCNativeWrapper(wantedFrame);
+ foundFrame = Array.prototype.find.call(frames, frame => {
+ return new XPCNativeWrapper(frame.frameElement) === wrappedWanted;
+ });
+ }
+
+ if (!foundFrame) {
+ // Either the frame has been removed or we have a OOP frame
+ // so lets just get all the iframes and do a quick loop before
+ // throwing in the towel
+ let iframes = curContainer.frame.document.getElementsByTagName("iframe");
+ let wrappedWanted = new XPCNativeWrapper(wantedFrame);
+ foundFrame = Array.prototype.find.call(iframes, frame => {
+ return new XPCNativeWrapper(frame) === wrappedWanted;
+ });
+ }
+ }
+
+ if (!foundFrame) {
+ if (typeof id === "number") {
+ try {
+ let frameEl;
+ if (id >= 0 && id < frames.length) {
+ frameEl = frames[id].frameElement;
+ if (frameEl !== null) {
+ foundFrame = frameEl.contentWindow;
+ } else {
+ // If foundFrame is null at this point then we have the top
+ // level browsing context so should treat it accordingly.
+ curContainer.frame = content;
+ sendSyncMessage("Marionette:switchedToFrame", {
+ browsingContextId: curContainer.id,
+ });
+
+ sendOk(commandID);
+ return;
+ }
+ }
+ } catch (e) {
+ // Since window.frames does not return OOP frames it will throw
+ // and we land up here. Let's not give up and check if there are
+ // iframes and switch to the indexed frame there
+ let iframes = foundFrame.document.getElementsByTagName("iframe");
+ if (id >= 0 && id < iframes.length) {
+ foundFrame = iframes[id];
+ }
+ }
+ }
+ }
+
+ if (!foundFrame) {
+ let failedFrame = id || element;
+ let err = new error.NoSuchFrameError(
+ `Unable to locate frame: ${failedFrame}`
+ );
+ sendError(err, commandID);
+ return;
+ }
+
+ curContainer.frame = foundFrame;
+
+ sendSyncMessage("Marionette:switchedToFrame", {
+ browsingContextId: curContainer.id,
+ });
+
+ sendOk(commandID);
+}
+
+/**
+ * Returns the rect of the element to screenshot.
+ *
+ * Because the screen capture takes place in the parent process the dimensions
+ * for the screenshot have to be determined in the appropriate child process.
+ *
+ * Also it takes care of scrolling an element into view if requested.
+ *
+ * @param {Object.<string, ?>} opts
+ * Options.
+ *
+ * Accepted values for |opts|:
+ *
+ * @param {WebElement} webEl
+ * Optional element to take a screenshot of.
+ * @param {boolean=} full
+ * True to take a screenshot of the entire document element. Is only
+ * considered if <var>id</var> is not defined. Defaults to true.
+ * @param {boolean=} scroll
+ * When <var>id</var> is given, scroll it into view before taking the
+ * screenshot. Defaults to true.
+ * @param {capture.Format} format
+ * Format to return the screenshot in.
+ * @param {Object.<string, ?>} opts
+ * Options.
+ *
+ * @return {DOMRect}
+ * The area to take a snapshot from
+ */
+function getScreenshotRect({ el, full = true, scroll = true } = {}) {
+ let win = el ? curContainer.frame : content;
+
+ let rect;
+
+ if (el) {
+ if (scroll) {
+ element.scrollIntoView(el);
+ }
+ rect = getElementRect(el);
+ } else if (full) {
+ const docEl = win.document.documentElement;
+ rect = new DOMRect(0, 0, docEl.scrollWidth, docEl.scrollHeight);
+ } else {
+ // viewport
+ rect = new DOMRect(
+ win.pageXOffset,
+ win.pageYOffset,
+ win.innerWidth,
+ win.innerHeight
+ );
+ }
+
+ return rect;
+}
+
+function domAddEventListener(msg) {
+ eventObservers.add(msg.json.type);
+}
+
+function domRemoveEventListener(msg) {
+ eventObservers.remove(msg.json.type);
+}
+
+const eventDispatcher = {
+ enabled: false,
+
+ enable() {
+ if (this.enabled) {
+ return;
+ }
+
+ addEventListener("unload", this, false);
+
+ addEventListener("beforeunload", this, true);
+ addEventListener("pagehide", this, true);
+ addEventListener("popstate", this, true);
+
+ addEventListener("DOMContentLoaded", this, true);
+ addEventListener("hashchange", this, true);
+ addEventListener("pageshow", this, true);
+
+ this.enabled = true;
+ },
+
+ disable() {
+ if (!this.enabled) {
+ return;
+ }
+
+ removeEventListener("unload", this, false);
+
+ removeEventListener("beforeunload", this, true);
+ removeEventListener("pagehide", this, true);
+ removeEventListener("popstate", this, true);
+
+ removeEventListener("DOMContentLoaded", this, true);
+ removeEventListener("hashchange", this, true);
+ removeEventListener("pageshow", this, true);
+
+ this.enabled = false;
+ },
+
+ handleEvent(event) {
+ const { target, type } = event;
+
+ // An unload event indicates that the framescript died because of a process
+ // change, or that the tab / window has been closed.
+ if (type === "unload" && target === contentFrameMessageManager) {
+ logger.trace(`Frame script unloaded`);
+ sendAsyncMessage("Marionette:Unloaded", {
+ browsingContext: content.docShell.browsingContext,
+ });
+ return;
+ }
+
+ // Only care about events from the currently selected browsing context,
+ // whereby some of those do not bubble up to the window.
+ if (![curContainer.frame, curContainer.frame.document].includes(target)) {
+ return;
+ }
+
+ // Ignore invalid combinations of load events and document's readyState.
+ if (
+ (type === "DOMContentLoaded" && target.readyState != "interactive") ||
+ (type === "pageshow" && target.readyState != "complete")
+ ) {
+ logger.warn(
+ `Ignoring event '${type}' because document has an invalid ` +
+ `readyState of '${target.readyState}'.`
+ );
+ return;
+ }
+
+ if (type === "pagehide") {
+ // The content window has been replaced. Immediately register the page
+ // load events again so that we don't miss possible load events
+ addEventListener("DOMContentLoaded", this, true);
+ addEventListener("pageshow", this, true);
+ }
+
+ sendAsyncMessage("Marionette:NavigationEvent", {
+ browsingContext: content.docShell.browsingContext,
+ documentURI: target.documentURI,
+ readyState: target.readyState,
+ type,
+ });
+ },
+};
+
+/**
+ * Called when listener is first started up. The listener sends its
+ * unique window ID and its current URI to the actor. If the actor returns
+ * an ID, we start the listeners. Otherwise, nothing happens.
+ */
+function registerSelf() {
+ logger.trace("Frame script loaded");
+
+ curContainer.frame = content;
+
+ sandboxes.clear();
+ legacyactions.mouseEventsOnly = false;
+
+ let reply = sendSyncMessage("Marionette:Register", {
+ frameId: contentId,
+ });
+
+ if (reply.length == 0) {
+ logger.error("No reply from Marionette:Register");
+ return;
+ }
+
+ if (reply[0].frameId === contentId) {
+ startListeners();
+ sendAsyncMessage("Marionette:ListenersAttached", {
+ frameId: contentId,
+ });
+ }
+}
+
+// Call register self when we get loaded
+registerSelf();
diff --git a/testing/marionette/log.js b/testing/marionette/log.js
new file mode 100644
index 0000000000..53ca7208b1
--- /dev/null
+++ b/testing/marionette/log.js
@@ -0,0 +1,66 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["Log"];
+
+const StdLog = ChromeUtils.import("resource://gre/modules/Log.jsm", {}).Log;
+
+const PREF_LOG_LEVEL = "marionette.log.level";
+
+/**
+ * Shorthand for accessing the Marionette logging repository.
+ *
+ * Using this class to retrieve the `Log.jsm` repository for
+ * Marionette will ensure the logger is set up correctly with the
+ * appropriate stdout dumper and with the correct log level.
+ *
+ * Unlike `Log.jsm` this logger is E10s safe, meaning repository
+ * configuration is communicated across processes.
+ */
+class Log {
+ /**
+ * Obtain the `Marionette` logger.
+ *
+ * The returned {@link Logger} instance is shared among all
+ * callers in the same process.
+ *
+ * @return {Logger}
+ */
+ static get() {
+ let logger = StdLog.repository.getLogger("Marionette");
+ if (logger.ownAppenders.length == 0) {
+ logger.addAppender(new StdLog.DumpAppender());
+ logger.manageLevelFromPref(PREF_LOG_LEVEL);
+ }
+ return logger;
+ }
+
+ /**
+ * Obtain a logger that logs all messages with a prefix.
+ *
+ * Unlike {@link LoggerRepository.getLoggerWithMessagePrefix()}
+ * this function will ensure invoke {@link #get()} first to ensure
+ * the logger has been properly set up.
+ *
+ * This returns a new object with a prototype chain that chains
+ * up the original {@link Logger} instance. The new prototype has
+ * log functions that prefix `prefix` to each message.
+ *
+ * @param {string} prefix
+ * String to prefix each logged message with.
+ *
+ * @return {Proxy.<Logger>}
+ */
+ static getWithPrefix(prefix) {
+ this.get();
+ return StdLog.repository.getLoggerWithMessagePrefix(
+ "Marionette",
+ `[${prefix}] `
+ );
+ }
+}
+
+this.Log = Log;
diff --git a/testing/marionette/mach_commands.py b/testing/marionette/mach_commands.py
new file mode 100644
index 0000000000..8f3c009ff5
--- /dev/null
+++ b/testing/marionette/mach_commands.py
@@ -0,0 +1,112 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import argparse
+import functools
+import logging
+import os
+import sys
+
+from six import iteritems
+
+from mach.decorators import (
+ CommandProvider,
+ Command,
+)
+
+from mozbuild.base import (
+ MachCommandBase,
+ MachCommandConditions as conditions,
+ BinaryNotFoundException,
+)
+
+SUPPORTED_APPS = ["firefox", "android", "thunderbird"]
+
+
+def create_parser_tests():
+ from marionette_harness.runtests import MarionetteArguments
+ from mozlog.structured import commandline
+
+ parser = MarionetteArguments()
+ commandline.add_logging_group(parser)
+ return parser
+
+
+def run_marionette(tests, binary=None, topsrcdir=None, **kwargs):
+ from mozlog.structured import commandline
+
+ from marionette_harness.runtests import MarionetteTestRunner, MarionetteHarness
+
+ parser = create_parser_tests()
+
+ args = argparse.Namespace(tests=tests)
+
+ args.binary = binary
+ args.logger = kwargs.pop("log", None)
+
+ for k, v in iteritems(kwargs):
+ setattr(args, k, v)
+
+ parser.verify_usage(args)
+
+ if not args.logger:
+ args.logger = commandline.setup_logging(
+ "Marionette Unit Tests", args, {"mach": sys.stdout}
+ )
+ failed = MarionetteHarness(MarionetteTestRunner, args=vars(args)).run()
+ if failed > 0:
+ return 1
+ else:
+ return 0
+
+
+@CommandProvider
+class MarionetteTest(MachCommandBase):
+ @Command(
+ "marionette-test",
+ category="testing",
+ description="Remote control protocol to Gecko, used for browser automation.",
+ conditions=[functools.partial(conditions.is_buildapp_in, apps=SUPPORTED_APPS)],
+ parser=create_parser_tests,
+ )
+ def marionette_test(self, tests, **kwargs):
+ if "test_objects" in kwargs:
+ tests = []
+ for obj in kwargs["test_objects"]:
+ tests.append(obj["file_relpath"])
+ del kwargs["test_objects"]
+
+ if not tests:
+ if conditions.is_thunderbird(self):
+ tests = [
+ os.path.join(
+ self.topsrcdir, "comm/testing/marionette/unit-tests.ini"
+ )
+ ]
+ else:
+ tests = [
+ os.path.join(
+ self.topsrcdir,
+ "testing/marionette/harness/marionette_harness/tests/unit-tests.ini",
+ )
+ ]
+
+ if not kwargs.get("binary") and (
+ conditions.is_firefox(self) or conditions.is_thunderbird(self)
+ ):
+ try:
+ kwargs["binary"] = self.get_binary_path("app")
+ except BinaryNotFoundException as e:
+ self.log(
+ logging.ERROR,
+ "marionette-test",
+ {"error": str(e)},
+ "ERROR: {error}",
+ )
+ self.log(logging.INFO, "marionette-test", {"help": e.help()}, "{help}")
+ return 1
+
+ return run_marionette(tests, topsrcdir=self.topsrcdir, **kwargs)
diff --git a/testing/marionette/mach_test_package_commands.py b/testing/marionette/mach_test_package_commands.py
new file mode 100644
index 0000000000..ad0328b2ab
--- /dev/null
+++ b/testing/marionette/mach_test_package_commands.py
@@ -0,0 +1,75 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import absolute_import
+
+import argparse
+import os
+import sys
+
+from functools import partial
+
+from mach.decorators import (
+ CommandProvider,
+ Command,
+)
+from mozbuild.base import MachCommandBase
+
+parser = None
+
+
+def run_marionette(context, **kwargs):
+ from marionette.runtests import MarionetteTestRunner, MarionetteHarness
+ from mozlog.structured import commandline
+
+ args = argparse.Namespace(**kwargs)
+ args.binary = args.binary or context.firefox_bin
+
+ test_root = os.path.join(context.package_root, "marionette", "tests")
+ if not args.tests:
+ args.tests = [
+ os.path.join(
+ test_root,
+ "testing",
+ "marionette",
+ "harness",
+ "marionette_harness",
+ "tests",
+ "unit-tests.ini",
+ )
+ ]
+
+ normalize = partial(context.normalize_test_path, test_root)
+ args.tests = list(map(normalize, args.tests))
+
+ commandline.add_logging_group(parser)
+ parser.verify_usage(args)
+
+ args.logger = commandline.setup_logging(
+ "Marionette Unit Tests", args, {"mach": sys.stdout}
+ )
+ status = MarionetteHarness(MarionetteTestRunner, args=vars(args)).run()
+ return 1 if status else 0
+
+
+def setup_marionette_argument_parser():
+ from marionette.runner.base import BaseMarionetteArguments
+
+ global parser
+ parser = BaseMarionetteArguments()
+ return parser
+
+
+@CommandProvider
+class MachCommands(MachCommandBase):
+ @Command(
+ "marionette-test",
+ category="testing",
+ description="Run a Marionette test (Check UI or the internal JavaScript "
+ "using marionette).",
+ parser=setup_marionette_argument_parser,
+ )
+ def run_marionette_test(self, **kwargs):
+ self.context.activate_mozharness_venv()
+ return run_marionette(self.context, **kwargs)
diff --git a/testing/marionette/message.js b/testing/marionette/message.js
new file mode 100644
index 0000000000..9159112f27
--- /dev/null
+++ b/testing/marionette/message.js
@@ -0,0 +1,331 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["Command", "Message", "Response"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ assert: "chrome://marionette/content/assert.js",
+ error: "chrome://marionette/content/error.js",
+ truncate: "chrome://marionette/content/format.js",
+});
+
+/** Representation of the packets transproted over the wire. */
+class Message {
+ /**
+ * @param {number} messageID
+ * Message ID unique identifying this message.
+ */
+ constructor(messageID) {
+ this.id = assert.integer(messageID);
+ }
+
+ toString() {
+ let content = JSON.stringify(this.toPacket());
+ return truncate`${content}`;
+ }
+
+ /**
+ * Converts a data packet into a {@link Command} or {@link Response}.
+ *
+ * @param {Array.<number, number, ?, ?>} data
+ * A four element array where the elements, in sequence, signifies
+ * message type, message ID, method name or error, and parameters
+ * or result.
+ *
+ * @return {Message}
+ * Based on the message type, a {@link Command} or {@link Response}
+ * instance.
+ *
+ * @throws {TypeError}
+ * If the message type is not recognised.
+ */
+ static fromPacket(data) {
+ const [type] = data;
+
+ switch (type) {
+ case Command.Type:
+ return Command.fromPacket(data);
+
+ case Response.Type:
+ return Response.fromPacket(data);
+
+ default:
+ throw new TypeError(
+ "Unrecognised message type in packet: " + JSON.stringify(data)
+ );
+ }
+ }
+}
+
+/**
+ * Messages may originate from either the server or the client.
+ * Because the remote protocol is full duplex, both endpoints may be
+ * the origin of both commands and responses.
+ *
+ * @enum
+ * @see {@link Message}
+ */
+Message.Origin = {
+ /** Indicates that the message originates from the client. */
+ Client: 0,
+ /** Indicates that the message originates from the server. */
+ Server: 1,
+};
+
+/**
+ * A command is a request from the client to run a series of remote end
+ * steps and return a fitting response.
+ *
+ * The command can be synthesised from the message passed over the
+ * Marionette socket using the {@link fromPacket} function. The format of
+ * a message is:
+ *
+ * <pre>
+ * [<var>type</var>, <var>id</var>, <var>name</var>, <var>params</var>]
+ * </pre>
+ *
+ * where
+ *
+ * <dl>
+ * <dt><var>type</var> (integer)
+ * <dd>
+ * Must be zero (integer). Zero means that this message is
+ * a command.
+ *
+ * <dt><var>id</var> (integer)
+ * <dd>
+ * Integer used as a sequence number. The server replies with
+ * the same ID for the response.
+ *
+ * <dt><var>name</var> (string)
+ * <dd>
+ * String representing the command name with an associated set
+ * of remote end steps.
+ *
+ * <dt><var>params</var> (JSON Object or null)
+ * <dd>
+ * Object of command function arguments. The keys of this object
+ * must be strings, but the values can be arbitrary values.
+ * </dl>
+ *
+ * A command has an associated message <var>id</var> that prevents
+ * the dispatcher from sending responses in the wrong order.
+ *
+ * The command may also have optional error- and result handlers that
+ * are called when the client returns with a response. These are
+ * <code>function onerror({Object})</code>,
+ * <code>function onresult({Object})</code>, and
+ * <code>function onresult({Response})</code>:
+ *
+ * @param {number} messageID
+ * Message ID unique identifying this message.
+ * @param {string} name
+ * Command name.
+ * @param {Object.<string, ?>} params
+ * Command parameters.
+ */
+class Command extends Message {
+ constructor(messageID, name, params = {}) {
+ super(messageID);
+
+ this.name = assert.string(name);
+ this.parameters = assert.object(params);
+
+ this.onerror = null;
+ this.onresult = null;
+
+ this.origin = Message.Origin.Client;
+ this.sent = false;
+ }
+
+ /**
+ * Calls the error- or result handler associated with this command.
+ * This function can be replaced with a custom response handler.
+ *
+ * @param {Response} resp
+ * The response to pass on to the result or error to the
+ * <code>onerror</code> or <code>onresult</code> handlers to.
+ */
+ onresponse(resp) {
+ if (this.onerror && resp.error) {
+ this.onerror(resp.error);
+ } else if (this.onresult && resp.body) {
+ this.onresult(resp.body);
+ }
+ }
+
+ /**
+ * Encodes the command to a packet.
+ *
+ * @return {Array}
+ * Packet.
+ */
+ toPacket() {
+ return [Command.Type, this.id, this.name, this.parameters];
+ }
+
+ /**
+ * Converts a data packet into {@link Command}.
+ *
+ * @param {Array.<number, number, ?, ?>} data
+ * A four element array where the elements, in sequence, signifies
+ * message type, message ID, command name, and parameters.
+ *
+ * @return {Command}
+ * Representation of packet.
+ *
+ * @throws {TypeError}
+ * If the message type is not recognised.
+ */
+ static fromPacket(payload) {
+ let [type, msgID, name, params] = payload;
+ assert.that(n => n === Command.Type)(type);
+
+ // if parameters are given but null, treat them as undefined
+ if (params === null) {
+ params = undefined;
+ }
+
+ return new Command(msgID, name, params);
+ }
+}
+Command.Type = 0;
+
+/**
+ * @callback ResponseCallback
+ *
+ * @param {Response} resp
+ * Response to handle.
+ */
+
+/**
+ * Represents the response returned from the remote end after execution
+ * of its corresponding command.
+ *
+ * The response is a mutable object passed to each command for
+ * modification through the available setters. To send data in a response,
+ * you modify the body property on the response. The body property can
+ * also be replaced completely.
+ *
+ * The response is sent implicitly by
+ * {@link server.TCPConnection#execute when a command has finished
+ * executing, and any modifications made subsequent to that will have
+ * no effect.
+ *
+ * @param {number} messageID
+ * Message ID tied to the corresponding command request this is
+ * a response for.
+ * @param {ResponseHandler} respHandler
+ * Function callback called on sending the response.
+ */
+class Response extends Message {
+ constructor(messageID, respHandler = () => {}) {
+ super(messageID);
+
+ this.respHandler_ = assert.callable(respHandler);
+
+ this.error = null;
+ this.body = { value: null };
+
+ this.origin = Message.Origin.Server;
+ this.sent = false;
+ }
+
+ /**
+ * Sends response conditionally, given a predicate.
+ *
+ * @param {function(Response): boolean} predicate
+ * A predicate taking a Response object and returning a boolean.
+ */
+ sendConditionally(predicate) {
+ if (predicate(this)) {
+ this.send();
+ }
+ }
+
+ /**
+ * Sends response using the response handler provided on
+ * construction.
+ *
+ * @throws {RangeError}
+ * If the response has already been sent.
+ */
+ send() {
+ if (this.sent) {
+ throw new RangeError("Response has already been sent: " + this);
+ }
+ this.respHandler_(this);
+ this.sent = true;
+ }
+
+ /**
+ * Send error to client.
+ *
+ * Turns the response into an error response, clears any previously
+ * set body data, and sends it using the response handler provided
+ * on construction.
+ *
+ * @param {Error} err
+ * The Error instance to send.
+ *
+ * @throws {Error}
+ * If <var>err</var> is not a {@link WebDriverError}, the error
+ * is propagated, i.e. rethrown.
+ */
+ sendError(err) {
+ this.error = error.wrap(err).toJSON();
+ this.body = null;
+ this.send();
+
+ // propagate errors which are implementation problems
+ if (!error.isWebDriverError(err)) {
+ throw err;
+ }
+ }
+
+ /**
+ * Encodes the response to a packet.
+ *
+ * @return {Array}
+ * Packet.
+ */
+ toPacket() {
+ return [Response.Type, this.id, this.error, this.body];
+ }
+
+ /**
+ * Converts a data packet into {@link Response}.
+ *
+ * @param {Array.<number, number, ?, ?>} data
+ * A four element array where the elements, in sequence, signifies
+ * message type, message ID, error, and result.
+ *
+ * @return {Response}
+ * Representation of packet.
+ *
+ * @throws {TypeError}
+ * If the message type is not recognised.
+ */
+ static fromPacket(payload) {
+ let [type, msgID, err, body] = payload;
+ assert.that(n => n === Response.Type)(type);
+
+ let resp = new Response(msgID);
+ resp.error = assert.string(err);
+
+ resp.body = body;
+ return resp;
+ }
+}
+Response.Type = 1;
+
+this.Message = Message;
+this.Command = Command;
+this.Response = Response;
diff --git a/testing/marionette/modal.js b/testing/marionette/modal.js
new file mode 100644
index 0000000000..e277830475
--- /dev/null
+++ b/testing/marionette/modal.js
@@ -0,0 +1,244 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["modal"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ Log: "chrome://marionette/content/log.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+
+const COMMON_DIALOG = "chrome://global/content/commonDialog.xhtml";
+
+const isFirefox = () =>
+ Services.appinfo.ID == "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}";
+
+/** @namespace */
+this.modal = {
+ ACTION_CLOSED: "closed",
+ ACTION_OPENED: "opened",
+};
+
+/**
+ * Check for already existing modal or tab modal dialogs
+ *
+ * @param {browser.Context} context
+ * Reference to the browser context to check for existent dialogs.
+ *
+ * @return {modal.Dialog}
+ * Returns instance of the Dialog class, or `null` if no modal dialog
+ * is present.
+ */
+modal.findModalDialogs = function(context) {
+ // First check if there is a modal dialog already present for the
+ // current browser window.
+ for (let win of Services.wm.getEnumerator(null)) {
+ // TODO: Use BrowserWindowTracker.getTopWindow for modal dialogs without
+ // an opener.
+ if (
+ win.document.documentURI === COMMON_DIALOG &&
+ win.opener &&
+ win.opener === context.window
+ ) {
+ return new modal.Dialog(() => context, Cu.getWeakReference(win));
+ }
+ }
+
+ // If no modal dialog has been found, also check if there is an open
+ // tab modal dialog present for the current tab.
+ // TODO: Find an adequate implementation for Fennec.
+ if (context.tab && context.tabBrowser.getTabModalPromptBox) {
+ let contentBrowser = context.contentBrowser;
+ let promptManager = context.tabBrowser.getTabModalPromptBox(contentBrowser);
+ let prompts = promptManager.listPrompts();
+
+ if (prompts.length) {
+ return new modal.Dialog(() => context, null);
+ }
+ }
+
+ // No dialog found yet, check the TabDialogBox.
+ // This is for prompts that are shown in SubDialogs in the browser chrome.
+ if (context.tab && context.tabBrowser.getTabDialogBox) {
+ let contentBrowser = context.contentBrowser;
+ let dialogManager = context.tabBrowser
+ .getTabDialogBox(contentBrowser)
+ .getTabDialogManager();
+ let dialogs = dialogManager._dialogs.filter(
+ dialog => dialog._openedURL === COMMON_DIALOG
+ );
+
+ if (dialogs.length) {
+ return new modal.Dialog(
+ () => context,
+ Cu.getWeakReference(dialogs[0]._frame.contentWindow)
+ );
+ }
+ }
+
+ return null;
+};
+
+/**
+ * Observer for modal and tab modal dialogs.
+ *
+ * @return {modal.DialogObserver}
+ * Returns instance of the DialogObserver class.
+ */
+modal.DialogObserver = class {
+ constructor() {
+ this.callbacks = new Set();
+ this.register();
+ }
+
+ register() {
+ Services.obs.addObserver(this, "common-dialog-loaded");
+ Services.obs.addObserver(this, "tabmodal-dialog-loaded");
+ Services.obs.addObserver(this, "toplevel-window-ready");
+
+ // Register event listener for all already open windows
+ for (let win of Services.wm.getEnumerator(null)) {
+ win.addEventListener("DOMModalDialogClosed", this);
+ }
+ }
+
+ unregister() {
+ Services.obs.removeObserver(this, "common-dialog-loaded");
+ Services.obs.removeObserver(this, "tabmodal-dialog-loaded");
+ Services.obs.removeObserver(this, "toplevel-window-ready");
+
+ // Unregister event listener for all open windows
+ for (let win of Services.wm.getEnumerator(null)) {
+ win.removeEventListener("DOMModalDialogClosed", this);
+ }
+ }
+
+ cleanup() {
+ this.callbacks.clear();
+ this.unregister();
+ }
+
+ handleEvent(event) {
+ logger.trace(`Received event ${event.type}`);
+
+ let chromeWin = event.target.opener
+ ? event.target.opener.ownerGlobal
+ : event.target.ownerGlobal;
+
+ let targetRef = Cu.getWeakReference(event.target);
+
+ this.callbacks.forEach(callback => {
+ callback(modal.ACTION_CLOSED, targetRef, chromeWin);
+ });
+ }
+
+ observe(subject, topic) {
+ logger.trace(`Received observer notification ${topic}`);
+
+ switch (topic) {
+ case "common-dialog-loaded":
+ case "tabmodal-dialog-loaded":
+ let chromeWin = subject.opener
+ ? subject.opener.ownerGlobal
+ : subject.ownerGlobal;
+
+ // Always keep a weak reference to the current dialog
+ let targetRef = Cu.getWeakReference(subject);
+
+ this.callbacks.forEach(callback => {
+ callback(modal.ACTION_OPENED, targetRef, chromeWin);
+ });
+ break;
+
+ case "toplevel-window-ready":
+ subject.addEventListener("DOMModalDialogClosed", this);
+ break;
+ }
+ }
+
+ /**
+ * Add dialog handler by function reference.
+ *
+ * @param {function} callback
+ * The handler to be added.
+ */
+ add(callback) {
+ if (this.callbacks.has(callback)) {
+ return;
+ }
+ this.callbacks.add(callback);
+ }
+
+ /**
+ * Remove dialog handler by function reference.
+ *
+ * @param {function} callback
+ * The handler to be removed.
+ */
+ remove(callback) {
+ if (!this.callbacks.has(callback)) {
+ return;
+ }
+ this.callbacks.delete(callback);
+ }
+};
+
+/**
+ * Represents a modal dialog.
+ *
+ * @param {function(): browser.Context} curBrowserFn
+ * Function that returns the current |browser.Context|.
+ * @param {nsIWeakReference=} winRef
+ * A weak reference to the current |ChromeWindow|.
+ */
+modal.Dialog = class {
+ constructor(curBrowserFn, winRef = undefined) {
+ this.curBrowserFn_ = curBrowserFn;
+ this.win_ = winRef;
+ }
+
+ get curBrowser_() {
+ return this.curBrowserFn_();
+ }
+
+ /**
+ * Returns the ChromeWindow associated with an open dialog window if
+ * it is currently attached to the DOM.
+ */
+ get window() {
+ if (this.win_) {
+ let win = this.win_.get();
+ if (win && win.parent) {
+ return win;
+ }
+ }
+ return null;
+ }
+
+ get tabModal() {
+ let win = this.window;
+ if (win) {
+ return win.Dialog;
+ }
+ return this.curBrowser_.getTabModal();
+ }
+
+ get args() {
+ let tm = this.tabModal;
+ return tm ? tm.args : null;
+ }
+
+ get ui() {
+ let tm = this.tabModal;
+ return tm ? tm.ui : null;
+ }
+};
diff --git a/testing/marionette/moz.build b/testing/marionette/moz.build
new file mode 100644
index 0000000000..c7d0911020
--- /dev/null
+++ b/testing/marionette/moz.build
@@ -0,0 +1,22 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += ["components"]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+MARIONETTE_UNIT_MANIFESTS += ["harness/marionette_harness/tests/unit/unit-tests.ini"]
+XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"]
+
+with Files("**"):
+ BUG_COMPONENT = ("Testing", "Marionette")
+
+with Files("harness/**"):
+ SCHEDULES.exclusive = ["marionette", "firefox-ui"]
+
+SPHINX_TREES["/testing/marionette"] = "doc"
+SPHINX_PYTHON_PACKAGE_DIRS += ["client/marionette_driver"]
+
+with Files("doc/**"):
+ SCHEDULES.exclusive = ["docs"]
diff --git a/testing/marionette/navigate.js b/testing/marionette/navigate.js
new file mode 100644
index 0000000000..4392045e93
--- /dev/null
+++ b/testing/marionette/navigate.js
@@ -0,0 +1,414 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["navigate"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ error: "chrome://marionette/content/error.js",
+ EventDispatcher:
+ "chrome://marionette/content/actors/MarionetteEventsParent.jsm",
+ Log: "chrome://marionette/content/log.js",
+ MarionettePrefs: "chrome://marionette/content/prefs.js",
+ modal: "chrome://marionette/content/modal.js",
+ PageLoadStrategy: "chrome://marionette/content/capabilities.js",
+ TimedPromise: "chrome://marionette/content/sync.js",
+ truncate: "chrome://marionette/content/format.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+
+// Timeouts used to check if a new navigation has been initiated.
+const TIMEOUT_BEFOREUNLOAD_EVENT = 200;
+const TIMEOUT_UNLOAD_EVENT = 5000;
+
+/** @namespace */
+this.navigate = {};
+
+/**
+ * Checks the value of readyState for the current page
+ * load activity, and resolves the command if the load
+ * has been finished. It also takes care of the selected
+ * page load strategy.
+ *
+ * @param {PageLoadStrategy} pageLoadStrategy
+ * Strategy when navigation is considered as finished.
+ * @param {object} eventData
+ * @param {string} eventData.documentURI
+ * Current document URI of the document.
+ * @param {string} eventData.readyState
+ * Current ready state of the document.
+ *
+ * @return {boolean}
+ * True if the page load has been finished.
+ */
+function checkReadyState(pageLoadStrategy, eventData = {}) {
+ const { documentURI, readyState } = eventData;
+
+ const result = { error: null, finished: false };
+
+ switch (readyState) {
+ case "interactive":
+ if (documentURI.startsWith("about:certerror")) {
+ result.error = new error.InsecureCertificateError();
+ result.finished = true;
+ } else if (/about:.*(error)\?/.exec(documentURI)) {
+ result.error = new error.UnknownError(
+ `Reached error page: ${documentURI}`
+ );
+ result.finished = true;
+
+ // Return early with a page load strategy of eager, and also
+ // special-case about:blocked pages which should be treated as
+ // non-error pages but do not raise a pageshow event. about:blank
+ // is also treaded specifically here, because it gets temporary
+ // loaded for new content processes, and we only want to rely on
+ // complete loads for it.
+ } else if (
+ (pageLoadStrategy === PageLoadStrategy.Eager &&
+ documentURI != "about:blank") ||
+ /about:blocked\?/.exec(documentURI)
+ ) {
+ result.finished = true;
+ }
+ break;
+
+ case "complete":
+ result.finished = true;
+ break;
+ }
+
+ return result;
+}
+
+/**
+ * Determines if we expect to get a DOM load event (DOMContentLoaded)
+ * on navigating to the <code>future</code> URL.
+ *
+ * @param {URL} current
+ * URL the browser is currently visiting.
+ * @param {Object} options
+ * @param {BrowsingContext=} options.browsingContext
+ * The current browsing context. Needed for targets of _parent and _top.
+ * @param {URL=} options.future
+ * Destination URL, if known.
+ * @param {target=} options.target
+ * Link target, if known.
+ *
+ * @return {boolean}
+ * Full page load would be expected if future is followed.
+ *
+ * @throws TypeError
+ * If <code>current</code> is not defined, or any of
+ * <code>current</code> or <code>future</code> are invalid URLs.
+ */
+navigate.isLoadEventExpected = function(current, options = {}) {
+ const { browsingContext, future, target } = options;
+
+ if (typeof current == "undefined") {
+ throw new TypeError("Expected at least one URL");
+ }
+
+ if (["_parent", "_top"].includes(target) && !browsingContext) {
+ throw new TypeError(
+ "Expected browsingContext when target is _parent or _top"
+ );
+ }
+
+ // Don't wait if the navigation happens in a different browsing context
+ if (
+ target === "_blank" ||
+ (target === "_parent" && browsingContext.parent) ||
+ (target === "_top" && browsingContext.top != browsingContext)
+ ) {
+ return false;
+ }
+
+ // Assume we will go somewhere exciting
+ if (typeof future == "undefined") {
+ return true;
+ }
+
+ // Assume javascript:<whatever> will modify the current document
+ // but this is not an entirely safe assumption to make,
+ // considering it could be used to set window.location
+ if (future.protocol == "javascript:") {
+ return false;
+ }
+
+ // If hashes are present and identical
+ if (
+ current.href.includes("#") &&
+ future.href.includes("#") &&
+ current.hash === future.hash
+ ) {
+ return false;
+ }
+
+ return true;
+};
+
+/**
+ * Load the given URL in the specified browsing context.
+ *
+ * @param {CanonicalBrowsingContext} browsingContext
+ * Browsing context to load the URL into.
+ * @param {string} url
+ * URL to navigate to.
+ */
+navigate.navigateTo = async function(browsingContext, url) {
+ const opts = {
+ loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_IS_LINK,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ };
+ browsingContext.loadURI(url, opts);
+};
+
+/**
+ * Reload the page.
+ *
+ * @param {CanonicalBrowsingContext} browsingContext
+ * Browsing context to refresh.
+ */
+navigate.refresh = async function(browsingContext) {
+ const flags = Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
+ browsingContext.reload(flags);
+};
+
+/**
+ * Execute a callback and wait for a possible navigation to complete
+ *
+ * @param {GeckoDriver} driver
+ * Reference to driver instance.
+ * @param {Function} callback
+ * Callback to execute that might trigger a navigation.
+ * @param {Object} options
+ * @param {BrowsingContext=} browsingContext
+ * Browsing context to observe. Defaults to the current browsing context.
+ * @param {boolean=} loadEventExpected
+ * If false, return immediately and don't wait for
+ * the navigation to be completed. Defaults to true.
+ * @param {boolean=} requireBeforeUnload
+ * If false and no beforeunload event is fired, abort waiting
+ * for the navigation. Defaults to true.
+ */
+navigate.waitForNavigationCompleted = async function waitForNavigationCompleted(
+ driver,
+ callback,
+ options = {}
+) {
+ const {
+ browsingContextFn = driver.getBrowsingContext.bind(driver),
+ loadEventExpected = true,
+ requireBeforeUnload = true,
+ } = options;
+
+ const chromeWindow = browsingContextFn().topChromeWindow;
+ const pageLoadStrategy = driver.capabilities.get("pageLoadStrategy");
+
+ // Return immediately if no load event is expected
+ if (!loadEventExpected || pageLoadStrategy === PageLoadStrategy.None) {
+ await callback();
+ return Promise.resolve();
+ }
+
+ let rejectNavigation;
+ let resolveNavigation;
+
+ let seenBeforeUnload = false;
+ let seenUnload = false;
+
+ let unloadTimer;
+
+ const checkDone = ({ finished, error }) => {
+ if (finished) {
+ if (error) {
+ rejectNavigation(error);
+ } else {
+ resolveNavigation();
+ }
+ }
+ };
+
+ const onDialogOpened = (action, dialog, win) => {
+ // Only care about modals of the currently selected window.
+ if (win !== chromeWindow) {
+ return;
+ }
+
+ if (action === modal.ACTION_OPENED) {
+ logger.trace("Canceled page load listener because a dialog opened");
+ checkDone({ finished: true });
+ }
+ };
+
+ const onTimer = timer => {
+ // In the case when a document has a beforeunload handler
+ // registered, the currently active command will return immediately
+ // due to the modal dialog observer in proxy.js.
+ //
+ // Otherwise the timeout waiting for the document to start
+ // navigating is increased by 5000 ms to ensure a possible load
+ // event is not missed. In the common case such an event should
+ // occur pretty soon after beforeunload, and we optimise for this.
+ if (seenBeforeUnload) {
+ seenBeforeUnload = false;
+ unloadTimer.initWithCallback(
+ onTimer,
+ TIMEOUT_UNLOAD_EVENT,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+
+ // If no page unload has been detected, ensure to properly stop
+ // the load listener, and return from the currently active command.
+ } else if (!seenUnload) {
+ logger.trace(
+ "Canceled page load listener because no navigation " +
+ "has been detected"
+ );
+ checkDone({ finished: true });
+ }
+ };
+
+ const onNavigation = ({ json }, message) => {
+ let data = MarionettePrefs.useActors ? message : json;
+
+ if (MarionettePrefs.useActors) {
+ // Only care about navigation events from the actor of the current frame.
+ // Bug 1674329: Always use the currently active browsing context,
+ // and not the original one to not cause hangs for remoteness changes.
+ if (data.browsingContext != browsingContextFn()) {
+ return;
+ }
+ } else if (
+ data.browsingContext.browserId != browsingContextFn().browserId
+ ) {
+ return;
+ }
+
+ logger.trace(truncate`Received event ${data.type} for ${data.documentURI}`);
+
+ switch (data.type) {
+ case "beforeunload":
+ seenBeforeUnload = true;
+ break;
+
+ case "pagehide":
+ seenUnload = true;
+ break;
+
+ case "hashchange":
+ case "popstate":
+ checkDone({ finished: true });
+ break;
+
+ case "DOMContentLoaded":
+ case "pageshow":
+ if (!seenUnload) {
+ return;
+ }
+ const result = checkReadyState(pageLoadStrategy, data);
+ checkDone(result);
+ break;
+ }
+ };
+
+ // In the case when the currently selected frame is closed,
+ // there will be no further load events. Stop listening immediately.
+ const onBrowsingContextDiscarded = (subject, topic) => {
+ // With the currentWindowGlobal gone the browsing context hasn't been
+ // replaced due to a remoteness change but closed.
+ if (subject == browsingContextFn() && !subject.currentWindowGlobal) {
+ logger.trace(
+ "Canceled page load listener " +
+ `because browsing context with id ${subject.id} has been removed`
+ );
+ checkDone({ finished: true });
+ }
+ };
+
+ const onUnload = event => {
+ logger.trace(
+ "Canceled page load listener " +
+ "because the top-browsing context has been closed"
+ );
+ checkDone({ finished: true });
+ };
+
+ chromeWindow.addEventListener("TabClose", onUnload);
+ chromeWindow.addEventListener("unload", onUnload);
+ driver.dialogObserver.add(onDialogOpened);
+ Services.obs.addObserver(
+ onBrowsingContextDiscarded,
+ "browsing-context-discarded"
+ );
+
+ if (MarionettePrefs.useActors) {
+ EventDispatcher.on("page-load", onNavigation);
+ } else {
+ driver.mm.addMessageListener(
+ "Marionette:NavigationEvent",
+ onNavigation,
+ true
+ );
+ }
+
+ return new TimedPromise(
+ async (resolve, reject) => {
+ rejectNavigation = reject;
+ resolveNavigation = resolve;
+
+ try {
+ await callback();
+
+ // Certain commands like clickElement can cause a navigation. Setup a timer
+ // to check if a "beforeunload" event has been emitted within the given
+ // time frame. If not resolve the Promise.
+ if (!requireBeforeUnload) {
+ unloadTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ unloadTimer.initWithCallback(
+ onTimer,
+ TIMEOUT_BEFOREUNLOAD_EVENT,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ }
+ } catch (e) {
+ // Executing the callback above could destroy the actor pair before the
+ // command returns. Such an error has to be ignored.
+ if (e.name !== "AbortError") {
+ checkDone({ finished: true, error: e });
+ }
+ }
+ },
+ {
+ timeout: driver.timeouts.pageLoad,
+ }
+ ).finally(() => {
+ // Clean-up all registered listeners and timers
+ Services.obs.removeObserver(
+ onBrowsingContextDiscarded,
+ "browsing-context-discarded"
+ );
+ chromeWindow.removeEventListener("TabClose", onUnload);
+ chromeWindow.removeEventListener("unload", onUnload);
+ driver.dialogObserver?.remove(onDialogOpened);
+ unloadTimer?.cancel();
+
+ if (MarionettePrefs.useActors) {
+ EventDispatcher.off("page-load", onNavigation);
+ } else {
+ driver.mm.removeMessageListener(
+ "Marionette:NavigationEvent",
+ onNavigation,
+ true
+ );
+ }
+ });
+};
diff --git a/testing/marionette/packets.js b/testing/marionette/packets.js
new file mode 100644
index 0000000000..0d5ad47b27
--- /dev/null
+++ b/testing/marionette/packets.js
@@ -0,0 +1,429 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["RawPacket", "Packet", "JSONPacket", "BulkPacket"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ StreamUtils: "chrome://marionette/content/stream-utils.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "unicodeConverter", () => {
+ const unicodeConverter = Cc[
+ "@mozilla.org/intl/scriptableunicodeconverter"
+ ].createInstance(Ci.nsIScriptableUnicodeConverter);
+ unicodeConverter.charset = "UTF-8";
+
+ return unicodeConverter;
+});
+
+/**
+ * Packets contain read / write functionality for the different packet types
+ * supported by the debugging protocol, so that a transport can focus on
+ * delivery and queue management without worrying too much about the specific
+ * packet types.
+ *
+ * They are intended to be "one use only", so a new packet should be
+ * instantiated for each incoming or outgoing packet.
+ *
+ * A complete Packet type should expose at least the following:
+ * * read(stream, scriptableStream)
+ * Called when the input stream has data to read
+ * * write(stream)
+ * Called when the output stream is ready to write
+ * * get done()
+ * Returns true once the packet is done being read / written
+ * * destroy()
+ * Called to clean up at the end of use
+ */
+
+const defer = function() {
+ let deferred = {
+ promise: new Promise((resolve, reject) => {
+ deferred.resolve = resolve;
+ deferred.reject = reject;
+ }),
+ };
+ return deferred;
+};
+
+// The transport's previous check ensured the header length did not
+// exceed 20 characters. Here, we opt for the somewhat smaller, but still
+// large limit of 1 TiB.
+const PACKET_LENGTH_MAX = Math.pow(2, 40);
+
+/**
+ * A generic Packet processing object (extended by two subtypes below).
+ *
+ * @class
+ */
+function Packet(transport) {
+ this._transport = transport;
+ this._length = 0;
+}
+
+/**
+ * Attempt to initialize a new Packet based on the incoming packet header
+ * we've received so far. We try each of the types in succession, trying
+ * JSON packets first since they are much more common.
+ *
+ * @param {string} header
+ * Packet header string to attempt parsing.
+ * @param {DebuggerTransport} transport
+ * Transport instance that will own the packet.
+ *
+ * @return {Packet}
+ * Parsed packet of the matching type, or null if no types matched.
+ */
+Packet.fromHeader = function(header, transport) {
+ return (
+ JSONPacket.fromHeader(header, transport) ||
+ BulkPacket.fromHeader(header, transport)
+ );
+};
+
+Packet.prototype = {
+ get length() {
+ return this._length;
+ },
+
+ set length(length) {
+ if (length > PACKET_LENGTH_MAX) {
+ throw new Error(
+ "Packet length " +
+ length +
+ " exceeds the max length of " +
+ PACKET_LENGTH_MAX
+ );
+ }
+ this._length = length;
+ },
+
+ destroy() {
+ this._transport = null;
+ },
+};
+
+/**
+ * With a JSON packet (the typical packet type sent via the transport),
+ * data is transferred as a JSON packet serialized into a string,
+ * with the string length prepended to the packet, followed by a colon
+ * ([length]:[packet]). The contents of the JSON packet are specified in
+ * the Remote Debugging Protocol specification.
+ *
+ * @param {DebuggerTransport} transport
+ * Transport instance that will own the packet.
+ */
+function JSONPacket(transport) {
+ Packet.call(this, transport);
+ this._data = "";
+ this._done = false;
+}
+
+/**
+ * Attempt to initialize a new JSONPacket based on the incoming packet
+ * header we've received so far.
+ *
+ * @param {string} header
+ * Packet header string to attempt parsing.
+ * @param {DebuggerTransport} transport
+ * Transport instance that will own the packet.
+ *
+ * @return {JSONPacket}
+ * Parsed packet, or null if it's not a match.
+ */
+JSONPacket.fromHeader = function(header, transport) {
+ let match = this.HEADER_PATTERN.exec(header);
+
+ if (!match) {
+ return null;
+ }
+
+ let packet = new JSONPacket(transport);
+ packet.length = +match[1];
+ return packet;
+};
+
+JSONPacket.HEADER_PATTERN = /^(\d+):$/;
+
+JSONPacket.prototype = Object.create(Packet.prototype);
+
+Object.defineProperty(JSONPacket.prototype, "object", {
+ /**
+ * Gets the object (not the serialized string) being read or written.
+ */
+ get() {
+ return this._object;
+ },
+
+ /**
+ * Sets the object to be sent when write() is called.
+ */
+ set(object) {
+ this._object = object;
+ let data = JSON.stringify(object);
+ this._data = unicodeConverter.ConvertFromUnicode(data);
+ this.length = this._data.length;
+ },
+});
+
+JSONPacket.prototype.read = function(stream, scriptableStream) {
+ // Read in more packet data.
+ this._readData(stream, scriptableStream);
+
+ if (!this.done) {
+ // Don't have a complete packet yet.
+ return;
+ }
+
+ let json = this._data;
+ try {
+ json = unicodeConverter.ConvertToUnicode(json);
+ this._object = JSON.parse(json);
+ } catch (e) {
+ let msg =
+ "Error parsing incoming packet: " +
+ json +
+ " (" +
+ e +
+ " - " +
+ e.stack +
+ ")";
+ console.error(msg);
+ dump(msg + "\n");
+ return;
+ }
+
+ this._transport._onJSONObjectReady(this._object);
+};
+
+JSONPacket.prototype._readData = function(stream, scriptableStream) {
+ let bytesToRead = Math.min(
+ this.length - this._data.length,
+ stream.available()
+ );
+ this._data += scriptableStream.readBytes(bytesToRead);
+ this._done = this._data.length === this.length;
+};
+
+JSONPacket.prototype.write = function(stream) {
+ if (this._outgoing === undefined) {
+ // Format the serialized packet to a buffer
+ this._outgoing = this.length + ":" + this._data;
+ }
+
+ let written = stream.write(this._outgoing, this._outgoing.length);
+ this._outgoing = this._outgoing.slice(written);
+ this._done = !this._outgoing.length;
+};
+
+Object.defineProperty(JSONPacket.prototype, "done", {
+ get() {
+ return this._done;
+ },
+});
+
+JSONPacket.prototype.toString = function() {
+ return JSON.stringify(this._object, null, 2);
+};
+
+/**
+ * With a bulk packet, data is transferred by temporarily handing over
+ * the transport's input or output stream to the application layer for
+ * writing data directly. This can be much faster for large data sets,
+ * and avoids various stages of copies and data duplication inherent in
+ * the JSON packet type. The bulk packet looks like:
+ *
+ * bulk [actor] [type] [length]:[data]
+ *
+ * The interpretation of the data portion depends on the kind of actor and
+ * the packet's type. See the Remote Debugging Protocol Stream Transport
+ * spec for more details.
+ *
+ * @param {DebuggerTransport} transport
+ * Transport instance that will own the packet.
+ */
+function BulkPacket(transport) {
+ Packet.call(this, transport);
+ this._done = false;
+ this._readyForWriting = defer();
+}
+
+/**
+ * Attempt to initialize a new BulkPacket based on the incoming packet
+ * header we've received so far.
+ *
+ * @param {string} header
+ * Packet header string to attempt parsing.
+ * @param {DebuggerTransport} transport
+ * Transport instance that will own the packet.
+ *
+ * @return {BulkPacket}
+ * Parsed packet, or null if it's not a match.
+ */
+BulkPacket.fromHeader = function(header, transport) {
+ let match = this.HEADER_PATTERN.exec(header);
+
+ if (!match) {
+ return null;
+ }
+
+ let packet = new BulkPacket(transport);
+ packet.header = {
+ actor: match[1],
+ type: match[2],
+ length: +match[3],
+ };
+ return packet;
+};
+
+BulkPacket.HEADER_PATTERN = /^bulk ([^: ]+) ([^: ]+) (\d+):$/;
+
+BulkPacket.prototype = Object.create(Packet.prototype);
+
+BulkPacket.prototype.read = function(stream) {
+ // Temporarily pause monitoring of the input stream
+ this._transport.pauseIncoming();
+
+ let deferred = defer();
+
+ this._transport._onBulkReadReady({
+ actor: this.actor,
+ type: this.type,
+ length: this.length,
+ copyTo: output => {
+ let copying = StreamUtils.copyStream(stream, output, this.length);
+ deferred.resolve(copying);
+ return copying;
+ },
+ stream,
+ done: deferred,
+ });
+
+ // Await the result of reading from the stream
+ deferred.promise.then(() => {
+ this._done = true;
+ this._transport.resumeIncoming();
+ }, this._transport.close);
+
+ // Ensure this is only done once
+ this.read = () => {
+ throw new Error("Tried to read() a BulkPacket's stream multiple times.");
+ };
+};
+
+BulkPacket.prototype.write = function(stream) {
+ if (this._outgoingHeader === undefined) {
+ // Format the serialized packet header to a buffer
+ this._outgoingHeader =
+ "bulk " + this.actor + " " + this.type + " " + this.length + ":";
+ }
+
+ // Write the header, or whatever's left of it to write.
+ if (this._outgoingHeader.length) {
+ let written = stream.write(
+ this._outgoingHeader,
+ this._outgoingHeader.length
+ );
+ this._outgoingHeader = this._outgoingHeader.slice(written);
+ return;
+ }
+
+ // Temporarily pause the monitoring of the output stream
+ this._transport.pauseOutgoing();
+
+ let deferred = defer();
+
+ this._readyForWriting.resolve({
+ copyFrom: input => {
+ let copying = StreamUtils.copyStream(input, stream, this.length);
+ deferred.resolve(copying);
+ return copying;
+ },
+ stream,
+ done: deferred,
+ });
+
+ // Await the result of writing to the stream
+ deferred.promise.then(() => {
+ this._done = true;
+ this._transport.resumeOutgoing();
+ }, this._transport.close);
+
+ // Ensure this is only done once
+ this.write = () => {
+ throw new Error("Tried to write() a BulkPacket's stream multiple times.");
+ };
+};
+
+Object.defineProperty(BulkPacket.prototype, "streamReadyForWriting", {
+ get() {
+ return this._readyForWriting.promise;
+ },
+});
+
+Object.defineProperty(BulkPacket.prototype, "header", {
+ get() {
+ return {
+ actor: this.actor,
+ type: this.type,
+ length: this.length,
+ };
+ },
+
+ set(header) {
+ this.actor = header.actor;
+ this.type = header.type;
+ this.length = header.length;
+ },
+});
+
+Object.defineProperty(BulkPacket.prototype, "done", {
+ get() {
+ return this._done;
+ },
+});
+
+BulkPacket.prototype.toString = function() {
+ return "Bulk: " + JSON.stringify(this.header, null, 2);
+};
+
+/**
+ * RawPacket is used to test the transport's error handling of malformed
+ * packets, by writing data directly onto the stream.
+ * @param transport DebuggerTransport
+ * The transport instance that will own the packet.
+ * @param data string
+ * The raw string to send out onto the stream.
+ */
+function RawPacket(transport, data) {
+ Packet.call(this, transport);
+ this._data = data;
+ this.length = data.length;
+ this._done = false;
+}
+
+RawPacket.prototype = Object.create(Packet.prototype);
+
+RawPacket.prototype.read = function() {
+ // this has not yet been needed for testing
+ throw new Error("Not implemented");
+};
+
+RawPacket.prototype.write = function(stream) {
+ let written = stream.write(this._data, this._data.length);
+ this._data = this._data.slice(written);
+ this._done = !this._data.length;
+};
+
+Object.defineProperty(RawPacket.prototype, "done", {
+ get() {
+ return this._done;
+ },
+});
diff --git a/testing/marionette/prefs.js b/testing/marionette/prefs.js
new file mode 100644
index 0000000000..937ec647d1
--- /dev/null
+++ b/testing/marionette/prefs.js
@@ -0,0 +1,280 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["Branch", "MarionettePrefs"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ Log: "resource://gre/modules/Log.jsm",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "env",
+ "@mozilla.org/process/environment;1",
+ "nsIEnvironment"
+);
+
+const { PREF_BOOL, PREF_INT, PREF_INVALID, PREF_STRING } = Ci.nsIPrefBranch;
+
+class Branch {
+ /**
+ * @param {string=} branch
+ * Preference subtree. Uses root tree given `null`.
+ */
+ constructor(branch) {
+ this._branch = Services.prefs.getBranch(branch);
+ }
+
+ /**
+ * Gets value of `pref` in its known type.
+ *
+ * @param {string} pref
+ * Preference name.
+ * @param {?=} fallback
+ * Fallback value to return if `pref` does not exist.
+ *
+ * @return {(string|boolean|number)}
+ * Value of `pref`, or the `fallback` value if `pref` does
+ * not exist.
+ *
+ * @throws {TypeError}
+ * If `pref` is not a recognised preference and no `fallback`
+ * value has been provided.
+ */
+ get(pref, fallback = null) {
+ switch (this._branch.getPrefType(pref)) {
+ case PREF_STRING:
+ return this._branch.getStringPref(pref);
+
+ case PREF_BOOL:
+ return this._branch.getBoolPref(pref);
+
+ case PREF_INT:
+ return this._branch.getIntPref(pref);
+
+ case PREF_INVALID:
+ default:
+ if (fallback != null) {
+ return fallback;
+ }
+ throw new TypeError(`Unrecognised preference: ${pref}`);
+ }
+ }
+
+ /**
+ * Sets the value of `pref`.
+ *
+ * @param {string} pref
+ * Preference name.
+ * @param {(string|boolean|number)} value
+ * `pref`'s new value.
+ *
+ * @throws {TypeError}
+ * If `value` is not the correct type for `pref`.
+ */
+ set(pref, value) {
+ let typ;
+ if (typeof value != "undefined" && value != null) {
+ typ = value.constructor.name;
+ }
+
+ switch (typ) {
+ case "String":
+ // Unicode compliant
+ return this._branch.setStringPref(pref, value);
+
+ case "Boolean":
+ return this._branch.setBoolPref(pref, value);
+
+ case "Number":
+ return this._branch.setIntPref(pref, value);
+
+ default:
+ throw new TypeError(`Illegal preference type value: ${typ}`);
+ }
+ }
+}
+
+/**
+ * Provides shortcuts for lazily getting and setting typed Marionette
+ * preferences.
+ *
+ * Some of Marionette's preferences are stored using primitive values
+ * that internally are represented by complex types. One such example
+ * is `marionette.log.level` which stores a string such as `info` or
+ * `DEBUG`, and which is represented as `Log.Level`.
+ *
+ * Because we cannot trust the input of many of these preferences,
+ * this class provides abstraction that lets us safely deal with
+ * potentially malformed input. In the `marionette.log.level` example,
+ * `DEBUG`, `Debug`, and `dEbUg` are considered valid inputs and the
+ * `LogBranch` specialisation deserialises the string value to the
+ * correct `Log.Level` by sanitising the input data first.
+ *
+ * A further complication is that we cannot rely on `Preferences.jsm`
+ * in Marionette. See https://bugzilla.mozilla.org/show_bug.cgi?id=1357517
+ * for further details.
+ */
+class MarionetteBranch extends Branch {
+ constructor(branch = "marionette.") {
+ super(branch);
+ }
+
+ /**
+ * The `marionette.enabled` preference. When it returns true,
+ * this signifies that the Marionette server is running.
+ *
+ * @return {boolean}
+ */
+ get enabled() {
+ return this.get("enabled", false);
+ }
+
+ set enabled(isEnabled) {
+ this.set("enabled", isEnabled);
+ }
+
+ /**
+ * The `marionette.debugging.clicktostart` preference delays
+ * server startup until a modal dialogue has been clicked to allow
+ * time for user to set breakpoints in the Browser Toolbox.
+ *
+ * @return {boolean}
+ */
+ get clickToStart() {
+ return this.get("debugging.clicktostart", false);
+ }
+
+ /**
+ * Whether content scripts can be safely reused.
+ *
+ * @deprecated
+ * @return {boolean}
+ */
+ get contentListener() {
+ return this.get("contentListener", false);
+ }
+
+ set contentListener(value) {
+ this.set("contentListener", value);
+ }
+
+ /**
+ * The `marionette.port` preference, detailing which port
+ * the TCP server should listen on.
+ *
+ * @return {number}
+ */
+ get port() {
+ return this.get("port", 2828);
+ }
+
+ set port(newPort) {
+ this.set("port", newPort);
+ }
+
+ /**
+ * Fail-safe return of the current log level from preference
+ * `marionette.log.level`.
+ *
+ * @return {Log.Level}
+ */
+ get logLevel() {
+ // TODO: when geckodriver's minimum supported Firefox version reaches 62,
+ // the lower-casing here can be dropped (https://bugzil.la/1482829)
+ switch (this.get("log.level", "info").toLowerCase()) {
+ case "fatal":
+ return Log.Level.Fatal;
+ case "error":
+ return Log.Level.Error;
+ case "warn":
+ return Log.Level.Warn;
+ case "config":
+ return Log.Level.Config;
+ case "debug":
+ return Log.Level.Debug;
+ case "trace":
+ return Log.Level.Trace;
+ case "info":
+ default:
+ dump(`*** log: ${Log}\n\n`);
+ return Log.Level.Info;
+ }
+ }
+
+ /**
+ * Certain log messages that are known to be long are truncated
+ * before they are dumped to stdout. The `marionette.log.truncate`
+ * preference indicates that the values should not be truncated.
+ *
+ * @return {boolean}
+ */
+ get truncateLog() {
+ return this.get("log.truncate");
+ }
+
+ /**
+ * Gets the `marionette.prefs.recommended` preference, signifying
+ * whether recommended automation preferences will be set when
+ * Marionette is started.
+ *
+ * @return {boolean}
+ */
+ get recommendedPrefs() {
+ return this.get("prefs.recommended", true);
+ }
+
+ /**
+ * Temporary preference to enable the usage of the JSWindowActor
+ * implementation for commands that already support Fission.
+ */
+ get useActors() {
+ return this.get("actors.enabled", true);
+ }
+}
+
+/** Reads a JSON serialised blob stored in the environment. */
+class EnvironmentPrefs {
+ /**
+ * Reads the environment variable `key` and tries to parse it as
+ * JSON Object, then provides an iterator over its keys and values.
+ *
+ * If the environment variable is not set, this function returns empty.
+ *
+ * @param {string} key
+ * Environment variable.
+ *
+ * @return {Iterable.<string, (string|boolean|number)>
+ */
+ static *from(key) {
+ if (!env.exists(key)) {
+ return;
+ }
+
+ let prefs;
+ try {
+ prefs = JSON.parse(env.get(key));
+ } catch (e) {
+ throw new TypeError(`Unable to parse prefs from ${key}`, e);
+ }
+
+ for (let prefName of Object.keys(prefs)) {
+ yield [prefName, prefs[prefName]];
+ }
+ }
+}
+
+this.Branch = Branch;
+this.EnvironmentPrefs = EnvironmentPrefs;
+
+// There is a future potential of exposing this as Marionette.prefs.port
+// if we introduce a Marionette.jsm module.
+this.MarionettePrefs = new MarionetteBranch();
diff --git a/testing/marionette/print.js b/testing/marionette/print.js
new file mode 100644
index 0000000000..72f98ed77e
--- /dev/null
+++ b/testing/marionette/print.js
@@ -0,0 +1,129 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["print"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ clearInterval: "resource://gre/modules/Timer.jsm",
+ OS: "resource://gre/modules/osfile.jsm",
+ setInterval: "resource://gre/modules/Timer.jsm",
+
+ assert: "chrome://marionette/content/assert.js",
+ Log: "chrome://marionette/content/log.js",
+ pprint: "chrome://marionette/content/format.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+
+this.print = {
+ maxScaleValue: 2.0,
+ minScaleValue: 0.1,
+ letterPaperSizeCm: {
+ width: 21.59,
+ height: 27.94,
+ },
+};
+
+print.addDefaultSettings = function(settings) {
+ const {
+ landscape = false,
+ margin = {
+ top: 1,
+ bottom: 1,
+ left: 1,
+ right: 1,
+ },
+ page = print.letterPaperSizeCm,
+ shrinkToFit = true,
+ printBackground = false,
+ scale = 1.0,
+ } = settings;
+ return { landscape, margin, page, shrinkToFit, printBackground, scale };
+};
+
+function getPrintSettings(settings, filePath) {
+ const psService = Cc["@mozilla.org/gfx/printsettings-service;1"].getService(
+ Ci.nsIPrintSettingsService
+ );
+
+ let cmToInches = cm => cm / 2.54;
+ const printSettings = psService.newPrintSettings;
+ printSettings.isInitializedFromPrinter = true;
+ printSettings.isInitializedFromPrefs = true;
+ printSettings.outputFormat = Ci.nsIPrintSettings.kOutputFormatPDF;
+ printSettings.printerName = "marionette";
+ printSettings.printSilent = true;
+ printSettings.printToFile = true;
+ printSettings.showPrintProgress = false;
+ printSettings.toFileName = filePath;
+
+ // Setting the paperSizeUnit to kPaperSizeMillimeters doesn't work on mac
+ printSettings.paperSizeUnit = Ci.nsIPrintSettings.kPaperSizeInches;
+ printSettings.paperWidth = cmToInches(settings.page.width);
+ printSettings.paperHeight = cmToInches(settings.page.height);
+
+ printSettings.marginBottom = cmToInches(settings.margin.bottom);
+ printSettings.marginLeft = cmToInches(settings.margin.left);
+ printSettings.marginRight = cmToInches(settings.margin.right);
+ printSettings.marginTop = cmToInches(settings.margin.top);
+
+ printSettings.printBGColors = settings.printBackground;
+ printSettings.printBGImages = settings.printBackground;
+ printSettings.scaling = settings.scale;
+ printSettings.shrinkToFit = settings.shrinkToFit;
+
+ printSettings.headerStrCenter = "";
+ printSettings.headerStrLeft = "";
+ printSettings.headerStrRight = "";
+ printSettings.footerStrCenter = "";
+ printSettings.footerStrLeft = "";
+ printSettings.footerStrRight = "";
+
+ // Override any os-specific unwriteable margins
+ printSettings.unwriteableMarginTop = 0;
+ printSettings.unwriteableMarginLeft = 0;
+ printSettings.unwriteableMarginBottom = 0;
+ printSettings.unwriteableMarginRight = 0;
+
+ if (settings.landscape) {
+ printSettings.orientation = Ci.nsIPrintSettings.kLandscapeOrientation;
+ }
+ return printSettings;
+}
+
+print.printToFile = async function(browser, outerWindowID, settings) {
+ // Create a unique filename for the temporary PDF file
+ const basePath = OS.Path.join(OS.Constants.Path.tmpDir, "marionette.pdf");
+ const { file, path: filePath } = await OS.File.openUnique(basePath);
+ await file.close();
+
+ let printSettings = getPrintSettings(settings, filePath);
+
+ await browser.print(outerWindowID, printSettings);
+
+ // Bug 1603739 - With e10s enabled the promise returned by print() resolves
+ // too early, which means the file hasn't been completely written.
+ await new Promise(resolve => {
+ const DELAY_CHECK_FILE_COMPLETELY_WRITTEN = 100;
+
+ let lastSize = 0;
+ const timerId = setInterval(async () => {
+ const fileInfo = await OS.File.stat(filePath);
+ if (lastSize > 0 && fileInfo.size == lastSize) {
+ clearInterval(timerId);
+ resolve();
+ }
+ lastSize = fileInfo.size;
+ }, DELAY_CHECK_FILE_COMPLETELY_WRITTEN);
+ });
+
+ logger.debug(`PDF output written to ${filePath}`);
+ return filePath;
+};
diff --git a/testing/marionette/proxy.js b/testing/marionette/proxy.js
new file mode 100644
index 0000000000..fd16971081
--- /dev/null
+++ b/testing/marionette/proxy.js
@@ -0,0 +1,340 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["proxy"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ Log: "chrome://marionette/content/log.js",
+ error: "chrome://marionette/content/error.js",
+ evaluate: "chrome://marionette/content/evaluate.js",
+ MessageManagerDestroyedPromise: "chrome://marionette/content/sync.js",
+ modal: "chrome://marionette/content/modal.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "uuidgen",
+ "@mozilla.org/uuid-generator;1",
+ "nsIUUIDGenerator"
+);
+
+// Proxy handler that traps requests to get a property. Will prioritise
+// properties that exist on the object's own prototype.
+const ownPriorityGetterTrap = {
+ get: (obj, prop) => {
+ if (obj.hasOwnProperty(prop)) {
+ return obj[prop];
+ }
+ return (...args) => obj.send(prop, args);
+ },
+};
+
+/** @namespace */
+this.proxy = {};
+
+/**
+ * Creates a transparent interface between the chrome- and content
+ * contexts.
+ *
+ * Calls to this object will be proxied via the message manager to a
+ * content frame script, and responses are returend as promises.
+ *
+ * The argument sequence is serialised and passed as an array, unless it
+ * consists of a single object type that isn't null, in which case it's
+ * passed literally. The latter specialisation is temporary to achieve
+ * backwards compatibility with listener.js.
+ *
+ * @param {function(string, Object, number)} sendAsyncFn
+ * Callback for sending async messages.
+ * @param {function(): browser.Context} browserFn
+ * Closure that returns the current browsing context.
+ */
+proxy.toListener = function(sendAsyncFn, browserFn) {
+ let sender = new proxy.AsyncMessageChannel(sendAsyncFn, browserFn);
+ return new Proxy(sender, ownPriorityGetterTrap);
+};
+
+/**
+ * Provides a transparent interface between chrome- and content space.
+ *
+ * The AsyncMessageChannel is an abstraction of the message manager
+ * IPC architecture allowing calls to be made to any registered message
+ * listener in Marionette. The <code>#send(...)</code> method
+ * returns a promise that gets resolved when the message handler calls
+ * <code>.reply(...)</code>.
+ */
+proxy.AsyncMessageChannel = class {
+ constructor(sendAsyncFn, browserFn) {
+ this.sendAsync = sendAsyncFn;
+ this.browserFn_ = browserFn;
+
+ // TODO(ato): Bug 1242595
+ this.activeMessageId = null;
+
+ this.listeners_ = new Map();
+ this.dialogHandler = null;
+ this.closeHandler = null;
+ }
+
+ get browser() {
+ return this.browserFn_();
+ }
+
+ /**
+ * Send a message across the channel. The name of the function to
+ * call must be registered as a message listener.
+ *
+ * Usage:
+ *
+ * <pre><code>
+ * let channel = new AsyncMessageChannel(
+ * messageManager, sendAsyncMessage.bind(this));
+ * let rv = await channel.send("remoteFunction", ["argument"]);
+ * </code></pre>
+ *
+ * @param {string} name
+ * Function to call in the listener, e.g. for the message listener
+ * <tt>Marionette:foo8</tt>, use <tt>foo</tt>.
+ * @param {Array.<?>=} args
+ * Argument list to pass the function. If args has a single entry
+ * that is an object, we assume it's an old style dispatch, and
+ * the object will passed literally.
+ *
+ * @return {Promise}
+ * A promise that resolves to the result of the command.
+ * @throws {TypeError}
+ * If an unsupported reply type is received.
+ * @throws {WebDriverError}
+ * If an error is returned over the channel.
+ */
+ send(name, args = []) {
+ let uuid = uuidgen.generateUUID().toString();
+ // TODO(ato): Bug 1242595
+ this.activeMessageId = uuid;
+
+ return new Promise((resolve, reject) => {
+ let path = proxy.AsyncMessageChannel.makePath(uuid);
+ let cb = msg => {
+ this.activeMessageId = null;
+ let { data, type } = msg.json;
+
+ switch (msg.json.type) {
+ case proxy.AsyncMessageChannel.ReplyType.Ok:
+ case proxy.AsyncMessageChannel.ReplyType.Value:
+ let payload = evaluate.fromJSON(data);
+ resolve(payload);
+ break;
+
+ case proxy.AsyncMessageChannel.ReplyType.Error:
+ let err = error.WebDriverError.fromJSON(data);
+ reject(err);
+ break;
+
+ default:
+ throw new TypeError(`Unknown async response type: ${type}`);
+ }
+ };
+
+ // The currently selected tab or window is closing. Make sure to wait
+ // until it's fully gone.
+ this.closeHandler = async ({ type, target }) => {
+ logger.trace(`Received DOM event ${type} for ${target}`);
+
+ let messageManager;
+ switch (type) {
+ case "unload":
+ messageManager = this.browser.window.messageManager;
+ break;
+ case "TabClose":
+ messageManager = this.browser.messageManager;
+ break;
+ }
+
+ await new MessageManagerDestroyedPromise(messageManager);
+ this.removeHandlers();
+ resolve();
+ };
+
+ // A modal or tab modal dialog has been opened. To be able to handle it,
+ // the active command has to be aborted. Therefore remove all handlers,
+ // and cancel any ongoing requests in the listener.
+ this.dialogHandler = (action, dialogRef, win) => {
+ // Only care about modals of the currently selected window.
+ if (win !== this.browser.window) {
+ return;
+ }
+
+ this.removeAllListeners_();
+ // TODO(ato): It's not ideal to have listener specific behaviour here:
+ this.sendAsync("cancelRequest");
+
+ this.removeHandlers();
+ resolve();
+ };
+
+ // start content message listener, and install handlers for
+ // modal dialogues, and window/tab state changes.
+ this.addListener_(path, cb);
+ this.addHandlers();
+
+ // sendAsync is GeckoDriver#sendAsync
+ this.sendAsync(name, marshal(args), uuid);
+ });
+ }
+
+ /**
+ * Add all necessary handlers for events and observer notifications.
+ */
+ addHandlers() {
+ this.browser.driver.dialogObserver.add(this.dialogHandler.bind(this));
+
+ // Register event handlers in case the command closes the current
+ // tab or window, and the promise has to be escaped.
+ if (this.browser) {
+ this.browser.window.addEventListener("unload", this.closeHandler);
+
+ if (this.browser.tab) {
+ let node = this.browser.tab.addEventListener
+ ? this.browser.tab
+ : this.browser.contentBrowser;
+ node.addEventListener("TabClose", this.closeHandler);
+ }
+ }
+ }
+
+ /**
+ * Remove all registered handlers for events and observer notifications.
+ */
+ removeHandlers() {
+ this.browser.driver.dialogObserver.remove(this.dialogHandler.bind(this));
+
+ if (this.browser) {
+ this.browser.window.removeEventListener("unload", this.closeHandler);
+
+ if (this.browser.tab) {
+ let node = this.browser.tab.addEventListener
+ ? this.browser.tab
+ : this.browser.contentBrowser;
+ if (node) {
+ node.removeEventListener("TabClose", this.closeHandler);
+ }
+ }
+ }
+ }
+
+ /**
+ * Reply to an asynchronous request.
+ *
+ * Passing an {@link WebDriverError} prototype will cause the receiving
+ * channel to throw this error.
+ *
+ * Usage:
+ *
+ * <pre><code>
+ * let channel = proxy.AsyncMessageChannel(
+ * messageManager, sendAsyncMessage.bind(this));
+ *
+ * // throws in requester:
+ * channel.reply(uuid, new error.WebDriverError());
+ *
+ * // returns with value:
+ * channel.reply(uuid, "hello world!");
+ *
+ * // returns with undefined:
+ * channel.reply(uuid);
+ * </pre></code>
+ *
+ * @param {UUID} uuid
+ * Unique identifier of the request.
+ * @param {*} obj
+ * Message data to reply with.
+ */
+ reply(uuid, obj = undefined) {
+ // TODO(ato): Eventually the uuid will be hidden in the dispatcher
+ // in listener, and passing it explicitly to this function will be
+ // unnecessary.
+ if (typeof obj == "undefined") {
+ this.sendReply_(uuid, proxy.AsyncMessageChannel.ReplyType.Ok);
+ } else if (error.isError(obj)) {
+ let err = error.wrap(obj);
+ this.sendReply_(uuid, proxy.AsyncMessageChannel.ReplyType.Error, err);
+ } else {
+ this.sendReply_(uuid, proxy.AsyncMessageChannel.ReplyType.Value, obj);
+ }
+ }
+
+ sendReply_(uuid, type, payload = undefined) {
+ const path = proxy.AsyncMessageChannel.makePath(uuid);
+
+ let data = evaluate.toJSON(payload);
+ const msg = { type, data };
+
+ // here sendAsync is actually the content frame's
+ // sendAsyncMessage(path, message) global
+ this.sendAsync(path, msg);
+ }
+
+ /**
+ * Produces a path, or a name, for the message listener handler that
+ * listens for a reply.
+ *
+ * @param {UUID} uuid
+ * Unique identifier of the channel request.
+ *
+ * @return {string}
+ * Path to be used for nsIMessageListener.addMessageListener.
+ */
+ static makePath(uuid) {
+ return "Marionette:asyncReply:" + uuid;
+ }
+
+ addListener_(path, callback) {
+ let autoRemover = msg => {
+ this.removeListener_(path);
+ this.removeHandlers();
+ callback(msg);
+ };
+
+ Services.mm.addMessageListener(path, autoRemover);
+ this.listeners_.set(path, autoRemover);
+ }
+
+ removeListener_(path) {
+ if (!this.listeners_.has(path)) {
+ return true;
+ }
+
+ let l = this.listeners_.get(path);
+ Services.mm.removeMessageListener(path, l);
+ return this.listeners_.delete(path);
+ }
+
+ removeAllListeners_() {
+ let ok = true;
+ for (let [p] of this.listeners_) {
+ ok |= this.removeListener_(p);
+ }
+ return ok;
+ }
+};
+proxy.AsyncMessageChannel.ReplyType = {
+ Ok: 0,
+ Value: 1,
+ Error: 2,
+};
+
+function marshal(args) {
+ if (args.length == 1 && typeof args[0] == "object") {
+ return args[0];
+ }
+ return args;
+}
diff --git a/testing/marionette/reftest-content.js b/testing/marionette/reftest-content.js
new file mode 100644
index 0000000000..9ad2a86448
--- /dev/null
+++ b/testing/marionette/reftest-content.js
@@ -0,0 +1,67 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "PrintUtils",
+ "chrome://global/content/printUtils.js"
+);
+
+// This is an implementation of nsIBrowserDOMWindow that handles only opening
+// print browsers, because the "open a new window fallback" is just too slow
+// in some cases and causes timeouts.
+function BrowserDOMWindow() {}
+BrowserDOMWindow.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIBrowserDOMWindow"]),
+
+ _maybeOpen(aOpenWindowInfo, aWhere) {
+ if (aWhere == Ci.nsIBrowserDOMWindow.OPEN_PRINT_BROWSER) {
+ return PrintUtils.startPrintWindow(
+ "window_print",
+ aOpenWindowInfo.parent,
+ { openWindowInfo: aOpenWindowInfo }
+ );
+ }
+ return null;
+ },
+
+ createContentWindow(
+ aURI,
+ aOpenWindowInfo,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal,
+ aCsp
+ ) {
+ return this._maybeOpen(aOpenWindowInfo, aWhere)?.browsingContext;
+ },
+
+ openURI(aURI, aOpenWindowInfo, aWhere, aFlags, aTriggeringPrincipal, aCsp) {
+ return this._maybeOpen(aOpenWindowInfo, aWhere)?.browsingContext;
+ },
+
+ createContentWindowInFrame(aURI, aParams, aWhere, aFlags, aName) {
+ return this._maybeOpen(aParams.openWindowInfo, aWhere);
+ },
+
+ openURIInFrame(aURI, aParams, aWhere, aFlags, aName) {
+ return this._maybeOpen(aParams.openWindowInfo, aWhere);
+ },
+
+ canClose() {
+ return true;
+ },
+
+ get tabCount() {
+ return 1;
+ },
+};
+
+window.browserDOMWindow = new BrowserDOMWindow();
diff --git a/testing/marionette/reftest.js b/testing/marionette/reftest.js
new file mode 100644
index 0000000000..fad350b334
--- /dev/null
+++ b/testing/marionette/reftest.js
@@ -0,0 +1,908 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["reftest"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ E10SUtils: "resource://gre/modules/E10SUtils.jsm",
+ OS: "resource://gre/modules/osfile.jsm",
+ Preferences: "resource://gre/modules/Preferences.jsm",
+
+ assert: "chrome://marionette/content/assert.js",
+ capture: "chrome://marionette/content/capture.js",
+ error: "chrome://marionette/content/error.js",
+ Log: "chrome://marionette/content/log.js",
+ navigate: "chrome://marionette/content/navigate.js",
+ print: "chrome://marionette/content/print.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+const SCREENSHOT_MODE = {
+ unexpected: 0,
+ fail: 1,
+ always: 2,
+};
+
+const STATUS = {
+ PASS: "PASS",
+ FAIL: "FAIL",
+ ERROR: "ERROR",
+ TIMEOUT: "TIMEOUT",
+};
+
+const DEFAULT_REFTEST_WIDTH = 600;
+const DEFAULT_REFTEST_HEIGHT = 600;
+
+// reftest-print page dimensions in cm
+const CM_PER_INCH = 2.54;
+const DEFAULT_PAGE_WIDTH = 5 * CM_PER_INCH;
+const DEFAULT_PAGE_HEIGHT = 3 * CM_PER_INCH;
+const DEFAULT_PAGE_MARGIN = 0.5 * CM_PER_INCH;
+
+// CSS 96 pixels per inch, compared to pdf.js default 72 pixels per inch
+const DEFAULT_PDF_RESOLUTION = 96 / 72;
+
+/**
+ * Implements an fast runner for web-platform-tests format reftests
+ * c.f. http://web-platform-tests.org/writing-tests/reftests.html.
+ *
+ * @namespace
+ */
+this.reftest = {};
+
+/**
+ * @memberof reftest
+ * @class Runner
+ */
+reftest.Runner = class {
+ constructor(driver) {
+ this.driver = driver;
+ this.canvasCache = new DefaultMap(undefined, () => new Map([[null, []]]));
+ this.isPrint = null;
+ this.windowUtils = null;
+ this.lastURL = null;
+ this.useRemoteTabs = Services.appinfo.browserTabsRemoteAutostart;
+ this.useRemoteSubframes = Services.appinfo.fissionAutostart;
+ }
+
+ /**
+ * Setup the required environment for running reftests.
+ *
+ * This will open a non-browser window in which the tests will
+ * be loaded, and set up various caches for the reftest run.
+ *
+ * @param {Object.<Number>} urlCount
+ * Object holding a map of URL: number of times the URL
+ * will be opened during the reftest run, where that's
+ * greater than 1.
+ * @param {string} screenshotMode
+ * String enum representing when screenshots should be taken
+ */
+ setup(urlCount, screenshotMode, isPrint = false) {
+ this.isPrint = isPrint;
+
+ assert.open(this.driver.getBrowsingContext({ top: true }));
+ this.parentWindow = this.driver.getCurrentWindow();
+
+ this.screenshotMode =
+ SCREENSHOT_MODE[screenshotMode] || SCREENSHOT_MODE.unexpected;
+
+ this.urlCount = Object.keys(urlCount || {}).reduce(
+ (map, key) => map.set(key, urlCount[key]),
+ new Map()
+ );
+
+ if (isPrint) {
+ this.loadPdfJs();
+ }
+
+ ChromeUtils.registerWindowActor("MarionetteReftest", {
+ kind: "JSWindowActor",
+ parent: {
+ moduleURI:
+ "chrome://marionette/content/actors/MarionetteReftestParent.jsm",
+ },
+ child: {
+ moduleURI:
+ "chrome://marionette/content/actors/MarionetteReftestChild.jsm",
+ events: {
+ load: { mozSystemGroup: true, capture: true },
+ },
+ },
+ allFrames: true,
+ });
+ }
+
+ /**
+ * Cleanup the environment once the reftest is finished.
+ */
+ teardown() {
+ // Abort the current test if any.
+ this.abort();
+
+ // Unregister the JSWindowActors.
+ ChromeUtils.unregisterWindowActor("MarionetteReftest");
+ }
+
+ async ensureWindow(timeout, width, height) {
+ logger.debug(`ensuring we have a window ${width}x${height}`);
+
+ if (this.reftestWin && !this.reftestWin.closed) {
+ let browserRect = this.reftestWin.gBrowser.getBoundingClientRect();
+ if (browserRect.width === width && browserRect.height === height) {
+ return this.reftestWin;
+ }
+ logger.debug(`current: ${browserRect.width}x${browserRect.height}`);
+ }
+
+ let reftestWin;
+ if (Services.appinfo.OS == "Android") {
+ logger.debug("Using current window");
+ reftestWin = this.parentWindow;
+ await navigate.waitForNavigationCompleted(this.driver, () => {
+ const browsingContext = this.driver.getBrowsingContext();
+ navigate.navigateTo(browsingContext, "about:blank");
+ });
+ } else {
+ logger.debug("Using separate window");
+ if (this.reftestWin && !this.reftestWin.closed) {
+ this.reftestWin.close();
+ }
+ reftestWin = await this.openWindow(width, height);
+ }
+
+ this.setupWindow(reftestWin, width, height);
+ this.windowUtils = reftestWin.windowUtils;
+ this.reftestWin = reftestWin;
+
+ let found = this.driver.findWindow([reftestWin], () => true);
+ await this.driver.setWindowHandle(found, true);
+
+ const url = await this.driver._getCurrentURL();
+ this.lastURL = url.href;
+ logger.debug(`loaded initial URL: ${this.lastURL}`);
+
+ let browserRect = reftestWin.gBrowser.getBoundingClientRect();
+ logger.debug(`new: ${browserRect.width}x${browserRect.height}`);
+
+ return reftestWin;
+ }
+
+ async openWindow(width, height) {
+ assert.positiveInteger(width);
+ assert.positiveInteger(height);
+
+ let reftestWin = this.parentWindow.open(
+ "chrome://marionette/content/reftest.xhtml",
+ "reftest",
+ `chrome,height=${height},width=${width}`
+ );
+
+ await new Promise(resolve => {
+ reftestWin.addEventListener("load", resolve, { once: true });
+ });
+ return reftestWin;
+ }
+
+ setupWindow(reftestWin, width, height) {
+ let browser;
+ if (Services.appinfo.OS === "Android") {
+ browser = reftestWin.document.getElementsByTagName("browser")[0];
+ browser.setAttribute("remote", "false");
+ } else {
+ browser = reftestWin.document.createElementNS(XUL_NS, "xul:browser");
+ browser.permanentKey = {};
+ browser.setAttribute("id", "browser");
+ browser.setAttribute("type", "content");
+ browser.setAttribute("primary", "true");
+ browser.setAttribute("remote", this.useRemoteTabs ? "true" : "false");
+ }
+ // Make sure the browser element is exactly the right size, no matter
+ // what size our window is
+ const windowStyle = `padding: 0px; margin: 0px; border:none;
+min-width: ${width}px; min-height: ${height}px;
+max-width: ${width}px; max-height: ${height}px`;
+ browser.setAttribute("style", windowStyle);
+
+ if (Services.appinfo.OS !== "Android") {
+ let doc = reftestWin.document.documentElement;
+ while (doc.firstChild) {
+ doc.firstChild.remove();
+ }
+ doc.appendChild(browser);
+ }
+ if (reftestWin.BrowserApp) {
+ reftestWin.BrowserApp = browser;
+ }
+ reftestWin.gBrowser = browser;
+ return reftestWin;
+ }
+
+ async abort() {
+ if (this.reftestWin && this.reftestWin != this.parentWindow) {
+ this.driver.closeChromeWindow();
+ let parentHandle = this.driver.findWindow(
+ [this.parentWindow],
+ () => true
+ );
+ await this.driver.setWindowHandle(parentHandle);
+ }
+ this.reftestWin = null;
+ }
+
+ /**
+ * Run a specific reftest.
+ *
+ * The assumed semantics are those of web-platform-tests where
+ * references form a tree and each test must meet all the conditions
+ * to reach one leaf node of the tree in order for the overall test
+ * to pass.
+ *
+ * @param {string} testUrl
+ * URL of the test itself.
+ * @param {Array.<Array>} references
+ * Array representing a tree of references to try.
+ *
+ * Each item in the array represents a single reference node and
+ * has the form <code>[referenceUrl, references, relation]</code>,
+ * where <var>referenceUrl</var> is a string to the URL, relation
+ * is either <code>==</code> or <code>!=</code> depending on the
+ * type of reftest, and references is another array containing
+ * items of the same form, representing further comparisons treated
+ * as AND with the current item. Sibling entries are treated as OR.
+ *
+ * For example with testUrl of T:
+ *
+ * <pre><code>
+ * references = [[A, [[B, [], ==]], ==]]
+ * Must have T == A AND A == B to pass
+ *
+ * references = [[A, [], ==], [B, [], !=]
+ * Must have T == A OR T != B
+ *
+ * references = [[A, [[B, [], ==], [C, [], ==]], ==], [D, [], ]]
+ * Must have (T == A AND A == B) OR (T == A AND A == C) OR (T == D)
+ * </code></pre>
+ *
+ * @param {string} expected
+ * Expected test outcome (e.g. <tt>PASS</tt>, <tt>FAIL</tt>).
+ * @param {number} timeout
+ * Test timeout in milliseconds.
+ *
+ * @return {Object}
+ * Result object with fields status, message and extra.
+ */
+ async run(
+ testUrl,
+ references,
+ expected,
+ timeout,
+ pageRanges = {},
+ width = DEFAULT_REFTEST_WIDTH,
+ height = DEFAULT_REFTEST_HEIGHT
+ ) {
+ let timeoutHandle;
+
+ let timeoutPromise = new Promise(resolve => {
+ timeoutHandle = this.parentWindow.setTimeout(() => {
+ resolve({ status: STATUS.TIMEOUT, message: null, extra: {} });
+ }, timeout);
+ });
+
+ let testRunner = (async () => {
+ let result;
+ try {
+ result = await this.runTest(
+ testUrl,
+ references,
+ expected,
+ timeout,
+ pageRanges,
+ width,
+ height
+ );
+ } catch (e) {
+ result = {
+ status: STATUS.ERROR,
+ message: String(e),
+ stack: e.stack,
+ extra: {},
+ };
+ }
+ return result;
+ })();
+
+ let result = await Promise.race([testRunner, timeoutPromise]);
+ this.parentWindow.clearTimeout(timeoutHandle);
+ if (result.status === STATUS.TIMEOUT) {
+ await this.abort();
+ }
+
+ return result;
+ }
+
+ async runTest(
+ testUrl,
+ references,
+ expected,
+ timeout,
+ pageRanges,
+ width,
+ height
+ ) {
+ let win = await this.ensureWindow(timeout, width, height);
+
+ function toBase64(screenshot) {
+ let dataURL = screenshot.canvas.toDataURL();
+ return dataURL.split(",")[1];
+ }
+
+ let result = {
+ status: STATUS.FAIL,
+ message: "",
+ stack: null,
+ extra: {},
+ };
+
+ let screenshotData = [];
+
+ let stack = [];
+ for (let i = references.length - 1; i >= 0; i--) {
+ let item = references[i];
+ stack.push([testUrl, ...item]);
+ }
+
+ let done = false;
+
+ while (stack.length && !done) {
+ let [lhsUrl, rhsUrl, references, relation, extras = {}] = stack.pop();
+ result.message += `Testing ${lhsUrl} ${relation} ${rhsUrl}\n`;
+
+ let comparison;
+ try {
+ comparison = await this.compareUrls(
+ win,
+ lhsUrl,
+ rhsUrl,
+ relation,
+ timeout,
+ pageRanges,
+ extras
+ );
+ } catch (e) {
+ comparison = {
+ lhs: null,
+ rhs: null,
+ passed: false,
+ error: e,
+ msg: null,
+ };
+ }
+ if (comparison.msg) {
+ result.message += `${comparison.msg}\n`;
+ }
+ if (comparison.error !== null) {
+ result.status = STATUS.ERROR;
+ result.message += String(comparison.error);
+ result.stack = comparison.error.stack;
+ }
+
+ function recordScreenshot() {
+ let encodedLHS = comparison.lhs ? toBase64(comparison.lhs) : "";
+ let encodedRHS = comparison.rhs ? toBase64(comparison.rhs) : "";
+ screenshotData.push([
+ { url: lhsUrl, screenshot: encodedLHS },
+ relation,
+ { url: rhsUrl, screenshot: encodedRHS },
+ ]);
+ }
+
+ if (this.screenshotMode === SCREENSHOT_MODE.always) {
+ recordScreenshot();
+ }
+
+ if (comparison.passed) {
+ if (references.length) {
+ for (let i = references.length - 1; i >= 0; i--) {
+ let item = references[i];
+ stack.push([rhsUrl, ...item]);
+ }
+ } else {
+ // Reached a leaf node so all of one reference chain passed
+ result.status = STATUS.PASS;
+ if (
+ this.screenshotMode <= SCREENSHOT_MODE.fail &&
+ expected != result.status
+ ) {
+ recordScreenshot();
+ }
+ done = true;
+ }
+ } else if (!stack.length || result.status == STATUS.ERROR) {
+ // If we don't have any alternatives to try then this will be
+ // the last iteration, so save the failing screenshots if required.
+ let isFail = this.screenshotMode === SCREENSHOT_MODE.fail;
+ let isUnexpected = this.screenshotMode === SCREENSHOT_MODE.unexpected;
+ if (isFail || (isUnexpected && expected != result.status)) {
+ recordScreenshot();
+ }
+ }
+
+ // Return any reusable canvases to the pool
+ let cacheKey = width + "x" + height;
+ let canvasPool = this.canvasCache.get(cacheKey).get(null);
+ [comparison.lhs, comparison.rhs].map(screenshot => {
+ if (screenshot !== null && screenshot.reuseCanvas) {
+ canvasPool.push(screenshot.canvas);
+ }
+ });
+ logger.debug(
+ `Canvas pool (${cacheKey}) is of length ${canvasPool.length}`
+ );
+ }
+
+ if (screenshotData.length) {
+ // For now the tbpl formatter only accepts one screenshot, so just
+ // return the last one we took.
+ let lastScreenshot = screenshotData[screenshotData.length - 1];
+ // eslint-disable-next-line camelcase
+ result.extra.reftest_screenshots = lastScreenshot;
+ }
+
+ return result;
+ }
+
+ async compareUrls(
+ win,
+ lhsUrl,
+ rhsUrl,
+ relation,
+ timeout,
+ pageRanges,
+ extras
+ ) {
+ logger.info(`Testing ${lhsUrl} ${relation} ${rhsUrl}`);
+
+ if (relation !== "==" && relation != "!=") {
+ throw new error.InvalidArgumentError(
+ "Reftest operator should be '==' or '!='"
+ );
+ }
+
+ let lhsIter, lhsCount, rhsIter, rhsCount;
+ if (!this.isPrint) {
+ // Take the reference screenshot first so that if we pause
+ // we see the test rendering
+ rhsIter = [await this.screenshot(win, rhsUrl, timeout)].values();
+ lhsIter = [await this.screenshot(win, lhsUrl, timeout)].values();
+ lhsCount = rhsCount = 1;
+ } else {
+ [rhsIter, rhsCount] = await this.screenshotPaginated(
+ win,
+ rhsUrl,
+ timeout,
+ pageRanges
+ );
+ [lhsIter, lhsCount] = await this.screenshotPaginated(
+ win,
+ lhsUrl,
+ timeout,
+ pageRanges
+ );
+ }
+
+ let passed = null;
+ let error = null;
+ let pixelsDifferent = null;
+ let maxDifferences = {};
+ let msg = null;
+
+ if (lhsCount != rhsCount) {
+ passed = false;
+ msg = `Got different numbers of pages; test has ${lhsCount}, ref has ${rhsCount}`;
+ }
+
+ let lhs = null;
+ let rhs = null;
+ logger.debug(`Comparing ${lhsCount} pages`);
+ if (passed === null) {
+ for (let i = 0; i < lhsCount; i++) {
+ lhs = (await lhsIter.next()).value;
+ rhs = (await rhsIter.next()).value;
+ logger.debug(
+ `lhs canvas size ${lhs.canvas.width}x${lhs.canvas.height}`
+ );
+ logger.debug(
+ `rhs canvas size ${rhs.canvas.width}x${rhs.canvas.height}`
+ );
+ try {
+ pixelsDifferent = this.windowUtils.compareCanvases(
+ lhs.canvas,
+ rhs.canvas,
+ maxDifferences
+ );
+ } catch (e) {
+ error = e;
+ passed = false;
+ break;
+ }
+
+ let areEqual = this.isAcceptableDifference(
+ maxDifferences.value,
+ pixelsDifferent,
+ extras.fuzzy
+ );
+ logger.debug(
+ `Page ${i + 1} maxDifferences: ${maxDifferences.value} ` +
+ `pixelsDifferent: ${pixelsDifferent}`
+ );
+ logger.debug(
+ `Page ${i + 1} ${areEqual ? "compare equal" : "compare unequal"}`
+ );
+ if (!areEqual) {
+ if (relation == "==") {
+ passed = false;
+ msg =
+ `Found ${pixelsDifferent} pixels different, ` +
+ `maximum difference per channel ${maxDifferences.value}`;
+ if (this.isPrint) {
+ msg += ` on page ${i + 1}`;
+ }
+ } else {
+ passed = true;
+ }
+ break;
+ }
+ }
+ }
+
+ // If passed isn't set we got to the end without finding differences
+ if (passed === null) {
+ if (relation == "==") {
+ passed = true;
+ } else {
+ msg = `mismatch reftest has no differences`;
+ passed = false;
+ }
+ }
+ return { lhs, rhs, passed, error, msg };
+ }
+
+ isAcceptableDifference(maxDifference, pixelsDifferent, allowed) {
+ if (!allowed) {
+ logger.info(`No differences allowed`);
+ return pixelsDifferent === 0;
+ }
+ let [allowedDiff, allowedPixels] = allowed;
+ logger.info(
+ `Allowed ${allowedPixels.join("-")} pixels different, ` +
+ `maximum difference per channel ${allowedDiff.join("-")}`
+ );
+ return (
+ (pixelsDifferent === 0 && allowedPixels[0] == 0) ||
+ (maxDifference === 0 && allowedDiff[0] == 0) ||
+ (maxDifference >= allowedDiff[0] &&
+ maxDifference <= allowedDiff[1] &&
+ (pixelsDifferent >= allowedPixels[0] ||
+ pixelsDifferent <= allowedPixels[1]))
+ );
+ }
+
+ ensureFocus(win) {
+ const focusManager = Services.focus;
+ if (focusManager.activeWindow != win) {
+ win.focus();
+ }
+ this.driver.curBrowser.contentBrowser.focus();
+ }
+
+ updateBrowserRemotenessByURL(browser, url) {
+ // We don't use remote tabs on Android.
+ if (Services.appinfo.OS === "Android") {
+ return;
+ }
+ let oa = E10SUtils.predictOriginAttributes({ browser });
+ let remoteType = E10SUtils.getRemoteTypeForURI(
+ url,
+ this.useRemoteTabs,
+ this.useRemoteSubframes,
+ E10SUtils.DEFAULT_REMOTE_TYPE,
+ null,
+ oa
+ );
+
+ // Only re-construct the browser if its remote type needs to change.
+ if (browser.remoteType !== remoteType) {
+ if (remoteType === E10SUtils.NOT_REMOTE) {
+ browser.removeAttribute("remote");
+ browser.removeAttribute("remoteType");
+ } else {
+ browser.setAttribute("remote", "true");
+ browser.setAttribute("remoteType", remoteType);
+ }
+
+ browser.changeRemoteness({ remoteType });
+ browser.construct();
+
+ // XXX: This appears to be working fine as is, should we be reinitializing
+ // something here? If so, what? The listener.js framescript is registered
+ // on the reftest.xhtml chrome window (which shouldn't be changing?), and
+ // driver.js uses the global message manager to listen for messages.
+ }
+ }
+
+ async loadTestUrl(win, url, timeout) {
+ const browsingContext = this.driver.getBrowsingContext({ top: true });
+
+ logger.debug(`Starting load of ${url}`);
+ if (this.lastURL === url) {
+ logger.debug(`Refreshing page`);
+ await navigate.waitForNavigationCompleted(this.driver, () => {
+ navigate.refresh(browsingContext);
+ });
+ } else {
+ // HACK: DocumentLoadListener currently doesn't know how to
+ // process-switch loads in a non-tabbed <browser>. We need to manually
+ // set the browser's remote type in order to ensure that the load
+ // happens in the correct process.
+ //
+ // See bug 1636169.
+ this.updateBrowserRemotenessByURL(win.gBrowser, url);
+ navigate.navigateTo(browsingContext, url);
+
+ this.lastURL = url;
+ }
+
+ this.ensureFocus(win);
+
+ // TODO: Move all the wait logic into the parent process (bug 1669787)
+ let isReftestReady = false;
+ while (!isReftestReady) {
+ // Note: We cannot compare the URL here. Before the navigation is complete
+ // currentWindowGlobal.documentURI.spec will still point to the old URL.
+ const actor = browsingContext.currentWindowGlobal.getActor(
+ "MarionetteReftest"
+ );
+ isReftestReady = await actor.reftestWait(url, this.useRemoteTabs);
+ }
+ }
+
+ async screenshot(win, url, timeout) {
+ // On windows the above doesn't *actually* set the window to be the
+ // reftest size; but *does* set the content area to be the right size;
+ // the window is given some extra borders that aren't explicable from CSS
+ let browserRect = win.gBrowser.getBoundingClientRect();
+ let canvas = null;
+ let remainingCount = this.urlCount.get(url) || 1;
+ let cache = remainingCount > 1;
+ let cacheKey = browserRect.width + "x" + browserRect.height;
+ logger.debug(
+ `screenshot ${url} remainingCount: ` +
+ `${remainingCount} cache: ${cache} cacheKey: ${cacheKey}`
+ );
+ let reuseCanvas = false;
+ let sizedCache = this.canvasCache.get(cacheKey);
+ if (sizedCache.has(url)) {
+ logger.debug(`screenshot ${url} taken from cache`);
+ canvas = sizedCache.get(url);
+ if (!cache) {
+ sizedCache.delete(url);
+ }
+ } else {
+ let canvasPool = sizedCache.get(null);
+ if (canvasPool.length) {
+ logger.debug("reusing canvas from canvas pool");
+ canvas = canvasPool.pop();
+ } else {
+ logger.debug("using new canvas");
+ canvas = null;
+ }
+ reuseCanvas = !cache;
+
+ let ctxInterface = win.CanvasRenderingContext2D;
+ let flags =
+ ctxInterface.DRAWWINDOW_DRAW_CARET |
+ ctxInterface.DRAWWINDOW_DRAW_VIEW |
+ ctxInterface.DRAWWINDOW_USE_WIDGET_LAYERS;
+
+ if (
+ !(
+ 0 <= browserRect.left &&
+ 0 <= browserRect.top &&
+ win.innerWidth >= browserRect.width &&
+ win.innerHeight >= browserRect.height
+ )
+ ) {
+ logger.error(`Invalid window dimensions:
+browserRect.left: ${browserRect.left}
+browserRect.top: ${browserRect.top}
+win.innerWidth: ${win.innerWidth}
+browserRect.width: ${browserRect.width}
+win.innerHeight: ${win.innerHeight}
+browserRect.height: ${browserRect.height}`);
+ throw new Error("Window has incorrect dimensions");
+ }
+
+ url = new URL(url).href; // normalize the URL
+
+ await this.loadTestUrl(win, url, timeout);
+
+ canvas = await capture.canvas(
+ win,
+ win.docShell.browsingContext,
+ 0, // left
+ 0, // top
+ browserRect.width,
+ browserRect.height,
+ { canvas, flags, readback: true }
+ );
+ }
+ if (
+ canvas.width !== browserRect.width ||
+ canvas.height !== browserRect.height
+ ) {
+ logger.warn(
+ `Canvas dimensions changed to ${canvas.width}x${canvas.height}`
+ );
+ reuseCanvas = false;
+ cache = false;
+ }
+ if (cache) {
+ sizedCache.set(url, canvas);
+ }
+ this.urlCount.set(url, remainingCount - 1);
+ return { canvas, reuseCanvas };
+ }
+
+ async screenshotPaginated(win, url, timeout, pageRanges) {
+ url = new URL(url).href; // normalize the URL
+ await this.loadTestUrl(win, url, timeout);
+
+ const [width, height] = [DEFAULT_PAGE_WIDTH, DEFAULT_PAGE_HEIGHT];
+ const margin = DEFAULT_PAGE_MARGIN;
+ const settings = print.addDefaultSettings({
+ page: {
+ width,
+ height,
+ },
+ margin: {
+ left: margin,
+ right: margin,
+ top: margin,
+ bottom: margin,
+ },
+ shrinkToFit: false,
+ printBackground: true,
+ });
+
+ const filePath = await print.printToFile(
+ win.gBrowser.frameLoader,
+ win.gBrowser.outerWindowID,
+ settings
+ );
+
+ const fp = await OS.File.open(filePath, { read: true });
+ try {
+ const pdf = await this.loadPdf(url, fp);
+ let pages = this.getPages(pageRanges, url, pdf.numPages);
+ return [this.renderPages(pdf, pages), pages.size];
+ } finally {
+ fp.close();
+ await OS.File.remove(filePath);
+ }
+ }
+
+ async loadPdfJs() {
+ // Ensure pdf.js is loaded in the opener window
+ await new Promise((resolve, reject) => {
+ const doc = this.parentWindow.document;
+ const script = doc.createElement("script");
+ script.src = "resource://pdf.js/build/pdf.js";
+ script.onload = resolve;
+ script.onerror = () => reject(new Error("pdfjs load failed"));
+ doc.documentElement.appendChild(script);
+ });
+ this.parentWindow.pdfjsLib.GlobalWorkerOptions.workerSrc =
+ "resource://pdf.js/build/pdf.worker.js";
+ }
+
+ async loadPdf(url, fp) {
+ const data = await fp.read();
+ return this.parentWindow.pdfjsLib.getDocument({ data }).promise;
+ }
+
+ async *renderPages(pdf, pages) {
+ let canvas = null;
+ for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber++) {
+ if (!pages.has(pageNumber)) {
+ logger.info(`Skipping page ${pageNumber}/${pdf.numPages}`);
+ continue;
+ }
+ logger.info(`Rendering page ${pageNumber}/${pdf.numPages}`);
+ let page = await pdf.getPage(pageNumber);
+ let viewport = page.getViewport({ scale: DEFAULT_PDF_RESOLUTION });
+ // Prepare canvas using PDF page dimensions
+ if (canvas === null) {
+ canvas = this.parentWindow.document.createElementNS(XHTML_NS, "canvas");
+ canvas.height = viewport.height;
+ canvas.width = viewport.width;
+ }
+
+ // Render PDF page into canvas context
+ let context = canvas.getContext("2d");
+ let renderContext = {
+ canvasContext: context,
+ viewport,
+ };
+ await page.render(renderContext).promise;
+ yield { canvas, reuseCanvas: false };
+ }
+ }
+
+ getPages(pageRanges, url, totalPages) {
+ // Extract test id from URL without parsing
+ let afterHost = url.slice(url.indexOf(":") + 3);
+ afterHost = afterHost.slice(afterHost.indexOf("/"));
+ const ranges = pageRanges[afterHost];
+ let rv = new Set();
+
+ if (!ranges) {
+ for (let i = 1; i <= totalPages; i++) {
+ rv.add(i);
+ }
+ return rv;
+ }
+
+ for (let rangePart of ranges) {
+ if (rangePart.length === 1) {
+ rv.add(rangePart[0]);
+ } else {
+ if (rangePart.length !== 2) {
+ throw new Error(
+ `Page ranges must be <int> or <int> '-' <int>, got ${rangePart}`
+ );
+ }
+ let [lower, upper] = rangePart;
+ if (lower === null) {
+ lower = 1;
+ }
+ if (upper === null) {
+ upper = totalPages;
+ }
+ for (let i = lower; i <= upper; i++) {
+ rv.add(i);
+ }
+ }
+ }
+ return rv;
+ }
+};
+
+class DefaultMap extends Map {
+ constructor(iterable, defaultFactory) {
+ super(iterable);
+ this.defaultFactory = defaultFactory;
+ }
+
+ get(key) {
+ if (this.has(key)) {
+ return super.get(key);
+ }
+
+ let v = this.defaultFactory();
+ this.set(key, v);
+ return v;
+ }
+}
diff --git a/testing/marionette/reftest.xhtml b/testing/marionette/reftest.xhtml
new file mode 100644
index 0000000000..7135ce2862
--- /dev/null
+++ b/testing/marionette/reftest.xhtml
@@ -0,0 +1,6 @@
+<window id="reftest"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ hidechrome="true"
+ style="background-color:white; overflow:hidden">
+ <script src="reftest-content.js"></script>
+</window>
diff --git a/testing/marionette/server.js b/testing/marionette/server.js
new file mode 100644
index 0000000000..f994177829
--- /dev/null
+++ b/testing/marionette/server.js
@@ -0,0 +1,410 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["TCPConnection", "TCPListener"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ OS: "resource://gre/modules/osfile.jsm",
+
+ assert: "chrome://marionette/content/assert.js",
+ Command: "chrome://marionette/content/message.js",
+ DebuggerTransport: "chrome://marionette/content/transport.js",
+ error: "chrome://marionette/content/error.js",
+ GeckoDriver: "chrome://marionette/content/driver.js",
+ Log: "chrome://marionette/content/log.js",
+ MarionettePrefs: "chrome://marionette/content/prefs.js",
+ Message: "chrome://marionette/content/message.js",
+ Response: "chrome://marionette/content/message.js",
+ WebElement: "chrome://marionette/content/element.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+XPCOMUtils.defineLazyGetter(this, "ServerSocket", () => {
+ return Components.Constructor(
+ "@mozilla.org/network/server-socket;1",
+ "nsIServerSocket",
+ "initSpecialConnection"
+ );
+});
+
+const { KeepWhenOffline, LoopbackOnly } = Ci.nsIServerSocket;
+
+/** @namespace */
+this.server = {};
+
+const PROTOCOL_VERSION = 3;
+
+/**
+ * Bootstraps Marionette and handles incoming client connections.
+ *
+ * Starting the Marionette server will open a TCP socket sporting the
+ * debugger transport interface on the provided `port`. For every
+ * new connection, a {@link TCPConnection} is created.
+ */
+class TCPListener {
+ /**
+ * @param {number} port
+ * Port for server to listen to.
+ */
+ constructor(port) {
+ this.port = port;
+ this.socket = null;
+ this.conns = new Set();
+ this.nextConnID = 0;
+ this.alive = false;
+ }
+
+ /**
+ * Function produces a {@link GeckoDriver}.
+ *
+ * Determines the application to initialise the driver with.
+ *
+ * @return {GeckoDriver}
+ * A driver instance.
+ */
+ driverFactory() {
+ MarionettePrefs.contentListener = false;
+ return new GeckoDriver(this);
+ }
+
+ set acceptConnections(value) {
+ if (value) {
+ if (!this.socket) {
+ try {
+ const flags = KeepWhenOffline | LoopbackOnly;
+ const backlog = 1;
+ this.socket = new ServerSocket(this.port, flags, backlog);
+ } catch (e) {
+ throw new Error(`Could not bind to port ${this.port} (${e.name})`);
+ }
+
+ this.port = this.socket.port;
+
+ this.socket.asyncListen(this);
+ logger.info(`Listening on port ${this.port}`);
+ }
+ } else if (this.socket) {
+ // Note that closing the server socket will not close currently active
+ // connections.
+ this.socket.close();
+ this.socket = null;
+ logger.info(`Stopped listening on port ${this.port}`);
+ }
+ }
+
+ /**
+ * Bind this listener to {@link #port} and start accepting incoming
+ * socket connections on {@link #onSocketAccepted}.
+ *
+ * The marionette.port preference will be populated with the value
+ * of {@link #port}.
+ */
+ start() {
+ if (this.alive) {
+ return;
+ }
+
+ // Start socket server and listening for connection attempts
+ this.acceptConnections = true;
+ MarionettePrefs.port = this.port;
+ this.alive = true;
+ }
+
+ stop() {
+ if (!this.alive) {
+ return;
+ }
+
+ // Shutdown server socket, and no longer listen for new connections
+ this.acceptConnections = false;
+ this.alive = false;
+ }
+
+ onSocketAccepted(serverSocket, clientSocket) {
+ let input = clientSocket.openInputStream(0, 0, 0);
+ let output = clientSocket.openOutputStream(0, 0, 0);
+ let transport = new DebuggerTransport(input, output);
+
+ let conn = new TCPConnection(
+ this.nextConnID++,
+ transport,
+ this.driverFactory.bind(this)
+ );
+ conn.onclose = this.onConnectionClosed.bind(this);
+ this.conns.add(conn);
+
+ logger.debug(
+ `Accepted connection ${conn.id} ` +
+ `from ${clientSocket.host}:${clientSocket.port}`
+ );
+ conn.sayHello();
+ transport.ready();
+ }
+
+ onConnectionClosed(conn) {
+ logger.debug(`Closed connection ${conn.id}`);
+ this.conns.delete(conn);
+ }
+}
+this.TCPListener = TCPListener;
+
+/**
+ * Marionette client connection.
+ *
+ * Dispatches packets received to their correct service destinations
+ * and sends back the service endpoint's return values.
+ *
+ * @param {number} connID
+ * Unique identifier of the connection this dispatcher should handle.
+ * @param {DebuggerTransport} transport
+ * Debugger transport connection to the client.
+ * @param {function(): GeckoDriver} driverFactory
+ * Factory function that produces a {@link GeckoDriver}.
+ */
+class TCPConnection {
+ constructor(connID, transport, driverFactory) {
+ this.id = connID;
+ this.conn = transport;
+
+ // transport hooks are TCPConnection#onPacket
+ // and TCPConnection#onClosed
+ this.conn.hooks = this;
+
+ // callback for when connection is closed
+ this.onclose = null;
+
+ // last received/sent message ID
+ this.lastID = 0;
+
+ this.driver = driverFactory();
+ this.driver.init();
+ }
+
+ /**
+ * Debugger transport callback that cleans up
+ * after a connection is closed.
+ */
+ onClosed() {
+ this.driver.deleteSession();
+ this.driver.uninit();
+ if (this.onclose) {
+ this.onclose(this);
+ }
+ }
+
+ /**
+ * Callback that receives data packets from the client.
+ *
+ * If the message is a Response, we look up the command previously
+ * issued to the client and run its callback, if any. In case of
+ * a Command, the corresponding is executed.
+ *
+ * @param {Array.<number, number, ?, ?>} data
+ * A four element array where the elements, in sequence, signifies
+ * message type, message ID, method name or error, and parameters
+ * or result.
+ */
+ onPacket(data) {
+ // unable to determine how to respond
+ if (!Array.isArray(data)) {
+ let e = new TypeError(
+ "Unable to unmarshal packet data: " + JSON.stringify(data)
+ );
+ error.report(e);
+ return;
+ }
+
+ // return immediately with any error trying to unmarshal message
+ let msg;
+ try {
+ msg = Message.fromPacket(data);
+ msg.origin = Message.Origin.Client;
+ this.log_(msg);
+ } catch (e) {
+ let resp = this.createResponse(data[1]);
+ resp.sendError(e);
+ return;
+ }
+
+ // execute new command
+ if (msg instanceof Command) {
+ (async () => {
+ await this.execute(msg);
+ })();
+ } else {
+ logger.fatal("Cannot process messages other than Command");
+ }
+ }
+
+ /**
+ * Executes a Marionette command and sends back a response when it
+ * has finished executing.
+ *
+ * If the command implementation sends the response itself by calling
+ * <code>resp.send()</code>, the response is guaranteed to not be
+ * sent twice.
+ *
+ * Errors thrown in commands are marshaled and sent back, and if they
+ * are not {@link WebDriverError} instances, they are additionally
+ * propagated and reported to {@link Components.utils.reportError}.
+ *
+ * @param {Command} cmd
+ * Command to execute.
+ */
+ async execute(cmd) {
+ let resp = this.createResponse(cmd.id);
+ let sendResponse = () => resp.sendConditionally(resp => !resp.sent);
+ let sendError = resp.sendError.bind(resp);
+
+ await this.despatch(cmd, resp)
+ .then(sendResponse, sendError)
+ .catch(error.report);
+ }
+
+ /**
+ * Despatches command to appropriate Marionette service.
+ *
+ * @param {Command} cmd
+ * Command to run.
+ * @param {Response} resp
+ * Mutable response where the command's return value will be
+ * assigned.
+ *
+ * @throws {Error}
+ * A command's implementation may throw at any time.
+ */
+ async despatch(cmd, resp) {
+ let fn = this.driver.commands[cmd.name];
+ if (typeof fn == "undefined") {
+ throw new error.UnknownCommandError(cmd.name);
+ }
+
+ if (cmd.name != "WebDriver:NewSession") {
+ assert.session(
+ this.driver,
+ "Tried to run command without establishing a connection"
+ );
+ }
+
+ let rv = await fn.bind(this.driver)(cmd);
+
+ if (rv != null) {
+ if (rv instanceof WebElement || typeof rv != "object") {
+ resp.body = { value: rv };
+ } else {
+ resp.body = rv;
+ }
+ }
+ }
+
+ /**
+ * Fail-safe creation of a new instance of {@link Response}.
+ *
+ * @param {number} msgID
+ * Message ID to respond to. If it is not a number, -1 is used.
+ *
+ * @return {Response}
+ * Response to the message with `msgID`.
+ */
+ createResponse(msgID) {
+ if (typeof msgID != "number") {
+ msgID = -1;
+ }
+ return new Response(msgID, this.send.bind(this));
+ }
+
+ sendError(err, cmdID) {
+ let resp = new Response(cmdID, this.send.bind(this));
+ resp.sendError(err);
+ }
+
+ /**
+ * When a client connects we send across a JSON Object defining the
+ * protocol level.
+ *
+ * This is the only message sent by Marionette that does not follow
+ * the regular message format.
+ */
+ sayHello() {
+ let whatHo = {
+ applicationType: "gecko",
+ marionetteProtocol: PROTOCOL_VERSION,
+ };
+ this.sendRaw(whatHo);
+ }
+
+ /**
+ * Delegates message to client based on the provided {@code cmdID}.
+ * The message is sent over the debugger transport socket.
+ *
+ * The command ID is a unique identifier assigned to the client's request
+ * that is used to distinguish the asynchronous responses.
+ *
+ * Whilst responses to commands are synchronous and must be sent in the
+ * correct order.
+ *
+ * @param {Message} msg
+ * The command or response to send.
+ */
+ send(msg) {
+ msg.origin = Message.Origin.Server;
+ if (msg instanceof Response) {
+ this.sendToClient(msg);
+ } else {
+ logger.fatal("Cannot send messages other than Response");
+ }
+ }
+
+ // Low-level methods:
+
+ /**
+ * Send given response to the client over the debugger transport socket.
+ *
+ * @param {Response} resp
+ * The response to send back to the client.
+ */
+ sendToClient(resp) {
+ this.sendMessage(resp);
+ }
+
+ /**
+ * Marshal message to the Marionette message format and send it.
+ *
+ * @param {Message} msg
+ * The message to send.
+ */
+ sendMessage(msg) {
+ this.log_(msg);
+ let payload = msg.toPacket();
+ this.sendRaw(payload);
+ }
+
+ /**
+ * Send the given payload over the debugger transport socket to the
+ * connected client.
+ *
+ * @param {Object.<string, ?>} payload
+ * The payload to ship.
+ */
+ sendRaw(payload) {
+ this.conn.send(payload);
+ }
+
+ log_(msg) {
+ let dir = msg.origin == Message.Origin.Client ? "->" : "<-";
+ logger.debug(`${this.id} ${dir} ${msg}`);
+ }
+
+ toString() {
+ return `[object TCPConnection ${this.id}]`;
+ }
+}
+this.TCPConnection = TCPConnection;
diff --git a/testing/marionette/stream-utils.js b/testing/marionette/stream-utils.js
new file mode 100644
index 0000000000..ea7bdcb82a
--- /dev/null
+++ b/testing/marionette/stream-utils.js
@@ -0,0 +1,261 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["StreamUtils"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ EventEmitter: "resource://gre/modules/EventEmitter.jsm",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "IOUtil",
+ "@mozilla.org/io-util;1",
+ "nsIIOUtil"
+);
+
+XPCOMUtils.defineLazyGetter(this, "ScriptableInputStream", () => {
+ return Components.Constructor(
+ "@mozilla.org/scriptableinputstream;1",
+ "nsIScriptableInputStream",
+ "init"
+ );
+});
+
+const BUFFER_SIZE = 0x8000;
+
+/**
+ * This helper function (and its companion object) are used by bulk
+ * senders and receivers to read and write data in and out of other streams.
+ * Functions that make use of this tool are passed to callers when it is
+ * time to read or write bulk data. It is highly recommended to use these
+ * copier functions instead of the stream directly because the copier
+ * enforces the agreed upon length. Since bulk mode reuses an existing
+ * stream, the sender and receiver must write and read exactly the agreed
+ * upon amount of data, or else the entire transport will be left in a
+ * invalid state. Additionally, other methods of stream copying (such as
+ * NetUtil.asyncCopy) close the streams involved, which would terminate
+ * the debugging transport, and so it is avoided here.
+ *
+ * Overall, this *works*, but clearly the optimal solution would be
+ * able to just use the streams directly. If it were possible to fully
+ * implement nsIInputStream/nsIOutputStream in JS, wrapper streams could
+ * be created to enforce the length and avoid closing, and consumers could
+ * use familiar stream utilities like NetUtil.asyncCopy.
+ *
+ * The function takes two async streams and copies a precise number
+ * of bytes from one to the other. Copying begins immediately, but may
+ * complete at some future time depending on data size. Use the returned
+ * promise to know when it's complete.
+ *
+ * @param {nsIAsyncInputStream} input
+ * Stream to copy from.
+ * @param {nsIAsyncOutputStream} output
+ * Stream to copy to.
+ * @param {number} length
+ * Amount of data that needs to be copied.
+ *
+ * @return {Promise}
+ * Promise is resolved when copying completes or rejected if any
+ * (unexpected) errors occur.
+ */
+function copyStream(input, output, length) {
+ let copier = new StreamCopier(input, output, length);
+ return copier.copy();
+}
+
+/** @class */
+function StreamCopier(input, output, length) {
+ EventEmitter.decorate(this);
+ this._id = StreamCopier._nextId++;
+ this.input = input;
+ // Save off the base output stream, since we know it's async as we've
+ // required
+ this.baseAsyncOutput = output;
+ if (IOUtil.outputStreamIsBuffered(output)) {
+ this.output = output;
+ } else {
+ this.output = Cc[
+ "@mozilla.org/network/buffered-output-stream;1"
+ ].createInstance(Ci.nsIBufferedOutputStream);
+ this.output.init(output, BUFFER_SIZE);
+ }
+ this._length = length;
+ this._amountLeft = length;
+ this._deferred = {
+ promise: new Promise((resolve, reject) => {
+ this._deferred.resolve = resolve;
+ this._deferred.reject = reject;
+ }),
+ };
+
+ this._copy = this._copy.bind(this);
+ this._flush = this._flush.bind(this);
+ this._destroy = this._destroy.bind(this);
+
+ // Copy promise's then method up to this object.
+ //
+ // Allows the copier to offer a promise interface for the simple succeed
+ // or fail scenarios, but also emit events (due to the EventEmitter)
+ // for other states, like progress.
+ this.then = this._deferred.promise.then.bind(this._deferred.promise);
+ this.then(this._destroy, this._destroy);
+
+ // Stream ready callback starts as |_copy|, but may switch to |_flush|
+ // at end if flushing would block the output stream.
+ this._streamReadyCallback = this._copy;
+}
+StreamCopier._nextId = 0;
+
+StreamCopier.prototype = {
+ copy() {
+ // Dispatch to the next tick so that it's possible to attach a progress
+ // event listener, even for extremely fast copies (like when testing).
+ Services.tm.currentThread.dispatch(() => {
+ try {
+ this._copy();
+ } catch (e) {
+ this._deferred.reject(e);
+ }
+ }, 0);
+ return this;
+ },
+
+ _copy() {
+ let bytesAvailable = this.input.available();
+ let amountToCopy = Math.min(bytesAvailable, this._amountLeft);
+ this._debug("Trying to copy: " + amountToCopy);
+
+ let bytesCopied;
+ try {
+ bytesCopied = this.output.writeFrom(this.input, amountToCopy);
+ } catch (e) {
+ if (e.result == Cr.NS_BASE_STREAM_WOULD_BLOCK) {
+ this._debug("Base stream would block, will retry");
+ this._debug("Waiting for output stream");
+ this.baseAsyncOutput.asyncWait(this, 0, 0, Services.tm.currentThread);
+ return;
+ }
+ throw e;
+ }
+
+ this._amountLeft -= bytesCopied;
+ this._debug("Copied: " + bytesCopied + ", Left: " + this._amountLeft);
+ this._emitProgress();
+
+ if (this._amountLeft === 0) {
+ this._debug("Copy done!");
+ this._flush();
+ return;
+ }
+
+ this._debug("Waiting for input stream");
+ this.input.asyncWait(this, 0, 0, Services.tm.currentThread);
+ },
+
+ _emitProgress() {
+ this.emit("progress", {
+ bytesSent: this._length - this._amountLeft,
+ totalBytes: this._length,
+ });
+ },
+
+ _flush() {
+ try {
+ this.output.flush();
+ } catch (e) {
+ if (
+ e.result == Cr.NS_BASE_STREAM_WOULD_BLOCK ||
+ e.result == Cr.NS_ERROR_FAILURE
+ ) {
+ this._debug("Flush would block, will retry");
+ this._streamReadyCallback = this._flush;
+ this._debug("Waiting for output stream");
+ this.baseAsyncOutput.asyncWait(this, 0, 0, Services.tm.currentThread);
+ return;
+ }
+ throw e;
+ }
+ this._deferred.resolve();
+ },
+
+ _destroy() {
+ this._destroy = null;
+ this._copy = null;
+ this._flush = null;
+ this.input = null;
+ this.output = null;
+ },
+
+ // nsIInputStreamCallback
+ onInputStreamReady() {
+ this._streamReadyCallback();
+ },
+
+ // nsIOutputStreamCallback
+ onOutputStreamReady() {
+ this._streamReadyCallback();
+ },
+
+ _debug() {},
+};
+
+/**
+ * Read from a stream, one byte at a time, up to the next
+ * <var>delimiter</var> character, but stopping if we've read |count|
+ * without finding it. Reading also terminates early if there are less
+ * than <var>count</var> bytes available on the stream. In that case,
+ * we only read as many bytes as the stream currently has to offer.
+ *
+ * @param {nsIInputStream} stream
+ * Input stream to read from.
+ * @param {string} delimiter
+ * Character we're trying to find.
+ * @param {number} count
+ * Max number of characters to read while searching.
+ *
+ * @return {string}
+ * Collected data. If the delimiter was found, this string will
+ * end with it.
+ */
+// TODO: This implementation could be removed if bug 984651 is fixed,
+// which provides a native version of the same idea.
+function delimitedRead(stream, delimiter, count) {
+ let scriptableStream;
+ if (stream instanceof Ci.nsIScriptableInputStream) {
+ scriptableStream = stream;
+ } else {
+ scriptableStream = new ScriptableInputStream(stream);
+ }
+
+ let data = "";
+
+ // Don't exceed what's available on the stream
+ count = Math.min(count, stream.available());
+
+ if (count <= 0) {
+ return data;
+ }
+
+ let char;
+ while (char !== delimiter && count > 0) {
+ char = scriptableStream.readBytes(1);
+ count--;
+ data += char;
+ }
+
+ return data;
+}
+
+this.StreamUtils = {
+ copyStream,
+ delimitedRead,
+};
diff --git a/testing/marionette/sync.js b/testing/marionette/sync.js
new file mode 100644
index 0000000000..4b135809c2
--- /dev/null
+++ b/testing/marionette/sync.js
@@ -0,0 +1,650 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "executeSoon",
+ "DebounceCallback",
+ "IdlePromise",
+ "MessageManagerDestroyedPromise",
+ "PollPromise",
+ "Sleep",
+ "TimedPromise",
+ "waitForEvent",
+ "waitForLoadEvent",
+ "waitForMessage",
+ "waitForObserverTopic",
+];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AppConstants: "resource://gre/modules/AppConstants.jsm",
+
+ error: "chrome://marionette/content/error.js",
+ EventDispatcher:
+ "chrome://marionette/content/actors/MarionetteEventsParent.jsm",
+ Log: "chrome://marionette/content/log.js",
+ truncate: "chrome://marionette/content/format.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get());
+
+const { TYPE_ONE_SHOT, TYPE_REPEATING_SLACK } = Ci.nsITimer;
+
+const PROMISE_TIMEOUT = AppConstants.DEBUG ? 4500 : 1500;
+
+/**
+ * Dispatch a function to be executed on the main thread.
+ *
+ * @param {function} func
+ * Function to be executed.
+ */
+function executeSoon(func) {
+ if (typeof func != "function") {
+ throw new TypeError();
+ }
+
+ Services.tm.dispatchToMainThread(func);
+}
+
+/**
+ * Runs a Promise-like function off the main thread until it is resolved
+ * through ``resolve`` or ``rejected`` callbacks. The function is
+ * guaranteed to be run at least once, irregardless of the timeout.
+ *
+ * The ``func`` is evaluated every ``interval`` for as long as its
+ * runtime duration does not exceed ``interval``. Evaluations occur
+ * sequentially, meaning that evaluations of ``func`` are queued if
+ * the runtime evaluation duration of ``func`` is greater than ``interval``.
+ *
+ * ``func`` is given two arguments, ``resolve`` and ``reject``,
+ * of which one must be called for the evaluation to complete.
+ * Calling ``resolve`` with an argument indicates that the expected
+ * wait condition was met and will return the passed value to the
+ * caller. Conversely, calling ``reject`` will evaluate ``func``
+ * again until the ``timeout`` duration has elapsed or ``func`` throws.
+ * The passed value to ``reject`` will also be returned to the caller
+ * once the wait has expired.
+ *
+ * Usage::
+ *
+ * let els = new PollPromise((resolve, reject) => {
+ * let res = document.querySelectorAll("p");
+ * if (res.length > 0) {
+ * resolve(Array.from(res));
+ * } else {
+ * reject([]);
+ * }
+ * }, {timeout: 1000});
+ *
+ * @param {Condition} func
+ * Function to run off the main thread.
+ * @param {number=} [timeout] timeout
+ * Desired timeout if wanted. If 0 or less than the runtime evaluation
+ * time of ``func``, ``func`` is guaranteed to run at least once.
+ * Defaults to using no timeout.
+ * @param {number=} [interval=10] interval
+ * Duration between each poll of ``func`` in milliseconds.
+ * Defaults to 10 milliseconds.
+ *
+ * @return {Promise.<*>}
+ * Yields the value passed to ``func``'s
+ * ``resolve`` or ``reject`` callbacks.
+ *
+ * @throws {*}
+ * If ``func`` throws, its error is propagated.
+ * @throws {TypeError}
+ * If `timeout` or `interval`` are not numbers.
+ * @throws {RangeError}
+ * If `timeout` or `interval` are not unsigned integers.
+ */
+function PollPromise(func, { timeout = null, interval = 10 } = {}) {
+ const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+
+ if (typeof func != "function") {
+ throw new TypeError();
+ }
+ if (timeout != null && typeof timeout != "number") {
+ throw new TypeError();
+ }
+ if (typeof interval != "number") {
+ throw new TypeError();
+ }
+ if (
+ (timeout && (!Number.isInteger(timeout) || timeout < 0)) ||
+ !Number.isInteger(interval) ||
+ interval < 0
+ ) {
+ throw new RangeError();
+ }
+
+ return new Promise((resolve, reject) => {
+ let start, end;
+
+ if (Number.isInteger(timeout)) {
+ start = new Date().getTime();
+ end = start + timeout;
+ }
+
+ let evalFn = () => {
+ new Promise(func)
+ .then(resolve, rejected => {
+ if (error.isError(rejected)) {
+ throw rejected;
+ }
+
+ // return if there is a timeout and set to 0,
+ // allowing |func| to be evaluated at least once
+ if (
+ typeof end != "undefined" &&
+ (start == end || new Date().getTime() >= end)
+ ) {
+ resolve(rejected);
+ }
+ })
+ .catch(reject);
+ };
+
+ // the repeating slack timer waits |interval|
+ // before invoking |evalFn|
+ evalFn();
+
+ timer.init(evalFn, interval, TYPE_REPEATING_SLACK);
+ }).then(
+ res => {
+ timer.cancel();
+ return res;
+ },
+ err => {
+ timer.cancel();
+ throw err;
+ }
+ );
+}
+
+/**
+ * Represents the timed, eventual completion (or failure) of an
+ * asynchronous operation, and its resulting value.
+ *
+ * In contrast to a regular Promise, it times out after ``timeout``.
+ *
+ * @param {Condition} func
+ * Function to run, which will have its ``reject``
+ * callback invoked after the ``timeout`` duration is reached.
+ * It is given two callbacks: ``resolve(value)`` and
+ * ``reject(error)``.
+ * @param {timeout=} timeout
+ * ``condition``'s ``reject`` callback will be called
+ * after this timeout, given in milliseconds.
+ * By default 1500 ms in an optimised build and 4500 ms in
+ * debug builds.
+ * @param {Error=} [throws=TimeoutError] throws
+ * When the ``timeout`` is hit, this error class will be
+ * thrown. If it is null, no error is thrown and the promise is
+ * instead resolved on timeout.
+ *
+ * @return {Promise.<*>}
+ * Timed promise.
+ *
+ * @throws {TypeError}
+ * If `timeout` is not a number.
+ * @throws {RangeError}
+ * If `timeout` is not an unsigned integer.
+ */
+function TimedPromise(
+ fn,
+ { timeout = PROMISE_TIMEOUT, throws = error.TimeoutError } = {}
+) {
+ const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+
+ if (typeof fn != "function") {
+ throw new TypeError();
+ }
+ if (typeof timeout != "number") {
+ throw new TypeError();
+ }
+ if (!Number.isInteger(timeout) || timeout < 0) {
+ throw new RangeError();
+ }
+
+ return new Promise((resolve, reject) => {
+ let trace;
+
+ // Reject only if |throws| is given. Otherwise it is assumed that
+ // the user is OK with the promise timing out.
+ let bail = () => {
+ if (throws !== null) {
+ let err = new throws();
+ reject(err);
+ } else {
+ logger.warn(`TimedPromise timed out after ${timeout} ms`, trace);
+ resolve();
+ }
+ };
+
+ trace = error.stack();
+ timer.initWithCallback({ notify: bail }, timeout, TYPE_ONE_SHOT);
+
+ try {
+ fn(resolve, reject);
+ } catch (e) {
+ reject(e);
+ }
+ }).then(
+ res => {
+ timer.cancel();
+ return res;
+ },
+ err => {
+ timer.cancel();
+ throw err;
+ }
+ );
+}
+
+/**
+ * Pauses for the given duration.
+ *
+ * @param {number} timeout
+ * Duration to wait before fulfilling promise in milliseconds.
+ *
+ * @return {Promise}
+ * Promise that fulfills when the `timeout` is elapsed.
+ *
+ * @throws {TypeError}
+ * If `timeout` is not a number.
+ * @throws {RangeError}
+ * If `timeout` is not an unsigned integer.
+ */
+function Sleep(timeout) {
+ if (typeof timeout != "number") {
+ throw new TypeError();
+ }
+ if (!Number.isInteger(timeout) || timeout < 0) {
+ throw new RangeError();
+ }
+
+ return new Promise(resolve => {
+ const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.init(
+ () => {
+ // Bug 1663880 - Explicitely cancel the timer for now to prevent a hang
+ timer.cancel();
+ resolve();
+ },
+ timeout,
+ TYPE_ONE_SHOT
+ );
+ });
+}
+
+/**
+ * Detects when the specified message manager has been destroyed.
+ *
+ * One can observe the removal and detachment of a content browser
+ * (`<xul:browser>`) or a chrome window by its message manager
+ * disconnecting.
+ *
+ * When a browser is associated with a tab, this is safer than only
+ * relying on the event `TabClose` which signalises the _intent to_
+ * remove a tab and consequently would lead to the destruction of
+ * the content browser and its browser message manager.
+ *
+ * When closing a chrome window it is safer than only relying on
+ * the event 'unload' which signalises the _intent to_ close the
+ * chrome window and consequently would lead to the destruction of
+ * the window and its window message manager.
+ *
+ * @param {MessageListenerManager} messageManager
+ * The message manager to observe for its disconnect state.
+ * Use the browser message manager when closing a content browser,
+ * and the window message manager when closing a chrome window.
+ *
+ * @return {Promise}
+ * A promise that resolves when the message manager has been destroyed.
+ */
+function MessageManagerDestroyedPromise(messageManager) {
+ return new Promise(resolve => {
+ function observe(subject, topic) {
+ logger.trace(`Received observer notification ${topic}`);
+
+ if (subject == messageManager) {
+ Services.obs.removeObserver(this, "message-manager-disconnect");
+ resolve();
+ }
+ }
+
+ Services.obs.addObserver(observe, "message-manager-disconnect");
+ });
+}
+
+/**
+ * Throttle until the main thread is idle and `window` has performed
+ * an animation frame (in that order).
+ *
+ * @param {ChromeWindow} win
+ * Window to request the animation frame from.
+ *
+ * @return Promise
+ */
+function IdlePromise(win) {
+ const animationFramePromise = new Promise(resolve => {
+ executeSoon(() => {
+ win.requestAnimationFrame(resolve);
+ });
+ });
+
+ // Abort if the underlying window gets closed
+ const windowClosedPromise = new PollPromise(resolve => {
+ if (win.closed) {
+ resolve();
+ }
+ });
+
+ return Promise.race([animationFramePromise, windowClosedPromise]);
+}
+
+/**
+ * Wraps a callback function, that, as long as it continues to be
+ * invoked, will not be triggered. The given function will be
+ * called after the timeout duration is reached, after no more
+ * events fire.
+ *
+ * This class implements the {@link EventListener} interface,
+ * which means it can be used interchangably with `addEventHandler`.
+ *
+ * Debouncing events can be useful when dealing with e.g. DOM events
+ * that fire at a high rate. It is generally advisable to avoid
+ * computationally expensive operations such as DOM modifications
+ * under these circumstances.
+ *
+ * One such high frequenecy event is `resize` that can fire multiple
+ * times before the window reaches its final dimensions. In order
+ * to delay an operation until the window has completed resizing,
+ * it is possible to use this technique to only invoke the callback
+ * after the last event has fired::
+ *
+ * let cb = new DebounceCallback(event => {
+ * // fires after the final resize event
+ * console.log("resize", event);
+ * });
+ * window.addEventListener("resize", cb);
+ *
+ * Note that it is not possible to use this synchronisation primitive
+ * with `addEventListener(..., {once: true})`.
+ *
+ * @param {function(Event)} fn
+ * Callback function that is guaranteed to be invoked once only,
+ * after `timeout`.
+ * @param {number=} [timeout = 250] timeout
+ * Time since last event firing, before `fn` will be invoked.
+ */
+class DebounceCallback {
+ constructor(fn, { timeout = 250 } = {}) {
+ if (typeof fn != "function" || typeof timeout != "number") {
+ throw new TypeError();
+ }
+ if (!Number.isInteger(timeout) || timeout < 0) {
+ throw new RangeError();
+ }
+
+ this.fn = fn;
+ this.timeout = timeout;
+ this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ }
+
+ handleEvent(ev) {
+ this.timer.cancel();
+ this.timer.initWithCallback(
+ () => {
+ this.timer.cancel();
+ this.fn(ev);
+ },
+ this.timeout,
+ TYPE_ONE_SHOT
+ );
+ }
+}
+this.DebounceCallback = DebounceCallback;
+
+/**
+ * Wait for an event to be fired on a specified element.
+ *
+ * This method has been duplicated from BrowserTestUtils.jsm.
+ *
+ * Because this function is intended for testing, any error in checkFn
+ * will cause the returned promise to be rejected instead of waiting for
+ * the next event, since this is probably a bug in the test.
+ *
+ * Usage::
+ *
+ * let promiseEvent = waitForEvent(element, "eventName");
+ * // Do some processing here that will cause the event to be fired
+ * // ...
+ * // Now wait until the Promise is fulfilled
+ * let receivedEvent = await promiseEvent;
+ *
+ * The promise resolution/rejection handler for the returned promise is
+ * guaranteed not to be called until the next event tick after the event
+ * listener gets called, so that all other event listeners for the element
+ * are executed before the handler is executed::
+ *
+ * let promiseEvent = waitForEvent(element, "eventName");
+ * // Same event tick here.
+ * await promiseEvent;
+ * // Next event tick here.
+ *
+ * If some code, such like adding yet another event listener, needs to be
+ * executed in the same event tick, use raw addEventListener instead and
+ * place the code inside the event listener::
+ *
+ * element.addEventListener("load", () => {
+ * // Add yet another event listener in the same event tick as the load
+ * // event listener.
+ * p = waitForEvent(element, "ready");
+ * }, { once: true });
+ *
+ * @param {Element} subject
+ * The element that should receive the event.
+ * @param {string} eventName
+ * Name of the event to listen to.
+ * @param {Object=} options
+ * Extra options.
+ * @param {boolean=} options.capture
+ * True to use a capturing listener.
+ * @param {function(Event)=} options.checkFn
+ * Called with the ``Event`` object as argument, should return ``true``
+ * if the event is the expected one, or ``false`` if it should be
+ * ignored and listening should continue. If not specified, the first
+ * event with the specified name resolves the returned promise.
+ * @param {boolean=} options.wantsUntrusted
+ * True to receive synthetic events dispatched by web content.
+ *
+ * @return {Promise.<Event>}
+ * Promise which resolves to the received ``Event`` object, or rejects
+ * in case of a failure.
+ */
+function waitForEvent(
+ subject,
+ eventName,
+ { capture = false, checkFn = null, wantsUntrusted = false } = {}
+) {
+ if (subject == null || !("addEventListener" in subject)) {
+ throw new TypeError();
+ }
+ if (typeof eventName != "string") {
+ throw new TypeError();
+ }
+ if (capture != null && typeof capture != "boolean") {
+ throw new TypeError();
+ }
+ if (checkFn != null && typeof checkFn != "function") {
+ throw new TypeError();
+ }
+ if (wantsUntrusted != null && typeof wantsUntrusted != "boolean") {
+ throw new TypeError();
+ }
+
+ return new Promise((resolve, reject) => {
+ subject.addEventListener(
+ eventName,
+ function listener(event) {
+ logger.trace(`Received DOM event ${event.type} for ${event.target}`);
+ try {
+ if (checkFn && !checkFn(event)) {
+ return;
+ }
+ subject.removeEventListener(eventName, listener, capture);
+ executeSoon(() => resolve(event));
+ } catch (ex) {
+ try {
+ subject.removeEventListener(eventName, listener, capture);
+ } catch (ex2) {
+ // Maybe the provided object does not support removeEventListener.
+ }
+ executeSoon(() => reject(ex));
+ }
+ },
+ capture,
+ wantsUntrusted
+ );
+ });
+}
+
+/**
+ * Wait for a load event to be fired on a specific browsing context.
+ * The supported events are:
+ * - beforeunload
+ * - DOMContentLoaded
+ * - hashchange
+ * - pagehide
+ * - pageshow
+ * - popstate
+ *
+ * @param {string} eventName
+ * The specific load event name to wait for.
+ * @param {function(): BrowsingContext} browsingContextFn
+ * A function that returns the reference to the browsing context for which
+ * the load event should be fired.
+ *
+ * @return {Promise.<Object>}
+ * Promise which resolves when the load event has been fired
+ */
+function waitForLoadEvent(eventName, browsingContextFn) {
+ let onPageLoad;
+ return new Promise(resolve => {
+ onPageLoad = (_, data) => {
+ logger.trace(`Received event ${data.type} for ${data.documentURI}`);
+ if (
+ data.browsingContext === browsingContextFn() &&
+ data.type === eventName
+ ) {
+ EventDispatcher.off("page-load", onPageLoad);
+ resolve(data);
+ }
+ };
+ EventDispatcher.on("page-load", onPageLoad);
+ });
+}
+
+/**
+ * Wait for a message to be fired from a particular message manager.
+ *
+ * This method has been duplicated from BrowserTestUtils.jsm.
+ *
+ * @param {nsIMessageManager} messageManager
+ * The message manager that should be used.
+ * @param {string} messageName
+ * The message to wait for.
+ * @param {Object=} options
+ * Extra options.
+ * @param {function(Message)=} options.checkFn
+ * Called with the ``Message`` object as argument, should return ``true``
+ * if the message is the expected one, or ``false`` if it should be
+ * ignored and listening should continue. If not specified, the first
+ * message with the specified name resolves the returned promise.
+ *
+ * @return {Promise.<Object>}
+ * Promise which resolves to the data property of the received
+ * ``Message``.
+ */
+function waitForMessage(
+ messageManager,
+ messageName,
+ { checkFn = undefined } = {}
+) {
+ if (messageManager == null || !("addMessageListener" in messageManager)) {
+ throw new TypeError();
+ }
+ if (typeof messageName != "string") {
+ throw new TypeError();
+ }
+ if (checkFn && typeof checkFn != "function") {
+ throw new TypeError();
+ }
+
+ return new Promise(resolve => {
+ messageManager.addMessageListener(messageName, function onMessage(msg) {
+ logger.trace(`Received ${messageName} for ${msg.target}`);
+ if (checkFn && !checkFn(msg)) {
+ return;
+ }
+ messageManager.removeMessageListener(messageName, onMessage);
+ resolve(msg.data);
+ });
+ });
+}
+
+/**
+ * Wait for the specified observer topic to be observed.
+ *
+ * This method has been duplicated from TestUtils.jsm.
+ *
+ * Because this function is intended for testing, any error in checkFn
+ * will cause the returned promise to be rejected instead of waiting for
+ * the next notification, since this is probably a bug in the test.
+ *
+ * @param {string} topic
+ * The topic to observe.
+ * @param {Object=} options
+ * Extra options.
+ * @param {function(String,Object)=} options.checkFn
+ * Called with ``subject``, and ``data`` as arguments, should return true
+ * if the notification is the expected one, or false if it should be
+ * ignored and listening should continue. If not specified, the first
+ * notification for the specified topic resolves the returned promise.
+ *
+ * @return {Promise.<Array<String, Object>>}
+ * Promise which resolves to an array of ``subject``, and ``data`` from
+ * the observed notification.
+ */
+function waitForObserverTopic(topic, { checkFn = null } = {}) {
+ if (typeof topic != "string") {
+ throw new TypeError();
+ }
+ if (checkFn != null && typeof checkFn != "function") {
+ throw new TypeError();
+ }
+
+ return new Promise((resolve, reject) => {
+ Services.obs.addObserver(function observer(subject, topic, data) {
+ logger.trace(`Received observer notification ${topic}`);
+ try {
+ if (checkFn && !checkFn(subject, data)) {
+ return;
+ }
+ Services.obs.removeObserver(observer, topic);
+ resolve({ subject, data });
+ } catch (ex) {
+ Services.obs.removeObserver(observer, topic);
+ reject(ex);
+ }
+ }, topic);
+ });
+}
diff --git a/testing/marionette/test/README b/testing/marionette/test/README
new file mode 100644
index 0000000000..9305b92cab
--- /dev/null
+++ b/testing/marionette/test/README
@@ -0,0 +1 @@
+See ../doc/Testing.md \ No newline at end of file
diff --git a/testing/marionette/test/unit/.eslintrc.js b/testing/marionette/test/unit/.eslintrc.js
new file mode 100644
index 0000000000..2ef179ab5e
--- /dev/null
+++ b/testing/marionette/test/unit/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ rules: {
+ camelcase: "off",
+ },
+};
diff --git a/testing/marionette/test/unit/README b/testing/marionette/test/unit/README
new file mode 100644
index 0000000000..06eca782e7
--- /dev/null
+++ b/testing/marionette/test/unit/README
@@ -0,0 +1,16 @@
+To run the tests in this directory, from the top source directory,
+either invoke the test despatcher in mach:
+
+ % ./mach test testing/marionette/test/unit
+
+Or call out the harness specifically:
+
+ % ./mach xpcshell-test testing/marionette/test/unit
+
+The latter gives you the --sequential option which can be useful
+when debugging to prevent tests from running in parallel.
+
+When adding new tests you must make sure they are listed in
+xpcshell.ini, otherwise they will not run on try.
+
+See also ../../doc/Testing.md for more advice on our other types of tests.
diff --git a/testing/marionette/test/unit/test_action.js b/testing/marionette/test/unit/test_action.js
new file mode 100644
index 0000000000..1d515d6382
--- /dev/null
+++ b/testing/marionette/test/unit/test_action.js
@@ -0,0 +1,712 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { action } = ChromeUtils.import("chrome://marionette/content/action.js");
+
+const XHTMLNS = "http://www.w3.org/1999/xhtml";
+
+const domEl = {
+ nodeType: 1,
+ ELEMENT_NODE: 1,
+ namespaceURI: XHTMLNS,
+};
+
+action.inputStateMap = new Map();
+
+add_test(function test_createAction() {
+ Assert.throws(
+ () => new action.Action(),
+ /InvalidArgumentError/,
+ "Missing Action constructor args"
+ );
+ Assert.throws(
+ () => new action.Action(1, 2),
+ /InvalidArgumentError/,
+ "Missing Action constructor args"
+ );
+ Assert.throws(
+ () => new action.Action(1, 2, "sometype"),
+ /Expected string/,
+ "Non-string arguments."
+ );
+ ok(new action.Action("id", "sometype", "sometype"));
+
+ run_next_test();
+});
+
+add_test(function test_defaultPointerParameters() {
+ let defaultParameters = { pointerType: action.PointerType.Mouse };
+ deepEqual(action.PointerParameters.fromJSON(), defaultParameters);
+
+ run_next_test();
+});
+
+add_test(function test_processPointerParameters() {
+ let check = (regex, message, arg) =>
+ checkErrors(regex, action.PointerParameters.fromJSON, [arg], message);
+ let parametersData;
+ for (let d of ["foo", "", "get", "Get"]) {
+ parametersData = { pointerType: d };
+ let message = `parametersData: [pointerType: ${parametersData.pointerType}]`;
+ check(/Unknown pointerType/, message, parametersData);
+ }
+ parametersData.pointerType = "mouse"; // TODO "pen";
+ deepEqual(action.PointerParameters.fromJSON(parametersData), {
+ pointerType: "mouse",
+ }); // TODO action.PointerType.Pen});
+
+ run_next_test();
+});
+
+add_test(function test_processPointerUpDownAction() {
+ let actionItem = { type: "pointerDown" };
+ let actionSequence = { type: "pointer", id: "some_id" };
+ for (let d of [-1, "a"]) {
+ actionItem.button = d;
+ checkErrors(
+ /Expected 'button' \(.*\) to be >= 0/,
+ action.Action.fromJSON,
+ [actionSequence, actionItem],
+ `button: ${actionItem.button}`
+ );
+ }
+ actionItem.button = 5;
+ let act = action.Action.fromJSON(actionSequence, actionItem);
+ equal(act.button, actionItem.button);
+
+ run_next_test();
+});
+
+add_test(function test_validateActionDurationAndCoordinates() {
+ let actionItem = {};
+ let actionSequence = { id: "some_id" };
+ let check = function(type, subtype, message = undefined) {
+ message =
+ message || `duration: ${actionItem.duration}, subtype: ${subtype}`;
+ actionItem.type = subtype;
+ actionSequence.type = type;
+ checkErrors(
+ /Expected '.*' \(.*\) to be >= 0/,
+ action.Action.fromJSON,
+ [actionSequence, actionItem],
+ message
+ );
+ };
+ for (let d of [-1, "a"]) {
+ actionItem.duration = d;
+ check("none", "pause");
+ check("pointer", "pointerMove");
+ }
+ actionItem.duration = 5000;
+ for (let name of ["x", "y"]) {
+ actionItem[name] = "a";
+ actionItem.type = "pointerMove";
+ actionSequence.type = "pointer";
+ checkErrors(
+ /Expected '.*' \(.*\) to be an Integer/,
+ action.Action.fromJSON,
+ [actionSequence, actionItem],
+ `duration: ${actionItem.duration}, subtype: pointerMove`
+ );
+ }
+ run_next_test();
+});
+
+add_test(function test_processPointerMoveActionOriginValidation() {
+ let actionSequence = { type: "pointer", id: "some_id" };
+ let actionItem = { duration: 5000, type: "pointerMove" };
+ for (let d of [-1, { a: "blah" }, []]) {
+ actionItem.origin = d;
+
+ checkErrors(
+ /Expected \'origin\' to be undefined, "viewport", "pointer", or an element/,
+ action.Action.fromJSON,
+ [actionSequence, actionItem],
+ `actionItem.origin: (${getTypeString(d)})`
+ );
+ }
+
+ run_next_test();
+});
+
+add_test(function test_processPointerMoveActionOriginStringValidation() {
+ let actionSequence = { type: "pointer", id: "some_id" };
+ let actionItem = { duration: 5000, type: "pointerMove" };
+ for (let d of ["a", "", "get", "Get"]) {
+ actionItem.origin = d;
+ checkErrors(
+ /Unknown pointer-move origin/,
+ action.Action.fromJSON,
+ [actionSequence, actionItem],
+ `actionItem.origin: ${d}`
+ );
+ }
+
+ run_next_test();
+});
+
+add_test(function test_processPointerMoveActionElementOrigin() {
+ let actionSequence = { type: "pointer", id: "some_id" };
+ let actionItem = { duration: 5000, type: "pointerMove" };
+ actionItem.origin = domEl;
+ let a = action.Action.fromJSON(actionSequence, actionItem);
+ deepEqual(a.origin, actionItem.origin);
+ run_next_test();
+});
+
+add_test(function test_processPointerMoveActionDefaultOrigin() {
+ let actionSequence = { type: "pointer", id: "some_id" };
+ // origin left undefined
+ let actionItem = { duration: 5000, type: "pointerMove" };
+ let a = action.Action.fromJSON(actionSequence, actionItem);
+ deepEqual(a.origin, action.PointerOrigin.Viewport);
+ run_next_test();
+});
+
+add_test(function test_processPointerMoveAction() {
+ let actionSequence = { id: "some_id", type: "pointer" };
+ let actionItems = [
+ {
+ duration: 5000,
+ type: "pointerMove",
+ origin: undefined,
+ x: undefined,
+ y: undefined,
+ },
+ {
+ duration: undefined,
+ type: "pointerMove",
+ origin: domEl,
+ x: undefined,
+ y: undefined,
+ },
+ {
+ duration: 5000,
+ type: "pointerMove",
+ x: 0,
+ y: undefined,
+ origin: undefined,
+ },
+ {
+ duration: 5000,
+ type: "pointerMove",
+ x: 1,
+ y: 2,
+ origin: undefined,
+ },
+ ];
+ for (let expected of actionItems) {
+ let actual = action.Action.fromJSON(actionSequence, expected);
+ ok(actual instanceof action.Action);
+ equal(actual.duration, expected.duration);
+ equal(actual.x, expected.x);
+ equal(actual.y, expected.y);
+
+ let origin = expected.origin;
+ if (typeof origin == "undefined") {
+ origin = action.PointerOrigin.Viewport;
+ }
+ deepEqual(actual.origin, origin);
+ }
+ run_next_test();
+});
+
+add_test(function test_computePointerDestinationViewport() {
+ let act = { type: "pointerMove", x: 100, y: 200, origin: "viewport" };
+ let inputState = new action.InputState.Pointer(action.PointerType.Mouse);
+ // these values should not affect the outcome
+ inputState.x = "99";
+ inputState.y = "10";
+ let target = action.computePointerDestination(act, inputState);
+ equal(act.x, target.x);
+ equal(act.y, target.y);
+
+ run_next_test();
+});
+
+add_test(function test_computePointerDestinationPointer() {
+ let act = { type: "pointerMove", x: 100, y: 200, origin: "pointer" };
+ let inputState = new action.InputState.Pointer(action.PointerType.Mouse);
+ inputState.x = 10;
+ inputState.y = 99;
+ let target = action.computePointerDestination(act, inputState);
+ equal(act.x + inputState.x, target.x);
+ equal(act.y + inputState.y, target.y);
+
+ run_next_test();
+});
+
+add_test(function test_computePointerDestinationElement() {
+ // origin represents a web element
+ // using an object literal instead to test default case in computePointerDestination
+ let act = { type: "pointerMove", x: 100, y: 200, origin: {} };
+ let inputState = new action.InputState.Pointer(action.PointerType.Mouse);
+ let elementCenter = { x: 10, y: 99 };
+ let target = action.computePointerDestination(act, inputState, elementCenter);
+ equal(act.x + elementCenter.x, target.x);
+ equal(act.y + elementCenter.y, target.y);
+
+ Assert.throws(
+ () => action.computePointerDestination(act, inputState, { a: 1 }),
+ /InvalidArgumentError/,
+ "Invalid element center coordinates."
+ );
+
+ Assert.throws(
+ () => action.computePointerDestination(act, inputState, undefined),
+ /InvalidArgumentError/,
+ "Undefined element center coordinates."
+ );
+
+ run_next_test();
+});
+
+add_test(function test_processPointerAction() {
+ let actionSequence = {
+ type: "pointer",
+ id: "some_id",
+ parameters: {
+ pointerType: "mouse", // TODO "touch"
+ },
+ };
+ let actionItems = [
+ {
+ duration: 2000,
+ type: "pause",
+ },
+ {
+ type: "pointerMove",
+ duration: 2000,
+ },
+ {
+ type: "pointerUp",
+ button: 1,
+ },
+ ];
+ for (let expected of actionItems) {
+ let actual = action.Action.fromJSON(actionSequence, expected);
+ equal(actual.type, actionSequence.type);
+ equal(actual.subtype, expected.type);
+ equal(actual.id, actionSequence.id);
+ if (expected.type === "pointerUp") {
+ equal(actual.button, expected.button);
+ } else {
+ equal(actual.duration, expected.duration);
+ }
+ if (expected.type !== "pause") {
+ equal(actual.pointerType, actionSequence.parameters.pointerType);
+ }
+ }
+
+ run_next_test();
+});
+
+add_test(function test_processPauseAction() {
+ let actionItem = { type: "pause", duration: 5000 };
+ let actionSequence = { id: "some_id" };
+ for (let type of ["none", "key", "pointer"]) {
+ actionSequence.type = type;
+ let act = action.Action.fromJSON(actionSequence, actionItem);
+ ok(act instanceof action.Action);
+ equal(act.type, type);
+ equal(act.subtype, actionItem.type);
+ equal(act.id, actionSequence.id);
+ equal(act.duration, actionItem.duration);
+ }
+ actionItem.duration = undefined;
+ let act = action.Action.fromJSON(actionSequence, actionItem);
+ equal(act.duration, actionItem.duration);
+
+ run_next_test();
+});
+
+add_test(function test_processActionSubtypeValidation() {
+ let actionItem = { type: "dancing" };
+ let actionSequence = { id: "some_id" };
+ let check = function(regex) {
+ let message = `type: ${actionSequence.type}, subtype: ${actionItem.type}`;
+ checkErrors(
+ regex,
+ action.Action.fromJSON,
+ [actionSequence, actionItem],
+ message
+ );
+ };
+ for (let type of ["none", "key", "pointer"]) {
+ actionSequence.type = type;
+ check(new RegExp(`Unknown subtype for ${type} action`));
+ }
+ run_next_test();
+});
+
+add_test(function test_processKeyActionUpDown() {
+ let actionSequence = { type: "key", id: "some_id" };
+ let actionItem = { type: "keyDown" };
+
+ for (let v of [-1, undefined, [], ["a"], { length: 1 }, null]) {
+ actionItem.value = v;
+ let message = `actionItem.value: (${getTypeString(v)})`;
+ Assert.throws(
+ () => action.Action.fromJSON(actionSequence, actionItem),
+ /InvalidArgumentError/,
+ message
+ );
+ Assert.throws(
+ () => action.Action.fromJSON(actionSequence, actionItem),
+ /Expected 'value' to be a string that represents single code point/,
+ message
+ );
+ }
+
+ actionItem.value = "\uE004";
+ let act = action.Action.fromJSON(actionSequence, actionItem);
+ ok(act instanceof action.Action);
+ equal(act.type, actionSequence.type);
+ equal(act.subtype, actionItem.type);
+ equal(act.id, actionSequence.id);
+ equal(act.value, actionItem.value);
+
+ run_next_test();
+});
+
+add_test(function test_processInputSourceActionSequenceValidation() {
+ let actionSequence = { type: "swim", id: "some id" };
+ let check = (message, regex) =>
+ checkErrors(regex, action.Sequence.fromJSON, [actionSequence], message);
+ check(`actionSequence.type: ${actionSequence.type}`, /Unknown action type/);
+ action.inputStateMap.clear();
+
+ actionSequence.type = "none";
+ actionSequence.id = -1;
+ check(
+ `actionSequence.id: ${getTypeString(actionSequence.id)}`,
+ /Expected 'id' to be a string/
+ );
+ action.inputStateMap.clear();
+
+ actionSequence.id = undefined;
+ check(
+ `actionSequence.id: ${getTypeString(actionSequence.id)}`,
+ /Expected 'id' to be defined/
+ );
+ action.inputStateMap.clear();
+
+ actionSequence.id = "some_id";
+ actionSequence.actions = -1;
+ check(
+ `actionSequence.actions: ${getTypeString(actionSequence.actions)}`,
+ /Expected 'actionSequence.actions' to be an array/
+ );
+ action.inputStateMap.clear();
+
+ run_next_test();
+});
+
+add_test(function test_processInputSourceActionSequence() {
+ let actionItem = { type: "pause", duration: 5 };
+ let actionSequence = {
+ type: "none",
+ id: "some id",
+ actions: [actionItem],
+ };
+ let expectedAction = new action.Action(
+ actionSequence.id,
+ "none",
+ actionItem.type
+ );
+ expectedAction.duration = actionItem.duration;
+ let actions = action.Sequence.fromJSON(actionSequence);
+ equal(actions.length, 1);
+ deepEqual(actions[0], expectedAction);
+ action.inputStateMap.clear();
+ run_next_test();
+});
+
+add_test(function test_processInputSourceActionSequencePointer() {
+ let actionItem = { type: "pointerDown", button: 1 };
+ let actionSequence = {
+ type: "pointer",
+ id: "9",
+ actions: [actionItem],
+ parameters: {
+ pointerType: "mouse", // TODO "pen"
+ },
+ };
+ let expectedAction = new action.Action(
+ actionSequence.id,
+ actionSequence.type,
+ actionItem.type
+ );
+ expectedAction.pointerType = actionSequence.parameters.pointerType;
+ expectedAction.button = actionItem.button;
+ let actions = action.Sequence.fromJSON(actionSequence);
+ equal(actions.length, 1);
+ deepEqual(actions[0], expectedAction);
+ action.inputStateMap.clear();
+ run_next_test();
+});
+
+add_test(function test_processInputSourceActionSequenceKey() {
+ let actionItem = { type: "keyUp", value: "a" };
+ let actionSequence = {
+ type: "key",
+ id: "9",
+ actions: [actionItem],
+ };
+ let expectedAction = new action.Action(
+ actionSequence.id,
+ actionSequence.type,
+ actionItem.type
+ );
+ expectedAction.value = actionItem.value;
+ let actions = action.Sequence.fromJSON(actionSequence);
+ equal(actions.length, 1);
+ deepEqual(actions[0], expectedAction);
+ action.inputStateMap.clear();
+ run_next_test();
+});
+
+add_test(function test_processInputSourceActionSequenceInputStateMap() {
+ let id = "1";
+ let actionItem = { type: "pause", duration: 5000 };
+ let actionSequence = {
+ type: "key",
+ id,
+ actions: [actionItem],
+ };
+ let wrongInputState = new action.InputState.Null();
+ action.inputStateMap.set(actionSequence.id, wrongInputState);
+ checkErrors(
+ /to be mapped to/,
+ action.Sequence.fromJSON,
+ [actionSequence],
+ `${actionSequence.type} using ${wrongInputState}`
+ );
+ action.inputStateMap.clear();
+ let rightInputState = new action.InputState.Key();
+ action.inputStateMap.set(id, rightInputState);
+ let acts = action.Sequence.fromJSON(actionSequence);
+ equal(acts.length, 1);
+ action.inputStateMap.clear();
+ run_next_test();
+});
+
+add_test(function test_processPointerActionInputStateMap() {
+ let actionItem = { type: "pointerDown" };
+ let id = "1";
+ let parameters = { pointerType: "mouse" };
+ let a = new action.Action(id, "pointer", actionItem.type);
+ let wrongInputState = new action.InputState.Key();
+ action.inputStateMap.set(id, wrongInputState);
+ checkErrors(
+ /to be mapped to InputState whose type is/,
+ action.processPointerAction,
+ [id, parameters, a],
+ `type "pointer" with ${wrongInputState.type} in inputState`
+ );
+ action.inputStateMap.clear();
+
+ // TODO - uncomment once pen is supported
+ // wrongInputState = new action.InputState.Pointer("pen");
+ // action.inputStateMap.set(id, wrongInputState);
+ // checkErrors(
+ // /to be mapped to InputState whose subtype is/, action.processPointerAction,
+ // [id, parameters, a],
+ // `subtype ${parameters.pointerType} with ${wrongInputState.subtype} in inputState`);
+ // action.inputStateMap.clear();
+
+ let rightInputState = new action.InputState.Pointer("mouse");
+ action.inputStateMap.set(id, rightInputState);
+ action.processPointerAction(id, parameters, a);
+ action.inputStateMap.clear();
+ run_next_test();
+});
+
+add_test(function test_createInputState() {
+ for (let kind in action.InputState) {
+ let state;
+ if (kind == "Pointer") {
+ state = new action.InputState[kind]("mouse");
+ } else {
+ state = new action.InputState[kind]();
+ }
+ ok(state);
+ if (kind === "Null") {
+ equal(state.type, "none");
+ } else {
+ equal(state.type, kind.toLowerCase());
+ }
+ }
+ Assert.throws(
+ () => new action.InputState.Pointer(),
+ /InvalidArgumentError/,
+ "Missing InputState.Pointer constructor arg"
+ );
+ Assert.throws(
+ () => new action.InputState.Pointer("foo"),
+ /InvalidArgumentError/,
+ "Invalid InputState.Pointer constructor arg"
+ );
+ run_next_test();
+});
+
+add_test(function test_extractActionChainValidation() {
+ for (let actions of [-1, "a", undefined, null]) {
+ let message = `actions: ${getTypeString(actions)}`;
+ Assert.throws(
+ () => action.Chain.fromJSON(actions),
+ /InvalidArgumentError/,
+ message
+ );
+ Assert.throws(
+ () => action.Chain.fromJSON(actions),
+ /Expected 'actions' to be an array/,
+ message
+ );
+ }
+ run_next_test();
+});
+
+add_test(function test_extractActionChainEmpty() {
+ deepEqual(action.Chain.fromJSON([]), []);
+ run_next_test();
+});
+
+add_test(function test_extractActionChain_oneTickOneInput() {
+ let actionItem = { type: "pause", duration: 5000 };
+ let actionSequence = {
+ type: "none",
+ id: "some id",
+ actions: [actionItem],
+ };
+ let expectedAction = new action.Action(
+ actionSequence.id,
+ "none",
+ actionItem.type
+ );
+ expectedAction.duration = actionItem.duration;
+ let actionsByTick = action.Chain.fromJSON([actionSequence]);
+ equal(1, actionsByTick.length);
+ equal(1, actionsByTick[0].length);
+ deepEqual(actionsByTick, [[expectedAction]]);
+ action.inputStateMap.clear();
+ run_next_test();
+});
+
+add_test(function test_extractActionChain_twoAndThreeTicks() {
+ let mouseActionItems = [
+ {
+ type: "pointerDown",
+ button: 2,
+ },
+ {
+ type: "pointerUp",
+ button: 2,
+ },
+ ];
+ let mouseActionSequence = {
+ type: "pointer",
+ id: "7",
+ actions: mouseActionItems,
+ parameters: {
+ pointerType: "mouse", // TODO "touch"
+ },
+ };
+ let keyActionItems = [
+ {
+ type: "keyDown",
+ value: "a",
+ },
+ {
+ type: "pause",
+ duration: 4,
+ },
+ {
+ type: "keyUp",
+ value: "a",
+ },
+ ];
+ let keyActionSequence = {
+ type: "key",
+ id: "1",
+ actions: keyActionItems,
+ };
+ let actionsByTick = action.Chain.fromJSON([
+ keyActionSequence,
+ mouseActionSequence,
+ ]);
+ // number of ticks is same as longest action sequence
+ equal(keyActionItems.length, actionsByTick.length);
+ equal(2, actionsByTick[0].length);
+ equal(2, actionsByTick[1].length);
+ equal(1, actionsByTick[2].length);
+ let expectedAction = new action.Action(
+ keyActionSequence.id,
+ "key",
+ keyActionItems[2].type
+ );
+ expectedAction.value = keyActionItems[2].value;
+ deepEqual(actionsByTick[2][0], expectedAction);
+ action.inputStateMap.clear();
+
+ // one empty action sequence
+ actionsByTick = action.Chain.fromJSON([
+ keyActionSequence,
+ { type: "none", id: "some", actions: [] },
+ ]);
+ equal(keyActionItems.length, actionsByTick.length);
+ equal(1, actionsByTick[0].length);
+ action.inputStateMap.clear();
+ run_next_test();
+});
+
+add_test(function test_computeTickDuration() {
+ let expected = 8000;
+ let tickActions = [
+ { type: "none", subtype: "pause", duration: 5000 },
+ { type: "key", subtype: "pause", duration: 1000 },
+ { type: "pointer", subtype: "pointerMove", duration: 6000 },
+ // invalid because keyDown should not have duration, so duration should be ignored.
+ { type: "key", subtype: "keyDown", duration: 100000 },
+ { type: "pointer", subtype: "pause", duration: expected },
+ { type: "pointer", subtype: "pointerUp" },
+ ];
+ equal(expected, action.computeTickDuration(tickActions));
+ run_next_test();
+});
+
+add_test(function test_computeTickDuration_empty() {
+ equal(0, action.computeTickDuration([]));
+ run_next_test();
+});
+
+add_test(function test_computeTickDuration_noDurations() {
+ let tickActions = [
+ // invalid because keyDown should not have duration, so duration should be ignored.
+ { type: "key", subtype: "keyDown", duration: 100000 },
+ // undefined duration permitted
+ { type: "none", subtype: "pause" },
+ { type: "pointer", subtype: "pointerMove" },
+ { type: "pointer", subtype: "pointerDown" },
+ { type: "key", subtype: "keyUp" },
+ ];
+
+ equal(0, action.computeTickDuration(tickActions));
+ run_next_test();
+});
+
+// helpers
+function getTypeString(obj) {
+ return Object.prototype.toString.call(obj);
+}
+
+function checkErrors(regex, func, args, message) {
+ if (typeof message == "undefined") {
+ message = `actionFunc: ${func.name}; args: ${args}`;
+ }
+ Assert.throws(() => func.apply(this, args), /InvalidArgumentError/, message);
+ Assert.throws(() => func.apply(this, args), regex, message);
+}
diff --git a/testing/marionette/test/unit/test_actors.js b/testing/marionette/test/unit/test_actors.js
new file mode 100644
index 0000000000..584533f869
--- /dev/null
+++ b/testing/marionette/test/unit/test_actors.js
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ EventDispatcher:
+ "chrome://marionette/content/actors/MarionetteEventsParent.jsm",
+ registerCommandsActor:
+ "chrome://marionette/content/actors/MarionetteCommandsParent.jsm",
+ registerEventsActor:
+ "chrome://marionette/content/actors/MarionetteEventsParent.jsm",
+ unregisterCommandsActor:
+ "chrome://marionette/content/actors/MarionetteCommandsParent.jsm",
+ unregisterEventsActor:
+ "chrome://marionette/content/actors/MarionetteEventsParent.jsm",
+});
+
+registerCleanupFunction(function() {
+ unregisterCommandsActor();
+ unregisterEventsActor();
+});
+
+add_test(function test_commandsActor_register() {
+ registerCommandsActor();
+ unregisterCommandsActor();
+
+ registerCommandsActor();
+ registerCommandsActor();
+ unregisterCommandsActor();
+
+ run_next_test();
+});
+
+add_test(function test_eventsActor_register() {
+ registerEventsActor();
+ unregisterEventsActor();
+
+ registerEventsActor();
+ registerEventsActor();
+ unregisterEventsActor();
+
+ run_next_test();
+});
diff --git a/testing/marionette/test/unit/test_assert.js b/testing/marionette/test/unit/test_assert.js
new file mode 100644
index 0000000000..aa93453139
--- /dev/null
+++ b/testing/marionette/test/unit/test_assert.js
@@ -0,0 +1,207 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+/* eslint-disable no-array-constructor, no-new-object */
+
+const { assert } = ChromeUtils.import("chrome://marionette/content/assert.js");
+const { error } = ChromeUtils.import("chrome://marionette/content/error.js");
+
+add_test(function test_acyclic() {
+ assert.acyclic({});
+
+ Assert.throws(() => {
+ let obj = {};
+ obj.reference = obj;
+ assert.acyclic(obj);
+ }, /JavaScriptError/);
+
+ // custom message
+ let cyclic = {};
+ cyclic.reference = cyclic;
+ Assert.throws(() => assert.acyclic(cyclic, "", RangeError), RangeError);
+ Assert.throws(() => assert.acyclic(cyclic, "foo"), /JavaScriptError: foo/);
+ Assert.throws(
+ () => assert.acyclic(cyclic, "bar", RangeError),
+ /RangeError: bar/
+ );
+
+ run_next_test();
+});
+
+add_test(function test_session() {
+ assert.session({ sessionID: "foo" });
+ for (let typ of [null, undefined, ""]) {
+ Assert.throws(
+ () => assert.session({ sessionId: typ }),
+ /InvalidSessionIDError/
+ );
+ }
+
+ Assert.throws(() => assert.session({ sessionId: null }, "custom"), /custom/);
+
+ run_next_test();
+});
+
+add_test(function test_platforms() {
+ // at least one will fail
+ let raised;
+ for (let fn of [assert.firefox, assert.fennec]) {
+ try {
+ fn();
+ } catch (e) {
+ raised = e;
+ }
+ }
+ ok(raised instanceof error.UnsupportedOperationError);
+
+ run_next_test();
+});
+
+add_test(function test_noUserPrompt() {
+ assert.noUserPrompt(null);
+ assert.noUserPrompt(undefined);
+ Assert.throws(() => assert.noUserPrompt({}), /UnexpectedAlertOpenError/);
+ Assert.throws(() => assert.noUserPrompt({}, "custom"), /custom/);
+
+ run_next_test();
+});
+
+add_test(function test_defined() {
+ assert.defined({});
+ Assert.throws(() => assert.defined(undefined), /InvalidArgumentError/);
+ Assert.throws(() => assert.noUserPrompt({}, "custom"), /custom/);
+
+ run_next_test();
+});
+
+add_test(function test_number() {
+ assert.number(1);
+ assert.number(0);
+ assert.number(-1);
+ assert.number(1.2);
+ for (let i of ["foo", "1", {}, [], NaN, Infinity, undefined]) {
+ Assert.throws(() => assert.number(i), /InvalidArgumentError/);
+ }
+
+ Assert.throws(() => assert.number("foo", "custom"), /custom/);
+
+ run_next_test();
+});
+
+add_test(function test_callable() {
+ assert.callable(function() {});
+ assert.callable(() => {});
+
+ for (let typ of [undefined, "", true, {}, []]) {
+ Assert.throws(() => assert.callable(typ), /InvalidArgumentError/);
+ }
+
+ Assert.throws(() => assert.callable("foo", "custom"), /custom/);
+
+ run_next_test();
+});
+
+add_test(function test_integer() {
+ assert.integer(1);
+ assert.integer(0);
+ assert.integer(-1);
+ Assert.throws(() => assert.integer("foo"), /InvalidArgumentError/);
+ Assert.throws(() => assert.integer(1.2), /InvalidArgumentError/);
+
+ Assert.throws(() => assert.integer("foo", "custom"), /custom/);
+
+ run_next_test();
+});
+
+add_test(function test_positiveInteger() {
+ assert.positiveInteger(1);
+ assert.positiveInteger(0);
+ Assert.throws(() => assert.positiveInteger(-1), /InvalidArgumentError/);
+ Assert.throws(() => assert.positiveInteger("foo"), /InvalidArgumentError/);
+ Assert.throws(() => assert.positiveInteger("foo", "custom"), /custom/);
+
+ run_next_test();
+});
+
+add_test(function test_boolean() {
+ assert.boolean(true);
+ assert.boolean(false);
+ Assert.throws(() => assert.boolean("false"), /InvalidArgumentError/);
+ Assert.throws(() => assert.boolean(undefined), /InvalidArgumentError/);
+ Assert.throws(() => assert.boolean(undefined, "custom"), /custom/);
+
+ run_next_test();
+});
+
+add_test(function test_string() {
+ assert.string("foo");
+ assert.string(`bar`);
+ Assert.throws(() => assert.string(42), /InvalidArgumentError/);
+ Assert.throws(() => assert.string(42, "custom"), /custom/);
+
+ run_next_test();
+});
+
+add_test(function test_open() {
+ assert.open({ currentWindowGlobal: {} });
+
+ for (let typ of [null, undefined, { currentWindowGlobal: null }]) {
+ Assert.throws(() => assert.open(typ), /NoSuchWindowError/);
+ }
+
+ Assert.throws(() => assert.open(null, "custom"), /custom/);
+
+ run_next_test();
+});
+
+add_test(function test_object() {
+ assert.object({});
+ assert.object(new Object());
+ for (let typ of [42, "foo", true, null, undefined]) {
+ Assert.throws(() => assert.object(typ), /InvalidArgumentError/);
+ }
+
+ Assert.throws(() => assert.object(null, "custom"), /custom/);
+
+ run_next_test();
+});
+
+add_test(function test_in() {
+ assert.in("foo", { foo: 42 });
+ for (let typ of [{}, 42, true, null, undefined]) {
+ Assert.throws(() => assert.in("foo", typ), /InvalidArgumentError/);
+ }
+
+ Assert.throws(() => assert.in("foo", { bar: 42 }, "custom"), /custom/);
+
+ run_next_test();
+});
+
+add_test(function test_array() {
+ assert.array([]);
+ assert.array(new Array());
+ Assert.throws(() => assert.array(42), /InvalidArgumentError/);
+ Assert.throws(() => assert.array({}), /InvalidArgumentError/);
+
+ Assert.throws(() => assert.array(42, "custom"), /custom/);
+
+ run_next_test();
+});
+
+add_test(function test_that() {
+ equal(1, assert.that(n => n + 1)(1));
+ Assert.throws(() => assert.that(() => false)(), /InvalidArgumentError/);
+ Assert.throws(() => assert.that(val => val)(false), /InvalidArgumentError/);
+ Assert.throws(
+ () => assert.that(val => val, "foo", error.SessionNotCreatedError)(false),
+ /SessionNotCreatedError/
+ );
+
+ Assert.throws(() => assert.that(() => false, "custom")(), /custom/);
+
+ run_next_test();
+});
+
+/* eslint-enable no-array-constructor, no-new-object */
diff --git a/testing/marionette/test/unit/test_browser.js b/testing/marionette/test/unit/test_browser.js
new file mode 100644
index 0000000000..3f89cd0b1a
--- /dev/null
+++ b/testing/marionette/test/unit/test_browser.js
@@ -0,0 +1,25 @@
+const { Context } = ChromeUtils.import(
+ "chrome://marionette/content/browser.js"
+);
+
+add_test(function test_Context() {
+ ok(Context.hasOwnProperty("Chrome"));
+ ok(Context.hasOwnProperty("Content"));
+ equal(typeof Context.Chrome, "string");
+ equal(typeof Context.Content, "string");
+ equal(Context.Chrome, "chrome");
+ equal(Context.Content, "content");
+
+ run_next_test();
+});
+
+add_test(function test_Context_fromString() {
+ equal(Context.fromString("chrome"), Context.Chrome);
+ equal(Context.fromString("content"), Context.Content);
+
+ for (let typ of ["", "foo", true, 42, [], {}, null, undefined]) {
+ Assert.throws(() => Context.fromString(typ), /TypeError/);
+ }
+
+ run_next_test();
+});
diff --git a/testing/marionette/test/unit/test_capabilities.js b/testing/marionette/test/unit/test_capabilities.js
new file mode 100644
index 0000000000..afc8e75d16
--- /dev/null
+++ b/testing/marionette/test/unit/test_capabilities.js
@@ -0,0 +1,609 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { Preferences } = ChromeUtils.import(
+ "resource://gre/modules/Preferences.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+const { error } = ChromeUtils.import("chrome://marionette/content/error.js");
+const {
+ Capabilities,
+ PageLoadStrategy,
+ Proxy,
+ Timeouts,
+ UnhandledPromptBehavior,
+} = ChromeUtils.import("chrome://marionette/content/capabilities.js");
+
+// FTP protocol handler is needed for ftpProxy tests
+registerCleanupFunction(function() {
+ Preferences.reset("network.ftp.enabled");
+});
+Preferences.set("network.ftp.enabled", true);
+
+add_test(function test_Timeouts_ctor() {
+ let ts = new Timeouts();
+ equal(ts.implicit, 0);
+ equal(ts.pageLoad, 300000);
+ equal(ts.script, 30000);
+
+ run_next_test();
+});
+
+add_test(function test_Timeouts_toString() {
+ equal(new Timeouts().toString(), "[object Timeouts]");
+
+ run_next_test();
+});
+
+add_test(function test_Timeouts_toJSON() {
+ let ts = new Timeouts();
+ deepEqual(ts.toJSON(), { implicit: 0, pageLoad: 300000, script: 30000 });
+
+ run_next_test();
+});
+
+add_test(function test_Timeouts_fromJSON() {
+ let json = {
+ implicit: 0,
+ pageLoad: 2.0,
+ script: Number.MAX_SAFE_INTEGER,
+ };
+ let ts = Timeouts.fromJSON(json);
+ equal(ts.implicit, json.implicit);
+ equal(ts.pageLoad, json.pageLoad);
+ equal(ts.script, json.script);
+
+ run_next_test();
+});
+
+add_test(function test_Timeouts_fromJSON_unrecognised_field() {
+ let json = {
+ sessionId: "foobar",
+ };
+ try {
+ Timeouts.fromJSON(json);
+ } catch (e) {
+ equal(e.name, error.InvalidArgumentError.name);
+ equal(e.message, "Unrecognised timeout: sessionId");
+ }
+
+ run_next_test();
+});
+
+add_test(function test_Timeouts_fromJSON_invalid_types() {
+ for (let value of [null, [], {}, false, "10", 2.5]) {
+ Assert.throws(
+ () => Timeouts.fromJSON({ implicit: value }),
+ /InvalidArgumentError/
+ );
+ }
+
+ run_next_test();
+});
+
+add_test(function test_Timeouts_fromJSON_bounds() {
+ for (let value of [-1, Number.MAX_SAFE_INTEGER + 1]) {
+ Assert.throws(
+ () => Timeouts.fromJSON({ script: value }),
+ /InvalidArgumentError/
+ );
+ }
+
+ run_next_test();
+});
+
+add_test(function test_PageLoadStrategy() {
+ equal(PageLoadStrategy.None, "none");
+ equal(PageLoadStrategy.Eager, "eager");
+ equal(PageLoadStrategy.Normal, "normal");
+
+ run_next_test();
+});
+
+add_test(function test_Proxy_ctor() {
+ let p = new Proxy();
+ let props = [
+ "proxyType",
+ "httpProxy",
+ "sslProxy",
+ "ftpProxy",
+ "socksProxy",
+ "socksVersion",
+ "proxyAutoconfigUrl",
+ ];
+ for (let prop of props) {
+ ok(prop in p, `${prop} in ${JSON.stringify(props)}`);
+ equal(p[prop], null);
+ }
+
+ run_next_test();
+});
+
+add_test(function test_Proxy_init() {
+ let p = new Proxy();
+
+ // no changed made, and 5 (system) is default
+ equal(p.init(), false);
+ equal(Preferences.get("network.proxy.type"), 5);
+
+ // pac
+ p.proxyType = "pac";
+ p.proxyAutoconfigUrl = "http://localhost:1234";
+ ok(p.init());
+
+ equal(Preferences.get("network.proxy.type"), 2);
+ equal(
+ Preferences.get("network.proxy.autoconfig_url"),
+ "http://localhost:1234"
+ );
+
+ // direct
+ p = new Proxy();
+ p.proxyType = "direct";
+ ok(p.init());
+ equal(Preferences.get("network.proxy.type"), 0);
+
+ // autodetect
+ p = new Proxy();
+ p.proxyType = "autodetect";
+ ok(p.init());
+ equal(Preferences.get("network.proxy.type"), 4);
+
+ // system
+ p = new Proxy();
+ p.proxyType = "system";
+ ok(p.init());
+ equal(Preferences.get("network.proxy.type"), 5);
+
+ // manual
+ for (let proxy of ["ftp", "http", "ssl", "socks"]) {
+ p = new Proxy();
+ p.proxyType = "manual";
+ p.noProxy = ["foo", "bar"];
+ p[`${proxy}Proxy`] = "foo";
+ p[`${proxy}ProxyPort`] = 42;
+ if (proxy === "socks") {
+ p[`${proxy}Version`] = 4;
+ }
+
+ ok(p.init());
+ equal(Preferences.get("network.proxy.type"), 1);
+ equal(Preferences.get("network.proxy.no_proxies_on"), "foo, bar");
+ equal(Preferences.get(`network.proxy.${proxy}`), "foo");
+ equal(Preferences.get(`network.proxy.${proxy}_port`), 42);
+ if (proxy === "socks") {
+ equal(Preferences.get(`network.proxy.${proxy}_version`), 4);
+ }
+ }
+
+ // empty no proxy should reset default exclustions
+ p = new Proxy();
+ p.proxyType = "manual";
+ p.noProxy = [];
+ ok(p.init());
+ equal(Preferences.get("network.proxy.no_proxies_on"), "");
+
+ run_next_test();
+});
+
+add_test(function test_Proxy_toString() {
+ equal(new Proxy().toString(), "[object Proxy]");
+
+ run_next_test();
+});
+
+add_test(function test_Proxy_toJSON() {
+ let p = new Proxy();
+ deepEqual(p.toJSON(), {});
+
+ // autoconfig url
+ p = new Proxy();
+ p.proxyType = "pac";
+ p.proxyAutoconfigUrl = "foo";
+ deepEqual(p.toJSON(), { proxyType: "pac", proxyAutoconfigUrl: "foo" });
+
+ // manual proxy
+ p = new Proxy();
+ p.proxyType = "manual";
+ deepEqual(p.toJSON(), { proxyType: "manual" });
+
+ for (let proxy of ["ftpProxy", "httpProxy", "sslProxy", "socksProxy"]) {
+ let expected = { proxyType: "manual" };
+
+ p = new Proxy();
+ p.proxyType = "manual";
+
+ if (proxy == "socksProxy") {
+ p.socksVersion = 5;
+ expected.socksVersion = 5;
+ }
+
+ // without port
+ p[proxy] = "foo";
+ expected[proxy] = "foo";
+ deepEqual(p.toJSON(), expected);
+
+ // with port
+ p[proxy] = "foo";
+ p[`${proxy}Port`] = 0;
+ expected[proxy] = "foo:0";
+ deepEqual(p.toJSON(), expected);
+
+ p[`${proxy}Port`] = 42;
+ expected[proxy] = "foo:42";
+ deepEqual(p.toJSON(), expected);
+
+ // add brackets for IPv6 address as proxy hostname
+ p[proxy] = "2001:db8::1";
+ p[`${proxy}Port`] = 42;
+ expected[proxy] = "foo:42";
+ expected[proxy] = "[2001:db8::1]:42";
+ deepEqual(p.toJSON(), expected);
+ }
+
+ // noProxy: add brackets for IPv6 address
+ p = new Proxy();
+ p.proxyType = "manual";
+ p.noProxy = ["2001:db8::1"];
+ let expected = { proxyType: "manual", noProxy: "[2001:db8::1]" };
+ deepEqual(p.toJSON(), expected);
+
+ run_next_test();
+});
+
+add_test(function test_Proxy_fromJSON() {
+ let p = new Proxy();
+ deepEqual(p, Proxy.fromJSON(undefined));
+ deepEqual(p, Proxy.fromJSON(null));
+
+ for (let typ of [true, 42, "foo", []]) {
+ Assert.throws(() => Proxy.fromJSON(typ), /InvalidArgumentError/);
+ }
+
+ // must contain a valid proxyType
+ Assert.throws(() => Proxy.fromJSON({}), /InvalidArgumentError/);
+ Assert.throws(
+ () => Proxy.fromJSON({ proxyType: "foo" }),
+ /InvalidArgumentError/
+ );
+
+ // autoconfig url
+ for (let url of [true, 42, [], {}]) {
+ Assert.throws(
+ () => Proxy.fromJSON({ proxyType: "pac", proxyAutoconfigUrl: url }),
+ /InvalidArgumentError/
+ );
+ }
+
+ p = new Proxy();
+ p.proxyType = "pac";
+ p.proxyAutoconfigUrl = "foo";
+ deepEqual(p, Proxy.fromJSON({ proxyType: "pac", proxyAutoconfigUrl: "foo" }));
+
+ // manual proxy
+ p = new Proxy();
+ p.proxyType = "manual";
+ deepEqual(p, Proxy.fromJSON({ proxyType: "manual" }));
+
+ for (let proxy of ["httpProxy", "sslProxy", "ftpProxy", "socksProxy"]) {
+ let manual = { proxyType: "manual" };
+
+ // invalid hosts
+ for (let host of [
+ true,
+ 42,
+ [],
+ {},
+ null,
+ "http://foo",
+ "foo:-1",
+ "foo:65536",
+ "foo/test",
+ "foo#42",
+ "foo?foo=bar",
+ "2001:db8::1",
+ ]) {
+ manual[proxy] = host;
+ Assert.throws(() => Proxy.fromJSON(manual), /InvalidArgumentError/);
+ }
+
+ p = new Proxy();
+ p.proxyType = "manual";
+ if (proxy == "socksProxy") {
+ manual.socksVersion = 5;
+ p.socksVersion = 5;
+ }
+
+ let host_map = {
+ "foo:1": { hostname: "foo", port: 1 },
+ "foo:21": { hostname: "foo", port: 21 },
+ "foo:80": { hostname: "foo", port: 80 },
+ "foo:443": { hostname: "foo", port: 443 },
+ "foo:65535": { hostname: "foo", port: 65535 },
+ "127.0.0.1:42": { hostname: "127.0.0.1", port: 42 },
+ "[2001:db8::1]:42": { hostname: "2001:db8::1", port: "42" },
+ };
+
+ // valid proxy hosts with port
+ for (let host in host_map) {
+ manual[proxy] = host;
+
+ p[`${proxy}`] = host_map[host].hostname;
+ p[`${proxy}Port`] = host_map[host].port;
+
+ deepEqual(p, Proxy.fromJSON(manual));
+ }
+
+ // Without a port the default port of the scheme is used
+ for (let host of ["foo", "foo:"]) {
+ manual[proxy] = host;
+
+ // For socks no default port is available
+ p[proxy] = `foo`;
+ if (proxy === "socksProxy") {
+ p[`${proxy}Port`] = null;
+ } else {
+ let default_ports = { ftpProxy: 21, httpProxy: 80, sslProxy: 443 };
+
+ p[`${proxy}Port`] = default_ports[proxy];
+ }
+
+ deepEqual(p, Proxy.fromJSON(manual));
+ }
+ }
+
+ // missing required socks version
+ Assert.throws(
+ () => Proxy.fromJSON({ proxyType: "manual", socksProxy: "foo:1234" }),
+ /InvalidArgumentError/
+ );
+
+ // noProxy: invalid settings
+ for (let noProxy of [true, 42, {}, null, "foo", [true], [42], [{}], [null]]) {
+ Assert.throws(
+ () => Proxy.fromJSON({ proxyType: "manual", noProxy }),
+ /InvalidArgumentError/
+ );
+ }
+
+ // noProxy: valid settings
+ p = new Proxy();
+ p.proxyType = "manual";
+ for (let noProxy of [[], ["foo"], ["foo", "bar"], ["127.0.0.1"]]) {
+ let manual = { proxyType: "manual", noProxy };
+ p.noProxy = noProxy;
+ deepEqual(p, Proxy.fromJSON(manual));
+ }
+
+ // noProxy: IPv6 needs brackets removed
+ p = new Proxy();
+ p.proxyType = "manual";
+ p.noProxy = ["2001:db8::1"];
+ let manual = { proxyType: "manual", noProxy: ["[2001:db8::1]"] };
+ deepEqual(p, Proxy.fromJSON(manual));
+
+ run_next_test();
+});
+
+add_test(function test_UnhandledPromptBehavior() {
+ equal(UnhandledPromptBehavior.Accept, "accept");
+ equal(UnhandledPromptBehavior.AcceptAndNotify, "accept and notify");
+ equal(UnhandledPromptBehavior.Dismiss, "dismiss");
+ equal(UnhandledPromptBehavior.DismissAndNotify, "dismiss and notify");
+ equal(UnhandledPromptBehavior.Ignore, "ignore");
+
+ run_next_test();
+});
+
+add_test(function test_Capabilities_ctor() {
+ let caps = new Capabilities();
+ ok(caps.has("browserName"));
+ ok(caps.has("browserVersion"));
+ ok(caps.has("platformName"));
+ ok(["linux", "mac", "windows", "android"].includes(caps.get("platformName")));
+ ok(caps.has("platformVersion"));
+ equal(PageLoadStrategy.Normal, caps.get("pageLoadStrategy"));
+ equal(false, caps.get("acceptInsecureCerts"));
+ ok(caps.get("timeouts") instanceof Timeouts);
+ ok(caps.get("proxy") instanceof Proxy);
+ equal(caps.get("setWindowRect"), !Services.androidBridge);
+ equal(caps.get("strictFileInteractability"), false);
+
+ ok(caps.has("rotatable"));
+
+ equal(false, caps.get("moz:accessibilityChecks"));
+ ok(caps.has("moz:buildID"));
+ ok(caps.has("moz:debuggerAddress"));
+ ok(caps.has("moz:processID"));
+ ok(caps.has("moz:profile"));
+ equal(false, caps.get("moz:useNonSpecCompliantPointerOrigin"));
+ equal(true, caps.get("moz:webdriverClick"));
+
+ run_next_test();
+});
+
+add_test(function test_Capabilities_toString() {
+ equal("[object Capabilities]", new Capabilities().toString());
+
+ run_next_test();
+});
+
+add_test(function test_Capabilities_toJSON() {
+ let caps = new Capabilities();
+ let json = caps.toJSON();
+
+ equal(caps.get("browserName"), json.browserName);
+ equal(caps.get("browserVersion"), json.browserVersion);
+ equal(caps.get("platformName"), json.platformName);
+ equal(caps.get("platformVersion"), json.platformVersion);
+ equal(caps.get("pageLoadStrategy"), json.pageLoadStrategy);
+ equal(caps.get("acceptInsecureCerts"), json.acceptInsecureCerts);
+ deepEqual(caps.get("timeouts").toJSON(), json.timeouts);
+ equal(undefined, json.proxy);
+ equal(caps.get("setWindowRect"), json.setWindowRect);
+ equal(caps.get("strictFileInteractability"), json.strictFileInteractability);
+
+ equal(caps.get("rotatable"), json.rotatable);
+
+ equal(caps.get("moz:accessibilityChecks"), json["moz:accessibilityChecks"]);
+ equal(caps.get("moz:buildID"), json["moz:buildID"]);
+ equal(caps.get("moz:debuggerAddress"), json["moz:debuggerAddress"]);
+ equal(caps.get("moz:processID"), json["moz:processID"]);
+ equal(caps.get("moz:profile"), json["moz:profile"]);
+ equal(
+ caps.get("moz:useNonSpecCompliantPointerOrigin"),
+ json["moz:useNonSpecCompliantPointerOrigin"]
+ );
+ equal(caps.get("moz:webdriverClick"), json["moz:webdriverClick"]);
+
+ run_next_test();
+});
+
+add_test(function test_Capabilities_fromJSON() {
+ const { fromJSON } = Capabilities;
+
+ // plain
+ for (let typ of [{}, null, undefined]) {
+ ok(fromJSON(typ).has("browserName"));
+ }
+ for (let typ of [true, 42, "foo", []]) {
+ Assert.throws(() => fromJSON(typ), /InvalidArgumentError/);
+ }
+
+ // matching
+ let caps = new Capabilities();
+
+ caps = fromJSON({ acceptInsecureCerts: true });
+ equal(true, caps.get("acceptInsecureCerts"));
+ caps = fromJSON({ acceptInsecureCerts: false });
+ equal(false, caps.get("acceptInsecureCerts"));
+ Assert.throws(
+ () => fromJSON({ acceptInsecureCerts: "foo" }),
+ /InvalidArgumentError/
+ );
+
+ for (let strategy of Object.values(PageLoadStrategy)) {
+ caps = fromJSON({ pageLoadStrategy: strategy });
+ equal(strategy, caps.get("pageLoadStrategy"));
+ }
+ Assert.throws(
+ () => fromJSON({ pageLoadStrategy: "foo" }),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => fromJSON({ pageLoadStrategy: null }),
+ /InvalidArgumentError/
+ );
+
+ let proxyConfig = { proxyType: "manual" };
+ caps = fromJSON({ proxy: proxyConfig });
+ equal("manual", caps.get("proxy").proxyType);
+
+ let timeoutsConfig = { implicit: 123 };
+ caps = fromJSON({ timeouts: timeoutsConfig });
+ equal(123, caps.get("timeouts").implicit);
+
+ if (!Services.androidBridge) {
+ caps = fromJSON({ setWindowRect: true });
+ equal(true, caps.get("setWindowRect"));
+ Assert.throws(
+ () => fromJSON({ setWindowRect: false }),
+ /InvalidArgumentError/
+ );
+ } else {
+ Assert.throws(
+ () => fromJSON({ setWindowRect: true }),
+ /InvalidArgumentError/
+ );
+ }
+
+ caps = fromJSON({ strictFileInteractability: false });
+ equal(false, caps.get("strictFileInteractability"));
+ caps = fromJSON({ strictFileInteractability: true });
+ equal(true, caps.get("strictFileInteractability"));
+
+ caps = fromJSON({ "moz:accessibilityChecks": true });
+ equal(true, caps.get("moz:accessibilityChecks"));
+ caps = fromJSON({ "moz:accessibilityChecks": false });
+ equal(false, caps.get("moz:accessibilityChecks"));
+ Assert.throws(
+ () => fromJSON({ "moz:accessibilityChecks": "foo" }),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => fromJSON({ "moz:accessibilityChecks": 1 }),
+ /InvalidArgumentError/
+ );
+
+ // capability is always populated with null if remote agent is not listening
+ caps = fromJSON({});
+ equal(null, caps.get("moz:debuggerAddress"));
+ caps = fromJSON({ "moz:debuggerAddress": "foo" });
+ equal(null, caps.get("moz:debuggerAddress"));
+ caps = fromJSON({ "moz:debuggerAddress": true });
+ equal(null, caps.get("moz:debuggerAddress"));
+
+ caps = fromJSON({ "moz:useNonSpecCompliantPointerOrigin": false });
+ equal(false, caps.get("moz:useNonSpecCompliantPointerOrigin"));
+ caps = fromJSON({ "moz:useNonSpecCompliantPointerOrigin": true });
+ equal(true, caps.get("moz:useNonSpecCompliantPointerOrigin"));
+ Assert.throws(
+ () => fromJSON({ "moz:useNonSpecCompliantPointerOrigin": "foo" }),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => fromJSON({ "moz:useNonSpecCompliantPointerOrigin": 1 }),
+ /InvalidArgumentError/
+ );
+
+ caps = fromJSON({ "moz:webdriverClick": true });
+ equal(true, caps.get("moz:webdriverClick"));
+ caps = fromJSON({ "moz:webdriverClick": false });
+ equal(false, caps.get("moz:webdriverClick"));
+ Assert.throws(
+ () => fromJSON({ "moz:webdriverClick": "foo" }),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => fromJSON({ "moz:webdriverClick": 1 }),
+ /InvalidArgumentError/
+ );
+
+ run_next_test();
+});
+
+// use Proxy.toJSON to test marshal
+add_test(function test_marshal() {
+ let proxy = new Proxy();
+
+ // drop empty fields
+ deepEqual({}, proxy.toJSON());
+ proxy.proxyType = "manual";
+ deepEqual({ proxyType: "manual" }, proxy.toJSON());
+ proxy.proxyType = null;
+ deepEqual({}, proxy.toJSON());
+ proxy.proxyType = undefined;
+ deepEqual({}, proxy.toJSON());
+
+ // iterate over object literals
+ proxy.proxyType = { foo: "bar" };
+ deepEqual({ proxyType: { foo: "bar" } }, proxy.toJSON());
+
+ // iterate over complex object that implement toJSON
+ proxy.proxyType = new Proxy();
+ deepEqual({}, proxy.toJSON());
+ proxy.proxyType.proxyType = "manual";
+ deepEqual({ proxyType: { proxyType: "manual" } }, proxy.toJSON());
+
+ // drop objects with no entries
+ proxy.proxyType = { foo: {} };
+ deepEqual({}, proxy.toJSON());
+ proxy.proxyType = { foo: new Proxy() };
+ deepEqual({}, proxy.toJSON());
+
+ run_next_test();
+});
diff --git a/testing/marionette/test/unit/test_cookie.js b/testing/marionette/test/unit/test_cookie.js
new file mode 100644
index 0000000000..933b9f8ef8
--- /dev/null
+++ b/testing/marionette/test/unit/test_cookie.js
@@ -0,0 +1,368 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { cookie } = ChromeUtils.import("chrome://marionette/content/cookie.js");
+
+/* eslint-disable mozilla/use-chromeutils-generateqi */
+
+cookie.manager = {
+ cookies: [],
+
+ add(
+ domain,
+ path,
+ name,
+ value,
+ secure,
+ httpOnly,
+ session,
+ expiry,
+ originAttributes,
+ sameSite
+ ) {
+ if (name === "fail") {
+ throw new Error("An error occurred while adding cookie");
+ }
+ let newCookie = {
+ host: domain,
+ path,
+ name,
+ value,
+ isSecure: secure,
+ isHttpOnly: httpOnly,
+ isSession: session,
+ expiry,
+ originAttributes,
+ sameSite,
+ };
+ cookie.manager.cookies.push(newCookie);
+ },
+
+ remove(host, name, path) {
+ for (let i = 0; i < this.cookies.length; ++i) {
+ let candidate = this.cookies[i];
+ if (
+ candidate.host === host &&
+ candidate.name === name &&
+ candidate.path === path
+ ) {
+ return this.cookies.splice(i, 1);
+ }
+ }
+ return false;
+ },
+
+ getCookiesFromHost(host) {
+ let hostCookies = this.cookies.filter(
+ c => c.host === host || c.host === "." + host
+ );
+
+ return hostCookies;
+ },
+};
+
+add_test(function test_fromJSON() {
+ // object
+ for (let invalidType of ["foo", 42, true, [], null, undefined]) {
+ Assert.throws(() => cookie.fromJSON(invalidType), /Expected cookie object/);
+ }
+
+ // name and value
+ for (let invalidType of [42, true, [], {}, null, undefined]) {
+ Assert.throws(
+ () => cookie.fromJSON({ name: invalidType }),
+ /Cookie name must be string/
+ );
+ Assert.throws(
+ () => cookie.fromJSON({ name: "foo", value: invalidType }),
+ /Cookie value must be string/
+ );
+ }
+
+ // domain
+ for (let invalidType of [42, true, [], {}, null]) {
+ let domainTest = {
+ name: "foo",
+ value: "bar",
+ domain: invalidType,
+ };
+ Assert.throws(
+ () => cookie.fromJSON(domainTest),
+ /Cookie domain must be string/
+ );
+ }
+ let domainTest = {
+ name: "foo",
+ value: "bar",
+ domain: "domain",
+ };
+ let parsedCookie = cookie.fromJSON(domainTest);
+ equal(parsedCookie.domain, "domain");
+
+ // path
+ for (let invalidType of [42, true, [], {}, null]) {
+ let pathTest = {
+ name: "foo",
+ value: "bar",
+ path: invalidType,
+ };
+ Assert.throws(
+ () => cookie.fromJSON(pathTest),
+ /Cookie path must be string/
+ );
+ }
+
+ // secure
+ for (let invalidType of ["foo", 42, [], {}, null]) {
+ let secureTest = {
+ name: "foo",
+ value: "bar",
+ secure: invalidType,
+ };
+ Assert.throws(
+ () => cookie.fromJSON(secureTest),
+ /Cookie secure flag must be boolean/
+ );
+ }
+
+ // httpOnly
+ for (let invalidType of ["foo", 42, [], {}, null]) {
+ let httpOnlyTest = {
+ name: "foo",
+ value: "bar",
+ httpOnly: invalidType,
+ };
+ Assert.throws(
+ () => cookie.fromJSON(httpOnlyTest),
+ /Cookie httpOnly flag must be boolean/
+ );
+ }
+
+ // expiry
+ for (let invalidType of [
+ -1,
+ Number.MAX_SAFE_INTEGER + 1,
+ "foo",
+ true,
+ [],
+ {},
+ null,
+ ]) {
+ let expiryTest = {
+ name: "foo",
+ value: "bar",
+ expiry: invalidType,
+ };
+ Assert.throws(
+ () => cookie.fromJSON(expiryTest),
+ /Cookie expiry must be a positive integer/
+ );
+ }
+
+ // sameSite
+ for (let invalidType of ["foo", 42, [], {}, null]) {
+ const sameSiteTest = {
+ name: "foo",
+ value: "bar",
+ sameSite: invalidType,
+ };
+ Assert.throws(
+ () => cookie.fromJSON(sameSiteTest),
+ /Cookie SameSite flag must be one of None, Lax, or Strict/
+ );
+ }
+
+ // bare requirements
+ let bare = cookie.fromJSON({ name: "name", value: "value" });
+ equal("name", bare.name);
+ equal("value", bare.value);
+ for (let missing of [
+ "path",
+ "secure",
+ "httpOnly",
+ "session",
+ "expiry",
+ "sameSite",
+ ]) {
+ ok(!bare.hasOwnProperty(missing));
+ }
+
+ // everything
+ let full = cookie.fromJSON({
+ name: "name",
+ value: "value",
+ domain: ".domain",
+ path: "path",
+ secure: true,
+ httpOnly: true,
+ expiry: 42,
+ sameSite: "Lax",
+ });
+ equal("name", full.name);
+ equal("value", full.value);
+ equal(".domain", full.domain);
+ equal("path", full.path);
+ equal(true, full.secure);
+ equal(true, full.httpOnly);
+ equal(42, full.expiry);
+ equal("Lax", full.sameSite);
+
+ run_next_test();
+});
+
+add_test(function test_add() {
+ cookie.manager.cookies = [];
+
+ for (let invalidType of [42, true, [], {}, null, undefined]) {
+ Assert.throws(
+ () => cookie.add({ name: invalidType }),
+ /Cookie name must be string/
+ );
+ Assert.throws(
+ () => cookie.add({ name: "name", value: invalidType }),
+ /Cookie value must be string/
+ );
+ Assert.throws(
+ () => cookie.add({ name: "name", value: "value", domain: invalidType }),
+ /Cookie domain must be string/
+ );
+ }
+
+ cookie.add({
+ name: "name",
+ value: "value",
+ domain: "domain",
+ });
+ equal(1, cookie.manager.cookies.length);
+ equal("name", cookie.manager.cookies[0].name);
+ equal("value", cookie.manager.cookies[0].value);
+ equal(".domain", cookie.manager.cookies[0].host);
+ equal("/", cookie.manager.cookies[0].path);
+ ok(cookie.manager.cookies[0].expiry > new Date(Date.now()).getTime() / 1000);
+
+ cookie.add({
+ name: "name2",
+ value: "value2",
+ domain: "domain2",
+ });
+ equal(2, cookie.manager.cookies.length);
+
+ Assert.throws(() => {
+ let biscuit = { name: "name3", value: "value3", domain: "domain3" };
+ cookie.add(biscuit, { restrictToHost: "other domain" });
+ }, /Cookies may only be set for the current domain/);
+
+ cookie.add({
+ name: "name4",
+ value: "value4",
+ domain: "my.domain:1234",
+ });
+ equal(".my.domain", cookie.manager.cookies[2].host);
+
+ cookie.add({
+ name: "name5",
+ value: "value5",
+ domain: "domain5",
+ path: "/foo/bar",
+ });
+ equal("/foo/bar", cookie.manager.cookies[3].path);
+
+ cookie.add({
+ name: "name6",
+ value: "value",
+ domain: ".domain",
+ });
+ equal(".domain", cookie.manager.cookies[4].host);
+
+ const sameSiteMap = new Map([
+ ["None", Ci.nsICookie.SAMESITE_NONE],
+ ["Lax", Ci.nsICookie.SAMESITE_LAX],
+ ["Strict", Ci.nsICookie.SAMESITE_STRICT],
+ ]);
+
+ Array.from(sameSiteMap.keys()).forEach((entry, index) => {
+ cookie.add({
+ name: "name" + index,
+ value: "value",
+ domain: ".domain",
+ sameSite: entry,
+ });
+ equal(sameSiteMap.get(entry), cookie.manager.cookies[5 + index].sameSite);
+ });
+
+ Assert.throws(() => {
+ cookie.add({ name: "fail", value: "value6", domain: "domain6" });
+ }, /UnableToSetCookieError/);
+
+ run_next_test();
+});
+
+add_test(function test_remove() {
+ cookie.manager.cookies = [];
+
+ let crumble = {
+ name: "test_remove",
+ value: "value",
+ domain: "domain",
+ path: "/custom/path",
+ };
+
+ equal(0, cookie.manager.cookies.length);
+ cookie.add(crumble);
+ equal(1, cookie.manager.cookies.length);
+
+ cookie.remove(crumble);
+ equal(0, cookie.manager.cookies.length);
+ equal(undefined, cookie.manager.cookies[0]);
+
+ run_next_test();
+});
+
+add_test(function test_iter() {
+ cookie.manager.cookies = [];
+ let tomorrow = new Date();
+ tomorrow.setHours(tomorrow.getHours() + 24);
+
+ cookie.add({
+ expiry: tomorrow,
+ name: "0",
+ value: "",
+ domain: "foo.example.com",
+ });
+ cookie.add({
+ expiry: tomorrow,
+ name: "1",
+ value: "",
+ domain: "bar.example.com",
+ });
+
+ let fooCookies = [...cookie.iter("foo.example.com")];
+ equal(1, fooCookies.length);
+ equal(".foo.example.com", fooCookies[0].domain);
+ equal(true, fooCookies[0].hasOwnProperty("expiry"));
+
+ cookie.add({
+ name: "aSessionCookie",
+ value: "",
+ domain: "session.com",
+ });
+
+ let sessionCookies = [...cookie.iter("session.com")];
+ equal(1, sessionCookies.length);
+ equal("aSessionCookie", sessionCookies[0].name);
+ equal(false, sessionCookies[0].hasOwnProperty("expiry"));
+
+ cookie.add({
+ name: "2",
+ value: "",
+ domain: "samesite.example.com",
+ sameSite: "Lax",
+ });
+
+ let sameSiteCookies = [...cookie.iter("samesite.example.com")];
+ equal(1, sameSiteCookies.length);
+ equal("Lax", sameSiteCookies[0].sameSite);
+
+ run_next_test();
+});
diff --git a/testing/marionette/test/unit/test_dom.js b/testing/marionette/test/unit/test_dom.js
new file mode 100644
index 0000000000..ddb1c7e30b
--- /dev/null
+++ b/testing/marionette/test/unit/test_dom.js
@@ -0,0 +1,275 @@
+const {
+ ContentEventObserverService,
+ WebElementEventTarget,
+} = ChromeUtils.import("chrome://marionette/content/dom.js");
+
+class MessageSender {
+ constructor() {
+ this.listeners = {};
+ this.sent = [];
+ }
+
+ addMessageListener(name, listener) {
+ this.listeners[name] = listener;
+ }
+
+ sendAsyncMessage(name, data) {
+ this.sent.push({ name, data });
+ }
+}
+
+class Window {
+ constructor() {
+ this.events = [];
+ }
+
+ addEventListener(type) {
+ this.events.push(type);
+ }
+
+ removeEventListener(type) {
+ for (let i = 0; i < this.events.length; ++i) {
+ if (this.events[i] === type) {
+ this.events.splice(i, 1);
+ return;
+ }
+ }
+ }
+}
+
+add_test(function test_WebElementEventTarget_addEventListener_init() {
+ let ipc = new MessageSender();
+ let eventTarget = new WebElementEventTarget(ipc);
+ equal(Object.keys(eventTarget.listeners).length, 0);
+ equal(Object.keys(ipc.listeners).length, 1);
+
+ run_next_test();
+});
+
+add_test(function test_addEventListener() {
+ let ipc = new MessageSender();
+ let eventTarget = new WebElementEventTarget(ipc);
+
+ let listener = () => {};
+ eventTarget.addEventListener("click", listener);
+
+ // click listener was appended
+ equal(Object.keys(eventTarget.listeners).length, 1);
+ ok("click" in eventTarget.listeners);
+ equal(eventTarget.listeners.click.length, 1);
+ equal(eventTarget.listeners.click[0], listener);
+
+ // should have sent a registration message
+ deepEqual(ipc.sent[0], {
+ name: "Marionette:DOM:AddEventListener",
+ data: { type: "click" },
+ });
+
+ run_next_test();
+});
+
+add_test(function test_addEventListener_sameReference() {
+ let ipc = new MessageSender();
+ let eventTarget = new WebElementEventTarget(ipc);
+
+ let listener = () => {};
+ eventTarget.addEventListener("click", listener);
+ eventTarget.addEventListener("click", listener);
+ equal(eventTarget.listeners.click.length, 1);
+
+ run_next_test();
+});
+
+add_test(function test_WebElementEventTarget_addEventListener_once() {
+ let ipc = new MessageSender();
+ let eventTarget = new WebElementEventTarget(ipc);
+
+ eventTarget.addEventListener("click", () => {}, { once: true });
+ equal(eventTarget.listeners.click[0].once, true);
+
+ eventTarget.dispatchEvent({ type: "click" });
+ equal(eventTarget.listeners.click.length, 0);
+ deepEqual(ipc.sent[1], {
+ name: "Marionette:DOM:RemoveEventListener",
+ data: { type: "click" },
+ });
+
+ run_next_test();
+});
+
+add_test(function test_WebElementEventTarget_removeEventListener() {
+ let ipc = new MessageSender();
+ let eventTarget = new WebElementEventTarget(ipc);
+
+ equal(Object.keys(eventTarget.listeners).length, 0);
+ eventTarget.removeEventListener("click", () => {});
+ equal(Object.keys(eventTarget.listeners).length, 0);
+
+ let firstListener = () => {};
+ eventTarget.addEventListener("click", firstListener);
+ equal(eventTarget.listeners.click.length, 1);
+ ok(eventTarget.listeners.click[0] === firstListener);
+
+ let secondListener = () => {};
+ eventTarget.addEventListener("click", secondListener);
+ equal(eventTarget.listeners.click.length, 2);
+ ok(eventTarget.listeners.click[1] === secondListener);
+
+ ok(eventTarget.listeners.click[0] !== eventTarget.listeners.click[1]);
+
+ eventTarget.removeEventListener("click", secondListener);
+ equal(eventTarget.listeners.click.length, 1);
+ ok(eventTarget.listeners.click[0] === firstListener);
+
+ // event should not have been unregistered
+ // because there still exists another click event
+ equal(ipc.sent[ipc.sent.length - 1].name, "Marionette:DOM:AddEventListener");
+
+ eventTarget.removeEventListener("click", firstListener);
+ equal(eventTarget.listeners.click.length, 0);
+ deepEqual(ipc.sent[ipc.sent.length - 1], {
+ name: "Marionette:DOM:RemoveEventListener",
+ data: { type: "click" },
+ });
+
+ run_next_test();
+});
+
+add_test(function test_WebElementEventTarget_dispatchEvent() {
+ let ipc = new MessageSender();
+ let eventTarget = new WebElementEventTarget(ipc);
+
+ let listenerCalled = false;
+ let listener = () => (listenerCalled = true);
+ eventTarget.addEventListener("click", listener);
+ eventTarget.dispatchEvent({ type: "click" });
+ ok(listenerCalled);
+
+ run_next_test();
+});
+
+add_test(function test_WebElementEventTarget_dispatchEvent_multipleListeners() {
+ let ipc = new MessageSender();
+ let eventTarget = new WebElementEventTarget(ipc);
+
+ let clicksA = 0;
+ let clicksB = 0;
+ let listenerA = () => ++clicksA;
+ let listenerB = () => ++clicksB;
+
+ // the same listener should only be added, and consequently fire, once
+ eventTarget.addEventListener("click", listenerA);
+ eventTarget.addEventListener("click", listenerA);
+ eventTarget.addEventListener("click", listenerB);
+ eventTarget.dispatchEvent({ type: "click" });
+ equal(clicksA, 1);
+ equal(clicksB, 1);
+
+ run_next_test();
+});
+
+add_test(function test_ContentEventObserverService_add() {
+ let ipc = new MessageSender();
+ let win = new Window();
+ let obs = new ContentEventObserverService(
+ win,
+ ipc.sendAsyncMessage.bind(ipc)
+ );
+
+ equal(obs.events.size, 0);
+ equal(win.events.length, 0);
+
+ obs.add("foo");
+ equal(obs.events.size, 1);
+ equal(win.events.length, 1);
+ equal(obs.events.values().next().value, "foo");
+ equal(win.events[0], "foo");
+
+ obs.add("foo");
+ equal(obs.events.size, 1);
+ equal(win.events.length, 1);
+
+ run_next_test();
+});
+
+add_test(function test_ContentEventObserverService_remove() {
+ let ipc = new MessageSender();
+ let win = new Window();
+ let obs = new ContentEventObserverService(
+ win,
+ ipc.sendAsyncMessage.bind(ipc)
+ );
+
+ obs.remove("foo");
+ equal(obs.events.size, 0);
+ equal(win.events.length, 0);
+
+ obs.add("bar");
+ equal(obs.events.size, 1);
+ equal(win.events.length, 1);
+
+ obs.remove("bar");
+ equal(obs.events.size, 0);
+ equal(win.events.length, 0);
+
+ obs.add("baz");
+ obs.add("baz");
+ equal(obs.events.size, 1);
+ equal(win.events.length, 1);
+
+ obs.add("bah");
+ equal(obs.events.size, 2);
+ equal(win.events.length, 2);
+
+ obs.remove("baz");
+ equal(obs.events.size, 1);
+ equal(win.events.length, 1);
+
+ obs.remove("bah");
+ equal(obs.events.size, 0);
+ equal(win.events.length, 0);
+
+ run_next_test();
+});
+
+add_test(function test_ContentEventObserverService_clear() {
+ let ipc = new MessageSender();
+ let win = new Window();
+ let obs = new ContentEventObserverService(
+ win,
+ ipc.sendAsyncMessage.bind(ipc)
+ );
+
+ obs.clear();
+ equal(obs.events.size, 0);
+ equal(win.events.length, 0);
+
+ obs.add("foo");
+ obs.add("foo");
+ obs.add("bar");
+ equal(obs.events.size, 2);
+ equal(win.events.length, 2);
+
+ obs.clear();
+ equal(obs.events.size, 0);
+ equal(win.events.length, 0);
+
+ run_next_test();
+});
+
+add_test(function test_ContentEventObserverService_handleEvent() {
+ let ipc = new MessageSender();
+ let win = new Window();
+ let obs = new ContentEventObserverService(
+ win,
+ ipc.sendAsyncMessage.bind(ipc)
+ );
+
+ obs.handleEvent({ type: "click", target: win });
+ deepEqual(ipc.sent[0], {
+ name: "Marionette:DOM:OnEvent",
+ data: { type: "click" },
+ });
+
+ run_next_test();
+});
diff --git a/testing/marionette/test/unit/test_element.js b/testing/marionette/test/unit/test_element.js
new file mode 100644
index 0000000000..1644ff9346
--- /dev/null
+++ b/testing/marionette/test/unit/test_element.js
@@ -0,0 +1,609 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const {
+ ChromeWebElement,
+ ContentWebElement,
+ ContentWebFrame,
+ ContentWebWindow,
+ element,
+ WebElement,
+} = ChromeUtils.import("chrome://marionette/content/element.js");
+const { InvalidArgumentError } = ChromeUtils.import(
+ "chrome://marionette/content/error.js"
+);
+
+const SVG_NS = "http://www.w3.org/2000/svg";
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+class Element {
+ constructor(tagName, attrs = {}) {
+ this.tagName = tagName;
+ this.localName = tagName;
+
+ for (let attr in attrs) {
+ this[attr] = attrs[attr];
+ }
+ }
+
+ get nodeType() {
+ return 1;
+ }
+ get ELEMENT_NODE() {
+ return 1;
+ }
+
+ // this is a severely limited CSS selector
+ // that only supports lists of tag names
+ matches(selector) {
+ let tags = selector.split(",");
+ return tags.includes(this.localName);
+ }
+}
+
+class DOMElement extends Element {
+ constructor(tagName, attrs = {}) {
+ super(tagName, attrs);
+
+ if (typeof this.namespaceURI == "undefined") {
+ this.namespaceURI = XHTML_NS;
+ }
+ if (typeof this.ownerDocument == "undefined") {
+ this.ownerDocument = { designMode: "off" };
+ }
+ if (typeof this.ownerDocument.documentElement == "undefined") {
+ this.ownerDocument.documentElement = { namespaceURI: XHTML_NS };
+ }
+
+ if (typeof this.type == "undefined") {
+ this.type = "text";
+ }
+
+ if (this.localName == "option") {
+ this.selected = false;
+ }
+
+ if (
+ this.localName == "input" &&
+ ["checkbox", "radio"].includes(this.type)
+ ) {
+ this.checked = false;
+ }
+ }
+
+ getBoundingClientRect() {
+ return {
+ top: 0,
+ left: 0,
+ width: 100,
+ height: 100,
+ };
+ }
+}
+
+class SVGElement extends Element {
+ constructor(tagName, attrs = {}) {
+ super(tagName, attrs);
+ this.namespaceURI = SVG_NS;
+ }
+}
+
+class XULElement extends Element {
+ constructor(tagName, attrs = {}) {
+ super(tagName, attrs);
+ this.namespaceURI = XUL_NS;
+
+ if (typeof this.ownerDocument == "undefined") {
+ this.ownerDocument = {};
+ }
+ if (typeof this.ownerDocument.documentElement == "undefined") {
+ this.ownerDocument.documentElement = { namespaceURI: XUL_NS };
+ }
+ }
+}
+
+const domEl = new DOMElement("p");
+const svgEl = new SVGElement("rect");
+const xulEl = new XULElement("browser");
+const domElInXULDocument = new DOMElement("input", {
+ ownerDocument: {
+ documentElement: { namespaceURI: XUL_NS },
+ },
+});
+
+class WindowProxy {
+ get parent() {
+ return this;
+ }
+ get self() {
+ return this;
+ }
+ toString() {
+ return "[object Window]";
+ }
+}
+const domWin = new WindowProxy();
+const domFrame = new (class extends WindowProxy {
+ get parent() {
+ return domWin;
+ }
+})();
+
+add_test(function test_findClosest() {
+ equal(element.findClosest(domEl, "foo"), null);
+
+ let foo = new DOMElement("foo");
+ let bar = new DOMElement("bar");
+ bar.parentNode = foo;
+ equal(element.findClosest(bar, "foo"), foo);
+
+ run_next_test();
+});
+
+add_test(function test_isSelected() {
+ let checkbox = new DOMElement("input", { type: "checkbox" });
+ ok(!element.isSelected(checkbox));
+ checkbox.checked = true;
+ ok(element.isSelected(checkbox));
+
+ // selected is not a property of <input type=checkbox>
+ checkbox.selected = true;
+ checkbox.checked = false;
+ ok(!element.isSelected(checkbox));
+
+ let option = new DOMElement("option");
+ ok(!element.isSelected(option));
+ option.selected = true;
+ ok(element.isSelected(option));
+
+ // checked is not a property of <option>
+ option.checked = true;
+ option.selected = false;
+ ok(!element.isSelected(option));
+
+ // anything else should not be selected
+ for (let typ of [domEl, undefined, null, "foo", true, [], {}]) {
+ ok(!element.isSelected(typ));
+ }
+
+ run_next_test();
+});
+
+add_test(function test_isElement() {
+ ok(element.isElement(domEl));
+ ok(element.isElement(svgEl));
+ ok(element.isElement(xulEl));
+ ok(!element.isElement(domWin));
+ ok(!element.isElement(domFrame));
+ for (let typ of [true, 42, {}, [], undefined, null]) {
+ ok(!element.isElement(typ));
+ }
+
+ run_next_test();
+});
+
+add_test(function test_isDOMElement() {
+ ok(element.isDOMElement(domEl));
+ ok(element.isDOMElement(domElInXULDocument));
+ ok(element.isDOMElement(svgEl));
+ ok(!element.isDOMElement(xulEl));
+ ok(!element.isDOMElement(domWin));
+ ok(!element.isDOMElement(domFrame));
+ for (let typ of [true, 42, {}, [], undefined, null]) {
+ ok(!element.isDOMElement(typ));
+ }
+
+ run_next_test();
+});
+
+add_test(function test_isXULElement() {
+ ok(element.isXULElement(xulEl));
+ ok(!element.isXULElement(domElInXULDocument));
+ ok(!element.isXULElement(domEl));
+ ok(!element.isXULElement(svgEl));
+ ok(!element.isDOMElement(domWin));
+ ok(!element.isDOMElement(domFrame));
+ for (let typ of [true, 42, {}, [], undefined, null]) {
+ ok(!element.isXULElement(typ));
+ }
+
+ run_next_test();
+});
+
+add_test(function test_isDOMWindow() {
+ ok(element.isDOMWindow(domWin));
+ ok(element.isDOMWindow(domFrame));
+ ok(!element.isDOMWindow(domEl));
+ ok(!element.isDOMWindow(domElInXULDocument));
+ ok(!element.isDOMWindow(svgEl));
+ ok(!element.isDOMWindow(xulEl));
+ for (let typ of [true, 42, {}, [], undefined, null]) {
+ ok(!element.isDOMWindow(typ));
+ }
+
+ run_next_test();
+});
+
+add_test(function test_isReadOnly() {
+ ok(!element.isReadOnly(null));
+ ok(!element.isReadOnly(domEl));
+ ok(!element.isReadOnly(new DOMElement("p", { readOnly: true })));
+ ok(element.isReadOnly(new DOMElement("input", { readOnly: true })));
+ ok(element.isReadOnly(new DOMElement("textarea", { readOnly: true })));
+
+ run_next_test();
+});
+
+add_test(function test_isDisabled() {
+ ok(!element.isDisabled(new DOMElement("p")));
+ ok(!element.isDisabled(new SVGElement("rect", { disabled: true })));
+ ok(!element.isDisabled(new XULElement("browser", { disabled: true })));
+
+ let select = new DOMElement("select", { disabled: true });
+ let option = new DOMElement("option");
+ option.parentNode = select;
+ ok(element.isDisabled(option));
+
+ let optgroup = new DOMElement("optgroup", { disabled: true });
+ option.parentNode = optgroup;
+ optgroup.parentNode = select;
+ select.disabled = false;
+ ok(element.isDisabled(option));
+
+ ok(element.isDisabled(new DOMElement("button", { disabled: true })));
+ ok(element.isDisabled(new DOMElement("input", { disabled: true })));
+ ok(element.isDisabled(new DOMElement("select", { disabled: true })));
+ ok(element.isDisabled(new DOMElement("textarea", { disabled: true })));
+
+ run_next_test();
+});
+
+add_test(function test_isEditingHost() {
+ ok(!element.isEditingHost(null));
+ ok(element.isEditingHost(new DOMElement("p", { isContentEditable: true })));
+ ok(
+ element.isEditingHost(
+ new DOMElement("p", { ownerDocument: { designMode: "on" } })
+ )
+ );
+
+ run_next_test();
+});
+
+add_test(function test_isEditable() {
+ ok(!element.isEditable(null));
+ ok(!element.isEditable(domEl));
+ ok(!element.isEditable(new DOMElement("textarea", { readOnly: true })));
+ ok(!element.isEditable(new DOMElement("textarea", { disabled: true })));
+
+ for (let type of [
+ "checkbox",
+ "radio",
+ "hidden",
+ "submit",
+ "button",
+ "image",
+ ]) {
+ ok(!element.isEditable(new DOMElement("input", { type })));
+ }
+ ok(element.isEditable(new DOMElement("input", { type: "text" })));
+ ok(element.isEditable(new DOMElement("input")));
+
+ ok(element.isEditable(new DOMElement("textarea")));
+ ok(
+ element.isEditable(
+ new DOMElement("p", { ownerDocument: { designMode: "on" } })
+ )
+ );
+ ok(element.isEditable(new DOMElement("p", { isContentEditable: true })));
+
+ run_next_test();
+});
+
+add_test(function test_isMutableFormControlElement() {
+ ok(!element.isMutableFormControl(null));
+ ok(
+ !element.isMutableFormControl(
+ new DOMElement("textarea", { readOnly: true })
+ )
+ );
+ ok(
+ !element.isMutableFormControl(
+ new DOMElement("textarea", { disabled: true })
+ )
+ );
+
+ const mutableStates = new Set([
+ "color",
+ "date",
+ "datetime-local",
+ "email",
+ "file",
+ "month",
+ "number",
+ "password",
+ "range",
+ "search",
+ "tel",
+ "text",
+ "url",
+ "week",
+ ]);
+ for (let type of mutableStates) {
+ ok(element.isMutableFormControl(new DOMElement("input", { type })));
+ }
+ ok(element.isMutableFormControl(new DOMElement("textarea")));
+
+ ok(
+ !element.isMutableFormControl(new DOMElement("input", { type: "hidden" }))
+ );
+ ok(!element.isMutableFormControl(new DOMElement("p")));
+ ok(
+ !element.isMutableFormControl(
+ new DOMElement("p", { isContentEditable: true })
+ )
+ );
+ ok(
+ !element.isMutableFormControl(
+ new DOMElement("p", { ownerDocument: { designMode: "on" } })
+ )
+ );
+
+ run_next_test();
+});
+
+add_test(function test_coordinates() {
+ let p = element.coordinates(domEl);
+ ok(p.hasOwnProperty("x"));
+ ok(p.hasOwnProperty("y"));
+ equal("number", typeof p.x);
+ equal("number", typeof p.y);
+
+ deepEqual({ x: 50, y: 50 }, element.coordinates(domEl));
+ deepEqual({ x: 10, y: 10 }, element.coordinates(domEl, 10, 10));
+ deepEqual({ x: -5, y: -5 }, element.coordinates(domEl, -5, -5));
+
+ Assert.throws(() => element.coordinates(null), /node is null/);
+
+ Assert.throws(
+ () => element.coordinates(domEl, "string", undefined),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => element.coordinates(domEl, undefined, "string"),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => element.coordinates(domEl, "string", "string"),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => element.coordinates(domEl, {}, undefined),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => element.coordinates(domEl, undefined, {}),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => element.coordinates(domEl, {}, {}),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => element.coordinates(domEl, [], undefined),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => element.coordinates(domEl, undefined, []),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => element.coordinates(domEl, [], []),
+ /Offset must be a number/
+ );
+
+ run_next_test();
+});
+
+add_test(function test_WebElement_ctor() {
+ let el = new WebElement("foo");
+ equal(el.uuid, "foo");
+
+ for (let t of [42, true, [], {}, null, undefined]) {
+ Assert.throws(() => new WebElement(t), /to be a string/);
+ }
+
+ run_next_test();
+});
+
+add_test(function test_WebElemenet_is() {
+ let a = new WebElement("a");
+ let b = new WebElement("b");
+
+ ok(a.is(a));
+ ok(b.is(b));
+ ok(!a.is(b));
+ ok(!b.is(a));
+
+ ok(!a.is({}));
+
+ run_next_test();
+});
+
+add_test(function test_WebElement_from() {
+ ok(WebElement.from(domEl) instanceof ContentWebElement);
+ ok(WebElement.from(domWin) instanceof ContentWebWindow);
+ ok(WebElement.from(domFrame) instanceof ContentWebFrame);
+ ok(WebElement.from(xulEl) instanceof ChromeWebElement);
+ ok(WebElement.from(domElInXULDocument) instanceof ChromeWebElement);
+
+ Assert.throws(() => WebElement.from({}), /InvalidArgumentError/);
+
+ run_next_test();
+});
+
+add_test(function test_WebElement_fromJSON_ContentWebElement() {
+ const { Identifier } = ContentWebElement;
+
+ let ref = { [Identifier]: "foo" };
+ let webEl = WebElement.fromJSON(ref);
+ ok(webEl instanceof ContentWebElement);
+ equal(webEl.uuid, "foo");
+
+ let identifierPrecedence = {
+ [Identifier]: "identifier-uuid",
+ };
+ let precedenceEl = WebElement.fromJSON(identifierPrecedence);
+ ok(precedenceEl instanceof ContentWebElement);
+ equal(precedenceEl.uuid, "identifier-uuid");
+
+ run_next_test();
+});
+
+add_test(function test_WebElement_fromJSON_ContentWebWindow() {
+ let ref = { [ContentWebWindow.Identifier]: "foo" };
+ let win = WebElement.fromJSON(ref);
+ ok(win instanceof ContentWebWindow);
+ equal(win.uuid, "foo");
+
+ run_next_test();
+});
+
+add_test(function test_WebElement_fromJSON_ContentWebFrame() {
+ let ref = { [ContentWebFrame.Identifier]: "foo" };
+ let frame = WebElement.fromJSON(ref);
+ ok(frame instanceof ContentWebFrame);
+ equal(frame.uuid, "foo");
+
+ run_next_test();
+});
+
+add_test(function test_WebElement_fromJSON_ChromeWebElement() {
+ let ref = { [ChromeWebElement.Identifier]: "foo" };
+ let el = WebElement.fromJSON(ref);
+ ok(el instanceof ChromeWebElement);
+ equal(el.uuid, "foo");
+
+ run_next_test();
+});
+
+add_test(function test_WebElement_fromJSON_malformed() {
+ Assert.throws(() => WebElement.fromJSON({}), /InvalidArgumentError/);
+ Assert.throws(() => WebElement.fromJSON(null), /InvalidArgumentError/);
+ run_next_test();
+});
+
+add_test(function test_WebElement_fromUUID() {
+ let xulWebEl = WebElement.fromUUID("foo", "chrome");
+ ok(xulWebEl instanceof ChromeWebElement);
+ equal(xulWebEl.uuid, "foo");
+
+ let domWebEl = WebElement.fromUUID("bar", "content");
+ ok(domWebEl instanceof ContentWebElement);
+ equal(domWebEl.uuid, "bar");
+
+ Assert.throws(
+ () => WebElement.fromUUID("baz", "bah"),
+ /InvalidArgumentError/
+ );
+
+ run_next_test();
+});
+
+add_test(function test_WebElement_isReference() {
+ for (let t of [42, true, "foo", [], {}]) {
+ ok(!WebElement.isReference(t));
+ }
+
+ ok(WebElement.isReference({ [ContentWebElement.Identifier]: "foo" }));
+ ok(WebElement.isReference({ [ContentWebWindow.Identifier]: "foo" }));
+ ok(WebElement.isReference({ [ContentWebFrame.Identifier]: "foo" }));
+ ok(WebElement.isReference({ [ChromeWebElement.Identifier]: "foo" }));
+
+ run_next_test();
+});
+
+add_test(function test_WebElement_generateUUID() {
+ equal(typeof WebElement.generateUUID(), "string");
+ run_next_test();
+});
+
+add_test(function test_ContentWebElement_toJSON() {
+ const { Identifier } = ContentWebElement;
+
+ let el = new ContentWebElement("foo");
+ let json = el.toJSON();
+
+ ok(Identifier in json);
+ equal(json[Identifier], "foo");
+
+ run_next_test();
+});
+
+add_test(function test_ContentWebElement_fromJSON() {
+ const { Identifier } = ContentWebElement;
+
+ let el = ContentWebElement.fromJSON({ [Identifier]: "foo" });
+ ok(el instanceof ContentWebElement);
+ equal(el.uuid, "foo");
+
+ Assert.throws(() => ContentWebElement.fromJSON({}), /InvalidArgumentError/);
+
+ run_next_test();
+});
+
+add_test(function test_ContentWebWindow_toJSON() {
+ let win = new ContentWebWindow("foo");
+ let json = win.toJSON();
+ ok(ContentWebWindow.Identifier in json);
+ equal(json[ContentWebWindow.Identifier], "foo");
+
+ run_next_test();
+});
+
+add_test(function test_ContentWebWindow_fromJSON() {
+ let ref = { [ContentWebWindow.Identifier]: "foo" };
+ let win = ContentWebWindow.fromJSON(ref);
+ ok(win instanceof ContentWebWindow);
+ equal(win.uuid, "foo");
+
+ run_next_test();
+});
+
+add_test(function test_ContentWebFrame_toJSON() {
+ let frame = new ContentWebFrame("foo");
+ let json = frame.toJSON();
+ ok(ContentWebFrame.Identifier in json);
+ equal(json[ContentWebFrame.Identifier], "foo");
+
+ run_next_test();
+});
+
+add_test(function test_ContentWebFrame_fromJSON() {
+ let ref = { [ContentWebFrame.Identifier]: "foo" };
+ let win = ContentWebFrame.fromJSON(ref);
+ ok(win instanceof ContentWebFrame);
+ equal(win.uuid, "foo");
+
+ run_next_test();
+});
+
+add_test(function test_ChromeWebElement_toJSON() {
+ let el = new ChromeWebElement("foo");
+ let json = el.toJSON();
+ ok(ChromeWebElement.Identifier in json);
+ equal(json[ChromeWebElement.Identifier], "foo");
+
+ run_next_test();
+});
+
+add_test(function test_ChromeWebElement_fromJSON() {
+ let ref = { [ChromeWebElement.Identifier]: "foo" };
+ let win = ChromeWebElement.fromJSON(ref);
+ ok(win instanceof ChromeWebElement);
+ equal(win.uuid, "foo");
+
+ run_next_test();
+});
diff --git a/testing/marionette/test/unit/test_error.js b/testing/marionette/test/unit/test_error.js
new file mode 100644
index 0000000000..0be71dec65
--- /dev/null
+++ b/testing/marionette/test/unit/test_error.js
@@ -0,0 +1,477 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { error } = ChromeUtils.import("chrome://marionette/content/error.js");
+
+function notok(condition) {
+ ok(!condition);
+}
+
+add_test(function test_isError() {
+ notok(error.isError(null));
+ notok(error.isError([]));
+ notok(error.isError(new Date()));
+
+ ok(error.isError(new Components.Exception()));
+ ok(error.isError(new Error()));
+ ok(error.isError(new EvalError()));
+ ok(error.isError(new InternalError()));
+ ok(error.isError(new RangeError()));
+ ok(error.isError(new ReferenceError()));
+ ok(error.isError(new SyntaxError()));
+ ok(error.isError(new TypeError()));
+ ok(error.isError(new URIError()));
+ ok(error.isError(new error.WebDriverError()));
+ ok(error.isError(new error.InvalidArgumentError()));
+
+ run_next_test();
+});
+
+add_test(function test_isWebDriverError() {
+ notok(error.isWebDriverError(new Components.Exception()));
+ notok(error.isWebDriverError(new Error()));
+ notok(error.isWebDriverError(new EvalError()));
+ notok(error.isWebDriverError(new InternalError()));
+ notok(error.isWebDriverError(new RangeError()));
+ notok(error.isWebDriverError(new ReferenceError()));
+ notok(error.isWebDriverError(new SyntaxError()));
+ notok(error.isWebDriverError(new TypeError()));
+ notok(error.isWebDriverError(new URIError()));
+
+ ok(error.isWebDriverError(new error.WebDriverError()));
+ ok(error.isWebDriverError(new error.InvalidArgumentError()));
+ ok(error.isWebDriverError(new error.JavaScriptError()));
+
+ run_next_test();
+});
+
+add_test(function test_wrap() {
+ // webdriver-derived errors should not be wrapped
+ equal(error.wrap(new error.WebDriverError()).name, "WebDriverError");
+ ok(error.wrap(new error.WebDriverError()) instanceof error.WebDriverError);
+ equal(
+ error.wrap(new error.InvalidArgumentError()).name,
+ "InvalidArgumentError"
+ );
+ ok(
+ error.wrap(new error.InvalidArgumentError()) instanceof error.WebDriverError
+ );
+ ok(
+ error.wrap(new error.InvalidArgumentError()) instanceof
+ error.InvalidArgumentError
+ );
+
+ // JS errors should be wrapped in UnknownError
+ equal(error.wrap(new Error()).name, "UnknownError");
+ ok(error.wrap(new Error()) instanceof error.UnknownError);
+ equal(error.wrap(new EvalError()).name, "UnknownError");
+ equal(error.wrap(new InternalError()).name, "UnknownError");
+ equal(error.wrap(new RangeError()).name, "UnknownError");
+ equal(error.wrap(new ReferenceError()).name, "UnknownError");
+ equal(error.wrap(new SyntaxError()).name, "UnknownError");
+ equal(error.wrap(new TypeError()).name, "UnknownError");
+ equal(error.wrap(new URIError()).name, "UnknownError");
+
+ // wrapped JS errors should retain their type
+ // as part of the message field
+ equal(error.wrap(new error.WebDriverError("foo")).message, "foo");
+ equal(error.wrap(new TypeError("foo")).message, "TypeError: foo");
+
+ run_next_test();
+});
+
+add_test(function test_stringify() {
+ equal("<unprintable error>", error.stringify());
+ equal("<unprintable error>", error.stringify("foo"));
+ equal("[object Object]", error.stringify({}));
+ equal("[object Object]\nfoo", error.stringify({ stack: "foo" }));
+ equal("Error: foo", error.stringify(new Error("foo")).split("\n")[0]);
+ equal(
+ "WebDriverError: foo",
+ error.stringify(new error.WebDriverError("foo")).split("\n")[0]
+ );
+ equal(
+ "InvalidArgumentError: foo",
+ error.stringify(new error.InvalidArgumentError("foo")).split("\n")[0]
+ );
+
+ run_next_test();
+});
+
+add_test(function test_stack() {
+ equal("string", typeof error.stack());
+ ok(error.stack().includes("test_stack"));
+ ok(!error.stack().includes("add_test"));
+
+ run_next_test();
+});
+
+add_test(function test_toJSON() {
+ let e0 = new error.WebDriverError();
+ let e0s = e0.toJSON();
+ equal(e0s.error, "webdriver error");
+ equal(e0s.message, "");
+ equal(e0s.stacktrace, e0.stack);
+
+ let e1 = new error.WebDriverError("a");
+ let e1s = e1.toJSON();
+ equal(e1s.message, e1.message);
+ equal(e1s.stacktrace, e1.stack);
+
+ let e2 = new error.JavaScriptError("foo");
+ let e2s = e2.toJSON();
+ equal(e2.status, e2s.error);
+ equal(e2.message, e2s.message);
+
+ run_next_test();
+});
+
+add_test(function test_fromJSON() {
+ Assert.throws(
+ () => error.WebDriverError.fromJSON({ error: "foo" }),
+ /Not of WebDriverError descent/
+ );
+ Assert.throws(
+ () => error.WebDriverError.fromJSON({ error: "Error" }),
+ /Not of WebDriverError descent/
+ );
+ Assert.throws(
+ () => error.WebDriverError.fromJSON({}),
+ /Undeserialisable error type/
+ );
+ Assert.throws(() => error.WebDriverError.fromJSON(undefined), /TypeError/);
+
+ // stacks will be different
+ let e1 = new error.WebDriverError("1");
+ let e1r = error.WebDriverError.fromJSON({
+ error: "webdriver error",
+ message: "1",
+ });
+ ok(e1r instanceof error.WebDriverError);
+ equal(e1r.name, e1.name);
+ equal(e1r.status, e1.status);
+ equal(e1r.message, e1.message);
+
+ // stacks will be different
+ let e2 = new error.InvalidArgumentError("2");
+ let e2r = error.WebDriverError.fromJSON({
+ error: "invalid argument",
+ message: "2",
+ });
+ ok(e2r instanceof error.WebDriverError);
+ ok(e2r instanceof error.InvalidArgumentError);
+ equal(e2r.name, e2.name);
+ equal(e2r.status, e2.status);
+ equal(e2r.message, e2.message);
+
+ // test stacks
+ let e3j = { error: "no such element", message: "3", stacktrace: "4" };
+ let e3r = error.WebDriverError.fromJSON(e3j);
+ ok(e3r instanceof error.WebDriverError);
+ ok(e3r instanceof error.NoSuchElementError);
+ equal(e3r.name, "NoSuchElementError");
+ equal(e3r.status, e3j.error);
+ equal(e3r.message, e3j.message);
+ equal(e3r.stack, e3j.stacktrace);
+
+ // parity with toJSON
+ let e4j = new error.JavaScriptError("foo").toJSON();
+ let e4 = error.WebDriverError.fromJSON(e4j);
+ equal(e4j.error, e4.status);
+ equal(e4j.message, e4.message);
+ equal(e4j.stacktrace, e4.stack);
+
+ run_next_test();
+});
+
+add_test(function test_WebDriverError() {
+ let err = new error.WebDriverError("foo");
+ equal("WebDriverError", err.name);
+ equal("foo", err.message);
+ equal("webdriver error", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_ElementClickInterceptedError() {
+ let otherEl = {
+ hasAttribute: attr => attr in otherEl,
+ getAttribute: attr => (attr in otherEl ? otherEl[attr] : null),
+ nodeType: 1,
+ localName: "a",
+ };
+ let obscuredEl = {
+ hasAttribute: attr => attr in obscuredEl,
+ getAttribute: attr => (attr in obscuredEl ? obscuredEl[attr] : null),
+ nodeType: 1,
+ localName: "b",
+ ownerDocument: {
+ elementFromPoint() {
+ return otherEl;
+ },
+ },
+ style: {
+ pointerEvents: "auto",
+ },
+ };
+
+ let err1 = new error.ElementClickInterceptedError(obscuredEl, { x: 1, y: 2 });
+ equal("ElementClickInterceptedError", err1.name);
+ equal(
+ "Element <b> is not clickable at point (1,2) " +
+ "because another element <a> obscures it",
+ err1.message
+ );
+ equal("element click intercepted", err1.status);
+ ok(err1 instanceof error.WebDriverError);
+
+ obscuredEl.style.pointerEvents = "none";
+ let err2 = new error.ElementClickInterceptedError(obscuredEl, { x: 1, y: 2 });
+ equal(
+ "Element <b> is not clickable at point (1,2) " +
+ "because it does not have pointer events enabled, " +
+ "and element <a> would receive the click instead",
+ err2.message
+ );
+
+ run_next_test();
+});
+
+add_test(function test_ElementNotAccessibleError() {
+ let err = new error.ElementNotAccessibleError("foo");
+ equal("ElementNotAccessibleError", err.name);
+ equal("foo", err.message);
+ equal("element not accessible", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_ElementNotInteractableError() {
+ let err = new error.ElementNotInteractableError("foo");
+ equal("ElementNotInteractableError", err.name);
+ equal("foo", err.message);
+ equal("element not interactable", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_InsecureCertificateError() {
+ let err = new error.InsecureCertificateError("foo");
+ equal("InsecureCertificateError", err.name);
+ equal("foo", err.message);
+ equal("insecure certificate", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_InvalidArgumentError() {
+ let err = new error.InvalidArgumentError("foo");
+ equal("InvalidArgumentError", err.name);
+ equal("foo", err.message);
+ equal("invalid argument", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_InvalidCookieDomainError() {
+ let err = new error.InvalidCookieDomainError("foo");
+ equal("InvalidCookieDomainError", err.name);
+ equal("foo", err.message);
+ equal("invalid cookie domain", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_InvalidElementStateError() {
+ let err = new error.InvalidElementStateError("foo");
+ equal("InvalidElementStateError", err.name);
+ equal("foo", err.message);
+ equal("invalid element state", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_InvalidSelectorError() {
+ let err = new error.InvalidSelectorError("foo");
+ equal("InvalidSelectorError", err.name);
+ equal("foo", err.message);
+ equal("invalid selector", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_InvalidSessionIDError() {
+ let err = new error.InvalidSessionIDError("foo");
+ equal("InvalidSessionIDError", err.name);
+ equal("foo", err.message);
+ equal("invalid session id", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_JavaScriptError() {
+ let err = new error.JavaScriptError("foo");
+ equal("JavaScriptError", err.name);
+ equal("foo", err.message);
+ equal("javascript error", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ equal("", new error.JavaScriptError(undefined).message);
+
+ let superErr = new RangeError("foo");
+ let inheritedErr = new error.JavaScriptError(superErr);
+ equal("RangeError: foo", inheritedErr.message);
+ equal(superErr.stack, inheritedErr.stack);
+
+ run_next_test();
+});
+
+add_test(function test_MoveTargetOutOfBoundsError() {
+ let err = new error.MoveTargetOutOfBoundsError("foo");
+ equal("MoveTargetOutOfBoundsError", err.name);
+ equal("foo", err.message);
+ equal("move target out of bounds", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_NoSuchAlertError() {
+ let err = new error.NoSuchAlertError("foo");
+ equal("NoSuchAlertError", err.name);
+ equal("foo", err.message);
+ equal("no such alert", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_NoSuchElementError() {
+ let err = new error.NoSuchElementError("foo");
+ equal("NoSuchElementError", err.name);
+ equal("foo", err.message);
+ equal("no such element", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_NoSuchFrameError() {
+ let err = new error.NoSuchFrameError("foo");
+ equal("NoSuchFrameError", err.name);
+ equal("foo", err.message);
+ equal("no such frame", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_NoSuchWindowError() {
+ let err = new error.NoSuchWindowError("foo");
+ equal("NoSuchWindowError", err.name);
+ equal("foo", err.message);
+ equal("no such window", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_ScriptTimeoutError() {
+ let err = new error.ScriptTimeoutError("foo");
+ equal("ScriptTimeoutError", err.name);
+ equal("foo", err.message);
+ equal("script timeout", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_SessionNotCreatedError() {
+ let err = new error.SessionNotCreatedError("foo");
+ equal("SessionNotCreatedError", err.name);
+ equal("foo", err.message);
+ equal("session not created", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_StaleElementReferenceError() {
+ let err = new error.StaleElementReferenceError("foo");
+ equal("StaleElementReferenceError", err.name);
+ equal("foo", err.message);
+ equal("stale element reference", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_TimeoutError() {
+ let err = new error.TimeoutError("foo");
+ equal("TimeoutError", err.name);
+ equal("foo", err.message);
+ equal("timeout", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_UnableToSetCookieError() {
+ let err = new error.UnableToSetCookieError("foo");
+ equal("UnableToSetCookieError", err.name);
+ equal("foo", err.message);
+ equal("unable to set cookie", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_UnexpectedAlertOpenError() {
+ let err = new error.UnexpectedAlertOpenError("foo");
+ equal("UnexpectedAlertOpenError", err.name);
+ equal("foo", err.message);
+ equal("unexpected alert open", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_UnknownCommandError() {
+ let err = new error.UnknownCommandError("foo");
+ equal("UnknownCommandError", err.name);
+ equal("foo", err.message);
+ equal("unknown command", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_UnknownError() {
+ let err = new error.UnknownError("foo");
+ equal("UnknownError", err.name);
+ equal("foo", err.message);
+ equal("unknown error", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
+
+add_test(function test_UnsupportedOperationError() {
+ let err = new error.UnsupportedOperationError("foo");
+ equal("UnsupportedOperationError", err.name);
+ equal("foo", err.message);
+ equal("unsupported operation", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ run_next_test();
+});
diff --git a/testing/marionette/test/unit/test_evaluate.js b/testing/marionette/test/unit/test_evaluate.js
new file mode 100644
index 0000000000..6b426e13fa
--- /dev/null
+++ b/testing/marionette/test/unit/test_evaluate.js
@@ -0,0 +1,342 @@
+const { element, ReferenceStore, WebElement } = ChromeUtils.import(
+ "chrome://marionette/content/element.js"
+);
+const { evaluate } = ChromeUtils.import(
+ "chrome://marionette/content/evaluate.js"
+);
+
+const SVG_NS = "http://www.w3.org/2000/svg";
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+class Element {
+ constructor(tagName, attrs = {}) {
+ this.tagName = tagName;
+ this.localName = tagName;
+
+ // Set default properties
+ this.isConnected = true;
+ this.ownerDocument = { documentElement: {} };
+ this.ownerGlobal = { document: this.ownerDocument };
+
+ for (let attr in attrs) {
+ this[attr] = attrs[attr];
+ }
+ }
+
+ get nodeType() {
+ return 1;
+ }
+ get ELEMENT_NODE() {
+ return 1;
+ }
+}
+
+class DOMElement extends Element {
+ constructor(tagName, attrs = {}) {
+ super(tagName, attrs);
+ this.namespaceURI = XHTML_NS;
+ }
+}
+
+class SVGElement extends Element {
+ constructor(tagName, attrs = {}) {
+ super(tagName, attrs);
+ this.namespaceURI = SVG_NS;
+ }
+}
+
+class XULElement extends Element {
+ constructor(tagName, attrs = {}) {
+ super(tagName, attrs);
+ this.namespaceURI = XUL_NS;
+ }
+}
+
+const domEl = new DOMElement("p");
+const svgEl = new SVGElement("rect");
+const xulEl = new XULElement("browser");
+
+const domWebEl = WebElement.from(domEl);
+const svgWebEl = WebElement.from(svgEl);
+const xulWebEl = WebElement.from(xulEl);
+
+const domElId = { id: 1, browsingContextId: 4, webElRef: domWebEl.toJSON() };
+const svgElId = { id: 2, browsingContextId: 5, webElRef: svgWebEl.toJSON() };
+const xulElId = { id: 3, browsingContextId: 6, webElRef: xulWebEl.toJSON() };
+
+const seenEls = new element.Store();
+const elementIdCache = new element.ReferenceStore();
+
+add_test(function test_toJSON_types() {
+ // null
+ equal(null, evaluate.toJSON(undefined));
+ equal(null, evaluate.toJSON(null));
+
+ // primitives
+ equal(true, evaluate.toJSON(true));
+ equal(42, evaluate.toJSON(42));
+ equal("foo", evaluate.toJSON("foo"));
+
+ // collections
+ deepEqual([], evaluate.toJSON([]));
+
+ // elements
+ ok(evaluate.toJSON(domEl, seenEls) instanceof WebElement);
+ ok(evaluate.toJSON(svgEl, seenEls) instanceof WebElement);
+ ok(evaluate.toJSON(xulEl, seenEls) instanceof WebElement);
+
+ // toJSON
+ equal(
+ "foo",
+ evaluate.toJSON({
+ toJSON() {
+ return "foo";
+ },
+ })
+ );
+
+ // arbitrary object
+ deepEqual({ foo: "bar" }, evaluate.toJSON({ foo: "bar" }));
+
+ run_next_test();
+});
+
+add_test(function test_toJSON_types_ReferenceStore() {
+ // Temporarily add custom elements until xpcshell tests
+ // have access to real DOM nodes (including the Window Proxy)
+ elementIdCache.add(domElId);
+ elementIdCache.add(svgElId);
+ elementIdCache.add(xulElId);
+
+ deepEqual(evaluate.toJSON(domWebEl, elementIdCache), domElId);
+ deepEqual(evaluate.toJSON(svgWebEl, elementIdCache), svgElId);
+ deepEqual(evaluate.toJSON(xulWebEl, elementIdCache), xulElId);
+
+ Assert.throws(
+ () => evaluate.toJSON(domEl, elementIdCache),
+ /TypeError/,
+ "Reference store not usable for elements"
+ );
+
+ elementIdCache.clear();
+
+ run_next_test();
+});
+
+add_test(function test_toJSON_sequences() {
+ const input = [
+ null,
+ true,
+ [],
+ domEl,
+ {
+ toJSON() {
+ return "foo";
+ },
+ },
+ { bar: "baz" },
+ ];
+ const actual = evaluate.toJSON(input, seenEls);
+
+ equal(null, actual[0]);
+ equal(true, actual[1]);
+ deepEqual([], actual[2]);
+ ok(actual[3] instanceof WebElement);
+ equal("foo", actual[4]);
+ deepEqual({ bar: "baz" }, actual[5]);
+
+ run_next_test();
+});
+
+add_test(function test_toJSON_sequences_ReferenceStore() {
+ const input = [
+ null,
+ true,
+ [],
+ domWebEl,
+ {
+ toJSON() {
+ return "foo";
+ },
+ },
+ { bar: "baz" },
+ ];
+
+ Assert.throws(
+ () => evaluate.toJSON(input, elementIdCache),
+ /NoSuchElementError/,
+ "Expected no element"
+ );
+
+ elementIdCache.add(domElId);
+
+ const actual = evaluate.toJSON(input, elementIdCache);
+
+ equal(null, actual[0]);
+ equal(true, actual[1]);
+ deepEqual([], actual[2]);
+ deepEqual(actual[3], domElId);
+ equal("foo", actual[4]);
+ deepEqual({ bar: "baz" }, actual[5]);
+
+ elementIdCache.clear();
+
+ run_next_test();
+});
+
+add_test(function test_toJSON_objects() {
+ const input = {
+ null: null,
+ boolean: true,
+ array: [],
+ webElement: domEl,
+ elementId: domElId,
+ toJSON: {
+ toJSON() {
+ return "foo";
+ },
+ },
+ object: { bar: "baz" },
+ };
+ const actual = evaluate.toJSON(input, seenEls);
+
+ equal(null, actual.null);
+ equal(true, actual.boolean);
+ deepEqual([], actual.array);
+ ok(actual.webElement instanceof WebElement);
+ ok(actual.elementId instanceof WebElement);
+ equal("foo", actual.toJSON);
+ deepEqual({ bar: "baz" }, actual.object);
+
+ run_next_test();
+});
+
+add_test(function test_toJSON_objects_ReferenceStore() {
+ const input = {
+ null: null,
+ boolean: true,
+ array: [],
+ webElement: domWebEl,
+ elementId: domElId,
+ toJSON: {
+ toJSON() {
+ return "foo";
+ },
+ },
+ object: { bar: "baz" },
+ };
+
+ Assert.throws(
+ () => evaluate.toJSON(input, elementIdCache),
+ /NoSuchElementError/,
+ "Expected no element"
+ );
+
+ elementIdCache.add(domElId);
+
+ const actual = evaluate.toJSON(input, elementIdCache);
+
+ equal(null, actual.null);
+ equal(true, actual.boolean);
+ deepEqual([], actual.array);
+ deepEqual(actual.webElement, domElId);
+ deepEqual(actual.elementId, domElId);
+ equal("foo", actual.toJSON);
+ deepEqual({ bar: "baz" }, actual.object);
+
+ elementIdCache.clear();
+
+ run_next_test();
+});
+
+add_test(function test_fromJSON_ReferenceStore() {
+ // Add unknown element to reference store
+ let webEl = evaluate.fromJSON(domElId, elementIdCache);
+ deepEqual(webEl, domWebEl);
+ deepEqual(elementIdCache.get(webEl), domElId);
+
+ // Previously seen element is associated with original web element reference
+ const domElId2 = {
+ id: 1,
+ browsingContextId: 4,
+ webElRef: WebElement.from(domEl).toJSON(),
+ };
+ webEl = evaluate.fromJSON(domElId2, elementIdCache);
+ deepEqual(webEl, domWebEl);
+ deepEqual(elementIdCache.get(webEl), domElId);
+
+ // Store doesn't contain ElementIdentifiers
+ Assert.throws(
+ () => evaluate.fromJSON(domElId, seenEls),
+ /TypeError/,
+ "Expected element.ReferenceStore"
+ );
+
+ elementIdCache.clear();
+
+ run_next_test();
+});
+
+add_test(function test_fromJSON_Store() {
+ // Pass-through WebElements without adding it to the element store
+ let webEl = evaluate.fromJSON(domWebEl.toJSON());
+ deepEqual(webEl, domWebEl);
+ ok(!seenEls.has(domWebEl));
+
+ // Find element in the element store
+ webEl = seenEls.add(domEl);
+ const el = evaluate.fromJSON(webEl.toJSON(), seenEls);
+ deepEqual(el, domEl);
+
+ // Reference store doesn't contain web elements
+ Assert.throws(
+ () => evaluate.fromJSON(domWebEl.toJSON(), elementIdCache),
+ /TypeError/,
+ "Expected element.Store"
+ );
+
+ seenEls.clear();
+
+ run_next_test();
+});
+
+add_test(function test_isCyclic_noncyclic() {
+ for (let type of [true, 42, "foo", [], {}, null, undefined]) {
+ ok(!evaluate.isCyclic(type));
+ }
+
+ run_next_test();
+});
+
+add_test(function test_isCyclic_object() {
+ let obj = {};
+ obj.reference = obj;
+ ok(evaluate.isCyclic(obj));
+
+ run_next_test();
+});
+
+add_test(function test_isCyclic_array() {
+ let arr = [];
+ arr.push(arr);
+ ok(evaluate.isCyclic(arr));
+
+ run_next_test();
+});
+
+add_test(function test_isCyclic_arrayInObject() {
+ let arr = [];
+ arr.push(arr);
+ ok(evaluate.isCyclic({ arr }));
+
+ run_next_test();
+});
+
+add_test(function test_isCyclic_objectInArray() {
+ let obj = {};
+ obj.reference = obj;
+ ok(evaluate.isCyclic([obj]));
+
+ run_next_test();
+});
diff --git a/testing/marionette/test/unit/test_format.js b/testing/marionette/test/unit/test_format.js
new file mode 100644
index 0000000000..7cce50a231
--- /dev/null
+++ b/testing/marionette/test/unit/test_format.js
@@ -0,0 +1,118 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { pprint, truncate } = ChromeUtils.import(
+ "chrome://marionette/content/format.js"
+);
+
+const MAX_STRING_LENGTH = 250;
+const HALF = "x".repeat(MAX_STRING_LENGTH / 2);
+
+add_test(function test_pprint() {
+ equal('[object Object] {"foo":"bar"}', pprint`${{ foo: "bar" }}`);
+
+ equal("[object Number] 42", pprint`${42}`);
+ equal("[object Boolean] true", pprint`${true}`);
+ equal("[object Undefined] undefined", pprint`${undefined}`);
+ equal("[object Null] null", pprint`${null}`);
+
+ let complexObj = { toJSON: () => "foo" };
+ equal('[object Object] "foo"', pprint`${complexObj}`);
+
+ let cyclic = {};
+ cyclic.me = cyclic;
+ equal("[object Object] <cyclic object value>", pprint`${cyclic}`);
+
+ let el = {
+ hasAttribute: attr => attr in el,
+ getAttribute: attr => (attr in el ? el[attr] : null),
+ nodeType: 1,
+ localName: "input",
+ id: "foo",
+ class: "a b",
+ href: "#",
+ name: "bar",
+ src: "s",
+ type: "t",
+ };
+ equal(
+ '<input id="foo" class="a b" href="#" name="bar" src="s" type="t">',
+ pprint`${el}`
+ );
+
+ run_next_test();
+});
+
+add_test(function test_truncate_empty() {
+ equal(truncate``, "");
+ run_next_test();
+});
+
+add_test(function test_truncate_noFields() {
+ equal(truncate`foo bar`, "foo bar");
+ run_next_test();
+});
+
+add_test(function test_truncate_multipleFields() {
+ equal(truncate`${0}`, "0");
+ equal(truncate`${1}${2}${3}`, "123");
+ equal(truncate`a${1}b${2}c${3}`, "a1b2c3");
+ run_next_test();
+});
+
+add_test(function test_truncate_primitiveFields() {
+ equal(truncate`${123}`, "123");
+ equal(truncate`${true}`, "true");
+ equal(truncate`${null}`, "");
+ equal(truncate`${undefined}`, "");
+ run_next_test();
+});
+
+add_test(function test_truncate_string() {
+ equal(truncate`${"foo"}`, "foo");
+ equal(truncate`${"x".repeat(250)}`, "x".repeat(250));
+ equal(truncate`${"x".repeat(260)}`, `${HALF} ... ${HALF}`);
+ run_next_test();
+});
+
+add_test(function test_truncate_array() {
+ equal(truncate`${["foo"]}`, JSON.stringify(["foo"]));
+ equal(truncate`${"foo"} ${["bar"]}`, `foo ${JSON.stringify(["bar"])}`);
+ equal(
+ truncate`${["x".repeat(260)]}`,
+ JSON.stringify([`${HALF} ... ${HALF}`])
+ );
+
+ run_next_test();
+});
+
+add_test(function test_truncate_object() {
+ equal(truncate`${{}}`, JSON.stringify({}));
+ equal(truncate`${{ foo: "bar" }}`, JSON.stringify({ foo: "bar" }));
+ equal(
+ truncate`${{ foo: "x".repeat(260) }}`,
+ JSON.stringify({ foo: `${HALF} ... ${HALF}` })
+ );
+ equal(truncate`${{ foo: ["bar"] }}`, JSON.stringify({ foo: ["bar"] }));
+ equal(
+ truncate`${{ foo: ["bar", { baz: 42 }] }}`,
+ JSON.stringify({ foo: ["bar", { baz: 42 }] })
+ );
+
+ let complex = {
+ toString() {
+ return "hello world";
+ },
+ };
+ equal(truncate`${complex}`, "hello world");
+
+ let longComplex = {
+ toString() {
+ return "x".repeat(260);
+ },
+ };
+ equal(truncate`${longComplex}`, `${HALF} ... ${HALF}`);
+
+ run_next_test();
+});
diff --git a/testing/marionette/test/unit/test_message.js b/testing/marionette/test/unit/test_message.js
new file mode 100644
index 0000000000..4d3f09d2a5
--- /dev/null
+++ b/testing/marionette/test/unit/test_message.js
@@ -0,0 +1,277 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { error } = ChromeUtils.import("chrome://marionette/content/error.js");
+const { Command, Message, Response } = ChromeUtils.import(
+ "chrome://marionette/content/message.js"
+);
+
+add_test(function test_Message_Origin() {
+ equal(0, Message.Origin.Client);
+ equal(1, Message.Origin.Server);
+
+ run_next_test();
+});
+
+add_test(function test_Message_fromPacket() {
+ let cmd = new Command(4, "foo");
+ let resp = new Response(5, () => {});
+ resp.error = "foo";
+
+ ok(Message.fromPacket(cmd.toPacket()) instanceof Command);
+ ok(Message.fromPacket(resp.toPacket()) instanceof Response);
+ Assert.throws(
+ () => Message.fromPacket([3, 4, 5, 6]),
+ /Unrecognised message type in packet/
+ );
+
+ run_next_test();
+});
+
+add_test(function test_Command() {
+ let cmd = new Command(42, "foo", { bar: "baz" });
+ equal(42, cmd.id);
+ equal("foo", cmd.name);
+ deepEqual({ bar: "baz" }, cmd.parameters);
+ equal(null, cmd.onerror);
+ equal(null, cmd.onresult);
+ equal(Message.Origin.Client, cmd.origin);
+ equal(false, cmd.sent);
+
+ run_next_test();
+});
+
+add_test(function test_Command_onresponse() {
+ let onerrorOk = false;
+ let onresultOk = false;
+
+ let cmd = new Command(7, "foo");
+ cmd.onerror = () => (onerrorOk = true);
+ cmd.onresult = () => (onresultOk = true);
+
+ let errorResp = new Response(8, () => {});
+ errorResp.error = new error.WebDriverError("foo");
+
+ let bodyResp = new Response(9, () => {});
+ bodyResp.body = "bar";
+
+ cmd.onresponse(errorResp);
+ equal(true, onerrorOk);
+ equal(false, onresultOk);
+
+ cmd.onresponse(bodyResp);
+ equal(true, onresultOk);
+
+ run_next_test();
+});
+
+add_test(function test_Command_ctor() {
+ let cmd = new Command(42, "bar", { bar: "baz" });
+ let msg = cmd.toPacket();
+
+ equal(Command.Type, msg[0]);
+ equal(cmd.id, msg[1]);
+ equal(cmd.name, msg[2]);
+ equal(cmd.parameters, msg[3]);
+
+ run_next_test();
+});
+
+add_test(function test_Command_toString() {
+ let cmd = new Command(42, "foo", { bar: "baz" });
+ equal(JSON.stringify(cmd.toPacket()), cmd.toString());
+
+ run_next_test();
+});
+
+add_test(function test_Command_fromPacket() {
+ let c1 = new Command(42, "foo", { bar: "baz" });
+
+ let msg = c1.toPacket();
+ let c2 = Command.fromPacket(msg);
+
+ equal(c1.id, c2.id);
+ equal(c1.name, c2.name);
+ equal(c1.parameters, c2.parameters);
+
+ Assert.throws(
+ () => Command.fromPacket([null, 2, "foo", {}]),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => Command.fromPacket([1, 2, "foo", {}]),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => Command.fromPacket([0, null, "foo", {}]),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => Command.fromPacket([0, 2, null, {}]),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => Command.fromPacket([0, 2, "foo", false]),
+ /InvalidArgumentError/
+ );
+
+ let nullParams = Command.fromPacket([0, 2, "foo", null]);
+ equal(
+ "[object Object]",
+ Object.prototype.toString.call(nullParams.parameters)
+ );
+
+ run_next_test();
+});
+
+add_test(function test_Command_Type() {
+ equal(0, Command.Type);
+ run_next_test();
+});
+
+add_test(function test_Response_ctor() {
+ let handler = () => run_next_test();
+
+ let resp = new Response(42, handler);
+ equal(42, resp.id);
+ equal(null, resp.error);
+ ok("origin" in resp);
+ equal(Message.Origin.Server, resp.origin);
+ equal(false, resp.sent);
+ equal(handler, resp.respHandler_);
+
+ run_next_test();
+});
+
+add_test(function test_Response_sendConditionally() {
+ let fired = false;
+ let resp = new Response(42, () => (fired = true));
+ resp.sendConditionally(() => false);
+ equal(false, resp.sent);
+ equal(false, fired);
+ resp.sendConditionally(() => true);
+ equal(true, resp.sent);
+ equal(true, fired);
+
+ run_next_test();
+});
+
+add_test(function test_Response_send() {
+ let fired = false;
+ let resp = new Response(42, () => (fired = true));
+ resp.send();
+ equal(true, resp.sent);
+ equal(true, fired);
+
+ run_next_test();
+});
+
+add_test(function test_Response_sendError_sent() {
+ let resp = new Response(42, r => equal(false, r.sent));
+ resp.sendError(new error.WebDriverError());
+ ok(resp.sent);
+ Assert.throws(() => resp.send(), /already been sent/);
+
+ run_next_test();
+});
+
+add_test(function test_Response_sendError_body() {
+ let resp = new Response(42, r => equal(null, r.body));
+ resp.sendError(new error.WebDriverError());
+
+ run_next_test();
+});
+
+add_test(function test_Response_sendError_errorSerialisation() {
+ let err1 = new error.WebDriverError();
+ let resp1 = new Response(42);
+ resp1.sendError(err1);
+ equal(err1.status, resp1.error.error);
+ deepEqual(err1.toJSON(), resp1.error);
+
+ let err2 = new error.InvalidArgumentError();
+ let resp2 = new Response(43);
+ resp2.sendError(err2);
+ equal(err2.status, resp2.error.error);
+ deepEqual(err2.toJSON(), resp2.error);
+
+ run_next_test();
+});
+
+add_test(function test_Response_sendError_wrapInternalError() {
+ let err = new ReferenceError("foo");
+
+ // errors that originate from JavaScript (i.e. Marionette implementation
+ // issues) should be converted to UnknownError for transport
+ let resp = new Response(42, r => {
+ equal("unknown error", r.error.error);
+ equal(false, resp.sent);
+ });
+
+ // they should also throw after being sent
+ Assert.throws(() => resp.sendError(err), /foo/);
+ equal(true, resp.sent);
+
+ run_next_test();
+});
+
+add_test(function test_Response_toPacket() {
+ let resp = new Response(42, () => {});
+ let msg = resp.toPacket();
+
+ equal(Response.Type, msg[0]);
+ equal(resp.id, msg[1]);
+ equal(resp.error, msg[2]);
+ equal(resp.body, msg[3]);
+
+ run_next_test();
+});
+
+add_test(function test_Response_toString() {
+ let resp = new Response(42, () => {});
+ resp.error = "foo";
+ resp.body = "bar";
+
+ equal(JSON.stringify(resp.toPacket()), resp.toString());
+
+ run_next_test();
+});
+
+add_test(function test_Response_fromPacket() {
+ let r1 = new Response(42, () => {});
+ r1.error = "foo";
+ r1.body = "bar";
+
+ let msg = r1.toPacket();
+ let r2 = Response.fromPacket(msg);
+
+ equal(r1.id, r2.id);
+ equal(r1.error, r2.error);
+ equal(r1.body, r2.body);
+
+ Assert.throws(
+ () => Response.fromPacket([null, 2, "foo", {}]),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => Response.fromPacket([0, 2, "foo", {}]),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => Response.fromPacket([1, null, "foo", {}]),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => Response.fromPacket([1, 2, null, {}]),
+ /InvalidArgumentError/
+ );
+ Response.fromPacket([1, 2, "foo", null]);
+
+ run_next_test();
+});
+
+add_test(function test_Response_Type() {
+ equal(1, Response.Type);
+ run_next_test();
+});
diff --git a/testing/marionette/test/unit/test_modal.js b/testing/marionette/test/unit/test_modal.js
new file mode 100644
index 0000000000..0a7c365af0
--- /dev/null
+++ b/testing/marionette/test/unit/test_modal.js
@@ -0,0 +1,148 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { modal } = ChromeUtils.import("chrome://marionette/content/modal.js");
+
+const mockModalDialog = {
+ opener: {
+ ownerGlobal: "foo",
+ },
+};
+
+const mockTabModalDialog = {
+ ownerGlobal: "foo",
+};
+
+add_test(function test_addCallback() {
+ let observer = new modal.DialogObserver();
+ let cb1 = () => true;
+ let cb2 = () => false;
+
+ equal(observer.callbacks.size, 0);
+ observer.add(cb1);
+ equal(observer.callbacks.size, 1);
+ observer.add(cb1);
+ equal(observer.callbacks.size, 1);
+ observer.add(cb2);
+ equal(observer.callbacks.size, 2);
+
+ run_next_test();
+});
+
+add_test(function test_removeCallback() {
+ let observer = new modal.DialogObserver();
+ let cb1 = () => true;
+ let cb2 = () => false;
+
+ equal(observer.callbacks.size, 0);
+ observer.add(cb1);
+ observer.add(cb2);
+
+ equal(observer.callbacks.size, 2);
+ observer.remove(cb1);
+ equal(observer.callbacks.size, 1);
+ observer.remove(cb1);
+ equal(observer.callbacks.size, 1);
+ observer.remove(cb2);
+ equal(observer.callbacks.size, 0);
+
+ run_next_test();
+});
+
+add_test(function test_registerDialogClosedEventHandler() {
+ let observer = new modal.DialogObserver();
+ let mockChromeWindow = {
+ addEventListener(event, cb) {
+ equal(
+ event,
+ "DOMModalDialogClosed",
+ "registered event for closing modal"
+ );
+ equal(cb, observer, "set itself as handler");
+ run_next_test();
+ },
+ };
+
+ observer.observe(mockChromeWindow, "toplevel-window-ready");
+});
+
+add_test(function test_handleCallbackOpenModalDialog() {
+ let observer = new modal.DialogObserver();
+
+ observer.add((action, target, win) => {
+ equal(action, modal.ACTION_OPENED, "'opened' action has been passed");
+ equal(
+ target.get(),
+ mockModalDialog,
+ "weak reference has been created for target"
+ );
+ equal(
+ win,
+ mockModalDialog.opener.ownerGlobal,
+ "chrome window has been passed"
+ );
+ run_next_test();
+ });
+ observer.observe(mockModalDialog, "common-dialog-loaded");
+});
+
+add_test(function test_handleCallbackCloseModalDialog() {
+ let observer = new modal.DialogObserver();
+
+ observer.add((action, target, win) => {
+ equal(action, modal.ACTION_CLOSED, "'closed' action has been passed");
+ equal(
+ target.get(),
+ mockModalDialog,
+ "weak reference has been created for target"
+ );
+ equal(
+ win,
+ mockModalDialog.opener.ownerGlobal,
+ "chrome window has been passed"
+ );
+ run_next_test();
+ });
+ observer.handleEvent({
+ type: "DOMModalDialogClosed",
+ target: mockModalDialog,
+ });
+});
+
+add_test(function test_handleCallbackOpenTabModalDialog() {
+ let observer = new modal.DialogObserver();
+
+ observer.add((action, target, win) => {
+ equal(action, modal.ACTION_OPENED, "'opened' action has been passed");
+ equal(
+ target.get(),
+ mockTabModalDialog,
+ "weak reference has been created for target"
+ );
+ equal(win, mockTabModalDialog.ownerGlobal, "chrome window has been passed");
+ run_next_test();
+ });
+ observer.observe(mockTabModalDialog, "tabmodal-dialog-loaded");
+});
+
+add_test(function test_handleCallbackCloseTabModalDialog() {
+ let observer = new modal.DialogObserver();
+
+ observer.add((action, target, win) => {
+ equal(action, modal.ACTION_CLOSED, "'closed' action has been passed");
+ equal(
+ target.get(),
+ mockTabModalDialog,
+ "weak reference has been created for target"
+ );
+ equal(win, mockTabModalDialog.ownerGlobal, "chrome window has been passed");
+ run_next_test();
+ });
+ observer.handleEvent({
+ type: "DOMModalDialogClosed",
+ target: mockTabModalDialog,
+ });
+});
diff --git a/testing/marionette/test/unit/test_navigate.js b/testing/marionette/test/unit/test_navigate.js
new file mode 100644
index 0000000000..1298d9e14b
--- /dev/null
+++ b/testing/marionette/test/unit/test_navigate.js
@@ -0,0 +1,88 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
+
+const { navigate } = ChromeUtils.import(
+ "chrome://marionette/content/navigate.js"
+);
+
+const topContext = {
+ id: 7,
+ get top() {
+ return this;
+ },
+};
+
+const nestedContext = {
+ id: 8,
+ parent: topContext,
+ top: topContext,
+};
+
+add_test(function test_isLoadEventExpectedForCurrent() {
+ Assert.throws(
+ () => navigate.isLoadEventExpected(undefined),
+ /Expected at least one URL/
+ );
+
+ ok(navigate.isLoadEventExpected(new URL("http://a/")));
+
+ run_next_test();
+});
+
+add_test(function test_isLoadEventExpectedForFuture() {
+ const data = [
+ { current: "http://a/", future: undefined, expected: true },
+ { current: "http://a/", future: "http://a/", expected: true },
+ { current: "http://a/", future: "http://a/#", expected: true },
+ { current: "http://a/#", future: "http://a/", expected: true },
+ { current: "http://a/#a", future: "http://a/#A", expected: true },
+ { current: "http://a/#a", future: "http://a/#a", expected: false },
+ { current: "http://a/", future: "javascript:whatever", expected: false },
+ ];
+
+ for (const entry of data) {
+ const current = new URL(entry.current);
+ const future = entry.future ? new URL(entry.future) : undefined;
+ equal(navigate.isLoadEventExpected(current, { future }), entry.expected);
+ }
+
+ run_next_test();
+});
+
+add_test(function test_isLoadEventExpectedForTarget() {
+ for (const target of ["_parent", "_top"]) {
+ Assert.throws(
+ () => navigate.isLoadEventExpected(new URL("http://a"), { target }),
+ /Expected browsingContext when target is _parent or _top/
+ );
+ }
+
+ const data = [
+ { cur: "http://a/", target: "", expected: true },
+ { cur: "http://a/", target: "_blank", expected: false },
+ { cur: "http://a/", target: "_parent", bc: topContext, expected: true },
+ { cur: "http://a/", target: "_parent", bc: nestedContext, expected: false },
+ { cur: "http://a/", target: "_self", expected: true },
+ { cur: "http://a/", target: "_top", bc: topContext, expected: true },
+ { cur: "http://a/", target: "_top", bc: nestedContext, expected: false },
+ ];
+
+ for (const entry of data) {
+ const current = entry.cur ? new URL(entry.cur) : undefined;
+ equal(
+ navigate.isLoadEventExpected(current, {
+ target: entry.target,
+ browsingContext: entry.bc,
+ }),
+ entry.expected
+ );
+ }
+
+ run_next_test();
+});
diff --git a/testing/marionette/test/unit/test_prefs.js b/testing/marionette/test/unit/test_prefs.js
new file mode 100644
index 0000000000..cd3f38a657
--- /dev/null
+++ b/testing/marionette/test/unit/test_prefs.js
@@ -0,0 +1,133 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm");
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "env",
+ "@mozilla.org/process/environment;1",
+ "nsIEnvironment"
+);
+
+const { Branch, EnvironmentPrefs, MarionettePrefs } = ChromeUtils.import(
+ "chrome://marionette/content/prefs.js",
+ null
+);
+
+function reset() {
+ Services.prefs.setBoolPref("test.bool", false);
+ Services.prefs.setStringPref("test.string", "foo");
+ Services.prefs.setIntPref("test.int", 777);
+}
+
+// Give us something to work with:
+reset();
+
+add_test(function test_Branch_get_root() {
+ let root = new Branch(null);
+ equal(false, root.get("test.bool"));
+ equal("foo", root.get("test.string"));
+ equal(777, root.get("test.int"));
+ Assert.throws(() => root.get("doesnotexist"), /TypeError/);
+
+ run_next_test();
+});
+
+add_test(function test_Branch_get_branch() {
+ let test = new Branch("test.");
+ equal(false, test.get("bool"));
+ equal("foo", test.get("string"));
+ equal(777, test.get("int"));
+ Assert.throws(() => test.get("doesnotexist"), /TypeError/);
+
+ run_next_test();
+});
+
+add_test(function test_Branch_set_root() {
+ let root = new Branch(null);
+
+ try {
+ root.set("test.string", "bar");
+ root.set("test.in", 777);
+ root.set("test.bool", true);
+
+ equal("bar", Services.prefs.getStringPref("test.string"));
+ equal(777, Services.prefs.getIntPref("test.int"));
+ equal(true, Services.prefs.getBoolPref("test.bool"));
+ } finally {
+ reset();
+ }
+
+ run_next_test();
+});
+
+add_test(function test_Branch_set_branch() {
+ let test = new Branch("test.");
+
+ try {
+ test.set("string", "bar");
+ test.set("int", 888);
+ test.set("bool", true);
+
+ equal("bar", Services.prefs.getStringPref("test.string"));
+ equal(888, Services.prefs.getIntPref("test.int"));
+ equal(true, Services.prefs.getBoolPref("test.bool"));
+ } finally {
+ reset();
+ }
+
+ run_next_test();
+});
+
+add_test(function test_EnvironmentPrefs_from() {
+ let prefsTable = {
+ "test.bool": true,
+ "test.int": 888,
+ "test.string": "bar",
+ };
+ env.set("FOO", JSON.stringify(prefsTable));
+
+ try {
+ for (let [key, value] of EnvironmentPrefs.from("FOO")) {
+ equal(prefsTable[key], value);
+ }
+ } finally {
+ env.set("FOO", null);
+ }
+
+ run_next_test();
+});
+
+add_test(function test_MarionettePrefs_getters() {
+ equal(false, MarionettePrefs.enabled);
+ equal(false, MarionettePrefs.clickToStart);
+ equal(false, MarionettePrefs.contentListener);
+ equal(2828, MarionettePrefs.port);
+ equal(Log.Level.Info, MarionettePrefs.logLevel);
+ equal(true, MarionettePrefs.recommendedPrefs);
+
+ run_next_test();
+});
+
+add_test(function test_MarionettePrefs_setters() {
+ try {
+ MarionettePrefs.contentListener = true;
+ MarionettePrefs.port = 777;
+ equal(true, MarionettePrefs.contentListener);
+ equal(777, MarionettePrefs.port);
+ } finally {
+ Services.prefs.clearUserPref("marionette.contentListener");
+ Services.prefs.clearUserPref("marionette.port");
+ }
+
+ run_next_test();
+});
diff --git a/testing/marionette/test/unit/test_store.js b/testing/marionette/test/unit/test_store.js
new file mode 100644
index 0000000000..81a51b577c
--- /dev/null
+++ b/testing/marionette/test/unit/test_store.js
@@ -0,0 +1,220 @@
+const { element, ReferenceStore, WebElement } = ChromeUtils.import(
+ "chrome://marionette/content/element.js"
+);
+
+const SVG_NS = "http://www.w3.org/2000/svg";
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+class Element {
+ constructor(tagName, attrs = {}) {
+ this.tagName = tagName;
+ this.localName = tagName;
+
+ // Set default properties
+ this.isConnected = true;
+ this.ownerDocument = {};
+ this.ownerGlobal = { document: this.ownerDocument };
+
+ for (let attr in attrs) {
+ this[attr] = attrs[attr];
+ }
+ }
+
+ get nodeType() {
+ return 1;
+ }
+ get ELEMENT_NODE() {
+ return 1;
+ }
+}
+
+class DOMElement extends Element {
+ constructor(tagName, attrs = {}) {
+ super(tagName, attrs);
+ this.namespaceURI = XHTML_NS;
+ this.ownerDocument = { documentElement: { namespaceURI: XHTML_NS } };
+ }
+}
+
+class SVGElement extends Element {
+ constructor(tagName, attrs = {}) {
+ super(tagName, attrs);
+ this.namespaceURI = SVG_NS;
+ this.ownerDocument = { documentElement: { namespaceURI: SVG_NS } };
+ }
+}
+
+class XULElement extends Element {
+ constructor(tagName, attrs = {}) {
+ super(tagName, attrs);
+ this.namespaceURI = XUL_NS;
+ this.ownerDocument = { documentElement: { namespaceURI: XUL_NS } };
+ }
+}
+
+function makeIterator(items) {
+ return function*() {
+ for (const i of items) {
+ yield i;
+ }
+ };
+}
+
+const nestedBrowsingContext = {
+ id: 7,
+ getAllBrowsingContextsInSubtree: makeIterator([
+ { id: 7 },
+ { id: 71 },
+ { id: 72 },
+ ]),
+};
+
+const domEl = new DOMElement("p");
+const svgEl = new SVGElement("rect");
+const xulEl = new XULElement("browser");
+const frameEl = new DOMElement("iframe");
+const innerEl = new DOMElement("p", { id: "inner" });
+
+const domWebEl = WebElement.from(domEl);
+const svgWebEl = WebElement.from(svgEl);
+const xulWebEl = WebElement.from(xulEl);
+const frameWebEl = WebElement.from(frameEl);
+const innerWebEl = WebElement.from(innerEl);
+
+const domElId = { id: 1, browsingContextId: 4, webElRef: domWebEl.toJSON() };
+const svgElId = { id: 2, browsingContextId: 15, webElRef: svgWebEl.toJSON() };
+const xulElId = { id: 3, browsingContextId: 15, webElRef: xulWebEl.toJSON() };
+const frameElId = {
+ id: 10,
+ browsingContextId: 7,
+ webElRef: frameWebEl.toJSON(),
+};
+const innerElId = {
+ id: 11,
+ browsingContextId: 72,
+ webElRef: innerWebEl.toJSON(),
+};
+
+const elementIdCache = new element.ReferenceStore();
+
+registerCleanupFunction(() => {
+ elementIdCache.clear();
+});
+
+add_test(function test_add_element() {
+ elementIdCache.add(domElId);
+ equal(elementIdCache.refs.size, 1);
+ equal(elementIdCache.domRefs.size, 1);
+ deepEqual(elementIdCache.refs.get(domWebEl.uuid), domElId);
+ deepEqual(elementIdCache.domRefs.get(domElId.id), domWebEl.toJSON());
+
+ elementIdCache.add(domElId);
+ equal(elementIdCache.refs.size, 1);
+ equal(elementIdCache.domRefs.size, 1);
+
+ elementIdCache.add(xulElId);
+ equal(elementIdCache.refs.size, 2);
+ equal(elementIdCache.domRefs.size, 2);
+
+ elementIdCache.clear();
+ equal(elementIdCache.refs.size, 0);
+ equal(elementIdCache.domRefs.size, 0);
+
+ run_next_test();
+});
+
+add_test(function test_get_element() {
+ elementIdCache.add(domElId);
+ deepEqual(elementIdCache.get(domWebEl), domElId);
+
+ run_next_test();
+});
+
+add_test(function test_get_no_such_element() {
+ throws(() => elementIdCache.get(frameWebEl), /NoSuchElementError/);
+
+ elementIdCache.add(domElId);
+ throws(() => elementIdCache.get(frameWebEl), /NoSuchElementError/);
+
+ run_next_test();
+});
+
+add_test(function test_clear_by_unknown_browsing_context() {
+ const unknownContext = {
+ id: 1000,
+ getAllBrowsingContextsInSubtree: makeIterator([{ id: 1000 }]),
+ };
+ elementIdCache.add(domElId);
+ elementIdCache.add(svgElId);
+ elementIdCache.add(xulElId);
+ elementIdCache.add(frameElId);
+ elementIdCache.add(innerElId);
+
+ equal(elementIdCache.refs.size, 5);
+ equal(elementIdCache.domRefs.size, 5);
+
+ elementIdCache.clear(unknownContext);
+
+ equal(elementIdCache.refs.size, 5);
+ equal(elementIdCache.domRefs.size, 5);
+
+ run_next_test();
+});
+
+add_test(function test_clear_by_known_browsing_context() {
+ const context = {
+ id: 15,
+ getAllBrowsingContextsInSubtree: makeIterator([{ id: 15 }]),
+ };
+ const anotherContext = {
+ id: 4,
+ getAllBrowsingContextsInSubtree: makeIterator([{ id: 4 }]),
+ };
+ elementIdCache.add(domElId);
+ elementIdCache.add(svgElId);
+ elementIdCache.add(xulElId);
+ elementIdCache.add(frameElId);
+ elementIdCache.add(innerElId);
+
+ equal(elementIdCache.refs.size, 5);
+ equal(elementIdCache.domRefs.size, 5);
+
+ elementIdCache.clear(context);
+
+ equal(elementIdCache.refs.size, 3);
+ equal(elementIdCache.domRefs.size, 3);
+ ok(elementIdCache.has(domWebEl));
+ ok(!elementIdCache.has(svgWebEl));
+ ok(!elementIdCache.has(xulWebEl));
+
+ elementIdCache.clear(anotherContext);
+
+ equal(elementIdCache.refs.size, 2);
+ equal(elementIdCache.domRefs.size, 2);
+ ok(!elementIdCache.has(domWebEl));
+
+ run_next_test();
+});
+
+add_test(function test_clear_by_nested_browsing_context() {
+ elementIdCache.add(domElId);
+ elementIdCache.add(svgElId);
+ elementIdCache.add(xulElId);
+ elementIdCache.add(frameElId);
+ elementIdCache.add(innerElId);
+
+ equal(elementIdCache.refs.size, 5);
+ equal(elementIdCache.domRefs.size, 5);
+
+ elementIdCache.clear(nestedBrowsingContext);
+
+ equal(elementIdCache.refs.size, 3);
+ equal(elementIdCache.domRefs.size, 3);
+
+ ok(elementIdCache.has(domWebEl));
+ ok(!elementIdCache.has(frameWebEl));
+ ok(!elementIdCache.has(innerWebEl));
+
+ run_next_test();
+});
diff --git a/testing/marionette/test/unit/test_sync.js b/testing/marionette/test/unit/test_sync.js
new file mode 100644
index 0000000000..4120cafe91
--- /dev/null
+++ b/testing/marionette/test/unit/test_sync.js
@@ -0,0 +1,521 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+const {
+ DebounceCallback,
+ IdlePromise,
+ PollPromise,
+ Sleep,
+ TimedPromise,
+ waitForEvent,
+ waitForLoadEvent,
+ waitForMessage,
+ waitForObserverTopic,
+} = ChromeUtils.import("chrome://marionette/content/sync.js");
+
+const { EventDispatcher } = ChromeUtils.import(
+ "chrome://marionette/content/actors/MarionetteEventsParent.jsm"
+);
+
+/**
+ * Mimic a DOM node for listening for events.
+ */
+class MockElement {
+ constructor() {
+ this.capture = false;
+ this.func = null;
+ this.eventName = null;
+ this.untrusted = false;
+ }
+
+ addEventListener(name, func, capture, untrusted) {
+ this.eventName = name;
+ this.func = func;
+ if (capture != null) {
+ this.capture = capture;
+ }
+ if (untrusted != null) {
+ this.untrusted = untrusted;
+ }
+ }
+
+ click() {
+ if (this.func) {
+ let details = {
+ capture: this.capture,
+ target: this,
+ type: this.eventName,
+ untrusted: this.untrusted,
+ };
+ this.func(details);
+ }
+ }
+
+ removeEventListener(name, func) {
+ this.capture = false;
+ this.func = null;
+ this.eventName = null;
+ this.untrusted = false;
+ }
+}
+
+/**
+ * Mimic a message manager for sending messages.
+ */
+class MessageManager {
+ constructor() {
+ this.func = null;
+ this.message = null;
+ }
+
+ addMessageListener(message, func) {
+ this.func = func;
+ this.message = message;
+ }
+
+ removeMessageListener(message) {
+ this.func = null;
+ this.message = null;
+ }
+
+ send(message, data) {
+ if (this.func) {
+ this.func({
+ data,
+ message,
+ target: this,
+ });
+ }
+ }
+}
+
+/**
+ * Mimics nsITimer, but instead of using a system clock you can
+ * preprogram it to invoke the callback after a given number of ticks.
+ */
+class MockTimer {
+ constructor(ticksBeforeFiring) {
+ this.goal = ticksBeforeFiring;
+ this.ticks = 0;
+ this.cancelled = false;
+ }
+
+ initWithCallback(cb, timeout, type) {
+ this.ticks++;
+ if (this.ticks >= this.goal) {
+ cb();
+ }
+ }
+
+ cancel() {
+ this.cancelled = true;
+ }
+}
+
+add_test(function test_executeSoon_callback() {
+ // executeSoon() is already defined for xpcshell in head.js. As such import
+ // our implementation into a custom namespace.
+ let sync = {};
+ ChromeUtils.import("chrome://marionette/content/sync.js", sync);
+
+ for (let func of ["foo", null, true, [], {}]) {
+ Assert.throws(() => sync.executeSoon(func), /TypeError/);
+ }
+
+ let a;
+ sync.executeSoon(() => {
+ a = 1;
+ });
+ executeSoon(() => equal(1, a));
+
+ run_next_test();
+});
+
+add_test(function test_PollPromise_funcTypes() {
+ for (let type of ["foo", 42, null, undefined, true, [], {}]) {
+ Assert.throws(() => new PollPromise(type), /TypeError/);
+ }
+ new PollPromise(() => {});
+ new PollPromise(function() {});
+
+ run_next_test();
+});
+
+add_test(function test_PollPromise_timeoutTypes() {
+ for (let timeout of ["foo", true, [], {}]) {
+ Assert.throws(() => new PollPromise(() => {}, { timeout }), /TypeError/);
+ }
+ for (let timeout of [1.2, -1]) {
+ Assert.throws(() => new PollPromise(() => {}, { timeout }), /RangeError/);
+ }
+ for (let timeout of [null, undefined, 42]) {
+ new PollPromise(resolve => resolve(1), { timeout });
+ }
+
+ run_next_test();
+});
+
+add_test(function test_PollPromise_intervalTypes() {
+ for (let interval of ["foo", null, true, [], {}]) {
+ Assert.throws(() => new PollPromise(() => {}, { interval }), /TypeError/);
+ }
+ for (let interval of [1.2, -1]) {
+ Assert.throws(() => new PollPromise(() => {}, { interval }), /RangeError/);
+ }
+ new PollPromise(() => {}, { interval: 42 });
+
+ run_next_test();
+});
+
+add_task(async function test_PollPromise_retvalTypes() {
+ for (let typ of [true, false, "foo", 42, [], {}]) {
+ strictEqual(typ, await new PollPromise(resolve => resolve(typ)));
+ }
+});
+
+add_task(async function test_PollPromise_rethrowError() {
+ let nevals = 0;
+ let err;
+ try {
+ await PollPromise(() => {
+ ++nevals;
+ throw new Error();
+ });
+ } catch (e) {
+ err = e;
+ }
+ equal(1, nevals);
+ ok(err instanceof Error);
+});
+
+add_task(async function test_PollPromise_noTimeout() {
+ let nevals = 0;
+ await new PollPromise((resolve, reject) => {
+ ++nevals;
+ nevals < 100 ? reject() : resolve();
+ });
+ equal(100, nevals);
+});
+
+add_task(async function test_PollPromise_zeroTimeout() {
+ // run at least once when timeout is 0
+ let nevals = 0;
+ let start = new Date().getTime();
+ await new PollPromise(
+ (resolve, reject) => {
+ ++nevals;
+ reject();
+ },
+ { timeout: 0 }
+ );
+ let end = new Date().getTime();
+ equal(1, nevals);
+ less(end - start, 500);
+});
+
+add_task(async function test_PollPromise_timeoutElapse() {
+ let nevals = 0;
+ let start = new Date().getTime();
+ await new PollPromise(
+ (resolve, reject) => {
+ ++nevals;
+ reject();
+ },
+ { timeout: 100 }
+ );
+ let end = new Date().getTime();
+ lessOrEqual(nevals, 11);
+ greaterOrEqual(end - start, 100);
+});
+
+add_task(async function test_PollPromise_interval() {
+ let nevals = 0;
+ await new PollPromise(
+ (resolve, reject) => {
+ ++nevals;
+ reject();
+ },
+ { timeout: 100, interval: 100 }
+ );
+ equal(2, nevals);
+});
+
+add_test(function test_TimedPromise_funcTypes() {
+ for (let type of ["foo", 42, null, undefined, true, [], {}]) {
+ Assert.throws(() => new TimedPromise(type), /TypeError/);
+ }
+ new TimedPromise(resolve => resolve());
+ new TimedPromise(function(resolve) {
+ resolve();
+ });
+
+ run_next_test();
+});
+
+add_test(function test_TimedPromise_timeoutTypes() {
+ for (let timeout of ["foo", null, true, [], {}]) {
+ Assert.throws(
+ () => new TimedPromise(resolve => resolve(), { timeout }),
+ /TypeError/
+ );
+ }
+ for (let timeout of [1.2, -1]) {
+ Assert.throws(
+ () => new TimedPromise(resolve => resolve(), { timeout }),
+ /RangeError/
+ );
+ }
+ new TimedPromise(resolve => resolve(), { timeout: 42 });
+
+ run_next_test();
+});
+
+add_task(async function test_Sleep() {
+ await Sleep(0);
+ for (let type of ["foo", true, null, undefined]) {
+ Assert.throws(() => new Sleep(type), /TypeError/);
+ }
+ Assert.throws(() => new Sleep(1.2), /RangeError/);
+ Assert.throws(() => new Sleep(-1), /RangeError/);
+});
+
+add_task(async function test_IdlePromise() {
+ let called = false;
+ let win = {
+ requestAnimationFrame(callback) {
+ called = true;
+ callback();
+ },
+ };
+ await IdlePromise(win);
+ ok(called);
+});
+
+add_task(async function test_IdlePromiseAbortWhenWindowClosed() {
+ let win = {
+ closed: true,
+ requestAnimationFrame() {},
+ };
+ await IdlePromise(win);
+});
+
+add_test(function test_DebounceCallback_constructor() {
+ for (let cb of [42, "foo", true, null, undefined, [], {}]) {
+ Assert.throws(() => new DebounceCallback(cb), /TypeError/);
+ }
+ for (let timeout of ["foo", true, [], {}, () => {}]) {
+ Assert.throws(
+ () => new DebounceCallback(() => {}, { timeout }),
+ /TypeError/
+ );
+ }
+ for (let timeout of [-1, 2.3, NaN]) {
+ Assert.throws(
+ () => new DebounceCallback(() => {}, { timeout }),
+ /RangeError/
+ );
+ }
+
+ run_next_test();
+});
+
+add_task(async function test_DebounceCallback_repeatedCallback() {
+ let uniqueEvent = {};
+ let ncalls = 0;
+
+ let cb = ev => {
+ ncalls++;
+ equal(ev, uniqueEvent);
+ };
+ let debouncer = new DebounceCallback(cb);
+ debouncer.timer = new MockTimer(3);
+
+ // flood the debouncer with events,
+ // we only expect the last one to fire
+ debouncer.handleEvent(uniqueEvent);
+ debouncer.handleEvent(uniqueEvent);
+ debouncer.handleEvent(uniqueEvent);
+
+ equal(ncalls, 1);
+ ok(debouncer.timer.cancelled);
+});
+
+add_task(async function test_waitForEvent_subjectAndEventNameTypes() {
+ let element = new MockElement();
+
+ for (let subject of ["foo", 42, null, undefined, true, [], {}]) {
+ Assert.throws(() => waitForEvent(subject, "click"), /TypeError/);
+ }
+
+ for (let eventName of [42, null, undefined, true, [], {}]) {
+ Assert.throws(() => waitForEvent(element, eventName), /TypeError/);
+ }
+
+ let clicked = waitForEvent(element, "click");
+ element.click();
+ let event = await clicked;
+ equal(element, event.target);
+});
+
+add_task(async function test_waitForEvent_captureTypes() {
+ let element = new MockElement();
+
+ for (let capture of ["foo", 42, [], {}]) {
+ Assert.throws(
+ () => waitForEvent(element, "click", { capture }),
+ /TypeError/
+ );
+ }
+
+ for (let capture of [null, undefined, false, true]) {
+ let expected_capture = capture == null ? false : capture;
+
+ element = new MockElement();
+ let clicked = waitForEvent(element, "click", { capture });
+ element.click();
+ let event = await clicked;
+ equal(element, event.target);
+ equal(expected_capture, event.capture);
+ }
+});
+
+add_task(async function test_waitForEvent_checkFnTypes() {
+ let element = new MockElement();
+
+ for (let checkFn of ["foo", 42, true, [], {}]) {
+ Assert.throws(
+ () => waitForEvent(element, "click", { checkFn }),
+ /TypeError/
+ );
+ }
+
+ let count;
+ for (let checkFn of [null, undefined, event => count++ > 0]) {
+ let expected_count = checkFn == null ? 0 : 2;
+ count = 0;
+
+ element = new MockElement();
+ let clicked = waitForEvent(element, "click", { checkFn });
+ element.click();
+ element.click();
+ let event = await clicked;
+ equal(element, event.target);
+ equal(expected_count, count);
+ }
+});
+
+add_task(async function test_waitForEvent_wantsUntrustedTypes() {
+ let element = new MockElement();
+
+ for (let wantsUntrusted of ["foo", 42, [], {}]) {
+ Assert.throws(
+ () => waitForEvent(element, "click", { wantsUntrusted }),
+ /TypeError/
+ );
+ }
+
+ for (let wantsUntrusted of [null, undefined, false, true]) {
+ let expected_untrusted = wantsUntrusted == null ? false : wantsUntrusted;
+
+ element = new MockElement();
+ let clicked = waitForEvent(element, "click", { wantsUntrusted });
+ element.click();
+ let event = await clicked;
+ equal(element, event.target);
+ equal(expected_untrusted, event.untrusted);
+ }
+});
+
+add_task(async function test_waitForLoadEvent() {
+ const mockBrowsingContext = {};
+ const onLoad = waitForLoadEvent("pageshow", () => mockBrowsingContext);
+
+ // Fake a page load by emitting the expected event on the EventDispatcher.
+ EventDispatcher.emit("page-load", {
+ type: "pageshow",
+ browsingContext: mockBrowsingContext,
+ });
+
+ const loadEvent = await onLoad;
+ equal(loadEvent.type, "pageshow");
+ equal(loadEvent.browsingContext, mockBrowsingContext);
+});
+
+add_task(async function test_waitForMessage_messageManagerAndMessageTypes() {
+ let messageManager = new MessageManager();
+
+ for (let manager of ["foo", 42, null, undefined, true, [], {}]) {
+ Assert.throws(() => waitForMessage(manager, "message"), /TypeError/);
+ }
+
+ for (let message of [42, null, undefined, true, [], {}]) {
+ Assert.throws(() => waitForEvent(messageManager, message), /TypeError/);
+ }
+
+ let data = { foo: "bar" };
+ let sent = waitForMessage(messageManager, "message");
+ messageManager.send("message", data);
+ equal(data, await sent);
+});
+
+add_task(async function test_waitForMessage_checkFnTypes() {
+ let messageManager = new MessageManager();
+
+ for (let checkFn of ["foo", 42, true, [], {}]) {
+ Assert.throws(
+ () => waitForMessage(messageManager, "message", { checkFn }),
+ /TypeError/
+ );
+ }
+
+ let data1 = { fo: "bar" };
+ let data2 = { foo: "bar" };
+
+ for (let checkFn of [null, undefined, msg => "foo" in msg.data]) {
+ let expected_data = checkFn == null ? data1 : data2;
+
+ messageManager = new MessageManager();
+ let sent = waitForMessage(messageManager, "message", { checkFn });
+ messageManager.send("message", data1);
+ messageManager.send("message", data2);
+ equal(expected_data, await sent);
+ }
+});
+
+add_task(async function test_waitForObserverTopic_topicTypes() {
+ for (let topic of [42, null, undefined, true, [], {}]) {
+ Assert.throws(() => waitForObserverTopic(topic), /TypeError/);
+ }
+
+ let data = { foo: "bar" };
+ let sent = waitForObserverTopic("message");
+ Services.obs.notifyObservers(this, "message", data);
+ let result = await sent;
+ equal(this, result.subject);
+ equal(data, result.data);
+});
+
+add_task(async function test_waitForObserverTopic_checkFnTypes() {
+ for (let checkFn of ["foo", 42, true, [], {}]) {
+ Assert.throws(
+ () => waitForObserverTopic("message", { checkFn }),
+ /TypeError/
+ );
+ }
+
+ let data1 = { fo: "bar" };
+ let data2 = { foo: "bar" };
+
+ for (let checkFn of [null, undefined, (subject, data) => data == data2]) {
+ let expected_data = checkFn == null ? data1 : data2;
+
+ let sent = waitForObserverTopic("message");
+ Services.obs.notifyObservers(this, "message", data1);
+ Services.obs.notifyObservers(this, "message", data2);
+ let result = await sent;
+ equal(expected_data, result.data);
+ }
+});
diff --git a/testing/marionette/test/unit/xpcshell.ini b/testing/marionette/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..d804ec6d39
--- /dev/null
+++ b/testing/marionette/test/unit/xpcshell.ini
@@ -0,0 +1,24 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+[DEFAULT]
+skip-if = appname == "thunderbird"
+
+[test_action.js]
+[test_actors.js]
+[test_assert.js]
+[test_browser.js]
+[test_capabilities.js]
+[test_cookie.js]
+[test_dom.js]
+[test_element.js]
+[test_error.js]
+[test_evaluate.js]
+[test_format.js]
+[test_message.js]
+[test_modal.js]
+[test_navigate.js]
+[test_prefs.js]
+[test_store.js]
+[test_sync.js]
diff --git a/testing/marionette/transport.js b/testing/marionette/transport.js
new file mode 100644
index 0000000000..16e87bad79
--- /dev/null
+++ b/testing/marionette/transport.js
@@ -0,0 +1,537 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["DebuggerTransport"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ EventEmitter: "resource://gre/modules/EventEmitter.jsm",
+
+ BulkPacket: "chrome://marionette/content/packets.js",
+ executeSoon: "chrome://marionette/content/sync.js",
+ JSONPacket: "chrome://marionette/content/packets.js",
+ Packet: "chrome://marionette/content/packets.js",
+ StreamUtils: "chrome://marionette/content/stream-utils.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "Pipe", () => {
+ return Components.Constructor("@mozilla.org/pipe;1", "nsIPipe", "init");
+});
+
+XPCOMUtils.defineLazyGetter(this, "ScriptableInputStream", () => {
+ return Components.Constructor(
+ "@mozilla.org/scriptableinputstream;1",
+ "nsIScriptableInputStream",
+ "init"
+ );
+});
+
+const flags = { wantVerbose: false, wantLogging: false };
+
+const dumpv = flags.wantVerbose
+ ? function(msg) {
+ dump(msg + "\n");
+ }
+ : function() {};
+
+const PACKET_HEADER_MAX = 200;
+
+/**
+ * An adapter that handles data transfers between the debugger client
+ * and server. It can work with both nsIPipe and nsIServerSocket
+ * transports so long as the properly created input and output streams
+ * are specified. (However, for intra-process connections,
+ * LocalDebuggerTransport, below, is more efficient than using an nsIPipe
+ * pair with DebuggerTransport.)
+ *
+ * @param {nsIAsyncInputStream} input
+ * The input stream.
+ * @param {nsIAsyncOutputStream} output
+ * The output stream.
+ *
+ * Given a DebuggerTransport instance dt:
+ * 1) Set dt.hooks to a packet handler object (described below).
+ * 2) Call dt.ready() to begin watching for input packets.
+ * 3) Call dt.send() / dt.startBulkSend() to send packets.
+ * 4) Call dt.close() to close the connection, and disengage from
+ * the event loop.
+ *
+ * A packet handler is an object with the following methods:
+ *
+ * - onPacket(packet) - called when we have received a complete packet.
+ * |packet| is the parsed form of the packet --- a JavaScript value, not
+ * a JSON-syntax string.
+ *
+ * - onBulkPacket(packet) - called when we have switched to bulk packet
+ * receiving mode. |packet| is an object containing:
+ * * actor: Name of actor that will receive the packet
+ * * type: Name of actor's method that should be called on receipt
+ * * length: Size of the data to be read
+ * * stream: This input stream should only be used directly if you
+ * can ensure that you will read exactly |length| bytes and
+ * will not close the stream when reading is complete
+ * * done: If you use the stream directly (instead of |copyTo|
+ * below), you must signal completion by resolving/rejecting
+ * this deferred. If it's rejected, the transport will
+ * be closed. If an Error is supplied as a rejection value,
+ * it will be logged via |dump|. If you do use |copyTo|,
+ * resolving is taken care of for you when copying completes.
+ * * copyTo: A helper function for getting your data out of the
+ * stream that meets the stream handling requirements above,
+ * and has the following signature:
+ *
+ * @param nsIAsyncOutputStream {output}
+ * The stream to copy to.
+ *
+ * @return {Promise}
+ * The promise is resolved when copying completes or
+ * rejected if any (unexpected) errors occur. This object
+ * also emits "progress" events for each chunk that is
+ * copied. See stream-utils.js.
+ *
+ * - onClosed(reason) - called when the connection is closed. |reason|
+ * is an optional nsresult or object, typically passed when the
+ * transport is closed due to some error in a underlying stream.
+ *
+ * See ./packets.js and the Remote Debugging Protocol specification for
+ * more details on the format of these packets.
+ *
+ * @class
+ */
+function DebuggerTransport(input, output) {
+ EventEmitter.decorate(this);
+
+ this._input = input;
+ this._scriptableInput = new ScriptableInputStream(input);
+ this._output = output;
+
+ // The current incoming (possibly partial) header, which will determine
+ // which type of Packet |_incoming| below will become.
+ this._incomingHeader = "";
+ // The current incoming Packet object
+ this._incoming = null;
+ // A queue of outgoing Packet objects
+ this._outgoing = [];
+
+ this.hooks = null;
+ this.active = false;
+
+ this._incomingEnabled = true;
+ this._outgoingEnabled = true;
+
+ this.close = this.close.bind(this);
+}
+
+DebuggerTransport.prototype = {
+ /**
+ * Transmit an object as a JSON packet.
+ *
+ * This method returns immediately, without waiting for the entire
+ * packet to be transmitted, registering event handlers as needed to
+ * transmit the entire packet. Packets are transmitted in the order they
+ * are passed to this method.
+ */
+ send(object) {
+ this.emit("send", object);
+
+ let packet = new JSONPacket(this);
+ packet.object = object;
+ this._outgoing.push(packet);
+ this._flushOutgoing();
+ },
+
+ /**
+ * Transmit streaming data via a bulk packet.
+ *
+ * This method initiates the bulk send process by queuing up the header
+ * data. The caller receives eventual access to a stream for writing.
+ *
+ * N.B.: Do *not* attempt to close the stream handed to you, as it
+ * will continue to be used by this transport afterwards. Most users
+ * should instead use the provided |copyFrom| function instead.
+ *
+ * @param {Object} header
+ * This is modeled after the format of JSON packets above, but does
+ * not actually contain the data, but is instead just a routing
+ * header:
+ *
+ * - actor: Name of actor that will receive the packet
+ * - type: Name of actor's method that should be called on receipt
+ * - length: Size of the data to be sent
+ *
+ * @return {Promise}
+ * The promise will be resolved when you are allowed to write to
+ * the stream with an object containing:
+ *
+ * - stream: This output stream should only be used directly
+ * if you can ensure that you will write exactly
+ * |length| bytes and will not close the stream when
+ * writing is complete.
+ * - done: If you use the stream directly (instead of
+ * |copyFrom| below), you must signal completion by
+ * resolving/rejecting this deferred. If it's
+ * rejected, the transport will be closed. If an
+ * Error is supplied as a rejection value, it will
+ * be logged via |dump|. If you do use |copyFrom|,
+ * resolving is taken care of for you when copying
+ * completes.
+ * - copyFrom: A helper function for getting your data onto the
+ * stream that meets the stream handling requirements
+ * above, and has the following signature:
+ *
+ * @param {nsIAsyncInputStream} input
+ * The stream to copy from.
+ *
+ * @return {Promise}
+ * The promise is resolved when copying completes
+ * or rejected if any (unexpected) errors occur.
+ * This object also emits "progress" events for
+ * each chunkthat is copied. See stream-utils.js.
+ */
+ startBulkSend(header) {
+ this.emit("startbulksend", header);
+
+ let packet = new BulkPacket(this);
+ packet.header = header;
+ this._outgoing.push(packet);
+ this._flushOutgoing();
+ return packet.streamReadyForWriting;
+ },
+
+ /**
+ * Close the transport.
+ *
+ * @param {(nsresult|object)=} reason
+ * The status code or error message that corresponds to the reason
+ * for closing the transport (likely because a stream closed
+ * or failed).
+ */
+ close(reason) {
+ this.emit("close", reason);
+
+ this.active = false;
+ this._input.close();
+ this._scriptableInput.close();
+ this._output.close();
+ this._destroyIncoming();
+ this._destroyAllOutgoing();
+ if (this.hooks) {
+ this.hooks.onClosed(reason);
+ this.hooks = null;
+ }
+ if (reason) {
+ dumpv("Transport closed: " + reason);
+ } else {
+ dumpv("Transport closed.");
+ }
+ },
+
+ /**
+ * The currently outgoing packet (at the top of the queue).
+ */
+ get _currentOutgoing() {
+ return this._outgoing[0];
+ },
+
+ /**
+ * Flush data to the outgoing stream. Waits until the output
+ * stream notifies us that it is ready to be written to (via
+ * onOutputStreamReady).
+ */
+ _flushOutgoing() {
+ if (!this._outgoingEnabled || this._outgoing.length === 0) {
+ return;
+ }
+
+ // If the top of the packet queue has nothing more to send, remove it.
+ if (this._currentOutgoing.done) {
+ this._finishCurrentOutgoing();
+ }
+
+ if (this._outgoing.length > 0) {
+ let threadManager = Cc["@mozilla.org/thread-manager;1"].getService();
+ this._output.asyncWait(this, 0, 0, threadManager.currentThread);
+ }
+ },
+
+ /**
+ * Pause this transport's attempts to write to the output stream.
+ * This is used when we've temporarily handed off our output stream for
+ * writing bulk data.
+ */
+ pauseOutgoing() {
+ this._outgoingEnabled = false;
+ },
+
+ /**
+ * Resume this transport's attempts to write to the output stream.
+ */
+ resumeOutgoing() {
+ this._outgoingEnabled = true;
+ this._flushOutgoing();
+ },
+
+ // nsIOutputStreamCallback
+ /**
+ * This is called when the output stream is ready for more data to
+ * be written. The current outgoing packet will attempt to write some
+ * amount of data, but may not complete.
+ */
+ onOutputStreamReady(stream) {
+ if (!this._outgoingEnabled || this._outgoing.length === 0) {
+ return;
+ }
+
+ try {
+ this._currentOutgoing.write(stream);
+ } catch (e) {
+ if (e.result != Cr.NS_BASE_STREAM_WOULD_BLOCK) {
+ this.close(e.result);
+ return;
+ }
+ throw e;
+ }
+
+ this._flushOutgoing();
+ },
+
+ /**
+ * Remove the current outgoing packet from the queue upon completion.
+ */
+ _finishCurrentOutgoing() {
+ if (this._currentOutgoing) {
+ this._currentOutgoing.destroy();
+ this._outgoing.shift();
+ }
+ },
+
+ /**
+ * Clear the entire outgoing queue.
+ */
+ _destroyAllOutgoing() {
+ for (let packet of this._outgoing) {
+ packet.destroy();
+ }
+ this._outgoing = [];
+ },
+
+ /**
+ * Initialize the input stream for reading. Once this method has been
+ * called, we watch for packets on the input stream, and pass them to
+ * the appropriate handlers via this.hooks.
+ */
+ ready() {
+ this.active = true;
+ this._waitForIncoming();
+ },
+
+ /**
+ * Asks the input stream to notify us (via onInputStreamReady) when it is
+ * ready for reading.
+ */
+ _waitForIncoming() {
+ if (this._incomingEnabled) {
+ let threadManager = Cc["@mozilla.org/thread-manager;1"].getService();
+ this._input.asyncWait(this, 0, 0, threadManager.currentThread);
+ }
+ },
+
+ /**
+ * Pause this transport's attempts to read from the input stream.
+ * This is used when we've temporarily handed off our input stream for
+ * reading bulk data.
+ */
+ pauseIncoming() {
+ this._incomingEnabled = false;
+ },
+
+ /**
+ * Resume this transport's attempts to read from the input stream.
+ */
+ resumeIncoming() {
+ this._incomingEnabled = true;
+ this._flushIncoming();
+ this._waitForIncoming();
+ },
+
+ // nsIInputStreamCallback
+ /**
+ * Called when the stream is either readable or closed.
+ */
+ onInputStreamReady(stream) {
+ try {
+ while (
+ stream.available() &&
+ this._incomingEnabled &&
+ this._processIncoming(stream, stream.available())
+ ) {
+ // Loop until there is nothing more to process
+ }
+ this._waitForIncoming();
+ } catch (e) {
+ if (e.result != Cr.NS_BASE_STREAM_WOULD_BLOCK) {
+ this.close(e.result);
+ } else {
+ throw e;
+ }
+ }
+ },
+
+ /**
+ * Process the incoming data. Will create a new currently incoming
+ * Packet if needed. Tells the incoming Packet to read as much data
+ * as it can, but reading may not complete. The Packet signals that
+ * its data is ready for delivery by calling one of this transport's
+ * _on*Ready methods (see ./packets.js and the _on*Ready methods below).
+ *
+ * @return {boolean}
+ * Whether incoming stream processing should continue for any
+ * remaining data.
+ */
+ _processIncoming(stream, count) {
+ dumpv("Data available: " + count);
+
+ if (!count) {
+ dumpv("Nothing to read, skipping");
+ return false;
+ }
+
+ try {
+ if (!this._incoming) {
+ dumpv("Creating a new packet from incoming");
+
+ if (!this._readHeader(stream)) {
+ // Not enough data to read packet type
+ return false;
+ }
+
+ // Attempt to create a new Packet by trying to parse each possible
+ // header pattern.
+ this._incoming = Packet.fromHeader(this._incomingHeader, this);
+ if (!this._incoming) {
+ throw new Error(
+ "No packet types for header: " + this._incomingHeader
+ );
+ }
+ }
+
+ if (!this._incoming.done) {
+ // We have an incomplete packet, keep reading it.
+ dumpv("Existing packet incomplete, keep reading");
+ this._incoming.read(stream, this._scriptableInput);
+ }
+ } catch (e) {
+ dump(`Error reading incoming packet: (${e} - ${e.stack})\n`);
+
+ // Now in an invalid state, shut down the transport.
+ this.close();
+ return false;
+ }
+
+ if (!this._incoming.done) {
+ // Still not complete, we'll wait for more data.
+ dumpv("Packet not done, wait for more");
+ return true;
+ }
+
+ // Ready for next packet
+ this._flushIncoming();
+ return true;
+ },
+
+ /**
+ * Read as far as we can into the incoming data, attempting to build
+ * up a complete packet header (which terminates with ":"). We'll only
+ * read up to PACKET_HEADER_MAX characters.
+ *
+ * @return {boolean}
+ * True if we now have a complete header.
+ */
+ _readHeader() {
+ let amountToRead = PACKET_HEADER_MAX - this._incomingHeader.length;
+ this._incomingHeader += StreamUtils.delimitedRead(
+ this._scriptableInput,
+ ":",
+ amountToRead
+ );
+ if (flags.wantVerbose) {
+ dumpv("Header read: " + this._incomingHeader);
+ }
+
+ if (this._incomingHeader.endsWith(":")) {
+ if (flags.wantVerbose) {
+ dumpv("Found packet header successfully: " + this._incomingHeader);
+ }
+ return true;
+ }
+
+ if (this._incomingHeader.length >= PACKET_HEADER_MAX) {
+ throw new Error("Failed to parse packet header!");
+ }
+
+ // Not enough data yet.
+ return false;
+ },
+
+ /**
+ * If the incoming packet is done, log it as needed and clear the buffer.
+ */
+ _flushIncoming() {
+ if (!this._incoming.done) {
+ return;
+ }
+ if (flags.wantLogging) {
+ dumpv("Got: " + this._incoming);
+ }
+ this._destroyIncoming();
+ },
+
+ /**
+ * Handler triggered by an incoming JSONPacket completing it's |read|
+ * method. Delivers the packet to this.hooks.onPacket.
+ */
+ _onJSONObjectReady(object) {
+ executeSoon(() => {
+ // Ensure the transport is still alive by the time this runs.
+ if (this.active) {
+ this.emit("packet", object);
+ this.hooks.onPacket(object);
+ }
+ });
+ },
+
+ /**
+ * Handler triggered by an incoming BulkPacket entering the |read|
+ * phase for the stream portion of the packet. Delivers info about the
+ * incoming streaming data to this.hooks.onBulkPacket. See the main
+ * comment on the transport at the top of this file for more details.
+ */
+ _onBulkReadReady(...args) {
+ executeSoon(() => {
+ // Ensure the transport is still alive by the time this runs.
+ if (this.active) {
+ this.emit("bulkpacket", ...args);
+ this.hooks.onBulkPacket(...args);
+ }
+ });
+ },
+
+ /**
+ * Remove all handlers and references related to the current incoming
+ * packet, either because it is now complete or because the transport
+ * is closing.
+ */
+ _destroyIncoming() {
+ if (this._incoming) {
+ this._incoming.destroy();
+ }
+ this._incomingHeader = "";
+ this._incoming = null;
+ },
+};
diff --git a/testing/marionette/wm.js b/testing/marionette/wm.js
new file mode 100644
index 0000000000..7389099e13
--- /dev/null
+++ b/testing/marionette/wm.js
@@ -0,0 +1,7 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [];