diff options
Diffstat (limited to 'devtools/client/shared/test')
177 files changed, 24680 insertions, 0 deletions
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 <body> +// 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( + "<title>JS Debugger BrowserTabList test page</title><body>Yo.</body>" + ); + +// 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,<svg\\ " + + 'xmlns=\\"http://www.w3.org/2000/svg\\"><filter\\ id=\\"blur\\">' + + '<feGaussianBlur\\ stdDeviation=\\"3\\"/></filter></svg>#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 = "<div id=test style='height:50px'></div>"; + 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 <span> is hidden"); + is(doc.querySelectorAll("input").length, 1, "Only one <input>"); + is( + doc.querySelectorAll("span").length, + 2, + "Correct number of <span> 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 <button> or <input> are not rendered, so let's use divs with + // tabindex attribute to make them focusable. + doc.body.innerHTML = ` + <main> + <header> + <div role=button tabindex=0 id="header-button">HEADER</div> + </header> + <section> + <span id="span-1" tabindex=0>SPAN 1</span> + <div role=button tabindex=0 id="section-button-1">BUTTON 1</div> + <p> + <span id="span-2" tabindex=0>SPAN 2</span> + <div role=button tabindex=0 id="section-button-2">BUTTON 2</div> + </p> + <span id="span-3" tabindex=0>SPAN 3</span> + <div role=button tabindex=0 id="section-button-3">BUTTON 3</div> + </section> + <sidebar> + <div role=button tabindex=0 id="sidebar-button">SIDEBAR</div> + </sidebar> + <main>`; + + const span1 = doc.getElementById("span-1"); + const span2 = doc.getElementById("span-2"); + const span3 = doc.getElementById("span-3"); + + info("Create 3 editable fields for the 3 spans inside the main element"); + const options = { + focusEditableFieldAfterApply: true, + focusEditableFieldContainerSelector: "main", + }; + editableField({ + element: span1, + ...options, + }); + editableField({ + element: span2, + ...options, + }); + editableField({ + element: span3, + ...options, + }); + + span1.click(); + + is( + doc.activeElement.inplaceEditor.elt.id, + "span-1", + "Visible editable field is the one associated with span-1" + ); + assertFocusedElementInplaceEditorInput(doc); + + EventUtils.sendKey("Tab"); + + is( + doc.activeElement.inplaceEditor.elt.id, + "span-2", + "Using Tab moved focus to span-2 editable field" + ); + assertFocusedElementInplaceEditorInput(doc); + + EventUtils.sendKey("Tab"); + + is( + doc.activeElement.inplaceEditor.elt.id, + "span-3", + "Using Tab moved focus to span-3 editable field" + ); + assertFocusedElementInplaceEditorInput(doc); + + EventUtils.sendKey("Tab"); + + is( + doc.activeElement.id, + "sidebar-button", + "Using Tab moved focus outside of <main>" + ); +} + +function testFocusNavigationWithNonMatchingFocusEditableFieldContainerSelector( + doc +) { + // For some reason <button> or <input> are not rendered, so let's use divs with + // tabindex attribute to make them focusable. + doc.body.innerHTML = ` + <main> + <span id="span-1" tabindex=0>SPAN 1</span> + <div role=button tabindex=0 id="section-button-1">BUTTON 1</div> + <span id="span-2" tabindex=0>SPAN 2</span> + <div role=button tabindex=0 id="section-button-2">BUTTON 2</div> + <main>`; + + const span1 = doc.getElementById("span-1"); + const span2 = doc.getElementById("span-2"); + + info( + "Create editable fields for the 2 spans, but pass a focusEditableFieldContainerSelector that does not match any element" + ); + const options = { + focusEditableFieldAfterApply: true, + focusEditableFieldContainerSelector: ".does-not-exist", + }; + editableField({ + element: span1, + ...options, + }); + editableField({ + element: span2, + ...options, + }); + + span1.click(); + + is( + doc.activeElement.inplaceEditor.elt.id, + "span-1", + "Visible editable field is the one associated with span-1" + ); + assertFocusedElementInplaceEditorInput(doc); + + EventUtils.sendKey("Tab"); + + is( + doc.activeElement.id, + "section-button-1", + "Using Tab moved focus to section-button-1, non-editable field" + ); + ok(!doc.querySelector("input"), "There's no editable input displayed"); +} + +function testMissingFocusEditableFieldContainerSelector(doc) { + doc.body.innerHTML = ""; + const element = createSpan(doc); + editableField({ + element, + focusEditableFieldAfterApply: true, + }); + + element.click(); + is( + element.style.display, + "inline-block", + "The original <span> was not hidden" + ); + ok(!doc.querySelector("input"), "No input was created"); +} + +function assertFocusedElementInplaceEditorInput(doc) { + ok( + doc.activeElement.matches("input.styleinspector-propertyeditor"), + "inplace editor input is focused" + ); +} diff --git a/devtools/client/shared/test/browser_inplace-editor_maxwidth.js b/devtools/client/shared/test/browser_inplace-editor_maxwidth.js new file mode 100644 index 0000000000..78ac6beba8 --- /dev/null +++ b/devtools/client/shared/test/browser_inplace-editor_maxwidth.js @@ -0,0 +1,138 @@ +/* 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"); + +const MAX_WIDTH = 300; +const START_TEXT = "Start text"; +const LONG_TEXT = + "I am a long text and I will not fit in a 300px container. " + + "I expect the inplace editor to wrap over more than two lines."; + +// Test the inplace-editor behavior with a maxWidth configuration option +// defined. + +add_task(async function () { + await addTab("data:text/html;charset=utf-8,inplace editor max width tests"); + const { host, doc } = await createHost(); + + info("Testing the maxWidth option in pixels, to precisely check the size"); + await new Promise(resolve => { + createInplaceEditorAndClick( + { + multiline: true, + maxWidth: MAX_WIDTH, + start: testMaxWidth, + done: resolve, + }, + doc, + START_TEXT + ); + }); + + host.destroy(); + gBrowser.removeCurrentTab(); +}); + +const testMaxWidth = async function (editor) { + is(editor.input.value, START_TEXT, "Span text content should be used"); + Assert.less( + editor.input.offsetWidth, + MAX_WIDTH, + "Input width should be strictly smaller than MAX_WIDTH" + ); + is(getLines(editor.input), 1, "Input should display 1 line of text"); + + info("Check a text is on several lines if it does not fit MAX_WIDTH"); + for (const key of LONG_TEXT) { + EventUtils.sendChar(key); + checkScrollbars(editor.input); + } + + is(editor.input.value, LONG_TEXT, "Long text should be the input value"); + is( + editor.input.offsetWidth, + MAX_WIDTH, + "Input width should be the same as MAX_WIDTH" + ); + is(getLines(editor.input), 3, "Input should display 3 lines of text"); + checkScrollbars(editor.input); + + info("Delete all characters on line 3."); + while (getLines(editor.input) === 3) { + EventUtils.sendKey("BACK_SPACE"); + checkScrollbars(editor.input); + } + + is( + editor.input.offsetWidth, + MAX_WIDTH, + "Input width should be the same as MAX_WIDTH" + ); + is(getLines(editor.input), 2, "Input should display 2 lines of text"); + checkScrollbars(editor.input); + + info("Delete all characters on line 2."); + while (getLines(editor.input) === 2) { + EventUtils.sendKey("BACK_SPACE"); + checkScrollbars(editor.input); + } + + is(getLines(editor.input), 1, "Input should display 1 line of text"); + checkScrollbars(editor.input); + + info("Delete all characters."); + while (editor.input.value !== "") { + EventUtils.sendKey("BACK_SPACE"); + checkScrollbars(editor.input); + } + + Assert.less( + editor.input.offsetWidth, + MAX_WIDTH, + "Input width should again be strictly smaller than MAX_WIDTH" + ); + Assert.greater( + editor.input.offsetWidth, + 0, + "Even with no content, the input has a non-zero width" + ); + is(getLines(editor.input), 1, "Input should display 1 line of text"); + checkScrollbars(editor.input); + + info("Leave the inplace-editor"); + EventUtils.sendKey("RETURN"); +}; + +/** + * Retrieve the current number of lines displayed in the provided textarea. + * + * @param {DOMNode} textarea + * @return {Number} the number of lines + */ +function getLines(textarea) { + const win = textarea.ownerDocument.defaultView; + const style = win.getComputedStyle(textarea); + return Math.floor(textarea.clientHeight / parseFloat(style.fontSize)); +} + +/** + * Verify that the provided textarea has no vertical or horizontal scrollbar. + * + * @param {DOMNode} textarea + */ +function checkScrollbars(textarea) { + is( + textarea.scrollHeight, + textarea.clientHeight, + "Textarea should never have vertical scrollbars" + ); + is( + textarea.scrollWidth, + textarea.clientWidth, + "Textarea should never have horizontal scrollbars" + ); +} diff --git a/devtools/client/shared/test/browser_inplace-editor_stop_on_key.js b/devtools/client/shared/test/browser_inplace-editor_stop_on_key.js new file mode 100644 index 0000000000..50ab5631ae --- /dev/null +++ b/devtools/client/shared/test/browser_inplace-editor_stop_on_key.js @@ -0,0 +1,219 @@ +/* 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 stopOnX options behavior + +add_task(async function () { + await addTab("data:text/html;charset=utf-8,inline editor stopOnX"); + const { host, doc } = await createHost(); + + testStopOnReturn(doc); + testStopOnTab(doc); + testStopOnShiftTab(doc); + + host.destroy(); + gBrowser.removeCurrentTab(); +}); + +function testStopOnReturn(doc) { + const { span1El, span2El } = setupMarkupAndCreateInplaceEditors(doc); + + info(`Create an editable field with "stopOnReturn" set to true`); + editableField({ + element: span1El, + focusEditableFieldAfterApply: true, + focusEditableFieldContainerSelector: "main", + stopOnReturn: true, + }); + editableField({ + element: span2El, + }); + + info("Activate inplace editor on first span"); + span1El.click(); + + is( + doc.activeElement.inplaceEditor.elt.id, + "span-1", + "Visible editable field is the one associated with first span" + ); + assertFocusedElementInplaceEditorInput(doc); + + info("Press Return"); + EventUtils.synthesizeKey("VK_RETURN"); + + is( + doc.activeElement.id, + "span-1", + "Using Enter did not advance the editor to the next focusable element" + ); + + info("Activate inplace editor on first span again"); + span1El.click(); + + is( + doc.activeElement.inplaceEditor.elt.id, + "span-1", + "Visible editable field is the one associated with first span" + ); + assertFocusedElementInplaceEditorInput(doc); + + const isMacOS = Services.appinfo.OS === "Darwin"; + info(`Press ${isMacOS ? "Cmd" : "Ctrl"} + Enter`); + EventUtils.synthesizeKey("VK_RETURN", { + [isMacOS ? "metaKey" : "ctrlKey"]: true, + }); + + is( + doc.activeElement.inplaceEditor.elt.id, + "span-2", + `Using ${ + isMacOS ? "Cmd" : "Ctrl" + } + Enter did advance the editor to the next focusable element` + ); +} + +function testStopOnTab(doc) { + const { span1El, span2El } = setupMarkupAndCreateInplaceEditors(doc); + + info(`Create editable fields with "stopOnTab" set to true`); + const options = { + focusEditableFieldAfterApply: true, + focusEditableFieldContainerSelector: "main", + stopOnTab: true, + }; + editableField({ + element: span1El, + ...options, + }); + editableField({ + element: span2El, + ...options, + }); + + info("Activate inplace editor on first span"); + span1El.click(); + + is( + doc.activeElement.inplaceEditor.elt.id, + "span-1", + "Visible editable field is the one associated with first span" + ); + assertFocusedElementInplaceEditorInput(doc); + + info("Press Tab"); + EventUtils.synthesizeKey("VK_TAB"); + + is( + doc.activeElement.id, + "span-1", + "Using Tab did not advance the editor to the next focusable element" + ); + + info( + "Activate inplace editor on second span to check that Shift+Tab does work" + ); + span2El.click(); + + is( + doc.activeElement.inplaceEditor.elt.id, + "span-2", + "Visible editable field is the one associated with second span" + ); + assertFocusedElementInplaceEditorInput(doc); + + info("Press Shift+Tab"); + EventUtils.synthesizeKey("VK_TAB", { + shiftKey: true, + }); + + is( + doc.activeElement.inplaceEditor.elt.id, + "span-1", + `Using Shift + Tab did move the editor to the previous focusable element` + ); +} + +function testStopOnShiftTab(doc) { + const { span1El, span2El } = setupMarkupAndCreateInplaceEditors(doc); + info(`Create editable fields with "stopOnShiftTab" set to true`); + const options = { + focusEditableFieldAfterApply: true, + focusEditableFieldContainerSelector: "main", + stopOnShiftTab: true, + }; + editableField({ + element: span1El, + ...options, + }); + editableField({ + element: span2El, + ...options, + }); + + info("Activate inplace editor on second span"); + span2El.click(); + + is( + doc.activeElement.inplaceEditor.elt.id, + "span-2", + "Visible editable field is the one associated with second span" + ); + assertFocusedElementInplaceEditorInput(doc); + + info("Press Shift+Tab"); + EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }); + + is( + doc.activeElement.id, + "span-2", + "Using Shift+Tab did not move the editor to the previous focusable element" + ); + + info( + "Activate inplace editor on first span to check that Tab is not impacted" + ); + span1El.click(); + + is( + doc.activeElement.inplaceEditor.elt.id, + "span-1", + "Visible editable field is the one associated with first span" + ); + assertFocusedElementInplaceEditorInput(doc); + + info("Press Tab"); + EventUtils.synthesizeKey("VK_TAB"); + + is( + doc.activeElement.inplaceEditor.elt.id, + "span-2", + `Using Tab did move the editor to the next focusable element` + ); +} + +function setupMarkupAndCreateInplaceEditors(doc) { + // For some reason <button> or <input> are not rendered, so let's use divs with + // tabindex attribute to make them focusable. + doc.body.innerHTML = ` + <main> + <span id="span-1" tabindex=0>SPAN 1</span> + <span id="span-2" tabindex=0>SPAN 2</span> + <main>`; + + const span1El = doc.getElementById("span-1"); + const span2El = doc.getElementById("span-2"); + return { span1El, span2El }; +} + +function assertFocusedElementInplaceEditorInput(doc) { + ok( + doc.activeElement.matches("input.styleinspector-propertyeditor"), + "inplace editor input is focused" + ); +} diff --git a/devtools/client/shared/test/browser_key_shortcuts.js b/devtools/client/shared/test/browser_key_shortcuts.js new file mode 100644 index 0000000000..d48666ddca --- /dev/null +++ b/devtools/client/shared/test/browser_key_shortcuts.js @@ -0,0 +1,468 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var isOSX = Services.appinfo.OS === "Darwin"; + +add_task(async function () { + const shortcuts = new KeyShortcuts({ + window, + }); + + await testSimple(shortcuts); + await testNonLetterCharacter(shortcuts); + await testPlusCharacter(shortcuts); + await testFunctionKey(shortcuts); + await testMixup(shortcuts); + await testLooseDigits(shortcuts); + await testExactModifiers(shortcuts); + await testLooseShiftModifier(shortcuts); + await testStrictLetterShiftModifier(shortcuts); + await testAltModifier(shortcuts); + await testCommandOrControlModifier(shortcuts); + await testCtrlModifier(shortcuts); + await testInvalidShortcutString(shortcuts); + await testNullShortcut(shortcuts); + await testCmdShiftShortcut(shortcuts); + await testTabCharacterShortcut(shortcuts); + shortcuts.destroy(); + + await testTarget(); +}); + +// Test helper to listen to the next key press for a given key, +// returning a promise to help using Tasks. +function once(shortcuts, key, listener) { + let called = false; + return new Promise(done => { + const onShortcut = event => { + shortcuts.off(key, onShortcut); + ok(!called, "once listener called only once (i.e. off() works)"); + called = true; + listener(event); + done(); + }; + shortcuts.on(key, onShortcut); + }); +} + +async function testSimple(shortcuts) { + info("Test simple key shortcuts"); + + const onKey = once(shortcuts, "0", event => { + is(event.key, "0"); + + // Display another key press to ensure that once() correctly stop listening + EventUtils.synthesizeKey("0", {}, window); + }); + + EventUtils.synthesizeKey("0", {}, window); + await onKey; +} + +async function testNonLetterCharacter(shortcuts) { + info("Test non-naive character key shortcuts"); + + const onKey = once(shortcuts, "[", event => { + is(event.key, "["); + }); + + EventUtils.synthesizeKey("[", {}, window); + await onKey; +} + +async function testFunctionKey(shortcuts) { + info("Test function key shortcuts"); + + const onKey = once(shortcuts, "F12", event => { + is(event.key, "F12"); + }); + + EventUtils.synthesizeKey("F12", { keyCode: 123 }, window); + await onKey; +} + +// Plus is special. It's keycode is the one for "=". That's because it requires +// shift to be pressed and is behind "=" key. So it should be considered as a +// character key +async function testPlusCharacter(shortcuts) { + info("Test 'Plus' key shortcuts"); + + const onKey = once(shortcuts, "Plus", event => { + is(event.key, "+"); + }); + + EventUtils.synthesizeKey("+", { keyCode: 61, shiftKey: true }, window); + await onKey; +} + +// Test they listeners are not mixed up between shortcuts +async function testMixup(shortcuts) { + info("Test possible listener mixup"); + + let hitFirst = false, + hitSecond = false; + const onFirstKey = once(shortcuts, "0", event => { + is(event.key, "0"); + hitFirst = true; + }); + const onSecondKey = once(shortcuts, "Alt+A", event => { + is(event.key, "a"); + ok(event.altKey); + hitSecond = true; + }); + + // Dispatch the first shortcut and expect only this one to be notified + ok(!hitFirst, "First shortcut isn't notified before firing the key event"); + EventUtils.synthesizeKey("0", {}, window); + await onFirstKey; + ok(hitFirst, "Got the first shortcut notified"); + ok(!hitSecond, "No mixup, second shortcut is still not notified (1/2)"); + + // Wait an extra time, just to be sure this isn't racy + await new Promise(done => { + window.setTimeout(done, 0); + }); + ok(!hitSecond, "No mixup, second shortcut is still not notified (2/2)"); + + // Finally dispatch the second shortcut + EventUtils.synthesizeKey("a", { altKey: true }, window); + await onSecondKey; + ok(hitSecond, "Got the second shortcut notified once it is actually fired"); +} + +// On azerty keyboard, digits are only available by pressing Shift/Capslock, +// but we accept them even if we omit doing that. +async function testLooseDigits(shortcuts) { + info("Test Loose digits"); + let onKey = once(shortcuts, "0", event => { + is(event.key, "à"); + ok(!event.altKey); + ok(!event.ctrlKey); + ok(!event.metaKey); + ok(!event.shiftKey); + }); + // Simulate a press on the "0" key, without shift pressed on a french + // keyboard + EventUtils.synthesizeKey("à", { keyCode: 48 }, window); + await onKey; + + onKey = once(shortcuts, "0", event => { + is(event.key, "0"); + ok(!event.altKey); + ok(!event.ctrlKey); + ok(!event.metaKey); + ok(event.shiftKey); + }); + // Simulate the same press with shift pressed + EventUtils.synthesizeKey("0", { keyCode: 48, shiftKey: true }, window); + await onKey; +} + +// Test that shortcuts is notified only when the modifiers match exactly +async function testExactModifiers(shortcuts) { + info("Test exact modifiers match"); + + let hit = false; + const onKey = once(shortcuts, "Alt+A", event => { + is(event.key, "a"); + ok(event.altKey); + ok(!event.ctrlKey); + ok(!event.metaKey); + ok(!event.shiftKey); + hit = true; + }); + + // Dispatch with unexpected set of modifiers + ok(!hit, "Shortcut isn't notified before firing the key event"); + EventUtils.synthesizeKey( + "a", + { accelKey: true, altKey: true, shiftKey: true }, + window + ); + EventUtils.synthesizeKey( + "a", + { accelKey: true, altKey: false, shiftKey: false }, + window + ); + EventUtils.synthesizeKey( + "a", + { accelKey: false, altKey: false, shiftKey: true }, + window + ); + EventUtils.synthesizeKey( + "a", + { accelKey: false, altKey: false, shiftKey: false }, + window + ); + + // Wait an extra time to let a chance to call the listener + await new Promise(done => { + window.setTimeout(done, 0); + }); + ok(!hit, "Listener isn't called when modifiers aren't exactly matching"); + + // Dispatch the expected modifiers + EventUtils.synthesizeKey( + "a", + { accelKey: false, altKey: true, shiftKey: false }, + window + ); + await onKey; + ok(hit, "Got shortcut notified once it is actually fired"); +} + +// Some keys are only accessible via shift and listener should also be called +// even if the key didn't explicitely requested Shift modifier. +// For example, `%` on french keyboards is only accessible via Shift. +// Same thing for `@` on US keybords. +async function testLooseShiftModifier(shortcuts) { + info("Test Loose shift modifier"); + let onKey = once(shortcuts, "%", event => { + is(event.key, "%"); + ok(!event.altKey); + ok(!event.ctrlKey); + ok(!event.metaKey); + ok(event.shiftKey); + }); + EventUtils.synthesizeKey( + "%", + { accelKey: false, altKey: false, ctrlKey: false, shiftKey: true }, + window + ); + await onKey; + + onKey = once(shortcuts, "@", event => { + is(event.key, "@"); + ok(!event.altKey); + ok(!event.ctrlKey); + ok(!event.metaKey); + ok(event.shiftKey); + }); + EventUtils.synthesizeKey( + "@", + { accelKey: false, altKey: false, ctrlKey: false, shiftKey: true }, + window + ); + await onKey; +} + +// But Shift modifier is strict on all letter characters (a to Z) +async function testStrictLetterShiftModifier(shortcuts) { + info("Test strict shift modifier on letters"); + let hitFirst = false; + const onKey = once(shortcuts, "a", event => { + is(event.key, "a"); + ok(!event.altKey); + ok(!event.ctrlKey); + ok(!event.metaKey); + ok(!event.shiftKey); + hitFirst = true; + }); + const onShiftKey = once(shortcuts, "Shift+a", event => { + is(event.key, "a"); + ok(!event.altKey); + ok(!event.ctrlKey); + ok(!event.metaKey); + ok(event.shiftKey); + }); + EventUtils.synthesizeKey("a", { shiftKey: true }, window); + await onShiftKey; + ok(!hitFirst, "Didn't fire the explicit shift+a"); + + EventUtils.synthesizeKey("a", { shiftKey: false }, window); + await onKey; +} + +async function testAltModifier(shortcuts) { + info("Test Alt modifier"); + const onKey = once(shortcuts, "Alt+F1", event => { + is(event.keyCode, window.KeyboardEvent.DOM_VK_F1); + ok(event.altKey); + ok(!event.ctrlKey); + ok(!event.metaKey); + ok(!event.shiftKey); + }); + EventUtils.synthesizeKey("VK_F1", { altKey: true }, window); + await onKey; +} + +async function testCommandOrControlModifier(shortcuts) { + info("Test CommandOrControl modifier"); + const onKey = once(shortcuts, "CommandOrControl+F1", event => { + is(event.keyCode, window.KeyboardEvent.DOM_VK_F1); + ok(!event.altKey); + if (isOSX) { + ok(!event.ctrlKey); + ok(event.metaKey); + } else { + ok(event.ctrlKey); + ok(!event.metaKey); + } + ok(!event.shiftKey); + }); + const onKeyAlias = once(shortcuts, "CmdOrCtrl+F1", event => { + is(event.keyCode, window.KeyboardEvent.DOM_VK_F1); + ok(!event.altKey); + if (isOSX) { + ok(!event.ctrlKey); + ok(event.metaKey); + } else { + ok(event.ctrlKey); + ok(!event.metaKey); + } + ok(!event.shiftKey); + }); + if (isOSX) { + EventUtils.synthesizeKey("VK_F1", { metaKey: true }, window); + } else { + EventUtils.synthesizeKey("VK_F1", { ctrlKey: true }, window); + } + await onKey; + await onKeyAlias; +} + +async function testCtrlModifier(shortcuts) { + info("Test Ctrl modifier"); + const onKey = once(shortcuts, "Ctrl+F1", event => { + is(event.keyCode, window.KeyboardEvent.DOM_VK_F1); + ok(!event.altKey); + ok(event.ctrlKey); + ok(!event.metaKey); + ok(!event.shiftKey); + }); + const onKeyAlias = once(shortcuts, "Control+F1", event => { + is(event.keyCode, window.KeyboardEvent.DOM_VK_F1); + ok(!event.altKey); + ok(event.ctrlKey); + ok(!event.metaKey); + ok(!event.shiftKey); + }); + EventUtils.synthesizeKey("VK_F1", { ctrlKey: true }, window); + await onKey; + await onKeyAlias; +} + +async function testCmdShiftShortcut(shortcuts) { + if (!isOSX) { + // This test is OSX only (Bug 1300458). + return; + } + + const onCmdKey = once(shortcuts, "CmdOrCtrl+[", event => { + is(event.key, "["); + ok(!event.altKey); + ok(!event.ctrlKey); + ok(event.metaKey); + ok(!event.shiftKey); + }); + const onCmdShiftKey = once(shortcuts, "CmdOrCtrl+Shift+[", event => { + is(event.key, "["); + ok(!event.altKey); + ok(!event.ctrlKey); + ok(event.metaKey); + ok(event.shiftKey); + }); + + EventUtils.synthesizeKey("[", { metaKey: true, shiftKey: true }, window); + EventUtils.synthesizeKey("[", { metaKey: true }, window); + + await onCmdKey; + await onCmdShiftKey; +} + +async function testTarget() { + info("Test KeyShortcuts with target argument"); + + const target = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "input" + ); + document.documentElement.appendChild(target); + target.focus(); + + const shortcuts = new KeyShortcuts({ + window, + target, + }); + const onKey = once(shortcuts, "0", event => { + is(event.key, "0"); + is(event.target, target); + }); + EventUtils.synthesizeKey("0", {}, window); + await onKey; + + target.remove(); + + shortcuts.destroy(); +} + +function testInvalidShortcutString(shortcuts) { + info("Test wrong shortcut string"); + + const shortcut = KeyShortcuts.parseElectronKey(window, "Cmmd+F"); + ok( + !shortcut, + "Passing a invalid shortcut string should return a null object" + ); + + shortcuts.on("Cmmd+F", function () {}); + ok(true, "on() shouldn't throw when passing invalid shortcut string"); +} + +// Can happen on localized builds where the value of the localized string is +// empty, eg `toolbox.elementPicker.key=`. See Bug 1569572. +function testNullShortcut(shortcuts) { + info("Test null shortcut"); + + const shortcut = KeyShortcuts.parseElectronKey(window, null); + ok(!shortcut, "Passing a null object should return a null object"); + + const stringified = KeyShortcuts.stringify(shortcut); + is(stringified, "", "A null object should be stringified as an empty string"); + + shortcuts.on(null, function () {}); + ok(true, "on() shouldn't throw when passing a null object"); +} + +/** + * Shift+Alt+I generates ^ key (`event.key`) on OSX and KeyShortcuts module + * must ensure that this doesn't interfere with shortcuts CmdOrCtrl+Alt+Shift+I + * for opening the Browser Toolbox and CmdOrCtrl+Alt+I for toggling the Toolbox. + */ +async function testTabCharacterShortcut(shortcuts) { + if (!isOSX) { + return; + } + + info("Test tab character shortcut"); + + once(shortcuts, "CmdOrCtrl+Alt+I", event => { + ok(false, "This handler must not be executed"); + }); + + const onKey = once(shortcuts, "CmdOrCtrl+Alt+Shift+I", event => { + info("Test for CmdOrCtrl+Alt+Shift+I"); + is(event.key, "^"); + is(event.keyCode, 73); + }); + + // Simulate `CmdOrCtrl+Alt+Shift+I` shortcut. Note that EventUtils doesn't + // generate `^` like real keyboard, so we need to pass it explicitly + // and use proper keyCode for `I` character. + EventUtils.synthesizeKey( + "^", + { + code: "KeyI", + key: "^", + keyCode: 73, + shiftKey: true, + altKey: true, + metaKey: true, + }, + window + ); + + await onKey; +} diff --git a/devtools/client/shared/test/browser_keycodes.js b/devtools/client/shared/test/browser_keycodes.js new file mode 100644 index 0000000000..bcfeafdf65 --- /dev/null +++ b/devtools/client/shared/test/browser_keycodes.js @@ -0,0 +1,12 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); + +add_task(async function () { + for (const key in KeyCodes) { + is(KeyCodes[key], KeyboardEvent[key], "checking value for " + key); + } +}); diff --git a/devtools/client/shared/test/browser_layoutHelpers.js b/devtools/client/shared/test/browser_layoutHelpers.js new file mode 100644 index 0000000000..5698c47bce --- /dev/null +++ b/devtools/client/shared/test/browser_layoutHelpers.js @@ -0,0 +1,131 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that scrollIntoViewIfNeeded works properly. +const { + scrollIntoViewIfNeeded, +} = require("resource://devtools/client/shared/scroll.js"); + +const TEST_URI = CHROME_URL_ROOT + "doc_layoutHelpers.html"; + +add_task(async function () { + const { host, win } = await createHost("bottom", TEST_URI); + await runTest(win); + host.destroy(); +}); + +async function runTest(win) { + const some = win.document.getElementById("some"); + + some.style.top = win.innerHeight + "px"; + some.style.left = win.innerWidth + "px"; + // The tests start with a black 2x2 pixels square below bottom right. + // Do not resize the window during the tests. + + const xPos = Math.floor(win.innerWidth / 2); + // Above the viewport. + win.scroll(xPos, win.innerHeight + 2); + scrollIntoViewIfNeeded(some); + is( + win.scrollY, + Math.floor(win.innerHeight / 2) + 1, + "Element completely hidden above should appear centered." + ); + is(win.scrollX, xPos, "scrollX position has not changed."); + + // On the top edge. + win.scroll(win.innerWidth / 2, win.innerHeight + 1); + scrollIntoViewIfNeeded(some); + is( + win.scrollY, + win.innerHeight, + "Element partially visible above should appear above." + ); + is(win.scrollX, xPos, "scrollX position has not changed."); + + // Just below the viewport. + win.scroll(win.innerWidth / 2, 0); + scrollIntoViewIfNeeded(some); + is( + win.scrollY, + Math.floor(win.innerHeight / 2) + 1, + "Element completely hidden below should appear centered." + ); + is(win.scrollX, xPos, "scrollX position has not changed."); + + // On the bottom edge. + win.scroll(win.innerWidth / 2, 1); + scrollIntoViewIfNeeded(some); + is(win.scrollY, 2, "Element partially visible below should appear below."); + is(win.scrollX, xPos, "scrollX position has not changed."); + + // Above the viewport. + win.scroll(win.innerWidth / 2, win.innerHeight + 2); + scrollIntoViewIfNeeded(some, false); + is( + win.scrollY, + win.innerHeight, + "Element completely hidden above should appear above " + + "if parameter is false." + ); + is(win.scrollX, xPos, "scrollX position has not changed."); + + // On the top edge. + win.scroll(win.innerWidth / 2, win.innerHeight + 1); + scrollIntoViewIfNeeded(some, false); + is( + win.scrollY, + win.innerHeight, + "Element partially visible above should appear above " + + "if parameter is false." + ); + is(win.scrollX, xPos, "scrollX position has not changed."); + + // Below the viewport. + win.scroll(win.innerWidth / 2, 0); + scrollIntoViewIfNeeded(some, false); + is( + win.scrollY, + 2, + "Element completely hidden below should appear below " + + "if parameter is false." + ); + is(win.scrollX, xPos, "scrollX position has not changed."); + + // On the bottom edge. + win.scroll(win.innerWidth / 2, 1); + scrollIntoViewIfNeeded(some, false); + is( + win.scrollY, + 2, + "Element partially visible below should appear below " + + "if parameter is false." + ); + is(win.scrollX, xPos, "scrollX position has not changed."); + + // Check smooth flag (scroll goes below the viewport) + await pushPref("ui.prefersReducedMotion", 0); + + info("Checking smooth flag"); + is( + win.matchMedia("(prefers-reduced-motion)").matches, + false, + "Reduced motion is disabled" + ); + + const other = win.document.getElementById("other"); + other.style.top = win.innerHeight + "px"; + other.style.left = win.innerWidth + "px"; + win.scroll(0, 0); + + scrollIntoViewIfNeeded(other, false, true); + Assert.less( + win.scrollY, + other.clientHeight, + "Window has not instantly scrolled to the final position" + ); + await waitUntil(() => win.scrollY === other.clientHeight); + ok(true, "Window did finish scrolling"); +} diff --git a/devtools/client/shared/test/browser_layoutHelpers_getBoxQuads1.js b/devtools/client/shared/test/browser_layoutHelpers_getBoxQuads1.js new file mode 100644 index 0000000000..a8119df3fd --- /dev/null +++ b/devtools/client/shared/test/browser_layoutHelpers_getBoxQuads1.js @@ -0,0 +1,353 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests getAdjustedQuads works properly in a variety of use cases including +// iframes, scroll and zoom + +"use strict"; + +const TEST_URI = TEST_URI_ROOT + "doc_layoutHelpers_getBoxQuads1.html"; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + info("Running tests"); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + // This function allows the Content Task to easily call `FullZoom` API in + // the parent process. + function sendCommand(cmd) { + return SpecialPowers.spawnChrome([cmd], async data => { + const window = this.browsingContext.topChromeWindow; + switch (data) { + case "zoom-enlarge": + window.FullZoom.enlarge(); + break; + case "zoom-reset": + await window.FullZoom.reset(); + break; + case "zoom-reduce": + window.FullZoom.reduce(); + break; + } + }); + } + + const doc = content.document; + + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + getAdjustedQuads, + } = require("resource://devtools/shared/layout/utils.js"); + + Assert.strictEqual( + typeof getAdjustedQuads, + "function", + "getAdjustedQuads is defined" + ); + + returnsTheRightDataStructure(); + isEmptyForMissingNode(); + isEmptyForHiddenNodes(); + defaultsToBorderBoxIfNoneProvided(); + returnsLikeGetBoxQuadsInSimpleCase(); + takesIframesOffsetsIntoAccount(); + takesScrollingIntoAccount(); + await takesZoomIntoAccount(); + returnsMultipleItemsForWrappingInlineElements(); + + function returnsTheRightDataStructure() { + info("Checks that the returned data contains bounds and 4 points"); + + const node = doc.querySelector("body"); + const [res] = getAdjustedQuads(doc.defaultView, node, "content"); + + ok("bounds" in res, "The returned data has a bounds property"); + ok("p1" in res, "The returned data has a p1 property"); + ok("p2" in res, "The returned data has a p2 property"); + ok("p3" in res, "The returned data has a p3 property"); + ok("p4" in res, "The returned data has a p4 property"); + + for (const boundProp of [ + "bottom", + "top", + "right", + "left", + "width", + "height", + "x", + "y", + ]) { + ok( + boundProp in res.bounds, + "The bounds has a " + boundProp + " property" + ); + } + + for (const point of ["p1", "p2", "p3", "p4"]) { + for (const pointProp of ["x", "y", "z", "w"]) { + ok( + pointProp in res[point], + point + " has a " + pointProp + " property" + ); + } + } + } + + function isEmptyForMissingNode() { + info("Checks that null is returned for invalid nodes"); + + for (const input of [null, undefined, "", 0]) { + is( + getAdjustedQuads(doc.defaultView, input).length, + 0, + "A 0-length array is returned for input " + input + ); + } + } + + function isEmptyForHiddenNodes() { + info("Checks that null is returned for nodes that aren't rendered"); + + const style = doc.querySelector("#styles"); + is( + getAdjustedQuads(doc.defaultView, style).length, + 0, + "null is returned for a <style> node" + ); + + const hidden = doc.querySelector("#hidden-node"); + is( + getAdjustedQuads(doc.defaultView, hidden).length, + 0, + "null is returned for a hidden node" + ); + } + + function defaultsToBorderBoxIfNoneProvided() { + info( + "Checks that if no boxtype is passed, then border is the default one" + ); + + const node = doc.querySelector("#simple-node-with-margin-padding-border"); + const [withBoxType] = getAdjustedQuads(doc.defaultView, node, "border"); + const [withoutBoxType] = getAdjustedQuads(doc.defaultView, node); + + for (const boundProp of [ + "bottom", + "top", + "right", + "left", + "width", + "height", + "x", + "y", + ]) { + is( + withBoxType.bounds[boundProp], + withoutBoxType.bounds[boundProp], + boundProp + " bound is equal with or without the border box type" + ); + } + + for (const point of ["p1", "p2", "p3", "p4"]) { + for (const pointProp of ["x", "y", "z", "w"]) { + is( + withBoxType[point][pointProp], + withoutBoxType[point][pointProp], + point + + "." + + pointProp + + " is equal with or without the border box type" + ); + } + } + } + + function returnsLikeGetBoxQuadsInSimpleCase() { + info( + "Checks that for an element in the main frame, without scroll nor zoom" + + "that the returned value is similar to the returned value of getBoxQuads" + ); + + const node = doc.querySelector("#simple-node-with-margin-padding-border"); + + for (const region of ["content", "padding", "border", "margin"]) { + const expected = node.getBoxQuads({ + box: region, + })[0]; + const [actual] = getAdjustedQuads(doc.defaultView, node, region); + + for (const boundProp of [ + "bottom", + "top", + "right", + "left", + "width", + "height", + "x", + "y", + ]) { + is( + actual.bounds[boundProp], + expected.getBounds()[boundProp], + boundProp + + " bound is equal to the one returned by getBoxQuads for " + + region + + " box" + ); + } + + for (const point of ["p1", "p2", "p3", "p4"]) { + for (const pointProp of ["x", "y", "z", "w"]) { + is( + actual[point][pointProp], + expected[point][pointProp], + point + + "." + + pointProp + + " is equal to the one returned by getBoxQuads for " + + region + + " box" + ); + } + } + } + } + + function takesIframesOffsetsIntoAccount() { + info( + "Checks that the quad returned for a node inside iframes that have " + + "margins takes those offsets into account" + ); + + const rootIframe = doc.querySelector("iframe"); + const subIframe = rootIframe.contentDocument.querySelector("iframe"); + const innerNode = subIframe.contentDocument.querySelector("#inner-node"); + + const [quad] = getAdjustedQuads(doc.defaultView, innerNode, "content"); + + // rootIframe margin + subIframe margin + node margin + node border + node padding + const p1x = 10 + 10 + 10 + 10 + 10; + is(quad.p1.x, p1x, "The inner node's p1 x position is correct"); + + // Same as p1x + the inner node width + const p2x = p1x + 100; + is(quad.p2.x, p2x, "The inner node's p2 x position is correct"); + } + + function takesScrollingIntoAccount() { + info( + "Checks that the quad returned for a node inside multiple scrolled " + + "containers takes the scroll values into account" + ); + + // For info, the container being tested here is absolutely positioned at 0 0 + // to simplify asserting the coordinates + + info("Scroll the container nodes down"); + const scrolledNode = doc.querySelector("#scrolled-node"); + scrolledNode.scrollTop = 100; + const subScrolledNode = doc.querySelector("#sub-scrolled-node"); + subScrolledNode.scrollTop = 200; + const innerNode = doc.querySelector("#inner-scrolled-node"); + + let [quad] = getAdjustedQuads(doc.defaultView, innerNode, "content"); + is( + quad.p1.x, + 0, + "p1.x of the scrolled node is correct after scrolling down" + ); + is( + quad.p1.y, + -300, + "p1.y of the scrolled node is correct after scrolling down" + ); + + info("Scrolling back up"); + scrolledNode.scrollTop = 0; + subScrolledNode.scrollTop = 0; + + [quad] = getAdjustedQuads(doc.defaultView, innerNode, "content"); + is( + quad.p1.x, + 0, + "p1.x of the scrolled node is correct after scrolling up" + ); + is( + quad.p1.y, + 0, + "p1.y of the scrolled node is correct after scrolling up" + ); + } + + async function takesZoomIntoAccount() { + info( + "Checks that if the page is zoomed in/out, the quad returned is correct" + ); + + // Hard-coding coordinates in this zoom test is a bad idea as it can vary + // depending on the platform, so we simply test that zooming in produces a + // bigger quad and zooming out produces a smaller quad + + const node = doc.querySelector("#simple-node-with-margin-padding-border"); + const [defaultQuad] = getAdjustedQuads(doc.defaultView, node); + + info("Zoom in"); + await sendCommand("zoom-enlarge"); + const [zoomedInQuad] = getAdjustedQuads(doc.defaultView, node); + + Assert.greater( + zoomedInQuad.bounds.width, + defaultQuad.bounds.width, + "The zoomed in quad is bigger than the default one" + ); + Assert.greater( + zoomedInQuad.bounds.height, + defaultQuad.bounds.height, + "The zoomed in quad is bigger than the default one" + ); + + info("Zoom out"); + await sendCommand("zoom-reset"); + await sendCommand("zoom-reduce"); + + const [zoomedOutQuad] = getAdjustedQuads(doc.defaultView, node); + + Assert.less( + zoomedOutQuad.bounds.width, + defaultQuad.bounds.width, + "The zoomed out quad is smaller than the default one" + ); + Assert.less( + zoomedOutQuad.bounds.height, + defaultQuad.bounds.height, + "The zoomed out quad is smaller than the default one" + ); + + await sendCommand("zoom-reset"); + } + + function returnsMultipleItemsForWrappingInlineElements() { + info( + "Checks that several quads are returned " + + "for inline elements that span line-breaks" + ); + + const node = doc.querySelector("#inline"); + const quads = getAdjustedQuads(doc.defaultView, node, "content"); + // At least 3 because of the 2 <br />, maybe more depending on the window size. + Assert.greaterOrEqual(quads.length, 3, "Multiple quads were returned"); + + is( + quads.length, + node.getBoxQuads().length, + "The same number of boxes as getBoxQuads was returned" + ); + } + }); + + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/client/shared/test/browser_layoutHelpers_getBoxQuads2.js b/devtools/client/shared/test/browser_layoutHelpers_getBoxQuads2.js new file mode 100644 index 0000000000..051b2c5a7a --- /dev/null +++ b/devtools/client/shared/test/browser_layoutHelpers_getBoxQuads2.js @@ -0,0 +1,185 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that getBoxQuadsFromWindowOrigin works across process +// boundaries. +// +// The test forces a fission window, because there is some +// imprecision in the APZ transforms for non-fission windows, +// and the getBoxQuadsFromWindowOrigin is designed specifically +// to be used by a fission browser. +// +// This test embeds a number of documents within iframes, +// with a variety of borders and padding. Each iframe hosts +// a document in different domain than its container. +// +// The innermost documents have a 50px x 50px div with a +// 50px margin. So relative to its own iframe, the offset +// for the div should be 50, 50. +// +// Here's the embedding diagram: +// +-- A -----------------------------------------------------+ +// | | +// | +- div -----+ | +// | | 100 x 100 | | +// | +-----------+ | +// | | +// | +- div 20px margin ------------------------------------+ | +// | | | | +// | | +- B: iframe 10px border -+ +- D: iframe 40px pad -+ | | +// | | | 250 x 250 | | 250 x 250 | | | +// | | | 50px margin | | 50px margin | | | +// | | | | | | | | +// | | | +- C: iframe ---+ | | +- E: iframe ---+ | | | +// | | | | 150 x 150 | | | | 150 x 150 | | | | +// | | | +---------------+ | | +---------------+ | | | +// | | +-------------------------+ +----------------------+ | | +// | +------------------------------------------------------+ | +// +----------------------------------------------------------+ +// +// The following checks are made: +// C-div relative to A should have offset 130, 230. +// E-div relative to A should have offset 430, 260. +// +// This tests the most likely cases for the code that handles +// relativeToTopLevelBrowsingContextId. It does not check these +// cases: +// 1) A css-transform'ed iframe. +// 2) An abspos iframe. +// 3) An iframe embedded in an SVG. + +"use strict"; +/* import-globals-from ../../../../gfx/layers/apz/test/mochitest/apz_test_utils.js */ + +const TEST_URI = TEST_URI_ROOT_SSL + "doc_layoutHelpers_getBoxQuads2-a.html"; + +add_task(async function () { + info("Opening a fission window."); + const fissionWin = await BrowserTestUtils.openNewBrowserWindow({ + remote: true, + fission: true, + }); + + info("Load APZ test utils."); + loadHelperScript( + "../../../../gfx/layers/apz/test/mochitest/apz_test_utils.js" + ); + info("Load paint_listener."); + loadHelperScript("../../../../../tests/SimpleTest/paint_listener.js"); + + info("Open a new tab."); + const tab = await BrowserTestUtils.openNewForegroundTab( + fissionWin.gBrowser, + TEST_URI + ); + + info("Running tests"); + + ok(waitUntilApzStable, "waitUntilApzStable is defined."); + await waitUntilApzStable(); + + await ContentTask.spawn(tab.linkedBrowser, null, async function () { + const win = content.window; + const doc = content.document; + const refNode = doc.documentElement; + const iframeB = doc.getElementById("b"); + const iframeD = doc.getElementById("d"); + + // Get the offset of the reference node to the window origin. We'll use + // this offset later to adjust the quads we get from the iframes. + const refQuad = refNode.getBoxQuadsFromWindowOrigin()[0]; + const offsetX = refQuad.p1.x; + const offsetY = refQuad.p1.y; + info(`Reference node is offset (${offsetX}, ${offsetY}) from window.`); + + function postAndReceiveMessage(iframe) { + return new Promise(resolve => { + const onmessage = event => { + if (event.data.quad) { + win.removeEventListener("message", onmessage); + resolve(event.data.quad); + } + }; + win.addEventListener("message", onmessage, { capture: false }); + iframe.contentWindow.postMessage({ callGetBoxQuads: true }, "*"); + }); + } + + // Bug 1624659: Our checks are not always precise, though for these test + // cases they should align precisely to css pixels. For now we use an + // epsilon and a locally-defined isfuzzy to compensate. We can't use + // SimpleTest.isfuzzy, because it's not bridged to the ContentTask. + // If that is ever bridged, we can remove the isfuzzy definition here and + // everything should "just work". + function isfuzzy(actual, expected, epsilon, msg) { + if (actual >= expected - epsilon && actual <= expected + epsilon) { + ok(true, msg); + } else { + // This will trigger the usual failure message for is. + is(actual, expected, msg); + } + } + + const ADDITIVE_EPSILON = 1; + const checksToMake = [ + { + msg: "C-div", + iframe: iframeB, + left: 130, + top: 230, + right: 180, + bottom: 280, + }, + { + msg: "E-div", + iframe: iframeD, + left: 430, + top: 260, + right: 480, + bottom: 310, + }, + ]; + + for (const { msg, iframe, left, top, right, bottom } of checksToMake) { + info("Checking " + msg + "."); + const quad = await postAndReceiveMessage(iframe); + const bounds = quad.getBounds(); + info( + `Quad bounds is (${bounds.left}, ${bounds.top}) to (${bounds.right}, ${bounds.bottom}).` + ); + isfuzzy( + bounds.left - offsetX, + left, + ADDITIVE_EPSILON, + msg + " quad left position is as expected." + ); + isfuzzy( + bounds.top - offsetY, + top, + ADDITIVE_EPSILON, + msg + " quad top position is as expected." + ); + isfuzzy( + bounds.right - offsetX, + right, + ADDITIVE_EPSILON, + msg + " quad right position is as expected." + ); + isfuzzy( + bounds.bottom - offsetY, + bottom, + ADDITIVE_EPSILON, + msg + " quad bottom position is as expected." + ); + } + }); + + fissionWin.gBrowser.removeCurrentTab(); + + await BrowserTestUtils.closeWindow(fissionWin); + + // Clean up the properties added to window by paint_listener.js. + delete window.waitForAllPaintsFlushed; + delete window.waitForAllPaints; + delete window.promiseAllPaintsDone; +}); diff --git a/devtools/client/shared/test/browser_link.js b/devtools/client/shared/test/browser_link.js new file mode 100644 index 0000000000..ef566288d9 --- /dev/null +++ b/devtools/client/shared/test/browser_link.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test link helpers openDocLink, openTrustedLink. + +// Use any valid test page here. +const TEST_URI = TEST_URI_ROOT_SSL + "dummy.html"; + +const { + openDocLink, + openTrustedLink, +} = require("resource://devtools/client/shared/link.js"); + +add_task(async function () { + // Open a link to a page that will not trigger any request. + info("Open web link to example.com test page"); + openDocLink(TEST_URI); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + is( + gBrowser.selectedBrowser.currentURI.spec, + TEST_URI, + "openDocLink opened a tab with the expected url" + ); + + info("Open trusted link to about:config"); + openTrustedLink("about:config"); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + is( + gBrowser.selectedBrowser.currentURI.spec, + "about:config", + "openTrustedLink opened a tab with the expected url" + ); + + await removeTab(gBrowser.selectedTab); + await removeTab(gBrowser.selectedTab); +}); diff --git a/devtools/client/shared/test/browser_num-l10n.js b/devtools/client/shared/test/browser_num-l10n.js new file mode 100644 index 0000000000..0bdda44f5d --- /dev/null +++ b/devtools/client/shared/test/browser_num-l10n.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the localization utils work properly. + +function test() { + const l10n = new LocalizationHelper(); + + is( + l10n.numberWithDecimals(1234.56789, 2), + "1,234.57", + "The first number was properly localized." + ); + is( + l10n.numberWithDecimals(0.0001, 2), + "0", + "The second number was properly localized." + ); + is( + l10n.numberWithDecimals(1.0001, 2), + "1", + "The third number was properly localized." + ); + is(l10n.numberWithDecimals(NaN, 2), "0", "NaN was properly localized."); + is(l10n.numberWithDecimals(null, 2), "0", "`null` was properly localized."); + is( + l10n.numberWithDecimals(undefined, 2), + "0", + "`undefined` was properly localized." + ); + is( + l10n.numberWithDecimals(-1234.56789, 2), + "-1,234.57", + "Negative number was properly localized." + ); + is( + l10n.numberWithDecimals(1234.56789, 0), + "1,235", + "Number was properly localized with decimals set 0." + ); + is( + l10n.numberWithDecimals(-1234.56789, 0), + "-1,235", + "Negative number was properly localized with decimals set 0." + ); + is( + l10n.numberWithDecimals(12, 2), + "12", + "The integer was properly localized, without decimals." + ); + is( + l10n.numberWithDecimals(-12, 2), + "-12", + "The negative integer was properly localized, without decimals." + ); + is( + l10n.numberWithDecimals(1200, 2), + "1,200", + "The big integer was properly localized, no decimals but with a separator." + ); + is( + l10n.numberWithDecimals(-1200, 2), + "-1,200", + "The negative big integer was properly localized, no decimals but with a separator." + ); + + finish(); +} diff --git a/devtools/client/shared/test/browser_outputparser.js b/devtools/client/shared/test/browser_outputparser.js new file mode 100644 index 0000000000..226e0bb685 --- /dev/null +++ b/devtools/client/shared/test/browser_outputparser.js @@ -0,0 +1,856 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + await pushPref("layout.css.backdrop-filter.enabled", true); + await pushPref("layout.css.individual-transform.enabled", true); + await pushPref("layout.css.motion-path-basic-shapes.enabled", true); + await addTab("about:blank"); + await performTest(); + gBrowser.removeCurrentTab(); +}); + +async function performTest() { + await SpecialPowers.pushPrefEnv({ + set: [["security.allow_unsafe_parent_loads", true]], + }); + + const OutputParser = require("resource://devtools/client/shared/output-parser.js"); + + const { host, doc } = await createHost( + "bottom", + "data:text/html," + "<h1>browser_outputParser.js</h1><div></div>" + ); + + const cssProperties = getClientCssProperties(); + + const parser = new OutputParser(doc, cssProperties); + testParseCssProperty(doc, parser); + testParseCssVar(doc, parser); + testParseURL(doc, parser); + testParseFilter(doc, parser); + testParseBackdropFilter(doc, parser); + testParseAngle(doc, parser); + testParseShape(doc, parser); + testParseVariable(doc, parser); + testParseColorVariable(doc, parser); + testParseFontFamily(doc, parser); + + host.destroy(); +} + +// Class name used in color swatch. +var COLOR_TEST_CLASS = "test-class"; + +// Create a new CSS color-parsing test. |name| is the name of the CSS +// property. |value| is the CSS text to use. |segments| is an array +// describing the expected result. If an element of |segments| is a +// string, it is simply appended to the expected string. Otherwise, +// it must be an object with a |name| property, which is the color +// name as it appears in the input. +// +// This approach is taken to reduce boilerplate and to make it simpler +// to modify the test when the parseCssProperty output changes. +function makeColorTest(name, value, segments) { + const result = { + name, + value, + expected: "", + }; + + for (const segment of segments) { + if (typeof segment === "string") { + result.expected += segment; + } else { + const buttonAttributes = { + class: COLOR_TEST_CLASS, + style: `background-color:${segment.name}`, + tabindex: 0, + role: "button", + }; + if (segment.colorFunction) { + buttonAttributes["data-color-function"] = segment.colorFunction; + } + const buttonAttrString = Object.entries(buttonAttributes) + .map(([attr, v]) => `${attr}="${v}"`) + .join(" "); + + // prettier-ignore + result.expected += + `<span data-color="${segment.name}">` + + `<span ${buttonAttrString}></span>`+ + `<span>${segment.name}</span>` + + `</span>`; + } + } + + result.desc = "Testing " + name + ": " + value; + + return result; +} + +function testParseCssProperty(doc, parser) { + const tests = [ + makeColorTest("border", "1px solid red", ["1px solid ", { name: "red" }]), + + makeColorTest( + "background-image", + "linear-gradient(to right, #F60 10%, rgba(0,0,0,1))", + [ + "linear-gradient(to right, ", + { name: "#F60", colorFunction: "linear-gradient" }, + " 10%, ", + { name: "rgba(0,0,0,1)", colorFunction: "linear-gradient" }, + ")", + ] + ), + + // In "arial black", "black" is a font, not a color. + // (The font-family parser creates a span) + makeColorTest("font-family", "arial black", ["<span>arial black</span>"]), + + makeColorTest("box-shadow", "0 0 1em red", ["0 0 1em ", { name: "red" }]), + + makeColorTest("box-shadow", "0 0 1em red, 2px 2px 0 0 rgba(0,0,0,.5)", [ + "0 0 1em ", + { name: "red" }, + ", 2px 2px 0 0 ", + { name: "rgba(0,0,0,.5)" }, + ]), + + makeColorTest("content", '"red"', ['"red"']), + + // Invalid property names should not cause exceptions. + makeColorTest("hellothere", "'red'", ["'red'"]), + + makeColorTest( + "filter", + "blur(1px) drop-shadow(0 0 0 blue) url(red.svg#blue)", + [ + '<span data-filters="blur(1px) drop-shadow(0 0 0 blue) ', + 'url(red.svg#blue)"><span>', + "blur(1px) drop-shadow(0 0 0 ", + { name: "blue", colorFunction: "drop-shadow" }, + ") url(red.svg#blue)</span></span>", + ] + ), + + makeColorTest("color", "currentColor", ["currentColor"]), + + // Test a very long property. + makeColorTest( + "background-image", + "linear-gradient(to left, transparent 0, transparent 5%,#F00 0, #F00 10%,#FF0 0, #FF0 15%,#0F0 0, #0F0 20%,#0FF 0, #0FF 25%,#00F 0, #00F 30%,#800 0, #800 35%,#880 0, #880 40%,#080 0, #080 45%,#088 0, #088 50%,#008 0, #008 55%,#FFF 0, #FFF 60%,#EEE 0, #EEE 65%,#CCC 0, #CCC 70%,#999 0, #999 75%,#666 0, #666 80%,#333 0, #333 85%,#111 0, #111 90%,#000 0, #000 95%,transparent 0, transparent 100%)", + [ + "linear-gradient(to left, ", + { name: "transparent", colorFunction: "linear-gradient" }, + " 0, ", + { name: "transparent", colorFunction: "linear-gradient" }, + " 5%,", + { name: "#F00", colorFunction: "linear-gradient" }, + " 0, ", + { name: "#F00", colorFunction: "linear-gradient" }, + " 10%,", + { name: "#FF0", colorFunction: "linear-gradient" }, + " 0, ", + { name: "#FF0", colorFunction: "linear-gradient" }, + " 15%,", + { name: "#0F0", colorFunction: "linear-gradient" }, + " 0, ", + { name: "#0F0", colorFunction: "linear-gradient" }, + " 20%,", + { name: "#0FF", colorFunction: "linear-gradient" }, + " 0, ", + { name: "#0FF", colorFunction: "linear-gradient" }, + " 25%,", + { name: "#00F", colorFunction: "linear-gradient" }, + " 0, ", + { name: "#00F", colorFunction: "linear-gradient" }, + " 30%,", + { name: "#800", colorFunction: "linear-gradient" }, + " 0, ", + { name: "#800", colorFunction: "linear-gradient" }, + " 35%,", + { name: "#880", colorFunction: "linear-gradient" }, + " 0, ", + { name: "#880", colorFunction: "linear-gradient" }, + " 40%,", + { name: "#080", colorFunction: "linear-gradient" }, + " 0, ", + { name: "#080", colorFunction: "linear-gradient" }, + " 45%,", + { name: "#088", colorFunction: "linear-gradient" }, + " 0, ", + { name: "#088", colorFunction: "linear-gradient" }, + " 50%,", + { name: "#008", colorFunction: "linear-gradient" }, + " 0, ", + { name: "#008", colorFunction: "linear-gradient" }, + " 55%,", + { name: "#FFF", colorFunction: "linear-gradient" }, + " 0, ", + { name: "#FFF", colorFunction: "linear-gradient" }, + " 60%,", + { name: "#EEE", colorFunction: "linear-gradient" }, + " 0, ", + { name: "#EEE", colorFunction: "linear-gradient" }, + " 65%,", + { name: "#CCC", colorFunction: "linear-gradient" }, + " 0, ", + { name: "#CCC", colorFunction: "linear-gradient" }, + " 70%,", + { name: "#999", colorFunction: "linear-gradient" }, + " 0, ", + { name: "#999", colorFunction: "linear-gradient" }, + " 75%,", + { name: "#666", colorFunction: "linear-gradient" }, + " 0, ", + { name: "#666", colorFunction: "linear-gradient" }, + " 80%,", + { name: "#333", colorFunction: "linear-gradient" }, + " 0, ", + { name: "#333", colorFunction: "linear-gradient" }, + " 85%,", + { name: "#111", colorFunction: "linear-gradient" }, + " 0, ", + { name: "#111", colorFunction: "linear-gradient" }, + " 90%,", + { name: "#000", colorFunction: "linear-gradient" }, + " 0, ", + { name: "#000", colorFunction: "linear-gradient" }, + " 95%,", + { name: "transparent", colorFunction: "linear-gradient" }, + " 0, ", + { name: "transparent", colorFunction: "linear-gradient" }, + " 100%)", + ] + ), + + // Note the lack of a space before the color here. + makeColorTest("border", "1px dotted#f06", [ + "1px dotted ", + { name: "#f06" }, + ]), + + makeColorTest("color", "color-mix(in srgb, red, blue)", [ + "color-mix(in srgb, ", + { name: "red", colorFunction: "color-mix" }, + ", ", + { name: "blue", colorFunction: "color-mix" }, + ")", + ]), + + makeColorTest( + "background-image", + "linear-gradient(to top, color-mix(in srgb, #008000, rgba(255, 255, 0, 0.9)), blue)", + [ + "linear-gradient(to top, ", + "color-mix(in srgb, ", + { name: "#008000", colorFunction: "color-mix" }, + ", ", + { name: "rgba(255, 255, 0, 0.9)", colorFunction: "color-mix" }, + "), ", + { name: "blue", colorFunction: "linear-gradient" }, + ")", + ] + ), + ]; + + const target = doc.querySelector("div"); + ok(target, "captain, we have the div"); + + for (const test of tests) { + info(test.desc); + + const frag = parser.parseCssProperty(test.name, test.value, { + colorSwatchClass: COLOR_TEST_CLASS, + }); + + target.appendChild(frag); + + is( + target.innerHTML, + test.expected, + "CSS property correctly parsed for " + test.name + ": " + test.value + ); + + target.innerHTML = ""; + } +} + +function testParseCssVar(doc, parser) { + const frag = parser.parseCssProperty("color", "var(--some-kind-of-green)", { + colorSwatchClass: "test-colorswatch", + }); + + const target = doc.querySelector("div"); + ok(target, "captain, we have the div"); + target.appendChild(frag); + + is( + target.innerHTML, + "var(--some-kind-of-green)", + "CSS property correctly parsed" + ); + + target.innerHTML = ""; +} + +function testParseURL(doc, parser) { + info("Test that URL parsing preserves quoting style"); + + const tests = [ + { + desc: "simple test without quotes", + leader: "url(", + trailer: ")", + }, + { + desc: "simple test with single quotes", + leader: "url('", + trailer: "')", + }, + { + desc: "simple test with double quotes", + leader: 'url("', + trailer: '")', + }, + { + desc: "test with single quotes and whitespace", + leader: "url( \t'", + trailer: "'\r\n\f)", + }, + { + desc: "simple test with uppercase", + leader: "URL(", + trailer: ")", + }, + { + desc: "bad url, missing paren", + leader: "url(", + trailer: "", + expectedTrailer: ")", + }, + { + desc: "bad url, missing paren, with baseURI", + baseURI: "data:text/html,<style></style>", + leader: "url(", + trailer: "", + expectedTrailer: ")", + }, + { + desc: "bad url, double quote, missing paren", + leader: 'url("', + trailer: '"', + expectedTrailer: '")', + }, + { + desc: "bad url, single quote, missing paren and quote", + leader: "url('", + trailer: "", + expectedTrailer: "')", + }, + ]; + + for (const test of tests) { + const url = test.leader + "something.jpg" + test.trailer; + const frag = parser.parseCssProperty("background", url, { + urlClass: "test-urlclass", + baseURI: test.baseURI, + }); + + const target = doc.querySelector("div"); + target.appendChild(frag); + + const expectedTrailer = test.expectedTrailer || test.trailer; + + const expected = + test.leader + + '<a target="_blank" class="test-urlclass" ' + + 'href="something.jpg">something.jpg</a>' + + expectedTrailer; + + is(target.innerHTML, expected, test.desc); + + target.innerHTML = ""; + } +} + +function testParseFilter(doc, parser) { + const frag = parser.parseCssProperty("filter", "something invalid", { + filterSwatchClass: "test-filterswatch", + }); + + const swatchCount = frag.querySelectorAll(".test-filterswatch").length; + is(swatchCount, 1, "filter swatch was created"); +} + +function testParseBackdropFilter(doc, parser) { + const frag = parser.parseCssProperty("backdrop-filter", "something invalid", { + filterSwatchClass: "test-filterswatch", + }); + + const swatchCount = frag.querySelectorAll(".test-filterswatch").length; + is(swatchCount, 1, "filter swatch was created for backdrop-filter"); +} + +function testParseAngle(doc, parser) { + let frag = parser.parseCssProperty("rotate", "90deg", { + angleSwatchClass: "test-angleswatch", + }); + + let swatchCount = frag.querySelectorAll(".test-angleswatch").length; + is(swatchCount, 1, "angle swatch was created"); + + frag = parser.parseCssProperty( + "background-image", + "linear-gradient(90deg, red, blue", + { + angleSwatchClass: "test-angleswatch", + } + ); + + swatchCount = frag.querySelectorAll(".test-angleswatch").length; + is(swatchCount, 1, "angle swatch was created"); +} + +function testParseShape(doc, parser) { + info("Test shape parsing"); + + const tests = [ + { + desc: "Polygon shape", + definition: + "polygon(evenodd, 0px 0px, 10%200px,30%30% , calc(250px - 10px) 0 ,\n " + + "12em var(--variable), 100% 100%) margin-box", + spanCount: 18, + }, + { + desc: "POLYGON()", + definition: + "POLYGON(evenodd, 0px 0px, 10%200px,30%30% , calc(250px - 10px) 0 ,\n " + + "12em var(--variable), 100% 100%) margin-box", + spanCount: 18, + }, + { + desc: "Invalid polygon shape", + definition: "polygon(0px 0px 100px 20px, 20% 20%)", + spanCount: 0, + }, + { + desc: "Circle shape with all arguments", + definition: "circle(25% at\n 30% 200px) border-box", + spanCount: 4, + }, + { + desc: "Circle shape with only one center", + definition: "circle(25em at 40%)", + spanCount: 3, + }, + { + desc: "Circle shape with no radius", + definition: "circle(at 30% 40%)", + spanCount: 3, + }, + { + desc: "Circle shape with no center", + definition: "circle(12em)", + spanCount: 1, + }, + { + desc: "Circle shape with no arguments", + definition: "circle()", + spanCount: 0, + }, + { + desc: "Circle shape with no space before at", + definition: "circle(25%at 30% 30%)", + spanCount: 4, + }, + { + desc: "CIRCLE", + definition: "CIRCLE(12em)", + spanCount: 1, + }, + { + desc: "Invalid circle shape", + definition: "circle(25%at30%30%)", + spanCount: 0, + }, + { + desc: "Ellipse shape with all arguments", + definition: "ellipse(200px 10em at 25% 120px) content-box", + spanCount: 5, + }, + { + desc: "Ellipse shape with only one center", + definition: "ellipse(200px 10% at 120px)", + spanCount: 4, + }, + { + desc: "Ellipse shape with no radius", + definition: "ellipse(at 25% 120px)", + spanCount: 3, + }, + { + desc: "Ellipse shape with no center", + definition: "ellipse(200px\n10em)", + spanCount: 2, + }, + { + desc: "Ellipse shape with no arguments", + definition: "ellipse()", + spanCount: 0, + }, + { + desc: "ELLIPSE()", + definition: "ELLIPSE(200px 10em)", + spanCount: 2, + }, + { + desc: "Invalid ellipse shape", + definition: "ellipse(200px100px at 30$ 20%)", + spanCount: 0, + }, + { + desc: "Inset shape with 4 arguments", + definition: "inset(200px 100px\n 30%15%)", + spanCount: 4, + }, + { + desc: "Inset shape with 3 arguments", + definition: "inset(200px 100px 15%)", + spanCount: 3, + }, + { + desc: "Inset shape with 2 arguments", + definition: "inset(200px 100px)", + spanCount: 2, + }, + { + desc: "Inset shape with 1 argument", + definition: "inset(200px)", + spanCount: 1, + }, + { + desc: "Inset shape with 0 arguments", + definition: "inset()", + spanCount: 0, + }, + { + desc: "INSET()", + definition: "INSET(200px)", + spanCount: 1, + }, + { + desc: "offset-path property with inset shape value", + property: "offset-path", + definition: "inset(200px)", + spanCount: 1, + }, + ]; + + for (const { desc, definition, property = "clip-path", spanCount } of tests) { + info(desc); + const frag = parser.parseCssProperty(property, definition, { + shapeClass: "ruleview-shape", + }); + const spans = frag.querySelectorAll(".ruleview-shape-point"); + is(spans.length, spanCount, desc + " span count"); + is(frag.textContent, definition, desc + " text content"); + } +} + +function testParseVariable(doc, parser) { + const TESTS = [ + { + text: "var(--seen)", + variables: { "--seen": "chartreuse" }, + expected: + // prettier-ignore + '<span data-color="chartreuse">' + + "<span>var(" + + '<span data-variable="--seen = chartreuse">--seen</span>)' + + "</span>" + + "</span>", + }, + { + text: "var(--not-seen)", + variables: {}, + expected: + // prettier-ignore + "<span>var(" + + '<span class="unmatched-class" data-variable="--not-seen is not set">--not-seen</span>' + + ")</span>", + }, + { + text: "var(--seen, seagreen)", + variables: { "--seen": "chartreuse" }, + expected: + // prettier-ignore + '<span data-color="chartreuse">' + + "<span>var(" + + '<span data-variable="--seen = chartreuse">--seen</span>,' + + '<span class="unmatched-class"> ' + + '<span data-color="seagreen">' + + "<span>seagreen</span>" + + "</span>" + + "</span>)" + + "</span>" + + "</span>", + }, + { + text: "var(--not-seen, var(--seen))", + variables: { "--seen": "chartreuse" }, + expected: + // prettier-ignore + "<span>var(" + + '<span class="unmatched-class" data-variable="--not-seen is not set">--not-seen</span>,' + + "<span> " + + '<span data-color="chartreuse">' + + "<span>var(" + + '<span data-variable="--seen = chartreuse">--seen</span>)' + + "</span>" + + "</span>" + + "</span>)" + + "</span>", + }, + { + text: "color-mix(in sgrb, var(--x), purple)", + variables: { "--x": "yellow" }, + expected: + // prettier-ignore + `color-mix(in sgrb, ` + + `<span data-color="yellow">` + + `<span class="test-class" style="background-color:yellow" tabindex="0" role="button" data-color-function="color-mix">` + + `</span>` + + `<span>var(<span data-variable="--x = yellow">--x</span>)</span>` + + `</span>` + + `, ` + + `<span data-color="purple">` + + `<span class="test-class" style="background-color:purple" tabindex="0" role="button" data-color-function="color-mix">` + + `</span>` + + `<span>purple</span>` + + `</span>` + + `)`, + parserExtraOptions: { + colorSwatchClass: COLOR_TEST_CLASS, + }, + }, + { + text: "1px solid var(--seen, seagreen)", + variables: { "--seen": "chartreuse" }, + expected: + // prettier-ignore + '1px solid ' + + '<span data-color="chartreuse">' + + "<span>var(" + + '<span data-variable="--seen = chartreuse">--seen</span>,' + + '<span class="unmatched-class"> ' + + '<span data-color="seagreen">' + + "<span>seagreen</span>" + + "</span>" + + "</span>)" + + "</span>" + + "</span>", + }, + { + text: "1px solid var(--not-seen, seagreen)", + variables: {}, + expected: + // prettier-ignore + `1px solid ` + + `<span>var(` + + `<span class="unmatched-class" data-variable="--not-seen is not set">--not-seen</span>,` + + `<span> ` + + `<span data-color="seagreen">` + + `<span>seagreen</span>` + + `</span>` + + `</span>)` + + `</span>`, + }, + ]; + + for (const test of TESTS) { + const getValue = function (varName) { + return test.variables[varName]; + }; + + const frag = parser.parseCssProperty("color", test.text, { + getVariableValue: getValue, + unmatchedVariableClass: "unmatched-class", + ...(test.parserExtraOptions || {}), + }); + + const target = doc.querySelector("div"); + target.appendChild(frag); + + is(target.innerHTML, test.expected, test.text); + target.innerHTML = ""; + } +} + +function testParseColorVariable(doc, parser) { + const testCategories = [ + { + desc: "Test for CSS variable defining color", + tests: [ + makeColorTest("--test-var", "lime", [{ name: "lime" }]), + makeColorTest("--test-var", "#000", [{ name: "#000" }]), + ], + }, + { + desc: "Test for CSS variable not defining color", + tests: [ + makeColorTest("--foo", "something", ["something"]), + makeColorTest("--bar", "Arial Black", ["Arial Black"]), + makeColorTest("--baz", "10vmin", ["10vmin"]), + ], + }, + { + desc: "Test for non CSS variable defining color", + tests: [ + makeColorTest("non-css-variable", "lime", ["lime"]), + makeColorTest("-non-css-variable", "#000", ["#000"]), + ], + }, + ]; + + for (const category of testCategories) { + info(category.desc); + + for (const test of category.tests) { + info(test.desc); + const target = doc.querySelector("div"); + + const frag = parser.parseCssProperty(test.name, test.value, { + colorSwatchClass: COLOR_TEST_CLASS, + }); + + target.appendChild(frag); + + is( + target.innerHTML, + test.expected, + `The parsed result for '${test.name}: ${test.value}' is correct` + ); + + target.innerHTML = ""; + } + } +} + +function testParseFontFamily(doc, parser) { + info("Test font-family parsing"); + const tests = [ + { + desc: "No fonts", + definition: "", + families: [], + }, + { + desc: "List of fonts", + definition: "Arial,Helvetica,sans-serif", + families: ["Arial", "Helvetica", "sans-serif"], + }, + { + desc: "Fonts with spaces", + definition: "Open Sans", + families: ["Open Sans"], + }, + { + desc: "Quoted fonts", + definition: "\"Arial\",'Open Sans'", + families: ["Arial", "Open Sans"], + }, + { + desc: "Fonts with extra whitespace", + definition: " Open Sans ", + families: ["Open Sans"], + }, + ]; + + const textContentTests = [ + { + desc: "No whitespace between fonts", + definition: "Arial,Helvetica,sans-serif", + output: "Arial,Helvetica,sans-serif", + }, + { + desc: "Whitespace between fonts", + definition: "Arial , Helvetica, sans-serif", + output: "Arial , Helvetica, sans-serif", + }, + { + desc: "Whitespace before first font trimmed", + definition: " Arial,Helvetica,sans-serif", + output: "Arial,Helvetica,sans-serif", + }, + { + desc: "Whitespace after last font trimmed", + definition: "Arial,Helvetica,sans-serif ", + output: "Arial,Helvetica,sans-serif", + }, + { + desc: "Whitespace between quoted fonts", + definition: "'Arial' , \"Helvetica\" ", + output: "'Arial' , \"Helvetica\"", + }, + { + desc: "Whitespace within font preserved", + definition: "' Ari al '", + output: "' Ari al '", + }, + ]; + + for (const { desc, definition, families } of tests) { + info(desc); + const frag = parser.parseCssProperty("font-family", definition, { + fontFamilyClass: "ruleview-font-family", + }); + const spans = frag.querySelectorAll(".ruleview-font-family"); + + is(spans.length, families.length, desc + " span count"); + for (let i = 0; i < spans.length; i++) { + is(spans[i].textContent, families[i], desc + " span contents"); + } + } + + info("Test font-family text content"); + for (const { desc, definition, output } of textContentTests) { + info(desc); + const frag = parser.parseCssProperty("font-family", definition, {}); + is(frag.textContent, output, desc + " text content matches"); + } + + info("Test font-family with custom properties"); + const frag = parser.parseCssProperty( + "font-family", + "var(--family, Georgia, serif)", + { + getVariableValue: () => {}, + unmatchedVariableClass: "unmatched-class", + fontFamilyClass: "ruleview-font-family", + } + ); + const target = doc.createElement("div"); + target.appendChild(frag); + is( + target.innerHTML, + // prettier-ignore + `<span>var(` + + `<span class="unmatched-class" data-variable="--family is not set">` + + `--family` + + `</span>` + + `,` + + `<span> ` + + `<span class="ruleview-font-family">Georgia</span>` + + `, ` + + `<span class="ruleview-font-family">serif</span>` + + `</span>)` + + `</span>`, + "Got expected output for font-family with custom properties" + ); +} diff --git a/devtools/client/shared/test/browser_prefs-01.js b/devtools/client/shared/test/browser_prefs-01.js new file mode 100644 index 0000000000..2989cdee57 --- /dev/null +++ b/devtools/client/shared/test/browser_prefs-01.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the preference helpers work properly. + +const { PrefsHelper } = require("resource://devtools/client/shared/prefs.js"); + +function test() { + const Prefs = new PrefsHelper("devtools.debugger", { + foo: ["Bool", "enabled"], + }); + + const originalPrefValue = Services.prefs.getBoolPref( + "devtools.debugger.enabled" + ); + is(Prefs.foo, originalPrefValue, "The pref value was correctly fetched."); + + Prefs.foo = !originalPrefValue; + is(Prefs.foo, !originalPrefValue, "The pref was was correctly changed (1)."); + is( + Services.prefs.getBoolPref("devtools.debugger.enabled"), + !originalPrefValue, + "The pref was was correctly changed (2)." + ); + + Services.prefs.setBoolPref("devtools.debugger.enabled", originalPrefValue); + info("The pref value was reset (1)."); + is( + Prefs.foo, + !originalPrefValue, + "The cached pref value hasn't changed yet (1)." + ); + + Services.prefs.setBoolPref("devtools.debugger.enabled", !originalPrefValue); + info("The pref value was reset (2)."); + is( + Prefs.foo, + !originalPrefValue, + "The cached pref value hasn't changed yet (2)." + ); + + Prefs.registerObserver(); + + Services.prefs.setBoolPref("devtools.debugger.enabled", originalPrefValue); + info("The pref value was reset (3)."); + is(Prefs.foo, originalPrefValue, "The cached pref value has changed now."); + + Prefs.unregisterObserver(); + + finish(); +} diff --git a/devtools/client/shared/test/browser_prefs-02.js b/devtools/client/shared/test/browser_prefs-02.js new file mode 100644 index 0000000000..ad32aa29ce --- /dev/null +++ b/devtools/client/shared/test/browser_prefs-02.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that preference helpers work properly with custom types of Float and Json. + +const { PrefsHelper } = require("resource://devtools/client/shared/prefs.js"); + +function test() { + const Prefs = new PrefsHelper("prefs.helper.test", { + float: ["Float", "float"], + json: ["Json", "json"], + jsonWithSpecialChar: ["Json", "jsonWithSpecialChar"], + }); + + Prefs.registerObserver(); + + // JSON + Services.prefs.setCharPref("prefs.helper.test.json", '{"a":1}'); + is(Prefs.json.a, 1, "The JSON pref value is correctly casted on get."); + + Prefs.json = { b: 2 }; + is( + Prefs.json.a, + undefined, + "The JSON pref value is correctly casted on set (1)." + ); + is(Prefs.json.b, 2, "The JSON pref value is correctly casted on set (2)."); + + // JSON with special character + Services.prefs.setStringPref( + "prefs.helper.test.jsonWithSpecialChar", + `{"hello":"おはよう皆!"}` + ); + is( + Prefs.jsonWithSpecialChar.hello, + "おはよう皆!", + "The JSON pref value with a special character is correctly stored." + ); + + Prefs.jsonWithSpecialChar = { bye: "さよなら!" }; + is( + Prefs.jsonWithSpecialChar.hello, + undefined, + "The JSON with the special characters pref value is correctly set. (1)" + ); + is( + Prefs.jsonWithSpecialChar.bye, + "さよなら!", + "The JSON with the special characters pref value is correctly set. (2)" + ); + + // Float + Services.prefs.setCharPref("prefs.helper.test.float", "3.14"); + is(Prefs.float, 3.14, "The float pref value is correctly casted on get."); + + Prefs.float = 6.28; + is(Prefs.float, 6.28, "The float pref value is correctly casted on set."); + + Prefs.unregisterObserver(); + + Services.prefs.clearUserPref("prefs.helper.test.float"); + Services.prefs.clearUserPref("prefs.helper.test.json"); + Services.prefs.clearUserPref("prefs.helper.test.jsonWithSpecialChar"); + finish(); +} diff --git a/devtools/client/shared/test/browser_require_raw.js b/devtools/client/shared/test/browser_require_raw.js new file mode 100644 index 0000000000..bcf4afe98f --- /dev/null +++ b/devtools/client/shared/test/browser_require_raw.js @@ -0,0 +1,23 @@ +/* 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 { require: browserRequire } = BrowserLoader({ + baseURI: "resource://devtools/client/shared/", + window, +}); + +const variableFileContents = browserRequire( + "raw!chrome://devtools/skin/variables.css" +); + +function test() { + ok(!!variableFileContents.length, "raw browserRequire worked"); + delete window.getBrowserLoaderForWindow; + finish(); +} diff --git a/devtools/client/shared/test/browser_spectrum.js b/devtools/client/shared/test/browser_spectrum.js new file mode 100644 index 0000000000..6189b1f0af --- /dev/null +++ b/devtools/client/shared/test/browser_spectrum.js @@ -0,0 +1,518 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the spectrum color picker works correctly + +const Spectrum = require("resource://devtools/client/shared/widgets/Spectrum.js"); +const { + accessibility: { + SCORES: { FAIL, AAA, AA }, + }, +} = require("resource://devtools/shared/constants.js"); + +loader.lazyRequireGetter( + this, + "cssColors", + "resource://devtools/shared/css/color-db.js", + true +); + +const TEST_URI = CHROME_URL_ROOT + "doc_spectrum.html"; +const REGULAR_TEXT_PROPS = { + "font-size": { value: "11px" }, + "font-weight": { value: "bold" }, + opacity: { value: "1" }, +}; +const SINGLE_BG_COLOR = { + value: cssColors.white, +}; +const ZERO_ALPHA_COLOR = [0, 255, 255, 0]; + +add_task(async function () { + const { host, doc } = await createHost("bottom", TEST_URI); + + const container = doc.getElementById("spectrum-container"); + await testCreateAndDestroyShouldAppendAndRemoveElements(container); + await testPassingAColorAtInitShouldSetThatColor(container); + await testSettingAndGettingANewColor(container); + await testChangingColorShouldEmitEvents(container, doc); + await testSettingColorShoudUpdateTheUI(container); + await testChangingColorShouldUpdateColorPreview(container); + await testNotSettingTextPropsShouldNotShowContrastSection(container); + await testSettingTextPropsAndColorShouldUpdateContrastValue(container); + await testOnlySelectingLargeTextWithNonZeroAlphaShouldShowIndicator( + container + ); + await testSettingMultiColoredBackgroundShouldShowContrastRange(container); + + host.destroy(); +}); + +/** + * Helper method for extracting the rgba overlay value of the color preview's background + * image style. + * + * @param {String} linearGradientStr + * The linear gradient CSS string. + * @return {String} Returns the rgba string for the color overlay. + */ +function extractRgbaOverlayString(linearGradientStr) { + const start = linearGradientStr.indexOf("("); + const end = linearGradientStr.indexOf(")"); + + return linearGradientStr.substring(start + 1, end + 1); +} + +function testColorPreviewDisplay( + spectrum, + expectedRgbCssString, + expectedBorderColor +) { + const { colorPreview } = spectrum; + const colorPreviewStyle = window.getComputedStyle(colorPreview); + expectedBorderColor = + expectedBorderColor === "transparent" + ? "rgba(0, 0, 0, 0)" + : expectedBorderColor; + + spectrum.updateUI(); + + // Extract the first rgba value from the linear gradient + const linearGradientStr = + colorPreviewStyle.getPropertyValue("background-image"); + const colorPreviewValue = extractRgbaOverlayString(linearGradientStr); + + is( + colorPreviewValue, + expectedRgbCssString, + `Color preview should be ${expectedRgbCssString}` + ); + + info("Test if color preview has a border or not."); + // Since border-color is a shorthand CSS property, using getComputedStyle will return + // an empty string. Instead, use one of the border sides to find the border-color value + // since they will all be the same. + const borderColorTop = colorPreviewStyle.getPropertyValue("border-top-color"); + is( + borderColorTop, + expectedBorderColor, + "Color preview border color is correct." + ); +} + +async function testCreateAndDestroyShouldAppendAndRemoveElements(container) { + ok(container, "We have the root node to append spectrum to"); + is(container.childElementCount, 0, "Root node is empty"); + + const s = await createSpectrum(container, cssColors.white); + Assert.greater( + container.childElementCount, + 0, + "Spectrum has appended elements" + ); + + s.destroy(); + is(container.childElementCount, 0, "Destroying spectrum removed all nodes"); +} + +async function testPassingAColorAtInitShouldSetThatColor(container) { + const initRgba = cssColors.white; + + const s = await createSpectrum(container, initRgba); + + const setRgba = s.rgb; + + is(initRgba[0], setRgba[0], "Spectrum initialized with the right color"); + is(initRgba[1], setRgba[1], "Spectrum initialized with the right color"); + is(initRgba[2], setRgba[2], "Spectrum initialized with the right color"); + is(initRgba[3], setRgba[3], "Spectrum initialized with the right color"); + + s.destroy(); +} + +async function testSettingAndGettingANewColor(container) { + const s = await createSpectrum(container, cssColors.black); + + const colorToSet = cssColors.white; + s.rgb = colorToSet; + const newColor = s.rgb; + + is(colorToSet[0], newColor[0], "Spectrum set with the right color"); + is(colorToSet[1], newColor[1], "Spectrum set with the right color"); + is(colorToSet[2], newColor[2], "Spectrum set with the right color"); + is(colorToSet[3], newColor[3], "Spectrum set with the right color"); + + s.destroy(); +} + +async function testChangingColorShouldEmitEventsHelper( + spectrum, + moveFn, + expectedColor +) { + const onChanged = spectrum.once("changed", (rgba, color) => { + is(rgba[0], expectedColor[0], "New color is correct"); + is(rgba[1], expectedColor[1], "New color is correct"); + is(rgba[2], expectedColor[2], "New color is correct"); + is(rgba[3], expectedColor[3], "New color is correct"); + is(`rgba(${rgba.join(", ")})`, color, "RGBA and css color correspond"); + }); + + moveFn(); + await onChanged; + ok(true, "Changed event was emitted on color change"); +} + +async function testChangingColorShouldEmitEvents(container, doc) { + const s = await createSpectrum(container, cssColors.white); + + const sendUpKey = () => EventUtils.sendKey("Up"); + const sendDownKey = () => EventUtils.sendKey("Down"); + const sendLeftKey = () => EventUtils.sendKey("Left"); + const sendRightKey = () => EventUtils.sendKey("Right"); + + info( + "Test that simulating a mouse drag move event emits color changed event" + ); + const draggerMoveFn = () => + s.onDraggerMove(s.dragger.offsetWidth / 2, s.dragger.offsetHeight / 2); + testChangingColorShouldEmitEventsHelper(s, draggerMoveFn, [128, 64, 64, 1]); + + info( + "Test that moving the dragger with arrow keys emits color changed event." + ); + // Focus on the spectrum dragger when spectrum is shown + s.dragger.focus(); + is( + doc.activeElement.className, + "spectrum-color spectrum-box", + "Spectrum dragger has successfully received focus." + ); + testChangingColorShouldEmitEventsHelper(s, sendDownKey, [125, 62, 62, 1]); + testChangingColorShouldEmitEventsHelper(s, sendLeftKey, [125, 63, 63, 1]); + testChangingColorShouldEmitEventsHelper(s, sendUpKey, [128, 64, 64, 1]); + testChangingColorShouldEmitEventsHelper(s, sendRightKey, [128, 63, 63, 1]); + + info( + "Test that moving the hue slider with arrow keys emits color changed event." + ); + // Tab twice to focus on hue slider + EventUtils.sendKey("Tab"); + is( + doc.activeElement.className, + "devtools-button", + "Eyedropper has focus now." + ); + EventUtils.sendKey("Tab"); + is( + doc.activeElement.className, + "spectrum-hue-input", + "Hue slider has successfully received focus." + ); + testChangingColorShouldEmitEventsHelper(s, sendRightKey, [128, 66, 63, 1]); + testChangingColorShouldEmitEventsHelper(s, sendLeftKey, [128, 63, 63, 1]); + + info( + "Test that moving the hue slider with arrow keys emits color changed event." + ); + // Tab to focus on alpha slider + EventUtils.sendKey("Tab"); + is( + doc.activeElement.className, + "spectrum-alpha-input", + "Alpha slider has successfully received focus." + ); + testChangingColorShouldEmitEventsHelper(s, sendLeftKey, [128, 63, 63, 0.99]); + testChangingColorShouldEmitEventsHelper(s, sendRightKey, [128, 63, 63, 1]); + + s.destroy(); +} + +function setSpectrumProps(spectrum, props, updateUI = true) { + for (const prop in props) { + spectrum[prop] = props[prop]; + + // Setting textProps implies contrast should be enabled for spectrum + if (prop === "textProps") { + spectrum.contrastEnabled = true; + } + } + + if (updateUI) { + spectrum.updateUI(); + } +} + +function testAriaAttributesOnSpectrumElements( + spectrum, + colorName, + rgbString, + alpha +) { + for (const slider of [spectrum.dragger, spectrum.hueSlider]) { + is( + slider.getAttribute("aria-describedby"), + "spectrum-dragger", + "Slider contains the correct describedby text." + ); + is( + slider.getAttribute("aria-valuetext"), + rgbString, + "Slider contains the correct valuetext text." + ); + } + + is( + spectrum.colorPreview.title, + colorName, + "Spectrum element contains the correct title text." + ); +} + +async function testSettingColorShoudUpdateTheUI(container) { + const s = await createSpectrum(container, cssColors.white); + const dragHelperOriginalPos = [ + s.dragHelper.style.top, + s.dragHelper.style.left, + ]; + const alphaSliderOriginalVal = s.alphaSlider.value; + let hueSliderOriginalVal = s.hueSlider.value; + + setSpectrumProps(s, { rgb: [50, 240, 234, 0.2] }); + + Assert.notEqual( + s.alphaSlider.value, + alphaSliderOriginalVal, + "Alpha helper has moved" + ); + Assert.notStrictEqual( + s.dragHelper.style.top, + dragHelperOriginalPos[0], + "Drag helper has moved" + ); + Assert.notStrictEqual( + s.dragHelper.style.left, + dragHelperOriginalPos[1], + "Drag helper has moved" + ); + Assert.notStrictEqual( + s.hueSlider.value, + hueSliderOriginalVal, + "Hue helper has moved" + ); + testAriaAttributesOnSpectrumElements( + s, + "Closest to: aqua", + "rgba(50, 240, 234, 0.2)", + 0.2 + ); + + hueSliderOriginalVal = s.hueSlider.value; + + setSpectrumProps(s, { rgb: ZERO_ALPHA_COLOR }); + is(s.alphaSlider.value, "0", "Alpha range UI has been updated again"); + Assert.notStrictEqual( + hueSliderOriginalVal, + s.hueSlider.value, + "Hue slider should have move again" + ); + testAriaAttributesOnSpectrumElements(s, "aqua", "rgba(0, 255, 255, 0)", 0); + + s.destroy(); +} + +async function testChangingColorShouldUpdateColorPreview(container) { + const s = await createSpectrum(container, [0, 0, 1, 1]); + + info("Test that color preview is black."); + testColorPreviewDisplay(s, "rgb(0, 0, 1)", "transparent"); + + info("Test that color preview is blue."); + s.rgb = [0, 0, 255, 1]; + testColorPreviewDisplay(s, "rgb(0, 0, 255)", "transparent"); + + info("Test that color preview is red."); + s.rgb = [255, 0, 0, 1]; + testColorPreviewDisplay(s, "rgb(255, 0, 0)", "transparent"); + + info("Test that color preview is white and also has a light grey border."); + s.rgb = cssColors.white; + testColorPreviewDisplay(s, "rgb(255, 255, 255)", "rgb(204, 204, 204)"); + + s.destroy(); +} + +async function testNotSettingTextPropsShouldNotShowContrastSection(container) { + const s = await createSpectrum(container, cssColors.white); + + setSpectrumProps(s, { rgb: cssColors.black }); + ok( + !s.spectrumContrast.classList.contains("visible"), + "Contrast section is not shown." + ); + + s.destroy(); +} + +function testSpectrumContrast( + spectrum, + contrastValueEl, + rgb, + expectedValue, + expectedBadgeClass = "", + expectLargeTextIndicator = false +) { + setSpectrumProps(spectrum, { rgb }); + + is( + contrastValueEl.textContent, + expectedValue, + "Contrast value has the correct text." + ); + is( + contrastValueEl.className, + `accessibility-contrast-value${ + expectedBadgeClass ? " " + expectedBadgeClass : "" + }`, + `Contrast value contains ${expectedBadgeClass || "base"} class.` + ); + is( + spectrum.contrastLabel.childNodes.length === 3, + expectLargeTextIndicator, + `Large text indicator is ${expectLargeTextIndicator ? "" : "not"} shown.` + ); +} + +async function testSettingTextPropsAndColorShouldUpdateContrastValue( + container +) { + const s = await createSpectrum(container, cssColors.white); + + ok( + !s.spectrumContrast.classList.contains("visible"), + "Contrast value is not available yet." + ); + + info( + "Test that contrast ratio is calculated on setting 'textProps' and 'rgb'." + ); + setSpectrumProps( + s, + { textProps: REGULAR_TEXT_PROPS, backgroundColorData: SINGLE_BG_COLOR }, + false + ); + testSpectrumContrast(s, s.contrastValue, [50, 240, 234, 0.8], "1.35", FAIL); + + info("Test that contrast ratio is updated when color is changed."); + testSpectrumContrast(s, s.contrastValue, cssColors.black, "21.00", AAA); + + info("Test that contrast ratio cannot be calculated with zero alpha."); + testSpectrumContrast( + s, + s.contrastValue, + ZERO_ALPHA_COLOR, + "Unable to calculate" + ); + + s.destroy(); +} + +async function testOnlySelectingLargeTextWithNonZeroAlphaShouldShowIndicator( + container +) { + let s = await createSpectrum(container, cssColors.white); + + Assert.notStrictEqual( + s.contrastLabel.childNodes.length, + 3, + "Large text indicator is initially hidden." + ); + + info( + "Test that selecting large text with non-zero alpha shows large text indicator." + ); + setSpectrumProps( + s, + { + textProps: { + "font-size": { value: "24px" }, + "font-weight": { value: "normal" }, + opacity: { value: "1" }, + }, + backgroundColorData: SINGLE_BG_COLOR, + }, + false + ); + testSpectrumContrast(s, s.contrastValue, cssColors.black, "21.00", AAA, true); + + info( + "Test that selecting large text with zero alpha hides large text indicator." + ); + testSpectrumContrast( + s, + s.contrastValue, + ZERO_ALPHA_COLOR, + "Unable to calculate" + ); + + // Spectrum should be closed and opened again to reflect changes in text size + s.destroy(); + s = await createSpectrum(container, cssColors.white); + + info("Test that selecting regular text does not show large text indicator."); + setSpectrumProps( + s, + { textProps: REGULAR_TEXT_PROPS, backgroundColorData: SINGLE_BG_COLOR }, + false + ); + testSpectrumContrast(s, s.contrastValue, cssColors.black, "21.00", AAA); + + s.destroy(); +} + +async function testSettingMultiColoredBackgroundShouldShowContrastRange( + container +) { + const s = await createSpectrum(container, cssColors.white); + + info( + "Test setting text with non-zero alpha and multi-colored bg shows contrast range and empty single contrast." + ); + setSpectrumProps( + s, + { + textProps: REGULAR_TEXT_PROPS, + backgroundColorData: { + min: cssColors.yellow, + max: cssColors.green, + }, + }, + false + ); + testSpectrumContrast(s, s.contrastValueMin, cssColors.white, "1.07", FAIL); + testSpectrumContrast(s, s.contrastValueMax, cssColors.white, "5.14", AA); + testSpectrumContrast(s, s.contrastValue, cssColors.white, ""); + ok( + s.spectrumContrast.classList.contains("range"), + "Contrast section contains range class." + ); + + info("Test setting text with zero alpha shows error in contrast min span."); + testSpectrumContrast( + s, + s.contrastValueMin, + ZERO_ALPHA_COLOR, + "Unable to calculate" + ); + + s.destroy(); +} + +async function createSpectrum(...spectrumConstructorParams) { + const s = new Spectrum(...spectrumConstructorParams); + await waitFor(() => s.dragger.offsetHeight > 0); + s.show(); + return s; +} diff --git a/devtools/client/shared/test/browser_tableWidget_basic.js b/devtools/client/shared/test/browser_tableWidget_basic.js new file mode 100644 index 0000000000..a8b737b9d0 --- /dev/null +++ b/devtools/client/shared/test/browser_tableWidget_basic.js @@ -0,0 +1,448 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that the table widget api works fine + +"use strict"; + +const TEST_URI = CHROME_URL_ROOT + "doc_tableWidget_basic.html"; + +const { + TableWidget, +} = require("resource://devtools/client/shared/widgets/TableWidget.js"); + +add_task(async function () { + await addTab("about:blank"); + const { host, doc } = await createHost("bottom", TEST_URI); + + const table = new TableWidget(doc.querySelector("box"), { + initialColumns: { + col1: "Column 1", + col2: "Column 2", + col3: "Column 3", + col4: "Column 4", + }, + uniqueId: "col1", + emptyText: "dummy-text", + highlightUpdated: true, + removableColumns: true, + firstColumn: "col4", + l10n: { + setAttributes() {}, + }, + }); + + startTests(doc, table); + endTests(doc, host, table); +}); + +function startTests(doc, table) { + populateTable(doc, table); + + testTreeItemInsertedCorrectly(doc, table); + testAPI(doc, table); +} + +function endTests(doc, host, table) { + table.destroy(); + host.destroy(); + gBrowser.removeCurrentTab(); + table = null; + finish(); +} + +function populateTable(doc, table) { + table.push({ + col1: "id1", + col2: "value10", + col3: "value20", + col4: "value30", + }); + table.push({ + col1: "id2", + col2: "value14", + col3: "value29", + col4: "value32", + }); + table.push({ + col1: "id3", + col2: "value17", + col3: "value21", + col4: "value31", + extraData: "foobar", + extraData2: 42, + }); + table.push({ + col1: "id4", + col2: "value12", + col3: "value26", + col4: "value33", + }); + table.push({ + col1: "id5", + col2: "value19", + col3: "value26", + col4: "value37", + }); + table.push({ + col1: "id6", + col2: "value15", + col3: "value25", + col4: "value37", + }); + table.push({ + col1: "id7", + col2: "value18", + col3: "value21", + col4: "value36", + somethingExtra: "Hello World!", + }); + table.push({ + col1: "id8", + col2: "value11", + col3: "value27", + col4: "value34", + }); + + const span = doc.createElement("span"); + span.textContent = "domnode"; + + table.push({ + col1: "id9", + col2: "value11", + col3: "value23", + col4: span, + }); +} + +/** + * Test if the nodes are inserted correctly in the table. + */ +function testTreeItemInsertedCorrectly(doc, table) { + // double because of splitters + is(table.tbody.children.length, 4 * 2, "4 columns exist"); + + // Test firstColumn option and check if the nodes are inserted correctly + is( + table.tbody.children[0].children.length, + 9 + 1, + "Correct rows in column 4" + ); + is( + table.tbody.children[0].firstChild.value, + "Column 4", + "Correct column header value" + ); + + for (let i = 1; i < 4; i++) { + is( + table.tbody.children[i * 2].children.length, + 9 + 1, + `Correct rows in column ${i}` + ); + is( + table.tbody.children[i * 2].firstChild.value, + `Column ${i}`, + "Correct column header value" + ); + } + for (let i = 1; i < 10; i++) { + is( + table.tbody.children[2].children[i].value, + `id${i}`, + `Correct value in row ${i}` + ); + } + + // Remove firstColumn option and reset the table + info("resetting table"); + table.clear(); + table.firstColumn = ""; + table.setColumns({ + col1: "Column 1", + col2: "Column 2", + col3: "Column 3", + col4: "Column 4", + }); + populateTable(doc, table); + + // Check if the nodes are inserted correctly without firstColumn option + for (let i = 0; i < 4; i++) { + is( + table.tbody.children[i * 2].children.length, + 9 + 1, + `Correct rows in column ${i}` + ); + is( + table.tbody.children[i * 2].firstChild.value, + `Column ${i + 1}`, + "Correct column header value" + ); + } + + for (let i = 1; i < 10; i++) { + is( + table.tbody.firstChild.children[i].value, + `id${i}`, + `Correct value in row ${i}` + ); + } +} + +/** + * Tests if the API exposed by TableWidget works properly + */ +function testAPI(doc, table) { + info("Testing TableWidget API"); + // Check if selectRow and selectedRow setter works as expected + // Nothing should be selected beforehand + ok(!doc.querySelector(".theme-selected"), "Nothing is selected"); + table.selectRow("id4"); + const node = doc.querySelector(".theme-selected"); + ok(!!node, "Somthing got selected"); + is(node.getAttribute("data-id"), "id4", "Correct node selected"); + + table.selectRow("id7"); + const node2 = doc.querySelector(".theme-selected"); + ok(!!node2, "Somthing is still selected"); + isnot(node, node2, "Newly selected node is different from previous"); + is(node2.getAttribute("data-id"), "id7", "Correct node selected"); + + // test if selectedIRow getter works + is(table.selectedRow.col1, "id7", "Correct result of selectedRow getter"); + + // test if isSelected works + ok(table.isSelected("id7"), "isSelected with column id works"); + ok( + table.isSelected({ + col1: "id7", + col2: "value18", + col3: "value21", + col4: "value36", + somethingExtra: "Hello World!", + }), + "isSelected with json works" + ); + + table.selectedRow = "id4"; + const node3 = doc.querySelector(".theme-selected"); + ok(!!node3, "Somthing is still selected"); + isnot(node2, node3, "Newly selected node is different from previous"); + is(node3, node, "First and third selected nodes should be same"); + is(node3.getAttribute("data-id"), "id4", "Correct node selected"); + + // test if selectedRow getter works + is(table.selectedRow.col1, "id4", "Correct result of selectedRow getter"); + + // test if clear selection works + table.clearSelection(); + ok( + !doc.querySelector(".theme-selected"), + "Nothing selected after clear selection call" + ); + + // test if selectNextRow and selectPreviousRow work + table.selectedRow = "id7"; + ok(table.isSelected("id7"), "Correct row selected"); + table.selectNextRow(); + ok(table.isSelected("id8"), "Correct row selected after selectNextRow call"); + + table.selectNextRow(); + ok(table.isSelected("id9"), "Correct row selected after selectNextRow call"); + + table.selectNextRow(); + ok( + table.isSelected("id1"), + "Properly cycled to first row after selectNextRow call on last row" + ); + + table.selectNextRow(); + ok(table.isSelected("id2"), "Correct row selected after selectNextRow call"); + + table.selectPreviousRow(); + ok( + table.isSelected("id1"), + "Correct row selected after selectPreviousRow call" + ); + + table.selectPreviousRow(); + ok( + table.isSelected("id9"), + "Properly cycled to last row after selectPreviousRow call on first row" + ); + + // test if remove works + ok(doc.querySelector("[data-id='id4']"), "id4 row exists before removal"); + table.remove("id4"); + ok( + !doc.querySelector("[data-id='id4']"), + "id4 row does not exist after removal through id" + ); + + ok(doc.querySelector("[data-id='id6']"), "id6 row exists before removal"); + table.remove({ + col1: "id6", + col2: "value15", + col3: "value25", + col4: "value37", + }); + ok( + !doc.querySelector("[data-id='id6']"), + "id6 row does not exist after removal through json" + ); + + table.push({ + col1: "id4", + col2: "value12", + col3: "value26", + col4: "value33", + }); + table.push({ + col1: "id6", + col2: "value15", + col3: "value25", + col4: "value37", + }); + + // test if selectedIndex getter setter works + table.selectedIndex = 2; + ok(table.isSelected("id3"), "Correct row selected by selectedIndex setter"); + + table.selectedIndex = 4; + ok(table.isSelected("id5"), "Correct row selected by selectedIndex setter"); + + table.selectRow("id8"); + is(table.selectedIndex, 7, "Correct value of selectedIndex getter"); + + // testing if clear works + table.clear(); + // double because splitters + is(table.tbody.children.length, 4 * 2, "4 columns exist even after clear"); + for (let i = 0; i < 4; i++) { + is( + table.tbody.children[i * 2].children.length, + 1, + `Only header in the column ${i} after clear call` + ); + is( + table.tbody.children[i * 2].firstChild.value, + `Column ${i + 1}`, + "Correct column header value" + ); + } + + // testing if setColumns work + table.setColumns({ + col1: "Foobar", + col2: "Testing", + }); + + // double because splitters + is( + table.tbody.children.length, + 2 * 2, + "2 columns exist after setColumn call" + ); + is( + table.tbody.children[0].firstChild.getAttribute("value"), + "Foobar", + "Correct column header value for first column" + ); + is( + table.tbody.children[2].firstChild.getAttribute("value"), + "Testing", + "Correct column header value for second column" + ); + + table.setColumns({ + col1: "Column 1", + col2: "Column 2", + col3: "Column 3", + col4: "Column 4", + }); + // double because splitters + is( + table.tbody.children.length, + 4 * 2, + "4 columns exist after second setColumn call" + ); + + populateTable(doc, table); + + // testing if update works + is( + doc.querySelectorAll("[data-id='id4']")[1].value, + "value12", + "Correct value before update" + ); + table.update({ + col1: "id4", + col2: "UPDATED", + col3: "value26", + col4: "value33", + }); + is( + doc.querySelectorAll("[data-id='id4']")[1].value, + "UPDATED", + "Correct value after update" + ); + + // testing if sorting works by calling it once on an already sorted column + // should sort descending + table.sortBy("col1"); + for (let i = 1; i < 10; i++) { + is( + table.tbody.firstChild.children[i].value, + `id${10 - i}`, + `Correct value in row ${i} after descending sort by on col1` + ); + } + // Calling it on an unsorted column should sort by it in ascending manner + table.sortBy("col2"); + let cell = table.tbody.children[2].firstChild.children[2]; + checkAscendingOrder(cell); + + // Calling it again should sort by it in descending manner + table.sortBy("col2"); + cell = table.tbody.children[2].lastChild.previousSibling; + checkDescendingOrder(cell); + + // Calling it again should sort by it in ascending manner + table.sortBy("col2"); + cell = table.tbody.children[2].firstChild.children[2]; + checkAscendingOrder(cell); + + table.clear(); + populateTable(doc, table); + + // testing if sorting works should sort by ascending manner + table.sortBy("col4"); + cell = table.tbody.children[6].children[1]; + is(cell.textContent, "domnode", "DOMNode sorted correctly"); + checkAscendingOrder(cell.nextSibling); + + // Calling it again should sort it in descending order + table.sortBy("col4"); + cell = table.tbody.children[6].children[9]; + is(cell.textContent, "domnode", "DOMNode sorted correctly"); + checkDescendingOrder(cell.previousSibling); +} + +function checkAscendingOrder(cell) { + while (cell) { + const currentCell = cell.value || cell.textContent; + const prevCell = + cell.previousSibling.value || cell.previousSibling.textContent; + ok(currentCell >= prevCell, "Sorting is in ascending order"); + cell = cell.nextSibling; + } +} + +function checkDescendingOrder(cell) { + while (cell != cell.parentNode.firstChild) { + const currentCell = cell.value || cell.textContent; + const nextCell = cell.nextSibling.value || cell.nextSibling.textContent; + ok(currentCell >= nextCell, "Sorting is in descending order"); + cell = cell.previousSibling; + } +} diff --git a/devtools/client/shared/test/browser_tableWidget_keyboard_interaction.js b/devtools/client/shared/test/browser_tableWidget_keyboard_interaction.js new file mode 100644 index 0000000000..293ac71e7f --- /dev/null +++ b/devtools/client/shared/test/browser_tableWidget_keyboard_interaction.js @@ -0,0 +1,202 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that keyboard interaction works fine with the table widget + +"use strict"; + +const TEST_URI = CHROME_URL_ROOT + "doc_tableWidget_keyboard_interaction.xhtml"; +const TEST_OPT = "chrome,titlebar,toolbar,centerscreen,resizable,dialog=no"; + +const { + TableWidget, +} = require("resource://devtools/client/shared/widgets/TableWidget.js"); + +var doc, table; + +function test() { + waitForExplicitFinish(); + const win = Services.ww.openWindow(null, TEST_URI, "_blank", TEST_OPT, null); + + win.addEventListener( + "load", + function () { + waitForFocus(function () { + doc = win.document; + table = new TableWidget(doc.querySelector("box"), { + initialColumns: { + col1: "Column 1", + col2: "Column 2", + col3: "Column 3", + col4: "Column 4", + }, + uniqueId: "col1", + emptyText: "dummy-text", + highlightUpdated: true, + removableColumns: true, + l10n: { + setAttributes() {}, + }, + }); + startTests(); + }); + }, + { once: true } + ); +} + +function endTests() { + table.destroy(); + doc.defaultView.close(); + doc = table = null; + finish(); +} + +var startTests = async function () { + populateTable(); + await testKeyboardInteraction(); + endTests(); +}; + +function populateTable() { + table.push({ + col1: "id1", + col2: "value10", + col3: "value20", + col4: "value30", + }); + table.push({ + col1: "id2", + col2: "value14", + col3: "value29", + col4: "value32", + }); + table.push({ + col1: "id3", + col2: "value17", + col3: "value21", + col4: "value31", + extraData: "foobar", + extraData2: 42, + }); + table.push({ + col1: "id4", + col2: "value12", + col3: "value26", + col4: "value33", + }); + table.push({ + col1: "id5", + col2: "value19", + col3: "value26", + col4: "value37", + }); + table.push({ + col1: "id6", + col2: "value15", + col3: "value25", + col4: "value37", + }); + table.push({ + col1: "id7", + col2: "value18", + col3: "value21", + col4: "value36", + somethingExtra: "Hello World!", + }); + table.push({ + col1: "id8", + col2: "value11", + col3: "value27", + col4: "value34", + }); + table.push({ + col1: "id9", + col2: "value11", + col3: "value23", + col4: "value38", + }); +} + +// Sends a click event on the passed DOM node in an async manner +function click(node, button = 0) { + if (button == 0) { + executeSoon(() => + EventUtils.synthesizeMouseAtCenter(node, {}, doc.defaultView) + ); + } else { + executeSoon(() => + EventUtils.synthesizeMouseAtCenter( + node, + { + button, + type: "contextmenu", + }, + doc.defaultView + ) + ); + } +} + +function getNodeByValue(value) { + return table.tbody.querySelector("[value=" + value + "]"); +} + +/** + * Tests if pressing navigation keys on the table items does the expected + * behavior. + */ +var testKeyboardInteraction = async function () { + info("Testing keyboard interaction with the table"); + info("clicking on the row containing id2"); + const node = getNodeByValue("id2"); + const event = table.once(TableWidget.EVENTS.ROW_SELECTED); + click(node); + await event; + + await testRow("id3", "DOWN", "next row"); + await testRow("id4", "DOWN", "next row"); + await testRow("id3", "UP", "previous row"); + await testRow("id4", "DOWN", "next row"); + await testRow("id5", "DOWN", "next row"); + await testRow("id6", "DOWN", "next row"); + await testRow("id5", "UP", "previous row"); + await testRow("id4", "UP", "previous row"); + await testRow("id3", "UP", "previous row"); + + // selecting last item node to test edge navigation cycling case + table.selectedRow = "id9"; + + // pressing down on last row should move to first row. + await testRow("id1", "DOWN", "first row"); + + // pressing up now should move to last row. + await testRow("id9", "UP", "last row"); +}; + +async function testRow(id, key, destination) { + const node = getNodeByValue(id); + // node should not have selected class + ok( + !node.classList.contains("theme-selected"), + "Row should not have selected class" + ); + info(`Pressing ${key} to select ${destination}`); + + const event = table.once(TableWidget.EVENTS.ROW_SELECTED); + EventUtils.sendKey(key, doc.defaultView); + + const uniqueId = await event; + is(id, uniqueId, `Correct row was selected after pressing ${key}`); + + ok(node.classList.contains("theme-selected"), "row has selected class"); + + const nodes = doc.querySelectorAll(".theme-selected"); + for (let i = 0; i < nodes.length; i++) { + is( + nodes[i].getAttribute("data-id"), + id, + "Correct cell selected in all columns" + ); + } +} diff --git a/devtools/client/shared/test/browser_tableWidget_mouse_interaction.js b/devtools/client/shared/test/browser_tableWidget_mouse_interaction.js new file mode 100644 index 0000000000..91b93ac6d5 --- /dev/null +++ b/devtools/client/shared/test/browser_tableWidget_mouse_interaction.js @@ -0,0 +1,359 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that mosue interaction works fine with the table widget + +"use strict"; + +const TEST_URI = CHROME_URL_ROOT + "doc_tableWidget_mouse_interaction.xhtml"; +const TEST_OPT = "chrome,titlebar,toolbar,centerscreen,resizable,dialog=no"; + +const { + TableWidget, +} = require("resource://devtools/client/shared/widgets/TableWidget.js"); + +var doc, table; + +function test() { + waitForExplicitFinish(); + const win = Services.ww.openWindow(null, TEST_URI, "_blank", TEST_OPT, null); + + win.addEventListener( + "load", + function () { + waitForFocus(function () { + doc = win.document; + table = new TableWidget(doc.querySelector("box"), { + initialColumns: { + col1: "Column 1", + col2: "Column 2", + col3: "Column 3", + col4: "Column 4", + }, + uniqueId: "col1", + emptyText: "This is dummy empty text", + highlightUpdated: true, + removableColumns: true, + wrapTextInElements: true, + l10n: { + setAttributes() {}, + }, + }); + startTests(); + }); + }, + { once: true } + ); +} + +function endTests() { + table.destroy(); + doc.defaultView.close(); + doc = table = null; + finish(); +} + +var startTests = async function () { + populateTable(); + await testMouseInteraction(); + endTests(); +}; + +function populateTable() { + table.push({ + col1: "id1", + col2: "value10", + col3: "value20", + col4: "value30", + }); + table.push({ + col1: "id2", + col2: "value14", + col3: "value29", + col4: "value32", + }); + table.push({ + col1: "id3", + col2: "value17", + col3: "value21", + col4: "value31", + extraData: "foobar", + extraData2: 42, + }); + table.push({ + col1: "id4", + col2: "value12", + col3: "value26", + col4: "value33", + }); + table.push({ + col1: "id5", + col2: "value19", + col3: "value26", + col4: "value37", + }); + table.push({ + col1: "id6", + col2: "value15", + col3: "value25", + col4: "value37", + }); + table.push({ + col1: "id7", + col2: "value18", + col3: "value21", + col4: "value36", + somethingExtra: "Hello World!", + }); + table.push({ + col1: "id8", + col2: "value11", + col3: "value27", + col4: "value34", + }); + table.push({ + col1: "id9", + col2: "value11", + col3: "value23", + col4: "value38", + }); +} + +// Sends a click event on the passed DOM node in an async manner +function click(node, button = 0) { + if (button == 0) { + executeSoon(() => + EventUtils.synthesizeMouseAtCenter(node, {}, doc.defaultView) + ); + } else { + executeSoon(() => + EventUtils.synthesizeMouseAtCenter( + node, + { + button, + type: "contextmenu", + }, + doc.defaultView + ) + ); + } +} + +async function showCol(id) { + const onPopupHidden = once(table.menupopup, "popuphidden"); + const event = table.once(TableWidget.EVENTS.HEADER_CONTEXT_MENU); + const menuItem = table.menupopup.querySelector(`[data-id='${id}']`); + const column = table.tbody.querySelector(`#${id}`); + + info(`Showing ${id}`); + ok(BrowserTestUtils.isHidden(column), "Column is hidden before showing it"); + + table.menupopup.activateItem(menuItem); + const toShow = await event; + await onPopupHidden; + + is(toShow, id, `#${id} was selected to be shown`); + ok( + BrowserTestUtils.isVisible(column), + "Column is not hidden after showing it" + ); +} + +async function hideCol(id) { + const onPopupHidden = once(table.menupopup, "popuphidden"); + const event = table.once(TableWidget.EVENTS.HEADER_CONTEXT_MENU); + const menuItem = table.menupopup.querySelector(`[data-id='${id}']`); + const column = table.tbody.querySelector(`#${id}`); + + info(`selecting to hide #${id}`); + ok( + BrowserTestUtils.isVisible(column), + `Column #${id} is not hidden before hiding it` + ); + table.menupopup.activateItem(menuItem); + const toHide = await event; + await onPopupHidden; + is(toHide, id, `#${id} was selected to be hidden`); + ok( + BrowserTestUtils.isHidden(column), + `Column #${id} is hidden after hiding it` + ); +} + +/** + * Tests if clicking the table items does the expected behavior + */ +var testMouseInteraction = async function () { + info("Testing mouse interaction with the table"); + ok(!table.selectedRow, "Nothing should be selected beforehand"); + + let event = table.once(TableWidget.EVENTS.ROW_SELECTED); + const firstColumnFirstRowCell = table.tbody.querySelector("[data-id=id1]"); + info("clicking on the first row"); + ok( + !firstColumnFirstRowCell.classList.contains("theme-selected"), + "Node should not have selected class before clicking" + ); + click(firstColumnFirstRowCell); + let id = await event; + ok( + firstColumnFirstRowCell.classList.contains("theme-selected"), + "Node has selected class after click" + ); + is(id, "id1", "Correct row was selected"); + + info("clicking on second row to select it"); + event = table.once(TableWidget.EVENTS.ROW_SELECTED); + const firstColumnSecondRowCell = table.tbody.firstChild.children[2]; + // node should not have selected class + ok( + !firstColumnSecondRowCell.classList.contains("theme-selected"), + "New node should not have selected class before clicking" + ); + click(firstColumnSecondRowCell); + id = await event; + ok( + firstColumnSecondRowCell.classList.contains("theme-selected"), + "New node has selected class after clicking" + ); + is(id, "id2", "Correct table path is emitted for new node"); + isnot( + firstColumnFirstRowCell, + firstColumnSecondRowCell, + "Old and new node are different" + ); + ok( + !firstColumnFirstRowCell.classList.contains("theme-selected"), + "Old node should not have selected class after the click on new node" + ); + + info("clicking on the third row cell content to select third row"); + event = table.once(TableWidget.EVENTS.ROW_SELECTED); + const firstColumnThirdRowCell = table.tbody.firstChild.children[3]; + const firstColumnThirdRowCellInnerNode = + firstColumnThirdRowCell.querySelector("span"); + // node should not have selected class + ok( + !firstColumnThirdRowCell.classList.contains("theme-selected"), + "New node should not have selected class before clicking" + ); + click(firstColumnThirdRowCellInnerNode); + id = await event; + ok( + firstColumnThirdRowCell.classList.contains("theme-selected"), + "New node has selected class after clicking the cell content" + ); + is(id, "id3", "Correct table path is emitted for new node"); + + // clicking on table header to sort by it + event = table.once(TableWidget.EVENTS.COLUMN_SORTED); + let node = table.tbody.children[6].children[0]; + info("clicking on the 4th coulmn header to sort the table by it"); + ok( + !node.hasAttribute("sorted"), + "Node should not have sorted attribute before clicking" + ); + ok( + doc.querySelector("[sorted]"), + "Although, something else should be sorted on" + ); + isnot(doc.querySelector("[sorted]"), node, "Which is not equal to this node"); + click(node); + id = await event; + is(id, "col4", "Correct column was sorted on"); + ok( + node.hasAttribute("sorted"), + "Node should now have sorted attribute after clicking" + ); + is( + doc.querySelectorAll("[sorted]").length, + 1, + "Now only one column should be sorted on" + ); + is(doc.querySelector("[sorted]"), node, "Which should be this column"); + + // test context menu opening. + // hiding second column + // event listener for popupshown + info("right click on the first column header"); + node = table.tbody.firstChild.firstChild; + let onPopupShown = once(table.menupopup, "popupshown"); + click(node, 2); + await onPopupShown; + + is( + table.menupopup.querySelectorAll("menuitem[disabled]").length, + 1, + "Only 1 menuitem is disabled" + ); + is( + table.menupopup.querySelector("menuitem[disabled]"), + table.menupopup.querySelector("[data-id='col1']"), + "Which is the unique column" + ); + + // popup should be open now + // clicking on second column label + await hideCol("col2"); + + // hiding third column + // event listener for popupshown + info("right clicking on the first column header"); + node = table.tbody.firstChild.firstChild; + onPopupShown = once(table.menupopup, "popupshown"); + click(node, 2); + await onPopupShown; + + is( + table.menupopup.querySelectorAll("menuitem[disabled]").length, + 1, + "Only 1 menuitem is disabled" + ); + + await hideCol("col3"); + + // opening again to see if 2 items are disabled now + // event listener for popupshown + info("right clicking on the first column header"); + node = table.tbody.firstChild.firstChild; + onPopupShown = once(table.menupopup, "popupshown"); + click(node, 2); + await onPopupShown; + + is( + table.menupopup.querySelectorAll("menuitem[disabled]").length, + 2, + "2 menuitems are disabled now as only 2 columns remain visible" + ); + is( + table.menupopup.querySelectorAll("menuitem[disabled]")[0], + table.menupopup.querySelector("[data-id='col1']"), + "First is the unique column" + ); + is( + table.menupopup.querySelectorAll("menuitem[disabled]")[1], + table.menupopup.querySelector("[data-id='col4']"), + "Second is the last column" + ); + + // showing back 2nd column + // popup should be open now + // clicking on second column label + await showCol("col2"); + + // showing back 3rd column + // event listener for popupshown + info("right clicking on the first column header"); + node = table.tbody.firstChild.firstChild; + onPopupShown = once(table.menupopup, "popupshown"); + click(node, 2); + await onPopupShown; + + // popup should be open now + // clicking on second column label + await showCol("col3"); + + // reset table state + table.clearSelection(); + table.sortBy("col1"); +}; diff --git a/devtools/client/shared/test/browser_telemetry_button_eyedropper.js b/devtools/client/shared/test/browser_telemetry_button_eyedropper.js new file mode 100644 index 0000000000..88d019de91 --- /dev/null +++ b/devtools/client/shared/test/browser_telemetry_button_eyedropper.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URI = + "data:text/html;charset=utf-8," + + "<p>browser_telemetry_button_eyedropper.js</p><div>test</div>"; + +add_task(async function () { + await addTab(TEST_URI); + startTelemetry(); + + const tab = gBrowser.selectedTab; + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "inspector", + }); + + info("testing the eyedropper button"); + await testButton(toolbox); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); + +async function testButton(toolbox, Telemetry) { + info("Calling the eyedropper button's callback"); + // We call the button callback directly because we don't need to test the UI here, we're + // only concerned about testing the telemetry probe. + await toolbox.getPanel("inspector").showEyeDropper(); + + checkResults(); +} + +function checkResults() { + // For help generating these tests use generateTelemetryTests("devtools.") + // here. + checkTelemetry("devtools.toolbar.eyedropper.opened", "", 1, "scalar"); +} diff --git a/devtools/client/shared/test/browser_telemetry_button_responsive.js b/devtools/client/shared/test/browser_telemetry_button_responsive.js new file mode 100644 index 0000000000..ff4e3837bc --- /dev/null +++ b/devtools/client/shared/test/browser_telemetry_button_responsive.js @@ -0,0 +1,108 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URI = + "data:text/html;charset=utf-8," + + "<p>browser_telemetry_button_responsive.js</p>"; + +// Because we need to gather stats for the period of time that a tool has been +// opened we make use of setTimeout() to create tool active times. +const TOOL_DELAY = 200; + +const asyncStorage = require("resource://devtools/shared/async-storage.js"); + +// Toggling the RDM UI involves several docShell swap operations, which are somewhat slow +// on debug builds. Usually we are just barely over the limit, so a blanket factor of 2 +// should be enough. +requestLongerTimeout(2); + +Services.prefs.clearUserPref("devtools.responsive.html.displayedDeviceList"); + +registerCleanupFunction(() => { + Services.prefs.clearUserPref("devtools.responsive.html.displayedDeviceList"); + asyncStorage.removeItem("devtools.devices.local"); +}); + +loader.lazyRequireGetter( + this, + "ResponsiveUIManager", + "resource://devtools/client/responsive/manager.js" +); + +add_task(async function () { + await addTab(TEST_URI); + startTelemetry(); + + const tab = gBrowser.selectedTab; + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "inspector", + }); + info("inspector opened"); + + info("testing the responsivedesign button"); + await testButton(tab, toolbox); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); + +async function testButton(tab, toolbox) { + info("Testing command-button-responsive"); + + const button = toolbox.doc.querySelector("#command-button-responsive"); + ok(button, "Captain, we have the button"); + + await delayedClicks(tab, button, 4); + + checkResults(); +} + +function waitForToggle() { + return new Promise(resolve => { + const handler = () => { + ResponsiveUIManager.off("on", handler); + ResponsiveUIManager.off("off", handler); + resolve(); + }; + ResponsiveUIManager.on("on", handler); + ResponsiveUIManager.on("off", handler); + }); +} + +var delayedClicks = async function (tab, node, clicks) { + for (let i = 0; i < clicks; i++) { + info("Clicking button " + node.id); + const toggled = waitForToggle(); + node.click(); + await toggled; + // See TOOL_DELAY for why we need setTimeout here + await DevToolsUtils.waitForTime(TOOL_DELAY); + + // When opening RDM + if (i % 2 == 0) { + // wait for RDM to be fully loaded to prevent Promise rejection when closing + await waitFor(() => ResponsiveUIManager.isActiveForTab(tab)); + const rdmUI = ResponsiveUIManager.getResponsiveUIForTab(tab); + await waitForRDMLoaded(rdmUI); + } + } +}; + +function checkResults() { + // For help generating these tests use generateTelemetryTests("DEVTOOLS_RESPONSIVE_") + // here. + checkTelemetry( + "DEVTOOLS_RESPONSIVE_OPENED_COUNT", + "", + { 0: 2, 1: 0 }, + "array" + ); + checkTelemetry( + "DEVTOOLS_RESPONSIVE_TIME_ACTIVE_SECONDS", + "", + null, + "hasentries" + ); +} diff --git a/devtools/client/shared/test/browser_telemetry_misc.js b/devtools/client/shared/test/browser_telemetry_misc.js new file mode 100644 index 0000000000..eeed8c2c41 --- /dev/null +++ b/devtools/client/shared/test/browser_telemetry_misc.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URI = + "data:text/html;charset=utf-8,<p>browser_telemetry_misc.js</p>"; +const TOOL_DELAY = 0; + +add_task(async function () { + await addTab(TEST_URI); + + startTelemetry(); + + await openAndCloseToolbox(1, TOOL_DELAY, "inspector"); + checkResults(); + + gBrowser.removeCurrentTab(); +}); + +function checkResults() { + // For help generating these tests use generateTelemetryTests("DEVTOOLS_") + // here. + checkTelemetry("DEVTOOLS_TOOLBOX_OPENED_COUNT", "", { 0: 1, 1: 0 }, "array"); + checkTelemetry( + "DEVTOOLS_INSPECTOR_OPENED_COUNT", + "", + { 0: 1, 1: 0 }, + "array" + ); + checkTelemetry("DEVTOOLS_RULEVIEW_OPENED_COUNT", "", { 0: 1, 1: 0 }, "array"); + checkTelemetry( + "DEVTOOLS_TOOLBOX_TIME_ACTIVE_SECONDS", + "", + null, + "hasentries" + ); + checkTelemetry( + "DEVTOOLS_INSPECTOR_TIME_ACTIVE_SECONDS", + "", + null, + "hasentries" + ); + checkTelemetry( + "DEVTOOLS_RULEVIEW_TIME_ACTIVE_SECONDS", + "", + null, + "hasentries" + ); + checkTelemetry("DEVTOOLS_TOOLBOX_HOST", "", null, "hasentries"); +} diff --git a/devtools/client/shared/test/browser_telemetry_sidebar.js b/devtools/client/shared/test/browser_telemetry_sidebar.js new file mode 100644 index 0000000000..21ef27bc41 --- /dev/null +++ b/devtools/client/shared/test/browser_telemetry_sidebar.js @@ -0,0 +1,233 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +"use strict"; + +const TEST_URI = + "data:text/html;charset=utf-8,<p>browser_telemetry_sidebar.js</p>"; +const ALL_CHANNELS = Ci.nsITelemetry.DATASET_ALL_CHANNELS; + +// Because we need to gather stats for the period of time that a tool has been +// opened we make use of setTimeout() to create tool active times. +const TOOL_DELAY = 200; + +const DATA = [ + { + timestamp: null, + category: "devtools.main", + method: "sidepanel_changed", + object: "inspector", + value: null, + extra: { + oldpanel: "layoutview", + newpanel: "animationinspector", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "sidepanel_changed", + object: "inspector", + value: null, + extra: { + oldpanel: "animationinspector", + newpanel: "fontinspector", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "sidepanel_changed", + object: "inspector", + value: null, + extra: { + oldpanel: "fontinspector", + newpanel: "layoutview", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "sidepanel_changed", + object: "inspector", + value: null, + extra: { + oldpanel: "layoutview", + newpanel: "computedview", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "sidepanel_changed", + object: "inspector", + value: null, + extra: { + oldpanel: "computedview", + newpanel: "animationinspector", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "sidepanel_changed", + object: "inspector", + value: null, + extra: { + oldpanel: "animationinspector", + newpanel: "fontinspector", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "sidepanel_changed", + object: "inspector", + value: null, + extra: { + oldpanel: "fontinspector", + newpanel: "layoutview", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "sidepanel_changed", + object: "inspector", + value: null, + extra: { + oldpanel: "layoutview", + newpanel: "computedview", + }, + }, +]; + +add_task(async function () { + // Let's reset the counts. + Services.telemetry.clearEvents(); + + // Ensure no events have been logged + const snapshot = Services.telemetry.snapshotEvents(ALL_CHANNELS, true); + ok(!snapshot.parent, "No events have been logged for the main process"); + + await addTab(TEST_URI); + startTelemetry(); + + const tab = gBrowser.selectedTab; + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "inspector", + }); + info("inspector opened"); + + await testSidebar(toolbox); + checkResults(); + checkEventTelemetry(); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); + +function testSidebar(toolbox) { + info("Testing sidebar"); + + const inspector = toolbox.getCurrentPanel(); + let sidebarTools = [ + "computedview", + "layoutview", + "fontinspector", + "animationinspector", + ]; + + // Concatenate the array with itself so that we can open each tool twice. + sidebarTools = [...sidebarTools, ...sidebarTools]; + + return new Promise(resolve => { + // See TOOL_DELAY for why we need setTimeout here + setTimeout(function selectSidebarTab() { + const tool = sidebarTools.pop(); + if (tool) { + inspector.sidebar.select(tool); + setTimeout(function () { + setTimeout(selectSidebarTab, TOOL_DELAY); + }, TOOL_DELAY); + } else { + resolve(); + } + }, TOOL_DELAY); + }); +} + +function checkResults() { + // For help generating these tests use generateTelemetryTests("DEVTOOLS_") + // here. + checkTelemetry( + "DEVTOOLS_INSPECTOR_OPENED_COUNT", + "", + { 0: 1, 1: 0 }, + "array" + ); + checkTelemetry("DEVTOOLS_RULEVIEW_OPENED_COUNT", "", { 0: 1, 1: 0 }, "array"); + checkTelemetry( + "DEVTOOLS_COMPUTEDVIEW_OPENED_COUNT", + "", + { 0: 2, 1: 0 }, + "array" + ); + checkTelemetry( + "DEVTOOLS_LAYOUTVIEW_OPENED_COUNT", + "", + { 0: 3, 1: 0 }, + "array" + ); + checkTelemetry( + "DEVTOOLS_FONTINSPECTOR_OPENED_COUNT", + "", + { 0: 2, 1: 0 }, + "array" + ); + checkTelemetry( + "DEVTOOLS_COMPUTEDVIEW_TIME_ACTIVE_SECONDS", + "", + null, + "hasentries" + ); + checkTelemetry( + "DEVTOOLS_LAYOUTVIEW_TIME_ACTIVE_SECONDS", + "", + null, + "hasentries" + ); + checkTelemetry( + "DEVTOOLS_FONTINSPECTOR_TIME_ACTIVE_SECONDS", + "", + null, + "hasentries" + ); +} + +function checkEventTelemetry() { + const snapshot = Services.telemetry.snapshotEvents(ALL_CHANNELS, true); + const events = snapshot.parent.filter( + event => + event[1] === "devtools.main" && + event[2] === "sidepanel_changed" && + event[3] === "inspector" && + event[4] === null + ); + + for (const i in DATA) { + const [timestamp, category, method, object, value, extra] = events[i]; + const expected = DATA[i]; + + // ignore timestamp + Assert.greater(timestamp, 0, "timestamp is greater than 0"); + is(category, expected.category, "category is correct"); + is(method, expected.method, "method is correct"); + is(object, expected.object, "object is correct"); + is(value, expected.value, "value is correct"); + + is(extra.oldpanel, expected.extra.oldpanel, "oldpanel is correct"); + is(extra.newpanel, expected.extra.newpanel, "newpanel is correct"); + } +} diff --git a/devtools/client/shared/test/browser_telemetry_toolbox.js b/devtools/client/shared/test/browser_telemetry_toolbox.js new file mode 100644 index 0000000000..1150a7f280 --- /dev/null +++ b/devtools/client/shared/test/browser_telemetry_toolbox.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URI = + "data:text/html;charset=utf-8," + "<p>browser_telemetry_toolbox.js</p>"; + +// Because we need to gather stats for the period of time that a tool has been +// opened we make use of setTimeout() to create tool active times. +const TOOL_DELAY = 200; + +add_task(async function () { + await addTab(TEST_URI); + startTelemetry(); + + await openAndCloseToolbox(3, TOOL_DELAY, "inspector"); + checkResults(); + + gBrowser.removeCurrentTab(); +}); + +function checkResults() { + // For help generating these tests use generateTelemetryTests("DEVTOOLS_TOOLBOX_") + // here. + checkTelemetry("DEVTOOLS_TOOLBOX_OPENED_COUNT", "", { 0: 3, 1: 0 }, "array"); + checkTelemetry( + "DEVTOOLS_TOOLBOX_TIME_ACTIVE_SECONDS", + "", + null, + "hasentries" + ); + checkTelemetry("DEVTOOLS_TOOLBOX_HOST", "", null, "hasentries"); +} diff --git a/devtools/client/shared/test/browser_telemetry_toolboxtabs_inspector.js b/devtools/client/shared/test/browser_telemetry_toolboxtabs_inspector.js new file mode 100644 index 0000000000..0e756aaf9c --- /dev/null +++ b/devtools/client/shared/test/browser_telemetry_toolboxtabs_inspector.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URI = + "data:text/html;charset=utf-8," + + "<p>browser_telemetry_toolboxtabs_inspector.js</p>"; + +// Because we need to gather stats for the period of time that a tool has been +// opened we make use of setTimeout() to create tool active times. +const TOOL_DELAY = 200; + +add_task(async function () { + await addTab(TEST_URI); + startTelemetry(); + + await openAndCloseToolbox(2, TOOL_DELAY, "inspector"); + checkResults(); + + gBrowser.removeCurrentTab(); +}); + +function checkResults() { + // For help generating these tests use generateTelemetryTests("DEVTOOLS_INSPECTOR_") + // here. + checkTelemetry( + "DEVTOOLS_INSPECTOR_OPENED_COUNT", + "", + { 0: 2, 1: 0 }, + "array" + ); + checkTelemetry( + "DEVTOOLS_INSPECTOR_TIME_ACTIVE_SECONDS", + "", + null, + "hasentries" + ); +} diff --git a/devtools/client/shared/test/browser_telemetry_toolboxtabs_jsdebugger.js b/devtools/client/shared/test/browser_telemetry_toolboxtabs_jsdebugger.js new file mode 100644 index 0000000000..976af4a00a --- /dev/null +++ b/devtools/client/shared/test/browser_telemetry_toolboxtabs_jsdebugger.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URI = + "data:text/html;charset=utf-8," + + "<p>browser_telemetry_toolboxtabs_jsdebugger.js</p>"; + +// Because we need to gather stats for the period of time that a tool has been +// opened we make use of setTimeout() to create tool active times. +const TOOL_DELAY = 200; + +add_task(async function () { + await addTab(TEST_URI); + startTelemetry(); + + await openAndCloseToolbox(2, TOOL_DELAY, "jsdebugger"); + checkResults(); + + gBrowser.removeCurrentTab(); +}); + +function checkResults() { + // For help generating these tests use generateTelemetryTests("DEVTOOLS_JSDEBUGGER_") + // here. + checkTelemetry( + "DEVTOOLS_JSDEBUGGER_OPENED_COUNT", + "", + { 0: 2, 1: 0 }, + "array" + ); + checkTelemetry( + "DEVTOOLS_JSDEBUGGER_TIME_ACTIVE_SECONDS", + "", + null, + "hasentries" + ); +} diff --git a/devtools/client/shared/test/browser_telemetry_toolboxtabs_jsprofiler.js b/devtools/client/shared/test/browser_telemetry_toolboxtabs_jsprofiler.js new file mode 100644 index 0000000000..3cb3da0f26 --- /dev/null +++ b/devtools/client/shared/test/browser_telemetry_toolboxtabs_jsprofiler.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URI = + "data:text/html;charset=utf-8," + + "<p>browser_telemetry_toolboxtabs_jsprofiler.js</p>"; + +// Because we need to gather stats for the period of time that a tool has been +// opened we make use of setTimeout() to create tool active times. +const TOOL_DELAY = 200; + +add_task(async function () { + await addTab(TEST_URI); + startTelemetry(); + + await openAndCloseToolbox(2, TOOL_DELAY, "performance"); + checkResults(); + + gBrowser.removeCurrentTab(); +}); + +function checkResults() { + // For help generating these tests use generateTelemetryTests("DEVTOOLS_JSPROFILER") + // here. + checkTelemetry( + "DEVTOOLS_JSPROFILER_OPENED_COUNT", + "", + { 0: 2, 1: 0 }, + "array" + ); + checkTelemetry( + "DEVTOOLS_JSPROFILER_TIME_ACTIVE_SECONDS", + "", + null, + "hasentries" + ); +} diff --git a/devtools/client/shared/test/browser_telemetry_toolboxtabs_netmonitor.js b/devtools/client/shared/test/browser_telemetry_toolboxtabs_netmonitor.js new file mode 100644 index 0000000000..26a1fb89ba --- /dev/null +++ b/devtools/client/shared/test/browser_telemetry_toolboxtabs_netmonitor.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URI = + "data:text/html;charset=utf-8," + + "<p>browser_telemetry_toolboxtabs_netmonitor.js</p>"; + +// Because we need to gather stats for the period of time that a tool has been +// opened we make use of setTimeout() to create tool active times. +const TOOL_DELAY = 200; + +add_task(async function () { + await addTab(TEST_URI); + startTelemetry(); + + await openAndCloseToolbox(2, TOOL_DELAY, "netmonitor"); + checkResults(); + + gBrowser.removeCurrentTab(); +}); + +function checkResults() { + // For help generating these tests use generateTelemetryTests("DEVTOOLS_NETMONITOR_") + // here. + checkTelemetry( + "DEVTOOLS_NETMONITOR_OPENED_COUNT", + "", + { 0: 2, 1: 0 }, + "array" + ); + checkTelemetry( + "DEVTOOLS_NETMONITOR_TIME_ACTIVE_SECONDS", + "", + null, + "hasentries" + ); +} diff --git a/devtools/client/shared/test/browser_telemetry_toolboxtabs_options.js b/devtools/client/shared/test/browser_telemetry_toolboxtabs_options.js new file mode 100644 index 0000000000..08ba39d0cf --- /dev/null +++ b/devtools/client/shared/test/browser_telemetry_toolboxtabs_options.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URI = + "data:text/html;charset=utf-8," + + "<p>browser_telemetry_toolboxtabs_options.js</p>"; + +// Because we need to gather stats for the period of time that a tool has been +// opened we make use of setTimeout() to create tool active times. +const TOOL_DELAY = 200; + +add_task(async function () { + await addTab(TEST_URI); + startTelemetry(); + + await openAndCloseToolbox(2, TOOL_DELAY, "options"); + checkResults(); + + gBrowser.removeCurrentTab(); +}); + +function checkResults() { + // For help generating these tests use generateTelemetryTests("DEVTOOLS_OPTIONS_") + // here. + checkTelemetry("DEVTOOLS_OPTIONS_OPENED_COUNT", "", { 0: 2, 1: 0 }, "array"); + checkTelemetry( + "DEVTOOLS_OPTIONS_TIME_ACTIVE_SECONDS", + "", + null, + "hasentries" + ); +} diff --git a/devtools/client/shared/test/browser_telemetry_toolboxtabs_storage.js b/devtools/client/shared/test/browser_telemetry_toolboxtabs_storage.js new file mode 100644 index 0000000000..788e38643c --- /dev/null +++ b/devtools/client/shared/test/browser_telemetry_toolboxtabs_storage.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URI = + "data:text/html;charset=utf-8," + + "<p>browser_telemetry_toolboxtabs_storage.js</p>"; + +// Because we need to gather stats for the period of time that a tool has been +// opened we make use of setTimeout() to create tool active times. +const TOOL_DELAY = 1000; + +add_task(async function () { + await addTab(TEST_URI); + startTelemetry(); + + await openAndCloseToolbox(2, TOOL_DELAY, "storage"); + checkResults(); + + gBrowser.removeCurrentTab(); +}); + +function checkResults() { + // For help generating these tests use generateTelemetryTests("DEVTOOLS_STORAGE_") + // here. + checkTelemetry("DEVTOOLS_STORAGE_OPENED_COUNT", "", { 0: 2, 1: 0 }, "array"); + checkTelemetry( + "DEVTOOLS_STORAGE_TIME_ACTIVE_SECONDS", + "", + null, + "hasentries" + ); +} diff --git a/devtools/client/shared/test/browser_telemetry_toolboxtabs_styleeditor.js b/devtools/client/shared/test/browser_telemetry_toolboxtabs_styleeditor.js new file mode 100644 index 0000000000..0a1b6ed5f4 --- /dev/null +++ b/devtools/client/shared/test/browser_telemetry_toolboxtabs_styleeditor.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URI = + "data:text/html;charset=utf-8," + + "<p>browser_telemetry_toolboxtabs_styleeditor.js</p>"; + +// Because we need to gather stats for the period of time that a tool has been +// opened we make use of setTimeout() to create tool active times. +const TOOL_DELAY = 200; + +add_task(async function () { + await addTab(TEST_URI); + startTelemetry(); + + await openAndCloseToolbox(2, TOOL_DELAY, "styleeditor"); + checkResults(); + + gBrowser.removeCurrentTab(); +}); + +function checkResults() { + // For help generating these tests use generateTelemetryTests("DEVTOOLS_STYLEEDITOR_") + // here. + checkTelemetry( + "DEVTOOLS_STYLEEDITOR_OPENED_COUNT", + "", + { 0: 2, 1: 0 }, + "array" + ); + checkTelemetry( + "DEVTOOLS_STYLEEDITOR_TIME_ACTIVE_SECONDS", + "", + null, + "hasentries" + ); +} diff --git a/devtools/client/shared/test/browser_telemetry_toolboxtabs_webconsole.js b/devtools/client/shared/test/browser_telemetry_toolboxtabs_webconsole.js new file mode 100644 index 0000000000..d5f52ed91c --- /dev/null +++ b/devtools/client/shared/test/browser_telemetry_toolboxtabs_webconsole.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URI = + "data:text/html;charset=utf-8," + + "<p>browser_telemetry_toolboxtabs_styleeditor_webconsole.js</p>"; + +// Because we need to gather stats for the period of time that a tool has been +// opened we make use of setTimeout() to create tool active times. +const TOOL_DELAY = 200; + +add_task(async function () { + await addTab(TEST_URI); + startTelemetry(); + + await openAndCloseToolbox(2, TOOL_DELAY, "webconsole"); + checkResults(); + + gBrowser.removeCurrentTab(); +}); + +function checkResults() { + // For help generating these tests use generateTelemetryTests("DEVTOOLS_WEBCONSOLE_") + // here. + checkTelemetry( + "DEVTOOLS_WEBCONSOLE_OPENED_COUNT", + "", + { 0: 2, 1: 0 }, + "array" + ); + checkTelemetry( + "DEVTOOLS_WEBCONSOLE_TIME_ACTIVE_SECONDS", + "", + null, + "hasentries" + ); +} diff --git a/devtools/client/shared/test/browser_theme.js b/devtools/client/shared/test/browser_theme.js new file mode 100644 index 0000000000..b70bafafa1 --- /dev/null +++ b/devtools/client/shared/test/browser_theme.js @@ -0,0 +1,145 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that theme utilities work + +const { + getColor, + getTheme, + setTheme, +} = require("resource://devtools/client/shared/theme.js"); +const { PrefObserver } = require("resource://devtools/client/shared/prefs.js"); + +add_task(async function () { + testGetTheme(); + testSetTheme(); + testGetColor(); + testColorExistence(); +}); + +function testGetTheme() { + const originalTheme = getTheme(); + ok(originalTheme, "has some theme to start with."); + Services.prefs.setCharPref("devtools.theme", "light"); + is(getTheme(), "light", "getTheme() correctly returns light theme"); + Services.prefs.setCharPref("devtools.theme", "dark"); + is(getTheme(), "dark", "getTheme() correctly returns dark theme"); + Services.prefs.setCharPref("devtools.theme", "unknown"); + is(getTheme(), "unknown", "getTheme() correctly returns an unknown theme"); + Services.prefs.setCharPref("devtools.theme", originalTheme); +} + +function testSetTheme() { + const originalTheme = getTheme(); + // Put this in a variable rather than hardcoding it because the default + // changes between aurora and nightly + const otherTheme = originalTheme == "dark" ? "light" : "dark"; + + const prefObserver = new PrefObserver("devtools."); + prefObserver.once("devtools.theme", () => { + const newValue = Services.prefs.getCharPref("devtools.theme"); + is( + newValue, + otherTheme, + "A preference event triggered by setTheme comes after the value is set." + ); + }); + setTheme(otherTheme); + is( + Services.prefs.getCharPref("devtools.theme"), + otherTheme, + "setTheme() correctly sets another theme." + ); + setTheme(originalTheme); + is( + Services.prefs.getCharPref("devtools.theme"), + originalTheme, + "setTheme() correctly sets the original theme." + ); + setTheme("unknown"); + is( + Services.prefs.getCharPref("devtools.theme"), + "unknown", + "setTheme() correctly sets an unknown theme." + ); + Services.prefs.setCharPref("devtools.theme", originalTheme); + + prefObserver.destroy(); +} + +function testGetColor() { + const BLUE_DARK = "#75bfff"; + const BLUE_LIGHT = "#0074e8"; + const originalTheme = getTheme(); + + setTheme("dark"); + is( + getColor("highlight-blue"), + BLUE_DARK, + "correctly gets color for enabled theme." + ); + setTheme("light"); + is( + getColor("highlight-blue"), + BLUE_LIGHT, + "correctly gets color for enabled theme." + ); + setTheme("metal"); + is( + getColor("highlight-blue"), + BLUE_LIGHT, + "correctly uses light for default theme if enabled theme not found" + ); + + is( + getColor("highlight-blue", "dark"), + BLUE_DARK, + "if provided and found, uses the provided theme." + ); + is( + getColor("highlight-blue", "metal"), + BLUE_LIGHT, + "if provided and not found, defaults to light theme." + ); + is( + getColor("somecomponents"), + null, + "if a type cannot be found, should return null." + ); + + setTheme(originalTheme); +} + +function testColorExistence() { + const vars = [ + "body-background", + "sidebar-background", + "contrast-background", + "tab-toolbar-background", + "toolbar-background", + "selection-background", + "selection-color", + "selection-background-hover", + "splitter-color", + "comment", + "body-color", + "text-color-alt", + "text-color-inactive", + "text-color-strong", + "highlight-green", + "highlight-blue", + "highlight-bluegrey", + "highlight-purple", + "highlight-lightorange", + "highlight-orange", + "highlight-red", + "highlight-pink", + ]; + + for (const type of vars) { + ok(getColor(type, "light"), `${type} is a valid color in light theme`); + ok(getColor(type, "dark"), `${type} is a valid color in dark theme`); + } +} diff --git a/devtools/client/shared/test/browser_theme_switching.js b/devtools/client/shared/test/browser_theme_switching.js new file mode 100644 index 0000000000..642938e9af --- /dev/null +++ b/devtools/client/shared/test/browser_theme_switching.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + await pushPref("devtools.theme", "light"); + + // For some reason, mochitest spawn a very special default tab, + // whose WindowGlobal is still the initial about:blank document. + // This seems to be specific to mochitest, this doesn't reproduce + // in regular firefox run. Even having about:blank as home page, + // force loading another final about:blank document (which isn't the initial one) + // + // To workaround this, force opening a dedicated test tab + const tab = await addTab("data:text/html;charset=utf-8,Test page"); + + const toolbox = await gDevTools.showToolboxForTab(tab); + const doc = toolbox.doc; + const root = doc.documentElement; + + const platform = root.getAttribute("platform"); + const expectedPlatform = getPlatform(); + is(platform, expectedPlatform, ":root[platform] is correct"); + + const theme = Services.prefs.getCharPref("devtools.theme"); + const className = "theme-" + theme; + ok( + root.classList.contains(className), + ":root has " + className + " class (current theme)" + ); + + const sheetsInDOM = Array.from( + doc.querySelectorAll("link[rel='stylesheet']"), + l => l.href + ); + + const sheetsFromTheme = gDevTools.getThemeDefinition(theme).stylesheets; + info("Checking for existence of " + sheetsInDOM.length + " sheets"); + for (const themeSheet of sheetsFromTheme) { + ok( + sheetsInDOM.some(s => s.includes(themeSheet)), + "There is a stylesheet for " + themeSheet + ); + } + + await toolbox.destroy(); +}); + +function getPlatform() { + const { OS } = Services.appinfo; + if (OS == "WINNT") { + return "win"; + } else if (OS == "Darwin") { + return "mac"; + } + return "linux"; +} diff --git a/devtools/client/shared/test/browser_treeWidget_basic.js b/devtools/client/shared/test/browser_treeWidget_basic.js new file mode 100644 index 0000000000..48702b9b8d --- /dev/null +++ b/devtools/client/shared/test/browser_treeWidget_basic.js @@ -0,0 +1,391 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that the tree widget api works fine +"use strict"; + +const TEST_URI = + "data:text/html;charset=utf-8,<head>" + + "<link rel='stylesheet' type='text/css' href='chrome://devtools/skin/widg" + + "ets.css'></head><body><div></div><span></span></body>"; +const { + TreeWidget, +} = require("resource://devtools/client/shared/widgets/TreeWidget.js"); + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["security.allow_unsafe_parent_loads", true]], + }); + await addTab("about:blank"); + const { host, doc } = await createHost("bottom", TEST_URI); + + const tree = new TreeWidget(doc.querySelector("div"), { + defaultType: "store", + }); + + populateTree(tree, doc); + testTreeItemInsertedCorrectly(tree, doc); + testAPI(tree, doc); + populateUnsortedTree(tree, doc); + testUnsortedTreeItemInsertedCorrectly(tree, doc); + + tree.destroy(); + host.destroy(); + gBrowser.removeCurrentTab(); +}); + +function populateTree(tree, doc) { + tree.add([ + { + id: "level1", + label: "Level 1", + }, + { + id: "level2-1", + label: "Level 2", + }, + { + id: "level3-1", + label: "Level 3 - Child 1", + type: "dir", + }, + ]); + tree.add([ + "level1", + "level2-1", + { + id: "level3-2", + label: "Level 3 - Child 2", + }, + ]); + tree.add([ + "level1", + "level2-1", + { + id: "level3-3", + label: "Level 3 - Child 3", + }, + ]); + tree.add([ + "level1", + { + id: "level2-2", + label: "Level 2.1", + }, + { + id: "level3-1", + label: "Level 3.1", + }, + ]); + tree.add([ + { + id: "level1", + label: "Level 1", + }, + { + id: "level2", + label: "Level 2", + }, + { + id: "level3", + label: "Level 3", + type: "js", + }, + ]); + tree.add(["level1.1", "level2", { id: "level3", type: "url" }]); +} + +/** + * Test if the nodes are inserted correctly in the tree. + */ +function testTreeItemInsertedCorrectly(tree, doc) { + is( + tree.root.children.children.length, + 2, + "Number of top level elements match" + ); + is( + tree.root.children.firstChild.lastChild.children.length, + 3, + "Number of first second level elements match" + ); + is( + tree.root.children.lastChild.lastChild.children.length, + 1, + "Number of second second level elements match" + ); + + ok(tree.root.items.has("level1"), "Level1 top level element exists"); + is( + tree.root.children.firstChild.dataset.id, + JSON.stringify(["level1"]), + "Data id of first top level element matches" + ); + is( + tree.root.children.firstChild.firstChild.textContent, + "Level 1", + "Text content of first top level element matches" + ); + + ok(tree.root.items.has("level1.1"), "Level1.1 top level element exists"); + is( + tree.root.children.firstChild.nextSibling.dataset.id, + JSON.stringify(["level1.1"]), + "Data id of second top level element matches" + ); + is( + tree.root.children.firstChild.nextSibling.firstChild.textContent, + "level1.1", + "Text content of second top level element matches" + ); + + // Adding a new non text item in the tree. + const node = doc.createElement("div"); + node.textContent = "Foo Bar"; + node.className = "foo bar"; + tree.add([ + { + id: "level1.2", + node, + attachment: { + foo: "bar", + }, + }, + ]); + + is( + tree.root.children.children.length, + 3, + "Number of top level elements match after update" + ); + ok(tree.root.items.has("level1.2"), "New level node got added"); + ok( + tree.attachments.has(JSON.stringify(["level1.2"])), + "Attachment is present for newly added node" + ); + // The item should be added before level1 and level 1.1 as lexical sorting + is( + tree.root.children.firstChild.dataset.id, + JSON.stringify(["level1.2"]), + "Data id of last top level element matches" + ); + is( + tree.root.children.firstChild.firstChild.firstChild, + node, + "Newly added node is inserted at the right location" + ); +} + +/** + * Populate the unsorted tree. + */ +function populateUnsortedTree(tree, doc) { + tree.sorted = false; + + tree.add([{ id: "g-1", label: "g-1" }]); + tree.add(["g-1", { id: "d-2", label: "d-2.1" }]); + tree.add(["g-1", { id: "b-2", label: "b-2.2" }]); + tree.add(["g-1", { id: "a-2", label: "a-2.3" }]); +} + +/** + * Test if the nodes are inserted correctly in the unsorted tree. + */ +function testUnsortedTreeItemInsertedCorrectly(tree, doc) { + ok(tree.root.items.has("g-1"), "g-1 top level element exists"); + + is( + tree.root.children.firstChild.lastChild.children.length, + 3, + "Number of children for g-1 matches" + ); + is( + tree.root.children.firstChild.dataset.id, + JSON.stringify(["g-1"]), + "Data id of g-1 matches" + ); + is( + tree.root.children.firstChild.firstChild.textContent, + "g-1", + "Text content of g-1 matches" + ); + is( + tree.root.children.firstChild.lastChild.firstChild.dataset.id, + JSON.stringify(["g-1", "d-2"]), + "Data id of d-2 matches" + ); + is( + tree.root.children.firstChild.lastChild.firstChild.textContent, + "d-2.1", + "Text content of d-2 matches" + ); + is( + tree.root.children.firstChild.lastChild.firstChild.nextSibling.textContent, + "b-2.2", + "Text content of b-2 matches" + ); + is( + tree.root.children.firstChild.lastChild.lastChild.textContent, + "a-2.3", + "Text content of a-2 matches" + ); +} + +/** + * Tests if the API exposed by TreeWidget works properly + */ +function testAPI(tree, doc) { + info("Testing TreeWidget API"); + // Check if selectItem and selectedItem setter works as expected + // Nothing should be selected beforehand + ok(!doc.querySelector(".theme-selected"), "Nothing is selected"); + tree.selectItem(["level1"]); + const node = doc.querySelector(".theme-selected"); + ok(!!node, "Something got selected"); + is( + node.parentNode.dataset.id, + JSON.stringify(["level1"]), + "Correct node selected" + ); + + tree.selectItem(["level1", "level2"]); + const node2 = doc.querySelector(".theme-selected"); + ok(!!node2, "Something is still selected"); + isnot(node, node2, "Newly selected node is different from previous"); + is( + node2.parentNode.dataset.id, + JSON.stringify(["level1", "level2"]), + "Correct node selected" + ); + + // test if selectedItem getter works + is(tree.selectedItem.length, 2, "Correct length of selected item"); + is(tree.selectedItem[0], "level1", "Correct selected item"); + is(tree.selectedItem[1], "level2", "Correct selected item"); + + // test if isSelected works + ok(tree.isSelected(["level1", "level2"]), "isSelected works"); + + tree.selectedItem = ["level1"]; + const node3 = doc.querySelector(".theme-selected"); + ok(!!node3, "Something is still selected"); + isnot(node2, node3, "Newly selected node is different from previous"); + is(node3, node, "First and third selected nodes should be same"); + is( + node3.parentNode.dataset.id, + JSON.stringify(["level1"]), + "Correct node selected" + ); + + // test if selectedItem getter works + is(tree.selectedItem.length, 1, "Correct length of selected item"); + is(tree.selectedItem[0], "level1", "Correct selected item"); + + // test if clear selection works + tree.clearSelection(); + ok( + !doc.querySelector(".theme-selected"), + "Nothing selected after clear selection call" + ); + + // test if collapseAll/expandAll work + ok(!!doc.querySelectorAll("[expanded]").length, "Some nodes are expanded"); + tree.collapseAll(); + is( + doc.querySelectorAll("[expanded]").length, + 0, + "Nothing is expanded after collapseAll call" + ); + tree.expandAll(); + is( + doc.querySelectorAll("[expanded]").length, + 13, + "All tree items expanded after expandAll call" + ); + + // test if selectNextItem and selectPreviousItem work + tree.selectedItem = ["level1", "level2"]; + ok(tree.isSelected(["level1", "level2"]), "Correct item selected"); + tree.selectNextItem(); + ok( + tree.isSelected(["level1", "level2", "level3"]), + "Correct item selected after selectNextItem call" + ); + + tree.selectNextItem(); + ok( + tree.isSelected(["level1", "level2-1"]), + "Correct item selected after second selectNextItem call" + ); + + tree.selectNextItem(); + ok( + tree.isSelected(["level1", "level2-1", "level3-1"]), + "Correct item selected after third selectNextItem call" + ); + + tree.selectPreviousItem(); + ok( + tree.isSelected(["level1", "level2-1"]), + "Correct item selected after selectPreviousItem call" + ); + + tree.selectPreviousItem(); + ok( + tree.isSelected(["level1", "level2", "level3"]), + "Correct item selected after second selectPreviousItem call" + ); + + // test if remove works + ok( + doc.querySelector( + "[data-id='" + JSON.stringify(["level1", "level2", "level3"]) + "']" + ), + "level1-level2-level3 item exists before removing" + ); + tree.remove(["level1", "level2", "level3"]); + ok( + !doc.querySelector( + "[data-id='" + JSON.stringify(["level1", "level2", "level3"]) + "']" + ), + "level1-level2-level3 item does not exist after removing" + ); + const level2item = doc.querySelector( + "[data-id='" + + JSON.stringify(["level1", "level2"]) + + "'] > .tree-widget-item" + ); + ok( + level2item.hasAttribute("empty"), + "level1-level2 item is marked as empty after removing" + ); + + tree.add([ + { + id: "level1", + label: "Level 1", + }, + { + id: "level2", + label: "Level 2", + }, + { + id: "level3", + label: "Level 3", + type: "js", + }, + ]); + + // test if clearing the tree works + is( + doc.querySelectorAll("[level='1']").length, + 3, + "Correct number of top level items before clearing" + ); + tree.clear(); + is( + doc.querySelectorAll("[level='1']").length, + 0, + "No top level item after clearing the tree" + ); +} diff --git a/devtools/client/shared/test/browser_treeWidget_keyboard_interaction.js b/devtools/client/shared/test/browser_treeWidget_keyboard_interaction.js new file mode 100644 index 0000000000..05f1f95b4e --- /dev/null +++ b/devtools/client/shared/test/browser_treeWidget_keyboard_interaction.js @@ -0,0 +1,291 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that keyboard interaction works fine with the tree widget + +const TEST_URI = + "data:text/html;charset=utf-8,<head>" + + "<link rel='stylesheet' type='text/css' href='chrome://devtools/skin/widg" + + "ets.css'></head><body><div></div><span></span></body>"; +const { + TreeWidget, +} = require("resource://devtools/client/shared/widgets/TreeWidget.js"); + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["security.allow_unsafe_parent_loads", true]], + }); + + await addTab("about:blank"); + const { host, win, 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 tree = new TreeWidget(doc.querySelector("div"), { + defaultType: "store", + }); + + populateTree(tree, doc); + + await testKeyboardInteraction(tree, win); + + tree.destroy(); + host.destroy(); + gBrowser.removeCurrentTab(); +}); + +function populateTree(tree, doc) { + tree.add([ + { + id: "level1", + label: "Level 1", + }, + { + id: "level2-1", + label: "Level 2", + }, + { + id: "level3-1", + label: "Level 3 - Child 1", + type: "dir", + }, + ]); + tree.add([ + "level1", + "level2-1", + { id: "level3-2", label: "Level 3 - Child 2" }, + ]); + tree.add([ + "level1", + "level2-1", + { id: "level3-3", label: "Level 3 - Child 3" }, + ]); + tree.add([ + "level1", + { + id: "level2-2", + label: "Level 2.1", + }, + { + id: "level3-1", + label: "Level 3.1", + }, + ]); + tree.add([ + { + id: "level1", + label: "Level 1", + }, + { + id: "level2", + label: "Level 2", + }, + { + id: "level3", + label: "Level 3", + type: "js", + }, + ]); + tree.add(["level1.1", "level2", { id: "level3", type: "url" }]); + + // Adding a new non text item in the tree. + const node = doc.createElement("div"); + node.textContent = "Foo Bar"; + node.className = "foo bar"; + tree.add([ + { + id: "level1.2", + node, + attachment: { + foo: "bar", + }, + }, + ]); +} + +// Sends a click event on the passed DOM node in an async manner +function click(node) { + const win = node.ownerDocument.defaultView; + executeSoon(() => EventUtils.synthesizeMouseAtCenter(node, {}, win)); +} + +/** + * Tests if pressing navigation keys on the tree items does the expected behavior + */ +async function testKeyboardInteraction(tree, win) { + info("Testing keyboard interaction with the tree"); + const waitForSelect = () => + new Promise(resolve => { + tree.once("select", (d, a) => resolve({ data: d, attachment: a })); + }); + + info("clicking on first top level item"); + let node = tree.root.children.firstChild.firstChild; + + // The select event handler will be called before the click event hasn't + // fully finished, so wait for both of them. + const clicked = once(node, "click"); + let onTreeSelect = waitForSelect(); + click(node); + + info("Wait for the click event"); + await clicked; + + info("Wait for the select event on tree"); + await onTreeSelect; + + node = tree.root.children.firstChild.nextSibling.firstChild; + // node should not have selected class + ok( + !node.classList.contains("theme-selected"), + "Node should not have selected class" + ); + ok(!node.hasAttribute("expanded"), "Node is not expanded"); + + info("Pressing down key to select next item"); + onTreeSelect = waitForSelect(); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + + let { data, attachment } = await onTreeSelect; + is(data.length, 1, "Correct level item was selected after keydown"); + is(data[0], "level1", "Correct item was selected after pressing down"); + ok(!attachment, "null attachment was emitted"); + ok(node.classList.contains("theme-selected"), "Node has selected class"); + ok(node.hasAttribute("expanded"), "Node is expanded now"); + + info("Pressing down key again to select next item"); + onTreeSelect = waitForSelect(); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + ({ data, attachment } = await onTreeSelect); + is( + data.length, + 2, + "Correct level item was selected after second down keypress" + ); + is(data[0], "level1", "Correct parent level"); + is(data[1], "level2", "Correct second level"); + + info("Pressing down key again to select next item"); + onTreeSelect = waitForSelect(); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + ({ data, attachment } = await onTreeSelect); + is( + data.length, + 3, + "Correct level item was selected after third down keypress" + ); + is(data[0], "level1", "Correct parent level"); + is(data[1], "level2", "Correct second level"); + is(data[2], "level3", "Correct third level"); + + info("Pressing down key again to select next item"); + onTreeSelect = waitForSelect(); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + ({ data, attachment } = await onTreeSelect); + is( + data.length, + 2, + "Correct level item was selected after fourth down keypress" + ); + is(data[0], "level1", "Correct parent level"); + is(data[1], "level2-1", "Correct second level"); + + const waitForKeydown = () => + new Promise(resolve => { + tree.root.children.addEventListener( + "keydown", + () => { + // executeSoon so that other listeners on the same method are executed first + executeSoon(() => resolve()); + }, + { once: true } + ); + }); + + // pressing left to check expand collapse feature. + // This does not emit any event, so listening for keypress + let onTreeKeydown = waitForKeydown(); + info("Pressing left key to collapse the item"); + node = tree._selectedLabel; + ok(node.hasAttribute("expanded"), "Item is expanded before left keypress"); + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + await onTreeKeydown; + + ok( + !node.hasAttribute("expanded"), + "Item is not expanded after left keypress" + ); + + // pressing left on collapsed item should select the previous item + + info("Pressing left key on collapsed item to select previous"); + onTreeSelect = waitForSelect(); + // parent node should have no effect of this keypress + node = tree.root.children.firstChild.nextSibling.firstChild; + ok(node.hasAttribute("expanded"), "Parent is expanded"); + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + ({ data } = await onTreeSelect); + is( + data.length, + 3, + "Correct level item was selected after second left keypress" + ); + is(data[0], "level1", "Correct parent level"); + is(data[1], "level2", "Correct second level"); + is(data[2], "level3", "Correct third level"); + ok( + node.hasAttribute("expanded"), + "Parent is still expanded after left keypress" + ); + + // pressing down again + + info("Pressing down key to select next item"); + onTreeSelect = waitForSelect(); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + ({ data, attachment } = await onTreeSelect); + is( + data.length, + 2, + "Correct level item was selected after fifth down keypress" + ); + is(data[0], "level1", "Correct parent level"); + is(data[1], "level2-1", "Correct second level"); + + // collapsing the item to check expand feature. + onTreeKeydown = waitForKeydown(); + info("Pressing left key to collapse the item"); + node = tree._selectedLabel; + ok(node.hasAttribute("expanded"), "Item is expanded before left keypress"); + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + await onTreeKeydown; + ok(!node.hasAttribute("expanded"), "Item is collapsed after left keypress"); + + // pressing right should expand this now. + onTreeKeydown = waitForKeydown(); + info("Pressing right key to expend the collapsed item"); + node = tree._selectedLabel; + ok(!node.hasAttribute("expanded"), "Item is collapsed before right keypress"); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + await onTreeKeydown; + ok(node.hasAttribute("expanded"), "Item is expanded after right keypress"); + + // selecting last item node to test edge navigation case + + tree.selectedItem = ["level1.1", "level2", "level3"]; + node = tree._selectedLabel; + // pressing down again should not change selection + onTreeKeydown = waitForKeydown(); + info("Pressing down key on last item of the tree"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + await onTreeKeydown; + + ok( + tree.isSelected(["level1.1", "level2", "level3"]), + "Last item is still selected after pressing down on last item of the tree" + ); +} diff --git a/devtools/client/shared/test/browser_treeWidget_mouse_interaction.js b/devtools/client/shared/test/browser_treeWidget_mouse_interaction.js new file mode 100644 index 0000000000..d031845f6f --- /dev/null +++ b/devtools/client/shared/test/browser_treeWidget_mouse_interaction.js @@ -0,0 +1,185 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that mouse interaction works fine with tree widget + +const TEST_URI = + "data:text/html;charset=utf-8,<head>" + + "<link rel='stylesheet' type='text/css' href='chrome://devtools/skin/widg" + + "ets.css'></head><body><div></div><span></span></body>"; +const { + TreeWidget, +} = require("resource://devtools/client/shared/widgets/TreeWidget.js"); + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["security.allow_unsafe_parent_loads", true]], + }); + + await addTab("about:blank"); + const { host, 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 tree = new TreeWidget(doc.querySelector("div"), { + defaultType: "store", + }); + + populateTree(tree, doc); + await testMouseInteraction(tree); + + tree.destroy(); + host.destroy(); + gBrowser.removeCurrentTab(); +}); + +function populateTree(tree, doc) { + tree.add([ + { + id: "level1", + label: "Level 1", + }, + { + id: "level2-1", + label: "Level 2", + }, + { + id: "level3-1", + label: "Level 3 - Child 1", + type: "dir", + }, + ]); + tree.add([ + "level1", + "level2-1", + { id: "level3-2", label: "Level 3 - Child 2" }, + ]); + tree.add([ + "level1", + "level2-1", + { id: "level3-3", label: "Level 3 - Child 3" }, + ]); + tree.add([ + "level1", + { + id: "level2-2", + label: "Level 2.1", + }, + { + id: "level3-1", + label: "Level 3.1", + }, + ]); + tree.add([ + { + id: "level1", + label: "Level 1", + }, + { + id: "level2", + label: "Level 2", + }, + { + id: "level3", + label: "Level 3", + type: "js", + }, + ]); + tree.add(["level1.1", "level2", { id: "level3", type: "url" }]); + + // Adding a new non text item in the tree. + const node = doc.createElement("div"); + node.textContent = "Foo Bar"; + node.className = "foo bar"; + tree.add([ + { + id: "level1.2", + node, + attachment: { + foo: "bar", + }, + }, + ]); +} + +// Sends a click event on the passed DOM node in an async manner +function click(node) { + const win = node.ownerDocument.defaultView; + executeSoon(() => EventUtils.synthesizeMouseAtCenter(node, {}, win)); +} + +/** + * Tests if clicking the tree items does the expected behavior + */ +async function testMouseInteraction(tree) { + info("Testing mouse interaction with the tree"); + const waitForSelect = () => + new Promise(resolve => { + tree.once("select", (d, a) => resolve({ data: d, attachment: a })); + }); + + ok(!tree.selectedItem, "Nothing should be selected beforehand"); + + let onTreeSelect = waitForSelect(); + const node = tree.root.children.firstChild.firstChild; + info("clicking on first top level item"); + ok( + !node.classList.contains("theme-selected"), + "Node should not have selected class before clicking" + ); + click(node); + let { data, attachment } = await onTreeSelect; + ok( + node.classList.contains("theme-selected"), + "Node has selected class after click" + ); + is(data[0], "level1.2", "Correct tree path is emitted"); + ok(attachment && attachment.foo, "Correct attachment is emitted"); + is(attachment.foo, "bar", "Correct attachment value is emitted"); + + info("clicking second top level item with children to check if it expands"); + const node2 = tree.root.children.firstChild.nextSibling.firstChild; + // node should not have selected class + ok( + !node2.classList.contains("theme-selected"), + "New node should not have selected class before clicking" + ); + ok( + !node2.hasAttribute("expanded"), + "New node is not expanded before clicking" + ); + onTreeSelect = waitForSelect(); + click(node2); + ({ data, attachment } = await onTreeSelect); + ok( + node2.classList.contains("theme-selected"), + "New node has selected class after clicking" + ); + is(data[0], "level1", "Correct tree path is emitted for new node"); + ok(!attachment, "null attachment should be emitted for new node"); + ok(node2.hasAttribute("expanded"), "New node expanded after click"); + + ok( + !node.classList.contains("theme-selected"), + "Old node should not have selected class after the click on new node" + ); + + // clicking again should just collapse + // this will not emit "select" event + const onClick = new Promise(resolve => { + node2.addEventListener( + "click", + () => { + executeSoon(() => resolve(null)); + }, + { once: true } + ); + }); + click(node2); + await onClick; + ok(!node2.hasAttribute("expanded"), "New node collapsed after click again"); +} diff --git a/devtools/client/shared/test/code_WorkerTargetActor.attachThread-worker.js b/devtools/client/shared/test/code_WorkerTargetActor.attachThread-worker.js new file mode 100644 index 0000000000..ca12b86a59 --- /dev/null +++ b/devtools/client/shared/test/code_WorkerTargetActor.attachThread-worker.js @@ -0,0 +1,18 @@ +"use strict"; + +function f() { + const a = 1; + const b = 2; + const c = 3; + + return [a, b, c]; +} + +self.onmessage = function (event) { + if (event.data == "ping") { + f(); + postMessage("pong"); + } +}; + +postMessage("load"); diff --git a/devtools/client/shared/test/code_listworkers-worker1.js b/devtools/client/shared/test/code_listworkers-worker1.js new file mode 100644 index 0000000000..8cee6809e5 --- /dev/null +++ b/devtools/client/shared/test/code_listworkers-worker1.js @@ -0,0 +1,3 @@ +"use strict"; + +self.onmessage = function () {}; diff --git a/devtools/client/shared/test/code_listworkers-worker2.js b/devtools/client/shared/test/code_listworkers-worker2.js new file mode 100644 index 0000000000..8cee6809e5 --- /dev/null +++ b/devtools/client/shared/test/code_listworkers-worker2.js @@ -0,0 +1,3 @@ +"use strict"; + +self.onmessage = function () {}; diff --git a/devtools/client/shared/test/doc_WorkerTargetActor.attachThread-tab.html b/devtools/client/shared/test/doc_WorkerTargetActor.attachThread-tab.html new file mode 100644 index 0000000000..62ab9be7d2 --- /dev/null +++ b/devtools/client/shared/test/doc_WorkerTargetActor.attachThread-tab.html @@ -0,0 +1,8 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"/> + </head> + <body> + </body> +</html> diff --git a/devtools/client/shared/test/doc_cubic-bezier-01.html b/devtools/client/shared/test/doc_cubic-bezier-01.html new file mode 100644 index 0000000000..9667395f96 --- /dev/null +++ b/devtools/client/shared/test/doc_cubic-bezier-01.html @@ -0,0 +1 @@ +<div id="cubic-bezier-container" /> diff --git a/devtools/client/shared/test/doc_cubic-bezier-02.html b/devtools/client/shared/test/doc_cubic-bezier-02.html new file mode 100644 index 0000000000..3a909f18a6 --- /dev/null +++ b/devtools/client/shared/test/doc_cubic-bezier-02.html @@ -0,0 +1,3 @@ +<html><body> + <div id="cubic-bezier-container"/> +</body></html> diff --git a/devtools/client/shared/test/doc_empty-tab-01.html b/devtools/client/shared/test/doc_empty-tab-01.html new file mode 100644 index 0000000000..28398f7768 --- /dev/null +++ b/devtools/client/shared/test/doc_empty-tab-01.html @@ -0,0 +1,14 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Empty test page 1</title> + </head> + + <body> + </body> + +</html> diff --git a/devtools/client/shared/test/doc_empty-tab-02.html b/devtools/client/shared/test/doc_empty-tab-02.html new file mode 100644 index 0000000000..5db1508447 --- /dev/null +++ b/devtools/client/shared/test/doc_empty-tab-02.html @@ -0,0 +1,14 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Empty test page 2</title> + </head> + + <body> + </body> + +</html> diff --git a/devtools/client/shared/test/doc_event-listeners-01.html b/devtools/client/shared/test/doc_event-listeners-01.html new file mode 100644 index 0000000000..5a9c2d53b6 --- /dev/null +++ b/devtools/client/shared/test/doc_event-listeners-01.html @@ -0,0 +1,45 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> +<html> + <head> + <meta charset="utf-8"/> + <title>Debugger test page</title> + </head> + + <body> + <button>Click me!</button> + <input type="text" onchange="changeHandler()"> + + <script type="text/javascript"> + "use strict"; + + window.addEventListener("load", function() { + function initialSetup(event) { + // eslint-disable-next-line no-debugger + debugger; + const button = document.querySelector("button"); + button.onclick = clickHandler; + } + function clickHandler(event) { + window.foobar = "clickHandler"; + } + function changeHandler(event) { + window.foobar = "changeHandler"; + } + function keyupHandler(event) { + window.foobar = "keyupHandler"; + } + + const button = document.querySelector("button"); + button.onclick = initialSetup; + + const input = document.querySelector("input"); + input.addEventListener("keyup", keyupHandler, true); + + window.changeHandler = changeHandler; + }, {once: true}); + </script> + </body> + +</html> 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 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> +<html> + <head> + <meta charset="utf-8"/> + <title>Bound event listeners test page</title> + </head> + + <body> + <button id="initialSetup">initialSetup</button> + <button id="clicker">clicker</button> + <button id="handleEventClick">handleEventClick</button> + <button id="boundHandleEventClick">boundHandleEventClick</button> + + <script type="text/javascript"> + "use strict"; + + window.addEventListener("load", function() { + function initialSetup(event) { + const button = document.getElementById("initialSetup"); + button.removeEventListener("click", initialSetup); + // eslint-disable-next-line no-debugger + debugger; + } + + function clicker(event) { + window.foobar = "clicker"; + } + + function HandleEventClick() { + const button = document.getElementById("handleEventClick"); + // Create a long prototype chain to test for weird edge cases. + button.addEventListener("click", Object.create(Object.create(this))); + } + + HandleEventClick.prototype.handleEvent = function() { + window.foobar = "HandleEventClick"; + }; + + function BoundHandleEventClick() { + const button = document.getElementById("boundHandleEventClick"); + this.handleEvent = this.handleEvent.bind(this); + button.addEventListener("click", this); + } + + BoundHandleEventClick.prototype.handleEvent = function() { + window.foobar = "BoundHandleEventClick"; + }; + + const button = document.getElementById("clicker"); + // Bind more than once to test for weird edge cases. + const boundClicker = clicker.bind(this).bind(this).bind(this); + button.addEventListener("click", boundClicker); + + new HandleEventClick(); + new BoundHandleEventClick(); + + const initButton = document.getElementById("initialSetup"); + initButton.addEventListener("click", initialSetup); + }, {once: true}); + </script> + </body> + +</html> 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 @@ +<div id="filter-container" /> 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 @@ +<?xml version='1.0'?> +<?xml-stylesheet href="chrome://global/skin/global.css"?> +<?xml-stylesheet href="chrome://devtools/skin/tooltips.css"?> +<window + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + htmlns="http://www.w3.org/1999/xhtml" + title="Tooltip test"> + <vbox flex="1"> + <hbox id="box1" flex="1">test1</hbox> + <hbox id="box2" flex="1">test2</hbox> + <hbox id="box3" flex="1">test3</hbox> + <hbox id="box4" flex="1">test4</hbox> + <iframe id="frame" width="200"></iframe> + </vbox> +</window> 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 @@ +<?xml version='1.0'?> +<?xml-stylesheet href="chrome://global/skin/global.css"?> +<?xml-stylesheet href="chrome://devtools/skin/tooltips.css"?> +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + title="Tooltip test"> + <vbox flex="1"> + <hbox id="box1" flex="1"> + <html:input /> + </hbox> + <hbox id="box2" flex="1">test2</hbox> + <hbox id="box3" flex="1"> + <html:input id="box3-input" /> + </hbox> + <hbox id="box4" flex="1"> + <html:input id="box4-input" /> + </hbox> + </vbox> +</window> 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 @@ +<?xml version='1.0'?> +<?xml-stylesheet href="chrome://global/skin/global.css"?> +<?xml-stylesheet href="chrome://devtools/skin/tooltips.css"?> +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="Tooltip test"> + <vbox flex="1"> + <hbox style="height: 10px">spacer</hbox> + <hbox id="box1" style="height: 50px">test1</hbox> + <hbox id="box2" style="height: 50px">test2</hbox> + <hbox flex="1">MIDDLE</hbox> + <hbox id="box3" style="height: 50px">test3</hbox> + <hbox id="box4" style="height: 50px">test4</hbox> + <hbox style="height: 10px">spacer</hbox> + </vbox> +</window> 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 @@ +<?xml version='1.0'?> +<?xml-stylesheet href="chrome://global/skin/global.css"?> +<?xml-stylesheet href="chrome://devtools/skin/tooltips.css"?> +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="Tooltip test"> + <vbox flex="1"> + <hbox id="box1" style="height: 50px">test1</hbox> + <hbox id="box2" style="height: 50px">test2</hbox> + <hbox id="box3" style="height: 50px">test3</hbox> + <hbox id="box4" style="height: 50px">test4</hbox> + </vbox> +</window> 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 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin/global.css"?> +<?xml-stylesheet href="chrome://devtools/skin/tooltips.css"?> +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="Tooltip test"> + <vbox flex="1"> + <hbox id="box1" flex="1">test1</hbox> + <hbox id="box2" flex="1">test2</hbox> + <hbox id="box3" flex="1">test3</hbox> + <hbox id="box4" flex="1">test4</hbox> + </vbox> +</window> 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 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin/global.css"?> +<?xml-stylesheet href="chrome://devtools/skin/light-theme.css"?> +<window class="theme-light" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + title="Tooltip test"> + +<vbox flex="1" style="position: relative"> + <html:div class="anchor" + style="width:10px; height: 10px; position: absolute; background: red; + top: 0; left: 0;"> + </html:div> + + <html:div class="anchor" + style="width:10px; height: 10px; position: absolute; background: red; + top: 0; left: 25px;"> + </html:div> + + <html:div class="anchor" + style="width:10px; height: 10px; position: absolute; background: red; + top: 0; left: 50px;"> + </html:div> + + <html:div class="anchor" + style="width:10px; height: 10px; position: absolute; background: red; + top: 0; left: 75px;"> + </html:div> + + <html:div class="anchor" + style="width:10px; height: 10px; position: absolute; background: red; + bottom: 0; left: 0;"> + </html:div> + + <html:div class="anchor" + style="width:10px; height: 10px; position: absolute; background: red; + bottom: 0; left: 25px;"> + </html:div> + + <html:div class="anchor" + style="width:10px; height: 10px; position: absolute; background: red; + bottom: 0; left: 50px;"> + </html:div> + + <html:div class="anchor" + style="width:10px; height: 10px; position: absolute; background: red; + bottom: 0; left: 75px;"> + </html:div> + + <html:div class="anchor" + style="width:10px; height: 10px; position: absolute; background: red; + bottom: 0; right: 0;"> + </html:div> + + <html:div class="anchor" + style="width:10px; height: 10px; position: absolute; background: red; + bottom: 0; right: 25px;"> + </html:div> + + <html:div class="anchor" + style="width:10px; height: 10px; position: absolute; background: red; + bottom: 0; right: 50px;"> + </html:div> + + <html:div class="anchor" + style="width:10px; height: 10px; position: absolute; background: red; + bottom: 0; right: 75px;"> + </html:div> + + <html:div class="anchor" + style="width:10px; height: 10px; position: absolute; background: red; + top: 0; right: 0;"> + </html:div> + + <html:div class="anchor" + style="width:10px; height: 10px; position: absolute; background: red; + top: 0; right: 25px;"> + </html:div> + + <html:div class="anchor" + style="width:10px; height: 10px; position: absolute; background: red; + top: 0; right: 50px;"> + </html:div> + + <html:div class="anchor" + style="width:10px; height: 10px; position: absolute; background: red; + top: 0; right: 75px;"> + </html:div> + </vbox> +</window> 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 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin/global.css"?> +<?xml-stylesheet href="chrome://devtools/skin/light-theme.css"?> + +<window class="theme-light" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + title="Tooltip test"> + <vbox flex="1" style="position: relative"> + <html:div class="anchor" + style="height: 5px; position: absolute; background: red; + top: 0; left: 0; width: 50px;"> + </html:div> + + <html:div class="anchor" + style="height: 5px; position: absolute; background: red; + top: 10px; left: 0; width: 100px;"> + </html:div> + + <html:div class="anchor" + style="height: 5px; position: absolute; background: red; + top: 20px; left: 0; width: 150px;"> + </html:div> + + <html:div class="anchor" + style="height: 5px; position: absolute; background: red; + top: 30px; left: 0; width: 200px;"> + </html:div> + + <html:div class="anchor" + style="height: 5px; position: absolute; background: red; + top: 40px; left: 0; width: 250px;"> + </html:div> + + <html:div class="anchor" + style="height: 5px; position: absolute; background: red; + top: 50px; left: 100px; width: 250px;"> + </html:div> + + <html:div class="anchor" + style="height: 5px; position: absolute; background: red; + top: 100px; width: 50px; right: 0;"> + </html:div> + + <html:div class="anchor" + style="height: 5px; position: absolute; background: red; + top: 110px; width: 100px; right: 0;"> + </html:div> + + <html:div class="anchor" + style="height: 5px; position: absolute; background: red; + top: 120px; width: 150px; right: 0;"> + </html:div> + + <html:div class="anchor" + style="height: 5px; position: absolute; background: red; + top: 130px; width: 200px; right: 0;"> + </html:div> + + <html:div class="anchor" + style="height: 5px; position: absolute; background: red; + top: 140px; width: 250px; right: 0;"> + </html:div> + </vbox> +</window> 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 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin/global.css"?> +<?xml-stylesheet href="chrome://devtools/skin/light-theme.css"?> +<window class="theme-light" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + title="Tooltip test"> + + <vbox flex="1" style="position: relative"> + <!-- LTR on left edge --> + <html:div + id="anchor1" + class="anchor" + data-hang="right" + style="width:10px; height: 10px; position: absolute; background: red; + top: 0; left: 0;"> + </html:div> + + <!-- RTL but too close to the left edge for the doorhanger to hang left --> + <html:div + id="anchor2" + class="anchor" + data-hang="right" + style="width:10px; height: 10px; position: absolute; background: red; + top: 0; left: 25px; direction: rtl"> + </html:div> + + <!-- RTL --> + <html:div + id="anchor3" + class="anchor" + data-hang="left" + style="width:10px; height: 10px; position: absolute; background: red; + top: 0; left: 250px; direction: rtl"> + </html:div> + + <!-- LTR --> + <html:div + id="anchor4" + class="anchor" + data-hang="right" + style="width:80%; height: 10px; position: absolute; background: red; + top: 0; left: 50px;"> + </html:div> + + <!-- LTR on right edge --> + <html:div + id="anchor5" + class="anchor" + data-hang="left" + style="width:10px; height: 10px; position: absolute; background: red; + bottom: 0; right: 0;"> + </html:div> + + <!-- RTL near right edge --> + <html:div + id="anchor6" + class="anchor" + data-hang="left" + style="width:10px; height: 10px; position: absolute; background: red; + bottom: 0; right: 25px; direction: rtl"> + </html:div> + + <!-- LTR near left edge due to wide anchor --> + <html:div + id="anchor7" + class="anchor" + data-hang="right" + style="width:80%; height: 10px; position: absolute; background: red; + bottom: 0; right: 50px;"> + </html:div> + </vbox> +</window> 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 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin/global.css"?> +<?xml-stylesheet href="chrome://devtools/skin/light-theme.css"?> +<window class="theme-light" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + title="Tooltip test"> + +<vbox flex="1" style="position: relative"> + <!-- Towards the left --> + <html:div class="anchor" + style="width:10px; height: 10px; position: absolute; background: red; + top: 0; left: 25px;"> + </html:div> + + <!-- Towards the left with RTL direction --> + <html:div class="anchor" + style="width:10px; height: 10px; position: absolute; background: red; + top: 0; left: 50px; direction: rtl;"> + </html:div> + + <!-- Towards the right --> + <html:div class="anchor" + style="width:10px; height: 10px; position: absolute; background: red; + bottom: 0; right: 25px;"> + </html:div> + + <!-- Towards the right with RTL direction --> + <html:div class="anchor" + style="width:10px; height: 10px; position: absolute; background: red; + bottom: 0; right: 50px; direction: rtl;"> + </html:div> + </vbox> +</window> 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 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin/global.css"?> +<?xml-stylesheet href="chrome://devtools/skin/variables.css"?> +<?xml-stylesheet href="chrome://devtools/skin/tooltips.css"?> +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + class="theme-light" title="Tooltip hover test"> + <vbox id="container" flex="1"> + <hbox id="box1" flex="1"><label>test1</label></hbox> + <hbox id="box2" flex="1"><label>test2</label></hbox> + <hbox id="box3" flex="1"><label>test3</label></hbox> + <hbox id="box4" flex="1"><label>test4</label></hbox> + </vbox> +</window> 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 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin/global.css"?> +<?xml-stylesheet href="chrome://devtools/skin/tooltips.css"?> +<window + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + htmlns="http://www.w3.org/1999/xhtml" + title="Tooltip test"> + <hbox style="padding: 90px 0;" flex="1"> + <hbox id="box1" flex="1" style="background:red; direction: rtl;">test1</hbox> + <hbox id="box2" flex="1" style="background:blue; direction: rtl;">test2</hbox> + <hbox id="box3" flex="1" style="background:red; direction: ltr;">test3</hbox> + <hbox id="box4" flex="1" style="background:blue; direction: ltr;">test4</hbox> + </hbox> +</window> 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 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin/global.css"?> +<?xml-stylesheet href="chrome://devtools/skin/common.css"?> +<?xml-stylesheet href="chrome://devtools/skin/tooltips.css"?> +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="Tooltip test"> +</window> 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 @@ +<!doctype html> +<meta charset=utf-8> +<title> Layout Helpers </title> + +<style> + html { + height: 300%; + width: 300%; + } + div#some { + position: absolute; + background: black; + width: 2px; + height: 2px; + } + div#other { + position: absolute; + background: red; + width: 2px; + height: 300px; + } + iframe { + position: absolute; + width: 40px; + height: 40px; + border: 0; + } +</style> + +<div id=some></div> +<div id="other"></div> 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 @@ +<!doctype html> +<meta charset=utf-8> +<title>Layout Helpers</title> +<style id="styles"> + body { + margin: 0; + padding: 0; + } + + #hidden-node { + display: none; + } + + #simple-node-with-margin-padding-border { + width: 200px; + height: 200px; + background: #f06; + + padding: 20px; + margin: 50px; + border: 10px solid black; + } + + #scrolled-node { + position: absolute; + top: 0; + left: 0; + + width: 300px; + height: 100px; + overflow: scroll; + background: linear-gradient(red, pink); + } + + #sub-scrolled-node { + width: 200px; + height: 200px; + overflow: scroll; + background: linear-gradient(yellow, green); + } + + #inner-scrolled-node { + width: 100px; + height: 400px; + background: linear-gradient(black, white); + } +</style> +<div id="hidden-node"></div> +<div id="simple-node-with-margin-padding-border"></div> +<!-- The inline encoded code below corresponds to: +<iframe style="margin:10px;border:0;width:300px;height:300px;"> + <iframe style="margin:10px;border:0;width:200px;height:200px;"> + <div id="inner-node" style="width:100px;height:100px;border:10px solid red;margin:10px;padding:10px;"></div> + </iframe> +</iframe> + --> +<iframe src="data:text/html,%3Cstyle%3Ebody%7Bmargin:0;padding:0;%7D%3C/style%3E%3Ciframe%20src=%22data:text/html,%253Cstyle%253Ebody%257Bmargin:0;padding:0;%257D%253C/style%253E%253Cdiv%2520id='inner-node'%2520style='width:100px;height:100px;border:10px%2520solid%2520red;margin:10px;padding:10px;'%253E%253C/div%253E%22%20style=%22margin:10px;border:0;width:200px;height:200px;%22%3E%3C/iframe%3E" style="margin:10px;border:0;width:300px;height:300px;"></iframe> +<div id="scrolled-node"> + <div id="sub-scrolled-node"> + <div id="inner-scrolled-node"></div> + </div> +</div> +<span id="inline">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.<br/ > +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.<br/ > +Praesent tristique maximus lacus, nec ultricies neque ultrices non. Phasellus vel lobortis justo. </span> 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 @@ +<!doctype html> +<meta charset=utf-8> +<style> + body { + margin: 0; + padding: 0; + } + iframe { + vertical-align: top; + } +</style> +<body> +<div style="width:100px; height:100px; background:green"></div> + +<div style="margin:20px"> +<iframe id="b" style="width:250px; height:250px; border:10px solid black" src="https://example.org/browser/devtools/client/shared/test/doc_layoutHelpers_getBoxQuads2-b-and-d.html"> +</iframe><iframe id="d" style="width:250px; height:250px; border:0; padding:40px" src="https://example.org/browser/devtools/client/shared/test/doc_layoutHelpers_getBoxQuads2-b-and-d.html"> +</iframe> +</div> +</body> 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 @@ +<!doctype html> +<meta charset=utf-8> +<script> +'use strict'; +window.onmessage = event => { + // This is a bi-directional message passthrough. We pass "callGetBoxQuads" + // messages down, and everything else back up. + const iframe = document.querySelector("iframe"); + const goDown = !!event.data.callGetBoxQuads; + const targetWindow = (goDown ? iframe.contentWindow : window.parent); + + targetWindow.postMessage(event.data, "*"); +}; +</script> +<style> + body { + margin: 0; + padding: 0; + background: lavender; + } + iframe { + width: 150px; + height: 150px; + margin: 50px; + border: 0; + } +</style> +<iframe src="https://test1.example.com/browser/devtools/client/shared/test/doc_layoutHelpers_getBoxQuads2-c-and-e.html"> +</iframe> 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 @@ +<!doctype html> +<meta charset=utf-8> +<script> +'use strict'; +window.onmessage = event => { + const innerNode = window.document.getElementById("inner-node"); + const wrappedNode = SpecialPowers.wrap(innerNode); + const wrappedQuads = wrappedNode.getBoxQuadsFromWindowOrigin(); + const quad = SpecialPowers.unwrap(wrappedQuads[0]); + + window.parent.postMessage({ quad }, "*"); +}; +</script> +<style> + body { + margin: 0; + padding: 0; + background: pink; + } + div { + margin: 50px; + width: 50px; + height: 50px; + background: green; + } +</style> +<div id="inner-node"></div> 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 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"/> + </head> + <body> + </body> +</html> 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 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <title>A video element with native event handlers</title> + <script type="text/javascript"> + "use strict"; + + /* exported initialSetup */ + function initialSetup(event) { + // eslint-disable-next-line no-debugger + debugger; + } + window.addEventListener("load", function() {}); + </script> + </head> + <body> + <button onclick="initialSetup()">Click me!</button> + <!-- the "controls" attribute ensures that there are extra event handlers in + the element. --> + <video controls></video> + </body> +</html> 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 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Debugger test page</title> + </head> + + <body> + <button onclick="firstCall()">Click me!</button> + + <script type="text/javascript" src="code_script-switching-01.js"></script> + <script type="text/javascript" src="code_script-switching-02.js"></script> + </body> + +</html> 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 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Debugger test page</title> + </head> + + <body> + <button onclick="firstCall()">Click me!</button> + + <script type="text/javascript" src="code_script-switching-01.js"></script> + <script type="text/javascript" src="code_script-switching-02.js?foo=bar,baz|lol"></script> + </body> + +</html> 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 @@ +<link rel="stylesheet" href="chrome://devtools/content/shared/widgets/spectrum.css" type="text/css"/> +<div id="spectrum-container" /> 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 @@ +<?xml version='1.0'?> +<?xml-stylesheet href='chrome://global/skin/global.css'?> +<?xml-stylesheet href='chrome://devtools/skin/light-theme.css'?> +<?xml-stylesheet href='chrome://devtools/skin/widgets.css'?> +<window xmlns='http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul' + title='Table Widget' width='600' height='500'> +<box flex='1' class='theme-light'/></window> 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 @@ +<?xml version='1.0'?> +<?xml-stylesheet href='chrome://global/skin/global.css'?> +<?xml-stylesheet href='chrome://devtools/skin/light-theme.css'?> +<?xml-stylesheet href='chrome://devtools/skin/widgets.css'?> + +<window xmlns='http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul' + title='Table Widget' width='600' height='500'> +<box flex='1' class='theme-light'/></window> 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 @@ +<?xml version="1.0"?> +<?xml-stylesheet href='chrome://global/skin/global.css'?> +<?xml-stylesheet href='chrome://devtools/skin/light-theme.css'?> +<?xml-stylesheet href='chrome://devtools/skin/widgets.css'?> + +<window xmlns='http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul' title='Table Widget' width='600' height='500'> +<box flex='1' class='theme-light'/></window> 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 @@ +<!doctype html> +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> + +<html> +<head> + <title>DOM Template Tests</title> +</head> +<body> + +</body> +</html> 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 @@ +<html></html> 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<String>} 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 + * <path> 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<undefined> 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<String>} 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<Object>} + * 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<string>} resourceIds A list of .ftl files to load. + * @returns {(id: string, args?: Record<string, FluentVariable>) => 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<string, FluentVariable>} [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 <a> 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 {<state>: "pending"}'); + + equal(VariablesView.getString(FULFILLED, { concise: true }), "Promise"); + equal( + VariablesView.getString(FULFILLED), + 'Promise {<state>: "fulfilled", <value>: 10}' + ); + + equal(VariablesView.getString(REJECTED, { concise: true }), "Promise"); + equal( + VariablesView.getString(REJECTED), + 'Promise {<state>: "rejected", <reason>: 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: red; --> color: blue;", + expected: [ + { name: "<!-- color", value: "red", priority: "", offsets: [0, 16] }, + { 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,<!DOCTYPE html></html>"; + 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,<!DOCTYPE html></html>", + "data:<!DOCTYPE html></html>", + "data:text/html;charset=utf-8,<!DOCTYPE html></html>" + ); + + 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"] |