From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- .../shared/test/addons/test-addon-1/manifest.json | 10 + .../shared/test/addons/test-addon-2/manifest.json | 10 + devtools/client/shared/test/browser.toml | 323 +++ .../shared/test/browser_autocomplete_popup.js | 121 + .../browser_autocomplete_popup_consecutive-show.js | 57 + .../test/browser_autocomplete_popup_input.js | 251 +++ .../shared/test/browser_browserloader_mocks.js | 162 ++ devtools/client/shared/test/browser_css_angle.js | 204 ++ devtools/client/shared/test/browser_css_color.js | 106 + .../client/shared/test/browser_cubic-bezier-01.js | 38 + .../client/shared/test/browser_cubic-bezier-02.js | 206 ++ .../client/shared/test/browser_cubic-bezier-03.js | 70 + .../client/shared/test/browser_cubic-bezier-04.js | 59 + .../client/shared/test/browser_cubic-bezier-05.js | 69 + .../client/shared/test/browser_cubic-bezier-06.js | 95 + .../client/shared/test/browser_cubic-bezier-07.js | 69 + .../client/shared/test/browser_dbg_globalactor.js | 71 + .../client/shared/test/browser_dbg_listaddons.js | 137 ++ .../client/shared/test/browser_dbg_listtabs-01.js | 84 + .../client/shared/test/browser_dbg_listtabs-02.js | 248 +++ .../client/shared/test/browser_dbg_listworkers.js | 75 + .../shared/test/browser_dbg_multiple-windows.js | 122 + .../test/browser_dbg_target-scoped-actor-01.js | 43 + .../test/browser_dbg_target-scoped-actor-02.js | 58 + devtools/client/shared/test/browser_devices.js | 76 + .../client/shared/test/browser_filter-editor-01.js | 150 ++ .../client/shared/test/browser_filter-editor-02.js | 114 + .../client/shared/test/browser_filter-editor-03.js | 84 + .../client/shared/test/browser_filter-editor-04.js | 106 + .../client/shared/test/browser_filter-editor-05.js | 166 ++ .../client/shared/test/browser_filter-editor-06.js | 77 + .../client/shared/test/browser_filter-editor-07.js | 32 + .../client/shared/test/browser_filter-editor-08.js | 103 + .../client/shared/test/browser_filter-editor-09.js | 155 ++ .../client/shared/test/browser_filter-editor-10.js | 100 + .../shared/test/browser_filter-presets-01.js | 117 + .../shared/test/browser_filter-presets-02.js | 47 + .../shared/test/browser_filter-presets-03.js | 42 + .../client/shared/test/browser_html_tooltip-01.js | 78 + .../client/shared/test/browser_html_tooltip-02.js | 227 ++ .../client/shared/test/browser_html_tooltip-03.js | 96 + .../client/shared/test/browser_html_tooltip-04.js | 100 + .../client/shared/test/browser_html_tooltip-05.js | 101 + .../shared/test/browser_html_tooltip_arrow-01.js | 86 + .../shared/test/browser_html_tooltip_arrow-02.js | 83 + .../test/browser_html_tooltip_consecutive-show.js | 70 + .../test/browser_html_tooltip_doorhanger-01.js | 79 + .../test/browser_html_tooltip_doorhanger-02.js | 76 + .../test/browser_html_tooltip_height-auto.js | 108 + .../shared/test/browser_html_tooltip_hover.js | 65 + .../shared/test/browser_html_tooltip_offset.js | 97 + .../shared/test/browser_html_tooltip_resize.js | 97 + .../client/shared/test/browser_html_tooltip_rtl.js | 226 ++ .../test/browser_html_tooltip_screen_edge.js | 74 + .../test/browser_html_tooltip_variable-height.js | 77 + .../shared/test/browser_html_tooltip_width-auto.js | 53 + .../test/browser_html_tooltip_xul-wrapper.js | 79 + .../shared/test/browser_html_tooltip_zoom.js | 74 + .../shared/test/browser_inplace-editor-01.js | 202 ++ .../shared/test/browser_inplace-editor-02.js | 80 + ...browser_inplace-editor_autoclose_parentheses.js | 77 + .../test/browser_inplace-editor_autocomplete_01.js | 79 + .../test/browser_inplace-editor_autocomplete_02.js | 78 + ...ser_inplace-editor_autocomplete_css_variable.js | 104 + .../browser_inplace-editor_autocomplete_offset.js | 115 + .../browser_inplace-editor_focus_closest_editor.js | 180 ++ .../shared/test/browser_inplace-editor_maxwidth.js | 138 ++ .../test/browser_inplace-editor_stop_on_key.js | 219 ++ .../client/shared/test/browser_key_shortcuts.js | 468 ++++ devtools/client/shared/test/browser_keycodes.js | 12 + .../client/shared/test/browser_layoutHelpers.js | 131 ++ .../test/browser_layoutHelpers_getBoxQuads1.js | 353 +++ .../test/browser_layoutHelpers_getBoxQuads2.js | 185 ++ devtools/client/shared/test/browser_link.js | 40 + devtools/client/shared/test/browser_num-l10n.js | 70 + .../client/shared/test/browser_outputparser.js | 856 +++++++ devtools/client/shared/test/browser_prefs-01.js | 53 + devtools/client/shared/test/browser_prefs-02.js | 67 + devtools/client/shared/test/browser_require_raw.js | 23 + devtools/client/shared/test/browser_spectrum.js | 518 +++++ .../shared/test/browser_tableWidget_basic.js | 448 ++++ .../browser_tableWidget_keyboard_interaction.js | 202 ++ .../test/browser_tableWidget_mouse_interaction.js | 359 +++ .../test/browser_telemetry_button_eyedropper.js | 39 + .../test/browser_telemetry_button_responsive.js | 108 + .../client/shared/test/browser_telemetry_misc.js | 51 + .../shared/test/browser_telemetry_sidebar.js | 233 ++ .../shared/test/browser_telemetry_toolbox.js | 34 + .../browser_telemetry_toolboxtabs_inspector.js | 39 + .../browser_telemetry_toolboxtabs_jsdebugger.js | 39 + .../browser_telemetry_toolboxtabs_jsprofiler.js | 39 + .../browser_telemetry_toolboxtabs_netmonitor.js | 39 + .../test/browser_telemetry_toolboxtabs_options.js | 34 + .../test/browser_telemetry_toolboxtabs_storage.js | 34 + .../browser_telemetry_toolboxtabs_styleeditor.js | 39 + .../browser_telemetry_toolboxtabs_webconsole.js | 39 + devtools/client/shared/test/browser_theme.js | 145 ++ .../client/shared/test/browser_theme_switching.js | 58 + .../client/shared/test/browser_treeWidget_basic.js | 391 ++++ .../browser_treeWidget_keyboard_interaction.js | 291 +++ .../test/browser_treeWidget_mouse_interaction.js | 185 ++ .../code_WorkerTargetActor.attachThread-worker.js | 18 + .../client/shared/test/code_listworkers-worker1.js | 3 + .../client/shared/test/code_listworkers-worker2.js | 3 + .../doc_WorkerTargetActor.attachThread-tab.html | 8 + .../client/shared/test/doc_cubic-bezier-01.html | 1 + .../client/shared/test/doc_cubic-bezier-02.html | 3 + devtools/client/shared/test/doc_empty-tab-01.html | 14 + devtools/client/shared/test/doc_empty-tab-02.html | 14 + .../client/shared/test/doc_event-listeners-01.html | 45 + .../client/shared/test/doc_event-listeners-03.html | 65 + .../client/shared/test/doc_filter-editor-01.html | 1 + .../client/shared/test/doc_html_tooltip-02.xhtml | 15 + .../client/shared/test/doc_html_tooltip-03.xhtml | 19 + .../client/shared/test/doc_html_tooltip-04.xhtml | 15 + .../client/shared/test/doc_html_tooltip-05.xhtml | 12 + devtools/client/shared/test/doc_html_tooltip.xhtml | 12 + .../shared/test/doc_html_tooltip_arrow-01.xhtml | 90 + .../shared/test/doc_html_tooltip_arrow-02.xhtml | 65 + .../test/doc_html_tooltip_doorhanger-01.xhtml | 73 + .../test/doc_html_tooltip_doorhanger-02.xhtml | 34 + .../shared/test/doc_html_tooltip_hover.xhtml | 13 + .../client/shared/test/doc_html_tooltip_rtl.xhtml | 14 + .../doc_inplace-editor_autocomplete_offset.xhtml | 7 + devtools/client/shared/test/doc_layoutHelpers.html | 31 + .../test/doc_layoutHelpers_getBoxQuads1.html | 65 + .../test/doc_layoutHelpers_getBoxQuads2-a.html | 20 + .../doc_layoutHelpers_getBoxQuads2-b-and-d.html | 29 + .../doc_layoutHelpers_getBoxQuads2-c-and-e.html | 27 + .../client/shared/test/doc_listworkers-tab.html | 8 + .../shared/test/doc_native-event-handler.html | 25 + .../shared/test/doc_script-switching-01.html | 18 + .../shared/test/doc_script-switching-02.html | 18 + devtools/client/shared/test/doc_spectrum.html | 2 + .../client/shared/test/doc_tableWidget_basic.html | 7 + .../doc_tableWidget_keyboard_interaction.xhtml | 8 + .../test/doc_tableWidget_mouse_interaction.xhtml | 7 + .../client/shared/test/doc_templater_basic.html | 12 + devtools/client/shared/test/dummy.html | 1 + devtools/client/shared/test/head.js | 211 ++ devtools/client/shared/test/helper_color_data.js | 1499 +++++++++++++ devtools/client/shared/test/helper_html_tooltip.js | 116 + .../client/shared/test/helper_inplace_editor.js | 164 ++ .../client/shared/test/highlighter-test-actor.js | 939 ++++++++ devtools/client/shared/test/leakhunt.js | 173 ++ devtools/client/shared/test/shared-head.js | 2324 ++++++++++++++++++++ .../client/shared/test/telemetry-test-helpers.js | 273 +++ devtools/client/shared/test/test-mocked-module.js | 11 + devtools/client/shared/test/testactors.js | 27 + devtools/client/shared/test/xpcshell/.eslintrc.js | 6 + devtools/client/shared/test/xpcshell/head.js | 10 + .../test_VariablesView_getString_promise.js | 81 + .../client/shared/test/xpcshell/test_WeakMapMap.js | 69 + .../shared/test/xpcshell/test_advanceValidate.js | 33 + .../test/xpcshell/test_attribute-parsing-01.js | 77 + .../test/xpcshell/test_attribute-parsing-02.js | 148 ++ .../shared/test/xpcshell/test_bezierCanvas.js | 122 + .../client/shared/test/xpcshell/test_classnames.js | 53 + .../client/shared/test/xpcshell/test_cssAngle.js | 32 + .../shared/test/xpcshell/test_cssColor-01.js | 75 + .../shared/test/xpcshell/test_cssColor-02.js | 50 + .../test/xpcshell/test_cssColor-8-digit-hex.js | 20 + .../shared/test/xpcshell/test_cssColorDatabase.js | 17 + .../shared/test/xpcshell/test_cubicBezier.js | 152 ++ devtools/client/shared/test/xpcshell/test_curl.js | 397 ++++ .../shared/test/xpcshell/test_escapeCSSComment.js | 41 + .../shared/test/xpcshell/test_hasCSSVariable.js | 60 + .../shared/test/xpcshell/test_linearEasing.js | 217 ++ .../shared/test/xpcshell/test_parseDeclarations.js | 1641 ++++++++++++++ .../test_parsePseudoClassesAndAttributes.js | 202 ++ .../shared/test/xpcshell/test_parseSingleValue.js | 106 + .../test/xpcshell/test_rewriteDeclarations.js | 816 +++++++ .../shared/test/xpcshell/test_source-utils.js | 249 +++ .../shared/test/xpcshell/test_suggestion-picker.js | 147 ++ .../client/shared/test/xpcshell/test_undoStack.js | 88 + .../shared/test/xpcshell/test_unicode-url.js | 258 +++ devtools/client/shared/test/xpcshell/xpcshell.toml | 57 + 177 files changed, 24680 insertions(+) create mode 100644 devtools/client/shared/test/addons/test-addon-1/manifest.json create mode 100644 devtools/client/shared/test/addons/test-addon-2/manifest.json create mode 100644 devtools/client/shared/test/browser.toml create mode 100644 devtools/client/shared/test/browser_autocomplete_popup.js create mode 100644 devtools/client/shared/test/browser_autocomplete_popup_consecutive-show.js create mode 100644 devtools/client/shared/test/browser_autocomplete_popup_input.js create mode 100644 devtools/client/shared/test/browser_browserloader_mocks.js create mode 100644 devtools/client/shared/test/browser_css_angle.js create mode 100644 devtools/client/shared/test/browser_css_color.js create mode 100644 devtools/client/shared/test/browser_cubic-bezier-01.js create mode 100644 devtools/client/shared/test/browser_cubic-bezier-02.js create mode 100644 devtools/client/shared/test/browser_cubic-bezier-03.js create mode 100644 devtools/client/shared/test/browser_cubic-bezier-04.js create mode 100644 devtools/client/shared/test/browser_cubic-bezier-05.js create mode 100644 devtools/client/shared/test/browser_cubic-bezier-06.js create mode 100644 devtools/client/shared/test/browser_cubic-bezier-07.js create mode 100644 devtools/client/shared/test/browser_dbg_globalactor.js create mode 100644 devtools/client/shared/test/browser_dbg_listaddons.js create mode 100644 devtools/client/shared/test/browser_dbg_listtabs-01.js create mode 100644 devtools/client/shared/test/browser_dbg_listtabs-02.js create mode 100644 devtools/client/shared/test/browser_dbg_listworkers.js create mode 100644 devtools/client/shared/test/browser_dbg_multiple-windows.js create mode 100644 devtools/client/shared/test/browser_dbg_target-scoped-actor-01.js create mode 100644 devtools/client/shared/test/browser_dbg_target-scoped-actor-02.js create mode 100644 devtools/client/shared/test/browser_devices.js create mode 100644 devtools/client/shared/test/browser_filter-editor-01.js create mode 100644 devtools/client/shared/test/browser_filter-editor-02.js create mode 100644 devtools/client/shared/test/browser_filter-editor-03.js create mode 100644 devtools/client/shared/test/browser_filter-editor-04.js create mode 100644 devtools/client/shared/test/browser_filter-editor-05.js create mode 100644 devtools/client/shared/test/browser_filter-editor-06.js create mode 100644 devtools/client/shared/test/browser_filter-editor-07.js create mode 100644 devtools/client/shared/test/browser_filter-editor-08.js create mode 100644 devtools/client/shared/test/browser_filter-editor-09.js create mode 100644 devtools/client/shared/test/browser_filter-editor-10.js create mode 100644 devtools/client/shared/test/browser_filter-presets-01.js create mode 100644 devtools/client/shared/test/browser_filter-presets-02.js create mode 100644 devtools/client/shared/test/browser_filter-presets-03.js create mode 100644 devtools/client/shared/test/browser_html_tooltip-01.js create mode 100644 devtools/client/shared/test/browser_html_tooltip-02.js create mode 100644 devtools/client/shared/test/browser_html_tooltip-03.js create mode 100644 devtools/client/shared/test/browser_html_tooltip-04.js create mode 100644 devtools/client/shared/test/browser_html_tooltip-05.js create mode 100644 devtools/client/shared/test/browser_html_tooltip_arrow-01.js create mode 100644 devtools/client/shared/test/browser_html_tooltip_arrow-02.js create mode 100644 devtools/client/shared/test/browser_html_tooltip_consecutive-show.js create mode 100644 devtools/client/shared/test/browser_html_tooltip_doorhanger-01.js create mode 100644 devtools/client/shared/test/browser_html_tooltip_doorhanger-02.js create mode 100644 devtools/client/shared/test/browser_html_tooltip_height-auto.js create mode 100644 devtools/client/shared/test/browser_html_tooltip_hover.js create mode 100644 devtools/client/shared/test/browser_html_tooltip_offset.js create mode 100644 devtools/client/shared/test/browser_html_tooltip_resize.js create mode 100644 devtools/client/shared/test/browser_html_tooltip_rtl.js create mode 100644 devtools/client/shared/test/browser_html_tooltip_screen_edge.js create mode 100644 devtools/client/shared/test/browser_html_tooltip_variable-height.js create mode 100644 devtools/client/shared/test/browser_html_tooltip_width-auto.js create mode 100644 devtools/client/shared/test/browser_html_tooltip_xul-wrapper.js create mode 100644 devtools/client/shared/test/browser_html_tooltip_zoom.js create mode 100644 devtools/client/shared/test/browser_inplace-editor-01.js create mode 100644 devtools/client/shared/test/browser_inplace-editor-02.js create mode 100644 devtools/client/shared/test/browser_inplace-editor_autoclose_parentheses.js create mode 100644 devtools/client/shared/test/browser_inplace-editor_autocomplete_01.js create mode 100644 devtools/client/shared/test/browser_inplace-editor_autocomplete_02.js create mode 100644 devtools/client/shared/test/browser_inplace-editor_autocomplete_css_variable.js create mode 100644 devtools/client/shared/test/browser_inplace-editor_autocomplete_offset.js create mode 100644 devtools/client/shared/test/browser_inplace-editor_focus_closest_editor.js create mode 100644 devtools/client/shared/test/browser_inplace-editor_maxwidth.js create mode 100644 devtools/client/shared/test/browser_inplace-editor_stop_on_key.js create mode 100644 devtools/client/shared/test/browser_key_shortcuts.js create mode 100644 devtools/client/shared/test/browser_keycodes.js create mode 100644 devtools/client/shared/test/browser_layoutHelpers.js create mode 100644 devtools/client/shared/test/browser_layoutHelpers_getBoxQuads1.js create mode 100644 devtools/client/shared/test/browser_layoutHelpers_getBoxQuads2.js create mode 100644 devtools/client/shared/test/browser_link.js create mode 100644 devtools/client/shared/test/browser_num-l10n.js create mode 100644 devtools/client/shared/test/browser_outputparser.js create mode 100644 devtools/client/shared/test/browser_prefs-01.js create mode 100644 devtools/client/shared/test/browser_prefs-02.js create mode 100644 devtools/client/shared/test/browser_require_raw.js create mode 100644 devtools/client/shared/test/browser_spectrum.js create mode 100644 devtools/client/shared/test/browser_tableWidget_basic.js create mode 100644 devtools/client/shared/test/browser_tableWidget_keyboard_interaction.js create mode 100644 devtools/client/shared/test/browser_tableWidget_mouse_interaction.js create mode 100644 devtools/client/shared/test/browser_telemetry_button_eyedropper.js create mode 100644 devtools/client/shared/test/browser_telemetry_button_responsive.js create mode 100644 devtools/client/shared/test/browser_telemetry_misc.js create mode 100644 devtools/client/shared/test/browser_telemetry_sidebar.js create mode 100644 devtools/client/shared/test/browser_telemetry_toolbox.js create mode 100644 devtools/client/shared/test/browser_telemetry_toolboxtabs_inspector.js create mode 100644 devtools/client/shared/test/browser_telemetry_toolboxtabs_jsdebugger.js create mode 100644 devtools/client/shared/test/browser_telemetry_toolboxtabs_jsprofiler.js create mode 100644 devtools/client/shared/test/browser_telemetry_toolboxtabs_netmonitor.js create mode 100644 devtools/client/shared/test/browser_telemetry_toolboxtabs_options.js create mode 100644 devtools/client/shared/test/browser_telemetry_toolboxtabs_storage.js create mode 100644 devtools/client/shared/test/browser_telemetry_toolboxtabs_styleeditor.js create mode 100644 devtools/client/shared/test/browser_telemetry_toolboxtabs_webconsole.js create mode 100644 devtools/client/shared/test/browser_theme.js create mode 100644 devtools/client/shared/test/browser_theme_switching.js create mode 100644 devtools/client/shared/test/browser_treeWidget_basic.js create mode 100644 devtools/client/shared/test/browser_treeWidget_keyboard_interaction.js create mode 100644 devtools/client/shared/test/browser_treeWidget_mouse_interaction.js create mode 100644 devtools/client/shared/test/code_WorkerTargetActor.attachThread-worker.js create mode 100644 devtools/client/shared/test/code_listworkers-worker1.js create mode 100644 devtools/client/shared/test/code_listworkers-worker2.js create mode 100644 devtools/client/shared/test/doc_WorkerTargetActor.attachThread-tab.html create mode 100644 devtools/client/shared/test/doc_cubic-bezier-01.html create mode 100644 devtools/client/shared/test/doc_cubic-bezier-02.html create mode 100644 devtools/client/shared/test/doc_empty-tab-01.html create mode 100644 devtools/client/shared/test/doc_empty-tab-02.html create mode 100644 devtools/client/shared/test/doc_event-listeners-01.html create mode 100644 devtools/client/shared/test/doc_event-listeners-03.html create mode 100644 devtools/client/shared/test/doc_filter-editor-01.html create mode 100644 devtools/client/shared/test/doc_html_tooltip-02.xhtml create mode 100644 devtools/client/shared/test/doc_html_tooltip-03.xhtml create mode 100644 devtools/client/shared/test/doc_html_tooltip-04.xhtml create mode 100644 devtools/client/shared/test/doc_html_tooltip-05.xhtml create mode 100644 devtools/client/shared/test/doc_html_tooltip.xhtml create mode 100644 devtools/client/shared/test/doc_html_tooltip_arrow-01.xhtml create mode 100644 devtools/client/shared/test/doc_html_tooltip_arrow-02.xhtml create mode 100644 devtools/client/shared/test/doc_html_tooltip_doorhanger-01.xhtml create mode 100644 devtools/client/shared/test/doc_html_tooltip_doorhanger-02.xhtml create mode 100644 devtools/client/shared/test/doc_html_tooltip_hover.xhtml create mode 100644 devtools/client/shared/test/doc_html_tooltip_rtl.xhtml create mode 100644 devtools/client/shared/test/doc_inplace-editor_autocomplete_offset.xhtml create mode 100644 devtools/client/shared/test/doc_layoutHelpers.html create mode 100644 devtools/client/shared/test/doc_layoutHelpers_getBoxQuads1.html create mode 100644 devtools/client/shared/test/doc_layoutHelpers_getBoxQuads2-a.html create mode 100644 devtools/client/shared/test/doc_layoutHelpers_getBoxQuads2-b-and-d.html create mode 100644 devtools/client/shared/test/doc_layoutHelpers_getBoxQuads2-c-and-e.html create mode 100644 devtools/client/shared/test/doc_listworkers-tab.html create mode 100644 devtools/client/shared/test/doc_native-event-handler.html create mode 100644 devtools/client/shared/test/doc_script-switching-01.html create mode 100644 devtools/client/shared/test/doc_script-switching-02.html create mode 100644 devtools/client/shared/test/doc_spectrum.html create mode 100644 devtools/client/shared/test/doc_tableWidget_basic.html create mode 100644 devtools/client/shared/test/doc_tableWidget_keyboard_interaction.xhtml create mode 100644 devtools/client/shared/test/doc_tableWidget_mouse_interaction.xhtml create mode 100644 devtools/client/shared/test/doc_templater_basic.html create mode 100644 devtools/client/shared/test/dummy.html create mode 100644 devtools/client/shared/test/head.js create mode 100644 devtools/client/shared/test/helper_color_data.js create mode 100644 devtools/client/shared/test/helper_html_tooltip.js create mode 100644 devtools/client/shared/test/helper_inplace_editor.js create mode 100644 devtools/client/shared/test/highlighter-test-actor.js create mode 100644 devtools/client/shared/test/leakhunt.js create mode 100644 devtools/client/shared/test/shared-head.js create mode 100644 devtools/client/shared/test/telemetry-test-helpers.js create mode 100644 devtools/client/shared/test/test-mocked-module.js create mode 100644 devtools/client/shared/test/testactors.js create mode 100644 devtools/client/shared/test/xpcshell/.eslintrc.js create mode 100644 devtools/client/shared/test/xpcshell/head.js create mode 100644 devtools/client/shared/test/xpcshell/test_VariablesView_getString_promise.js create mode 100644 devtools/client/shared/test/xpcshell/test_WeakMapMap.js create mode 100644 devtools/client/shared/test/xpcshell/test_advanceValidate.js create mode 100644 devtools/client/shared/test/xpcshell/test_attribute-parsing-01.js create mode 100644 devtools/client/shared/test/xpcshell/test_attribute-parsing-02.js create mode 100644 devtools/client/shared/test/xpcshell/test_bezierCanvas.js create mode 100644 devtools/client/shared/test/xpcshell/test_classnames.js create mode 100644 devtools/client/shared/test/xpcshell/test_cssAngle.js create mode 100644 devtools/client/shared/test/xpcshell/test_cssColor-01.js create mode 100644 devtools/client/shared/test/xpcshell/test_cssColor-02.js create mode 100644 devtools/client/shared/test/xpcshell/test_cssColor-8-digit-hex.js create mode 100644 devtools/client/shared/test/xpcshell/test_cssColorDatabase.js create mode 100644 devtools/client/shared/test/xpcshell/test_cubicBezier.js create mode 100644 devtools/client/shared/test/xpcshell/test_curl.js create mode 100644 devtools/client/shared/test/xpcshell/test_escapeCSSComment.js create mode 100644 devtools/client/shared/test/xpcshell/test_hasCSSVariable.js create mode 100644 devtools/client/shared/test/xpcshell/test_linearEasing.js create mode 100644 devtools/client/shared/test/xpcshell/test_parseDeclarations.js create mode 100644 devtools/client/shared/test/xpcshell/test_parsePseudoClassesAndAttributes.js create mode 100644 devtools/client/shared/test/xpcshell/test_parseSingleValue.js create mode 100644 devtools/client/shared/test/xpcshell/test_rewriteDeclarations.js create mode 100644 devtools/client/shared/test/xpcshell/test_source-utils.js create mode 100644 devtools/client/shared/test/xpcshell/test_suggestion-picker.js create mode 100644 devtools/client/shared/test/xpcshell/test_undoStack.js create mode 100644 devtools/client/shared/test/xpcshell/test_unicode-url.js create mode 100644 devtools/client/shared/test/xpcshell/xpcshell.toml (limited to 'devtools/client/shared/test') diff --git a/devtools/client/shared/test/addons/test-addon-1/manifest.json b/devtools/client/shared/test/addons/test-addon-1/manifest.json new file mode 100644 index 0000000000..2d6ec55940 --- /dev/null +++ b/devtools/client/shared/test/addons/test-addon-1/manifest.json @@ -0,0 +1,10 @@ +{ + "manifest_version": 2, + "name": "test-addon-1", + "version": "1.0", + "browser_specific_settings": { + "gecko": { + "id": "test-addon-1@mozilla.org" + } + } +} diff --git a/devtools/client/shared/test/addons/test-addon-2/manifest.json b/devtools/client/shared/test/addons/test-addon-2/manifest.json new file mode 100644 index 0000000000..7d5e2d54ae --- /dev/null +++ b/devtools/client/shared/test/addons/test-addon-2/manifest.json @@ -0,0 +1,10 @@ +{ + "manifest_version": 2, + "name": "test-addon-2", + "version": "1.0", + "browser_specific_settings": { + "gecko": { + "id": "test-addon-2@mozilla.org" + } + } +} diff --git a/devtools/client/shared/test/browser.toml b/devtools/client/shared/test/browser.toml new file mode 100644 index 0000000000..4c55c7fee9 --- /dev/null +++ b/devtools/client/shared/test/browser.toml @@ -0,0 +1,323 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "addons/*", + "code_listworkers-worker1.js", + "code_listworkers-worker2.js", + "code_WorkerTargetActor.attachThread-worker.js", + "doc_cubic-bezier-01.html", + "doc_cubic-bezier-02.html", + "doc_empty-tab-01.html", + "doc_empty-tab-02.html", + "doc_filter-editor-01.html", + "doc_html_tooltip-02.xhtml", + "doc_html_tooltip-03.xhtml", + "doc_html_tooltip-04.xhtml", + "doc_html_tooltip-05.xhtml", + "doc_html_tooltip.xhtml", + "doc_html_tooltip_arrow-01.xhtml", + "doc_html_tooltip_arrow-02.xhtml", + "doc_html_tooltip_doorhanger-01.xhtml", + "doc_html_tooltip_doorhanger-02.xhtml", + "doc_html_tooltip_hover.xhtml", + "doc_html_tooltip_rtl.xhtml", + "doc_inplace-editor_autocomplete_offset.xhtml", + "doc_layoutHelpers_getBoxQuads1.html", + "doc_layoutHelpers_getBoxQuads2-a.html", + "doc_layoutHelpers_getBoxQuads2-b-and-d.html", + "doc_layoutHelpers_getBoxQuads2-c-and-e.html", + "doc_layoutHelpers.html", + "doc_listworkers-tab.html", + "doc_native-event-handler.html", + "doc_script-switching-01.html", + "doc_script-switching-02.html", + "doc_spectrum.html", + "doc_tableWidget_basic.html", + "doc_tableWidget_keyboard_interaction.xhtml", + "doc_tableWidget_mouse_interaction.xhtml", + "doc_templater_basic.html", + "doc_WorkerTargetActor.attachThread-tab.html", + "dummy.html", + "head.js", + "helper_color_data.js", + "helper_html_tooltip.js", + "helper_inplace_editor.js", + "highlighter-test-actor.js", + "leakhunt.js", + "shared-head.js", + "telemetry-test-helpers.js", + "test-mocked-module.js", + "testactors.js", + "!/devtools/client/debugger/test/mochitest/shared-head.js", + "!/gfx/layers/apz/test/mochitest/apz_test_utils.js", +] + +["browser_autocomplete_popup.js"] + +["browser_autocomplete_popup_consecutive-show.js"] + +["browser_autocomplete_popup_input.js"] + +["browser_browserloader_mocks.js"] + +["browser_css_angle.js"] + +["browser_css_color.js"] + +["browser_cubic-bezier-01.js"] + +["browser_cubic-bezier-02.js"] +skip-if = [ + "apple_catalina", # Bug 1713158 + "os == 'linux' && !asan && !debug && !swgl && !ccov", # Bug 1721159 +] + +["browser_cubic-bezier-03.js"] + +["browser_cubic-bezier-04.js"] + +["browser_cubic-bezier-05.js"] + +["browser_cubic-bezier-06.js"] +skip-if = [ + "apple_catalina", # Bug 1713158 + "os == 'linux' && !asan && !debug && !swgl && !ccov", # Bug 1721159 +] + +["browser_cubic-bezier-07.js"] +tags = "addons" + +["browser_dbg_listaddons.js"] +skip-if = ["debug"] +tags = "addons" + +["browser_dbg_listtabs-01.js"] + +["browser_dbg_listtabs-02.js"] +skip-if = ["true"] # Never worked for remote frames, needs a mock DevToolsServerConnection + +["browser_dbg_listworkers.js"] + +["browser_dbg_multiple-windows.js"] + +["browser_dbg_target-scoped-actor-01.js"] + +["browser_dbg_target-scoped-actor-02.js"] + +["browser_devices.js"] +skip-if = ["verify"] + +["browser_filter-editor-01.js"] + +["browser_filter-editor-02.js"] + +["browser_filter-editor-03.js"] + +["browser_filter-editor-04.js"] + +["browser_filter-editor-05.js"] + +["browser_filter-editor-06.js"] + +["browser_filter-editor-07.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_filter-editor-08.js"] + +["browser_filter-editor-09.js"] + +["browser_filter-editor-10.js"] + +["browser_filter-presets-01.js"] + +["browser_filter-presets-02.js"] + +["browser_filter-presets-03.js"] + +["browser_html_tooltip-01.js"] + +["browser_html_tooltip-02.js"] +skip-if = [ + "apple_catalina", # Bug 1713158 + "os == 'linux' && !asan && !debug && !swgl && !ccov", # Bug 1721159 + "a11y_checks && debug", # Bugs 1849028 and 1858041 for causing intermittent test results +] + +["browser_html_tooltip-03.js"] +skip-if = [ + "apple_catalina", # Bug 1713158 + "os == 'linux' && !asan && !debug && !swgl && !ccov", # Bug 1721159 +] + +["browser_html_tooltip-04.js"] +skip-if = ["os == 'linux' && !asan && !debug && !swgl && !ccov"] # Bug 1721159 + +["browser_html_tooltip-05.js"] + +["browser_html_tooltip_arrow-01.js"] +skip-if = [ + "apple_catalina", # Bug 1713158 + "os == 'linux' && !asan && !debug && !swgl && !ccov", # Bug 1721159 +] + +["browser_html_tooltip_arrow-02.js"] +skip-if = [ + "apple_catalina", # Bug 1713158 + "os == 'linux' && !asan && !debug && !swgl && !ccov", # Bug 1721159 +] + +["browser_html_tooltip_consecutive-show.js"] + +["browser_html_tooltip_doorhanger-01.js"] + +["browser_html_tooltip_doorhanger-02.js"] + +["browser_html_tooltip_height-auto.js"] + +["browser_html_tooltip_hover.js"] + +["browser_html_tooltip_offset.js"] +skip-if = [ + "apple_catalina", # Bug 1713158 + "os == 'linux' && !asan && !debug && !swgl && !ccov", # Bug 1721159 +] + +["browser_html_tooltip_resize.js"] + +["browser_html_tooltip_rtl.js"] + +["browser_html_tooltip_screen_edge.js"] + +["browser_html_tooltip_variable-height.js"] +skip-if = [ + "apple_catalina", # Bug 1713158 + "os == 'linux' && !asan && !debug && !swgl && !ccov", # Bug 1721159 +] + +["browser_html_tooltip_width-auto.js"] +skip-if = [ + "apple_catalina", # Bug 1713158 + "os == 'linux' && !asan && !debug && !swgl && !ccov", # Bug 1721159 +] + +["browser_html_tooltip_xul-wrapper.js"] + +["browser_html_tooltip_zoom.js"] + +["browser_inplace-editor-01.js"] + +["browser_inplace-editor-02.js"] +skip-if = [ + "apple_catalina", # Bug 1713158 + "os == 'linux' && !asan && !debug && !swgl && !ccov", # Bug 1721159 +] + +["browser_inplace-editor_autoclose_parentheses.js"] + +["browser_inplace-editor_autocomplete_01.js"] +skip-if = [ + "apple_catalina", # Bug 1713158 + "os == 'linux' && !asan && !debug && !swgl && !ccov", # Bug 1721159 +] + +["browser_inplace-editor_autocomplete_02.js"] + +["browser_inplace-editor_autocomplete_css_variable.js"] +skip-if = [ + "apple_catalina", # Bug 1713158 + "os == 'linux' && !asan && !debug && !swgl && !ccov", # Bug 1721159 +] + +["browser_inplace-editor_autocomplete_offset.js"] +skip-if = [ + "apple_catalina", # Bug 1713158 + "os == 'linux' && !asan && !debug && !swgl && !ccov", # Bug 1721159 +] + +["browser_inplace-editor_focus_closest_editor.js"] + +["browser_inplace-editor_maxwidth.js"] +skip-if = [ + "apple_catalina", # Bug 1713158 + "os == 'linux' && !asan && !debug && !swgl && !ccov", # Bug 1721159 +] + +["browser_inplace-editor_stop_on_key.js"] + +["browser_key_shortcuts.js"] + +["browser_keycodes.js"] + +["browser_layoutHelpers.js"] + +["browser_layoutHelpers_getBoxQuads1.js"] +skip-if = [ + "verify", +] + +["browser_layoutHelpers_getBoxQuads2.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_link.js"] + +["browser_num-l10n.js"] + +["browser_outputparser.js"] + +["browser_prefs-01.js"] + +["browser_prefs-02.js"] + +["browser_require_raw.js"] + +["browser_spectrum.js"] + +["browser_tableWidget_basic.js"] + +["browser_tableWidget_keyboard_interaction.js"] + +["browser_tableWidget_mouse_interaction.js"] +skip-if = ["(os == 'linux' && os_version == '18.04' && bits == 64) && (!debug && !asan)"] #Bug 1118592 + +["browser_telemetry_button_eyedropper.js"] + +["browser_telemetry_button_responsive.js"] +skip-if = ["os == 'win'"] # Win: bug 1404197 + +["browser_telemetry_misc.js"] + +["browser_telemetry_sidebar.js"] + +["browser_telemetry_toolbox.js"] + +["browser_telemetry_toolboxtabs_inspector.js"] + +["browser_telemetry_toolboxtabs_jsdebugger.js"] + +["browser_telemetry_toolboxtabs_jsprofiler.js"] + +["browser_telemetry_toolboxtabs_netmonitor.js"] + +["browser_telemetry_toolboxtabs_options.js"] + +["browser_telemetry_toolboxtabs_storage.js"] + +["browser_telemetry_toolboxtabs_styleeditor.js"] + +["browser_telemetry_toolboxtabs_webconsole.js"] + +["browser_theme.js"] + +["browser_theme_switching.js"] + +["browser_treeWidget_basic.js"] + +["browser_treeWidget_keyboard_interaction.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_treeWidget_mouse_interaction.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled diff --git a/devtools/client/shared/test/browser_autocomplete_popup.js b/devtools/client/shared/test/browser_autocomplete_popup.js new file mode 100644 index 0000000000..d9d057ea1c --- /dev/null +++ b/devtools/client/shared/test/browser_autocomplete_popup.js @@ -0,0 +1,121 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js"); + + info("Create an autocompletion popup"); + const { doc } = await createHost(); + const input = doc.createElement("input"); + doc.body.appendChild(input); + + const autocompleteOptions = { + position: "top", + autoSelect: true, + }; + const popup = new AutocompletePopup(doc, autocompleteOptions); + input.focus(); + + const items = [ + { label: "item0", value: "value0" }, + { label: "item1", value: "value1" }, + { label: "item2", value: "value2" }, + ]; + + ok(!popup.isOpen, "popup is not open"); + ok(!input.hasAttribute("aria-activedescendant"), "no aria-activedescendant"); + + const onPopupOpen = popup.once("popup-opened"); + popup.openPopup(input); + await onPopupOpen; + + ok(popup.isOpen, "popup is open"); + is(popup.itemCount, 0, "no items"); + ok(!input.hasAttribute("aria-activedescendant"), "no aria-activedescendant"); + + popup.setItems(items); + + is(popup.itemCount, items.length, "items added"); + is( + JSON.stringify(popup.getItems()), + JSON.stringify(items), + "getItems returns back the same items" + ); + is(popup.selectedIndex, 0, "Index of the first item from top is selected."); + is(popup.selectedItem, items[0], "First item from top is selected"); + // Make sure the list containing the active descendant doesn't get rebuilt + // when the selected item changes. + const listClone = getListFromActiveDescendant(popup, input); + checkActiveDescendant(popup, input, listClone); + + popup.selectItemAtIndex(1); + + is(popup.selectedIndex, 1, "index 1 is selected"); + is(popup.selectedItem, items[1], "item1 is selected"); + checkActiveDescendant(popup, input, listClone); + + popup.selectedItem = items[2]; + + is(popup.selectedIndex, 2, "index 2 is selected"); + is(popup.selectedItem, items[2], "item2 is selected"); + checkActiveDescendant(popup, input, listClone); + + is(popup.selectPreviousItem(), items[1], "selectPreviousItem() works"); + + is(popup.selectedIndex, 1, "index 1 is selected"); + is(popup.selectedItem, items[1], "item1 is selected"); + checkActiveDescendant(popup, input, listClone); + + is(popup.selectNextItem(), items[2], "selectNextItem() works"); + + is(popup.selectedIndex, 2, "index 2 is selected"); + is(popup.selectedItem, items[2], "item2 is selected"); + checkActiveDescendant(popup, input, listClone); + + ok(popup.selectNextItem(), "selectNextItem() works"); + + is(popup.selectedIndex, 0, "index 0 is selected"); + is(popup.selectedItem, items[0], "item0 is selected"); + checkActiveDescendant(popup, input, listClone); + + popup.clearItems(); + is(popup.itemCount, 0, "items cleared"); + ok(!input.hasAttribute("aria-activedescendant"), "no aria-activedescendant"); + + const onPopupClose = popup.once("popup-closed"); + popup.hidePopup(); + await onPopupClose; +}); + +function stripNS(text) { + return text.replace(RegExp(' xmlns="http://www.w3.org/1999/xhtml"', "g"), ""); +} + +function getListFromActiveDescendant(popup, input) { + const activeElement = input.ownerDocument.activeElement; + const descendantId = activeElement.getAttribute("aria-activedescendant"); + const cloneItem = input.ownerDocument.querySelector("#" + descendantId); + return cloneItem.parentNode; +} + +function checkActiveDescendant(popup, input, list) { + const activeElement = input.ownerDocument.activeElement; + const descendantId = activeElement.getAttribute("aria-activedescendant"); + const popupItem = popup._tooltip.panel.querySelector("#" + descendantId); + const cloneItem = input.ownerDocument.querySelector("#" + descendantId); + + ok(popupItem, "Active descendant is found in the popup list"); + ok(cloneItem, "Active descendant is found in the list clone"); + is( + cloneItem.parentNode, + list, + "Active descendant is a child of the expected list" + ); + is( + stripNS(popupItem.outerHTML), + cloneItem.outerHTML, + "Cloned item has the same HTML as the original element" + ); +} diff --git a/devtools/client/shared/test/browser_autocomplete_popup_consecutive-show.js b/devtools/client/shared/test/browser_autocomplete_popup_consecutive-show.js new file mode 100644 index 0000000000..1af2df3a1f --- /dev/null +++ b/devtools/client/shared/test/browser_autocomplete_popup_consecutive-show.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that calling `showPopup` multiple time does not lead to invalid state. + +add_task(async function () { + const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js"); + + info("Create an autocompletion popup"); + const { doc } = await createHost(); + const input = doc.createElement("input"); + doc.body.appendChild(input); + + const autocompleteOptions = { + position: "top", + autoSelect: true, + useXulWrapper: true, + }; + const popup = new AutocompletePopup(doc, autocompleteOptions); + const items = [{ label: "a" }, { label: "b" }, { label: "c" }]; + popup.setItems(items); + + input.focus(); + + let onAllEventsReceived = waitForNEvents(popup, "popup-opened", 3); + // Note that the lack of `await` on those function calls are wanted. + popup.openPopup(input, 0, 0, 0); + popup.openPopup(input, 0, 0, 1); + popup.openPopup(input, 0, 0, 2); + await onAllEventsReceived; + + ok(popup.isOpen, "popup is open"); + is( + popup.selectedIndex, + 2, + "Selected index matches the one that was set last when calling openPopup" + ); + + onAllEventsReceived = waitForNEvents(popup, "popup-opened", 2); + // Note that the lack of `await` on those function calls are wanted. + popup.openPopup(input, 0, 0, 1); + popup.openPopup(input); + await onAllEventsReceived; + + ok(popup.isOpen, "popup is open"); + is( + popup.selectedIndex, + 0, + "First item is selected, as last call to openPopup did not specify an index and autoSelect is true" + ); + + const onPopupClose = popup.once("popup-closed"); + popup.hidePopup(); + await onPopupClose; +}); diff --git a/devtools/client/shared/test/browser_autocomplete_popup_input.js b/devtools/client/shared/test/browser_autocomplete_popup_input.js new file mode 100644 index 0000000000..a7d04f1c9c --- /dev/null +++ b/devtools/client/shared/test/browser_autocomplete_popup_input.js @@ -0,0 +1,251 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + // Prevent the URL Bar to steal the focus. + const preventUrlBarFocus = e => { + e.preventDefault(); + }; + window.gURLBar.addEventListener("beforefocus", preventUrlBarFocus); + registerCleanupFunction(() => { + window.gURLBar.removeEventListener("beforefocus", preventUrlBarFocus); + }); + + const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js"); + + info("Create an autocompletion popup and an input that will be bound to it"); + const { doc } = await createHost(); + + const input = doc.createElement("input"); + doc.body.append(input, doc.createElement("input")); + + const onSelectCalled = []; + const onClickCalled = []; + const popup = new AutocompletePopup(doc, { + input, + position: "top", + autoSelect: true, + onSelect: item => onSelectCalled.push(item), + onClick: (e, item) => onClickCalled.push(item), + }); + + input.focus(); + ok(hasFocus(input), "input has focus"); + + info( + "Check that Tab moves the focus out of the input when the popup isn't opened" + ); + EventUtils.synthesizeKey("KEY_Tab"); + is(onClickCalled.length, 0, "onClick wasn't called"); + is(hasFocus(input), false, "input does not have the focus anymore"); + info("Set the focus back to the input and open the popup"); + input.focus(); + await new Promise(res => setTimeout(res, 0)); + ok(hasFocus(input), "input is focused"); + + await populateAndOpenPopup(popup); + + const checkSelectedItem = (expected, info) => + checkPopupSelectedItem(popup, input, expected, info); + + checkSelectedItem(popupItems[0], "First item from top is selected"); + is( + onSelectCalled[0].label, + popupItems[0].label, + "onSelect was called with expected param" + ); + + info("Check that arrow down/up navigates into the list"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + checkSelectedItem(popupItems[1], "item-1 is selected"); + is( + onSelectCalled[1].label, + popupItems[1].label, + "onSelect was called with expected param" + ); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + checkSelectedItem(popupItems[2], "item-2 is selected"); + is( + onSelectCalled[2].label, + popupItems[2].label, + "onSelect was called with expected param" + ); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + checkSelectedItem(popupItems[0], "item-0 is selected"); + is( + onSelectCalled[3].label, + popupItems[0].label, + "onSelect was called with expected param" + ); + + EventUtils.synthesizeKey("KEY_ArrowUp"); + checkSelectedItem(popupItems[2], "item-2 is selected"); + is( + onSelectCalled[4].label, + popupItems[2].label, + "onSelect was called with expected param" + ); + + EventUtils.synthesizeKey("KEY_ArrowUp"); + checkSelectedItem(popupItems[1], "item-2 is selected"); + is( + onSelectCalled[5].label, + popupItems[1].label, + "onSelect was called with expected param" + ); + + info("Check that Escape closes the popup"); + let onPopupClosed = popup.once("popup-closed"); + EventUtils.synthesizeKey("KEY_Escape"); + await onPopupClosed; + ok(true, "popup was closed with Escape key"); + ok(hasFocus(input), "input still has the focus"); + is(onClickCalled.length, 0, "onClick wasn't called"); + + info("Fill the input"); + const value = "item"; + EventUtils.sendString(value); + is(input.value, value, "input has the expected value"); + is( + input.selectionStart, + value.length, + "input cursor is at expected position" + ); + info("Open the popup again"); + await populateAndOpenPopup(popup); + + info("Check that Arrow Left + Shift does not close the popup"); + const timeoutRes = "TIMED_OUT"; + const onRaceEnded = Promise.race([ + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(res => setTimeout(() => res(timeoutRes), 500)), + popup.once("popup-closed"), + ]); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }); + const raceResult = await onRaceEnded; + is(raceResult, timeoutRes, "popup wasn't closed"); + ok(popup.isOpen, "popup is still open"); + is(input.selectionEnd - input.selectionStart, 1, "text was selected"); + ok(hasFocus(input), "input still has the focus"); + + info("Check that Arrow Left closes the popup"); + onPopupClosed = popup.once("popup-closed"); + EventUtils.synthesizeKey("KEY_ArrowLeft"); + await onPopupClosed; + is( + input.selectionStart, + value.length - 1, + "input cursor was moved one char back" + ); + is(input.selectionEnd, input.selectionStart, "selection was removed"); + is(onClickCalled.length, 0, "onClick wasn't called"); + ok(hasFocus(input), "input still has the focus"); + + info("Open the popup again"); + await populateAndOpenPopup(popup); + + info("Check that Arrow Right + Shift does not trigger onClick"); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }); + is(onClickCalled.length, 0, "onClick wasn't called"); + is(input.selectionEnd - input.selectionStart, 1, "input text was selected"); + ok(hasFocus(input), "input still has the focus"); + + info("Check that Arrow Right triggers onClick"); + EventUtils.synthesizeKey("KEY_ArrowRight"); + is(onClickCalled.length, 1, "onClick was called"); + is( + onClickCalled[0], + popupItems[0], + "onClick was called with the selected item" + ); + ok(hasFocus(input), "input still has the focus"); + + info("Check that Enter triggers onClick"); + EventUtils.synthesizeKey("KEY_Enter"); + is(onClickCalled.length, 2, "onClick was called"); + is( + onClickCalled[1], + popupItems[0], + "onClick was called with the selected item" + ); + ok(hasFocus(input), "input still has the focus"); + + info("Check that Tab triggers onClick"); + EventUtils.synthesizeKey("KEY_Tab"); + is(onClickCalled.length, 3, "onClick was called"); + is( + onClickCalled[2], + popupItems[0], + "onClick was called with the selected item" + ); + ok(hasFocus(input), "input still has the focus"); + + info( + "Check that Shift+Tab does not trigger onClick and move the focus out of the input" + ); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + is(onClickCalled.length, 3, "onClick wasn't called"); + is(hasFocus(input), false, "input does not have the focus anymore"); + + const onPopupClose = popup.once("popup-closed"); + popup.hidePopup(); + await onPopupClose; +}); + +const popupItems = [ + { label: "item-0", value: "value-0" }, + { label: "item-1", value: "value-1" }, + { label: "item-2", value: "value-2" }, +]; + +async function populateAndOpenPopup(popup) { + popup.setItems(popupItems); + await popup.openPopup(); +} + +/** + * Returns true if the give node is currently focused. + */ +function hasFocus(node) { + return ( + node.ownerDocument.activeElement == node && node.ownerDocument.hasFocus() + ); +} + +/** + * Check that the selected item in the popup is the expected one. Also check that the + * active descendant is properly set and that the popup has the focus. + * + * @param {AutocompletePopup} popup + * @param {HTMLInput} input + * @param {Object} expectedSelectedItem + * @param {String} info + */ +function checkPopupSelectedItem(popup, input, expectedSelectedItem, info) { + is(popup.selectedItem.label, expectedSelectedItem.label, info); + checkActiveDescendant(popup, input); + ok(hasFocus(input), "input still has the focus"); +} + +function checkActiveDescendant(popup, input) { + const activeElement = input.ownerDocument.activeElement; + const descendantId = activeElement.getAttribute("aria-activedescendant"); + const popupItem = popup._tooltip.panel.querySelector(`#${descendantId}`); + const cloneItem = input.ownerDocument.querySelector(`#${descendantId}`); + + ok(popupItem, "Active descendant is found in the popup list"); + ok(cloneItem, "Active descendant is found in the list clone"); + is( + stripNS(popupItem.outerHTML), + cloneItem.outerHTML, + "Cloned item has the same HTML as the original element" + ); +} + +function stripNS(text) { + return text.replace(RegExp(' xmlns="http://www.w3.org/1999/xhtml"', "g"), ""); +} diff --git a/devtools/client/shared/test/browser_browserloader_mocks.js b/devtools/client/shared/test/browser_browserloader_mocks.js new file mode 100644 index 0000000000..6cc38259f3 --- /dev/null +++ b/devtools/client/shared/test/browser_browserloader_mocks.js @@ -0,0 +1,162 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { BrowserLoader } = ChromeUtils.import( + "resource://devtools/shared/loader/browser-loader.js" +); + +const { + getMockedModule, + setMockedModule, + removeMockedModule, +} = require("resource://devtools/shared/loader/browser-loader-mocks.js"); +const { require: browserRequire } = BrowserLoader({ + baseURI: "resource://devtools/client/shared/", + window, +}); + +// Check that modules can be mocked in the browser loader. +// Test with a custom test module under the chrome:// scheme. +function testWithChromeScheme() { + // Full chrome URI for the test module. + const CHROME_URI = CHROME_URL_ROOT + "test-mocked-module.js"; + const ORIGINAL_VALUE = "Original value"; + const MOCKED_VALUE_1 = "Mocked value 1"; + const MOCKED_VALUE_2 = "Mocked value 2"; + + const m1 = browserRequire(CHROME_URI); + ok(m1, "Regular module can be required"); + is(m1.methodToMock(), ORIGINAL_VALUE, "Method returns the expected value"); + is(m1.someProperty, "someProperty", "Property has the expected value"); + + info("Create a simple mocked version of the test module"); + const mockedModule = { + methodToMock: () => MOCKED_VALUE_1, + someProperty: "somePropertyMocked", + }; + setMockedModule(mockedModule, CHROME_URI); + ok(!!getMockedModule(CHROME_URI), "Has an entry for the chrome URI."); + + const m2 = browserRequire(CHROME_URI); + ok(m2, "Mocked module can be required via chrome URI"); + is( + m2.methodToMock(), + MOCKED_VALUE_1, + "Mocked method returns the expected value" + ); + is( + m2.someProperty, + "somePropertyMocked", + "Mocked property has the expected value" + ); + + const { methodToMock: requiredMethod } = browserRequire(CHROME_URI); + Assert.strictEqual( + requiredMethod(), + MOCKED_VALUE_1, + "Mocked method returns the expected value when imported with destructuring" + ); + + info("Update the mocked method to return a different value"); + mockedModule.methodToMock = () => MOCKED_VALUE_2; + is( + requiredMethod(), + MOCKED_VALUE_2, + "Mocked method returns the updated value when imported with destructuring" + ); + + info("Remove the mock for the test module"); + removeMockedModule(CHROME_URI); + ok(!getMockedModule(CHROME_URI), "Has no entry for the chrome URI."); + + const m3 = browserRequire(CHROME_URI); + ok(m3, "Regular module can be required after removing the mock"); + is( + m3.methodToMock(), + ORIGINAL_VALUE, + "Method on module returns the expected value" + ); +} + +// Similar tests as in testWithChromeScheme, but this time with a devtools module +// available under the resource:// scheme. +function testWithRegularDevtoolsModule() { + // Testing with devtools/shared/path because it is a simple module, that can be imported + // with destructuring. Any other module would do. + const DEVTOOLS_MODULE_PATH = "devtools/shared/path"; + const DEVTOOLS_MODULE_URI = "resource://devtools/shared/path.js"; + + const m1 = browserRequire(DEVTOOLS_MODULE_PATH); + is( + m1.joinURI("https://a", "b"), + "https://a/b", + "Original module was required" + ); + + info( + "Set a mock for a sub-part of the path, which should not match require calls" + ); + setMockedModule({ joinURI: () => "WRONG_PATH" }, "shared/path"); + + ok( + !getMockedModule(DEVTOOLS_MODULE_URI), + "Has no mock entry for the full URI" + ); + const m2 = browserRequire(DEVTOOLS_MODULE_PATH); + is( + m2.joinURI("https://a", "b"), + "https://a/b", + "Original module is still required" + ); + + info( + "Set a mock for the complete path, which should now match require calls" + ); + const mockedModule = { + joinURI: () => "MOCKED VALUE", + }; + setMockedModule(mockedModule, DEVTOOLS_MODULE_PATH); + ok( + !!getMockedModule(DEVTOOLS_MODULE_URI), + "Has a mock entry for the full URI." + ); + + const m3 = browserRequire(DEVTOOLS_MODULE_PATH); + is( + m3.joinURI("https://a", "b"), + "MOCKED VALUE", + "The mocked module has been returned" + ); + + info( + "Check that the mocked methods can be updated after a destructuring import" + ); + const { joinURI } = browserRequire(DEVTOOLS_MODULE_PATH); + mockedModule.joinURI = () => "UPDATED VALUE"; + is( + joinURI("https://a", "b"), + "UPDATED VALUE", + "Mocked method was correctly updated" + ); + + removeMockedModule(DEVTOOLS_MODULE_PATH); + ok( + !getMockedModule(DEVTOOLS_MODULE_URI), + "Has no mock entry for the full URI" + ); + const m4 = browserRequire(DEVTOOLS_MODULE_PATH); + is( + m4.joinURI("https://a", "b"), + "https://a/b", + "Original module can be required again" + ); +} + +function test() { + testWithChromeScheme(); + testWithRegularDevtoolsModule(); + delete window.getBrowserLoaderForWindow; + finish(); +} diff --git a/devtools/client/shared/test/browser_css_angle.js b/devtools/client/shared/test/browser_css_angle.js new file mode 100644 index 0000000000..a29d3e4f0e --- /dev/null +++ b/devtools/client/shared/test/browser_css_angle.js @@ -0,0 +1,204 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* import-globals-from head.js */ +"use strict"; + +var { angleUtils } = require("resource://devtools/client/shared/css-angle.js"); + +add_task(async function () { + await addTab("about:blank"); + const { host } = await createHost("bottom"); + + info("Starting the test"); + testAngleUtils(); + testAngleValidity(); + + host.destroy(); + gBrowser.removeCurrentTab(); +}); + +function testAngleUtils() { + const data = getTestData(); + + for (const { authored, deg, rad, grad, turn } of data) { + const angle = new angleUtils.CssAngle(authored); + + // Check all values. + info("Checking values for " + authored); + is(angle.deg, deg, "color.deg === deg"); + is(angle.rad, rad, "color.rad === rad"); + is(angle.grad, grad, "color.grad === grad"); + is(angle.turn, turn, "color.turn === turn"); + + testToString(angle, deg, rad, grad, turn); + } +} + +function testAngleValidity() { + const data = getAngleValidityData(); + + for (const { angle, result } of data) { + const testAngle = new angleUtils.CssAngle(angle); + const validString = testAngle.valid ? " a valid" : "an invalid"; + + is( + testAngle.valid, + result, + `Testing that "${angle}" is ${validString} angle` + ); + } +} + +function testToString(angle, deg, rad, grad, turn) { + const { ANGLEUNIT } = angleUtils.CssAngle.prototype; + angle.angleUnit = ANGLEUNIT.deg; + is(angle.toString(), deg, "toString() with deg type"); + + angle.angleUnit = ANGLEUNIT.rad; + is(angle.toString(), rad, "toString() with rad type"); + + angle.angleUnit = ANGLEUNIT.grad; + is(angle.toString(), grad, "toString() with grad type"); + + angle.angleUnit = ANGLEUNIT.turn; + is(angle.toString(), turn, "toString() with turn type"); +} + +function getAngleValidityData() { + return [ + { + angle: "0.2turn", + result: true, + }, + { + angle: "-0.2turn", + result: true, + }, + { + angle: "-.2turn", + result: true, + }, + { + angle: "1e02turn", + result: true, + }, + { + angle: "-2e2turn", + result: true, + }, + { + angle: ".2turn", + result: true, + }, + { + angle: "0.2aaturn", + result: false, + }, + { + angle: "2dega", + result: false, + }, + { + angle: "0.deg", + result: false, + }, + { + angle: ".deg", + result: false, + }, + { + angle: "..2turn", + result: false, + }, + ]; +} + +function getTestData() { + return [ + { + authored: "0deg", + deg: "0deg", + rad: "0rad", + grad: "0grad", + turn: "0turn", + }, + { + authored: "180deg", + deg: "180deg", + rad: `${Math.round(Math.PI * 10000) / 10000}rad`, + grad: "200grad", + turn: "0.5turn", + }, + { + authored: "180DEG", + deg: "180DEG", + rad: `${Math.round(Math.PI * 10000) / 10000}RAD`, + grad: "200GRAD", + turn: "0.5TURN", + }, + { + authored: `-${Math.PI}rad`, + deg: "-180deg", + rad: `-${Math.PI}rad`, + grad: "-200grad", + turn: "-0.5turn", + }, + { + authored: `-${Math.PI}RAD`, + deg: "-180DEG", + rad: `-${Math.PI}RAD`, + grad: "-200GRAD", + turn: "-0.5TURN", + }, + { + authored: "100grad", + deg: "90deg", + rad: `${Math.round((Math.PI / 2) * 10000) / 10000}rad`, + grad: "100grad", + turn: "0.25turn", + }, + { + authored: "100GRAD", + deg: "90DEG", + rad: `${Math.round((Math.PI / 2) * 10000) / 10000}RAD`, + grad: "100GRAD", + turn: "0.25TURN", + }, + { + authored: "-1turn", + deg: "-360deg", + rad: `${(-1 * Math.round(Math.PI * 2 * 10000)) / 10000}rad`, + grad: "-400grad", + turn: "-1turn", + }, + { + authored: "-10TURN", + deg: "-3600DEG", + rad: `${(-1 * Math.round(Math.PI * 2 * 10 * 10000)) / 10000}RAD`, + grad: "-4000GRAD", + turn: "-10TURN", + }, + { + authored: "inherit", + deg: "inherit", + rad: "inherit", + grad: "inherit", + turn: "inherit", + }, + { + authored: "initial", + deg: "initial", + rad: "initial", + grad: "initial", + turn: "initial", + }, + { + authored: "unset", + deg: "unset", + rad: "unset", + grad: "unset", + turn: "unset", + }, + ]; +} diff --git a/devtools/client/shared/test/browser_css_color.js b/devtools/client/shared/test/browser_css_color.js new file mode 100644 index 0000000000..d66656ac86 --- /dev/null +++ b/devtools/client/shared/test/browser_css_color.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var { colorUtils } = require("resource://devtools/shared/css/color.js"); +/* global getFixtureColorData */ +loadHelperScript("helper_color_data.js"); + +add_task(async function () { + await addTab("about:blank"); + const { host, doc } = await createHost("bottom"); + + info("Creating a test canvas element to test colors"); + const canvas = createTestCanvas(doc); + info("Starting the test"); + testColorUtils(canvas); + + host.destroy(); + gBrowser.removeCurrentTab(); +}); + +function createTestCanvas(doc) { + const canvas = doc.createElement("canvas"); + canvas.width = canvas.height = 10; + doc.body.appendChild(canvas); + return canvas; +} + +function testColorUtils(canvas) { + const data = getFixtureColorData(); + + for (const { authored, name, hex, hsl, rgb } of data) { + const color = new colorUtils.CssColor(authored); + + // Check all values. + info("Checking values for " + authored); + is(color.name, name, "color.name === name"); + is(color.hex, hex, "color.hex === hex"); + is(color.hsl, hsl, "color.hsl === hsl"); + is(color.rgb, rgb, "color.rgb === rgb"); + + testToString(color, name, hex, hsl, rgb); + testColorMatch(name, hex, hsl, rgb, color.rgba, canvas); + } +} + +function testToString(color, name, hex, hsl, rgb) { + const { COLORUNIT } = colorUtils.CssColor; + is(color.toString(COLORUNIT.name), name, "toString() with authored type"); + is(color.toString(COLORUNIT.hex), hex, "toString() with hex type"); + is(color.toString(COLORUNIT.hsl), hsl, "toString() with hsl type"); + is(color.toString(COLORUNIT.rgb), rgb, "toString() with rgb type"); +} + +function testColorMatch(name, hex, hsl, rgb, rgba, canvas) { + let target; + const ctx = canvas.getContext("2d"); + + const clearCanvas = function () { + canvas.width = 1; + }; + const setColor = function (color) { + ctx.fillStyle = color; + ctx.fillRect(0, 0, 1, 1); + }; + const setTargetColor = function () { + clearCanvas(); + // All colors have rgba so we can use this to compare against. + setColor(rgba); + const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data; + target = { r, g, b, a }; + }; + const test = function (color, type) { + // hsla -> rgba -> hsla produces inaccurate results so we + // need some tolerence here. + const tolerance = 3; + clearCanvas(); + + setColor(color); + const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data; + + const rgbFail = + Math.abs(r - target.r) > tolerance || + Math.abs(g - target.g) > tolerance || + Math.abs(b - target.b) > tolerance; + ok(!rgbFail, "color " + rgba + " matches target. Type: " + type); + if (rgbFail) { + info( + `target: ${JSON.stringify( + target + )}, color: [r: ${r}, g: ${g}, b: ${b}, a: ${a}]` + ); + } + + const alphaFail = a !== target.a; + ok(!alphaFail, "color " + rgba + " alpha value matches target."); + }; + + setTargetColor(); + + test(name, "name"); + test(hex, "hex"); + test(hsl, "hsl"); + test(rgb, "rgb"); +} diff --git a/devtools/client/shared/test/browser_cubic-bezier-01.js b/devtools/client/shared/test/browser_cubic-bezier-01.js new file mode 100644 index 0000000000..b57e2de25b --- /dev/null +++ b/devtools/client/shared/test/browser_cubic-bezier-01.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the CubicBezierWidget generates content in a given parent node + +const { + CubicBezierWidget, +} = require("resource://devtools/client/shared/widgets/CubicBezierWidget.js"); + +const TEST_URI = CHROME_URL_ROOT + "doc_cubic-bezier-01.html"; + +add_task(async function () { + const { host, doc } = await createHost("bottom", TEST_URI); + + info("Checking that the graph markup is created in the parent"); + const container = doc.querySelector("#cubic-bezier-container"); + const w = new CubicBezierWidget(container); + + ok(container.querySelector(".display-wrap"), "The display has been added"); + + ok( + container.querySelector(".coordinate-plane"), + "The coordinate plane has been added" + ); + const buttons = container.querySelectorAll("button"); + is(buttons.length, 2, "The 2 control points have been added"); + is(buttons[0].className, "control-point"); + is(buttons[1].className, "control-point"); + ok(container.querySelector("canvas"), "The curve canvas has been added"); + + info("Destroying the widget"); + w.destroy(); + is(container.children.length, 0, "All nodes have been removed"); + + host.destroy(); +}); diff --git a/devtools/client/shared/test/browser_cubic-bezier-02.js b/devtools/client/shared/test/browser_cubic-bezier-02.js new file mode 100644 index 0000000000..3ce0af2f99 --- /dev/null +++ b/devtools/client/shared/test/browser_cubic-bezier-02.js @@ -0,0 +1,206 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the CubicBezierWidget events + +const { + CubicBezierWidget, +} = require("resource://devtools/client/shared/widgets/CubicBezierWidget.js"); +const { + PREDEFINED, +} = require("resource://devtools/client/shared/widgets/CubicBezierPresets.js"); + +// In this test we have to use a slightly more complete HTML tree, with +// in order to remove its margin and prevent shifted positions +const TEST_URI = CHROME_URL_ROOT + "doc_cubic-bezier-02.html"; + +add_task(async function () { + const { host, win, doc } = await createHost("bottom", TEST_URI); + + // Required or widget will be clipped inside of 'bottom' + // host by -14. Setting `fixed` zeroes this which is needed for + // calculating offsets. Occurs in test env only. + doc.body.setAttribute("style", "position: fixed; margin: 0;"); + + const container = doc.querySelector("#cubic-bezier-container"); + const w = new CubicBezierWidget(container, PREDEFINED.linear); + + const rect = w.curve.getBoundingClientRect(); + rect.graphTop = rect.height * w.bezierCanvas.padding[0]; + rect.graphBottom = rect.height - rect.graphTop; + rect.graphHeight = rect.graphBottom - rect.graphTop; + + await pointsCanBeDragged(w, win, doc, rect); + await curveCanBeClicked(w, win, doc, rect); + await pointsCanBeMovedWithKeyboard(w, win, doc, rect); + + w.destroy(); + host.destroy(); +}); + +async function pointsCanBeDragged(widget, win, doc, offsets) { + info("Checking that the control points can be dragged with the mouse"); + + info("Listening for the update event"); + let onUpdated = widget.once("updated"); + + info("Generating a mousedown/move/up on P1"); + widget._onPointMouseDown({ target: widget.p1 }); + doc.onmousemove({ pageX: offsets.left, pageY: offsets.graphTop }); + doc.onmouseup(); + + let bezier = await onUpdated; + ok(true, "The widget fired the updated event"); + ok(bezier, "The updated event contains a bezier argument"); + is(bezier.P1[0], 0, "The new P1 time coordinate is correct"); + is(bezier.P1[1], 1, "The new P1 progress coordinate is correct"); + + info("Listening for the update event"); + onUpdated = widget.once("updated"); + + info("Generating a mousedown/move/up on P2"); + widget._onPointMouseDown({ target: widget.p2 }); + doc.onmousemove({ pageX: offsets.right, pageY: offsets.graphBottom }); + doc.onmouseup(); + + bezier = await onUpdated; + is(bezier.P2[0], 1, "The new P2 time coordinate is correct"); + is(bezier.P2[1], 0, "The new P2 progress coordinate is correct"); +} + +async function curveCanBeClicked(widget, win, doc, offsets) { + info("Checking that clicking on the curve moves the closest control point"); + + info("Listening for the update event"); + let onUpdated = widget.once("updated"); + + info("Click close to P1"); + let x = offsets.left + offsets.width / 4.0; + let y = offsets.graphTop + offsets.graphHeight / 4.0; + widget._onCurveClick({ pageX: x, pageY: y }); + + let bezier = await onUpdated; + ok(true, "The widget fired the updated event"); + is(bezier.P1[0], 0.25, "The new P1 time coordinate is correct"); + is(bezier.P1[1], 0.75, "The new P1 progress coordinate is correct"); + is(bezier.P2[0], 1, "P2 time coordinate remained unchanged"); + is(bezier.P2[1], 0, "P2 progress coordinate remained unchanged"); + + info("Listening for the update event"); + onUpdated = widget.once("updated"); + + info("Click close to P2"); + x = offsets.right - offsets.width / 4; + y = offsets.graphBottom - offsets.graphHeight / 4; + widget._onCurveClick({ pageX: x, pageY: y }); + + bezier = await onUpdated; + is(bezier.P2[0], 0.75, "The new P2 time coordinate is correct"); + is(bezier.P2[1], 0.25, "The new P2 progress coordinate is correct"); + is(bezier.P1[0], 0.25, "P1 time coordinate remained unchanged"); + is(bezier.P1[1], 0.75, "P1 progress coordinate remained unchanged"); +} + +async function pointsCanBeMovedWithKeyboard(widget, win, doc, offsets) { + info("Checking that points respond to keyboard events"); + + const singleStep = 3; + const shiftStep = 30; + + info("Moving P1 to the left"); + let newOffset = parseInt(widget.p1.style.left, 10) - singleStep; + let x = widget.bezierCanvas.offsetsToCoordinates({ + style: { left: newOffset }, + })[0]; + + let onUpdated = widget.once("updated"); + widget._onPointKeyDown(getKeyEvent(widget.p1, 37)); + let bezier = await onUpdated; + + is(bezier.P1[0], x, "The new P1 time coordinate is correct"); + is(bezier.P1[1], 0.75, "The new P1 progress coordinate is correct"); + + info("Moving P1 to the left, fast"); + newOffset = parseInt(widget.p1.style.left, 10) - shiftStep; + x = widget.bezierCanvas.offsetsToCoordinates({ + style: { left: newOffset }, + })[0]; + + onUpdated = widget.once("updated"); + widget._onPointKeyDown(getKeyEvent(widget.p1, 37, true)); + bezier = await onUpdated; + is(bezier.P1[0], x, "The new P1 time coordinate is correct"); + is(bezier.P1[1], 0.75, "The new P1 progress coordinate is correct"); + + info("Moving P1 to the right, fast"); + newOffset = parseInt(widget.p1.style.left, 10) + shiftStep; + x = widget.bezierCanvas.offsetsToCoordinates({ + style: { left: newOffset }, + })[0]; + + onUpdated = widget.once("updated"); + widget._onPointKeyDown(getKeyEvent(widget.p1, 39, true)); + bezier = await onUpdated; + is(bezier.P1[0], x, "The new P1 time coordinate is correct"); + is(bezier.P1[1], 0.75, "The new P1 progress coordinate is correct"); + + info("Moving P1 to the bottom"); + newOffset = parseInt(widget.p1.style.top, 10) + singleStep; + let y = widget.bezierCanvas.offsetsToCoordinates({ + style: { top: newOffset }, + })[1]; + + onUpdated = widget.once("updated"); + widget._onPointKeyDown(getKeyEvent(widget.p1, 40)); + bezier = await onUpdated; + is(bezier.P1[0], x, "The new P1 time coordinate is correct"); + is(bezier.P1[1], y, "The new P1 progress coordinate is correct"); + + info("Moving P1 to the bottom, fast"); + newOffset = parseInt(widget.p1.style.top, 10) + shiftStep; + y = widget.bezierCanvas.offsetsToCoordinates({ + style: { top: newOffset }, + })[1]; + + onUpdated = widget.once("updated"); + widget._onPointKeyDown(getKeyEvent(widget.p1, 40, true)); + bezier = await onUpdated; + is(bezier.P1[0], x, "The new P1 time coordinate is correct"); + is(bezier.P1[1], y, "The new P1 progress coordinate is correct"); + + info("Moving P1 to the top, fast"); + newOffset = parseInt(widget.p1.style.top, 10) - shiftStep; + y = widget.bezierCanvas.offsetsToCoordinates({ + style: { top: newOffset }, + })[1]; + + onUpdated = widget.once("updated"); + widget._onPointKeyDown(getKeyEvent(widget.p1, 38, true)); + bezier = await onUpdated; + is(bezier.P1[0], x, "The new P1 time coordinate is correct"); + is(bezier.P1[1], y, "The new P1 progress coordinate is correct"); + + info("Checking that keyboard events also work with P2"); + info("Moving P2 to the left"); + newOffset = parseInt(widget.p2.style.left, 10) - singleStep; + x = widget.bezierCanvas.offsetsToCoordinates({ + style: { left: newOffset }, + })[0]; + + onUpdated = widget.once("updated"); + widget._onPointKeyDown(getKeyEvent(widget.p2, 37)); + bezier = await onUpdated; + is(bezier.P2[0], x, "The new P2 time coordinate is correct"); + is(bezier.P2[1], 0.25, "The new P2 progress coordinate is correct"); +} + +function getKeyEvent(target, keyCode, shift = false) { + return { + target, + keyCode, + shiftKey: shift, + preventDefault: () => {}, + }; +} diff --git a/devtools/client/shared/test/browser_cubic-bezier-03.js b/devtools/client/shared/test/browser_cubic-bezier-03.js new file mode 100644 index 0000000000..2c722bbf41 --- /dev/null +++ b/devtools/client/shared/test/browser_cubic-bezier-03.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that coordinates can be changed programatically in the CubicBezierWidget + +const { + CubicBezierWidget, +} = require("resource://devtools/client/shared/widgets/CubicBezierWidget.js"); +const { + PREDEFINED, +} = require("resource://devtools/client/shared/widgets/CubicBezierPresets.js"); + +const TEST_URI = CHROME_URL_ROOT + "doc_cubic-bezier-01.html"; + +add_task(async function () { + const { host, doc } = await createHost("bottom", TEST_URI); + + const container = doc.querySelector("#cubic-bezier-container"); + const w = new CubicBezierWidget(container, PREDEFINED.linear); + + await coordinatesCanBeChangedByProvidingAnArray(w); + await coordinatesCanBeChangedByProvidingAValue(w); + + w.destroy(); + host.destroy(); +}); + +async function coordinatesCanBeChangedByProvidingAnArray(widget) { + info("Listening for the update event"); + const onUpdated = widget.once("updated"); + + info("Setting new coordinates"); + widget.coordinates = [0, 1, 1, 0]; + + const bezier = await onUpdated; + ok(true, "The updated event was fired as a result of setting coordinates"); + + is(bezier.P1[0], 0, "The new P1 time coordinate is correct"); + is(bezier.P1[1], 1, "The new P1 progress coordinate is correct"); + is(bezier.P2[0], 1, "The new P2 time coordinate is correct"); + is(bezier.P2[1], 0, "The new P2 progress coordinate is correct"); +} + +async function coordinatesCanBeChangedByProvidingAValue(widget) { + info("Listening for the update event"); + let onUpdated = widget.once("updated"); + + info("Setting linear css value"); + widget.cssCubicBezierValue = "linear"; + let bezier = await onUpdated; + ok(true, "The updated event was fired as a result of setting cssValue"); + + is(bezier.P1[0], 0, "The new P1 time coordinate is correct"); + is(bezier.P1[1], 0, "The new P1 progress coordinate is correct"); + is(bezier.P2[0], 1, "The new P2 time coordinate is correct"); + is(bezier.P2[1], 1, "The new P2 progress coordinate is correct"); + + info("Setting a custom cubic-bezier css value"); + onUpdated = widget.once("updated"); + widget.cssCubicBezierValue = "cubic-bezier(.25,-0.5, 1, 1.25)"; + bezier = await onUpdated; + ok(true, "The updated event was fired as a result of setting cssValue"); + + is(bezier.P1[0], 0.25, "The new P1 time coordinate is correct"); + is(bezier.P1[1], -0.5, "The new P1 progress coordinate is correct"); + is(bezier.P2[0], 1, "The new P2 time coordinate is correct"); + is(bezier.P2[1], 1.25, "The new P2 progress coordinate is correct"); +} diff --git a/devtools/client/shared/test/browser_cubic-bezier-04.js b/devtools/client/shared/test/browser_cubic-bezier-04.js new file mode 100644 index 0000000000..1fff1821ff --- /dev/null +++ b/devtools/client/shared/test/browser_cubic-bezier-04.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the CubicBezierPresetWidget generates markup. + +const { + CubicBezierPresetWidget, +} = require("resource://devtools/client/shared/widgets/CubicBezierWidget.js"); +const { + PRESETS, +} = require("resource://devtools/client/shared/widgets/CubicBezierPresets.js"); + +const TEST_URI = CHROME_URL_ROOT + "doc_cubic-bezier-01.html"; + +add_task(async function () { + const { host, doc } = await createHost("bottom", TEST_URI); + + const container = doc.querySelector("#cubic-bezier-container"); + const w = new CubicBezierPresetWidget(container); + + info("Checking that the presets are created in the parent"); + ok(container.querySelector(".preset-pane"), "The preset pane has been added"); + + ok( + container.querySelector("#preset-categories"), + "The preset categories have been added" + ); + const categories = container.querySelectorAll(".category"); + is( + categories.length, + Object.keys(PRESETS).length, + "The preset categories have been added" + ); + Object.keys(PRESETS).forEach(category => { + ok(container.querySelector("#" + category), `${category} has been added`); + ok( + container.querySelector("#preset-category-" + category), + `The preset list for ${category} has been added.` + ); + }); + + info("Checking that each of the presets and its preview have been added"); + Object.keys(PRESETS).forEach(category => { + Object.keys(PRESETS[category]).forEach(presetLabel => { + const preset = container.querySelector("#" + presetLabel); + ok(preset, `${presetLabel} has been added`); + ok( + preset.querySelector("canvas"), + `${presetLabel}'s canvas preview has been added` + ); + ok(preset.querySelector("p"), `${presetLabel}'s label has been added`); + }); + }); + + w.destroy(); + host.destroy(); +}); diff --git a/devtools/client/shared/test/browser_cubic-bezier-05.js b/devtools/client/shared/test/browser_cubic-bezier-05.js new file mode 100644 index 0000000000..2e6659c07d --- /dev/null +++ b/devtools/client/shared/test/browser_cubic-bezier-05.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the CubicBezierPresetWidget cycles menus + +const { + CubicBezierPresetWidget, +} = require("resource://devtools/client/shared/widgets/CubicBezierWidget.js"); +const { + PREDEFINED, + PRESETS, + DEFAULT_PRESET_CATEGORY, +} = require("resource://devtools/client/shared/widgets/CubicBezierPresets.js"); + +const TEST_URI = CHROME_URL_ROOT + "doc_cubic-bezier-01.html"; + +add_task(async function () { + const { host, doc } = await createHost("bottom", TEST_URI); + + const container = doc.querySelector("#cubic-bezier-container"); + const w = new CubicBezierPresetWidget(container); + + info("Checking that preset is selected if coordinates are known"); + + w.refreshMenu([0, 0, 0, 0]); + is( + w.activeCategory, + container.querySelector(`#${DEFAULT_PRESET_CATEGORY}`), + "The default category is selected" + ); + is(w._activePreset, null, "There is no selected category"); + + w.refreshMenu(PREDEFINED.linear); + is( + w.activeCategory, + container.querySelector("#ease-in-out"), + "The ease-in-out category is active" + ); + is( + w._activePreset, + container.querySelector("#ease-in-out-linear"), + "The ease-in-out-linear preset is active" + ); + + w.refreshMenu(PRESETS["ease-out"]["ease-out-sine"]); + is( + w.activeCategory, + container.querySelector("#ease-out"), + "The ease-out category is active" + ); + is( + w._activePreset, + container.querySelector("#ease-out-sine"), + "The ease-out-sine preset is active" + ); + + w.refreshMenu([0, 0, 0, 0]); + is( + w.activeCategory, + container.querySelector("#ease-out"), + "The ease-out category is still active" + ); + is(w._activePreset, null, "No preset is active"); + + w.destroy(); + host.destroy(); +}); diff --git a/devtools/client/shared/test/browser_cubic-bezier-06.js b/devtools/client/shared/test/browser_cubic-bezier-06.js new file mode 100644 index 0000000000..9cb00e8bf7 --- /dev/null +++ b/devtools/client/shared/test/browser_cubic-bezier-06.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the integration between CubicBezierWidget and CubicBezierPresets + +const { + CubicBezierWidget, +} = require("resource://devtools/client/shared/widgets/CubicBezierWidget.js"); +const { + PRESETS, +} = require("resource://devtools/client/shared/widgets/CubicBezierPresets.js"); + +const TEST_URI = CHROME_URL_ROOT + "doc_cubic-bezier-01.html"; + +add_task(async function () { + const { host, win, doc } = await createHost("bottom", TEST_URI); + + const container = doc.querySelector("#cubic-bezier-container"); + const w = new CubicBezierWidget( + container, + PRESETS["ease-in"]["ease-in-sine"] + ); + w.presets.refreshMenu(PRESETS["ease-in"]["ease-in-sine"]); + + const rect = w.curve.getBoundingClientRect(); + rect.graphTop = rect.height * w.bezierCanvas.padding[0]; + + await adjustingBezierUpdatesPreset(w, win, doc, rect); + await selectingPresetUpdatesBezier(w, win, doc, rect); + + w.destroy(); + host.destroy(); +}); + +function adjustingBezierUpdatesPreset(widget, win, doc, rect) { + info("Checking that changing the bezier refreshes the preset menu"); + + is( + widget.presets.activeCategory, + doc.querySelector("#ease-in"), + "The selected category is ease-in" + ); + + is( + widget.presets._activePreset, + doc.querySelector("#ease-in-sine"), + "The selected preset is ease-in-sine" + ); + + info("Generating custom bezier curve by dragging"); + widget._onPointMouseDown({ target: widget.p1 }); + doc.onmousemove({ pageX: rect.left, pageY: rect.graphTop }); + doc.onmouseup(); + + is( + widget.presets.activeCategory, + doc.querySelector("#ease-in"), + "The selected category is still ease-in" + ); + + is(widget.presets._activePreset, null, "There is no active preset"); +} + +async function selectingPresetUpdatesBezier(widget, win, doc, rect) { + info("Checking that selecting a preset updates bezier curve"); + + info("Listening for the new coordinates event"); + const onNewCoordinates = widget.presets.once("new-coordinates"); + const onUpdated = widget.once("updated"); + + info("Click a preset"); + const preset = doc.querySelector("#ease-in-sine"); + widget.presets._onPresetClick({ currentTarget: preset }); + + await onNewCoordinates; + ok(true, "The preset widget fired the new-coordinates event"); + + const bezier = await onUpdated; + ok(true, "The bezier canvas fired the updated event"); + + is( + bezier.P1[0], + preset.coordinates[0], + "The new P1 time coordinate is correct" + ); + is( + bezier.P1[1], + preset.coordinates[1], + "The new P1 progress coordinate is correct" + ); + is(bezier.P2[0], preset.coordinates[2], "P2 time coordinate is correct "); + is(bezier.P2[1], preset.coordinates[3], "P2 progress coordinate is correct"); +} diff --git a/devtools/client/shared/test/browser_cubic-bezier-07.js b/devtools/client/shared/test/browser_cubic-bezier-07.js new file mode 100644 index 0000000000..2c7d81b2e3 --- /dev/null +++ b/devtools/client/shared/test/browser_cubic-bezier-07.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that changing the cubic-bezier curve in the widget does change the dot animation +// preview too. + +const { + CubicBezierWidget, +} = require("resource://devtools/client/shared/widgets/CubicBezierWidget.js"); +const { + PREDEFINED, +} = require("resource://devtools/client/shared/widgets/CubicBezierPresets.js"); + +const TEST_URI = CHROME_URL_ROOT + "doc_cubic-bezier-01.html"; + +registerCleanupFunction(() => { + Services.prefs.clearUserPref("ui.prefersReducedMotion"); +}); + +add_task(async function () { + const { host, doc } = await createHost("bottom", TEST_URI); + // Unset "prefers reduced motion", otherwise the dot animation preview won't be created. + // See Bug 1637842 + // https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion + await pushPref("ui.prefersReducedMotion", 0); + + const container = doc.querySelector("#cubic-bezier-container"); + const w = new CubicBezierWidget(container, PREDEFINED.linear); + + await previewDotReactsToChanges(w, [0.6, -0.28, 0.74, 0.05]); + await previewDotReactsToChanges(w, [0.9, 0.03, 0.69, 0.22]); + await previewDotReactsToChanges(w, [0.68, -0.55, 0.27, 1.55]); + await previewDotReactsToChanges(w, PREDEFINED.ease, "ease"); + await previewDotReactsToChanges(w, PREDEFINED["ease-in-out"], "ease-in-out"); + + w.destroy(); + host.destroy(); +}); + +async function previewDotReactsToChanges(widget, coords, expectedEasing) { + const onUpdated = widget.once("updated"); + widget.coordinates = coords; + await onUpdated; + + const animatedDot = widget.timingPreview.dot; + const animations = animatedDot.getAnimations(); + + if (!expectedEasing) { + expectedEasing = `cubic-bezier(${coords[0]}, ${coords[1]}, ${coords[2]}, ${coords[3]})`; + } + + is(animations.length, 1, "The dot is animated"); + + const goingToRight = animations[0].effect.getKeyframes()[2]; + is( + goingToRight.easing, + expectedEasing, + `The easing when going to the right was set correctly to ${coords}` + ); + + const goingToLeft = animations[0].effect.getKeyframes()[6]; + is( + goingToLeft.easing, + expectedEasing, + `The easing when going to the left was set correctly to ${coords}` + ); +} diff --git a/devtools/client/shared/test/browser_dbg_globalactor.js b/devtools/client/shared/test/browser_dbg_globalactor.js new file mode 100644 index 0000000000..71cda04b47 --- /dev/null +++ b/devtools/client/shared/test/browser_dbg_globalactor.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check extension-added global actor API. + */ + +"use strict"; + +var { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); +var { + ActorRegistry, +} = require("resource://devtools/server/actors/utils/actor-registry.js"); +var { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); + +const ACTORS_URL = EXAMPLE_URL + "testactors.js"; + +add_task(async function () { + DevToolsServer.init(); + DevToolsServer.registerAllActors(); + + ActorRegistry.registerModule(ACTORS_URL, { + prefix: "testOne", + constructor: "TestActor1", + type: { global: true }, + }); + + const transport = DevToolsServer.connectPipe(); + const client = new DevToolsClient(transport); + const [type] = await client.connect(); + is(type, "browser", "Root actor should identify itself as a browser."); + + let response = await client.mainRoot.rootForm; + const globalActor = response.testOneActor; + ok(globalActor, "Found the test global actor."); + ok( + globalActor.includes("testOne"), + "testGlobalActor1's typeName should be used." + ); + + response = await client.request({ to: globalActor, type: "ping" }); + is(response.pong, "pong", "Actor should respond to requests."); + + // Send another ping to see if the same actor is used. + response = await client.request({ to: globalActor, type: "ping" }); + is(response.pong, "pong", "Actor should respond to requests."); + + // Make sure that lazily-created actors are created only once. + let count = 0; + for (const connID of Object.getOwnPropertyNames( + DevToolsServer._connections + )) { + const conn = DevToolsServer._connections[connID]; + const computedPrefix = conn._prefix + "testOne"; + for (const pool of conn._extraPools) { + for (const actor of pool.poolChildren()) { + if (actor.actorID.startsWith(computedPrefix)) { + count++; + } + } + } + } + + is(count, 1, "Only one actor exists in all pools. One global actor."); + + await client.close(); +}); diff --git a/devtools/client/shared/test/browser_dbg_listaddons.js b/devtools/client/shared/test/browser_dbg_listaddons.js new file mode 100644 index 0000000000..d6d302db34 --- /dev/null +++ b/devtools/client/shared/test/browser_dbg_listaddons.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); +var { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); + +/** + * Make sure the listAddons request works as specified. + */ +const ADDON1_ID = "test-addon-1@mozilla.org"; +const ADDON1_PATH = "addons/test-addon-1/"; +const ADDON2_ID = "test-addon-2@mozilla.org"; +const ADDON2_PATH = "addons/test-addon-2/"; + +add_task(async function () { + DevToolsServer.init(); + DevToolsServer.registerAllActors(); + + const transport = DevToolsServer.connectPipe(); + const client = new DevToolsClient(transport); + + const [type] = await client.connect(); + is(type, "browser", "Root actor should identify itself as a browser."); + + let addonListChangedEvents = 0; + client.mainRoot.on("addonListChanged", () => addonListChangedEvents++); + const addons = await client.mainRoot.getFront("addons"); + + const addon1 = await addTemporaryAddon({ + addons, + path: ADDON1_PATH, + openDevTools: false, + }); + const addonFront1 = await client.mainRoot.getAddon({ id: ADDON1_ID }); + is(addonListChangedEvents, 0, "Should not receive addonListChanged yet."); + ok(addonFront1, "Should find an addon actor for addon1."); + + const addon2 = await addTemporaryAddon({ + addons, + path: ADDON2_PATH, + openDevTools: true, + }); + const front1AfterAddingAddon2 = await client.mainRoot.getAddon({ + id: ADDON1_ID, + }); + const addonFront2 = await client.mainRoot.getAddon({ id: ADDON2_ID }); + + is( + addonListChangedEvents, + 1, + "Should have received an addonListChanged event." + ); + ok(addonFront2, "Should find an addon actor for addon2."); + is( + addonFront1, + front1AfterAddingAddon2, + "Front for addon1 should be the same" + ); + + await removeAddon(addon1); + const front1AfterRemove = await client.mainRoot.getAddon({ id: ADDON1_ID }); + is( + addonListChangedEvents, + 2, + "Should have received an addonListChanged event." + ); + ok(!front1AfterRemove, "Should no longer get a front for addon1"); + + await removeAddon(addon2); + const front2AfterRemove = await client.mainRoot.getAddon({ id: ADDON2_ID }); + is( + addonListChangedEvents, + 3, + "Should have received an addonListChanged event." + ); + ok(!front2AfterRemove, "Should no longer get a front for addon1"); + + // Check behavior when openDevTools is not passed: + const addon2again = await addTemporaryAddon({ + addons, + path: ADDON2_PATH, + // openDevTools: null, + }); + const addonFront2again = await client.mainRoot.getAddon({ id: ADDON2_ID }); + ok(addonFront2again, "Should find an addon actor for addon2."); + is(addonListChangedEvents, 4, "Should have seen addonListChanged."); + await removeAddon(addon2again); + is(addonListChangedEvents, 5, "Should have seen addonListChanged."); + + await client.close(); +}); + +async function addTemporaryAddon({ addons, path, openDevTools }) { + const addonFilePath = getTestFilePath(path); + info("Installing addon: " + addonFilePath); + + const onToolboxReady = gDevTools.once("toolbox-ready"); + const { id } = await addons.installTemporaryAddon( + addonFilePath, + openDevTools + ); + + if (openDevTools) { + info("Wait for toolbox to be opened"); + const toolbox = await onToolboxReady; + ok(true, "Toolbox was opened when openDevTools option was true"); + info("Destroying this toolbox"); + await toolbox.destroy(); + } + + return AddonManager.getAddonByID(id); +} + +function removeAddon(addon) { + return new Promise(resolve => { + info("Removing addon."); + + const listener = { + onUninstalled(uninstalledAddon) { + if (uninstalledAddon != addon) { + return; + } + AddonManager.removeAddonListener(listener); + resolve(); + }, + }; + + AddonManager.addAddonListener(listener); + addon.uninstall(); + }); +} diff --git a/devtools/client/shared/test/browser_dbg_listtabs-01.js b/devtools/client/shared/test/browser_dbg_listtabs-01.js new file mode 100644 index 0000000000..e804a6b91c --- /dev/null +++ b/devtools/client/shared/test/browser_dbg_listtabs-01.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Make sure the listTabs request works as specified. + */ + +var { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); +var { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); + +const TAB1_URL = EXAMPLE_URL + "doc_empty-tab-01.html"; +const TAB2_URL = EXAMPLE_URL + "doc_empty-tab-02.html"; + +add_task(async function test() { + DevToolsServer.init(); + DevToolsServer.registerAllActors(); + + const transport = DevToolsServer.connectPipe(); + const client = new DevToolsClient(transport); + const [aType] = await client.connect(); + is(aType, "browser", "Root actor should identify itself as a browser."); + + const firstTab = await testFirstTab(client); + const secondTab = await testSecondTab(client, firstTab.front); + await testRemoveTab(client, firstTab.tab); + await testAttachRemovedTab(secondTab.tab, secondTab.front); + await client.close(); +}); + +async function testFirstTab(client) { + const tab = await addTab(TAB1_URL); + + const front = await getDescriptorActorForUrl(client, TAB1_URL); + ok(front, "Should find a target actor for the first tab."); + return { tab, front }; +} + +async function testSecondTab(client, firstTabFront) { + const tab = await addTab(TAB2_URL); + + const firstFront = await getDescriptorActorForUrl(client, TAB1_URL); + const secondFront = await getDescriptorActorForUrl(client, TAB2_URL); + is(firstFront, firstTabFront, "First tab's actor shouldn't have changed."); + ok(secondFront, "Should find a target actor for the second tab."); + return { tab, front: secondFront }; +} + +async function testRemoveTab(client, firstTab) { + await removeTab(firstTab); + const front = await getDescriptorActorForUrl(client, TAB1_URL); + ok(!front, "Shouldn't find a target actor for the first tab anymore."); +} + +async function testAttachRemovedTab(secondTab, secondTabFront) { + await removeTab(secondTab); + + const { actorID } = secondTabFront; + try { + await secondTabFront.getFavicon({}); + ok( + false, + "any request made to the descriptor for a closed tab should have failed" + ); + } catch (error) { + ok( + error.message.includes( + `Connection closed, pending request to ${actorID}, type getFavicon failed` + ), + "Actor is gone since the tab was removed." + ); + } +} + +async function getDescriptorActorForUrl(client, url) { + const tabDescriptors = await client.mainRoot.listTabs(); + const tabDescriptor = tabDescriptors.find(front => front.url == url); + return tabDescriptor; +} diff --git a/devtools/client/shared/test/browser_dbg_listtabs-02.js b/devtools/client/shared/test/browser_dbg_listtabs-02.js new file mode 100644 index 0000000000..a23981a93b --- /dev/null +++ b/devtools/client/shared/test/browser_dbg_listtabs-02.js @@ -0,0 +1,248 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Make sure the root actor's live tab list implementation works as specified. + */ + +var { + BrowserTabList, +} = require("resource://devtools/server/actors/webbrowser.js"); +var { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); + +var gTestPage = + "data:text/html;charset=utf-8," + + encodeURIComponent( + "JS Debugger BrowserTabList test pageYo." + ); + +// The tablist object whose behavior we observe. +var gTabList; +var gFirstActor, gActorA; +var gTabA, gTabB, gTabC; +var gNewWindow; + +// Stock onListChanged handler. +var onListChangedCount = 0; +function onListChangedHandler() { + onListChangedCount++; +} + +function test() { + DevToolsServer.init(); + DevToolsServer.registerAllActors(); + + gTabList = new BrowserTabList("fake DevToolsServerConnection"); + gTabList._testing = true; + gTabList.onListChanged = onListChangedHandler; + + checkSingleTab() + .then(addTabA) + .then(testTabA) + .then(addTabB) + .then(testTabB) + .then(removeTabA) + .then(testTabClosed) + .then(addTabC) + .then(testTabC) + .then(removeTabC) + .then(testNewWindow) + .then(removeNewWindow) + .then(testWindowClosed) + .then(removeTabB) + .then(checkSingleTab) + .then(finishUp); +} + +function checkSingleTab() { + return gTabList.getList().then(targetActors => { + is(targetActors.length, 1, "initial tab list: contains initial tab"); + gFirstActor = targetActors[0]; + is( + gFirstActor.url, + "about:blank", + "initial tab list: initial tab URL is 'about:blank'" + ); + is( + gFirstActor.title, + "New Tab", + "initial tab list: initial tab title is 'New Tab'" + ); + }); +} + +function addTabA() { + return addTab(gTestPage).then(tab => { + gTabA = tab; + }); +} + +function testTabA() { + is(onListChangedCount, 1, "onListChanged handler call count"); + + return gTabList.getList().then(targetActors => { + targetActors = new Set(targetActors); + is(targetActors.size, 2, "gTabA opened: two tabs in list"); + ok(targetActors.has(gFirstActor), "gTabA opened: initial tab present"); + + info("actors: " + [...targetActors].map(a => a.url)); + gActorA = [...targetActors].filter(a => a !== gFirstActor)[0]; + ok(gActorA.url.match(/^data:text\/html;/), "gTabA opened: new tab URL"); + is( + gActorA.title, + "JS Debugger BrowserTabList test page", + "gTabA opened: new tab title" + ); + }); +} + +function addTabB() { + return addTab(gTestPage).then(tab => { + gTabB = tab; + }); +} + +function testTabB() { + is(onListChangedCount, 2, "onListChanged handler call count"); + + return gTabList.getList().then(targetActors => { + targetActors = new Set(targetActors); + is(targetActors.size, 3, "gTabB opened: three tabs in list"); + }); +} + +function removeTabA() { + return new Promise(resolve => { + once(gBrowser.tabContainer, "TabClose").then(event => { + ok(!event.detail.adoptedBy, "This was a normal tab close"); + + // Let the actor's TabClose handler finish first. + executeSoon(resolve); + }, false); + + removeTab(gTabA); + }); +} + +function testTabClosed() { + is(onListChangedCount, 3, "onListChanged handler call count"); + + gTabList.getList().then(targetActors => { + targetActors = new Set(targetActors); + is(targetActors.size, 2, "gTabA closed: two tabs in list"); + ok(targetActors.has(gFirstActor), "gTabA closed: initial tab present"); + + info("actors: " + [...targetActors].map(a => a.url)); + gActorA = [...targetActors].filter(a => a !== gFirstActor)[0]; + ok(gActorA.url.match(/^data:text\/html;/), "gTabA closed: new tab URL"); + is( + gActorA.title, + "JS Debugger BrowserTabList test page", + "gTabA closed: new tab title" + ); + }); +} + +function addTabC() { + return addTab(gTestPage).then(tab => { + gTabC = tab; + }); +} + +function testTabC() { + is(onListChangedCount, 4, "onListChanged handler call count"); + + gTabList.getList().then(targetActors => { + targetActors = new Set(targetActors); + is(targetActors.size, 3, "gTabC opened: three tabs in list"); + }); +} + +function removeTabC() { + return new Promise(resolve => { + once(gBrowser.tabContainer, "TabClose").then(event => { + ok(event.detail.adoptedBy, "This was a tab closed by moving"); + + // Let the actor's TabClose handler finish first. + executeSoon(resolve); + }, false); + + gNewWindow = gBrowser.replaceTabWithWindow(gTabC); + }); +} + +function testNewWindow() { + is(onListChangedCount, 5, "onListChanged handler call count"); + + return gTabList.getList().then(targetActors => { + targetActors = new Set(targetActors); + is(targetActors.size, 3, "gTabC closed: three tabs in list"); + ok(targetActors.has(gFirstActor), "gTabC closed: initial tab present"); + + info("actors: " + [...targetActors].map(a => a.url)); + gActorA = [...targetActors].filter(a => a !== gFirstActor)[0]; + ok(gActorA.url.match(/^data:text\/html;/), "gTabC closed: new tab URL"); + is( + gActorA.title, + "JS Debugger BrowserTabList test page", + "gTabC closed: new tab title" + ); + }); +} + +function removeNewWindow() { + return new Promise(resolve => { + once(gNewWindow, "unload").then(event => { + ok(!event.detail, "This was a normal window close"); + + // Let the actor's TabClose handler finish first. + executeSoon(resolve); + }, false); + + gNewWindow.close(); + }); +} + +function testWindowClosed() { + is(onListChangedCount, 6, "onListChanged handler call count"); + + return gTabList.getList().then(targetActors => { + targetActors = new Set(targetActors); + is(targetActors.size, 2, "gNewWindow closed: two tabs in list"); + ok(targetActors.has(gFirstActor), "gNewWindow closed: initial tab present"); + + info("actors: " + [...targetActors].map(a => a.url)); + gActorA = [...targetActors].filter(a => a !== gFirstActor)[0]; + ok( + gActorA.url.match(/^data:text\/html;/), + "gNewWindow closed: new tab URL" + ); + is( + gActorA.title, + "JS Debugger BrowserTabList test page", + "gNewWindow closed: new tab title" + ); + }); +} + +function removeTabB() { + return new Promise(resolve => { + once(gBrowser.tabContainer, "TabClose").then(event => { + ok(!event.detail.adoptedBy, "This was a normal tab close"); + + // Let the actor's TabClose handler finish first. + executeSoon(resolve); + }, false); + + removeTab(gTabB); + }); +} + +function finishUp() { + gTabList = gFirstActor = gActorA = gTabA = gTabB = gTabC = gNewWindow = null; + finish(); +} diff --git a/devtools/client/shared/test/browser_dbg_listworkers.js b/devtools/client/shared/test/browser_dbg_listworkers.js new file mode 100644 index 0000000000..fcdcf8e5dd --- /dev/null +++ b/devtools/client/shared/test/browser_dbg_listworkers.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true); + +registerCleanupFunction(() => { + Services.prefs.clearUserPref("security.allow_eval_with_system_principal"); +}); + +var TAB_URL = EXAMPLE_URL + "doc_listworkers-tab.html"; +var WORKER1_URL = "code_listworkers-worker1.js"; +var WORKER2_URL = "code_listworkers-worker2.js"; + +add_task(async function test() { + const tab = await addTab(TAB_URL); + const target = await createAndAttachTargetForTab(tab); + + let { workers } = await listWorkers(target); + is(workers.length, 0); + + let onWorkerListChanged = waitForWorkerListChanged(target); + await SpecialPowers.spawn(tab.linkedBrowser, [WORKER1_URL], workerUrl => { + content.worker1 = new content.Worker(workerUrl); + }); + await onWorkerListChanged; + + ({ workers } = await listWorkers(target)); + is(workers.length, 1); + is(workers[0].url, WORKER1_URL); + + onWorkerListChanged = waitForWorkerListChanged(target); + await SpecialPowers.spawn(tab.linkedBrowser, [WORKER2_URL], workerUrl => { + content.worker2 = new content.Worker(workerUrl); + }); + await onWorkerListChanged; + + ({ workers } = await listWorkers(target)); + is(workers.length, 2); + is(workers[0].url, WORKER1_URL); + is(workers[1].url, WORKER2_URL); + + onWorkerListChanged = waitForWorkerListChanged(target); + await SpecialPowers.spawn(tab.linkedBrowser, [WORKER2_URL], workerUrl => { + content.worker1.terminate(); + }); + await onWorkerListChanged; + + ({ workers } = await listWorkers(target)); + is(workers.length, 1); + is(workers[0].url, WORKER2_URL); + + onWorkerListChanged = waitForWorkerListChanged(target); + await SpecialPowers.spawn(tab.linkedBrowser, [WORKER2_URL], workerUrl => { + content.worker2.terminate(); + }); + await onWorkerListChanged; + + ({ workers } = await listWorkers(target)); + is(workers.length, 0); + + await target.destroy(); + finish(); +}); + +function listWorkers(targetFront) { + info("Listing workers."); + return targetFront.listWorkers(); +} + +function waitForWorkerListChanged(targetFront) { + info("Waiting for worker list to change."); + return targetFront.once("workerListChanged"); +} diff --git a/devtools/client/shared/test/browser_dbg_multiple-windows.js b/devtools/client/shared/test/browser_dbg_multiple-windows.js new file mode 100644 index 0000000000..2e2013479c --- /dev/null +++ b/devtools/client/shared/test/browser_dbg_multiple-windows.js @@ -0,0 +1,122 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Make sure that the debugger attaches to the right tab when multiple windows + * are open. + */ + +var { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); +var { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); + +const TAB1_URL = "data:text/html;charset=utf-8,first-tab"; +const TAB2_URL = "data:text/html;charset=utf-8,second-tab"; + +add_task(async function () { + DevToolsServer.init(); + DevToolsServer.registerAllActors(); + + const transport = DevToolsServer.connectPipe(); + const client = new DevToolsClient(transport); + const [type] = await client.connect(); + is(type, "browser", "Root actor should identify itself as a browser."); + + const tab = await addTab(TAB1_URL); + await testFirstTab(client, tab); + const win = await addWindow(TAB2_URL); + await testNewWindow(client, win); + testFocusFirst(client); + await testRemoveTab(client, win, tab); + await client.close(); +}); + +async function testFirstTab(client, tab) { + ok(!!tab, "Second tab created."); + + const tabs = await client.mainRoot.listTabs(); + const targetFront = tabs.find(grip => grip.url == TAB1_URL); + ok(targetFront, "Should find a target actor for the first tab."); + + ok(!tabs[0].selected, "The previously opened tab isn't selected."); + ok(tabs[1].selected, "The first tab is selected."); +} + +async function testNewWindow(client, win) { + ok(!!win, "Second window created."); + + win.focus(); + + const topWindow = Services.wm.getMostRecentWindow("navigator:browser"); + is(topWindow, win, "The second window is on top."); + + if (Services.focus.activeWindow != win) { + await new Promise(resolve => { + win.addEventListener( + "activate", + function onActivate(event) { + if (event.target != win) { + return; + } + win.removeEventListener("activate", onActivate, true); + resolve(); + }, + true + ); + }); + } + + const tabs = await client.mainRoot.listTabs(); + ok(!tabs[0].selected, "The previously opened tab isn't selected."); + ok(!tabs[1].selected, "The first tab isn't selected."); + ok(tabs[2].selected, "The second tab is selected."); +} + +async function testFocusFirst(client) { + const tab = window.gBrowser.selectedTab; + await ContentTask.spawn(tab.linkedBrowser, null, async function () { + const onFocus = new Promise(resolve => { + content.addEventListener("focus", resolve, { once: true }); + }); + await onFocus; + }); + + const tabs = await client.mainRoot.listTabs(); + ok(!tabs[0].selected, "The previously opened tab isn't selected."); + ok(!tabs[1].selected, "The first tab is selected after focusing on i."); + ok(tabs[2].selected, "The second tab isn't selected."); +} + +async function testRemoveTab(client, win, tab) { + win.close(); + + // give it time to close + await new Promise(resolve => executeSoon(resolve)); + await continue_remove_tab(client, tab); +} + +async function continue_remove_tab(client, tab) { + removeTab(tab); + + const tabs = await client.mainRoot.listTabs(); + + // Verify that tabs are no longer included in listTabs. + const foundTab1 = tabs.some(grip => grip.url == TAB1_URL); + const foundTab2 = tabs.some(grip => grip.url == TAB2_URL); + ok(!foundTab1, "Tab1 should be gone."); + ok(!foundTab2, "Tab2 should be gone."); + + ok(tabs[0].selected, "The previously opened tab is selected."); +} + +async function addWindow(url) { + info("Adding window: " + url); + const onNewWindow = BrowserTestUtils.waitForNewWindow({ url }); + window.open(url, "_blank", "noopener"); + return onNewWindow; +} diff --git a/devtools/client/shared/test/browser_dbg_target-scoped-actor-01.js b/devtools/client/shared/test/browser_dbg_target-scoped-actor-01.js new file mode 100644 index 0000000000..004c7bbc9d --- /dev/null +++ b/devtools/client/shared/test/browser_dbg_target-scoped-actor-01.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check target-scoped actor lifetimes. + */ + +const ACTORS_URL = EXAMPLE_URL + "testactors.js"; +const TAB_URL = TEST_URI_ROOT + "doc_empty-tab-01.html"; + +add_task(async function test() { + const tab = await addTab(TAB_URL); + + await registerActorInContentProcess(ACTORS_URL, { + prefix: "testOne", + constructor: "TestActor1", + type: { target: true }, + }); + + const target = await createAndAttachTargetForTab(tab); + const { client } = target; + const form = target.targetForm; + + await testTargetScopedActor(client, form); + await removeTab(gBrowser.selectedTab); + await target.destroy(); +}); + +async function testTargetScopedActor(client, form) { + ok(form.testOneActor, "Found the test target-scoped actor."); + ok( + form.testOneActor.includes("testOne"), + "testOneActor's typeName should be used." + ); + + const response = await client.request({ + to: form.testOneActor, + type: "ping", + }); + is(response.pong, "pong", "Actor should respond to requests."); +} diff --git a/devtools/client/shared/test/browser_dbg_target-scoped-actor-02.js b/devtools/client/shared/test/browser_dbg_target-scoped-actor-02.js new file mode 100644 index 0000000000..c87eda05e0 --- /dev/null +++ b/devtools/client/shared/test/browser_dbg_target-scoped-actor-02.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check target-scoped actor lifetimes. + */ + +const ACTORS_URL = EXAMPLE_URL + "testactors.js"; +const TAB_URL = TEST_URI_ROOT + "doc_empty-tab-01.html"; + +add_task(async function () { + const tab = await addTab(TAB_URL); + + await registerActorInContentProcess(ACTORS_URL, { + prefix: "testOne", + constructor: "TestActor1", + type: { target: true }, + }); + + const target = await createAndAttachTargetForTab(tab); + const { client } = target; + const form = target.targetForm; + + await testTargetScopedActor(client, form); + await closeTab(client, form); + await target.destroy(); +}); + +async function testTargetScopedActor(client, form) { + ok(form.testOneActor, "Found the test target-scoped actor."); + ok( + form.testOneActor.includes("testOne"), + "testOneActor's typeName should be used." + ); + + const response = await client.request({ + to: form.testOneActor, + type: "ping", + }); + is(response.pong, "pong", "Actor should respond to requests."); +} + +async function closeTab(client, form) { + // We need to start listening for the rejection before removing the tab + /* eslint-disable-next-line mozilla/rejects-requires-await*/ + const onReject = Assert.rejects( + client.request({ to: form.testOneActor, type: "ping" }), + err => + err.message === + `'ping' active request packet to '${form.testOneActor}' ` + + `can't be sent as the connection just closed.`, + "testOneActor went away." + ); + await removeTab(gBrowser.selectedTab); + await onReject; +} diff --git a/devtools/client/shared/test/browser_devices.js b/devtools/client/shared/test/browser_devices.js new file mode 100644 index 0000000000..35a68d2714 --- /dev/null +++ b/devtools/client/shared/test/browser_devices.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + getDevices, + getDeviceString, + addDevice, +} = require("resource://devtools/client/shared/devices.js"); + +add_task(async function () { + let devices = await getDevices(); + + let types = [...devices.keys()]; + ok(!!types.length, `Found ${types.length} device types.`); + + for (const type of types) { + const string = getDeviceString(type); + ok( + typeof string === "string" && + !!string.length && + string != `device.${type}`, + `Able to localize "${type}": "${string}"` + ); + + ok( + !!devices.get(type).length, + `Found ${devices.get(type).length} ${type} devices` + ); + } + + const type1 = types[0]; + const type1DeviceCount = devices.get(type1).length; + + const device1 = { + name: "SquarePhone", + width: 320, + height: 320, + pixelRatio: 2, + userAgent: "Mozilla/5.0 (Mobile; rv:42.0)", + touch: true, + firefoxOS: true, + }; + addDevice(device1, types[0]); + devices = await getDevices(); + + is( + devices.get(type1).length, + type1DeviceCount + 1, + `Added new device of type "${type1}".` + ); + ok( + devices.get(type1).find(d => d.name === device1.name), + "Found the new device." + ); + + const type2 = "appliances"; + const device2 = { + name: "Mr Freezer", + width: 800, + height: 600, + pixelRatio: 5, + userAgent: "Mozilla/5.0 (Appliance; rv:42.0)", + touch: true, + firefoxOS: true, + }; + + const typeCount = types.length; + addDevice(device2, type2); + devices = await getDevices(); + types = [...devices.keys()]; + + is(types.length, typeCount + 1, `Added device type "${type2}".`); + is(devices.get(type2).length, 1, `Added new "${type2}" device`); +}); diff --git a/devtools/client/shared/test/browser_filter-editor-01.js b/devtools/client/shared/test/browser_filter-editor-01.js new file mode 100644 index 0000000000..557a02857c --- /dev/null +++ b/devtools/client/shared/test/browser_filter-editor-01.js @@ -0,0 +1,150 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the Filter Editor Widget parses filter values correctly (setCssValue) + +const { + CSSFilterEditorWidget, +} = require("resource://devtools/client/shared/widgets/FilterWidget.js"); + +const TEST_URI = CHROME_URL_ROOT + "doc_filter-editor-01.html"; +const { getCSSLexer } = require("resource://devtools/shared/css/lexer.js"); + +// Verify that the given string consists of a valid CSS URL token. +// Return true on success, false on error. +function verifyURL(string) { + const lexer = getCSSLexer(string); + + const token = lexer.nextToken(); + if (!token || token.tokenType !== "url") { + return false; + } + + return lexer.nextToken() === null; +} + +add_task(async function () { + const { doc } = await createHost("bottom", TEST_URI); + + const container = doc.querySelector("#filter-container"); + const widget = new CSSFilterEditorWidget(container, "none"); + + info("Test parsing of a valid CSS Filter value"); + widget.setCssValue("blur(2px) contrast(200%)"); + is( + widget.getCssValue(), + "blur(2px) contrast(200%)", + "setCssValue should work for computed values" + ); + + info("Test parsing of space-filled value"); + widget.setCssValue("blur( 2px ) contrast( 2 )"); + is( + widget.getCssValue(), + "blur(2px) contrast(200%)", + "setCssValue should work for spaced values" + ); + + info("Test parsing of string-typed values"); + widget.setCssValue( + "drop-shadow( 2px 1px 5px black) url( example.svg#filter )" + ); + + is( + widget.getCssValue(), + "drop-shadow(2px 1px 5px black) url(example.svg#filter)", + "setCssValue should work for string-typed values" + ); + + info("Test parsing of mixed-case function names"); + widget.setCssValue("BLUR(2px) Contrast(200%) Drop-Shadow(2px 1px 5px Black)"); + is( + widget.getCssValue(), + "BLUR(2px) Contrast(200%) Drop-Shadow(2px 1px 5px Black)", + "setCssValue should work for mixed-case function names" + ); + + info("Test parsing of invalid filter value"); + widget.setCssValue("totallyinvalid"); + is( + widget.getCssValue(), + "none", + "setCssValue should turn completely invalid value to 'none'" + ); + + info("Test parsing of invalid function argument"); + widget.setCssValue("blur('hello')"); + is( + widget.getCssValue(), + "blur(0px)", + "setCssValue should replace invalid function argument with default" + ); + + info("Test parsing of invalid function argument #2"); + widget.setCssValue("drop-shadow(whatever)"); + is( + widget.getCssValue(), + "drop-shadow()", + "setCssValue should replace invalid drop-shadow argument with empty string" + ); + + info("Test parsing of mixed invalid argument"); + widget.setCssValue("contrast(5%) whatever invert('xxx')"); + is( + widget.getCssValue(), + "contrast(5%) invert(0%)", + "setCssValue should handle multiple errors" + ); + + info("Test parsing of 'unset'"); + widget.setCssValue("unset"); + is(widget.getCssValue(), "unset", "setCssValue should handle 'unset'"); + info("Test parsing of 'initial'"); + widget.setCssValue("initial"); + is(widget.getCssValue(), "initial", "setCssValue should handle 'initial'"); + info("Test parsing of 'inherit'"); + widget.setCssValue("inherit"); + is(widget.getCssValue(), "inherit", "setCssValue should handle 'inherit'"); + + info("Test parsing of quoted URL"); + widget.setCssValue("url('invalid ) when ) unquoted')"); + is( + widget.getCssValue(), + "url('invalid ) when ) unquoted')", + "setCssValue should re-quote single-quoted URL contents" + ); + widget.setCssValue('url("invalid ) when ) unquoted")'); + is( + widget.getCssValue(), + 'url("invalid ) when ) unquoted")', + "setCssValue should re-quote double-quoted URL contents" + ); + widget.setCssValue("url(ordinary)"); + is( + widget.getCssValue(), + "url(ordinary)", + "setCssValue should not quote ordinary unquoted URL contents" + ); + + const quotedurl = + "url(invalid\\ \\)\\ {\\\twhen\\ }\\ ;\\ \\\\unquoted\\'\\\")"; + ok(verifyURL(quotedurl), "weird URL is valid"); + widget.setCssValue(quotedurl); + is( + widget.getCssValue(), + quotedurl, + "setCssValue should re-quote weird unquoted URL contents" + ); + + const dataurl = + "url(data:image/svg+xml;utf8,' + + '#blur)'; + ok(verifyURL(dataurl), "data URL is valid"); + widget.setCssValue(dataurl); + is(widget.getCssValue(), dataurl, "setCssValue should not mangle data urls"); + + widget.destroy(); +}); diff --git a/devtools/client/shared/test/browser_filter-editor-02.js b/devtools/client/shared/test/browser_filter-editor-02.js new file mode 100644 index 0000000000..2a69f5265f --- /dev/null +++ b/devtools/client/shared/test/browser_filter-editor-02.js @@ -0,0 +1,114 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the Filter Editor Widget renders filters correctly + +const { + CSSFilterEditorWidget, +} = require("resource://devtools/client/shared/widgets/FilterWidget.js"); + +const STRINGS_URI = "devtools/client/locales/filterwidget.properties"; +const L10N = new LocalizationHelper(STRINGS_URI); + +const TEST_URI = CHROME_URL_ROOT + "doc_filter-editor-01.html"; + +add_task(async function () { + const { doc } = await createHost("bottom", TEST_URI); + + const TEST_DATA = [ + { + cssValue: + "blur(2px) contrast(200%) hue-rotate(20.2deg) drop-shadow(5px 5px black)", + expected: [ + { + label: "blur", + value: "2", + unit: "px", + }, + { + label: "contrast", + value: "200", + unit: "%", + }, + { + label: "hue-rotate", + value: "20.2", + unit: "deg", + }, + { + label: "drop-shadow", + value: "5px 5px black", + unit: null, + }, + ], + }, + { + cssValue: "hue-rotate(420.2deg)", + expected: [ + { + label: "hue-rotate", + value: "420.2", + unit: "deg", + }, + ], + }, + { + cssValue: "url(example.svg)", + expected: [ + { + label: "url", + value: "example.svg", + unit: null, + }, + ], + }, + { + cssValue: "none", + expected: [], + }, + ]; + + const container = doc.querySelector("#filter-container"); + const widget = new CSSFilterEditorWidget(container, "none"); + + info("Test rendering of different types"); + + for (const { cssValue, expected } of TEST_DATA) { + widget.setCssValue(cssValue); + + if (cssValue === "none") { + const text = container.querySelector("#filters").textContent; + Assert.greater( + text.indexOf(L10N.getStr("emptyFilterList")), + -1, + "Contains |emptyFilterList| string when given value 'none'" + ); + Assert.greater( + text.indexOf(L10N.getStr("addUsingList")), + -1, + "Contains |addUsingList| string when given value 'none'" + ); + continue; + } + const filters = container.querySelectorAll(".filter"); + testRenderedFilters(filters, expected); + } + widget.destroy(); +}); + +function testRenderedFilters(filters, expected) { + for (const [index, filter] of [...filters].entries()) { + const [name, value] = filter.children, + label = name.children[1], + [input, unit] = value.children; + + const eq = expected[index]; + is(label.textContent, eq.label, "Label should match"); + is(input.value, eq.value, "Values should match"); + if (eq.unit) { + is(unit.textContent, eq.unit, "Unit should match"); + } + } +} diff --git a/devtools/client/shared/test/browser_filter-editor-03.js b/devtools/client/shared/test/browser_filter-editor-03.js new file mode 100644 index 0000000000..4c66134ea9 --- /dev/null +++ b/devtools/client/shared/test/browser_filter-editor-03.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the Filter Editor Widget add, removeAt, updateAt, getValueAt methods + +const { + CSSFilterEditorWidget, +} = require("resource://devtools/client/shared/widgets/FilterWidget.js"); +const GRAYSCALE_MAX = 100; +const INVERT_MIN = 0; + +const TEST_URI = CHROME_URL_ROOT + "doc_filter-editor-01.html"; + +add_task(async function () { + const { doc } = await createHost("bottom", TEST_URI); + + const container = doc.querySelector("#filter-container"); + const widget = new CSSFilterEditorWidget(container, "none"); + + info("Test add method"); + const blur = widget.add("blur", "10.2px"); + is(widget.getCssValue(), "blur(10.2px)", "Should add filters"); + + const url = widget.add("url", "test.svg"); + is( + widget.getCssValue(), + "blur(10.2px) url(test.svg)", + "Should add filters in order" + ); + + info("Test updateValueAt method"); + widget.updateValueAt(url, "test2.svg"); + widget.updateValueAt(blur, 5); + is( + widget.getCssValue(), + "blur(5px) url(test2.svg)", + "Should update values correctly" + ); + + info("Test getValueAt method"); + is(widget.getValueAt(blur), "5px", "Should return value + unit"); + is( + widget.getValueAt(url), + "test2.svg", + "Should return value for string-type filters" + ); + + info("Test removeAt method"); + widget.removeAt(url); + is(widget.getCssValue(), "blur(5px)", "Should remove the specified filter"); + + info("Test add method applying filter range to value"); + const grayscale = widget.add("grayscale", GRAYSCALE_MAX + 1); + is( + widget.getValueAt(grayscale), + `${GRAYSCALE_MAX}%`, + "Shouldn't allow values higher than max" + ); + + const invert = widget.add("invert", INVERT_MIN - 1); + is( + widget.getValueAt(invert), + `${INVERT_MIN}%`, + "Shouldn't allow values less than INVERT_MIN" + ); + + info("Test updateValueAt method applying filter range to value"); + widget.updateValueAt(grayscale, GRAYSCALE_MAX + 1); + is( + widget.getValueAt(grayscale), + `${GRAYSCALE_MAX}%`, + "Shouldn't allow values higher than max" + ); + + widget.updateValueAt(invert, INVERT_MIN - 1); + is( + widget.getValueAt(invert), + `${INVERT_MIN}%`, + "Shouldn't allow values less than INVERT_MIN" + ); + widget.destroy(); +}); diff --git a/devtools/client/shared/test/browser_filter-editor-04.js b/devtools/client/shared/test/browser_filter-editor-04.js new file mode 100644 index 0000000000..fdc966fa7f --- /dev/null +++ b/devtools/client/shared/test/browser_filter-editor-04.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the Filter Editor Widget's drag-drop re-ordering + +const { + CSSFilterEditorWidget, +} = require("resource://devtools/client/shared/widgets/FilterWidget.js"); +const LIST_ITEM_HEIGHT = 32; + +const TEST_URI = CHROME_URL_ROOT + "doc_filter-editor-01.html"; + +add_task(async function () { + const { doc } = await createHost("bottom", TEST_URI); + + const container = doc.querySelector("#filter-container"); + const initialValue = "blur(2px) contrast(200%) brightness(200%)"; + const widget = new CSSFilterEditorWidget(container, initialValue); + + const filters = widget.el.querySelector("#filters"); + function first() { + return filters.children[0]; + } + function mid() { + return filters.children[1]; + } + function last() { + return filters.children[2]; + } + + info("Test re-ordering neighbour filters"); + widget._mouseDown({ + target: first().querySelector("i"), + pageY: 0, + }); + widget._mouseMove({ pageY: LIST_ITEM_HEIGHT }); + + // Element re-ordering should be instant + is( + mid().querySelector("label").textContent, + "blur", + "Should reorder elements correctly" + ); + + widget._mouseUp(); + + is( + widget.getCssValue(), + "contrast(200%) blur(2px) brightness(200%)", + "Should reorder filters objects correctly" + ); + + info("Test re-ordering first and last filters"); + widget._mouseDown({ + target: first().querySelector("i"), + pageY: 0, + }); + widget._mouseMove({ pageY: LIST_ITEM_HEIGHT * 2 }); + + // Element re-ordering should be instant + is( + last().querySelector("label").textContent, + "contrast", + "Should reorder elements correctly" + ); + widget._mouseUp(); + + is( + widget.getCssValue(), + "brightness(200%) blur(2px) contrast(200%)", + "Should reorder filters objects correctly" + ); + + info("Test dragging first element out of list"); + const boundaries = filters.getBoundingClientRect(); + + widget._mouseDown({ + target: first().querySelector("i"), + pageY: 0, + }); + widget._mouseMove({ pageY: -LIST_ITEM_HEIGHT * 5 }); + Assert.greaterOrEqual( + first().getBoundingClientRect().top, + boundaries.top, + "First filter should not move outside filter list" + ); + + widget._mouseUp(); + + info("Test dragging last element out of list"); + widget._mouseDown({ + target: last().querySelector("i"), + pageY: 0, + }); + widget._mouseMove({ pageY: -LIST_ITEM_HEIGHT * 5 }); + Assert.lessOrEqual( + last().getBoundingClientRect().bottom, + boundaries.bottom, + "Last filter should not move outside filter list" + ); + + widget._mouseUp(); + widget.destroy(); +}); diff --git a/devtools/client/shared/test/browser_filter-editor-05.js b/devtools/client/shared/test/browser_filter-editor-05.js new file mode 100644 index 0000000000..37c53ff6f4 --- /dev/null +++ b/devtools/client/shared/test/browser_filter-editor-05.js @@ -0,0 +1,166 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Tests the Filter Editor Widget's label-dragging + +const { + CSSFilterEditorWidget, +} = require("resource://devtools/client/shared/widgets/FilterWidget.js"); + +const FAST_VALUE_MULTIPLIER = 10; +const SLOW_VALUE_MULTIPLIER = 0.1; +const DEFAULT_VALUE_MULTIPLIER = 1; + +const GRAYSCALE_MAX = 100, + GRAYSCALE_MIN = 0; + +const TEST_URI = CHROME_URL_ROOT + "doc_filter-editor-01.html"; + +add_task(async function () { + const { doc } = await createHost("bottom", TEST_URI); + + const container = doc.querySelector("#filter-container"); + const widget = new CSSFilterEditorWidget( + container, + "grayscale(0%) url(test.svg)" + ); + + const filters = widget.el.querySelector("#filters"); + const grayscale = filters.children[0]; + const url = filters.children[1]; + + info("Test label-dragging on number-type filters without modifiers"); + widget._mouseDown({ + target: grayscale.querySelector("label"), + pageX: 0, + altKey: false, + shiftKey: false, + }); + + widget._mouseMove({ + pageX: 12, + altKey: false, + shiftKey: false, + }); + let expected = DEFAULT_VALUE_MULTIPLIER * 12; + is( + widget.getValueAt(0), + `${expected}%`, + "Should update value correctly without modifiers" + ); + + info("Test label-dragging on number-type filters with alt"); + widget._mouseMove({ + // 20 - 12 = 8 + pageX: 20, + altKey: true, + shiftKey: false, + }); + + expected = expected + SLOW_VALUE_MULTIPLIER * 8; + is( + widget.getValueAt(0), + `${expected}%`, + "Should update value correctly with alt key" + ); + + info("Test label-dragging on number-type filters with shift"); + widget._mouseMove({ + // 25 - 20 = 5 + pageX: 25, + altKey: false, + shiftKey: true, + }); + + expected = expected + FAST_VALUE_MULTIPLIER * 5; + is( + widget.getValueAt(0), + `${expected}%`, + "Should update value correctly with shift key" + ); + + info("Test releasing mouse and dragging again"); + + widget._mouseUp(); + + widget._mouseDown({ + target: grayscale.querySelector("label"), + pageX: 0, + altKey: false, + shiftKey: false, + }); + + widget._mouseMove({ + pageX: 5, + altKey: false, + shiftKey: false, + }); + + expected = expected + DEFAULT_VALUE_MULTIPLIER * 5; + is( + widget.getValueAt(0), + `${expected}%`, + "Should reset multiplier to default" + ); + + info("Test value ranges"); + + widget._mouseMove({ + // 30 - 25 = 5 + pageX: 30, + altKey: false, + shiftKey: true, + }); + + expected = GRAYSCALE_MAX; + is( + widget.getValueAt(0), + `${expected}%`, + "Shouldn't allow values higher than max" + ); + + widget._mouseMove({ + pageX: -11, + altKey: false, + shiftKey: true, + }); + + expected = GRAYSCALE_MIN; + is( + widget.getValueAt(0), + `${expected}%`, + "Shouldn't allow values less than min" + ); + + widget._mouseUp(); + + info("Test label-dragging on string-type filters"); + widget._mouseDown({ + target: url.querySelector("label"), + pageX: 0, + altKey: false, + shiftKey: false, + }); + + ok( + !widget.isDraggingLabel, + "Label-dragging should not work for string-type filters" + ); + + widget._mouseMove({ + pageX: -11, + altKey: false, + shiftKey: true, + }); + + is( + widget.getValueAt(1), + "test.svg", + "Label-dragging on string-type filters shouldn't affect their value" + ); + widget.destroy(); +}); diff --git a/devtools/client/shared/test/browser_filter-editor-06.js b/devtools/client/shared/test/browser_filter-editor-06.js new file mode 100644 index 0000000000..0f5f5abd4c --- /dev/null +++ b/devtools/client/shared/test/browser_filter-editor-06.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the Filter Editor Widget's add button + +const { + CSSFilterEditorWidget, +} = require("resource://devtools/client/shared/widgets/FilterWidget.js"); + +const STRINGS_URI = "devtools/client/locales/filterwidget.properties"; +const L10N = new LocalizationHelper(STRINGS_URI); + +const TEST_URI = CHROME_URL_ROOT + "doc_filter-editor-01.html"; + +add_task(async function () { + const { doc } = await createHost("bottom", TEST_URI); + + const container = doc.querySelector("#filter-container"); + const widget = new CSSFilterEditorWidget(container, "none"); + + const select = widget.el.querySelector("select"), + add = widget.el.querySelector("#add-filter"); + + const TEST_DATA = [ + { + name: "blur", + unit: "px", + type: "length", + }, + { + name: "contrast", + unit: "%", + type: "percentage", + }, + { + name: "hue-rotate", + unit: "deg", + type: "angle", + }, + { + name: "drop-shadow", + placeholder: L10N.getStr("dropShadowPlaceholder"), + type: "string", + }, + { + name: "url", + placeholder: "example.svg#c1", + type: "string", + }, + ]; + + info("Test adding new filters with different units"); + + for (const [index, filter] of TEST_DATA.entries()) { + select.value = filter.name; + add.click(); + + if (filter.unit) { + is( + widget.getValueAt(index), + `0${filter.unit}`, + `Should add ${filter.unit} to ${filter.type} filters` + ); + } else if (filter.placeholder) { + const i = index + 1; + const input = widget.el.querySelector(`.filter:nth-child(${i}) input`); + is( + input.placeholder, + filter.placeholder, + "Should set the appropriate placeholder for string-type filters" + ); + } + } + widget.destroy(); +}); diff --git a/devtools/client/shared/test/browser_filter-editor-07.js b/devtools/client/shared/test/browser_filter-editor-07.js new file mode 100644 index 0000000000..354d46addc --- /dev/null +++ b/devtools/client/shared/test/browser_filter-editor-07.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the Filter Editor Widget's remove button + +const { + CSSFilterEditorWidget, +} = require("resource://devtools/client/shared/widgets/FilterWidget.js"); + +const TEST_URI = CHROME_URL_ROOT + "doc_filter-editor-01.html"; + +add_task(async function () { + const { doc } = await createHost("bottom", TEST_URI); + + const container = doc.querySelector("#filter-container"); + const widget = new CSSFilterEditorWidget( + container, + "blur(2px) contrast(200%)" + ); + + info("Test removing filters with remove button"); + widget.el.querySelector(".filter button").click(); + + is( + widget.getCssValue(), + "contrast(200%)", + "Should remove the clicked filter" + ); + widget.destroy(); +}); diff --git a/devtools/client/shared/test/browser_filter-editor-08.js b/devtools/client/shared/test/browser_filter-editor-08.js new file mode 100644 index 0000000000..8759a230a0 --- /dev/null +++ b/devtools/client/shared/test/browser_filter-editor-08.js @@ -0,0 +1,103 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the Filter Editor Widget inputs increase/decrease value using +// arrow keys, applying multiplier using alt/shift on number-type filters + +const { + CSSFilterEditorWidget, +} = require("resource://devtools/client/shared/widgets/FilterWidget.js"); + +const FAST_VALUE_MULTIPLIER = 10; +const SLOW_VALUE_MULTIPLIER = 0.1; +const DEFAULT_VALUE_MULTIPLIER = 1; + +const TEST_URI = CHROME_URL_ROOT + "doc_filter-editor-01.html"; + +add_task(async function () { + const { doc } = await createHost("bottom", TEST_URI); + + const container = doc.querySelector("#filter-container"); + const initialValue = "blur(2px)"; + const widget = new CSSFilterEditorWidget(container, initialValue); + + let value = 2; + + triggerKey = triggerKey.bind(widget); + + info("Test simple arrow keys"); + triggerKey(40); + + value -= DEFAULT_VALUE_MULTIPLIER; + is( + widget.getValueAt(0), + `${value}px`, + "Should decrease value using down arrow" + ); + + triggerKey(38); + + value += DEFAULT_VALUE_MULTIPLIER; + is( + widget.getValueAt(0), + `${value}px`, + "Should decrease value using down arrow" + ); + + info("Test shift key multiplier"); + triggerKey(38, "shiftKey"); + + value += FAST_VALUE_MULTIPLIER; + is( + widget.getValueAt(0), + `${value}px`, + "Should increase value by fast multiplier using up arrow" + ); + + triggerKey(40, "shiftKey"); + + value -= FAST_VALUE_MULTIPLIER; + is( + widget.getValueAt(0), + `${value}px`, + "Should decrease value by fast multiplier using down arrow" + ); + + info("Test alt key multiplier"); + triggerKey(38, "altKey"); + + value += SLOW_VALUE_MULTIPLIER; + is( + widget.getValueAt(0), + `${value}px`, + "Should increase value by slow multiplier using up arrow" + ); + + triggerKey(40, "altKey"); + + value -= SLOW_VALUE_MULTIPLIER; + is( + widget.getValueAt(0), + `${value}px`, + "Should decrease value by slow multiplier using down arrow" + ); + + widget.destroy(); + triggerKey = null; +}); + +// Triggers the specified keyCode and modifier key on +// first filter's input +function triggerKey(key, modifier) { + const filter = this.el.querySelector("#filters").children[0]; + const input = filter.querySelector("input"); + + this._keyDown({ + target: input, + keyCode: key, + [modifier]: true, + preventDefault() {}, + }); +} diff --git a/devtools/client/shared/test/browser_filter-editor-09.js b/devtools/client/shared/test/browser_filter-editor-09.js new file mode 100644 index 0000000000..e22898cfd5 --- /dev/null +++ b/devtools/client/shared/test/browser_filter-editor-09.js @@ -0,0 +1,155 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the Filter Editor Widget inputs increase/decrease value when cursor is +// on a number using arrow keys, applying multiplier using alt/shift on strings + +const { + CSSFilterEditorWidget, +} = require("resource://devtools/client/shared/widgets/FilterWidget.js"); + +const FAST_VALUE_MULTIPLIER = 10; +const SLOW_VALUE_MULTIPLIER = 0.1; +const DEFAULT_VALUE_MULTIPLIER = 1; + +const TEST_URI = CHROME_URL_ROOT + "doc_filter-editor-01.html"; + +add_task(async function () { + const { doc } = await createHost("bottom", TEST_URI); + + const container = doc.querySelector("#filter-container"); + const initialValue = "drop-shadow(rgb(0, 0, 0) 1px 1px 0px)"; + const widget = new CSSFilterEditorWidget(container, initialValue); + widget.el.querySelector("#filters input").setSelectionRange(13, 13); + + let value = 1; + + triggerKey = triggerKey.bind(widget); + + info("Test simple arrow keys"); + triggerKey(40); + + value -= DEFAULT_VALUE_MULTIPLIER; + is( + widget.getValueAt(0), + val(value), + "Should decrease value using down arrow" + ); + + triggerKey(38); + + value += DEFAULT_VALUE_MULTIPLIER; + is( + widget.getValueAt(0), + val(value), + "Should decrease value using down arrow" + ); + + info("Test shift key multiplier"); + triggerKey(38, "shiftKey"); + + value += FAST_VALUE_MULTIPLIER; + is( + widget.getValueAt(0), + val(value), + "Should increase value by fast multiplier using up arrow" + ); + + triggerKey(40, "shiftKey"); + + value -= FAST_VALUE_MULTIPLIER; + is( + widget.getValueAt(0), + val(value), + "Should decrease value by fast multiplier using down arrow" + ); + + info("Test alt key multiplier"); + triggerKey(38, "altKey"); + + value += SLOW_VALUE_MULTIPLIER; + is( + widget.getValueAt(0), + val(value), + "Should increase value by slow multiplier using up arrow" + ); + + triggerKey(40, "altKey"); + + value -= SLOW_VALUE_MULTIPLIER; + is( + widget.getValueAt(0), + val(value), + "Should decrease value by slow multiplier using down arrow" + ); + + triggerKey(40, "shiftKey"); + + value -= FAST_VALUE_MULTIPLIER; + is(widget.getValueAt(0), val(value), "Should decrease to negative"); + + triggerKey(40); + + value -= DEFAULT_VALUE_MULTIPLIER; + is( + widget.getValueAt(0), + val(value), + "Should decrease negative numbers correctly" + ); + + triggerKey(38); + + value += DEFAULT_VALUE_MULTIPLIER; + is( + widget.getValueAt(0), + val(value), + "Should increase negative values correctly" + ); + + triggerKey(40, "altKey"); + triggerKey(40, "altKey"); + + value -= SLOW_VALUE_MULTIPLIER * 2; + is( + widget.getValueAt(0), + val(value), + "Should decrease float numbers correctly" + ); + + triggerKey(38, "altKey"); + + value += SLOW_VALUE_MULTIPLIER; + is( + widget.getValueAt(0), + val(value), + "Should increase float numbers correctly" + ); + + widget.destroy(); + triggerKey = null; +}); + +// Triggers the specified keyCode and modifier key on +// first filter's input +function triggerKey(key, modifier) { + const filter = this.el.querySelector("#filters").children[0]; + const input = filter.querySelector("input"); + + this._keyDown({ + target: input, + keyCode: key, + [modifier]: true, + preventDefault() {}, + }); +} + +function val(value) { + let v = value.toFixed(1); + + if (v.indexOf(".0") > -1) { + v = v.slice(0, -2); + } + return `rgb(0, 0, 0) ${v}px 1px 0px`; +} diff --git a/devtools/client/shared/test/browser_filter-editor-10.js b/devtools/client/shared/test/browser_filter-editor-10.js new file mode 100644 index 0000000000..248ee0c506 --- /dev/null +++ b/devtools/client/shared/test/browser_filter-editor-10.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the Filter Editor Widget inputs increase/decrease value when cursor is +// on a number using arrow keys if cursor is behind/mid/after the number strings + +const { + CSSFilterEditorWidget, +} = require("resource://devtools/client/shared/widgets/FilterWidget.js"); + +const DEFAULT_VALUE_MULTIPLIER = 1; + +const TEST_URI = CHROME_URL_ROOT + "doc_filter-editor-01.html"; + +add_task(async function () { + const { doc } = await createHost("bottom", TEST_URI); + + const container = doc.querySelector("#filter-container"); + const initialValue = "drop-shadow(rgb(0, 0, 0) 10px 1px 0px)"; + const widget = new CSSFilterEditorWidget(container, initialValue); + const input = widget.el.querySelector("#filters input"); + + let value = 10; + + triggerKey = triggerKey.bind(widget); + + info("Test increment/decrement of string-type numbers without selection"); + + input.setSelectionRange(14, 14); + triggerKey(40); + + value -= DEFAULT_VALUE_MULTIPLIER; + is( + widget.getValueAt(0), + val(value), + "Should work with cursor in the middle of number" + ); + + input.setSelectionRange(13, 13); + triggerKey(38); + + value += DEFAULT_VALUE_MULTIPLIER; + is( + widget.getValueAt(0), + val(value), + "Should work with cursor before the number" + ); + + input.setSelectionRange(15, 15); + triggerKey(40); + + value -= DEFAULT_VALUE_MULTIPLIER; + is( + widget.getValueAt(0), + val(value), + "Should work with cursor after the number" + ); + + info("Test increment/decrement of string-type numbers with a selection"); + + input.setSelectionRange(13, 15); + triggerKey(38); + input.setSelectionRange(13, 18); + triggerKey(38); + + value += DEFAULT_VALUE_MULTIPLIER * 2; + is( + widget.getValueAt(0), + val(value), + "Should work if a there is a selection, starting with the number" + ); + + widget.destroy(); + triggerKey = null; +}); + +// Triggers the specified keyCode and modifier key on +// first filter's input +function triggerKey(key, modifier) { + const filter = this.el.querySelector("#filters").children[0]; + const input = filter.querySelector("input"); + + this._keyDown({ + target: input, + keyCode: key, + [modifier]: true, + preventDefault() {}, + }); +} + +function val(value) { + let v = value.toFixed(1); + + if (v.indexOf(".0") > -1) { + v = v.slice(0, -2); + } + return `rgb(0, 0, 0) ${v}px 1px 0px`; +} diff --git a/devtools/client/shared/test/browser_filter-presets-01.js b/devtools/client/shared/test/browser_filter-presets-01.js new file mode 100644 index 0000000000..3a6d4a93d5 --- /dev/null +++ b/devtools/client/shared/test/browser_filter-presets-01.js @@ -0,0 +1,117 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests saving presets + +const { + CSSFilterEditorWidget, +} = require("resource://devtools/client/shared/widgets/FilterWidget.js"); + +const TEST_URI = CHROME_URL_ROOT + "doc_filter-editor-01.html"; + +add_task(async function () { + const { doc } = await createHost("bottom", TEST_URI); + + const container = doc.querySelector("#filter-container"); + const widget = new CSSFilterEditorWidget(container, "none"); + // First render + await widget.once("render"); + + const VALUE = "blur(2px) contrast(150%)"; + const NAME = "Test"; + + await showFilterPopupPresetsAndCreatePreset(widget, NAME, VALUE); + + const preset = widget.el.querySelector(".preset"); + is( + preset.querySelector("label").textContent, + NAME, + "Should show preset name correctly" + ); + is( + preset.querySelector("span").textContent, + VALUE, + "Should show preset value preview correctly" + ); + + let list = await widget.getPresets(); + const input = widget.el.querySelector(".presets-list .footer input"); + let data = list[0]; + + is(data.name, NAME, "Should add the preset to asyncStorage - name property"); + is( + data.value, + VALUE, + "Should add the preset to asyncStorage - name property" + ); + + info("Test overriding preset by using the same name"); + + const VALUE_2 = "saturate(50%) brightness(10%)"; + + widget.setCssValue(VALUE_2); + + await savePreset(widget); + + is( + widget.el.querySelectorAll(".preset").length, + 1, + "Should override the preset with the same name - render" + ); + + list = await widget.getPresets(); + data = list[0]; + + is( + list.length, + 1, + "Should override the preset with the same name - asyncStorage" + ); + + is( + data.name, + NAME, + "Should override the preset with the same name - prop name" + ); + is( + data.value, + VALUE_2, + "Should override the preset with the same name - prop value" + ); + + await widget.setPresets([]); + + info("Test saving a preset without name"); + input.value = ""; + + await savePreset(widget, "preset-save-error"); + + list = await widget.getPresets(); + is(list.length, 0, "Should not add a preset without name"); + + info("Test saving a preset without filters"); + + input.value = NAME; + widget.setCssValue("none"); + + await savePreset(widget, "preset-save-error"); + + list = await widget.getPresets(); + is(list.length, 0, "Should not add a preset without filters (value: none)"); +}); + +/** + * Call savePreset on widget and wait for the specified event to emit + * @param {CSSFilterWidget} widget + * @param {string} expectEvent="render" The event to listen on + * @return {Promise} + */ +function savePreset(widget, expectEvent = "render") { + const onEvent = widget.once(expectEvent); + widget._savePreset({ + preventDefault: () => {}, + }); + return onEvent; +} diff --git a/devtools/client/shared/test/browser_filter-presets-02.js b/devtools/client/shared/test/browser_filter-presets-02.js new file mode 100644 index 0000000000..ef9cb62bbe --- /dev/null +++ b/devtools/client/shared/test/browser_filter-presets-02.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests loading presets + +const { + CSSFilterEditorWidget, +} = require("resource://devtools/client/shared/widgets/FilterWidget.js"); + +const TEST_URI = CHROME_URL_ROOT + "doc_filter-editor-01.html"; + +add_task(async function () { + const { doc } = await createHost("bottom", TEST_URI); + + const container = doc.querySelector("#filter-container"); + const widget = new CSSFilterEditorWidget(container, "none"); + // First render + await widget.once("render"); + + const VALUE = "blur(2px) contrast(150%)"; + const NAME = "Test"; + + await showFilterPopupPresetsAndCreatePreset(widget, NAME, VALUE); + + let onRender = widget.once("render"); + // reset value + widget.setCssValue("saturate(100%) brightness(150%)"); + await onRender; + + const preset = widget.el.querySelector(".preset"); + + onRender = widget.once("render"); + widget._presetClick({ + target: preset, + }); + + await onRender; + + is(widget.getCssValue(), VALUE, "Should set widget's value correctly"); + is( + widget.el.querySelector(".presets-list .footer input").value, + NAME, + "Should set input's value to name" + ); +}); diff --git a/devtools/client/shared/test/browser_filter-presets-03.js b/devtools/client/shared/test/browser_filter-presets-03.js new file mode 100644 index 0000000000..2c679c3a1b --- /dev/null +++ b/devtools/client/shared/test/browser_filter-presets-03.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests deleting presets + +const { + CSSFilterEditorWidget, +} = require("resource://devtools/client/shared/widgets/FilterWidget.js"); + +const TEST_URI = CHROME_URL_ROOT + "doc_filter-editor-01.html"; + +add_task(async function () { + const { doc } = await createHost("bottom", TEST_URI); + + const container = doc.querySelector("#filter-container"); + const widget = new CSSFilterEditorWidget(container, "none"); + // First render + await widget.once("render"); + + const NAME = "Test"; + const VALUE = "blur(2px) contrast(150%)"; + + await showFilterPopupPresetsAndCreatePreset(widget, NAME, VALUE); + + const removeButton = widget.el.querySelector(".preset .remove-button"); + const onRender = widget.once("render"); + widget._presetClick({ + target: removeButton, + }); + + await onRender; + is( + widget.el.querySelector(".preset"), + null, + "Should re-render after removing preset" + ); + + const list = await widget.getPresets(); + is(list.length, 0, "Should remove presets from asyncStorage"); +}); diff --git a/devtools/client/shared/test/browser_html_tooltip-01.js b/devtools/client/shared/test/browser_html_tooltip-01.js new file mode 100644 index 0000000000..401d9d1c61 --- /dev/null +++ b/devtools/client/shared/test/browser_html_tooltip-01.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_html_tooltip.js */ + +"use strict"; + +/** + * Test the HTMLTooltip show & hide methods. + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip.xhtml"; + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); +loadHelperScript("helper_html_tooltip.js"); + +let useXulWrapper; + +function getTooltipContent(doc) { + const div = doc.createElementNS(HTML_NS, "div"); + div.style.height = "50px"; + div.style.boxSizing = "border-box"; + div.textContent = "tooltip"; + return div; +} + +add_task(async function () { + const { doc } = await createHost("bottom", TEST_URI); + + info("Run tests for a Tooltip without using a XUL panel"); + useXulWrapper = false; + await runTests(doc); + + info("Run tests for a Tooltip with a XUL panel"); + useXulWrapper = true; + await runTests(doc); +}); + +async function runTests(doc) { + const tooltip = new HTMLTooltip(doc, { useXulWrapper }); + + info("Set tooltip content"); + tooltip.panel.appendChild(getTooltipContent(doc)); + tooltip.setContentSize({ width: 100, height: 50 }); + + is(tooltip.isVisible(), false, "Tooltip is not visible"); + + info("Show the tooltip and check the expected events are fired."); + + let shown = 0; + tooltip.on("shown", () => shown++); + + const onShown = tooltip.once("shown"); + tooltip.show(doc.getElementById("box1")); + await onShown; + is(shown, 1, "Event shown was fired once"); + + await waitForReflow(tooltip); + is(tooltip.isVisible(), true, "Tooltip is visible"); + + info("Hide the tooltip and check the expected events are fired."); + + let hidden = 0; + tooltip.on("hidden", () => hidden++); + + const onPopupHidden = tooltip.once("hidden"); + tooltip.hide(); + + await onPopupHidden; + is(hidden, 1, "Event hidden was fired once"); + + await waitForReflow(tooltip); + is(tooltip.isVisible(), false, "Tooltip is not visible"); + + tooltip.destroy(); +} diff --git a/devtools/client/shared/test/browser_html_tooltip-02.js b/devtools/client/shared/test/browser_html_tooltip-02.js new file mode 100644 index 0000000000..4a622a7e7a --- /dev/null +++ b/devtools/client/shared/test/browser_html_tooltip-02.js @@ -0,0 +1,227 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_html_tooltip.js */ +"use strict"; + +/** + * Test the HTMLTooltip is closed when clicking outside of its container. + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip-02.xhtml"; +const PROMISE_TIMEOUT = 3000; + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); +loadHelperScript("helper_html_tooltip.js"); + +let useXulWrapper; + +add_task(async function () { + await addTab("about:blank"); + const { doc } = await createHost("bottom", TEST_URI); + + info("Run tests for a Tooltip without using a XUL panel"); + useXulWrapper = false; + await runTests(doc); + + info("Run tests for a Tooltip with a XUL panel"); + useXulWrapper = true; + await runTests(doc); +}); + +async function runTests(doc) { + await testClickInTooltipContent(doc); + await testClickInTooltipIcon(doc); + await testConsumeOutsideClicksFalse(doc); + await testConsumeOutsideClicksTrue(doc); + await testConsumeWithRightClick(doc); + await testClickInOuterIframe(doc); + await testClickInInnerIframe(doc); +} + +async function testClickInTooltipContent(doc) { + info("Test a tooltip is not closed when clicking inside itself"); + + const tooltip = new HTMLTooltip(doc, { useXulWrapper }); + tooltip.panel.appendChild(getTooltipContent(doc)); + tooltip.setContentSize({ width: 100, height: 50 }); + await showTooltip(tooltip, doc.getElementById("box1")); + + const onTooltipContainerClick = once(tooltip.container, "click"); + EventUtils.synthesizeMouseAtCenter(tooltip.container, {}, doc.defaultView); + await onTooltipContainerClick; + is(tooltip.isVisible(), true, "Tooltip is still visible"); + + tooltip.destroy(); +} + +async function testClickInTooltipIcon(doc) { + info("Test a tooltip is not closed when clicking it's icon"); + + const tooltip = new HTMLTooltip(doc, { useXulWrapper, noAutoHide: true }); + tooltip.panel.appendChild(getTooltipContent(doc)); + tooltip.setContentSize({ width: 100, height: 50 }); + + const box1 = doc.getElementById("box1"); + await showTooltip(tooltip, box1); + + const onHidden = once(tooltip, "hidden"); + box1.click(); + + // Hiding the tooltip is async so we need to wait for "hidden" to be emitted + // timing out after 3 seconds. If hidden is emitted we need to fail, + // otherwise the test passes. + const shown = await Promise.race([ + onHidden, + wait(PROMISE_TIMEOUT).then(() => true), + ]); + + ok(shown, "Tooltip is still visible"); + + tooltip.destroy(); +} + +async function testConsumeOutsideClicksFalse(doc) { + info("Test closing a tooltip via click with consumeOutsideClicks: false"); + const box4 = doc.getElementById("box4"); + + const tooltip = new HTMLTooltip(doc, { + consumeOutsideClicks: false, + useXulWrapper, + }); + tooltip.panel.appendChild(getTooltipContent(doc)); + tooltip.setContentSize({ width: 100, height: 50 }); + await showTooltip(tooltip, doc.getElementById("box1")); + + const onBox4Clicked = once(box4, "click"); + const onHidden = once(tooltip, "hidden"); + box4.click(); + await onHidden; + await onBox4Clicked; + + is(tooltip.isVisible(), false, "Tooltip is hidden"); + + tooltip.destroy(); +} + +async function testConsumeOutsideClicksTrue(doc) { + info("Test closing a tooltip via click with consumeOutsideClicks: true"); + const box4 = doc.getElementById("box4"); + + // Count clicks on box4 + let box4clicks = 0; + box4.addEventListener("click", () => box4clicks++); + + const tooltip = new HTMLTooltip(doc, { + consumeOutsideClicks: true, + useXulWrapper, + }); + tooltip.panel.appendChild(getTooltipContent(doc)); + tooltip.setContentSize({ width: 100, height: 50 }); + await showTooltip(tooltip, doc.getElementById("box1")); + + const onHidden = once(tooltip, "hidden"); + box4.click(); + await onHidden; + + is(box4clicks, 0, "box4 catched no click event"); + is(tooltip.isVisible(), false, "Tooltip is hidden"); + + tooltip.destroy(); +} + +async function testConsumeWithRightClick(doc) { + info( + "Test closing a tooltip with a right-click, with consumeOutsideClicks: true" + ); + const box4 = doc.getElementById("box4"); + + const tooltip = new HTMLTooltip(doc, { + consumeOutsideClicks: true, + useXulWrapper, + }); + tooltip.panel.appendChild(getTooltipContent(doc)); + tooltip.setContentSize({ width: 100, height: 50 }); + await showTooltip(tooltip, doc.getElementById("box1")); + + // Only left-click events should be consumed, so we expect to catch a click when using + // {button: 2}, which simulates a right-click. + info( + "Right click on box4, expect tooltip to be hidden, event should not be consumed" + ); + const onBox4Clicked = once(box4, "click"); + const onHidden = once(tooltip, "hidden"); + EventUtils.synthesizeMouseAtCenter(box4, { button: 2 }, doc.defaultView); + await onHidden; + await onBox4Clicked; + + is(tooltip.isVisible(), false, "Tooltip is hidden"); + + tooltip.destroy(); +} + +async function testClickInOuterIframe(doc) { + info("Test clicking an iframe outside of the tooltip closes the tooltip"); + const frame = doc.getElementById("frame"); + + const tooltip = new HTMLTooltip(doc, { useXulWrapper }); + tooltip.panel.appendChild(getTooltipContent(doc)); + tooltip.setContentSize({ width: 100, height: 50 }); + await showTooltip(tooltip, doc.getElementById("box1")); + + const onHidden = once(tooltip, "hidden"); + frame.click(); + await onHidden; + + is(tooltip.isVisible(), false, "Tooltip is hidden"); + tooltip.destroy(); +} + +async function testClickInInnerIframe(doc) { + info( + "Test clicking an iframe inside the tooltip content does not close the tooltip" + ); + + const tooltip = new HTMLTooltip(doc, { + consumeOutsideClicks: false, + useXulWrapper, + }); + + const iframe = doc.createElementNS(HTML_NS, "iframe"); + iframe.style.width = "100px"; + iframe.style.height = "50px"; + + tooltip.panel.appendChild(iframe); + + tooltip.setContentSize({ width: 100, height: 50 }); + await showTooltip(tooltip, doc.getElementById("box1")); + + iframe.srcdoc = "
"; + await new Promise(r => { + const frameLoad = () => { + r(); + }; + DOMHelpers.onceDOMReady(iframe.contentWindow, frameLoad); + }); + + await waitUntil(() => iframe.contentWindow.document.getElementById("test")); + + const target = iframe.contentWindow.document.getElementById("test"); + const onTooltipClick = once(target, "click"); + target.click(); + await onTooltipClick; + + is(tooltip.isVisible(), true, "Tooltip is still visible"); + + tooltip.destroy(); +} + +function getTooltipContent(doc) { + const div = doc.createElementNS(HTML_NS, "div"); + div.style.height = "50px"; + div.style.boxSizing = "border-box"; + div.textContent = "tooltip"; + return div; +} diff --git a/devtools/client/shared/test/browser_html_tooltip-03.js b/devtools/client/shared/test/browser_html_tooltip-03.js new file mode 100644 index 0000000000..15be3e3382 --- /dev/null +++ b/devtools/client/shared/test/browser_html_tooltip-03.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_html_tooltip.js */ + +"use strict"; + +/** + * This is the sanity test for the HTMLTooltip focus + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip-03.xhtml"; + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); +loadHelperScript("helper_html_tooltip.js"); + +let useXulWrapper; + +add_task(async function () { + await addTab("about:blank"); + const { doc } = await createHost("bottom", TEST_URI); + + info("Run tests for a Tooltip without using a XUL panel"); + useXulWrapper = false; + await runTests(doc); + + info("Run tests for a Tooltip with a XUL panel"); + useXulWrapper = true; + await runTests(doc); +}); + +async function runTests(doc) { + await focusNode(doc, "#box4-input"); + ok(doc.activeElement.closest("#box4-input"), "Focus is in the #box4-input"); + + info("Test a tooltip will not take focus"); + const tooltip = await createTooltip(doc); + + await showTooltip(tooltip, doc.getElementById("box1")); + ok( + doc.activeElement.closest("#box4-input"), + "Focus is still in the #box4-input" + ); + + await hideTooltip(tooltip); + await blurNode(doc, "#box4-input"); + + tooltip.destroy(); +} + +/** + * Fpcus the node corresponding to the provided selector in the provided document. Returns + * a promise that will resolve when receiving the focus event on the node. + */ +function focusNode(doc, selector) { + const node = doc.querySelector(selector); + const onFocus = once(node, "focus"); + node.focus(); + return onFocus; +} + +/** + * Blur the node corresponding to the provided selector in the provided document. Returns + * a promise that will resolve when receiving the blur event on the node. + */ +function blurNode(doc, selector) { + const node = doc.querySelector(selector); + const onBlur = once(node, "blur"); + node.blur(); + return onBlur; +} + +/** + * Create an HTMLTooltip instance. + * + * @param {Document} doc + * Document in which the tooltip should be created + * @return {Promise} promise that will resolve the HTMLTooltip instance created when the + * tooltip content will be ready. + */ +function createTooltip(doc) { + const tooltip = new HTMLTooltip(doc, { useXulWrapper }); + const div = doc.createElementNS(HTML_NS, "div"); + div.classList.add("tooltip-content"); + div.style.height = "50px"; + + const input = doc.createElementNS(HTML_NS, "input"); + input.setAttribute("type", "text"); + div.appendChild(input); + + tooltip.panel.appendChild(div); + tooltip.setContentSize({ width: 150, height: 50 }); + return tooltip; +} diff --git a/devtools/client/shared/test/browser_html_tooltip-04.js b/devtools/client/shared/test/browser_html_tooltip-04.js new file mode 100644 index 0000000000..9fe854bee4 --- /dev/null +++ b/devtools/client/shared/test/browser_html_tooltip-04.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_html_tooltip.js */ + +"use strict"; + +/** + * Test the HTMLTooltip positioning for a small tooltip element (should aways + * find a way to fit). + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip-04.xhtml"; + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); +loadHelperScript("helper_html_tooltip.js"); + +const TOOLTIP_HEIGHT = 30; +const TOOLTIP_WIDTH = 100; + +add_task(async function () { + // Force the toolbox to be 400px high; + await pushPref("devtools.toolbox.footer.height", 400); + + await addTab("about:blank"); + const { doc } = await createHost("bottom", TEST_URI); + + info("Create HTML tooltip"); + const tooltip = new HTMLTooltip(doc, { useXulWrapper: false }); + const div = doc.createElementNS(HTML_NS, "div"); + div.style.height = "100%"; + tooltip.panel.appendChild(div); + tooltip.setContentSize({ width: TOOLTIP_WIDTH, height: TOOLTIP_HEIGHT }); + + const box1 = doc.getElementById("box1"); + const box2 = doc.getElementById("box2"); + const box3 = doc.getElementById("box3"); + const box4 = doc.getElementById("box4"); + const height = TOOLTIP_HEIGHT, + width = TOOLTIP_WIDTH; + + // box1: Can only fit below box1 + info("Display the tooltip on box1."); + await showTooltip(tooltip, box1); + let expectedTooltipGeometry = { position: "bottom", height, width }; + checkTooltipGeometry(tooltip, box1, expectedTooltipGeometry); + await hideTooltip(tooltip); + + info("Try to display the tooltip on top of box1."); + await showTooltip(tooltip, box1, { position: "top" }); + expectedTooltipGeometry = { position: "bottom", height, width }; + checkTooltipGeometry(tooltip, box1, expectedTooltipGeometry); + await hideTooltip(tooltip); + + // box2: Can fit above or below, will default to bottom, more height + // available. + info("Try to display the tooltip on box2."); + await showTooltip(tooltip, box2); + expectedTooltipGeometry = { position: "bottom", height, width }; + checkTooltipGeometry(tooltip, box2, expectedTooltipGeometry); + await hideTooltip(tooltip); + + info("Try to display the tooltip on top of box2."); + await showTooltip(tooltip, box2, { position: "top" }); + expectedTooltipGeometry = { position: "top", height, width }; + checkTooltipGeometry(tooltip, box2, expectedTooltipGeometry); + await hideTooltip(tooltip); + + // box3: Can fit above or below, will default to top, more height available. + info("Try to display the tooltip on box3."); + await showTooltip(tooltip, box3); + expectedTooltipGeometry = { position: "top", height, width }; + checkTooltipGeometry(tooltip, box3, expectedTooltipGeometry); + await hideTooltip(tooltip); + + info("Try to display the tooltip on bottom of box3."); + await showTooltip(tooltip, box3, { position: "bottom" }); + expectedTooltipGeometry = { position: "bottom", height, width }; + checkTooltipGeometry(tooltip, box3, expectedTooltipGeometry); + await hideTooltip(tooltip); + + // box4: Can only fit above box4 + info("Display the tooltip on box4."); + await showTooltip(tooltip, box4); + expectedTooltipGeometry = { position: "top", height, width }; + checkTooltipGeometry(tooltip, box4, expectedTooltipGeometry); + await hideTooltip(tooltip); + + info("Try to display the tooltip on bottom of box4."); + await showTooltip(tooltip, box4, { position: "bottom" }); + expectedTooltipGeometry = { position: "top", height, width }; + checkTooltipGeometry(tooltip, box4, expectedTooltipGeometry); + await hideTooltip(tooltip); + + is(tooltip.isVisible(), false, "Tooltip is not visible"); + + tooltip.destroy(); +}); diff --git a/devtools/client/shared/test/browser_html_tooltip-05.js b/devtools/client/shared/test/browser_html_tooltip-05.js new file mode 100644 index 0000000000..7217039cb4 --- /dev/null +++ b/devtools/client/shared/test/browser_html_tooltip-05.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_html_tooltip.js */ + +"use strict"; + +/** + * Test the HTMLTooltip positioning for a huge tooltip element (can not fit in + * the viewport). + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip-05.xhtml"; + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); +loadHelperScript("helper_html_tooltip.js"); + +const TOOLTIP_HEIGHT = 200; +const TOOLTIP_WIDTH = 200; + +add_task(async function () { + // Force the toolbox to be 200px high; + await pushPref("devtools.toolbox.footer.height", 200); + await addTab("about:blank"); + const { doc } = await createHost("bottom", TEST_URI); + + info("Create HTML tooltip"); + const tooltip = new HTMLTooltip(doc, { useXulWrapper: false }); + const div = doc.createElementNS(HTML_NS, "div"); + div.style.height = "100%"; + tooltip.panel.appendChild(div); + tooltip.setContentSize({ width: TOOLTIP_WIDTH, height: TOOLTIP_HEIGHT }); + + const box1 = doc.getElementById("box1"); + const box2 = doc.getElementById("box2"); + const box3 = doc.getElementById("box3"); + const box4 = doc.getElementById("box4"); + const width = TOOLTIP_WIDTH; + + // box1: Can not fit above or below box1, default to bottom with a reduced + // height of 150px. + info("Display the tooltip on box1."); + await showTooltip(tooltip, box1); + let expectedTooltipGeometry = { position: "bottom", height: 150, width }; + checkTooltipGeometry(tooltip, box1, expectedTooltipGeometry); + await hideTooltip(tooltip); + + info("Try to display the tooltip on top of box1."); + await showTooltip(tooltip, box1, { position: "top" }); + expectedTooltipGeometry = { position: "bottom", height: 150, width }; + checkTooltipGeometry(tooltip, box1, expectedTooltipGeometry); + await hideTooltip(tooltip); + + // box2: Can not fit above or below box2, default to bottom with a reduced + // height of 100px. + info("Try to display the tooltip on box2."); + await showTooltip(tooltip, box2); + expectedTooltipGeometry = { position: "bottom", height: 100, width }; + checkTooltipGeometry(tooltip, box2, expectedTooltipGeometry); + await hideTooltip(tooltip); + + info("Try to display the tooltip on top of box2."); + await showTooltip(tooltip, box2, { position: "top" }); + expectedTooltipGeometry = { position: "bottom", height: 100, width }; + checkTooltipGeometry(tooltip, box2, expectedTooltipGeometry); + await hideTooltip(tooltip); + + // box3: Can not fit above or below box3, default to top with a reduced height + // of 100px. + info("Try to display the tooltip on box3."); + await showTooltip(tooltip, box3); + expectedTooltipGeometry = { position: "top", height: 100, width }; + checkTooltipGeometry(tooltip, box3, expectedTooltipGeometry); + await hideTooltip(tooltip); + + info("Try to display the tooltip on bottom of box3."); + await showTooltip(tooltip, box3, { position: "bottom" }); + expectedTooltipGeometry = { position: "top", height: 100, width }; + checkTooltipGeometry(tooltip, box3, expectedTooltipGeometry); + await hideTooltip(tooltip); + + // box4: Can not fit above or below box4, default to top with a reduced height + // of 150px. + info("Display the tooltip on box4."); + await showTooltip(tooltip, box4); + expectedTooltipGeometry = { position: "top", height: 150, width }; + checkTooltipGeometry(tooltip, box4, expectedTooltipGeometry); + await hideTooltip(tooltip); + + info("Try to display the tooltip on bottom of box4."); + await showTooltip(tooltip, box4, { position: "bottom" }); + expectedTooltipGeometry = { position: "top", height: 150, width }; + checkTooltipGeometry(tooltip, box4, expectedTooltipGeometry); + await hideTooltip(tooltip); + + is(tooltip.isVisible(), false, "Tooltip is not visible"); + + tooltip.destroy(); +}); diff --git a/devtools/client/shared/test/browser_html_tooltip_arrow-01.js b/devtools/client/shared/test/browser_html_tooltip_arrow-01.js new file mode 100644 index 0000000000..222d43b696 --- /dev/null +++ b/devtools/client/shared/test/browser_html_tooltip_arrow-01.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_html_tooltip.js */ + +"use strict"; + +/** + * Test the HTMLTooltip "arrow" type on small anchors. The arrow should remain + * aligned with the anchors as much as possible + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip_arrow-01.xhtml"; + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); +loadHelperScript("helper_html_tooltip.js"); + +let useXulWrapper; + +add_task(async function () { + // Force the toolbox to be 200px high; + await pushPref("devtools.toolbox.footer.height", 200); + + await addTab("about:blank"); + const { doc } = await createHost("bottom", TEST_URI); + + info("Run tests for a Tooltip without using a XUL panel"); + useXulWrapper = false; + await runTests(doc); + + info("Run tests for a Tooltip with a XUL panel"); + useXulWrapper = true; + await runTests(doc); +}); + +async function runTests(doc) { + info("Create HTML tooltip"); + const tooltip = new HTMLTooltip(doc, { type: "arrow", useXulWrapper }); + const div = doc.createElementNS(HTML_NS, "div"); + div.style.height = "35px"; + tooltip.panel.appendChild(div); + tooltip.setContentSize({ width: 200, height: 35 }); + + const { right: docRight } = doc.documentElement.getBoundingClientRect(); + + const elements = [...doc.querySelectorAll(".anchor")]; + for (const el of elements) { + info("Display the tooltip on an anchor."); + await showTooltip(tooltip, el); + + const arrow = tooltip.arrow; + ok(arrow, "Tooltip has an arrow"); + + // Get the geometry of the anchor, the tooltip panel & arrow. + const arrowBounds = arrow.getBoxQuads({ relativeTo: doc })[0].getBounds(); + const panelBounds = tooltip.panel + .getBoxQuads({ relativeTo: doc })[0] + .getBounds(); + const anchorBounds = el.getBoxQuads({ relativeTo: doc })[0].getBounds(); + + const intersects = + arrowBounds.left <= anchorBounds.right && + arrowBounds.right >= anchorBounds.left; + const isBlockedByViewport = + arrowBounds.left == 0 || arrowBounds.right == docRight; + ok( + intersects || isBlockedByViewport, + "Tooltip arrow is aligned with the anchor, or stuck on viewport's edge." + ); + + const isInPanel = + arrowBounds.left >= panelBounds.left && + arrowBounds.right <= panelBounds.right; + + ok( + isInPanel, + "The tooltip arrow remains inside the tooltip panel horizontally" + ); + + await hideTooltip(tooltip); + } + + tooltip.destroy(); +} diff --git a/devtools/client/shared/test/browser_html_tooltip_arrow-02.js b/devtools/client/shared/test/browser_html_tooltip_arrow-02.js new file mode 100644 index 0000000000..07222a6a49 --- /dev/null +++ b/devtools/client/shared/test/browser_html_tooltip_arrow-02.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_html_tooltip.js */ + +"use strict"; + +/** + * Test the HTMLTooltip "arrow" type on wide anchors. The arrow should remain + * aligned with the anchors as much as possible + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip_arrow-02.xhtml"; + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); +loadHelperScript("helper_html_tooltip.js"); + +let useXulWrapper; + +add_task(async function () { + // Force the toolbox to be 200px high; + await pushPref("devtools.toolbox.footer.height", 200); + + const { doc } = await createHost("bottom", TEST_URI); + + info("Run tests for a Tooltip without using a XUL panel"); + useXulWrapper = false; + await runTests(doc); + + info("Run tests for a Tooltip with a XUL panel"); + useXulWrapper = true; + await runTests(doc); +}); + +async function runTests(doc) { + info("Create HTML tooltip"); + const tooltip = new HTMLTooltip(doc, { type: "arrow", useXulWrapper }); + const div = doc.createElementNS(HTML_NS, "div"); + div.style.height = "35px"; + tooltip.panel.appendChild(div); + tooltip.setContentSize({ width: 200, height: 35 }); + + const { right: docRight } = doc.documentElement.getBoundingClientRect(); + + const elements = [...doc.querySelectorAll(".anchor")]; + for (const el of elements) { + info("Display the tooltip on an anchor."); + await showTooltip(tooltip, el); + + const arrow = tooltip.arrow; + ok(arrow, "Tooltip has an arrow"); + + // Get the geometry of the anchor, the tooltip panel & arrow. + const arrowBounds = arrow.getBoxQuads({ relativeTo: doc })[0].getBounds(); + const panelBounds = tooltip.panel + .getBoxQuads({ relativeTo: doc })[0] + .getBounds(); + const anchorBounds = el.getBoxQuads({ relativeTo: doc })[0].getBounds(); + + const intersects = + arrowBounds.left <= anchorBounds.right && + arrowBounds.right >= anchorBounds.left; + const isBlockedByViewport = + arrowBounds.left == 0 || arrowBounds.right == docRight; + ok( + intersects || isBlockedByViewport, + "Tooltip arrow is aligned with the anchor, or stuck on viewport's edge." + ); + + const isInPanel = + arrowBounds.left >= panelBounds.left && + arrowBounds.right <= panelBounds.right; + ok( + isInPanel, + "The tooltip arrow remains inside the tooltip panel horizontally" + ); + await hideTooltip(tooltip); + } + + tooltip.destroy(); +} diff --git a/devtools/client/shared/test/browser_html_tooltip_consecutive-show.js b/devtools/client/shared/test/browser_html_tooltip_consecutive-show.js new file mode 100644 index 0000000000..d61d57caff --- /dev/null +++ b/devtools/client/shared/test/browser_html_tooltip_consecutive-show.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_html_tooltip.js */ + +"use strict"; + +/** + * Test the HTMLTooltip show can be called several times. It should move according to the + * new anchor/options and should not leak event listeners. + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip.xhtml"; + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); +loadHelperScript("helper_html_tooltip.js"); + +function getTooltipContent(doc) { + const div = doc.createElementNS(HTML_NS, "div"); + div.style.height = "50px"; + div.textContent = "tooltip"; + return div; +} + +add_task(async function () { + const { doc } = await createHost("bottom", TEST_URI); + + // Creating a host is not correctly waiting when DevTools run in content frame + // See Bug 1571421. + await wait(1000); + + const box1 = doc.getElementById("box1"); + const box2 = doc.getElementById("box2"); + const box3 = doc.getElementById("box3"); + const box4 = doc.getElementById("box4"); + + const width = 100, + height = 50; + + const tooltip = new HTMLTooltip(doc, { useXulWrapper: false }); + tooltip.panel.appendChild(getTooltipContent(doc)); + tooltip.setContentSize({ width, height }); + + info( + "Show the tooltip on each of the 4 hbox, without calling hide in between" + ); + + info("Show tooltip on box1"); + tooltip.show(box1); + checkTooltipGeometry(tooltip, box1, { position: "bottom", width, height }); + + info("Show tooltip on box2"); + tooltip.show(box2); + checkTooltipGeometry(tooltip, box2, { position: "bottom", width, height }); + + info("Show tooltip on box3"); + tooltip.show(box3); + checkTooltipGeometry(tooltip, box3, { position: "top", width, height }); + + info("Show tooltip on box4"); + tooltip.show(box4); + checkTooltipGeometry(tooltip, box4, { position: "top", width, height }); + + info("Hide tooltip before leaving test"); + await hideTooltip(tooltip); + + tooltip.destroy(); +}); diff --git a/devtools/client/shared/test/browser_html_tooltip_doorhanger-01.js b/devtools/client/shared/test/browser_html_tooltip_doorhanger-01.js new file mode 100644 index 0000000000..7e2d9e5657 --- /dev/null +++ b/devtools/client/shared/test/browser_html_tooltip_doorhanger-01.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_html_tooltip.js */ + +"use strict"; + +/** + * Test the HTMLTooltip "doorhanger" type's hang direction. It should hang + * along the flow of text e.g. in RTL mode it should hang left and in LTR mode + * it should hang right. + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip_doorhanger-01.xhtml"; + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); +loadHelperScript("helper_html_tooltip.js"); + +let useXulWrapper; + +add_task(async function () { + // Force the toolbox to be 200px high; + await pushPref("devtools.toolbox.footer.height", 200); + + await addTab("about:blank"); + const { doc } = await createHost("bottom", TEST_URI); + + info("Run tests for a Tooltip without using a XUL panel"); + useXulWrapper = false; + await runTests(doc); +}); + +async function runTests(doc) { + info("Create HTML tooltip"); + const tooltip = new HTMLTooltip(doc, { type: "doorhanger", useXulWrapper }); + const div = doc.createElementNS(HTML_NS, "div"); + div.style.width = "200px"; + div.style.height = "35px"; + tooltip.panel.appendChild(div); + + const anchors = [...doc.querySelectorAll(".anchor")]; + for (const anchor of anchors) { + const hangDirection = anchor.getAttribute("data-hang"); + + info("Display the tooltip on an anchor."); + await showTooltip(tooltip, anchor); + + const arrow = tooltip.arrow; + ok(arrow, "Tooltip has an arrow"); + + // Get the geometry of the the tooltip panel & arrow. + const panelBounds = tooltip.panel + .getBoxQuads({ relativeTo: doc })[0] + .getBounds(); + const arrowBounds = arrow.getBoxQuads({ relativeTo: doc })[0].getBounds(); + const panelBoundsCentre = (panelBounds.left + panelBounds.right) / 2; + const arrowCentre = (arrowBounds.left + arrowBounds.right) / 2; + + if (hangDirection === "left") { + Assert.greater( + arrowCentre, + panelBoundsCentre, + `tooltip hangs to the left for ${anchor.id}` + ); + } else { + Assert.less( + arrowCentre, + panelBoundsCentre, + `tooltip hangs to the right for ${anchor.id}` + ); + } + + await hideTooltip(tooltip); + } + + tooltip.destroy(); +} diff --git a/devtools/client/shared/test/browser_html_tooltip_doorhanger-02.js b/devtools/client/shared/test/browser_html_tooltip_doorhanger-02.js new file mode 100644 index 0000000000..b1564f698c --- /dev/null +++ b/devtools/client/shared/test/browser_html_tooltip_doorhanger-02.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_html_tooltip.js */ + +"use strict"; + +/** + * Test the HTMLTooltip "doorhanger" type's arrow tip is precisely centered on + * the anchor when the anchor is small. + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip_doorhanger-02.xhtml"; + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); +loadHelperScript("helper_html_tooltip.js"); + +let useXulWrapper; + +add_task(async function () { + // Force the toolbox to be 200px high; + await pushPref("devtools.toolbox.footer.height", 200); + + await addTab("about:blank"); + const { doc } = await createHost("bottom", TEST_URI); + + info("Run tests for a Tooltip without using a XUL panel"); + useXulWrapper = false; + await runTests(doc); + + info("Run tests for a Tooltip with a XUL panel"); + useXulWrapper = true; + await runTests(doc); +}); + +async function runTests(doc) { + info("Create HTML tooltip"); + const tooltip = new HTMLTooltip(doc, { type: "doorhanger", useXulWrapper }); + const div = doc.createElementNS(HTML_NS, "div"); + div.style.width = "200px"; + div.style.height = "35px"; + tooltip.panel.appendChild(div); + + const elements = [...doc.querySelectorAll(".anchor")]; + for (const el of elements) { + info("Display the tooltip on an anchor."); + await showTooltip(tooltip, el); + + const arrow = tooltip.arrow; + ok(arrow, "Tooltip has an arrow"); + + // Get the geometry of the anchor and arrow. + const anchorBounds = el.getBoxQuads({ relativeTo: doc })[0].getBounds(); + const arrowBounds = arrow.getBoxQuads({ relativeTo: doc })[0].getBounds(); + + // Compare the centers + const center = bounds => bounds.left + bounds.width / 2; + const delta = Math.abs(center(anchorBounds) - center(arrowBounds)); + const describeBounds = bounds => + `${bounds.left}<--[${center(bounds)}]-->${bounds.right}`; + const params = + `anchor: ${describeBounds(anchorBounds)}, ` + + `arrow: ${describeBounds(arrowBounds)}`; + Assert.lessOrEqual( + delta, + 1, + `Arrow center is roughly aligned with anchor center (${params})` + ); + + await hideTooltip(tooltip); + } + + tooltip.destroy(); +} diff --git a/devtools/client/shared/test/browser_html_tooltip_height-auto.js b/devtools/client/shared/test/browser_html_tooltip_height-auto.js new file mode 100644 index 0000000000..7a42183f80 --- /dev/null +++ b/devtools/client/shared/test/browser_html_tooltip_height-auto.js @@ -0,0 +1,108 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_html_tooltip.js */ + +"use strict"; + +/** + * Test the HTMLTooltip content can automatically calculate its height based on + * content. + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip.xhtml"; + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); +loadHelperScript("helper_html_tooltip.js"); + +let useXulWrapper; + +add_task(async function () { + await addTab("about:blank"); + const { doc } = await createHost("bottom", TEST_URI); + + info("Run tests for a Tooltip without using a XUL panel"); + useXulWrapper = false; + await runTests(doc); + + info("Run tests for a Tooltip with a XUL panel"); + useXulWrapper = true; + await runTests(doc); +}); + +async function runTests(doc) { + const tooltip = new HTMLTooltip(doc, { useXulWrapper }); + info("Create tooltip content height to 150px"); + const tooltipContent = doc.createElementNS(HTML_NS, "div"); + tooltipContent.style.cssText = + "width: 300px; height: 150px; background: red;"; + + info("Set tooltip content using width:auto and height:auto"); + tooltip.panel.appendChild(tooltipContent); + + info("Show the tooltip and check the tooltip container dimensions."); + await showTooltip(tooltip, doc.getElementById("box1")); + + let panelRect = tooltip.container.getBoundingClientRect(); + is(panelRect.width, 300, "Tooltip container has the expected width."); + is(panelRect.height, 150, "Tooltip container has the expected height."); + + await hideTooltip(tooltip); + + info("Set tooltip content using fixed width and height:auto"); + tooltipContent.style.cssText = "width: auto; height: 160px; background: red;"; + tooltip.setContentSize({ width: 400 }); + + info("Show the tooltip and check the tooltip container height."); + await showTooltip(tooltip, doc.getElementById("box1")); + + panelRect = tooltip.container.getBoundingClientRect(); + is(panelRect.height, 160, "Tooltip container has the expected height."); + + await hideTooltip(tooltip); + + info("Update the height and show the tooltip again"); + tooltipContent.style.cssText = "width: auto; height: 165px; background: red;"; + + await showTooltip(tooltip, doc.getElementById("box1")); + + panelRect = tooltip.container.getBoundingClientRect(); + is( + panelRect.height, + 165, + "Tooltip container has the expected updated height." + ); + + await hideTooltip(tooltip); + + info( + "Check that refreshing the tooltip when it overflows does keep scroll position" + ); + // Set the tooltip panel to overflow. Some consumers of the HTMLTooltip are doing that + // via CSS (e.g. the iframe dropdown, the context selector, …). + tooltip.panel.style.overflowY = "auto"; + tooltipContent.style.cssText = + "width: auto; height: 3000px; background: tomato;"; + await showTooltip(tooltip, doc.getElementById("box1")); + + Assert.greater( + tooltip.panel.scrollHeight, + tooltip.panel.clientHeight, + "Tooltip overflows" + ); + + const scrollPosition = 500; + tooltip.panel.scrollTop = scrollPosition; + + await showTooltip(tooltip, doc.getElementById("box1")); + is( + tooltip.panel.scrollTop, + scrollPosition, + "scroll position was kept during the update" + ); + await hideTooltip(tooltip); + + tooltip.destroy(); +} diff --git a/devtools/client/shared/test/browser_html_tooltip_hover.js b/devtools/client/shared/test/browser_html_tooltip_hover.js new file mode 100644 index 0000000000..c7d055ba35 --- /dev/null +++ b/devtools/client/shared/test/browser_html_tooltip_hover.js @@ -0,0 +1,65 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_html_tooltip.js */ + +"use strict"; + +/** + * Test the TooltipToggle helper class for HTMLTooltip + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip_hover.xhtml"; + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); +loadHelperScript("helper_html_tooltip.js"); + +add_task(async function () { + const { doc } = await createHost("bottom", TEST_URI); + // Wait for full page load before synthesizing events on the page. + await waitUntil(() => doc.readyState === "complete"); + + const width = 100, + height = 50; + const tooltipContent = doc.createElementNS(HTML_NS, "div"); + tooltipContent.textContent = "tooltip"; + const tooltip = new HTMLTooltip(doc, { useXulWrapper: false }); + tooltip.panel.appendChild(tooltipContent); + tooltip.setContentSize({ width, height }); + + const container = doc.getElementById("container"); + tooltip.startTogglingOnHover(container, () => true); + + info("Hover on each of the 4 boxes, expect the tooltip to appear"); + async function showAndCheck(boxId, position) { + info(`Show tooltip on ${boxId}`); + const box = doc.getElementById(boxId); + const shown = tooltip.once("shown"); + EventUtils.synthesizeMouseAtCenter( + box, + { type: "mousemove" }, + doc.defaultView + ); + await shown; + checkTooltipGeometry(tooltip, box, { position, width, height }); + } + + await showAndCheck("box1", "bottom"); + await showAndCheck("box2", "bottom"); + await showAndCheck("box3", "top"); + await showAndCheck("box4", "top"); + + info("Move out of the container"); + const hidden = tooltip.once("hidden"); + EventUtils.synthesizeMouseAtCenter( + container, + { type: "mousemove" }, + doc.defaultView + ); + await hidden; + + info("Destroy the tooltip and finish"); + tooltip.destroy(); +}); diff --git a/devtools/client/shared/test/browser_html_tooltip_offset.js b/devtools/client/shared/test/browser_html_tooltip_offset.js new file mode 100644 index 0000000000..d7d7e1200e --- /dev/null +++ b/devtools/client/shared/test/browser_html_tooltip_offset.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_html_tooltip.js */ +"use strict"; + +/** + * Test the HTMLTooltip can be displayed with vertical and horizontal offsets. + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip.xhtml"; + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); +loadHelperScript("helper_html_tooltip.js"); + +add_task(async function () { + // Force the toolbox to be 200px high; + await pushPref("devtools.toolbox.footer.height", 200); + + const { doc } = await createHost("bottom", TEST_URI); + + info("Test a tooltip is not closed when clicking inside itself"); + + const box1 = doc.getElementById("box1"); + const box2 = doc.getElementById("box2"); + const box3 = doc.getElementById("box3"); + const box4 = doc.getElementById("box4"); + + const tooltip = new HTMLTooltip(doc, { useXulWrapper: false }); + + const div = doc.createElementNS(HTML_NS, "div"); + div.style.height = "100px"; + div.style.boxSizing = "border-box"; + div.textContent = "tooltip"; + tooltip.panel.appendChild(div); + tooltip.setContentSize({ width: 50, height: 100 }); + + info("Display the tooltip on box1."); + await showTooltip(tooltip, box1, { x: 5, y: 10 }); + + let panelRect = tooltip.container.getBoundingClientRect(); + let anchorRect = box1.getBoundingClientRect(); + + // Tooltip will be displayed below box1 + is(panelRect.top, anchorRect.bottom + 10, "Tooltip top has 10px offset"); + is(panelRect.left, anchorRect.left + 5, "Tooltip left has 5px offset"); + is(panelRect.height, 100, "Tooltip height is at 100px as expected"); + + info("Display the tooltip on box2."); + await showTooltip(tooltip, box2, { x: 5, y: 10 }); + + panelRect = tooltip.container.getBoundingClientRect(); + anchorRect = box2.getBoundingClientRect(); + + // Tooltip will be displayed below box2, but can't be fully displayed because of the + // offset + is(panelRect.top, anchorRect.bottom + 10, "Tooltip top has 10px offset"); + is(panelRect.left, anchorRect.left + 5, "Tooltip left has 5px offset"); + is(panelRect.height, 90, "Tooltip height is only 90px"); + + info("Display the tooltip on box3."); + await showTooltip(tooltip, box3, { x: 5, y: 10 }); + + panelRect = tooltip.container.getBoundingClientRect(); + anchorRect = box3.getBoundingClientRect(); + + // Tooltip will be displayed above box3, but can't be fully displayed because of the + // offset + is( + panelRect.bottom, + anchorRect.top - 10, + "Tooltip bottom is 10px above anchor" + ); + is(panelRect.left, anchorRect.left + 5, "Tooltip left has 5px offset"); + is(panelRect.height, 90, "Tooltip height is only 90px"); + + info("Display the tooltip on box4."); + await showTooltip(tooltip, box4, { x: 5, y: 10 }); + + panelRect = tooltip.container.getBoundingClientRect(); + anchorRect = box4.getBoundingClientRect(); + + // Tooltip will be displayed above box4 + is( + panelRect.bottom, + anchorRect.top - 10, + "Tooltip bottom is 10px above anchor" + ); + is(panelRect.left, anchorRect.left + 5, "Tooltip left has 5px offset"); + is(panelRect.height, 100, "Tooltip height is at 100px as expected"); + + await hideTooltip(tooltip); + + tooltip.destroy(); +}); diff --git a/devtools/client/shared/test/browser_html_tooltip_resize.js b/devtools/client/shared/test/browser_html_tooltip_resize.js new file mode 100644 index 0000000000..145d2582ca --- /dev/null +++ b/devtools/client/shared/test/browser_html_tooltip_resize.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_html_tooltip.js */ +"use strict"; + +/** + * Test the HTMLTooltip can be resized. + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip.xhtml"; + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); +loadHelperScript("helper_html_tooltip.js"); + +const TOOLBOX_WIDTH = 500; + +add_task(async function () { + await pushPref("devtools.toolbox.sidebar.width", TOOLBOX_WIDTH); + + // Open the host on the right so that the doorhangers hang right. + const { doc } = await createHost("right", TEST_URI); + + info("Test resizing of a tooltip"); + + const tooltip = new HTMLTooltip(doc, { + useXulWrapper: true, + type: "doorhanger", + }); + const div = doc.createElementNS(HTML_NS, "div"); + div.textContent = "tooltip"; + div.style.cssText = "width: 100px; height: 40px"; + tooltip.panel.appendChild(div); + + const box1 = doc.getElementById("box1"); + + await showTooltip(tooltip, box1, { position: "top" }); + + // Get the original position of the panel and arrow. + const originalPanelBounds = tooltip.panel + .getBoxQuads({ relativeTo: doc })[0] + .getBounds(); + const originalArrowBounds = tooltip.arrow + .getBoxQuads({ relativeTo: doc })[0] + .getBounds(); + + // Resize the content + div.style.cssText = "width: 200px; height: 30px"; + tooltip.show(box1, { position: "top" }); + + // The panel should have moved 100px to the left and 10px down + const updatedPanelBounds = tooltip.panel + .getBoxQuads({ relativeTo: doc })[0] + .getBounds(); + + const panelXMovement = + `panel right: ${originalPanelBounds.right} -> ` + updatedPanelBounds.right; + Assert.strictEqual( + Math.round(updatedPanelBounds.right - originalPanelBounds.right), + 100, + `Panel should have moved 100px to the right (actual: ${panelXMovement})` + ); + + const panelYMovement = + `panel top: ${originalPanelBounds.top} -> ` + updatedPanelBounds.top; + Assert.strictEqual( + Math.round(updatedPanelBounds.top - originalPanelBounds.top), + 10, + `Panel should have moved 10px down (actual: ${panelYMovement})` + ); + + // The arrow should be in the same position + const updatedArrowBounds = tooltip.arrow + .getBoxQuads({ relativeTo: doc })[0] + .getBounds(); + + const arrowXMovement = + `arrow left: ${originalArrowBounds.left} -> ` + updatedArrowBounds.left; + Assert.strictEqual( + Math.round(updatedArrowBounds.left - originalArrowBounds.left), + 0, + `Arrow should not have moved (actual: ${arrowXMovement})` + ); + + const arrowYMovement = + `arrow top: ${originalArrowBounds.top} -> ` + updatedArrowBounds.top; + Assert.strictEqual( + Math.round(updatedArrowBounds.top - originalArrowBounds.top), + 0, + `Arrow should not have moved (actual: ${arrowYMovement})` + ); + + await hideTooltip(tooltip); + tooltip.destroy(); +}); diff --git a/devtools/client/shared/test/browser_html_tooltip_rtl.js b/devtools/client/shared/test/browser_html_tooltip_rtl.js new file mode 100644 index 0000000000..11fe3932f3 --- /dev/null +++ b/devtools/client/shared/test/browser_html_tooltip_rtl.js @@ -0,0 +1,226 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_html_tooltip.js */ +"use strict"; + +/** + * Test the HTMLTooltip anchor alignment changes with the anchor direction. + * - should be aligned to the right of RTL anchors + * - should be aligned to the left of LTR anchors + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip_rtl.xhtml"; + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); +loadHelperScript("helper_html_tooltip.js"); + +const TOOLBOX_WIDTH = 500; +const TOOLTIP_WIDTH = 150; +const TOOLTIP_HEIGHT = 30; + +add_task(async function () { + await pushPref("devtools.toolbox.sidebar.width", TOOLBOX_WIDTH); + + const { doc } = await createHost("right", TEST_URI); + + info("Test the positioning of tooltips in RTL and LTR directions"); + + const tooltip = new HTMLTooltip(doc, { useXulWrapper: false }); + const div = doc.createElementNS(HTML_NS, "div"); + div.textContent = "tooltip"; + div.style.cssText = "box-sizing: border-box; border: 1px solid black"; + tooltip.panel.appendChild(div); + tooltip.setContentSize({ width: TOOLTIP_WIDTH, height: TOOLTIP_HEIGHT }); + + await testRtlAnchors(doc, tooltip); + await testLtrAnchors(doc, tooltip); + await hideTooltip(tooltip); + + tooltip.destroy(); + + await testRtlArrow(doc); +}); + +async function testRtlAnchors(doc, tooltip) { + /* + * The layout of the test page is as follows: + * _______________________________ + * | toolbox | + * | _____ _____ _____ _____ | + * || | | | | | | || + * || box1| | box2| | box3| | box4|| + * ||_____| |_____| |_____| |_____|| + * |_______________________________| + * + * - box1 is aligned with the left edge of the toolbox + * - box2 is displayed right after box1 + * - total toolbox width is 500px so each box is 125px wide + */ + + const box1 = doc.getElementById("box1"); + const box2 = doc.getElementById("box2"); + + info("Display the tooltip on box1."); + await showTooltip(tooltip, box1, { position: "bottom" }); + + let panelRect = tooltip.container.getBoundingClientRect(); + let anchorRect = box1.getBoundingClientRect(); + + // box1 uses RTL direction, so the tooltip should be aligned with the right edge of the + // anchor, but it is shifted to the right to fit in the toolbox. + is(panelRect.left, 0, "Tooltip is aligned with left edge of the toolbox"); + is( + panelRect.top, + anchorRect.bottom, + "Tooltip aligned with the anchor bottom edge" + ); + is( + panelRect.height, + TOOLTIP_HEIGHT, + "Tooltip height is at 100px as expected" + ); + + info("Display the tooltip on box2."); + await showTooltip(tooltip, box2, { position: "bottom" }); + + panelRect = tooltip.container.getBoundingClientRect(); + anchorRect = box2.getBoundingClientRect(); + + // box2 uses RTL direction, so the tooltip is aligned with the right edge of the anchor + is( + Math.round(panelRect.right), + Math.round(anchorRect.right), + "Tooltip is aligned with right edge of anchor" + ); + is( + panelRect.top, + anchorRect.bottom, + "Tooltip aligned with the anchor bottom edge" + ); + is( + panelRect.height, + TOOLTIP_HEIGHT, + "Tooltip height is at 100px as expected" + ); +} + +async function testLtrAnchors(doc, tooltip) { + /* + * The layout of the test page is as follows: + * _______________________________ + * | toolbox | + * | _____ _____ _____ _____ | + * || | | | | | | || + * || box1| | box2| | box3| | box4|| + * ||_____| |_____| |_____| |_____|| + * |_______________________________| + * + * - box3 is is displayed right after box2 + * - box4 is aligned with the right edge of the toolbox + * - total toolbox width is 500px so each box is 125px wide + */ + + const box3 = doc.getElementById("box3"); + const box4 = doc.getElementById("box4"); + + info("Display the tooltip on box3."); + await showTooltip(tooltip, box3, { position: "bottom" }); + + let panelRect = tooltip.container.getBoundingClientRect(); + let anchorRect = box3.getBoundingClientRect(); + + // box3 uses LTR direction, so the tooltip is aligned with the left edge of the anchor. + is( + Math.round(panelRect.left), + Math.round(anchorRect.left), + "Tooltip is aligned with left edge of anchor" + ); + is( + panelRect.top, + anchorRect.bottom, + "Tooltip aligned with the anchor bottom edge" + ); + is( + panelRect.height, + TOOLTIP_HEIGHT, + "Tooltip height is at 100px as expected" + ); + + info("Display the tooltip on box4."); + await showTooltip(tooltip, box4, { position: "bottom" }); + + panelRect = tooltip.container.getBoundingClientRect(); + anchorRect = box4.getBoundingClientRect(); + + // box4 uses LTR direction, so the tooltip should be aligned with the left edge of the + // anchor, but it is shifted to the left to fit in the toolbox. + is( + panelRect.right, + TOOLBOX_WIDTH, + "Tooltip is aligned with right edge of toolbox" + ); + is( + panelRect.top, + anchorRect.bottom, + "Tooltip aligned with the anchor bottom edge" + ); + is( + panelRect.height, + TOOLTIP_HEIGHT, + "Tooltip height is at 100px as expected" + ); +} + +async function testRtlArrow(doc) { + // Set up the arrow-style tooltip + const arrowTooltip = new HTMLTooltip(doc, { + type: "arrow", + useXulWrapper: false, + }); + const div = doc.createElementNS(HTML_NS, "div"); + div.textContent = "tooltip"; + div.style.cssText = "box-sizing: border-box; border: 1px solid black"; + arrowTooltip.panel.appendChild(div); + arrowTooltip.setContentSize({ + width: TOOLTIP_WIDTH, + height: TOOLTIP_HEIGHT, + }); + + // box2 uses RTL direction and is far enough from the edge that the arrow + // should not be squashed in the wrong direction. + const box2 = doc.getElementById("box2"); + + info("Display the arrow tooltip on box2."); + await showTooltip(arrowTooltip, box2, { position: "top" }); + + const arrow = arrowTooltip.arrow; + ok(arrow, "Tooltip has an arrow"); + + const panelRect = arrowTooltip.container.getBoundingClientRect(); + const arrowRect = arrow.getBoundingClientRect(); + + // The arrow should be offset from the right edge, but still closer to the + // right edge than the left edge. + Assert.less( + arrowRect.right, + panelRect.right, + "Right edge of the arrow " + + `(${arrowRect.right}) is less than the right edge of the panel ` + + `(${panelRect.right})` + ); + const rightMargin = panelRect.right - arrowRect.right; + const leftMargin = arrowRect.left - panelRect.right; + Assert.greater( + rightMargin, + leftMargin, + "Arrow should be closer to the right side of " + + ` the panel (margin: ${rightMargin}) than the left side ` + + ` (margin: ${leftMargin})` + ); + + await hideTooltip(arrowTooltip); + arrowTooltip.destroy(); +} diff --git a/devtools/client/shared/test/browser_html_tooltip_screen_edge.js b/devtools/client/shared/test/browser_html_tooltip_screen_edge.js new file mode 100644 index 0000000000..e27c1e808b --- /dev/null +++ b/devtools/client/shared/test/browser_html_tooltip_screen_edge.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_html_tooltip.js */ + +"use strict"; + +/** + * Test the HTMLTooltip "doorhanger" when the anchor is near the edge of the + * screen and uses a XUL wrapper. The XUL panel cannot be displayed off screen + * at all so this verifies that the calculated position of the tooltip always + * ensure that the whole tooltip is rendered on the screen + * + * See Bug 1590408 + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip_doorhanger-01.xhtml"; + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); +loadHelperScript("helper_html_tooltip.js"); + +add_task(async function () { + // Force the toolbox to be 200px high; + await pushPref("devtools.toolbox.footer.height", 200); + + const { win, doc } = await createHost("bottom", TEST_URI); + + const originalTop = win.screenTop; + const originalLeft = win.screenLeft; + const screenWidth = win.screen.width; + await moveWindowTo(win, screenWidth - win.outerWidth, originalTop); + + registerCleanupFunction(async () => { + info(`Restore original window position. ${originalLeft}, ${originalTop}`); + await moveWindowTo(win, originalLeft, originalTop); + }); + + info("Create a doorhanger HTML tooltip with XULPanel"); + const tooltip = new HTMLTooltip(doc, { + type: "doorhanger", + useXulWrapper: true, + }); + const div = doc.createElementNS(HTML_NS, "div"); + div.style.width = "200px"; + div.style.height = "35px"; + tooltip.panel.appendChild(div); + + const anchor = doc.querySelector("#anchor5"); + + info("Display the tooltip on an anchor."); + await showTooltip(tooltip, anchor); + + const arrow = tooltip.arrow; + ok(arrow, "Tooltip has an arrow"); + + const panelBounds = tooltip.panel + .getBoxQuads({ relativeTo: doc })[0] + .getBounds(); + + const anchorBounds = anchor.getBoxQuads({ relativeTo: doc })[0].getBounds(); + ok( + anchorBounds.left < panelBounds.right && + panelBounds.left < anchorBounds.right, + `The tooltip panel is over (ie intersects) the anchor horizontally: ` + + `${anchorBounds.left} < ${panelBounds.right} and ` + + `${panelBounds.left} < ${anchorBounds.right}` + ); + + await hideTooltip(tooltip); + + tooltip.destroy(); +}); diff --git a/devtools/client/shared/test/browser_html_tooltip_variable-height.js b/devtools/client/shared/test/browser_html_tooltip_variable-height.js new file mode 100644 index 0000000000..69f6d17aed --- /dev/null +++ b/devtools/client/shared/test/browser_html_tooltip_variable-height.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_html_tooltip.js */ + +"use strict"; + +/** + * Test the HTMLTooltip content can have a variable height. + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip.xhtml"; + +const CONTAINER_HEIGHT = 300; +const CONTAINER_WIDTH = 200; +const TOOLTIP_HEIGHT = 50; + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); +loadHelperScript("helper_html_tooltip.js"); + +add_task(async function () { + // Force the toolbox to be 400px tall => 50px for each box. + await pushPref("devtools.toolbox.footer.height", 400); + + await addTab("about:blank"); + const { doc } = await createHost("bottom", TEST_URI); + + const tooltip = new HTMLTooltip(doc, { useXulWrapper: false }); + info("Set tooltip content 50px tall, but request a container 200px tall"); + const tooltipContent = doc.createElementNS(HTML_NS, "div"); + tooltipContent.style.cssText = + "height: " + TOOLTIP_HEIGHT + "px; background: red;"; + tooltip.panel.appendChild(tooltipContent); + tooltip.setContentSize({ width: CONTAINER_WIDTH, height: Infinity }); + + info("Show the tooltip and check the container and panel height."); + await showTooltip(tooltip, doc.getElementById("box1")); + + const containerRect = tooltip.container.getBoundingClientRect(); + const panelRect = tooltip.panel.getBoundingClientRect(); + is( + containerRect.height, + CONTAINER_HEIGHT, + "Tooltip container has the expected height." + ); + is( + panelRect.height, + TOOLTIP_HEIGHT, + "Tooltip panel has the expected height." + ); + + info("Click below the tooltip panel but in the tooltip filler element."); + let onHidden = once(tooltip, "hidden"); + EventUtils.synthesizeMouse(tooltip.container, 100, 100, {}, doc.defaultView); + await onHidden; + + info("Show the tooltip one more time, and increase the content height"); + await showTooltip(tooltip, doc.getElementById("box1")); + tooltipContent.style.height = 2 * CONTAINER_HEIGHT + "px"; + + info( + "Click at the same coordinates as earlier, this time it should hit the tooltip." + ); + const onPanelClick = once(tooltip.panel, "click"); + EventUtils.synthesizeMouse(tooltip.container, 100, 100, {}, doc.defaultView); + await onPanelClick; + is(tooltip.isVisible(), true, "Tooltip is still visible"); + + info("Click above the tooltip container, the tooltip should be closed."); + onHidden = once(tooltip, "hidden"); + EventUtils.synthesizeMouse(tooltip.container, 100, -10, {}, doc.defaultView); + await onHidden; + + tooltip.destroy(); +}); diff --git a/devtools/client/shared/test/browser_html_tooltip_width-auto.js b/devtools/client/shared/test/browser_html_tooltip_width-auto.js new file mode 100644 index 0000000000..8aaf969d36 --- /dev/null +++ b/devtools/client/shared/test/browser_html_tooltip_width-auto.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_html_tooltip.js */ + +"use strict"; + +/** + * Test the HTMLTooltip content can automatically calculate its width based on content. + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip.xhtml"; + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); +loadHelperScript("helper_html_tooltip.js"); + +let useXulWrapper; + +add_task(async function () { + await addTab("about:blank"); + const { doc } = await createHost("bottom", TEST_URI); + + info("Run tests for a Tooltip without using a XUL panel"); + useXulWrapper = false; + await runTests(doc); + + info("Run tests for a Tooltip with a XUL panel"); + useXulWrapper = true; + await runTests(doc); +}); + +async function runTests(doc) { + const tooltip = new HTMLTooltip(doc, { useXulWrapper }); + info("Create tooltip content width to 150px"); + const tooltipContent = doc.createElementNS(HTML_NS, "div"); + tooltipContent.style.cssText = "height: 100%; width: 150px; background: red;"; + + info("Set tooltip content using width:auto"); + tooltip.panel.appendChild(tooltipContent); + tooltip.setContentSize({ width: "auto", height: 50 }); + + info("Show the tooltip and check the tooltip panel width."); + await showTooltip(tooltip, doc.getElementById("box1")); + + const containerRect = tooltip.container.getBoundingClientRect(); + is(containerRect.width, 150, "Tooltip container has the expected width."); + + await hideTooltip(tooltip); + + tooltip.destroy(); +} diff --git a/devtools/client/shared/test/browser_html_tooltip_xul-wrapper.js b/devtools/client/shared/test/browser_html_tooltip_xul-wrapper.js new file mode 100644 index 0000000000..1eae311d5a --- /dev/null +++ b/devtools/client/shared/test/browser_html_tooltip_xul-wrapper.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_html_tooltip.js */ + +"use strict"; + +/** + * Test the HTMLTooltip can overflow out of the toolbox when using a XUL panel wrapper. + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip-05.xhtml"; + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); +loadHelperScript("helper_html_tooltip.js"); + +// The test toolbox will be 200px tall, the anchors are 50px tall, therefore, the maximum +// tooltip height that could fit in the toolbox is 150px. Setting 160px, the tooltip will +// either have to overflow or to be resized. +const TOOLTIP_HEIGHT = 160; +const TOOLTIP_WIDTH = 200; + +add_task(async function () { + // Force the toolbox to be 200px high; + await pushPref("devtools.toolbox.footer.height", 200); + + const { win, doc } = await createHost("bottom", TEST_URI); + + info("Resize and move the window to have space below."); + const originalWidth = win.outerWidth; + const originalHeight = win.outerHeight; + win.resizeBy(-100, -200); + + const originalTop = win.screenTop; + const originalLeft = win.screenLeft; + await moveWindowTo(win, 100, 100); + + registerCleanupFunction(async () => { + info("Restore original window dimensions and position."); + win.resizeTo(originalWidth, originalHeight); + await moveWindowTo(win, originalLeft, originalTop); + }); + + info("Create HTML tooltip"); + const tooltip = new HTMLTooltip(doc, { useXulWrapper: true }); + const div = doc.createElementNS(HTML_NS, "div"); + div.style.height = "200px"; + div.style.background = "red"; + tooltip.panel.appendChild(div); + tooltip.setContentSize({ width: TOOLTIP_WIDTH, height: TOOLTIP_HEIGHT }); + + const box1 = doc.getElementById("box1"); + + // Above box1: check that the tooltip can overflow onto the content page. + info("Display the tooltip above box1."); + await showTooltip(tooltip, box1, { position: "top" }); + checkTooltip(tooltip, "top", TOOLTIP_HEIGHT); + await hideTooltip(tooltip); + + // Below box1: check that the tooltip can overflow out of the browser window. + info("Display the tooltip below box1."); + await showTooltip(tooltip, box1, { position: "bottom" }); + checkTooltip(tooltip, "bottom", TOOLTIP_HEIGHT); + await hideTooltip(tooltip); + + is(tooltip.isVisible(), false, "Tooltip is not visible"); + + tooltip.destroy(); +}); + +function checkTooltip(tooltip, position, height) { + is(tooltip.position, position, "Actual tooltip position is " + position); + const rect = tooltip.container.getBoundingClientRect(); + is(rect.height, height, "Actual tooltip height is " + height); + // Testing the actual left/top offsets is not relevant here as it is handled by the XUL + // panel. +} diff --git a/devtools/client/shared/test/browser_html_tooltip_zoom.js b/devtools/client/shared/test/browser_html_tooltip_zoom.js new file mode 100644 index 0000000000..f30aa586d3 --- /dev/null +++ b/devtools/client/shared/test/browser_html_tooltip_zoom.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_html_tooltip.js */ + +"use strict"; + +/** + * Test the HTMLTooltip is displayed correct position if content is zoomed in. + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const TEST_URI = CHROME_URL_ROOT + "doc_html_tooltip.xhtml"; + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); + +function getTooltipContent(doc) { + const div = doc.createElementNS(HTML_NS, "div"); + div.style.height = "50px"; + div.style.boxSizing = "border-box"; + div.style.backgroundColor = "red"; + div.textContent = "tooltip"; + return div; +} + +add_task(async function () { + const { host, doc } = await createHost("window", TEST_URI); + + // Creating a window host is not correctly waiting when DevTools run in content frame + // See Bug 1571421. + await wait(1000); + + const zoom = 1.5; + await pushPref("devtools.toolbox.zoomValue", zoom.toString(10)); + + // Change this xul zoom to the x1.5 since this test doesn't use the toolbox preferences. + host.frame.docShell.browsingContext.fullZoom = zoom; + const tooltip = new HTMLTooltip(doc, { useXulWrapper: true }); + + info("Set tooltip content"); + tooltip.panel.appendChild(getTooltipContent(doc)); + tooltip.setContentSize({ width: 100, height: 50 }); + + is(tooltip.isVisible(), false, "Tooltip is not visible"); + + info("Show the tooltip and check the expected events are fired."); + const onShown = tooltip.once("shown"); + tooltip.show(doc.getElementById("box1")); + await onShown; + + const menuRect = doc + .querySelector(".tooltip-xul-wrapper > .tooltip-container") + .getBoxQuads({ relativeTo: doc })[0] + .getBounds(); + const anchorRect = doc + .getElementById("box1") + .getBoxQuads({ relativeTo: doc })[0] + .getBounds(); + const xDelta = Math.abs(menuRect.left - anchorRect.left); + const yDelta = Math.abs(menuRect.top - anchorRect.bottom); + + Assert.less(xDelta, 1, "xDelta: " + xDelta + "."); + Assert.less(yDelta, 1, "yDelta: " + yDelta + "."); + + info("Hide the tooltip and check the expected events are fired."); + + const onPopupHidden = tooltip.once("hidden"); + tooltip.hide(); + await onPopupHidden; + + tooltip.destroy(); + await host.destroy(); +}); diff --git a/devtools/client/shared/test/browser_inplace-editor-01.js b/devtools/client/shared/test/browser_inplace-editor-01.js new file mode 100644 index 0000000000..b919fca946 --- /dev/null +++ b/devtools/client/shared/test/browser_inplace-editor-01.js @@ -0,0 +1,202 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_inplace_editor.js */ + +"use strict"; + +loadHelperScript("helper_inplace_editor.js"); + +// Test the inplace-editor behavior. + +add_task(async function () { + await addTab("data:text/html;charset=utf-8,inline editor tests"); + const { host, doc } = await createHost(); + + await testMultipleInitialization(doc); + await testReturnCommit(doc); + await testBlurCommit(doc); + await testAdvanceCharCommit(doc); + await testAdvanceCharsFunction(doc); + await testEscapeCancel(doc); + await testInputAriaLabel(doc); + + host.destroy(); + gBrowser.removeCurrentTab(); +}); + +function testMultipleInitialization(doc) { + doc.body.innerHTML = ""; + const options = {}; + const span = (options.element = createSpan(doc)); + + info("Creating multiple inplace-editor fields"); + editableField(options); + editableField(options); + + info("Clicking on the inplace-editor field to turn to edit mode"); + span.click(); + + is(span.style.display, "none", "The original is hidden"); + is(doc.querySelectorAll("input").length, 1, "Only one "); + is( + doc.querySelectorAll("span").length, + 2, + "Correct number of elements" + ); + is( + doc.querySelectorAll("span.autosizer").length, + 1, + "There is an autosizer element" + ); +} + +function testReturnCommit(doc) { + info("Testing that pressing return commits the new value"); + return new Promise(resolve => { + createInplaceEditorAndClick( + { + initial: "explicit initial", + start(editor) { + is( + editor.input.value, + "explicit initial", + "Explicit initial value should be used." + ); + editor.input.value = "Test Value"; + EventUtils.sendKey("return"); + }, + done: onDone("Test Value", true, resolve), + }, + doc + ); + }); +} + +function testBlurCommit(doc) { + info("Testing that bluring the field commits the new value"); + return new Promise(resolve => { + createInplaceEditorAndClick( + { + start(editor) { + is(editor.input.value, "Edit Me!", "textContent of the span used."); + editor.input.value = "Test Value"; + editor.input.blur(); + }, + done: onDone("Test Value", true, resolve), + }, + doc, + "Edit Me!" + ); + }); +} + +function testAdvanceCharCommit(doc) { + info("Testing that configured advanceChars commit the new value"); + return new Promise(resolve => { + createInplaceEditorAndClick( + { + advanceChars: ":", + start(editor) { + EventUtils.sendString("Test:"); + }, + done: onDone("Test", true, resolve), + }, + doc + ); + }); +} + +function testAdvanceCharsFunction(doc) { + info("Testing advanceChars as a function"); + return new Promise(resolve => { + let firstTime = true; + + createInplaceEditorAndClick( + { + initial: "", + advanceChars(charCode, text, insertionPoint) { + if (charCode !== KeyboardEvent.DOM_VK_COLON) { + return false; + } + if (firstTime) { + firstTime = false; + return false; + } + + // Just to make sure we check it somehow. + return !!text.length; + }, + start(editor) { + for (const ch of ":Test:") { + EventUtils.sendChar(ch); + } + }, + done: onDone(":Test", true, resolve), + }, + doc + ); + }); +} + +function testEscapeCancel(doc) { + info("Testing that escape cancels the new value"); + return new Promise(resolve => { + createInplaceEditorAndClick( + { + initial: "initial text", + start(editor) { + editor.input.value = "Test Value"; + EventUtils.sendKey("escape"); + }, + done: onDone("initial text", false, resolve), + }, + doc + ); + }); +} + +function testInputAriaLabel(doc) { + info("Testing that inputAriaLabel works as expected"); + doc.body.innerHTML = ""; + + let element = createSpan(doc); + editableField({ + element, + inputAriaLabel: "TEST_ARIA_LABEL", + }); + + info("Clicking on the inplace-editor field to turn to edit mode"); + element.click(); + let input = doc.querySelector("input"); + is( + input.getAttribute("aria-label"), + "TEST_ARIA_LABEL", + "Input has expected aria-label" + ); + + info("Testing that inputAriaLabelledBy works as expected"); + doc.body.innerHTML = ""; + element = createSpan(doc); + editableField({ + element, + inputAriaLabelledBy: "TEST_ARIA_LABELLED_BY", + }); + + info("Clicking on the inplace-editor field to turn to edit mode"); + element.click(); + input = doc.querySelector("input"); + is( + input.getAttribute("aria-labelledby"), + "TEST_ARIA_LABELLED_BY", + "Input has expected aria-labelledby" + ); +} + +function onDone(value, isCommit, resolve) { + return function (actualValue, actualCommit) { + info("Inplace-editor's done callback executed, checking its state"); + is(actualValue, value, "The value is correct"); + is(actualCommit, isCommit, "The commit boolean is correct"); + resolve(); + }; +} diff --git a/devtools/client/shared/test/browser_inplace-editor-02.js b/devtools/client/shared/test/browser_inplace-editor-02.js new file mode 100644 index 0000000000..9ecb0bca02 --- /dev/null +++ b/devtools/client/shared/test/browser_inplace-editor-02.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_inplace_editor.js */ + +"use strict"; + +loadHelperScript("helper_inplace_editor.js"); + +// Test that the trimOutput option for the inplace editor works correctly. + +add_task(async function () { + await addTab("data:text/html;charset=utf-8,inline editor tests"); + const { host, doc } = await createHost(); + + await testNonTrimmed(doc); + await testTrimmed(doc); + + host.destroy(); + gBrowser.removeCurrentTab(); +}); + +function testNonTrimmed(doc) { + info("Testing the trimOutput=false option"); + return new Promise(resolve => { + const initial = "\nMultiple\nLines\n"; + const changed = " \nMultiple\nLines\n with more whitespace "; + createInplaceEditorAndClick( + { + trimOutput: false, + multiline: true, + initial, + start(editor) { + is( + editor.input.value, + initial, + "Explicit initial value should be used." + ); + editor.input.value = changed; + EventUtils.sendKey("return"); + }, + done: onDone(changed, true, resolve), + }, + doc + ); + }); +} + +function testTrimmed(doc) { + info("Testing the trimOutput=true option (default value)"); + return new Promise(resolve => { + const initial = "\nMultiple\nLines\n"; + const changed = " \nMultiple\nLines\n with more whitespace "; + createInplaceEditorAndClick( + { + initial, + multiline: true, + start(editor) { + is( + editor.input.value, + initial, + "Explicit initial value should be used." + ); + editor.input.value = changed; + EventUtils.sendKey("return"); + }, + done: onDone(changed.trim(), true, resolve), + }, + doc + ); + }); +} + +function onDone(value, isCommit, resolve) { + return function (actualValue, actualCommit) { + info("Inplace-editor's done callback executed, checking its state"); + is(actualValue, value, "The value is correct"); + is(actualCommit, isCommit, "The commit boolean is correct"); + resolve(); + }; +} diff --git a/devtools/client/shared/test/browser_inplace-editor_autoclose_parentheses.js b/devtools/client/shared/test/browser_inplace-editor_autoclose_parentheses.js new file mode 100644 index 0000000000..79f4a2d14a --- /dev/null +++ b/devtools/client/shared/test/browser_inplace-editor_autoclose_parentheses.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_inplace_editor.js */ + +"use strict"; + +const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js"); +const { + InplaceEditor, +} = require("resource://devtools/client/shared/inplace-editor.js"); +loadHelperScript("helper_inplace_editor.js"); + +// Test the inplace-editor closes parentheses automatically. + +// format : +// [ +// what key to press, +// expected input box value after keypress, +// selected suggestion index (-1 if popup is hidden), +// number of suggestions in the popup (0 if popup is hidden), +// ] +const testData = [ + ["u", "u", -1, 0], + ["r", "ur", -1, 0], + ["l", "url", -1, 0], + ["(", "url()", -1, 0], + ["v", "url(v)", -1, 0], + ["a", "url(va)", -1, 0], + ["r", "url(var)", -1, 0], + ["(", "url(var())", -1, 0], + ["-", "url(var(-))", -1, 0], + ["-", "url(var(--))", -1, 0], + ["a", "url(var(--a))", -1, 0], + [")", "url(var(--a))", -1, 0], + [")", "url(var(--a))", -1, 0], +]; + +add_task(async function () { + await addTab( + "data:text/html;charset=utf-8," + "inplace editor parentheses autoclose" + ); + const { host, doc } = await createHost(); + + const popup = new AutocompletePopup(doc, { autoSelect: true }); + await new Promise(resolve => { + createInplaceEditorAndClick( + { + start: runPropertyAutocompletionTest, + contentType: InplaceEditor.CONTENT_TYPES.CSS_VALUE, + property: { + name: "background-image", + }, + cssProperties: { + // No need to test autocompletion here, return an empty array. + getNames: () => [], + getValues: () => [], + }, + cssVariables: new Map(), + done: resolve, + popup, + }, + doc + ); + }); + + popup.destroy(); + host.destroy(); + gBrowser.removeCurrentTab(); +}); + +const runPropertyAutocompletionTest = async function (editor) { + info("Starting to test for css property completion"); + for (const data of testData) { + await testCompletion(data, editor); + } + EventUtils.synthesizeKey("VK_RETURN", {}, editor.input.defaultView); +}; diff --git a/devtools/client/shared/test/browser_inplace-editor_autocomplete_01.js b/devtools/client/shared/test/browser_inplace-editor_autocomplete_01.js new file mode 100644 index 0000000000..1ed10778c3 --- /dev/null +++ b/devtools/client/shared/test/browser_inplace-editor_autocomplete_01.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_inplace_editor.js */ + +"use strict"; + +const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js"); +const { + InplaceEditor, +} = require("resource://devtools/client/shared/inplace-editor.js"); +loadHelperScript("helper_inplace_editor.js"); + +// Test the inplace-editor autocomplete popup for CSS properties suggestions. +// Using a mocked list of CSS properties to avoid test failures linked to +// engine changes (new property, removed property, ...). + +// format : +// [ +// what key to press, +// expected input box value after keypress, +// selected suggestion index (-1 if popup is hidden), +// number of suggestions in the popup (0 if popup is hidden), +// ] +const testData = [ + ["b", "border", 1, 3], + ["VK_DOWN", "box-sizing", 2, 3], + ["VK_DOWN", "background", 0, 3], + ["VK_DOWN", "border", 1, 3], + ["VK_BACK_SPACE", "b", -1, 0], + ["VK_BACK_SPACE", "", -1, 0], + ["VK_DOWN", "background", 0, 6], + ["VK_LEFT", "background", -1, 0], +]; + +const mockValues = { + background: [], + border: [], + "box-sizing": [], + color: [], + display: [], + visibility: [], +}; + +add_task(async function () { + await addTab( + "data:text/html;charset=utf-8," + "inplace editor CSS property autocomplete" + ); + const { host, doc } = await createHost(); + + const popup = new AutocompletePopup(doc, { autoSelect: true }); + await new Promise(resolve => { + createInplaceEditorAndClick( + { + start: runPropertyAutocompletionTest, + contentType: InplaceEditor.CONTENT_TYPES.CSS_PROPERTY, + done: resolve, + popup, + cssProperties: { + getNames: () => Object.keys(mockValues), + getValues: propertyName => mockValues[propertyName] || [], + }, + }, + doc + ); + }); + + popup.destroy(); + host.destroy(); + gBrowser.removeCurrentTab(); +}); + +const runPropertyAutocompletionTest = async function (editor) { + info("Starting to test for css property completion"); + for (const data of testData) { + await testCompletion(data, editor); + } + + EventUtils.synthesizeKey("VK_RETURN", {}, editor.input.defaultView); +}; diff --git a/devtools/client/shared/test/browser_inplace-editor_autocomplete_02.js b/devtools/client/shared/test/browser_inplace-editor_autocomplete_02.js new file mode 100644 index 0000000000..44fbffc207 --- /dev/null +++ b/devtools/client/shared/test/browser_inplace-editor_autocomplete_02.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_inplace_editor.js */ + +"use strict"; + +const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js"); +const { + InplaceEditor, +} = require("resource://devtools/client/shared/inplace-editor.js"); +loadHelperScript("helper_inplace_editor.js"); + +// Test the inplace-editor autocomplete popup for CSS values suggestions. +// Using a mocked list of CSS properties to avoid test failures linked to +// engine changes (new property, removed property, ...). + +// format : +// [ +// what key to press, +// expected input box value after keypress, +// selected suggestion index (-1 if popup is hidden), +// number of suggestions in the popup (0 if popup is hidden), +// ] +const testData = [ + ["b", "block", -1, 0], + ["VK_BACK_SPACE", "b", -1, 0], + ["VK_BACK_SPACE", "", -1, 0], + ["i", "inline", 0, 2], + ["VK_DOWN", "inline-block", 1, 2], + ["VK_DOWN", "inline", 0, 2], + ["VK_LEFT", "inline", -1, 0], +]; + +const mockValues = { + display: ["block", "flex", "inline", "inline-block", "none"], +}; + +add_task(async function () { + await addTab( + "data:text/html;charset=utf-8," + "inplace editor CSS value autocomplete" + ); + const { host, win, doc } = await createHost(); + + const xulDocument = win.top.document; + const popup = new AutocompletePopup(xulDocument, { autoSelect: true }); + + await new Promise(resolve => { + createInplaceEditorAndClick( + { + start: runAutocompletionTest, + contentType: InplaceEditor.CONTENT_TYPES.CSS_VALUE, + property: { + name: "display", + }, + cssProperties: { + getNames: () => Object.keys(mockValues), + getValues: propertyName => mockValues[propertyName] || [], + }, + done: resolve, + popup, + }, + doc + ); + }); + + popup.destroy(); + host.destroy(); + gBrowser.removeCurrentTab(); +}); + +const runAutocompletionTest = async function (editor) { + info("Starting to test for css property completion"); + for (const data of testData) { + await testCompletion(data, editor); + } + + EventUtils.synthesizeKey("VK_RETURN", {}, editor.input.defaultView); +}; diff --git a/devtools/client/shared/test/browser_inplace-editor_autocomplete_css_variable.js b/devtools/client/shared/test/browser_inplace-editor_autocomplete_css_variable.js new file mode 100644 index 0000000000..8a27778670 --- /dev/null +++ b/devtools/client/shared/test/browser_inplace-editor_autocomplete_css_variable.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_inplace_editor.js */ + +"use strict"; + +const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js"); +const { + InplaceEditor, +} = require("resource://devtools/client/shared/inplace-editor.js"); +loadHelperScript("helper_inplace_editor.js"); + +// Test the inplace-editor autocomplete popup for variable suggestions. +// Using a mocked list of CSS variables to avoid test failures linked to +// engine changes (new property, removed property, ...). +// Also using a mocked list of CSS properties to avoid autocompletion when +// typing in "var" + +// Used for representing the expectation of a visible color swatch +const COLORSWATCH = true; +// format : +// [ +// what key to press, +// expected input box value after keypress, +// selected suggestion index (-1 if popup is hidden), +// number of suggestions in the popup (0 if popup is hidden), +// expected post label corresponding with the input box value, +// boolean representing if there should be a colour swatch visible, +// ] +const testData = [ + ["v", "v", -1, 0, null, !COLORSWATCH], + ["a", "va", -1, 0, null, !COLORSWATCH], + ["r", "var", -1, 0, null, !COLORSWATCH], + ["(", "var()", -1, 0, null, !COLORSWATCH], + ["-", "var(--abc)", 0, 9, "inherit", !COLORSWATCH], + ["VK_BACK_SPACE", "var(-)", -1, 0, null, !COLORSWATCH], + ["-", "var(--abc)", 0, 9, "inherit", !COLORSWATCH], + ["VK_DOWN", "var(--def)", 1, 9, "transparent", !COLORSWATCH], + ["VK_DOWN", "var(--ghi)", 2, 9, "#00FF00", COLORSWATCH], + ["VK_DOWN", "var(--jkl)", 3, 9, "rgb(255, 0, 0)", COLORSWATCH], + ["VK_DOWN", "var(--mno)", 4, 9, "hsl(120, 60%, 70%)", COLORSWATCH], + ["VK_DOWN", "var(--pqr)", 5, 9, "BlueViolet", COLORSWATCH], + ["VK_DOWN", "var(--stu)", 6, 9, "15px", !COLORSWATCH], + ["VK_DOWN", "var(--vwx)", 7, 9, "rgba(255, 0, 0, 0.4)", COLORSWATCH], + ["VK_DOWN", "var(--yz)", 8, 9, "hsla(120, 60%, 70%, 0.3)", COLORSWATCH], + ["VK_DOWN", "var(--abc)", 0, 9, "inherit", !COLORSWATCH], + ["VK_DOWN", "var(--def)", 1, 9, "transparent", !COLORSWATCH], + ["VK_DOWN", "var(--ghi)", 2, 9, "#00FF00", COLORSWATCH], + ["VK_LEFT", "var(--ghi)", -1, 0, null, !COLORSWATCH], +]; + +const CSS_VARIABLES = [ + ["--abc", "inherit"], + ["--def", "transparent"], + ["--ghi", "#00FF00"], + ["--jkl", "rgb(255, 0, 0)"], + ["--mno", "hsl(120, 60%, 70%)"], + ["--pqr", "BlueViolet"], + ["--stu", "15px"], + ["--vwx", "rgba(255, 0, 0, 0.4)"], + ["--yz", "hsla(120, 60%, 70%, 0.3)"], +]; + +add_task(async function () { + await addTab( + "data:text/html;charset=utf-8,inplace editor CSS variable autocomplete" + ); + const { host, doc } = await createHost(); + + const popup = new AutocompletePopup(doc, { autoSelect: true }); + + await new Promise(resolve => { + createInplaceEditorAndClick( + { + start: runAutocompletionTest, + contentType: InplaceEditor.CONTENT_TYPES.CSS_VALUE, + property: { + name: "color", + }, + cssProperties: { + getNames: () => [], + getValues: () => [], + }, + cssVariables: new Map(CSS_VARIABLES), + done: resolve, + popup, + }, + doc + ); + }); + + popup.destroy(); + host.destroy(); + gBrowser.removeCurrentTab(); +}); + +const runAutocompletionTest = async function (editor) { + info("Starting to test for css variable completion"); + for (const data of testData) { + await testCompletion(data, editor); + } + + EventUtils.synthesizeKey("VK_RETURN", {}, editor.input.defaultView); +}; diff --git a/devtools/client/shared/test/browser_inplace-editor_autocomplete_offset.js b/devtools/client/shared/test/browser_inplace-editor_autocomplete_offset.js new file mode 100644 index 0000000000..7272ad7091 --- /dev/null +++ b/devtools/client/shared/test/browser_inplace-editor_autocomplete_offset.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_inplace_editor.js */ + +"use strict"; + +const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js"); +const { + InplaceEditor, +} = require("resource://devtools/client/shared/inplace-editor.js"); +loadHelperScript("helper_inplace_editor.js"); + +const TEST_URI = + CHROME_URL_ROOT + "doc_inplace-editor_autocomplete_offset.xhtml"; + +// Test the inplace-editor autocomplete popup is aligned with the completed query. +// Which means when completing "style=display:flex; color:" the popup will aim to be +// aligned with the ":" next to "color". + +// format : +// [ +// what key to press, +// expected input box value after keypress, +// selected suggestion index (-1 if popup is hidden), +// number of suggestions in the popup (0 if popup is hidden), +// ] +// or +// ["checkPopupOffset"] +// to measure and test the autocomplete popup left offset. +const testData = [ + ["VK_RIGHT", "style=", -1, 0], + ["d", "style=display", 1, 2], + ["checkPopupOffset"], + ["VK_RIGHT", "style=display", -1, 0], + [":", "style=display:block", 0, 3], + ["checkPopupOffset"], + ["f", "style=display:flex", -1, 0], + ["VK_RIGHT", "style=display:flex", -1, 0], + [";", "style=display:flex;", -1, 0], + ["c", "style=display:flex;color", 1, 2], + ["checkPopupOffset"], + ["VK_RIGHT", "style=display:flex;color", -1, 0], + [":", "style=display:flex;color:blue", 0, 2], + ["checkPopupOffset"], +]; + +const mockValues = { + clear: [], + color: ["blue", "red"], + direction: [], + display: ["block", "flex", "none"], +}; + +add_task(async function () { + await addTab( + "data:text/html;charset=utf-8,inplace editor CSS value autocomplete" + ); + const { host, doc } = await createHost("bottom", TEST_URI); + + const popup = new AutocompletePopup(doc, { autoSelect: true }); + + info("Create a CSS_MIXED type autocomplete"); + await new Promise(resolve => { + createInplaceEditorAndClick( + { + initial: "style=", + start: runAutocompletionTest, + contentType: InplaceEditor.CONTENT_TYPES.CSS_MIXED, + done: resolve, + popup, + cssProperties: { + getNames: () => Object.keys(mockValues), + getValues: propertyName => mockValues[propertyName] || [], + }, + }, + doc + ); + }); + + popup.destroy(); + host.destroy(); + gBrowser.removeCurrentTab(); +}); + +const runAutocompletionTest = async function (editor) { + info("Starting autocomplete test for inplace-editor popup offset"); + let previousOffset = -1; + for (const data of testData) { + if (data[0] === "checkPopupOffset") { + info("Check the popup offset has been modified"); + // We are not testing hard coded offset values here, which could be fragile. We only + // want to ensure the popup tries to match the position of the query in the editor + // input. + const offset = getPopupOffset(editor); + Assert.greater( + offset, + previousOffset, + "New popup offset is greater than the previous one" + ); + previousOffset = offset; + } else { + await testCompletion(data, editor); + } + } + + EventUtils.synthesizeKey("VK_RETURN", {}, editor.input.defaultView); +}; + +/** + * Get the autocomplete panel left offset, relative to the provided input's left offset. + */ +function getPopupOffset({ popup, input }) { + const popupQuads = popup._panel.getBoxQuads({ relativeTo: input }); + return popupQuads[0].getBounds().left; +} diff --git a/devtools/client/shared/test/browser_inplace-editor_focus_closest_editor.js b/devtools/client/shared/test/browser_inplace-editor_focus_closest_editor.js new file mode 100644 index 0000000000..5d721410ac --- /dev/null +++ b/devtools/client/shared/test/browser_inplace-editor_focus_closest_editor.js @@ -0,0 +1,180 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* import-globals-from helper_inplace_editor.js */ + +"use strict"; + +loadHelperScript("helper_inplace_editor.js"); + +// Test the inplace-editor behavior with focusEditableFieldAfterApply +// and focusEditableFieldContainerSelector options + +add_task(async function () { + await addTab( + "data:text/html;charset=utf-8,inline editor focusEditableFieldAfterApply" + ); + const { host, doc } = await createHost(); + + testFocusNavigationWithMultipleEditor(doc); + testFocusNavigationWithNonMatchingFocusEditableFieldContainerSelector(doc); + testMissingFocusEditableFieldContainerSelector(doc); + + host.destroy(); + gBrowser.removeCurrentTab(); +}); + +function testFocusNavigationWithMultipleEditor(doc) { + // For some reason + + + + + + diff --git a/devtools/client/shared/test/doc_event-listeners-03.html b/devtools/client/shared/test/doc_event-listeners-03.html new file mode 100644 index 0000000000..2660f9f141 --- /dev/null +++ b/devtools/client/shared/test/doc_event-listeners-03.html @@ -0,0 +1,65 @@ + + + + + + Bound event listeners test page + + + + + + + + + + + + diff --git a/devtools/client/shared/test/doc_filter-editor-01.html b/devtools/client/shared/test/doc_filter-editor-01.html new file mode 100644 index 0000000000..4f4ad23116 --- /dev/null +++ b/devtools/client/shared/test/doc_filter-editor-01.html @@ -0,0 +1 @@ +
diff --git a/devtools/client/shared/test/doc_html_tooltip-02.xhtml b/devtools/client/shared/test/doc_html_tooltip-02.xhtml new file mode 100644 index 0000000000..286dc01fdd --- /dev/null +++ b/devtools/client/shared/test/doc_html_tooltip-02.xhtml @@ -0,0 +1,15 @@ + + + + + + test1 + test2 + test3 + test4 + + + diff --git a/devtools/client/shared/test/doc_html_tooltip-03.xhtml b/devtools/client/shared/test/doc_html_tooltip-03.xhtml new file mode 100644 index 0000000000..4510d6e445 --- /dev/null +++ b/devtools/client/shared/test/doc_html_tooltip-03.xhtml @@ -0,0 +1,19 @@ + + + + + + + + + test2 + + + + + + + + diff --git a/devtools/client/shared/test/doc_html_tooltip-04.xhtml b/devtools/client/shared/test/doc_html_tooltip-04.xhtml new file mode 100644 index 0000000000..f74b53ccdd --- /dev/null +++ b/devtools/client/shared/test/doc_html_tooltip-04.xhtml @@ -0,0 +1,15 @@ + + + + + + spacer + test1 + test2 + MIDDLE + test3 + test4 + spacer + + diff --git a/devtools/client/shared/test/doc_html_tooltip-05.xhtml b/devtools/client/shared/test/doc_html_tooltip-05.xhtml new file mode 100644 index 0000000000..0878522968 --- /dev/null +++ b/devtools/client/shared/test/doc_html_tooltip-05.xhtml @@ -0,0 +1,12 @@ + + + + + + test1 + test2 + test3 + test4 + + diff --git a/devtools/client/shared/test/doc_html_tooltip.xhtml b/devtools/client/shared/test/doc_html_tooltip.xhtml new file mode 100644 index 0000000000..329164acd9 --- /dev/null +++ b/devtools/client/shared/test/doc_html_tooltip.xhtml @@ -0,0 +1,12 @@ + + + + + + test1 + test2 + test3 + test4 + + diff --git a/devtools/client/shared/test/doc_html_tooltip_arrow-01.xhtml b/devtools/client/shared/test/doc_html_tooltip_arrow-01.xhtml new file mode 100644 index 0000000000..f16ac1d67d --- /dev/null +++ b/devtools/client/shared/test/doc_html_tooltip_arrow-01.xhtml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/devtools/client/shared/test/doc_html_tooltip_arrow-02.xhtml b/devtools/client/shared/test/doc_html_tooltip_arrow-02.xhtml new file mode 100644 index 0000000000..a468177bab --- /dev/null +++ b/devtools/client/shared/test/doc_html_tooltip_arrow-02.xhtml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/devtools/client/shared/test/doc_html_tooltip_doorhanger-01.xhtml b/devtools/client/shared/test/doc_html_tooltip_doorhanger-01.xhtml new file mode 100644 index 0000000000..8bdbf3d2b8 --- /dev/null +++ b/devtools/client/shared/test/doc_html_tooltip_doorhanger-01.xhtml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/devtools/client/shared/test/doc_html_tooltip_doorhanger-02.xhtml b/devtools/client/shared/test/doc_html_tooltip_doorhanger-02.xhtml new file mode 100644 index 0000000000..d76fe6775c --- /dev/null +++ b/devtools/client/shared/test/doc_html_tooltip_doorhanger-02.xhtml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/devtools/client/shared/test/doc_html_tooltip_hover.xhtml b/devtools/client/shared/test/doc_html_tooltip_hover.xhtml new file mode 100644 index 0000000000..27417aee8c --- /dev/null +++ b/devtools/client/shared/test/doc_html_tooltip_hover.xhtml @@ -0,0 +1,13 @@ + + + + + + + + + + + + diff --git a/devtools/client/shared/test/doc_html_tooltip_rtl.xhtml b/devtools/client/shared/test/doc_html_tooltip_rtl.xhtml new file mode 100644 index 0000000000..bd8818057a --- /dev/null +++ b/devtools/client/shared/test/doc_html_tooltip_rtl.xhtml @@ -0,0 +1,14 @@ + + + + + + test1 + test2 + test3 + test4 + + diff --git a/devtools/client/shared/test/doc_inplace-editor_autocomplete_offset.xhtml b/devtools/client/shared/test/doc_inplace-editor_autocomplete_offset.xhtml new file mode 100644 index 0000000000..da0ab8bc3a --- /dev/null +++ b/devtools/client/shared/test/doc_inplace-editor_autocomplete_offset.xhtml @@ -0,0 +1,7 @@ + + + + + + diff --git a/devtools/client/shared/test/doc_layoutHelpers.html b/devtools/client/shared/test/doc_layoutHelpers.html new file mode 100644 index 0000000000..ba31753b09 --- /dev/null +++ b/devtools/client/shared/test/doc_layoutHelpers.html @@ -0,0 +1,31 @@ + + + Layout Helpers + + + +
+
diff --git a/devtools/client/shared/test/doc_layoutHelpers_getBoxQuads1.html b/devtools/client/shared/test/doc_layoutHelpers_getBoxQuads1.html new file mode 100644 index 0000000000..11c789c508 --- /dev/null +++ b/devtools/client/shared/test/doc_layoutHelpers_getBoxQuads1.html @@ -0,0 +1,65 @@ + + +Layout Helpers + +
+
+ + +
+
+
+
+
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus porttitor luctus sem id scelerisque. Cras quis velit sed risus euismod lacinia. Donec viverra enim eu ligula efficitur, quis vulputate metus cursus. Duis sed interdum risus. Ut blandit velit vitae faucibus efficitur. Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Sed vitae dolor metus. Aliquam sed velit sit amet libero vestibulum aliquam vel a lorem. Integer eget ex eget justo auctor ullamcorper.
+Praesent tristique maximus lacus, nec ultricies neque ultrices non. Phasellus vel lobortis justo.
diff --git a/devtools/client/shared/test/doc_layoutHelpers_getBoxQuads2-a.html b/devtools/client/shared/test/doc_layoutHelpers_getBoxQuads2-a.html new file mode 100644 index 0000000000..9e52ebd7de --- /dev/null +++ b/devtools/client/shared/test/doc_layoutHelpers_getBoxQuads2-a.html @@ -0,0 +1,20 @@ + + + + +
+ +
+ +
+ diff --git a/devtools/client/shared/test/doc_layoutHelpers_getBoxQuads2-b-and-d.html b/devtools/client/shared/test/doc_layoutHelpers_getBoxQuads2-b-and-d.html new file mode 100644 index 0000000000..10d91e7b3d --- /dev/null +++ b/devtools/client/shared/test/doc_layoutHelpers_getBoxQuads2-b-and-d.html @@ -0,0 +1,29 @@ + + + + + diff --git a/devtools/client/shared/test/doc_layoutHelpers_getBoxQuads2-c-and-e.html b/devtools/client/shared/test/doc_layoutHelpers_getBoxQuads2-c-and-e.html new file mode 100644 index 0000000000..7917c82411 --- /dev/null +++ b/devtools/client/shared/test/doc_layoutHelpers_getBoxQuads2-c-and-e.html @@ -0,0 +1,27 @@ + + + + +
diff --git a/devtools/client/shared/test/doc_listworkers-tab.html b/devtools/client/shared/test/doc_listworkers-tab.html new file mode 100644 index 0000000000..62ab9be7d2 --- /dev/null +++ b/devtools/client/shared/test/doc_listworkers-tab.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/devtools/client/shared/test/doc_native-event-handler.html b/devtools/client/shared/test/doc_native-event-handler.html new file mode 100644 index 0000000000..f08d7c01f1 --- /dev/null +++ b/devtools/client/shared/test/doc_native-event-handler.html @@ -0,0 +1,25 @@ + + + + + + A video element with native event handlers + + + + + + + + diff --git a/devtools/client/shared/test/doc_script-switching-01.html b/devtools/client/shared/test/doc_script-switching-01.html new file mode 100644 index 0000000000..afb4484b5d --- /dev/null +++ b/devtools/client/shared/test/doc_script-switching-01.html @@ -0,0 +1,18 @@ + + + + + + + Debugger test page + + + + + + + + + + diff --git a/devtools/client/shared/test/doc_script-switching-02.html b/devtools/client/shared/test/doc_script-switching-02.html new file mode 100644 index 0000000000..cceeea2c8e --- /dev/null +++ b/devtools/client/shared/test/doc_script-switching-02.html @@ -0,0 +1,18 @@ + + + + + + + Debugger test page + + + + + + + + + + diff --git a/devtools/client/shared/test/doc_spectrum.html b/devtools/client/shared/test/doc_spectrum.html new file mode 100644 index 0000000000..07e8f37ecc --- /dev/null +++ b/devtools/client/shared/test/doc_spectrum.html @@ -0,0 +1,2 @@ + +
diff --git a/devtools/client/shared/test/doc_tableWidget_basic.html b/devtools/client/shared/test/doc_tableWidget_basic.html new file mode 100644 index 0000000000..c3afa2d88f --- /dev/null +++ b/devtools/client/shared/test/doc_tableWidget_basic.html @@ -0,0 +1,7 @@ + + + + + + diff --git a/devtools/client/shared/test/doc_tableWidget_keyboard_interaction.xhtml b/devtools/client/shared/test/doc_tableWidget_keyboard_interaction.xhtml new file mode 100644 index 0000000000..40656a7470 --- /dev/null +++ b/devtools/client/shared/test/doc_tableWidget_keyboard_interaction.xhtml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/devtools/client/shared/test/doc_tableWidget_mouse_interaction.xhtml b/devtools/client/shared/test/doc_tableWidget_mouse_interaction.xhtml new file mode 100644 index 0000000000..2a23170230 --- /dev/null +++ b/devtools/client/shared/test/doc_tableWidget_mouse_interaction.xhtml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/devtools/client/shared/test/doc_templater_basic.html b/devtools/client/shared/test/doc_templater_basic.html new file mode 100644 index 0000000000..47b3cd258b --- /dev/null +++ b/devtools/client/shared/test/doc_templater_basic.html @@ -0,0 +1,12 @@ + + + + + + DOM Template Tests + + + + + diff --git a/devtools/client/shared/test/dummy.html b/devtools/client/shared/test/dummy.html new file mode 100644 index 0000000000..18ecdcb795 --- /dev/null +++ b/devtools/client/shared/test/dummy.html @@ -0,0 +1 @@ + diff --git a/devtools/client/shared/test/head.js b/devtools/client/shared/test/head.js new file mode 100644 index 0000000000..47731bc74a --- /dev/null +++ b/devtools/client/shared/test/head.js @@ -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/. */ +/* eslint no-unused-vars: [2, {"vars": "local", "args": "none"}] */ + +"use strict"; + +// shared-head.js handles imports, constants, and utility functions +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +const { DOMHelpers } = require("resource://devtools/shared/dom-helpers.js"); +const { + Hosts, +} = require("resource://devtools/client/framework/toolbox-hosts.js"); + +const TEST_URI_ROOT = "http://example.com/browser/devtools/client/shared/test/"; +const TEST_URI_ROOT_SSL = + "https://example.com/browser/devtools/client/shared/test/"; + +const EXAMPLE_URL = + "chrome://mochitests/content/browser/devtools/client/shared/test/"; + +function catchFail(func) { + return function () { + try { + return func.apply(null, arguments); + } catch (ex) { + ok(false, ex); + console.error(ex); + finish(); + throw ex; + } + }; +} + +/** + * Polls a given function waiting for the given value. + * + * @param object options + * Options object with the following properties: + * - validator + * A validator function that should return the expected value. This is + * called every few milliseconds to check if the result is the expected + * one. When the returned result is the expected one, then the |success| + * function is called and polling stops. If |validator| never returns + * the expected value, then polling timeouts after several tries and + * a failure is recorded - the given |failure| function is invoked. + * - success + * A function called when the validator function returns the expected + * value. + * - failure + * A function called if the validator function timeouts - fails to return + * the expected value in the given time. + * - name + * Name of test. This is used to generate the success and failure + * messages. + * - timeout + * Timeout for validator function, in milliseconds. Default is 5000 ms. + * - value + * The expected value. If this option is omitted then the |validator| + * function must return a trueish value. + * Each of the provided callback functions will receive two arguments: + * the |options| object and the last value returned by |validator|. + */ +function waitForValue(options) { + const start = Date.now(); + const timeout = options.timeout || 5000; + let lastValue; + + function wait(validatorFn, successFn, failureFn) { + if (Date.now() - start > timeout) { + // Log the failure. + ok(false, "Timed out while waiting for: " + options.name); + const expected = + "value" in options ? "'" + options.value + "'" : "a trueish value"; + info("timeout info :: got '" + lastValue + "', expected " + expected); + failureFn(options, lastValue); + return; + } + + lastValue = validatorFn(options, lastValue); + const successful = + "value" in options ? lastValue == options.value : lastValue; + if (successful) { + ok(true, options.name); + successFn(options, lastValue); + } else { + setTimeout(() => { + wait(validatorFn, successFn, failureFn); + }, 100); + } + } + + wait(options.validator, options.success, options.failure); +} + +function oneTimeObserve(name, callback) { + return new Promise(resolve => { + const func = function () { + Services.obs.removeObserver(func, name); + if (callback) { + callback(); + } + resolve(); + }; + Services.obs.addObserver(func, name); + }); +} + +const createHost = async function ( + type = "bottom", + src = CHROME_URL_ROOT + "dummy.html" +) { + const host = new Hosts[type](gBrowser.selectedTab); + const iframe = await host.create(); + + await new Promise(resolve => { + iframe.setAttribute("src", src); + DOMHelpers.onceDOMReady(iframe.contentWindow, resolve); + }); + + // Popup tests fail very frequently on Linux + webrender because they run + // too early. + await waitForPresShell(iframe); + + return { host, win: iframe.contentWindow, doc: iframe.contentDocument }; +}; + +/** + * Open and close the toolbox in the current browser tab, several times, waiting + * some amount of time in between. + * @param {Number} nbOfTimes + * @param {Number} usageTime in milliseconds + * @param {String} toolId + */ +async function openAndCloseToolbox(nbOfTimes, usageTime, toolId) { + for (let i = 0; i < nbOfTimes; i++) { + info("Opening toolbox " + (i + 1)); + + const tab = gBrowser.selectedTab; + const toolbox = await gDevTools.showToolboxForTab(tab, { toolId }); + + // We use a timeout to check the toolbox's active time + await new Promise(resolve => setTimeout(resolve, usageTime)); + + info("Closing toolbox " + (i + 1)); + await toolbox.destroy(); + } +} + +/** + * Waits until a predicate returns true. + * + * @param function predicate + * Invoked once in a while until it returns true. + * @param number interval [optional] + * How often the predicate is invoked, in milliseconds. + */ +function waitUntil(predicate, interval = 10) { + if (predicate()) { + return Promise.resolve(true); + } + return new Promise(resolve => { + setTimeout(function () { + waitUntil(predicate).then(() => resolve(true)); + }, interval); + }); +} + +/** + * Show the presets list sidebar in the cssfilter widget popup + * @param {CSSFilterWidget} widget + * @return {Promise} + */ +function showFilterPopupPresets(widget) { + const onRender = widget.once("render"); + widget._togglePresets(); + return onRender; +} + +/** + * Show presets list and create a sample preset with the name and value provided + * @param {CSSFilterWidget} widget + * @param {string} name + * @param {string} value + * @return {Promise} + */ +const showFilterPopupPresetsAndCreatePreset = async function ( + widget, + name, + value +) { + await showFilterPopupPresets(widget); + + let onRender = widget.once("render"); + widget.setCssValue(value); + await onRender; + + const footer = widget.el.querySelector(".presets-list .footer"); + footer.querySelector("input").value = name; + + onRender = widget.once("render"); + widget._savePreset({ + preventDefault: () => {}, + }); + + await onRender; +}; diff --git a/devtools/client/shared/test/helper_color_data.js b/devtools/client/shared/test/helper_color_data.js new file mode 100644 index 0000000000..6429c95530 --- /dev/null +++ b/devtools/client/shared/test/helper_color_data.js @@ -0,0 +1,1499 @@ +"use strict"; + +// This is used to test color.js in browser_css_color.js + +/* eslint-disable max-len */ +function getFixtureColorData() { + return [ + { + authored: "aliceblue", + name: "aliceblue", + hex: "#f0f8ff", + hsl: "hsl(208, 100%, 97.1%)", + rgb: "rgb(240, 248, 255)", + hwb: "hwb(208 94.1% 0%)", + cycle: 5, + }, + { + authored: "antiquewhite", + name: "antiquewhite", + hex: "#faebd7", + hsl: "hsl(34.3, 77.8%, 91.2%)", + rgb: "rgb(250, 235, 215)", + hwb: "hwb(34.3 84.3% 2%)", + cycle: 5, + }, + { + authored: "aqua", + name: "aqua", + hex: "#0ff", + hsl: "hsl(180, 100%, 50%)", + rgb: "rgb(0, 255, 255)", + hwb: "hwb(180 0% 0%)", + cycle: 5, + }, + { + authored: "aquamarine", + name: "aquamarine", + hex: "#7fffd4", + hsl: "hsl(159.8, 100%, 74.9%)", + rgb: "rgb(127, 255, 212)", + hwb: "hwb(159.8 49.8% 0%)", + cycle: 5, + }, + { + authored: "azure", + name: "azure", + hex: "#f0ffff", + hsl: "hsl(180, 100%, 97.1%)", + rgb: "rgb(240, 255, 255)", + hwb: "hwb(180 94.1% 0%)", + cycle: 5, + }, + { + authored: "beige", + name: "beige", + hex: "#f5f5dc", + hsl: "hsl(60, 55.6%, 91.2%)", + rgb: "rgb(245, 245, 220)", + hwb: "hwb(60 86.3% 3.9%)", + cycle: 5, + }, + { + authored: "bisque", + name: "bisque", + hex: "#ffe4c4", + hsl: "hsl(32.5, 100%, 88.4%)", + rgb: "rgb(255, 228, 196)", + hwb: "hwb(32.5 76.9% 0%)", + cycle: 5, + }, + { + authored: "black", + name: "black", + hex: "#000", + hsl: "hsl(0, 0%, 0%)", + rgb: "rgb(0, 0, 0)", + hwb: "hwb(0 0% 100%)", + cycle: 5, + }, + { + authored: "blanchedalmond", + name: "blanchedalmond", + hex: "#ffebcd", + hsl: "hsl(36, 100%, 90.2%)", + rgb: "rgb(255, 235, 205)", + hwb: "hwb(36 80.4% 0%)", + cycle: 5, + }, + { + authored: "blue", + name: "blue", + hex: "#00f", + hsl: "hsl(240, 100%, 50%)", + rgb: "rgb(0, 0, 255)", + hwb: "hwb(240 0% 0%)", + cycle: 5, + }, + { + authored: "blueviolet", + name: "blueviolet", + hex: "#8a2be2", + hsl: "hsl(271.1, 75.9%, 52.7%)", + rgb: "rgb(138, 43, 226)", + hwb: "hwb(271.1 16.9% 11.4%)", + cycle: 5, + }, + { + authored: "brown", + name: "brown", + hex: "#a52a2a", + hsl: "hsl(0, 59.4%, 40.6%)", + rgb: "rgb(165, 42, 42)", + hwb: "hwb(0 16.5% 35.3%)", + cycle: 5, + }, + { + authored: "burlywood", + name: "burlywood", + hex: "#deb887", + hsl: "hsl(33.8, 56.9%, 70%)", + rgb: "rgb(222, 184, 135)", + hwb: "hwb(33.8 52.9% 12.9%)", + cycle: 5, + }, + { + authored: "cadetblue", + name: "cadetblue", + hex: "#5f9ea0", + hsl: "hsl(181.8, 25.5%, 50%)", + rgb: "rgb(95, 158, 160)", + hwb: "hwb(181.8 37.3% 37.3%)", + cycle: 5, + }, + { + authored: "chartreuse", + name: "chartreuse", + hex: "#7fff00", + hsl: "hsl(90.1, 100%, 50%)", + rgb: "rgb(127, 255, 0)", + hwb: "hwb(90.1 0% 0%)", + cycle: 5, + }, + { + authored: "chocolate", + name: "chocolate", + hex: "#d2691e", + hsl: "hsl(25, 75%, 47.1%)", + rgb: "rgb(210, 105, 30)", + hwb: "hwb(25 11.8% 17.6%)", + cycle: 5, + }, + { + authored: "coral", + name: "coral", + hex: "#ff7f50", + hsl: "hsl(16.1, 100%, 65.7%)", + rgb: "rgb(255, 127, 80)", + hwb: "hwb(16.1 31.4% 0%)", + cycle: 5, + }, + { + authored: "cornflowerblue", + name: "cornflowerblue", + hex: "#6495ed", + hsl: "hsl(218.5, 79.2%, 66.1%)", + rgb: "rgb(100, 149, 237)", + hwb: "hwb(218.5 39.2% 7.1%)", + cycle: 5, + }, + { + authored: "cornsilk", + name: "cornsilk", + hex: "#fff8dc", + hsl: "hsl(48, 100%, 93.1%)", + rgb: "rgb(255, 248, 220)", + hwb: "hwb(48 86.3% 0%)", + cycle: 5, + }, + { + authored: "crimson", + name: "crimson", + hex: "#dc143c", + hsl: "hsl(348, 83.3%, 47.1%)", + rgb: "rgb(220, 20, 60)", + hwb: "hwb(348 7.8% 13.7%)", + cycle: 5, + }, + { + authored: "cyan", + name: "aqua", + hex: "#0ff", + hsl: "hsl(180, 100%, 50%)", + rgb: "rgb(0, 255, 255)", + hwb: "hwb(180 0% 0%)", + cycle: 5, + }, + { + authored: "darkblue", + name: "darkblue", + hex: "#00008b", + hsl: "hsl(240, 100%, 27.3%)", + rgb: "rgb(0, 0, 139)", + hwb: "hwb(240 0% 45.5%)", + cycle: 5, + }, + { + authored: "darkcyan", + name: "darkcyan", + hex: "#008b8b", + hsl: "hsl(180, 100%, 27.3%)", + rgb: "rgb(0, 139, 139)", + hwb: "hwb(180 0% 45.5%)", + cycle: 5, + }, + { + authored: "darkgoldenrod", + name: "darkgoldenrod", + hex: "#b8860b", + hsl: "hsl(42.7, 88.7%, 38.2%)", + rgb: "rgb(184, 134, 11)", + hwb: "hwb(42.7 4.3% 27.8%)", + cycle: 5, + }, + { + authored: "darkgray", + name: "darkgray", + hex: "#a9a9a9", + hsl: "hsl(0, 0%, 66.3%)", + rgb: "rgb(169, 169, 169)", + hwb: "hwb(0 66.3% 33.7%)", + cycle: 5, + }, + { + authored: "darkgreen", + name: "darkgreen", + hex: "#006400", + hsl: "hsl(120, 100%, 19.6%)", + rgb: "rgb(0, 100, 0)", + hwb: "hwb(120 0% 60.8%)", + cycle: 5, + }, + { + authored: "darkgrey", + name: "darkgray", + hex: "#a9a9a9", + hsl: "hsl(0, 0%, 66.3%)", + rgb: "rgb(169, 169, 169)", + hwb: "hwb(0 66.3% 33.7%)", + cycle: 5, + }, + { + authored: "darkkhaki", + name: "darkkhaki", + hex: "#bdb76b", + hsl: "hsl(55.6, 38.3%, 58%)", + rgb: "rgb(189, 183, 107)", + hwb: "hwb(55.6 42% 25.9%)", + cycle: 5, + }, + { + authored: "darkmagenta", + name: "darkmagenta", + hex: "#8b008b", + hsl: "hsl(300, 100%, 27.3%)", + rgb: "rgb(139, 0, 139)", + hwb: "hwb(300 0% 45.5%)", + cycle: 5, + }, + { + authored: "darkolivegreen", + name: "darkolivegreen", + hex: "#556b2f", + hsl: "hsl(82, 39%, 30.2%)", + rgb: "rgb(85, 107, 47)", + hwb: "hwb(82 18.4% 58%)", + cycle: 5, + }, + { + authored: "darkorange", + name: "darkorange", + hex: "#ff8c00", + hsl: "hsl(32.9, 100%, 50%)", + rgb: "rgb(255, 140, 0)", + hwb: "hwb(32.9 0% 0%)", + cycle: 5, + }, + { + authored: "darkorchid", + name: "darkorchid", + hex: "#9932cc", + hsl: "hsl(280.1, 60.6%, 49.8%)", + rgb: "rgb(153, 50, 204)", + hwb: "hwb(280.1 19.6% 20%)", + cycle: 5, + }, + { + authored: "darkred", + name: "darkred", + hex: "#8b0000", + hsl: "hsl(0, 100%, 27.3%)", + rgb: "rgb(139, 0, 0)", + hwb: "hwb(0 0% 45.5%)", + cycle: 5, + }, + { + authored: "darksalmon", + name: "darksalmon", + hex: "#e9967a", + hsl: "hsl(15.1, 71.6%, 69.6%)", + rgb: "rgb(233, 150, 122)", + hwb: "hwb(15.1 47.8% 8.6%)", + cycle: 5, + }, + { + authored: "darkseagreen", + name: "darkseagreen", + hex: "#8fbc8f", + hsl: "hsl(120, 25.1%, 64.9%)", + rgb: "rgb(143, 188, 143)", + hwb: "hwb(120 56.1% 26.3%)", + cycle: 5, + }, + { + authored: "darkslateblue", + name: "darkslateblue", + hex: "#483d8b", + hsl: "hsl(248.5, 39%, 39.2%)", + rgb: "rgb(72, 61, 139)", + hwb: "hwb(248.5 23.9% 45.5%)", + cycle: 5, + }, + { + authored: "darkslategray", + name: "darkslategray", + hex: "#2f4f4f", + hsl: "hsl(180, 25.4%, 24.7%)", + rgb: "rgb(47, 79, 79)", + hwb: "hwb(180 18.4% 69%)", + cycle: 5, + }, + { + authored: "darkslategrey", + name: "darkslategray", + hex: "#2f4f4f", + hsl: "hsl(180, 25.4%, 24.7%)", + rgb: "rgb(47, 79, 79)", + hwb: "hwb(180 18.4% 69%)", + cycle: 5, + }, + { + authored: "darkturquoise", + name: "darkturquoise", + hex: "#00ced1", + hsl: "hsl(180.9, 100%, 41%)", + rgb: "rgb(0, 206, 209)", + hwb: "hwb(180.9 0% 18%)", + cycle: 5, + }, + { + authored: "darkviolet", + name: "darkviolet", + hex: "#9400d3", + hsl: "hsl(282.1, 100%, 41.4%)", + rgb: "rgb(148, 0, 211)", + hwb: "hwb(282.1 0% 17.3%)", + cycle: 5, + }, + { + authored: "deeppink", + name: "deeppink", + hex: "#ff1493", + hsl: "hsl(327.6, 100%, 53.9%)", + rgb: "rgb(255, 20, 147)", + hwb: "hwb(327.6 7.8% 0%)", + cycle: 5, + }, + { + authored: "deepskyblue", + name: "deepskyblue", + hex: "#00bfff", + hsl: "hsl(195.1, 100%, 50%)", + rgb: "rgb(0, 191, 255)", + hwb: "hwb(195.1 0% 0%)", + cycle: 5, + }, + { + authored: "dimgray", + name: "dimgray", + hex: "#696969", + hsl: "hsl(0, 0%, 41.2%)", + rgb: "rgb(105, 105, 105)", + hwb: "hwb(0 41.2% 58.8%)", + cycle: 5, + }, + { + authored: "dodgerblue", + name: "dodgerblue", + hex: "#1e90ff", + hsl: "hsl(209.6, 100%, 55.9%)", + rgb: "rgb(30, 144, 255)", + hwb: "hwb(209.6 11.8% 0%)", + cycle: 5, + }, + { + authored: "firebrick", + name: "firebrick", + hex: "#b22222", + hsl: "hsl(0, 67.9%, 41.6%)", + rgb: "rgb(178, 34, 34)", + hwb: "hwb(0 13.3% 30.2%)", + cycle: 5, + }, + { + authored: "floralwhite", + name: "floralwhite", + hex: "#fffaf0", + hsl: "hsl(40, 100%, 97.1%)", + rgb: "rgb(255, 250, 240)", + hwb: "hwb(40 94.1% 0%)", + cycle: 5, + }, + { + authored: "forestgreen", + name: "forestgreen", + hex: "#228b22", + hsl: "hsl(120, 60.7%, 33.9%)", + rgb: "rgb(34, 139, 34)", + hwb: "hwb(120 13.3% 45.5%)", + cycle: 5, + }, + { + authored: "fuchsia", + name: "fuchsia", + hex: "#f0f", + hsl: "hsl(300, 100%, 50%)", + rgb: "rgb(255, 0, 255)", + hwb: "hwb(300 0% 0%)", + cycle: 5, + }, + { + authored: "gainsboro", + name: "gainsboro", + hex: "#dcdcdc", + hsl: "hsl(0, 0%, 86.3%)", + rgb: "rgb(220, 220, 220)", + hwb: "hwb(0 86.3% 13.7%)", + cycle: 5, + }, + { + authored: "ghostwhite", + name: "ghostwhite", + hex: "#f8f8ff", + hsl: "hsl(240, 100%, 98.6%)", + rgb: "rgb(248, 248, 255)", + hwb: "hwb(240 97.3% 0%)", + cycle: 5, + }, + { + authored: "gold", + name: "gold", + hex: "#ffd700", + hsl: "hsl(50.6, 100%, 50%)", + rgb: "rgb(255, 215, 0)", + hwb: "hwb(50.6 0% 0%)", + cycle: 5, + }, + { + authored: "goldenrod", + name: "goldenrod", + hex: "#daa520", + hsl: "hsl(42.9, 74.4%, 49%)", + rgb: "rgb(218, 165, 32)", + hwb: "hwb(42.9 12.5% 14.5%)", + cycle: 5, + }, + { + authored: "gray", + name: "gray", + hex: "#808080", + hsl: "hsl(0, 0%, 50.2%)", + rgb: "rgb(128, 128, 128)", + hwb: "hwb(0 50.2% 49.8%)", + cycle: 5, + }, + { + authored: "green", + name: "green", + hex: "#008000", + hsl: "hsl(120, 100%, 25.1%)", + rgb: "rgb(0, 128, 0)", + hwb: "hwb(120 0% 49.8%)", + cycle: 5, + }, + { + authored: "greenyellow", + name: "greenyellow", + hex: "#adff2f", + hsl: "hsl(83.7, 100%, 59.2%)", + rgb: "rgb(173, 255, 47)", + hwb: "hwb(83.7 18.4% 0%)", + cycle: 5, + }, + { + authored: "grey", + name: "gray", + hex: "#808080", + hsl: "hsl(0, 0%, 50.2%)", + rgb: "rgb(128, 128, 128)", + hwb: "hwb(0 50.2% 49.8%)", + cycle: 5, + }, + { + authored: "honeydew", + name: "honeydew", + hex: "#f0fff0", + hsl: "hsl(120, 100%, 97.1%)", + rgb: "rgb(240, 255, 240)", + hwb: "hwb(120 94.1% 0%)", + cycle: 5, + }, + { + authored: "hotpink", + name: "hotpink", + hex: "#ff69b4", + hsl: "hsl(330, 100%, 70.6%)", + rgb: "rgb(255, 105, 180)", + hwb: "hwb(330 41.2% 0%)", + cycle: 5, + }, + { + authored: "indianred", + name: "indianred", + hex: "#cd5c5c", + hsl: "hsl(0, 53.1%, 58.2%)", + rgb: "rgb(205, 92, 92)", + hwb: "hwb(0 36.1% 19.6%)", + cycle: 5, + }, + { + authored: "indigo", + name: "indigo", + hex: "#4b0082", + hsl: "hsl(274.6, 100%, 25.5%)", + rgb: "rgb(75, 0, 130)", + hwb: "hwb(274.6 0% 49%)", + cycle: 5, + }, + { + authored: "ivory", + name: "ivory", + hex: "#fffff0", + hsl: "hsl(60, 100%, 97.1%)", + rgb: "rgb(255, 255, 240)", + hwb: "hwb(60 94.1% 0%)", + cycle: 5, + }, + { + authored: "khaki", + name: "khaki", + hex: "#f0e68c", + hsl: "hsl(54, 76.9%, 74.5%)", + rgb: "rgb(240, 230, 140)", + hwb: "hwb(54 54.9% 5.9%)", + cycle: 5, + }, + { + authored: "lavender", + name: "lavender", + hex: "#e6e6fa", + hsl: "hsl(240, 66.7%, 94.1%)", + rgb: "rgb(230, 230, 250)", + hwb: "hwb(240 90.2% 2%)", + cycle: 5, + }, + { + authored: "lavenderblush", + name: "lavenderblush", + hex: "#fff0f5", + hsl: "hsl(340, 100%, 97.1%)", + rgb: "rgb(255, 240, 245)", + hwb: "hwb(340 94.1% 0%)", + cycle: 5, + }, + { + authored: "lawngreen", + name: "lawngreen", + hex: "#7cfc00", + hsl: "hsl(90.5, 100%, 49.4%)", + rgb: "rgb(124, 252, 0)", + hwb: "hwb(90.5 0% 1.2%)", + cycle: 5, + }, + { + authored: "lemonchiffon", + name: "lemonchiffon", + hex: "#fffacd", + hsl: "hsl(54, 100%, 90.2%)", + rgb: "rgb(255, 250, 205)", + hwb: "hwb(54 80.4% 0%)", + cycle: 5, + }, + { + authored: "lightblue", + name: "lightblue", + hex: "#add8e6", + hsl: "hsl(194.7, 53.3%, 79%)", + rgb: "rgb(173, 216, 230)", + hwb: "hwb(194.7 67.8% 9.8%)", + cycle: 5, + }, + { + authored: "lightcoral", + name: "lightcoral", + hex: "#f08080", + hsl: "hsl(0, 78.9%, 72.2%)", + rgb: "rgb(240, 128, 128)", + hwb: "hwb(0 50.2% 5.9%)", + cycle: 5, + }, + { + authored: "lightcyan", + name: "lightcyan", + hex: "#e0ffff", + hsl: "hsl(180, 100%, 93.9%)", + rgb: "rgb(224, 255, 255)", + hwb: "hwb(180 87.8% 0%)", + cycle: 5, + }, + { + authored: "lightgoldenrodyellow", + name: "lightgoldenrodyellow", + hex: "#fafad2", + hsl: "hsl(60, 80%, 90.2%)", + rgb: "rgb(250, 250, 210)", + hwb: "hwb(60 82.4% 2%)", + cycle: 5, + }, + { + authored: "lightgray", + name: "lightgray", + hex: "#d3d3d3", + hsl: "hsl(0, 0%, 82.7%)", + rgb: "rgb(211, 211, 211)", + hwb: "hwb(0 82.7% 17.3%)", + cycle: 5, + }, + { + authored: "lightgreen", + name: "lightgreen", + hex: "#90ee90", + hsl: "hsl(120, 73.4%, 74.9%)", + rgb: "rgb(144, 238, 144)", + hwb: "hwb(120 56.5% 6.7%)", + cycle: 5, + }, + { + authored: "lightgrey", + name: "lightgray", + hex: "#d3d3d3", + hsl: "hsl(0, 0%, 82.7%)", + rgb: "rgb(211, 211, 211)", + hwb: "hwb(0 82.7% 17.3%)", + cycle: 5, + }, + { + authored: "lightpink", + name: "lightpink", + hex: "#ffb6c1", + hsl: "hsl(351, 100%, 85.7%)", + rgb: "rgb(255, 182, 193)", + hwb: "hwb(351 71.4% 0%)", + cycle: 5, + }, + { + authored: "lightsalmon", + name: "lightsalmon", + hex: "#ffa07a", + hsl: "hsl(17.1, 100%, 73.9%)", + rgb: "rgb(255, 160, 122)", + hwb: "hwb(17.1 47.8% 0%)", + cycle: 5, + }, + { + authored: "lightseagreen", + name: "lightseagreen", + hex: "#20b2aa", + hsl: "hsl(176.7, 69.5%, 41.2%)", + rgb: "rgb(32, 178, 170)", + hwb: "hwb(176.7 12.5% 30.2%)", + cycle: 5, + }, + { + authored: "lightskyblue", + name: "lightskyblue", + hex: "#87cefa", + hsl: "hsl(203, 92%, 75.5%)", + rgb: "rgb(135, 206, 250)", + hwb: "hwb(203 52.9% 2%)", + cycle: 5, + }, + { + authored: "lightslategray", + name: "lightslategray", + hex: "#789", + hsl: "hsl(210, 14.3%, 53.3%)", + rgb: "rgb(119, 136, 153)", + hwb: "hwb(210 46.7% 40%)", + cycle: 5, + }, + { + authored: "lightslategrey", + name: "lightslategray", + hex: "#789", + hsl: "hsl(210, 14.3%, 53.3%)", + rgb: "rgb(119, 136, 153)", + hwb: "hwb(210 46.7% 40%)", + cycle: 5, + }, + { + authored: "lightsteelblue", + name: "lightsteelblue", + hex: "#b0c4de", + hsl: "hsl(213.9, 41.1%, 78%)", + rgb: "rgb(176, 196, 222)", + hwb: "hwb(213.9 69% 12.9%)", + cycle: 5, + }, + { + authored: "lightyellow", + name: "lightyellow", + hex: "#ffffe0", + hsl: "hsl(60, 100%, 93.9%)", + rgb: "rgb(255, 255, 224)", + hwb: "hwb(60 87.8% 0%)", + cycle: 5, + }, + { + authored: "lime", + name: "lime", + hex: "#0f0", + hsl: "hsl(120, 100%, 50%)", + rgb: "rgb(0, 255, 0)", + hwb: "hwb(120 0% 0%)", + cycle: 5, + }, + { + authored: "limegreen", + name: "limegreen", + hex: "#32cd32", + hsl: "hsl(120, 60.8%, 50%)", + rgb: "rgb(50, 205, 50)", + hwb: "hwb(120 19.6% 19.6%)", + cycle: 5, + }, + { + authored: "linen", + name: "linen", + hex: "#faf0e6", + hsl: "hsl(30, 66.7%, 94.1%)", + rgb: "rgb(250, 240, 230)", + hwb: "hwb(30 90.2% 2%)", + cycle: 5, + }, + { + authored: "magenta", + name: "fuchsia", + hex: "#f0f", + hsl: "hsl(300, 100%, 50%)", + rgb: "rgb(255, 0, 255)", + hwb: "hwb(300 0% 0%)", + cycle: 5, + }, + { + authored: "maroon", + name: "maroon", + hex: "#800000", + hsl: "hsl(0, 100%, 25.1%)", + rgb: "rgb(128, 0, 0)", + hwb: "hwb(0 0% 49.8%)", + cycle: 5, + }, + { + authored: "mediumaquamarine", + name: "mediumaquamarine", + hex: "#66cdaa", + hsl: "hsl(159.6, 50.7%, 60.2%)", + rgb: "rgb(102, 205, 170)", + hwb: "hwb(159.6 40% 19.6%)", + cycle: 5, + }, + { + authored: "mediumblue", + name: "mediumblue", + hex: "#0000cd", + hsl: "hsl(240, 100%, 40.2%)", + rgb: "rgb(0, 0, 205)", + hwb: "hwb(240 0% 19.6%)", + cycle: 5, + }, + { + authored: "mediumorchid", + name: "mediumorchid", + hex: "#ba55d3", + hsl: "hsl(288.1, 58.9%, 58%)", + rgb: "rgb(186, 85, 211)", + hwb: "hwb(288.1 33.3% 17.3%)", + cycle: 5, + }, + { + authored: "mediumpurple", + name: "mediumpurple", + hex: "#9370db", + hsl: "hsl(259.6, 59.8%, 64.9%)", + rgb: "rgb(147, 112, 219)", + hwb: "hwb(259.6 43.9% 14.1%)", + cycle: 5, + }, + { + authored: "mediumseagreen", + name: "mediumseagreen", + hex: "#3cb371", + hsl: "hsl(146.7, 49.8%, 46.9%)", + rgb: "rgb(60, 179, 113)", + hwb: "hwb(146.7 23.5% 29.8%)", + cycle: 5, + }, + { + authored: "mediumslateblue", + name: "mediumslateblue", + hex: "#7b68ee", + hsl: "hsl(248.5, 79.8%, 67.1%)", + rgb: "rgb(123, 104, 238)", + hwb: "hwb(248.5 40.8% 6.7%)", + cycle: 5, + }, + { + authored: "mediumspringgreen", + name: "mediumspringgreen", + hex: "#00fa9a", + hsl: "hsl(157, 100%, 49%)", + rgb: "rgb(0, 250, 154)", + hwb: "hwb(157 0% 2%)", + cycle: 5, + }, + { + authored: "mediumturquoise", + name: "mediumturquoise", + hex: "#48d1cc", + hsl: "hsl(177.8, 59.8%, 55.1%)", + rgb: "rgb(72, 209, 204)", + hwb: "hwb(177.8 28.2% 18%)", + cycle: 5, + }, + { + authored: "mediumvioletred", + name: "mediumvioletred", + hex: "#c71585", + hsl: "hsl(322.2, 80.9%, 43.1%)", + rgb: "rgb(199, 21, 133)", + hwb: "hwb(322.2 8.2% 22%)", + cycle: 5, + }, + { + authored: "midnightblue", + name: "midnightblue", + hex: "#191970", + hsl: "hsl(240, 63.5%, 26.9%)", + rgb: "rgb(25, 25, 112)", + hwb: "hwb(240 9.8% 56.1%)", + cycle: 5, + }, + { + authored: "mintcream", + name: "mintcream", + hex: "#f5fffa", + hsl: "hsl(150, 100%, 98%)", + rgb: "rgb(245, 255, 250)", + hwb: "hwb(150 96.1% 0%)", + cycle: 5, + }, + { + authored: "mistyrose", + name: "mistyrose", + hex: "#ffe4e1", + hsl: "hsl(6, 100%, 94.1%)", + rgb: "rgb(255, 228, 225)", + hwb: "hwb(6 88.2% 0%)", + cycle: 5, + }, + { + authored: "moccasin", + name: "moccasin", + hex: "#ffe4b5", + hsl: "hsl(38.1, 100%, 85.5%)", + rgb: "rgb(255, 228, 181)", + hwb: "hwb(38.1 71% 0%)", + cycle: 5, + }, + { + authored: "navajowhite", + name: "navajowhite", + hex: "#ffdead", + hsl: "hsl(35.9, 100%, 83.9%)", + rgb: "rgb(255, 222, 173)", + hwb: "hwb(35.9 67.8% 0%)", + cycle: 5, + }, + { + authored: "navy", + name: "navy", + hex: "#000080", + hsl: "hsl(240, 100%, 25.1%)", + rgb: "rgb(0, 0, 128)", + hwb: "hwb(240 0% 49.8%)", + cycle: 5, + }, + { + authored: "oldlace", + name: "oldlace", + hex: "#fdf5e6", + hsl: "hsl(39.1, 85.2%, 94.7%)", + rgb: "rgb(253, 245, 230)", + hwb: "hwb(39.1 90.2% 0.8%)", + cycle: 5, + }, + { + authored: "olive", + name: "olive", + hex: "#808000", + hsl: "hsl(60, 100%, 25.1%)", + rgb: "rgb(128, 128, 0)", + hwb: "hwb(60 0% 49.8%)", + cycle: 5, + }, + { + authored: "olivedrab", + name: "olivedrab", + hex: "#6b8e23", + hsl: "hsl(79.6, 60.5%, 34.7%)", + rgb: "rgb(107, 142, 35)", + hwb: "hwb(79.6 13.7% 44.3%)", + cycle: 5, + }, + { + authored: "orange", + name: "orange", + hex: "#ffa500", + hsl: "hsl(38.8, 100%, 50%)", + rgb: "rgb(255, 165, 0)", + hwb: "hwb(38.8 0% 0%)", + cycle: 5, + }, + { + authored: "orangered", + name: "orangered", + hex: "#ff4500", + hsl: "hsl(16.2, 100%, 50%)", + rgb: "rgb(255, 69, 0)", + hwb: "hwb(16.2 0% 0%)", + cycle: 5, + }, + { + authored: "orchid", + name: "orchid", + hex: "#da70d6", + hsl: "hsl(302.3, 58.9%, 64.7%)", + rgb: "rgb(218, 112, 214)", + hwb: "hwb(302.3 43.9% 14.5%)", + cycle: 5, + }, + { + authored: "palegoldenrod", + name: "palegoldenrod", + hex: "#eee8aa", + hsl: "hsl(54.7, 66.7%, 80%)", + rgb: "rgb(238, 232, 170)", + hwb: "hwb(54.7 66.7% 6.7%)", + cycle: 5, + }, + { + authored: "palegreen", + name: "palegreen", + hex: "#98fb98", + hsl: "hsl(120, 92.5%, 79%)", + rgb: "rgb(152, 251, 152)", + hwb: "hwb(120 59.6% 1.6%)", + cycle: 5, + }, + { + authored: "paleturquoise", + name: "paleturquoise", + hex: "#afeeee", + hsl: "hsl(180, 64.9%, 81%)", + rgb: "rgb(175, 238, 238)", + hwb: "hwb(180 68.6% 6.7%)", + cycle: 5, + }, + { + authored: "palevioletred", + name: "palevioletred", + hex: "#db7093", + hsl: "hsl(340.4, 59.8%, 64.9%)", + rgb: "rgb(219, 112, 147)", + hwb: "hwb(340.4 43.9% 14.1%)", + cycle: 5, + }, + { + authored: "papayawhip", + name: "papayawhip", + hex: "#ffefd5", + hsl: "hsl(37.1, 100%, 91.8%)", + rgb: "rgb(255, 239, 213)", + hwb: "hwb(37.1 83.5% 0%)", + cycle: 5, + }, + { + authored: "peachpuff", + name: "peachpuff", + hex: "#ffdab9", + hsl: "hsl(28.3, 100%, 86.3%)", + rgb: "rgb(255, 218, 185)", + hwb: "hwb(28.3 72.5% 0%)", + cycle: 5, + }, + { + authored: "peru", + name: "peru", + hex: "#cd853f", + hsl: "hsl(29.6, 58.7%, 52.5%)", + rgb: "rgb(205, 133, 63)", + hwb: "hwb(29.6 24.7% 19.6%)", + cycle: 5, + }, + { + authored: "pink", + name: "pink", + hex: "#ffc0cb", + hsl: "hsl(349.5, 100%, 87.6%)", + rgb: "rgb(255, 192, 203)", + hwb: "hwb(349.5 75.3% 0%)", + cycle: 5, + }, + { + authored: "plum", + name: "plum", + hex: "#dda0dd", + hsl: "hsl(300, 47.3%, 74.7%)", + rgb: "rgb(221, 160, 221)", + hwb: "hwb(300 62.7% 13.3%)", + cycle: 5, + }, + { + authored: "powderblue", + name: "powderblue", + hex: "#b0e0e6", + hsl: "hsl(186.7, 51.9%, 79.6%)", + rgb: "rgb(176, 224, 230)", + hwb: "hwb(186.7 69% 9.8%)", + cycle: 5, + }, + { + authored: "purple", + name: "purple", + hex: "#800080", + hsl: "hsl(300, 100%, 25.1%)", + rgb: "rgb(128, 0, 128)", + hwb: "hwb(300 0% 49.8%)", + cycle: 5, + }, + { + authored: "rebeccapurple", + name: "rebeccapurple", + hex: "#639", + hsl: "hsl(270, 50%, 40%)", + rgb: "rgb(102, 51, 153)", + hwb: "hwb(270 20% 40%)", + cycle: 5, + }, + { + authored: "red", + name: "red", + hex: "#f00", + hsl: "hsl(0, 100%, 50%)", + rgb: "rgb(255, 0, 0)", + hwb: "hwb(0 0% 0%)", + cycle: 5, + }, + { + authored: "rosybrown", + name: "rosybrown", + hex: "#bc8f8f", + hsl: "hsl(0, 25.1%, 64.9%)", + rgb: "rgb(188, 143, 143)", + hwb: "hwb(0 56.1% 26.3%)", + cycle: 5, + }, + { + authored: "royalblue", + name: "royalblue", + hex: "#4169e1", + hsl: "hsl(225, 72.7%, 56.9%)", + rgb: "rgb(65, 105, 225)", + hwb: "hwb(225 25.5% 11.8%)", + cycle: 5, + }, + { + authored: "saddlebrown", + name: "saddlebrown", + hex: "#8b4513", + hsl: "hsl(25, 75.9%, 31%)", + rgb: "rgb(139, 69, 19)", + hwb: "hwb(25 7.5% 45.5%)", + cycle: 5, + }, + { + authored: "salmon", + name: "salmon", + hex: "#fa8072", + hsl: "hsl(6.2, 93.2%, 71.4%)", + rgb: "rgb(250, 128, 114)", + hwb: "hwb(6.2 44.7% 2%)", + cycle: 5, + }, + { + authored: "sandybrown", + name: "sandybrown", + hex: "#f4a460", + hsl: "hsl(27.6, 87.1%, 66.7%)", + rgb: "rgb(244, 164, 96)", + hwb: "hwb(27.6 37.6% 4.3%)", + cycle: 5, + }, + { + authored: "seagreen", + name: "seagreen", + hex: "#2e8b57", + hsl: "hsl(146.5, 50.3%, 36.3%)", + rgb: "rgb(46, 139, 87)", + hwb: "hwb(146.5 18% 45.5%)", + cycle: 5, + }, + { + authored: "seashell", + name: "seashell", + hex: "#fff5ee", + hsl: "hsl(24.7, 100%, 96.7%)", + rgb: "rgb(255, 245, 238)", + hwb: "hwb(24.7 93.3% 0%)", + cycle: 5, + }, + { + authored: "sienna", + name: "sienna", + hex: "#a0522d", + hsl: "hsl(19.3, 56.1%, 40.2%)", + rgb: "rgb(160, 82, 45)", + hwb: "hwb(19.3 17.6% 37.3%)", + cycle: 5, + }, + { + authored: "silver", + name: "silver", + hex: "#c0c0c0", + hsl: "hsl(0, 0%, 75.3%)", + rgb: "rgb(192, 192, 192)", + hwb: "hwb(0 75.3% 24.7%)", + cycle: 5, + }, + { + authored: "skyblue", + name: "skyblue", + hex: "#87ceeb", + hsl: "hsl(197.4, 71.4%, 72.5%)", + rgb: "rgb(135, 206, 235)", + hwb: "hwb(197.4 52.9% 7.8%)", + cycle: 5, + }, + { + authored: "slateblue", + name: "slateblue", + hex: "#6a5acd", + hsl: "hsl(248.3, 53.5%, 57.8%)", + rgb: "rgb(106, 90, 205)", + hwb: "hwb(248.3 35.3% 19.6%)", + cycle: 5, + }, + { + authored: "slategray", + name: "slategray", + hex: "#708090", + hsl: "hsl(210, 12.6%, 50.2%)", + rgb: "rgb(112, 128, 144)", + hwb: "hwb(210 43.9% 43.5%)", + cycle: 5, + }, + { + authored: "slategrey", + name: "slategray", + hex: "#708090", + hsl: "hsl(210, 12.6%, 50.2%)", + rgb: "rgb(112, 128, 144)", + hwb: "hwb(210 43.9% 43.5%)", + cycle: 5, + }, + { + authored: "snow", + name: "snow", + hex: "#fffafa", + hsl: "hsl(0, 100%, 99%)", + rgb: "rgb(255, 250, 250)", + hwb: "hwb(0 98% 0%)", + cycle: 5, + }, + { + authored: "springgreen", + name: "springgreen", + hex: "#00ff7f", + hsl: "hsl(149.9, 100%, 50%)", + rgb: "rgb(0, 255, 127)", + hwb: "hwb(149.9 0% 0%)", + cycle: 5, + }, + { + authored: "steelblue", + name: "steelblue", + hex: "#4682b4", + hsl: "hsl(207.3, 44%, 49%)", + rgb: "rgb(70, 130, 180)", + hwb: "hwb(207.3 27.5% 29.4%)", + cycle: 5, + }, + { + authored: "tan", + name: "tan", + hex: "#d2b48c", + hsl: "hsl(34.3, 43.7%, 68.6%)", + rgb: "rgb(210, 180, 140)", + hwb: "hwb(34.3 54.9% 17.6%)", + cycle: 5, + }, + { + authored: "teal", + name: "teal", + hex: "#008080", + hsl: "hsl(180, 100%, 25.1%)", + rgb: "rgb(0, 128, 128)", + hwb: "hwb(180 0% 49.8%)", + cycle: 5, + }, + { + authored: "thistle", + name: "thistle", + hex: "#d8bfd8", + hsl: "hsl(300, 24.3%, 79.8%)", + rgb: "rgb(216, 191, 216)", + hwb: "hwb(300 74.9% 15.3%)", + cycle: 5, + }, + { + authored: "tomato", + name: "tomato", + hex: "#ff6347", + hsl: "hsl(9.1, 100%, 63.9%)", + rgb: "rgb(255, 99, 71)", + hwb: "hwb(9.1 27.8% 0%)", + cycle: 5, + }, + { + authored: "turquoise", + name: "turquoise", + hex: "#40e0d0", + hsl: "hsl(174, 72.1%, 56.5%)", + rgb: "rgb(64, 224, 208)", + hwb: "hwb(174 25.1% 12.2%)", + cycle: 5, + }, + { + authored: "violet", + name: "violet", + hex: "#ee82ee", + hsl: "hsl(300, 76.1%, 72.2%)", + rgb: "rgb(238, 130, 238)", + hwb: "hwb(300 51% 6.7%)", + cycle: 5, + }, + { + authored: "wheat", + name: "wheat", + hex: "#f5deb3", + hsl: "hsl(39.1, 76.7%, 83.1%)", + rgb: "rgb(245, 222, 179)", + hwb: "hwb(39.1 70.2% 3.9%)", + cycle: 5, + }, + { + authored: "white", + name: "white", + hex: "#fff", + hsl: "hsl(0, 0%, 100%)", + rgb: "rgb(255, 255, 255)", + hwb: "hwb(0 100% 0%)", + cycle: 5, + }, + { + authored: "whitesmoke", + name: "whitesmoke", + hex: "#f5f5f5", + hsl: "hsl(0, 0%, 96.1%)", + rgb: "rgb(245, 245, 245)", + hwb: "hwb(0 96.1% 3.9%)", + cycle: 5, + }, + { + authored: "yellow", + name: "yellow", + hex: "#ff0", + hsl: "hsl(60, 100%, 50%)", + rgb: "rgb(255, 255, 0)", + hwb: "hwb(60 0% 0%)", + cycle: 5, + }, + { + authored: "yellowgreen", + name: "yellowgreen", + hex: "#9acd32", + hsl: "hsl(79.7, 60.8%, 50%)", + rgb: "rgb(154, 205, 50)", + hwb: "hwb(79.7 19.6% 19.6%)", + cycle: 5, + }, + { + authored: "rgba(0, 0, 0, 0)", + name: "#0000", + hex: "#0000", + hsl: "hsla(0, 0%, 0%, 0)", + rgb: "rgba(0, 0, 0, 0)", + hwb: "hwb(0 0% 100% / 0)", + cycle: 4, + }, + { + authored: "hsla(0, 0%, 0%, 0)", + name: "#0000", + hex: "#0000", + hsl: "hsla(0, 0%, 0%, 0)", + rgb: "rgba(0, 0, 0, 0)", + hwb: "hwb(0 0% 100% / 0)", + cycle: 4, + }, + { + authored: "rgba(50, 60, 70, 0.5)", + name: "#323c4680", + hex: "#323c4680", + hsl: "hsla(210, 16.7%, 23.5%, 0.5)", + rgb: "rgba(50, 60, 70, 0.5)", + hwb: "hwb(210 19.6% 72.5% / 0.5)", + cycle: 4, + }, + { + authored: "rgba(0, 0, 0, 0.3)", + name: "#0000004d", + hex: "#0000004d", + hsl: "hsla(0, 0%, 0%, 0.3)", + rgb: "rgba(0, 0, 0, 0.3)", + hwb: "hwb(0 0% 100% / 0.3)", + cycle: 4, + }, + { + authored: "rgba(255, 255, 255, 0.6)", + name: "#fff9", + hex: "#fff9", + hsl: "hsla(0, 0%, 100%, 0.6)", + rgb: "rgba(255, 255, 255, 0.6)", + hwb: "hwb(0 100% 0% / 0.6)", + cycle: 4, + }, + { + authored: "rgba(127, 89, 45, 1)", + name: "#7f592d", + hex: "#7f592d", + hsl: "hsl(32.2, 47.7%, 33.7%)", + rgb: "rgb(127, 89, 45)", + hwb: "hwb(32.2 17.6% 50.2%)", + cycle: 4, + }, + { + authored: "hsla(19.304, 56%, 40%, 1)", + name: "#9f522d", + hex: "#9f522d", + hsl: "hsl(19.5, 55.9%, 40%)", + rgb: "rgb(159, 82, 45)", + hwb: "hwb(19.5 17.6% 37.6%)", + cycle: 4, + }, + { + authored: "#f089", + name: "#f089", + hex: "#f089", + hsl: "hsla(328, 100%, 50%, 0.6)", + rgb: "rgba(255, 0, 136, 0.6)", + hwb: "hwb(328 0% 0% / 0.6)", + cycle: 4, + }, + { + authored: "#00ff8080", + name: "#00ff8080", + hex: "#00ff8080", + hsl: "hsla(150.1, 100%, 50%, 0.5)", + rgb: "rgba(0, 255, 128, 0.5)", + hwb: "hwb(150.1 0% 0% / 0.5)", + cycle: 4, + }, + { + authored: "#aaaaaa08", + name: "#aaaaaa08", + hex: "#aaaaaa08", + hsl: "hsla(0, 0%, 66.7%, 0.03)", + rgb: "rgba(170, 170, 170, 0.03)", + hwb: "hwb(0 66.7% 33.3% / 0.03)", + }, + { + authored: "currentcolor", + name: "currentcolor", + hex: "currentcolor", + hsl: "currentcolor", + rgb: "currentcolor", + hwb: "currentcolor", + cycle: false, + }, + { + authored: "inherit", + name: "inherit", + hex: "inherit", + hsl: "inherit", + rgb: "inherit", + hwb: "inherit", + cycle: false, + }, + { + authored: "initial", + name: "initial", + hex: "initial", + hsl: "initial", + rgb: "initial", + hwb: "initial", + cycle: false, + }, + { + authored: "invalidColor", + name: "", + hex: "", + hsl: "", + rgb: "", + hwb: "", + cycle: false, + }, + { + authored: "transparent", + name: "transparent", + hex: "transparent", + hsl: "transparent", + rgb: "transparent", + hwb: "transparent", + cycle: false, + }, + { + authored: "unset", + name: "unset", + hex: "unset", + hsl: "unset", + rgb: "unset", + hwb: "unset", + cycle: false, + }, + { + authored: "currentcolor", + name: "currentcolor", + hex: "currentcolor", + hsl: "currentcolor", + rgb: "currentcolor", + hwb: "currentcolor", + cycle: false, + }, + { + authored: "accentcolor", + name: "", + hex: "", + hsl: "", + rgb: "", + hwg: "", + cycle: false, + }, + ]; +} +/* eslint-enable max-len */ + +// Allow this function to be shared on mochitests and xpcshell tests. +if (typeof module === "object") { + module.exports = getFixtureColorData; +} diff --git a/devtools/client/shared/test/helper_html_tooltip.js b/devtools/client/shared/test/helper_html_tooltip.js new file mode 100644 index 0000000000..24b28ad702 --- /dev/null +++ b/devtools/client/shared/test/helper_html_tooltip.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint no-unused-vars: [2, {"vars": "local", "args": "none"}] */ +/* import-globals-from ../../shared/test/shared-head.js */ + +"use strict"; + +/** + * Helper methods for the HTMLTooltip integration tests. + */ + +/** + * Display an existing HTMLTooltip on an anchor and properly wait for the popup to be + * repainted. + * + * @param {HTMLTooltip} tooltip + * The tooltip instance to display + * @param {Node} anchor + * The anchor that should be used to display the tooltip + * @param {Object} see HTMLTooltip:show documentation + * @return {Promise} promise that resolves when reflow and repaint are done. + */ +async function showTooltip(tooltip, anchor, { position, x, y } = {}) { + await tooltip.show(anchor, { position, x, y }); + await waitForReflow(tooltip); + + // Wait for next tick. Tooltip tests sometimes fail to successively hide and + // show tooltips on Win32 debug. + await waitForTick(); +} + +/** + * Hide an existing HTMLTooltip. After the tooltip "hidden" event has been fired + * a reflow will be triggered. + * + * @param {HTMLTooltip} tooltip + * The tooltip instance to hide + * @return {Promise} promise that resolves when "hidden" has been fired, reflow + * and repaint done. + */ +async function hideTooltip(tooltip) { + const onPopupHidden = tooltip.once("hidden"); + tooltip.hide(); + await onPopupHidden; + await waitForReflow(tooltip); + + // Wait for next tick. Tooltip tests sometimes fail to successively hide and + // show tooltips on Win32 debug. + await waitForTick(); +} + +/** + * Forces the reflow of an HTMLTooltip document and waits for the next repaint. + * + * @param {HTMLTooltip} the tooltip to reflow + * @return {Promise} a promise that will resolve after the reflow and repaint + * have been executed. + */ +function waitForReflow(tooltip) { + const { doc } = tooltip; + return new Promise(resolve => { + doc.documentElement.offsetWidth; + doc.defaultView.requestAnimationFrame(resolve); + }); +} + +/** + * Test helper designed to check that a tooltip is displayed at the expected + * position relative to an anchor, given a set of expectations. + * + * @param {HTMLTooltip} tooltip + * The HTMLTooltip instance to check + * @param {Node} anchor + * The tooltip's anchor + * @param {Object} expected + * - {String} position : "top" or "bottom" + * - {Boolean} leftAligned + * - {Number} width: expected tooltip width + * - {Number} height: expected tooltip height + */ +function checkTooltipGeometry( + tooltip, + anchor, + { position, leftAligned = true, height, width } = {} +) { + info("Check the tooltip geometry matches expected position and dimensions"); + const tooltipRect = tooltip.container.getBoundingClientRect(); + const anchorRect = anchor.getBoundingClientRect(); + + if (position === "top") { + is( + tooltipRect.bottom, + Math.round(anchorRect.top), + "Tooltip is above the anchor" + ); + } else if (position === "bottom") { + is( + tooltipRect.top, + Math.round(anchorRect.bottom), + "Tooltip is below the anchor" + ); + } else { + ok(false, "Invalid position provided to checkTooltipGeometry"); + } + + if (leftAligned) { + is( + tooltipRect.left, + Math.round(anchorRect.left), + "Tooltip left-aligned with the anchor" + ); + } + + is(tooltipRect.height, height, "Tooltip has the expected height"); + is(tooltipRect.width, width, "Tooltip has the expected width"); +} diff --git a/devtools/client/shared/test/helper_inplace_editor.js b/devtools/client/shared/test/helper_inplace_editor.js new file mode 100644 index 0000000000..a7e544f708 --- /dev/null +++ b/devtools/client/shared/test/helper_inplace_editor.js @@ -0,0 +1,164 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint no-unused-vars: [2, {"vars": "local", "args": "none"}] */ +/* import-globals-from head.js */ + +"use strict"; + +/** + * Helper methods for the HTMLTooltip integration tests. + */ + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const { + editableField, +} = require("resource://devtools/client/shared/inplace-editor.js"); +const { colorUtils } = require("resource://devtools/shared/css/color.js"); + +/** + * Create an inplace editor linked to a span element and click on the span to + * to turn to edit mode. + * + * @param {Object} options + * Options passed to the InplaceEditor/editableField constructor. + * @param {Document} doc + * Document where the span element will be created. + * @param {String} textContent + * (optional) String that will be used as the text content of the span. + */ +const createInplaceEditorAndClick = async function (options, doc, textContent) { + const span = (options.element = createSpan(doc)); + if (textContent) { + span.textContent = textContent; + } + + info("Creating an inplace-editor field"); + editableField(options); + + info("Clicking on the inplace-editor field to turn to edit mode"); + span.click(); +}; + +/** + * Helper to create a span in the provided document. + * + * @param {Document} doc + * Document where the span element will be created. + * @return {Element} the created span element. + */ +function createSpan(doc) { + info("Creating a new span element"); + const div = doc.createElementNS(HTML_NS, "div"); + const span = doc.createElementNS(HTML_NS, "span"); + span.setAttribute("tabindex", "0"); + span.style.fontSize = "11px"; + span.style.display = "inline-block"; + span.style.width = "100px"; + span.style.border = "1px solid red"; + span.style.fontFamily = "monospace"; + + div.style.height = "100%"; + div.style.position = "absolute"; + div.appendChild(span); + + const parent = doc.querySelector("window") || doc.body; + parent.appendChild(div); + return span; +} + +/** + * Test helper simulating a key event in an InplaceEditor and checking that the + * autocompletion works as expected. + * + * @param {Array} testData + * - {String} key, the key to send + * - {String} completion, the expected value of the auto-completion + * - {Number} index, the index of the selected suggestion in the popup + * - {Number} total, the total number of suggestions in the popup + * - {String} postLabel, the expected post label for the selected suggestion + * - {Boolean} colorSwatch, if there is a swatch of color expected to be visible + * @param {InplaceEditor} editor + * The InplaceEditor instance being tested + */ +async function testCompletion( + [key, completion, index, total, postLabel, colorSwatch], + editor +) { + info("Pressing key " + key); + info("Expecting " + completion); + + let onVisibilityChange = null; + const open = total > 0; + if (editor.popup.isOpen != open) { + onVisibilityChange = editor.popup.once( + open ? "popup-opened" : "popup-closed" + ); + } + + let onSuggest; + if (/(left|right|back_space|escape)/gi.test(key)) { + info("Adding event listener for right|back_space|escape keys"); + onSuggest = once(editor.input, "keypress"); + } else { + info("Waiting for after-suggest event on the editor"); + onSuggest = editor.once("after-suggest"); + } + + info("Synthesizing key " + key); + EventUtils.synthesizeKey(key, {}, editor.input.defaultView); + + await onSuggest; + await onVisibilityChange; + await waitForTime(5); + + info("Checking the state"); + if (completion !== null) { + is(editor.input.value, completion, "Correct value is autocompleted"); + } + + if (postLabel) { + const selectedItem = editor.popup.getItems()[index]; + const selectedElement = editor.popup.elements.get(selectedItem); + ok( + selectedElement.textContent.includes(postLabel), + "Selected popup element contains the expected post-label" + ); + + // Determines if there is a color swatch attached to the label + // and if the color swatch's background color matches the post label + const swatchSpan = selectedElement.getElementsByClassName( + "autocomplete-swatch autocomplete-colorswatch" + ); + if (colorSwatch) { + Assert.strictEqual( + swatchSpan.length, + 1, + "Displayed the expected color swatch" + ); + const color = new colorUtils.CssColor( + swatchSpan[0].style.backgroundColor + ); + const swatchColor = color.rgba; + const postColor = new colorUtils.CssColor(postLabel).rgba; + Assert.equal( + swatchColor, + postColor, + "Color swatch matches postLabel value" + ); + } else { + Assert.strictEqual( + swatchSpan.length, + 0, + "As expected no swatches were available" + ); + } + } + + if (total === 0) { + ok(!(editor.popup && editor.popup.isOpen), "Popup is closed"); + } else { + ok(editor.popup.isOpen, "Popup is open"); + is(editor.popup.getItems().length, total, "Number of suggestions match"); + is(editor.popup.selectedIndex, index, "Expected item is selected"); + } +} diff --git a/devtools/client/shared/test/highlighter-test-actor.js b/devtools/client/shared/test/highlighter-test-actor.js new file mode 100644 index 0000000000..8a7b404df1 --- /dev/null +++ b/devtools/client/shared/test/highlighter-test-actor.js @@ -0,0 +1,939 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* exported HighlighterTestActor, HighlighterTestFront */ + +"use strict"; + +// A helper actor for testing highlighters. +// ⚠️ This should only be used for getting data for objects using CanvasFrameAnonymousContentHelper, +// that we can't get directly from tests. +const { + getRect, + getAdjustedQuads, +} = require("resource://devtools/shared/layout/utils.js"); + +// Set up a dummy environment so that EventUtils works. We need to be careful to +// pass a window object into each EventUtils method we call rather than having +// it rely on the |window| global. +const EventUtils = {}; +EventUtils.window = {}; +EventUtils.parent = {}; +/* eslint-disable camelcase */ +EventUtils._EU_Ci = Ci; +EventUtils._EU_Cc = Cc; +/* eslint-disable camelcase */ +Services.scriptloader.loadSubScript( + "chrome://mochikit/content/tests/SimpleTest/EventUtils.js", + EventUtils +); + +// We're an actor so we don't run in the browser test environment, so +// we need to import TestUtils manually despite what the linter thinks. +// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +const protocol = require("resource://devtools/shared/protocol.js"); +const { Arg, RetVal } = protocol; + +const dumpn = msg => { + dump(msg + "\n"); +}; + +/** + * Get the instance of CanvasFrameAnonymousContentHelper used by a given + * highlighter actor. + * The instance provides methods to get/set attributes/text/style on nodes of + * the highlighter, inserted into the nsCanvasFrame. + * @see /devtools/server/actors/highlighters.js + * @param {String} actorID + */ +function getHighlighterCanvasFrameHelper(conn, actorID) { + // Retrieve the CustomHighlighterActor by its actorID: + const actor = conn.getActor(actorID); + if (!actor) { + return null; + } + + // Retrieve the sub class instance specific to each highlighter type: + let highlighter = actor.instance; + + // SelectorHighlighter and TabbingOrderHighlighter can hold multiple highlighters. + // For now, only retrieve the first highlighter. + if ( + highlighter._highlighters && + Array.isArray(highlighter._highlighters) && + highlighter._highlighters.length + ) { + highlighter = highlighter._highlighters[0]; + } + + // Now, `highlighter` should be a final highlighter class, exposing + // `CanvasFrameAnonymousContentHelper` via a `markup` attribute. + if (highlighter.markup) { + return highlighter.markup; + } + + // Here we didn't find any highlighter; it can happen if the actor is a + // FontsHighlighter (which does not use a CanvasFrameAnonymousContentHelper). + return null; +} + +var highlighterTestSpec = protocol.generateActorSpec({ + typeName: "highlighterTest", + + events: { + "highlighter-updated": {}, + }, + + methods: { + getHighlighterAttribute: { + request: { + nodeID: Arg(0, "string"), + name: Arg(1, "string"), + actorID: Arg(2, "string"), + }, + response: { + value: RetVal("string"), + }, + }, + getHighlighterBoundingClientRect: { + request: { + nodeID: Arg(0, "string"), + actorID: Arg(1, "string"), + }, + response: { + value: RetVal("json"), + }, + }, + getHighlighterComputedStyle: { + request: { + nodeID: Arg(0, "string"), + property: Arg(1, "string"), + actorID: Arg(2, "string"), + }, + response: { + value: RetVal("string"), + }, + }, + getHighlighterNodeTextContent: { + request: { + nodeID: Arg(0, "string"), + actorID: Arg(1, "string"), + }, + response: { + value: RetVal("string"), + }, + }, + getSelectorHighlighterBoxNb: { + request: { + highlighter: Arg(0, "string"), + }, + response: { + value: RetVal("number"), + }, + }, + changeHighlightedNodeWaitForUpdate: { + request: { + name: Arg(0, "string"), + value: Arg(1, "string"), + actorID: Arg(2, "string"), + }, + response: {}, + }, + registerOneTimeHighlighterUpdate: { + request: { + actorID: Arg(0, "string"), + }, + response: {}, + }, + getNodeRect: { + request: { + selector: Arg(0, "string"), + }, + response: { + value: RetVal("json"), + }, + }, + getTextNodeRect: { + request: { + parentSelector: Arg(0, "string"), + childNodeIndex: Arg(1, "number"), + }, + response: { + value: RetVal("json"), + }, + }, + isPausedDebuggerOverlayVisible: { + request: {}, + response: { + value: RetVal("boolean"), + }, + }, + clickPausedDebuggerOverlayButton: { + request: { + id: Arg(0, "string"), + }, + response: {}, + }, + isEyeDropperVisible: { + request: {}, + response: { + value: RetVal("boolean"), + }, + }, + getEyeDropperElementAttribute: { + request: { + elementId: Arg(0, "string"), + attributeName: Arg(1, "string"), + }, + response: { + value: RetVal("string"), + }, + }, + getEyeDropperColorValue: { + request: {}, + response: { + value: RetVal("string"), + }, + }, + getTabbingOrderHighlighterData: { + request: {}, + response: { + value: RetVal("json"), + }, + }, + }, +}); + +class HighlighterTestActor extends protocol.Actor { + constructor(conn, targetActor, options) { + super(conn, highlighterTestSpec); + + this.targetActor = targetActor; + } + + get content() { + return this.targetActor.window; + } + + /** + * Helper to retrieve a DOM element. + * @param {string | array} selector Either a regular selector string + * or a selector array. If an array, each item, except the last one + * are considered matching an iframe, so that we can query element + * within deep iframes. + */ + _querySelector(selector) { + let document = this.content.document; + if (Array.isArray(selector)) { + const fullSelector = selector.join(" >> "); + while (selector.length > 1) { + const str = selector.shift(); + const iframe = document.querySelector(str); + if (!iframe) { + throw new Error( + 'Unable to find element with selector "' + + str + + '"' + + " (full selector:" + + fullSelector + + ")" + ); + } + if (!iframe.contentWindow) { + throw new Error( + "Iframe selector doesn't target an iframe \"" + + str + + '"' + + " (full selector:" + + fullSelector + + ")" + ); + } + document = iframe.contentWindow.document; + } + selector = selector.shift(); + } + const node = document.querySelector(selector); + if (!node) { + throw new Error( + 'Unable to find element with selector "' + selector + '"' + ); + } + return node; + } + + /** + * Get a value for a given attribute name, on one of the elements of the box + * model highlighter, given its ID. + * @param {String} nodeID The full ID of the element to get the attribute for + * @param {String} name The name of the attribute to get + * @param {String} actorID The highlighter actor ID + * @return {String} The value, if found, null otherwise + */ + getHighlighterAttribute(nodeID, name, actorID) { + const helper = getHighlighterCanvasFrameHelper(this.conn, actorID); + + if (!helper) { + throw new Error(`Highlighter not found`); + } + + return helper.getAttributeForElement(nodeID, name); + } + + /** + * Get the bounding client rect for an highlighter element, given its ID. + * + * @param {String} nodeID The full ID of the element to get the DOMRect for + * @param {String} actorID The highlighter actor ID + * @return {DOMRect} The value, if found, null otherwise + */ + getHighlighterBoundingClientRect(nodeID, actorID) { + const helper = getHighlighterCanvasFrameHelper(this.conn, actorID); + + if (!helper) { + throw new Error(`Highlighter not found`); + } + + return helper.getBoundingClientRect(nodeID); + } + + /** + * Get the computed style for a given property, on one of the elements of the + * box model highlighter, given its ID. + * @param {String} nodeID The full ID of the element to get the attribute for + * @param {String} property The name of the property + * @param {String} actorID The highlighter actor ID + * @return {String} The computed style of the property + */ + getHighlighterComputedStyle(nodeID, property, actorID) { + const helper = getHighlighterCanvasFrameHelper(this.conn, actorID); + + if (!helper) { + throw new Error(`Highlighter not found`); + } + + return helper.getElement(nodeID).computedStyle.getPropertyValue(property); + } + + /** + * Get the textcontent of one of the elements of the box model highlighter, + * given its ID. + * @param {String} nodeID The full ID of the element to get the attribute for + * @param {String} actorID The highlighter actor ID + * @return {String} The textcontent value + */ + getHighlighterNodeTextContent(nodeID, actorID) { + let value; + const helper = getHighlighterCanvasFrameHelper(this.conn, actorID); + if (helper) { + value = helper.getTextContentForElement(nodeID); + } + return value; + } + + /** + * Get the number of box-model highlighters created by the SelectorHighlighter + * @param {String} actorID The highlighter actor ID + * @return {Number} The number of box-model highlighters created, or null if the + * SelectorHighlighter was not found. + */ + getSelectorHighlighterBoxNb(actorID) { + const highlighter = this.conn.getActor(actorID); + const { _highlighter: h } = highlighter; + if (!h || !h._highlighters) { + return null; + } + return h._highlighters.length; + } + + /** + * Subscribe to the box-model highlighter's update event, modify an attribute of + * the currently highlighted node and send a message when the highlighter has + * updated. + * @param {String} the name of the attribute to be changed + * @param {String} the new value for the attribute + * @param {String} actorID The highlighter actor ID + */ + changeHighlightedNodeWaitForUpdate(name, value, actorID) { + return new Promise(resolve => { + const highlighter = this.conn.getActor(actorID); + const { _highlighter: h } = highlighter; + + h.once("updated", resolve); + + h.currentNode.setAttribute(name, value); + }); + } + + /** + * Register a one-time "updated" event listener. + * The method does not wait for the "updated" event itself so the response can be sent + * back and the client would know the event listener is properly set. + * A separate event, "highlighter-updated", will be emitted when the highlighter updates. + * + * @param {String} actorID The highlighter actor ID + */ + registerOneTimeHighlighterUpdate(actorID) { + const { _highlighter } = this.conn.getActor(actorID); + _highlighter.once("updated").then(() => this.emit("highlighter-updated")); + + // Return directly so the client knows the event listener is set + } + + async getNodeRect(selector) { + const node = this._querySelector(selector); + return getRect(this.content, node, this.content); + } + + async getTextNodeRect(parentSelector, childNodeIndex) { + const parentNode = this._querySelector(parentSelector); + const node = parentNode.childNodes[childNodeIndex]; + return getAdjustedQuads(this.content, node)[0].bounds; + } + + /** + * @returns {PausedDebuggerOverlay} The paused overlay instance + */ + _getPausedDebuggerOverlay() { + // We use `_pauseOverlay` since it's the cached value; `pauseOverlay` is a getter that + // will create the overlay when called (if it does not exist yet). + return this.targetActor?.threadActor?._pauseOverlay; + } + + isPausedDebuggerOverlayVisible() { + const pauseOverlay = this._getPausedDebuggerOverlay(); + if (!pauseOverlay) { + return false; + } + + const root = pauseOverlay.getElement("root"); + const toolbar = pauseOverlay.getElement("toolbar"); + + return ( + root.getAttribute("hidden") !== "true" && + root.getAttribute("overlay") == "true" && + toolbar.getAttribute("hidden") !== "true" && + !!toolbar.getTextContent() + ); + } + + /** + * Simulates a click on a button of the debugger pause overlay. + * + * @param {String} id: The id of the element (e.g. "paused-dbg-resume-button"). + */ + async clickPausedDebuggerOverlayButton(id) { + const pauseOverlay = this._getPausedDebuggerOverlay(); + if (!pauseOverlay) { + return; + } + + // Because the highlighter markup elements live inside an anonymous content frame which + // does not expose an API to dispatch events to them, we can't directly dispatch + // events to the nodes themselves. + // We're directly calling `handleEvent` on the pause overlay, which is the mouse events + // listener callback on the overlay. + pauseOverlay.handleEvent({ type: "mousedown", target: { id } }); + } + + /** + * @returns {EyeDropper} + */ + _getEyeDropper() { + const form = this.targetActor.form(); + const inspectorActor = this.conn._getOrCreateActor(form.inspectorActor); + return inspectorActor?._eyeDropper; + } + + isEyeDropperVisible() { + const eyeDropper = this._getEyeDropper(); + if (!eyeDropper) { + return false; + } + + return eyeDropper.getElement("root").getAttribute("hidden") !== "true"; + } + + getEyeDropperElementAttribute(elementId, attributeName) { + const eyeDropper = this._getEyeDropper(); + if (!eyeDropper) { + return null; + } + + return eyeDropper.getElement(elementId).getAttribute(attributeName); + } + + async getEyeDropperColorValue() { + const eyeDropper = this._getEyeDropper(); + if (!eyeDropper) { + return null; + } + + // It might happen that while the eyedropper isn't hidden anymore, the color-value + // is not set yet. + const color = await TestUtils.waitForCondition(() => { + const colorValueElement = eyeDropper.getElement("color-value"); + const textContent = colorValueElement.getTextContent(); + return textContent; + }, "Couldn't get a non-empty text content for the color-value element"); + + return color; + } + + /** + * Get the TabbingOrderHighlighter for the associated targetActor + * + * @returns {TabbingOrderHighlighter} + */ + _getTabbingOrderHighlighter() { + const form = this.targetActor.form(); + const accessibilityActor = this.conn._getOrCreateActor( + form.accessibilityActor + ); + + if (!accessibilityActor) { + return null; + } + // We use `_tabbingOrderHighlighter` since it's the cached value; `tabbingOrderHighlighter` + // is a getter that will create the highlighter when called (if it does not exist yet). + return accessibilityActor.walker?._tabbingOrderHighlighter; + } + + /** + * Get a representation of the NodeTabbingOrderHighlighters created by the + * TabbingOrderHighlighter of a given targetActor. + * + * @returns {Array} An array which will contain as many entry as they are + * NodeTabbingOrderHighlighters displayed. + * Each item will be of the form `nodename[#id]: index`. + * For example: + * [ + * `button#top-btn-1 : 1`, + * `html : 2`, + * `button#iframe-btn-1 : 3`, + * `button#iframe-btn-2 : 4`, + * `button#top-btn-2 : 5`, + * ] + */ + getTabbingOrderHighlighterData() { + const highlighter = this._getTabbingOrderHighlighter(); + if (!highlighter) { + return []; + } + + const nodeTabbingOrderHighlighters = [ + ...highlighter._highlighter._highlighters.values(), + ].filter(h => h.getElement("root").getAttribute("hidden") !== "true"); + + return nodeTabbingOrderHighlighters.map(h => { + let nodeStr = h.currentNode.nodeName.toLowerCase(); + if (h.currentNode.id) { + nodeStr = `${nodeStr}#${h.currentNode.id}`; + } + return `${nodeStr} : ${h.getElement("root").getTextContent()}`; + }); + } +} +exports.HighlighterTestActor = HighlighterTestActor; + +class HighlighterTestFront extends protocol.FrontClassWithSpec( + highlighterTestSpec +) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + this.formAttributeName = "highlighterTestActor"; + // The currently active highlighter is obtained by calling a custom getter + // provided manually after requesting TestFront. See `getHighlighterTestFront(toolbox)` + this._highlighter = null; + } + + /** + * Override the highlighter getter with a custom method that returns + * the currently active highlighter instance. + * + * @param {Function|Highlighter} _customHighlighterGetter + */ + set highlighter(_customHighlighterGetter) { + this._highlighter = _customHighlighterGetter; + } + + /** + * The currently active highlighter instance. + * If there is a custom getter for the highlighter, return its result. + * + * @return {Highlighter|null} + */ + get highlighter() { + return typeof this._highlighter === "function" + ? this._highlighter() + : this._highlighter; + } + + /* eslint-disable max-len */ + changeHighlightedNodeWaitForUpdate(name, value, highlighter) { + /* eslint-enable max-len */ + return super.changeHighlightedNodeWaitForUpdate( + name, + value, + (highlighter || this.highlighter).actorID + ); + } + + /** + * Get the value of an attribute on one of the highlighter's node. + * @param {String} nodeID The Id of the node in the highlighter. + * @param {String} name The name of the attribute. + * @param {Object} highlighter Optional custom highlighter to target + * @return {String} value + */ + getHighlighterNodeAttribute(nodeID, name, highlighter) { + return this.getHighlighterAttribute( + nodeID, + name, + (highlighter || this.highlighter).actorID + ); + } + + getHighlighterNodeTextContent(nodeID, highlighter) { + return super.getHighlighterNodeTextContent( + nodeID, + (highlighter || this.highlighter).actorID + ); + } + + /** + * Get the computed style of a property on one of the highlighter's node. + * @param {String} nodeID The Id of the node in the highlighter. + * @param {String} property The name of the property. + * @param {Object} highlighter Optional custom highlighter to target + * @return {String} value + */ + getHighlighterComputedStyle(nodeID, property, highlighter) { + return super.getHighlighterComputedStyle( + nodeID, + property, + (highlighter || this.highlighter).actorID + ); + } + + /** + * Is the highlighter currently visible on the page? + */ + async isHighlighting() { + // Once the highlighter is hidden, the reference to it is lost. + // Assume it is not highlighting. + if (!this.highlighter) { + return false; + } + + try { + const hidden = await this.getHighlighterNodeAttribute( + "box-model-elements", + "hidden" + ); + return hidden === null; + } catch (e) { + if (e.message.match(/Highlighter not found/)) { + return false; + } + throw e; + } + } + + /** + * Get the current rect of the border region of the box-model highlighter + */ + async getSimpleBorderRect() { + const { border } = await this.getBoxModelStatus(); + const { p1, p2, p4 } = border.points; + + return { + top: p1.y, + left: p1.x, + width: p2.x - p1.x, + height: p4.y - p1.y, + }; + } + + /** + * Get the current positions and visibility of the various box-model highlighter + * elements. + */ + async getBoxModelStatus() { + const isVisible = await this.isHighlighting(); + + const ret = { + visible: isVisible, + }; + + for (const region of ["margin", "border", "padding", "content"]) { + const points = await this._getPointsForRegion(region); + const visible = await this._isRegionHidden(region); + ret[region] = { points, visible }; + } + + ret.guides = {}; + for (const guide of ["top", "right", "bottom", "left"]) { + ret.guides[guide] = await this._getGuideStatus(guide); + } + + return ret; + } + + /** + * Check that the box-model highlighter is currently highlighting the node matching the + * given selector. + * @param {String} selector + * @return {Boolean} + */ + async assertHighlightedNode(selector) { + const rect = await this.getNodeRect(selector); + return this.isNodeRectHighlighted(rect); + } + + /** + * Check that the box-model highlighter is currently highlighting the text node that can + * be found at a given index within the list of childNodes of a parent element matching + * the given selector. + * @param {String} parentSelector + * @param {Number} childNodeIndex + * @return {Boolean} + */ + async assertHighlightedTextNode(parentSelector, childNodeIndex) { + const rect = await this.getTextNodeRect(parentSelector, childNodeIndex); + return this.isNodeRectHighlighted(rect); + } + + /** + * Check that the box-model highlighter is currently highlighting the given rect. + * @param {Object} rect + * @return {Boolean} + */ + async isNodeRectHighlighted({ left, top, width, height }) { + const { visible, border } = await this.getBoxModelStatus(); + let points = border.points; + if (!visible) { + return false; + } + + // Check that the node is within the box model + const right = left + width; + const bottom = top + height; + + // Converts points dictionnary into an array + const list = []; + for (let i = 1; i <= 4; i++) { + const p = points["p" + i]; + list.push([p.x, p.y]); + } + points = list; + + // Check that each point of the node is within the box model + return ( + isInside([left, top], points) && + isInside([right, top], points) && + isInside([right, bottom], points) && + isInside([left, bottom], points) + ); + } + + /** + * Get the coordinate (points attribute) from one of the polygon elements in the + * box model highlighter. + */ + async _getPointsForRegion(region) { + const d = await this.getHighlighterNodeAttribute( + "box-model-" + region, + "d" + ); + + if (!d) { + return null; + } + + const polygons = d.match(/M[^M]+/g); + if (!polygons) { + return null; + } + + const points = polygons[0] + .trim() + .split(" ") + .map(i => { + return i.replace(/M|L/, "").split(","); + }); + + return { + p1: { + x: parseFloat(points[0][0]), + y: parseFloat(points[0][1]), + }, + p2: { + x: parseFloat(points[1][0]), + y: parseFloat(points[1][1]), + }, + p3: { + x: parseFloat(points[2][0]), + y: parseFloat(points[2][1]), + }, + p4: { + x: parseFloat(points[3][0]), + y: parseFloat(points[3][1]), + }, + }; + } + + /** + * Is a given region polygon element of the box-model highlighter currently + * hidden? + */ + async _isRegionHidden(region) { + const value = await this.getHighlighterNodeAttribute( + "box-model-" + region, + "hidden" + ); + return value !== null; + } + + async _getGuideStatus(location) { + const id = "box-model-guide-" + location; + + const hidden = await this.getHighlighterNodeAttribute(id, "hidden"); + const x1 = await this.getHighlighterNodeAttribute(id, "x1"); + const y1 = await this.getHighlighterNodeAttribute(id, "y1"); + const x2 = await this.getHighlighterNodeAttribute(id, "x2"); + const y2 = await this.getHighlighterNodeAttribute(id, "y2"); + + return { + visible: !hidden, + x1, + y1, + x2, + y2, + }; + } + + /** + * Get the coordinates of the rectangle that is defined by the 4 guides displayed + * in the toolbox box-model highlighter. + * @return {Object} Null if at least one guide is hidden. Otherwise an object + * with p1, p2, p3, p4 properties being {x, y} objects. + */ + async getGuidesRectangle() { + const tGuide = await this._getGuideStatus("top"); + const rGuide = await this._getGuideStatus("right"); + const bGuide = await this._getGuideStatus("bottom"); + const lGuide = await this._getGuideStatus("left"); + + if ( + !tGuide.visible || + !rGuide.visible || + !bGuide.visible || + !lGuide.visible + ) { + return null; + } + + return { + p1: { x: lGuide.x1, y: tGuide.y1 }, + p2: { x: +rGuide.x1 + 1, y: tGuide.y1 }, + p3: { x: +rGuide.x1 + 1, y: +bGuide.y1 + 1 }, + p4: { x: lGuide.x1, y: +bGuide.y1 + 1 }, + }; + } + + /** + * Get the "d" attribute value for one of the box-model highlighter's region + * elements, and parse it to a list of points. + * @param {String} region The box model region name. + * @param {Front} highlighter The front of the highlighter. + * @return {Object} The object returned has the following form: + * - d {String} the d attribute value + * - points {Array} an array of all the polygons defined by the path. Each box + * is itself an Array of points, themselves being [x,y] coordinates arrays. + */ + async getHighlighterRegionPath(region, highlighter) { + const d = await this.getHighlighterNodeAttribute( + `box-model-${region}`, + "d", + highlighter + ); + if (!d) { + return { d: null }; + } + + const polygons = d.match(/M[^M]+/g); + if (!polygons) { + return { d }; + } + + const points = []; + for (const polygon of polygons) { + points.push( + polygon + .trim() + .split(" ") + .map(i => { + return i.replace(/M|L/, "").split(","); + }) + ); + } + + return { d, points }; + } +} +protocol.registerFront(HighlighterTestFront); +/** + * Check whether a point is included in a polygon. + * Taken and tweaked from: + * https://github.com/iominh/point-in-polygon-extended/blob/master/src/index.js#L30-L85 + * @param {Array} point [x,y] coordinates + * @param {Array} polygon An array of [x,y] points + * @return {Boolean} + */ +function isInside(point, polygon) { + if (polygon.length === 0) { + return false; + } + + // Reduce the length of the fractional part because this is likely to cause errors when + // the point is on the edge of the polygon. + point = point.map(n => n.toFixed(2)); + polygon = polygon.map(p => p.map(n => n.toFixed(2))); + + const n = polygon.length; + const newPoints = polygon.slice(0); + newPoints.push(polygon[0]); + let wn = 0; + + // loop through all edges of the polygon + for (let i = 0; i < n; i++) { + // Accept points on the edges + const r = isLeft(newPoints[i], newPoints[i + 1], point); + if (r === 0) { + return true; + } + if (newPoints[i][1] <= point[1]) { + if (newPoints[i + 1][1] > point[1] && r > 0) { + wn++; + } + } else if (newPoints[i + 1][1] <= point[1] && r < 0) { + wn--; + } + } + if (wn === 0) { + dumpn(JSON.stringify(point) + " is outside of " + JSON.stringify(polygon)); + } + // the point is outside only when this winding number wn===0, otherwise it's inside + return wn !== 0; +} + +function isLeft(p0, p1, p2) { + const l = + (p1[0] - p0[0]) * (p2[1] - p0[1]) - (p2[0] - p0[0]) * (p1[1] - p0[1]); + return l; +} diff --git a/devtools/client/shared/test/leakhunt.js b/devtools/client/shared/test/leakhunt.js new file mode 100644 index 0000000000..40b5fb6792 --- /dev/null +++ b/devtools/client/shared/test/leakhunt.js @@ -0,0 +1,173 @@ +/* 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"; + +/** + * Memory leak hunter. Walks a tree of objects looking for DOM nodes. + * Usage: + * leakHunt({ + * thing: thing, + * otherthing: otherthing + * }); + */ +function leakHunt(root) { + const path = []; + const seen = []; + + try { + const output = leakHunt.inner(root, path, seen); + output.forEach(function (line) { + dump(line + "\n"); + }); + } catch (ex) { + dump(ex + "\n"); + } +} + +leakHunt.inner = function (root, path, seen) { + const prefix = new Array(path.length).join(" "); + + const reply = []; + function log(msg) { + reply.push(msg); + } + + let direct; + try { + direct = Object.keys(root); + } catch (ex) { + log(prefix + " Error enumerating: " + ex); + return reply; + } + + try { + let index = 0; + for (const data of root) { + const prop = "" + index; + leakHunt.digProperty(prop, data, path, seen, direct, log); + index++; + } + } catch (ex) { + /* Ignore things that are not enumerable */ + } + + for (const prop in root) { + let data; + try { + data = root[prop]; + } catch (ex) { + log(prefix + " " + prop + " = Error: " + ex.toString().substring(0, 30)); + continue; + } + + leakHunt.digProperty(prop, data, path, seen, direct, log); + } + + return reply; +}; + +leakHunt.hide = [/^string$/, /^number$/, /^boolean$/, /^null/, /^undefined/]; + +leakHunt.noRecurse = [ + /^string$/, + /^number$/, + /^boolean$/, + /^null/, + /^undefined/, + /^Window$/, + /^Document$/, + /^XULElement$/, + /^DOMWindow$/, + /^HTMLDocument$/, + /^HTML.*Element$/, + /^ChromeWindow$/, +]; + +leakHunt.digProperty = function (prop, data, path, seen, direct, log) { + const newPath = path.slice(); + newPath.push(prop); + const prefix = new Array(newPath.length).join(" "); + + let recurse = true; + let message = leakHunt.getType(data); + + if (leakHunt.matchesAnyPattern(message, leakHunt.hide)) { + return; + } + + if (message === "function" && !direct.includes(prop)) { + return; + } + + if (message === "string") { + const extra = data.length > 10 ? data.substring(0, 9) + "_" : data; + message += ' "' + extra.replace(/\n/g, "|") + '"'; + recurse = false; + } else if (leakHunt.matchesAnyPattern(message, leakHunt.noRecurse)) { + message += " (no recurse)"; + recurse = false; + } else if (seen.includes(data)) { + message += " (already seen)"; + recurse = false; + } + + if (recurse) { + seen.push(data); + const lines = leakHunt.inner(data, newPath, seen); + if (!lines.length) { + if (message !== "function") { + log(prefix + prop + " = " + message + " { }"); + } + } else { + log(prefix + prop + " = " + message + " {"); + lines.forEach(function (line) { + log(line); + }); + log(prefix + "}"); + } + } else { + log(prefix + prop + " = " + message); + } +}; + +leakHunt.matchesAnyPattern = function (str, patterns) { + let match = false; + patterns.forEach(function (pattern) { + if (str.match(pattern)) { + match = true; + } + }); + return match; +}; + +leakHunt.getType = function (data) { + if (data === null) { + return "null"; + } + if (data === undefined) { + return "undefined"; + } + + let type = typeof data; + if (type === "object" || type === "Object") { + type = leakHunt.getCtorName(data); + } + + return type; +}; + +leakHunt.getCtorName = function (obj) { + try { + if (obj.constructor && obj.constructor.name) { + return obj.constructor.name; + } + } catch (ex) { + return "UnknownObject"; + } + + // If that fails, use Objects toString which sometimes gives something + // better than 'Object', and at least defaults to Object if nothing better + return Object.prototype.toString.call(obj).slice(8, -1); +}; diff --git a/devtools/client/shared/test/shared-head.js b/devtools/client/shared/test/shared-head.js new file mode 100644 index 0000000000..2c8df188a6 --- /dev/null +++ b/devtools/client/shared/test/shared-head.js @@ -0,0 +1,2324 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +/* import-globals-from ../../inspector/test/shared-head.js */ + +"use strict"; + +// This shared-head.js file is used by most mochitests +// and we start using it in xpcshell tests as well. +// It contains various common helper functions. + +const isMochitest = "gTestPath" in this; +const isXpcshell = !isMochitest; +if (isXpcshell) { + // gTestPath isn't exposed to xpcshell tests + // _TEST_FILE is an array for a unique string + /* global _TEST_FILE */ + this.gTestPath = _TEST_FILE[0]; +} + +const { Constructor: CC } = Components; + +// Print allocation count if DEBUG_DEVTOOLS_ALLOCATIONS is set to "normal", +// and allocation sites if DEBUG_DEVTOOLS_ALLOCATIONS is set to "verbose". +const DEBUG_ALLOCATIONS = Services.env.get("DEBUG_DEVTOOLS_ALLOCATIONS"); +if (DEBUG_ALLOCATIONS) { + // Use a custom loader with `invisibleToDebugger` flag for the allocation tracker + // as it instantiates custom Debugger API instances and has to be running in a distinct + // compartments from DevTools and system scopes (JSMs, XPCOM,...) + const { + useDistinctSystemPrincipalLoader, + releaseDistinctSystemPrincipalLoader, + } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs" + ); + const requester = {}; + const loader = useDistinctSystemPrincipalLoader(requester); + registerCleanupFunction(() => + releaseDistinctSystemPrincipalLoader(requester) + ); + + const { allocationTracker } = loader.require( + "resource://devtools/shared/test-helpers/allocation-tracker.js" + ); + const tracker = allocationTracker({ watchAllGlobals: true }); + registerCleanupFunction(() => { + if (DEBUG_ALLOCATIONS == "normal") { + tracker.logCount(); + } else if (DEBUG_ALLOCATIONS == "verbose") { + tracker.logAllocationSites(); + } + tracker.stop(); + }); +} + +const { loader, require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +// When loaded from xpcshell test, this file is loaded via xpcshell.ini's head property +// and so it loaded first before anything else and isn't having access to Services global. +// Whereas many head.js files from mochitest import this file via loadSubScript +// and already expose Services as a global. + +const { + gDevTools, +} = require("resource://devtools/client/framework/devtools.js"); +const { + CommandsFactory, +} = require("resource://devtools/shared/commands/commands-factory.js"); +const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); + +const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js"); + +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); + +loader.lazyRequireGetter( + this, + "ResponsiveUIManager", + "resource://devtools/client/responsive/manager.js" +); +loader.lazyRequireGetter( + this, + "localTypes", + "resource://devtools/client/responsive/types.js" +); +loader.lazyRequireGetter( + this, + "ResponsiveMessageHelper", + "resource://devtools/client/responsive/utils/message.js" +); + +loader.lazyRequireGetter( + this, + "FluentReact", + "resource://devtools/client/shared/vendor/fluent-react.js" +); + +const TEST_DIR = gTestPath.substr(0, gTestPath.lastIndexOf("/")); +const CHROME_URL_ROOT = TEST_DIR + "/"; +const URL_ROOT = CHROME_URL_ROOT.replace( + "chrome://mochitests/content/", + "http://example.com/" +); +const URL_ROOT_SSL = CHROME_URL_ROOT.replace( + "chrome://mochitests/content/", + "https://example.com/" +); + +// Add aliases which make it more explicit that URL_ROOT uses a com TLD. +const URL_ROOT_COM = URL_ROOT; +const URL_ROOT_COM_SSL = URL_ROOT_SSL; + +// Also expose http://example.org, http://example.net, https://example.org to +// test Fission scenarios easily. +// Note: example.net is not available for https. +const URL_ROOT_ORG = CHROME_URL_ROOT.replace( + "chrome://mochitests/content/", + "http://example.org/" +); +const URL_ROOT_ORG_SSL = CHROME_URL_ROOT.replace( + "chrome://mochitests/content/", + "https://example.org/" +); +const URL_ROOT_NET = CHROME_URL_ROOT.replace( + "chrome://mochitests/content/", + "http://example.net/" +); +const URL_ROOT_NET_SSL = CHROME_URL_ROOT.replace( + "chrome://mochitests/content/", + "https://example.net/" +); +// mochi.test:8888 is the actual primary location where files are served. +const URL_ROOT_MOCHI_8888 = CHROME_URL_ROOT.replace( + "chrome://mochitests/content/", + "http://mochi.test:8888/" +); + +try { + if (isMochitest) { + Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/telemetry-test-helpers.js", + this + ); + } +} catch (e) { + ok( + false, + "MISSING DEPENDENCY ON telemetry-test-helpers.js\n" + + "Please add the following line in browser.ini:\n" + + " !/devtools/client/shared/test/telemetry-test-helpers.js\n" + ); + throw e; +} + +// Force devtools to be initialized so menu items and keyboard shortcuts get installed +require("resource://devtools/client/framework/devtools-browser.js"); + +// All tests are asynchronous +if (isMochitest) { + waitForExplicitFinish(); +} + +var EXPECTED_DTU_ASSERT_FAILURE_COUNT = 0; + +registerCleanupFunction(function () { + if ( + DevToolsUtils.assertionFailureCount !== EXPECTED_DTU_ASSERT_FAILURE_COUNT + ) { + ok( + false, + "Should have had the expected number of DevToolsUtils.assert() failures." + + " Expected " + + EXPECTED_DTU_ASSERT_FAILURE_COUNT + + ", got " + + DevToolsUtils.assertionFailureCount + ); + } +}); + +// Uncomment this pref to dump all devtools emitted events to the console. +// Services.prefs.setBoolPref("devtools.dump.emit", true); + +/** + * Watch console messages for failed propType definitions in React components. + */ +function onConsoleMessage(subject) { + const message = subject.wrappedJSObject.arguments[0]; + + if (message && /Failed propType/.test(message.toString())) { + ok(false, message); + } +} + +const ConsoleAPIStorage = Cc["@mozilla.org/consoleAPI-storage;1"].getService( + Ci.nsIConsoleAPIStorage +); + +ConsoleAPIStorage.addLogEventListener( + onConsoleMessage, + Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal) +); +registerCleanupFunction(() => { + ConsoleAPIStorage.removeLogEventListener(onConsoleMessage); +}); + +Services.prefs.setBoolPref("devtools.inspector.three-pane-enabled", true); + +// Disable this preference to reduce exceptions related to pending `listWorkers` +// requests occuring after a process is created/destroyed. See Bug 1620983. +Services.prefs.setBoolPref("dom.ipc.processPrelaunch.enabled", false); + +// Disable this preference to capture async stacks across all locations during +// DevTools mochitests. Async stacks provide very valuable information to debug +// intermittents, but come with a performance overhead, which is why they are +// only captured in Debuggees by default. +Services.prefs.setBoolPref( + "javascript.options.asyncstack_capture_debuggee_only", + false +); + +// On some Linux platforms, prefers-reduced-motion is enabled, which would +// trigger the notification to be displayed in the toolbox. Dismiss the message +// by default. +Services.prefs.setBoolPref( + "devtools.inspector.simple-highlighters.message-dismissed", + true +); + +registerCleanupFunction(() => { + Services.prefs.clearUserPref("devtools.dump.emit"); + Services.prefs.clearUserPref("devtools.inspector.three-pane-enabled"); + Services.prefs.clearUserPref("dom.ipc.processPrelaunch.enabled"); + Services.prefs.clearUserPref("devtools.toolbox.host"); + Services.prefs.clearUserPref("devtools.toolbox.previousHost"); + Services.prefs.clearUserPref("devtools.toolbox.splitconsoleEnabled"); + Services.prefs.clearUserPref("devtools.toolbox.splitconsoleHeight"); + Services.prefs.clearUserPref( + "javascript.options.asyncstack_capture_debuggee_only" + ); + Services.prefs.clearUserPref( + "devtools.inspector.simple-highlighters.message-dismissed" + ); +}); + +var { + BrowserConsoleManager, +} = require("resource://devtools/client/webconsole/browser-console-manager.js"); + +registerCleanupFunction(async function cleanup() { + // Closing the browser console if there's one + const browserConsole = BrowserConsoleManager.getBrowserConsole(); + if (browserConsole) { + await safeCloseBrowserConsole({ clearOutput: true }); + } + + // Close any tab opened by the test. + // There should be only one tab opened by default when firefox starts the test. + while (isMochitest && gBrowser.tabs.length > 1) { + await closeTabAndToolbox(gBrowser.selectedTab); + } + + // Note that this will run before cleanup functions registered by tests or other head.js files. + // So all connections must be cleaned up by the test when the test ends, + // before the harness starts invoking the cleanup functions + await waitForTick(); + + // All connections must be cleaned up by the test when the test ends. + const { + DevToolsServer, + } = require("resource://devtools/server/devtools-server.js"); + ok( + !DevToolsServer.hasConnection(), + "The main process DevToolsServer has no pending connection when the test ends" + ); + // If there is still open connection, close all of them so that following tests + // could pass. + if (DevToolsServer.hasConnection()) { + for (const conn of Object.values(DevToolsServer._connections)) { + conn.close(); + } + } +}); + +async function safeCloseBrowserConsole({ clearOutput = false } = {}) { + const hud = BrowserConsoleManager.getBrowserConsole(); + if (!hud) { + return; + } + + if (clearOutput) { + info("Clear the browser console output"); + const { ui } = hud; + const promises = [ui.once("messages-cleared")]; + // If there's an object inspector, we need to wait for the actors to be released. + if (ui.outputNode.querySelector(".object-inspector")) { + promises.push(ui.once("fronts-released")); + } + await ui.clearOutput(true); + await Promise.all(promises); + info("Browser console cleared"); + } + + info("Wait for all Browser Console targets to be attached"); + // It might happen that waitForAllTargetsToBeAttached does not resolve, so we set a + // timeout of 1s before closing + await Promise.race([ + waitForAllTargetsToBeAttached(hud.commands.targetCommand), + wait(1000), + ]); + + info("Close the Browser Console"); + await BrowserConsoleManager.closeBrowserConsole(); + info("Browser Console closed"); +} + +/** + * Observer code to register the test actor in every DevTools server which + * starts registering its own actors. + * + * We require immediately the highlighter test actor file, because it will force to load and + * register the front and the spec for HighlighterTestActor. Normally specs and fronts are + * in separate files registered in specs/index.js. But here to simplify the + * setup everything is in the same file and we force to load it here. + * + * DevToolsServer will emit "devtools-server-initialized" after finishing its + * initialization. We watch this observable to add our custom actor. + * + * As a single test may create several DevTools servers, we keep the observer + * alive until the test ends. + * + * To avoid leaks, the observer needs to be removed at the end of each test. + * The test cleanup will send the async message "remove-devtools-highlightertestactor-observer", + * we listen to this message to cleanup the observer. + */ +function highlighterTestActorBootstrap() { + /* eslint-env mozilla/process-script */ + const HIGHLIGHTER_TEST_ACTOR_URL = + "chrome://mochitests/content/browser/devtools/client/shared/test/highlighter-test-actor.js"; + + const { require: _require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + _require(HIGHLIGHTER_TEST_ACTOR_URL); + + const actorRegistryObserver = subject => { + const actorRegistry = subject.wrappedJSObject; + actorRegistry.registerModule(HIGHLIGHTER_TEST_ACTOR_URL, { + prefix: "highlighterTest", + constructor: "HighlighterTestActor", + type: { target: true }, + }); + }; + Services.obs.addObserver( + actorRegistryObserver, + "devtools-server-initialized" + ); + + const unloadListener = () => { + Services.cpmm.removeMessageListener( + "remove-devtools-testactor-observer", + unloadListener + ); + Services.obs.removeObserver( + actorRegistryObserver, + "devtools-server-initialized" + ); + }; + Services.cpmm.addMessageListener( + "remove-devtools-testactor-observer", + unloadListener + ); +} + +if (isMochitest) { + const highlighterTestActorBootstrapScript = + "data:,(" + highlighterTestActorBootstrap + ")()"; + Services.ppmm.loadProcessScript( + highlighterTestActorBootstrapScript, + // Load this script in all processes (created or to be created) + true + ); + + registerCleanupFunction(() => { + Services.ppmm.broadcastAsyncMessage("remove-devtools-testactor-observer"); + Services.ppmm.removeDelayedProcessScript( + highlighterTestActorBootstrapScript + ); + }); +} + +/** + * Spawn an instance of the highlighter test actor for the given toolbox + * + * @param {Toolbox} toolbox + * @param {Object} options + * @param {Function} options.target: Optional target to get the highlighterTestFront for. + * If not provided, the top level target will be used. + * @returns {HighlighterTestFront} + */ +async function getHighlighterTestFront(toolbox, { target } = {}) { + // Loading the Inspector panel in order to overwrite the TestActor getter for the + // highlighter instance with a method that points to the currently visible + // Box Model Highlighter managed by the Inspector panel. + const inspector = await toolbox.loadTool("inspector"); + + const highlighterTestFront = await (target || toolbox.target).getFront( + "highlighterTest" + ); + // Override the highligher getter with a method to return the active box model + // highlighter. Adaptation for multi-process scenarios where there can be multiple + // highlighters, one per process. + highlighterTestFront.highlighter = () => { + return inspector.highlighters.getActiveHighlighter( + inspector.highlighters.TYPES.BOXMODEL + ); + }; + return highlighterTestFront; +} + +/** + * Spawn an instance of the highlighter test actor for the given tab, when we need the + * highlighter test front before opening or without a toolbox. + * + * @param {Tab} tab + * @returns {HighlighterTestFront} + */ +async function getHighlighterTestFrontWithoutToolbox(tab) { + const commands = await CommandsFactory.forTab(tab); + // Initialize the TargetCommands which require some async stuff to be done + // before being fully ready. This will define the `targetCommand.targetFront` attribute. + await commands.targetCommand.startListening(); + + const targetFront = commands.targetCommand.targetFront; + return targetFront.getFront("highlighterTest"); +} + +/** + * Returns a Promise that resolves when all the targets are fully attached. + * + * @param {TargetCommand} targetCommand + */ +function waitForAllTargetsToBeAttached(targetCommand) { + return Promise.allSettled( + targetCommand + .getAllTargets(targetCommand.ALL_TYPES) + .map(target => target.initialized) + ); +} + +/** + * Add a new test tab in the browser and load the given url. + * @param {String} url The url to be loaded in the new tab + * @param {Object} options Object with various optional fields: + * - {Boolean} background If true, open the tab in background + * - {ChromeWindow} window Firefox top level window we should use to open the tab + * - {Number} userContextId The userContextId of the tab. + * - {String} preferredRemoteType + * - {Boolean} waitForLoad Wait for the page in the new tab to load. (Defaults to true.) + * @return a promise that resolves to the tab object when the url is loaded + */ +async function addTab(url, options = {}) { + info("Adding a new tab with URL: " + url); + + const { + background = false, + userContextId, + preferredRemoteType, + waitForLoad = true, + } = options; + const { gBrowser } = options.window ? options.window : window; + + const tab = BrowserTestUtils.addTab(gBrowser, url, { + userContextId, + preferredRemoteType, + }); + + if (!background) { + gBrowser.selectedTab = tab; + } + + if (waitForLoad) { + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + // Waiting for presShell helps with test timeouts in webrender platforms. + await waitForPresShell(tab.linkedBrowser); + info("Tab added and finished loading"); + } else { + info("Tab added"); + } + + return tab; +} + +/** + * Remove the given tab. + * @param {Object} tab The tab to be removed. + * @return Promise resolved when the tab is successfully removed. + */ +async function removeTab(tab) { + info("Removing tab."); + + const { gBrowser } = tab.ownerDocument.defaultView; + const onClose = once(gBrowser.tabContainer, "TabClose"); + gBrowser.removeTab(tab); + await onClose; + + info("Tab removed and finished closing"); +} + +/** + * Alias for navigateTo which will reuse the current URI of the provided browser + * to trigger a navigation. + */ +async function reloadBrowser({ + browser = gBrowser.selectedBrowser, + isErrorPage = false, + waitForLoad = true, +} = {}) { + return navigateTo(browser.currentURI.spec, { + browser, + isErrorPage, + waitForLoad, + }); +} + +/** + * Navigate the currently selected tab to a new URL and wait for it to load. + * Also wait for the toolbox to attach to the new target, if we navigated + * to a new process. + * + * @param {String} url The url to be loaded in the current tab. + * @param {JSON} options Optional dictionary object with the following keys: + * - {XULBrowser} browser + * The browser element which should navigate. Defaults to the selected + * browser. + * - {Boolean} isErrorPage + * You may pass `true` if the URL is an error page. Otherwise + * BrowserTestUtils.browserLoaded will wait for 'load' event, which + * never fires for error pages. + * - {Boolean} waitForLoad + * You may pass `false` if the page load is expected to be blocked by + * a script or a breakpoint. + * + * @return a promise that resolves when the page has fully loaded. + */ +async function navigateTo( + uri, + { + browser = gBrowser.selectedBrowser, + isErrorPage = false, + waitForLoad = true, + } = {} +) { + const waitForDevToolsReload = await watchForDevToolsReload(browser, { + isErrorPage, + waitForLoad, + }); + + uri = uri.replaceAll("\n", ""); + info(`Navigating to "${uri}"`); + + const onBrowserLoaded = BrowserTestUtils.browserLoaded( + browser, + // includeSubFrames + false, + // resolve on this specific page to load (if null, it would be any page load) + loadedUrl => { + // loadedUrl is encoded, while uri might not be. + return loadedUrl === uri || decodeURI(loadedUrl) === uri; + }, + isErrorPage + ); + + // if we're navigating to the same page we're already on, use reloadTab instead as the + // behavior slightly differs from loadURI (e.g. scroll position isn't keps with the latter). + if (uri === browser.currentURI.spec) { + gBrowser.reloadTab(gBrowser.getTabForBrowser(browser)); + } else { + BrowserTestUtils.startLoadingURIString(browser, uri); + } + + if (waitForLoad) { + info(`Waiting for page to be loaded…`); + await onBrowserLoaded; + info(`→ page loaded`); + } + + await waitForDevToolsReload(); +} + +/** + * This method should be used to watch for completion of any browser navigation + * performed with a DevTools UI. + * + * It should watch for: + * - Toolbox reload + * - Toolbox commands reload + * - RDM reload + * - RDM commands reload + * + * And it should work both for target switching or old-style navigations. + * + * This method, similarly to all the other watch* navigation methods in this file, + * is async but returns another method which should be called after the navigation + * is done. Browser navigation might be monitored differently depending on the + * situation, so it's up to the caller to handle it as needed. + * + * Typically, this would be used as follows: + * ``` + * async function someNavigationHelper(browser) { + * const waitForDevToolsFn = await watchForDevToolsReload(browser); + * + * // This step should wait for the load to be completed from the browser's + * // point of view, so that waitForDevToolsFn can compare pIds, browsing + * // contexts etc... and check if we should expect a target switch + * await performBrowserNavigation(browser); + * + * await waitForDevToolsFn(); + * } + * ``` + */ +async function watchForDevToolsReload( + browser, + { isErrorPage = false, waitForLoad = true } = {} +) { + const waitForToolboxReload = await _watchForToolboxReload(browser, { + isErrorPage, + waitForLoad, + }); + const waitForResponsiveReload = await _watchForResponsiveReload(browser, { + isErrorPage, + waitForLoad, + }); + + return async function () { + info("Wait for the toolbox to reload"); + await waitForToolboxReload(); + + info("Wait for Responsive UI to reload"); + await waitForResponsiveReload(); + }; +} + +/** + * Start watching for the toolbox reload to be completed: + * - watch for the toolbox's commands to be fully reloaded + * - watch for the toolbox's current panel to be reloaded + */ +async function _watchForToolboxReload( + browser, + { isErrorPage, waitForLoad } = {} +) { + const tab = gBrowser.getTabForBrowser(browser); + + const toolbox = gDevTools.getToolboxForTab(tab); + + if (!toolbox) { + // No toolbox to wait for + return function () {}; + } + + const waitForCurrentPanelReload = watchForCurrentPanelReload(toolbox); + const waitForToolboxCommandsReload = await watchForCommandsReload( + toolbox.commands, + { isErrorPage, waitForLoad } + ); + const checkTargetSwitching = await watchForTargetSwitching( + toolbox.commands, + browser + ); + + return async function () { + const isTargetSwitching = checkTargetSwitching(); + + info(`Waiting for toolbox commands to be reloaded…`); + await waitForToolboxCommandsReload(isTargetSwitching); + + // TODO: We should wait for all loaded panels to reload here, because some + // of them might still perform background updates. + if (waitForCurrentPanelReload) { + info(`Waiting for ${toolbox.currentToolId} to be reloaded…`); + await waitForCurrentPanelReload(); + info(`→ panel reloaded`); + } + }; +} + +/** + * Start watching for Responsive UI (RDM) reload to be completed: + * - watch for the Responsive UI's commands to be fully reloaded + * - watch for the Responsive UI's target switch to be done + */ +async function _watchForResponsiveReload( + browser, + { isErrorPage, waitForLoad } = {} +) { + const tab = gBrowser.getTabForBrowser(browser); + const ui = ResponsiveUIManager.getResponsiveUIForTab(tab); + + if (!ui) { + // No responsive UI to wait for + return function () {}; + } + + const onResponsiveTargetSwitch = ui.once("responsive-ui-target-switch-done"); + const waitForResponsiveCommandsReload = await watchForCommandsReload( + ui.commands, + { isErrorPage, waitForLoad } + ); + const checkTargetSwitching = await watchForTargetSwitching( + ui.commands, + browser + ); + + return async function () { + const isTargetSwitching = checkTargetSwitching(); + + info(`Waiting for responsive ui commands to be reloaded…`); + await waitForResponsiveCommandsReload(isTargetSwitching); + + if (isTargetSwitching) { + await onResponsiveTargetSwitch; + } + }; +} + +/** + * Watch for the current panel selected in the provided toolbox to be reloaded. + * Some panels implement custom events that should be expected for every reload. + * + * Note about returning a method instead of a promise: + * In general this pattern is useful so that we can check if a target switch + * occurred or not, and decide which events to listen for. So far no panel is + * behaving differently whether there was a target switch or not. But to remain + * consistent with other watch* methods we still return a function here. + * + * @param {Toolbox} + * The Toolbox instance which is going to experience a reload + * @return {function} An async method to be called and awaited after the reload + * started. Will return `null` for panels which don't implement any + * specific reload event. + */ +function watchForCurrentPanelReload(toolbox) { + return _watchForPanelReload(toolbox, toolbox.currentToolId); +} + +/** + * Watch for all the panels loaded in the provided toolbox to be reloaded. + * Some panels implement custom events that should be expected for every reload. + * + * Note about returning a method instead of a promise: + * See comment for watchForCurrentPanelReload + * + * @param {Toolbox} + * The Toolbox instance which is going to experience a reload + * @return {function} An async method to be called and awaited after the reload + * started. + */ +function watchForLoadedPanelsReload(toolbox) { + const waitForPanels = []; + for (const [id] of toolbox.getToolPanels()) { + // Store a watcher method for each panel already loaded. + waitForPanels.push(_watchForPanelReload(toolbox, id)); + } + + return function () { + return Promise.all( + waitForPanels.map(async watchPanel => { + // Wait for all panels to be reloaded. + if (watchPanel) { + await watchPanel(); + } + }) + ); + }; +} + +function _watchForPanelReload(toolbox, toolId) { + const panel = toolbox.getPanel(toolId); + + if (toolId == "inspector") { + const markuploaded = panel.once("markuploaded"); + const onNewRoot = panel.once("new-root"); + const onUpdated = panel.once("inspector-updated"); + const onReloaded = panel.once("reloaded"); + + return async function () { + info("Waiting for markup view to load after navigation."); + await markuploaded; + + info("Waiting for new root."); + await onNewRoot; + + info("Waiting for inspector to update after new-root event."); + await onUpdated; + + info("Waiting for inspector updates after page reload"); + await onReloaded; + }; + } else if ( + ["netmonitor", "accessibility", "webconsole", "jsdebugger"].includes(toolId) + ) { + const onReloaded = panel.once("reloaded"); + return async function () { + info(`Waiting for ${toolId} updates after page reload`); + await onReloaded; + }; + } + return null; +} + +/** + * Watch for a Commands instance to be reloaded after a navigation. + * + * As for other navigation watch* methods, this should be called before the + * navigation starts, and the function it returns should be called after the + * navigation is done from a Browser point of view. + * + * !!! The wait function expects a `isTargetSwitching` argument to be provided, + * which needs to be monitored using watchForTargetSwitching !!! + */ +async function watchForCommandsReload( + commands, + { isErrorPage = false, waitForLoad = true } = {} +) { + // If we're switching origins, we need to wait for the 'switched-target' + // event to make sure everything is ready. + // Navigating from/to pages loaded in the parent process, like about:robots, + // also spawn new targets. + // (If target switching is disabled, the toolbox will reboot) + const onTargetSwitched = commands.targetCommand.once("switched-target"); + + // Wait until we received a page load resource: + // - dom-complete if we can wait for a full page load + // - dom-loading otherwise + // This allows to wait for page load for consumers calling directly + // waitForDevTools instead of navigateTo/reloadBrowser. + // This is also useful as an alternative to target switching, when no target + // switch is supposed to happen. + const waitForCompleteLoad = waitForLoad && !isErrorPage; + const documentEventName = waitForCompleteLoad + ? "dom-complete" + : "dom-loading"; + + const { onResource: onTopLevelDomEvent } = + await commands.resourceCommand.waitForNextResource( + commands.resourceCommand.TYPES.DOCUMENT_EVENT, + { + ignoreExistingResources: true, + predicate: resource => + resource.targetFront.isTopLevel && + resource.name === documentEventName, + } + ); + + return async function (isTargetSwitching) { + if (typeof isTargetSwitching === "undefined") { + throw new Error("isTargetSwitching was not provided to the wait method"); + } + + if (isTargetSwitching) { + info(`Waiting for target switch…`); + await onTargetSwitched; + info(`→ switched-target emitted`); + } + + info(`Waiting for '${documentEventName}' resource…`); + await onTopLevelDomEvent; + info(`→ '${documentEventName}' resource emitted`); + + return isTargetSwitching; + }; +} + +/** + * Watch if an upcoming navigation will trigger a target switching, for the + * provided Commands instance and the provided Browser. + * + * As for other navigation watch* methods, this should be called before the + * navigation starts, and the function it returns should be called after the + * navigation is done from a Browser point of view. + */ +async function watchForTargetSwitching(commands, browser) { + browser = browser || gBrowser.selectedBrowser; + const currentPID = browser.browsingContext.currentWindowGlobal.osPid; + const currentBrowsingContextID = browser.browsingContext.id; + + // If the current top-level target follows the window global lifecycle, a + // target switch will occur regardless of process changes. + const targetFollowsWindowLifecycle = + commands.targetCommand.targetFront.targetForm.followWindowGlobalLifeCycle; + + return function () { + // Compare the PIDs (and not the toolbox's targets) as PIDs are updated also immediately, + // while target may be updated slightly later. + const switchedProcess = + currentPID !== browser.browsingContext.currentWindowGlobal.osPid; + const switchedBrowsingContext = + currentBrowsingContextID !== browser.browsingContext.id; + + return ( + targetFollowsWindowLifecycle || switchedProcess || switchedBrowsingContext + ); + }; +} + +/** + * Create a Target for the provided tab and attach to it before resolving. + * This should only be used for tests which don't involve the frontend or a + * toolbox. Typically, retrieving the target and attaching to it should be + * handled at framework level when a Toolbox is used. + * + * @param {XULTab} tab + * The tab for which a target should be created. + * @return {WindowGlobalTargetFront} The attached target front. + */ +async function createAndAttachTargetForTab(tab) { + info("Creating and attaching to a local tab target"); + + const commands = await CommandsFactory.forTab(tab); + + // Initialize the TargetCommands which require some async stuff to be done + // before being fully ready. This will define the `targetCommand.targetFront` attribute. + await commands.targetCommand.startListening(); + + const target = commands.targetCommand.targetFront; + return target; +} + +function isFissionEnabled() { + return SpecialPowers.useRemoteSubframes; +} + +function isEveryFrameTargetEnabled() { + return Services.prefs.getBoolPref( + "devtools.every-frame-target.enabled", + false + ); +} + +/** + * Open the inspector in a tab with given URL. + * @param {string} url The URL to open. + * @param {String} hostType Optional hostType, as defined in Toolbox.HostType + * @return A promise that is resolved once the tab and inspector have loaded + * with an object: { tab, toolbox, inspector, highlighterTestFront }. + */ +async function openInspectorForURL(url, hostType) { + const tab = await addTab(url); + const { inspector, toolbox, highlighterTestFront } = await openInspector( + hostType + ); + return { tab, inspector, toolbox, highlighterTestFront }; +} + +function getActiveInspector() { + const toolbox = gDevTools.getToolboxForTab(gBrowser.selectedTab); + return toolbox.getPanel("inspector"); +} + +/** + * Simulate a key event from an electron key shortcut string: + * https://github.com/electron/electron/blob/master/docs/api/accelerator.md + * + * @param {String} key + * @param {DOMWindow} target + * Optional window where to fire the key event + */ +function synthesizeKeyShortcut(key, target) { + // parseElectronKey requires any window, just to access `KeyboardEvent` + const window = Services.appShell.hiddenDOMWindow; + const shortcut = KeyShortcuts.parseElectronKey(window, key); + const keyEvent = { + altKey: shortcut.alt, + ctrlKey: shortcut.ctrl, + metaKey: shortcut.meta, + shiftKey: shortcut.shift, + }; + if (shortcut.keyCode) { + keyEvent.keyCode = shortcut.keyCode; + } + + info("Synthesizing key shortcut: " + key); + EventUtils.synthesizeKey(shortcut.key || "", keyEvent, target); +} + +var waitForTime = DevToolsUtils.waitForTime; + +/** + * Wait for a tick. + * @return {Promise} + */ +function waitForTick() { + return new Promise(resolve => DevToolsUtils.executeSoon(resolve)); +} + +/** + * This shouldn't be used in the tests, but is useful when writing new tests or + * debugging existing tests in order to introduce delays in the test steps + * + * @param {Number} ms + * The time to wait + * @return A promise that resolves when the time is passed + */ +function wait(ms) { + return new Promise(resolve => { + setTimeout(resolve, ms); + info("Waiting " + ms / 1000 + " seconds."); + }); +} + +/** + * Wait for a predicate to return a result. + * + * @param function condition + * Invoked once in a while until it returns a truthy value. This should be an + * idempotent function, since we have to run it a second time after it returns + * true in order to return the value. + * @param string message [optional] + * A message to output if the condition fails. + * @param number interval [optional] + * How often the predicate is invoked, in milliseconds. + * Can be set globally for a test via `waitFor.overrideIntervalForTestFile = someNumber;`. + * @param number maxTries [optional] + * How many times the predicate is invoked before timing out. + * Can be set globally for a test via `waitFor.overrideMaxTriesForTestFile = someNumber;`. + * @return object + * A promise that is resolved with the result of the condition. + */ +async function waitFor(condition, message = "", interval = 10, maxTries = 500) { + // Update interval & maxTries if overrides are defined on the waitFor object. + interval = + typeof waitFor.overrideIntervalForTestFile !== "undefined" + ? waitFor.overrideIntervalForTestFile + : interval; + maxTries = + typeof waitFor.overrideMaxTriesForTestFile !== "undefined" + ? waitFor.overrideMaxTriesForTestFile + : maxTries; + + try { + const value = await BrowserTestUtils.waitForCondition( + condition, + message, + interval, + maxTries + ); + return value; + } catch (e) { + const errorMessage = `Failed waitFor(): ${message} \nFailed condition: ${condition} \nException Message: ${e}`; + throw new Error(errorMessage); + } +} + +/** + * Wait for eventName on target to be delivered a number of times. + * + * @param {Object} target + * An observable object that either supports on/off or + * addEventListener/removeEventListener + * @param {String} eventName + * @param {Number} numTimes + * Number of deliveries to wait for. + * @param {Boolean} useCapture + * Optional, for addEventListener/removeEventListener + * @return A promise that resolves when the event has been handled + */ +function waitForNEvents(target, eventName, numTimes, useCapture = false) { + info("Waiting for event: '" + eventName + "' on " + target + "."); + + let count = 0; + + return new Promise(resolve => { + for (const [add, remove] of [ + ["on", "off"], + ["addEventListener", "removeEventListener"], + ["addListener", "removeListener"], + ["addMessageListener", "removeMessageListener"], + ]) { + if (add in target && remove in target) { + target[add]( + eventName, + function onEvent(...args) { + if (typeof info === "function") { + info("Got event: '" + eventName + "' on " + target + "."); + } + + if (++count == numTimes) { + target[remove](eventName, onEvent, useCapture); + resolve(...args); + } + }, + useCapture + ); + break; + } + } + }); +} + +/** + * Wait for DOM change on target. + * + * @param {Object} target + * The Node on which to observe DOM mutations. + * @param {String} selector + * Given a selector to watch whether the expected element is changed + * on target. + * @param {Number} expectedLength + * Optional, default set to 1 + * There may be more than one element match an array match the selector, + * give an expected length to wait for more elements. + * @return A promise that resolves when the event has been handled + */ +function waitForDOM(target, selector, expectedLength = 1) { + return new Promise(resolve => { + const observer = new MutationObserver(mutations => { + mutations.forEach(mutation => { + const elements = mutation.target.querySelectorAll(selector); + + if (elements.length === expectedLength) { + observer.disconnect(); + resolve(elements); + } + }); + }); + + observer.observe(target, { + attributes: true, + childList: true, + subtree: true, + }); + }); +} + +/** + * Wait for eventName on target. + * + * @param {Object} target + * An observable object that either supports on/off or + * addEventListener/removeEventListener + * @param {String} eventName + * @param {Boolean} useCapture + * Optional, for addEventListener/removeEventListener + * @return A promise that resolves when the event has been handled + */ +function once(target, eventName, useCapture = false) { + return waitForNEvents(target, eventName, 1, useCapture); +} + +/** + * Some tests may need to import one or more of the test helper scripts. + * A test helper script is simply a js file that contains common test code that + * is either not common-enough to be in head.js, or that is located in a + * separate directory. + * The script will be loaded synchronously and in the test's scope. + * @param {String} filePath The file path, relative to the current directory. + * Examples: + * - "helper_attributes_test_runner.js" + */ +function loadHelperScript(filePath) { + const testDir = gTestPath.substr(0, gTestPath.lastIndexOf("/")); + Services.scriptloader.loadSubScript(testDir + "/" + filePath, this); +} + +/** + * Open the toolbox in a given tab. + * @param {XULNode} tab The tab the toolbox should be opened in. + * @param {String} toolId Optional. The ID of the tool to be selected. + * @param {String} hostType Optional. The type of toolbox host to be used. + * @return {Promise} Resolves with the toolbox, when it has been opened. + */ +async function openToolboxForTab(tab, toolId, hostType) { + info("Opening the toolbox"); + + // Check if the toolbox is already loaded. + let toolbox = gDevTools.getToolboxForTab(tab); + if (toolbox) { + if (!toolId || (toolId && toolbox.getPanel(toolId))) { + info("Toolbox is already opened"); + return toolbox; + } + } + + // If not, load it now. + toolbox = await gDevTools.showToolboxForTab(tab, { toolId, hostType }); + + // Make sure that the toolbox frame is focused. + await new Promise(resolve => waitForFocus(resolve, toolbox.win)); + + info("Toolbox opened and focused"); + + return toolbox; +} + +/** + * Add a new tab and open the toolbox in it. + * @param {String} url The URL for the tab to be opened. + * @param {String} toolId Optional. The ID of the tool to be selected. + * @param {String} hostType Optional. The type of toolbox host to be used. + * @return {Promise} Resolves when the tab has been added, loaded and the + * toolbox has been opened. Resolves to the toolbox. + */ +async function openNewTabAndToolbox(url, toolId, hostType) { + const tab = await addTab(url); + return openToolboxForTab(tab, toolId, hostType); +} + +/** + * Close a tab and if necessary, the toolbox that belongs to it + * @param {Tab} tab The tab to close. + * @return {Promise} Resolves when the toolbox and tab have been destroyed and + * closed. + */ +async function closeTabAndToolbox(tab = gBrowser.selectedTab) { + if (gDevTools.hasToolboxForTab(tab)) { + await gDevTools.closeToolboxForTab(tab); + } + + await removeTab(tab); + + await new Promise(resolve => setTimeout(resolve, 0)); +} + +/** + * Close a toolbox and the current tab. + * @param {Toolbox} toolbox The toolbox to close. + * @return {Promise} Resolves when the toolbox and tab have been destroyed and + * closed. + */ +async function closeToolboxAndTab(toolbox) { + await toolbox.destroy(); + await removeTab(gBrowser.selectedTab); +} + +/** + * Waits until a predicate returns true. + * + * @param function predicate + * Invoked once in a while until it returns true. + * @param number interval [optional] + * How often the predicate is invoked, in milliseconds. + */ +function waitUntil(predicate, interval = 10) { + if (predicate()) { + return Promise.resolve(true); + } + return new Promise(resolve => { + setTimeout(function () { + waitUntil(predicate, interval).then(() => resolve(true)); + }, interval); + }); +} + +/** + * Variant of waitUntil that accepts a predicate returning a promise. + */ +async function asyncWaitUntil(predicate, interval = 10) { + let success = await predicate(); + while (!success) { + // Wait for X milliseconds. + await new Promise(resolve => setTimeout(resolve, interval)); + // Test the predicate again. + success = await predicate(); + } +} + +/** + * Wait for a context menu popup to open. + * + * @param Element popup + * The XUL popup you expect to open. + * @param Element button + * The button/element that receives the contextmenu event. This is + * expected to open the popup. + * @param function onShown + * Function to invoke on popupshown event. + * @param function onHidden + * Function to invoke on popuphidden event. + * @return object + * A Promise object that is resolved after the popuphidden event + * callback is invoked. + */ +function waitForContextMenu(popup, button, onShown, onHidden) { + return new Promise(resolve => { + function onPopupShown() { + info("onPopupShown"); + popup.removeEventListener("popupshown", onPopupShown); + + onShown && onShown(); + + // Use executeSoon() to get out of the popupshown event. + popup.addEventListener("popuphidden", onPopupHidden); + DevToolsUtils.executeSoon(() => popup.hidePopup()); + } + function onPopupHidden() { + info("onPopupHidden"); + popup.removeEventListener("popuphidden", onPopupHidden); + + onHidden && onHidden(); + + resolve(popup); + } + + popup.addEventListener("popupshown", onPopupShown); + + info("wait for the context menu to open"); + synthesizeContextMenuEvent(button); + }); +} + +function synthesizeContextMenuEvent(el) { + el.scrollIntoView(); + const eventDetails = { type: "contextmenu", button: 2 }; + EventUtils.synthesizeMouse( + el, + 5, + 2, + eventDetails, + el.ownerDocument.defaultView + ); +} + +/** + * Promise wrapper around SimpleTest.waitForClipboard + */ +function waitForClipboardPromise(setup, expected) { + return new Promise((resolve, reject) => { + SimpleTest.waitForClipboard(expected, setup, resolve, reject); + }); +} + +/** + * Simple helper to push a temporary preference. Wrapper on SpecialPowers + * pushPrefEnv that returns a promise resolving when the preferences have been + * updated. + * + * @param {String} preferenceName + * The name of the preference to updated + * @param {} value + * The preference value, type can vary + * @return {Promise} resolves when the preferences have been updated + */ +function pushPref(preferenceName, value) { + const options = { set: [[preferenceName, value]] }; + return SpecialPowers.pushPrefEnv(options); +} + +async function closeToolbox() { + await gDevTools.closeToolboxForTab(gBrowser.selectedTab); +} + +/** + * Clean the logical clipboard content. This method only clears the OS clipboard on + * Windows (see Bug 666254). + */ +function emptyClipboard() { + const clipboard = Services.clipboard; + clipboard.emptyClipboard(clipboard.kGlobalClipboard); +} + +/** + * Check if the current operating system is Windows. + */ +function isWindows() { + return Services.appinfo.OS === "WINNT"; +} + +/** + * Create an HTTP server that can be used to simulate custom requests within + * a test. It is automatically cleaned up when the test ends, so no need to + * call `destroy`. + * + * See https://developer.mozilla.org/en-US/docs/Httpd.js/HTTP_server_for_unit_tests + * for more information about how to register handlers. + * + * The server can be accessed like: + * + * const server = createTestHTTPServer(); + * let url = "http://localhost: " + server.identity.primaryPort + "/path"; + * @returns {HttpServer} + */ +function createTestHTTPServer() { + const { HttpServer } = ChromeUtils.importESModule( + "resource://testing-common/httpd.sys.mjs" + ); + const server = new HttpServer(); + + registerCleanupFunction(async function cleanup() { + await new Promise(resolve => server.stop(resolve)); + }); + + server.start(-1); + return server; +} + +/* + * Register an actor in the content process of the current tab. + * + * Calling ActorRegistry.registerModule only registers the actor in the current process. + * As all test scripts are ran in the parent process, it is only registered here. + * This function helps register them in the content process used for the current tab. + * + * @param {string} url + * Actor module URL or absolute require path + * @param {json} options + * Arguments to be passed to DevToolsServer.registerModule + */ +async function registerActorInContentProcess(url, options) { + function convertChromeToFile(uri) { + return Cc["@mozilla.org/chrome/chrome-registry;1"] + .getService(Ci.nsIChromeRegistry) + .convertChromeURL(Services.io.newURI(uri)).spec; + } + // chrome://mochitests URI is registered only in the parent process, so convert these + // URLs to file:// one in order to work in the content processes + url = url.startsWith("chrome://mochitests") ? convertChromeToFile(url) : url; + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [{ url, options }], + args => { + // eslint-disable-next-line no-shadow + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + ActorRegistry, + } = require("resource://devtools/server/actors/utils/actor-registry.js"); + ActorRegistry.registerModule(args.url, args.options); + } + ); +} + +/** + * Move the provided Window to the provided left, top coordinates and wait for + * the window position to be updated. + */ +async function moveWindowTo(win, left, top) { + // Check that the expected coordinates are within the window available area. + left = Math.max(win.screen.availLeft, left); + left = Math.min(win.screen.width, left); + top = Math.max(win.screen.availTop, top); + top = Math.min(win.screen.height, top); + + info(`Moving window to {${left}, ${top}}`); + win.moveTo(left, top); + + // Bug 1600809: window move/resize can be async on Linux sometimes. + // Wait so that the anchor's position is correctly measured. + return waitUntil(() => { + info( + `Wait for window screenLeft and screenTop to be updated: (${win.screenLeft}, ${win.screenTop})` + ); + return win.screenLeft === left && win.screenTop === top; + }); +} + +function getCurrentTestFilePath() { + return gTestPath.replace("chrome://mochitests/content/browser/", ""); +} + +/** + * Unregister all registered service workers. + * + * @param {DevToolsClient} client + */ +async function unregisterAllServiceWorkers(client) { + info("Wait until all workers have a valid registrationFront"); + let workers; + await asyncWaitUntil(async function () { + workers = await client.mainRoot.listAllWorkers(); + const allWorkersRegistered = workers.service.every( + worker => !!worker.registrationFront + ); + return allWorkersRegistered; + }); + + info("Unregister all service workers"); + const promises = []; + for (const worker of workers.service) { + promises.push(worker.registrationFront.unregister()); + } + await Promise.all(promises); +} + +/********************** + * Screenshot helpers * + **********************/ + +/** + * Returns an object containing the r,g and b colors of the provided image at + * the passed position + * + * @param {Image} image + * @param {Int} x + * @param {Int} y + * @returns Object with the following properties: + * - {Int} r: The red component of the pixel + * - {Int} g: The green component of the pixel + * - {Int} b: The blue component of the pixel + */ +function colorAt(image, x, y) { + // Create a test canvas element. + const HTML_NS = "http://www.w3.org/1999/xhtml"; + const canvas = document.createElementNS(HTML_NS, "canvas"); + canvas.width = image.width; + canvas.height = image.height; + + // Draw the image in the canvas + const context = canvas.getContext("2d"); + context.drawImage(image, 0, 0, image.width, image.height); + + // Return the color found at the provided x,y coordinates as a "r, g, b" string. + const [r, g, b] = context.getImageData(x, y, 1, 1).data; + return { r, g, b }; +} + +let allDownloads = []; +/** + * Returns a Promise that resolves when a new screenshot is available in the download folder. + * + * @param {Object} [options] + * @param {Boolean} options.isWindowPrivate: Set to true if the window from which the screenshot + * is taken is a private window. This will ensure that we check that the + * screenshot appears in the private window, not the non-private one (See Bug 1783373) + */ +async function waitUntilScreenshot({ isWindowPrivate = false } = {}) { + const { Downloads } = ChromeUtils.importESModule( + "resource://gre/modules/Downloads.sys.mjs" + ); + const list = await Downloads.getList(Downloads.ALL); + + return new Promise(function (resolve) { + const view = { + onDownloadAdded: async download => { + await download.whenSucceeded(); + if (allDownloads.includes(download)) { + return; + } + + is( + !!download.source.isPrivate, + isWindowPrivate, + `The download occured in the expected${ + isWindowPrivate ? " private" : "" + } window` + ); + + allDownloads.push(download); + resolve(download.target.path); + list.removeView(view); + }, + }; + + list.addView(view); + }); +} + +/** + * Clear all the download references. + */ +async function resetDownloads() { + info("Reset downloads"); + const { Downloads } = ChromeUtils.importESModule( + "resource://gre/modules/Downloads.sys.mjs" + ); + const downloadList = await Downloads.getList(Downloads.ALL); + const downloads = await downloadList.getAll(); + for (const download of downloads) { + downloadList.remove(download); + await download.finalize(true); + } + allDownloads = []; +} + +/** + * Return a screenshot of the currently selected node in the inspector (using the internal + * Inspector#screenshotNode method). + * + * @param {Inspector} inspector + * @returns {Image} + */ +async function takeNodeScreenshot(inspector) { + // Cleanup all downloads at the end of the test. + registerCleanupFunction(resetDownloads); + + info( + "Call screenshotNode() and wait until the screenshot is found in the Downloads" + ); + const whenScreenshotSucceeded = waitUntilScreenshot(); + inspector.screenshotNode(); + const filePath = await whenScreenshotSucceeded; + + info("Create an image using the downloaded fileas source"); + const image = new Image(); + const onImageLoad = once(image, "load"); + image.src = PathUtils.toFileURI(filePath); + await onImageLoad; + + info("Remove the downloaded screenshot file"); + await IOUtils.remove(filePath); + + // See intermittent Bug 1508435. Even after removing the file, tests still manage to + // reuse files from the previous test if they have the same name. Since our file name + // is based on a timestamp that has "second" precision, wait for one second to make sure + // screenshots will have different names. + info( + "Wait for one second to make sure future screenshots will use a different name" + ); + await new Promise(r => setTimeout(r, 1000)); + + return image; +} + +/** + * Check that the provided image has the expected width, height, and color. + * NOTE: This test assumes that the image is only made of a single color and will only + * check one pixel. + */ +async function assertSingleColorScreenshotImage( + image, + width, + height, + { r, g, b } +) { + info(`Assert ${image.src} content`); + const ratio = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => content.wrappedJSObject.devicePixelRatio + ); + + is( + image.width, + ratio * width, + `node screenshot has the expected width (dpr = ${ratio})` + ); + is( + image.height, + height * ratio, + `node screenshot has the expected height (dpr = ${ratio})` + ); + + const color = colorAt(image, 0, 0); + is(color.r, r, "node screenshot has the expected red component"); + is(color.g, g, "node screenshot has the expected green component"); + is(color.b, b, "node screenshot has the expected blue component"); +} + +/** + * Check that the provided image has the expected color at a given position + */ +function checkImageColorAt({ image, x = 0, y, expectedColor, label }) { + const color = colorAt(image, x, y); + is(`rgb(${Object.values(color).join(", ")})`, expectedColor, label); +} + +/** + * Wait until the store has reached a state that matches the predicate. + * @param Store store + * The Redux store being used. + * @param function predicate + * A function that returns true when the store has reached the expected + * state. + * @return Promise + * Resolved once the store reaches the expected state. + */ +function waitUntilState(store, predicate) { + return new Promise(resolve => { + const unsubscribe = store.subscribe(check); + + info(`Waiting for state predicate "${predicate}"`); + function check() { + if (predicate(store.getState())) { + info(`Found state predicate "${predicate}"`); + unsubscribe(); + resolve(); + } + } + + // Fire the check immediately in case the action has already occurred + check(); + }); +} + +/** + * Wait for a specific action type to be dispatched. + * + * If the action is async and defines a `status` property, this helper will wait + * for the status to reach either "error" or "done". + * + * @param {Object} store + * Redux store where the action should be dispatched. + * @param {String} actionType + * The actionType to wait for. + * @param {Number} repeat + * Optional, number of time the action is expected to be dispatched. + * Defaults to 1 + * @return {Promise} + */ +function waitForDispatch(store, actionType, repeat = 1) { + let count = 0; + return new Promise(resolve => { + store.dispatch({ + type: "@@service/waitUntil", + predicate: action => { + const isDone = + !action.status || + action.status === "done" || + action.status === "error"; + + if (action.type === actionType && isDone && ++count == repeat) { + return true; + } + + return false; + }, + run: (dispatch, getState, action) => { + resolve(action); + }, + }); + }); +} + +/** + * Retrieve a browsing context in nested frames. + * + * @param {BrowsingContext|XULBrowser} browsingContext + * The topmost browsing context under which we should search for the + * browsing context. + * @param {Array} selectors + * Array of CSS selectors that form a path to a specific nested frame. + * @return {BrowsingContext} The nested browsing context. + */ +async function getBrowsingContextInFrames(browsingContext, selectors) { + let context = browsingContext; + + if (!Array.isArray(selectors)) { + throw new Error( + "getBrowsingContextInFrames called with an invalid selectors argument" + ); + } + + if (selectors.length === 0) { + throw new Error( + "getBrowsingContextInFrames called with an empty selectors array" + ); + } + + const clonedSelectors = [...selectors]; + while (clonedSelectors.length) { + const selector = clonedSelectors.shift(); + context = await SpecialPowers.spawn(context, [selector], _selector => { + return content.document.querySelector(_selector).browsingContext; + }); + } + + return context; +} + +/** + * Synthesize a mouse event on an element, after ensuring that it is visible + * in the viewport. + * + * @param {String|Array} selector: The node selector to get the node target for the event. + * To target an element in a specific iframe, pass an array of CSS selectors + * (e.g. ["iframe", ".el-in-iframe"]) + * @param {number} x + * @param {number} y + * @param {object} options: Options that will be passed to BrowserTestUtils.synthesizeMouse + */ +async function safeSynthesizeMouseEventInContentPage( + selector, + x, + y, + options = {} +) { + let context = gBrowser.selectedBrowser.browsingContext; + + // If an array of selector is passed, we need to retrieve the context in which the node + // lives in. + if (Array.isArray(selector)) { + if (selector.length === 1) { + selector = selector[0]; + } else { + context = await getBrowsingContextInFrames( + context, + // only pass the iframe path + selector.slice(0, -1) + ); + // retrieve the last item of the selector, which should be the one for the node we want. + selector = selector.at(-1); + } + } + + await scrollContentPageNodeIntoView(context, selector); + BrowserTestUtils.synthesizeMouse(selector, x, y, options, context); +} + +/** + * Synthesize a mouse event at the center of an element, after ensuring that it is visible + * in the viewport. + * + * @param {String|Array} selector: The node selector to get the node target for the event. + * To target an element in a specific iframe, pass an array of CSS selectors + * (e.g. ["iframe", ".el-in-iframe"]) + * @param {object} options: Options that will be passed to BrowserTestUtils.synthesizeMouse + */ +async function safeSynthesizeMouseEventAtCenterInContentPage( + selector, + options = {} +) { + let context = gBrowser.selectedBrowser.browsingContext; + + // If an array of selector is passed, we need to retrieve the context in which the node + // lives in. + if (Array.isArray(selector)) { + if (selector.length === 1) { + selector = selector[0]; + } else { + context = await getBrowsingContextInFrames( + context, + // only pass the iframe path + selector.slice(0, -1) + ); + // retrieve the last item of the selector, which should be the one for the node we want. + selector = selector.at(-1); + } + } + + await scrollContentPageNodeIntoView(context, selector); + BrowserTestUtils.synthesizeMouseAtCenter(selector, options, context); +} + +/** + * Scroll into view an element in the content page matching the passed selector + * + * @param {BrowsingContext} browsingContext: The browsing context the element lives in. + * @param {String} selector: The node selector to get the node to scroll into view + * @returns {Promise} + */ +function scrollContentPageNodeIntoView(browsingContext, selector) { + return SpecialPowers.spawn( + browsingContext, + [selector], + function (innerSelector) { + const node = + content.wrappedJSObject.document.querySelector(innerSelector); + node.scrollIntoView(); + } + ); +} + +/** + * Change the zoom level of the selected page. + * + * @param {Number} zoomLevel + */ +function setContentPageZoomLevel(zoomLevel) { + gBrowser.selectedBrowser.fullZoom = zoomLevel; +} + +/** + * Wait for the next DOCUMENT_EVENT dom-complete resource on a top-level target + * + * @param {Object} commands + * @return {Promise} + * Return a promise which resolves once we fully settle the resource listener. + * You should await for its resolution before doing the action which may fire + * your resource. + * This promise will resolve with an object containing a `onDomCompleteResource` property, + * which is also a promise, that will resolve once a "top-level" DOCUMENT_EVENT dom-complete + * is received. + */ +async function waitForNextTopLevelDomCompleteResource(commands) { + const { onResource: onDomCompleteResource } = + await commands.resourceCommand.waitForNextResource( + commands.resourceCommand.TYPES.DOCUMENT_EVENT, + { + ignoreExistingResources: true, + predicate: resource => + resource.name === "dom-complete" && resource.targetFront.isTopLevel, + } + ); + return { onDomCompleteResource }; +} + +/** + * Wait for the provided context to have a valid presShell. This can be useful + * for tests which try to create popup panels or interact with the document very + * early. + * + * @param {BrowsingContext} context + **/ +function waitForPresShell(context) { + return SpecialPowers.spawn(context, [], async () => { + const winUtils = SpecialPowers.getDOMWindowUtils(content); + await ContentTaskUtils.waitForCondition(() => { + try { + return !!winUtils.getPresShellId(); + } catch (e) { + return false; + } + }, "Waiting for a valid presShell"); + }); +} + +/** + * In tests using Fluent localization, it is preferable to match DOM elements using + * a message ID rather than the raw string as: + * + * 1. It allows testing infrastructure to be multilingual if needed. + * 2. It isolates the tests from localization changes. + * + * @param {Array} resourceIds A list of .ftl files to load. + * @returns {(id: string, args?: Record) => string} + */ +async function getFluentStringHelper(resourceIds) { + const locales = Services.locale.appLocalesAsBCP47; + const generator = L10nRegistry.getInstance().generateBundles( + locales, + resourceIds + ); + + const bundles = []; + for await (const bundle of generator) { + bundles.push(bundle); + } + + const reactLocalization = new FluentReact.ReactLocalization(bundles); + + /** + * Get the string from a message id. It throws when the message is not found. + * + * @param {string} id + * @param {string} attributeName: attribute name if you need to access a specific attribute + * defined in the fluent string, e.g. setting "title" for this param + * will retrieve the `title` string in + * compatibility-issue-browsers-list = + * .title = This is the title + * @param {Record} [args] optional + * @returns {string} + */ + return (id, attributeName, args) => { + let string; + + if (!attributeName) { + string = reactLocalization.getString(id, args); + } else { + for (const bundle of reactLocalization.bundles) { + const msg = bundle.getMessage(id); + if (msg?.attributes[attributeName]) { + string = bundle.formatPattern( + msg.attributes[attributeName], + args, + [] + ); + break; + } + } + } + + if (!string) { + throw new Error( + `Could not find a string for "${id}"${ + attributeName ? ` and attribute "${attributeName}")` : "" + }. Was the correct resource bundle loaded?` + ); + } + return string; + }; +} + +/** + * Open responsive design mode for the given tab. + */ +async function openRDM(tab, { waitForDeviceList = true } = {}) { + info("Opening responsive design mode"); + const manager = ResponsiveUIManager; + const ui = await manager.openIfNeeded(tab.ownerGlobal, tab, { + trigger: "test", + }); + info("Responsive design mode opened"); + + await ResponsiveMessageHelper.wait(ui.toolWindow, "post-init"); + info("Responsive design initialized"); + + await waitForRDMLoaded(ui, { waitForDeviceList }); + + return { ui, manager }; +} + +async function waitForRDMLoaded(ui, { waitForDeviceList = true } = {}) { + // Always wait for the viewport to be added. + const { store } = ui.toolWindow; + await waitUntilState(store, state => state.viewports.length == 1); + + if (waitForDeviceList) { + // Wait until the device list has been loaded. + await waitUntilState( + store, + state => state.devices.listState == localTypes.loadableState.LOADED + ); + } +} + +/** + * Close responsive design mode for the given tab. + */ +async function closeRDM(tab, options) { + info("Closing responsive design mode"); + const manager = ResponsiveUIManager; + await manager.closeIfNeeded(tab.ownerGlobal, tab, options); + info("Responsive design mode closed"); +} + +function getInputStream(data) { + const BufferStream = Components.Constructor( + "@mozilla.org/io/arraybuffer-input-stream;1", + "nsIArrayBufferInputStream", + "setData" + ); + const buffer = new TextEncoder().encode(data).buffer; + return new BufferStream(buffer, 0, buffer.byteLength); +} + +/** + * Wait for a specific target to have been fully processed by targetCommand. + * + * @param {Commands} commands + * The commands instance + * @param {Function} isExpectedTargetFn + * Predicate which will be called with a target front argument. Should + * return true if the target front is the expected one, false otherwise. + * @return {Promise} + * Promise which resolves when a target matching `isExpectedTargetFn` + * has been processed by targetCommand. + */ +function waitForTargetProcessed(commands, isExpectedTargetFn) { + return new Promise(resolve => { + const onProcessed = targetFront => { + try { + if (isExpectedTargetFn(targetFront)) { + commands.targetCommand.off("processed-available-target", onProcessed); + resolve(); + } + } catch { + // Ignore errors from isExpectedTargetFn. + } + }; + + commands.targetCommand.on("processed-available-target", onProcessed); + }); +} + +/** + * Instantiate a HTTP Server that serves files from a given test folder. + * The test folder should be made of multiple sub folder named: v1, v2, v3,... + * We will serve the content from one of these sub folder + * and switch to the next one, each time `httpServer.switchToNextVersion()` + * is called. + * + * @return Object Test server with two functions: + * - urlFor(path) + * Returns the absolute url for a given file. + * - switchToNextVersion() + * Start serving files from the next available sub folder. + * - backToFirstVersion() + * When running more than one test, helps restart from the first folder. + */ +function createVersionizedHttpTestServer(testFolderName) { + const httpServer = createTestHTTPServer(); + + let currentVersion = 1; + + httpServer.registerPrefixHandler("/", async (request, response) => { + response.processAsync(); + response.setStatusLine(request.httpVersion, 200, "OK"); + if (request.path.endsWith(".js")) { + response.setHeader("Content-Type", "application/javascript"); + } else if (request.path.endsWith(".js.map")) { + response.setHeader("Content-Type", "application/json"); + } + if (request.path == "/" || request.path.endsWith(".html")) { + response.setHeader("Content-Type", "text/html"); + } + // If a query string is passed, lookup with a matching file, if available + // The '?' is replaced by '.' + let fetchResponse; + + if (request.queryString) { + const url = `${URL_ROOT_SSL}${testFolderName}/v${currentVersion}${request.path}.${request.queryString}`; + try { + fetchResponse = await fetch(url); + // Log this only if the request succeed + info(`[test-http-server] serving: ${url}`); + } catch (e) { + // Ignore any error and proceed without the query string + fetchResponse = null; + } + } + + if (!fetchResponse) { + const url = `${URL_ROOT_SSL}${testFolderName}/v${currentVersion}${request.path}`; + info(`[test-http-server] serving: ${url}`); + fetchResponse = await fetch(url); + } + + // Ensure forwarding the response headers generated by the other http server + // (this can be especially useful when query .sjs files) + for (const [name, value] of fetchResponse.headers.entries()) { + response.setHeader(name, value); + } + + // Override cache settings so that versionized requests are never cached + // and we get brand new content for any request. + response.setHeader("Cache-Control", "no-store"); + + const text = await fetchResponse.text(); + response.write(text); + response.finish(); + }); + + return { + switchToNextVersion() { + currentVersion++; + }, + backToFirstVersion() { + currentVersion = 1; + }, + urlFor(path) { + const port = httpServer.identity.primaryPort; + return `http://localhost:${port}/${path}`; + }, + }; +} + +/** + * Fake clicking a link and return the URL we would have navigated to. + * This function should be used to check external links since we can't access + * network in tests. + * This can also be used to test that a click will not be fired. + * + * @param ElementNode element + * The element we want to simulate click on. + * @returns Promise + * A Promise that is resolved when the link click simulation occured or + * when the click is not dispatched. + * The promise resolves with an object that holds the following properties + * - link: url of the link or null(if event not fired) + * - where: "tab" if tab is active or "tabshifted" if tab is inactive + * or null(if event not fired) + */ +function simulateLinkClick(element) { + const browserWindow = Services.wm.getMostRecentWindow( + gDevTools.chromeWindowType + ); + + const onOpenLink = new Promise(resolve => { + const openLinkIn = (link, where) => resolve({ link, where }); + sinon.replace(browserWindow, "openTrustedLinkIn", openLinkIn); + sinon.replace(browserWindow, "openWebLinkIn", openLinkIn); + }); + + element.click(); + + // Declare a timeout Promise that we can use to make sure spied methods were not called. + const onTimeout = new Promise(function (resolve) { + setTimeout(() => { + resolve({ link: null, where: null }); + }, 1000); + }); + + const raceResult = Promise.race([onOpenLink, onTimeout]); + sinon.restore(); + return raceResult; +} + +/** + * Since the MDN data is updated frequently, it might happen that the properties used in + * this test are not in the dataset anymore/now have URLs. + * This function will return properties in the dataset that don't have MDN url so you + * can easily find a replacement. + */ +function logCssCompatDataPropertiesWithoutMDNUrl() { + const cssPropertiesCompatData = require("resource://devtools/shared/compatibility/dataset/css-properties.json"); + + function walk(node) { + for (const propertyName in node) { + const property = node[propertyName]; + if (property.__compat) { + if (!property.__compat.mdn_url) { + dump( + `"${propertyName}" - MDN URL: ${ + property.__compat.mdn_url || "❌" + } - Spec URL: ${property.__compat.spec_url || "❌"}\n` + ); + } + } else if (typeof property == "object") { + walk(property); + } + } + } + walk(cssPropertiesCompatData); +} + +/** + * Craft a CssProperties instance without involving RDP for tests + * manually spawning OutputParser, CssCompleter, Editor... + * + * Otherwise this should instead be fetched from CssPropertiesFront. + * + * @return {CssProperties} + */ +function getClientCssProperties() { + const { + generateCssProperties, + } = require("resource://devtools/server/actors/css-properties.js"); + const { + CssProperties, + normalizeCssData, + } = require("resource://devtools/client/fronts/css-properties.js"); + return new CssProperties( + normalizeCssData({ properties: generateCssProperties(document) }) + ); +} + +/** + * Helper method to stop a Service Worker promptly. + * + * @param {String} workerUrl + * Absolute Worker URL to stop. + */ +async function stopServiceWorker(workerUrl) { + info(`Stop Service Worker: ${workerUrl}\n`); + + // Help the SW to be immediately destroyed after unregistering it. + Services.prefs.setIntPref("dom.serviceWorkers.idle_timeout", 0); + + const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + // Unfortunately we can't use swm.getRegistrationByPrincipal, as it requires a "scope", which doesn't seem to be the worker URL. + // So let's use getAllRegistrations to find the nsIServiceWorkerInfo in order to: + // - retrieve its active worker, + // - call attach+detachDebugger, + // - reset the idle timeout. + // This way, the unregister instruction is immediate, thanks to the 0 dom.serviceWorkers.idle_timeout we set at the beginning of the function + const registrations = swm.getAllRegistrations(); + let matchedInfo; + for (let i = 0; i < registrations.length; i++) { + const info = registrations.queryElementAt( + i, + Ci.nsIServiceWorkerRegistrationInfo + ); + // Lookup for an exact URL match. + if (info.scriptSpec === workerUrl) { + matchedInfo = info; + break; + } + } + ok(!!matchedInfo, "Found the service worker info"); + + info("Wait for the worker to be active"); + await waitFor(() => matchedInfo.activeWorker, "Wait for the SW to be active"); + + // We need to attach+detach the debugger in order to reset the idle timeout. + // Otherwise the worker would still be waiting for a previously registered timeout + // which would be the 0ms one we set by tweaking the preference. + function resetWorkerTimeout(worker) { + worker.attachDebugger(); + worker.detachDebugger(); + } + resetWorkerTimeout(matchedInfo.activeWorker); + // Also reset all the other possible worker instances + if (matchedInfo.evaluatingWorker) { + resetWorkerTimeout(matchedInfo.evaluatingWorker); + } + if (matchedInfo.installingWorker) { + resetWorkerTimeout(matchedInfo.installingWorker); + } + if (matchedInfo.waitingWorker) { + resetWorkerTimeout(matchedInfo.waitingWorker); + } + // Reset this preference in order to ensure other SW are not immediately destroyed. + Services.prefs.clearUserPref("dom.serviceWorkers.idle_timeout"); + + // Spin the event loop to ensure the worker had time to really be shut down. + await wait(0); + + return matchedInfo; +} + +/** + * Helper method to stop and unregister a Service Worker promptly. + * + * @param {String} workerUrl + * Absolute Worker URL to unregister. + */ +async function unregisterServiceWorker(workerUrl) { + const swInfo = await stopServiceWorker(workerUrl); + + info(`Unregister Service Worker: ${workerUrl}\n`); + // Now call unregister on that worker so that it can be destroyed immediately + const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + const unregisterSuccess = await new Promise(resolve => { + swm.unregister( + swInfo.principal, + { + unregisterSucceeded(success) { + resolve(success); + }, + }, + swInfo.scope + ); + }); + ok(unregisterSuccess, "Service worker successfully unregistered"); +} diff --git a/devtools/client/shared/test/telemetry-test-helpers.js b/devtools/client/shared/test/telemetry-test-helpers.js new file mode 100644 index 0000000000..2a5032f466 --- /dev/null +++ b/devtools/client/shared/test/telemetry-test-helpers.js @@ -0,0 +1,273 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* global is ok registerCleanupFunction Services */ + +"use strict"; + +// We try to avoid polluting the global scope as far as possible by defining +// constants in the methods that use them because this script is not sandboxed +// meaning that it is loaded via Services.scriptloader.loadSubScript() + +class TelemetryHelpers { + constructor() { + this.oldCanRecord = Services.telemetry.canRecordExtended; + this.generateTelemetryTests = this.generateTelemetryTests.bind(this); + registerCleanupFunction(this.stopTelemetry.bind(this)); + } + + /** + * Allow collection of extended telemetry data. + */ + startTelemetry() { + Services.telemetry.canRecordExtended = true; + } + + /** + * Clear all telemetry types. + */ + stopTelemetry() { + // Clear histograms, scalars and Telemetry Events. + this.clearHistograms(Services.telemetry.getSnapshotForHistograms); + this.clearHistograms(Services.telemetry.getSnapshotForKeyedHistograms); + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + Services.telemetry.canRecordExtended = this.oldCanRecord; + } + + /** + * Clears Telemetry Histograms. + * + * @param {Function} snapshotFunc + * The function used to take the snapshot. This can be one of the + * following: + * - Services.telemetry.getSnapshotForHistograms + * - Services.telemetry.getSnapshotForKeyedHistograms + */ + clearHistograms(snapshotFunc) { + snapshotFunc("main", true); + } + + /** + * Check the value of a given telemetry histogram. + * + * @param {String} histId + * Histogram id + * @param {String} key + * Keyed histogram key + * @param {Array|Number} expected + * Expected value + * @param {String} checkType + * "array" (default) - Check that an array matches the histogram data. + * "hasentries" - For non-enumerated linear and exponential + * histograms. This checks for at least one entry. + * "scalar" - Telemetry type is a scalar. + * "keyedscalar" - Telemetry type is a keyed scalar. + */ + checkTelemetry(histId, key, expected, checkType) { + let actual; + let msg; + + if (checkType === "array" || checkType === "hasentries") { + if (key) { + const keyedHistogram = Services.telemetry + .getKeyedHistogramById(histId) + .snapshot(); + const result = keyedHistogram[key]; + + if (result) { + actual = result.values; + } else { + ok(false, `${histId}[${key}] exists`); + return; + } + } else { + actual = Services.telemetry.getHistogramById(histId).snapshot().values; + } + } + + switch (checkType) { + case "array": + msg = key ? `${histId}["${key}"] correct.` : `${histId} correct.`; + is(JSON.stringify(actual), JSON.stringify(expected), msg); + break; + case "hasentries": + const hasEntry = Object.values(actual).some(num => num > 0); + if (key) { + ok(hasEntry, `${histId}["${key}"] has at least one entry.`); + } else { + ok(hasEntry, `${histId} has at least one entry.`); + } + break; + case "scalar": + const scalars = Services.telemetry.getSnapshotForScalars( + "main", + false + ).parent; + + is(scalars[histId], expected, `${histId} correct`); + break; + case "keyedscalar": + const keyedScalars = Services.telemetry.getSnapshotForKeyedScalars( + "main", + false + ).parent; + const value = keyedScalars[histId][key]; + + msg = key ? `${histId}["${key}"] correct.` : `${histId} correct.`; + is(value, expected, msg); + break; + } + } + + /** + * Generate telemetry tests. You should call generateTelemetryTests("DEVTOOLS_") + * from your result checking code in telemetry tests. It logs checkTelemetry + * calls for all changed telemetry values. + * + * @param {String} prefix + * Optionally limits results to histogram ids starting with prefix. + */ + generateTelemetryTests(prefix = "") { + // Get all histograms and scalars + const histograms = Services.telemetry.getSnapshotForHistograms( + "main", + true + ).parent; + const keyedHistograms = Services.telemetry.getSnapshotForKeyedHistograms( + "main", + true + ).parent; + const scalars = Services.telemetry.getSnapshotForScalars( + "main", + false + ).parent; + const keyedScalars = Services.telemetry.getSnapshotForKeyedScalars( + "main", + false + ).parent; + const allHistograms = Object.assign( + {}, + histograms, + keyedHistograms, + scalars, + keyedScalars + ); + // Get all keys + const histIds = Object.keys(allHistograms).filter(histId => + histId.startsWith(prefix) + ); + + dump("=".repeat(80) + "\n"); + for (const histId of histIds) { + const snapshot = allHistograms[histId]; + + if (histId === histId.toLowerCase()) { + if (typeof snapshot === "object") { + // Keyed Scalar + const keys = Object.keys(snapshot); + + for (const key of keys) { + const value = snapshot[key]; + + dump( + `checkTelemetry("${histId}", "${key}", ${value}, "keyedscalar");\n` + ); + } + } else { + // Scalar + dump(`checkTelemetry("${histId}", "", ${snapshot}, "scalar");\n`); + } + } else if ( + typeof snapshot.histogram_type !== "undefined" && + typeof snapshot.values !== "undefined" + ) { + // Histogram + const actual = snapshot.values; + + this.displayDataFromHistogramSnapshot(snapshot, "", histId, actual); + } else { + // Keyed Histogram + const keys = Object.keys(snapshot); + + for (const key of keys) { + const value = snapshot[key]; + const actual = value.counts; + + this.displayDataFromHistogramSnapshot(value, key, histId, actual); + } + } + } + dump("=".repeat(80) + "\n"); + } + + /** + * Generates the inner contents of a test's checkTelemetry() method. + * + * @param {HistogramSnapshot} snapshot + * A snapshot of a telemetry chart obtained via getSnapshotForHistograms or + * similar. + * @param {String} key + * Only used for keyed histograms. This is the key we are interested in + * checking. + * @param {String} histId + * The histogram ID. + * @param {Array|String|Boolean} actual + * The value of the histogram data. + */ + displayDataFromHistogramSnapshot(snapshot, key, histId, actual) { + key = key ? `"${key}"` : `""`; + + switch (snapshot.histogram_type) { + case Services.telemetry.HISTOGRAM_EXPONENTIAL: + case Services.telemetry.HISTOGRAM_LINEAR: + let total = 0; + for (const val of Object.values(actual)) { + total += val; + } + + if (histId.endsWith("_ENUMERATED")) { + if (total > 0) { + actual = actual.toSource(); + dump(`checkTelemetry("${histId}", ${key}, ${actual}, "array");\n`); + } + return; + } + + dump(`checkTelemetry("${histId}", ${key}, null, "hasentries");\n`); + break; + case Services.telemetry.HISTOGRAM_BOOLEAN: + actual = actual.toSource(); + + if (actual !== "({})") { + dump(`checkTelemetry("${histId}", ${key}, ${actual}, "array");\n`); + } + break; + case Services.telemetry.HISTOGRAM_FLAG: + actual = actual.toSource(); + + if (actual !== "({0:1, 1:0})") { + dump(`checkTelemetry("${histId}", ${key}, ${actual}, "array");\n`); + } + break; + case Services.telemetry.HISTOGRAM_COUNT: + actual = actual.toSource(); + + dump(`checkTelemetry("${histId}", ${key}, ${actual}, "array");\n`); + break; + } + } +} + +// "exports"... because this is a helper and not imported via require we need to +// expose the three main methods that should be used by tests. The reason this +// is not imported via require is because it needs access to test methods +// (is, ok etc). + +/* eslint-disable no-unused-vars */ +const telemetryHelpers = new TelemetryHelpers(); +const generateTelemetryTests = telemetryHelpers.generateTelemetryTests; +const checkTelemetry = telemetryHelpers.checkTelemetry; +const startTelemetry = telemetryHelpers.startTelemetry; +/* eslint-enable no-unused-vars */ diff --git a/devtools/client/shared/test/test-mocked-module.js b/devtools/client/shared/test/test-mocked-module.js new file mode 100644 index 0000000000..0063c1c427 --- /dev/null +++ b/devtools/client/shared/test/test-mocked-module.js @@ -0,0 +1,11 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const methodToMock = function () { + return "Original value"; +}; + +exports.methodToMock = methodToMock; +exports.someProperty = "someProperty"; diff --git a/devtools/client/shared/test/testactors.js b/devtools/client/shared/test/testactors.js new file mode 100644 index 0000000000..79b0240723 --- /dev/null +++ b/devtools/client/shared/test/testactors.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Actor } = require("resource://devtools/shared/protocol/Actor.js"); + +class TestActor1 extends Actor { + constructor(conn, tab) { + super(conn, { typeName: "testOne", methods: [] }); + this.tab = tab; + + this.requestTypes = { + ping: TestActor1.prototype.onPing, + }; + } + + grip() { + return { actor: this.actorID, test: "TestActor1" }; + } + + onPing() { + return { pong: "pong" }; + } +} + +exports.TestActor1 = TestActor1; diff --git a/devtools/client/shared/test/xpcshell/.eslintrc.js b/devtools/client/shared/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..8611c174f5 --- /dev/null +++ b/devtools/client/shared/test/xpcshell/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the common devtools xpcshell eslintrc config. + extends: "../../../../.eslintrc.xpcshell.js", +}; diff --git a/devtools/client/shared/test/xpcshell/head.js b/devtools/client/shared/test/xpcshell/head.js new file mode 100644 index 0000000000..e65552771e --- /dev/null +++ b/devtools/client/shared/test/xpcshell/head.js @@ -0,0 +1,10 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* exported require */ + +"use strict"; + +var { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); diff --git a/devtools/client/shared/test/xpcshell/test_VariablesView_getString_promise.js b/devtools/client/shared/test/xpcshell/test_VariablesView_getString_promise.js new file mode 100644 index 0000000000..feaec04fd0 --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_VariablesView_getString_promise.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { VariablesView } = ChromeUtils.importESModule( + "resource://devtools/client/storage/VariablesView.sys.mjs" +); + +const PENDING = { + type: "object", + class: "Promise", + actor: "conn0.obj35", + extensible: true, + frozen: false, + sealed: false, + promiseState: { + state: "pending", + }, + preview: { + kind: "Object", + ownProperties: {}, + ownPropertiesLength: 0, + safeGetterValues: {}, + }, +}; + +const FULFILLED = { + type: "object", + class: "Promise", + actor: "conn0.obj35", + extensible: true, + frozen: false, + sealed: false, + promiseState: { + state: "fulfilled", + value: 10, + }, + preview: { + kind: "Object", + ownProperties: {}, + ownPropertiesLength: 0, + safeGetterValues: {}, + }, +}; + +const REJECTED = { + type: "object", + class: "Promise", + actor: "conn0.obj35", + extensible: true, + frozen: false, + sealed: false, + promiseState: { + state: "rejected", + reason: 10, + }, + preview: { + kind: "Object", + ownProperties: {}, + ownPropertiesLength: 0, + safeGetterValues: {}, + }, +}; + +function run_test() { + equal(VariablesView.getString(PENDING, { concise: true }), "Promise"); + equal(VariablesView.getString(PENDING), 'Promise {: "pending"}'); + + equal(VariablesView.getString(FULFILLED, { concise: true }), "Promise"); + equal( + VariablesView.getString(FULFILLED), + 'Promise {: "fulfilled", : 10}' + ); + + equal(VariablesView.getString(REJECTED, { concise: true }), "Promise"); + equal( + VariablesView.getString(REJECTED), + 'Promise {: "rejected", : 10}' + ); +} diff --git a/devtools/client/shared/test/xpcshell/test_WeakMapMap.js b/devtools/client/shared/test/xpcshell/test_WeakMapMap.js new file mode 100644 index 0000000000..94a006265b --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_WeakMapMap.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test WeakMapMap. + +"use strict"; + +const WeakMapMap = require("resource://devtools/client/shared/WeakMapMap.js"); + +const myWeakMapMap = new WeakMapMap(); +const key = { randomObject: true }; + +// eslint-disable-next-line +function run_test() { + test_set(); + test_has(); + test_get(); + test_delete(); + test_clear(); +} + +function test_set() { + // myWeakMapMap.set + myWeakMapMap.set(key, "text1", "value1"); + myWeakMapMap.set(key, "text2", "value2"); + myWeakMapMap.set(key, "text3", "value3"); +} + +function test_has() { + // myWeakMapMap.has + ok(myWeakMapMap.has(key, "text1"), "text1 exists"); + ok(myWeakMapMap.has(key, "text2"), "text2 exists"); + ok(myWeakMapMap.has(key, "text3"), "text3 exists"); + ok(!myWeakMapMap.has(key, "notakey"), "notakey does not exist"); +} + +function test_get() { + // myWeakMapMap.get + const value1 = myWeakMapMap.get(key, "text1"); + equal(value1, "value1", "test value1"); + + const value2 = myWeakMapMap.get(key, "text2"); + equal(value2, "value2", "test value2"); + + const value3 = myWeakMapMap.get(key, "text3"); + equal(value3, "value3", "test value3"); + + const value4 = myWeakMapMap.get(key, "notakey"); + equal(value4, undefined, "test value4"); +} + +function test_delete() { + // myWeakMapMap.delete + myWeakMapMap.delete(key, "text2"); + + // Check that the correct entry was deleted + ok(myWeakMapMap.has(key, "text1"), "text1 exists"); + ok(!myWeakMapMap.has(key, "text2"), "text2 no longer exists"); + ok(myWeakMapMap.has(key, "text3"), "text3 exists"); +} + +function test_clear() { + // myWeakMapMap.clear + myWeakMapMap.clear(); + + // Ensure myWeakMapMap was properly cleared + ok(!myWeakMapMap.has(key, "text1"), "text1 no longer exists"); + ok(!myWeakMapMap.has(key, "text3"), "text3 no longer exists"); +} diff --git a/devtools/client/shared/test/xpcshell/test_advanceValidate.js b/devtools/client/shared/test/xpcshell/test_advanceValidate.js new file mode 100644 index 0000000000..47ad4b92c7 --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_advanceValidate.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the advanceValidate function from rule-view.js. + +const { + advanceValidate, +} = require("resource://devtools/client/inspector/shared/utils.js"); +const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); + +// 1 2 3 +// 0123456789012345678901234567890 +const sampleInput = '\\symbol "string" url(somewhere)'; + +function testInsertion(where, result, testName) { + info(testName); + equal( + advanceValidate(KeyCodes.DOM_VK_SEMICOLON, sampleInput, where), + result, + "testing advanceValidate at " + where + ); +} + +function run_test() { + testInsertion(4, true, "inside a symbol"); + testInsertion(1, false, "after a backslash"); + testInsertion(8, true, "after whitespace"); + testInsertion(11, false, "inside a string"); + testInsertion(24, false, "inside a URL"); + testInsertion(31, true, "at the end"); +} diff --git a/devtools/client/shared/test/xpcshell/test_attribute-parsing-01.js b/devtools/client/shared/test/xpcshell/test_attribute-parsing-01.js new file mode 100644 index 0000000000..f3c0c159cd --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_attribute-parsing-01.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test splitBy from node-attribute-parser.js + +const { + splitBy, +} = require("resource://devtools/client/shared/node-attribute-parser.js"); + +const TEST_DATA = [ + { + value: "this is a test", + splitChar: " ", + expected: [ + { value: "this" }, + { value: " ", type: "string" }, + { value: "is" }, + { value: " ", type: "string" }, + { value: "a" }, + { value: " ", type: "string" }, + { value: "test" }, + ], + }, + { + value: "/path/to/handler", + splitChar: " ", + expected: [{ value: "/path/to/handler" }], + }, + { + value: "test", + splitChar: " ", + expected: [{ value: "test" }], + }, + { + value: " test ", + splitChar: " ", + expected: [ + { value: " ", type: "string" }, + { value: "test" }, + { value: " ", type: "string" }, + ], + }, + { + value: "", + splitChar: " ", + expected: [], + }, + { + value: " ", + splitChar: " ", + expected: [ + { value: " ", type: "string" }, + { value: " ", type: "string" }, + { value: " ", type: "string" }, + ], + }, +]; + +function run_test() { + for (const { value, splitChar, expected } of TEST_DATA) { + info("Splitting string: " + value); + const tokens = splitBy(value, splitChar); + + info("Checking that the number of parsed tokens is correct"); + Assert.equal(tokens.length, expected.length); + + for (let i = 0; i < tokens.length; i++) { + info("Checking the data in token " + i); + Assert.equal(tokens[i].value, expected[i].value); + if (expected[i].type) { + Assert.equal(tokens[i].type, expected[i].type); + } + } + } +} diff --git a/devtools/client/shared/test/xpcshell/test_attribute-parsing-02.js b/devtools/client/shared/test/xpcshell/test_attribute-parsing-02.js new file mode 100644 index 0000000000..2cc05574dd --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_attribute-parsing-02.js @@ -0,0 +1,148 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test parseAttribute from node-attribute-parser.js + +const { + parseAttribute, +} = require("resource://devtools/client/shared/node-attribute-parser.js"); + +const TEST_DATA = [ + { + tagName: "body", + namespaceURI: "http://www.w3.org/1999/xhtml", + attributeName: "class", + attributeValue: "some css class names", + expected: [{ value: "some css class names", type: "string" }], + }, + { + tagName: "box", + namespaceURI: + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul", + attributeName: "datasources", + attributeValue: "/url/1?test=1#test http://mozilla.org/wow", + expected: [ + { value: "/url/1?test=1#test", type: "uri" }, + { value: " ", type: "string" }, + { value: "http://mozilla.org/wow", type: "uri" }, + ], + }, + { + tagName: "form", + namespaceURI: "http://www.w3.org/1999/xhtml", + attributeName: "action", + attributeValue: "/path/to/handler", + expected: [{ value: "/path/to/handler", type: "uri" }], + }, + { + tagName: "a", + namespaceURI: "http://www.w3.org/1999/xhtml", + attributeName: "ping", + attributeValue: + "http://analytics.com/track?id=54 http://analytics.com/track?id=55", + expected: [ + { value: "http://analytics.com/track?id=54", type: "uri" }, + { value: " ", type: "string" }, + { value: "http://analytics.com/track?id=55", type: "uri" }, + ], + }, + { + tagName: "link", + namespaceURI: "http://www.w3.org/1999/xhtml", + attributeName: "href", + attributeValue: "styles.css", + otherAttributes: [{ name: "rel", value: "stylesheet" }], + expected: [{ value: "styles.css", type: "cssresource" }], + }, + { + tagName: "link", + namespaceURI: "http://www.w3.org/1999/xhtml", + attributeName: "href", + attributeValue: "styles.css", + expected: [{ value: "styles.css", type: "uri" }], + }, + { + tagName: "output", + namespaceURI: "http://www.w3.org/1999/xhtml", + attributeName: "for", + attributeValue: "element-id something id", + expected: [ + { value: "element-id", type: "idref" }, + { value: " ", type: "string" }, + { value: "something", type: "idref" }, + { value: " ", type: "string" }, + { value: "id", type: "idref" }, + ], + }, + { + tagName: "img", + namespaceURI: "http://www.w3.org/1999/xhtml", + attributeName: "contextmenu", + attributeValue: "id-of-menu", + expected: [{ value: "id-of-menu", type: "idref" }], + }, + { + tagName: "img", + namespaceURI: "http://www.w3.org/1999/xhtml", + attributeName: "src", + attributeValue: "omg-thats-so-funny.gif", + expected: [{ value: "omg-thats-so-funny.gif", type: "uri" }], + }, + { + tagName: "key", + namespaceURI: + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul", + attributeName: "command", + attributeValue: "some_command_id", + expected: [{ value: "some_command_id", type: "idref" }], + }, + { + tagName: "script", + namespaceURI: "whatever", + attributeName: "src", + attributeValue: "script.js", + expected: [{ value: "script.js", type: "jsresource" }], + }, +]; + +function run_test() { + for (const { + tagName, + namespaceURI, + attributeName, + otherAttributes, + attributeValue, + expected, + } of TEST_DATA) { + info( + "Testing <" + tagName + " " + attributeName + "='" + attributeValue + "'>" + ); + + const attributes = [ + ...(otherAttributes || []), + { name: attributeName, value: attributeValue }, + ]; + const tokens = parseAttribute( + namespaceURI, + tagName, + attributes, + attributeName, + attributeValue + ); + if (!expected) { + Assert.ok(!tokens); + continue; + } + + info("Checking that the number of parsed tokens is correct"); + Assert.equal(tokens.length, expected.length); + + for (let i = 0; i < tokens.length; i++) { + info("Checking the data in token " + i); + Assert.equal(tokens[i].value, expected[i].value); + Assert.equal(tokens[i].type, expected[i].type); + } + } +} diff --git a/devtools/client/shared/test/xpcshell/test_bezierCanvas.js b/devtools/client/shared/test/xpcshell/test_bezierCanvas.js new file mode 100644 index 0000000000..d7fac599c5 --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_bezierCanvas.js @@ -0,0 +1,122 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the BezierCanvas API in the CubicBezierWidget module + +var { + CubicBezier, + BezierCanvas, +} = require("resource://devtools/client/shared/widgets/CubicBezierWidget.js"); + +function run_test() { + offsetsGetterReturnsData(); + convertsOffsetsToCoordinates(); + plotsCanvas(); +} + +function offsetsGetterReturnsData() { + info("offsets getter returns an array of 2 offset objects"); + + let b = new BezierCanvas(getCanvasMock(), getCubicBezier(), [0.25, 0]); + let offsets = b.offsets; + + Assert.equal(offsets.length, 2); + + Assert.ok("top" in offsets[0]); + Assert.ok("left" in offsets[0]); + Assert.ok("top" in offsets[1]); + Assert.ok("left" in offsets[1]); + + Assert.equal(offsets[0].top, "300px"); + Assert.equal(offsets[0].left, "0px"); + Assert.equal(offsets[1].top, "100px"); + Assert.equal(offsets[1].left, "200px"); + + info("offsets getter returns data according to current padding"); + + b = new BezierCanvas(getCanvasMock(), getCubicBezier(), [0, 0]); + offsets = b.offsets; + + Assert.equal(offsets[0].top, "400px"); + Assert.equal(offsets[0].left, "0px"); + Assert.equal(offsets[1].top, "0px"); + Assert.equal(offsets[1].left, "200px"); +} + +function convertsOffsetsToCoordinates() { + info("Converts offsets to coordinates"); + + const b = new BezierCanvas(getCanvasMock(), getCubicBezier(), [0.25, 0]); + + let coordinates = b.offsetsToCoordinates({ + style: { + left: "0px", + top: "0px", + }, + }); + Assert.equal(coordinates.length, 2); + Assert.equal(coordinates[0], 0); + Assert.equal(coordinates[1], 1.5); + + coordinates = b.offsetsToCoordinates({ + style: { + left: "0px", + top: "300px", + }, + }); + Assert.equal(coordinates[0], 0); + Assert.equal(coordinates[1], 0); + + coordinates = b.offsetsToCoordinates({ + style: { + left: "200px", + top: "100px", + }, + }); + Assert.equal(coordinates[0], 1); + Assert.equal(coordinates[1], 1); +} + +function plotsCanvas() { + info("Plots the curve to the canvas"); + + let hasDrawnCurve = false; + const b = new BezierCanvas(getCanvasMock(), getCubicBezier(), [0.25, 0]); + b.ctx.bezierCurveTo = () => { + hasDrawnCurve = true; + }; + b.plot(); + + Assert.ok(hasDrawnCurve); +} + +function getCubicBezier() { + return new CubicBezier([0, 0, 1, 1]); +} + +function getCanvasMock(w = 200, h = 400) { + return { + getContext() { + return { + scale: () => {}, + translate: () => {}, + clearRect: () => {}, + beginPath: () => {}, + closePath: () => {}, + moveTo: () => {}, + lineTo: () => {}, + stroke: () => {}, + arc: () => {}, + fill: () => {}, + bezierCurveTo: () => {}, + save: () => {}, + restore: () => {}, + setTransform: () => {}, + }; + }, + width: w, + height: h, + }; +} diff --git a/devtools/client/shared/test/xpcshell/test_classnames.js b/devtools/client/shared/test/xpcshell/test_classnames.js new file mode 100644 index 0000000000..22a98c47da --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_classnames.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests utility function in `classnames.js` + */ + +const classnames = require("resource://devtools/client/shared/classnames.js"); + +add_task(async function () { + Assert.equal( + classnames(), + "", + "Returns an empty string when called with no params" + ); + Assert.equal( + classnames(null, undefined, false), + "", + "Returns an empty string when called with only falsy params" + ); + Assert.equal( + classnames("hello"), + "hello", + "Returns expected result when string is passed" + ); + Assert.equal( + classnames("hello", "", "world"), + "hello world", + "Doesn't add extra spaces for empty strings" + ); + Assert.equal( + classnames("hello", null, undefined, false, "world"), + "hello world", + "Doesn't add extra spaces for falsy values" + ); + Assert.equal( + classnames("hello", { nice: true, blue: 42, world: {} }), + "hello nice blue world", + "Add property key when property value is truthy" + ); + Assert.equal( + classnames("hello", { nice: false, blue: null, world: false }), + "hello", + "Does not add property key when property value is falsy" + ); + Assert.equal( + classnames("hello", { nice: true }, { blue: true }, "world"), + "hello nice blue world", + "Handles multiple objects" + ); +}); diff --git a/devtools/client/shared/test/xpcshell/test_cssAngle.js b/devtools/client/shared/test/xpcshell/test_cssAngle.js new file mode 100644 index 0000000000..a1ddcdf254 --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_cssAngle.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test classifyAngle. + +"use strict"; + +const { + angleUtils, +} = require("resource://devtools/client/shared/css-angle.js"); + +const CLASSIFY_TESTS = [ + { input: "180deg", output: "deg" }, + { input: "-180deg", output: "deg" }, + { input: "180DEG", output: "deg" }, + { input: "200rad", output: "rad" }, + { input: "-200rad", output: "rad" }, + { input: "200RAD", output: "rad" }, + { input: "0.5grad", output: "grad" }, + { input: "-0.5grad", output: "grad" }, + { input: "0.5GRAD", output: "grad" }, + { input: "0.33turn", output: "turn" }, + { input: "0.33TURN", output: "turn" }, + { input: "-0.33turn", output: "turn" }, +]; + +function run_test() { + for (const test of CLASSIFY_TESTS) { + const result = angleUtils.classifyAngle(test.input); + equal(result, test.output, "test classifyAngle(" + test.input + ")"); + } +} diff --git a/devtools/client/shared/test/xpcshell/test_cssColor-01.js b/devtools/client/shared/test/xpcshell/test_cssColor-01.js new file mode 100644 index 0000000000..bccd66a0a4 --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_cssColor-01.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test classifyColor. + +"use strict"; + +const { colorUtils } = require("resource://devtools/shared/css/color.js"); + +const CLASSIFY_TESTS = [ + { input: "rgb(255,0,192)", output: "rgb" }, + { input: "RGB(255,0,192)", output: "rgb" }, + { input: "RGB(100%,0%,83%)", output: "rgb" }, + { input: "rgba(255,0,192, 0.25)", output: "rgb" }, + { input: "hsl(5, 5%, 5%)", output: "hsl" }, + { input: "hsla(5, 5%, 5%, 0.25)", output: "hsl" }, + { input: "hSlA(5, 5%, 5%, 0.25)", output: "hsl" }, + { input: "#f06", output: "hex" }, + { input: "#f060", output: "hex" }, + { input: "#fe01cb", output: "hex" }, + { input: "#fe01cb80", output: "hex" }, + { input: "#FE01CB", output: "hex" }, + { input: "#FE01CB80", output: "hex" }, + { input: "blue", output: "name" }, + { input: "orange", output: "name" }, + // // Once bug 1824400 is closed, these types should be recognized + // { input: "oklch(50% 0.3 180)", output: "oklch" }, + // { input: "oklch(50% 0.3 180 / 0.5)", output: "oklch" }, + // { input: "oklab(50% -0.3 0.3)", output: "oklab" }, + // { input: "oklab(50% -0.3 0.3 / 0.5)", output: "oklab" }, + // { input: "lch(50% 0.3 180)", output: "lch" }, + // { input: "lch(50% 0.3 180 / 0.5)", output: "lch" }, + // { input: "lab(50% -0.3 0.3)", output: "lab" }, + // { input: "lab(50% -0.3 0.3 / 0.5)", output: "lab" }, + // // But if they are not recognized, they should be classified as "authored" + { input: "oklch(50% 0.3 180)", output: "authored" }, + { input: "oklch(50% 0.3 180 / 0.5)", output: "authored" }, + { input: "oklab(50% -0.3 0.3)", output: "authored" }, + { input: "oklab(50% -0.3 0.3 / 0.5)", output: "authored" }, + { input: "lch(50% 0.3 180)", output: "authored" }, + { input: "lch(50% 0.3 180 / 0.5)", output: "authored" }, + { input: "lab(50% -0.3 0.3)", output: "authored" }, + { input: "lab(50% -0.3 0.3 / 0.5)", output: "authored" }, +]; + +function run_test() { + for (const test of CLASSIFY_TESTS) { + const result = colorUtils.classifyColor(test.input); + equal(result, test.output, "test classifyColor(" + test.input + ")"); + + Assert.notStrictEqual( + InspectorUtils.colorToRGBA(test.input), + null, + "'" + test.input + "' is a color" + ); + + // check some obvious errors. + const invalidColors = ["mumble" + test.input, test.input + "trailingstuff"]; + for (const invalidColor of invalidColors) { + Assert.equal( + InspectorUtils.colorToRGBA(invalidColor), + null, + `'${invalidColor}' is not a color` + ); + } + } + + // Regression test for bug 1303826. + const black = new colorUtils.CssColor("#000"); + equal(black.toString("name"), "black", "test non-upper-case color cycling"); + + const upper = new colorUtils.CssColor("BLACK"); + equal(upper.toString("hex"), "#000", "test upper-case color cycling"); + equal(upper.toString("name"), "BLACK", "test upper-case color preservation"); +} diff --git a/devtools/client/shared/test/xpcshell/test_cssColor-02.js b/devtools/client/shared/test/xpcshell/test_cssColor-02.js new file mode 100644 index 0000000000..77b692e6a7 --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_cssColor-02.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Test color cycling regression - Bug 1303748. + * + * Values should cycle from a starting value, back to their original values. This can + * potentially be a little flaky due to the precision of different color representations. + */ + +const { colorUtils } = require("resource://devtools/shared/css/color.js"); +const getFixtureColorData = require("resource://test/helper_color_data.js"); + +function run_test() { + getFixtureColorData().forEach( + ({ authored, name, hex, hsl, rgb, hwb, cycle }) => { + if (cycle) { + const nameCycled = runCycle(name, cycle); + const hexCycled = runCycle(hex, cycle); + const hslCycled = runCycle(hsl, cycle); + const rgbCycled = runCycle(rgb, cycle); + const hwbCycled = runCycle(hwb, cycle); + // Cut down on log output by only reporting a single pass/fail for the color. + ok( + nameCycled && hexCycled && hslCycled && rgbCycled && hwbCycled, + `${authored} was able to cycle back to the original value` + ); + } + } + ); +} + +/** + * Test a color cycle to see if a color cycles back to its original value in a fixed + * number of steps. + * + * @param {string} value - The color value, e.g. "#000". + * @param {integer) times - The number of times it takes to cycle back to the + * original color. + */ +function runCycle(value, times) { + let color = new colorUtils.CssColor(value); + const colorUnit = colorUtils.classifyColor(value); + for (let i = 0; i < times; i++) { + const newColor = color.nextColorUnit(); + color = new colorUtils.CssColor(newColor); + } + return color.toString(colorUnit) === value; +} diff --git a/devtools/client/shared/test/xpcshell/test_cssColor-8-digit-hex.js b/devtools/client/shared/test/xpcshell/test_cssColor-8-digit-hex.js new file mode 100644 index 0000000000..877920294f --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_cssColor-8-digit-hex.js @@ -0,0 +1,20 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// 8 character hex colors have 256 possible alpha values compared to the +// standard 100 values possible via rgba() colors. This test ensures that they +// are stored correctly without any alpha loss. + +"use strict"; + +const { colorUtils } = require("resource://devtools/shared/css/color.js"); + +const EIGHT_CHARACTER_HEX = "#fefefef0"; + +// eslint-disable-next-line +function run_test() { + const cssColor = new colorUtils.CssColor(EIGHT_CHARACTER_HEX); + const color = cssColor.toString(colorUtils.CssColor.COLORUNIT.hex); + + equal(color, EIGHT_CHARACTER_HEX, "alpha value is correct"); +} diff --git a/devtools/client/shared/test/xpcshell/test_cssColorDatabase.js b/devtools/client/shared/test/xpcshell/test_cssColorDatabase.js new file mode 100644 index 0000000000..ec0cc0a4d8 --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_cssColorDatabase.js @@ -0,0 +1,17 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that css-color-db matches platform. + +"use strict"; + +const { cssColors } = require("resource://devtools/shared/css/color-db.js"); + +add_task(() => { + for (const name in cssColors) { + ok( + InspectorUtils.isValidCSSColor(name), + name + " is valid in InspectorUtils" + ); + } +}); diff --git a/devtools/client/shared/test/xpcshell/test_cubicBezier.js b/devtools/client/shared/test/xpcshell/test_cubicBezier.js new file mode 100644 index 0000000000..708c910fd2 --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_cubicBezier.js @@ -0,0 +1,152 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the CubicBezier API in the CubicBezierWidget module + +var { + CubicBezier, + parseTimingFunction, +} = require("resource://devtools/client/shared/widgets/CubicBezierWidget.js"); + +function run_test() { + throwsWhenMissingCoordinates(); + throwsWhenIncorrectCoordinates(); + convertsStringCoordinates(); + coordinatesToStringOutputsAString(); + pointGettersReturnPointCoordinatesArrays(); + toStringOutputsCubicBezierValue(); + toStringOutputsCssPresetValues(); + testParseTimingFunction(); +} + +function throwsWhenMissingCoordinates() { + do_check_throws(() => { + new CubicBezier(); + }, "Throws an exception when coordinates are missing"); +} + +function throwsWhenIncorrectCoordinates() { + do_check_throws(() => { + new CubicBezier([]); + }, "Throws an exception when coordinates are incorrect (empty array)"); + + do_check_throws(() => { + new CubicBezier([0, 0]); + }, "Throws an exception when coordinates are incorrect (incomplete array)"); + + do_check_throws(() => { + new CubicBezier(["a", "b", "c", "d"]); + }, "Throws an exception when coordinates are incorrect (invalid type)"); + + do_check_throws(() => { + new CubicBezier([1.5, 0, 1.5, 0]); + }, "Throws an exception when coordinates are incorrect (time range invalid)"); + + do_check_throws(() => { + new CubicBezier([-0.5, 0, -0.5, 0]); + }, "Throws an exception when coordinates are incorrect (time range invalid)"); +} + +function convertsStringCoordinates() { + info("Converts string coordinates to numbers"); + const c = new CubicBezier(["0", "1", ".5", "-2"]); + + Assert.equal(c.coordinates[0], 0); + Assert.equal(c.coordinates[1], 1); + Assert.equal(c.coordinates[2], 0.5); + Assert.equal(c.coordinates[3], -2); +} + +function coordinatesToStringOutputsAString() { + info("coordinates.toString() outputs a string representation"); + + let c = new CubicBezier(["0", "1", "0.5", "-2"]); + let string = c.coordinates.toString(); + Assert.equal(string, "0,1,.5,-2"); + + c = new CubicBezier([1, 1, 1, 1]); + string = c.coordinates.toString(); + Assert.equal(string, "1,1,1,1"); +} + +function pointGettersReturnPointCoordinatesArrays() { + info("Points getters return arrays of coordinates"); + + const c = new CubicBezier([0, 0.2, 0.5, 1]); + Assert.equal(c.P1[0], 0); + Assert.equal(c.P1[1], 0.2); + Assert.equal(c.P2[0], 0.5); + Assert.equal(c.P2[1], 1); +} + +function toStringOutputsCubicBezierValue() { + info("toString() outputs the cubic-bezier() value"); + + const c = new CubicBezier([0, 1, 1, 0]); + Assert.equal(c.toString(), "cubic-bezier(0,1,1,0)"); +} + +function toStringOutputsCssPresetValues() { + info("toString() outputs the css predefined values"); + + let c = new CubicBezier([0, 0, 1, 1]); + Assert.equal(c.toString(), "linear"); + + c = new CubicBezier([0.25, 0.1, 0.25, 1]); + Assert.equal(c.toString(), "ease"); + + c = new CubicBezier([0.42, 0, 1, 1]); + Assert.equal(c.toString(), "ease-in"); + + c = new CubicBezier([0, 0, 0.58, 1]); + Assert.equal(c.toString(), "ease-out"); + + c = new CubicBezier([0.42, 0, 0.58, 1]); + Assert.equal(c.toString(), "ease-in-out"); +} + +function testParseTimingFunction() { + info("test parseTimingFunction"); + + for (const test of ["ease", "linear", "ease-in", "ease-out", "ease-in-out"]) { + ok(parseTimingFunction(test), test); + } + + ok(!parseTimingFunction("something"), "non-function token"); + ok(!parseTimingFunction("something()"), "non-cubic-bezier function"); + ok( + !parseTimingFunction( + "cubic-bezier(something)", + "cubic-bezier with non-numeric argument" + ) + ); + ok(!parseTimingFunction("cubic-bezier(1,2,3:7)", "did not see comma")); + ok(!parseTimingFunction("cubic-bezier(1,2,3,7:", "did not see close paren")); + ok(!parseTimingFunction("cubic-bezier(1,2", "early EOF after number")); + ok(!parseTimingFunction("cubic-bezier(1,2,", "early EOF after comma")); + deepEqual( + parseTimingFunction("cubic-bezier(1,2,3,7)"), + [1, 2, 3, 7], + "correct invocation" + ); + deepEqual( + parseTimingFunction("cubic-bezier(1, /* */ 2,3, 7 )"), + [1, 2, 3, 7], + "correct with comments and whitespace" + ); +} + +function do_check_throws(cb, details) { + info(details); + + let hasThrown = false; + try { + cb(); + } catch (e) { + hasThrown = true; + } + + Assert.ok(hasThrown); +} diff --git a/devtools/client/shared/test/xpcshell/test_curl.js b/devtools/client/shared/test/xpcshell/test_curl.js new file mode 100644 index 0000000000..a2a6c3412e --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_curl.js @@ -0,0 +1,397 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests utility functions contained in `source-utils.js` + */ + +const curl = require("resource://devtools/client/shared/curl.js"); +const Curl = curl.Curl; +const CurlUtils = curl.CurlUtils; + +// Test `Curl.generateCommand` headers forwarding/filtering +add_task(async function () { + const request = { + url: "https://example.com/form/", + method: "GET", + headers: [ + { name: "Host", value: "example.com" }, + { + name: "User-Agent", + value: + "Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0", + }, + { name: "Accept", value: "*/*" }, + { name: "Accept-Language", value: "en-US,en;q=0.5" }, + { name: "Accept-Encoding", value: "gzip, deflate, br" }, + { name: "Origin", value: "https://example.com" }, + { name: "Connection", value: "keep-alive" }, + { name: "Referer", value: "https://example.com/home/" }, + { name: "Content-Type", value: "text/plain" }, + ], + responseHeaders: [], + httpVersion: "HTTP/2.0", + }; + + const cmd = Curl.generateCommand(request); + const curlParams = parseCurl(cmd); + + ok( + !headerTypeInParams(curlParams, "Host"), + "host header ignored - to be generated from url" + ); + ok( + exactHeaderInParams(curlParams, "Accept: */*"), + "accept header present in curl command" + ); + ok( + exactHeaderInParams( + curlParams, + "User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0" + ), + "user-agent header present in curl command" + ); + ok( + exactHeaderInParams(curlParams, "Accept-Language: en-US,en;q=0.5"), + "accept-language header present in curl output" + ); + ok( + exactHeaderInParams(curlParams, "Accept-Encoding: gzip, deflate, br"), + "accept-encoding header present in curl output" + ); + ok( + exactHeaderInParams(curlParams, "Origin: https://example.com"), + "origin header present in curl output" + ); + ok( + exactHeaderInParams(curlParams, "Connection: keep-alive"), + "connection header present in curl output" + ); + ok( + exactHeaderInParams(curlParams, "Referer: https://example.com/home/"), + "referer header present in curl output" + ); + ok( + exactHeaderInParams(curlParams, "Content-Type: text/plain"), + "content-type header present in curl output" + ); + ok(!inParams(curlParams, "--data"), "no data param in GET curl output"); + ok( + !inParams(curlParams, "--data-raw"), + "no raw data param in GET curl output" + ); +}); + +// Test `Curl.generateCommand` URL glob handling +add_task(async function () { + let request = { + url: "https://example.com/", + method: "GET", + headers: [], + responseHeaders: [], + httpVersion: "HTTP/2.0", + }; + + let cmd = Curl.generateCommand(request); + let curlParams = parseCurl(cmd); + + ok( + !inParams(curlParams, "--globoff"), + "no globoff param in curl output when not needed" + ); + + request = { + url: "https://example.com/[]", + method: "GET", + headers: [], + responseHeaders: [], + httpVersion: "HTTP/2.0", + }; + + cmd = Curl.generateCommand(request); + curlParams = parseCurl(cmd); + + ok( + inParams(curlParams, "--globoff"), + "globoff param present in curl output when needed" + ); +}); + +// Test `Curl.generateCommand` data POSTing +add_task(async function () { + const request = { + url: "https://example.com/form/", + method: "POST", + headers: [ + { name: "Content-Length", value: "1000" }, + { name: "Content-Type", value: "text/plain" }, + ], + responseHeaders: [], + httpVersion: "HTTP/2.0", + postDataText: "A piece of plain payload text", + }; + + const cmd = Curl.generateCommand(request); + const curlParams = parseCurl(cmd); + + ok( + !headerTypeInParams(curlParams, "Content-Length"), + "content-length header ignored - curl generates new one" + ); + ok( + exactHeaderInParams(curlParams, "Content-Type: text/plain"), + "content-type header present in curl output" + ); + ok( + inParams(curlParams, "--data-raw"), + '"--data-raw" param present in curl output' + ); + ok( + inParams(curlParams, `--data-raw ${quote(request.postDataText)}`), + "proper payload data present in output" + ); +}); + +// Test `Curl.generateCommand` data POSTing - not post data +add_task(async function () { + const request = { + url: "https://example.com/form/", + method: "POST", + headers: [ + { name: "Content-Length", value: "1000" }, + { name: "Content-Type", value: "text/plain" }, + ], + responseHeaders: [], + httpVersion: "HTTP/2.0", + }; + + const cmd = Curl.generateCommand(request); + const curlParams = parseCurl(cmd); + + ok( + !inParams(curlParams, "--data-raw"), + '"--data-raw" param not present in curl output' + ); + + const methodIndex = curlParams.indexOf("-X"); + + ok( + methodIndex !== -1 && curlParams[methodIndex + 1] === "POST", + "request method explicit is POST" + ); +}); + +// Test `Curl.generateCommand` multipart data POSTing +add_task(async function () { + const boundary = "----------14808"; + const request = { + url: "https://example.com/form/", + method: "POST", + headers: [ + { + name: "Content-Type", + value: `multipart/form-data; boundary=${boundary}`, + }, + ], + responseHeaders: [], + httpVersion: "HTTP/2.0", + postDataText: [ + `--${boundary}`, + 'Content-Disposition: form-data; name="field_one"', + "", + "value_one", + `--${boundary}`, + 'Content-Disposition: form-data; name="field_two"', + "", + "value two", + `--${boundary}--`, + "", + ].join("\r\n"), + }; + + const cmd = Curl.generateCommand(request); + + // Check content type + const contentTypePos = cmd.indexOf(headerParamPrefix("Content-Type")); + const contentTypeParam = headerParam( + `Content-Type: multipart/form-data; boundary=${boundary}` + ); + Assert.notStrictEqual( + contentTypePos, + -1, + "content type header present in curl output" + ); + equal( + cmd.substr(contentTypePos, contentTypeParam.length), + contentTypeParam, + "proper content type header present in curl output" + ); + + // Check binary data + const dataBinaryPos = cmd.indexOf("--data-binary"); + const dataBinaryParam = `--data-binary ${isWin() ? "" : "$"}${escapeNewline( + quote(request.postDataText) + )}`; + Assert.notStrictEqual( + dataBinaryPos, + -1, + "--data-binary param present in curl output" + ); + equal( + cmd.substr(dataBinaryPos, dataBinaryParam.length), + dataBinaryParam, + "proper multipart data present in curl output" + ); +}); + +// Test `CurlUtils.removeBinaryDataFromMultipartText` doesn't change text data +add_task(async function () { + const boundary = "----------14808"; + const postTextLines = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="field_one"', + "", + "value_one", + `--${boundary}`, + 'Content-Disposition: form-data; name="field_two"', + "", + "value two", + `--${boundary}--`, + "", + ]; + + const cleanedText = CurlUtils.removeBinaryDataFromMultipartText( + postTextLines.join("\r\n"), + boundary + ); + equal( + cleanedText, + postTextLines.join("\r\n"), + "proper non-binary multipart text unchanged" + ); +}); + +// Test `CurlUtils.removeBinaryDataFromMultipartText` removes binary data +add_task(async function () { + const boundary = "----------14808"; + const postTextLines = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="field_one"', + "", + "value_one", + `--${boundary}`, + 'Content-Disposition: form-data; name="field_two"; filename="file_field_two.txt"', + "", + "file content", + `--${boundary}--`, + "", + ]; + + const cleanedText = CurlUtils.removeBinaryDataFromMultipartText( + postTextLines.join("\r\n"), + boundary + ); + postTextLines.splice(7, 1); + equal( + cleanedText, + postTextLines.join("\r\n"), + "file content removed from multipart text" + ); +}); + +// Test `Curl.generateCommand` add --compressed flag +add_task(async function () { + let request = { + url: "https://example.com/", + method: "GET", + headers: [], + responseHeaders: [], + httpVersion: "HTTP/2.0", + }; + + let cmd = Curl.generateCommand(request); + let curlParams = parseCurl(cmd); + + ok( + !inParams(curlParams, "--compressed"), + "no compressed param in curl output when not needed" + ); + + request = { + url: "https://example.com/", + method: "GET", + headers: [], + responseHeaders: [{ name: "Content-Encoding", value: "gzip" }], + httpVersion: "HTTP/2.0", + }; + + cmd = Curl.generateCommand(request); + curlParams = parseCurl(cmd); + + ok( + inParams(curlParams, "--compressed"), + "compressed param present in curl output when needed" + ); +}); + +function isWin() { + return Services.appinfo.OS === "WINNT"; +} + +const QUOTE = isWin() ? '"' : "'"; + +// Quote a string, escape the quotes inside the string +function quote(str) { + let escaped; + if (isWin()) { + escaped = str.replace(new RegExp(QUOTE, "g"), `${QUOTE}${QUOTE}`); + } else { + escaped = str.replace(new RegExp(QUOTE, "g"), `\\${QUOTE}`); + } + return QUOTE + escaped + QUOTE; +} + +function escapeNewline(txt) { + if (isWin()) { + // Add `"` to close quote, then escape newline outside of quote, then start new quote + return txt.replace(/[\r\n]{1,2}/g, '"^$&$&"'); + } + return txt.replace(/\r/g, "\\r").replace(/\n/g, "\\n"); +} + +// Header param is formatted as -H "Header: value" or -H 'Header: value' +function headerParam(h) { + return "-H " + quote(h); +} + +// Header param prefix is formatted as `-H "HeaderName` or `-H 'HeaderName` +function headerParamPrefix(headerName) { + return `-H ${QUOTE}${headerName}`; +} + +// If any params startswith `-H "HeaderName` or `-H 'HeaderName` +function headerTypeInParams(curlParams, headerName) { + return curlParams.some(param => + param.toLowerCase().startsWith(headerParamPrefix(headerName).toLowerCase()) + ); +} + +function exactHeaderInParams(curlParams, header) { + return curlParams.some(param => param === headerParam(header)); +} + +function inParams(curlParams, param) { + return curlParams.some(p => p.startsWith(param)); +} + +// Parse complete curl command to array of params. Can be applied to simple headers/data, +// but will not on WIN with sophisticated values of --data-binary with e.g. escaped quotes +function parseCurl(curlCmd) { + // This monster regexp parses the command line into an array of arguments, + // recognizing quoted args with matching quotes and escaped quotes inside: + // [ "curl 'url'", "--standalone-arg", "-arg-with-quoted-string 'value\'s'" ] + const matchRe = /[-A-Za-z1-9]+(?: \$?([\"'])(?:\\\1|.)*?\1)?/g; + return curlCmd.match(matchRe); +} diff --git a/devtools/client/shared/test/xpcshell/test_escapeCSSComment.js b/devtools/client/shared/test/xpcshell/test_escapeCSSComment.js new file mode 100644 index 0000000000..7a77cc7e88 --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_escapeCSSComment.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + escapeCSSComment, + unescapeCSSComment, +} = require("resource://devtools/shared/css/parsing-utils.js"); + +const TEST_DATA = [ + { + input: "simple", + expected: "simple", + }, + { + input: "/* comment */", + expected: "/\\* comment *\\/", + }, + { + input: "/* two *//* comments */", + expected: "/\\* two *\\//\\* comments *\\/", + }, + { + input: "/* nested /\\* comment *\\/ */", + expected: "/\\* nested /\\\\* comment *\\\\/ *\\/", + }, +]; + +function run_test() { + let i = 0; + for (const test of TEST_DATA) { + ++i; + info("Test #" + i); + + const escaped = escapeCSSComment(test.input); + equal(escaped, test.expected); + const unescaped = unescapeCSSComment(escaped); + equal(unescaped, test.input); + } +} diff --git a/devtools/client/shared/test/xpcshell/test_hasCSSVariable.js b/devtools/client/shared/test/xpcshell/test_hasCSSVariable.js new file mode 100644 index 0000000000..168add6abb --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_hasCSSVariable.js @@ -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/. + */ + +"use strict"; + +// Test whether hasCSSVariable function of utils.js works correctly or not. + +const { + hasCSSVariable, +} = require("resource://devtools/client/inspector/rules/utils/utils.js"); + +function run_test() { + info("Normal usage"); + ok( + hasCSSVariable("var(--color)", "--color"), + "Found --color variable in var(--color)" + ); + ok( + !hasCSSVariable("var(--color)", "--col"), + "Did not find --col variable in var(--color)" + ); + + info("Variable with fallback"); + ok( + hasCSSVariable("var(--color, red)", "--color"), + "Found --color variable in var(--color)" + ); + ok( + !hasCSSVariable("var(--color, red)", "--col"), + "Did not find --col variable in var(--color, red)" + ); + + info("Nested variables"); + ok( + hasCSSVariable("var(--color1, var(--color2, blue))", "--color1"), + "Found --color1 variable in var(--color1, var(--color2, blue))" + ); + ok( + hasCSSVariable("var(--color1, var(--color2, blue))", "--color2"), + "Found --color2 variable in var(--color1, var(--color2, blue))" + ); + ok( + !hasCSSVariable("var(--color1, var(--color2, blue))", "--color"), + "Did not find --color variable in var(--color1, var(--color2, blue))" + ); + + info("Invalid variable"); + ok( + !hasCSSVariable("--color", "--color"), + "Did not find --color variable in --color" + ); + + info("Variable with whitespace"); + ok( + hasCSSVariable("var( --color )", "--color"), + "Found --color variable in var( --color )" + ); +} diff --git a/devtools/client/shared/test/xpcshell/test_linearEasing.js b/devtools/client/shared/test/xpcshell/test_linearEasing.js new file mode 100644 index 0000000000..66e487e17b --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_linearEasing.js @@ -0,0 +1,217 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests methods from the LinearEasingWidget module + +const { + LinearEasingFunctionWidget, + parseTimingFunction, +} = require("resource://devtools/client/shared/widgets/LinearEasingFunctionWidget.js"); + +add_task(function testParseTimingFunction() { + info("test parseTimingFunction"); + + for (const test of ["ease", "linear", "ease-in", "ease-out", "ease-in-out"]) { + ok(!parseTimingFunction(test), `"${test}" is not valid`); + } + + ok(!parseTimingFunction("something"), "non-function token"); + ok(!parseTimingFunction("something()"), "non-linear function"); + ok( + !parseTimingFunction( + "linear(something)", + "linear with non-numeric argument" + ) + ); + + ok(!parseTimingFunction("linear(0)", "linear with only 1 point")); + + deepEqual( + parseTimingFunction("linear(0, 0.5, 1)"), + [ + { input: 0, output: 0 }, + { input: 0.5, output: 0.5 }, + { input: 1, output: 1 }, + ], + "correct invocation" + ); + deepEqual( + parseTimingFunction("linear(0, 0.5 /* mid */, 1)"), + [ + { input: 0, output: 0 }, + { input: 0.5, output: 0.5 }, + { input: 1, output: 1 }, + ], + "correct with comments and whitespace" + ); + deepEqual( + parseTimingFunction("linear(0 10%, 0.5 20%, 1 90%)"), + [ + { input: 0.1, output: 0 }, + { input: 0.2, output: 0.5 }, + { input: 0.9, output: 1 }, + ], + "correct invocation with single stop" + ); + deepEqual( + parseTimingFunction( + "linear(0, 0.1, 0.2, 0.3, 0.4, 0.5 50%, 0.6, 0.7, 0.8, 0.9 70%, 1)" + ), + [ + { input: 0, output: 0 }, + { input: 0.1, output: 0.1 }, + { input: 0.2, output: 0.2 }, + { input: 0.3, output: 0.3 }, + { input: 0.4, output: 0.4 }, + { input: 0.5, output: 0.5 }, + { input: 0.55, output: 0.6 }, + { input: 0.6, output: 0.7 }, + { + // This should be 0.65, but JS doesn't play well with floating points, which makes + // the test fail. So re-do the computation here + input: 0.5 * 0.25 + 0.7 * 0.75, + output: 0.8, + }, + { input: 0.7, output: 0.9 }, + { input: 1, output: 1 }, + ], + "correct invocation with single stop and run of non-stop values" + ); + + deepEqual( + parseTimingFunction("linear(0, 0.5 80%, 0.75 40%, 1)"), + [ + { input: 0, output: 0 }, + { input: 0.8, output: 0.5 }, + { input: 0.8, output: 0.75 }, + { input: 1, output: 1 }, + ], + "correct invocation with out of order single stop" + ); + + deepEqual( + parseTimingFunction("linear(0.5 10% 40%, 0, 0.2 60% 70%, 0.75 80% 100%)"), + [ + { input: 0.1, output: 0.5 }, + { input: 0.4, output: 0.5 }, + { input: 0.5, output: 0 }, + { input: 0.6, output: 0.2 }, + { input: 0.7, output: 0.2 }, + { input: 0.8, output: 0.75 }, + { input: 1, output: 0.75 }, + ], + "correct invocation with multiple stops" + ); + + deepEqual( + parseTimingFunction("linear(0, 0.2 60% 10%, 1)"), + [ + { input: 0, output: 0 }, + { input: 0.6, output: 0.2 }, + { input: 0.6, output: 0.2 }, + { input: 1, output: 1 }, + ], + "correct invocation with multiple out of order stops" + ); + + deepEqual( + parseTimingFunction("linear(0, 1.5, 1)"), + [ + { input: 0, output: 0 }, + { input: 0.5, output: 1.5 }, + { input: 1, output: 1 }, + ], + "linear function easing with output greater than 1" + ); + + deepEqual( + parseTimingFunction("linear(1, -0.5, 0)"), + [ + { input: 0, output: 1 }, + { input: 0.5, output: -0.5 }, + { input: 1, output: 0 }, + ], + "linear function easing with output less than 1" + ); + + deepEqual( + parseTimingFunction("linear(0, 0.1 -10%, 1)"), + [ + { input: 0, output: 0 }, + { input: 0, output: 0.1 }, + { input: 1, output: 1 }, + ], + "correct invocation, input value being unspecified in the first entry implies zero" + ); + + deepEqual( + parseTimingFunction("linear(0, 0.9 110%, 1)"), + [ + { input: 0, output: 0 }, + { input: 1.1, output: 0.9 }, + { input: 1.1, output: 1 }, + ], + "correct invocation, input value being unspecified in the last entry implies max input value" + ); +}); + +add_task(function testGetSetCssLinearValue() { + const doc = Services.appShell.createWindowlessBrowser().document; + const widget = new LinearEasingFunctionWidget(doc.body); + + widget.setCssLinearValue("linear(0)"); + ok(!widget.getCssLinearValue(), "no value returned for invalid value"); + + widget.setCssLinearValue("linear(0, 0.5, 1)"); + deepEqual( + widget.getCssLinearValue(), + "linear(0 0%, 0.5 50%, 1 100%)", + "no stops" + ); + + widget.setCssLinearValue("linear(0 10%, 0.5 20%, 1 90%)"); + deepEqual( + widget.getCssLinearValue(), + "linear(0 10%, 0.5 20%, 1 90%)", + "with single stops" + ); + + widget.setCssLinearValue("linear(0, 0.5 80%, 0.75 40%, 1)"); + deepEqual( + widget.getCssLinearValue(), + "linear(0 0%, 0.5 80%, 0.75 80%, 1 100%)", + "correcting out of order single stops" + ); + + widget.setCssLinearValue( + "linear(0.5 10% 40%, 0, 0.2 60% 70%, 0.75 80% 100%)" + ); + deepEqual( + widget.getCssLinearValue(), + "linear(0.5 10%, 0.5 40%, 0 50%, 0.2 60%, 0.2 70%, 0.75 80%, 0.75 100%)", + "multiple stops" + ); + + widget.setCssLinearValue("linear(0, 0.2 60% 10%, 1)"); + deepEqual( + widget.getCssLinearValue(), + "linear(0 0%, 0.2 60%, 0.2 60%, 1 100%)", + "correcting multiple out-of-order stops" + ); + + widget.setCssLinearValue("linear(1, -0.5, 1.5)"); + deepEqual( + widget.getCssLinearValue(), + "linear(1 0%, -0.5 50%, 1.5 100%)", + "output outside of [0,1] range" + ); + + widget.setCssLinearValue("linear(0 -10%, 0.5, 1 130%)"); + deepEqual( + widget.getCssLinearValue(), + "linear(0 -10%, 0.5 60%, 1 130%)", + "input outside of [0%,100%] range" + ); +}); diff --git a/devtools/client/shared/test/xpcshell/test_parseDeclarations.js b/devtools/client/shared/test/xpcshell/test_parseDeclarations.js new file mode 100644 index 0000000000..593087d46b --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_parseDeclarations.js @@ -0,0 +1,1641 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + parseDeclarations, + _parseCommentDeclarations, + parseNamedDeclarations, +} = require("resource://devtools/shared/css/parsing-utils.js"); +const { + isCssPropertyKnown, +} = require("resource://devtools/server/actors/css-properties.js"); + +const TEST_DATA = [ + // Simple test + { + input: "p:v;", + expected: [ + { + name: "p", + value: "v", + priority: "", + offsets: [0, 4], + declarationText: "p:v;", + }, + ], + }, + // Simple test + { + input: "this:is;a:test;", + expected: [ + { + name: "this", + value: "is", + priority: "", + offsets: [0, 8], + declarationText: "this:is;", + }, + { + name: "a", + value: "test", + priority: "", + offsets: [8, 15], + declarationText: "a:test;", + }, + ], + }, + // Test a single declaration with semi-colon + { + input: "name:value;", + expected: [ + { + name: "name", + value: "value", + priority: "", + offsets: [0, 11], + declarationText: "name:value;", + }, + ], + }, + // Test a single declaration without semi-colon + { + input: "name:value", + expected: [ + { + name: "name", + value: "value", + priority: "", + offsets: [0, 10], + declarationText: "name:value", + }, + ], + }, + // Test multiple declarations separated by whitespaces and carriage + // returns and tabs + { + input: "p1 : v1 ; \t\t \n p2:v2; \n\n\n\n\t p3 : v3;", + expected: [ + { + name: "p1", + value: "v1", + priority: "", + offsets: [0, 9], + declarationText: "p1 : v1 ;", + }, + { + name: "p2", + value: "v2", + priority: "", + offsets: [16, 22], + declarationText: "p2:v2;", + }, + { + name: "p3", + value: "v3", + priority: "", + offsets: [32, 45], + declarationText: "p3 : v3;", + }, + ], + }, + // Test simple priority + { + input: "p1: v1; p2: v2 !important;", + expected: [ + { + name: "p1", + value: "v1", + priority: "", + offsets: [0, 7], + declarationText: "p1: v1;", + }, + { + name: "p2", + value: "v2", + priority: "important", + offsets: [8, 26], + declarationText: "p2: v2 !important;", + }, + ], + }, + // Test simple priority + { + input: "p1: v1 !important; p2: v2", + expected: [ + { + name: "p1", + value: "v1", + priority: "important", + offsets: [0, 18], + declarationText: "p1: v1 !important;", + }, + { + name: "p2", + value: "v2", + priority: "", + offsets: [19, 25], + declarationText: "p2: v2", + }, + ], + }, + // Test simple priority + { + input: "p1: v1 ! important; p2: v2 ! important;", + expected: [ + { + name: "p1", + value: "v1", + priority: "important", + offsets: [0, 20], + declarationText: "p1: v1 ! important;", + }, + { + name: "p2", + value: "v2", + priority: "important", + offsets: [21, 40], + declarationText: "p2: v2 ! important;", + }, + ], + }, + // Test simple priority + { + input: "p1: v1 !/*comment*/important;", + expected: [ + { + name: "p1", + value: "v1", + priority: "important", + offsets: [0, 29], + declarationText: "p1: v1 !/*comment*/important;", + }, + ], + }, + // Test priority without terminating ";". + { + input: "p1: v1 !important", + expected: [ + { + name: "p1", + value: "v1", + priority: "important", + offsets: [0, 17], + declarationText: "p1: v1 !important", + }, + ], + }, + // Test trailing "!" without terminating ";". + { + input: "p1: v1 !", + expected: [ + { + name: "p1", + value: "v1 !", + priority: "", + offsets: [0, 8], + declarationText: "p1: v1 !", + }, + ], + }, + // Test invalid priority + { + input: "p1: v1 important;", + expected: [ + { + name: "p1", + value: "v1 important", + priority: "", + offsets: [0, 17], + declarationText: "p1: v1 important;", + }, + ], + }, + // Test invalid priority (in the middle of the declaration). + // See bug 1462553. + { + input: "p1: v1 !important v2;", + expected: [ + { + name: "p1", + value: "v1 !important v2", + priority: "", + offsets: [0, 21], + declarationText: "p1: v1 !important v2;", + }, + ], + }, + { + input: "p1: v1 ! important v2;", + expected: [ + { + name: "p1", + value: "v1 ! important v2", + priority: "", + offsets: [0, 25], + declarationText: "p1: v1 ! important v2;", + }, + ], + }, + { + input: "p1: v1 ! /*comment*/ important v2;", + expected: [ + { + name: "p1", + value: "v1 ! important v2", + priority: "", + offsets: [0, 36], + declarationText: "p1: v1 ! /*comment*/ important v2;", + }, + ], + }, + { + input: "p1: v1 !/*hi*/important v2;", + expected: [ + { + name: "p1", + value: "v1 ! important v2", + priority: "", + offsets: [0, 27], + declarationText: "p1: v1 !/*hi*/important v2;", + }, + ], + }, + // Test various types of background-image urls + { + input: "background-image: url(../../relative/image.png)", + expected: [ + { + name: "background-image", + value: "url(../../relative/image.png)", + priority: "", + offsets: [0, 47], + declarationText: "background-image: url(../../relative/image.png)", + }, + ], + }, + { + input: "background-image: url(http://site.com/test.png)", + expected: [ + { + name: "background-image", + value: "url(http://site.com/test.png)", + priority: "", + offsets: [0, 47], + declarationText: "background-image: url(http://site.com/test.png)", + }, + ], + }, + { + input: "background-image: url(wow.gif)", + expected: [ + { + name: "background-image", + value: "url(wow.gif)", + priority: "", + offsets: [0, 30], + declarationText: "background-image: url(wow.gif)", + }, + ], + }, + // Test that urls with :;{} characters in them are parsed correctly + { + input: + 'background: red url("http://site.com/image{}:;.png?id=4#wat") ' + + "repeat top right", + expected: [ + { + name: "background", + value: + 'red url("http://site.com/image{}:;.png?id=4#wat") ' + + "repeat top right", + priority: "", + offsets: [0, 78], + declarationText: + 'background: red url("http://site.com/image{}:;.png?id=4#wat") ' + + "repeat top right", + }, + ], + }, + // Test that an empty string results in an empty array + { input: "", expected: [] }, + // Test that a string comprised only of whitespaces results in an empty array + { input: " \n \n \n \n \t \t\t\t ", expected: [] }, + // Test that a null input throws an exception + { input: null, throws: true }, + // Test that a undefined input throws an exception + { input: undefined, throws: true }, + // Test that :;{} characters in quoted content are not parsed as multiple + // declarations + { + input: 'content: ";color:red;}selector{color:yellow;"', + expected: [ + { + name: "content", + value: '";color:red;}selector{color:yellow;"', + priority: "", + offsets: [0, 45], + declarationText: 'content: ";color:red;}selector{color:yellow;"', + }, + ], + }, + // Test that rules aren't parsed, just declarations. + { + input: "body {color:red;} p {color: blue;}", + expected: [], + }, + // Test unbalanced : and ; + { + input: "color :red : font : arial;", + expected: [ + { + name: "color", + value: "red : font : arial", + priority: "", + offsets: [0, 26], + }, + ], + }, + { + input: "background: red;;;;;", + expected: [ + { + name: "background", + value: "red", + priority: "", + offsets: [0, 16], + declarationText: "background: red;", + }, + ], + }, + { + input: "background:;", + expected: [ + { name: "background", value: "", priority: "", offsets: [0, 12] }, + ], + }, + { input: ";;;;;", expected: [] }, + { input: ":;:;", expected: [] }, + // Test name only + { + input: "color", + expected: [{ name: "color", value: "", priority: "", offsets: [0, 5] }], + }, + // Test trailing name without : + { + input: "color:blue;font", + expected: [ + { + name: "color", + value: "blue", + priority: "", + offsets: [0, 11], + declarationText: "color:blue;", + }, + { name: "font", value: "", priority: "", offsets: [11, 15] }, + ], + }, + // Test trailing name with : + { + input: "color:blue;font:", + expected: [ + { + name: "color", + value: "blue", + priority: "", + offsets: [0, 11], + declarationText: "color:blue;", + }, + { name: "font", value: "", priority: "", offsets: [11, 16] }, + ], + }, + // Test leading value + { + input: "Arial;color:blue;", + expected: [ + { name: "", value: "Arial", priority: "", offsets: [0, 6] }, + { + name: "color", + value: "blue", + priority: "", + offsets: [6, 17], + declarationText: "color:blue;", + }, + ], + }, + // Test hex colors + { + input: "color: #333", + expected: [ + { + name: "color", + value: "#333", + priority: "", + offsets: [0, 11], + declarationText: "color: #333", + }, + ], + }, + { + input: "color: #456789", + expected: [ + { + name: "color", + value: "#456789", + priority: "", + offsets: [0, 14], + declarationText: "color: #456789", + }, + ], + }, + { + input: "wat: #XYZ", + expected: [ + { + name: "wat", + value: "#XYZ", + priority: "", + offsets: [0, 9], + declarationText: "wat: #XYZ", + }, + ], + }, + // Test string/url quotes escaping + { + input: "content: \"this is a 'string'\"", + expected: [ + { + name: "content", + value: "\"this is a 'string'\"", + priority: "", + offsets: [0, 29], + declarationText: "content: \"this is a 'string'\"", + }, + ], + }, + { + input: 'content: "this is a \\"string\\""', + expected: [ + { + name: "content", + value: '"this is a \\"string\\""', + priority: "", + offsets: [0, 31], + declarationText: 'content: "this is a \\"string\\""', + }, + ], + }, + { + input: "content: 'this is a \"string\"'", + expected: [ + { + name: "content", + value: "'this is a \"string\"'", + priority: "", + offsets: [0, 29], + declarationText: "content: 'this is a \"string\"'", + }, + ], + }, + { + input: "content: 'this is a \\'string\\''", + expected: [ + { + name: "content", + value: "'this is a \\'string\\''", + priority: "", + offsets: [0, 31], + declarationText: "content: 'this is a \\'string\\''", + }, + ], + }, + { + input: "content: 'this \\' is a \" really strange string'", + expected: [ + { + name: "content", + value: "'this \\' is a \" really strange string'", + priority: "", + offsets: [0, 47], + declarationText: "content: 'this \\' is a \" really strange string'", + }, + ], + }, + { + input: 'content: "a not s\\ o very long title"', + expected: [ + { + name: "content", + value: '"a not s\\ o very long title"', + priority: "", + offsets: [0, 46], + declarationText: 'content: "a not s\\ o very long title"', + }, + ], + }, + // Test calc with nested parentheses + { + input: "width: calc((100% - 3em) / 2)", + expected: [ + { + name: "width", + value: "calc((100% - 3em) / 2)", + priority: "", + offsets: [0, 29], + declarationText: "width: calc((100% - 3em) / 2)", + }, + ], + }, + + // Simple embedded comment test. + { + parseComments: true, + input: "width: 5; /* background: green; */ background: red;", + expected: [ + { + name: "width", + value: "5", + priority: "", + offsets: [0, 9], + declarationText: "width: 5;", + }, + { + name: "background", + value: "green", + priority: "", + offsets: [13, 31], + declarationText: "background: green;", + commentOffsets: [10, 34], + }, + { + name: "background", + value: "red", + priority: "", + offsets: [35, 51], + declarationText: "background: red;", + }, + ], + }, + + // Embedded comment where the parsing heuristic fails. + { + parseComments: true, + input: "width: 5; /* background something: green; */ background: red;", + expected: [ + { + name: "width", + value: "5", + priority: "", + offsets: [0, 9], + declarationText: "width: 5;", + }, + { + name: "background", + value: "red", + priority: "", + offsets: [45, 61], + declarationText: "background: red;", + }, + ], + }, + + // Embedded comment where the parsing heuristic is a bit funny. + { + parseComments: true, + input: "width: 5; /* background: */ background: red;", + expected: [ + { + name: "width", + value: "5", + priority: "", + offsets: [0, 9], + declarationText: "width: 5;", + }, + { + name: "background", + value: "", + priority: "", + offsets: [13, 24], + commentOffsets: [10, 27], + }, + { + name: "background", + value: "red", + priority: "", + offsets: [28, 44], + declarationText: "background: red;", + }, + ], + }, + + // Another case where the parsing heuristic says not to bother; note + // that there is no ";" in the comment. + { + parseComments: true, + input: "width: 5; /* background: yellow */ background: red;", + expected: [ + { + name: "width", + value: "5", + priority: "", + offsets: [0, 9], + declarationText: "width: 5;", + }, + { + name: "background", + value: "yellow", + priority: "", + offsets: [13, 31], + declarationText: "background: yellow", + commentOffsets: [10, 34], + }, + { + name: "background", + value: "red", + priority: "", + offsets: [35, 51], + declarationText: "background: red;", + }, + ], + }, + + // Parsing a comment should yield text that has been unescaped, and + // the offsets should refer to the original text. + { + parseComments: true, + input: "/* content: '*\\/'; */", + expected: [ + { + name: "content", + value: "'*/'", + priority: "", + offsets: [3, 18], + declarationText: "content: '*\\/';", + commentOffsets: [0, 21], + }, + ], + }, + + // Parsing a comment should yield text that has been unescaped, and + // the offsets should refer to the original text. This variant + // tests the no-semicolon path. + { + parseComments: true, + input: "/* content: '*\\/' */", + expected: [ + { + name: "content", + value: "'*/'", + priority: "", + offsets: [3, 17], + declarationText: "content: '*\\/'", + commentOffsets: [0, 20], + }, + ], + }, + + // A comment-in-a-comment should yield the correct offsets. + { + parseComments: true, + input: "/* color: /\\* comment *\\/ red; */", + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [3, 30], + declarationText: "color: /\\* comment *\\/ red;", + commentOffsets: [0, 33], + }, + ], + }, + + // HTML comments are not special -- they are just ordinary tokens. + { + parseComments: true, + input: " color: blue;", + expected: [ + { name: " color", value: "blue", priority: "", offsets: [17, 33] }, + ], + }, + + // Don't error on an empty comment. + { + parseComments: true, + input: "/**/", + expected: [], + }, + + // Parsing our special comments skips the name-check heuristic. + { + parseComments: true, + input: "/*! walrus: zebra; */", + expected: [ + { + name: "walrus", + value: "zebra", + priority: "", + offsets: [4, 18], + declarationText: "walrus: zebra;", + commentOffsets: [0, 21], + }, + ], + }, + + // Regression test for bug 1287620. + { + input: "color: blue \\9 no\\_need", + expected: [ + { + name: "color", + value: "blue \\9 no_need", + priority: "", + offsets: [0, 23], + declarationText: "color: blue \\9 no\\_need", + }, + ], + }, + + // Regression test for bug 1297890 - don't paste tokens. + { + parseComments: true, + input: "stroke-dasharray: 1/*ThisIsAComment*/2;", + expected: [ + { + name: "stroke-dasharray", + value: "1 2", + priority: "", + offsets: [0, 39], + declarationText: "stroke-dasharray: 1/*ThisIsAComment*/2;", + }, + ], + }, + + // Regression test for bug 1384463 - don't trim significant + // whitespace. + { + // \u00a0 is non-breaking space. + input: "\u00a0vertical-align: top", + expected: [ + { + name: "\u00a0vertical-align", + value: "top", + priority: "", + offsets: [0, 20], + declarationText: "\u00a0vertical-align: top", + }, + ], + }, + + // Regression test for bug 1544223 - take CSS blocks into consideration before handling + // ; and : (i.e. don't advance to the property name or value automatically). + { + input: `--foo: ( + :doodle { + @grid: 30x1 / 18vmin; + } + :container { + perspective: 30vmin; + } + + @place-cell: center; + @size: 100%; + + :after, :before { + content: ''; + @size: 100%; + position: absolute; + border: 2.4vmin solid var(--color); + border-image: radial-gradient( + var(--color), transparent 60% + ); + border-image-width: 4; + transform: rotate(@pn(0, 5deg)); + } + + will-change: transform, opacity; + animation: scale-up 15s linear infinite; + animation-delay: calc(-15s / @size() * @i()); + box-shadow: inset 0 0 1em var(--color); + border-radius: 50%; + filter: var(--filter); + + @keyframes scale-up { + 0%, 100% { + transform: translateZ(0) scale(0) rotate(0); + opacity: 0; + } + 50% { + opacity: 1; + } + 99% { + transform: + translateZ(30vmin) + scale(1) + rotate(-270deg); + } + } + );`, + expected: [ + { + name: "--foo", + value: + "( :doodle { @grid: 30x1 / 18vmin; } :container { perspective: 30vmin; } " + + "@place-cell: center; @size: 100%; :after, :before { content: ''; @size: " + + "100%; position: absolute; border: 2.4vmin solid var(--color); " + + "border-image: radial-gradient( var(--color), transparent 60% ); " + + "border-image-width: 4; transform: rotate(@pn(0, 5deg)); } will-change: " + + "transform, opacity; animation: scale-up 15s linear infinite; " + + "animation-delay: calc(-15s / @size() * @i()); box-shadow: inset 0 0 1em " + + "var(--color); border-radius: 50%; filter: var(--filter); @keyframes " + + "scale-up { 0%, 100% { transform: translateZ(0) scale(0) rotate(0); " + + "opacity: 0; } 50% { opacity: 1; } 99% { transform: translateZ(30vmin) " + + "scale(1) rotate(-270deg); } } )", + priority: "", + offsets: [0, 1036], + }, + ], + }, + + /***** Testing nested rules *****/ + + // Testing basic nesting with tagname selector + { + input: ` + color: red; + div { + background: blue; + } + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + ], + }, + + // Testing basic nesting with tagname + pseudo selector + { + input: ` + color: red; + div:hover { + background: blue; + } + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + ], + }, + + // Testing basic nesting with id selector + { + input: ` + color: red; + #myEl { + background: blue; + } + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + ], + }, + + // Testing basic nesting with class selector + { + input: ` + color: red; + .myEl { + background: blue; + } + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + ], + }, + + // Testing basic nesting with & + ident selector + { + input: ` + color: red; + & div { + background: blue; + } + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + ], + }, + + // Testing basic nesting with direct child selector + { + input: ` + color: red; + > div { + background: blue; + } + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + ], + }, + + // Testing basic nesting with & and :hover pseudo-class selector + { + input: ` + color: red; + &:hover { + background: blue; + } + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + ], + }, + + // Testing basic nesting with :not pseudo-class selector and non-leading & + { + input: ` + color: red; + :not(&) { + background: blue; + } + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + ], + }, + + // Testing basic nesting with attribute selector + { + input: ` + color: red; + [class] { + background: blue; + } + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + ], + }, + + // Testing basic nesting with & compound selector + { + input: ` + color: red; + &div { + background: blue; + } + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + ], + }, + + // Testing basic nesting with relative + selector + { + input: ` + color: red; + + div { + background: blue; + } + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + ], + }, + + // Testing basic nesting with relative ~ selector + { + input: ` + color: red; + ~ div { + background: blue; + } + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + ], + }, + + // Testing basic nesting with relative * selector + { + input: ` + color: red; + * div { + background: blue; + } + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + ], + }, + + // Testing basic nesting after a property with !important + { + input: ` + color: red !important; + & div { + background: blue; + } + `, + expected: [ + { + name: "color", + value: "red", + priority: "important", + offsets: [7, 29], + declarationText: "color: red !important;", + }, + ], + }, + + // Testing basic nesting after a comment + { + input: ` + color: red; + /* nested rules */ + & div { + background: blue; + } + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + ], + }, + + // Testing at-rules (with condition) nesting + { + input: ` + color: red; + @media (orientation: landscape) { + background: blue; + } + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + ], + }, + + // Testing at-rules (without condition) nesting + { + input: ` + color: red; + @media screen { + background: blue; + } + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + ], + }, + + // Testing multi-level nesting + { + input: ` + color: red; + &div { + &.active { + border: 1px; + } + padding: 10px; + } + background: gold; + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + { + name: "background", + value: "gold", + priority: "", + offsets: [121, 138], + declarationText: "background: gold;", + }, + ], + }, + + // Testing multi-level nesting with at-rules + { + input: ` + color: red; + @layer { + background: yellow; + @media screen { + & { + border-color: blue; + } + } + } + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + ], + }, + + // Testing sibling nested rules + { + input: ` + color: red; + .active { + border: 1px; + } + &div { + padding: 10px; + } + border-color: cyan + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + { + name: "border-color", + value: "cyan", + priority: "", + offsets: [114, 132], + declarationText: "border-color: cyan", + }, + ], + }, + + // Testing nesting interwined between property declarations + { + input: ` + color: red; + .active { + border: 1px; + } + background: gold; + &div { + padding: 10px; + } + border-color: cyan + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + { + name: "background", + value: "gold", + priority: "", + offsets: [70, 87], + declarationText: "background: gold;", + }, + { + name: "border-color", + value: "cyan", + priority: "", + offsets: [138, 156], + declarationText: "border-color: cyan", + }, + ], + }, + + // Testing that "}" in content property does not impact the nested state + { + input: ` + color: red; + &div { + content: "}" + color: blue; + } + background: gold; + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + { + name: "background", + value: "gold", + priority: "", + offsets: [88, 105], + declarationText: "background: gold;", + }, + ], + }, + + // Testing that "}" in attribute selector does not impact the nested state + { + input: ` + color: red; + + .foo { + [class="}"] { + padding: 10px; + } + } + background: gold; + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + { + name: "background", + value: "gold", + priority: "", + offsets: [105, 122], + declarationText: "background: gold;", + }, + ], + }, + + // Testing that in function does not impact the nested state + { + input: ` + color: red; + + .foo { + background: url("img.png?x=}") + } + background: gold; + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + { + name: "background", + value: "gold", + priority: "", + offsets: [87, 104], + declarationText: "background: gold;", + }, + ], + }, + + // Testing that "}" in comment does not impact the nested state + { + input: ` + color: red; + + .foo { + /* Check } */ + padding: 10px; + } + background: gold; + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + { + name: "background", + value: "gold", + priority: "", + offsets: [93, 110], + declarationText: "background: gold;", + }, + ], + }, + + // Testing that nested rules in comments aren't reported + { + parseComments: true, + input: "width: 5; /* div { color: cyan; } */ background: red;", + expected: [ + { + name: "width", + value: "5", + priority: "", + offsets: [0, 9], + declarationText: "width: 5;", + }, + { + name: "background", + value: "red", + priority: "", + offsets: [37, 53], + declarationText: "background: red;", + }, + ], + }, + + // Testing that declarations in comments are still handled while nested rule in same comment is ignored + { + parseComments: true, + input: + "width: 5; /* padding: 12px; div { color: cyan; } margin: 1em; */ background: red;", + expected: [ + { + name: "width", + value: "5", + priority: "", + offsets: [0, 9], + declarationText: "width: 5;", + }, + { + name: "padding", + value: "12px", + priority: "", + offsets: [13, 27], + declarationText: "padding: 12px;", + }, + { + name: "margin", + value: "1em", + priority: "", + offsets: [49, 61], + declarationText: "margin: 1em;", + }, + { + name: "background", + value: "red", + priority: "", + offsets: [65, 81], + declarationText: "background: red;", + }, + ], + }, + + // Testing nesting without closing bracket + { + input: ` + color: red; + & div { + background: blue; + `, + expected: [ + { + name: "color", + value: "red", + priority: "", + offsets: [7, 18], + declarationText: "color: red;", + }, + ], + }, +]; + +function run_test() { + run_basic_tests(); + run_comment_tests(); + run_named_tests(); +} + +// Test parseDeclarations. +function run_basic_tests() { + for (const test of TEST_DATA) { + info("Test input string " + test.input); + let output; + try { + output = parseDeclarations( + isCssPropertyKnown, + test.input, + test.parseComments + ); + } catch (e) { + info( + "parseDeclarations threw an exception with the given input " + "string" + ); + if (test.throws) { + info("Exception expected"); + Assert.ok(true); + } else { + info("Exception unexpected\n" + e); + Assert.ok(false); + } + } + if (output) { + assertOutput(test.input, output, test.expected); + } + } +} + +const COMMENT_DATA = [ + { + input: "content: 'hi", + expected: [ + { + name: "content", + value: "'hi", + priority: "", + terminator: "';", + offsets: [2, 14], + colonOffsets: [9, 11], + commentOffsets: [0, 16], + }, + ], + }, + { + input: "text that once confounded the parser;", + expected: [], + }, +]; + +// Test parseCommentDeclarations. +function run_comment_tests() { + for (const test of COMMENT_DATA) { + info("Test input string " + test.input); + const output = _parseCommentDeclarations( + isCssPropertyKnown, + test.input, + 0, + test.input.length + 4 + ); + deepEqual(output, test.expected); + } +} + +const NAMED_DATA = [ + { + input: "position:absolute;top50px;height:50px;", + expected: [ + { + name: "position", + value: "absolute", + priority: "", + terminator: "", + offsets: [0, 18], + colonOffsets: [8, 9], + }, + { + name: "height", + value: "50px", + priority: "", + terminator: "", + offsets: [26, 38], + colonOffsets: [32, 33], + }, + ], + }, +]; + +// Test parseNamedDeclarations. +function run_named_tests() { + for (const test of NAMED_DATA) { + info("Test input string " + test.input); + const output = parseNamedDeclarations(isCssPropertyKnown, test.input, true); + info(JSON.stringify(output)); + deepEqual(output, test.expected); + } +} + +function assertOutput(input, actual, expected) { + if (actual.length === expected.length) { + for (let i = 0; i < expected.length; i++) { + Assert.ok(!!actual[i]); + info( + "Check that the output item has the expected name, " + + "value and priority" + ); + Assert.equal(expected[i].name, actual[i].name); + Assert.equal(expected[i].value, actual[i].value); + Assert.equal(expected[i].priority, actual[i].priority); + deepEqual(expected[i].offsets, actual[i].offsets); + if ("commentOffsets" in expected[i]) { + deepEqual(expected[i].commentOffsets, actual[i].commentOffsets); + } + + if (expected[i].declarationText) { + Assert.equal( + input.substring(expected[i].offsets[0], expected[i].offsets[1]), + expected[i].declarationText + ); + } + } + } else { + for (const prop of actual) { + info( + "Actual output contained: {name: " + + prop.name + + ", value: " + + prop.value + + ", priority: " + + prop.priority + + "}" + ); + } + Assert.equal(actual.length, expected.length); + } +} diff --git a/devtools/client/shared/test/xpcshell/test_parsePseudoClassesAndAttributes.js b/devtools/client/shared/test/xpcshell/test_parsePseudoClassesAndAttributes.js new file mode 100644 index 0000000000..6aa2185c7d --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_parsePseudoClassesAndAttributes.js @@ -0,0 +1,202 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + parsePseudoClassesAndAttributes, + SELECTOR_ATTRIBUTE, + SELECTOR_ELEMENT, + SELECTOR_PSEUDO_CLASS, +} = require("resource://devtools/shared/css/parsing-utils.js"); + +const TEST_DATA = [ + // Test that a null input throws an exception + { + input: null, + throws: true, + }, + // Test that a undefined input throws an exception + { + input: undefined, + throws: true, + }, + { + input: ":root", + expected: [{ value: ":root", type: SELECTOR_PSEUDO_CLASS }], + }, + { + input: ".testclass", + expected: [{ value: ".testclass", type: SELECTOR_ELEMENT }], + }, + { + input: "div p", + expected: [{ value: "div p", type: SELECTOR_ELEMENT }], + }, + { + input: "div > p", + expected: [{ value: "div > p", type: SELECTOR_ELEMENT }], + }, + { + input: "a[hidden]", + expected: [ + { value: "a", type: SELECTOR_ELEMENT }, + { value: "[hidden]", type: SELECTOR_ATTRIBUTE }, + ], + }, + { + input: "a[hidden=true]", + expected: [ + { value: "a", type: SELECTOR_ELEMENT }, + { value: "[hidden=true]", type: SELECTOR_ATTRIBUTE }, + ], + }, + { + input: "a[hidden=true] p:hover", + expected: [ + { value: "a", type: SELECTOR_ELEMENT }, + { value: "[hidden=true]", type: SELECTOR_ATTRIBUTE }, + { value: " p", type: SELECTOR_ELEMENT }, + { value: ":hover", type: SELECTOR_PSEUDO_CLASS }, + ], + }, + { + input: 'a[checked="true"]', + expected: [ + { value: "a", type: SELECTOR_ELEMENT }, + { value: '[checked="true"]', type: SELECTOR_ATTRIBUTE }, + ], + }, + { + input: "a[title~=test]", + expected: [ + { value: "a", type: SELECTOR_ELEMENT }, + { value: "[title~=test]", type: SELECTOR_ATTRIBUTE }, + ], + }, + { + input: 'h1[hidden="true"][title^="Important"]', + expected: [ + { value: "h1", type: SELECTOR_ELEMENT }, + { value: '[hidden="true"]', type: SELECTOR_ATTRIBUTE }, + { value: '[title^="Important"]', type: SELECTOR_ATTRIBUTE }, + ], + }, + { + input: "p:hover", + expected: [ + { value: "p", type: SELECTOR_ELEMENT }, + { value: ":hover", type: SELECTOR_PSEUDO_CLASS }, + ], + }, + { + input: "p + .testclass:hover", + expected: [ + { value: "p + .testclass", type: SELECTOR_ELEMENT }, + { value: ":hover", type: SELECTOR_PSEUDO_CLASS }, + ], + }, + { + input: "p::before", + expected: [ + { value: "p", type: SELECTOR_ELEMENT }, + { value: "::before", type: SELECTOR_PSEUDO_CLASS }, + ], + }, + { + input: "p:nth-child(2)", + expected: [ + { value: "p", type: SELECTOR_ELEMENT }, + { value: ":nth-child(2)", type: SELECTOR_PSEUDO_CLASS }, + ], + }, + { + input: 'p:not([title="test"]) .testclass', + expected: [ + { value: "p", type: SELECTOR_ELEMENT }, + { value: ':not([title="test"])', type: SELECTOR_PSEUDO_CLASS }, + { value: " .testclass", type: SELECTOR_ELEMENT }, + ], + }, + { + input: "a\\:hover", + expected: [{ value: "a\\:hover", type: SELECTOR_ELEMENT }], + }, + { + input: ":not(:lang(it))", + expected: [{ value: ":not(:lang(it))", type: SELECTOR_PSEUDO_CLASS }], + }, + { + input: "p:not(:lang(it))", + expected: [ + { value: "p", type: SELECTOR_ELEMENT }, + { value: ":not(:lang(it))", type: SELECTOR_PSEUDO_CLASS }, + ], + }, + { + input: "p:not(p:lang(it))", + expected: [ + { value: "p", type: SELECTOR_ELEMENT }, + { value: ":not(p:lang(it))", type: SELECTOR_PSEUDO_CLASS }, + ], + }, + { + input: ":not(:lang(it)", + expected: [{ value: ":not(:lang(it)", type: SELECTOR_ELEMENT }], + }, + { + input: ":not(:lang(it)))", + expected: [ + { value: ":not(:lang(it))", type: SELECTOR_PSEUDO_CLASS }, + { value: ")", type: SELECTOR_ELEMENT }, + ], + }, +]; + +function run_test() { + for (const test of TEST_DATA) { + dump("Test input string " + test.input + "\n"); + let output; + + try { + output = parsePseudoClassesAndAttributes(test.input); + } catch (e) { + dump( + "parsePseudoClassesAndAttributes threw an exception with the " + + "given input string\n" + ); + if (test.throws) { + ok(true, "Exception expected"); + } else { + dump(); + ok(false, "Exception unexpected\n" + e); + } + } + + if (output) { + assertOutput(output, test.expected); + } + } +} + +function assertOutput(actual, expected) { + if (actual.length === expected.length) { + for (let i = 0; i < expected.length; i++) { + dump("Check that the output item has the expected value and type\n"); + ok(!!actual[i]); + equal(expected[i].value, actual[i].value); + equal(expected[i].type, actual[i].type); + } + } else { + for (const prop of actual) { + dump( + "Actual output contained: {value: " + + prop.value + + ", type: " + + prop.type + + "}\n" + ); + } + equal(actual.length, expected.length); + } +} diff --git a/devtools/client/shared/test/xpcshell/test_parseSingleValue.js b/devtools/client/shared/test/xpcshell/test_parseSingleValue.js new file mode 100644 index 0000000000..4617c6de2c --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_parseSingleValue.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + parseSingleValue, +} = require("resource://devtools/shared/css/parsing-utils.js"); +const { + isCssPropertyKnown, +} = require("resource://devtools/server/actors/css-properties.js"); + +const TEST_DATA = [ + { input: null, throws: true }, + { input: undefined, throws: true }, + { input: "", expected: { value: "", priority: "" } }, + { input: " \t \t \n\n ", expected: { value: "", priority: "" } }, + { input: "blue", expected: { value: "blue", priority: "" } }, + { + input: "blue !important", + expected: { value: "blue", priority: "important" }, + }, + { + input: "blue!important", + expected: { value: "blue", priority: "important" }, + }, + { + input: "blue ! important", + expected: { value: "blue", priority: "important" }, + }, + { + input: "blue ! important", + expected: { value: "blue", priority: "important" }, + }, + { input: "blue !", expected: { value: "blue !", priority: "" } }, + { + input: "blue !mportant", + expected: { value: "blue !mportant", priority: "" }, + }, + { + input: " blue !important ", + expected: { value: "blue", priority: "important" }, + }, + { + input: 'url("http://url.com/whyWouldYouDoThat!important.png") !important', + expected: { + value: 'url("http://url.com/whyWouldYouDoThat!important.png")', + priority: "important", + }, + }, + { + input: 'url("http://url.com/whyWouldYouDoThat!important.png")', + expected: { + value: 'url("http://url.com/whyWouldYouDoThat!important.png")', + priority: "", + }, + }, + { + input: '"content!important" !important', + expected: { + value: '"content!important"', + priority: "important", + }, + }, + { + input: '"content!important"', + expected: { + value: '"content!important"', + priority: "", + }, + }, + { + input: '"all the \\"\'\\\\ special characters"', + expected: { + value: '"all the \\"\'\\\\ special characters"', + priority: "", + }, + }, +]; + +function run_test() { + for (const test of TEST_DATA) { + info("Test input value " + test.input); + try { + const output = parseSingleValue(isCssPropertyKnown, test.input); + assertOutput(output, test.expected); + } catch (e) { + info( + "parseSingleValue threw an exception with the given input " + "value" + ); + if (test.throws) { + info("Exception expected"); + Assert.ok(true); + } else { + info("Exception unexpected\n" + e); + Assert.ok(false); + } + } + } +} + +function assertOutput(actual, expected) { + info("Check that the output has the expected value and priority"); + Assert.equal(expected.value, actual.value); + Assert.equal(expected.priority, actual.priority); +} diff --git a/devtools/client/shared/test/xpcshell/test_rewriteDeclarations.js b/devtools/client/shared/test/xpcshell/test_rewriteDeclarations.js new file mode 100644 index 0000000000..228d2dc79d --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_rewriteDeclarations.js @@ -0,0 +1,816 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const RuleRewriter = require("resource://devtools/client/fronts/inspector/rule-rewriter.js"); +const { + isCssPropertyKnown, +} = require("resource://devtools/server/actors/css-properties.js"); + +const TEST_DATA = [ + { + desc: "simple set", + input: "p:v;", + instruction: { type: "set", name: "p", value: "N", priority: "", index: 0 }, + expected: "p:N;", + }, + { + desc: "simple set clearing !important", + input: "p:v !important;", + instruction: { type: "set", name: "p", value: "N", priority: "", index: 0 }, + expected: "p:N;", + }, + { + desc: "simple set adding !important", + input: "p:v;", + instruction: { + type: "set", + name: "p", + value: "N", + priority: "important", + index: 0, + }, + expected: "p:N !important;", + }, + { + desc: "simple set between comments", + input: "/*color:red;*/ p:v; /*color:green;*/", + instruction: { type: "set", name: "p", value: "N", priority: "", index: 1 }, + expected: "/*color:red;*/ p:N; /*color:green;*/", + }, + // The rule view can generate a "set" with a previously unknown + // property index; which should work like "create". + { + desc: "set at unknown index", + input: "a:b; e: f;", + instruction: { type: "set", name: "c", value: "d", priority: "", index: 2 }, + expected: "a:b; e: f;c: d;", + }, + { + desc: "simple rename", + input: "p:v;", + instruction: { type: "rename", name: "p", newName: "q", index: 0 }, + expected: "q:v;", + }, + // "rename" is passed the name that the user entered, and must do + // any escaping necessary to ensure that this is an identifier. + { + desc: "rename requiring escape", + input: "p:v;", + instruction: { type: "rename", name: "p", newName: "a b", index: 0 }, + expected: "a\\ b:v;", + }, + { + desc: "simple create", + input: "", + instruction: { + type: "create", + name: "p", + value: "v", + priority: "important", + index: 0, + enabled: true, + }, + expected: "p: v !important;", + }, + { + desc: "create between two properties", + input: "a:b; e: f;", + instruction: { + type: "create", + name: "c", + value: "d", + priority: "", + index: 1, + enabled: true, + }, + expected: "a:b; c: d;e: f;", + }, + // "create" is passed the name that the user entered, and must do + // any escaping necessary to ensure that this is an identifier. + { + desc: "create requiring escape", + input: "", + instruction: { + type: "create", + name: "a b", + value: "d", + priority: "", + index: 1, + enabled: true, + }, + expected: "a\\ b: d;", + }, + { + desc: "simple disable", + input: "p:v;", + instruction: { type: "enable", name: "p", value: false, index: 0 }, + expected: "/*! p:v; */", + }, + { + desc: "simple enable", + input: "/* color:v; */", + instruction: { type: "enable", name: "color", value: true, index: 0 }, + expected: "color:v;", + }, + { + desc: "enable with following property in comment", + input: "/* color:red; color: blue; */", + instruction: { type: "enable", name: "color", value: true, index: 0 }, + expected: "color:red; /* color: blue; */", + }, + { + desc: "enable with preceding property in comment", + input: "/* color:red; color: blue; */", + instruction: { type: "enable", name: "color", value: true, index: 1 }, + expected: "/* color:red; */ color: blue;", + }, + { + desc: "simple remove", + input: "a:b;c:d;e:f;", + instruction: { type: "remove", name: "c", index: 1 }, + expected: "a:b;e:f;", + }, + { + desc: "disable with comment ender in string", + input: "content: '*/';", + instruction: { type: "enable", name: "content", value: false, index: 0 }, + expected: "/*! content: '*\\/'; */", + }, + { + desc: "enable with comment ender in string", + input: "/* content: '*\\/'; */", + instruction: { type: "enable", name: "content", value: true, index: 0 }, + expected: "content: '*/';", + }, + { + desc: "enable requiring semicolon insertion", + // Note the lack of a trailing semicolon in the comment. + input: "/* color:red */ color: blue;", + instruction: { type: "enable", name: "color", value: true, index: 0 }, + expected: "color:red; color: blue;", + }, + { + desc: "create requiring semicolon insertion", + // Note the lack of a trailing semicolon. + input: "color: red", + instruction: { + type: "create", + name: "a", + value: "b", + priority: "", + index: 1, + enabled: true, + }, + expected: "color: red;a: b;", + }, + + // Newline insertion. + { + desc: "simple newline insertion", + input: "\ncolor: red;\n", + instruction: { + type: "create", + name: "a", + value: "b", + priority: "", + index: 1, + enabled: true, + }, + expected: "\ncolor: red;\na: b;\n", + }, + // Newline insertion. + { + desc: "semicolon insertion before newline", + // Note the lack of a trailing semicolon. + input: "\ncolor: red\n", + instruction: { + type: "create", + name: "a", + value: "b", + priority: "", + index: 1, + enabled: true, + }, + expected: "\ncolor: red;\na: b;\n", + }, + // Newline insertion. + { + desc: "newline and semicolon insertion", + // Note the lack of a trailing semicolon and newline. + input: "\ncolor: red", + instruction: { + type: "create", + name: "a", + value: "b", + priority: "", + index: 1, + enabled: true, + }, + expected: "\ncolor: red;\na: b;\n", + }, + + // Newline insertion and indentation. + { + desc: "indentation with create", + input: "\n color: red;\n", + instruction: { + type: "create", + name: "a", + value: "b", + priority: "", + index: 1, + enabled: true, + }, + expected: "\n color: red;\n a: b;\n", + }, + // Newline insertion and indentation. + { + desc: "indentation plus semicolon insertion before newline", + // Note the lack of a trailing semicolon. + input: "\n color: red\n", + instruction: { + type: "create", + name: "a", + value: "b", + priority: "", + index: 1, + enabled: true, + }, + expected: "\n color: red;\n a: b;\n", + }, + { + desc: "indentation inserted before trailing whitespace", + // Note the trailing whitespace. This could come from a rule + // like: + // @supports (mumble) { + // body { + // color: red; + // } + // } + // Here if we create a rule we don't want it to follow + // the indentation of the "}". + input: "\n color: red;\n ", + instruction: { + type: "create", + name: "a", + value: "b", + priority: "", + index: 1, + enabled: true, + }, + expected: "\n color: red;\n a: b;\n ", + }, + // Newline insertion and indentation. + { + desc: "indentation comes from preceding comment", + // Note how the comment comes before the declaration. + input: "\n /* comment */ color: red\n", + instruction: { + type: "create", + name: "a", + value: "b", + priority: "", + index: 1, + enabled: true, + }, + expected: "\n /* comment */ color: red;\n a: b;\n", + }, + // Default indentation. + { + desc: "use of default indentation", + input: "\n", + instruction: { + type: "create", + name: "a", + value: "b", + priority: "", + index: 0, + enabled: true, + }, + expected: "\n\ta: b;\n", + }, + + // Deletion handles newlines properly. + { + desc: "deletion removes newline", + input: "a:b;\nc:d;\ne:f;", + instruction: { type: "remove", name: "c", index: 1 }, + expected: "a:b;\ne:f;", + }, + // Deletion handles newlines properly. + { + desc: "deletion remove blank line", + input: "\n a:b;\n c:d; \ne:f;", + instruction: { type: "remove", name: "c", index: 1 }, + expected: "\n a:b;\ne:f;", + }, + // Deletion handles newlines properly. + { + desc: "deletion leaves comment", + input: "\n a:b;\n /* something */ c:d; \ne:f;", + instruction: { type: "remove", name: "c", index: 1 }, + expected: "\n a:b;\n /* something */ \ne:f;", + }, + // Deletion handles newlines properly. + { + desc: "deletion leaves previous newline", + input: "\n a:b;\n c:d; \ne:f;", + instruction: { type: "remove", name: "e", index: 2 }, + expected: "\n a:b;\n c:d; \n", + }, + // Deletion handles newlines properly. + { + desc: "deletion removes trailing whitespace", + input: "\n a:b;\n c:d; \n e:f;", + instruction: { type: "remove", name: "e", index: 2 }, + expected: "\n a:b;\n c:d; \n", + }, + // Deletion handles newlines properly. + { + desc: "deletion preserves indentation", + input: " a:b;\n c:d; \n e:f;", + instruction: { type: "remove", name: "a", index: 0 }, + expected: " c:d; \n e:f;", + }, + + // Termination insertion corner case. + { + desc: "enable single quote termination", + input: "/* content: 'hi */ color: red;", + instruction: { type: "enable", name: "content", value: true, index: 0 }, + expected: "content: 'hi'; color: red;", + changed: { 0: "'hi'" }, + }, + // Termination insertion corner case. + { + desc: "create single quote termination", + input: "content: 'hi", + instruction: { + type: "create", + name: "color", + value: "red", + priority: "", + index: 1, + enabled: true, + }, + expected: "content: 'hi';color: red;", + changed: { 0: "'hi'" }, + }, + + // Termination insertion corner case. + { + desc: "enable double quote termination", + input: '/* content: "hi */ color: red;', + instruction: { type: "enable", name: "content", value: true, index: 0 }, + expected: 'content: "hi"; color: red;', + changed: { 0: '"hi"' }, + }, + // Termination insertion corner case. + { + desc: "create double quote termination", + input: 'content: "hi', + instruction: { + type: "create", + name: "color", + value: "red", + priority: "", + index: 1, + enabled: true, + }, + expected: 'content: "hi";color: red;', + changed: { 0: '"hi"' }, + }, + + // Termination insertion corner case. + { + desc: "enable url termination", + input: "/* background-image: url(something.jpg */ color: red;", + instruction: { + type: "enable", + name: "background-image", + value: true, + index: 0, + }, + expected: "background-image: url(something.jpg); color: red;", + changed: { 0: "url(something.jpg)" }, + }, + // Termination insertion corner case. + { + desc: "create url termination", + input: "background-image: url(something.jpg", + instruction: { + type: "create", + name: "color", + value: "red", + priority: "", + index: 1, + enabled: true, + }, + expected: "background-image: url(something.jpg);color: red;", + changed: { 0: "url(something.jpg)" }, + }, + + // Termination insertion corner case. + { + desc: "enable url single quote termination", + input: "/* background-image: url('something.jpg */ color: red;", + instruction: { + type: "enable", + name: "background-image", + value: true, + index: 0, + }, + expected: "background-image: url('something.jpg'); color: red;", + changed: { 0: "url('something.jpg')" }, + }, + // Termination insertion corner case. + { + desc: "create url single quote termination", + input: "background-image: url('something.jpg", + instruction: { + type: "create", + name: "color", + value: "red", + priority: "", + index: 1, + enabled: true, + }, + expected: "background-image: url('something.jpg');color: red;", + changed: { 0: "url('something.jpg')" }, + }, + + // Termination insertion corner case. + { + desc: "create url double quote termination", + input: '/* background-image: url("something.jpg */ color: red;', + instruction: { + type: "enable", + name: "background-image", + value: true, + index: 0, + }, + expected: 'background-image: url("something.jpg"); color: red;', + changed: { 0: 'url("something.jpg")' }, + }, + // Termination insertion corner case. + { + desc: "enable url double quote termination", + input: 'background-image: url("something.jpg', + instruction: { + type: "create", + name: "color", + value: "red", + priority: "", + index: 1, + enabled: true, + }, + expected: 'background-image: url("something.jpg");color: red;', + changed: { 0: 'url("something.jpg")' }, + }, + + // Termination insertion corner case. + { + desc: "create backslash termination", + input: "something: \\", + instruction: { + type: "create", + name: "color", + value: "red", + priority: "", + index: 1, + enabled: true, + }, + expected: "something: \\\\;color: red;", + // The lexer rewrites the token before we see it. However this is + // so obscure as to be inconsequential. + changed: { 0: "\uFFFD\\" }, + }, + + // Termination insertion corner case. + { + desc: "enable backslash single quote termination", + input: "something: '\\", + instruction: { + type: "create", + name: "color", + value: "red", + priority: "", + index: 1, + enabled: true, + }, + expected: "something: '\\\\';color: red;", + changed: { 0: "'\\\\'" }, + }, + { + desc: "enable backslash double quote termination", + input: 'something: "\\', + instruction: { + type: "create", + name: "color", + value: "red", + priority: "", + index: 1, + enabled: true, + }, + expected: 'something: "\\\\";color: red;', + changed: { 0: '"\\\\"' }, + }, + + // Termination insertion corner case. + { + desc: "enable comment termination", + input: "something: blah /* comment ", + instruction: { + type: "create", + name: "color", + value: "red", + priority: "", + index: 1, + enabled: true, + }, + expected: "something: blah /* comment*/; color: red;", + }, + + // Rewrite a "heuristic override" comment. + { + desc: "enable with heuristic override comment", + input: "/*! walrus: zebra; */", + instruction: { type: "enable", name: "walrus", value: true, index: 0 }, + expected: "walrus: zebra;", + }, + + // Sanitize a bad value. + { + desc: "create sanitize unpaired brace", + input: "", + instruction: { + type: "create", + name: "p", + value: "}", + priority: "", + index: 0, + enabled: true, + }, + expected: "p: \\};", + changed: { 0: "\\}" }, + }, + // Sanitize a bad value. + { + desc: "set sanitize unpaired brace", + input: "walrus: zebra;", + instruction: { + type: "set", + name: "walrus", + value: "{{}}}", + priority: "", + index: 0, + }, + expected: "walrus: {{}}\\};", + changed: { 0: "{{}}\\}" }, + }, + // Sanitize a bad value. + { + desc: "enable sanitize unpaired brace", + input: "/*! walrus: }*/", + instruction: { type: "enable", name: "walrus", value: true, index: 0 }, + expected: "walrus: \\};", + changed: { 0: "\\}" }, + }, + + // Creating a new declaration does not require an attempt to + // terminate a previous commented declaration. + { + desc: "disabled declaration does not need semicolon insertion", + input: "/*! no: semicolon */\n", + instruction: { + type: "create", + name: "walrus", + value: "zebra", + priority: "", + index: 1, + enabled: true, + }, + expected: "/*! no: semicolon */\nwalrus: zebra;\n", + changed: {}, + }, + + { + desc: "create commented-out property", + input: "p: v", + instruction: { + type: "create", + name: "shoveler", + value: "duck", + priority: "", + index: 1, + enabled: false, + }, + expected: "p: v;/*! shoveler: duck; */", + }, + { + desc: "disabled create with comment ender in string", + input: "", + instruction: { + type: "create", + name: "content", + value: "'*/'", + priority: "", + index: 0, + enabled: false, + }, + expected: "/*! content: '*\\/'; */", + }, + + { + desc: "delete disabled property", + input: "\n a:b;\n /* color:#f06; */\n e:f;", + instruction: { type: "remove", name: "color", index: 1 }, + expected: "\n a:b;\n e:f;", + }, + { + desc: "delete heuristic-disabled property", + input: "\n a:b;\n /*! c:d; */\n e:f;", + instruction: { type: "remove", name: "c", index: 1 }, + expected: "\n a:b;\n e:f;", + }, + { + desc: "delete disabled property leaving other disabled property", + input: "\n a:b;\n /* color:#f06; background-color: seagreen; */\n e:f;", + instruction: { type: "remove", name: "color", index: 1 }, + expected: "\n a:b;\n /* background-color: seagreen; */\n e:f;", + }, + + { + desc: "regression test for bug 1328016", + input: "position:absolute;top50px;height:50px;width:50px;", + instruction: { + type: "set", + name: "width", + value: "60px", + priority: "", + index: 2, + }, + expected: "position:absolute;top50px;height:50px;width:60px;", + }, + + { + desc: "url regression test for bug 1321970", + input: "", + instruction: { + type: "create", + name: "p", + value: "url(", + priority: "", + index: 0, + enabled: true, + }, + expected: "p: url();", + changed: { 0: "url()" }, + }, + + { + desc: "url semicolon regression test for bug 1321970", + input: "", + instruction: { + type: "create", + name: "p", + value: "url(;", + priority: "", + index: 0, + enabled: true, + }, + expected: "p: url();", + changed: { 0: "url()" }, + }, + + { + desc: "basic regression test for bug 1321970", + input: "", + instruction: { + type: "create", + name: "p", + value: "(", + priority: "", + index: 0, + enabled: true, + }, + expected: "p: \\(;", + changed: { 0: "\\(" }, + }, + + { + desc: "unbalanced regression test for bug 1321970", + input: "", + instruction: { + type: "create", + name: "p", + value: "({[})", + priority: "", + index: 0, + enabled: true, + }, + expected: "p: ({\\[});", + changed: { 0: "({\\[})" }, + }, + + { + desc: "function regression test for bug 1321970", + input: "", + instruction: { + type: "create", + name: "p", + value: "func(1,2)", + priority: "", + index: 0, + enabled: true, + }, + expected: "p: func(1,2);", + }, + + { + desc: "function regression test for bug 1355233", + input: "", + instruction: { + type: "create", + name: "p", + value: "func(", + priority: "", + index: 0, + enabled: true, + }, + expected: "p: func\\(;", + changed: { 0: "func\\(" }, + }, +]; + +function rewriteDeclarations(inputString, instruction, defaultIndentation) { + const rewriter = new RuleRewriter(isCssPropertyKnown, null, inputString); + rewriter.defaultIndentation = defaultIndentation; + + switch (instruction.type) { + case "rename": + rewriter.renameProperty( + instruction.index, + instruction.name, + instruction.newName + ); + break; + + case "enable": + rewriter.setPropertyEnabled( + instruction.index, + instruction.name, + instruction.value + ); + break; + + case "create": + rewriter.createProperty( + instruction.index, + instruction.name, + instruction.value, + instruction.priority, + instruction.enabled + ); + break; + + case "set": + rewriter.setProperty( + instruction.index, + instruction.name, + instruction.value, + instruction.priority + ); + break; + + case "remove": + rewriter.removeProperty(instruction.index, instruction.name); + break; + + default: + throw new Error("unrecognized instruction"); + } + + return rewriter.getResult(); +} + +function run_test() { + for (const test of TEST_DATA) { + const { changed, text } = rewriteDeclarations( + test.input, + test.instruction, + "\t" + ); + equal(text, test.expected, "output for " + test.desc); + + let expectChanged; + if ("changed" in test) { + expectChanged = test.changed; + } else { + expectChanged = {}; + } + deepEqual(changed, expectChanged, "changed result for " + test.desc); + } +} diff --git a/devtools/client/shared/test/xpcshell/test_source-utils.js b/devtools/client/shared/test/xpcshell/test_source-utils.js new file mode 100644 index 0000000000..dec32eb531 --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_source-utils.js @@ -0,0 +1,249 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests utility functions contained in `source-utils.js` + */ + +const sourceUtils = require("resource://devtools/client/shared/source-utils.js"); + +const CHROME_URLS = [ + "chrome://foo", + "resource://baz", + "jar:file:///Users/root", +]; + +const CONTENT_URLS = [ + "http://mozilla.org", + "https://mozilla.org", + "file:///Users/root", + "app://fxosapp", + "blob:http://mozilla.org", + "blob:https://mozilla.org", +]; + +// Test `sourceUtils.parseURL` +add_task(async function () { + let parsed = sourceUtils.parseURL("https://foo.com:8888/boo/bar.js?q=query"); + equal(parsed.fileName, "bar.js", "parseURL parsed valid fileName"); + equal(parsed.host, "foo.com:8888", "parseURL parsed valid host"); + equal(parsed.hostname, "foo.com", "parseURL parsed valid hostname"); + equal(parsed.port, "8888", "parseURL parsed valid port"); + equal( + parsed.href, + "https://foo.com:8888/boo/bar.js?q=query", + "parseURL parsed valid href" + ); + + parsed = sourceUtils.parseURL("https://foo.com"); + equal( + parsed.host, + "foo.com", + "parseURL parsed valid host when no port given" + ); + equal( + parsed.hostname, + "foo.com", + "parseURL parsed valid hostname when no port given" + ); + + equal( + sourceUtils.parseURL("self-hosted"), + null, + "parseURL returns `null` for invalid URLs" + ); +}); + +// Test `sourceUtils.isContentScheme`. +add_task(async function () { + for (const url of CHROME_URLS) { + ok( + !sourceUtils.isContentScheme(url), + `${url} correctly identified as not content scheme` + ); + } + for (const url of CONTENT_URLS) { + ok( + sourceUtils.isContentScheme(url), + `${url} correctly identified as content scheme` + ); + } +}); + +// Test `sourceUtils.isChromeScheme`. +add_task(async function () { + for (const url of CHROME_URLS) { + ok( + sourceUtils.isChromeScheme(url), + `${url} correctly identified as chrome scheme` + ); + } + for (const url of CONTENT_URLS) { + ok( + !sourceUtils.isChromeScheme(url), + `${url} correctly identified as not chrome scheme` + ); + } +}); + +// Test `sourceUtils.isWASM`. +add_task(async function () { + ok( + sourceUtils.isWASM("wasm-function[66240] (?:13870536)"), + "wasm function correctly identified" + ); + ok( + !sourceUtils.isWASM(CHROME_URLS[0]), + `A chrome url does not identify as wasm.` + ); +}); + +// Test `sourceUtils.isDataScheme`. +add_task(async function () { + const dataURI = "data:text/html;charset=utf-8,"; + ok( + sourceUtils.isDataScheme(dataURI), + `${dataURI} correctly identified as data scheme` + ); + + for (const url of CHROME_URLS) { + ok( + !sourceUtils.isDataScheme(url), + `${url} correctly identified as not data scheme` + ); + } + for (const url of CONTENT_URLS) { + ok( + !sourceUtils.isDataScheme(url), + `${url} correctly identified as not data scheme` + ); + } +}); + +// Test `sourceUtils.getSourceNames`. +add_task(async function () { + testAbbreviation( + "http://example.com/foo/bar/baz/boo.js", + "boo.js", + "http://example.com/foo/bar/baz/boo.js", + "example.com" + ); +}); + +// Test `sourceUtils.getSourceNames`. +add_task(async function () { + // Check length + const longMalformedURL = `example.com${new Array(100) + .fill("/a") + .join("")}/file.js`; + Assert.lessOrEqual( + sourceUtils.getSourceNames(longMalformedURL).short.length, + 100, + "`short` names are capped at 100 characters" + ); + + testAbbreviation("self-hosted", "self-hosted", "self-hosted"); + testAbbreviation("", "(unknown)", "(unknown)"); + + // Test shortening data URIs, stripping mime/charset + testAbbreviation( + "data:text/html;charset=utf-8,", + "data:", + "data:text/html;charset=utf-8," + ); + + const longDataURI = `data:image/png;base64,${new Array(100) + .fill("a") + .join("")}`; + const longDataURIShort = sourceUtils.getSourceNames(longDataURI).short; + + // Test shortening data URIs and that the `short` result is capped + Assert.lessOrEqual( + longDataURIShort.length, + 100, + "`short` names are capped at 100 characters for data URIs" + ); + equal( + longDataURIShort.substr(0, 10), + "data:aaaaa", + "truncated data URI short names still have `data:...`" + ); + + // Test simple URL and cache retrieval by calling the same input multiple times. + const testUrl = "http://example.com/foo/bar/baz/boo.js"; + testAbbreviation(testUrl, "boo.js", testUrl, "example.com"); + testAbbreviation(testUrl, "boo.js", testUrl, "example.com"); + + // Check query and hash and port + testAbbreviation( + "http://example.com:8888/foo/bar/baz.js?q=query#go", + "baz.js", + "http://example.com:8888/foo/bar/baz.js", + "example.com:8888" + ); + + // Trailing "/" with nothing beyond host + testAbbreviation( + "http://example.com/", + "/", + "http://example.com/", + "example.com" + ); + + // Trailing "/" + testAbbreviation( + "http://example.com/foo/bar/", + "bar", + "http://example.com/foo/bar/", + "example.com" + ); + + // Non-extension ending + testAbbreviation( + "http://example.com/bar", + "bar", + "http://example.com/bar", + "example.com" + ); + + // Check query + testAbbreviation( + "http://example.com/foo.js?bar=1&baz=2", + "foo.js", + "http://example.com/foo.js", + "example.com" + ); + + // Check query with trailing slash + testAbbreviation( + "http://example.com/foo/?bar=1&baz=2", + "foo", + "http://example.com/foo/", + "example.com" + ); +}); + +// Test for source mapped file name +add_task(async function () { + const { getSourceMappedFile } = sourceUtils; + const source = "baz.js"; + const output = getSourceMappedFile(source); + equal(output, "baz.js", "correctly formats file name"); + // Test for OSX file path + const source1 = "/foo/bar/baz.js"; + const output1 = getSourceMappedFile(source1); + equal(output1, "baz.js", "correctly formats Linux file path"); + // Test for Windows file path + const source2 = "Z:\\foo\\bar\\baz.js"; + const output2 = getSourceMappedFile(source2); + equal(output2, "baz.js", "correctly formats Windows file path"); +}); + +function testAbbreviation(source, short, long, host) { + const results = sourceUtils.getSourceNames(source); + equal(results.short, short, `${source} has correct "short" name`); + equal(results.long, long, `${source} has correct "long" name`); + equal(results.host, host, `${source} has correct "host" name`); +} diff --git a/devtools/client/shared/test/xpcshell/test_suggestion-picker.js b/devtools/client/shared/test/xpcshell/test_suggestion-picker.js new file mode 100644 index 0000000000..9d08fba4cd --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_suggestion-picker.js @@ -0,0 +1,147 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test the suggestion-picker helper methods. + */ + +const { + findMostRelevantIndex, + findMostRelevantCssPropertyIndex, +} = require("resource://devtools/client/shared/suggestion-picker.js"); + +/** + * Run all tests defined below. + */ +function run_test() { + ensureMostRelevantIndexProvidedByHelperFunction(); + ensureMostRelevantIndexProvidedByClassMethod(); + ensureErrorThrownWithInvalidArguments(); +} + +/** + * Generic test data. + */ +const TEST_DATA = [ + { + // Match in sortedItems array. + items: ["chrome", "edge", "firefox"], + sortedItems: ["firefox", "chrome", "edge"], + expectedIndex: 2, + }, + { + // No match in sortedItems array. + items: ["apple", "oranges", "banana"], + sortedItems: ["kiwi", "pear", "peach"], + expectedIndex: 0, + }, + { + // Empty items array. + items: [], + sortedItems: ["empty", "arrays", "can't", "have", "relevant", "indexes"], + expectedIndex: -1, + }, +]; + +function ensureMostRelevantIndexProvidedByHelperFunction() { + info("Running ensureMostRelevantIndexProvidedByHelperFunction()"); + + for (const testData of TEST_DATA) { + const { items, sortedItems, expectedIndex } = testData; + const mostRelevantIndex = findMostRelevantIndex(items, sortedItems); + strictEqual(mostRelevantIndex, expectedIndex); + } +} + +/** + * CSS properties test data. + */ +const CSS_TEST_DATA = [ + { + items: [ + "backface-visibility", + "background", + "background-attachment", + "background-blend-mode", + "background-clip", + "background-color", + "background-image", + "background-origin", + "background-position", + "background-repeat", + ], + expectedIndex: 1, + }, + { + items: [ + "caption-side", + "clear", + "clip", + "clip-path", + "clip-rule", + "color", + "color-interpolation", + "color-interpolation-filters", + "content", + "counter-increment", + ], + expectedIndex: 5, + }, + { + items: ["direction", "display", "dominant-baseline"], + expectedIndex: 1, + }, + { + items: [ + "object-fit", + "object-position", + "offset-block-end", + "offset-block-start", + "offset-inline-end", + "offset-inline-start", + "opacity", + "order", + "orphans", + "outline", + ], + expectedIndex: 6, + }, + { + items: [ + "white-space", + "widows", + "width", + "will-change", + "word-break", + "word-spacing", + "word-wrap", + "writing-mode", + ], + expectedIndex: 2, + }, +]; + +function ensureMostRelevantIndexProvidedByClassMethod() { + info("Running ensureMostRelevantIndexProvidedByClassMethod()"); + + for (const testData of CSS_TEST_DATA) { + const { items, expectedIndex } = testData; + const mostRelevantIndex = findMostRelevantCssPropertyIndex(items); + strictEqual(mostRelevantIndex, expectedIndex); + } +} + +function ensureErrorThrownWithInvalidArguments() { + info("Running ensureErrorThrownWithInvalidTypeArgument()"); + + const expectedError = /Please provide valid items and sortedItems arrays\./; + // No arguments passed. + Assert.throws(() => findMostRelevantIndex(), expectedError); + // Invalid arguments passed. + Assert.throws(() => findMostRelevantIndex([]), expectedError); + Assert.throws(() => findMostRelevantIndex(null, []), expectedError); + Assert.throws(() => findMostRelevantIndex([], "string"), expectedError); + Assert.throws(() => findMostRelevantIndex("string", []), expectedError); +} diff --git a/devtools/client/shared/test/xpcshell/test_undoStack.js b/devtools/client/shared/test/xpcshell/test_undoStack.js new file mode 100644 index 0000000000..dafb007120 --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_undoStack.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { UndoStack } = require("resource://devtools/client/shared/undo.js"); + +const MAX_SIZE = 5; + +function run_test() { + let str = ""; + const stack = new UndoStack(MAX_SIZE); + + function add(ch) { + stack.do( + function () { + str += ch; + }, + function () { + str = str.slice(0, -1); + } + ); + } + + Assert.ok(!stack.canUndo()); + Assert.ok(!stack.canRedo()); + + // Check adding up to the limit of the size + add("a"); + Assert.ok(stack.canUndo()); + Assert.ok(!stack.canRedo()); + + add("b"); + add("c"); + add("d"); + add("e"); + + Assert.equal(str, "abcde"); + + // Check a simple undo+redo + stack.undo(); + + Assert.equal(str, "abcd"); + Assert.ok(stack.canRedo()); + + stack.redo(); + Assert.equal(str, "abcde"); + Assert.ok(!stack.canRedo()); + + // Check an undo followed by a new action + stack.undo(); + Assert.equal(str, "abcd"); + + add("q"); + Assert.equal(str, "abcdq"); + Assert.ok(!stack.canRedo()); + + stack.undo(); + Assert.equal(str, "abcd"); + stack.redo(); + Assert.equal(str, "abcdq"); + + // Revert back to the beginning of the queue... + while (stack.canUndo()) { + stack.undo(); + } + Assert.equal(str, ""); + + // Now put it all back.... + while (stack.canRedo()) { + stack.redo(); + } + Assert.equal(str, "abcdq"); + + // Now go over the undo limit... + add("1"); + add("2"); + add("3"); + + Assert.equal(str, "abcdq123"); + + // And now undoing the whole stack should only undo 5 actions. + while (stack.canUndo()) { + stack.undo(); + } + + Assert.equal(str, "abc"); +} diff --git a/devtools/client/shared/test/xpcshell/test_unicode-url.js b/devtools/client/shared/test/xpcshell/test_unicode-url.js new file mode 100644 index 0000000000..4fe7d1fdcf --- /dev/null +++ b/devtools/client/shared/test/xpcshell/test_unicode-url.js @@ -0,0 +1,258 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests utility functions contained in `unicode-url.js` + */ + +const { + getUnicodeUrl, + getUnicodeUrlPath, + getUnicodeHostname, +} = require("resource://devtools/client/shared/unicode-url.js"); + +// List of URLs used to test Unicode URL conversion +const TEST_URLS = [ + // Type: Readable ASCII URLs + // Expected: All of Unicode versions should equal to the raw. + { + raw: "https://example.org", + expectedUnicode: "https://example.org", + }, + { + raw: "http://example.org", + expectedUnicode: "http://example.org", + }, + { + raw: "ftp://example.org", + expectedUnicode: "ftp://example.org", + }, + { + raw: "https://example.org.", + expectedUnicode: "https://example.org.", + }, + { + raw: "https://example.org/", + expectedUnicode: "https://example.org/", + }, + { + raw: "https://example.org/test", + expectedUnicode: "https://example.org/test", + }, + { + raw: "https://example.org/test.html", + expectedUnicode: "https://example.org/test.html", + }, + { + raw: "https://example.org/test.html?one=1&two=2", + expectedUnicode: "https://example.org/test.html?one=1&two=2", + }, + { + raw: "https://example.org/test.html#here", + expectedUnicode: "https://example.org/test.html#here", + }, + { + raw: "https://example.org/test.html?one=1&two=2#here", + expectedUnicode: "https://example.org/test.html?one=1&two=2#here", + }, + // Type: Unreadable URLs with either Punycode domain names or URI-encoded + // paths + // Expected: Unreadable domain names and URI-encoded paths should be converted + // to readable Unicode. + { + raw: "https://xn--g6w.xn--8pv/test.html", + // Do not type Unicode characters directly, because this test file isn't + // specified with a known encoding. + expectedUnicode: "https://\u6e2c.\u672c/test.html", + }, + { + raw: "https://example.org/%E6%B8%AC%E8%A9%A6.html", + // Do not type Unicode characters directly, because this test file isn't + // specified with a known encoding. + expectedUnicode: "https://example.org/\u6e2c\u8a66.html", + }, + { + raw: "https://example.org/test.html?One=%E4%B8%80", + // Do not type Unicode characters directly, because this test file isn't + // specified with a known encoding. + expectedUnicode: "https://example.org/test.html?One=\u4e00", + }, + { + raw: "https://example.org/test.html?%E4%B8%80=1", + // Do not type Unicode characters directly, because this test file isn't + // specified with a known encoding. + expectedUnicode: "https://example.org/test.html?\u4e00=1", + }, + { + raw: + "https://xn--g6w.xn--8pv/%E6%B8%AC%E8%A9%A6.html" + + "?%E4%B8%80=%E4%B8%80" + + "#%E6%AD%A4", + // Do not type Unicode characters directly, because this test file isn't + // specified with a known encoding. + expectedUnicode: + "https://\u6e2c.\u672c/\u6e2c\u8a66.html" + "?\u4e00=\u4e00" + "#\u6b64", + }, + // Type: data: URIs + // Expected: All should not be converted. + { + raw: "data:text/plain;charset=UTF-8;Hello%20world", + expectedUnicode: "data:text/plain;charset=UTF-8;Hello%20world", + }, + { + raw: "data:text/plain;charset=UTF-8;%E6%B8%AC%20%E8%A9%A6", + expectedUnicode: "data:text/plain;charset=UTF-8;%E6%B8%AC%20%E8%A9%A6", + }, + { + raw: + "data:image/png;base64,iVBORw0KGgoAAA" + + "ANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4" + + "//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU" + + "5ErkJggg==", + expectedUnicode: + "data:image/png;base64,iVBORw0KGgoAAA" + + "ANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4" + + "//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU" + + "5ErkJggg==", + }, + // Type: Malformed URLs + // Expected: All should not be converted. + { + raw: "://example.org/test", + expectedUnicode: "://example.org/test", + }, + { + raw: "://xn--g6w.xn--8pv/%E6%B8%AC%E8%A9%A6.html" + "?%E4%B8%80=%E4%B8%80", + expectedUnicode: + "://xn--g6w.xn--8pv/%E6%B8%AC%E8%A9%A6.html" + "?%E4%B8%80=%E4%B8%80", + }, + { + // %E8%A9 isn't a valid UTF-8 code, so this URL is malformed. + raw: "https://xn--g6w.xn--8pv/%E6%B8%AC%E8%A9", + expectedUnicode: "https://xn--g6w.xn--8pv/%E6%B8%AC%E8%A9", + }, +]; + +// List of hostanmes used to test Unicode hostname conversion +const TEST_HOSTNAMES = [ + // Type: Readable ASCII hostnames + // Expected: All of Unicode versions should equal to the raw. + { + raw: "example", + expectedUnicode: "example", + }, + { + raw: "example.org", + expectedUnicode: "example.org", + }, + // Type: Unreadable Punycode hostnames + // Expected: Punycode should be converted to readable Unicode. + { + raw: "xn--g6w", + // Do not type Unicode characters directly, because this test file isn't + // specified with a known encoding. + expectedUnicode: "\u6e2c", + }, + { + raw: "xn--g6w.xn--8pv", + // Do not type Unicode characters directly, because this test file isn't + // specified with a known encoding. + expectedUnicode: "\u6e2c.\u672c", + }, +]; + +// List of URL paths used to test Unicode URL path conversion +const TEST_URL_PATHS = [ + // Type: Readable ASCII URL paths + // Expected: All of Unicode versions should equal to the raw. + { + raw: "test", + expectedUnicode: "test", + }, + { + raw: "/", + expectedUnicode: "/", + }, + { + raw: "/test", + expectedUnicode: "/test", + }, + { + raw: "/test.html?one=1&two=2#here", + expectedUnicode: "/test.html?one=1&two=2#here", + }, + // Type: Unreadable URI-encoded URL paths + // Expected: URL paths should be converted to readable Unicode. + { + raw: "/%E6%B8%AC%E8%A9%A6", + // Do not type Unicode characters directly, because this test file isn't + // specified with a known encoding. + expectedUnicode: "/\u6e2c\u8a66", + }, + { + raw: "/%E6%B8%AC%E8%A9%A6.html", + // Do not type Unicode characters directly, because this test file isn't + // specified with a known encoding. + expectedUnicode: "/\u6e2c\u8a66.html", + }, + { + raw: + "/%E6%B8%AC%E8%A9%A6.html" + + "?%E4%B8%80=%E4%B8%80&%E4%BA%8C=%E4%BA%8C" + + "#%E6%AD%A4", + // Do not type Unicode characters directly, because this test file isn't + // specified with a known encoding. + expectedUnicode: + "/\u6e2c\u8a66.html" + "?\u4e00=\u4e00&\u4e8c=\u4e8c" + "#\u6b64", + }, + // Type: Malformed URL paths + // Expected: All should not be converted. + { + // %E8%A9 isn't a valid UTF-8 code, so this URL is malformed. + raw: "/%E6%B8%AC%E8%A9", + expectedUnicode: "/%E6%B8%AC%E8%A9", + }, +]; + +function run_test() { + // Test URLs + for (const url of TEST_URLS) { + const result = getUnicodeUrl(url.raw); + equal( + result, + url.expectedUnicode, + "Test getUnicodeUrl: " + + url.raw + + " should be unicodized to " + + url.expectedUnicode + ); + } + + // Test hostnames + for (const hostname of TEST_HOSTNAMES) { + const result = getUnicodeHostname(hostname.raw); + equal( + result, + hostname.expectedUnicode, + "Test getUnicodeHostname: " + + hostname.raw + + " should be unicodized to " + + hostname.expectedUnicode + ); + } + + // Test URL paths + for (const urlPath of TEST_URL_PATHS) { + const result = getUnicodeUrlPath(urlPath.raw); + equal( + result, + urlPath.expectedUnicode, + "Test getUnicodeUrlPath: " + + urlPath.raw + + " should be unicodized to " + + urlPath.expectedUnicode + ); + } +} diff --git a/devtools/client/shared/test/xpcshell/xpcshell.toml b/devtools/client/shared/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..4916be1092 --- /dev/null +++ b/devtools/client/shared/test/xpcshell/xpcshell.toml @@ -0,0 +1,57 @@ +[DEFAULT] +tags = "devtools" +head = "head.js" +firefox-appdir = "browser" +skip-if = ["os == 'android'"] + +support-files = ["../helper_color_data.js"] + +["test_VariablesView_getString_promise.js"] + +["test_WeakMapMap.js"] + +["test_advanceValidate.js"] + +["test_attribute-parsing-01.js"] + +["test_attribute-parsing-02.js"] + +["test_bezierCanvas.js"] + +["test_classnames.js"] + +["test_cssAngle.js"] + +["test_cssColor-01.js"] + +["test_cssColor-02.js"] + +["test_cssColor-8-digit-hex.js"] + +["test_cssColorDatabase.js"] + +["test_cubicBezier.js"] + +["test_curl.js"] + +["test_escapeCSSComment.js"] + +["test_hasCSSVariable.js"] + +["test_linearEasing.js"] + +["test_parseDeclarations.js"] + +["test_parsePseudoClassesAndAttributes.js"] + +["test_parseSingleValue.js"] + +["test_rewriteDeclarations.js"] + +["test_source-utils.js"] + +["test_suggestion-picker.js"] + +["test_undoStack.js"] + +["test_unicode-url.js"] -- cgit v1.2.3