From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- .../framework/actions/dom-mutation-breakpoints.js | 155 + devtools/client/framework/actions/index.js | 8 + devtools/client/framework/actions/moz.build | 11 + devtools/client/framework/browser-menus.js | 340 ++ .../framework/browser-toolbox/Launcher.sys.mjs | 467 ++ .../client/framework/browser-toolbox/README.md | 37 + .../client/framework/browser-toolbox/moz.build | 13 + .../framework/browser-toolbox/test/browser.toml | 53 + .../test/browser_browser_toolbox.js | 66 + .../test/browser_browser_toolbox_debugger.js | 222 + .../browser_browser_toolbox_evaluation_context.js | 199 + ...owser_toolbox_fission_contentframe_inspector.js | 66 + .../browser_browser_toolbox_fission_inspector.js | 220 + ...owser_toolbox_fission_inspector_webextension.js | 94 + .../test/browser_browser_toolbox_l10n_buttons.js | 88 + .../test/browser_browser_toolbox_navigate_tab.js | 85 + .../test/browser_browser_toolbox_netmonitor.js | 151 + .../test/browser_browser_toolbox_print_preview.js | 58 + .../test/browser_browser_toolbox_rtl.js | 29 + .../browser_browser_toolbox_ruleview_stylesheet.js | 82 + ...browser_browser_toolbox_shouldprocessupdates.js | 42 + ...browser_browser_toolbox_unavailable_children.js | 144 + .../browser_browser_toolbox_watchedByDevTools.js | 122 + ...olbox_fission_contentframe_inspector_frame.html | 14 + ...oolbox_fission_contentframe_inspector_page.html | 16 + .../doc_browser_toolbox_ruleview_stylesheet.html | 12 + .../client/framework/browser-toolbox/test/head.js | 13 + .../test/helpers-browser-toolbox.js | 233 + .../style_browser_toolbox_ruleview_stylesheet.css | 3 + .../client/framework/browser-toolbox/window.css | 41 + .../client/framework/browser-toolbox/window.html | 29 + .../client/framework/browser-toolbox/window.js | 336 ++ devtools/client/framework/commands-from-url.js | 179 + .../framework/components/ChromeDebugToolbar.css | 60 + .../framework/components/ChromeDebugToolbar.js | 123 + .../framework/components/DebugTargetErrorPage.css | 21 + .../framework/components/DebugTargetErrorPage.js | 49 + .../client/framework/components/DebugTargetInfo.js | 401 ++ .../client/framework/components/MeatballMenu.js | 299 ++ .../framework/components/ToolboxController.js | 231 + devtools/client/framework/components/ToolboxTab.js | 106 + .../client/framework/components/ToolboxTabs.js | 331 ++ .../client/framework/components/ToolboxToolbar.js | 547 +++ devtools/client/framework/components/moz.build | 17 + devtools/client/framework/devtools-browser.js | 627 +++ devtools/client/framework/devtools.js | 998 ++++ .../client/framework/local-tab-commands-factory.js | 72 + devtools/client/framework/menu-item.js | 79 + devtools/client/framework/menu.js | 248 + devtools/client/framework/moz.build | 50 + devtools/client/framework/options-panel.css | 203 + .../framework/reducers/dom-mutation-breakpoints.js | 134 + devtools/client/framework/reducers/index.js | 10 + devtools/client/framework/reducers/moz.build | 11 + devtools/client/framework/selection.js | 367 ++ .../client/framework/source-map-url-service.js | 501 ++ devtools/client/framework/store-provider.js | 10 + devtools/client/framework/store.js | 13 + .../browser_allocations_browser_console.js | 69 + .../browser_allocations_browser_console.toml | 17 + .../browser_allocations_reload_debugger.js | 13 + .../browser_allocations_reload_debugger.toml | 20 + .../browser_allocations_reload_inspector.js | 13 + .../browser_allocations_reload_inspector.toml | 20 + .../browser_allocations_reload_netmonitor.js | 13 + .../browser_allocations_reload_netmonitor.toml | 20 + .../browser_allocations_reload_no_devtools.js | 42 + .../browser_allocations_reload_no_devtools.toml | 19 + .../browser_allocations_reload_webconsole.js | 13 + .../browser_allocations_reload_webconsole.toml | 20 + .../test/allocations/browser_allocations_target.js | 51 + .../allocations/browser_allocations_target.toml | 17 + .../allocations/browser_allocations_toolbox.js | 53 + .../allocations/browser_allocations_toolbox.toml | 17 + .../framework/test/allocations/docs/index.md | 241 + devtools/client/framework/test/allocations/head.js | 250 + .../client/framework/test/allocations/moz.build | 16 + .../framework/test/allocations/reload-test.js | 84 + .../framework/test/allocations/reloaded-page.html | 11 + .../client/framework/test/allocations/reloaded.png | Bin 0 -> 580 bytes .../framework/test/browser-telemetry-startup.toml | 14 + devtools/client/framework/test/browser.toml | 315 ++ .../test/browser_about-devtools-toolbox_load.js | 31 + .../test/browser_about-devtools-toolbox_reload.js | 73 + .../framework/test/browser_commands_from_url.js | 161 + .../framework/test/browser_devtools_api_destroy.js | 72 + .../test/browser_dynamic_tool_enabling.js | 44 + .../framework/test/browser_front_parentFront.js | 39 + .../browser_ignore_toolbox_network_requests.js | 30 + .../framework/test/browser_keybindings_01.js | 107 + .../framework/test/browser_keybindings_02.js | 66 + .../framework/test/browser_keybindings_03.js | 51 + devtools/client/framework/test/browser_menu_api.js | 239 + .../test/browser_new_activation_workflow.js | 79 + .../client/framework/test/browser_source_map-01.js | 71 + .../framework/test/browser_source_map-absolute.js | 36 + .../test/browser_source_map-cross-domain.js | 41 + .../framework/test/browser_source_map-init.js | 51 + .../framework/test/browser_source_map-inline.js | 42 + .../test/browser_source_map-late-script.js | 52 + .../framework/test/browser_source_map-no-race.js | 43 + .../framework/test/browser_source_map-pub-sub.js | 97 + .../framework/test/browser_source_map-reload.js | 56 + .../framework/test/browser_tab_commands_factory.js | 52 + .../test/browser_tab_descriptor_fission.js | 68 + .../framework/test/browser_target_cached-front.js | 23 + .../test/browser_target_cached-resource.js | 50 + .../framework/test/browser_target_get-front.js | 112 + .../framework/test/browser_target_listeners.js | 33 + .../framework/test/browser_target_loading.js | 37 + .../framework/test/browser_target_parents.js | 189 + .../client/framework/test/browser_target_remote.js | 15 + .../test/browser_target_server_compartment.js | 126 + .../framework/test/browser_target_support.js | 40 + .../browser_toolbox_backward_forward_navigation.js | 188 + .../test/browser_toolbox_browsertoolbox_host.js | 27 + .../browser_toolbox_contentpage_contextmenu.js | 83 + .../framework/test/browser_toolbox_disable_f12.js | 102 + .../test/browser_toolbox_dynamic_registration.js | 87 + .../framework/test/browser_toolbox_error_count.js | 183 + ...wser_toolbox_error_count_reset_on_navigation.js | 84 + .../test/browser_toolbox_fission_navigation.js | 56 + .../framework/test/browser_toolbox_frames_list.js | 150 + .../test/browser_toolbox_getpanelwhenready.js | 39 + .../framework/test/browser_toolbox_highlight.js | 122 + .../client/framework/test/browser_toolbox_hosts.js | 226 + .../framework/test/browser_toolbox_hosts_size.js | 136 + .../test/browser_toolbox_hosts_telemetry.js | 49 + .../test/browser_toolbox_keyboard_navigation.js | 136 + ...toolbox_keyboard_navigation_notification_box.js | 49 + .../framework/test/browser_toolbox_meatball.js | 135 + .../framework/test/browser_toolbox_options.js | 556 +++ .../browser_toolbox_options_disable_buttons.js | 270 ++ .../browser_toolbox_options_disable_cache-01.js | 36 + .../browser_toolbox_options_disable_cache-02.js | 52 + .../browser_toolbox_options_disable_cache-03.js | 60 + .../browser_toolbox_options_disable_cache.css.sjs | 10 + .../test/browser_toolbox_options_disable_cache.sjs | 31 + .../test/browser_toolbox_options_disable_js.html | 47 + .../test/browser_toolbox_options_disable_js.js | 141 + .../browser_toolbox_options_disable_js_iframe.html | 34 + ...lbox_options_enable_serviceworkers_testing.html | 81 + ...oolbox_options_enable_serviceworkers_testing.js | 76 + .../test/browser_toolbox_options_frames_button.js | 104 + .../test/browser_toolbox_options_multiple_tabs.js | 130 + .../test/browser_toolbox_options_panel_toggle.js | 82 + .../test/browser_toolbox_popups_debugging.js | 56 + .../client/framework/test/browser_toolbox_races.js | 96 + .../client/framework/test/browser_toolbox_raise.js | 68 + .../client/framework/test/browser_toolbox_ready.js | 22 + .../test/browser_toolbox_remoteness_change.js | 52 + .../test/browser_toolbox_screenshot_tool.js | 126 + .../framework/test/browser_toolbox_select_event.js | 98 + .../browser_toolbox_selected_tool_unavailable.js | 46 + .../test/browser_toolbox_selectionchanged_event.js | 40 + .../browser_toolbox_show_toolbox_tool_ready.js | 69 + .../test/browser_toolbox_split_console.js | 84 + .../test/browser_toolbox_tabsswitch_shortcuts.js | 76 + ...wser_toolbox_telemetry_activate_splitconsole.js | 108 + .../test/browser_toolbox_telemetry_close.js | 63 + .../test/browser_toolbox_telemetry_enter.js | 152 + .../test/browser_toolbox_telemetry_exit.js | 129 + .../test/browser_toolbox_telemetry_open_event.js | 38 + .../test/browser_toolbox_textbox_context_menu.js | 137 + .../client/framework/test/browser_toolbox_theme.js | 33 + .../test/browser_toolbox_theme_registration.js | 166 + .../framework/test/browser_toolbox_toggle.js | 111 + .../framework/test/browser_toolbox_tool_ready.js | 33 + .../test/browser_toolbox_tool_remote_reopen.js | 100 + .../test/browser_toolbox_toolbar_minimum_width.js | 43 + .../test/browser_toolbox_toolbar_overflow.js | 83 + ...r_toolbox_toolbar_overflow_button_visibility.js | 72 + .../test/browser_toolbox_toolbar_reorder_by_dnd.js | 188 + .../browser_toolbox_toolbar_reorder_by_width.js | 104 + ...owser_toolbox_toolbar_reorder_with_extension.js | 150 + ...oolbox_toolbar_reorder_with_hidden_extension.js | 248 + ...owser_toolbox_tools_per_toolbox_registration.js | 138 + .../test/browser_toolbox_view_source_01.js | 31 + .../test/browser_toolbox_view_source_02.js | 38 + .../test/browser_toolbox_view_source_03.js | 51 + ...er_toolbox_view_source_style_editor_fallback.js | 38 + .../test/browser_toolbox_watchedByDevTools.js | 72 + .../test/browser_toolbox_window_reload_target.js | 130 + .../browser_toolbox_window_reload_target_force.js | 55 + .../test/browser_toolbox_window_shortcuts.js | 104 + .../test/browser_toolbox_window_title_changes.js | 98 + .../browser_toolbox_window_title_changes_page.html | 10 + .../browser_toolbox_window_title_frame_select.js | 171 + ...ser_toolbox_window_title_frame_select_page.html | 11 + .../client/framework/test/browser_toolbox_zoom.js | 62 + .../framework/test/browser_toolbox_zoom_popup.js | 213 + .../test/browser_webextension_descriptor.js | 31 + .../test/browser_webextension_dropdown.js | 135 + .../framework/test/code_binary_search.coffee | 18 + .../client/framework/test/code_binary_search.js | 29 + .../client/framework/test/code_binary_search.map | 10 + .../framework/test/code_binary_search_absolute.js | 29 + .../framework/test/code_binary_search_absolute.map | 10 + .../framework/test/code_bundle_cross_domain.js | 93 + .../framework/test/code_bundle_cross_domain.js.map | 1 + .../framework/test/code_bundle_late_script.js | 116 + .../framework/test/code_bundle_late_script.js.map | 1 + .../client/framework/test/code_bundle_no_race.js | 95 + .../framework/test/code_bundle_no_race.js.map | 1 + .../client/framework/test/code_cross_domain.js | 19 + .../client/framework/test/code_inline_bundle.js | 92 + .../client/framework/test/code_inline_original.js | 14 + devtools/client/framework/test/code_late_script.js | 14 + devtools/client/framework/test/code_math.js | 7 + devtools/client/framework/test/code_no_race.js | 17 + .../test/doc_backward_forward_navigation.html | 40 + .../client/framework/test/doc_cached-resource.html | 15 + .../framework/test/doc_cached-resource_iframe.html | 14 + .../client/framework/test/doc_empty-tab-01.html | 14 + devtools/client/framework/test/doc_lazy_tool.html | 6 + .../client/framework/test/doc_textbox_tool.html | 10 + devtools/client/framework/test/doc_theme.css | 3 + devtools/client/framework/test/doc_viewsource.html | 13 + devtools/client/framework/test/head.js | 490 ++ .../client/framework/test/helper_disable_cache.js | 144 + .../framework/test/metrics/browser_metrics.toml | 19 + .../test/metrics/browser_metrics_debugger.js | 61 + .../test/metrics/browser_metrics_debugger.toml | 18 + .../test/metrics/browser_metrics_inspector.js | 43 + .../test/metrics/browser_metrics_inspector.toml | 18 + .../test/metrics/browser_metrics_netmonitor.js | 89 + .../test/metrics/browser_metrics_netmonitor.toml | 18 + .../framework/test/metrics/browser_metrics_pool.js | 118 + .../test/metrics/browser_metrics_webconsole.js | 56 + .../test/metrics/browser_metrics_webconsole.toml | 18 + devtools/client/framework/test/metrics/head.js | 171 + devtools/client/framework/test/node/.eslintrc.js | 22 + devtools/client/framework/test/node/README.md | 22 + .../client/framework/test/node/babel.config.js | 13 + .../__snapshots__/debug-target-info.test.js.snap | 586 +++ .../test/node/components/debug-target-info.test.js | 319 ++ devtools/client/framework/test/node/jest.config.js | 13 + devtools/client/framework/test/node/package.json | 22 + devtools/client/framework/test/node/setup.js | 10 + .../framework/test/node/store/targets.test.js | 142 + devtools/client/framework/test/node/yarn.lock | 3144 +++++++++++++ devtools/client/framework/test/reload/.eslintrc.js | 18 + devtools/client/framework/test/reload/README.md | 4 + devtools/client/framework/test/reload/package.json | 12 + .../framework/test/reload/v1/code_bundle_reload.js | 19 + .../test/reload/v1/code_bundle_reload.js.map | 1 + .../framework/test/reload/v1/code_reload_1.js | 10 + .../framework/test/reload/v1/doc_reload.html | 15 + .../framework/test/reload/v2/code_bundle_reload.js | 19 + .../test/reload/v2/code_bundle_reload.js.map | 1 + .../framework/test/reload/v2/code_reload_2.js | 10 + .../framework/test/reload/v2/doc_reload.html | 15 + .../client/framework/test/reload/webpack.config.js | 13 + devtools/client/framework/test/serviceworker.js | 4 + .../framework/test/sjs_cache_controle_header.sjs | 19 + .../client/framework/test/test_chrome_page.html | 9 + .../client/framework/test/xpcshell/.eslintrc.js | 6 + .../test/xpcshell/test_tabs_absolute_order.js | 81 + .../client/framework/test/xpcshell/xpcshell.toml | 6 + devtools/client/framework/toolbox-context-menu.js | 119 + devtools/client/framework/toolbox-host-manager.js | 358 ++ devtools/client/framework/toolbox-hosts.js | 460 ++ devtools/client/framework/toolbox-init.js | 135 + devtools/client/framework/toolbox-options.html | 275 ++ devtools/client/framework/toolbox-options.js | 613 +++ .../client/framework/toolbox-tabs-order-manager.js | 285 ++ devtools/client/framework/toolbox-window.js | 20 + devtools/client/framework/toolbox-window.xhtml | 21 + devtools/client/framework/toolbox.js | 4787 ++++++++++++++++++++ devtools/client/framework/toolbox.xhtml | 57 + 270 files changed, 34080 insertions(+) create mode 100644 devtools/client/framework/actions/dom-mutation-breakpoints.js create mode 100644 devtools/client/framework/actions/index.js create mode 100644 devtools/client/framework/actions/moz.build create mode 100644 devtools/client/framework/browser-menus.js create mode 100644 devtools/client/framework/browser-toolbox/Launcher.sys.mjs create mode 100644 devtools/client/framework/browser-toolbox/README.md create mode 100644 devtools/client/framework/browser-toolbox/moz.build create mode 100644 devtools/client/framework/browser-toolbox/test/browser.toml create mode 100644 devtools/client/framework/browser-toolbox/test/browser_browser_toolbox.js create mode 100644 devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_debugger.js create mode 100644 devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_evaluation_context.js create mode 100644 devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_contentframe_inspector.js create mode 100644 devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_inspector.js create mode 100644 devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_inspector_webextension.js create mode 100644 devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_l10n_buttons.js create mode 100644 devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_navigate_tab.js create mode 100644 devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_netmonitor.js create mode 100644 devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_print_preview.js create mode 100644 devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_rtl.js create mode 100644 devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_ruleview_stylesheet.js create mode 100644 devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_shouldprocessupdates.js create mode 100644 devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_unavailable_children.js create mode 100644 devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_watchedByDevTools.js create mode 100644 devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_fission_contentframe_inspector_frame.html create mode 100644 devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_fission_contentframe_inspector_page.html create mode 100644 devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_ruleview_stylesheet.html create mode 100644 devtools/client/framework/browser-toolbox/test/head.js create mode 100644 devtools/client/framework/browser-toolbox/test/helpers-browser-toolbox.js create mode 100644 devtools/client/framework/browser-toolbox/test/style_browser_toolbox_ruleview_stylesheet.css create mode 100644 devtools/client/framework/browser-toolbox/window.css create mode 100644 devtools/client/framework/browser-toolbox/window.html create mode 100644 devtools/client/framework/browser-toolbox/window.js create mode 100644 devtools/client/framework/commands-from-url.js create mode 100644 devtools/client/framework/components/ChromeDebugToolbar.css create mode 100644 devtools/client/framework/components/ChromeDebugToolbar.js create mode 100644 devtools/client/framework/components/DebugTargetErrorPage.css create mode 100644 devtools/client/framework/components/DebugTargetErrorPage.js create mode 100644 devtools/client/framework/components/DebugTargetInfo.js create mode 100644 devtools/client/framework/components/MeatballMenu.js create mode 100644 devtools/client/framework/components/ToolboxController.js create mode 100644 devtools/client/framework/components/ToolboxTab.js create mode 100644 devtools/client/framework/components/ToolboxTabs.js create mode 100644 devtools/client/framework/components/ToolboxToolbar.js create mode 100644 devtools/client/framework/components/moz.build create mode 100644 devtools/client/framework/devtools-browser.js create mode 100644 devtools/client/framework/devtools.js create mode 100644 devtools/client/framework/local-tab-commands-factory.js create mode 100644 devtools/client/framework/menu-item.js create mode 100644 devtools/client/framework/menu.js create mode 100644 devtools/client/framework/moz.build create mode 100644 devtools/client/framework/options-panel.css create mode 100644 devtools/client/framework/reducers/dom-mutation-breakpoints.js create mode 100644 devtools/client/framework/reducers/index.js create mode 100644 devtools/client/framework/reducers/moz.build create mode 100644 devtools/client/framework/selection.js create mode 100644 devtools/client/framework/source-map-url-service.js create mode 100644 devtools/client/framework/store-provider.js create mode 100644 devtools/client/framework/store.js create mode 100644 devtools/client/framework/test/allocations/browser_allocations_browser_console.js create mode 100644 devtools/client/framework/test/allocations/browser_allocations_browser_console.toml create mode 100644 devtools/client/framework/test/allocations/browser_allocations_reload_debugger.js create mode 100644 devtools/client/framework/test/allocations/browser_allocations_reload_debugger.toml create mode 100644 devtools/client/framework/test/allocations/browser_allocations_reload_inspector.js create mode 100644 devtools/client/framework/test/allocations/browser_allocations_reload_inspector.toml create mode 100644 devtools/client/framework/test/allocations/browser_allocations_reload_netmonitor.js create mode 100644 devtools/client/framework/test/allocations/browser_allocations_reload_netmonitor.toml create mode 100644 devtools/client/framework/test/allocations/browser_allocations_reload_no_devtools.js create mode 100644 devtools/client/framework/test/allocations/browser_allocations_reload_no_devtools.toml create mode 100644 devtools/client/framework/test/allocations/browser_allocations_reload_webconsole.js create mode 100644 devtools/client/framework/test/allocations/browser_allocations_reload_webconsole.toml create mode 100644 devtools/client/framework/test/allocations/browser_allocations_target.js create mode 100644 devtools/client/framework/test/allocations/browser_allocations_target.toml create mode 100644 devtools/client/framework/test/allocations/browser_allocations_toolbox.js create mode 100644 devtools/client/framework/test/allocations/browser_allocations_toolbox.toml create mode 100644 devtools/client/framework/test/allocations/docs/index.md create mode 100644 devtools/client/framework/test/allocations/head.js create mode 100644 devtools/client/framework/test/allocations/moz.build create mode 100644 devtools/client/framework/test/allocations/reload-test.js create mode 100644 devtools/client/framework/test/allocations/reloaded-page.html create mode 100644 devtools/client/framework/test/allocations/reloaded.png create mode 100644 devtools/client/framework/test/browser-telemetry-startup.toml create mode 100644 devtools/client/framework/test/browser.toml create mode 100644 devtools/client/framework/test/browser_about-devtools-toolbox_load.js create mode 100644 devtools/client/framework/test/browser_about-devtools-toolbox_reload.js create mode 100644 devtools/client/framework/test/browser_commands_from_url.js create mode 100644 devtools/client/framework/test/browser_devtools_api_destroy.js create mode 100644 devtools/client/framework/test/browser_dynamic_tool_enabling.js create mode 100644 devtools/client/framework/test/browser_front_parentFront.js create mode 100644 devtools/client/framework/test/browser_ignore_toolbox_network_requests.js create mode 100644 devtools/client/framework/test/browser_keybindings_01.js create mode 100644 devtools/client/framework/test/browser_keybindings_02.js create mode 100644 devtools/client/framework/test/browser_keybindings_03.js create mode 100644 devtools/client/framework/test/browser_menu_api.js create mode 100644 devtools/client/framework/test/browser_new_activation_workflow.js create mode 100644 devtools/client/framework/test/browser_source_map-01.js create mode 100644 devtools/client/framework/test/browser_source_map-absolute.js create mode 100644 devtools/client/framework/test/browser_source_map-cross-domain.js create mode 100644 devtools/client/framework/test/browser_source_map-init.js create mode 100644 devtools/client/framework/test/browser_source_map-inline.js create mode 100644 devtools/client/framework/test/browser_source_map-late-script.js create mode 100644 devtools/client/framework/test/browser_source_map-no-race.js create mode 100644 devtools/client/framework/test/browser_source_map-pub-sub.js create mode 100644 devtools/client/framework/test/browser_source_map-reload.js create mode 100644 devtools/client/framework/test/browser_tab_commands_factory.js create mode 100644 devtools/client/framework/test/browser_tab_descriptor_fission.js create mode 100644 devtools/client/framework/test/browser_target_cached-front.js create mode 100644 devtools/client/framework/test/browser_target_cached-resource.js create mode 100644 devtools/client/framework/test/browser_target_get-front.js create mode 100644 devtools/client/framework/test/browser_target_listeners.js create mode 100644 devtools/client/framework/test/browser_target_loading.js create mode 100644 devtools/client/framework/test/browser_target_parents.js create mode 100644 devtools/client/framework/test/browser_target_remote.js create mode 100644 devtools/client/framework/test/browser_target_server_compartment.js create mode 100644 devtools/client/framework/test/browser_target_support.js create mode 100644 devtools/client/framework/test/browser_toolbox_backward_forward_navigation.js create mode 100644 devtools/client/framework/test/browser_toolbox_browsertoolbox_host.js create mode 100644 devtools/client/framework/test/browser_toolbox_contentpage_contextmenu.js create mode 100644 devtools/client/framework/test/browser_toolbox_disable_f12.js create mode 100644 devtools/client/framework/test/browser_toolbox_dynamic_registration.js create mode 100644 devtools/client/framework/test/browser_toolbox_error_count.js create mode 100644 devtools/client/framework/test/browser_toolbox_error_count_reset_on_navigation.js create mode 100644 devtools/client/framework/test/browser_toolbox_fission_navigation.js create mode 100644 devtools/client/framework/test/browser_toolbox_frames_list.js create mode 100644 devtools/client/framework/test/browser_toolbox_getpanelwhenready.js create mode 100644 devtools/client/framework/test/browser_toolbox_highlight.js create mode 100644 devtools/client/framework/test/browser_toolbox_hosts.js create mode 100644 devtools/client/framework/test/browser_toolbox_hosts_size.js create mode 100644 devtools/client/framework/test/browser_toolbox_hosts_telemetry.js create mode 100644 devtools/client/framework/test/browser_toolbox_keyboard_navigation.js create mode 100644 devtools/client/framework/test/browser_toolbox_keyboard_navigation_notification_box.js create mode 100644 devtools/client/framework/test/browser_toolbox_meatball.js create mode 100644 devtools/client/framework/test/browser_toolbox_options.js create mode 100644 devtools/client/framework/test/browser_toolbox_options_disable_buttons.js create mode 100644 devtools/client/framework/test/browser_toolbox_options_disable_cache-01.js create mode 100644 devtools/client/framework/test/browser_toolbox_options_disable_cache-02.js create mode 100644 devtools/client/framework/test/browser_toolbox_options_disable_cache-03.js create mode 100644 devtools/client/framework/test/browser_toolbox_options_disable_cache.css.sjs create mode 100644 devtools/client/framework/test/browser_toolbox_options_disable_cache.sjs create mode 100644 devtools/client/framework/test/browser_toolbox_options_disable_js.html create mode 100644 devtools/client/framework/test/browser_toolbox_options_disable_js.js create mode 100644 devtools/client/framework/test/browser_toolbox_options_disable_js_iframe.html create mode 100644 devtools/client/framework/test/browser_toolbox_options_enable_serviceworkers_testing.html create mode 100644 devtools/client/framework/test/browser_toolbox_options_enable_serviceworkers_testing.js create mode 100644 devtools/client/framework/test/browser_toolbox_options_frames_button.js create mode 100644 devtools/client/framework/test/browser_toolbox_options_multiple_tabs.js create mode 100644 devtools/client/framework/test/browser_toolbox_options_panel_toggle.js create mode 100644 devtools/client/framework/test/browser_toolbox_popups_debugging.js create mode 100644 devtools/client/framework/test/browser_toolbox_races.js create mode 100644 devtools/client/framework/test/browser_toolbox_raise.js create mode 100644 devtools/client/framework/test/browser_toolbox_ready.js create mode 100644 devtools/client/framework/test/browser_toolbox_remoteness_change.js create mode 100644 devtools/client/framework/test/browser_toolbox_screenshot_tool.js create mode 100644 devtools/client/framework/test/browser_toolbox_select_event.js create mode 100644 devtools/client/framework/test/browser_toolbox_selected_tool_unavailable.js create mode 100644 devtools/client/framework/test/browser_toolbox_selectionchanged_event.js create mode 100644 devtools/client/framework/test/browser_toolbox_show_toolbox_tool_ready.js create mode 100644 devtools/client/framework/test/browser_toolbox_split_console.js create mode 100644 devtools/client/framework/test/browser_toolbox_tabsswitch_shortcuts.js create mode 100644 devtools/client/framework/test/browser_toolbox_telemetry_activate_splitconsole.js create mode 100644 devtools/client/framework/test/browser_toolbox_telemetry_close.js create mode 100644 devtools/client/framework/test/browser_toolbox_telemetry_enter.js create mode 100644 devtools/client/framework/test/browser_toolbox_telemetry_exit.js create mode 100644 devtools/client/framework/test/browser_toolbox_telemetry_open_event.js create mode 100644 devtools/client/framework/test/browser_toolbox_textbox_context_menu.js create mode 100644 devtools/client/framework/test/browser_toolbox_theme.js create mode 100644 devtools/client/framework/test/browser_toolbox_theme_registration.js create mode 100644 devtools/client/framework/test/browser_toolbox_toggle.js create mode 100644 devtools/client/framework/test/browser_toolbox_tool_ready.js create mode 100644 devtools/client/framework/test/browser_toolbox_tool_remote_reopen.js create mode 100644 devtools/client/framework/test/browser_toolbox_toolbar_minimum_width.js create mode 100644 devtools/client/framework/test/browser_toolbox_toolbar_overflow.js create mode 100644 devtools/client/framework/test/browser_toolbox_toolbar_overflow_button_visibility.js create mode 100644 devtools/client/framework/test/browser_toolbox_toolbar_reorder_by_dnd.js create mode 100644 devtools/client/framework/test/browser_toolbox_toolbar_reorder_by_width.js create mode 100644 devtools/client/framework/test/browser_toolbox_toolbar_reorder_with_extension.js create mode 100644 devtools/client/framework/test/browser_toolbox_toolbar_reorder_with_hidden_extension.js create mode 100644 devtools/client/framework/test/browser_toolbox_tools_per_toolbox_registration.js create mode 100644 devtools/client/framework/test/browser_toolbox_view_source_01.js create mode 100644 devtools/client/framework/test/browser_toolbox_view_source_02.js create mode 100644 devtools/client/framework/test/browser_toolbox_view_source_03.js create mode 100644 devtools/client/framework/test/browser_toolbox_view_source_style_editor_fallback.js create mode 100644 devtools/client/framework/test/browser_toolbox_watchedByDevTools.js create mode 100644 devtools/client/framework/test/browser_toolbox_window_reload_target.js create mode 100644 devtools/client/framework/test/browser_toolbox_window_reload_target_force.js create mode 100644 devtools/client/framework/test/browser_toolbox_window_shortcuts.js create mode 100644 devtools/client/framework/test/browser_toolbox_window_title_changes.js create mode 100644 devtools/client/framework/test/browser_toolbox_window_title_changes_page.html create mode 100644 devtools/client/framework/test/browser_toolbox_window_title_frame_select.js create mode 100644 devtools/client/framework/test/browser_toolbox_window_title_frame_select_page.html create mode 100644 devtools/client/framework/test/browser_toolbox_zoom.js create mode 100644 devtools/client/framework/test/browser_toolbox_zoom_popup.js create mode 100644 devtools/client/framework/test/browser_webextension_descriptor.js create mode 100644 devtools/client/framework/test/browser_webextension_dropdown.js create mode 100644 devtools/client/framework/test/code_binary_search.coffee create mode 100644 devtools/client/framework/test/code_binary_search.js create mode 100644 devtools/client/framework/test/code_binary_search.map create mode 100644 devtools/client/framework/test/code_binary_search_absolute.js create mode 100644 devtools/client/framework/test/code_binary_search_absolute.map create mode 100644 devtools/client/framework/test/code_bundle_cross_domain.js create mode 100644 devtools/client/framework/test/code_bundle_cross_domain.js.map create mode 100644 devtools/client/framework/test/code_bundle_late_script.js create mode 100644 devtools/client/framework/test/code_bundle_late_script.js.map create mode 100644 devtools/client/framework/test/code_bundle_no_race.js create mode 100644 devtools/client/framework/test/code_bundle_no_race.js.map create mode 100644 devtools/client/framework/test/code_cross_domain.js create mode 100644 devtools/client/framework/test/code_inline_bundle.js create mode 100644 devtools/client/framework/test/code_inline_original.js create mode 100644 devtools/client/framework/test/code_late_script.js create mode 100644 devtools/client/framework/test/code_math.js create mode 100644 devtools/client/framework/test/code_no_race.js create mode 100644 devtools/client/framework/test/doc_backward_forward_navigation.html create mode 100644 devtools/client/framework/test/doc_cached-resource.html create mode 100644 devtools/client/framework/test/doc_cached-resource_iframe.html create mode 100644 devtools/client/framework/test/doc_empty-tab-01.html create mode 100644 devtools/client/framework/test/doc_lazy_tool.html create mode 100644 devtools/client/framework/test/doc_textbox_tool.html create mode 100644 devtools/client/framework/test/doc_theme.css create mode 100644 devtools/client/framework/test/doc_viewsource.html create mode 100644 devtools/client/framework/test/head.js create mode 100644 devtools/client/framework/test/helper_disable_cache.js create mode 100644 devtools/client/framework/test/metrics/browser_metrics.toml create mode 100644 devtools/client/framework/test/metrics/browser_metrics_debugger.js create mode 100644 devtools/client/framework/test/metrics/browser_metrics_debugger.toml create mode 100644 devtools/client/framework/test/metrics/browser_metrics_inspector.js create mode 100644 devtools/client/framework/test/metrics/browser_metrics_inspector.toml create mode 100644 devtools/client/framework/test/metrics/browser_metrics_netmonitor.js create mode 100644 devtools/client/framework/test/metrics/browser_metrics_netmonitor.toml create mode 100644 devtools/client/framework/test/metrics/browser_metrics_pool.js create mode 100644 devtools/client/framework/test/metrics/browser_metrics_webconsole.js create mode 100644 devtools/client/framework/test/metrics/browser_metrics_webconsole.toml create mode 100644 devtools/client/framework/test/metrics/head.js create mode 100644 devtools/client/framework/test/node/.eslintrc.js create mode 100644 devtools/client/framework/test/node/README.md create mode 100644 devtools/client/framework/test/node/babel.config.js create mode 100644 devtools/client/framework/test/node/components/__snapshots__/debug-target-info.test.js.snap create mode 100644 devtools/client/framework/test/node/components/debug-target-info.test.js create mode 100644 devtools/client/framework/test/node/jest.config.js create mode 100644 devtools/client/framework/test/node/package.json create mode 100644 devtools/client/framework/test/node/setup.js create mode 100644 devtools/client/framework/test/node/store/targets.test.js create mode 100644 devtools/client/framework/test/node/yarn.lock create mode 100644 devtools/client/framework/test/reload/.eslintrc.js create mode 100644 devtools/client/framework/test/reload/README.md create mode 100644 devtools/client/framework/test/reload/package.json create mode 100644 devtools/client/framework/test/reload/v1/code_bundle_reload.js create mode 100644 devtools/client/framework/test/reload/v1/code_bundle_reload.js.map create mode 100644 devtools/client/framework/test/reload/v1/code_reload_1.js create mode 100644 devtools/client/framework/test/reload/v1/doc_reload.html create mode 100644 devtools/client/framework/test/reload/v2/code_bundle_reload.js create mode 100644 devtools/client/framework/test/reload/v2/code_bundle_reload.js.map create mode 100644 devtools/client/framework/test/reload/v2/code_reload_2.js create mode 100644 devtools/client/framework/test/reload/v2/doc_reload.html create mode 100644 devtools/client/framework/test/reload/webpack.config.js create mode 100644 devtools/client/framework/test/serviceworker.js create mode 100644 devtools/client/framework/test/sjs_cache_controle_header.sjs create mode 100644 devtools/client/framework/test/test_chrome_page.html create mode 100644 devtools/client/framework/test/xpcshell/.eslintrc.js create mode 100644 devtools/client/framework/test/xpcshell/test_tabs_absolute_order.js create mode 100644 devtools/client/framework/test/xpcshell/xpcshell.toml create mode 100644 devtools/client/framework/toolbox-context-menu.js create mode 100644 devtools/client/framework/toolbox-host-manager.js create mode 100644 devtools/client/framework/toolbox-hosts.js create mode 100644 devtools/client/framework/toolbox-init.js create mode 100644 devtools/client/framework/toolbox-options.html create mode 100644 devtools/client/framework/toolbox-options.js create mode 100644 devtools/client/framework/toolbox-tabs-order-manager.js create mode 100644 devtools/client/framework/toolbox-window.js create mode 100644 devtools/client/framework/toolbox-window.xhtml create mode 100644 devtools/client/framework/toolbox.js create mode 100644 devtools/client/framework/toolbox.xhtml (limited to 'devtools/client/framework') diff --git a/devtools/client/framework/actions/dom-mutation-breakpoints.js b/devtools/client/framework/actions/dom-mutation-breakpoints.js new file mode 100644 index 0000000000..1e1273d711 --- /dev/null +++ b/devtools/client/framework/actions/dom-mutation-breakpoints.js @@ -0,0 +1,155 @@ +/* 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 . */ +"use strict"; + +const { assert } = require("resource://devtools/shared/DevToolsUtils.js"); +const { + getDOMMutationBreakpoint, + getDOMMutationBreakpoints, +} = require("resource://devtools/client/framework/reducers/dom-mutation-breakpoints.js"); + +exports.registerWalkerListeners = registerWalkerListeners; +function registerWalkerListeners(store, walker) { + walker.on("mutations", mutations => handleWalkerMutations(mutations, store)); +} + +/** + * Called when a target is destroyed. This will allow the reducer to remove breakpoints on + * nodeFront associated with the passed target + * + * @param {ToolboxStore} store: The toolbox redux store + * @param {TargetFront} targetFront + */ +function removeTarget(store, targetFront) { + store.dispatch({ + type: "REMOVE_TARGET", + targetFront, + }); +} +exports.removeTarget = removeTarget; + +function handleWalkerMutations(mutations, store) { + // If we got BP updates for detach/unload, we want to drop those nodes from + // the list of active DOM mutation breakpoints. We explicitly check these + // cases because BP updates could also happen due to explicitly API + // operations to add/remove bps. + const mutationItems = mutations.filter( + mutation => mutation.type === "mutationBreakpoint" + ); + if (mutationItems.length) { + store.dispatch(updateBreakpointsForMutations(mutationItems)); + } +} + +exports.createDOMMutationBreakpoint = createDOMMutationBreakpoint; +function createDOMMutationBreakpoint(nodeFront, mutationType) { + assert(typeof nodeFront === "object" && nodeFront); + assert(typeof mutationType === "string"); + + return async function ({ dispatch, getState }) { + const walker = nodeFront.walkerFront; + + dispatch({ + type: "ADD_DOM_MUTATION_BREAKPOINT", + nodeFront, + mutationType, + }); + + await walker.setMutationBreakpoints(nodeFront, { + [mutationType]: true, + }); + }; +} + +exports.deleteDOMMutationBreakpoint = deleteDOMMutationBreakpoint; +function deleteDOMMutationBreakpoint(nodeFront, mutationType) { + assert(typeof nodeFront === "object" && nodeFront); + assert(typeof mutationType === "string"); + + return async function ({ dispatch, getState }) { + const walker = nodeFront.walkerFront; + await walker.setMutationBreakpoints(nodeFront, { + [mutationType]: false, + }); + + dispatch({ + type: "REMOVE_DOM_MUTATION_BREAKPOINT", + nodeFront, + mutationType, + }); + }; +} + +function updateBreakpointsForMutations(mutationItems) { + return async function ({ dispatch, getState }) { + const removedNodeFronts = []; + const changedNodeFronts = new Set(); + + for (const { target: nodeFront, mutationReason } of mutationItems) { + switch (mutationReason) { + case "api": + changedNodeFronts.add(nodeFront); + break; + default: + console.error( + "Unexpected mutation reason", + mutationReason, + ", removing" + ); + // Fall Through + case "detach": + case "unload": + removedNodeFronts.push(nodeFront); + break; + } + } + + if (removedNodeFronts.length) { + dispatch({ + type: "REMOVE_DOM_MUTATION_BREAKPOINTS_FOR_FRONTS", + nodeFronts: removedNodeFronts, + }); + } + if (changedNodeFronts.size > 0) { + const enabledStates = []; + for (const { + id, + nodeFront, + mutationType, + enabled, + } of getDOMMutationBreakpoints(getState())) { + if (changedNodeFronts.has(nodeFront)) { + const bpEnabledOnFront = nodeFront.mutationBreakpoints[mutationType]; + if (bpEnabledOnFront !== enabled) { + // Sync the bp state from the front into the store. + enabledStates.push([id, bpEnabledOnFront]); + } + } + } + + dispatch({ + type: "SET_DOM_MUTATION_BREAKPOINTS_ENABLED_STATE", + enabledStates, + }); + } + }; +} + +exports.toggleDOMMutationBreakpointState = toggleDOMMutationBreakpointState; +function toggleDOMMutationBreakpointState(id, enabled) { + assert(typeof id === "string"); + assert(typeof enabled === "boolean"); + + return async function ({ dispatch, getState }) { + const bp = getDOMMutationBreakpoint(getState(), id); + if (!bp) { + throw new Error(`No DOM mutation BP with ID ${id}`); + } + + const walker = bp.nodeFront.getParent(); + await walker.setMutationBreakpoints(bp.nodeFront, { + [bp.mutationType]: enabled, + }); + }; +} diff --git a/devtools/client/framework/actions/index.js b/devtools/client/framework/actions/index.js new file mode 100644 index 0000000000..29da67d38c --- /dev/null +++ b/devtools/client/framework/actions/index.js @@ -0,0 +1,8 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +module.exports = { + ...require("resource://devtools/client/framework/actions/dom-mutation-breakpoints.js"), +}; diff --git a/devtools/client/framework/actions/moz.build b/devtools/client/framework/actions/moz.build new file mode 100644 index 0000000000..e77a7cc2cc --- /dev/null +++ b/devtools/client/framework/actions/moz.build @@ -0,0 +1,11 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "dom-mutation-breakpoints.js", + "index.js", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Framework") diff --git a/devtools/client/framework/browser-menus.js b/devtools/client/framework/browser-menus.js new file mode 100644 index 0000000000..bc070fc78d --- /dev/null +++ b/devtools/client/framework/browser-menus.js @@ -0,0 +1,340 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * This module inject dynamically menu items into browser UI. + * + * Menu definitions are fetched from: + * - devtools/client/menus for top level entires + * - devtools/client/definitions for tool-specifics entries + */ + +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const MENUS_L10N = new LocalizationHelper( + "devtools/client/locales/menus.properties" +); + +loader.lazyRequireGetter( + this, + "gDevTools", + "resource://devtools/client/framework/devtools.js", + true +); +loader.lazyRequireGetter( + this, + "gDevToolsBrowser", + "resource://devtools/client/framework/devtools-browser.js", + true +); +loader.lazyRequireGetter( + this, + "Telemetry", + "resource://devtools/client/shared/telemetry.js" +); + +let telemetry = null; + +// Keep list of inserted DOM Elements in order to remove them on unload +// Maps browser xul document => list of DOM Elements +const FragmentsCache = new Map(); + +function l10n(key) { + return MENUS_L10N.getStr(key); +} + +/** + * Create a xul:menuitem element + * + * @param {HTMLDocument} doc + * The document to which menus are to be added. + * @param {String} id + * Element id. + * @param {String} label + * Menu label. + * @param {String} accesskey (optional) + * Access key of the menuitem, used as shortcut while opening the menu. + * @param {Boolean} isCheckbox (optional) + * If true, the menuitem will act as a checkbox and have an optional + * tick on its left. + * @param {String} appMenuL10nId (optional) + * A Fluent key to set the appmenu-data-l10n-id attribute of the menuitem + * to. This can then be used to show a different string when cloning the + * menuitem to show in the AppMenu or panel contexts. + * + * @return XULMenuItemElement + */ +function createMenuItem({ + doc, + id, + label, + accesskey, + isCheckbox, + appMenuL10nId, +}) { + const menuitem = doc.createXULElement("menuitem"); + menuitem.id = id; + menuitem.setAttribute("label", label); + if (accesskey) { + menuitem.setAttribute("accesskey", accesskey); + } + if (isCheckbox) { + menuitem.setAttribute("type", "checkbox"); + menuitem.setAttribute("autocheck", "false"); + } + if (appMenuL10nId) { + menuitem.setAttribute("appmenu-data-l10n-id", appMenuL10nId); + } + return menuitem; +} + +/** + * Add a menu entry for a tool definition + * + * @param {Object} toolDefinition + * Tool definition of the tool to add a menu entry. + * @param {HTMLDocument} doc + * The document to which the tool menu item is to be added. + */ +function createToolMenuElements(toolDefinition, doc) { + const id = toolDefinition.id; + const menuId = "menuitem_" + id; + + // Prevent multiple entries for the same tool. + if (doc.getElementById(menuId)) { + return null; + } + + const oncommand = async function (id, event) { + try { + const window = event.target.ownerDocument.defaultView; + await gDevToolsBrowser.selectToolCommand(window, id, Cu.now()); + sendEntryPointTelemetry(window); + } catch (e) { + console.error(`Exception while opening ${id}: ${e}\n${e.stack}`); + } + }.bind(null, id); + + const menuitem = createMenuItem({ + doc, + id: "menuitem_" + id, + label: toolDefinition.menuLabel || toolDefinition.label, + accesskey: toolDefinition.accesskey, + appMenuL10nId: toolDefinition.appMenuL10nId, + }); + // Refer to the key in order to display the key shortcut at menu ends + // This element is being created by devtools/client/devtools-startup.js + menuitem.setAttribute("key", "key_" + id); + menuitem.addEventListener("command", oncommand); + + return menuitem; +} + +/** + * Send entry point telemetry explaining how the devtools were launched when + * launched from the System Menu.. This functionality also lives inside + * `devtools/startup/devtools-startup.js` but that codepath is only used the + * first time a toolbox is opened for a tab. + */ +function sendEntryPointTelemetry(window) { + if (!telemetry) { + telemetry = new Telemetry(); + } + + telemetry.addEventProperty(window, "open", "tools", null, "shortcut", ""); + + telemetry.addEventProperty( + window, + "open", + "tools", + null, + "entrypoint", + "SystemMenu" + ); +} + +/** + * Create xul menuitem, key elements for a given tool. + * And then insert them into browser DOM. + * + * @param {HTMLDocument} doc + * The document to which the tool is to be registered. + * @param {Object} toolDefinition + * Tool definition of the tool to register. + * @param {Object} prevDef + * The tool definition after which the tool menu item is to be added. + */ +function insertToolMenuElements(doc, toolDefinition, prevDef) { + const menuitem = createToolMenuElements(toolDefinition, doc); + if (!menuitem) { + return; + } + + let ref; + if (prevDef) { + const menuitem = doc.getElementById("menuitem_" + prevDef.id); + ref = menuitem?.nextSibling ? menuitem.nextSibling : null; + } else { + ref = doc.getElementById("menu_devtools_remotedebugging"); + } + + if (ref) { + ref.parentNode.insertBefore(menuitem, ref); + } +} +exports.insertToolMenuElements = insertToolMenuElements; + +/** + * Remove a tool's menuitem from a window + * + * @param {string} toolId + * Id of the tool to add a menu entry for + * @param {HTMLDocument} doc + * The document to which the tool menu item is to be removed from + */ +function removeToolFromMenu(toolId, doc) { + const key = doc.getElementById("key_" + toolId); + if (key) { + key.remove(); + } + + const menuitem = doc.getElementById("menuitem_" + toolId); + if (menuitem) { + menuitem.remove(); + } +} +exports.removeToolFromMenu = removeToolFromMenu; + +/** + * Add all tools to the developer tools menu of a window. + * + * @param {HTMLDocument} doc + * The document to which the tool items are to be added. + */ +function addAllToolsToMenu(doc) { + const fragMenuItems = doc.createDocumentFragment(); + + for (const toolDefinition of gDevTools.getToolDefinitionArray()) { + if (!toolDefinition.inMenu) { + continue; + } + + const menuItem = createToolMenuElements(toolDefinition, doc); + + if (!menuItem) { + continue; + } + + fragMenuItems.appendChild(menuItem); + } + + const mps = doc.getElementById("menu_devtools_remotedebugging"); + if (mps) { + mps.parentNode.insertBefore(fragMenuItems, mps); + } +} + +/** + * Add global menus that are not panel specific. + * + * @param {HTMLDocument} doc + * The document to which menus are to be added. + */ +function addTopLevelItems(doc) { + const menuItems = doc.createDocumentFragment(); + + const { menuitems } = require("resource://devtools/client/menus.js"); + for (const item of menuitems) { + if (item.separator) { + const separator = doc.createXULElement("menuseparator"); + separator.id = item.id; + menuItems.appendChild(separator); + } else { + const { id, l10nKey } = item; + + // Create a + const menuitem = createMenuItem({ + doc, + id, + label: l10n(l10nKey + ".label"), + accesskey: l10n(l10nKey + ".accesskey"), + isCheckbox: item.checkbox, + appMenuL10nId: item.appMenuL10nId, + }); + menuitem.addEventListener("command", item.oncommand); + menuItems.appendChild(menuitem); + + if (item.keyId) { + menuitem.setAttribute("key", "key_" + item.keyId); + } + } + } + + // Cache all nodes before insertion to be able to remove them on unload + const nodes = []; + for (const node of menuItems.children) { + nodes.push(node); + } + FragmentsCache.set(doc, nodes); + + const menu = doc.getElementById("menuWebDeveloperPopup"); + menu.appendChild(menuItems); + + // There is still "Page Source" and "Task Manager" menuitems hardcoded + // into browser.xhtml. Instead of manually inserting everything around it, + // move them to the expected position. + const pageSourceMenu = doc.getElementById("menu_pageSource"); + const extensionsForDevelopersMenu = doc.getElementById( + "extensionsForDevelopers" + ); + menu.insertBefore(pageSourceMenu, extensionsForDevelopersMenu); + + const taskManagerMenu = doc.getElementById("menu_taskManager"); + const remoteDebuggingMenu = doc.getElementById( + "menu_devtools_remotedebugging" + ); + menu.insertBefore(taskManagerMenu, remoteDebuggingMenu); +} + +/** + * Remove global menus that are not panel specific. + * + * @param {HTMLDocument} doc + * The document to which menus are to be added. + */ +function removeTopLevelItems(doc) { + const nodes = FragmentsCache.get(doc); + if (!nodes) { + return; + } + FragmentsCache.delete(doc); + for (const node of nodes) { + node.remove(); + } +} + +/** + * Add menus to a browser document + * + * @param {HTMLDocument} doc + * The document to which menus are to be added. + */ +exports.addMenus = function (doc) { + addTopLevelItems(doc); + + addAllToolsToMenu(doc); +}; + +/** + * Remove menus from a browser document + * + * @param {HTMLDocument} doc + * The document to which menus are to be removed. + */ +exports.removeMenus = function (doc) { + // We only remove top level entries. Per-tool entries are removed while + // unregistering each tool. + removeTopLevelItems(doc); +}; diff --git a/devtools/client/framework/browser-toolbox/Launcher.sys.mjs b/devtools/client/framework/browser-toolbox/Launcher.sys.mjs new file mode 100644 index 0000000000..a619fbdff2 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/Launcher.sys.mjs @@ -0,0 +1,467 @@ +/* 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"; + +// Keep this synchronized with the value of the same name in +// toolkit/xre/nsAppRunner.cpp. +const BROWSER_TOOLBOX_WINDOW_URL = + "chrome://devtools/content/framework/browser-toolbox/window.html"; +const CHROME_DEBUGGER_PROFILE_NAME = "chrome_debugger_profile"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { require } from "resource://devtools/shared/loader/Loader.sys.mjs"; +import { + useDistinctSystemPrincipalLoader, + releaseDistinctSystemPrincipalLoader, +} from "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs"; +import { Subprocess } from "resource://gre/modules/Subprocess.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + BackgroundTasksUtils: "resource://gre/modules/BackgroundTasksUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + XreDirProvider: [ + "@mozilla.org/xre/directory-provider;1", + "nsIXREDirProvider", + ], +}); + +const Telemetry = require("resource://devtools/client/shared/telemetry.js"); +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +const processes = new Set(); + +/** + * @typedef {Object} BrowserToolboxLauncherArgs + * @property {function} onRun - A function called when the process starts running. + * @property {boolean} overwritePreferences - Set to force overwriting the toolbox + * profile's preferences with the current set of preferences. + * @property {boolean} forceMultiprocess - Set to force the Browser Toolbox to be in + * multiprocess mode. + */ + +export class BrowserToolboxLauncher extends EventEmitter { + /** + * Initializes and starts a chrome toolbox process if the appropriated prefs are enabled + * + * @param {BrowserToolboxLauncherArgs} args + * @return {BrowserToolboxLauncher|null} The created instance, or null if the required prefs + * are not set. + */ + static init(args) { + if ( + !Services.prefs.getBoolPref("devtools.chrome.enabled") || + !Services.prefs.getBoolPref("devtools.debugger.remote-enabled") + ) { + console.error("Could not start Browser Toolbox, you need to enable it."); + return null; + } + return new BrowserToolboxLauncher(args); + } + + /** + * Figure out if there are any open Browser Toolboxes that'll need to be restored. + * @return {boolean} + */ + static getBrowserToolboxSessionState() { + return processes.size !== 0; + } + + #closed; + #devToolsServer; + #dbgProfilePath; + #dbgProcess; + #listener; + #loader; + #port; + #telemetry = new Telemetry(); + + /** + * Constructor for creating a process that will hold a chrome toolbox. + * + * @param {...BrowserToolboxLauncherArgs} args + */ + constructor({ forceMultiprocess, onRun, overwritePreferences } = {}) { + super(); + + if (onRun) { + this.once("run", onRun); + } + + this.close = this.close.bind(this); + Services.obs.addObserver(this.close, "quit-application"); + this.#initServer(); + this.#initProfile(overwritePreferences); + this.#create({ forceMultiprocess }); + + processes.add(this); + } + + /** + * Initializes the devtools server. + */ + #initServer() { + if (this.#devToolsServer) { + dumpn("The chrome toolbox server is already running."); + return; + } + + dumpn("Initializing the chrome toolbox server."); + + // Create a separate loader instance, so that we can be sure to receive a + // separate instance of the DebuggingServer from the rest of the devtools. + // This allows us to safely use the tools against even the actors and + // DebuggingServer itself, especially since we can mark this loader as + // invisible to the debugger (unlike the usual loader settings). + this.#loader = useDistinctSystemPrincipalLoader(this); + const { DevToolsServer } = this.#loader.require( + "resource://devtools/server/devtools-server.js" + ); + const { SocketListener } = this.#loader.require( + "resource://devtools/shared/security/socket.js" + ); + this.#devToolsServer = DevToolsServer; + dumpn("Created a separate loader instance for the DevToolsServer."); + + this.#devToolsServer.init(); + // We mainly need a root actor and target actors for opening a toolbox, even + // against chrome/content. But the "no auto hide" button uses the + // preference actor, so also register the browser actors. + this.#devToolsServer.registerAllActors(); + this.#devToolsServer.allowChromeProcess = true; + dumpn("initialized and added the browser actors for the DevToolsServer."); + + const bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService( + Ci.nsIBackgroundTasks + ); + if (bts?.isBackgroundTaskMode) { + // A special root actor, just for background tasks invoked with + // `--backgroundtask TASK --jsdebugger`. + const { createRootActor } = this.#loader.require( + "resource://gre/modules/backgroundtasks/dbg-actors.js" + ); + this.#devToolsServer.setRootActor(createRootActor); + } + + const chromeDebuggingWebSocket = Services.prefs.getBoolPref( + "devtools.debugger.chrome-debugging-websocket" + ); + const socketOptions = { + fromBrowserToolbox: true, + portOrPath: -1, + webSocket: chromeDebuggingWebSocket, + }; + const listener = new SocketListener(this.#devToolsServer, socketOptions); + listener.open(); + this.#listener = listener; + this.#port = listener.port; + + if (!this.#port) { + throw new Error("No devtools server port"); + } + + dumpn("Finished initializing the chrome toolbox server."); + dump( + `DevTools Server for Browser Toolbox listening on port: ${this.#port}\n` + ); + } + + /** + * Initializes a profile for the remote debugger process. + */ + #initProfile(overwritePreferences) { + dumpn("Initializing the chrome toolbox user profile."); + + const bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService( + Ci.nsIBackgroundTasks + ); + + let debuggingProfileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + if (bts?.isBackgroundTaskMode) { + // Background tasks run with a temporary ephemeral profile. We move the + // browser toolbox profile out of that ephemeral profile so that it has + // alonger life then the background task profile. This preserves + // breakpoints, etc, across repeated debugging invocations. This + // directory is close to the background task temporary profile name(s), + // but doesn't match the prefix that will get purged by the stale + // ephemeral profile cleanup mechanism. + // + // For example, the invocation + // `firefox --backgroundtask success --jsdebugger --wait-for-jsdebugger` + // might run with ephemeral profile + // `/tmp/MozillaBackgroundTask--success` + // and sibling directory browser toolbox profile + // `/tmp/MozillaBackgroundTask--chrome_debugger_profile-success` + // + // See `BackgroundTasks::Shutdown` for ephemeral profile cleanup details. + debuggingProfileDir = debuggingProfileDir.parent; + debuggingProfileDir.append( + `${Services.appinfo.vendor}BackgroundTask-` + + `${lazy.XreDirProvider.getInstallHash()}-${CHROME_DEBUGGER_PROFILE_NAME}-${bts.backgroundTaskName()}` + ); + } else { + debuggingProfileDir.append(CHROME_DEBUGGER_PROFILE_NAME); + } + try { + debuggingProfileDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + } catch (ex) { + if (ex.result === Cr.NS_ERROR_FILE_ALREADY_EXISTS) { + if (!overwritePreferences) { + this.#dbgProfilePath = debuggingProfileDir.path; + return; + } + // Fall through and copy the current set of prefs to the profile. + } else { + dumpn("Error trying to create a profile directory, failing."); + dumpn("Error: " + (ex.message || ex)); + return; + } + } + + this.#dbgProfilePath = debuggingProfileDir.path; + + // We would like to copy prefs into this new profile... + const prefsFile = debuggingProfileDir.clone(); + prefsFile.append("prefs.js"); + + if (bts?.isBackgroundTaskMode) { + // Background tasks run under a temporary profile. In order to set + // preferences for the launched browser toolbox, take the preferences from + // the default profile. This is the standard pattern for controlling + // background task settings. Without this, there'd be no way to increase + // logging in the browser toolbox process, etc. + const defaultProfile = lazy.BackgroundTasksUtils.getDefaultProfile(); + if (!defaultProfile) { + throw new Error( + "Cannot start Browser Toolbox from background task with no default profile" + ); + } + + const defaultPrefsFile = defaultProfile.rootDir.clone(); + defaultPrefsFile.append("prefs.js"); + defaultPrefsFile.copyTo(prefsFile.parent, prefsFile.leafName); + + dumpn( + `Copied browser toolbox prefs at '${prefsFile.path}'` + + ` from default profiles prefs at '${defaultPrefsFile.path}'` + ); + } else { + // ... but unfortunately, when we run tests, it seems the starting profile + // clears out the prefs file before re-writing it, and in practice the + // file is empty when we get here. So just copying doesn't work in that + // case. + // We could force a sync pref flush and then copy it... but if we're doing + // that, we might as well just flush directly to the new profile, which + // always works: + Services.prefs.savePrefFile(prefsFile); + } + + dumpn( + "Finished creating the chrome toolbox user profile at: " + + this.#dbgProfilePath + ); + } + + /** + * Creates and initializes the profile & process for the remote debugger. + * + * @param {Object} options + * @param {boolean} options.forceMultiprocess: Set to true to force the Browser Toolbox to be in + * multiprocess mode. + */ + #create({ forceMultiprocess } = {}) { + dumpn("Initializing chrome debugging process."); + + let command = Services.dirsvc.get("XREExeF", Ci.nsIFile).path; + let profilePath = this.#dbgProfilePath; + + // MOZ_BROWSER_TOOLBOX_BINARY is an absolute file path to a custom firefox binary. + // This is especially useful when debugging debug builds which are really slow + // so that you could pass an optimized build for the browser toolbox. + // This is also useful when debugging a patch that break devtools, + // so that you could use a build that works for the browser toolbox. + const customBinaryPath = Services.env.get("MOZ_BROWSER_TOOLBOX_BINARY"); + if (customBinaryPath) { + command = customBinaryPath; + profilePath = PathUtils.join(PathUtils.tempDir, "browserToolboxProfile"); + } + + dumpn("Running chrome debugging process."); + const args = [ + "-no-remote", + "-foreground", + "-profile", + profilePath, + "-chrome", + BROWSER_TOOLBOX_WINDOW_URL, + ]; + + const isInputContextEnabled = Services.prefs.getBoolPref( + "devtools.webconsole.input.context", + false + ); + const environment = { + // Allow recording the startup of the browser toolbox when setting + // MOZ_BROWSER_TOOLBOX_PROFILER_STARTUP=1 when running firefox. + MOZ_PROFILER_STARTUP: Services.env.get( + "MOZ_BROWSER_TOOLBOX_PROFILER_STARTUP" + ), + // And prevent profiling any subsequent toolbox + MOZ_BROWSER_TOOLBOX_PROFILER_STARTUP: "0", + + MOZ_BROWSER_TOOLBOX_FORCE_MULTIPROCESS: forceMultiprocess ? "1" : "0", + // Similar, but for the WebConsole input context dropdown. + MOZ_BROWSER_TOOLBOX_INPUT_CONTEXT: isInputContextEnabled ? "1" : "0", + // Disable safe mode for the new process in case this was opened via the + // keyboard shortcut. + MOZ_DISABLE_SAFE_MODE_KEY: "1", + MOZ_BROWSER_TOOLBOX_PORT: String(this.#port), + MOZ_HEADLESS: null, + // Never enable Marionette for the new process. + MOZ_MARIONETTE: null, + // Don't inherit debug settings from the process launching us. This can + // cause errors when log files collide. + MOZ_LOG: null, + MOZ_LOG_FILE: null, + XPCOM_MEM_BLOAT_LOG: null, + XPCOM_MEM_LEAK_LOG: null, + XPCOM_MEM_LOG_CLASSES: null, + XPCOM_MEM_REFCNT_LOG: null, + XRE_PROFILE_PATH: null, + XRE_PROFILE_LOCAL_PATH: null, + }; + + // During local development, incremental builds can trigger the main process + // to clear its startup cache with the "flag file" .purgecaches, but this + // file is removed during app startup time, so we aren't able to know if it + // was present in order to also clear the child profile's startup cache as + // well. + // + // As an approximation of "isLocalBuild", check for an unofficial build. + if (!AppConstants.MOZILLA_OFFICIAL) { + args.push("-purgecaches"); + } + + dump(`Starting Browser Toolbox ${command} ${args.join(" ")}\n`); + IOUtils.makeDirectory(profilePath, { ignoreExisting: true }) + .then(() => + Subprocess.call({ + command, + arguments: args, + environmentAppend: true, + stderr: "stdout", + environment, + }) + ) + .then(proc => { + this.#dbgProcess = proc; + + this.#telemetry.toolOpened("jsbrowserdebugger", this); + + dumpn("Chrome toolbox is now running..."); + this.emit("run", this, proc, this.#dbgProfilePath); + + proc.stdin.close(); + const dumpPipe = async pipe => { + let leftover = ""; + let data = await pipe.readString(); + while (data) { + data = leftover + data; + const lines = data.split(/\r\n|\r|\n/); + if (lines.length) { + for (const line of lines.slice(0, -1)) { + dump(`${proc.pid}> ${line}\n`); + } + leftover = lines[lines.length - 1]; + } + data = await pipe.readString(); + } + if (leftover) { + dump(`${proc.pid}> ${leftover}\n`); + } + }; + dumpPipe(proc.stdout); + + proc.wait().then(() => this.close()); + + return proc; + }) + .catch(err => { + console.log( + `Error loading Browser Toolbox: ${command} ${args.join(" ")}`, + err + ); + }); + } + + /** + * Closes the remote debugging server and kills the toolbox process. + */ + async close() { + if (this.#closed) { + return; + } + + this.#closed = true; + + dumpn("Cleaning up the chrome debugging process."); + + Services.obs.removeObserver(this.close, "quit-application"); + + // We tear down before killing the browser toolbox process to avoid leaking + // socket connection objects. + if (this.#listener) { + this.#listener.close(); + } + + // Note that the DevToolsServer can be shared with the DevToolsServer + // spawned by DevToolsFrameChild. We shouldn't destroy it from here. + // Instead we should let it auto-destroy itself once the last connection is closed. + this.#devToolsServer = null; + + this.#dbgProcess.stdout.close(); + await this.#dbgProcess.kill(); + + this.#telemetry.toolClosed("jsbrowserdebugger", this); + + dumpn("Chrome toolbox is now closed..."); + processes.delete(this); + + this.#dbgProcess = null; + if (this.#loader) { + releaseDistinctSystemPrincipalLoader(this); + } + this.#loader = null; + this.#telemetry = null; + } +} + +/** + * Helper method for debugging. + * @param string + */ +function dumpn(str) { + if (wantLogging) { + dump("DBG-FRONTEND: " + str + "\n"); + } +} + +var wantLogging = Services.prefs.getBoolPref("devtools.debugger.log"); +const prefObserver = { + observe: (...args) => { + wantLogging = Services.prefs.getBoolPref(args.pop()); + }, +}; +Services.prefs.addObserver("devtools.debugger.log", prefObserver); +const unloadObserver = function (subject) { + if (subject.wrappedJSObject == require("@loader/unload")) { + Services.prefs.removeObserver("devtools.debugger.log", prefObserver); + Services.obs.removeObserver(unloadObserver, "devtools:loader:destroy"); + } +}; +Services.obs.addObserver(unloadObserver, "devtools:loader:destroy"); diff --git a/devtools/client/framework/browser-toolbox/README.md b/devtools/client/framework/browser-toolbox/README.md new file mode 100644 index 0000000000..5f7b97aad7 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/README.md @@ -0,0 +1,37 @@ +# Browser Toolbox + +## Introduction + +The Browser Toolbox spawns a toolbox in a new dedicated Firefox instance to debug the currently running Firefox. This new instance runs in a distinct process. + +To enable it, you must first flip two preferences in the DevTools Options panel (F1): +- Enable browser chrome and add-on debugging toolboxes +- Enable remote debugging + +You can either start it via a keyboard shortcut (CmdOrCtrl+Alt+Shift+I) or via the Tools > Browser Tools > Browser Toolbox menu item. + +When describing the setup used by the Browser Toolbox, we will refer to those two distinct Firefox instances as: +- the target Firefox: this is the current instance, that we want to debug +- the client Firefox: this is the new instance that will only run the Browser Toolbox window + +## Browser Toolbox Architecture + +The startup sequence of the browser toolbox begins in the target Firefox. + +`browser-toolbox/Launcher.sys.mjs` will be first reponsible for creating a remote DevToolsServer. This new DevToolsServer runs in the parent process but is separated from any existing DevTools DevToolsServer that spawned earlier for regular DevTools usage. Thanks to this, we will be able to debug files loaded in those regular DevToolsServers used for content toolboxes, about:debugging, ... + +Then we need to start the client Firefox. To do that, `browser-toolbox/Launcher.sys.mjs` creates a profile that will be a copy of the current profile loaded in the target Firefox, so that all user preferences can be automatically ported over. As a reminder both client and target Firefox will run simultaneously, so they can't use the same profile. + +This new profile is stored inside the folder of the target profile, in a `chrome_debugger_profile` folder. So the next time the Browser Toolbox opens this for profile, it will be reused. + +Once the profile is ready (or if it was already there), `browser-toolbox/Launcher.sys.mjs` spawns a new Firefox instance with a few additional parameters, most importantly `-chrome chrome://devtools/content/framework/browser-toolbox/window.html`. + +This way Firefox will load `browser-toolbox/window.html` instead of the regular browser window. Most of the logic is then handled by `browser-toolbox/window.js` which will connect to the remote server opened on the target Firefox and will then load a toolbox connected to this server. + +## Debugging the Browser Toolbox + +Note that you can open a Browser Toolbox from the Browser Toolbox. Simply reuse the same shortcut as the one you used to open the first Browser Toolbox, but this time while the Browser Toolbox window is focused. + +Another Browser Toolbox will spawn, this time debugging the first Browser Toolbox Firefox instance. If you are curious about how this is done, `browser-toolbox/window.js` simply loads `browser-toolbox/Launcher.sys.mjs` and requests to open a new Browser Toolbox. + +This will open yet another Firefox instance, running in another process. And a new `chrome_debugger_profile` folder will be created inside the existing Browser Toolbox profile (which as explained in the previous section, is already in a `chrome_debugger_profile` folder under the target Firefox profile). diff --git a/devtools/client/framework/browser-toolbox/moz.build b/devtools/client/framework/browser-toolbox/moz.build new file mode 100644 index 0000000000..f04fedb0a4 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/moz.build @@ -0,0 +1,13 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +BROWSER_CHROME_MANIFESTS += [ + "test/browser.toml", +] + +DevToolsModules( + "Launcher.sys.mjs", +) diff --git a/devtools/client/framework/browser-toolbox/test/browser.toml b/devtools/client/framework/browser-toolbox/test/browser.toml new file mode 100644 index 0000000000..1fc6dcaa39 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser.toml @@ -0,0 +1,53 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +skip-if = ["asan"] # UNTIL Bug 1591064 IS FIXED ALL NEW TESTS SHOULD BE SKIPPED ON ASAN +support-files = [ + "doc_browser_toolbox_fission_contentframe_inspector_frame.html", + "doc_browser_toolbox_fission_contentframe_inspector_page.html", + "doc_browser_toolbox_ruleview_stylesheet.html", + "style_browser_toolbox_ruleview_stylesheet.css", + "head.js", + "helpers-browser-toolbox.js", + "!/devtools/client/debugger/test/mochitest/shared-head.js", + "!/devtools/client/inspector/test/shared-head.js", + "!/devtools/client/shared/test/shared-head.js", + "!/devtools/client/shared/test/telemetry-test-helpers.js", + "!/devtools/client/shared/test/highlighter-test-actor.js", +] +prefs = ["security.allow_unsafe_parent_loads=true"] # This is far from ideal. Bug 1565279 covers removing this pref flip. + +["browser_browser_toolbox.js"] + +["browser_browser_toolbox_debugger.js"] +skip-if = ["os == 'linux' && bits == 64 && debug"] # Bug 1756616 + +["browser_browser_toolbox_evaluation_context.js"] + +["browser_browser_toolbox_fission_contentframe_inspector.js"] +skip-if = ["os == 'linux' && bits == 64 && debug"] # Bug 1604751 + +["browser_browser_toolbox_fission_inspector.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_browser_toolbox_fission_inspector_webextension.js"] + +["browser_browser_toolbox_l10n_buttons.js"] + +["browser_browser_toolbox_navigate_tab.js"] + +["browser_browser_toolbox_netmonitor.js"] +skip-if = ["os == 'linux' && bits == 64 && !debug"] # Bug 1777831 + +["browser_browser_toolbox_print_preview.js"] + +["browser_browser_toolbox_rtl.js"] + +["browser_browser_toolbox_ruleview_stylesheet.js"] +skip-if = ["os == 'mac' && fission"] # high frequency intermittent + +["browser_browser_toolbox_shouldprocessupdates.js"] + +["browser_browser_toolbox_unavailable_children.js"] + +["browser_browser_toolbox_watchedByDevTools.js"] diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox.js new file mode 100644 index 0000000000..29d8856b05 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +add_task(async function () { + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({}); + + const hasCloseButton = await ToolboxTask.spawn(null, async () => { + /* global gToolbox */ + return !!gToolbox.doc.getElementById("toolbox-close"); + }); + ok(!hasCloseButton, "Browser toolbox doesn't have a close button"); + + info("Trigger F5 key shortcut and ensure nothing happens"); + info( + "If F5 triggers a full reload, the mochitest will stop here as firefox instance will be restarted" + ); + const previousInnerWindowId = + window.browsingContext.currentWindowGlobal.innerWindowId; + function onUnload() { + ok(false, "The top level window shouldn't be reloaded/closed"); + } + window.addEventListener("unload", onUnload); + await ToolboxTask.spawn(null, async () => { + const isMacOS = Services.appinfo.OS === "Darwin"; + const { win } = gToolbox; + // Simulate CmdOrCtrl+R + win.dispatchEvent( + new win.KeyboardEvent("keydown", { + bubbles: true, + ctrlKey: !isMacOS, + metaKey: isMacOS, + keyCode: "r".charCodeAt(0), + }) + ); + // Simulate F5 + win.dispatchEvent( + new win.KeyboardEvent("keydown", { + bubbles: true, + keyCode: win.KeyEvent.DOM_VK_F5, + }) + ); + }); + + // Let a chance to trigger the regression where the top level document closes or reloads + await wait(1000); + + is( + window.browsingContext.currentWindowGlobal.innerWindowId, + previousInnerWindowId, + "Check the browser.xhtml wasn't reloaded when pressing F5" + ); + window.removeEventListener("unload", onUnload); + + await ToolboxTask.destroy(); +}); diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_debugger.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_debugger.js new file mode 100644 index 0000000000..edcba359e2 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_debugger.js @@ -0,0 +1,222 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test asserts that the new debugger works from the browser toolbox process + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +// On debug test runner, it takes about 50s to run the test. +requestLongerTimeout(4); + +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +const { fetch } = require("resource://devtools/shared/DevToolsUtils.js"); + +const debuggerHeadURL = + CHROME_URL_ROOT + "../../../debugger/test/mochitest/shared-head.js"; + +add_task(async function runTest() { + let { content: debuggerHead } = await fetch(debuggerHeadURL); + + // We remove its import of shared-head, which isn't available in browser toolbox process + // And isn't needed thanks to testHead's symbols + debuggerHead = debuggerHead.replace( + /Services.scriptloader.loadSubScript[^\)]*\);/g, + "" + ); + + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({ + // head.js uses this method + registerCleanupFunction: () => {}, + waitForDispatch, + waitUntil, + }); + await ToolboxTask.importScript(debuggerHead); + + info("### First test breakpoint in the parent process script"); + const s = Cu.Sandbox("http://mozilla.org"); + + // Use a unique id for the fake script name in order to be able to run + // this test more than once. That's because the Sandbox is not immediately + // destroyed and so the debugger would display only one file but not necessarily + // connected to the latest sandbox. + const id = new Date().getTime(); + + // Pass a fake URL to evalInSandbox. If we just pass a filename, + // Debugger is going to fail and only display root folder (`/`) listing. + // But it won't try to fetch this url and use sandbox content as expected. + const testUrl = `http://mozilla.org/browser-toolbox-test-${id}.js`; + Cu.evalInSandbox( + `this.plop = function plop() { + const foo = 1; + return foo; +};`, + s, + "1.8", + testUrl, + 0 + ); + + // Execute the function every second in order to trigger the breakpoint + const interval = setInterval(s.plop, 1000); + + await ToolboxTask.spawn(testUrl, async _testUrl => { + /* global gToolbox, createDebuggerContext, waitForSources, waitForPaused, + addBreakpoint, assertPausedAtSourceAndLine, stepIn, findSource, + removeBreakpoint, resume, selectSource, assertNotPaused, assertBreakpoint, + assertTextContentOnLine, waitForResumed */ + Services.prefs.clearUserPref("devtools.debugger.tabs"); + Services.prefs.clearUserPref("devtools.debugger.pending-selected-location"); + + info("Waiting for debugger load"); + await gToolbox.selectTool("jsdebugger"); + const dbg = createDebuggerContext(gToolbox); + + await waitForSources(dbg, _testUrl); + + info("Loaded, selecting the test script to debug"); + const fileName = _testUrl.match(/browser-toolbox-test.*\.js/)[0]; + await selectSource(dbg, fileName); + + info("Add a breakpoint and wait to be paused"); + const onPaused = waitForPaused(dbg); + await addBreakpoint(dbg, fileName, 2); + await onPaused; + + const source = findSource(dbg, fileName); + assertPausedAtSourceAndLine(dbg, source.id, 2); + assertTextContentOnLine(dbg, 2, "const foo = 1;"); + is( + dbg.selectors.getBreakpointCount(), + 1, + "There is exactly one breakpoint" + ); + + await stepIn(dbg); + + assertPausedAtSourceAndLine(dbg, source.id, 3); + assertTextContentOnLine(dbg, 3, "return foo;"); + is( + dbg.selectors.getBreakpointCount(), + 1, + "We still have only one breakpoint after step-in" + ); + + // Remove the breakpoint before resuming in order to prevent hitting the breakpoint + // again during test closing. + await removeBreakpoint(dbg, source.id, 2); + + await resume(dbg); + + // Let a change for the interval to re-execute + await new Promise(r => setTimeout(r, 1000)); + + is(dbg.selectors.getBreakpointCount(), 0, "There is no more breakpoints"); + + assertNotPaused(dbg); + }); + + clearInterval(interval); + + info("### Now test breakpoint in a privileged content process script"); + const testUrl2 = `http://mozilla.org/content-process-test-${id}.js`; + await SpecialPowers.spawn(gBrowser.selectedBrowser, [testUrl2], testUrl => { + // Use a sandbox in order to have a URL to set a breakpoint + const s = Cu.Sandbox("http://mozilla.org"); + Cu.evalInSandbox( + `this.foo = function foo() { + const plop = 1; + return plop; +};`, + s, + "1.8", + testUrl, + 0 + ); + content.interval = content.setInterval(s.foo, 1000); + }); + await ToolboxTask.spawn(testUrl2, async _testUrl => { + const dbg = createDebuggerContext(gToolbox); + + const fileName = _testUrl.match(/content-process-test.*\.js/)[0]; + await waitForSources(dbg, _testUrl); + + await selectSource(dbg, fileName); + + const onPaused = waitForPaused(dbg); + await addBreakpoint(dbg, fileName, 2); + await onPaused; + + const source = findSource(dbg, fileName); + assertPausedAtSourceAndLine(dbg, source.id, 2); + assertTextContentOnLine(dbg, 2, "const plop = 1;"); + await assertBreakpoint(dbg, 2); + is(dbg.selectors.getBreakpointCount(), 1, "We have exactly one breakpoint"); + + await stepIn(dbg); + + assertPausedAtSourceAndLine(dbg, source.id, 3); + assertTextContentOnLine(dbg, 3, "return plop;"); + is( + dbg.selectors.getBreakpointCount(), + 1, + "We still have only one breakpoint after step-in" + ); + + // Remove the breakpoint before resuming in order to prevent hitting the breakpoint + // again during test closing. + await removeBreakpoint(dbg, source.id, 2); + + await resume(dbg); + + // Let a change for the interval to re-execute + await new Promise(r => setTimeout(r, 1000)); + + is(dbg.selectors.getBreakpointCount(), 0, "There is no more breakpoints"); + + assertNotPaused(dbg); + }); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.clearInterval(content.interval); + }); + + info("Trying pausing in a content process that crashes"); + + const crashingUrl = + "data:text/html,"; + const crashingTab = await addTab(crashingUrl); + await ToolboxTask.spawn(crashingUrl, async url => { + const dbg = createDebuggerContext(gToolbox); + await waitForPaused(dbg); + const source = findSource(dbg, url); + assertPausedAtSourceAndLine(dbg, source.id, 1); + const thread = dbg.selectors.getThread(dbg.selectors.getCurrentThread()); + is(thread.isTopLevel, false, "The current thread is not the top level one"); + is(thread.targetType, "process", "The current thread is the tab one"); + }); + + info( + "Crash the tab and ensure the debugger resumes and switch to the main thread" + ); + await BrowserTestUtils.crashFrame(crashingTab.linkedBrowser); + + await ToolboxTask.spawn(null, async () => { + const dbg = createDebuggerContext(gToolbox); + await waitForResumed(dbg); + const thread = dbg.selectors.getThread(dbg.selectors.getCurrentThread()); + is(thread.isTopLevel, true, "The current thread is the top level one"); + }); + + await removeTab(crashingTab); + + await ToolboxTask.destroy(); +}); diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_evaluation_context.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_evaluation_context.js new file mode 100644 index 0000000000..34e18d15c5 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_evaluation_context.js @@ -0,0 +1,199 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +// This test is used to test fission-like features via the Browser Toolbox: +// - the evaluation context selector in the console show the right targets +// - the iframe dropdown also show the right targets +// - both are updated accordingly when toggle to parent-process only scope + +add_task(async function () { + // Forces the Browser Toolbox to open on the console by default + await pushPref("devtools.browsertoolbox.panel", "webconsole"); + await pushPref("devtools.webconsole.input.context", true); + // Force EFT to have targets for all WindowGlobals + await pushPref("devtools.every-frame-target.enabled", true); + // Enable Multiprocess Browser Toolbox + await pushPref("devtools.browsertoolbox.scope", "everything"); + + // Open the test *before* opening the Browser toolbox in order to have the right target title. + // Once created, the target won't update its title, and so would be "New Tab", instead of "Test tab" + const tab = await addTab( + "https://example.com/document-builder.sjs?html=Test tab" + ); + + const ToolboxTask = await initBrowserToolboxTask(); + + await ToolboxTask.importFunctions({ + waitUntil, + getContextLabels, + getFramesLabels, + }); + + const tabProcessID = + tab.linkedBrowser.browsingContext.currentWindowGlobal.osPid; + + const decodedTabURI = decodeURI(tab.linkedBrowser.currentURI.spec); + + await ToolboxTask.spawn( + [tabProcessID, isFissionEnabled(), decodedTabURI], + async (processID, _isFissionEnabled, tabURI) => { + /* global gToolbox */ + const { hud } = await gToolbox.getPanel("webconsole"); + + const evaluationContextSelectorButton = hud.ui.outputNode.querySelector( + ".webconsole-evaluation-selector-button" + ); + + is( + !!evaluationContextSelectorButton, + true, + "The evaluation context selector is visible" + ); + is( + evaluationContextSelectorButton.innerText, + "Top", + "The button has the expected 'Top' text" + ); + + const labelTexts = getContextLabels(gToolbox); + + const expectedTitle = _isFissionEnabled + ? `(pid ${processID}) https://example.com` + : `(pid ${processID}) web`; + ok( + labelTexts.includes(expectedTitle), + `${processID} content process visible in the execution context (${labelTexts})` + ); + + ok( + labelTexts.includes(`Test tab`), + `Test tab is visible in the execution context (${labelTexts})` + ); + + // Also assert the behavior of the iframe dropdown and the mode selector + info("Check the iframe dropdown, start by opening it"); + const btn = gToolbox.doc.getElementById("command-button-frames"); + btn.click(); + + const panel = gToolbox.doc.getElementById("command-button-frames-panel"); + ok(panel, "popup panel has created."); + await waitUntil( + () => panel.classList.contains("tooltip-visible"), + "Wait for the menu to be displayed" + ); + + is( + getFramesLabels(gToolbox)[0], + "chrome://browser/content/browser.xhtml", + "The iframe dropdown lists first browser.xhtml, running in the parent process" + ); + ok( + getFramesLabels(gToolbox).includes(tabURI), + "The iframe dropdown lists the tab document, running in the content process" + ); + + // Click on top frame to hide the iframe picker, so clicks on other elements can be registered. + gToolbox.doc.querySelector("#toolbox-frame-menu .command").click(); + + await waitUntil( + () => !panel.classList.contains("tooltip-visible"), + "Wait for the menu to be hidden" + ); + + info("Check that the ChromeDebugToolbar is displayed"); + const chromeDebugToolbar = gToolbox.doc.querySelector( + ".chrome-debug-toolbar" + ); + ok(!!chromeDebugToolbar, "ChromeDebugToolbar is displayed"); + const chromeDebugToolbarScopeInputs = Array.from( + chromeDebugToolbar.querySelectorAll(`[name="chrome-debug-mode"]`) + ); + is( + chromeDebugToolbarScopeInputs.length, + 2, + "There are 2 mode inputs in the chromeDebugToolbar" + ); + const [ + chromeDebugToolbarParentProcessModeInput, + chromeDebugToolbarMultiprocessModeInput, + ] = chromeDebugToolbarScopeInputs; + is( + chromeDebugToolbarParentProcessModeInput.value, + "parent-process", + "Got expected value for the first input" + ); + is( + chromeDebugToolbarMultiprocessModeInput.value, + "everything", + "Got expected value for the second input" + ); + ok( + chromeDebugToolbarMultiprocessModeInput.checked, + "The multiprocess mode is selected" + ); + + info( + "Click on the parent-process input and check that it restricts the targets" + ); + chromeDebugToolbarParentProcessModeInput.click(); + info("Wait for the iframe dropdown to hide the tab target"); + await waitUntil(() => { + return !getFramesLabels(gToolbox).includes(tabURI); + }); + + info("Wait for the context selector to hide the tab context"); + await waitUntil(() => { + return !getContextLabels(gToolbox).includes(`Test tab`); + }); + + ok( + !chromeDebugToolbarMultiprocessModeInput.checked, + "Now, the multiprocess mode is disabled…" + ); + ok( + chromeDebugToolbarParentProcessModeInput.checked, + "…and the parent process mode is enabled" + ); + + info("Switch back to multiprocess mode"); + chromeDebugToolbarMultiprocessModeInput.click(); + + info("Wait for the iframe dropdown to show again the tab target"); + await waitUntil(() => { + return getFramesLabels(gToolbox).includes(tabURI); + }); + + info("Wait for the context selector to show again the tab context"); + await waitUntil(() => { + return getContextLabels(gToolbox).includes(`Test tab`); + }); + } + ); + + await ToolboxTask.destroy(); +}); + +function getContextLabels(toolbox) { + // Note that the context menu is in the top level chrome document (toolbox.xhtml) + // instead of webconsole.xhtml. + const labels = toolbox.doc.querySelectorAll( + "#webconsole-console-evaluation-context-selector-menu-list li .label" + ); + return Array.from(labels).map(item => item.textContent); +} + +function getFramesLabels(toolbox) { + return Array.from( + toolbox.doc.querySelectorAll("#toolbox-frame-menu .command .label") + ).map(el => el.textContent); +} diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_contentframe_inspector.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_contentframe_inspector.js new file mode 100644 index 0000000000..ba2ab2779c --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_contentframe_inspector.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js", + this +); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +/** + * Check that different-site iframes can be expanded in the Omniscient Browser + * Toolbox. The test is supposed to run successfully with or without fission. + * Pass --enable-fission to ./mach test to enable fission when running this + * test locally. + */ +add_task(async function () { + await pushPref("devtools.browsertoolbox.scope", "everything"); + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({ + getNodeFront, + getNodeFrontInFrames, + selectNode, + // selectNodeInFrames depends on selectNode, getNodeFront, getNodeFrontInFrames. + selectNodeInFrames, + }); + + const tab = await addTab( + `https://example.com/browser/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_fission_contentframe_inspector_page.html` + ); + + // Set a custom attribute on the tab's browser, in order to easily select it in the markup view + tab.linkedBrowser.setAttribute("test-tab", "true"); + + const testAttribute = await ToolboxTask.spawn(null, async () => { + /* global gToolbox */ + const inspector = await gToolbox.selectTool("inspector"); + const onSidebarSelect = inspector.sidebar.once("select"); + inspector.sidebar.select("computedview"); + await onSidebarSelect; + + info("Select the test element nested in the remote iframe"); + const nodeFront = await selectNodeInFrames( + ['browser[remote="true"][test-tab]', "iframe", "#inside-iframe"], + inspector + ); + + return nodeFront.getAttribute("test-attribute"); + }); + + is( + testAttribute, + "fission", + "Could successfully read attribute on a node inside a remote iframe" + ); + + await ToolboxTask.destroy(); +}); diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_inspector.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_inspector.js new file mode 100644 index 0000000000..24c7fa7918 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_inspector.js @@ -0,0 +1,220 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js", + this +); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +// This test is used to test fission-like features via the Browser Toolbox: +// - computed view is correct when selecting an element in a remote frame + +add_task(async function () { + // Forces the Browser Toolbox to open on the inspector by default + await pushPref("devtools.browsertoolbox.panel", "inspector"); + // Enable Multiprocess Browser Toolbox + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({ + getNodeFront, + getNodeFrontInFrames, + selectNode, + // selectNodeInFrames depends on selectNode, getNodeFront, getNodeFrontInFrames. + selectNodeInFrames, + }); + + // Open the tab *after* opening the Browser Toolbox in order to force creating the remote frames + // late and exercise frame target watching code. + const tab = await addTab( + `data:text/html,
Foo
Foo
` + ); + // Set a custom attribute on the tab's browser, in order to easily select it in the markup view + tab.linkedBrowser.setAttribute("test-tab", "true"); + + const color = await ToolboxTask.spawn(null, async () => { + /* global gToolbox */ + const inspector = gToolbox.getPanel("inspector"); + const onSidebarSelect = inspector.sidebar.once("select"); + inspector.sidebar.select("computedview"); + await onSidebarSelect; + + await selectNodeInFrames( + ['browser[remote="true"][test-tab]', "#my-div"], + inspector + ); + + const view = inspector.getPanel("computedview").computedView; + function getProperty(name) { + const propertyViews = view.propertyViews; + for (const propView of propertyViews) { + if (propView.name == name) { + return propView; + } + } + return null; + } + const prop = getProperty("color"); + return prop.valueNode.textContent; + }); + + is( + color, + "rgb(255, 0, 0)", + "The color property of the
within a tab isn't red" + ); + + info("Check that the node picker can be used on element in the content page"); + await pickNodeInContentPage( + ToolboxTask, + tab, + "browser[test-tab]", + "#second-div" + ); + const secondColor = await ToolboxTask.spawn(null, async () => { + const inspector = gToolbox.getPanel("inspector"); + + is( + inspector.selection.nodeFront.id, + "second-div", + "The expected element is selected in the inspector" + ); + + const view = inspector.getPanel("computedview").computedView; + function getProperty(name) { + const propertyViews = view.propertyViews; + for (const propView of propertyViews) { + if (propView.name == name) { + return propView; + } + } + return null; + } + const prop = getProperty("color"); + return prop.valueNode.textContent; + }); + + is( + secondColor, + "rgb(0, 0, 255)", + "The color property of the
within a tab isn't blue" + ); + + info( + "Check that the node picker can be used for element in non-remote " + ); + const nonRemoteUrl = "about:robots"; + const nonRemoteTab = await addTab(nonRemoteUrl); + // Set a custom attribute on the tab's browser, in order to target it + nonRemoteTab.linkedBrowser.setAttribute("test-tab-non-remote", ""); + + // check that the browser element is indeed not remote. If that changes for about:robots, + // this should be replaced with another page + is( + nonRemoteTab.linkedBrowser.hasAttribute("remote"), + false, + "The element for about:robots is not remote" + ); + + await pickNodeInContentPage( + ToolboxTask, + nonRemoteTab, + "browser[test-tab-non-remote]", + "#errorTryAgain" + ); + + await ToolboxTask.spawn(null, async () => { + const inspector = gToolbox.getPanel("inspector"); + is( + inspector.selection.nodeFront.id, + "errorTryAgain", + "The element inside a non-remote element is selected in the inspector" + ); + }); + + await ToolboxTask.destroy(); +}); + +async function pickNodeInContentPage( + ToolboxTask, + tab, + browserElementSelector, + contentElementSelector +) { + await ToolboxTask.spawn(contentElementSelector, async _selector => { + const onPickerStarted = gToolbox.nodePicker.once("picker-started"); + + // Wait until the inspector front was initialized in the target that + // contains the element we want to pick. + // Otherwise, even if the picker is "started", the corresponding WalkerActor + // might not be listening to the correct pick events (WalkerActor::pick) + const onPickerReady = new Promise(resolve => { + gToolbox.nodePicker.on( + "inspector-front-ready-for-picker", + async function onFrontReady(walker) { + if (await walker.querySelector(walker.rootNode, _selector)) { + gToolbox.nodePicker.off( + "inspector-front-ready-for-picker", + onFrontReady + ); + resolve(); + } + } + ); + }); + + gToolbox.nodePicker.start(); + await onPickerStarted; + await onPickerReady; + + const inspector = gToolbox.getPanel("inspector"); + + // Save the promises for later tasks, in order to start listening + // *before* hovering the element and wait for resolution *after* hovering. + this.onPickerStopped = gToolbox.nodePicker.once("picker-stopped"); + this.onInspectorUpdated = inspector.once("inspector-updated"); + }); + + // Retrieve the position of the element we want to pick in the content page + const { x, y } = await SpecialPowers.spawn( + tab.linkedBrowser, + [contentElementSelector], + _selector => { + const rect = content.document + .querySelector(_selector) + .getBoundingClientRect(); + return { x: rect.x, y: rect.y }; + } + ); + + // Synthesize the mouse event in the top level browsing context, but on the + // element containing the tab we're looking at, at the position where should be the + // content element. + // We need to do this to mimick what's actually done in node-picker.js + await EventUtils.synthesizeMouse( + document.querySelector(browserElementSelector), + x + 5, + y + 5, + {} + ); + + await ToolboxTask.spawn(null, async () => { + info(" # Waiting for picker stop"); + await this.onPickerStopped; + info(" # Waiting for inspector-updated"); + await this.onInspectorUpdated; + + delete this.onPickerStopped; + delete this.onInspectorUpdated; + }); +} diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_inspector_webextension.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_inspector_webextension.js new file mode 100644 index 0000000000..4f8a2f7535 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_fission_inspector_webextension.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js", + this +); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +// Test that expanding a browser element of a webextension in the browser toolbox works +// as expected (See Bug 1696862). + +add_task(async function () { + const extension = ExtensionTestUtils.loadExtension({ + // manifest_version: 2, + manifest: { + sidebar_action: { + default_title: "SideBarExtensionTest", + default_panel: "sidebar.html", + }, + }, + useAddonManager: "temporary", + + files: { + "sidebar.html": ` + + + + + + + +

Sidebar Extension Test

+ + `, + "sidebar.js": function () { + window.onload = () => { + // eslint-disable-next-line no-undef + browser.test.sendMessage("sidebar-ready"); + }; + }, + }, + }); + await extension.startup(); + await extension.awaitMessage("sidebar-ready"); + + ok(true, "Extension sidebar is displayed"); + + // Forces the Browser Toolbox to open on the inspector by default + await pushPref("devtools.browsertoolbox.panel", "inspector"); + // Enable Multiprocess Browser Toolbox + await pushPref("devtools.browsertoolbox.scope", "everything"); + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({ + getNodeFront, + getNodeFrontInFrames, + selectNode, + // selectNodeInFrames depends on selectNode, getNodeFront, getNodeFrontInFrames. + selectNodeInFrames, + }); + + const nodeId = await ToolboxTask.spawn(null, async () => { + /* global gToolbox */ + const inspector = gToolbox.getPanel("inspector"); + + const nodeFront = await selectNodeInFrames( + [ + "browser#sidebar", + "browser#webext-panels-browser", + "html.sidebar-extension-test h1", + ], + inspector + ); + return nodeFront.id; + }); + + is( + nodeId, + "sidebar-extension-h1", + "The Browser Toolbox can inspect a node in the webextension sidebar document" + ); + + await ToolboxTask.destroy(); + await extension.unload(); +}); diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_l10n_buttons.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_l10n_buttons.js new file mode 100644 index 0000000000..abd2f3806a --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_l10n_buttons.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +/** + * In the browser toolbox there are options to switch the language to the "bidi" and + * "accented" languages. These are useful for making sure the browser is correctly + * localized. This test opens the browser toolbox, and checks that these buttons + * work. + */ +add_task(async function () { + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({ clickMeatballItem }); + + is(getPseudoLocale(), "", "Starts out as empty"); + + await ToolboxTask.spawn(null, () => clickMeatballItem("accented")); + is(getPseudoLocale(), "accented", "Enabled the accented pseudo-locale"); + + await ToolboxTask.spawn(null, () => clickMeatballItem("accented")); + is(getPseudoLocale(), "", "Disabled the accented pseudo-locale."); + + await ToolboxTask.spawn(null, () => clickMeatballItem("bidi")); + is(getPseudoLocale(), "bidi", "Enabled the bidi pseudo-locale."); + + await ToolboxTask.spawn(null, () => clickMeatballItem("bidi")); + is(getPseudoLocale(), "", "Disabled the bidi pseudo-locale."); + + await ToolboxTask.spawn(null, () => clickMeatballItem("bidi")); + is(getPseudoLocale(), "bidi", "Enabled the bidi before closing."); + + await ToolboxTask.destroy(); + + is(getPseudoLocale(), "", "After closing the pseudo-locale is disabled."); +}); + +/** + * Return the pseudo-locale preference of the debuggee browser (not the browser toolbox). + * + * Another option for this test would be to test the text and layout of the + * browser directly, but this could be brittle. Checking the preference will + * hopefully provide adequate coverage. + */ +function getPseudoLocale() { + return Services.prefs.getCharPref("intl.l10n.pseudo"); +} + +/** + * This function is a ToolboxTask and is cloned into the toolbox context. It opens the + * "meatball menu" in the browser toolbox, clicks one of the pseudo-locale + * options, and finally returns the pseudo-locale preference from the target browser. + * + * @param {"accented" | "bidi"} type + */ +function clickMeatballItem(type) { + return new Promise(resolve => { + /* global gToolbox */ + + dump(`Opening the meatball menu in the browser toolbox.\n`); + gToolbox.doc.getElementById("toolbox-meatball-menu-button").click(); + + gToolbox.doc.addEventListener( + "popupshown", + async () => { + const menuItem = gToolbox.doc.getElementById( + "toolbox-meatball-menu-pseudo-locale-" + type + ); + dump(`Clicking the meatball menu item: "${type}".\n`); + menuItem.click(); + + // Request the pseudo-locale so that we know the preference actor is fully + // done setting the debuggee browser. + await gToolbox.getPseudoLocale(); + resolve(); + }, + { once: true } + ); + }); +} diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_navigate_tab.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_navigate_tab.js new file mode 100644 index 0000000000..46a6564a39 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_navigate_tab.js @@ -0,0 +1,85 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js", + this +); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +// Test that the Browser Toolbox still works after navigating a content tab +add_task(async function () { + // Forces the Browser Toolbox to open on the inspector by default + await pushPref("devtools.browsertoolbox.panel", "inspector"); + + await testNavigate("everything"); + await testNavigate("parent-process"); +}); + +async function testNavigate(browserToolboxScope) { + await pushPref("devtools.browsertoolbox.scope", browserToolboxScope); + + const tab = await addTab( + `data:text/html,
NAVIGATE TEST - BEFORE: ${browserToolboxScope}
` + ); + // Set the scope on the browser element to assert it easily in the Toolbox + // task. + tab.linkedBrowser.setAttribute("data-test-scope", browserToolboxScope); + + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({ + getNodeFront, + selectNode, + }); + + const hasBrowserContainerTask = async ({ scope, hasNavigated }) => { + /* global gToolbox */ + const inspector = await gToolbox.selectTool("inspector"); + info("Select the test browser element in the inspector"); + let selector = `browser[data-test-scope="${scope}"]`; + if (hasNavigated) { + selector += `[navigated="true"]`; + } + const nodeFront = await getNodeFront(selector, inspector); + await selectNode(nodeFront, inspector); + const browserContainer = inspector.markup.getContainer(nodeFront); + return !!browserContainer; + }; + + info("Select the test browser in the Browser Toolbox (before navigation)"); + const hasContainerBeforeNavigation = await ToolboxTask.spawn( + { scope: browserToolboxScope, hasNavigated: false }, + hasBrowserContainerTask + ); + ok( + hasContainerBeforeNavigation, + "Found a valid container for the browser element before navigation" + ); + + info("Navigate the test tab to another data-uri"); + await navigateTo( + `data:text/html,
NAVIGATE TEST - AFTER: ${browserToolboxScope}
` + ); + tab.linkedBrowser.setAttribute("navigated", "true"); + + info("Select the test browser in the Browser Toolbox (after navigation)"); + const hasContainerAfterNavigation = await ToolboxTask.spawn( + { scope: browserToolboxScope, hasNavigated: true }, + hasBrowserContainerTask + ); + ok( + hasContainerAfterNavigation, + "Found a valid container for the browser element after navigation" + ); + + await ToolboxTask.destroy(); +} diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_netmonitor.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_netmonitor.js new file mode 100644 index 0000000000..56e38998ce --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_netmonitor.js @@ -0,0 +1,151 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* global gToolbox */ + +add_task(async function () { + // Disable several prefs to avoid network requests. + await pushPref("browser.safebrowsing.blockedURIs.enabled", false); + await pushPref("browser.safebrowsing.downloads.enabled", false); + await pushPref("browser.safebrowsing.malware.enabled", false); + await pushPref("browser.safebrowsing.phishing.enabled", false); + await pushPref("privacy.query_stripping.enabled", false); + await pushPref("extensions.systemAddon.update.enabled", false); + + await pushPref("services.settings.server", "invalid://err"); + + // Define a set list of visible columns + await pushPref( + "devtools.netmonitor.visibleColumns", + JSON.stringify(["file", "url", "status"]) + ); + + // Force observice all processes to see the content process requests + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const ToolboxTask = await initBrowserToolboxTask(); + + await ToolboxTask.importFunctions({ + waitUntil, + }); + + await ToolboxTask.spawn(null, async () => { + const { resourceCommand } = gToolbox.commands; + + // Assert that the toolbox is not listening to network events + // before the netmonitor panel is opened. + is( + resourceCommand.isResourceWatched(resourceCommand.TYPES.NETWORK_EVENT), + false, + "The toolox is not watching for network event resources" + ); + + await gToolbox.selectTool("netmonitor"); + const monitor = gToolbox.getCurrentPanel(); + const { document, store, windowRequire } = monitor.panelWin; + + const Actions = windowRequire( + "devtools/client/netmonitor/src/actions/index" + ); + + store.dispatch(Actions.batchEnable(false)); + + await waitUntil( + () => !!document.querySelector(".request-list-empty-notice") + ); + + is( + resourceCommand.isResourceWatched(resourceCommand.TYPES.NETWORK_EVENT), + true, + "The network panel is now watching for network event resources" + ); + + const emptyListNotice = document.querySelector( + ".request-list-empty-notice" + ); + + ok( + !!emptyListNotice, + "An empty notice should be displayed when the frontend is opened." + ); + + is( + emptyListNotice.innerText, + "Perform a request to see detailed information about network activity.", + "The reload and perfomance analysis details should not be visible in the browser toolbox" + ); + + is( + store.getState().requests.requests.length, + 0, + "The requests should be empty when the frontend is opened." + ); + + ok( + !document.querySelector(".requests-list-network-summary-button"), + "The perfomance analysis button should not be visible in the browser toolbox" + ); + }); + + info("Trigger request in parent process and check that it shows up"); + await fetch("https://example.org/document-builder.sjs?html=fromParent"); + + await ToolboxTask.spawn(null, async () => { + const monitor = gToolbox.getCurrentPanel(); + const { document, store } = monitor.panelWin; + + await waitUntil( + () => !document.querySelector(".request-list-empty-notice") + ); + ok(true, "The empty notice is no longer displayed"); + is( + store.getState().requests.requests.length, + 1, + "There's 1 request in the store" + ); + + const requests = Array.from( + document.querySelectorAll("tbody .requests-list-column.requests-list-url") + ); + is(requests.length, 1, "One request displayed"); + is( + requests[0].textContent, + "https://example.org/document-builder.sjs?html=fromParent", + "Expected request is displayed" + ); + }); + + info("Trigger content process requests"); + const urlImg = `${URL_ROOT_SSL}test-image.png?fromContent&${Date.now()}-${Math.random()}`; + await addTab( + `https://example.com/document-builder.sjs?html=${encodeURIComponent( + `` + )}` + ); + + await ToolboxTask.spawn(urlImg, async innerUrlImg => { + const monitor = gToolbox.getCurrentPanel(); + const { document, store } = monitor.panelWin; + + await waitUntil(() => store.getState().requests.requests.length >= 3); + ok(true, "Expected content requests are displayed"); + + const requests = Array.from( + document.querySelectorAll("tbody .requests-list-column.requests-list-url") + ); + is(requests.length, 3, "Three requests displayed"); + ok( + requests[1].textContent.includes( + `https://example.com/document-builder.sjs` + ), + "Request for the tab is displayed" + ); + is( + requests[2].textContent, + innerUrlImg, + "Request for image image in tab is displayed" + ); + }); +}); diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_print_preview.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_print_preview.js new file mode 100644 index 0000000000..81ff0808fb --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_print_preview.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js", + this +); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +// Test that the MultiProcessBrowserToolbox can be opened when print preview is +// started, and can select elements in the print preview document. +add_task(async function () { + // Forces the Browser Toolbox to open on the inspector by default + await pushPref("devtools.browsertoolbox.panel", "inspector"); + + // Enable Multiprocess Browser Toolbox + await pushPref("devtools.browsertoolbox.scope", "everything"); + + // Open the tab *after* opening the Browser Toolbox in order to force creating the remote frames + // late and exercise frame target watching code. + await addTab(`data:text/html,
PRINT PREVIEW TEST
`); + + info("Start the print preview for the current tab"); + document.getElementById("cmd_print").doCommand(); + + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({ + getNodeFront, + getNodeFrontInFrames, + selectNode, + // selectNodeInFrames depends on selectNode, getNodeFront, getNodeFrontInFrames. + selectNodeInFrames, + }); + + const hasCloseButton = await ToolboxTask.spawn(null, async () => { + /* global gToolbox */ + const inspector = gToolbox.getPanel("inspector"); + + info("Select the #test-div element in the printpreview document"); + await selectNodeInFrames( + ['browser[printpreview="true"]', "#test-div"], + inspector + ); + return !!gToolbox.doc.getElementById("toolbox-close"); + }); + ok(!hasCloseButton, "Browser toolbox doesn't have a close button"); + + await ToolboxTask.destroy(); +}); diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_rtl.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_rtl.js new file mode 100644 index 0000000000..558be1a16c --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_rtl.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +// Test that DevTools panels are rendered in "rtl" (right-to-left) in the Browser Toolbox. +add_task(async function () { + await pushPref("intl.l10n.pseudo", "bidi"); + + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({}); + + const dir = await ToolboxTask.spawn(null, async () => { + /* global gToolbox */ + const inspector = await gToolbox.selectTool("inspector"); + return inspector.panelDoc.dir; + }); + is(dir, "rtl", "Inspector panel has the expected direction"); + + await ToolboxTask.destroy(); +}); diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_ruleview_stylesheet.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_ruleview_stylesheet.js new file mode 100644 index 0000000000..60f30b44b4 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_ruleview_stylesheet.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js", + this +); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +// Check that CSS rules are displayed with the proper source label in the +// browser toolbox. +add_task(async function () { + // Forces the Browser Toolbox to open on the inspector by default + await pushPref("devtools.browsertoolbox.panel", "inspector"); + // Enable Multiprocess Browser Toolbox + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({ + getNodeFront, + getNodeFrontInFrames, + getRuleViewLinkByIndex, + selectNode, + // selectNodeInFrames depends on selectNode, getNodeFront, getNodeFrontInFrames. + selectNodeInFrames, + waitUntil, + }); + + // This is a simple test page, which contains a
with a CSS rule `color: red` + // coming from a dedicated stylesheet. + const tab = await addTab( + `https://example.com/browser/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_ruleview_stylesheet.html` + ); + + // Set a custom attribute on the tab's browser, in order to easily select it in the markup view + tab.linkedBrowser.setAttribute("test-tab", "true"); + + info( + "Get the source label for a rule displayed in the Browser Toolbox ruleview" + ); + const sourceLabel = await ToolboxTask.spawn(null, async () => { + /* global gToolbox */ + const inspector = gToolbox.getPanel("inspector"); + + info("Select the rule view"); + const onSidebarSelect = inspector.sidebar.once("select"); + inspector.sidebar.select("ruleview"); + await onSidebarSelect; + + info("Select a DIV element in the test page"); + await selectNodeInFrames( + ['browser[remote="true"][test-tab]', "div"], + inspector + ); + + info("Retrieve the sourceLabel for the rule at index 1"); + const ruleView = inspector.getPanel("ruleview").view; + await waitUntil(() => getRuleViewLinkByIndex(ruleView, 1)); + const sourceLabelEl = getRuleViewLinkByIndex(ruleView, 1).querySelector( + ".ruleview-rule-source-label" + ); + + return sourceLabelEl.textContent; + }); + + is( + sourceLabel, + "style_browser_toolbox_ruleview_stylesheet.css:1", + "source label has the expected value in the ruleview" + ); + + await ToolboxTask.destroy(); +}); diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_shouldprocessupdates.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_shouldprocessupdates.js new file mode 100644 index 0000000000..6f5e18791b --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_shouldprocessupdates.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +add_task(async function () { + // Running devtools should prevent processing updates. By setting this + // environment variable and then inspecting it from the launched devtools + // process, we can witness update processing being skipped. + Services.env.set("MOZ_TEST_PROCESS_UPDATES", "1"); + + const ToolboxTask = await initBrowserToolboxTask(); + await ToolboxTask.importFunctions({}); + + let result = await ToolboxTask.spawn(null, async () => { + const result = { + exists: Services.env.exists("MOZ_TEST_PROCESS_UPDATES"), + get: Services.env.get("MOZ_TEST_PROCESS_UPDATES"), + }; + // Log so that we have a hope of debugging. + console.log("result", result); + return JSON.stringify(result); + }); + + result = JSON.parse(result); + ok(result.exists, "MOZ_TEST_PROCESS_UPDATES exists in subprocess"); + is( + result.get, + "ShouldNotProcessUpdates(): DevToolsLaunching", + "MOZ_TEST_PROCESS_UPDATES is correct in subprocess" + ); + + await ToolboxTask.destroy(); +}); diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_unavailable_children.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_unavailable_children.js new file mode 100644 index 0000000000..5029c62306 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_unavailable_children.js @@ -0,0 +1,144 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js", + this +); + +// On debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +// This test is used to test a badge displayed in the markup view under content +// browser elements when switching from Multi Process mode to Parent Process +// mode. + +add_task(async function () { + // Forces the Browser Toolbox to open on the inspector by default + await pushPref("devtools.browsertoolbox.panel", "inspector"); + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const tab = await addTab( + "https://example.com/document-builder.sjs?html=
Pickme" + ); + tab.linkedBrowser.setAttribute("test-tab", "true"); + + const ToolboxTask = await initBrowserToolboxTask(); + + await ToolboxTask.importFunctions({ + waitUntil, + getNodeFront, + selectNode, + }); + + const tabProcessID = + tab.linkedBrowser.browsingContext.currentWindowGlobal.osPid; + + const decodedTabURI = decodeURI(tab.linkedBrowser.currentURI.spec); + + await ToolboxTask.spawn( + [tabProcessID, isFissionEnabled(), decodedTabURI], + async (processID, _isFissionEnabled, tabURI) => { + /* global gToolbox */ + const inspector = gToolbox.getPanel("inspector"); + + info("Select the test browser element."); + await selectNode('browser[remote="true"][test-tab]', inspector); + + info("Retrieve the node front for selected node."); + const browserNodeFront = inspector.selection.nodeFront; + ok(!!browserNodeFront, "Retrieved a node front for the browser"); + is(browserNodeFront.displayName, "browser"); + + // Small helper to expand containers and return the child container + // matching the provided display name. + async function expandContainer(container, expectedChildName) { + info(`Expand the node expected to contain a ${expectedChildName}`); + await inspector.markup.expandNode(container.node); + await waitUntil(() => !!container.getChildContainers().length); + + const children = container + .getChildContainers() + .filter(child => child.node.displayName === expectedChildName); + is(children.length, 1); + return children[0]; + } + + info("Check that the corresponding markup view container has children"); + const browserContainer = inspector.markup.getContainer(browserNodeFront); + ok(browserContainer.hasChildren); + ok( + !browserContainer.node.childrenUnavailable, + "childrenUnavailable un-set" + ); + ok( + !browserContainer.elt.querySelector(".unavailable-children"), + "The unavailable badge is not displayed" + ); + + // Store the asserts as a helper to reuse it later in the test. + async function assertMarkupView() { + info("Check that the children are #document > html > body > div"); + let container = await expandContainer(browserContainer, "#document"); + container = await expandContainer(container, "html"); + container = await expandContainer(container, "body"); + container = await expandContainer(container, "div"); + + info("Select the #pick-me div"); + await selectNode(container.node, inspector); + is(inspector.selection.nodeFront.id, "pick-me"); + } + await assertMarkupView(); + + const parentProcessScope = gToolbox.doc.querySelector( + 'input[name="chrome-debug-mode"][value="parent-process"]' + ); + + info("Switch to parent process only scope"); + const onInspectorUpdated = inspector.once("inspector-updated"); + parentProcessScope.click(); + await onInspectorUpdated; + + // Note: `getChildContainers` returns null when the container has no + // children, instead of an empty array. + await waitUntil(() => browserContainer.getChildContainers() === null); + + ok(!browserContainer.hasChildren, "browser container has no children"); + ok(browserContainer.node.childrenUnavailable, "childrenUnavailable set"); + ok( + !!browserContainer.elt.querySelector(".unavailable-children"), + "The unavailable badge is displayed" + ); + + const everythingScope = gToolbox.doc.querySelector( + 'input[name="chrome-debug-mode"][value="everything"]' + ); + + info("Switch to multi process scope"); + everythingScope.click(); + + info("Wait until browserContainer has children"); + await waitUntil(() => browserContainer.hasChildren); + ok(browserContainer.hasChildren, "browser container has children"); + ok( + !browserContainer.node.childrenUnavailable, + "childrenUnavailable un-set" + ); + ok( + !browserContainer.elt.querySelector(".unavailable-children"), + "The unavailable badge is no longer displayed" + ); + + await assertMarkupView(); + } + ); + + await ToolboxTask.destroy(); +}); diff --git a/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_watchedByDevTools.js b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_watchedByDevTools.js new file mode 100644 index 0000000000..0eadaaeffe --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/browser_browser_toolbox_watchedByDevTools.js @@ -0,0 +1,122 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that the "watchedByDevTools" flag is properly handled. + */ + +const EXAMPLE_NET_URI = + "https://example.net/document-builder.sjs?html=
net"; +const EXAMPLE_COM_URI = + "https://example.com/document-builder.sjs?html=
com"; +const EXAMPLE_ORG_URI = + "https://example.org/document-builder.sjs?headers=Cross-Origin-Opener-Policy:same-origin&html=
org
"; + +add_task(async function () { + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const topWindow = Services.wm.getMostRecentWindow("navigator:browser"); + is( + topWindow.browsingContext.watchedByDevTools, + false, + "watchedByDevTools isn't set on the parent process browsing context when DevTools aren't opened" + ); + + // Open 2 tabs that we can check the flag on + const tabNet = await addTab(EXAMPLE_NET_URI); + const tabCom = await addTab(EXAMPLE_COM_URI); + + is( + tabNet.linkedBrowser.browsingContext.watchedByDevTools, + false, + "watchedByDevTools is not set on the .net tab" + ); + is( + tabCom.linkedBrowser.browsingContext.watchedByDevTools, + false, + "watchedByDevTools is not set on the .com tab" + ); + + info("Open the BrowserToolbox so the parent process will be watched"); + const ToolboxTask = await initBrowserToolboxTask(); + + is( + topWindow.browsingContext.watchedByDevTools, + true, + "watchedByDevTools is set when the browser toolbox is opened" + ); + + // Open a new tab when the browser toolbox is opened + const newTab = await addTab(EXAMPLE_COM_URI); + + is( + tabNet.linkedBrowser.browsingContext.watchedByDevTools, + true, + "watchedByDevTools is set on the .net tab browsing context after opening the browser toolbox" + ); + is( + tabCom.linkedBrowser.browsingContext.watchedByDevTools, + true, + "watchedByDevTools is set on the .com tab browsing context after opening the browser toolbox" + ); + + info( + "Check that adding watchedByDevTools is set on a tab that was added when the browser toolbox was opened" + ); + is( + newTab.linkedBrowser.browsingContext.watchedByDevTools, + true, + "watchedByDevTools is set on the newly opened tab" + ); + + info( + "Check that watchedByDevTools persist when navigating to a page that creates a new browsing context" + ); + const previousBrowsingContextId = newTab.linkedBrowser.browsingContext.id; + const onBrowserLoaded = BrowserTestUtils.browserLoaded( + newTab.linkedBrowser, + false, + encodeURI(EXAMPLE_ORG_URI) + ); + BrowserTestUtils.startLoadingURIString(newTab.linkedBrowser, EXAMPLE_ORG_URI); + await onBrowserLoaded; + + isnot( + newTab.linkedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + is( + newTab.linkedBrowser.browsingContext.watchedByDevTools, + true, + "watchedByDevTools is still set after navigating the tab to a page which forces a new browsing context" + ); + + info("Destroying browser toolbox"); + await ToolboxTask.destroy(); + + is( + topWindow.browsingContext.watchedByDevTools, + false, + "watchedByDevTools was reset when the browser toolbox was closed" + ); + + is( + tabNet.linkedBrowser.browsingContext.watchedByDevTools, + false, + "watchedByDevTools was reset on the .net tab after closing the browser toolbox" + ); + is( + tabCom.linkedBrowser.browsingContext.watchedByDevTools, + false, + "watchedByDevTools was reset on the .com tab after closing the browser toolbox" + ); + is( + newTab.linkedBrowser.browsingContext.watchedByDevTools, + false, + "watchedByDevTools was reset on the tab opened while the browser toolbox was opened" + ); +}); diff --git a/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_fission_contentframe_inspector_frame.html b/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_fission_contentframe_inspector_frame.html new file mode 100644 index 0000000000..1f365cc17f --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_fission_contentframe_inspector_frame.html @@ -0,0 +1,14 @@ + + + + + + + Frame for browser_browser_toolbox_fission_contentframe_inspector.js + + + +
Inside iframe
+ + diff --git a/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_fission_contentframe_inspector_page.html b/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_fission_contentframe_inspector_page.html new file mode 100644 index 0000000000..853c4ec91c --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_fission_contentframe_inspector_page.html @@ -0,0 +1,16 @@ + + + + + + + Frame for browser_browser_toolbox_fission_contentframe_inspector.js + + + + + + + diff --git a/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_ruleview_stylesheet.html b/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_ruleview_stylesheet.html new file mode 100644 index 0000000000..3fab2ff8a8 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/doc_browser_toolbox_ruleview_stylesheet.html @@ -0,0 +1,12 @@ + + + + + + + + +
test div with "color: red" applied from a stylesheet
+ + diff --git a/devtools/client/framework/browser-toolbox/test/head.js b/devtools/client/framework/browser-toolbox/test/head.js new file mode 100644 index 0000000000..4ea18d547e --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/head.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// shared-head.js handles imports, constants, and utility functions +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/framework/browser-toolbox/test/helpers-browser-toolbox.js", + this +); diff --git a/devtools/client/framework/browser-toolbox/test/helpers-browser-toolbox.js b/devtools/client/framework/browser-toolbox/test/helpers-browser-toolbox.js new file mode 100644 index 0000000000..bc97c20c01 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/helpers-browser-toolbox.js @@ -0,0 +1,233 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable no-unused-vars, no-undef */ + +"use strict"; + +const { BrowserToolboxLauncher } = ChromeUtils.importESModule( + "resource://devtools/client/framework/browser-toolbox/Launcher.sys.mjs" +); +const { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); + +/** + * Open up a browser toolbox and return a ToolboxTask object for interacting + * with it. ToolboxTask has the following methods: + * + * importFunctions(object) + * + * The object contains functions from this process which should be defined in + * the global evaluation scope of the toolbox. The toolbox cannot load testing + * files directly. + * + * destroy() + * + * Destroy the browser toolbox and make sure it exits cleanly. + * + * @param {Object}: + * - {Function} existingProcessClose: if truth-y, connect to an existing + * browser toolbox process rather than launching a new one and + * connecting to it. The given function is expected to return an + * object containing an `exitCode`, like `{exitCode}`, and will be + * awaited in the returned `destroy()` function. `exitCode` is + * asserted to be 0 (success). + */ +async function initBrowserToolboxTask({ existingProcessClose } = {}) { + if (AppConstants.ASAN) { + ok( + false, + "ToolboxTask cannot be used on ASAN builds. This test should be skipped (Bug 1591064)." + ); + } + + await pushPref("devtools.chrome.enabled", true); + await pushPref("devtools.debugger.remote-enabled", true); + await pushPref("devtools.browsertoolbox.enable-test-server", true); + await pushPref("devtools.debugger.prompt-connection", false); + + // This rejection seems to affect all tests using the browser toolbox. + ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" + ).PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); + + let process; + let dbgProcess; + if (!existingProcessClose) { + [process, dbgProcess] = await new Promise(resolve => { + BrowserToolboxLauncher.init({ + onRun: (_process, _dbgProcess) => resolve([_process, _dbgProcess]), + overwritePreferences: true, + }); + }); + ok(true, "Browser toolbox started"); + is( + BrowserToolboxLauncher.getBrowserToolboxSessionState(), + true, + "Has session state" + ); + } else { + ok(true, "Connecting to existing browser toolbox"); + } + + // The port of the DevToolsServer installed in the toolbox process is fixed. + // See browser-toolbox/window.js + let transport; + while (true) { + try { + transport = await DevToolsClient.socketConnect({ + host: "localhost", + port: 6001, + webSocket: false, + }); + break; + } catch (e) { + await waitForTime(100); + } + } + ok(true, "Got transport"); + + const client = new DevToolsClient(transport); + await client.connect(); + + const commands = await CommandsFactory.forMainProcess({ client }); + const target = await commands.descriptorFront.getTarget(); + const consoleFront = await target.getFront("console"); + + ok(true, "Connected"); + + await importFunctions({ + info: msg => dump(msg + "\n"), + is: (a, b, description) => { + let msg = + "'" + JSON.stringify(a) + "' is equal to '" + JSON.stringify(b) + "'"; + if (description) { + msg += " - " + description; + } + if (a !== b) { + msg = "FAILURE: " + msg; + dump(msg + "\n"); + throw new Error(msg); + } else { + msg = "SUCCESS: " + msg; + dump(msg + "\n"); + } + }, + ok: (a, description) => { + let msg = "'" + JSON.stringify(a) + "' is true"; + if (description) { + msg += " - " + description; + } + if (!a) { + msg = "FAILURE: " + msg; + dump(msg + "\n"); + throw new Error(msg); + } else { + msg = "SUCCESS: " + msg; + dump(msg + "\n"); + } + }, + }); + + async function evaluateExpression(expression, options = {}) { + const onEvaluationResult = consoleFront.once("evaluationResult"); + await consoleFront.evaluateJSAsync({ text: expression, ...options }); + return onEvaluationResult; + } + + /** + * Invoke the given function and argument(s) within the global evaluation scope + * of the toolbox. The evaluation scope predefines the name "gToolbox" for the + * toolbox itself. + * + * @param {value|Array} arg + * If an Array is passed, we will consider it as the list of arguments + * to pass to `fn`. Otherwise we will consider it as the unique argument + * to pass to it. + * @param {Function} fn + * Function to call in the global scope within the browser toolbox process. + * This function will be stringified and passed to the process via RDP. + * @return {Promise} + * Return the primitive value returned by `fn`. + */ + async function spawn(arg, fn) { + // Use JSON.stringify to ensure that we can pass strings + // as well as any JSON-able object. + const argString = JSON.stringify(Array.isArray(arg) ? arg : [arg]); + const rv = await evaluateExpression(`(${fn}).apply(null,${argString})`, { + // Use the following argument in order to ensure waiting for the completion + // of the promise returned by `fn` (in case this is an async method). + mapped: { await: true }, + }); + if (rv.exceptionMessage) { + throw new Error(`ToolboxTask.spawn failure: ${rv.exceptionMessage}`); + } else if (rv.topLevelAwaitRejected) { + throw new Error(`ToolboxTask.spawn await rejected`); + } + return rv.result; + } + + async function importFunctions(functions) { + for (const [key, fn] of Object.entries(functions)) { + await evaluateExpression(`this.${key} = ${fn}`); + } + } + + async function importScript(script) { + const response = await evaluateExpression(script); + if (response.hasException) { + ok( + false, + "ToolboxTask.spawn exception while importing script: " + + response.exceptionMessage + ); + } + } + + let destroyed = false; + async function destroy() { + // No need to do anything if `destroy` was already called. + if (destroyed) { + return; + } + + const closePromise = existingProcessClose + ? existingProcessClose() + : dbgProcess.wait(); + evaluateExpression("gToolbox.destroy()").catch(e => { + // Ignore connection close as the toolbox destroy may destroy + // everything quickly enough so that evaluate request is still pending + if (!e.message.includes("Connection closed")) { + throw e; + } + }); + + const { exitCode } = await closePromise; + ok(true, "Browser toolbox process closed"); + + is(exitCode, 0, "The remote debugger process died cleanly"); + + if (!existingProcessClose) { + is( + BrowserToolboxLauncher.getBrowserToolboxSessionState(), + false, + "No session state after closing" + ); + } + + await commands.destroy(); + destroyed = true; + } + + // When tests involving using this task fail, the spawned Browser Toolbox is not + // destroyed and might impact the next tests (e.g. pausing the content process before + // the debugger from the content toolbox does). So make sure to cleanup everything. + registerCleanupFunction(destroy); + + return { + importFunctions, + importScript, + spawn, + destroy, + }; +} diff --git a/devtools/client/framework/browser-toolbox/test/style_browser_toolbox_ruleview_stylesheet.css b/devtools/client/framework/browser-toolbox/test/style_browser_toolbox_ruleview_stylesheet.css new file mode 100644 index 0000000000..538fa56f4a --- /dev/null +++ b/devtools/client/framework/browser-toolbox/test/style_browser_toolbox_ruleview_stylesheet.css @@ -0,0 +1,3 @@ +div { + color: red; +} diff --git a/devtools/client/framework/browser-toolbox/window.css b/devtools/client/framework/browser-toolbox/window.css new file mode 100644 index 0000000000..367f6364d2 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/window.css @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +body { + padding: 0; + margin: 0; + display: flex; + height: 100vh; +} + +/** + * The main content of the BrowserToolbox runs within an iframe. + */ +.devtools-toolbox-browsertoolbox-iframe { + border: 0; + width: 100%; +} + +/** + * Status message shows connection (to the backend) info messages. + */ +#status-message-container { + width: calc(100% - 10px); + font-family: var(--monospace-font-family); + padding: 5px; + color: FieldText; + background-color: Field; +} + +#status-message-title { + font-size: 14px; + font-weight: bold; +} + +#status-message { + font-size: 12px; + width: 100%; + height: 200px; + overflow: auto; +} diff --git a/devtools/client/framework/browser-toolbox/window.html b/devtools/client/framework/browser-toolbox/window.html new file mode 100644 index 0000000000..0f83dab775 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/window.html @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + diff --git a/devtools/client/framework/browser-toolbox/window.js b/devtools/client/framework/browser-toolbox/window.js new file mode 100644 index 0000000000..e84ef02829 --- /dev/null +++ b/devtools/client/framework/browser-toolbox/window.js @@ -0,0 +1,336 @@ +/* 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"; + +var { loader, require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); + +var { useDistinctSystemPrincipalLoader, releaseDistinctSystemPrincipalLoader } = + ChromeUtils.importESModule( + "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs" + ); + +// Require this module to setup core modules +loader.require("resource://devtools/client/framework/devtools-browser.js"); + +var { gDevTools } = require("resource://devtools/client/framework/devtools.js"); +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); +var { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); +var { PrefsHelper } = require("resource://devtools/client/shared/prefs.js"); +const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js"); +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + BrowserToolboxLauncher: + "resource://devtools/client/framework/browser-toolbox/Launcher.sys.mjs", +}); + +const { + CommandsFactory, +} = require("resource://devtools/shared/commands/commands-factory.js"); + +// Timeout to wait before we assume that a connect() timed out without an error. +// In milliseconds. (With the Debugger pane open, this has been reported to last +// more than 10 seconds!) +const STATUS_REVEAL_TIME = 15000; + +/** + * Shortcuts for accessing various debugger preferences. + */ +var Prefs = new PrefsHelper("devtools.debugger", { + chromeDebuggingHost: ["Char", "chrome-debugging-host"], + chromeDebuggingWebSocket: ["Bool", "chrome-debugging-websocket"], +}); + +var gCommands, gToolbox, gShortcuts; + +function appendStatusMessage(msg) { + const statusMessage = document.getElementById("status-message"); + statusMessage.textContent += msg + "\n"; + if (msg.stack) { + statusMessage.textContent += msg.stack + "\n"; + } +} + +function toggleStatusMessage(visible = true) { + document.getElementById("status-message-container").hidden = !visible; +} + +function revealStatusMessage() { + toggleStatusMessage(true); +} + +function hideStatusMessage() { + toggleStatusMessage(false); +} + +var connect = async function () { + // Initiate the connection + + // MOZ_BROWSER_TOOLBOX_INPUT_CONTEXT is set by the target Firefox instance + // before opening the Browser Toolbox. + // If "devtools.webconsole.input.context" is true, the variable is set to "1", + // otherwise it is set to "0". + Services.prefs.setBoolPref( + "devtools.webconsole.input.context", + Services.env.get("MOZ_BROWSER_TOOLBOX_INPUT_CONTEXT") === "1" + ); + // Similar, but for the Browser Toolbox mode + if (Services.env.get("MOZ_BROWSER_TOOLBOX_FORCE_MULTIPROCESS") === "1") { + Services.prefs.setCharPref("devtools.browsertoolbox.scope", "everything"); + } + + const port = Services.env.get("MOZ_BROWSER_TOOLBOX_PORT"); + + // A port needs to be passed in from the environment, for instance: + // MOZ_BROWSER_TOOLBOX_PORT=6080 ./mach run -chrome \ + // chrome://devtools/content/framework/browser-toolbox/window.html + if (!port) { + throw new Error( + "Must pass a port in an env variable with MOZ_BROWSER_TOOLBOX_PORT" + ); + } + + const host = Prefs.chromeDebuggingHost; + const webSocket = Prefs.chromeDebuggingWebSocket; + appendStatusMessage(`Connecting to ${host}:${port}, ws: ${webSocket}`); + const transport = await DevToolsClient.socketConnect({ + host, + port, + webSocket, + }); + const client = new DevToolsClient(transport); + appendStatusMessage("Start protocol client for connection"); + await client.connect(); + + appendStatusMessage("Get root form for toolbox"); + gCommands = await CommandsFactory.forMainProcess({ client }); + + // Bug 1794607: for some unexpected reason, closing the DevToolsClient + // when the commands is destroyed by the toolbox would introduce leaks + // when running the browser-toolbox mochitests. + gCommands.shouldCloseClient = false; + + await openToolbox(gCommands); +}; + +// Certain options should be toggled since we can assume chrome debugging here +function setPrefDefaults() { + Services.prefs.setBoolPref("devtools.inspector.showUserAgentStyles", true); + Services.prefs.setBoolPref( + "devtools.inspector.showAllAnonymousContent", + true + ); + Services.prefs.setBoolPref("browser.dom.window.dump.enabled", true); + Services.prefs.setBoolPref("devtools.console.stdout.chrome", true); + Services.prefs.setBoolPref( + "devtools.command-button-noautohide.enabled", + true + ); + + // We force enabling the performance panel in the browser toolbox. + Services.prefs.setBoolPref("devtools.performance.enabled", true); + + // Bug 1773226: Try to avoid session restore to reopen a transient browser window + // if we ever opened a URL from the browser toolbox. (but it doesn't seem to be enough) + Services.prefs.setBoolPref("browser.sessionstore.resume_from_crash", false); + + // Disable Safe mode as the browser toolbox is often closed brutaly by subprocess + // and the safe mode kicks in when reopening it + Services.prefs.setIntPref("toolkit.startup.max_resumed_crashes", -1); +} + +window.addEventListener( + "load", + async function () { + gShortcuts = new KeyShortcuts({ window }); + gShortcuts.on("CmdOrCtrl+W", onCloseCommand); + gShortcuts.on("CmdOrCtrl+Alt+Shift+I", onDebugBrowserToolbox); + gShortcuts.on("CmdOrCtrl+Alt+R", onReloadBrowser); + + const statusMessageContainer = document.getElementById( + "status-message-title" + ); + statusMessageContainer.textContent = L10N.getStr( + "browserToolbox.statusMessage" + ); + + setPrefDefaults(); + + // Reveal status message if connecting is slow or if an error occurs. + const delayedStatusReveal = setTimeout( + revealStatusMessage, + STATUS_REVEAL_TIME + ); + try { + await connect(); + clearTimeout(delayedStatusReveal); + hideStatusMessage(); + } catch (e) { + clearTimeout(delayedStatusReveal); + appendStatusMessage(e); + revealStatusMessage(); + console.error(e); + } + }, + { once: true } +); + +function onCloseCommand(event) { + window.close(); +} + +/** + * Open a Browser toolbox debugging the current browser toolbox + * + * This helps debugging the browser toolbox code, especially the code + * running in the parent process. i.e. frontend code. + */ +function onDebugBrowserToolbox() { + lazy.BrowserToolboxLauncher.init(); +} + +/** + * Replicate the local-build-only key shortcut to reload the browser + */ +function onReloadBrowser() { + gToolbox.commands.targetCommand.reloadTopLevelTarget(); +} + +async function openToolbox(commands) { + const form = commands.descriptorFront._form; + appendStatusMessage( + `Create toolbox for target descriptor: ${JSON.stringify({ form }, null, 2)}` + ); + + // Remember the last panel that was used inside of this profile. + // But if we are testing, then it should always open the debugger panel. + const selectedTool = Services.prefs.getCharPref( + "devtools.browsertoolbox.panel", + Services.prefs.getCharPref("devtools.toolbox.selectedTool", "jsdebugger") + ); + + const toolboxOptions = { doc: document }; + appendStatusMessage(`Show toolbox with ${selectedTool} selected`); + + gToolbox = await gDevTools.showToolbox(commands, { + toolId: selectedTool, + hostType: Toolbox.HostType.BROWSERTOOLBOX, + hostOptions: toolboxOptions, + }); + + bindToolboxHandlers(); + + // Enable some testing features if the browser toolbox test pref is set. + if ( + Services.prefs.getBoolPref( + "devtools.browsertoolbox.enable-test-server", + false + ) + ) { + // setup a server so that the test can evaluate messages in this process. + installTestingServer(); + } + + await gToolbox.raise(); + + // Warn the user if we started recording this browser toolbox via MOZ_BROWSER_TOOLBOX_PROFILER_STARTUP=1 + if (Services.env.get("MOZ_PROFILER_STARTUP") === "1") { + const notificationBox = gToolbox.getNotificationBox(); + const text = + "The profiler started recording this toolbox, open another browser toolbox to open the profile via the performance panel"; + notificationBox.appendNotification( + text, + null, + null, + notificationBox.PRIORITY_INFO_HIGH + ); + } +} + +let releaseTestLoader = null; +function installTestingServer() { + // Install a DevToolsServer in this process and inform the server of its + // location. Tests operating on the browser toolbox run in the server + // (the firefox parent process) and can connect to this new server using + // initBrowserToolboxTask(), allowing them to evaluate scripts here. + + const requester = {}; + const testLoader = useDistinctSystemPrincipalLoader(requester); + releaseTestLoader = () => releaseDistinctSystemPrincipalLoader(requester); + const { DevToolsServer } = testLoader.require( + "resource://devtools/server/devtools-server.js" + ); + const { SocketListener } = testLoader.require( + "resource://devtools/shared/security/socket.js" + ); + + DevToolsServer.init(); + DevToolsServer.registerAllActors(); + DevToolsServer.allowChromeProcess = true; + + // Force this server to be kept alive until the browser toolbox process is closed. + // For some reason intermittents appears on Windows when destroying the server + // once the last connection drops. + DevToolsServer.keepAlive = true; + + // Use a fixed port which initBrowserToolboxTask can look for. + const socketOptions = { portOrPath: 6001 }; + const listener = new SocketListener(DevToolsServer, socketOptions); + listener.open(); +} + +async function bindToolboxHandlers() { + gToolbox.once("destroyed", quitApp); + window.addEventListener("unload", onUnload); + + // If the remote connection drops, firefox was closed + // In such case, force closing the browser toolbox + gCommands.client.once("closed", quitApp); + + if (Services.appinfo.OS == "Darwin") { + // Badge the dock icon to differentiate this process from the main application + // process. + updateBadgeText(false); + + gToolbox.on("toolbox-paused", () => updateBadgeText(true)); + gToolbox.on("toolbox-resumed", () => updateBadgeText(false)); + } +} + +function updateBadgeText(paused) { + const dockSupport = Cc["@mozilla.org/widget/macdocksupport;1"].getService( + Ci.nsIMacDockSupport + ); + dockSupport.badgeText = paused ? "▐▐ " : " ▶"; +} + +function onUnload() { + window.removeEventListener("unload", onUnload); + gToolbox.destroy(); + if (releaseTestLoader) { + releaseTestLoader(); + releaseTestLoader = null; + } +} + +function quitApp() { + const quit = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + Services.obs.notifyObservers(quit, "quit-application-requested"); + + const shouldProceed = !quit.data; + if (shouldProceed) { + Services.startup.quit(Ci.nsIAppStartup.eForceQuit); + } +} diff --git a/devtools/client/framework/commands-from-url.js b/devtools/client/framework/commands-from-url.js new file mode 100644 index 0000000000..f9b5cec46c --- /dev/null +++ b/devtools/client/framework/commands-from-url.js @@ -0,0 +1,179 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); +const { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); +const { + remoteClientManager, +} = require("resource://devtools/client/shared/remote-debugging/remote-client-manager.js"); +const { + CommandsFactory, +} = require("resource://devtools/shared/commands/commands-factory.js"); + +/** + * Construct a commands object for a given URL with various query parameters: + * + * - host, port & ws: See the documentation for clientFromURL + * + * - type: "tab", "extension", "worker" or "process" + * {String} The type of target to connect to. + * + * If type == "tab": + * - id: + * {Number} the tab browserId + * + * If type == "extension": + * - id: + * {String} the addonID of the webextension to debug. + * + * If type == "worker": + * - id: + * {String} the unique Worker id of the Worker to debug. + * + * If type == "process": + * - id: + * {Number} the process id to debug. Default to 0, which is the parent process. + * + * + * @param {URL} url + * The url to fetch query params from. + * + * @return A commands object + */ +exports.commandsFromURL = async function commandsFromURL(url) { + const client = await clientFromURL(url); + const params = url.searchParams; + + // Clients retrieved from the remote-client-manager are already connected. + const isCachedClient = params.get("remoteId"); + if (!isCachedClient) { + // Connect any other client. + await client.connect(); + } + + const id = params.get("id"); + const type = params.get("type"); + + let commands; + try { + commands = await _commandsFromURL(client, id, type); + } catch (e) { + if (!isCachedClient) { + // If the client was not cached, then the client was created here. If the target + // creation failed, we should close the client. + await client.close(); + } + throw e; + } + + // When opening about:debugging's toolboxes for remote runtimes, + // we create a new commands using a shared and cached client. + // Prevent closing the DevToolsClient on toolbox close and Commands destruction + // as this can be used by about:debugging and other toolboxes. + if (isCachedClient) { + commands.shouldCloseClient = false; + } + + return commands; +}; + +async function _commandsFromURL(client, id, type) { + if (!type) { + throw new Error("commandsFromURL, missing type parameter"); + } + + let commands; + if (type === "tab") { + // Fetch target for a remote tab + id = parseInt(id, 10); + if (isNaN(id)) { + throw new Error( + `commandsFromURL, wrong tab id '${id}', should be a number` + ); + } + try { + commands = await CommandsFactory.forRemoteTab(id, { client }); + } catch (ex) { + if (ex.message.startsWith("Protocol error (noTab)")) { + throw new Error( + `commandsFromURL, tab with browserId '${id}' doesn't exist` + ); + } + throw ex; + } + } else if (type === "extension") { + commands = await CommandsFactory.forAddon(id, { client }); + + if (!commands) { + throw new Error( + `commandsFromURL, extension with id '${id}' doesn't exist` + ); + } + } else if (type === "worker") { + commands = await CommandsFactory.forWorker(id, { client }); + + if (!commands) { + throw new Error(`commandsFromURL, worker with id '${id}' doesn't exist`); + } + } else if (type == "process") { + // When debugging firefox itself, force the server to accept debugging processes. + DevToolsServer.allowChromeProcess = true; + commands = await CommandsFactory.forMainProcess({ client }); + } else { + throw new Error(`commandsFromURL, unsupported type '${type}' parameter`); + } + + return commands; +} + +/** + * Create a DevToolsClient for a given URL object having various query parameters: + * + * host: + * {String} The hostname or IP address to connect to. + * port: + * {Number} The TCP port to connect to, to use with `host` argument. + * remoteId: + * {String} Remote client id, for runtimes from the remote-client-manager + * ws: + * {Boolean} If true, connect via websocket instead of regular TCP connection. + * + * @param {URL} url + * The url to fetch query params from. + * @return a promise that resolves a DevToolsClient object + */ +async function clientFromURL(url) { + const params = url.searchParams; + + // If a remote id was provided we should already have a connected client available. + const remoteId = params.get("remoteId"); + if (remoteId) { + const client = remoteClientManager.getClientByRemoteId(remoteId); + if (!client) { + throw new Error(`Could not find client with remote id: ${remoteId}`); + } + return client; + } + + const host = params.get("host"); + const port = params.get("port"); + const webSocket = !!params.get("ws"); + + let transport; + if (port) { + transport = await DevToolsClient.socketConnect({ host, port, webSocket }); + } else { + // Setup a server if we don't have one already running + DevToolsServer.init(); + DevToolsServer.registerAllActors(); + transport = DevToolsServer.connectPipe(); + } + return new DevToolsClient(transport); +} diff --git a/devtools/client/framework/components/ChromeDebugToolbar.css b/devtools/client/framework/components/ChromeDebugToolbar.css new file mode 100644 index 0000000000..4b74d47e05 --- /dev/null +++ b/devtools/client/framework/components/ChromeDebugToolbar.css @@ -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/. */ + +.chrome-debug-toolbar { + display: flex; + padding: 0.5em 1em; + font-size: 12px; + line-height: 1.5; + background-color: var(--theme-body-alternate-emphasized-background); + font-family: system-ui, -apple-system, sans-serif; + border-block-end: 1px solid var(--theme-toolbar-separator); +} + +.chrome-debug-toolbar section > h3 { + margin: 0; + font-weight: normal; +} + +.chrome-debug-toolbar__modes { + display: flex; + align-items: baseline; + gap: 0.5em 1em; + flex-wrap: wrap; +} + +.chrome-debug-toolbar__modes label { + border: 1px solid var(--theme-toolbar-separator); + border-radius: 4px; + padding: 4px 8px; +} + +.chrome-debug-toolbar__modes label.selected { + border-color: var(--theme-toolbar-selected-color); +} + +.chrome-debug-toolbar__modes label:where(:hover, :focus-within) { + background-color: var(--blue-50-a30); +} + +.chrome-debug-toolbar__modes label input { + margin: 0; + margin-inline-end: 4px; +} + +.mode__sublabel { + color: var(--theme-comment); + margin-inline-start: 4px; +} + +@media (prefers-contrast) { + .chrome-debug-toolbar { + background-color: Window; + color: WindowText; + } + + .mode_sublabel { + color: GrayText; + } +} diff --git a/devtools/client/framework/components/ChromeDebugToolbar.js b/devtools/client/framework/components/ChromeDebugToolbar.js new file mode 100644 index 0000000000..b126cf78fd --- /dev/null +++ b/devtools/client/framework/components/ChromeDebugToolbar.js @@ -0,0 +1,123 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + PureComponent, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const FluentReact = require("resource://devtools/client/shared/vendor/fluent-react.js"); +const Localized = createFactory(FluentReact.Localized); + +const MODE_PREF = "devtools.browsertoolbox.scope"; + +const MODE_VALUES = { + PARENT_PROCESS: "parent-process", + EVERYTHING: "everything", +}; + +const MODE_DATA = { + [MODE_VALUES.PARENT_PROCESS]: { + containerL10nId: "toolbox-mode-parent-process-container", + labelL10nId: "toolbox-mode-parent-process-label", + subLabelL10nId: "toolbox-mode-parent-process-sub-label", + }, + [MODE_VALUES.EVERYTHING]: { + containerL10nId: "toolbox-mode-everything-container", + labelL10nId: "toolbox-mode-everything-label", + subLabelL10nId: "toolbox-mode-everything-sub-label", + }, +}; + +/** + * Toolbar displayed on top of the regular toolbar in the Browser Toolbox and Browser Console, + * displaying chrome-debugging-specific options. + */ +class ChromeDebugToolbar extends PureComponent { + static get propTypes() { + return { + isBrowserConsole: PropTypes.bool, + }; + } + + constructor(props) { + super(props); + + this.state = { + mode: Services.prefs.getCharPref(MODE_PREF), + }; + + this.onModePrefChanged = this.onModePrefChanged.bind(this); + Services.prefs.addObserver(MODE_PREF, this.onModePrefChanged); + } + + componentWillUnmount() { + Services.prefs.removeObserver(MODE_PREF, this.onModePrefChanged); + } + + onModePrefChanged() { + this.setState({ + mode: Services.prefs.getCharPref(MODE_PREF), + }); + } + + renderModeItem(value) { + const { containerL10nId, labelL10nId, subLabelL10nId } = MODE_DATA[value]; + + const checked = this.state.mode == value; + return Localized( + { + id: containerL10nId, + attrs: { title: true }, + }, + dom.label( + { + className: checked ? "selected" : null, + }, + dom.input({ + type: `radio`, + name: `chrome-debug-mode`, + value, + checked: checked || null, + onChange: () => { + Services.prefs.setCharPref(MODE_PREF, value); + }, + }), + Localized({ id: labelL10nId }, dom.span({ className: "mode__label" })), + Localized( + { id: subLabelL10nId }, + dom.span({ className: "mode__sublabel" }) + ) + ) + ); + } + + render() { + return dom.header( + { + className: "chrome-debug-toolbar", + }, + dom.section( + { + className: "chrome-debug-toolbar__modes", + }, + Localized( + { + id: this.props.isBrowserConsole + ? "toolbox-mode-browser-console-label" + : "toolbox-mode-browser-toolbox-label", + }, + dom.h3({}) + ), + this.renderModeItem(MODE_VALUES.PARENT_PROCESS), + this.renderModeItem(MODE_VALUES.EVERYTHING) + ) + ); + } +} + +module.exports = ChromeDebugToolbar; diff --git a/devtools/client/framework/components/DebugTargetErrorPage.css b/devtools/client/framework/components/DebugTargetErrorPage.css new file mode 100644 index 0000000000..ffac30cece --- /dev/null +++ b/devtools/client/framework/components/DebugTargetErrorPage.css @@ -0,0 +1,21 @@ +/* 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/. */ + +.error-page { + --base-unit: 4px; /* from photon */ + + padding: calc(var(--base-unit) * 4); + font-size: 15px; /* from photon */ + min-height: 100vh; +} + +.error-page__title { + margin: 0; + font-size: 36px; /* from photon */ + font-weight: 200; /* from photon */ +} + +.error-page__details { + font-family: monospace; +} diff --git a/devtools/client/framework/components/DebugTargetErrorPage.js b/devtools/client/framework/components/DebugTargetErrorPage.js new file mode 100644 index 0000000000..9790b9cd7f --- /dev/null +++ b/devtools/client/framework/components/DebugTargetErrorPage.js @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +/** + * This component is displayed when the about:devtools-toolbox fails to load + * properly due to wrong parameters or debug targets that don't exist. + */ +class DebugTargetErrorPage extends PureComponent { + static get propTypes() { + return { + errorMessage: PropTypes.string.isRequired, + L10N: PropTypes.object.isRequired, + }; + } + + render() { + const { errorMessage, L10N } = this.props; + + return dom.article( + { + className: "error-page qa-error-page", + }, + dom.h1( + { + className: "error-page__title", + }, + L10N.getStr("toolbox.debugTargetErrorPage.title") + ), + dom.p({}, L10N.getStr("toolbox.debugTargetErrorPage.description")), + dom.output( + { + className: "error-page__details", + }, + errorMessage + ) + ); + } +} + +module.exports = DebugTargetErrorPage; diff --git a/devtools/client/framework/components/DebugTargetInfo.js b/devtools/client/framework/components/DebugTargetInfo.js new file mode 100644 index 0000000000..e3911e96c9 --- /dev/null +++ b/devtools/client/framework/components/DebugTargetInfo.js @@ -0,0 +1,401 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + PureComponent, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + CONNECTION_TYPES, +} = require("resource://devtools/client/shared/remote-debugging/constants.js"); +const DESCRIPTOR_TYPES = require("resource://devtools/client/fronts/descriptors/descriptor-types.js"); +const FluentReact = require("resource://devtools/client/shared/vendor/fluent-react.js"); +const Localized = createFactory(FluentReact.Localized); + +/** + * This is header that should be displayed on top of the toolbox when using + * about:devtools-toolbox. + */ +class DebugTargetInfo extends PureComponent { + static get propTypes() { + return { + alwaysOnTop: PropTypes.boolean.isRequired, + focusedState: PropTypes.boolean, + toggleAlwaysOnTop: PropTypes.func.isRequired, + debugTargetData: PropTypes.shape({ + connectionType: PropTypes.oneOf(Object.values(CONNECTION_TYPES)) + .isRequired, + runtimeInfo: PropTypes.shape({ + deviceName: PropTypes.string, + icon: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + version: PropTypes.string.isRequired, + }).isRequired, + descriptorType: PropTypes.oneOf(Object.values(DESCRIPTOR_TYPES)) + .isRequired, + }).isRequired, + L10N: PropTypes.object.isRequired, + toolbox: PropTypes.object.isRequired, + }; + } + + constructor(props) { + super(props); + + this.state = { urlValue: props.toolbox.target.url }; + + this.onChange = this.onChange.bind(this); + this.onFocus = this.onFocus.bind(this); + this.onSubmit = this.onSubmit.bind(this); + } + + componentDidMount() { + this.updateTitle(); + } + + updateTitle() { + const { L10N, debugTargetData, toolbox } = this.props; + const title = toolbox.target.name; + const descriptorTypeStr = L10N.getStr( + this.getAssetsForDebugDescriptorType().l10nId + ); + + const { connectionType } = debugTargetData; + if (connectionType === CONNECTION_TYPES.THIS_FIREFOX) { + toolbox.doc.title = L10N.getFormatStr( + "toolbox.debugTargetInfo.tabTitleLocal", + descriptorTypeStr, + title + ); + } else { + const connectionTypeStr = L10N.getStr( + this.getAssetsForConnectionType().l10nId + ); + toolbox.doc.title = L10N.getFormatStr( + "toolbox.debugTargetInfo.tabTitleRemote", + connectionTypeStr, + descriptorTypeStr, + title + ); + } + } + + getRuntimeText() { + const { debugTargetData, L10N } = this.props; + const { name, version } = debugTargetData.runtimeInfo; + const { connectionType } = debugTargetData; + const brandShorterName = L10N.getStr("brandShorterName"); + + return connectionType === CONNECTION_TYPES.THIS_FIREFOX + ? L10N.getFormatStr( + "toolbox.debugTargetInfo.runtimeLabel.thisRuntime", + brandShorterName, + version + ) + : L10N.getFormatStr( + "toolbox.debugTargetInfo.runtimeLabel", + name, + version + ); + } + + getAssetsForConnectionType() { + const { connectionType } = this.props.debugTargetData; + + switch (connectionType) { + case CONNECTION_TYPES.USB: + return { + image: "chrome://devtools/skin/images/aboutdebugging-usb-icon.svg", + l10nId: "toolbox.debugTargetInfo.connection.usb", + }; + case CONNECTION_TYPES.NETWORK: + return { + image: "chrome://devtools/skin/images/aboutdebugging-globe-icon.svg", + l10nId: "toolbox.debugTargetInfo.connection.network", + }; + default: + return {}; + } + } + + getAssetsForDebugDescriptorType() { + const { descriptorType } = this.props.debugTargetData; + + // TODO: https://bugzilla.mozilla.org/show_bug.cgi?id=1520723 + // Show actual favicon (currently toolbox.target.activeTab.favicon + // is unpopulated) + const favicon = "chrome://devtools/skin/images/globe.svg"; + + switch (descriptorType) { + case DESCRIPTOR_TYPES.EXTENSION: + return { + image: "chrome://devtools/skin/images/debugging-addons.svg", + l10nId: "toolbox.debugTargetInfo.targetType.extension", + }; + case DESCRIPTOR_TYPES.PROCESS: + return { + image: "chrome://devtools/skin/images/settings.svg", + l10nId: "toolbox.debugTargetInfo.targetType.process", + }; + case DESCRIPTOR_TYPES.TAB: + return { + image: favicon, + l10nId: "toolbox.debugTargetInfo.targetType.tab", + }; + case DESCRIPTOR_TYPES.WORKER: + return { + image: "chrome://devtools/skin/images/debugging-workers.svg", + l10nId: "toolbox.debugTargetInfo.targetType.worker", + }; + default: + return {}; + } + } + + onChange({ target }) { + this.setState({ urlValue: target.value }); + } + + onFocus({ target }) { + target.select(); + } + + onSubmit(event) { + event.preventDefault(); + let url = this.state.urlValue; + + if (!url || !url.length) { + return; + } + + try { + // Get the URL from the fixup service: + const flags = Services.uriFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS; + const uriInfo = Services.uriFixup.getFixupURIInfo(url, flags); + url = uriInfo.fixedURI.spec; + } catch (ex) { + // The getFixupURIInfo service will throw an error if a malformed URI is + // produced from the input. + console.error(ex); + } + + this.props.toolbox.target.navigateTo({ url }); + } + + shallRenderConnection() { + const { connectionType } = this.props.debugTargetData; + const renderableTypes = [CONNECTION_TYPES.USB, CONNECTION_TYPES.NETWORK]; + + return renderableTypes.includes(connectionType); + } + + renderConnection() { + const { connectionType } = this.props.debugTargetData; + const { image, l10nId } = this.getAssetsForConnectionType(); + + return dom.span( + { + className: "iconized-label qa-connection-info", + }, + dom.img({ src: image, alt: `${connectionType} icon` }), + this.props.L10N.getStr(l10nId) + ); + } + + renderRuntime() { + if ( + !this.props.debugTargetData.runtimeInfo || + (this.props.debugTargetData.connectionType === + CONNECTION_TYPES.THIS_FIREFOX && + this.props.debugTargetData.descriptorType === + DESCRIPTOR_TYPES.EXTENSION) + ) { + // Skip the runtime render if no runtimeInfo is available. + // Runtime info is retrieved from the remote-client-manager, which might not be + // setup if about:devtools-toolbox was not opened from about:debugging. + // + // Also skip the runtime if we are debugging firefox itself, mainly to save some space. + return null; + } + + const { icon, deviceName } = this.props.debugTargetData.runtimeInfo; + + return dom.span( + { + className: "iconized-label qa-runtime-info", + }, + dom.img({ src: icon, className: "channel-icon qa-runtime-icon" }), + dom.b({ className: "devtools-ellipsis-text" }, this.getRuntimeText()), + dom.span({ className: "devtools-ellipsis-text" }, deviceName) + ); + } + + renderTargetTitle() { + const title = this.props.toolbox.target.name; + + const { image, l10nId } = this.getAssetsForDebugDescriptorType(); + + return dom.span( + { + className: "iconized-label debug-target-title", + }, + dom.img({ src: image, alt: this.props.L10N.getStr(l10nId) }), + title + ? dom.b({ className: "devtools-ellipsis-text qa-target-title" }, title) + : null + ); + } + + renderTargetURI() { + const url = this.props.toolbox.target.url; + const { descriptorType } = this.props.debugTargetData; + const isURLEditable = descriptorType === DESCRIPTOR_TYPES.TAB; + + return dom.span( + { + key: url, + className: "debug-target-url", + }, + isURLEditable + ? this.renderTargetInput(url) + : dom.span( + { className: "debug-target-url-readonly devtools-ellipsis-text" }, + url + ) + ); + } + + renderTargetInput(url) { + return dom.form( + { + className: "debug-target-url-form", + onSubmit: this.onSubmit, + }, + dom.input({ + className: "devtools-textinput debug-target-url-input", + onChange: this.onChange, + onFocus: this.onFocus, + defaultValue: url, + }) + ); + } + + renderAlwaysOnTopButton() { + // This is only displayed for local web extension debugging + const { descriptorType, connectionType } = this.props.debugTargetData; + const isLocalWebExtension = + descriptorType === DESCRIPTOR_TYPES.EXTENSION && + connectionType === CONNECTION_TYPES.THIS_FIREFOX; + if (!isLocalWebExtension) { + return []; + } + + const checked = this.props.alwaysOnTop; + const toolboxFocused = this.props.focusedState; + return [ + Localized( + { + id: checked + ? "toolbox-always-on-top-enabled2" + : "toolbox-always-on-top-disabled2", + attrs: { title: true }, + }, + dom.button({ + className: + `toolbox-always-on-top` + + (checked ? " checked" : "") + + (toolboxFocused ? " toolbox-is-focused" : ""), + onClick: this.props.toggleAlwaysOnTop, + }) + ), + ]; + } + + renderNavigationButton(detail) { + const { L10N } = this.props; + + return dom.button( + { + className: `iconized-label navigation-button ${detail.className}`, + onClick: detail.onClick, + title: L10N.getStr(detail.l10nId), + }, + dom.img({ + src: detail.icon, + alt: L10N.getStr(detail.l10nId), + }) + ); + } + + renderNavigation() { + const { debugTargetData } = this.props; + const { descriptorType } = debugTargetData; + + if ( + descriptorType !== DESCRIPTOR_TYPES.TAB && + descriptorType !== DESCRIPTOR_TYPES.EXTENSION + ) { + return null; + } + + const items = []; + + // There is little value in exposing back/forward for WebExtensions + if ( + this.props.toolbox.target.getTrait("navigation") && + descriptorType === DESCRIPTOR_TYPES.TAB + ) { + items.push( + this.renderNavigationButton({ + className: "qa-back-button", + icon: "chrome://browser/skin/back.svg", + l10nId: "toolbox.debugTargetInfo.back", + onClick: () => this.props.toolbox.target.goBack(), + }), + this.renderNavigationButton({ + className: "qa-forward-button", + icon: "chrome://browser/skin/forward.svg", + l10nId: "toolbox.debugTargetInfo.forward", + onClick: () => this.props.toolbox.target.goForward(), + }) + ); + } + + items.push( + this.renderNavigationButton({ + className: "qa-reload-button", + icon: "chrome://global/skin/icons/reload.svg", + l10nId: "toolbox.debugTargetInfo.reload", + onClick: () => + this.props.toolbox.commands.targetCommand.reloadTopLevelTarget(), + }) + ); + + return dom.div( + { + className: "debug-target-navigation", + }, + ...items + ); + } + + render() { + return dom.header( + { + className: "debug-target-info qa-debug-target-info", + }, + this.shallRenderConnection() ? this.renderConnection() : null, + this.renderRuntime(), + this.renderTargetTitle(), + this.renderNavigation(), + this.renderTargetURI(), + ...this.renderAlwaysOnTopButton() + ); + } +} + +module.exports = DebugTargetInfo; diff --git a/devtools/client/framework/components/MeatballMenu.js b/devtools/client/framework/components/MeatballMenu.js new file mode 100644 index 0000000000..fc694171c8 --- /dev/null +++ b/devtools/client/framework/components/MeatballMenu.js @@ -0,0 +1,299 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + PureComponent, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { hr } = dom; + +loader.lazyGetter(this, "MenuItem", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuItem.js") + ); +}); +loader.lazyGetter(this, "MenuList", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuList.js") + ); +}); + +loader.lazyRequireGetter( + this, + "openDocLink", + "resource://devtools/client/shared/link.js", + true +); +loader.lazyRequireGetter( + this, + "assert", + "resource://devtools/shared/DevToolsUtils.js", + true +); + +const openDevToolsDocsLink = () => { + openDocLink("https://firefox-source-docs.mozilla.org/devtools-user/"); +}; + +const openCommunityLink = () => { + openDocLink( + "https://discourse.mozilla.org/c/devtools?utm_source=devtools&utm_medium=tabbar-menu" + ); +}; + +class MeatballMenu extends PureComponent { + static get propTypes() { + return { + // The id of the currently selected tool, e.g. "inspector" + currentToolId: PropTypes.string, + + // List of possible docking options. + hostTypes: PropTypes.arrayOf( + PropTypes.shape({ + position: PropTypes.string.isRequired, + switchHost: PropTypes.func.isRequired, + }) + ), + + // Current docking type. Typically one of the position values in + // |hostTypes| but this is not always the case (e.g. for "browsertoolbox"). + currentHostType: PropTypes.string, + + // Is the split console currently visible? + isSplitConsoleActive: PropTypes.bool, + + // Are we disabling the behavior where pop-ups are automatically closed + // when clicking outside them? + // + // This is a tri-state value that may be true/false or undefined where + // undefined means that the option is not relevant in this context + // (i.e. we're not in a browser toolbox). + disableAutohide: PropTypes.bool, + + // Apply a pseudo-locale to the Firefox UI. This is only available in the browser + // toolbox. This value can be undefined, "accented", "bidi", "none". + pseudoLocale: PropTypes.string, + + // Function to turn the options panel on / off. + toggleOptions: PropTypes.func.isRequired, + + // Function to turn the split console on / off. + toggleSplitConsole: PropTypes.func, + + // Function to turn the disable pop-up autohide behavior on / off. + toggleNoAutohide: PropTypes.func, + + // Manage the pseudo-localization for the Firefox UI. + // https://firefox-source-docs.mozilla.org/l10n/fluent/tutorial.html#manually-testing-ui-with-pseudolocalization + disablePseudoLocale: PropTypes.func, + enableAccentedPseudoLocale: PropTypes.func, + enableBidiPseudoLocale: PropTypes.func, + + // Bug 1709191 - The help shortcut key is localized without Fluent, and still needs + // to be migrated. This is the only remaining use of the legacy L10N object. + // Everything else should prefer the Fluent API. + L10N: PropTypes.object.isRequired, + + // Callback function that will be invoked any time the component contents + // update in such a way that its bounding box might change. + onResize: PropTypes.func, + }; + } + + componentDidUpdate(prevProps) { + if (!this.props.onResize) { + return; + } + + // We are only expecting the following kinds of dynamic changes when a popup + // is showing: + // + // - The "Disable pop-up autohide" menu item being added after the Browser + // Toolbox is connected. + // - The pseudo locale options being added after the Browser Toolbox is connected. + // - The split console label changing between "Show Split Console" and "Hide + // Split Console". + // - The "Show/Hide Split Console" entry being added removed or removed. + // + // The latter two cases are only likely to be noticed when "Disable pop-up + // autohide" is active, but for completeness we handle them here. + const didChange = + typeof this.props.disableAutohide !== typeof prevProps.disableAutohide || + this.props.pseudoLocale !== prevProps.pseudoLocale || + this.props.currentToolId !== prevProps.currentToolId || + this.props.isSplitConsoleActive !== prevProps.isSplitConsoleActive; + + if (didChange) { + this.props.onResize(); + } + } + + render() { + const items = []; + + // Dock options + for (const hostType of this.props.hostTypes) { + // This is more verbose than it needs to be but lets us easily search for + // l10n entities. + let l10nID; + switch (hostType.position) { + case "window": + l10nID = "toolbox-meatball-menu-dock-separate-window-label"; + break; + + case "bottom": + l10nID = "toolbox-meatball-menu-dock-bottom-label"; + break; + + case "left": + l10nID = "toolbox-meatball-menu-dock-left-label"; + break; + + case "right": + l10nID = "toolbox-meatball-menu-dock-right-label"; + break; + + default: + assert(false, `Unexpected hostType.position: ${hostType.position}`); + break; + } + + items.push( + MenuItem({ + id: `toolbox-meatball-menu-dock-${hostType.position}`, + key: `dock-${hostType.position}`, + l10nID, + onClick: hostType.switchHost, + checked: hostType.position === this.props.currentHostType, + className: "iconic", + }) + ); + } + + if (items.length) { + items.push(hr({ key: "dock-separator" })); + } + + // Split console + if (this.props.currentToolId !== "webconsole") { + const l10nID = this.props.isSplitConsoleActive + ? "toolbox-meatball-menu-hideconsole-label" + : "toolbox-meatball-menu-splitconsole-label"; + items.push( + MenuItem({ + id: "toolbox-meatball-menu-splitconsole", + key: "splitconsole", + l10nID, + accelerator: "Esc", + onClick: this.props.toggleSplitConsole, + className: "iconic", + }) + ); + } + + // Settings + items.push( + MenuItem({ + id: "toolbox-meatball-menu-settings", + key: "settings", + l10nID: "toolbox-meatball-menu-settings-label", + // Bug 1709191 - The help key is localized without Fluent, and still needs to + // be migrated. + accelerator: this.props.L10N.getStr("toolbox.help.key"), + onClick: this.props.toggleOptions, + className: "iconic", + }) + ); + + if ( + typeof this.props.disableAutohide !== "undefined" || + typeof this.props.pseudoLocale !== "undefined" + ) { + items.push(hr({ key: "docs-separator-1" })); + } + + // Disable pop-up autohide + // + // If |disableAutohide| is undefined, it means this feature is not available + // in this context. + if (typeof this.props.disableAutohide !== "undefined") { + items.push( + MenuItem({ + id: "toolbox-meatball-menu-noautohide", + key: "noautohide", + l10nID: "toolbox-meatball-menu-noautohide-label", + type: "checkbox", + checked: this.props.disableAutohide, + onClick: this.props.toggleNoAutohide, + className: "iconic", + }) + ); + } + + // Pseudo-locales. + if (typeof this.props.pseudoLocale !== "undefined") { + const { + pseudoLocale, + enableAccentedPseudoLocale, + enableBidiPseudoLocale, + disablePseudoLocale, + } = this.props; + items.push( + MenuItem({ + id: "toolbox-meatball-menu-pseudo-locale-accented", + key: "pseudo-locale-accented", + l10nID: "toolbox-meatball-menu-pseudo-locale-accented", + type: "checkbox", + checked: pseudoLocale === "accented", + onClick: + pseudoLocale === "accented" + ? disablePseudoLocale + : enableAccentedPseudoLocale, + className: "iconic", + }), + MenuItem({ + id: "toolbox-meatball-menu-pseudo-locale-bidi", + key: "pseudo-locale-bidi", + l10nID: "toolbox-meatball-menu-pseudo-locale-bidi", + type: "checkbox", + checked: pseudoLocale === "bidi", + onClick: + pseudoLocale === "bidi" + ? disablePseudoLocale + : enableBidiPseudoLocale, + className: "iconic", + }) + ); + } + + items.push(hr({ key: "docs-separator-2" })); + + // Getting started + items.push( + MenuItem({ + id: "toolbox-meatball-menu-documentation", + key: "documentation", + l10nID: "toolbox-meatball-menu-documentation-label", + onClick: openDevToolsDocsLink, + }) + ); + + // Give feedback + items.push( + MenuItem({ + id: "toolbox-meatball-menu-community", + key: "community", + l10nID: "toolbox-meatball-menu-community-label", + onClick: openCommunityLink, + }) + ); + + return MenuList({ id: "toolbox-meatball-menu" }, items); + } +} + +module.exports = MeatballMenu; diff --git a/devtools/client/framework/components/ToolboxController.js b/devtools/client/framework/components/ToolboxController.js new file mode 100644 index 0000000000..17d0c8a278 --- /dev/null +++ b/devtools/client/framework/components/ToolboxController.js @@ -0,0 +1,231 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const ToolboxToolbar = createFactory( + require("resource://devtools/client/framework/components/ToolboxToolbar.js") +); +const ELEMENT_PICKER_ID = "command-button-pick"; + +/** + * This component serves as a state controller for the toolbox React component. It's a + * thin layer for translating events and state of the outside world into the React update + * cycle. This solution was used to keep the amount of code changes to a minimimum while + * adapting the existing codebase to start using React. + */ +class ToolboxController extends Component { + constructor(props, context) { + super(props, context); + + // See the ToolboxToolbar propTypes for documentation on each of these items in + // state, and for the definitions of the props that are expected to be passed in. + this.state = { + focusedButton: ELEMENT_PICKER_ID, + toolboxButtons: [], + visibleToolboxButtonCount: 0, + currentToolId: null, + highlightedTools: new Set(), + panelDefinitions: [], + hostTypes: [], + currentHostType: undefined, + areDockOptionsEnabled: true, + canCloseToolbox: true, + isSplitConsoleActive: false, + disableAutohide: undefined, + alwaysOnTop: undefined, + pseudoLocale: undefined, + canRender: false, + buttonIds: [], + checkedButtonsUpdated: () => { + this.forceUpdate(); + }, + }; + + this.setFocusedButton = this.setFocusedButton.bind(this); + this.setToolboxButtons = this.setToolboxButtons.bind(this); + this.setCurrentToolId = this.setCurrentToolId.bind(this); + this.highlightTool = this.highlightTool.bind(this); + this.unhighlightTool = this.unhighlightTool.bind(this); + this.setHostTypes = this.setHostTypes.bind(this); + this.setCurrentHostType = this.setCurrentHostType.bind(this); + this.setDockOptionsEnabled = this.setDockOptionsEnabled.bind(this); + this.setCanCloseToolbox = this.setCanCloseToolbox.bind(this); + this.setIsSplitConsoleActive = this.setIsSplitConsoleActive.bind(this); + this.setDisableAutohide = this.setDisableAutohide.bind(this); + this.setCanRender = this.setCanRender.bind(this); + this.setPanelDefinitions = this.setPanelDefinitions.bind(this); + this.updateButtonIds = this.updateButtonIds.bind(this); + this.updateFocusedButton = this.updateFocusedButton.bind(this); + this.setDebugTargetData = this.setDebugTargetData.bind(this); + } + + shouldComponentUpdate() { + return this.state.canRender; + } + + componentWillUnmount() { + this.state.toolboxButtons.forEach(button => { + button.off("updatechecked", this.state.checkedButtonsUpdated); + }); + } + + /** + * The button and tab ids must be known in order to be able to focus left and right + * using the arrow keys. + */ + updateButtonIds() { + const { toolboxButtons, panelDefinitions, canCloseToolbox } = this.state; + + // This is a little gnarly, but go through all of the state and extract the IDs. + this.setState({ + buttonIds: [ + ...toolboxButtons + .filter(btn => btn.isInStartContainer) + .map(({ id }) => id), + ...panelDefinitions.map(({ id }) => id), + ...toolboxButtons + .filter(btn => !btn.isInStartContainer) + .map(({ id }) => id), + canCloseToolbox ? "toolbox-close" : null, + ].filter(id => id), + }); + + this.updateFocusedButton(); + } + + updateFocusedButton() { + this.setFocusedButton(this.state.focusedButton); + } + + setFocusedButton(focusedButton) { + const { buttonIds } = this.state; + + focusedButton = + focusedButton && buttonIds.includes(focusedButton) + ? focusedButton + : buttonIds[0]; + if (this.state.focusedButton !== focusedButton) { + this.setState({ + focusedButton, + }); + } + } + + setCurrentToolId(currentToolId) { + this.setState({ currentToolId }, () => { + // Also set the currently focused button to this tool. + this.setFocusedButton(currentToolId); + }); + } + + setCanRender() { + this.setState({ canRender: true }, this.updateButtonIds); + } + + highlightTool(highlightedTool) { + const { highlightedTools } = this.state; + highlightedTools.add(highlightedTool); + this.setState({ highlightedTools }); + } + + unhighlightTool(id) { + const { highlightedTools } = this.state; + if (highlightedTools.has(id)) { + highlightedTools.delete(id); + this.setState({ highlightedTools }); + } + } + + setDockOptionsEnabled(areDockOptionsEnabled) { + this.setState({ areDockOptionsEnabled }); + } + + setHostTypes(hostTypes) { + this.setState({ hostTypes }); + } + + setCurrentHostType(currentHostType) { + this.setState({ currentHostType }); + } + + setCanCloseToolbox(canCloseToolbox) { + this.setState({ canCloseToolbox }, this.updateButtonIds); + } + + setIsSplitConsoleActive(isSplitConsoleActive) { + this.setState({ isSplitConsoleActive }); + } + + /** + * @param {bool | undefined} disableAutohide + */ + setDisableAutohide(disableAutohide) { + this.setState({ disableAutohide }); + } + + /** + * @param {bool | undefined} alwaysOnTop + */ + setAlwaysOnTop(alwaysOnTop) { + this.setState({ alwaysOnTop }); + } + + /** + * @param {bool} focusedState + */ + setFocusedState(focusedState) { + // We only care about the focused state when the toolbox is always on top + if (this.state.alwaysOnTop) { + this.setState({ focusedState }); + } + } + + /** + * @param {"bidi" | "accented" | "none" | undefined} pseudoLocale + */ + setPseudoLocale(pseudoLocale) { + this.setState({ pseudoLocale }); + } + + setPanelDefinitions(panelDefinitions) { + this.setState({ panelDefinitions }, this.updateButtonIds); + } + + get panelDefinitions() { + return this.state.panelDefinitions; + } + + setToolboxButtons(toolboxButtons) { + // Listen for updates of the checked attribute. + this.state.toolboxButtons.forEach(button => { + button.off("updatechecked", this.state.checkedButtonsUpdated); + }); + toolboxButtons.forEach(button => { + button.on("updatechecked", this.state.checkedButtonsUpdated); + }); + + const visibleToolboxButtonCount = toolboxButtons.filter( + button => button.isVisible + ).length; + + this.setState( + { toolboxButtons, visibleToolboxButtonCount }, + this.updateButtonIds + ); + } + + setDebugTargetData(data) { + this.setState({ debugTargetData: data }); + } + + render() { + return ToolboxToolbar(Object.assign({}, this.props, this.state)); + } +} + +module.exports = ToolboxController; diff --git a/devtools/client/framework/components/ToolboxTab.js b/devtools/client/framework/components/ToolboxTab.js new file mode 100644 index 0000000000..680b68e3e5 --- /dev/null +++ b/devtools/client/framework/components/ToolboxTab.js @@ -0,0 +1,106 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { img, button, span } = dom; + +class ToolboxTab extends Component { + // See toolbox-toolbar propTypes for details on the props used here. + static get propTypes() { + return { + currentToolId: PropTypes.string, + focusButton: PropTypes.func, + focusedButton: PropTypes.string, + highlightedTools: PropTypes.object.isRequired, + panelDefinition: PropTypes.object, + selectTool: PropTypes.func, + }; + } + + constructor(props) { + super(props); + this.renderIcon = this.renderIcon.bind(this); + } + + renderIcon(definition) { + const { icon } = definition; + if (!icon) { + return []; + } + return [ + img({ + alt: "", + src: icon, + }), + ]; + } + + render() { + const { + panelDefinition, + currentToolId, + highlightedTools, + selectTool, + focusedButton, + focusButton, + } = this.props; + const { id, extensionId, tooltip, label, iconOnly, badge } = + panelDefinition; + const isHighlighted = id === currentToolId; + + const className = [ + "devtools-tab", + currentToolId === id ? "selected" : "", + highlightedTools.has(id) ? "highlighted" : "", + iconOnly ? "devtools-tab-icon-only" : "", + ].join(" "); + + return button( + { + className, + id: `toolbox-tab-${id}`, + "data-id": id, + "data-extension-id": extensionId, + title: tooltip, + type: "button", + "aria-pressed": currentToolId === id ? "true" : "false", + tabIndex: focusedButton === id ? "0" : "-1", + onFocus: () => focusButton(id), + onMouseDown: () => selectTool(id, "tab_switch"), + onKeyDown: evt => { + if (evt.key === "Enter" || evt.key === " ") { + selectTool(id, "tab_switch"); + } + }, + }, + span({ + className: "devtools-tab-line", + }), + ...this.renderIcon(panelDefinition), + iconOnly + ? null + : span( + { + className: "devtools-tab-label", + }, + label, + badge && !isHighlighted + ? span( + { + className: "devtools-tab-badge", + }, + badge + ) + : null + ) + ); + } +} + +module.exports = ToolboxTab; diff --git a/devtools/client/framework/components/ToolboxTabs.js b/devtools/client/framework/components/ToolboxTabs.js new file mode 100644 index 0000000000..04b7d653a4 --- /dev/null +++ b/devtools/client/framework/components/ToolboxTabs.js @@ -0,0 +1,331 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + Component, + createFactory, + createRef, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + ToolboxTabsOrderManager, +} = require("resource://devtools/client/framework/toolbox-tabs-order-manager.js"); + +const { div } = dom; + +const ToolboxTab = createFactory( + require("resource://devtools/client/framework/components/ToolboxTab.js") +); + +loader.lazyGetter(this, "MenuButton", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuButton.js") + ); +}); +loader.lazyGetter(this, "MenuItem", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuItem.js") + ); +}); +loader.lazyGetter(this, "MenuList", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuList.js") + ); +}); + +// 26px is chevron devtools button width.(i.e. tools-chevronmenu) +const CHEVRON_BUTTON_WIDTH = 26; + +class ToolboxTabs extends Component { + // See toolbox-toolbar propTypes for details on the props used here. + static get propTypes() { + return { + currentToolId: PropTypes.string, + focusButton: PropTypes.func, + focusedButton: PropTypes.string, + highlightedTools: PropTypes.object, + panelDefinitions: PropTypes.array, + selectTool: PropTypes.func, + toolbox: PropTypes.object, + visibleToolboxButtonCount: PropTypes.number.isRequired, + L10N: PropTypes.object, + onTabsOrderUpdated: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + + this.state = { + // Array of overflowed tool id. + overflowedTabIds: [], + }; + + this.wrapperEl = createRef(); + + // Map with tool Id and its width size. This lifecycle is out of React's + // lifecycle. If a tool is registered, ToolboxTabs will add target tool id + // to this map. ToolboxTabs will never remove tool id from this cache. + this._cachedToolTabsWidthMap = new Map(); + + this._resizeTimerId = null; + this.resizeHandler = this.resizeHandler.bind(this); + + const { toolbox, onTabsOrderUpdated, panelDefinitions } = props; + this._tabsOrderManager = new ToolboxTabsOrderManager( + toolbox, + onTabsOrderUpdated, + panelDefinitions + ); + } + + componentDidMount() { + window.addEventListener("resize", this.resizeHandler); + this.updateCachedToolTabsWidthMap(); + this.updateOverflowedTabs(); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillUpdate(nextProps, nextState) { + if (this.shouldUpdateToolboxTabs(this.props, nextProps)) { + // Force recalculate and render in this cycle if panel definition has + // changed or selected tool has changed. + nextState.overflowedTabIds = []; + } + } + + componentDidUpdate(prevProps, prevState) { + if (this.shouldUpdateToolboxTabs(prevProps, this.props)) { + this.updateCachedToolTabsWidthMap(); + this.updateOverflowedTabs(); + this._tabsOrderManager.setCurrentPanelDefinitions( + this.props.panelDefinitions + ); + } + } + + componentWillUnmount() { + window.removeEventListener("resize", this.resizeHandler); + window.cancelIdleCallback(this._resizeTimerId); + this._tabsOrderManager.destroy(); + } + + /** + * Check if two array of ids are the same or not. + */ + equalToolIdArray(prevPanels, nextPanels) { + if (prevPanels.length !== nextPanels.length) { + return false; + } + + // Check panel definitions even if both of array size is same. + // For example, the case of changing the tab's order. + return prevPanels.join("-") === nextPanels.join("-"); + } + + /** + * Return true if we should update the overflowed tabs. + */ + shouldUpdateToolboxTabs(prevProps, nextProps) { + if ( + prevProps.currentToolId !== nextProps.currentToolId || + prevProps.visibleToolboxButtonCount !== + nextProps.visibleToolboxButtonCount + ) { + return true; + } + + const prevPanels = prevProps.panelDefinitions.map(def => def.id); + const nextPanels = nextProps.panelDefinitions.map(def => def.id); + return !this.equalToolIdArray(prevPanels, nextPanels); + } + + /** + * Update the Map of tool id and tool tab width. + */ + updateCachedToolTabsWidthMap() { + const utils = window.windowUtils; + // Force a reflow before calling getBoundingWithoutFlushing on each tab. + this.wrapperEl.current.clientWidth; + + for (const tab of this.wrapperEl.current.querySelectorAll( + ".devtools-tab" + )) { + const tabId = tab.id.replace("toolbox-tab-", ""); + if (!this._cachedToolTabsWidthMap.has(tabId)) { + const rect = utils.getBoundsWithoutFlushing(tab); + this._cachedToolTabsWidthMap.set(tabId, rect.width); + } + } + } + + /** + * Update the overflowed tab array from currently displayed tool tab. + * If calculated result is the same as the current overflowed tab array, this + * function will not update state. + */ + updateOverflowedTabs() { + const toolboxWidth = parseInt( + getComputedStyle(this.wrapperEl.current).width, + 10 + ); + const { currentToolId } = this.props; + const enabledTabs = this.props.panelDefinitions.map(def => def.id); + let sumWidth = 0; + const visibleTabs = []; + + for (const id of enabledTabs) { + const width = this._cachedToolTabsWidthMap.get(id); + sumWidth += width; + if (sumWidth <= toolboxWidth) { + visibleTabs.push(id); + } else { + sumWidth = sumWidth - width + CHEVRON_BUTTON_WIDTH; + + // If toolbox can't display the Chevron, remove the last tool tab. + if (sumWidth > toolboxWidth) { + const removeTabId = visibleTabs.pop(); + sumWidth -= this._cachedToolTabsWidthMap.get(removeTabId); + } + break; + } + } + + // If the selected tab is in overflowed tabs, insert it into a visible + // toolbox. + if ( + !visibleTabs.includes(currentToolId) && + enabledTabs.includes(currentToolId) + ) { + const selectedToolWidth = this._cachedToolTabsWidthMap.get(currentToolId); + while ( + sumWidth + selectedToolWidth > toolboxWidth && + visibleTabs.length + ) { + const removingToolId = visibleTabs.pop(); + const removingToolWidth = + this._cachedToolTabsWidthMap.get(removingToolId); + sumWidth -= removingToolWidth; + } + + // If toolbox width is narrow, toolbox display only chevron menu. + // i.e. All tool tabs will overflow. + if (sumWidth + selectedToolWidth <= toolboxWidth) { + visibleTabs.push(currentToolId); + } + } + + const willOverflowTabs = enabledTabs.filter( + id => !visibleTabs.includes(id) + ); + if (!this.equalToolIdArray(this.state.overflowedTabIds, willOverflowTabs)) { + this.setState({ overflowedTabIds: willOverflowTabs }); + } + } + + resizeHandler(evt) { + window.cancelIdleCallback(this._resizeTimerId); + this._resizeTimerId = window.requestIdleCallback( + () => { + this.updateOverflowedTabs(); + }, + { timeout: 100 } + ); + } + + renderToolsChevronMenuList() { + const { panelDefinitions, selectTool } = this.props; + + const items = []; + for (const { id, label, icon } of panelDefinitions) { + if (this.state.overflowedTabIds.includes(id)) { + items.push( + MenuItem({ + key: id, + id: "tools-chevron-menupopup-" + id, + label, + type: "checkbox", + onClick: () => { + selectTool(id, "tab_switch"); + }, + icon, + }) + ); + } + } + + return MenuList({ id: "tools-chevron-menupopup" }, items); + } + + /** + * Render a button to access overflowed tools, displayed only when the toolbar + * presents an overflow. + */ + renderToolsChevronButton() { + const { toolbox } = this.props; + + return MenuButton( + { + id: "tools-chevron-menu-button", + menuId: "tools-chevron-menu-button-panel", + className: "devtools-tabbar-button tools-chevron-menu", + toolboxDoc: toolbox.doc, + }, + this.renderToolsChevronMenuList() + ); + } + + /** + * Render all of the tabs, based on the panel definitions and builds out + * a toolbox tab for each of them. Will render the chevron button if the + * container has an overflow. + */ + render() { + const { + currentToolId, + focusButton, + focusedButton, + highlightedTools, + panelDefinitions, + selectTool, + } = this.props; + + const tabs = panelDefinitions.map(panelDefinition => { + // Don't display overflowed tab. + if (!this.state.overflowedTabIds.includes(panelDefinition.id)) { + return ToolboxTab({ + key: panelDefinition.id, + currentToolId, + focusButton, + focusedButton, + highlightedTools, + panelDefinition, + selectTool, + }); + } + return null; + }); + + return div( + { + className: "toolbox-tabs-wrapper", + ref: this.wrapperEl, + }, + div( + { + className: "toolbox-tabs", + onMouseDown: e => this._tabsOrderManager.onMouseDown(e), + }, + tabs, + this.state.overflowedTabIds.length + ? this.renderToolsChevronButton() + : null + ) + ); + } +} + +module.exports = ToolboxTabs; diff --git a/devtools/client/framework/components/ToolboxToolbar.js b/devtools/client/framework/components/ToolboxToolbar.js new file mode 100644 index 0000000000..6f94d0282b --- /dev/null +++ b/devtools/client/framework/components/ToolboxToolbar.js @@ -0,0 +1,547 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { div, button } = dom; +const MenuButton = createFactory( + require("resource://devtools/client/shared/components/menu/MenuButton.js") +); +const ToolboxTabs = createFactory( + require("resource://devtools/client/framework/components/ToolboxTabs.js") +); +loader.lazyGetter(this, "MeatballMenu", function () { + return createFactory( + require("resource://devtools/client/framework/components/MeatballMenu.js") + ); +}); +loader.lazyGetter(this, "MenuItem", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuItem.js") + ); +}); +loader.lazyGetter(this, "MenuList", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuList.js") + ); +}); +loader.lazyGetter(this, "LocalizationProvider", function () { + return createFactory( + require("resource://devtools/client/shared/vendor/fluent-react.js") + .LocalizationProvider + ); +}); +loader.lazyGetter(this, "DebugTargetInfo", () => + createFactory( + require("resource://devtools/client/framework/components/DebugTargetInfo.js") + ) +); +loader.lazyGetter(this, "ChromeDebugToolbar", () => + createFactory( + require("resource://devtools/client/framework/components/ChromeDebugToolbar.js") + ) +); + +loader.lazyRequireGetter( + this, + "getUnicodeUrl", + "resource://devtools/client/shared/unicode-url.js", + true +); + +/** + * This is the overall component for the toolbox toolbar. It is designed to not know how + * the state is being managed, and attempts to be as pure as possible. The + * ToolboxController component controls the changing state, and passes in everything as + * props. + */ +class ToolboxToolbar extends Component { + static get propTypes() { + return { + // The currently focused item (for arrow keyboard navigation) + // This ID determines the tabindex being 0 or -1. + focusedButton: PropTypes.string, + // List of command button definitions. + toolboxButtons: PropTypes.array, + // The id of the currently selected tool, e.g. "inspector" + currentToolId: PropTypes.string, + // An optionally highlighted tools, e.g. "inspector" (used by ToolboxTabs + // component). + highlightedTools: PropTypes.instanceOf(Set), + // List of tool panel definitions (used by ToolboxTabs component). + panelDefinitions: PropTypes.array, + // List of possible docking options. + hostTypes: PropTypes.arrayOf( + PropTypes.shape({ + position: PropTypes.string.isRequired, + switchHost: PropTypes.func.isRequired, + }) + ), + // Current docking type. Typically one of the position values in + // |hostTypes| but this is not always the case (e.g. for "browsertoolbox"). + currentHostType: PropTypes.string, + // Are docking options enabled? They are not enabled in certain situations + // like when the toolbox is opened in a tab. + areDockOptionsEnabled: PropTypes.bool, + // Do we need to add UI for closing the toolbox? We don't when the + // toolbox is undocked, for example. + canCloseToolbox: PropTypes.bool, + // Is the split console currently visible? + isSplitConsoleActive: PropTypes.bool, + // Are we disabling the behavior where pop-ups are automatically closed + // when clicking outside them? + // + // This is a tri-state value that may be true/false or undefined where + // undefined means that the option is not relevant in this context + // (i.e. we're not in a browser toolbox). + disableAutohide: PropTypes.bool, + // Are we displaying the window always on top? + // + // This is a tri-state value that may be true/false or undefined where + // undefined means that the option is not relevant in this context + // (i.e. we're not in a local web extension toolbox). + alwaysOnTop: PropTypes.bool, + // Is the toolbox currently focused? + // + // This will only be defined when alwaysOnTop is true. + focusedState: PropTypes.bool, + // Function to turn the options panel on / off. + toggleOptions: PropTypes.func.isRequired, + // Function to turn the split console on / off. + toggleSplitConsole: PropTypes.func, + // Function to turn the disable pop-up autohide behavior on / off. + toggleNoAutohide: PropTypes.func, + // Function to turn the always on top behavior on / off. + toggleAlwaysOnTop: PropTypes.func, + // Function to completely close the toolbox. + closeToolbox: PropTypes.func, + // Keep a record of what button is focused. + focusButton: PropTypes.func, + // Hold off displaying the toolbar until enough information is ready for + // it to render nicely. + canRender: PropTypes.bool, + // Localization interface. + L10N: PropTypes.object.isRequired, + // The devtools toolbox + toolbox: PropTypes.object, + // Call back function to detect tabs order updated. + onTabsOrderUpdated: PropTypes.func.isRequired, + // Count of visible toolbox buttons which is used by ToolboxTabs component + // to recognize that the visibility of toolbox buttons were changed. + // Because in the component we cannot compare the visibility since the + // button definition instance in toolboxButtons will be unchanged. + visibleToolboxButtonCount: PropTypes.number, + // Data to show debug target info, if needed + debugTargetData: PropTypes.shape({ + runtimeInfo: PropTypes.object.isRequired, + descriptorType: PropTypes.string.isRequired, + }), + // The loaded Fluent localization bundles. + fluentBundles: PropTypes.array.isRequired, + }; + } + + constructor(props) { + super(props); + + this.hideMenu = this.hideMenu.bind(this); + this.createFrameList = this.createFrameList.bind(this); + this.highlightFrame = this.highlightFrame.bind(this); + } + + componentDidMount() { + this.props.toolbox.on("panel-changed", this.hideMenu); + } + + componentWillUnmount() { + this.props.toolbox.off("panel-changed", this.hideMenu); + } + + hideMenu() { + if (this.refs.meatballMenuButton) { + this.refs.meatballMenuButton.hideMenu(); + } + + if (this.refs.frameMenuButton) { + this.refs.frameMenuButton.hideMenu(); + } + } + + /** + * A little helper function to call renderToolboxButtons for buttons at the start + * of the toolbox. + */ + renderToolboxButtonsStart() { + return this.renderToolboxButtons(true); + } + + /** + * A little helper function to call renderToolboxButtons for buttons at the end + * of the toolbox. + */ + renderToolboxButtonsEnd() { + return this.renderToolboxButtons(false); + } + + /** + * Render all of the tabs, this takes in a list of toolbox button states. These are plain + * objects that have all of the relevant information needed to render the button. + * See Toolbox.prototype._createButtonState in devtools/client/framework/toolbox.js for + * documentation on this object. + * + * @param {String} focusedButton - The id of the focused button. + * @param {Array} toolboxButtons - Array of objects that define the command buttons. + * @param {Function} focusButton - Keep a record of the currently focused button. + * @param {boolean} isStart - Render either the starting buttons, or ending buttons. + */ + renderToolboxButtons(isStart) { + const { focusedButton, toolboxButtons, focusButton } = this.props; + const visibleButtons = toolboxButtons.filter(command => { + const { isVisible, isInStartContainer } = command; + return isVisible && (isStart ? isInStartContainer : !isInStartContainer); + }); + + if (visibleButtons.length === 0) { + return null; + } + + // The RDM button, if present, should always go last + const rdmIndex = visibleButtons.findIndex( + button => button.id === "command-button-responsive" + ); + if (rdmIndex !== -1 && rdmIndex !== visibleButtons.length - 1) { + const rdm = visibleButtons.splice(rdmIndex, 1)[0]; + visibleButtons.push(rdm); + } + + const renderedButtons = visibleButtons.map(command => { + const { + id, + description, + disabled, + onClick, + isChecked, + isToggle, + className: buttonClass, + onKeyDown, + } = command; + + // If button is frame button, create menu button in order to + // use the doorhanger menu. + if (id === "command-button-frames") { + return this.renderFrameButton(command); + } + + if (id === "command-button-errorcount") { + return this.renderErrorIcon(command); + } + + return button({ + id, + title: description, + disabled, + "aria-pressed": !isToggle ? null : isChecked, + className: `devtools-tabbar-button command-button ${ + buttonClass || "" + } ${isChecked ? "checked" : ""}`, + onClick: event => { + onClick(event); + focusButton(id); + }, + onFocus: () => focusButton(id), + tabIndex: id === focusedButton ? "0" : "-1", + onKeyDown: event => { + onKeyDown(event); + }, + }); + }); + + // Add the appropriate separator, if needed. + const children = renderedButtons; + if (renderedButtons.length) { + if (isStart) { + children.push(this.renderSeparator()); + // For the end group we add a separator *before* the RDM button if it + // exists, but only if it is not the only button. + } else if (rdmIndex !== -1 && renderedButtons.length > 1) { + children.splice(children.length - 1, 0, this.renderSeparator()); + } + } + + return div( + { id: `toolbox-buttons-${isStart ? "start" : "end"}` }, + ...children + ); + } + + renderFrameButton(command) { + const { id, isChecked, disabled, description } = command; + + const { toolbox } = this.props; + + return MenuButton( + { + id, + disabled, + menuId: id + "-panel", + toolboxDoc: toolbox.doc, + className: `devtools-tabbar-button command-button ${ + isChecked ? "checked" : "" + }`, + ref: "frameMenuButton", + title: description, + onCloseButton: async () => { + // Only try to unhighlight if the inspectorFront has been created already + const inspectorFront = toolbox.target.getCachedFront("inspector"); + if (inspectorFront) { + const highlighter = toolbox.getHighlighter(); + await highlighter.unhighlight(); + } + }, + }, + this.createFrameList + ); + } + + renderErrorIcon(command) { + let { errorCount, id } = command; + + if (!errorCount) { + return null; + } + + if (errorCount > 99) { + errorCount = "99+"; + } + + return button( + { + id, + className: "devtools-tabbar-button command-button toolbox-error", + onClick: () => { + if (this.props.currentToolId !== "webconsole") { + this.props.toolbox.openSplitConsole(); + } + }, + title: + this.props.currentToolId !== "webconsole" + ? this.props.L10N.getStr("toolbox.errorCountButton.tooltip") + : null, + }, + errorCount + ); + } + + highlightFrame(id) { + const { toolbox } = this.props; + if (!id) { + return; + } + + toolbox.onHighlightFrame(id); + } + + createFrameList() { + const { toolbox } = this.props; + if (toolbox.frameMap.size < 1) { + return null; + } + + const items = []; + toolbox.frameMap.forEach((frame, index) => { + const label = toolbox.target.isWebExtension + ? toolbox.target.getExtensionPathName(frame.url) + : getUnicodeUrl(frame.url); + + const item = MenuItem({ + id: frame.id.toString(), + key: "toolbox-frame-key-" + frame.id, + label, + checked: frame.id === toolbox.selectedFrameId, + onClick: () => toolbox.onIframePickerFrameSelected(frame.id), + }); + + // Always put the top level frame at the top + if (frame.isTopLevel) { + items.unshift(item); + } else { + items.push(item); + } + }); + + return MenuList( + { + id: "toolbox-frame-menu", + onHighlightedChildChange: this.highlightFrame, + }, + items + ); + } + + /** + * Render a separator. + */ + renderSeparator() { + return div({ className: "devtools-separator" }); + } + + /** + * Render the toolbox control buttons. The following props are expected: + * + * @param {string} props.focusedButton + * The id of the focused button. + * @param {string} props.currentToolId + * The id of the currently selected tool, e.g. "inspector". + * @param {Object[]} props.hostTypes + * Array of host type objects. + * @param {string} props.hostTypes[].position + * Position name. + * @param {Function} props.hostTypes[].switchHost + * Function to switch the host. + * @param {string} props.currentHostType + * The current docking configuration. + * @param {boolean} props.areDockOptionsEnabled + * They are not enabled in certain situations like when the toolbox is + * in a tab. + * @param {boolean} props.canCloseToolbox + * Do we need to add UI for closing the toolbox? We don't when the + * toolbox is undocked, for example. + * @param {boolean} props.isSplitConsoleActive + * Is the split console currently visible? + * toolbox is undocked, for example. + * @param {boolean|undefined} props.disableAutohide + * Are we disabling the behavior where pop-ups are automatically + * closed when clicking outside them? + * (Only defined for the browser toolbox.) + * @param {Function} props.selectTool + * Function to select a tool based on its id. + * @param {Function} props.toggleOptions + * Function to turn the options panel on / off. + * @param {Function} props.toggleSplitConsole + * Function to turn the split console on / off. + * @param {Function} props.toggleNoAutohide + * Function to turn the disable pop-up autohide behavior on / off. + * @param {Function} props.toggleAlwaysOnTop + * Function to turn the always on top behavior on / off. + * @param {Function} props.closeToolbox + * Completely close the toolbox. + * @param {Function} props.focusButton + * Keep a record of the currently focused button. + * @param {Object} props.L10N + * Localization interface. + * @param {Object} props.toolbox + * The devtools toolbox. Used by the MenuButton component to display + * the menu popup. + * @param {Object} refs + * The components refs object. Used to keep a reference to the MenuButton + * for the meatball menu so that we can tell it to resize its contents + * when they change. + */ + renderToolboxControls() { + const { + focusedButton, + canCloseToolbox, + closeToolbox, + focusButton, + L10N, + toolbox, + } = this.props; + + const meatballMenuButtonId = "toolbox-meatball-menu-button"; + + const meatballMenuButton = MenuButton( + { + id: meatballMenuButtonId, + menuId: meatballMenuButtonId + "-panel", + toolboxDoc: toolbox.doc, + onFocus: () => focusButton(meatballMenuButtonId), + className: "devtools-tabbar-button", + title: L10N.getStr("toolbox.meatballMenu.button.tooltip"), + tabIndex: focusedButton === meatballMenuButtonId ? "0" : "-1", + ref: "meatballMenuButton", + }, + MeatballMenu({ + ...this.props, + hostTypes: this.props.areDockOptionsEnabled ? this.props.hostTypes : [], + onResize: () => { + this.refs.meatballMenuButton.resizeContent(); + }, + }) + ); + + const closeButtonId = "toolbox-close"; + + const closeButton = canCloseToolbox + ? button({ + id: closeButtonId, + onFocus: () => focusButton(closeButtonId), + className: "devtools-tabbar-button", + title: L10N.getStr("toolbox.closebutton.tooltip"), + onClick: () => closeToolbox(), + tabIndex: focusedButton === "toolbox-close" ? "0" : "-1", + }) + : null; + + return div({ id: "toolbox-controls" }, meatballMenuButton, closeButton); + } + + /** + * The render function is kept fairly short for maintainability. See the individual + * render functions for how each of the sections is rendered. + */ + render() { + const { L10N, debugTargetData, toolbox, fluentBundles } = this.props; + const classnames = ["devtools-tabbar"]; + const startButtons = this.renderToolboxButtonsStart(); + const endButtons = this.renderToolboxButtonsEnd(); + + if (!startButtons) { + classnames.push("devtools-tabbar-has-start"); + } + if (!endButtons) { + classnames.push("devtools-tabbar-has-end"); + } + + const toolbar = this.props.canRender + ? div( + { + className: classnames.join(" "), + }, + startButtons, + ToolboxTabs(this.props), + endButtons, + this.renderToolboxControls() + ) + : div({ className: classnames.join(" ") }); + + const debugTargetInfo = debugTargetData + ? DebugTargetInfo({ + alwaysOnTop: this.props.alwaysOnTop, + focusedState: this.props.focusedState, + toggleAlwaysOnTop: this.props.toggleAlwaysOnTop, + debugTargetData, + L10N, + toolbox, + }) + : null; + + // Display the toolbar in the MBT and about:debugging MBT if we have server support for it. + const chromeDebugToolbar = toolbox.commands.targetCommand.descriptorFront + .isBrowserProcessDescriptor + ? ChromeDebugToolbar() + : null; + + return LocalizationProvider( + { bundles: fluentBundles }, + div({}, chromeDebugToolbar, debugTargetInfo, toolbar) + ); + } +} + +module.exports = ToolboxToolbar; diff --git a/devtools/client/framework/components/moz.build b/devtools/client/framework/components/moz.build new file mode 100644 index 0000000000..cb29f41ddb --- /dev/null +++ b/devtools/client/framework/components/moz.build @@ -0,0 +1,17 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + + +DevToolsModules( + "ChromeDebugToolbar.js", + "DebugTargetErrorPage.js", + "DebugTargetInfo.js", + "MeatballMenu.js", + "ToolboxController.js", + "ToolboxTab.js", + "ToolboxTabs.js", + "ToolboxToolbar.js", +) diff --git a/devtools/client/framework/devtools-browser.js b/devtools/client/framework/devtools-browser.js new file mode 100644 index 0000000000..2cfbc09331 --- /dev/null +++ b/devtools/client/framework/devtools-browser.js @@ -0,0 +1,627 @@ +/* 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"; + +/** + * This is the main module loaded in Firefox desktop that handles browser + * windows and coordinates devtools around each window. + * + * This module is loaded lazily by devtools-clhandler.js, once the first + * browser window is ready (i.e. fired browser-delayed-startup-finished event) + **/ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + BrowserToolboxLauncher: + "resource://devtools/client/framework/browser-toolbox/Launcher.sys.mjs", +}); + +const { + gDevTools, +} = require("resource://devtools/client/framework/devtools.js"); +const { + getTheme, + addThemeObserver, + removeThemeObserver, +} = require("resource://devtools/client/shared/theme.js"); + +// Load toolbox lazily as it needs gDevTools to be fully initialized +loader.lazyRequireGetter( + this, + "Toolbox", + "resource://devtools/client/framework/toolbox.js", + true +); +loader.lazyRequireGetter( + this, + "DevToolsServer", + "resource://devtools/server/devtools-server.js", + true +); +loader.lazyRequireGetter( + this, + "BrowserMenus", + "resource://devtools/client/framework/browser-menus.js" +); +loader.lazyRequireGetter( + this, + "appendStyleSheet", + "resource://devtools/client/shared/stylesheet-utils.js", + true +); +loader.lazyRequireGetter( + this, + "ResponsiveUIManager", + "resource://devtools/client/responsive/manager.js" +); + +const BROWSER_STYLESHEET_URL = "chrome://devtools/skin/devtools-browser.css"; + +const DEVTOOLS_F12_ENABLED_PREF = "devtools.f12_enabled"; + +/** + * gDevToolsBrowser exposes functions to connect the gDevTools instance with a + * Firefox instance. + */ +var gDevToolsBrowser = (exports.gDevToolsBrowser = { + /** + * A record of the windows whose menus we altered, so we can undo the changes + * as the window is closed + */ + _trackedBrowserWindows: new Set(), + + /** + * WeakMap keeping track of the devtools-browser stylesheets loaded in the various + * tracked windows. + */ + _browserStyleSheets: new WeakMap(), + + /** + * This function is for the benefit of Tools:DevToolbox in + * browser/base/content/browser-sets.inc and should not be used outside + * of there + */ + // used by browser-sets.inc, command + toggleToolboxCommand(gBrowser, startTime) { + const toolbox = gDevTools.getToolboxForTab(gBrowser.selectedTab); + + // If a toolbox exists, using toggle from the Main window : + // - should close a docked toolbox + // - should focus a windowed toolbox + const isDocked = toolbox && toolbox.hostType != Toolbox.HostType.WINDOW; + if (isDocked) { + gDevTools.closeToolboxForTab(gBrowser.selectedTab); + } else { + gDevTools.showToolboxForTab(gBrowser.selectedTab, { startTime }); + } + }, + + /** + * This function ensures the right commands are enabled in a window, + * depending on their relevant prefs. It gets run when a window is registered, + * or when any of the devtools prefs change. + */ + updateCommandAvailability(win) { + const doc = win.document; + + function toggleMenuItem(id, isEnabled) { + const cmd = doc.getElementById(id); + cmd.hidden = !isEnabled; + if (isEnabled) { + cmd.removeAttribute("disabled"); + } else { + cmd.setAttribute("disabled", "true"); + } + } + + // Enable Browser Toolbox? + const chromeEnabled = Services.prefs.getBoolPref("devtools.chrome.enabled"); + const devtoolsRemoteEnabled = Services.prefs.getBoolPref( + "devtools.debugger.remote-enabled" + ); + const remoteEnabled = chromeEnabled && devtoolsRemoteEnabled; + toggleMenuItem("menu_browserToolbox", remoteEnabled); + + if (Services.prefs.getBoolPref("devtools.policy.disabled", false)) { + toggleMenuItem("menu_devToolbox", false); + toggleMenuItem("menu_devtools_remotedebugging", false); + toggleMenuItem("menu_browserToolbox", false); + toggleMenuItem("menu_browserConsole", false); + toggleMenuItem("menu_responsiveUI", false); + toggleMenuItem("menu_eyedropper", false); + toggleMenuItem("extensionsForDevelopers", false); + } + }, + + /** + * This function makes sure that the "devtoolstheme" attribute is set on the browser + * window to make it possible to change colors on elements in the browser (like the + * splitter between the toolbox and web content). + */ + updateDevtoolsThemeAttribute(win) { + // Set an attribute on root element of each window to make it possible + // to change colors based on the selected devtools theme. + let devtoolsTheme = getTheme(); + if (devtoolsTheme != "dark") { + devtoolsTheme = "light"; + } + + // Style the splitter between the toolbox and page content. This used to + // set the attribute on the browser's root node but that regressed tpaint: + // bug 1331449. + win.document + .getElementById("appcontent") + .setAttribute("devtoolstheme", devtoolsTheme); + }, + + observe(subject, topic, prefName) { + switch (topic) { + case "browser-delayed-startup-finished": + this._registerBrowserWindow(subject); + break; + case "nsPref:changed": + if (prefName.endsWith("enabled")) { + for (const win of this._trackedBrowserWindows) { + this.updateCommandAvailability(win); + } + } + break; + case "quit-application": + gDevToolsBrowser.destroy({ shuttingDown: true }); + break; + case "devtools:loader:destroy": + // This event is fired when the devtools loader unloads, which happens + // only when the add-on workflow ask devtools to be reloaded. + if (subject.wrappedJSObject == require("@loader/unload")) { + gDevToolsBrowser.destroy({ shuttingDown: false }); + } + break; + } + }, + + _observersRegistered: false, + + /** + * This function is for the benefit of Tools:{toolId} commands, + * triggered from the WebDeveloper menu and keyboard shortcuts. + * + * selectToolCommand's behavior: + * - if the current page is about:devtools-toolbox + * we select the targeted tool + * - if the toolbox is closed, + * we open the toolbox and select the tool + * - if the toolbox is open, and the targeted tool is not selected, + * we select it + * - if the toolbox is open, and the targeted tool is selected, + * and the host is NOT a window, we close the toolbox + * - if the toolbox is open, and the targeted tool is selected, + * and the host is a window, we raise the toolbox window + * + * Used when: - registering a new tool + * - new xul window, to add menu items + */ + async selectToolCommand(win, toolId, startTime) { + if (gDevToolsBrowser._isAboutDevtoolsToolbox(win)) { + const toolbox = gDevToolsBrowser._getAboutDevtoolsToolbox(win); + await toolbox.selectTool(toolId, "key_shortcut"); + return; + } + + const tab = win.gBrowser.selectedTab; + const toolbox = gDevTools.getToolboxForTab(tab); + const toolDefinition = gDevTools.getToolDefinition(toolId); + + if ( + toolbox && + (toolbox.currentToolId == toolId || + (toolId == "webconsole" && toolbox.splitConsole)) + ) { + toolbox.fireCustomKey(toolId); + + if ( + toolDefinition.preventClosingOnKey || + toolbox.hostType == Toolbox.HostType.WINDOW + ) { + if (!toolDefinition.preventRaisingOnKey) { + await toolbox.raise(); + } + } else { + await toolbox.destroy(); + } + gDevTools.emit("select-tool-command", toolId); + } else { + await gDevTools + .showToolboxForTab(tab, { + raise: !toolDefinition.preventRaisingOnKey, + startTime, + toolId, + }) + .then(newToolbox => { + newToolbox.fireCustomKey(toolId); + gDevTools.emit("select-tool-command", toolId); + }); + } + }, + + /** + * Called by devtools/client/devtools-startup.js when a key shortcut is pressed + * + * @param {Window} window + * The top level browser window from which the key shortcut is pressed. + * @param {Object} key + * Key object describing the key shortcut being pressed. It comes + * from devtools-startup.js's KeyShortcuts array. The useful fields here + * are: + * - `toolId` used to identify a toolbox's panel like inspector or webconsole, + * - `id` used to identify any other key shortcuts like about:debugging + * @param {Number} startTime + * Optional, indicates the time at which the key event fired. This is a + * `Cu.now()` timing. + */ + async onKeyShortcut(window, key, startTime) { + // Avoid to open devtools when the about:devtools-toolbox page is showing + // on the window now. + if ( + gDevToolsBrowser._isAboutDevtoolsToolbox(window) && + (key.id === "toggleToolbox" || key.id === "toggleToolboxF12") + ) { + return; + } + + // If this is a toolbox's panel key shortcut, delegate to selectToolCommand + if (key.toolId) { + await gDevToolsBrowser.selectToolCommand(window, key.toolId, startTime); + return; + } + // Otherwise implement all other key shortcuts individually here + switch (key.id) { + case "toggleToolbox": + gDevToolsBrowser.toggleToolboxCommand(window.gBrowser, startTime); + break; + case "toggleToolboxF12": + if (Services.prefs.getBoolPref(DEVTOOLS_F12_ENABLED_PREF, true)) { + gDevToolsBrowser.toggleToolboxCommand(window.gBrowser, startTime); + } + break; + case "browserToolbox": + lazy.BrowserToolboxLauncher.init(); + break; + case "browserConsole": + const { + BrowserConsoleManager, + } = require("resource://devtools/client/webconsole/browser-console-manager.js"); + BrowserConsoleManager.openBrowserConsoleOrFocus(); + break; + case "responsiveDesignMode": + ResponsiveUIManager.toggle(window, window.gBrowser.selectedTab, { + trigger: "shortcut", + }); + break; + case "javascriptTracingToggle": + const toolbox = gDevTools.getToolboxForTab(window.gBrowser.selectedTab); + if (!toolbox) { + break; + } + await toolbox.commands.tracerCommand.toggle(); + break; + } + }, + + /** + * Open a tab on "about:debugging", optionally pre-select a given tab. + */ + // Used by browser-sets.inc, command + openAboutDebugging(gBrowser, hash) { + const url = "about:debugging" + (hash ? "#" + hash : ""); + gBrowser.selectedTab = gBrowser.addTrustedTab(url); + }, + + /** + * Add the devtools-browser stylesheet to browser window's document. Returns a promise. + * + * @param {Window} win + * The window on which the stylesheet should be added. + * @return {Promise} promise that resolves when the stylesheet is loaded (or rejects + * if it fails to load). + */ + loadBrowserStyleSheet(win) { + if (this._browserStyleSheets.has(win)) { + return Promise.resolve(); + } + + const doc = win.document; + const { styleSheet, loadPromise } = appendStyleSheet( + doc, + BROWSER_STYLESHEET_URL + ); + this._browserStyleSheets.set(win, styleSheet); + return loadPromise; + }, + + /** + * Add this DevTools's presence to a browser window's document + * + * @param {HTMLDocument} doc + * The document to which devtools should be hooked to. + */ + _registerBrowserWindow(win) { + if (gDevToolsBrowser._trackedBrowserWindows.has(win)) { + return; + } + if (!win.document.getElementById("menuWebDeveloperPopup")) { + // Menus etc. set up here are browser specific. + return; + } + gDevToolsBrowser._trackedBrowserWindows.add(win); + BrowserMenus.addMenus(win.document); + + this.updateCommandAvailability(win); + this.updateDevtoolsThemeAttribute(win); + if (!this._observersRegistered) { + this._observersRegistered = true; + Services.prefs.addObserver("devtools.", this); + this._onThemeChanged = this._onThemeChanged.bind(this); + addThemeObserver(this._onThemeChanged); + } + + win.addEventListener("unload", this); + + const tabContainer = win.gBrowser.tabContainer; + tabContainer.addEventListener("TabSelect", this); + }, + + _onThemeChanged() { + for (const win of this._trackedBrowserWindows) { + this.updateDevtoolsThemeAttribute(win); + } + }, + + /** + * Add the menuitem for a tool to all open browser windows. + * + * @param {object} toolDefinition + * properties of the tool to add + */ + _addToolToWindows(toolDefinition) { + // No menu item or global shortcut is required for options panel. + if (!toolDefinition.inMenu) { + return; + } + + // Skip if the tool is disabled. + try { + if ( + toolDefinition.visibilityswitch && + !Services.prefs.getBoolPref(toolDefinition.visibilityswitch) + ) { + return; + } + } catch (e) { + // Prevent breaking everything if the pref doesn't exists. + } + + // We need to insert the new tool in the right place, which means knowing + // the tool that comes before the tool that we're trying to add + const allDefs = gDevTools.getToolDefinitionArray(); + let prevDef; + for (const def of allDefs) { + if (!def.inMenu) { + continue; + } + if (def === toolDefinition) { + break; + } + prevDef = def; + } + + for (const win of gDevToolsBrowser._trackedBrowserWindows) { + BrowserMenus.insertToolMenuElements( + win.document, + toolDefinition, + prevDef + ); + // If we are on a page where devtools menu items are hidden such as + // about:devtools-toolbox, we need to call _updateMenuItems to update the + // visibility of the newly created menu item. + gDevToolsBrowser._updateMenuItems(win); + } + }, + + hasToolboxOpened(win) { + const tab = win.gBrowser.selectedTab; + for (const commands of gDevTools._toolboxesPerCommands.keys()) { + if (commands.descriptorFront.localTab == tab) { + return true; + } + } + return false; + }, + + /** + * Update developer tools menu items and the "Toggle Tools" checkbox. This is + * called when a toolbox is created or destroyed. + */ + _updateMenu() { + for (const win of gDevToolsBrowser._trackedBrowserWindows) { + gDevToolsBrowser._updateMenuItems(win); + } + }, + + /** + * Update developer tools menu items and the "Toggle Tools" checkbox of XULWindow. + * + * @param {XULWindow} win + */ + _updateMenuItems(win) { + const menu = win.document.getElementById("menu_devToolbox"); + + // Hide the "Toggle Tools" menu item if we are on about:devtools-toolbox. + menu.hidden = + gDevToolsBrowser._isAboutDevtoolsToolbox(win) || + Services.prefs.getBoolPref("devtools.policy.disabled", false); + + // Add a checkmark for the "Toggle Tools" menu item if a toolbox is already opened. + const hasToolbox = gDevToolsBrowser.hasToolboxOpened(win); + if (hasToolbox) { + menu.setAttribute("checked", "true"); + } else { + menu.removeAttribute("checked"); + } + }, + + /** + * Check whether the window is showing about:devtools-toolbox page or not. + * + * @param {XULWindow} win + * @return {boolean} true: about:devtools-toolbox is showing + * false: otherwise + */ + _isAboutDevtoolsToolbox(win) { + const currentURI = win.gBrowser.currentURI; + return ( + currentURI.scheme === "about" && + currentURI.filePath === "devtools-toolbox" + ); + }, + + /** + * Retrieve the Toolbox instance loaded in the current page if the page is + * about:devtools-toolbox, null otherwise. + * + * @param {XULWindow} win + * The chrome window containing about:devtools-toolbox. Will match + * toolbox.topWindow. + * @return {Toolbox} The toolbox instance loaded in about:devtools-toolbox + * + */ + _getAboutDevtoolsToolbox(win) { + if (!gDevToolsBrowser._isAboutDevtoolsToolbox(win)) { + return null; + } + return gDevTools.getToolboxes().find(toolbox => toolbox.topWindow === win); + }, + + /** + * Remove the menuitem for a tool to all open browser windows. + * + * @param {string} toolId + * id of the tool to remove + */ + _removeToolFromWindows(toolId) { + for (const win of gDevToolsBrowser._trackedBrowserWindows) { + BrowserMenus.removeToolFromMenu(toolId, win.document); + } + }, + + /** + * Called on browser unload to remove menu entries, toolboxes and event + * listeners from the closed browser window. + * + * @param {XULWindow} win + * The window containing the menu entry + */ + _forgetBrowserWindow(win) { + if (!gDevToolsBrowser._trackedBrowserWindows.has(win)) { + return; + } + gDevToolsBrowser._trackedBrowserWindows.delete(win); + win.removeEventListener("unload", this); + + BrowserMenus.removeMenus(win.document); + + // Destroy toolboxes for closed window + for (const [commands, toolbox] of gDevTools._toolboxesPerCommands) { + if ( + commands.descriptorFront.localTab?.ownerDocument?.defaultView == win + ) { + toolbox.destroy(); + } + } + + const styleSheet = this._browserStyleSheets.get(win); + if (styleSheet) { + styleSheet.remove(); + this._browserStyleSheets.delete(win); + } + + const tabContainer = win.gBrowser.tabContainer; + tabContainer.removeEventListener("TabSelect", this); + }, + + handleEvent(event) { + switch (event.type) { + case "TabSelect": + gDevToolsBrowser._updateMenu(); + break; + case "unload": + // top-level browser window unload + gDevToolsBrowser._forgetBrowserWindow(event.target.defaultView); + break; + } + }, + + /** + * Either the DevTools Loader has been destroyed by the add-on contribution + * workflow, or firefox is shutting down. + + * @param {boolean} shuttingDown + * True if firefox is currently shutting down. We may prevent doing + * some cleanups to speed it up. Otherwise everything need to be + * cleaned up in order to be able to load devtools again. + */ + destroy({ shuttingDown }) { + Services.prefs.removeObserver("devtools.", gDevToolsBrowser); + removeThemeObserver(this._onThemeChanged); + Services.obs.removeObserver( + gDevToolsBrowser, + "browser-delayed-startup-finished" + ); + Services.obs.removeObserver(gDevToolsBrowser, "quit-application"); + Services.obs.removeObserver(gDevToolsBrowser, "devtools:loader:destroy"); + + for (const win of gDevToolsBrowser._trackedBrowserWindows) { + gDevToolsBrowser._forgetBrowserWindow(win); + } + + // Remove scripts loaded in content process to support the Browser Content Toolbox. + DevToolsServer.removeContentServerScript(); + + gDevTools.destroy({ shuttingDown }); + }, +}); + +// Handle all already registered tools, +gDevTools + .getToolDefinitionArray() + .forEach(def => gDevToolsBrowser._addToolToWindows(def)); +// and the new ones. +gDevTools.on("tool-registered", function (toolId) { + const toolDefinition = gDevTools._tools.get(toolId); + // If the tool has been registered globally, add to all the + // available windows. + if (toolDefinition) { + gDevToolsBrowser._addToolToWindows(toolDefinition); + } +}); + +gDevTools.on("tool-unregistered", function (toolId) { + gDevToolsBrowser._removeToolFromWindows(toolId); +}); + +gDevTools.on("toolbox-ready", gDevToolsBrowser._updateMenu); +gDevTools.on("toolbox-destroyed", gDevToolsBrowser._updateMenu); + +Services.obs.addObserver(gDevToolsBrowser, "quit-application"); +Services.obs.addObserver(gDevToolsBrowser, "browser-delayed-startup-finished"); +// Watch for module loader unload. Fires when the tools are reloaded. +Services.obs.addObserver(gDevToolsBrowser, "devtools:loader:destroy"); + +// Fake end of browser window load event for all already opened windows +// that is already fully loaded. +for (const win of Services.wm.getEnumerator(gDevTools.chromeWindowType)) { + if (win.gBrowserInit?.delayedStartupFinished) { + gDevToolsBrowser._registerBrowserWindow(win); + } +} diff --git a/devtools/client/framework/devtools.js b/devtools/client/framework/devtools.js new file mode 100644 index 0000000000..e56efb0c4b --- /dev/null +++ b/devtools/client/framework/devtools.js @@ -0,0 +1,998 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { DevToolsShim } = ChromeUtils.importESModule( + "chrome://devtools-startup/content/DevToolsShim.sys.mjs" +); + +const { DEFAULT_SANDBOX_NAME } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + BrowserToolboxLauncher: + "resource://devtools/client/framework/browser-toolbox/Launcher.sys.mjs", +}); + +loader.lazyRequireGetter( + this, + "LocalTabCommandsFactory", + "resource://devtools/client/framework/local-tab-commands-factory.js", + true +); +loader.lazyRequireGetter( + this, + "CommandsFactory", + "resource://devtools/shared/commands/commands-factory.js", + true +); +loader.lazyRequireGetter( + this, + "ToolboxHostManager", + "resource://devtools/client/framework/toolbox-host-manager.js", + true +); +loader.lazyRequireGetter( + this, + "BrowserConsoleManager", + "resource://devtools/client/webconsole/browser-console-manager.js", + true +); +loader.lazyRequireGetter( + this, + "Toolbox", + "resource://devtools/client/framework/toolbox.js", + true +); + +loader.lazyRequireGetter( + this, + "Telemetry", + "resource://devtools/client/shared/telemetry.js" +); + +const { + defaultTools: DefaultTools, + defaultThemes: DefaultThemes, +} = require("resource://devtools/client/definitions.js"); +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const { + getTheme, + setTheme, + getAutoTheme, + addThemeObserver, + removeThemeObserver, +} = require("resource://devtools/client/shared/theme.js"); + +const FORBIDDEN_IDS = new Set(["toolbox", ""]); +const MAX_ORDINAL = 99; +const POPUP_DEBUG_PREF = "devtools.popups.debug"; +const DEVTOOLS_ALWAYS_ON_TOP = "devtools.toolbox.alwaysOnTop"; + +/** + * DevTools is a class that represents a set of developer tools, it holds a + * set of tools and keeps track of open toolboxes in the browser. + */ +function DevTools() { + // We should be careful to always load a unique instance of this module: + // - only in the parent process + // - only in the "shared JSM global" spawn by mozJSModuleLoader + // The server codebase typically use another global named "DevTools global", + // which will load duplicated instances of all the modules -or- another + // DevTools module loader named "DevTools (Server Module loader)". + // Also the realm location is appended the loading callsite, so only check + // the beginning of the string. + if ( + Services.appinfo.processType != Services.appinfo.PROCESS_TYPE_DEFAULT || + !Cu.getRealmLocation(globalThis).startsWith(DEFAULT_SANDBOX_NAME) + ) { + throw new Error( + "This module should be loaded in the parent process only, in the shared global." + ); + } + + this._tools = new Map(); // Map + this._themes = new Map(); // Map + this._toolboxesPerCommands = new Map(); // Map + // List of toolboxes that are still in process of creation + this._creatingToolboxes = new Map(); // Map + + EventEmitter.decorate(this); + this._telemetry = new Telemetry(); + this._telemetry.setEventRecordingEnabled(true); + + // List of all commands of debugged local Web Extension. + this._commandsPromiseByWebExtId = new Map(); // Map + + // Listen for changes to the theme pref. + this._onThemeChanged = this._onThemeChanged.bind(this); + addThemeObserver(this._onThemeChanged); + + // This is important step in initialization codepath where we are going to + // start registering all default tools and themes: create menuitems, keys, emit + // related events. + this.registerDefaults(); + + // Register this DevTools instance on the DevToolsShim, which is used by non-devtools + // code to interact with DevTools. + DevToolsShim.register(this); +} + +DevTools.prototype = { + // The windowtype of the main window, used in various tools. This may be set + // to something different by other gecko apps. + chromeWindowType: "navigator:browser", + + registerDefaults() { + // Ensure registering items in the sorted order (getDefault* functions + // return sorted lists) + this.getDefaultTools().forEach(definition => this.registerTool(definition)); + this.getDefaultThemes().forEach(definition => + this.registerTheme(definition) + ); + }, + + unregisterDefaults() { + for (const definition of this.getToolDefinitionArray()) { + this.unregisterTool(definition.id); + } + for (const definition of this.getThemeDefinitionArray()) { + this.unregisterTheme(definition.id); + } + }, + + /** + * Register a new developer tool. + * + * A definition is a light object that holds different information about a + * developer tool. This object is not supposed to have any operational code. + * See it as a "manifest". + * The only actual code lives in the build() function, which will be used to + * start an instance of this tool. + * + * Each toolDefinition has the following properties: + * - id: Unique identifier for this tool (string|required) + * - visibilityswitch: Property name to allow us to hide this tool from the + * DevTools Toolbox. + * A falsy value indicates that it cannot be hidden. + * - icon: URL pointing to a graphic which will be used as the src for an + * 16x16 img tag (string|required) + * - url: URL pointing to a XUL/XHTML document containing the user interface + * (string|required) + * - label: Localized name for the tool to be displayed to the user + * (string|required) + * - hideInOptions: Boolean indicating whether or not this tool should be + shown in toolbox options or not. Defaults to false. + * (boolean) + * - build: Function that takes an iframe, which has been populated with the + * markup from |url|, and also the toolbox containing the panel. + * And returns an instance of ToolPanel (function|required) + */ + registerTool(toolDefinition) { + const toolId = toolDefinition.id; + + if (!toolId || FORBIDDEN_IDS.has(toolId)) { + throw new Error("Invalid definition.id"); + } + + // Make sure that additional tools will always be able to be hidden. + // When being called from main.js, defaultTools has not yet been exported. + // But, we can assume that in this case, it is a default tool. + if (!DefaultTools.includes(toolDefinition)) { + toolDefinition.visibilityswitch = "devtools." + toolId + ".enabled"; + } + + this._tools.set(toolId, toolDefinition); + + this.emit("tool-registered", toolId); + }, + + /** + * Removes all tools that match the given |toolId| + * Needed so that add-ons can remove themselves when they are deactivated + * + * @param {string|object} tool + * Definition or the id of the tool to unregister. Passing the + * tool id should be avoided as it is a temporary measure. + * @param {boolean} isQuitApplication + * true to indicate that the call is due to app quit, so we should not + * cause a cascade of costly events + */ + unregisterTool(tool, isQuitApplication) { + let toolId = null; + if (typeof tool == "string") { + toolId = tool; + tool = this._tools.get(tool); + } else { + const { Deprecated } = ChromeUtils.importESModule( + "resource://gre/modules/Deprecated.sys.mjs" + ); + Deprecated.warning( + "Deprecation WARNING: gDevTools.unregisterTool(tool) is " + + "deprecated. You should unregister a tool using its toolId: " + + "gDevTools.unregisterTool(toolId)." + ); + toolId = tool.id; + } + this._tools.delete(toolId); + + if (!isQuitApplication) { + this.emit("tool-unregistered", toolId); + } + }, + + /** + * Sorting function used for sorting tools based on their ordinals. + */ + ordinalSort(d1, d2) { + const o1 = typeof d1.ordinal == "number" ? d1.ordinal : MAX_ORDINAL; + const o2 = typeof d2.ordinal == "number" ? d2.ordinal : MAX_ORDINAL; + return o1 - o2; + }, + + getDefaultTools() { + return DefaultTools.sort(this.ordinalSort); + }, + + getAdditionalTools() { + const tools = []; + for (const [, value] of this._tools) { + if (!DefaultTools.includes(value)) { + tools.push(value); + } + } + return tools.sort(this.ordinalSort); + }, + + getDefaultThemes() { + return DefaultThemes.sort(this.ordinalSort); + }, + + /** + * Get a tool definition if it exists and is enabled. + * + * @param {string} toolId + * The id of the tool to show + * + * @return {ToolDefinition|null} tool + * The ToolDefinition for the id or null. + */ + getToolDefinition(toolId) { + const tool = this._tools.get(toolId); + if (!tool) { + return null; + } else if (!tool.visibilityswitch) { + return tool; + } + + const enabled = Services.prefs.getBoolPref(tool.visibilityswitch, true); + + return enabled ? tool : null; + }, + + /** + * Allow ToolBoxes to get at the list of tools that they should populate + * themselves with. + * + * @return {Map} tools + * A map of the the tool definitions registered in this instance + */ + getToolDefinitionMap() { + const tools = new Map(); + + for (const [id, definition] of this._tools) { + if (this.getToolDefinition(id)) { + tools.set(id, definition); + } + } + + return tools; + }, + + /** + * Tools have an inherent ordering that can't be represented in a Map so + * getToolDefinitionArray provides an alternative representation of the + * definitions sorted by ordinal value. + * + * @return {Array} tools + * A sorted array of the tool definitions registered in this instance + */ + getToolDefinitionArray() { + const definitions = []; + + for (const [id, definition] of this._tools) { + if (this.getToolDefinition(id)) { + definitions.push(definition); + } + } + + return definitions.sort(this.ordinalSort); + }, + + /** + * Returns the name of the current theme for devtools. + * + * @return {string} theme + * The name of the current devtools theme. + */ + getTheme() { + return getTheme(); + }, + + /** + * Returns the name of the default (auto) theme for devtools. + * + * @return {string} theme + */ + getAutoTheme() { + return getAutoTheme(); + }, + + /** + * Called when the developer tools theme changes. + */ + _onThemeChanged() { + this.emit("theme-changed", getTheme()); + }, + + /** + * Register a new theme for developer tools toolbox. + * + * A definition is a light object that holds various information about a + * theme. + * + * Each themeDefinition has the following properties: + * - id: Unique identifier for this theme (string|required) + * - label: Localized name for the theme to be displayed to the user + * (string|required) + * - stylesheets: Array of URLs pointing to a CSS document(s) containing + * the theme style rules (array|required) + * - classList: Array of class names identifying the theme within a document. + * These names are set to document element when applying + * the theme (array|required) + * - onApply: Function that is executed by the framework when the theme + * is applied. The function takes the current iframe window + * and the previous theme id as arguments (function) + * - onUnapply: Function that is executed by the framework when the theme + * is unapplied. The function takes the current iframe window + * and the new theme id as arguments (function) + */ + registerTheme(themeDefinition) { + const themeId = themeDefinition.id; + + if (!themeId) { + throw new Error("Invalid theme id"); + } + + if (this._themes.get(themeId)) { + throw new Error("Theme with the same id is already registered"); + } + + this._themes.set(themeId, themeDefinition); + + this.emit("theme-registered", themeId); + }, + + /** + * Removes an existing theme from the list of registered themes. + * Needed so that add-ons can remove themselves when they are deactivated + * + * @param {string|object} theme + * Definition or the id of the theme to unregister. + */ + unregisterTheme(theme) { + let themeId = null; + if (typeof theme == "string") { + themeId = theme; + theme = this._themes.get(theme); + } else { + themeId = theme.id; + } + + const currTheme = getTheme(); + + // Note that we can't check if `theme` is an item + // of `DefaultThemes` as we end up reloading definitions + // module and end up with different theme objects + const isCoreTheme = DefaultThemes.some(t => t.id === themeId); + + // Reset the theme if an extension theme that's currently applied + // is being removed. + // Ignore shutdown since addons get disabled during that time. + if ( + !Services.startup.shuttingDown && + !isCoreTheme && + theme.id == currTheme + ) { + setTheme("auto"); + + this.emit("theme-unregistered", theme); + } + + this._themes.delete(themeId); + }, + + /** + * Get a theme definition if it exists. + * + * @param {string} themeId + * The id of the theme + * + * @return {ThemeDefinition|null} theme + * The ThemeDefinition for the id or null. + */ + getThemeDefinition(themeId) { + const theme = this._themes.get(themeId); + if (!theme) { + return null; + } + return theme; + }, + + /** + * Get map of registered themes. + * + * @return {Map} themes + * A map of the the theme definitions registered in this instance + */ + getThemeDefinitionMap() { + const themes = new Map(); + + for (const [id, definition] of this._themes) { + if (this.getThemeDefinition(id)) { + themes.set(id, definition); + } + } + + return themes; + }, + + /** + * Get registered themes definitions sorted by ordinal value. + * + * @return {Array} themes + * A sorted array of the theme definitions registered in this instance + */ + getThemeDefinitionArray() { + const definitions = []; + + for (const [id, definition] of this._themes) { + if (this.getThemeDefinition(id)) { + definitions.push(definition); + } + } + + return definitions.sort(this.ordinalSort); + }, + + /** + * Called from SessionStore.sys.mjs in mozilla-central when saving the current state. + * + * @param {Object} state + * A SessionStore state object that gets modified by reference + */ + saveDevToolsSession(state) { + state.browserConsole = + BrowserConsoleManager.getBrowserConsoleSessionState(); + state.browserToolbox = + lazy.BrowserToolboxLauncher.getBrowserToolboxSessionState(); + }, + + /** + * Restore the devtools session state as provided by SessionStore. + */ + async restoreDevToolsSession({ browserConsole, browserToolbox }) { + if (browserToolbox) { + lazy.BrowserToolboxLauncher.init(); + } + + if (browserConsole && !BrowserConsoleManager.getBrowserConsole()) { + await BrowserConsoleManager.toggleBrowserConsole(); + } + }, + + /** + * Boolean, true, if we never opened a toolbox. + * Used to implement the telemetry tracking toolbox opening. + */ + _firstShowToolbox: true, + + /** + * Show a Toolbox for a given "commands" (either by creating a new one, or if a + * toolbox already exists for the commands, by bringing to the front the + * existing one). + * + * If a Toolbox already exists, we will still update it based on some of the + * provided parameters: + * - if |toolId| is provided then the toolbox will switch to the specified + * tool. + * - if |hostType| is provided then the toolbox will be switched to the + * specified HostType. + * + * @param {Commands Object} commands + * The commands object which designates which context the toolbox will debug + * @param {Object} + * - {String} toolId + * The id of the tool to show + * - {Toolbox.HostType} hostType + * The type of host (bottom, window, left, right) + * - {object} hostOptions + * Options for host specifically + * - {Number} startTime + * Indicates the time at which the user event related to + * this toolbox opening started. This is a `Cu.now()` timing. + * - {string} reason + * Reason the tool was opened + * - {boolean} raise + * Whether we need to raise the toolbox or not. + * + * @return {Toolbox} toolbox + * The toolbox that was opened + */ + async showToolbox( + commands, + { + toolId, + hostType, + startTime, + raise = true, + reason = "toolbox_show", + hostOptions, + } = {} + ) { + let toolbox = this._toolboxesPerCommands.get(commands); + + if (toolbox) { + if (hostType != null && toolbox.hostType != hostType) { + await toolbox.switchHost(hostType); + } + + if (toolId != null) { + // selectTool will either select the tool if not currently selected, or wait for + // the tool to be loaded if needed. + await toolbox.selectTool(toolId, reason); + } + + if (raise) { + await toolbox.raise(); + } + } else { + // Toolbox creation is async, we have to be careful about races. + // Check if we are already waiting for a Toolbox for the provided + // commands before creating a new one. + const promise = this._creatingToolboxes.get(commands); + if (promise) { + return promise; + } + const toolboxPromise = this._createToolbox( + commands, + toolId, + hostType, + hostOptions + ); + this._creatingToolboxes.set(commands, toolboxPromise); + toolbox = await toolboxPromise; + this._creatingToolboxes.delete(commands); + + if (startTime) { + this.logToolboxOpenTime(toolbox, startTime); + } + this._firstShowToolbox = false; + } + + // We send the "enter" width here to ensure it is always sent *after* + // the "open" event. + const width = Math.ceil(toolbox.win.outerWidth / 50) * 50; + const panelName = this.makeToolIdHumanReadable( + toolId || toolbox.defaultToolId + ); + this._telemetry.addEventProperty( + toolbox, + "enter", + panelName, + null, + "width", + width + ); + + return toolbox; + }, + + /** + * Show the toolbox for a given tab. If a toolbox already exists for this tab + * the existing toolbox will be raised. Otherwise a new toolbox is created. + * + * Relies on `showToolbox`, see its jsDoc for additional information and + * arguments description. + * + * Also used by 3rd party tools (eg wptrunner) and exposed by + * DevToolsShim.sys.mjs. + * + * @param {XULTab} tab + * The tab the toolbox will debug + * @param {Object} options + * Various options that will be forwarded to `showToolbox`. See the + * JSDoc on this method. + */ + async showToolboxForTab( + tab, + { toolId, hostType, startTime, raise, reason, hostOptions } = {} + ) { + // Popups are debugged via the toolbox of their opener document/tab. + // So avoid opening dedicated toolbox for them. + if ( + tab.linkedBrowser.browsingContext.opener && + Services.prefs.getBoolPref(POPUP_DEBUG_PREF) + ) { + const openerTab = tab.ownerGlobal.gBrowser.getTabForBrowser( + tab.linkedBrowser.browsingContext.opener.embedderElement + ); + const openerCommands = await LocalTabCommandsFactory.getCommandsForTab( + openerTab + ); + if (this.getToolboxForCommands(openerCommands)) { + console.log( + "Can't open a toolbox for this document as this is debugged from its opener tab" + ); + return null; + } + } + const commands = await LocalTabCommandsFactory.createCommandsForTab(tab); + return this.showToolbox(commands, { + toolId, + hostType, + startTime, + raise, + reason, + hostOptions, + }); + }, + + /** + * Open a Toolbox in a dedicated top-level window for debugging a local WebExtension. + * This will re-open a previously opened toolbox if we try to re-debug the same extension. + * + * Note that this will spawn a new DevToolsClient. + * + * @param {String} extensionId + * ID of the extension to debug. + * @param {Object} (optional) + * - {String} toolId + * The id of the tool to show + */ + async showToolboxForWebExtension(extensionId, { toolId } = {}) { + // Ensure spawning only one commands instance per extension at a time by caching its commands. + // showToolbox will later reopen the previously opened toolbox if called with the same + // commands. + let commandsPromise = this._commandsPromiseByWebExtId.get(extensionId); + if (!commandsPromise) { + commandsPromise = CommandsFactory.forAddon(extensionId); + this._commandsPromiseByWebExtId.set(extensionId, commandsPromise); + } + const commands = await commandsPromise; + commands.client.once("closed").then(() => { + this._commandsPromiseByWebExtId.delete(extensionId); + }); + + return this.showToolbox(commands, { + hostType: Toolbox.HostType.WINDOW, + hostOptions: { + // The toolbox is always displayed on top so that we can keep + // the DevTools visible while interacting with the Firefox window. + alwaysOnTop: Services.prefs.getBoolPref(DEVTOOLS_ALWAYS_ON_TOP, false), + }, + toolId, + }); + }, + + /** + * Log telemetry related to toolbox opening. + * Two distinct probes are logged. One for cold startup, when we open the very first + * toolbox. This one includes devtools framework loading. And a second one for all + * subsequent toolbox opening, which should all be faster. + * These two probes are indexed by Tool ID. + * + * @param {String} toolbox + * Toolbox instance. + * @param {Number} startTime + * Indicates the time at which the user event related to the toolbox + * opening started. This is a `Cu.now()` timing. + */ + logToolboxOpenTime(toolbox, startTime) { + const toolId = toolbox.currentToolId || toolbox.defaultToolId; + const delay = Cu.now() - startTime; + const panelName = this.makeToolIdHumanReadable(toolId); + + const telemetryKey = this._firstShowToolbox + ? "DEVTOOLS_COLD_TOOLBOX_OPEN_DELAY_MS" + : "DEVTOOLS_WARM_TOOLBOX_OPEN_DELAY_MS"; + this._telemetry.getKeyedHistogramById(telemetryKey).add(toolId, delay); + + const browserWin = toolbox.topWindow; + this._telemetry.addEventProperty( + browserWin, + "open", + "tools", + null, + "first_panel", + panelName + ); + }, + + makeToolIdHumanReadable(toolId) { + if (/^[0-9a-fA-F]{40}_temporary-addon/.test(toolId)) { + return "temporary-addon"; + } + + let matches = toolId.match( + /^_([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})_/ + ); + if (matches && matches.length === 2) { + return matches[1]; + } + + matches = toolId.match(/^_?(.*)-\d+-\d+-devtools-panel$/); + if (matches && matches.length === 2) { + return matches[1]; + } + + return toolId; + }, + + /** + * Unconditionally create a new Toolbox instance for the provided commands. + * See `showToolbox` for the arguments' jsdoc. + */ + async _createToolbox(commands, toolId, hostType, hostOptions) { + const manager = new ToolboxHostManager(commands, hostType, hostOptions); + + const toolbox = await manager.create(toolId); + + this._toolboxesPerCommands.set(commands, toolbox); + + toolbox.once("destroy", () => { + this.emit("toolbox-destroy", toolbox); + }); + + toolbox.once("destroyed", () => { + this._toolboxesPerCommands.delete(commands); + this.emit("toolbox-destroyed", toolbox); + }); + + await toolbox.open(); + this.emit("toolbox-ready", toolbox); + + return toolbox; + }, + + /** + * Return the toolbox for a given commands object. + * + * @param {Commands Object} commands + * Debugging context commands that owns this toolbox + * + * @return {Toolbox} toolbox + * The toolbox that is debugging the given context designated by the commands + */ + getToolboxForCommands(commands) { + return this._toolboxesPerCommands.get(commands); + }, + + /** + * TabDescriptorFront requires a synchronous method and don't have a reference to its + * related commands object. So expose something handcrafted just for this. + */ + getToolboxForDescriptorFront(descriptorFront) { + for (const [commands, toolbox] of this._toolboxesPerCommands) { + if (commands.descriptorFront == descriptorFront) { + return toolbox; + } + } + return null; + }, + + /** + * Retrieve an existing toolbox for the provided tab if it was created before. + * Returns null otherwise. + * + * @param {XULTab} tab + * The browser tab. + * @return {Toolbox} + * Returns tab's toolbox object. + */ + getToolboxForTab(tab) { + return this.getToolboxes().find( + t => t.commands.descriptorFront.localTab === tab + ); + }, + + /** + * Close the toolbox for a given tab. + * + * @return {Promise} Returns a promise that resolves either: + * - immediately if no Toolbox was found + * - or after toolbox.destroy() resolved if a Toolbox was found + */ + async closeToolboxForTab(tab) { + const commands = await LocalTabCommandsFactory.getCommandsForTab(tab); + + let toolbox = await this._creatingToolboxes.get(commands); + if (!toolbox) { + toolbox = this._toolboxesPerCommands.get(commands); + } + if (!toolbox) { + return; + } + await toolbox.destroy(); + }, + + /** + * Compatibility layer for web-extensions. Used by DevToolsShim for + * browser/components/extensions/ext-devtools.js + * + * web-extensions need to use dedicated instances of Commands and cannot reuse the + * cached instances managed by DevTools. + * Note that is will end up being cached in WebExtension codebase, via + * DevToolsExtensionPageContextParent.getDevToolsCommands. + */ + createCommandsForTabForWebExtension(tab) { + return CommandsFactory.forTab(tab, { isWebExtension: true }); + }, + + /** + * Compatibility layer for web-extensions. Used by DevToolsShim for + * toolkit/components/extensions/ext-c-toolkit.js + */ + openBrowserConsole() { + const { + BrowserConsoleManager, + } = require("resource://devtools/client/webconsole/browser-console-manager.js"); + BrowserConsoleManager.openBrowserConsoleOrFocus(); + }, + + /** + * Called from the DevToolsShim, used by nsContextMenu.js. + * + * @param {XULTab} tab + * The browser tab on which inspect node was used. + * @param {ElementIdentifier} domReference + * Identifier generated by ContentDOMReference. It is a unique pair of + * BrowsingContext ID and a numeric ID. + * @param {Number} startTime + * Optional, indicates the time at which the user event related to this node + * inspection started. This is a `Cu.now()` timing. + * @return {Promise} a promise that resolves when the node is selected in the inspector + * markup view. + */ + async inspectNode(tab, domReference, startTime) { + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "inspector", + startTime, + reason: "inspect_dom", + }); + const inspector = toolbox.getCurrentPanel(); + + const nodeFront = + await inspector.inspectorFront.getNodeActorFromContentDomReference( + domReference + ); + if (!nodeFront) { + return; + } + + // "new-node-front" tells us when the node has been selected, whether the + // browser is remote or not. + const onNewNode = inspector.selection.once("new-node-front"); + // Select the final node + inspector.selection.setNodeFront(nodeFront, { + reason: "browser-context-menu", + }); + + await onNewNode; + // Now that the node has been selected, wait until the inspector is + // fully updated. + await inspector.once("inspector-updated"); + }, + + /** + * Called from the DevToolsShim, used by nsContextMenu.js. + * + * @param {XULTab} tab + * The browser tab on which inspect accessibility was used. + * @param {ElementIdentifier} domReference + * Identifier generated by ContentDOMReference. It is a unique pair of + * BrowsingContext ID and a numeric ID. + * @param {Number} startTime + * Optional, indicates the time at which the user event related to this + * node inspection started. This is a `Cu.now()` timing. + * @return {Promise} a promise that resolves when the accessible object is + * selected in the accessibility inspector. + */ + async inspectA11Y(tab, domReference, startTime) { + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "accessibility", + startTime, + }); + const inspectorFront = await toolbox.target.getFront("inspector"); + const nodeFront = await inspectorFront.getNodeActorFromContentDomReference( + domReference + ); + if (!nodeFront) { + return; + } + + // Select the accessible object in the panel and wait for the event that + // tells us it has been done. + const a11yPanel = toolbox.getCurrentPanel(); + const onSelected = a11yPanel.once("new-accessible-front-selected"); + a11yPanel.selectAccessibleForNode(nodeFront, "browser-context-menu"); + await onSelected; + }, + + /** + * Either the DevTools Loader has been destroyed or firefox is shutting down. + * @param {boolean} shuttingDown + * True if firefox is currently shutting down. We may prevent doing + * some cleanups to speed it up. Otherwise everything need to be + * cleaned up in order to be able to load devtools again. + */ + destroy({ shuttingDown }) { + // Do not cleanup everything during firefox shutdown. + if (!shuttingDown) { + for (const [, toolbox] of this._toolboxesPerCommands) { + toolbox.destroy(); + } + } + + for (const [key] of this.getToolDefinitionMap()) { + this.unregisterTool(key, true); + } + + gDevTools.unregisterDefaults(); + + removeThemeObserver(this._onThemeChanged); + + // Do not unregister devtools from the DevToolsShim if the destroy is caused by an + // application shutdown. For instance SessionStore needs to save the Browser Toolbox + // state on shutdown. + if (!shuttingDown) { + // Notify the DevToolsShim that DevTools are no longer available, particularly if + // the destroy was caused by disabling/removing DevTools. + DevToolsShim.unregister(); + } + + // Cleaning down the toolboxes: i.e. + // for (let [, toolbox] of this._toolboxesPerCommands) toolbox.destroy(); + // Is taken care of by the gDevToolsBrowser.forgetBrowserWindow + }, + + /** + * Returns the array of the existing toolboxes. + * + * @return {Array} + * An array of toolboxes. + */ + getToolboxes() { + return Array.from(this._toolboxesPerCommands.values()); + }, + + /** + * Returns whether the given tab has toolbox. + * + * @param {XULTab} tab + * The browser tab. + * @return {boolean} + * Returns true if the tab has toolbox. + */ + hasToolboxForTab(tab) { + return this.getToolboxes().some( + t => t.commands.descriptorFront.localTab === tab + ); + }, +}; + +const gDevTools = (exports.gDevTools = new DevTools()); diff --git a/devtools/client/framework/local-tab-commands-factory.js b/devtools/client/framework/local-tab-commands-factory.js new file mode 100644 index 0000000000..e7e32aa343 --- /dev/null +++ b/devtools/client/framework/local-tab-commands-factory.js @@ -0,0 +1,72 @@ +/* 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"; + +loader.lazyRequireGetter( + this, + "CommandsFactory", + "resource://devtools/shared/commands/commands-factory.js", + true +); + +// Map of existing Commands objects, keyed by XULTab. +const commandsMap = new WeakMap(); + +/** + * Functions for creating unique Commands for Local Tabs. + */ +exports.LocalTabCommandsFactory = { + /** + * Create a unique commands object for the given tab. + * + * If a commands was already created by this factory for the provided tab, + * it will be returned and no new commands created. + * + * Otherwise, this will automatically: + * - spawn a DevToolsServer in the parent process, + * - create a DevToolsClient + * - connect the DevToolsClient to the DevToolsServer + * - call RootActor's `getTab` request to retrieve the WindowGlobalTargetActor's form + * + * @param {XULTab} tab + * The tab to use in creating a new commands. + * + * @return {Commands object} The commands object for the provided tab. + */ + async createCommandsForTab(tab) { + let commands = commandsMap.get(tab); + if (commands) { + // Keep in mind that commands can be either a promise + // or a commands object. + return commands; + } + + const promise = CommandsFactory.forTab(tab); + // Immediately set the commands's promise in cache to prevent race + commandsMap.set(tab, promise); + commands = await promise; + // Then replace the promise with the commands object + commandsMap.set(tab, commands); + + commands.descriptorFront.once("descriptor-destroyed", () => { + commandsMap.delete(tab); + }); + return commands; + }, + + /** + * Retrieve an existing commands created by this factory for the provided + * tab. Returns null if no commands was created yet. + * + * @param {XULTab} tab + * The tab for which the commands should be retrieved + */ + async getCommandsForTab(tab) { + // commandsMap.get(tab) can either return an initialized commands, a promise + // which will resolve a commands, or null if no commands was ever created + // for this tab. + return commandsMap.get(tab); + }, +}; diff --git a/devtools/client/framework/menu-item.js b/devtools/client/framework/menu-item.js new file mode 100644 index 0000000000..dcfb12f93b --- /dev/null +++ b/devtools/client/framework/menu-item.js @@ -0,0 +1,79 @@ +/* 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"; + +/** + * A partial implementation of the MenuItem API provided by electron: + * https://github.com/electron/electron/blob/master/docs/api/menu-item.md. + * + * Missing features: + * - id String - Unique within a single menu. If defined then it can be used + * as a reference to this item by the position attribute. + * - role String - Define the action of the menu item; when specified the + * click property will be ignored + * - sublabel String + * - accelerator Accelerator + * - position String - This field allows fine-grained definition of the + * specific location within a given menu. + * + * Implemented features: + * @param Object options + * String accelerator + * Text that appears beside the menu label to indicate the shortcut key + * (accelerator key) to use to invoke the command. + * Unlike the Electron API, this is a label only and does not actually + * register a handler for the key. + * String accesskey [non-standard] + * A single character used as the shortcut key. This should be one of the + * characters that appears in the label. + * Function click + * Will be called with click(menuItem, browserWindow) when the menu item + * is clicked + * String type + * Can be normal, separator, submenu, checkbox or radio + * String label + * String image + * Boolean enabled + * If false, the menu item will be greyed out and unclickable. + * Boolean checked + * Should only be specified for checkbox or radio type menu items. + * Menu submenu + * Should be specified for submenu type menu items. If submenu is specified, + * the type: 'submenu' can be omitted. If the value is not a Menu then it + * will be automatically converted to one using Menu.buildFromTemplate. + * Boolean visible + * If false, the menu item will be entirely hidden. + */ +function MenuItem({ + accelerator = null, + accesskey = null, + l10nID = null, + checked = false, + click = () => {}, + disabled = false, + hover = () => {}, + id = null, + label = "", + image = null, + submenu = null, + type = "normal", + visible = true, +} = {}) { + this.accelerator = accelerator; + this.accesskey = accesskey; + this.l10nID = l10nID; + this.checked = checked; + this.click = click; + this.disabled = disabled; + this.hover = hover; + this.id = id; + this.label = label; + this.image = image; + this.submenu = submenu; + this.type = type; + this.visible = visible; +} + +module.exports = MenuItem; diff --git a/devtools/client/framework/menu.js b/devtools/client/framework/menu.js new file mode 100644 index 0000000000..a4cd8af5f7 --- /dev/null +++ b/devtools/client/framework/menu.js @@ -0,0 +1,248 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +/** + * A partial implementation of the Menu API provided by electron: + * https://github.com/electron/electron/blob/master/docs/api/menu.md. + * + * Extra features: + * - Emits an 'open' and 'close' event when the menu is opened/closed + + * @param String id (non standard) + * Needed so tests can confirm the XUL implementation is working + */ +function Menu({ id = null } = {}) { + this.menuitems = []; + this.id = id; + + Object.defineProperty(this, "items", { + get() { + return this.menuitems; + }, + }); + + EventEmitter.decorate(this); +} + +/** + * Add an item to the end of the Menu + * + * @param {MenuItem} menuItem + */ +Menu.prototype.append = function (menuItem) { + this.menuitems.push(menuItem); +}; + +/** + * Remove all items from the Menu + */ +Menu.prototype.clear = function () { + this.menuitems = []; +}; + +/** + * Add an item to a specified position in the menu + * + * @param {int} pos + * @param {MenuItem} menuItem + */ +Menu.prototype.insert = function (pos, menuItem) { + throw Error("Not implemented"); +}; + +/** + * Show the Menu next to the provided target. Anchor point is bottom-left. + * + * @param {Element} target + * The element to use as anchor. + */ +Menu.prototype.popupAtTarget = function (target) { + const rect = target.getBoundingClientRect(); + const doc = target.ownerDocument; + const defaultView = doc.defaultView; + const x = rect.left + defaultView.mozInnerScreenX; + const y = rect.bottom + defaultView.mozInnerScreenY; + + this.popup(x, y, doc); +}; + +/** + * Hide an existing menu, if there's any. + * + * @param {Document} doc + * The document that should own the context menu. + */ +Menu.prototype.hide = function (doc) { + const win = doc.defaultView; + doc = DevToolsUtils.getTopWindow(win).document; + const popup = doc.querySelector('popupset menupopup[menu-api="true"]'); + if (!popup) { + return; + } + popup.hidePopup(); +}; + +/** + * Show the Menu at a specified location on the screen + * + * Missing features: + * - browserWindow - BrowserWindow (optional) - Default is null. + * - positioningItem Number - (optional) OS X + * + * @param {int} screenX + * @param {int} screenY + * @param {Document} doc + * The document that should own the context menu. + */ +Menu.prototype.popup = function (screenX, screenY, doc) { + // See bug 1285229, on Windows, opening the same popup multiple times in a + // row ends up duplicating the popup. The newly inserted popup doesn't + // dismiss the old one. So remove any previously displayed popup before + // opening a new one. + this.hide(doc); + + // The context-menu will be created in the topmost window to preserve keyboard + // navigation (see Bug 1543940). + // Keep a reference on the window owning the menu to hide the popup on unload. + const win = doc.defaultView; + const topWin = DevToolsUtils.getTopWindow(win); + + // Convert coordinates from win's CSS coordinate space to topWin's + const winToTopWinCssScale = win.devicePixelRatio / topWin.devicePixelRatio; + screenX = screenX * winToTopWinCssScale; + screenY = screenY * winToTopWinCssScale; + + doc = topWin.document; + + let popupset = doc.querySelector("popupset"); + if (!popupset) { + popupset = doc.createXULElement("popupset"); + doc.documentElement.appendChild(popupset); + } + + const popup = doc.createXULElement("menupopup"); + popup.setAttribute("menu-api", "true"); + popup.setAttribute("consumeoutsideclicks", "false"); + popup.setAttribute("incontentshell", "false"); + + if (this.id) { + popup.id = this.id; + } + this._createMenuItems(popup); + + // The context menu will be created in the topmost chrome window. Hide it manually when + // the owner document is unloaded. + const onWindowUnload = () => popup.hidePopup(); + win.addEventListener("unload", onWindowUnload); + + // Remove the menu from the DOM once it's hidden. + popup.addEventListener("popuphidden", e => { + if (e.target === popup) { + win.removeEventListener("unload", onWindowUnload); + popup.remove(); + this.emit("close"); + } + }); + + popup.addEventListener("popupshown", e => { + if (e.target === popup) { + this.emit("open"); + } + }); + + popupset.appendChild(popup); + popup.openPopupAtScreen(screenX, screenY, true); +}; + +Menu.prototype._createMenuItems = function (parent) { + const doc = parent.ownerDocument; + this.menuitems.forEach(item => { + if (!item.visible) { + return; + } + + if (item.submenu) { + const menupopup = doc.createXULElement("menupopup"); + menupopup.setAttribute("incontentshell", "false"); + + item.submenu._createMenuItems(menupopup); + + const menu = doc.createXULElement("menu"); + menu.appendChild(menupopup); + applyItemAttributesToNode(item, menu); + parent.appendChild(menu); + } else if (item.type === "separator") { + const menusep = doc.createXULElement("menuseparator"); + parent.appendChild(menusep); + } else { + const menuitem = doc.createXULElement("menuitem"); + applyItemAttributesToNode(item, menuitem); + + menuitem.addEventListener("command", () => { + item.click(); + }); + menuitem.addEventListener("DOMMenuItemActive", () => { + item.hover(); + }); + + parent.appendChild(menuitem); + } + }); +}; + +Menu.getMenuElementById = function (id, doc) { + const menuDoc = DevToolsUtils.getTopWindow(doc.defaultView).document; + return menuDoc.getElementById(id); +}; + +Menu.setApplicationMenu = () => { + throw Error("Not implemented"); +}; + +Menu.sendActionToFirstResponder = () => { + throw Error("Not implemented"); +}; + +Menu.buildFromTemplate = () => { + throw Error("Not implemented"); +}; + +function applyItemAttributesToNode(item, node) { + if (item.l10nID) { + node.ownerDocument.l10n.setAttributes(node, item.l10nID); + } else { + node.setAttribute("label", item.label); + if (item.accelerator) { + node.setAttribute("acceltext", item.accelerator); + } + if (item.accesskey) { + node.setAttribute("accesskey", item.accesskey); + } + } + if (item.type === "checkbox") { + node.setAttribute("type", "checkbox"); + } + if (item.type === "radio") { + node.setAttribute("type", "radio"); + } + if (item.disabled) { + node.setAttribute("disabled", "true"); + } + if (item.checked) { + node.setAttribute("checked", "true"); + } + if (item.image) { + node.setAttribute("image", item.image); + } + if (item.id) { + node.id = item.id; + } +} + +module.exports = Menu; diff --git a/devtools/client/framework/moz.build b/devtools/client/framework/moz.build new file mode 100644 index 0000000000..d1fe829b28 --- /dev/null +++ b/devtools/client/framework/moz.build @@ -0,0 +1,50 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +BROWSER_CHROME_MANIFESTS += [ + "test/browser-telemetry-startup.toml", + "test/browser.toml", + "test/metrics/browser_metrics.toml", + "test/metrics/browser_metrics_debugger.toml", + "test/metrics/browser_metrics_inspector.toml", + "test/metrics/browser_metrics_netmonitor.toml", + "test/metrics/browser_metrics_webconsole.toml", +] +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"] + +DIRS += [ + "actions", + "browser-toolbox", + "components", + "reducers", + "test/allocations", +] + +DevToolsModules( + "browser-menus.js", + "commands-from-url.js", + "devtools-browser.js", + "devtools.js", + "local-tab-commands-factory.js", + "menu-item.js", + "menu.js", + "selection.js", + "source-map-url-service.js", + "store-provider.js", + "store.js", + "toolbox-context-menu.js", + "toolbox-host-manager.js", + "toolbox-hosts.js", + "toolbox-options.js", + "toolbox-tabs-order-manager.js", + "toolbox-window.js", + "toolbox.js", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Framework") + +SPHINX_TREES["/devtools/tests/memory"] = "test/allocations/docs" diff --git a/devtools/client/framework/options-panel.css b/devtools/client/framework/options-panel.css new file mode 100644 index 0000000000..80f7dad383 --- /dev/null +++ b/devtools/client/framework/options-panel.css @@ -0,0 +1,203 @@ +/* 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/. */ +:root{ + user-select: none; +} + +.theme-light { + --experimental-background: #E0EEFF; + --experimental-color: #436286; +} + +.theme-dark { + --experimental-background: #436286; + --experimental-color: #E0EEFF; +} + +#options-panel-container { + overflow: auto; +} + +#options-panel { + display: block; +} + +.options-vertical-pane { + display: inline; + float: inline-start; +} + +.options-vertical-pane { + margin: 5px; + width: calc(100%/3 - 10px); + min-width: 320px; + padding-inline-start: 5px; + box-sizing: border-box; +} + +/* Snap to 50% width once there is not room for 3 columns anymore. + This prevents having 2 columns showing in a row, but taking up + only ~66% of the available space. */ +@media (max-width: 1000px) { + .options-vertical-pane { + width: calc(100%/2 - 10px); + } +} + +.options-vertical-pane fieldset { + border: none; + min-inline-size: auto; +} + +.options-vertical-pane fieldset legend { + font-size: 1.4rem; + margin-inline-start: -15px; + margin-bottom: 3px; + cursor: default; +} + +.options-vertical-pane fieldset + fieldset { + margin-top: 1rem; +} + +.options-groupbox { + margin-inline-start: 15px; + padding: 2px; +} + +.options-groupbox label { + display: flex; + padding: 4px 0; + align-items: center; + width: max-content; + max-width: 100%; +} + +/* Add padding for label of select inputs in order to + align it with surrounding checkboxes */ +.options-groupbox label span:first-child { + padding-inline-start: 5px; +} + +.options-groupbox label span + select { + margin-inline-start: 4px; +} + +.options-groupbox.horizontal-options-groupbox label { + display: inline-flex; + align-items: flex-end; +} + +.options-groupbox.horizontal-options-groupbox label + label { + margin-inline-start: 4px; +} + +.options-groupbox > * { + padding: 2px; +} + +.options-citation-label { + display: inline-block; + font-size: 1rem; + font-style: italic; + /* To align it with the checkbox */ + padding: 4px 0 0; + padding-inline-end: 4px; +} + +#devtools-sourceeditor-keybinding-select { + min-width: 130px; +} + +#devtools-sourceeditor-tabsize-select { + min-width: 80px; +} + +#screenshot-options legend::after { + content: ""; + display: inline-block; + background-image: url("chrome://devtools/skin/images/command-screenshot.svg"); + width: 16px; + height: 16px; + vertical-align: sub; + margin-inline-start: 5px; + -moz-context-properties: fill; + fill: var(--theme-toolbar-color); + opacity: 0.6; +} + +.deprecation-notice::before { + background-image: url("chrome://devtools/skin/images/alert.svg"); + content: ''; + display: inline-block; + flex-shrink: 0; + height: 15px; + margin-inline-end: 5px; + width: 15px; +} + +.deprecation-notice { + align-items: center; + background-color: var(--theme-warning-background); + color: var(--theme-warning-color); + display: flex; + margin-inline-start: 8px; + outline: var(--theme-warning-background) solid 4px; +} + +.deprecation-notice a { + color: currentColor; +} +.deprecation-notice a:hover{ + text-decoration: underline; +} + +.experimental-notice::before { + mask-image: url("chrome://devtools/skin/images/filter-small.svg"); + mask-size: 15px; + transform: scaleY(-1); + background-color: var(--experimental-color); + display: inline-block; + content: ""; + flex-shrink: 0; + height: 15px; + margin-inline-end: 5px; + width: 15px; +} + +.experimental-notice { + background-color: var(--experimental-background); + color: var(--experimental-color); + outline: var(--experimental-background) solid 4px; + align-items: center; + display: flex; + margin-inline-start: 8px; +} + +.experimental-notice a { + color: currentColor; +} +.experimental-notice a:hover{ + text-decoration: underline; +} + +@keyframes highlight { + 0% { + background-color: var(--theme-highlight-yellow); + } + 100% { + background-color: transparent; + } +} + +.options-panel-highlight { + animation: highlight 8s; + animation-timing-function: ease; +} + +@media (prefers-reduced-motion) { + .highlighted { + animation-timing-function: steps(1, end); + } +} diff --git a/devtools/client/framework/reducers/dom-mutation-breakpoints.js b/devtools/client/framework/reducers/dom-mutation-breakpoints.js new file mode 100644 index 0000000000..f5e9c63a2c --- /dev/null +++ b/devtools/client/framework/reducers/dom-mutation-breakpoints.js @@ -0,0 +1,134 @@ +/* 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 . */ +"use strict"; + +const initialReducerState = { + counter: 1, + breakpoints: [], +}; + +exports.reducer = domMutationBreakpointReducer; +function domMutationBreakpointReducer(state = initialReducerState, action) { + switch (action.type) { + case "ADD_DOM_MUTATION_BREAKPOINT": + const hasExistingBp = state.breakpoints.some( + bp => + bp.nodeFront === action.nodeFront && + bp.mutationType === action.mutationType + ); + + if (hasExistingBp) { + break; + } + + state = { + ...state, + counter: state.counter + 1, + breakpoints: [ + ...state.breakpoints, + { + id: `${state.counter}`, + nodeFront: action.nodeFront, + targetFront: action.nodeFront.targetFront, + mutationType: action.mutationType, + enabled: true, + }, + ], + }; + break; + case "REMOVE_DOM_MUTATION_BREAKPOINT": + for (const [index, bp] of state.breakpoints.entries()) { + if ( + bp.nodeFront === action.nodeFront && + bp.mutationType === action.mutationType + ) { + state = { + ...state, + breakpoints: [ + ...state.breakpoints.slice(0, index), + ...state.breakpoints.slice(index + 1), + ], + }; + break; + } + } + break; + case "REMOVE_DOM_MUTATION_BREAKPOINTS_FOR_FRONTS": { + const { nodeFronts } = action; + const nodeFrontSet = new Set(nodeFronts); + + const breakpoints = state.breakpoints.filter( + bp => !nodeFrontSet.has(bp.nodeFront) + ); + + // Since we might not have made any actual changes, we verify first + // to avoid unnecessary changes in the state. + if (state.breakpoints.length !== breakpoints.length) { + state = { + ...state, + breakpoints, + }; + } + break; + } + + case "REMOVE_TARGET": { + const { targetFront } = action; + // When a target is destroyed, remove breakpoints associated with it. + const breakpoints = state.breakpoints.filter( + bp => bp.targetFront !== targetFront + ); + + // Since we might not have made any actual changes, we verify first + // to avoid unnecessary changes in the state. + if (state.breakpoints.length !== breakpoints.length) { + state = { + ...state, + breakpoints, + }; + } + break; + } + + case "SET_DOM_MUTATION_BREAKPOINTS_ENABLED_STATE": { + const { enabledStates } = action; + const toUpdateById = new Map(enabledStates); + + const breakpoints = state.breakpoints.map(bp => { + const newBpState = toUpdateById.get(bp.id); + if (typeof newBpState === "boolean" && newBpState !== bp.enabled) { + bp = { + ...bp, + enabled: newBpState, + }; + } + + return bp; + }); + + // Since we might not have made any actual changes, we verify first + // to avoid unnecessary changes in the state. + if (state.breakpoints.some((bp, i) => breakpoints[i] !== bp)) { + state = { + ...state, + breakpoints, + }; + } + break; + } + } + return state; +} + +exports.getDOMMutationBreakpoints = getDOMMutationBreakpoints; +function getDOMMutationBreakpoints(state) { + return state.domMutationBreakpoints.breakpoints; +} + +exports.getDOMMutationBreakpoint = getDOMMutationBreakpoint; +function getDOMMutationBreakpoint(state, id) { + return ( + state.domMutationBreakpoints.breakpoints.find(v => v.id === id) || null + ); +} diff --git a/devtools/client/framework/reducers/index.js b/devtools/client/framework/reducers/index.js new file mode 100644 index 0000000000..3ab06e349c --- /dev/null +++ b/devtools/client/framework/reducers/index.js @@ -0,0 +1,10 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +module.exports = { + domMutationBreakpoints: + require("resource://devtools/client/framework/reducers/dom-mutation-breakpoints.js") + .reducer, +}; diff --git a/devtools/client/framework/reducers/moz.build b/devtools/client/framework/reducers/moz.build new file mode 100644 index 0000000000..e77a7cc2cc --- /dev/null +++ b/devtools/client/framework/reducers/moz.build @@ -0,0 +1,11 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "dom-mutation-breakpoints.js", + "index.js", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Framework") diff --git a/devtools/client/framework/selection.js b/devtools/client/framework/selection.js new file mode 100644 index 0000000000..a71bdf6b56 --- /dev/null +++ b/devtools/client/framework/selection.js @@ -0,0 +1,367 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +loader.lazyRequireGetter( + this, + "nodeConstants", + "resource://devtools/shared/dom-node-constants.js" +); + +/** + * Selection is a singleton belonging to the Toolbox that manages the current selected + * NodeFront. In addition, it provides some helpers about the context of the selected + * node. + * + * API + * + * new Selection() + * destroy() + * nodeFront (readonly) + * setNodeFront(node, origin="unknown") + * + * Helpers: + * + * window + * document + * isRoot() + * isNode() + * isHTMLNode() + * + * Check the nature of the node: + * + * isElementNode() + * isAttributeNode() + * isTextNode() + * isCDATANode() + * isEntityRefNode() + * isEntityNode() + * isProcessingInstructionNode() + * isCommentNode() + * isDocumentNode() + * isDocumentTypeNode() + * isDocumentFragmentNode() + * isNotationNode() + * + * Events: + * "new-node-front" when the inner node changed + * "attribute-changed" when an attribute is changed + * "detached-front" when the node (or one of its parents) is removed from + * the document + * "reparented" when the node (or one of its parents) is moved under + * a different node + */ +function Selection() { + EventEmitter.decorate(this); + + // The WalkerFront is dynamic and is always set to the selected NodeFront's WalkerFront. + this._walker = null; + // A single node front can be represented twice on the client when the node is a slotted + // element. It will be displayed once as a direct child of the host element, and once as + // a child of a slot in the "shadow DOM". The latter is called the slotted version. + this._isSlotted = false; + + this._onMutations = this._onMutations.bind(this); + this.setNodeFront = this.setNodeFront.bind(this); +} + +Selection.prototype = { + _onMutations(mutations) { + let attributeChange = false; + let pseudoChange = false; + let detached = false; + let parentNode = null; + + for (const m of mutations) { + if (!attributeChange && m.type == "attributes") { + attributeChange = true; + } + if (m.type == "childList") { + if (!detached && !this.isConnected()) { + if (this.isNode()) { + parentNode = m.target; + } + detached = true; + } + } + if (m.type == "pseudoClassLock") { + pseudoChange = true; + } + } + + // Fire our events depending on what changed in the mutations array + if (attributeChange) { + this.emit("attribute-changed"); + } + if (pseudoChange) { + this.emit("pseudoclass"); + } + if (detached) { + this.emit("detached-front", parentNode); + } + }, + + destroy() { + this.setWalker(); + this._nodeFront = null; + }, + + /** + * @param {WalkerFront|null} walker + */ + setWalker(walker = null) { + if (this._walker) { + this._removeWalkerFrontEventListeners(this._walker); + } + + this._walker = walker; + if (this._walker) { + this._setWalkerFrontEventListeners(this._walker); + } + }, + + /** + * Set event listeners on the passed walker front + * + * @param {WalkerFront} walker + */ + _setWalkerFrontEventListeners(walker) { + walker.on("mutations", this._onMutations); + }, + + /** + * Remove event listeners we previously set on walker front + * + * @param {WalkerFront} walker + */ + _removeWalkerFrontEventListeners(walker) { + walker.off("mutations", this._onMutations); + }, + + /** + * Called when a target front is destroyed. + * + * @param {TargetFront} front + * @emits detached-front + */ + onTargetDestroyed(targetFront) { + // if the current walker belongs to the target that is destroyed, emit a `detached-front` + // event so consumers can act accordingly (e.g. in the inspector, another node will be + // selected) + if ( + this._walker && + !targetFront.isTopLevel && + this._walker.targetFront == targetFront + ) { + this._removeWalkerFrontEventListeners(this._walker); + this.emit("detached-front"); + } + }, + + /** + * Update the currently selected node-front. + * + * @param {NodeFront} nodeFront + * The NodeFront being selected. + * @param {Object} (optional) + * - {String} reason: Reason that triggered the selection, will be fired with + * the "new-node-front" event. + * - {Boolean} isSlotted: Is the selection representing the slotted version of + * the node. + */ + setNodeFront(nodeFront, { reason = "unknown", isSlotted = false } = {}) { + this.reason = reason; + + // If an inlineTextChild text node is being set, then set it's parent instead. + const parentNode = nodeFront && nodeFront.parentNode(); + if (nodeFront && parentNode && parentNode.inlineTextChild === nodeFront) { + nodeFront = parentNode; + } + + if (this._nodeFront == null && nodeFront == null) { + // Avoid to notify multiple "unselected" events with a null/undefined nodeFront + // (e.g. once when the webpage start to navigate away from the current webpage, + // and then again while the new page is being loaded). + return; + } + + this.emit("node-front-will-unset"); + + this._isSlotted = isSlotted; + this._nodeFront = nodeFront; + + if (nodeFront) { + this.setWalker(nodeFront.walkerFront); + } else { + this.setWalker(); + } + + this.emit("new-node-front", nodeFront, this.reason); + }, + + get nodeFront() { + return this._nodeFront; + }, + + isRoot() { + return ( + this.isNode() && this.isConnected() && this._nodeFront.isDocumentElement + ); + }, + + isNode() { + return !!this._nodeFront; + }, + + isConnected() { + let node = this._nodeFront; + if (!node || node.isDestroyed()) { + return false; + } + + while (node) { + if (node === this._walker.rootNode) { + return true; + } + node = node.parentOrHost(); + } + return false; + }, + + isHTMLNode() { + const xhtmlNs = "http://www.w3.org/1999/xhtml"; + return this.isNode() && this.nodeFront.namespaceURI == xhtmlNs; + }, + + isSVGNode() { + const svgNs = "http://www.w3.org/2000/svg"; + return this.isNode() && this.nodeFront.namespaceURI == svgNs; + }, + + isMathMLNode() { + const mathmlNs = "http://www.w3.org/1998/Math/MathML"; + return this.isNode() && this.nodeFront.namespaceURI == mathmlNs; + }, + + // Node type + + isElementNode() { + return ( + this.isNode() && this.nodeFront.nodeType == nodeConstants.ELEMENT_NODE + ); + }, + + isPseudoElementNode() { + return this.isNode() && this.nodeFront.isPseudoElement; + }, + + isAnonymousNode() { + return this.isNode() && this.nodeFront.isAnonymous; + }, + + isAttributeNode() { + return ( + this.isNode() && this.nodeFront.nodeType == nodeConstants.ATTRIBUTE_NODE + ); + }, + + isTextNode() { + return this.isNode() && this.nodeFront.nodeType == nodeConstants.TEXT_NODE; + }, + + isCDATANode() { + return ( + this.isNode() && + this.nodeFront.nodeType == nodeConstants.CDATA_SECTION_NODE + ); + }, + + isEntityRefNode() { + return ( + this.isNode() && + this.nodeFront.nodeType == nodeConstants.ENTITY_REFERENCE_NODE + ); + }, + + isEntityNode() { + return ( + this.isNode() && this.nodeFront.nodeType == nodeConstants.ENTITY_NODE + ); + }, + + isProcessingInstructionNode() { + return ( + this.isNode() && + this.nodeFront.nodeType == nodeConstants.PROCESSING_INSTRUCTION_NODE + ); + }, + + isCommentNode() { + return ( + this.isNode() && + this.nodeFront.nodeType == nodeConstants.PROCESSING_INSTRUCTION_NODE + ); + }, + + isDocumentNode() { + return ( + this.isNode() && this.nodeFront.nodeType == nodeConstants.DOCUMENT_NODE + ); + }, + + /** + * @returns true if the selection is the HTML element. + */ + isBodyNode() { + return ( + this.isHTMLNode() && + this.isConnected() && + this.nodeFront.nodeName === "BODY" + ); + }, + + /** + * @returns true if the selection is the HTML element. + */ + isHeadNode() { + return ( + this.isHTMLNode() && + this.isConnected() && + this.nodeFront.nodeName === "HEAD" + ); + }, + + isDocumentTypeNode() { + return ( + this.isNode() && + this.nodeFront.nodeType == nodeConstants.DOCUMENT_TYPE_NODE + ); + }, + + isDocumentFragmentNode() { + return ( + this.isNode() && + this.nodeFront.nodeType == nodeConstants.DOCUMENT_FRAGMENT_NODE + ); + }, + + isNotationNode() { + return ( + this.isNode() && this.nodeFront.nodeType == nodeConstants.NOTATION_NODE + ); + }, + + isSlotted() { + return this._isSlotted; + }, + + isShadowRootNode() { + return this.isNode() && this.nodeFront.isShadowRoot; + }, +}; + +module.exports = Selection; diff --git a/devtools/client/framework/source-map-url-service.js b/devtools/client/framework/source-map-url-service.js new file mode 100644 index 0000000000..8e08e9e4cb --- /dev/null +++ b/devtools/client/framework/source-map-url-service.js @@ -0,0 +1,501 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const SOURCE_MAP_PREF = "devtools.source-map.client-service.enabled"; + +/** + * A simple service to track source actors and keep a mapping between + * original URLs and objects holding the source or style actor's ID + * (which is used as a cookie by the devtools-source-map service) and + * the source map URL. + * + * @param {object} commands + * The commands object with all interfaces defined from devtools/shared/commands/ + * @param {SourceMapLoader} sourceMapLoader + * The source-map-loader implemented in devtools/client/shared/source-map-loader/ + */ +class SourceMapURLService { + constructor(commands, sourceMapLoader) { + this._commands = commands; + this._sourceMapLoader = sourceMapLoader; + + this._prefValue = Services.prefs.getBoolPref(SOURCE_MAP_PREF); + this._pendingIDSubscriptions = new Map(); + this._pendingURLSubscriptions = new Map(); + this._urlToIDMap = new Map(); + this._mapsById = new Map(); + this._sourcesLoading = null; + this._onResourceAvailable = this._onResourceAvailable.bind(this); + this._runningCallback = false; + + this._syncPrevValue = this._syncPrevValue.bind(this); + this._clearAllState = this._clearAllState.bind(this); + + Services.prefs.addObserver(SOURCE_MAP_PREF, this._syncPrevValue); + + // If a tool has changed or introduced a source map + // (e.g, by pretty-printing a source), tell the + // source map URL service about the change, so that + // subscribers to that service can be updated as + // well. + this._sourceMapLoader.on( + "source-map-created", + this.newSourceMapCreated.bind(this) + ); + } + + destroy() { + Services.prefs.removeObserver(SOURCE_MAP_PREF, this._syncPrevValue); + + this._clearAllState(); + + const { resourceCommand } = this._commands; + try { + resourceCommand.unwatchResources( + [ + resourceCommand.TYPES.STYLESHEET, + resourceCommand.TYPES.SOURCE, + resourceCommand.TYPES.DOCUMENT_EVENT, + ], + { onAvailable: this._onResourceAvailable } + ); + } catch (e) { + // If unwatchResources is called before finishing process of watchResources, + // it throws an error during stopping listener. + } + + this._sourcesLoading = null; + this._pendingIDSubscriptions = null; + this._pendingURLSubscriptions = null; + this._urlToIDMap = null; + this._mapsById = null; + } + + /** + * Subscribe to notifications about the original location of a given + * generated location, as it may not be known at this time, may become + * available at some unknown time in the future, or may change from one + * location to another. + * + * @param {string} id The actor ID of the source. + * @param {number} line The line number in the source. + * @param {number} column The column number in the source. + * @param {Function} callback A callback that may eventually be passed an + * an object with url/line/column properties specifying a location in + * the original file, or null if no particular original location could + * be found. The callback will run synchronously if the location is + * already know to the URL service. + * + * @return {Function} A function to call to remove this subscription. The + * "callback" argument is guaranteed to never run once unsubscribed. + */ + subscribeByID(id, line, column, callback) { + this._ensureAllSourcesPopulated(); + + let pending = this._pendingIDSubscriptions.get(id); + if (!pending) { + pending = new Set(); + this._pendingIDSubscriptions.set(id, pending); + } + const entry = { + line, + column, + callback, + unsubscribed: false, + owner: pending, + }; + pending.add(entry); + + const map = this._mapsById.get(id); + if (map) { + this._flushPendingIDSubscriptionsToMapQueries(map); + } + + return () => { + entry.unsubscribed = true; + entry.owner.delete(entry); + }; + } + + /** + * Subscribe to notifications about the original location of a given + * generated location, as it may not be known at this time, may become + * available at some unknown time in the future, or may change from one + * location to another. + * + * @param {string} id The actor ID of the source. + * @param {number} line The line number in the source. + * @param {number} column The column number in the source. + * @param {Function} callback A callback that may eventually be passed an + * an object with url/line/column properties specifying a location in + * the original file, or null if no particular original location could + * be found. The callback will run synchronously if the location is + * already know to the URL service. + * + * @return {Function} A function to call to remove this subscription. The + * "callback" argument is guaranteed to never run once unsubscribed. + */ + subscribeByURL(url, line, column, callback) { + this._ensureAllSourcesPopulated(); + + let pending = this._pendingURLSubscriptions.get(url); + if (!pending) { + pending = new Set(); + this._pendingURLSubscriptions.set(url, pending); + } + const entry = { + line, + column, + callback, + unsubscribed: false, + owner: pending, + }; + pending.add(entry); + + const id = this._urlToIDMap.get(url); + if (id) { + this._convertPendingURLSubscriptionsToID(url, id); + const map = this._mapsById.get(id); + if (map) { + this._flushPendingIDSubscriptionsToMapQueries(map); + } + } + + return () => { + entry.unsubscribed = true; + entry.owner.delete(entry); + }; + } + + /** + * Subscribe generically based on either an ID or a URL. + * + * In an ideal world we'd always know which of these to use, but there are + * still cases where end up with a mixture of both, so this is provided as + * a helper. If you can specifically use one of these, please do that + * instead however. + */ + subscribeByLocation({ id, url, line, column }, callback) { + if (id) { + return this.subscribeByID(id, line, column, callback); + } + + return this.subscribeByURL(url, line, column, callback); + } + + /** + * Tell the URL service than some external entity has registered a sourcemap + * in the worker for one of the source files. + * + * @param {Array} ids The actor ids of the sources that had the map registered. + */ + async newSourceMapCreated(ids) { + await this._ensureAllSourcesPopulated(); + + for (const id of ids) { + const map = this._mapsById.get(id); + if (!map) { + // State could have been cleared. + continue; + } + + map.loaded = Promise.resolve(); + for (const query of map.queries.values()) { + query.action = null; + query.result = null; + if (this._prefValue) { + this._dispatchQuery(query); + } + } + } + } + + _syncPrevValue() { + this._prefValue = Services.prefs.getBoolPref(SOURCE_MAP_PREF); + + for (const map of this._mapsById.values()) { + for (const query of map.queries.values()) { + this._ensureSubscribersSynchronized(query); + } + } + } + + _clearAllState() { + this._sourceMapLoader.clearSourceMaps(); + this._pendingIDSubscriptions.clear(); + this._pendingURLSubscriptions.clear(); + this._urlToIDMap.clear(); + this._mapsById.clear(); + } + + _onNewJavascript(source) { + const { url, actor: id, sourceMapBaseURL, sourceMapURL } = source; + + this._onNewSource(id, url, sourceMapURL, sourceMapBaseURL); + } + + _onNewStyleSheet(sheet) { + const { + href, + nodeHref, + sourceMapBaseURL, + sourceMapURL, + resourceId: id, + } = sheet; + const url = href || nodeHref; + + this._onNewSource(id, url, sourceMapURL, sourceMapBaseURL); + } + + _onNewSource(id, url, sourceMapURL, sourceMapBaseURL) { + this._urlToIDMap.set(url, id); + this._convertPendingURLSubscriptionsToID(url, id); + + let map = this._mapsById.get(id); + if (!map) { + map = { + id, + url, + sourceMapURL, + sourceMapBaseURL, + loaded: null, + queries: new Map(), + }; + this._mapsById.set(id, map); + } else if ( + map.id !== id && + map.url !== url && + map.sourceMapURL !== sourceMapURL && + map.sourceMapBaseURL !== sourceMapBaseURL + ) { + console.warn( + `Attempted to load populate sourcemap for source ${id} multiple times` + ); + } + + this._flushPendingIDSubscriptionsToMapQueries(map); + } + + _buildQuery(map, line, column) { + const key = `${line}:${column}`; + let query = map.queries.get(key); + if (!query) { + query = { + map, + line, + column, + subscribers: new Set(), + action: null, + result: null, + mostRecentEmitted: null, + }; + map.queries.set(key, query); + } + return query; + } + + _dispatchQuery(query, newSubscribers = null) { + if (!this._prefValue) { + throw new Error("This function should only be called if the pref is on."); + } + + if (!query.action) { + const { map } = query; + + // Call getOriginalURLs to make sure the source map has been + // fetched. We don't actually need the result of this though. + if (!map.loaded) { + map.loaded = this._sourceMapLoader.getOriginalURLs({ + id: map.id, + url: map.url, + sourceMapBaseURL: map.sourceMapBaseURL, + sourceMapURL: map.sourceMapURL, + }); + } + + const action = (async () => { + let result = null; + try { + await map.loaded; + } catch (e) { + // SourceMapLoader.getOriginalURLs may throw, but it will handle + // the exception and notify the user via a console message. + // So ignore the exception here, which is meant to be used by the Debugger. + } + + try { + const position = await this._sourceMapLoader.getOriginalLocation({ + sourceId: map.id, + line: query.line, + column: query.column, + }); + if (position && position.sourceId !== map.id) { + result = { + url: position.sourceUrl, + line: position.line, + column: position.column, + }; + } + } finally { + // If this action was dispatched and then the file was pretty-printed + // we want to ignore the result since the query has restarted. + if (action === query.action) { + // It is important that we consistently set the query result and + // trigger the subscribers here in order to maintain the invariant + // that if 'result' is truthy, then the subscribers will have run. + const position = result; + query.result = { position }; + this._ensureSubscribersSynchronized(query); + } + } + })(); + query.action = action; + } + + this._ensureSubscribersSynchronized(query); + } + + _ensureSubscribersSynchronized(query) { + // Synchronize the subscribers with the pref-disabled state if they need it. + if (!this._prefValue) { + if (query.mostRecentEmitted) { + query.mostRecentEmitted = null; + this._dispatchSubscribers(null, query.subscribers); + } + return; + } + + // Synchronize the subscribers with the newest computed result if they + // need it. + const { result } = query; + if (result && query.mostRecentEmitted !== result.position) { + query.mostRecentEmitted = result.position; + this._dispatchSubscribers(result.position, query.subscribers); + } + } + + _dispatchSubscribers(position, subscribers) { + // We copy the subscribers before iterating because something could be + // removed while we're calling the callbacks, which is also why we check + // the 'unsubscribed' flag. + for (const subscriber of Array.from(subscribers)) { + if (subscriber.unsubscribed) { + continue; + } + + if (this._runningCallback) { + console.error( + "The source map url service does not support reentrant subscribers." + ); + continue; + } + + try { + this._runningCallback = true; + + const { callback } = subscriber; + callback(position ? { ...position } : null); + } catch (err) { + console.error("Error in source map url service subscriber", err); + } finally { + this._runningCallback = false; + } + } + } + + _flushPendingIDSubscriptionsToMapQueries(map) { + const subscriptions = this._pendingIDSubscriptions.get(map.id); + if (!subscriptions || subscriptions.size === 0) { + return; + } + this._pendingIDSubscriptions.delete(map.id); + + for (const entry of subscriptions) { + const query = this._buildQuery(map, entry.line, entry.column); + + const { subscribers } = query; + + entry.owner = subscribers; + subscribers.add(entry); + + if (query.mostRecentEmitted) { + // Maintain the invariant that if a query has emitted a value, then + // _all_ subscribers will have received that value. + this._dispatchSubscribers(query.mostRecentEmitted, [entry]); + } + + if (this._prefValue) { + this._dispatchQuery(query); + } + } + } + + _ensureAllSourcesPopulated() { + if (!this._prefValue || this._commands.descriptorFront.isWorkerDescriptor) { + return null; + } + + if (!this._sourcesLoading) { + const { resourceCommand } = this._commands; + const { STYLESHEET, SOURCE, DOCUMENT_EVENT } = resourceCommand.TYPES; + + const onResources = resourceCommand.watchResources( + [STYLESHEET, SOURCE, DOCUMENT_EVENT], + { + onAvailable: this._onResourceAvailable, + } + ); + this._sourcesLoading = onResources; + } + + return this._sourcesLoading; + } + + waitForSourcesLoading() { + if (this._sourcesLoading) { + return this._sourcesLoading; + } + return Promise.resolve(); + } + + _onResourceAvailable(resources) { + const { resourceCommand } = this._commands; + const { STYLESHEET, SOURCE, DOCUMENT_EVENT } = resourceCommand.TYPES; + for (const resource of resources) { + // Only consider top level document, and ignore remote iframes top document + if ( + resource.resourceType == DOCUMENT_EVENT && + resource.name == "will-navigate" && + resource.targetFront.isTopLevel + ) { + this._clearAllState(); + } else if (resource.resourceType == STYLESHEET) { + this._onNewStyleSheet(resource); + } else if (resource.resourceType == SOURCE) { + this._onNewJavascript(resource); + } + } + } + + _convertPendingURLSubscriptionsToID(url, id) { + const urlSubscriptions = this._pendingURLSubscriptions.get(url); + if (!urlSubscriptions) { + return; + } + this._pendingURLSubscriptions.delete(url); + + let pending = this._pendingIDSubscriptions.get(id); + if (!pending) { + pending = new Set(); + this._pendingIDSubscriptions.set(id, pending); + } + for (const entry of urlSubscriptions) { + entry.owner = pending; + pending.add(entry); + } + } +} + +exports.SourceMapURLService = SourceMapURLService; diff --git a/devtools/client/framework/store-provider.js b/devtools/client/framework/store-provider.js new file mode 100644 index 0000000000..51a37b20f4 --- /dev/null +++ b/devtools/client/framework/store-provider.js @@ -0,0 +1,10 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + createProvider, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +module.exports = createProvider("toolbox-store"); diff --git a/devtools/client/framework/store.js b/devtools/client/framework/store.js new file mode 100644 index 0000000000..53556de887 --- /dev/null +++ b/devtools/client/framework/store.js @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const createStore = require("resource://devtools/client/shared/redux/create-store.js"); +const reducers = require("resource://devtools/client/framework/reducers/index.js"); + +exports.createToolboxStore = () => + createStore(reducers, { + // Uncomment this for logging in tests. + // shouldLog: true, + }); diff --git a/devtools/client/framework/test/allocations/browser_allocations_browser_console.js b/devtools/client/framework/test/allocations/browser_allocations_browser_console.js new file mode 100644 index 0000000000..13d0171dfa --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_browser_console.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Record allocations while opening and closing the Browser Console + +const TEST_URL = + "http://example.com/browser/devtools/client/framework/test/allocations/reloaded-page.html"; + +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); +const { + BrowserConsoleManager, +} = require("resource://devtools/client/webconsole/browser-console-manager.js"); + +async function testScript() { + // Open + await BrowserConsoleManager.toggleBrowserConsole(); + + // Reload the tab to make the test slightly more real + const hud = BrowserConsoleManager.getBrowserConsole(); + const onTargetProcessed = hud.commands.targetCommand.once( + "processed-available-target" + ); + + gBrowser.reloadTab(gBrowser.selectedTab); + + info("Wait for target of the new document to be fully processed"); + await onTargetProcessed; + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Close + await BrowserConsoleManager.toggleBrowserConsole(); + + // Browser console still cleanup stuff after the resolution of toggleBrowserConsole. + // So wait for a little while to ensure it completes all cleanups. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); +} + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["devtools.browsertoolbox.scope", "everything"]], + }); + + const tab = await addTab(TEST_URL); + + // Run the test scenario first before recording in order to load all the + // modules. Otherwise they get reported as "still allocated" objects, + // whereas we do expect them to be kept in memory as they are loaded via + // the main DevTools loader, which keeps the module loaded until the + // shutdown of Firefox + await testScript(); + + // Now, run the test script. This time, we record this run. + await startRecordingAllocations(); + + for (let i = 0; i < 3; i++) { + await testScript(); + } + + await stopRecordingAllocations("browser-console"); + + gBrowser.removeTab(tab); +}); diff --git a/devtools/client/framework/test/allocations/browser_allocations_browser_console.toml b/devtools/client/framework/test/allocations/browser_allocations_browser_console.toml new file mode 100644 index 0000000000..785f665b85 --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_browser_console.toml @@ -0,0 +1,17 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/shared/test-helpers/allocation-tracker.js", + "head.js", +] + +# Each metrics tests is loaded in a separate .ini file. This way the test is executed +# individually, without any other test being executed before or after. + +["browser_allocations_browser_console.js"] +run-if = ["os == 'linux'"] # Results should be platform agnostic - only run on linux64-opt +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/allocations/browser_allocations_reload_debugger.js b/devtools/client/framework/test/allocations/browser_allocations_reload_debugger.js new file mode 100644 index 0000000000..748a5e906e --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_reload_debugger.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Record allocations while reloading the page with the debugger opened + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/framework/test/allocations/reload-test.js", + this +); + +add_task(createPanelReloadTest("reload-debugger", "jsdebugger")); diff --git a/devtools/client/framework/test/allocations/browser_allocations_reload_debugger.toml b/devtools/client/framework/test/allocations/browser_allocations_reload_debugger.toml new file mode 100644 index 0000000000..aabb21dc1a --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_reload_debugger.toml @@ -0,0 +1,20 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/shared/test-helpers/allocation-tracker.js", + "head.js", + "reload-test.js", + "reloaded-page.html", + "reloaded.png", +] + +# Each metrics tests is loaded in a separate .ini file. This way the test is executed +# individually, without any other test being executed before or after. + +["browser_allocations_reload_debugger.js"] +run-if = ["os == 'linux'"] # Results should be platform agnostic - only run on linux64-opt +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/allocations/browser_allocations_reload_inspector.js b/devtools/client/framework/test/allocations/browser_allocations_reload_inspector.js new file mode 100644 index 0000000000..3369c54f24 --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_reload_inspector.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Record allocations while reloading the page with the inspector opened + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/framework/test/allocations/reload-test.js", + this +); + +add_task(createPanelReloadTest("reload-inspector", "inspector")); diff --git a/devtools/client/framework/test/allocations/browser_allocations_reload_inspector.toml b/devtools/client/framework/test/allocations/browser_allocations_reload_inspector.toml new file mode 100644 index 0000000000..f2046ea621 --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_reload_inspector.toml @@ -0,0 +1,20 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/shared/test-helpers/allocation-tracker.js", + "head.js", + "reload-test.js", + "reloaded-page.html", + "reloaded.png", +] + +# Each metrics tests is loaded in a separate .ini file. This way the test is executed +# individually, without any other test being executed before or after. + +["browser_allocations_reload_inspector.js"] +run-if = ["os == 'linux'"] # Results should be platform agnostic - only run on linux64-opt +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/allocations/browser_allocations_reload_netmonitor.js b/devtools/client/framework/test/allocations/browser_allocations_reload_netmonitor.js new file mode 100644 index 0000000000..2a57652ac5 --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_reload_netmonitor.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Record allocations while reloading the page with the netmonitor opened + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/framework/test/allocations/reload-test.js", + this +); + +add_task(createPanelReloadTest("reload-netmonitor", "netmonitor")); diff --git a/devtools/client/framework/test/allocations/browser_allocations_reload_netmonitor.toml b/devtools/client/framework/test/allocations/browser_allocations_reload_netmonitor.toml new file mode 100644 index 0000000000..3a4a0b8464 --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_reload_netmonitor.toml @@ -0,0 +1,20 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/shared/test-helpers/allocation-tracker.js", + "head.js", + "reload-test.js", + "reloaded-page.html", + "reloaded.png", +] + +# Each metrics tests is loaded in a separate .ini file. This way the test is executed +# individually, without any other test being executed before or after. + +["browser_allocations_reload_netmonitor.js"] +run-if = ["os == 'linux'"] # Results should be platform agnostic - only run on linux64-opt +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/allocations/browser_allocations_reload_no_devtools.js b/devtools/client/framework/test/allocations/browser_allocations_reload_no_devtools.js new file mode 100644 index 0000000000..e2f344fcb5 --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_reload_no_devtools.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Record allocations while reloading the page without anything related to DevTools running + +const TEST_URL = + "http://example.com/browser/devtools/client/framework/test/allocations/reloaded-page.html"; + +async function testScript() { + await BrowserTestUtils.reloadTab(gBrowser.selectedTab); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1000)); +} + +add_task(async function () { + const tab = await addTab(TEST_URL); + + // Run the test scenario first before recording in order to load all the + // modules. Otherwise they get reported as "still allocated" objects, + // whereas we do expect them to be kept in memory as they are loaded via + // the main DevTools loader, which keeps the module loaded until the + // shutdown of Firefox + await testScript(); + + await startRecordingAllocations({ + alsoRecordContentProcess: true, + }); + + // Now, run the test script. This time, we record this run. + for (let i = 0; i < 10; i++) { + await testScript(); + } + + await stopRecordingAllocations("reload-no-devtools", { + alsoRecordContentProcess: true, + }); + + gBrowser.removeTab(tab); +}); diff --git a/devtools/client/framework/test/allocations/browser_allocations_reload_no_devtools.toml b/devtools/client/framework/test/allocations/browser_allocations_reload_no_devtools.toml new file mode 100644 index 0000000000..58dfadc598 --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_reload_no_devtools.toml @@ -0,0 +1,19 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/shared/test-helpers/allocation-tracker.js", + "head.js", + "reloaded-page.html", + "reloaded.png", +] + +# Each metrics tests is loaded in a separate .ini file. This way the test is executed +# individually, without any other test being executed before or after. + +["browser_allocations_reload_no_devtools.js"] +run-if = ["os == 'linux'"] # Results should be platform agnostic - only run on linux64-opt +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/allocations/browser_allocations_reload_webconsole.js b/devtools/client/framework/test/allocations/browser_allocations_reload_webconsole.js new file mode 100644 index 0000000000..a60fd03b3c --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_reload_webconsole.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Record allocations while reloading the page with the webconsole opened + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/framework/test/allocations/reload-test.js", + this +); + +add_task(createPanelReloadTest("reload-webconsole", "webconsole")); diff --git a/devtools/client/framework/test/allocations/browser_allocations_reload_webconsole.toml b/devtools/client/framework/test/allocations/browser_allocations_reload_webconsole.toml new file mode 100644 index 0000000000..58bb9b733e --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_reload_webconsole.toml @@ -0,0 +1,20 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/shared/test-helpers/allocation-tracker.js", + "head.js", + "reload-test.js", + "reloaded-page.html", + "reloaded.png", +] + +# Each metrics tests is loaded in a separate .ini file. This way the test is executed +# individually, without any other test being executed before or after. + +["browser_allocations_reload_webconsole.js"] +run-if = ["os == 'linux'"] # Results should be platform agnostic - only run on linux64-opt +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/allocations/browser_allocations_target.js b/devtools/client/framework/test/allocations/browser_allocations_target.js new file mode 100644 index 0000000000..a93d6b51c9 --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_target.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Record allocations while spawning Commands and the first top level target + +const TEST_URL = + "data:text/html;charset=UTF-8,
Target allocations test
"; + +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); +const { + CommandsFactory, +} = require("resource://devtools/shared/commands/commands-factory.js"); + +async function testScript(tab) { + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1000)); + + // destroy the commands to also destroy the dedicated client. + await commands.destroy(); + + // Spin the event loop to ensure commands destruction is fully completed + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 0)); +} + +add_task(async function () { + const tab = await addTab(TEST_URL); + + // Run the test scenario first before recording in order to load all the + // modules. Otherwise they get reported as "still allocated" objects, + // whereas we do expect them to be kept in memory as they are loaded via + // the main DevTools loader, which keeps the module loaded until the + // shutdown of Firefox + await testScript(tab); + + await startRecordingAllocations(); + + // Now, run the test script. This time, we record this run. + await testScript(tab); + + await stopRecordingAllocations("target"); + + gBrowser.removeTab(tab); +}); diff --git a/devtools/client/framework/test/allocations/browser_allocations_target.toml b/devtools/client/framework/test/allocations/browser_allocations_target.toml new file mode 100644 index 0000000000..d5b4b158d5 --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_target.toml @@ -0,0 +1,17 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/shared/test-helpers/allocation-tracker.js", + "head.js", +] + +# Each metrics tests is loaded in a separate .ini file. This way the test is executed +# individually, without any other test being executed before or after. + +["browser_allocations_target.js"] +run-if = ["os == 'linux'"] # Results should be platform agnostic - only run on linux64-opt +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/allocations/browser_allocations_toolbox.js b/devtools/client/framework/test/allocations/browser_allocations_toolbox.js new file mode 100644 index 0000000000..e0f86511bc --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_toolbox.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Record allocations while opening and closing DevTools + +const TEST_URL = + "data:text/html;charset=UTF-8,
Target allocations test
"; + +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); +const { + gDevTools, +} = require("resource://devtools/client/framework/devtools.js"); + +async function testScript(tab) { + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "inspector", + }); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1000)); + + await toolbox.destroy(); + + // Spin the event loop to ensure toolbox destroy is fully completed + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 0)); +} + +add_task(async function () { + const tab = await addTab(TEST_URL); + + // Run the test scenario first before recording in order to load all the + // modules. Otherwise they get reported as "still allocated" objects, + // whereas we do expect them to be kept in memory as they are loaded via + // the main DevTools loader, which keeps the module loaded until the + // shutdown of Firefox + await testScript(tab); + + await startRecordingAllocations(); + + // Now, run the test script. This time, we record this run. + for (let i = 0; i < 3; i++) { + await testScript(tab); + } + + await stopRecordingAllocations("toolbox"); + + gBrowser.removeTab(tab); +}); diff --git a/devtools/client/framework/test/allocations/browser_allocations_toolbox.toml b/devtools/client/framework/test/allocations/browser_allocations_toolbox.toml new file mode 100644 index 0000000000..bbeeb14e45 --- /dev/null +++ b/devtools/client/framework/test/allocations/browser_allocations_toolbox.toml @@ -0,0 +1,17 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/shared/test-helpers/allocation-tracker.js", + "head.js", +] + +# Each metrics tests is loaded in a separate .ini file. This way the test is executed +# individually, without any other test being executed before or after. + +["browser_allocations_toolbox.js"] +run-if = ["os == 'linux'"] # Results should be platform agnostic - only run on linux64-opt +skip-if = [ + "debug", + "asan", +] diff --git a/devtools/client/framework/test/allocations/docs/index.md b/devtools/client/framework/test/allocations/docs/index.md new file mode 100644 index 0000000000..f0d6921325 --- /dev/null +++ b/devtools/client/framework/test/allocations/docs/index.md @@ -0,0 +1,241 @@ +# Allocation tests + +The [allocations](https://searchfox.org/mozilla-central/source/devtools/client/framework/test/allocations) folder contains special mochitests which are meant to record data about the memory usage of DevTools. +This uses Spidermonkey's Memory API implemented next to the debugger API. +For more info, see the following doc: + + +# Test example + +```javascript +add_task(async function() { + // Execute preliminary setup in order to be able to run your scenario + // You would typicaly load modules, open a tab, a toolbox, ... + ... + + // Run the test scenario first before recording in order to load all the + // modules. Otherwise they get reported as "still allocated" objects, + // whereas we do expect them to be kept in memory as they are loaded via + // the main DevTools loader, which keeps the module loaded until the + // shutdown of Firefox + await testScript(); + + // Pass alsoRecordContentProcess if you want to record the content process + // of the current tab. Otherwise it will only record parent process objects. + await startRecordingAllocations({ alsoRecordContentProcess: true }); + + // Now, run the test script. This time, we record this run. + await testScript(toolbox); + + // This will stop the record and also publish the results to Talos database + // Second argument will be the name of the test displayed in Talos. + // Many tests will be recorded, but all of them will be prefixed with this string. + await stopRecordingAllocations("reload", { alsoRecordContentProcess: true }); + + // Then, here you can execute cleanup. + // You would typically close the tab, toolbox, ... +}); +``` + +# How to run them locally + +```bash +$ ./mach mochitest --headless devtools/client/framework/test/allocations/ +``` + +And to only see the results: +```bash +$ ./mach mochitest --headless devtools/client/framework/test/allocations/ | grep " test leaked " +``` + +# Debug leaks + +If you are seeing a regression or an improvement, only seeing the number of objects being leaked isn't super helpful. +The tests includes some special debug modes which are printing lots of data to figure out what is leaking and why. + +You may run the test with the following env variable to turn debug mode on: +```bash +DEBUG_DEVTOOLS_ALLOCATIONS=leak|allocations $ ./mach mochitest --headless devtools/client/framework/test/allocations/the-fault-test.js +``` + +**DEBUG_DEVTOOLS_ALLOCATIONS** can enable two distinct debug output. (Only one can be enabled at a given time) + +**DEBUG_DEVTOOLS_ALLOCATIONS=allocations** will report all allocation sites that have been made +while running your test. This will include allocations which has been freed. +This view is especially useful if you want to reduce allocations in order to reduce GC overload. + +**DEBUG_DEVTOOLS_ALLOCATIONS=leak** will report only the allocations which are still allocated +at the end of your test. Sometimes it will only report allocations with missing stack trace. +Thus making the preview view helpful. + +## Example + +Let's assume we have the following code: + +```javascript + 1: exports.MyModule = { + 2: globalArray: [], + 3: test() { + 3: // The following object will be allocated but not leaked, + 5: // as we keep no reference to it anywhere + 6: const transientObject = {}; + 7: + 8: // The following object will be allocated on this line, + 9: // but leaked on the following one. By storing a reference +10: // to it in the global array which is never cleared. +11: const leakedObject = {}; +12: this.globalArray.push(leakedObject); +13: }, +14: }; +``` + +And that, we have a memory test doing this: + +```javascript + const { MyModule } = require("devtools/my-module"); + + await startRecordingAllocations(); + + MyModule.test(); + + await stopRecordingAllocations("target"); +``` + +We can first review all the allocations by running: + +```bash +DEBUG_DEVTOOLS_ALLOCATIONS=allocations $ ./mach mochitest --headless devtools/client/framework/test/allocations/browser_allocation_myTest.js + +``` + +which will print at the end: + +```javascript +DEVTOOLS ALLOCATION: all allocations (which may be freed or are still allocated): +[ + { + "src": "UNKNOWN", + "count": 80, + "lines": [ + "?: 80" + ] + }, + { + "src": "resource://devtools/my-module.js", + "count": 2, + "lines": [ + "11: 1" + "6: 1" + ] + } +] +``` + +The first part, with `UNKNOWN` can be ignored. This is about objects with missing allocation sites. +The second part of this logs tells us that 2 objects were allocated from my-module.js when running the test. +One has been allocated at line 6, it is `transcientObject`. +Another one has been allocated at line 11, it is `leakedObject`. + +Now, we can use the second view to focus only on objects that have been kept allocated: + +```bash +DEBUG_DEVTOOLS_ALLOCATIONS=leaks $ ./mach mochitest --headless devtools/client/framework/test/allocations/browser_allocation_myTest.js + +``` + +which will print at the end: + +```javascript +DEVTOOLS ALLOCATION: allocations which leaked: +[ + { + "src": "UNKNOWN", + "count": 80, + "lines": [ + "?: 80" + ] + }, + { + "src": "resource://devtools/shared/commands/commands-factory.js", + "count": 1, + "lines": [ + "11: 1" + ] + } +] +``` + +Similarly, we can focus only on the second part, which tells us that only one object is being leaked +and this object has been originally created from line 11, this is `leakedObject`. +This doesn't tell us why the object is being kept allocated, but at least we know which one is being kept in memory. + + +## Debug leaks via dominators + +This last feature might be the most powerful and isn't bound to DEBUG_DEVTOOLS_ALLOCATIONS. +This is always enabled. +Also, it requires to know which particular object is being leaked and also require to hack +the codebase in order to pass a reference of the suspicious object to the test helper. + +You can instruct the test helper to track a given object by doing this: + +```javascript + 1: // Let's say it is some code running from "my-module.js" + 2: + 3: // From a DevTools CommonJS module: + 4: const { track } = require("devtools/shared/test-helpers/tracked-objects.sys.mjs"); + 5: // From anything else, JSM, XPCOM module,...: + 6: const { track } = ChromeUtils.importESModule("resource://devtools/shared/test-helpers/tracked-objects.sys.mjs"); + 7: + 8: const g = []; + 9: function someFunctionInDevToolsCalledBySomething() { +10: const myLeakedObject = {}; +11: track(myLeakedObject); +12: +13: // Simulate a leak by holding a reference to the object in a global `g` array +14: g.push({ seeMyCustomAttributeHere: myLeakedObject }); +15: } +``` + +Then, when running the test you will get such output: + +```bash + 0:41.26 GECKO(644653) # Tracing: Object@my-module:10 + 0:40.65 GECKO(644653) ### Path(s) from root: + 0:41.26 GECKO(644653) - other@no-stack:undefined.WeakMap entry value + 0:41.26 GECKO(644653) \--> LexicalEnvironment@base-loader.sys.mjs:160.**UNKNOWN SLOT 1** + 0:41.26 GECKO(644653) \--> Object@base-loader.sys.mjs:155.g + 0:41.26 GECKO(644653) \--> Array@my-module.js:8.objectElements[0] + 0:41.26 GECKO(644653) \--> Object@my-module.js:14.seeMyCustomAttributeHere + 0:41.26 GECKO(644653) \--> Object@my-module.js:10 +``` + +This output means that `myLeakedObject` was originally allocated from my-module.js at line 10. +And is being held allocated because it is kept in an Object allocated from my-module.js at line 14. +This is our custom object we stored in `g` global Array. +This custom object it hold by the Array allocated at line 8 of my-module.js. +And this array is held allocated from an Object, itself allocated by base-loader.sys.mjs at line 155. +This is the global of the my-module.js's module, created by DevTools loader. +Then we see some more low level object up to another global object, which misses its allocation site. + +# How to easily get data from try run + +```bash +$ ./mach try fuzzy devtools/client/framework/test/allocations/ --query "'linux 'chrome-e10s 'opt '64-qr/opt" +``` + +You might also pass `--rebuild 3` if the test result is having some noise and you want more test runs. + +# Following trends for these tests + +You may try looking at: + + +Or at: + + +Link that you get from: +by looking at last year data for "DevTools" in the first dropdown, +and double clicking on the relevant line in "Tests" menulist. + +Significant improvements and regressions will be notified through [the following dashboard](https://treeherder.mozilla.org/perfherder/alerts?hideDwnToInv=1&page=1&framework=12). diff --git a/devtools/client/framework/test/allocations/head.js b/devtools/client/framework/test/allocations/head.js new file mode 100644 index 0000000000..a7e7f56a36 --- /dev/null +++ b/devtools/client/framework/test/allocations/head.js @@ -0,0 +1,250 @@ +/* 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"; + +// Load the tracker very first in order to ensure tracking all objects created by DevTools. +// This is especially important for allocation sites. We need to catch the global the +// earliest possible in order to ensure that all allocation objects come with a stack. +// +// If we want to track DevTools module loader we should ensure loading Loader.sys.mjs within +// the `testScript` Function. i.e. after having calling startRecordingAllocations. +let tracker, releaseTrackerLoader; +{ + const { + useDistinctSystemPrincipalLoader, + releaseDistinctSystemPrincipalLoader, + } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs" + ); + + const requester = {}; + const loader = useDistinctSystemPrincipalLoader(requester); + releaseTrackerLoader = () => releaseDistinctSystemPrincipalLoader(requester); + const { allocationTracker } = loader.require( + "chrome://mochitests/content/browser/devtools/shared/test-helpers/allocation-tracker.js" + ); + tracker = allocationTracker({ watchDevToolsGlobals: true }); +} + +// /!\ Be careful about imports/require +// +// Some tests may record the very first time we load a module. +// If we start loading them from here, we might only retrieve the already loaded +// module from the loader's cache. This would no longer highlight the cost +// of loading a new module from scratch. +// +// => Avoid loading devtools module as much as possible +// => If you really have to, lazy load them + +ChromeUtils.defineLazyGetter(this, "TrackedObjects", () => { + return ChromeUtils.importESModule( + "resource://devtools/shared/test-helpers/tracked-objects.sys.mjs" + ); +}); + +// So that PERFHERDER data can be extracted from the logs. +SimpleTest.requestCompleteLog(); + +// We have to disable testing mode, or various debug instructions are enabled. +// We especially want to disable redux store history, which would leak all the actions! +SpecialPowers.pushPrefEnv({ + set: [["devtools.testing", false]], +}); + +// Set DEBUG_DEVTOOLS_ALLOCATIONS=allocations|leaks in order print debug informations. +const DEBUG_ALLOCATIONS = Services.env.get("DEBUG_DEVTOOLS_ALLOCATIONS"); + +async function addTab(url) { + const tab = BrowserTestUtils.addTab(gBrowser, url); + gBrowser.selectedTab = tab; + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + return tab; +} + +/** + * This function will force some garbage collection before recording + * data about allocated objects. + * + * This accept an optional boolean to also record the content process objects + * of the current tab. That, in addition of objects from the parent process, + * which are always recorded. + * + * This return same data object which is meant to be passed to `stopRecordingAllocations` as-is. + * + * See README.md file in this folder. + */ +async function startRecordingAllocations({ + alsoRecordContentProcess = false, +} = {}) { + // Also start recording allocations in the content process, if requested + if (alsoRecordContentProcess) { + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [DEBUG_ALLOCATIONS], + async debug_allocations => { + const { DevToolsLoader } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + + const { + useDistinctSystemPrincipalLoader, + releaseDistinctSystemPrincipalLoader, + } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs" + ); + + const requester = {}; + const loader = useDistinctSystemPrincipalLoader(requester); + const { allocationTracker } = loader.require( + "chrome://mochitests/content/browser/devtools/shared/test-helpers/allocation-tracker.js" + ); + // We watch all globals in the content process, (instead of only DevTools global in parent process) + // because we may easily leak web page objects, which aren't in DevTools global. + const tracker = allocationTracker({ watchAllGlobals: true }); + + // /!\ HACK: store tracker and releaseTrackerLoader on DevToolsLoader in order + // to be able to reuse them in a following call to SpecialPowers.spawn + DevToolsLoader.tracker = tracker; + DevToolsLoader.releaseTrackerLoader = () => + releaseDistinctSystemPrincipalLoader(requester); + + await tracker.startRecordingAllocations(debug_allocations); + } + ); + // Trigger a GC in the parent process as this additional ContentTask + // seems to make harder to release objects created before we start recording. + await tracker.doGC(); + } + + await tracker.startRecordingAllocations(DEBUG_ALLOCATIONS); +} + +/** + * See doc of startRecordingAllocations + */ +async function stopRecordingAllocations( + recordName, + { alsoRecordContentProcess = false } = {} +) { + // Ensure that Memory API didn't ran out of buffers + ok(!tracker.overflowed, "Allocation were all recorded in the parent process"); + + // And finally, retrieve the record *after* having ran the test + const parentProcessData = await tracker.stopRecordingAllocations( + DEBUG_ALLOCATIONS + ); + + const objectNodeIds = TrackedObjects.getAllNodeIds(); + if (objectNodeIds.length) { + tracker.traceObjects(objectNodeIds); + } + + let contentProcessData = null; + if (alsoRecordContentProcess) { + contentProcessData = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [DEBUG_ALLOCATIONS], + debug_allocations => { + const { DevToolsLoader } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { tracker } = DevToolsLoader; + ok( + !tracker.overflowed, + "Allocation were all recorded in the content process" + ); + return tracker.stopRecordingAllocations(debug_allocations); + } + ); + } + + const trackedObjectsInContent = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => { + const TrackedObjects = ChromeUtils.importESModule( + "resource://devtools/shared/test-helpers/tracked-objects.sys.mjs" + ); + const objectNodeIds = TrackedObjects.getAllNodeIds(); + if (objectNodeIds.length) { + const { DevToolsLoader } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { tracker } = DevToolsLoader; + // Record the heap snapshot from the content process, + // and pass the record's filepath to the parent process + // As only the parent process can read the file because + // of sandbox restrictions made to content processes regarding file I/O. + const snapshotFile = tracker.getSnapshotFile(); + return { snapshotFile, objectNodeIds }; + } + return null; + } + ); + if (trackedObjectsInContent) { + tracker.traceObjects( + trackedObjectsInContent.objectNodeIds, + trackedObjectsInContent.snapshotFile + ); + } + + // Craft the JSON object required to save data in talos database + info( + `The ${recordName} test leaked ${parentProcessData.objectsWithStack} objects (${parentProcessData.objectsWithoutStack} with missing allocation site) in the parent process` + ); + const PERFHERDER_DATA = { + framework: { + name: "devtools", + }, + suites: [ + { + name: recordName + ":parent-process", + subtests: [ + { + name: "objects-with-stacks", + value: parentProcessData.objectsWithStack, + }, + { + name: "memory", + value: parentProcessData.memory, + }, + ], + }, + ], + }; + if (alsoRecordContentProcess) { + info( + `The ${recordName} test leaked ${contentProcessData.objectsWithStack} objects (${contentProcessData.objectsWithoutStack} with missing allocation site) in the content process` + ); + PERFHERDER_DATA.suites.push({ + name: recordName + ":content-process", + subtests: [ + { + name: "objects-with-stacks", + value: contentProcessData.objectsWithStack, + }, + { + name: "memory", + value: contentProcessData.memory, + }, + ], + }); + + // Finally release the tracker loader in content process. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const { DevToolsLoader } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + DevToolsLoader.releaseTrackerLoader(); + }); + } + + // And release the tracker loader in the parent process + releaseTrackerLoader(); + + // Log it to stdout so that perfherder can collect this data. + // This only works if we called `SimpleTest.requestCompleteLog()`! + info("PERFHERDER_DATA: " + JSON.stringify(PERFHERDER_DATA)); +} diff --git a/devtools/client/framework/test/allocations/moz.build b/devtools/client/framework/test/allocations/moz.build new file mode 100644 index 0000000000..5488c8e2fc --- /dev/null +++ b/devtools/client/framework/test/allocations/moz.build @@ -0,0 +1,16 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +BROWSER_CHROME_MANIFESTS += [ + "browser_allocations_browser_console.toml", + "browser_allocations_reload_debugger.toml", + "browser_allocations_reload_inspector.toml", + "browser_allocations_reload_netmonitor.toml", + "browser_allocations_reload_no_devtools.toml", + "browser_allocations_reload_webconsole.toml", + "browser_allocations_target.toml", + "browser_allocations_toolbox.toml", +] diff --git a/devtools/client/framework/test/allocations/reload-test.js b/devtools/client/framework/test/allocations/reload-test.js new file mode 100644 index 0000000000..3d09d5ef43 --- /dev/null +++ b/devtools/client/framework/test/allocations/reload-test.js @@ -0,0 +1,84 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* import-globals-from head.js */ +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +/** + * Generate a test Task to record allocation when reloading a test page + * while having one particular DevTools panel opened + * + * @param String recordName + * Name of the test recorded into PerfHerder/Talos database + * @param String toolId + * ID of the panel to open + */ +function createPanelReloadTest(recordName, toolId) { + return async function panelReloadTest() { + const TEST_URL = + "http://example.com/browser/devtools/client/framework/test/allocations/reloaded-page.html"; + + async function testScript(toolbox) { + const onTargetSwitched = + toolbox.commands.targetCommand.once("switched-target"); + const onReloaded = toolbox.getCurrentPanel().once("reloaded"); + + gBrowser.reloadTab(gBrowser.selectedTab); + + if ( + toolbox.commands.targetCommand.targetFront.targetForm + .followWindowGlobalLifeCycle + ) { + info("Wait for target switched"); + await onTargetSwitched; + } + + info("Wait for panel reload"); + await onReloaded; + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + const tab = await addTab(TEST_URL); + + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + gDevTools, + } = require("resource://devtools/client/framework/devtools.js"); + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId, + }); + + // Run the test scenario first before recording in order to load all the + // modules. Otherwise they get reported as "still allocated" objects, + // whereas we do expect them to be kept in memory as they are loaded via + // the main DevTools loader, which keeps the module loaded until the + // shutdown of Firefox + await testScript(toolbox); + // Running it a second time is helpful for the debugger which allocates different objects + // on the second run... which would be taken as leak otherwise. + await testScript(toolbox); + + await startRecordingAllocations({ + alsoRecordContentProcess: true, + }); + + // Now, run the test script. This time, we record this run. + for (let i = 0; i < 10; i++) { + await testScript(toolbox); + } + + await stopRecordingAllocations(recordName, { + alsoRecordContentProcess: true, + }); + + await toolbox.destroy(); + gBrowser.removeTab(tab); + }; +} diff --git a/devtools/client/framework/test/allocations/reloaded-page.html b/devtools/client/framework/test/allocations/reloaded-page.html new file mode 100644 index 0000000000..4f14c8a0c3 --- /dev/null +++ b/devtools/client/framework/test/allocations/reloaded-page.html @@ -0,0 +1,11 @@ + + + + Reloaded page + + + + The reloaded page + + + diff --git a/devtools/client/framework/test/allocations/reloaded.png b/devtools/client/framework/test/allocations/reloaded.png new file mode 100644 index 0000000000..769c636340 Binary files /dev/null and b/devtools/client/framework/test/allocations/reloaded.png differ diff --git a/devtools/client/framework/test/browser-telemetry-startup.toml b/devtools/client/framework/test/browser-telemetry-startup.toml new file mode 100644 index 0000000000..12b04456e7 --- /dev/null +++ b/devtools/client/framework/test/browser-telemetry-startup.toml @@ -0,0 +1,14 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "head.js", + "!/devtools/client/shared/test/shared-head.js", + "!/devtools/client/shared/test/telemetry-test-helpers.js", +] + +["browser_toolbox_telemetry_open_event.js"] +# This test suite is dedicated to run a test for the telemetry event logged when +# opening the toolbox for the first time. This test has to be the first test +# running for a given instance of Firefox. A dedicated manifest will ensure a +# new browser instance is created just for this test. diff --git a/devtools/client/framework/test/browser.toml b/devtools/client/framework/test/browser.toml new file mode 100644 index 0000000000..e7d979ebb3 --- /dev/null +++ b/devtools/client/framework/test/browser.toml @@ -0,0 +1,315 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "reload/*", + "browser_toolbox_options_disable_js.html", + "browser_toolbox_options_disable_js_iframe.html", + "browser_toolbox_options_disable_cache.sjs", + "browser_toolbox_options_disable_cache.css.sjs", + "browser_toolbox_window_title_changes_page.html", + "browser_toolbox_window_title_frame_select_page.html", + "code_bundle_late_script.js", + "code_bundle_late_script.js.map", + "code_binary_search.coffee", + "code_binary_search.js", + "code_binary_search.map", + "code_binary_search_absolute.js", + "code_binary_search_absolute.map", + "code_bundle_cross_domain.js", + "code_bundle_cross_domain.js.map", + "code_bundle_no_race.js", + "code_bundle_no_race.js.map", + "code_cross_domain.js", + "code_inline_bundle.js", + "code_inline_original.js", + "code_math.js", + "code_no_race.js", + "doc_backward_forward_navigation.html", + "doc_cached-resource.html", + "doc_cached-resource_iframe.html", + "doc_empty-tab-01.html", + "doc_lazy_tool.html", + "doc_textbox_tool.html", + "head.js", + "helper_disable_cache.js", + "doc_theme.css", + "doc_viewsource.html", + "browser_toolbox_options_enable_serviceworkers_testing.html", + "serviceworker.js", + "sjs_cache_controle_header.sjs", + "test_chrome_page.html", + "!/devtools/client/debugger/test/mochitest/shared-head.js", + "!/devtools/client/inspector/test/shared-head.js", + "!/devtools/client/shared/test/shared-head.js", + "!/devtools/client/shared/test/telemetry-test-helpers.js", + "!/devtools/client/webconsole/test/browser/shared-head.js", +] +prefs = ["security.allow_unsafe_parent_loads=true"] # This is far from ideal. Bug 1565279 covers removing this pref flip. + +["../../../../browser/base/content/test/static/browser_all_files_referenced.js"] +# We want this test to run for mochitest-dt as well, so we include it here +skip-if = [ + "debug", # no point in running on both opt and debug, and will likely intermittently timeout on debug, Bug 1598726 + "asan", + "ccov", +] + +["../../../../browser/base/content/test/static/browser_parsable_css.js"] +# We want this test to run for mochitest-dt as well, so we include it here +skip-if = [ + "debug", # no point in running on both opt and debug, and will likely intermittently timeout on debug + "asan", +] + +["browser_about-devtools-toolbox_load.js"] + +["browser_about-devtools-toolbox_reload.js"] + +["browser_commands_from_url.js"] + +["browser_devtools_api_destroy.js"] + +["browser_dynamic_tool_enabling.js"] + +["browser_front_parentFront.js"] + +["browser_ignore_toolbox_network_requests.js"] + +["browser_keybindings_01.js"] + +["browser_keybindings_02.js"] + +["browser_keybindings_03.js"] + +["browser_menu_api.js"] + +["browser_new_activation_workflow.js"] + +["browser_source_map-01.js"] + +["browser_source_map-absolute.js"] + +["browser_source_map-cross-domain.js"] + +["browser_source_map-init.js"] + +["browser_source_map-inline.js"] + +["browser_source_map-late-script.js"] + +["browser_source_map-no-race.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_source_map-pub-sub.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_source_map-reload.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_tab_commands_factory.js"] + +["browser_tab_descriptor_fission.js"] + +["browser_target_cached-front.js"] + +["browser_target_cached-resource.js"] + +["browser_target_get-front.js"] + +["browser_target_listeners.js"] + +["browser_target_loading.js"] + +["browser_target_parents.js"] +skip-if = ["tsan"] # bug 1807041 + +["browser_target_remote.js"] + +["browser_target_server_compartment.js"] + +["browser_target_support.js"] + +["browser_toolbox_backward_forward_navigation.js"] +skip-if = [ + "os == 'linux' && bits == 64", # Bug 1770314 + "os == 'mac'", # Bug 1770314 +] + +["browser_toolbox_browsertoolbox_host.js"] + +["browser_toolbox_contentpage_contextmenu.js"] + +["browser_toolbox_disable_f12.js"] + +["browser_toolbox_dynamic_registration.js"] + +["browser_toolbox_error_count.js"] + +["browser_toolbox_error_count_reset_on_navigation.js"] + +["browser_toolbox_fission_navigation.js"] +skip-if = [ + "os == 'linux'", # Bug 1742672 +] + +["browser_toolbox_frames_list.js"] + +["browser_toolbox_getpanelwhenready.js"] + +["browser_toolbox_highlight.js"] + +["browser_toolbox_hosts.js"] + +["browser_toolbox_hosts_size.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_toolbox_hosts_telemetry.js"] + +["browser_toolbox_keyboard_navigation.js"] + +["browser_toolbox_keyboard_navigation_notification_box.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_toolbox_meatball.js"] + +["browser_toolbox_options.js"] + +["browser_toolbox_options_disable_buttons.js"] +skip-if = ["a11y_checks"] # Bug 1849028 and 1849179 for causing crashes + +["browser_toolbox_options_disable_cache-01.js"] + +["browser_toolbox_options_disable_cache-02.js"] + +["browser_toolbox_options_disable_cache-03.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_toolbox_options_disable_js.js"] + +["browser_toolbox_options_enable_serviceworkers_testing.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_toolbox_options_frames_button.js"] + +["browser_toolbox_options_multiple_tabs.js"] + +["browser_toolbox_options_panel_toggle.js"] + +["browser_toolbox_popups_debugging.js"] + +["browser_toolbox_races.js"] + +["browser_toolbox_raise.js"] +disabled = "Bug 962258" + +["browser_toolbox_ready.js"] + +["browser_toolbox_remoteness_change.js"] + +["browser_toolbox_screenshot_tool.js"] +skip-if = ["a11y_checks"] # Bugs 1858041 and 1849028 for causing intermittent crashes + +["browser_toolbox_select_event.js"] + +["browser_toolbox_selected_tool_unavailable.js"] + +["browser_toolbox_selectionchanged_event.js"] + +["browser_toolbox_show_toolbox_tool_ready.js"] + +["browser_toolbox_split_console.js"] + +["browser_toolbox_tabsswitch_shortcuts.js"] + +["browser_toolbox_telemetry_activate_splitconsole.js"] + +["browser_toolbox_telemetry_close.js"] + +["browser_toolbox_telemetry_enter.js"] + +["browser_toolbox_telemetry_exit.js"] + +["browser_toolbox_textbox_context_menu.js"] + +["browser_toolbox_theme.js"] + +["browser_toolbox_theme_registration.js"] + +["browser_toolbox_toggle.js"] + +["browser_toolbox_tool_ready.js"] + +["browser_toolbox_tool_remote_reopen.js"] + +["browser_toolbox_toolbar_minimum_width.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled +skip-if = ["os == 'win' && !debug"] # Bug 1709840 + +["browser_toolbox_toolbar_overflow.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_toolbox_toolbar_overflow_button_visibility.js"] + +["browser_toolbox_toolbar_reorder_by_dnd.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_toolbox_toolbar_reorder_by_width.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_toolbox_toolbar_reorder_with_extension.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_toolbox_toolbar_reorder_with_hidden_extension.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_toolbox_tools_per_toolbox_registration.js"] + +["browser_toolbox_view_source_01.js"] + +["browser_toolbox_view_source_02.js"] + +["browser_toolbox_view_source_03.js"] + +["browser_toolbox_view_source_style_editor_fallback.js"] + +["browser_toolbox_watchedByDevTools.js"] + +["browser_toolbox_window_reload_target.js"] + +["browser_toolbox_window_reload_target_force.js"] + +["browser_toolbox_window_shortcuts.js"] + +["browser_toolbox_window_title_changes.js"] + +["browser_toolbox_window_title_frame_select.js"] + +["browser_toolbox_zoom.js"] +skip-if = ["os == 'win' && !debug"] # bug 1683265 + +["browser_toolbox_zoom_popup.js"] + +["browser_webextension_descriptor.js"] + +["browser_webextension_dropdown.js"] +skip-if = ["os == 'linux' && !debug"] # Bug 1714106 +# We want these tests to run for mochitest-dt as well, so we include them here: diff --git a/devtools/client/framework/test/browser_about-devtools-toolbox_load.js b/devtools/client/framework/test/browser_about-devtools-toolbox_load.js new file mode 100644 index 0000000000..abcb59a5d6 --- /dev/null +++ b/devtools/client/framework/test/browser_about-devtools-toolbox_load.js @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test that about:devtools-toolbox shows error an page when opened with invalid + * paramters + */ +add_task(async function () { + // test that error is shown when missing `type` param + let { document, tab } = await openAboutToolbox({ invalid: "invalid" }); + await assertErrorIsShown(document); + await removeTab(tab); + // test that error is shown if `id` is not provided + ({ document, tab } = await openAboutToolbox({ type: "tab" })); + await assertErrorIsShown(document); + await removeTab(tab); + // test that error is shown if `remoteId` refers to an unexisting target + ({ document, tab } = await openAboutToolbox({ + type: "tab", + remoteId: "13371337", + })); + await assertErrorIsShown(document); + await removeTab(tab); + + async function assertErrorIsShown(doc) { + await waitUntil(() => doc.querySelector(".qa-error-page")); + ok(doc.querySelector(".qa-error-page"), "Error page is rendered"); + } +}); diff --git a/devtools/client/framework/test/browser_about-devtools-toolbox_reload.js b/devtools/client/framework/test/browser_about-devtools-toolbox_reload.js new file mode 100644 index 0000000000..f350816b24 --- /dev/null +++ b/devtools/client/framework/test/browser_about-devtools-toolbox_reload.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test that about:devtools-toolbox is reloaded correctly when reusing the same debugger + * client instance. + */ +add_task(async function () { + const devToolsClient = await createLocalClient(); + + info( + "Preload a local DevToolsClient as this-firefox in the remoteClientManager" + ); + const { + remoteClientManager, + } = require("resource://devtools/client/shared/remote-debugging/remote-client-manager.js"); + remoteClientManager.setClient( + "this-firefox", + "this-firefox", + devToolsClient, + {} + ); + registerCleanupFunction(() => { + remoteClientManager.removeAllClients(); + }); + + info("Create a dummy target tab"); + const targetTab = await addTab("data:text/html,somehtml"); + + let onToolboxReady = gDevTools.once("toolbox-ready"); + const { tab } = await openAboutToolbox({ + id: targetTab.linkedBrowser.browserId, + remoteId: "this-firefox-this-firefox", + type: "tab", + }); + await onToolboxReady; + + info("Reload about:devtools-toolbox page"); + onToolboxReady = gDevTools.once("toolbox-ready"); + tab.linkedBrowser.reload(); + await onToolboxReady; + + info("Check if about:devtools-toolbox was reloaded correctly"); + const refreshedDoc = tab.linkedBrowser.contentDocument; + ok( + refreshedDoc.querySelector(".debug-target-info"), + "about:devtools-toolbox header is correctly displayed" + ); + + const onToolboxDestroy = gDevTools.once("toolbox-destroyed"); + await removeTab(tab); + await onToolboxDestroy; + await devToolsClient.close(); + await removeTab(targetTab); +}); + +async function createLocalClient() { + const { + DevToolsClient, + } = require("resource://devtools/client/devtools-client.js"); + const { + DevToolsServer, + } = require("resource://devtools/server/devtools-server.js"); + DevToolsServer.init(); + DevToolsServer.registerAllActors(); + DevToolsServer.allowChromeProcess = true; + + const devToolsClient = new DevToolsClient(DevToolsServer.connectPipe()); + await devToolsClient.connect(); + return devToolsClient; +} diff --git a/devtools/client/framework/test/browser_commands_from_url.js b/devtools/client/framework/test/browser_commands_from_url.js new file mode 100644 index 0000000000..6d1412005c --- /dev/null +++ b/devtools/client/framework/test/browser_commands_from_url.js @@ -0,0 +1,161 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URI = + "data:text/html;charset=utf-8," + "

browser_target-from-url.js

"; + +const { DevToolsLoader } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); +const { + commandsFromURL, +} = require("resource://devtools/client/framework/commands-from-url.js"); + +Services.prefs.setBoolPref("devtools.debugger.remote-enabled", true); +Services.prefs.setBoolPref("devtools.debugger.prompt-connection", false); + +SimpleTest.registerCleanupFunction(() => { + Services.prefs.clearUserPref("devtools.debugger.remote-enabled"); + Services.prefs.clearUserPref("devtools.debugger.prompt-connection"); +}); + +function assertTarget(target, url) { + is(target.url, url); + is(target.isBrowsingContext, true); +} + +add_task(async function () { + const tab = await addTab(TEST_URI); + const browser = tab.linkedBrowser; + let commands, target; + + info("Test invalid type"); + try { + await commandsFromURL(new URL("https://foo?type=x")); + ok(false, "Shouldn't pass"); + } catch (e) { + is(e.message, "commandsFromURL, unsupported type 'x' parameter"); + } + + info("Test tab"); + commands = await commandsFromURL( + new URL("https://foo?type=tab&id=" + browser.browserId) + ); + // Descriptor's getTarget will only work if the TargetCommand watches for the first top target + await commands.targetCommand.startListening(); + + // For now, we can't spawn a commands flagged as 'local tab' via URL query params + // The only way to has isLocalTab is to create the toolbox via showToolboxForTab + // and spawn the command via CommandsFactory.forTab. + is( + commands.descriptorFront.isLocalTab, + false, + "Even if we refer to a local tab, isLocalTab is false (for now)" + ); + + target = await commands.descriptorFront.getTarget(); + + assertTarget(target, TEST_URI); + await commands.destroy(); + + info("Test invalid tab id"); + try { + await commandsFromURL(new URL("https://foo?type=tab&id=10000")); + ok(false, "Shouldn't pass"); + } catch (e) { + is(e.message, "commandsFromURL, tab with browserId '10000' doesn't exist"); + } + + info("Test parent process"); + commands = await commandsFromURL(new URL("https://foo?type=process")); + target = await commands.descriptorFront.getTarget(); + const topWindow = Services.wm.getMostRecentWindow("navigator:browser"); + assertTarget(target, topWindow.location.href); + await commands.destroy(); + + await testRemoteTCP(); + await testRemoteWebSocket(); + + gBrowser.removeCurrentTab(); +}); + +async function setupDevToolsServer(webSocket) { + info("Create a separate loader instance for the DevToolsServer."); + const loader = new DevToolsLoader(); + const { DevToolsServer } = loader.require( + "resource://devtools/server/devtools-server.js" + ); + const { SocketListener } = loader.require( + "resource://devtools/shared/security/socket.js" + ); + + DevToolsServer.init(); + DevToolsServer.registerAllActors(); + DevToolsServer.allowChromeProcess = true; + const socketOptions = { + // Pass -1 to automatically choose an available port + portOrPath: -1, + webSocket, + }; + + const listener = new SocketListener(DevToolsServer, socketOptions); + ok(listener, "Socket listener created"); + await listener.open(); + is(DevToolsServer.listeningSockets, 1, "1 listening socket"); + + return { DevToolsServer, listener }; +} + +function teardownDevToolsServer({ DevToolsServer, listener }) { + info("Close the listener socket"); + listener.close(); + is(DevToolsServer.listeningSockets, 0, "0 listening sockets"); + + info("Destroy the temporary devtools server"); + DevToolsServer.destroy(); +} + +async function testRemoteTCP() { + info("Test remote process via TCP Connection"); + + const server = await setupDevToolsServer(false); + + const { port } = server.listener; + const commands = await commandsFromURL( + new URL("https://foo?type=process&host=127.0.0.1&port=" + port) + ); + const target = await commands.descriptorFront.getTarget(); + const topWindow = Services.wm.getMostRecentWindow("navigator:browser"); + assertTarget(target, topWindow.location.href); + + const settings = commands.client._transport.connectionSettings; + is(settings.host, "127.0.0.1"); + is(parseInt(settings.port, 10), port); + is(settings.webSocket, false); + + await commands.destroy(); + + teardownDevToolsServer(server); +} + +async function testRemoteWebSocket() { + info("Test remote process via WebSocket Connection"); + + const server = await setupDevToolsServer(true); + + const { port } = server.listener; + const commands = await commandsFromURL( + new URL("https://foo?type=process&host=127.0.0.1&port=" + port + "&ws=true") + ); + const target = await commands.descriptorFront.getTarget(); + const topWindow = Services.wm.getMostRecentWindow("navigator:browser"); + assertTarget(target, topWindow.location.href); + + const settings = commands.client._transport.connectionSettings; + is(settings.host, "127.0.0.1"); + is(parseInt(settings.port, 10), port); + is(settings.webSocket, true); + await commands.destroy(); + + teardownDevToolsServer(server); +} diff --git a/devtools/client/framework/test/browser_devtools_api_destroy.js b/devtools/client/framework/test/browser_devtools_api_destroy.js new file mode 100644 index 0000000000..736455df65 --- /dev/null +++ b/devtools/client/framework/test/browser_devtools_api_destroy.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests devtools API + +function test() { + addTab("about:blank").then(runTests); +} + +async function runTests(aTab) { + const toolDefinition = { + id: "testTool", + visibilityswitch: "devtools.testTool.enabled", + isToolSupported: () => true, + url: "about:blank", + label: "someLabel", + build(iframeWindow, toolbox) { + return new Promise(resolve => { + executeSoon(() => { + resolve({ + target: toolbox.target, + toolbox, + isReady: true, + destroy() {}, + }); + }); + }); + }, + }; + + gDevTools.registerTool(toolDefinition); + + const collectedEvents = []; + + gDevTools + .showToolboxForTab(aTab, { toolId: toolDefinition.id }) + .then(function (toolbox) { + const panel = toolbox.getPanel(toolDefinition.id); + ok(panel, "Tool open"); + + gDevTools.once("toolbox-destroy", (toolbox, iframe) => { + collectedEvents.push("toolbox-destroy"); + }); + + gDevTools.once(toolDefinition.id + "-destroy", (toolbox, iframe) => { + collectedEvents.push("gDevTools-" + toolDefinition.id + "-destroy"); + }); + + toolbox.once("destroy", () => { + collectedEvents.push("destroy"); + }); + + toolbox.once(toolDefinition.id + "-destroy", () => { + collectedEvents.push("toolbox-" + toolDefinition.id + "-destroy"); + }); + + toolbox.destroy().then(function () { + is( + collectedEvents.join(":"), + "toolbox-destroy:destroy:gDevTools-testTool-destroy:toolbox-testTool-destroy", + "Found the right amount of collected events." + ); + + gDevTools.unregisterTool(toolDefinition.id); + gBrowser.removeCurrentTab(); + + executeSoon(function () { + finish(); + }); + }); + }); +} diff --git a/devtools/client/framework/test/browser_dynamic_tool_enabling.js b/devtools/client/framework/test/browser_dynamic_tool_enabling.js new file mode 100644 index 0000000000..56313607cf --- /dev/null +++ b/devtools/client/framework/test/browser_dynamic_tool_enabling.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that toggling prefs immediately (de)activates the relevant menuitem + +var gItemsToTest = { + menu_browserToolbox: [ + "devtools.chrome.enabled", + "devtools.debugger.remote-enabled", + ], +}; + +function expectedAttributeValueFromPrefs(prefs) { + return prefs.every(pref => Services.prefs.getBoolPref(pref)) ? "" : "true"; +} + +function checkItem(el, prefs) { + const expectedValue = expectedAttributeValueFromPrefs(prefs); + is( + el.getAttribute("disabled"), + expectedValue, + "disabled attribute should match current pref state" + ); + is( + el.getAttribute("hidden"), + expectedValue, + "hidden attribute should match current pref state" + ); +} + +function test() { + for (const k in gItemsToTest) { + const el = document.getElementById(k); + const prefs = gItemsToTest[k]; + checkItem(el, prefs); + for (const pref of prefs) { + Services.prefs.setBoolPref(pref, !Services.prefs.getBoolPref(pref)); + checkItem(el, prefs); + Services.prefs.setBoolPref(pref, !Services.prefs.getBoolPref(pref)); + checkItem(el, prefs); + } + } + finish(); +} diff --git a/devtools/client/framework/test/browser_front_parentFront.js b/devtools/client/framework/test/browser_front_parentFront.js new file mode 100644 index 0000000000..106632dd45 --- /dev/null +++ b/devtools/client/framework/test/browser_front_parentFront.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the Front's parentFront attribute returns the correct parent front. + +const TEST_URL = `data:text/html;charset=utf-8,
`; + +add_task(async function () { + const tab = await addTab(TEST_URL); + const target = await createAndAttachTargetForTab(tab); + + const inspectorFront = await target.getFront("inspector"); + const walker = inspectorFront.walker; + const pageStyleFront = await inspectorFront.getPageStyle(); + const nodeFront = await walker.querySelector(walker.rootNode, "#test"); + + is( + inspectorFront.parentFront, + target, + "Got the correct parentFront from the InspectorFront." + ); + is( + walker.parentFront, + inspectorFront, + "Got the correct parentFront from the WalkerFront." + ); + is( + pageStyleFront.parentFront, + inspectorFront, + "Got the correct parentFront from the PageStyleFront." + ); + is( + nodeFront.parentFront, + walker, + "Got the correct parentFront from the NodeFront." + ); +}); diff --git a/devtools/client/framework/test/browser_ignore_toolbox_network_requests.js b/devtools/client/framework/test/browser_ignore_toolbox_network_requests.js new file mode 100644 index 0000000000..65daa4d78d --- /dev/null +++ b/devtools/client/framework/test/browser_ignore_toolbox_network_requests.js @@ -0,0 +1,30 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that network requests originating from the toolbox don't get recorded in +// the network panel. + +add_task(async function () { + let tab = await addTab(URL_ROOT + "doc_viewsource.html"); + let toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "styleeditor", + }); + let panel = toolbox.getPanel("styleeditor"); + + is(panel.UI.editors.length, 1, "correct number of editors opened"); + + const monitor = await toolbox.selectTool("netmonitor"); + const { store } = monitor.panelWin; + + is( + store.getState().requests.requests.length, + 0, + "No network requests appear in the network panel" + ); + + await toolbox.destroy(); + tab = toolbox = panel = null; + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/client/framework/test/browser_keybindings_01.js b/devtools/client/framework/test/browser_keybindings_01.js new file mode 100644 index 0000000000..968c3a3d3d --- /dev/null +++ b/devtools/client/framework/test/browser_keybindings_01.js @@ -0,0 +1,107 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(3); + +// Tests that the keybindings for opening and closing the inspector work as expected +// Can probably make this a shared test that tests all of the tools global keybindings +const TEST_URL = + "data:text/html,Test for the " + + "highlighter keybindings" + + "

Keybindings!

"; + +const { + gDevToolsBrowser, +} = require("resource://devtools/client/framework/devtools-browser.js"); + +const isMac = AppConstants.platform == "macosx"; + +const allKeys = []; +function buildDevtoolsKeysetMap(keyset) { + // Fetches all the keyboard shortcuts which were defined by lazyGetter 'KeyShortcuts' in + // devtools-startup.js and added to the DOM by 'hookKeyShortcuts' + [...keyset.querySelectorAll("key")].forEach(key => { + if (!key.getAttribute("key")) { + return; + } + + const modifiers = key.getAttribute("modifiers"); + allKeys.push({ + toolId: key.id.split("_")[1], + key: key.getAttribute("key"), + modifiers, + modifierOpt: { + shiftKey: modifiers.match("shift"), + ctrlKey: modifiers.match("ctrl"), + altKey: modifiers.match("alt"), + metaKey: modifiers.match("meta"), + accelKey: modifiers.match("accel"), + }, + synthesizeKey() { + EventUtils.synthesizeKey(this.key, this.modifierOpt); + }, + }); + }); +} + +function setupKeyBindingsTest() { + for (const win of gDevToolsBrowser._trackedBrowserWindows) { + buildDevtoolsKeysetMap(win.document.getElementById("devtoolsKeyset")); + } +} + +add_task(async function () { + await addTab(TEST_URL); + await new Promise(done => waitForFocus(done)); + + setupKeyBindingsTest(); + + const tests = [ + { id: "inspector", toolId: "inspector" }, + { id: "webconsole", toolId: "webconsole" }, + { id: "netmonitor", toolId: "netmonitor" }, + { id: "jsdebugger", toolId: "jsdebugger" }, + ]; + + // There are two possible keyboard shortcuts to open the inspector on macOS + if (isMac) { + tests.push({ id: "inspectorMac", toolId: "inspector" }); + } + + // Toolbox reference will be set by first tool to open. + let toolbox; + + for (const test of tests) { + const onToolboxReady = gDevTools.once("toolbox-ready"); + const onSelectTool = gDevTools.once("select-tool-command"); + + info(`Run the keyboard shortcut for ${test.id}`); + const key = allKeys.filter(({ toolId }) => toolId === test.id)[0]; + key.synthesizeKey(); + + if (!toolbox) { + toolbox = await onToolboxReady; + } + + if (test.toolId === "inspector") { + const onPickerStart = toolbox.nodePicker.once("picker-started"); + await onPickerStart; + ok(true, "picker-started event received, highlighter started"); + + info( + `Run the keyboard shortcut for ${test.id} again to stop the node picker` + ); + const onPickerStop = toolbox.nodePicker.once("picker-stopped"); + key.synthesizeKey(); + await onPickerStop; + ok(true, "picker-stopped event received, highlighter stopped"); + } + + await onSelectTool; + is(toolbox.currentToolId, test.toolId, `${test.toolId} should be selected`); + } + + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/client/framework/test/browser_keybindings_02.js b/devtools/client/framework/test/browser_keybindings_02.js new file mode 100644 index 0000000000..3be8309edf --- /dev/null +++ b/devtools/client/framework/test/browser_keybindings_02.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the toolbox keybindings still work after the host is changed. + +const URL = "data:text/html;charset=utf8,test page"; + +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); + +function getZoomValue() { + return parseFloat(Services.prefs.getCharPref("devtools.toolbox.zoomValue")); +} + +add_task(async function () { + info("Create a test tab and open the toolbox"); + const tab = await addTab(URL); + const toolbox = await gDevTools.showToolboxForTab(tab, "webconsole"); + + const { RIGHT, BOTTOM } = Toolbox.HostType; + for (const type of [RIGHT, BOTTOM, RIGHT]) { + info("Switch to host type " + type); + await toolbox.switchHost(type); + + info("Try to use the toolbox shortcuts"); + await checkKeyBindings(toolbox); + } + + Services.prefs.clearUserPref("devtools.toolbox.zoomValue"); + Services.prefs.setCharPref("devtools.toolbox.host", BOTTOM); + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); + +function zoomWithKey(toolbox, key) { + const shortcut = L10N.getStr(key); + if (!shortcut) { + info("Key was empty, skipping zoomWithKey"); + return; + } + info("Zooming with key: " + key); + const currentZoom = getZoomValue(); + synthesizeKeyShortcut(shortcut, toolbox.win); + isnot( + getZoomValue(), + currentZoom, + "The zoom level was changed in the toolbox" + ); +} + +function checkKeyBindings(toolbox) { + zoomWithKey(toolbox, "toolbox.zoomIn.key"); + zoomWithKey(toolbox, "toolbox.zoomIn2.key"); + + zoomWithKey(toolbox, "toolbox.zoomReset.key"); + + zoomWithKey(toolbox, "toolbox.zoomOut.key"); + zoomWithKey(toolbox, "toolbox.zoomOut2.key"); + + zoomWithKey(toolbox, "toolbox.zoomReset2.key"); +} diff --git a/devtools/client/framework/test/browser_keybindings_03.js b/devtools/client/framework/test/browser_keybindings_03.js new file mode 100644 index 0000000000..71ce51c1e0 --- /dev/null +++ b/devtools/client/framework/test/browser_keybindings_03.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the toolbox 'switch to previous host' feature works. +// Pressing ctrl/cmd+shift+d should switch to the last used host. + +const URL = "data:text/html;charset=utf8,test page for toolbox switching"; + +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); + +add_task(async function () { + info("Create a test tab and open the toolbox"); + const tab = await addTab(URL); + const toolbox = await gDevTools.showToolboxForTab(tab, "webconsole"); + + const shortcut = L10N.getStr("toolbox.toggleHost.key"); + + const { RIGHT, BOTTOM, WINDOW } = Toolbox.HostType; + checkHostType(toolbox, BOTTOM, RIGHT); + + info("Switching from bottom to right"); + let onHostChanged = toolbox.once("host-changed"); + synthesizeKeyShortcut(shortcut, toolbox.win); + await onHostChanged; + checkHostType(toolbox, RIGHT, BOTTOM); + + info("Switching from right to bottom"); + onHostChanged = toolbox.once("host-changed"); + synthesizeKeyShortcut(shortcut, toolbox.win); + await onHostChanged; + checkHostType(toolbox, BOTTOM, RIGHT); + + info("Switching to window"); + await toolbox.switchHost(WINDOW); + checkHostType(toolbox, WINDOW, BOTTOM); + + info("Switching from window to bottom"); + onHostChanged = toolbox.once("host-changed"); + synthesizeKeyShortcut(shortcut, toolbox.win); + await onHostChanged; + checkHostType(toolbox, BOTTOM, WINDOW); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/client/framework/test/browser_menu_api.js b/devtools/client/framework/test/browser_menu_api.js new file mode 100644 index 0000000000..daa69cf8dd --- /dev/null +++ b/devtools/client/framework/test/browser_menu_api.js @@ -0,0 +1,239 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the Menu API works + +const URL = "data:text/html;charset=utf8,test page for menu api"; +const Menu = require("resource://devtools/client/framework/menu.js"); +const MenuItem = require("resource://devtools/client/framework/menu-item.js"); + +add_task(async function () { + info("Create a test tab and open the toolbox"); + const tab = await addTab(URL); + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "webconsole", + }); + + // This test will involve localized strings, make sure the necessary FTL file is + // available in the toolbox top window. + toolbox.topWindow.MozXULElement.insertFTLIfNeeded( + "toolkit/global/textActions.ftl" + ); + + loadFTL(toolbox, "toolkit/global/textActions.ftl"); + + await testMenuItems(); + await testMenuPopup(toolbox); + await testSubmenu(toolbox); +}); + +function testMenuItems() { + const menu = new Menu(); + const menuItem1 = new MenuItem(); + const menuItem2 = new MenuItem(); + + menu.append(menuItem1); + menu.append(menuItem2); + + is(menu.items.length, 2, "Correct number of 'items'"); + is(menu.items[0], menuItem1, "Correct reference to MenuItem"); + is(menu.items[1], menuItem2, "Correct reference to MenuItem"); +} + +async function testMenuPopup(toolbox) { + let clickFired = false; + + const menu = new Menu({ + id: "menu-popup", + }); + menu.append(new MenuItem({ type: "separator" })); + + const MENU_ITEMS = [ + new MenuItem({ + id: "menu-item-1", + label: "Normal Item", + click: () => { + info("Click callback has fired for menu item"); + clickFired = true; + }, + }), + new MenuItem({ + label: "Checked Item", + type: "checkbox", + checked: true, + }), + new MenuItem({ + label: "Radio Item", + type: "radio", + }), + new MenuItem({ + label: "Disabled Item", + disabled: true, + }), + new MenuItem({ + l10nID: "text-action-undo", + }), + ]; + + for (const item of MENU_ITEMS) { + menu.append(item); + } + + // Append an invisible MenuItem, which shouldn't show up in the DOM + menu.append( + new MenuItem({ + label: "Invisible", + visible: false, + }) + ); + + menu.popup(0, 0, toolbox.doc); + const popup = toolbox.topDoc.querySelector("#menu-popup"); + ok(popup, "A popup is in the DOM"); + + const menuSeparators = toolbox.topDoc.querySelectorAll( + "#menu-popup > menuseparator" + ); + is(menuSeparators.length, 1, "A separator is in the menu"); + + const menuItems = toolbox.topDoc.querySelectorAll("#menu-popup > menuitem"); + is(menuItems.length, MENU_ITEMS.length, "Correct number of menuitems"); + + is(menuItems[0].id, MENU_ITEMS[0].id, "Correct id for menuitem"); + is(menuItems[0].getAttribute("label"), MENU_ITEMS[0].label, "Correct label"); + + is(menuItems[1].getAttribute("label"), MENU_ITEMS[1].label, "Correct label"); + is(menuItems[1].getAttribute("type"), "checkbox", "Correct type attr"); + is(menuItems[1].getAttribute("checked"), "true", "Has checked attr"); + + is(menuItems[2].getAttribute("label"), MENU_ITEMS[2].label, "Correct label"); + is(menuItems[2].getAttribute("type"), "radio", "Correct type attr"); + ok(!menuItems[2].hasAttribute("checked"), "Doesn't have checked attr"); + + is(menuItems[3].getAttribute("label"), MENU_ITEMS[3].label, "Correct label"); + is(menuItems[3].getAttribute("disabled"), "true", "disabled attr menuitem"); + + is( + menuItems[4].getAttribute("data-l10n-id"), + MENU_ITEMS[4].l10nID, + "Correct localization attribute" + ); + + await once(menu, "open"); + const closed = once(menu, "close"); + popup.activateItem(menuItems[0]); + await closed; + ok(clickFired, "Click has fired"); + + ok( + !toolbox.topDoc.querySelector("#menu-popup"), + "Popup removed from the DOM" + ); +} + +async function testSubmenu(toolbox) { + let clickFired = false; + const menu = new Menu({ + id: "menu-popup", + }); + const submenu = new Menu({ + id: "submenu-popup", + }); + submenu.append( + new MenuItem({ + label: "Submenu item", + click: () => { + info("Click callback has fired for submenu item"); + clickFired = true; + }, + }) + ); + menu.append( + new MenuItem({ + l10nID: "text-action-copy", + submenu, + }) + ); + menu.append( + new MenuItem({ + label: "Submenu parent with attributes", + id: "submenu-parent-with-attrs", + submenu, + accesskey: "A", + disabled: true, + }) + ); + + menu.popup(0, 0, toolbox.doc); + const popup = toolbox.topDoc.querySelector("#menu-popup"); + ok(popup, "A popup is in the DOM"); + is( + toolbox.topDoc.querySelectorAll("#menu-popup > menuitem").length, + 0, + "No menuitem children" + ); + + const menus = toolbox.topDoc.querySelectorAll("#menu-popup > menu"); + is(menus.length, 2, "Correct number of menus"); + ok( + !menus[0].hasAttribute("label"), + "No label: should be set by localization" + ); + ok(!menus[0].hasAttribute("disabled"), "Correct disabled state"); + is( + menus[0].getAttribute("data-l10n-id"), + "text-action-copy", + "Correct localization attribute" + ); + + is(menus[1].getAttribute("accesskey"), "A", "Correct accesskey"); + ok(menus[1].hasAttribute("disabled"), "Correct disabled state"); + is(menus[1].id, "submenu-parent-with-attrs", "Correct id"); + + const subMenuItems = menus[0].querySelectorAll("menupopup > menuitem"); + is(subMenuItems.length, 1, "Correct number of submenu items"); + is(subMenuItems[0].getAttribute("label"), "Submenu item", "Correct label"); + + await once(menu, "open"); + const closed = once(menu, "close"); + + // The following section tests keyboard navigation of the context menus. + // This doesn't work on macOS when native context menus are enabled. + if (Services.prefs.getBoolPref("widget.macos.native-context-menus", false)) { + info("Using openMenu semantics because of macOS native context menus."); + let shown = once(menus[0], "popupshown"); + menus[0].openMenu(true); + await shown; + + const hidden = once(menus[0], "popuphidden"); + menus[0].openMenu(false); + await hidden; + + shown = once(menus[0], "popupshown"); + menus[0].openMenu(true); + await shown; + } else { + info("Using keyboard navigation to open, close, and reopen the submenu"); + let shown = once(menus[0], "popupshown"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_ArrowRight"); + await shown; + + const hidden = once(menus[0], "popuphidden"); + EventUtils.synthesizeKey("KEY_ArrowLeft"); + await hidden; + + shown = once(menus[0], "popupshown"); + EventUtils.synthesizeKey("KEY_ArrowRight"); + await shown; + } + + info("Clicking the submenu item"); + const subMenu = subMenuItems[0].closest("menupopup"); + subMenu.activateItem(subMenuItems[0]); + + await closed; + ok(clickFired, "Click has fired"); +} diff --git a/devtools/client/framework/test/browser_new_activation_workflow.js b/devtools/client/framework/test/browser_new_activation_workflow.js new file mode 100644 index 0000000000..583e6d7ca8 --- /dev/null +++ b/devtools/client/framework/test/browser_new_activation_workflow.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests devtools API + +var toolbox; + +function test() { + addTab("about:blank").then(async function () { + loadWebConsole().then(function () { + console.log("loaded"); + }); + }); +} + +function loadWebConsole() { + ok(gDevTools, "gDevTools exists"); + const tab = gBrowser.selectedTab; + return gDevTools + .showToolboxForTab(tab, { toolId: "webconsole" }) + .then(function (aToolbox) { + toolbox = aToolbox; + checkToolLoading(); + }); +} + +function checkToolLoading() { + is(toolbox.currentToolId, "webconsole", "The web console is selected"); + ok(toolbox.isReady, "toolbox is ready"); + + selectAndCheckById("jsdebugger").then(function () { + selectAndCheckById("styleeditor").then(function () { + testToggle(); + }); + }); +} + +function selectAndCheckById(id) { + return toolbox.selectTool(id).then(function () { + const tab = toolbox.doc.getElementById("toolbox-tab-" + id); + is( + tab.classList.contains("selected"), + true, + "The " + id + " tab is selected" + ); + is( + tab.getAttribute("aria-pressed"), + "true", + "The " + id + " tab is pressed" + ); + }); +} + +function testToggle() { + toolbox.once("destroyed", async () => { + // Cannot reuse a target after it's destroyed. + gDevTools + .showToolboxForTab(gBrowser.selectedTab, { toolId: "styleeditor" }) + .then(function (aToolbox) { + toolbox = aToolbox; + is( + toolbox.currentToolId, + "styleeditor", + "The style editor is selected" + ); + finishUp(); + }); + }); + + toolbox.destroy(); +} + +function finishUp() { + toolbox.destroy().then(function () { + toolbox = null; + gBrowser.removeCurrentTab(); + finish(); + }); +} diff --git a/devtools/client/framework/test/browser_source_map-01.js b/devtools/client/framework/test/browser_source_map-01.js new file mode 100644 index 0000000000..373eaebf77 --- /dev/null +++ b/devtools/client/framework/test/browser_source_map-01.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests the SourceMapService updates generated sources when source maps + * are subsequently found. Also checks when no column is provided, and + * when tagging an already source mapped location initially. + */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/this\.worker is null/); +PromiseTestUtils.allowMatchingRejectionsGlobally(/Component not initialized/); + +// Empty page +const PAGE_URL = `${URL_ROOT_SSL}doc_empty-tab-01.html`; +const JS_URL = `${URL_ROOT_SSL}code_binary_search.js`; +const COFFEE_URL = `${URL_ROOT_SSL}code_binary_search.coffee`; + +add_task(async function () { + const toolbox = await openNewTabAndToolbox(PAGE_URL, "jsdebugger"); + const service = toolbox.sourceMapURLService; + + // Inject JS script + const sourceSeen = waitForSourceLoad(toolbox, JS_URL); + await createScript(JS_URL); + await sourceSeen; + + const loc1 = { url: JS_URL, line: 6 }; + const newLoc1 = await new Promise(r => + service.subscribeByURL(loc1.url, loc1.line, 4, r) + ); + checkLoc1(loc1, newLoc1); + + const loc2 = { url: JS_URL, line: 8, column: 3 }; + const newLoc2 = await new Promise(r => + service.subscribeByURL(loc2.url, loc2.line, loc2.column, r) + ); + checkLoc2(loc2, newLoc2); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); + finish(); +}); + +function checkLoc1(oldLoc, newLoc) { + is(oldLoc.line, 6, "Correct line for JS:6"); + is(oldLoc.column, undefined, "Correct column for JS:6"); + is(oldLoc.url, JS_URL, "Correct url for JS:6"); + is(newLoc.line, 4, "Correct line for JS:6 -> COFFEE"); + is( + newLoc.column, + 2, + "Correct column for JS:6 -> COFFEE -- handles falsy column entries" + ); + is(newLoc.url, COFFEE_URL, "Correct url for JS:6 -> COFFEE"); +} + +function checkLoc2(oldLoc, newLoc) { + is(oldLoc.line, 8, "Correct line for JS:8:3"); + is(oldLoc.column, 3, "Correct column for JS:8:3"); + is(oldLoc.url, JS_URL, "Correct url for JS:8:3"); + is(newLoc.line, 6, "Correct line for JS:8:3 -> COFFEE"); + is(newLoc.column, 10, "Correct column for JS:8:3 -> COFFEE"); + is(newLoc.url, COFFEE_URL, "Correct url for JS:8:3 -> COFFEE"); +} diff --git a/devtools/client/framework/test/browser_source_map-absolute.js b/devtools/client/framework/test/browser_source_map-absolute.js new file mode 100644 index 0000000000..206cbde944 --- /dev/null +++ b/devtools/client/framework/test/browser_source_map-absolute.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that an absolute sourceRoot works. + +"use strict"; + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/this\.worker is null/); + +// Empty page +const PAGE_URL = `${URL_ROOT_SSL}doc_empty-tab-01.html`; +const JS_URL = `${URL_ROOT_SSL}code_binary_search_absolute.js`; +const ORIGINAL_URL = `${URL_ROOT_SSL}code_binary_search.coffee`; + +add_task(async function () { + const toolbox = await openNewTabAndToolbox(PAGE_URL, "jsdebugger"); + const service = toolbox.sourceMapURLService; + + // Inject JS script + const sourceSeen = waitForSourceLoad(toolbox, JS_URL); + await createScript(JS_URL); + await sourceSeen; + + info(`checking original location for ${JS_URL}:6`); + const newLoc = await new Promise(r => + service.subscribeByURL(JS_URL, 6, 4, r) + ); + + is(newLoc.url, ORIGINAL_URL, "check mapped URL"); + is(newLoc.line, 4, "check mapped line number"); +}); diff --git a/devtools/client/framework/test/browser_source_map-cross-domain.js b/devtools/client/framework/test/browser_source_map-cross-domain.js new file mode 100644 index 0000000000..77fb381260 --- /dev/null +++ b/devtools/client/framework/test/browser_source_map-cross-domain.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that the source map service can fetch a source map from a +// different domain. + +"use strict"; + +const JS_URL = URL_ROOT + "code_bundle_cross_domain.js"; + +const PAGE_URL = `data:text/html, + + + + + + Empty test page to test cross domain source map + + + + + + +`; + +const ORIGINAL_URL = "webpack:///code_cross_domain.js"; + +const GENERATED_LINE = 82; +const ORIGINAL_LINE = 12; + +add_task(async function () { + const toolbox = await openNewTabAndToolbox(PAGE_URL, "webconsole"); + const service = toolbox.sourceMapURLService; + + info(`checking original location for ${JS_URL}:${GENERATED_LINE}`); + const newLoc = await new Promise(r => + service.subscribeByURL(JS_URL, GENERATED_LINE, undefined, r) + ); + is(newLoc.url, ORIGINAL_URL, "check mapped URL"); + is(newLoc.line, ORIGINAL_LINE, "check mapped line number"); +}); diff --git a/devtools/client/framework/test/browser_source_map-init.js b/devtools/client/framework/test/browser_source_map-init.js new file mode 100644 index 0000000000..60a3e4672a --- /dev/null +++ b/devtools/client/framework/test/browser_source_map-init.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that the source map service initializes properly when source +// actors have already been created. Regression test for bug 1391768. + +"use strict"; + +const JS_URL = URL_ROOT_SSL + "code_bundle_no_race.js"; + +const PAGE_URL = `data:text/html, + + + + + + Empty test page to test race case + + + + + + +`; + +const ORIGINAL_URL = "webpack:///code_no_race.js"; + +const GENERATED_LINE = 84; +const ORIGINAL_LINE = 11; + +add_task(async function () { + // Opening the debugger causes the source actors to be created. + const toolbox = await openNewTabAndToolbox(PAGE_URL, "jsdebugger"); + // In bug 1391768, when the sourceMapURLService was created, it was + // ignoring any source actors that already existed, leading to + // source-mapping failures for those. + const service = toolbox.sourceMapURLService; + + info(`checking original location for ${JS_URL}:${GENERATED_LINE}`); + const newLoc = await new Promise(r => + service.subscribeByURL(JS_URL, GENERATED_LINE, undefined, r) + ); + is(newLoc.url, ORIGINAL_URL, "check mapped URL"); + is(newLoc.line, ORIGINAL_LINE, "check mapped line number"); + + // See Bug 1637793 and Bug 1621337. + // Ideally the debugger should only resolve when the worker targets have been + // retrieved, which should be fixed by Bug 1621337 or a followup. + info("Wait for all pending requests to settle on the DevToolsClient"); + await toolbox.commands.client.waitForRequestsToSettle(); +}); diff --git a/devtools/client/framework/test/browser_source_map-inline.js b/devtools/client/framework/test/browser_source_map-inline.js new file mode 100644 index 0000000000..4e5f8c7fff --- /dev/null +++ b/devtools/client/framework/test/browser_source_map-inline.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that inline source maps work. + +"use strict"; + +// There are shutdown issues for which multiple rejections are left uncaught. +// See bug 1018184 for resolving these issues. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/this\.worker is null/); +PromiseTestUtils.allowMatchingRejectionsGlobally(/Component not initialized/); + +const TEST_ROOT = "https://example.com/browser/devtools/client/framework/test/"; +// Empty page +const PAGE_URL = `${TEST_ROOT}doc_empty-tab-01.html`; +const JS_URL = `${TEST_ROOT}code_inline_bundle.js`; +const ORIGINAL_URL = "webpack:///code_inline_original.js"; + +add_task(async function () { + const toolbox = await openNewTabAndToolbox(PAGE_URL, "jsdebugger"); + const service = toolbox.sourceMapURLService; + + // Inject JS script + const sourceSeen = waitForSourceLoad(toolbox, JS_URL); + await createScript(JS_URL); + await sourceSeen; + + info(`checking original location for ${JS_URL}:84`); + const newLoc = await new Promise(r => + service.subscribeByURL(JS_URL, 84, undefined, r) + ); + + is(newLoc.url, ORIGINAL_URL, "check mapped URL"); + is(newLoc.line, 11, "check mapped line number"); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); + finish(); +}); diff --git a/devtools/client/framework/test/browser_source_map-late-script.js b/devtools/client/framework/test/browser_source_map-late-script.js new file mode 100644 index 0000000000..f11d530db1 --- /dev/null +++ b/devtools/client/framework/test/browser_source_map-late-script.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that you can subscribe to notifications on a source before it has loaded. + +"use strict"; + +const PAGE_URL = `${URL_ROOT_SSL}doc_empty-tab-01.html`; +const JS_URL = URL_ROOT_SSL + "code_bundle_late_script.js"; + +const ORIGINAL_URL = "webpack:///code_late_script.js"; + +const GENERATED_LINE = 107; +const ORIGINAL_LINE = 11; + +add_task(async function () { + // Start with the empty page, then navigate, so that we can properly + // listen for new sources arriving. + const toolbox = await openNewTabAndToolbox(PAGE_URL, "webconsole"); + const service = toolbox.sourceMapURLService; + + const scriptMapped = new Promise(resolve => { + let count = 0; + service.subscribeByURL( + JS_URL, + GENERATED_LINE, + undefined, + originalLocation => { + if (count === 0) { + resolve(originalLocation); + } + count += 1; + + return () => {}; + } + ); + }); + + // Inject JS script + const sourceSeen = waitForSourceLoad(toolbox, JS_URL); + await createScript(JS_URL); + await sourceSeen; + + // Ensure that the URL service fired an event about the location loading. + const { url, line } = await scriptMapped; + is(url, ORIGINAL_URL, "check mapped URL"); + is(line, ORIGINAL_LINE, "check mapped line number"); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); + finish(); +}); diff --git a/devtools/client/framework/test/browser_source_map-no-race.js b/devtools/client/framework/test/browser_source_map-no-race.js new file mode 100644 index 0000000000..23751f7bc8 --- /dev/null +++ b/devtools/client/framework/test/browser_source_map-no-race.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that the source map service doesn't race against source +// reporting. + +"use strict"; + +const JS_URL = URL_ROOT + "code_bundle_no_race.js"; + +const PAGE_URL = `data:text/html, + + + + + + Empty test page to test race case + + + + + + +`; + +const ORIGINAL_URL = "webpack:///code_no_race.js"; + +const GENERATED_LINE = 84; +const ORIGINAL_LINE = 11; + +add_task(async function () { + // Start with the empty page, then navigate, so that we can properly + // listen for new sources arriving. + const toolbox = await openNewTabAndToolbox(PAGE_URL, "webconsole"); + const service = toolbox.sourceMapURLService; + + info(`checking original location for ${JS_URL}:${GENERATED_LINE}`); + const newLoc = await new Promise(r => + service.subscribeByURL(JS_URL, GENERATED_LINE, undefined, r) + ); + is(newLoc.url, ORIGINAL_URL, "check mapped URL"); + is(newLoc.line, ORIGINAL_LINE, "check mapped line number"); +}); diff --git a/devtools/client/framework/test/browser_source_map-pub-sub.js b/devtools/client/framework/test/browser_source_map-pub-sub.js new file mode 100644 index 0000000000..c7f69c91c7 --- /dev/null +++ b/devtools/client/framework/test/browser_source_map-pub-sub.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that the source map service subscribe mechanism work as expected. + +"use strict"; + +const JS_URL = URL_ROOT + "code_bundle_no_race.js"; + +const PAGE_URL = `data:text/html, + + + + + + + + +`; + +const ORIGINAL_URL = "webpack:///code_no_race.js"; + +const SOURCE_MAP_PREF = "devtools.source-map.client-service.enabled"; + +const GENERATED_LINE = 84; +const ORIGINAL_LINE = 11; + +add_task(async function () { + // Push a pref env so any changes will be reset at the end of the test. + await SpecialPowers.pushPrefEnv({}); + + // Opening the debugger causes the source actors to be created. + const toolbox = await openNewTabAndToolbox(PAGE_URL, "jsdebugger"); + const service = toolbox.sourceMapURLService; + + const cbCalls = []; + const cb = originalLocation => cbCalls.push(originalLocation); + const expectedArg = { url: ORIGINAL_URL, line: ORIGINAL_LINE, column: 0 }; + + // Wait for the sources to fully populate so that waitForSubscriptionsToSettle + // can be guaranteed that all actions have been queued. + await service._ensureAllSourcesPopulated(); + + const unsubscribe1 = service.subscribeByURL(JS_URL, GENERATED_LINE, 1, cb); + + // Wait for the query to finish and populate so that all of the later + // logic with this position will run synchronously, and the subscribe has run. + for (const map of service._mapsById.values()) { + for (const query of map.queries.values()) { + await query.action; + } + } + + is( + cbCalls.length, + 1, + "The callback function is called directly when subscribing" + ); + Assert.deepEqual( + cbCalls[0], + expectedArg, + "callback called with expected arguments" + ); + + const unsubscribe2 = service.subscribeByURL(JS_URL, GENERATED_LINE, 1, cb); + is(cbCalls.length, 2, "Subscribing to the same location twice works"); + Assert.deepEqual( + cbCalls[1], + expectedArg, + "callback called with expected arguments" + ); + + info("Manually call the dispatcher to ensure subscribers are called"); + Services.prefs.setBoolPref(SOURCE_MAP_PREF, false); + is(cbCalls.length, 4, "both subscribers were called"); + Assert.deepEqual(cbCalls[2], null, "callback called with expected arguments"); + Assert.deepEqual( + cbCalls[2], + cbCalls[3], + "callbacks were passed the same arguments" + ); + + info("Check unsubscribe functions"); + unsubscribe1(); + Services.prefs.setBoolPref(SOURCE_MAP_PREF, true); + is(cbCalls.length, 5, "Only remainer subscriber callback was called"); + Assert.deepEqual( + cbCalls[4], + expectedArg, + "callback called with expected arguments" + ); + + unsubscribe2(); + Services.prefs.setBoolPref(SOURCE_MAP_PREF, false); + Services.prefs.setBoolPref(SOURCE_MAP_PREF, true); + is(cbCalls.length, 5, "No callbacks were called"); +}); diff --git a/devtools/client/framework/test/browser_source_map-reload.js b/devtools/client/framework/test/browser_source_map-reload.js new file mode 100644 index 0000000000..13902062a7 --- /dev/null +++ b/devtools/client/framework/test/browser_source_map-reload.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that reloading re-reads the source maps. + +"use strict"; +const INITIAL_URL = + "data:text/html,html>Empty test page 1"; +const ORIGINAL_URL_1 = "webpack://code-reload/v1/code_reload_1.js"; +const ORIGINAL_URL_2 = "webpack://code-reload/v2/code_reload_2.js"; + +const GENERATED_LINE = 13; +const ORIGINAL_LINE = 7; + +const testServer = createVersionizedHttpTestServer("reload"); + +const PAGE_URL = testServer.urlFor("doc_reload.html"); +const JS_URL = testServer.urlFor("code_bundle_reload.js"); + +add_task(async function () { + // Start with the empty page, then navigate, so that we can properly + // listen for new sources arriving. + const toolbox = await openNewTabAndToolbox(INITIAL_URL, "webconsole"); + const service = toolbox.sourceMapURLService; + + let sourceSeen = waitForSourceLoad(toolbox, JS_URL); + await navigateTo(PAGE_URL); + await sourceSeen; + + info(`checking original location for ${JS_URL}:${GENERATED_LINE}`); + let newLoc = await new Promise(r => + service.subscribeByURL(JS_URL, GENERATED_LINE, undefined, r) + ); + + is(newLoc.url, ORIGINAL_URL_1, "check mapped URL"); + is(newLoc.line, ORIGINAL_LINE, "check mapped line number"); + + testServer.switchToNextVersion(); + + // Reload the page. A different source file will be loaded. + sourceSeen = waitForSourceLoad(toolbox, JS_URL); + await reloadBrowser(); + await sourceSeen; + + info( + `checking post-reload original location for ${JS_URL}:${GENERATED_LINE}` + ); + newLoc = await new Promise(r => + service.subscribeByURL(JS_URL, GENERATED_LINE, undefined, r) + ); + is(newLoc.url, ORIGINAL_URL_2, "check post-reload mapped URL"); + is(newLoc.line, ORIGINAL_LINE, "check post-reload mapped line number"); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/client/framework/test/browser_tab_commands_factory.js b/devtools/client/framework/test/browser_tab_commands_factory.js new file mode 100644 index 0000000000..d8f2f44ca8 --- /dev/null +++ b/devtools/client/framework/test/browser_tab_commands_factory.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test LocalTabCommandsFactory + +const { + LocalTabCommandsFactory, +} = require("resource://devtools/client/framework/local-tab-commands-factory.js"); + +add_task(async function () { + await testTabDescriptorWithURL("data:text/html;charset=utf-8,foo"); + + // Bug 1699497: Also test against a page in the parent process + // which can hit some race with frame-connector's frame scripts. + await testTabDescriptorWithURL("about:robots"); +}); + +async function testTabDescriptorWithURL(url) { + info(`Test TabDescriptor against url ${url}\n`); + const tab = await addTab(url); + + const commands = await LocalTabCommandsFactory.createCommandsForTab(tab); + is( + commands.descriptorFront.localTab, + tab, + "TabDescriptor's localTab is set correctly" + ); + + info( + "Calling a second time createCommandsForTab with the same tab, will return the same commands" + ); + const secondCommands = await LocalTabCommandsFactory.createCommandsForTab( + tab + ); + is(commands, secondCommands, "second commands is the same"); + + // We have to involve TargetCommand in order to have a function TabDescriptor.getTarget. + await commands.targetCommand.startListening(); + + info("Wait for descriptor's target"); + const target = await commands.descriptorFront.getTarget(); + + info("Call any method to ensure that each target works"); + await target.logInPage("foo"); + + info("Destroy the command"); + await commands.destroy(); + + gBrowser.removeCurrentTab(); +} diff --git a/devtools/client/framework/test/browser_tab_descriptor_fission.js b/devtools/client/framework/test/browser_tab_descriptor_fission.js new file mode 100644 index 0000000000..bf15f01293 --- /dev/null +++ b/devtools/client/framework/test/browser_tab_descriptor_fission.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that tab descriptor survives after the page navigates and changes + * process. + */ + +const EXAMPLE_COM_URI = + "https://example.com/document-builder.sjs?html=
com"; +const EXAMPLE_ORG_URI = + "https://example.org/document-builder.sjs?html=
org"; + +add_task(async function () { + const tab = await addTab(EXAMPLE_COM_URI); + const toolbox = await gDevTools.showToolboxForTab(tab); + const target = toolbox.target; + const client = toolbox.commands.client; + + info("Retrieve the initial list of tab descriptors"); + const tabDescriptors = await client.mainRoot.listTabs(); + const tabDescriptor = tabDescriptors.find( + d => decodeURIComponent(d.url) === EXAMPLE_COM_URI + ); + ok(tabDescriptor, "Should have a descriptor actor for the tab"); + + info("Retrieve the target corresponding to the TabDescriptor"); + const comTabTarget = await tabDescriptor.getTarget(); + is( + target, + comTabTarget, + "The toolbox target is also the target associated with the tab descriptor" + ); + + await navigateTo(EXAMPLE_ORG_URI); + + info("Call list tabs again to update the tab descriptor forms"); + await client.mainRoot.listTabs(); + + is( + decodeURIComponent(tabDescriptor.url), + EXAMPLE_ORG_URI, + "The existing descriptor now points to the new URI" + ); + + const newTarget = toolbox.target; + + is( + comTabTarget.actorID, + null, + "With Fission or server side target switching, example.com target front is destroyed" + ); + Assert.notEqual( + comTabTarget, + newTarget, + "With Fission or server side target switching, a new target was created for example.org" + ); + + const onDescriptorDestroyed = tabDescriptor.once("descriptor-destroyed"); + + await removeTab(tab); + + info("Wait for descriptor destroyed event"); + await onDescriptorDestroyed; + ok(tabDescriptor.isDestroyed(), "the descriptor front is really destroyed"); +}); diff --git a/devtools/client/framework/test/browser_target_cached-front.js b/devtools/client/framework/test/browser_target_cached-front.js new file mode 100644 index 0000000000..43156cbded --- /dev/null +++ b/devtools/client/framework/test/browser_target_cached-front.js @@ -0,0 +1,23 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function () { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + const target = await createAndAttachTargetForTab(gBrowser.selectedTab); + + info("Cached front when getFront has not been called"); + let getCachedFront = target.getCachedFront("accessibility"); + ok(!getCachedFront, "no front exists"); + + info("Cached front when getFront has been called but has not finished"); + const asyncFront = target.getFront("accessibility"); + getCachedFront = target.getCachedFront("accessibility"); + ok(!getCachedFront, "no front exists"); + + info("Cached front when getFront has been called and has finished"); + const front = await asyncFront; + getCachedFront = target.getCachedFront("accessibility"); + is(getCachedFront, front, "front is the same as async front"); +}); diff --git a/devtools/client/framework/test/browser_target_cached-resource.js b/devtools/client/framework/test/browser_target_cached-resource.js new file mode 100644 index 0000000000..b6f53fdee0 --- /dev/null +++ b/devtools/client/framework/test/browser_target_cached-resource.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// The target front holds resources that happend before ResourceCommand addeed listeners. +// Test whether that feature works correctly or not. +const TEST_URI = + "https://example.com/browser/devtools/client/framework/test/doc_cached-resource.html"; +const PARENT_MESSAGE = "Hello from parent"; +const CHILD_MESSAGE = "Hello from child"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/webconsole/test/browser/shared-head.js", + this +); + +add_task(async function () { + info("Open console"); + const tab = await addTab(TEST_URI); + const toolbox = await openToolboxForTab(tab, "webconsole"); + const hud = toolbox.getCurrentPanel().hud; + + info("Check the initial messages"); + ok( + findMessageByType(hud, PARENT_MESSAGE, ".console-api"), + "Message from parent document is in console" + ); + ok( + findMessageByType(hud, CHILD_MESSAGE, ".console-api"), + "Message from child document is in console" + ); + + info("Clear the messages"); + hud.ui.window.document.querySelector(".devtools-clear-icon").click(); + await waitUntil( + () => !findMessageByType(hud, PARENT_MESSAGE, ".console-api") + ); + + info("Reload the browsing page"); + await navigateTo(TEST_URI); + + info("Check the messages after reloading"); + await waitUntil( + () => + findMessageByType(hud, PARENT_MESSAGE, ".console-api") && + findMessageByType(hud, CHILD_MESSAGE, ".console-api") + ); + ok(true, "All messages are shown correctly"); +}); diff --git a/devtools/client/framework/test/browser_target_get-front.js b/devtools/client/framework/test/browser_target_get-front.js new file mode 100644 index 0000000000..9dac79d196 --- /dev/null +++ b/devtools/client/framework/test/browser_target_get-front.js @@ -0,0 +1,112 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function () { + const tab = await addTab("about:blank"); + const target = await createAndAttachTargetForTab(tab); + + const tab2 = await addTab("about:blank"); + const target2 = await createAndAttachTargetForTab(tab2); + + info("Test the targetFront attribute for the root"); + const { client } = target; + is( + client.mainRoot.targetFront, + null, + "got null from the targetFront attribute for the root" + ); + is( + client.mainRoot.parentFront, + null, + "got null from the parentFront attribute for the root" + ); + + info("Test getting a front twice"); + const getAccessibilityFront = await target.getFront("accessibility"); + const getAccessibilityFront2 = await target.getFront("accessibility"); + is( + getAccessibilityFront, + getAccessibilityFront2, + "got the same front when calling getFront twice" + ); + is( + getAccessibilityFront.targetFront, + target, + "got the correct targetFront attribute from the front" + ); + is( + getAccessibilityFront2.targetFront, + target, + "got the correct targetFront attribute from the front" + ); + is( + getAccessibilityFront.parentFront, + target, + "got the correct parentFront attribute from the front" + ); + is( + getAccessibilityFront2.parentFront, + target, + "got the correct parentFront attribute from the front" + ); + + info("Test getting a front on different targets"); + const target1Front = await target.getFront("accessibility"); + const target2Front = await target2.getFront("accessibility"); + is( + target1Front !== target2Front, + true, + "got different fronts when calling getFront on different targets" + ); + is( + target1Front.targetFront !== target2Front.targetFront, + true, + "got different targetFront from different fronts from different targets" + ); + is( + target2Front.targetFront, + target2, + "got the correct targetFront attribute from the front" + ); + + info("Test async front retrieval"); + // use two fronts that are initialized one after the other. + const asyncFront1 = target.getFront("accessibility"); + const asyncFront2 = target.getFront("accessibility"); + + info("waiting on async fronts returns a real front"); + const awaitedAsyncFront1 = await asyncFront1; + const awaitedAsyncFront2 = await asyncFront2; + is( + awaitedAsyncFront1, + awaitedAsyncFront2, + "got the same front when requesting the front first async then sync" + ); + await target.destroy(); + await target2.destroy(); + + info("destroying a front immediately is possible"); + await testDestroy(); +}); + +async function testDestroy() { + // initialize a clean target + const tab = await addTab("about:blank"); + const target = await createAndAttachTargetForTab(tab); + + // do not wait for the front to finish loading + target.getFront("accessibility"); + + try { + await target.destroy(); + ok( + true, + "calling destroy on an async front instantiated with getFront does not throw" + ); + } catch (e) { + ok( + false, + "calling destroy on an async front instantiated with getFront does not throw" + ); + } +} diff --git a/devtools/client/framework/test/browser_target_listeners.js b/devtools/client/framework/test/browser_target_listeners.js new file mode 100644 index 0000000000..942ac3bed1 --- /dev/null +++ b/devtools/client/framework/test/browser_target_listeners.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function () { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + const target = await createAndAttachTargetForTab(gBrowser.selectedTab); + + info("Test applying watchFronts to a front that will be created"); + const promise = new Promise(resolve => { + target.watchFronts("accessibility", resolve); + }); + const getFrontFront = await target.getFront("accessibility"); + const watchFrontsFront = await promise; + is( + getFrontFront, + watchFrontsFront, + "got the front instantiated in the future and it's the same" + ); + + info("Test applying watchFronts to an existing front"); + await new Promise(resolve => { + target.watchFronts("accessibility", front => { + is( + front, + getFrontFront, + "got the already instantiated front and it's the same" + ); + resolve(); + }); + }); +}); diff --git a/devtools/client/framework/test/browser_target_loading.js b/devtools/client/framework/test/browser_target_loading.js new file mode 100644 index 0000000000..fe579bdaa9 --- /dev/null +++ b/devtools/client/framework/test/browser_target_loading.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that toolbox can be opened right after a tab is added, while the document +// is still loading. + +add_task(async function testOpenToolboxOnLoadingDocument() { + const TEST_URI = + `https://example.com/document-builder.sjs?` + + `html=Test`; + + // ⚠️ Note that we don't await for `addTab` here, as we want to open the toolbox just + // after the tab is addded, with the document still loading. + info("Add tab…"); + const onTabAdded = addTab(TEST_URI); + const tab = gBrowser.selectedTab; + info("…and open the toolbox right away"); + const onToolboxShown = gDevTools.showToolboxForTab(tab, { + toolId: "webconsole", + }); + + await onTabAdded; + ok(true, "The tab as done loading"); + + const toolbox = await onToolboxShown; + ok(true, "The toolbox is shown"); + + info("Check that the console opened and has the message from the page"); + const { hud } = toolbox.getPanel("webconsole"); + await waitFor(() => + Array.from(hud.ui.window.document.querySelectorAll(".message-body")).some( + el => el.innerText.includes("page loaded") + ) + ); + ok(true, "The console opened with the expected content"); +}); diff --git a/devtools/client/framework/test/browser_target_parents.js b/devtools/client/framework/test/browser_target_parents.js new file mode 100644 index 0000000000..795219abef --- /dev/null +++ b/devtools/client/framework/test/browser_target_parents.js @@ -0,0 +1,189 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test a given Target's parentFront attribute returns the correct parent front. + +const { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); +const { + createCommandsDictionary, +} = require("resource://devtools/shared/commands/index.js"); + +const TEST_URL = `data:text/html;charset=utf-8,
`; + +// Test against Tab targets +add_task(async function () { + const tab = await addTab(TEST_URL); + + const client = await setupDebuggerClient(); + const mainRoot = client.mainRoot; + + const tabDescriptors = await mainRoot.listTabs(); + + const concurrentCommands = []; + for (const descriptor of tabDescriptors) { + concurrentCommands.push( + (async () => { + const commands = await createCommandsDictionary(descriptor); + // Descriptor's getTarget will only work if the TargetCommand watches for the first top target + await commands.targetCommand.startListening(); + })() + ); + } + info("Instantiate all tab's commands and initialize their TargetCommand"); + await Promise.all(concurrentCommands); + + await testGetTargetWithConcurrentCalls(tabDescriptors, tabTarget => { + // We only call BrowsingContextTargetFront.attach and not TargetMixin.attachAndInitThread. + // So very few things are done. + return !!tabTarget.targetForm?.traits; + }); + + await client.close(); + await removeTab(tab); +}); + +// Test against Process targets +add_task(async function () { + const client = await setupDebuggerClient(); + const mainRoot = client.mainRoot; + + const processes = await mainRoot.listProcesses(); + + // Assert that concurrent calls to getTarget resolves the same target and that it is already attached + // With that, we were chasing a precise race, where a second call to ProcessDescriptor.getTarget() + // happens between the instantiation of ContentProcessTarget and its call to attach() from getTarget + // function. + await testGetTargetWithConcurrentCalls(processes, processTarget => { + // We only call ContentProcessTargetFront.attach and not TargetMixin.attachAndInitThread. + // So nothing is done for content process targets. + return true; + }); + + await client.close(); +}); + +// Test against Webextension targets +add_task(async function () { + const client = await setupDebuggerClient(); + + const mainRoot = client.mainRoot; + + const addons = await mainRoot.listAddons(); + await Promise.all( + // some extensions, such as themes, are not debuggable. Filter those out + // before trying to connect. + addons + .filter(a => a.debuggable) + .map(async addonDescriptorFront => { + const addonFront = await addonDescriptorFront.getTarget(); + ok(addonFront, "Got the addon target"); + }) + ); + + await client.close(); +}); + +// Test against worker targets on parent process +add_task(async function () { + const client = await setupDebuggerClient(); + + const mainRoot = client.mainRoot; + + const { workers } = await mainRoot.listWorkers(); + + ok(!!workers.length, "list workers returned a non-empty list of workers"); + + for (const workerDescriptorFront of workers) { + let targetFront; + try { + targetFront = await workerDescriptorFront.getTarget(); + } catch (e) { + // Ignore race condition where we are trying to connect to a worker + // related to a previous test which is being destroyed. + if ( + e.message.includes("nsIWorkerDebugger.initialize") || + workerDescriptorFront.isDestroyed() || + !workerDescriptorFront.name + ) { + info("Failed to connect to " + workerDescriptorFront.url); + continue; + } + throw e; + } + // Bug 1767760: name might be null on some worker which are probably initializing or destroying. + if (!workerDescriptorFront.name) { + info("Failed to connect to " + workerDescriptorFront.url); + continue; + } + + is( + workerDescriptorFront, + targetFront, + "For now, worker descriptors and targets are the same object (see bug 1667404)" + ); + // Check that accessing descriptor#name getter doesn't throw (See Bug 1714974). + ok( + workerDescriptorFront.name.includes(".js") || + workerDescriptorFront.name.includes(".mjs"), + `worker descriptor front holds the worker file name (${workerDescriptorFront.name})` + ); + is( + workerDescriptorFront.isWorkerDescriptor, + true, + "isWorkerDescriptor is true" + ); + } + + await client.close(); +}); + +async function setupDebuggerClient() { + // Instantiate a minimal server + DevToolsServer.init(); + DevToolsServer.allowChromeProcess = true; + if (!DevToolsServer.createRootActor) { + DevToolsServer.registerAllActors(); + } + const transport = DevToolsServer.connectPipe(); + const client = new DevToolsClient(transport); + await client.connect(); + return client; +} + +async function testGetTargetWithConcurrentCalls(descriptors, isTargetAttached) { + // Assert that concurrent calls to getTarget resolves the same target and that it is already attached + await Promise.all( + descriptors.map(async descriptor => { + const promises = []; + const concurrentCalls = 10; + for (let i = 0; i < concurrentCalls; i++) { + const targetPromise = descriptor.getTarget(); + // Every odd runs, wait for a tick to introduce some more randomness + if (i % 2 == 0) { + await wait(0); + } + promises.push( + targetPromise.then(target => { + ok(isTargetAttached(target), "The target is attached"); + return target; + }) + ); + } + const targets = await Promise.all(promises); + for (let i = 1; i < concurrentCalls; i++) { + is( + targets[0], + targets[i], + "All the targets returned by concurrent calls to getTarget are the same" + ); + } + }) + ); +} diff --git a/devtools/client/framework/test/browser_target_remote.js b/devtools/client/framework/test/browser_target_remote.js new file mode 100644 index 0000000000..272797d626 --- /dev/null +++ b/devtools/client/framework/test/browser_target_remote.js @@ -0,0 +1,15 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Ensure target is closed if client is closed directly +function test() { + waitForExplicitFinish(); + + getParentProcessActors((client, target) => { + target.on("target-destroyed", () => { + ok(true, "Target was destroyed"); + finish(); + }); + client.close(); + }); +} diff --git a/devtools/client/framework/test/browser_target_server_compartment.js b/devtools/client/framework/test/browser_target_server_compartment.js new file mode 100644 index 0000000000..c0bd8e56f0 --- /dev/null +++ b/devtools/client/framework/test/browser_target_server_compartment.js @@ -0,0 +1,126 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Bug 1515290 - Ensure that DevToolsServer runs in its own compartment when debugging +// chrome context. If not, Debugger API's addGlobal will throw when trying to attach +// to chrome scripts as debugger actor's module and the chrome script will be in the same +// compartment. Debugger and debuggee can't be running in the same compartment. + +const CHROME_PAGE = + "chrome://mochitests/content/browser/devtools/client/framework/" + + "test/test_chrome_page.html"; + +add_task(async function () { + await testChromeTab(); + await testMainProcess(); +}); + +// Test that Tab Target can debug chrome pages +async function testChromeTab() { + const tab = await addTab(CHROME_PAGE); + const browser = tab.linkedBrowser; + ok(!browser.isRemoteBrowser, "chrome page is not remote"); + ok( + browser.contentWindow.document.nodePrincipal.isSystemPrincipal, + "chrome page is a privileged document" + ); + + const onThreadActorInstantiated = new Promise(resolve => { + const observe = function (subject, topic, data) { + if (topic === "devtools-thread-ready") { + Services.obs.removeObserver(observe, "devtools-thread-ready"); + const threadActor = subject.wrappedJSObject; + resolve(threadActor); + } + }; + Services.obs.addObserver(observe, "devtools-thread-ready"); + }); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + const sources = []; + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.SOURCE], + { + onAvailable(resources) { + sources.push(...resources); + }, + } + ); + ok( + sources.find(s => s.url == CHROME_PAGE), + "The thread actor is able to attach to the chrome page and its sources" + ); + + const threadActor = await onThreadActorInstantiated; + const serverGlobal = Cu.getGlobalForObject(threadActor); + isnot( + loader.id, + serverGlobal.loader.id, + "The actors are loaded in a distinct loader in order for the actors to use its very own compartment" + ); + + const onDedicatedLoaderDestroy = new Promise(resolve => { + const observe = function (subject, topic, data) { + if (topic === "devtools:loader:destroy") { + Services.obs.removeObserver(observe, "devtools:loader:destroy"); + resolve(); + } + }; + Services.obs.addObserver(observe, "devtools:loader:destroy"); + }); + + await commands.destroy(); + + // Wait for the dedicated loader used for DevToolsServer to be destroyed + // in order to prevent leak reports on try + await onDedicatedLoaderDestroy; +} + +// Test that Main process Target can debug chrome scripts +async function testMainProcess() { + const onThreadActorInstantiated = new Promise(resolve => { + const observe = function (subject, topic, data) { + if (topic === "devtools-thread-ready") { + Services.obs.removeObserver(observe, "devtools-thread-ready"); + const threadActor = subject.wrappedJSObject; + resolve(threadActor); + } + }; + Services.obs.addObserver(observe, "devtools-thread-ready"); + }); + + const client = await CommandsFactory.spawnClientToDebugSystemPrincipal(); + const commands = await CommandsFactory.forMainProcess({ client }); + await commands.targetCommand.startListening(); + + const sources = []; + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.SOURCE], + { + onAvailable(resources) { + sources.push(...resources); + }, + } + ); + ok( + sources.find( + s => s.url == "resource://devtools/client/framework/devtools.js" + ), + "The thread actor is able to attach to the chrome script, like client modules" + ); + + const threadActor = await onThreadActorInstantiated; + const serverGlobal = Cu.getGlobalForObject(threadActor); + isnot( + loader.id, + serverGlobal.loader.id, + "The actors are loaded in a distinct loader in order for the actors to use its very own compartment" + ); + + // As this target is remote (i.e. isn't a local tab) calling Target.destroy won't close + // the client. So do it manually here in order to ensure cleaning up the DevToolsServer + // spawn for this main process actor. + await commands.destroy(); +} diff --git a/devtools/client/framework/test/browser_target_support.js b/devtools/client/framework/test/browser_target_support.js new file mode 100644 index 0000000000..ff87b9fad4 --- /dev/null +++ b/devtools/client/framework/test/browser_target_support.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test support methods on Target, such as `hasActor` and `getTrait`. + +async function testTarget(client, target) { + is( + target.hasActor("inspector"), + true, + "target.hasActor() true when actor exists." + ); + is( + target.hasActor("notreal"), + false, + "target.hasActor() false when actor does not exist." + ); + + is( + target.getTrait("giddyup"), + undefined, + "target.getTrait() returns undefined when trait does not exist" + ); + + close(target, client); +} + +// Ensure target is closed if client is closed directly +function test() { + waitForExplicitFinish(); + + getParentProcessActors(testTarget); +} + +function close(target, client) { + target.on("target-destroyed", () => { + ok(true, "Target was destroyed"); + finish(); + }); + client.close(); +} diff --git a/devtools/client/framework/test/browser_toolbox_backward_forward_navigation.js b/devtools/client/framework/test/browser_toolbox_backward_forward_navigation.js new file mode 100644 index 0000000000..7350c2601c --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_backward_forward_navigation.js @@ -0,0 +1,188 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// The test can take a while to run +requestLongerTimeout(3); + +const FILENAME = "doc_backward_forward_navigation.html"; +const TEST_URI_ORG = `${URL_ROOT_ORG_SSL}${FILENAME}`; +const TEST_URI_COM = `${URL_ROOT_COM_SSL}${FILENAME}`; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/debugger/test/mochitest/shared-head.js", + this +); + +add_task(async function testMultipleNavigations() { + // Disable bfcache for Fission for now. + // If Fission is disabled, the pref is no-op. + await SpecialPowers.pushPrefEnv({ + set: [["fission.bfcacheInParent", false]], + }); + + info( + "Test that DevTools works fine after multiple backward/forward navigations" + ); + // Don't show the third panel to limit the logs and activity. + await pushPref("devtools.inspector.three-pane-enabled", false); + await pushPref("devtools.inspector.activeSidebar", "ruleview"); + const DATA_URL = `data:text/html,`; + const tab = await addTab(DATA_URL); + + // Select the debugger so there will be more activity + const toolbox = await openToolboxForTab(tab, "jsdebugger"); + const inspector = await toolbox.selectTool("inspector"); + + info("Navigate to the ORG test page"); + // We don't use `navigateTo` as the page is adding stylesheets and js files which might + // delay the load event indefinitely (and we don't need for anything to be loaded, or + // ready, just to register the initial navigation so we can go back and forth between urls) + let onLocationChange = BrowserTestUtils.waitForLocationChange( + gBrowser, + TEST_URI_ORG + ); + BrowserTestUtils.startLoadingURIString(gBrowser, TEST_URI_ORG); + await onLocationChange; + + info("And then navigate to a different origin"); + onLocationChange = BrowserTestUtils.waitForLocationChange( + gBrowser, + TEST_URI_COM + ); + BrowserTestUtils.startLoadingURIString(gBrowser, TEST_URI_COM); + await onLocationChange; + + info( + "Navigate backward and forward multiple times between the two origins, with different delays" + ); + await navigateBackAndForth(TEST_URI_ORG, TEST_URI_COM); + + // Navigate one last time to a document with less activity so we don't have to deal + // with pending promises when we destroy the toolbox + const onInspectorReloaded = inspector.once("reloaded"); + info("Navigate to final document"); + await navigateTo(`${TEST_URI_ORG}?no-mutation`); + info("Waiting for inspector to reload…"); + await onInspectorReloaded; + info("-> inspector reloaded"); + await checkToolboxState(toolbox); +}); + +add_task(async function testSingleBackAndForthInstantNavigation() { + // Disable bfcache for Fission for now. + // If Fission is disabled, the pref is no-op. + await SpecialPowers.pushPrefEnv({ + set: [["fission.bfcacheInParent", false]], + }); + + info( + "Test that DevTools works fine after navigating backward and forward right after" + ); + + // Don't show the third panel to limit the logs and activity. + await pushPref("devtools.inspector.three-pane-enabled", false); + await pushPref("devtools.inspector.activeSidebar", "ruleview"); + const DATA_URL = `data:text/html,`; + const tab = await addTab(DATA_URL); + + // Select the debugger so there will be more activity + const toolbox = await openToolboxForTab(tab, "jsdebugger"); + const inspector = await toolbox.selectTool("inspector"); + + info("Navigate to a different origin"); + await navigateTo(TEST_URI_COM); + + info("Then navigate back, and forth immediatly"); + // We can't call goBack and right away goForward as goForward and even the call to navigateTo + // a bit later might be ignored. So we wait at least for the location to change. + await safelyGoBack(DATA_URL); + await safelyGoForward(TEST_URI_COM); + + // Navigate one last time to a document with less activity so we don't have to deal + // with pending promises when we destroy the toolbox + const onInspectorReloaded = inspector.once("reloaded"); + info("Navigate to final document"); + await navigateTo(`${TEST_URI_ORG}?no-mutation`); + info("Waiting for inspector to reload…"); + await onInspectorReloaded; + info("-> inspector reloaded"); + await checkToolboxState(toolbox); +}); + +async function checkToolboxState(toolbox) { + info("Check that the toolbox toolbar is still visible"); + const toolboxTabsEl = toolbox.doc.querySelector(".toolbox-tabs"); + ok(toolboxTabsEl, "Toolbar is still visible"); + + info( + "Check that the markup view is rendered correctly and elements can be selected" + ); + const inspector = await toolbox.selectTool("inspector"); + await waitFor( + () => + inspector.markup && + inspector.markup.win.document.body.innerText.includes( + `` + ), + `wait for to be displayed in the markup view, got: ${inspector.markup?.win.document.body.innerText}`, + 100, + 100 + ); + ok(true, "the markup view is still rendered fine"); + await selectNode("ul.logs", inspector); + ok(true, "Nodes can be selected"); + + info("Check that the debugger has some sources"); + const dbgPanel = await toolbox.selectTool("jsdebugger"); + const dbg = createDebuggerContext(toolbox); + + info(`Wait for ${FILENAME} to be displayed in the debugger source panel`); + const rootNode = await waitFor(() => + dbgPanel.panelWin.document.querySelector(selectors.sourceTreeRootNode) + ); + await expandAllSourceNodes(dbg, rootNode); + const sourcesTreeScriptNode = await waitFor(() => + findSourceNodeWithText(dbg, FILENAME) + ); + + ok( + sourcesTreeScriptNode.innerText.includes(FILENAME), + "The debugger has the expected source" + ); +} + +async function navigateBackAndForth( + expectedUrlAfterBackwardNavigation, + expectedUrlAfterForwardNavigation +) { + const delays = [100, 0, 500]; + for (const delay of delays) { + // For each delays, do 3 back/forth navigations + for (let i = 0; i < 3; i++) { + await safelyGoBack(expectedUrlAfterBackwardNavigation); + await wait(delay); + await safelyGoForward(expectedUrlAfterForwardNavigation); + await wait(delay); + } + } +} + +async function safelyGoBack(expectedUrl) { + const onLocationChange = BrowserTestUtils.waitForLocationChange( + gBrowser, + expectedUrl + ); + gBrowser.goBack(); + await onLocationChange; +} + +async function safelyGoForward(expectedUrl) { + const onLocationChange = BrowserTestUtils.waitForLocationChange( + gBrowser, + expectedUrl + ); + gBrowser.goForward(); + await onLocationChange; +} diff --git a/devtools/client/framework/test/browser_toolbox_browsertoolbox_host.js b/devtools/client/framework/test/browser_toolbox_browsertoolbox_host.js new file mode 100644 index 0000000000..8efb7959ce --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_browsertoolbox_host.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URL = "data:text/html,test browsertoolbox host"; + +add_task(async function () { + const { + Toolbox, + } = require("resource://devtools/client/framework/toolbox.js"); + + const tab = await addTab(TEST_URL); + const options = { doc: document }; + const toolbox = await gDevTools.showToolboxForTab(tab, { + hostType: Toolbox.HostType.BROWSERTOOLBOX, + hostOptions: options, + }); + + is(toolbox.topWindow, window, "Toolbox is included in browser.xhtml"); + const iframe = document.querySelector( + ".devtools-toolbox-browsertoolbox-iframe" + ); + ok(iframe, "A toolbox iframe was created in the provided document"); + is(toolbox.doc, iframe.contentDocument, "Toolbox is in the custom iframe"); + + await toolbox.destroy(); + iframe.remove(); +}); diff --git a/devtools/client/framework/test/browser_toolbox_contentpage_contextmenu.js b/devtools/client/framework/test/browser_toolbox_contentpage_contextmenu.js new file mode 100644 index 0000000000..63363e4cf3 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_contentpage_contextmenu.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = "data:text/html;charset=utf8,
test content context menu
"; + +/** + * Check that the DevTools context menu opens without triggering the content + * context menu. See Bug 1591140. + */ +add_task(async function () { + const tab = await addTab(URL); + + info("Test context menu conflict with dom.event.contextmenu.enabled=true"); + await pushPref("dom.event.contextmenu.enabled", true); + await checkConflictWithContentPageMenu(tab); + + info("Test context menu conflict with dom.event.contextmenu.enabled=false"); + await pushPref("dom.event.contextmenu.enabled", false); + await checkConflictWithContentPageMenu(tab); +}); + +async function checkConflictWithContentPageMenu(tab) { + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "inspector", + }); + + info("Check that the content page context menu works as expected"); + const contextMenu = document.getElementById("contentAreaContextMenu"); + is(contextMenu.state, "closed", "Content contextmenu is closed"); + + info("Show the content context menu"); + const awaitPopupShown = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "div", + { + type: "contextmenu", + button: 2, + centered: true, + }, + gBrowser.selectedBrowser + ); + await awaitPopupShown; + is(contextMenu.state, "open", "Content contextmenu is open"); + + info("Hide the content context menu"); + const awaitPopupHidden = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + contextMenu.hidePopup(); + await awaitPopupHidden; + is(contextMenu.state, "closed", "Content contextmenu is closed again"); + + info("Check the DevTools menu opens without opening the content menu"); + const onContextMenuPopup = toolbox.once("menu-open"); + // Use inspector search box for the test, any other element should be ok as + // well. + const inspector = toolbox.getPanel("inspector"); + synthesizeContextMenuEvent(inspector.searchBox); + await onContextMenuPopup; + + const textboxContextMenu = toolbox.getTextBoxContextMenu(); + is(contextMenu.state, "closed", "Content contextmenu is still closed"); + is(textboxContextMenu.state, "open", "Toolbox contextmenu is open"); + + info("Check that the toolbox context menu is closed when pressing ESCAPE"); + const onContextMenuHidden = toolbox.once("menu-close"); + if (Services.prefs.getBoolPref("widget.macos.native-context-menus", false)) { + info("Using hidePopup semantics because of macOS native context menus."); + textboxContextMenu.hidePopup(); + } else { + EventUtils.sendKey("ESCAPE", toolbox.win); + } + await onContextMenuHidden; + is(textboxContextMenu.state, "closed", "Toolbox contextmenu is closed."); + + await toolbox.destroy(); +} diff --git a/devtools/client/framework/test/browser_toolbox_disable_f12.js b/devtools/client/framework/test/browser_toolbox_disable_f12.js new file mode 100644 index 0000000000..df0a755714 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_disable_f12.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + const tab = await addTab( + "https://example.com/document-builder.sjs?html=test" + ); + + info("Enable F12 and check that devtools open"); + await pushPref("devtools.f12_enabled", true); + await assertToolboxOpens(tab, { shouldOpen: true }); + await assertToolboxCloses(tab, { shouldClose: true }); + + info("Disable F12 and check that devtools will not open"); + await pushPref("devtools.f12_enabled", false); + await assertToolboxOpens(tab, { shouldOpen: false }); + + info("Enable F12 again and open devtools"); + await pushPref("devtools.f12_enabled", true); + await assertToolboxOpens(tab, { shouldOpen: true }); + + info("Disable F12 and check F12 no longer closes devtools"); + await pushPref("devtools.f12_enabled", false); + await assertToolboxCloses(tab, { shouldClose: false }); + + info("Enable F12 and close devtools"); + await pushPref("devtools.f12_enabled", true); + await assertToolboxCloses(tab, { shouldClose: true }); + + info("Disable F12 and check other shortcuts still work"); + await pushPref("devtools.f12_enabled", false); + const isMac = Services.appinfo.OS == "Darwin"; + const shortcut = { + key: "i", + options: { accelKey: true, altKey: isMac, shiftKey: !isMac }, + }; + await assertToolboxOpens(tab, { shouldOpen: true, shortcut }); + // Check F12 still doesn't close the toolbox + await assertToolboxCloses(tab, { shouldClose: false }); + await assertToolboxCloses(tab, { shouldClose: true, shortcut }); + + gBrowser.removeTab(tab); +}); + +const assertToolboxCloses = async function (tab, { shortcut, shouldClose }) { + info( + `Use ${ + shortcut ? "shortcut" : "F12" + } to close the toolbox (close expected: ${shouldClose})` + ); + const onToolboxDestroy = gDevTools.once("toolbox-destroyed"); + + if (shortcut) { + EventUtils.synthesizeKey(shortcut.key, shortcut.options); + } else { + EventUtils.synthesizeKey("VK_F12", {}); + } + + if (shouldClose) { + await onToolboxDestroy; + } else { + const onTimeout = wait(1000).then(() => "TIMEOUT"); + const res = await Promise.race([onTimeout, onToolboxDestroy]); + is(res, "TIMEOUT", "No toolbox-destroyed event received"); + } + is( + !gDevTools.getToolboxForTab(tab), + shouldClose, + `Toolbox was ${shouldClose ? "" : "not "}closed for the test tab` + ); +}; + +const assertToolboxOpens = async function (tab, { shortcut, shouldOpen }) { + info( + `Use ${ + shortcut ? "shortcut" : "F12" + } to open the toolbox (open expected: ${shouldOpen})` + ); + const onToolboxReady = gDevTools.once("toolbox-ready"); + + if (shortcut) { + EventUtils.synthesizeKey(shortcut.key, shortcut.options); + } else { + EventUtils.synthesizeKey("VK_F12", {}); + } + + if (shouldOpen) { + await onToolboxReady; + info(`Received toolbox-ready`); + } else { + const onTimeout = wait(1000).then(() => "TIMEOUT"); + const res = await Promise.race([onTimeout, onToolboxReady]); + is(res, "TIMEOUT", "No toolbox-ready event received"); + } + is( + !!gDevTools.getToolboxForTab(tab), + shouldOpen, + `Toolbox was ${shouldOpen ? "" : "not "}opened for the test tab` + ); +}; diff --git a/devtools/client/framework/test/browser_toolbox_dynamic_registration.js b/devtools/client/framework/test/browser_toolbox_dynamic_registration.js new file mode 100644 index 0000000000..0ea7388eec --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_dynamic_registration.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URL = + "data:text/html,test for dynamically registering and unregistering tools"; + +var toolbox; + +function test() { + addTab(TEST_URL).then(async tab => { + gDevTools.showToolboxForTab(tab).then(testRegister); + }); +} + +function testRegister(aToolbox) { + toolbox = aToolbox; + gDevTools.once("tool-registered", toolRegistered); + + gDevTools.registerTool({ + id: "testTool", + label: "Test Tool", + inMenu: true, + isToolSupported: () => true, + build() {}, + }); +} + +function toolRegistered(toolId) { + is(toolId, "testTool", "tool-registered event handler sent tool id"); + + ok(gDevTools.getToolDefinitionMap().has(toolId), "tool added to map"); + + // test that it appeared in the UI + const doc = toolbox.doc; + const tab = getToolboxTab(doc, toolId); + ok(tab, "new tool's tab exists in toolbox UI"); + + const panel = doc.getElementById("toolbox-panel-" + toolId); + ok(panel, "new tool's panel exists in toolbox UI"); + + for (const win of getAllBrowserWindows()) { + const menuitem = win.document.getElementById("menuitem_" + toolId); + ok(menuitem, "menu item of new tool added to every browser window"); + } + + // then unregister it + testUnregister(); +} + +function getAllBrowserWindows() { + return Array.from(Services.wm.getEnumerator("navigator:browser")); +} + +function testUnregister() { + gDevTools.once("tool-unregistered", toolUnregistered); + + gDevTools.unregisterTool("testTool"); +} + +function toolUnregistered(toolId) { + is(toolId, "testTool", "tool-unregistered event handler sent tool id"); + + ok(!gDevTools.getToolDefinitionMap().has(toolId), "tool removed from map"); + + // test that it disappeared from the UI + const doc = toolbox.doc; + const tab = getToolboxTab(doc, toolId); + ok(!tab, "tool's tab was removed from the toolbox UI"); + + const panel = doc.getElementById("toolbox-panel-" + toolId); + ok(!panel, "tool's panel was removed from toolbox UI"); + + for (const win of getAllBrowserWindows()) { + const menuitem = win.document.getElementById("menuitem_" + toolId); + ok(!menuitem, "menu item removed from every browser window"); + } + + cleanup(); +} + +function cleanup() { + toolbox.destroy().then(() => { + toolbox = null; + gBrowser.removeCurrentTab(); + finish(); + }); +} diff --git a/devtools/client/framework/test/browser_toolbox_error_count.js b/devtools/client/framework/test/browser_toolbox_error_count.js new file mode 100644 index 0000000000..e4dcf0214f --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_error_count.js @@ -0,0 +1,183 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +// Test for error icon and the error count displayed at right of the +// toolbox toolbar + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/webconsole/test/browser/shared-head.js", + this +); + +const TEST_URI = `https://example.com/document-builder.sjs?html= +`; + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function () { + // Make sure we start the test with the split console disabled. + await pushPref("devtools.toolbox.splitconsoleEnabled", false); + const tab = await addTab(TEST_URI); + + const toolbox = await openToolboxForTab( + tab, + "inspector", + Toolbox.HostType.BOTTOM + ); + + info("Check for cached errors"); + // (console.error + console.exception + console.assert + error) + let expectedErrorCount = 4; + + await waitFor(() => getErrorIcon(toolbox)); + is( + getErrorIcon(toolbox).getAttribute("title"), + "Show Split Console", + "Icon has expected title" + ); + is( + getErrorIconCount(toolbox), + expectedErrorCount, + "Correct count is displayed" + ); + + info("Check that calling console.clear clears the error count"); + ContentTask.spawn(tab.linkedBrowser, null, function () { + content.console.clear(); + }); + await waitFor( + () => !getErrorIcon(toolbox), + "Wait until the error button hides" + ); + ok(true, "The button was hidden after calling console.clear()"); + + info("Check that realtime errors increase the counter"); + ContentTask.spawn(tab.linkedBrowser, null, function () { + content.console.error("Live Error1"); + content.console.error("Live Error2"); + content.console.exception("Live Exception"); + content.console.warn("Live warning"); + content.console.assert(false, "Live assert"); + content.fetch("unknown-url-that-will-404"); + const script = content.document.createElement("script"); + script.textContent = `a.b.c.d`; + content.document.body.append(script); + }); + + expectedErrorCount = 6; + await waitFor(() => getErrorIconCount(toolbox) === expectedErrorCount); + + info("Check if split console opens on clicking the error icon"); + const onSplitConsoleOpen = toolbox.once("split-console"); + getErrorIcon(toolbox).click(); + await onSplitConsoleOpen; + ok( + toolbox.splitConsole, + "The split console was opened after clicking on the icon." + ); + + // Select the console and check that the icon title is updated + await toolbox.selectTool("webconsole"); + is( + getErrorIcon(toolbox).getAttribute("title"), + null, + "When the console is selected, the icon does not have a title" + ); + + const hud = toolbox.getCurrentPanel().hud; + const webconsoleDoc = hud.ui.window.document; + // wait until all error messages are displayed in the console + await waitFor( + async () => (await findAllErrors(hud)).length === expectedErrorCount + ); + + info("Clear the console output and check that the error icon is hidden"); + webconsoleDoc.querySelector(".devtools-clear-icon").click(); + await waitFor(() => !getErrorIcon(toolbox)); + ok(true, "Clearing the console does hide the icon"); + await waitFor(async () => (await findAllErrors(hud)).length === 0); + + info("Check that the error count is capped at 99"); + expectedErrorCount = 100; + ContentTask.spawn(tab.linkedBrowser, expectedErrorCount, function (count) { + for (let i = 0; i < count; i++) { + content.console.error(i); + } + }); + + // Wait until all the messages are displayed in the console + await waitFor( + async () => (await findAllErrors(hud)).length === expectedErrorCount + ); + + await waitFor(() => getErrorIconCount(toolbox) === "99+"); + ok(true, "The message count doesn't go higher than 99"); + + info( + "Reload the page and check that the error icon has the expected content" + ); + await reloadBrowser(); + + // (console.error, console.exception, console.assert and exception) + expectedErrorCount = 4; + await waitFor(() => getErrorIconCount(toolbox) === expectedErrorCount); + ok(true, "Correct count is displayed"); + + // wait until all error messages are displayed in the console + await waitFor( + async () => (await findAllErrors(hud)).length === expectedErrorCount + ); + + info("Disable the error icon from the options panel"); + const onOptionsSelected = toolbox.once("options-selected"); + toolbox.selectTool("options"); + const optionsPanel = await onOptionsSelected; + const errorCountButtonToggleEl = optionsPanel.panelWin.document.querySelector( + "input#command-button-errorcount" + ); + errorCountButtonToggleEl.click(); + + await waitFor(() => !getErrorIcon(toolbox)); + ok(true, "The error icon hides when disabling it from the settings panel"); + + info("Check that emitting new errors don't show the icon"); + ContentTask.spawn(tab.linkedBrowser, null, function () { + content.console.error("Live Error1 while disabled"); + content.console.error("Live Error2 while disabled"); + }); + + expectedErrorCount = expectedErrorCount + 2; + // Wait until messages are displayed in the console, so the toolbar would have the time + // to render the error icon again. + await toolbox.selectTool("webconsole"); + await waitFor( + async () => (await findAllErrors(hud)).length === expectedErrorCount + ); + is( + getErrorIcon(toolbox), + null, + "The icon is still hidden even after generating new errors" + ); + + info("Re-enable the error icon"); + await toolbox.selectTool("options"); + errorCountButtonToggleEl.click(); + await waitFor(() => getErrorIconCount(toolbox) === expectedErrorCount); + ok( + true, + "The error is displayed again, with the correct error count, after enabling it from the settings panel" + ); + + toolbox.destroy(); +}); + +function findAllErrors(hud) { + return findMessagesVirtualizedByType({ hud, typeSelector: ".error" }); +} diff --git a/devtools/client/framework/test/browser_toolbox_error_count_reset_on_navigation.js b/devtools/client/framework/test/browser_toolbox_error_count_reset_on_navigation.js new file mode 100644 index 0000000000..53f5068655 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_error_count_reset_on_navigation.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +// Test for error count in toolbar when navigating and webconsole isn't enabled +const TEST_URI = `http://example.org/document-builder.sjs?html= +`; + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function () { + // Disable bfcache for Fission for now. + // If Fission is disabled, the pref is no-op. + await SpecialPowers.pushPrefEnv({ + set: [["fission.bfcacheInParent", false]], + }); + + // Make sure we start the test with the split console disabled. + // ⚠️ In this test it's important to _not_ enable the console. + await pushPref("devtools.toolbox.splitconsoleEnabled", false); + const tab = await addTab(TEST_URI); + + const toolbox = await openToolboxForTab( + tab, + "inspector", + Toolbox.HostType.BOTTOM + ); + + info("Check for cached errors"); + // (console.error + console.exception + console.assert + error) + const expectedErrorCount = 4; + + await waitFor(() => getErrorIcon(toolbox)); + is( + getErrorIcon(toolbox).getAttribute("title"), + "Show Split Console", + "Icon has expected title" + ); + is( + getErrorIconCount(toolbox), + expectedErrorCount, + "Correct count is displayed" + ); + + info("Add another error so we have a different count"); + ContentTask.spawn(tab.linkedBrowser, null, function () { + content.console.error("Live Error1"); + }); + + const newExpectedErrorCount = expectedErrorCount + 1; + await waitFor(() => getErrorIconCount(toolbox) === newExpectedErrorCount); + + info( + "Reload the page and check that the error icon has the expected content" + ); + await reloadBrowser(); + + await waitFor( + () => getErrorIconCount(toolbox) === expectedErrorCount, + "Error count is cleared on navigation and then populated with the expected number of errors" + ); + ok(true, "Correct count is displayed"); + + info( + "Navigate to an error-less page and check that the error icon is hidden" + ); + await navigateTo(`data:text/html;charset=utf8,No errors`); + await waitFor( + () => !getErrorIcon(toolbox), + "Error count is cleared on navigation" + ); + ok( + true, + "The error icon was hidden when navigating to a new page without errors" + ); + + toolbox.destroy(); +}); diff --git a/devtools/client/framework/test/browser_toolbox_fission_navigation.js b/devtools/client/framework/test/browser_toolbox_fission_navigation.js new file mode 100644 index 0000000000..123a06cce2 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_fission_navigation.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +const EXAMPLE_COM_URI = + "https://example.com/document-builder.sjs?html=
com"; +const EXAMPLE_ORG_URI = + "https://example.org/document-builder.sjs?html=
org"; + +add_task(async function () { + const tab = await addTab(EXAMPLE_COM_URI); + + const toolbox = await openToolboxForTab(tab, "inspector"); + const comNode = await getNodeBySelector(toolbox, "#com"); + ok(comNode, "Found node for the COM page"); + + info("Navigate to the ORG page"); + await navigateTo(EXAMPLE_ORG_URI); + const orgNode = await getNodeBySelector(toolbox, "#org"); + ok(orgNode, "Found node for the ORG page"); + + info("Reload the ORG page"); + await navigateTo(EXAMPLE_ORG_URI); + const orgNodeAfterReload = await getNodeBySelector(toolbox, "#org"); + ok(orgNodeAfterReload, "Found node for the ORG page after reload"); + isnot(orgNode, orgNodeAfterReload, "The new node is different"); + + info("Navigate back to the COM page"); + await navigateTo(EXAMPLE_COM_URI); + const comNodeAfterNavigation = await getNodeBySelector(toolbox, "#com"); + ok(comNodeAfterNavigation, "Found node for the COM page after navigation"); + + info("Navigate to about:blank"); + await navigateTo("about:blank"); + const blankBodyAfterNavigation = await getNodeBySelector(toolbox, "body"); + ok( + blankBodyAfterNavigation, + "Found node for the about:blank page after navigation" + ); + + info("Navigate to about:robots"); + await navigateTo("about:robots"); + const aboutRobotsAfterNavigation = await getNodeBySelector( + toolbox, + "div.container" + ); + ok( + aboutRobotsAfterNavigation, + "Found node for the about:robots page after navigation" + ); +}); + +async function getNodeBySelector(toolbox, selector) { + const inspector = await toolbox.selectTool("inspector"); + return inspector.walker.querySelector(inspector.walker.rootNode, selector); +} diff --git a/devtools/client/framework/test/browser_toolbox_frames_list.js b/devtools/client/framework/test/browser_toolbox_frames_list.js new file mode 100644 index 0000000000..f1d7ff0510 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_frames_list.js @@ -0,0 +1,150 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that the frames list gets updated as iframes are added/removed from the document, +// and during navigation. + +const TEST_COM_URL = + "https://example.com/document-builder.sjs?html=
com"; +const TEST_ORG_URL = + `https://example.org/document-builder.sjs?html=
org
` + + `` + + ``; + +add_task(async function () { + // Enable the frames button. + await pushPref("devtools.command-button-frames.enabled", true); + + const tab = await addTab(TEST_COM_URL); + + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "webconsole", + }); + + ok( + !getFramesButton(toolbox), + "Frames button is not rendered when there's no iframes in the page" + ); + await checkFramesList(toolbox, []); + + info("Create a same origin (example.com) iframe"); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const comIframe = content.document.createElement("iframe"); + comIframe.src = + "https://example.com/document-builder.sjs?html=example.com iframe"; + content.document.body.appendChild(comIframe); + }); + + await waitFor(() => getFramesButton(toolbox)); + ok(true, "Button is displayed when adding an iframe"); + + info("Check the content of the frames list"); + await checkFramesList(toolbox, [ + TEST_COM_URL, + "https://example.com/document-builder.sjs?html=example.com iframe", + ]); + + info("Create a cross-process origin (example.org) iframe"); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const orgIframe = content.document.createElement("iframe"); + orgIframe.src = + "https://example.org/document-builder.sjs?html=example.org iframe"; + content.document.body.appendChild(orgIframe); + }); + + info("Check that the content of the frames list was updated"); + try { + await checkFramesList(toolbox, [ + TEST_COM_URL, + "https://example.com/document-builder.sjs?html=example.com iframe", + "https://example.org/document-builder.sjs?html=example.org iframe", + ]); + + // If Fission is enabled and EFT is not, we shouldn't hit this line as `checkFramesList` + // should throw (as remote frames are only displayed when EFT is enabled). + ok( + !isFissionEnabled() || isEveryFrameTargetEnabled(), + "iframe picker should only display remote frames when EFT is enabled" + ); + } catch (e) { + ok( + isFissionEnabled() && !isEveryFrameTargetEnabled(), + "iframe picker displays remote frames only when EFT is enabled" + ); + return; + } + + info("Reload and check that the frames list is cleared"); + await reloadBrowser(); + await waitFor(() => !getFramesButton(toolbox)); + ok( + true, + "The button was hidden when reloading as the page does not have iframes" + ); + await checkFramesList(toolbox, []); + + info("Navigate to a different origin, on a page with iframes"); + await navigateTo(TEST_ORG_URL); + await checkFramesList(toolbox, [ + TEST_ORG_URL, + "https://example.org/document-builder.sjs?html=example.org iframe", + "https://example.com/document-builder.sjs?html=example.com iframe", + ]); + + info("Check that frames list is updated when removing same-origin iframe"); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + content.document.querySelector("iframe").remove(); + }); + await checkFramesList(toolbox, [ + TEST_ORG_URL, + "https://example.com/document-builder.sjs?html=example.com iframe", + ]); + + info("Check that frames list is updated when removing cross-origin iframe"); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + content.document.querySelector("iframe").remove(); + }); + await waitFor(() => !getFramesButton(toolbox)); + ok(true, "The button was hidden when removing the last iframe on the page"); + await checkFramesList(toolbox, []); + + info("Check that the list does have expected items after reloading"); + await reloadBrowser(); + await waitFor(() => getFramesButton(toolbox)); + ok(true, "button is displayed after reloading"); + await checkFramesList(toolbox, [ + TEST_ORG_URL, + "https://example.org/document-builder.sjs?html=example.org iframe", + "https://example.com/document-builder.sjs?html=example.com iframe", + ]); +}); + +function getFramesButton(toolbox) { + return toolbox.doc.getElementById("command-button-frames"); +} + +async function checkFramesList(toolbox, expectedFrames) { + const frames = await waitFor(() => { + // items might be added in the list before their url is known, so exclude empty items. + const f = getFramesLabels(toolbox).filter(t => t !== ""); + if (f.length !== expectedFrames.length) { + return false; + } + + return f; + }); + + is( + JSON.stringify(frames.sort()), + JSON.stringify(expectedFrames.sort()), + "The expected frames are displayed" + ); +} + +function getFramesLabels(toolbox) { + return Array.from( + toolbox.doc.querySelectorAll("#toolbox-frame-menu .command .label") + ).map(el => el.textContent); +} diff --git a/devtools/client/framework/test/browser_toolbox_getpanelwhenready.js b/devtools/client/framework/test/browser_toolbox_getpanelwhenready.js new file mode 100644 index 0000000000..85436c2925 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_getpanelwhenready.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that getPanelWhenReady returns the correct panel in promise +// resolutions regardless of whether it has opened first. + +var toolbox = null; + +const URL = "data:text/html;charset=utf8,test for getPanelWhenReady"; + +add_task(async function () { + const tab = await addTab(URL); + toolbox = await gDevTools.showToolboxForTab(tab); + + const debuggerPanelPromise = toolbox.getPanelWhenReady("jsdebugger"); + await toolbox.selectTool("jsdebugger"); + const debuggerPanel = await debuggerPanelPromise; + + is( + debuggerPanel, + toolbox.getPanel("jsdebugger"), + "The debugger panel from getPanelWhenReady before loading is the actual panel" + ); + + const debuggerPanel2 = await toolbox.getPanelWhenReady("jsdebugger"); + is( + debuggerPanel2, + toolbox.getPanel("jsdebugger"), + "The debugger panel from getPanelWhenReady after loading is the actual panel" + ); + + await cleanup(); +}); + +async function cleanup() { + await toolbox.destroy(); + gBrowser.removeCurrentTab(); + toolbox = null; +} diff --git a/devtools/client/framework/test/browser_toolbox_highlight.js b/devtools/client/framework/test/browser_toolbox_highlight.js new file mode 100644 index 0000000000..d0712aeed5 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_highlight.js @@ -0,0 +1,122 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +var toolbox = null; + +function test() { + (async function () { + const URL = "data:text/plain;charset=UTF-8,Nothing to see here, move along"; + + const TOOL_ID_1 = "jsdebugger"; + const TOOL_ID_2 = "webconsole"; + await addTab(URL); + + toolbox = await gDevTools.showToolboxForTab(gBrowser.selectedTab, { + toolId: TOOL_ID_1, + hostType: Toolbox.HostType.BOTTOM, + }); + + // select tool 2 + await toolbox.selectTool(TOOL_ID_2); + // and highlight the first one + await highlightTab(TOOL_ID_1); + // to see if it has the proper class. + await checkHighlighted(TOOL_ID_1); + // Now switch back to first tool + await toolbox.selectTool(TOOL_ID_1); + // to check again. But there is no easy way to test if + // it is showing orange or not. + await checkNoHighlightWhenSelected(TOOL_ID_1); + // Switch to tool 2 again + await toolbox.selectTool(TOOL_ID_2); + // and check again. + await checkHighlighted(TOOL_ID_1); + // Highlight another tool + await highlightTab(TOOL_ID_2); + // Check that both tools are highlighted. + await checkHighlighted(TOOL_ID_1); + // Check second tool being both highlighted and selected. + await checkNoHighlightWhenSelected(TOOL_ID_2); + // Select tool 1 + await toolbox.selectTool(TOOL_ID_1); + // Check second tool is still highlighted + await checkHighlighted(TOOL_ID_2); + // Unhighlight the second tool + await unhighlightTab(TOOL_ID_2); + // to see the classes gone. + await checkNoHighlight(TOOL_ID_2); + // Now unhighlight the tool + await unhighlightTab(TOOL_ID_1); + // to see the classes gone. + await checkNoHighlight(TOOL_ID_1); + + // Now close the toolbox and exit. + executeSoon(() => { + toolbox.destroy().then(() => { + toolbox = null; + gBrowser.removeCurrentTab(); + finish(); + }); + }); + })().catch(error => { + ok(false, "There was an error running the test."); + }); +} + +function highlightTab(toolId) { + info(`Highlighting tool ${toolId}'s tab.`); + return toolbox.highlightTool(toolId); +} + +function unhighlightTab(toolId) { + info(`Unhighlighting tool ${toolId}'s tab.`); + return toolbox.unhighlightTool(toolId); +} + +function checkHighlighted(toolId) { + const tab = toolbox.doc.getElementById("toolbox-tab-" + toolId); + ok( + toolbox.isHighlighted(toolId), + `Toolbox.isHighlighted reports ${toolId} as highlighted` + ); + ok( + tab.classList.contains("highlighted"), + `The highlighted class is present in ${toolId}.` + ); + ok( + !tab.classList.contains("selected"), + `The tab is not selected in ${toolId}` + ); +} + +function checkNoHighlightWhenSelected(toolId) { + const tab = toolbox.doc.getElementById("toolbox-tab-" + toolId); + ok( + toolbox.isHighlighted(toolId), + `Toolbox.isHighlighted reports ${toolId} as highlighted` + ); + ok( + tab.classList.contains("highlighted"), + `The highlighted class is present in ${toolId}` + ); + ok( + tab.classList.contains("selected"), + `And the tab is selected, so the orange glow will not be present. in ${toolId}` + ); +} + +function checkNoHighlight(toolId) { + const tab = toolbox.doc.getElementById("toolbox-tab-" + toolId); + ok( + !toolbox.isHighlighted(toolId), + `Toolbox.isHighlighted reports ${toolId} as not highlighted` + ); + ok( + !tab.classList.contains("highlighted"), + `The highlighted class is not present in ${toolId}` + ); +} diff --git a/devtools/client/framework/test/browser_toolbox_hosts.js b/devtools/client/framework/test/browser_toolbox_hosts.js new file mode 100644 index 0000000000..37738865a9 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_hosts.js @@ -0,0 +1,226 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + gDevToolsBrowser, +} = require("resource://devtools/client/framework/devtools-browser.js"); + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); +const { LEFT, RIGHT, BOTTOM, WINDOW } = Toolbox.HostType; +let toolbox; + +// We are opening/close toolboxes many times, +// which introduces long GC pauses between each sub task +// and requires some more time to run in DEBUG builds. +requestLongerTimeout(2); + +const URL = + "data:text/html;charset=utf8,test for opening toolbox in different hosts"; + +add_task(async function () { + const win = await BrowserTestUtils.openNewBrowserWindow(); + win.gBrowser.selectedTab = BrowserTestUtils.addTab(win.gBrowser, URL); + + const tab = win.gBrowser.selectedTab; + toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "webconsole", + hostType: Toolbox.HostType.WINDOW, + }); + const onToolboxClosed = toolbox.once("destroyed"); + ok( + gDevToolsBrowser.hasToolboxOpened(win), + "hasToolboxOpened is true before closing the toolbox" + ); + await BrowserTestUtils.closeWindow(win); + ok( + !gDevToolsBrowser.hasToolboxOpened(win), + "hasToolboxOpened is false after closing the window" + ); + + info("Wait for toolbox to be destroyed after browser window is closed"); + await onToolboxClosed; + toolbox = null; +}); + +add_task(async function runTest() { + info("Create a test tab and open the toolbox"); + const tab = await addTab(URL); + toolbox = await gDevTools.showToolboxForTab(tab, { toolId: "webconsole" }); + + await runHostTests(gBrowser); + await toolbox.destroy(); + + toolbox = null; + gBrowser.removeCurrentTab(); +}); + +// We run the same host switching tests in a private window. +// See Bug 1581093 for an example of issue specific to private windows. +add_task(async function runPrivateWindowTest() { + info("Create a private window + tab and open the toolbox"); + await runHostTestsFromSeparateWindow({ + private: true, + }); +}); + +// We run the same host switching tests in a non-fission window. +// See Bug 1650963 for an example of issue specific to private windows. +add_task(async function runNonFissionWindowTest() { + info("Create a non-fission window + tab and open the toolbox"); + await runHostTestsFromSeparateWindow({ + fission: false, + }); +}); + +async function runHostTestsFromSeparateWindow(options) { + const win = await BrowserTestUtils.openNewBrowserWindow(options); + const browser = win.gBrowser; + browser.selectedTab = BrowserTestUtils.addTab(browser, URL); + + const tab = browser.selectedTab; + toolbox = await gDevTools.showToolboxForTab(tab, { toolId: "webconsole" }); + + await runHostTests(browser); + await toolbox.destroy(); + + toolbox = null; + await BrowserTestUtils.closeWindow(win); +} + +async function runHostTests(browser) { + await testBottomHost(browser); + await testLeftHost(browser); + await testRightHost(browser); + await testWindowHost(browser); + await testToolSelect(); + await testDestroy(browser); + await testRememberHost(); + await testPreviousHost(); +} + +function testBottomHost(browser) { + checkHostType(toolbox, BOTTOM); + + // test UI presence + const panel = browser.getPanel(); + const iframe = panel.querySelector(".devtools-toolbox-bottom-iframe"); + ok(iframe, "toolbox bottom iframe exists"); + + checkToolboxLoaded(iframe); +} + +async function testLeftHost(browser) { + await toolbox.switchHost(LEFT); + checkHostType(toolbox, LEFT); + + // test UI presence + const panel = browser.getPanel(); + const bottom = panel.querySelector(".devtools-toolbox-bottom-iframe"); + ok(!bottom, "toolbox bottom iframe doesn't exist"); + + const iframe = panel.querySelector(".devtools-toolbox-side-iframe"); + ok(iframe, "toolbox side iframe exists"); + + checkToolboxLoaded(iframe); +} + +async function testRightHost(browser) { + await toolbox.switchHost(RIGHT); + checkHostType(toolbox, RIGHT); + + // test UI presence + const panel = browser.getPanel(); + const bottom = panel.querySelector(".devtools-toolbox-bottom-iframe"); + ok(!bottom, "toolbox bottom iframe doesn't exist"); + + const iframe = panel.querySelector(".devtools-toolbox-side-iframe"); + ok(iframe, "toolbox side iframe exists"); + + checkToolboxLoaded(iframe); +} + +async function testWindowHost(browser) { + await toolbox.switchHost(WINDOW); + checkHostType(toolbox, WINDOW); + + const panel = browser.getPanel(); + const sidebar = panel.querySelector(".devtools-toolbox-side-iframe"); + ok(!sidebar, "toolbox sidebar iframe doesn't exist"); + + const win = Services.wm.getMostRecentWindow("devtools:toolbox"); + ok(win, "toolbox separate window exists"); + + const iframe = win.document.querySelector(".devtools-toolbox-window-iframe"); + checkToolboxLoaded(iframe); +} + +async function testToolSelect() { + // make sure we can load a tool after switching hosts + await toolbox.selectTool("inspector"); +} + +async function testDestroy(browser) { + await toolbox.destroy(); + toolbox = await gDevTools.showToolboxForTab(browser.selectedTab); +} + +function testRememberHost() { + // last host was the window - make sure it's the same when re-opening + is(toolbox.hostType, WINDOW, "host remembered"); + + const win = Services.wm.getMostRecentWindow("devtools:toolbox"); + ok(win, "toolbox separate window exists"); +} + +async function testPreviousHost() { + // last host was the window - make sure it's the same when re-opening + is(toolbox.hostType, WINDOW, "host remembered"); + + info("Switching to left"); + await toolbox.switchHost(LEFT); + checkHostType(toolbox, LEFT, WINDOW); + + info("Switching to right"); + await toolbox.switchHost(RIGHT); + checkHostType(toolbox, RIGHT, LEFT); + + info("Switching to bottom"); + await toolbox.switchHost(BOTTOM); + checkHostType(toolbox, BOTTOM, RIGHT); + + info("Switching from bottom to right"); + await toolbox.switchToPreviousHost(); + checkHostType(toolbox, RIGHT, BOTTOM); + + info("Switching from right to bottom"); + await toolbox.switchToPreviousHost(); + checkHostType(toolbox, BOTTOM, RIGHT); + + info("Switching to window"); + await toolbox.switchHost(WINDOW); + checkHostType(toolbox, WINDOW, BOTTOM); + + info("Switching from window to bottom"); + await toolbox.switchToPreviousHost(); + checkHostType(toolbox, BOTTOM, WINDOW); + + info("Forcing the previous host to match the current (bottom)"); + Services.prefs.setCharPref("devtools.toolbox.previousHost", BOTTOM); + + info("Switching from bottom to right (since previous=current=bottom"); + await toolbox.switchToPreviousHost(); + checkHostType(toolbox, RIGHT, BOTTOM); + + info("Forcing the previous host to match the current (right)"); + Services.prefs.setCharPref("devtools.toolbox.previousHost", RIGHT); + info("Switching from right to bottom (since previous=current=side"); + await toolbox.switchToPreviousHost(); + checkHostType(toolbox, BOTTOM, RIGHT); +} + +function checkToolboxLoaded(iframe) { + const tabs = iframe.contentDocument.querySelector(".toolbox-tabs"); + ok(tabs, "toolbox UI has been loaded into iframe"); +} diff --git a/devtools/client/framework/test/browser_toolbox_hosts_size.js b/devtools/client/framework/test/browser_toolbox_hosts_size.js new file mode 100644 index 0000000000..81cce09a67 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_hosts_size.js @@ -0,0 +1,136 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that getPanelWhenReady returns the correct panel in promise +// resolutions regardless of whether it has opened first. + +const URL = "data:text/html;charset=utf8,test for host sizes"; + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function () { + // Set size prefs to make the hosts way too big, so that the size has + // to be clamped to fit into the browser window. + Services.prefs.setIntPref("devtools.toolbox.footer.height", 10000); + Services.prefs.setIntPref("devtools.toolbox.sidebar.width", 10000); + + const tab = await addTab(URL); + const panel = gBrowser.getPanel(); + const { clientHeight: panelHeight, clientWidth: panelWidth } = panel; + const toolbox = await gDevTools.showToolboxForTab(tab); + + is( + panel.clientHeight, + panelHeight, + "Opening the toolbox hasn't changed the height of the panel" + ); + is( + panel.clientWidth, + panelWidth, + "Opening the toolbox hasn't changed the width of the panel" + ); + + let iframe = panel.querySelector(".devtools-toolbox-bottom-iframe"); + is( + iframe.clientHeight, + panelHeight - 25, + "The iframe fits within the available space" + ); + + iframe.style.height = "10000px"; // Set height to something unreasonably large. + Assert.less( + iframe.clientHeight, + panelHeight, + `The iframe fits within the available space (${iframe.clientHeight} < ${panelHeight})` + ); + + await toolbox.switchHost(Toolbox.HostType.RIGHT); + iframe = panel.querySelector(".devtools-toolbox-side-iframe"); + iframe.style.minWidth = "1px"; // Disable the min width set in css + is( + iframe.clientWidth, + panelWidth - 25, + "The iframe fits within the available space" + ); + + const oldWidth = iframe.style.width; + iframe.style.width = "10000px"; // Set width to something unreasonably large. + Assert.less( + iframe.clientWidth, + panelWidth, + `The iframe fits within the available space (${iframe.clientWidth} < ${panelWidth})` + ); + iframe.style.width = oldWidth; + + // on shutdown, the sidebar width will be set to the clientWidth of the iframe + const expectedWidth = iframe.clientWidth; + + info("waiting for cleanup"); + await cleanup(toolbox); + // Wait until the toolbox-host-manager was destroyed and updated the preferences + // to avoid side effects in the next test. + await waitUntil(() => { + const savedWidth = Services.prefs.getIntPref( + "devtools.toolbox.sidebar.width" + ); + info(`waiting for saved pref: ${savedWidth}, ${expectedWidth}`); + return savedWidth === expectedWidth; + }); +}); + +add_task(async function () { + // Set size prefs to something reasonable, so we can check to make sure + // they are being set properly. + Services.prefs.setIntPref("devtools.toolbox.footer.height", 100); + Services.prefs.setIntPref("devtools.toolbox.sidebar.width", 100); + + const tab = await addTab(URL); + const panel = gBrowser.getPanel(); + const { clientHeight: panelHeight, clientWidth: panelWidth } = panel; + const toolbox = await gDevTools.showToolboxForTab(tab); + + is( + panel.clientHeight, + panelHeight, + "Opening the toolbox hasn't changed the height of the panel" + ); + is( + panel.clientWidth, + panelWidth, + "Opening the toolbox hasn't changed the width of the panel" + ); + + let iframe = panel.querySelector(".devtools-toolbox-bottom-iframe"); + is(iframe.clientHeight, 100, "The iframe is resized properly"); + const horzSplitter = panel.querySelector(".devtools-horizontal-splitter"); + dragElement(horzSplitter, { startX: 1, startY: 1, deltaX: 0, deltaY: -50 }); + is(iframe.clientHeight, 150, "The iframe was resized by the splitter"); + + await toolbox.switchHost(Toolbox.HostType.RIGHT); + iframe = panel.querySelector(".devtools-toolbox-side-iframe"); + iframe.style.minWidth = "1px"; // Disable the min width set in css + is(iframe.clientWidth, 100, "The iframe is resized properly"); + + info("Resize the toolbox manually by 50 pixels"); + const sideSplitter = panel.querySelector(".devtools-side-splitter"); + dragElement(sideSplitter, { startX: 1, startY: 1, deltaX: -50, deltaY: 0 }); + is(iframe.clientWidth, 150, "The iframe was resized by the splitter"); + + await cleanup(toolbox); +}); + +function dragElement(el, { startX, startY, deltaX, deltaY }) { + const endX = startX + deltaX; + const endY = startY + deltaY; + EventUtils.synthesizeMouse(el, startX, startY, { type: "mousedown" }, window); + EventUtils.synthesizeMouse(el, endX, endY, { type: "mousemove" }, window); + EventUtils.synthesizeMouse(el, endX, endY, { type: "mouseup" }, window); +} + +async function cleanup(toolbox) { + Services.prefs.clearUserPref("devtools.toolbox.host"); + Services.prefs.clearUserPref("devtools.toolbox.footer.height"); + Services.prefs.clearUserPref("devtools.toolbox.sidebar.width"); + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +} diff --git a/devtools/client/framework/test/browser_toolbox_hosts_telemetry.js b/devtools/client/framework/test/browser_toolbox_hosts_telemetry.js new file mode 100644 index 0000000000..92992048dd --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_hosts_telemetry.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); +const { LEFT, RIGHT, BOTTOM, WINDOW } = Toolbox.HostType; + +const URL = "data:text/html;charset=utf8,browser_toolbox_hosts_telemetry.js"; + +add_task(async function () { + startTelemetry(); + + info("Create a test tab and open the toolbox"); + const tab = await addTab(URL); + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "webconsole", + }); + + await changeToolboxHost(toolbox); + await checkResults(); +}); + +async function changeToolboxHost(toolbox) { + info("Switch toolbox host"); + await toolbox.switchHost(RIGHT); + await toolbox.switchHost(WINDOW); + await toolbox.switchHost(BOTTOM); + await toolbox.switchHost(LEFT); + await toolbox.switchHost(RIGHT); + await toolbox.switchHost(WINDOW); + await toolbox.switchHost(BOTTOM); + await toolbox.switchHost(LEFT); + await toolbox.switchHost(RIGHT); +} + +function checkResults() { + // Check for: + // - 3 "bottom" entries. + // - 2 "left" entries. + // - 3 "right" entries. + // - 2 "window" entries. + checkTelemetry( + "DEVTOOLS_TOOLBOX_HOST", + "", + { 0: 3, 1: 3, 2: 2, 4: 2, 5: 0 }, + "array" + ); +} diff --git a/devtools/client/framework/test/browser_toolbox_keyboard_navigation.js b/devtools/client/framework/test/browser_toolbox_keyboard_navigation.js new file mode 100644 index 0000000000..17ba9efcf9 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_keyboard_navigation.js @@ -0,0 +1,136 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests keyboard navigation of devtools tabbar. + +const TEST_URL = + "data:text/html;charset=utf8,test page for toolbar keyboard navigation"; + +function containsFocus(aDoc, aElm) { + let elm = aDoc.activeElement; + while (elm) { + if (elm === aElm) { + return true; + } + elm = elm.parentNode; + } + return false; +} + +add_task(async function () { + info("Create a test tab and open the toolbox"); + const toolbox = await openNewTabAndToolbox(TEST_URL, "webconsole"); + const doc = toolbox.doc; + + const toolbar = doc.querySelector(".devtools-tabbar"); + const toolbarControls = [ + ...toolbar.querySelectorAll(".devtools-tab, button"), + ].filter( + elm => + !elm.hidden && + doc.defaultView.getComputedStyle(elm).getPropertyValue("display") !== + "none" + ); + + // Put the keyboard focus onto the first toolbar control. + toolbarControls[0].focus(); + ok(containsFocus(doc, toolbar), "Focus is within the toolbar"); + + // Move the focus away from toolbar to a next focusable element. + EventUtils.synthesizeKey("KEY_Tab"); + ok(!containsFocus(doc, toolbar), "Focus is outside of the toolbar"); + + // Move the focus back to the toolbar. + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + ok(containsFocus(doc, toolbar), "Focus is within the toolbar again"); + + // Move through the toolbar forward using the right arrow key. + for (let i = 0; i < toolbarControls.length; ++i) { + is(doc.activeElement.id, toolbarControls[i].id, "New control is focused"); + if (i < toolbarControls.length - 1) { + EventUtils.synthesizeKey("KEY_ArrowRight"); + } + } + + // Move the focus away from toolbar to a next focusable element. + EventUtils.synthesizeKey("KEY_Tab"); + ok(!containsFocus(doc, toolbar), "Focus is outside of the toolbar"); + + // Move the focus back to the toolbar. + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + ok(containsFocus(doc, toolbar), "Focus is within the toolbar again"); + + // Move through the toolbar backward using the left arrow key. + for (let i = toolbarControls.length - 1; i >= 0; --i) { + is(doc.activeElement.id, toolbarControls[i].id, "New control is focused"); + if (i > 0) { + EventUtils.synthesizeKey("KEY_ArrowLeft"); + } + } + + // Move focus to the 3rd (non-first) toolbar control. + const expectedFocusedControl = toolbarControls[2]; + EventUtils.synthesizeKey("KEY_ArrowRight"); + EventUtils.synthesizeKey("KEY_ArrowRight"); + is(doc.activeElement.id, expectedFocusedControl.id, "New control is focused"); + + // Move the focus away from toolbar to a next focusable element. + EventUtils.synthesizeKey("KEY_Tab"); + ok(!containsFocus(doc, toolbar), "Focus is outside of the toolbar"); + + // Move the focus back to the toolbar, ensure we land on the last active + // descendant control. + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + is(doc.activeElement.id, expectedFocusedControl.id, "New control is focused"); +}); + +// Test that moving the focus of tab button and selecting it. +add_task(async function () { + info("Create a test tab and open the toolbox"); + const toolbox = await openNewTabAndToolbox(TEST_URL, "inspector"); + const doc = toolbox.doc; + + const toolbar = doc.querySelector(".toolbox-tabs"); + const tabButtons = toolbar.querySelectorAll(".devtools-tab, button"); + const win = tabButtons[0].ownerDocument.defaultView; + + // Put the keyboard focus onto the first tab button. + tabButtons[0].focus(); + ok(containsFocus(doc, toolbar), "Focus is within the toolbox"); + is(doc.activeElement.id, tabButtons[0].id, "First tab button is focused."); + + // Move the focused tab and select it by using enter key. + let onKeyEvent = once(win, "keydown"); + EventUtils.synthesizeKey("KEY_ArrowRight"); + await onKeyEvent; + + let onceSelected = toolbox.once("webconsole-selected"); + EventUtils.synthesizeKey("Enter"); + await onceSelected; + is( + doc.activeElement.id, + "toolbox-panel-iframe-" + toolbox.currentToolId, + "Selected tool frame is now focused." + ); + + // Webconsole steal the focus from button after sending "webconsole-selected" + // event. + tabButtons[1].focus(); + + // Return the focused tab with space key. + onKeyEvent = once(win, "keydown"); + EventUtils.synthesizeKey("KEY_ArrowLeft"); + await onKeyEvent; + + onceSelected = toolbox.once("inspector-selected"); + EventUtils.synthesizeKey(" "); + await onceSelected; + + is( + doc.activeElement.id, + "toolbox-panel-iframe-" + toolbox.currentToolId, + "Selected tool frame is now focused." + ); +}); diff --git a/devtools/client/framework/test/browser_toolbox_keyboard_navigation_notification_box.js b/devtools/client/framework/test/browser_toolbox_keyboard_navigation_notification_box.js new file mode 100644 index 0000000000..135559cb2f --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_keyboard_navigation_notification_box.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests keyboard navigation of the DevTools notification box. + +// The test page attempts to load a stylesheet at an invalid URL which will +// trigger a devtools notification to show up on top of the window. +const TEST_PAGE = ``; +const TEST_URL = `data:text/html;charset=utf8,${TEST_PAGE}`; + +add_task(async function () { + info("Create a test tab and open the toolbox"); + const toolbox = await openNewTabAndToolbox(TEST_URL, "styleeditor"); + const doc = toolbox.doc; + + info("Wait until the notification box displays the stylesheet warning"); + const notificationBox = await waitFor(() => + doc.querySelector(".notificationbox") + ); + + ok( + notificationBox.querySelector(".notification"), + "A notification is rendered" + ); + + const toolbar = doc.querySelector(".devtools-tabbar"); + const tabButtons = toolbar.querySelectorAll(".devtools-tab, button"); + + // Put the keyboard focus onto the first tab button. + tabButtons[0].focus(); + is(doc.activeElement.id, tabButtons[0].id, "First tab button is focused."); + + // Move the focus to the notification box. + info("Send a shift+tab key event to focus the previous focusable element"); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + is( + doc.activeElement, + notificationBox.querySelector(".messageCloseButton"), + "The focus is on the close button of the notification" + ); + + info("Send a vk_space key event to click on the close button"); + EventUtils.synthesizeKey("VK_SPACE"); + + info("Wait until the notification is removed"); + await waitUntil(() => !notificationBox.querySelector(".notificationbox")); +}); diff --git a/devtools/client/framework/test/browser_toolbox_meatball.js b/devtools/client/framework/test/browser_toolbox_meatball.js new file mode 100644 index 0000000000..04d9a3a0cd --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_meatball.js @@ -0,0 +1,135 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Sanity test for meatball menu. +// +// We also use this to test the common Menu* components since we don't currently +// have a means of testing React components in isolation. + +const { + focusableSelector, +} = require("resource://devtools/client/shared/focus.js"); +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function () { + const tab = await addTab("about:blank"); + const toolbox = await openToolboxForTab( + tab, + "inspector", + Toolbox.HostType.BOTTOM + ); + + info("Check opening meatball menu by clicking the menu button"); + await openMeatballMenuWithClick(toolbox); + const menuDockToBottom = toolbox.doc.getElementById( + "toolbox-meatball-menu-dock-bottom" + ); + Assert.strictEqual( + menuDockToBottom.getAttribute("aria-checked"), + "true", + "menuDockToBottom has checked" + ); + + info("Check closing meatball menu by clicking outside the popup area"); + await closeMeatballMenuWithClick(toolbox); + + info("Check moving the focus element with key event"); + await openMeatballMenuWithClick(toolbox); + checkKeyHandling(toolbox); + + info("Check closing meatball menu with escape key"); + EventUtils.synthesizeKey("VK_ESCAPE", {}, toolbox.win); + await waitForMeatballMenuToClose(toolbox); + + // F1 should trigger the settings panel and close the menu at the same time. + info("Check closing meatball menu with F1 key"); + await openMeatballMenuWithClick(toolbox); + EventUtils.synthesizeKey("VK_F1", {}, toolbox.win); + await waitForMeatballMenuToClose(toolbox); + + await toolbox.destroy(); +}); + +async function openMeatballMenuWithClick(toolbox) { + const meatballButton = toolbox.doc.getElementById( + "toolbox-meatball-menu-button" + ); + await waitUntil(() => meatballButton.style.pointerEvents !== "none"); + EventUtils.synthesizeMouseAtCenter(meatballButton, {}, toolbox.win); + + const panel = toolbox.doc.querySelectorAll(".tooltip-xul-wrapper"); + const shownListener = new Promise(res => { + panel[0].addEventListener("popupshown", res, { once: true }); + }); + + const menuPanel = toolbox.doc.getElementById( + "toolbox-meatball-menu-button-panel" + ); + ok(menuPanel, "meatball panel is available"); + + info("Waiting for the menu panel to be displayed"); + + await shownListener; + await waitUntil(() => menuPanel.classList.contains("tooltip-visible")); +} + +async function closeMeatballMenuWithClick(toolbox) { + const meatballButton = toolbox.doc.getElementById( + "toolbox-meatball-menu-button" + ); + await waitUntil( + () => toolbox.win.getComputedStyle(meatballButton).pointerEvents === "none" + ); + meatballButton.click(); + + const menuPanel = toolbox.doc.getElementById( + "toolbox-meatball-menu-button-panel" + ); + ok(menuPanel, "meatball panel is available"); + + info("Waiting for the menu panel to be hidden"); + await waitUntil(() => !menuPanel.classList.contains("tooltip-visible")); +} + +async function waitForMeatballMenuToClose(toolbox) { + const menuPanel = toolbox.doc.getElementById( + "toolbox-meatball-menu-button-panel" + ); + ok(menuPanel, "meatball panel is available"); + + info("Waiting for the menu panel to be hidden"); + await waitUntil(() => !menuPanel.classList.contains("tooltip-visible")); +} + +function checkKeyHandling(toolbox) { + const selectable = toolbox.doc + .getElementById("toolbox-meatball-menu") + .querySelectorAll(focusableSelector); + + EventUtils.synthesizeKey("VK_DOWN", {}, toolbox.win); + is( + toolbox.doc.activeElement, + selectable[0], + "First item selected with down key." + ); + EventUtils.synthesizeKey("VK_UP", {}, toolbox.win); + is( + toolbox.doc.activeElement, + selectable[selectable.length - 1], + "End item selected with up key." + ); + EventUtils.synthesizeKey("VK_HOME", {}, toolbox.win); + is( + toolbox.doc.activeElement, + selectable[0], + "First item selected with home key." + ); + EventUtils.synthesizeKey("VK_END", {}, toolbox.win); + is( + toolbox.doc.activeElement, + selectable[selectable.length - 1], + "End item selected with down key." + ); +} diff --git a/devtools/client/framework/test/browser_toolbox_options.js b/devtools/client/framework/test/browser_toolbox_options.js new file mode 100644 index 0000000000..3b5d7f2661 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options.js @@ -0,0 +1,556 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that changing preferences in the options panel updates the prefs +// and toggles appropriate things in the toolbox. + +var doc = null, + toolbox = null, + panelWin = null, + modifiedPrefs = []; +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); +const { PrefObserver } = require("resource://devtools/client/shared/prefs.js"); + +add_task(async function () { + const URL = + "data:text/html;charset=utf8,test for dynamically registering " + + "and unregistering tools"; + registerNewTool(); + const tab = await addTab(URL); + toolbox = await gDevTools.showToolboxForTab(tab); + + doc = toolbox.doc; + await registerNewPerToolboxTool(); + await testSelectTool(); + await testOptionsShortcut(); + await testOptions(); + await testToggleTools(); + + // Test that registered WebExtensions becomes entries in the + // options panel and toggling their checkbox toggle the related + // preference. + await registerNewWebExtensions(); + await testToggleWebExtensions(); + + await cleanup(); +}); + +function registerNewTool() { + const toolDefinition = { + id: "testTool", + isToolSupported: () => true, + visibilityswitch: "devtools.test-tool.enabled", + url: "about:blank", + label: "someLabel", + }; + + ok(gDevTools, "gDevTools exists"); + ok( + !gDevTools.getToolDefinitionMap().has("testTool"), + "The tool is not registered" + ); + + gDevTools.registerTool(toolDefinition); + ok( + gDevTools.getToolDefinitionMap().has("testTool"), + "The tool is registered" + ); +} + +// Register a fake WebExtension to check that it is +// listed in the toolbox options. +function registerNewWebExtensions() { + // Register some fake extensions and init the related preferences + // (similarly to ext-devtools.js). + for (let i = 0; i < 2; i++) { + const extPref = `devtools.webextensions.fakeExtId${i}.enabled`; + Services.prefs.setBoolPref(extPref, true); + + toolbox.registerWebExtension(`fakeUUID${i}`, { + name: `Fake WebExtension ${i}`, + pref: extPref, + }); + } +} + +function registerNewPerToolboxTool() { + const toolDefinition = { + id: "test-pertoolbox-tool", + isToolSupported: () => true, + visibilityswitch: "devtools.test-pertoolbox-tool.enabled", + url: "about:blank", + label: "perToolboxSomeLabel", + }; + + ok(gDevTools, "gDevTools exists"); + ok( + !gDevTools.getToolDefinitionMap().has("test-pertoolbox-tool"), + "The per-toolbox tool is not registered globally" + ); + + ok(toolbox, "toolbox exists"); + ok( + !toolbox.hasAdditionalTool("test-pertoolbox-tool"), + "The per-toolbox tool is not yet registered to the toolbox" + ); + + toolbox.addAdditionalTool(toolDefinition); + + ok( + !gDevTools.getToolDefinitionMap().has("test-pertoolbox-tool"), + "The per-toolbox tool is not registered globally" + ); + ok( + toolbox.hasAdditionalTool("test-pertoolbox-tool"), + "The per-toolbox tool has been registered to the toolbox" + ); +} + +async function testSelectTool() { + info("Checking to make sure that the options panel can be selected."); + + const onceSelected = toolbox.once("options-selected"); + toolbox.selectTool("options"); + await onceSelected; + ok(true, "Toolbox selected via selectTool method"); +} + +async function testOptionsShortcut() { + info("Selecting another tool, then reselecting options panel with keyboard."); + + await toolbox.selectTool("webconsole"); + is(toolbox.currentToolId, "webconsole", "webconsole is selected"); + synthesizeKeyShortcut(L10N.getStr("toolbox.help.key")); + is(toolbox.currentToolId, "options", "Toolbox selected via shortcut key"); + synthesizeKeyShortcut(L10N.getStr("toolbox.help.key")); + is(toolbox.currentToolId, "webconsole", "webconsole is reselected"); + synthesizeKeyShortcut(L10N.getStr("toolbox.help.key")); + is(toolbox.currentToolId, "options", "Toolbox selected via shortcut key"); +} + +async function testOptions() { + const tool = toolbox.getPanel("options"); + panelWin = tool.panelWin; + const prefNodes = tool.panelDoc.querySelectorAll( + "input[type=checkbox][data-pref]" + ); + + // Store modified pref names so that they can be cleared on error. + for (const node of tool.panelDoc.querySelectorAll("[data-pref]")) { + const pref = node.getAttribute("data-pref"); + modifiedPrefs.push(pref); + } + + for (const node of prefNodes) { + const prefValue = GetPref(node.getAttribute("data-pref")); + + // Test clicking the checkbox for each options pref + await testMouseClick(node, prefValue); + + // Do again with opposite values to reset prefs + await testMouseClick(node, !prefValue); + } + + const prefSelects = tool.panelDoc.querySelectorAll("select[data-pref]"); + for (const node of prefSelects) { + await testSelect(node); + } +} + +async function testSelect(select) { + const pref = select.getAttribute("data-pref"); + const options = Array.from(select.options); + info("Checking select for: " + pref); + + is( + `${select.options[select.selectedIndex].value}`, + `${GetPref(pref)}`, + "select starts out selected" + ); + + for (const option of options) { + if (options.indexOf(option) === select.selectedIndex) { + continue; + } + + const observer = new PrefObserver("devtools."); + + let changeSeen = false; + const changeSeenPromise = new Promise(resolve => { + observer.once(pref, () => { + changeSeen = true; + is( + `${GetPref(pref)}`, + `${option.value}`, + "Preference been switched for " + pref + ); + resolve(); + }); + }); + + select.selectedIndex = options.indexOf(option); + const changeEvent = new Event("change"); + select.dispatchEvent(changeEvent); + + await changeSeenPromise; + + ok(changeSeen, "Correct pref was changed"); + observer.destroy(); + } +} + +async function testMouseClick(node, prefValue) { + const observer = new PrefObserver("devtools."); + + const pref = node.getAttribute("data-pref"); + let changeSeen = false; + const changeSeenPromise = new Promise(resolve => { + observer.once(pref, () => { + changeSeen = true; + is(GetPref(pref), !prefValue, "New value is correct for " + pref); + resolve(); + }); + }); + + node.scrollIntoView(); + + // We use executeSoon here to ensure that the element is in view and + // clickable. + executeSoon(function () { + info("Click event synthesized for pref " + pref); + EventUtils.synthesizeMouseAtCenter(node, {}, panelWin); + }); + + await changeSeenPromise; + + ok(changeSeen, "Correct pref was changed"); + observer.destroy(); +} + +async function testToggleWebExtensions() { + const disabledExtensions = new Set(); + const toggleableWebExtensions = toolbox.listWebExtensions(); + + function toggleWebExtension(node) { + node.scrollIntoView(); + EventUtils.synthesizeMouseAtCenter(node, {}, panelWin); + } + + function assertExpectedDisabledExtensions() { + for (const ext of toggleableWebExtensions) { + if (disabledExtensions.has(ext)) { + ok( + !toolbox.isWebExtensionEnabled(ext.uuid), + `The WebExtension "${ext.name}" should be disabled` + ); + } else { + ok( + toolbox.isWebExtensionEnabled(ext.uuid), + `The WebExtension "${ext.name}" should be enabled` + ); + } + } + } + + function assertAllExtensionsDisabled() { + const enabledUUIDs = toggleableWebExtensions + .filter(ext => toolbox.isWebExtensionEnabled(ext.uuid)) + .map(ext => ext.uuid); + + Assert.deepEqual( + enabledUUIDs, + [], + "All the registered WebExtensions should be disabled" + ); + } + + function assertAllExtensionsEnabled() { + const disabledUUIDs = toolbox + .listWebExtensions() + .filter(ext => !toolbox.isWebExtensionEnabled(ext.uuid)) + .map(ext => ext.uuid); + + Assert.deepEqual( + disabledUUIDs, + [], + "All the registered WebExtensions should be enabled" + ); + } + + function getWebExtensionNodes() { + const toolNodes = panelWin.document.querySelectorAll( + "#default-tools-box input[type=checkbox]:not([data-unsupported])," + + "#additional-tools-box input[type=checkbox]:not([data-unsupported])" + ); + + return [...toolNodes].filter(node => { + return toggleableWebExtensions.some( + ({ uuid }) => node.getAttribute("id") === `webext-${uuid}` + ); + }); + } + + let webExtensionNodes = getWebExtensionNodes(); + + is( + webExtensionNodes.length, + toggleableWebExtensions.length, + "There should be a toggle checkbox for every WebExtension registered" + ); + + for (const ext of toggleableWebExtensions) { + ok( + toolbox.isWebExtensionEnabled(ext.uuid), + `The WebExtension "${ext.name}" is initially enabled` + ); + } + + // Store modified pref names so that they can be cleared on error. + for (const ext of toggleableWebExtensions) { + modifiedPrefs.push(ext.pref); + } + + // Turn each registered WebExtension to disabled. + for (const node of webExtensionNodes) { + toggleWebExtension(node); + + const toggledExt = toggleableWebExtensions.find(ext => { + return node.id == `webext-${ext.uuid}`; + }); + ok(toggledExt, "Found a WebExtension for the checkbox element"); + disabledExtensions.add(toggledExt); + + assertExpectedDisabledExtensions(); + } + + assertAllExtensionsDisabled(); + + // Turn each registered WebExtension to enabled. + for (const node of webExtensionNodes) { + toggleWebExtension(node); + + const toggledExt = toggleableWebExtensions.find(ext => { + return node.id == `webext-${ext.uuid}`; + }); + ok(toggledExt, "Found a WebExtension for the checkbox element"); + disabledExtensions.delete(toggledExt); + + assertExpectedDisabledExtensions(); + } + + assertAllExtensionsEnabled(); + + // Unregister the WebExtensions one by one, and check that only the expected + // ones have been unregistered, and the remaining onea are still listed. + for (const ext of toggleableWebExtensions) { + ok( + !!toolbox.listWebExtensions().length, + "There should still be extensions registered" + ); + toolbox.unregisterWebExtension(ext.uuid); + + const registeredUUIDs = toolbox.listWebExtensions().map(item => item.uuid); + ok( + !registeredUUIDs.includes(ext.uuid), + `the WebExtension "${ext.name}" should have been unregistered` + ); + + webExtensionNodes = getWebExtensionNodes(); + + const checkboxEl = webExtensionNodes.find( + el => el.id === `webext-${ext.uuid}` + ); + is( + checkboxEl, + undefined, + "The unregistered WebExtension checkbox should have been removed" + ); + + is( + registeredUUIDs.length, + webExtensionNodes.length, + "There should be the expected number of WebExtensions checkboxes" + ); + } + + is( + toolbox.listWebExtensions().length, + 0, + "All WebExtensions have been unregistered" + ); + + webExtensionNodes = getWebExtensionNodes(); + + is( + webExtensionNodes.length, + 0, + "There should not be any checkbox for the unregistered WebExtensions" + ); +} + +function getToolNode(id) { + return panelWin.document.getElementById(id); +} + +async function testToggleTools() { + const toolNodes = panelWin.document.querySelectorAll( + "#default-tools-box input[type=checkbox]:not([data-unsupported])," + + "#additional-tools-box input[type=checkbox]:not([data-unsupported])" + ); + const toolNodeIds = [...toolNodes].map(node => node.id); + const enabledToolIds = [...toolNodes] + .filter(node => node.checked) + .map(node => node.id); + + const toggleableTools = gDevTools + .getDefaultTools() + .filter(tool => { + return tool.visibilityswitch; + }) + .concat(gDevTools.getAdditionalTools()) + .concat(toolbox.getAdditionalTools()); + + for (const node of toolNodes) { + const id = node.getAttribute("id"); + ok( + toggleableTools.some(tool => tool.id === id), + "There should be a toggle checkbox for: " + id + ); + } + + // Store modified pref names so that they can be cleared on error. + for (const tool of toggleableTools) { + const pref = tool.visibilityswitch; + modifiedPrefs.push(pref); + } + + // Toggle each tool + for (const id of toolNodeIds) { + await toggleTool(getToolNode(id)); + } + + // Toggle again to reset tool enablement state + for (const id of toolNodeIds) { + await toggleTool(getToolNode(id)); + } + + // Test that a tool can still be added when no tabs are present: + // Disable all tools + for (const id of enabledToolIds) { + await toggleTool(getToolNode(id)); + } + // Re-enable the tools which are enabled by default + for (const id of enabledToolIds) { + await toggleTool(getToolNode(id)); + } + + // Toggle first, middle, and last tools to ensure that toolbox tabs are + // inserted in order + const firstToolId = toolNodeIds[0]; + const middleToolId = toolNodeIds[(toolNodeIds.length / 2) | 0]; + const lastToolId = toolNodeIds[toolNodeIds.length - 1]; + + await toggleTool(getToolNode(firstToolId)); + await toggleTool(getToolNode(firstToolId)); + await toggleTool(getToolNode(middleToolId)); + await toggleTool(getToolNode(middleToolId)); + await toggleTool(getToolNode(lastToolId)); + await toggleTool(getToolNode(lastToolId)); +} + +/** + * Toggle tool node checkbox. Note: because toggling the checkbox will result in + * re-rendering of the tool list, we must re-query the checkboxes every time. + */ +async function toggleTool(node) { + const toolId = node.getAttribute("id"); + + const registeredPromise = new Promise(resolve => { + if (node.checked) { + gDevTools.once( + "tool-unregistered", + checkUnregistered.bind(null, toolId, resolve) + ); + } else { + gDevTools.once( + "tool-registered", + checkRegistered.bind(null, toolId, resolve) + ); + } + }); + node.scrollIntoView(); + EventUtils.synthesizeMouseAtCenter(node, {}, panelWin); + + await registeredPromise; +} + +function checkUnregistered(toolId, resolve, data) { + if (data == toolId) { + ok(true, "Correct tool removed"); + // checking tab on the toolbox + ok( + !doc.getElementById("toolbox-tab-" + toolId), + "Tab removed for " + toolId + ); + } else { + ok(false, "Something went wrong, " + toolId + " was not unregistered"); + } + resolve(); +} + +async function checkRegistered(toolId, resolve, data) { + if (data == toolId) { + ok(true, "Correct tool added back"); + // checking tab on the toolbox + const button = await lookupButtonForToolId(toolId); + ok(button, "Tab added back for " + toolId); + } else { + ok(false, "Something went wrong, " + toolId + " was not registered"); + } + resolve(); +} + +function GetPref(name) { + const type = Services.prefs.getPrefType(name); + switch (type) { + case Services.prefs.PREF_STRING: + return Services.prefs.getCharPref(name); + case Services.prefs.PREF_INT: + return Services.prefs.getIntPref(name); + case Services.prefs.PREF_BOOL: + return Services.prefs.getBoolPref(name); + default: + throw new Error("Unknown type"); + } +} + +/** + * Find the button from specified toolId. + * Generally, button which access to the tool panel is in toolbox or + * tools menu(in the Chevron menu). + */ +async function lookupButtonForToolId(toolId) { + let button = doc.getElementById("toolbox-tab-" + toolId); + if (!button) { + // search from the tools menu. + await openChevronMenu(toolbox); + button = doc.querySelector("#tools-chevron-menupopup-" + toolId); + + await closeChevronMenu(toolbox); + } + return button; +} + +async function cleanup() { + gDevTools.unregisterTool("testTool"); + await toolbox.destroy(); + gBrowser.removeCurrentTab(); + for (const pref of modifiedPrefs) { + Services.prefs.clearUserPref(pref); + } + toolbox = doc = panelWin = modifiedPrefs = null; +} diff --git a/devtools/client/framework/test/browser_toolbox_options_disable_buttons.js b/devtools/client/framework/test/browser_toolbox_options_disable_buttons.js new file mode 100644 index 0000000000..417970bc07 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_buttons.js @@ -0,0 +1,270 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let TEST_URL = + "data:text/html;charset=utf8,test for dynamically " + + "registering and unregistering tools"; + +// The frames button is only shown if the page has at least one iframe so we +// need to add one to the test page. +TEST_URL += ''; +// The error count button is only shown if there are errors on the page +TEST_URL += ''; + +var modifiedPrefs = []; +registerCleanupFunction(() => { + for (const pref of modifiedPrefs) { + Services.prefs.clearUserPref(pref); + } +}); + +const TOGGLE_BUTTONS = [ + "command-button-measure", + "command-button-rulers", + "command-button-responsive", + "command-button-pick", +]; + +add_task(async function test() { + const tab = await addTab(TEST_URL); + let toolbox = await gDevTools.showToolboxForTab(tab); + const optionsPanelWin = await selectOptionsPanel(toolbox); + await testToggleToolboxButtons(toolbox, optionsPanelWin); + toolbox = await testPrefsAreRespectedWhenReopeningToolbox(); + await testButtonStateOnClick(toolbox); + + await toolbox.destroy(); +}); + +async function selectOptionsPanel(toolbox) { + info("Selecting the options panel"); + + const onOptionsSelected = toolbox.once("options-selected"); + toolbox.selectTool("options"); + const optionsPanel = await onOptionsSelected; + ok(true, "Options panel selected via selectTool method"); + return optionsPanel.panelWin; +} + +async function testToggleToolboxButtons(toolbox, optionsPanelWin) { + const checkNodes = [ + ...optionsPanelWin.document.querySelectorAll( + "#enabled-toolbox-buttons-box input[type=checkbox]" + ), + ]; + + // Filter out all the buttons which are not supported on the current target. + // (DevTools Experimental Preferences etc...) + const toolbarButtons = toolbox.toolbarButtons.filter(tool => + tool.isToolSupported(toolbox) + ); + + const visibleToolbarButtons = toolbarButtons.filter(tool => tool.isVisible); + + const toolbarButtonNodes = [ + ...toolbox.doc.querySelectorAll(".command-button"), + ]; + + is( + checkNodes.length, + toolbarButtons.length, + "All of the buttons are toggleable." + ); + is( + visibleToolbarButtons.length, + toolbarButtonNodes.length, + "All of the DOM buttons are toggleable." + ); + + for (const tool of toolbarButtons) { + const id = tool.id; + const matchedCheckboxes = checkNodes.filter(node => node.id === id); + const matchedButtons = toolbarButtonNodes.filter( + button => button.id === id + ); + if (tool.isVisible) { + is( + matchedCheckboxes.length, + 1, + "There should be a single toggle checkbox for: " + id + ); + is( + matchedCheckboxes[0].nextSibling.textContent, + tool.description, + "The label for checkbox matches the tool definition." + ); + is( + matchedButtons.length, + 1, + "There should be a DOM button for the visible: " + id + ); + + // The error count button title isn't its description + if (id !== "command-button-errorcount") { + is( + matchedButtons[0].getAttribute("title"), + tool.description, + "The tooltip for button matches the tool definition." + ); + } + + if (TOGGLE_BUTTONS.includes(id)) { + is( + matchedButtons[0].getAttribute("aria-pressed"), + "false", + `The aria-pressed attribute is set to false for ${id} button` + ); + } else { + is( + matchedButtons[0].getAttribute("aria-pressed"), + null, + `The ${id} button does not have the aria-pressed attribute` + ); + } + } else { + is( + matchedButtons.length, + 0, + "There should not be a DOM button for the invisible: " + id + ); + } + } + + // Store modified pref names so that they can be cleared on error. + for (const tool of toolbarButtons) { + const pref = tool.visibilityswitch; + modifiedPrefs.push(pref); + } + + // Try checking each checkbox, making sure that it changes the preference + for (const node of checkNodes) { + const tool = toolbarButtons.filter( + commandButton => commandButton.id === node.id + )[0]; + const isVisible = getBoolPref(tool.visibilityswitch); + + testPreferenceAndUIStateIsConsistent(toolbox, optionsPanelWin); + node.click(); + testPreferenceAndUIStateIsConsistent(toolbox, optionsPanelWin); + + const isVisibleAfterClick = getBoolPref(tool.visibilityswitch); + + is( + isVisible, + !isVisibleAfterClick, + "Clicking on the node should have toggled visibility preference for " + + tool.visibilityswitch + ); + + if (isVisibleAfterClick) { + const matchedButton = toolbox.doc.getElementById(tool.id); + if (TOGGLE_BUTTONS.includes(tool.id)) { + is( + matchedButton.getAttribute("aria-pressed"), + "false", + `The aria-pressed attribute is set to false for ${tool.id} button` + ); + } else { + is( + matchedButton.getAttribute("aria-pressed"), + null, + `The ${tool.id} button does not have the aria-pressed attribute` + ); + } + } + } +} + +async function testPrefsAreRespectedWhenReopeningToolbox() { + info("Closing toolbox to test after reopening"); + await gDevTools.closeToolboxForTab(gBrowser.selectedTab); + + const toolbox = await gDevTools.showToolboxForTab(gBrowser.selectedTab); + const optionsPanelWin = await selectOptionsPanel(toolbox); + + info("Toolbox has been reopened. Checking UI state."); + await testPreferenceAndUIStateIsConsistent(toolbox, optionsPanelWin); + return toolbox; +} + +function testPreferenceAndUIStateIsConsistent(toolbox, optionsPanelWin) { + const checkNodes = [ + ...optionsPanelWin.document.querySelectorAll( + "#enabled-toolbox-buttons-box input[type=checkbox]" + ), + ]; + const toolboxButtonNodes = [ + ...toolbox.doc.querySelectorAll(".command-button"), + ]; + + for (const tool of toolbox.toolbarButtons) { + const isVisible = getBoolPref(tool.visibilityswitch); + + const button = toolboxButtonNodes.find( + toolboxButton => toolboxButton.id === tool.id + ); + is(!!button, isVisible, "Button visibility matches pref for " + tool.id); + + const check = checkNodes.filter(node => node.id === tool.id)[0]; + if (check) { + is( + check.checked, + isVisible, + "Checkbox should be selected based on current pref for " + tool.id + ); + } + } +} + +async function testButtonStateOnClick(toolbox) { + const toolboxButtons = ["#command-button-rulers", "#command-button-measure"]; + for (const toolboxButton of toolboxButtons) { + const button = toolbox.doc.querySelector(toolboxButton); + if (!button) { + ok(false, `Couldn't find ${toolboxButton}`); + continue; + } + + const isChecked = waitUntil(() => button.classList.contains("checked")); + is( + button.getAttribute("aria-pressed"), + "false", + `${toolboxButton} has aria-pressed set to false when it's off` + ); + + button.click(); + await isChecked; + ok( + button.classList.contains("checked"), + `Button for ${toolboxButton} can be toggled on` + ); + is( + button.getAttribute("aria-pressed"), + "true", + `${toolboxButton} has aria-pressed set to true when it's on` + ); + + const isUnchecked = waitUntil(() => !button.classList.contains("checked")); + button.click(); + await isUnchecked; + ok( + !button.classList.contains("checked"), + `Button for ${toolboxButton} can be toggled off` + ); + is( + button.getAttribute("aria-pressed"), + "false", + `aria-pressed is set back to false on ${toolboxButton} after it has been toggled off` + ); + } +} + +function getBoolPref(key) { + try { + return Services.prefs.getBoolPref(key); + } catch (e) { + return false; + } +} diff --git a/devtools/client/framework/test/browser_toolbox_options_disable_cache-01.js b/devtools/client/framework/test/browser_toolbox_options_disable_cache-01.js new file mode 100644 index 0000000000..77c2b1bccb --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_cache-01.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Tests that disabling the cache for a tab works as it should when toolboxes +// are not toggled. +/* import-globals-from helper_disable_cache.js */ +loadHelperScript("helper_disable_cache.js"); + +add_task(async function () { + // Disable rcwn to make cache behavior deterministic. + await pushPref("network.http.rcwn.enabled", false); + + // Ensure that the setting is cleared after the test. + registerCleanupFunction(() => { + info("Resetting devtools.cache.disabled to false."); + Services.prefs.setBoolPref("devtools.cache.disabled", false); + }); + + // Initialise tabs: 1 and 2 with a toolbox, 3 and 4 without. + for (const tab of tabs) { + await initTab(tab, tab.startToolbox); + } + + // Ensure cache is enabled for all tabs. + await checkCacheStateForAllTabs([true, true, true, true]); + + // Check the checkbox in tab 0 and ensure cache is disabled for tabs 0 and 1. + await setDisableCacheCheckboxChecked(tabs[0], true); + await checkCacheStateForAllTabs([false, false, true, true]); + + await finishUp(); +}); diff --git a/devtools/client/framework/test/browser_toolbox_options_disable_cache-02.js b/devtools/client/framework/test/browser_toolbox_options_disable_cache-02.js new file mode 100644 index 0000000000..235893ba60 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_cache-02.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Tests that disabling the cache for a tab works as it should when toolboxes +// are toggled. +/* import-globals-from helper_disable_cache.js */ +loadHelperScript("helper_disable_cache.js"); + +add_task(async function () { + // Disable rcwn to make cache behavior deterministic. + await pushPref("network.http.rcwn.enabled", false); + + // Ensure that the setting is cleared after the test. + registerCleanupFunction(() => { + info("Resetting devtools.cache.disabled to false."); + Services.prefs.setBoolPref("devtools.cache.disabled", false); + }); + + // Initialise tabs: 1 and 2 with a toolbox, 3 and 4 without. + for (const tab of tabs) { + await initTab(tab, tab.startToolbox); + } + + // Disable cache in tab 0 + await setDisableCacheCheckboxChecked(tabs[0], true); + + // Open toolbox in tab 2 and ensure the cache is then disabled. + tabs[2].toolbox = await gDevTools.showToolboxForTab(tabs[2].tab, { + toolId: "options", + }); + await checkCacheEnabled(tabs[2], false); + + // Close toolbox in tab 2 and ensure the cache is enabled again + await tabs[2].toolbox.destroy(); + await checkCacheEnabled(tabs[2], true); + + // Open toolbox in tab 2 and ensure the cache is then disabled. + tabs[2].toolbox = await gDevTools.showToolboxForTab(tabs[2].tab, { + toolId: "options", + }); + await checkCacheEnabled(tabs[2], false); + + // Check the checkbox in tab 2 and ensure cache is enabled for all tabs. + await setDisableCacheCheckboxChecked(tabs[2], false); + await checkCacheStateForAllTabs([true, true, true, true]); + + await finishUp(); +}); diff --git a/devtools/client/framework/test/browser_toolbox_options_disable_cache-03.js b/devtools/client/framework/test/browser_toolbox_options_disable_cache-03.js new file mode 100644 index 0000000000..f3a53f6422 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_cache-03.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that even when the cache is disabled, the inspector/styleeditor don't fetch again +// stylesheets from the server to display them in devtools, but use the cached version. + +const TEST_CSS = URL_ROOT + "browser_toolbox_options_disable_cache.css.sjs"; +const TEST_PAGE = ` + + + + + +`; + +add_task(async function () { + info("Setup preferences for testing"); + // Disable rcwn to make cache behavior deterministic. + await pushPref("network.http.rcwn.enabled", false); + // Disable the cache. + await pushPref("devtools.cache.disabled", true); + + info("Open inspector"); + const toolbox = await openNewTabAndToolbox( + `data:text/html;charset=UTF-8,${encodeURIComponent(TEST_PAGE)}`, + "inspector" + ); + const inspector = toolbox.getPanel("inspector"); + + info( + "Check that the CSS content loaded in the page " + + "and the one shown in the inspector are the same" + ); + const webContent = await getWebContent(); + const inspectorContent = await getInspectorContent(inspector); + is( + webContent, + inspectorContent, + "The contents of both web and DevTools are same" + ); + + await closeTabAndToolbox(); +}); + +async function getInspectorContent(inspector) { + const ruleView = inspector.getPanel("ruleview").view; + const valueEl = await waitFor(() => + ruleView.styleDocument.querySelector(".ruleview-propertyvalue") + ); + return valueEl.textContent; +} + +async function getWebContent() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const doc = content.document; + return doc.ownerGlobal.getComputedStyle(doc.body, "::before").content; + }); +} diff --git a/devtools/client/framework/test/browser_toolbox_options_disable_cache.css.sjs b/devtools/client/framework/test/browser_toolbox_options_disable_cache.css.sjs new file mode 100644 index 0000000000..0b5932ca02 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_cache.css.sjs @@ -0,0 +1,10 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(request, response) { + // This returns always new and different CSS content. + const page = `body::before { content: "${Date.now()}"; }`; + response.setHeader("Content-Type", "text/css; charset=utf-8", false); + response.setHeader("Content-Length", page.length + "", false); + response.write(page); +} diff --git a/devtools/client/framework/test/browser_toolbox_options_disable_cache.sjs b/devtools/client/framework/test/browser_toolbox_options_disable_cache.sjs new file mode 100644 index 0000000000..dc67043be1 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_cache.sjs @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(request, response) { + const Etag = '"4d881ab-b03-435f0a0f9ef00"'; + const IfNoneMatch = request.hasHeader("If-None-Match") + ? request.getHeader("If-None-Match") + : ""; + + const guid = "xxxxxxxx-xxxx-xxxx-yxxx-xxxxxxxxxxxx".replace( + /[xy]/g, + function (c) { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + + return v.toString(16); + } + ); + + const page = "

" + guid + "

"; + + response.setHeader("Etag", Etag, false); + + if (IfNoneMatch === Etag) { + response.setStatusLine(request.httpVersion, "304", "Not Modified"); + } else { + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.setHeader("Content-Length", page.length + "", false); + response.write(page); + } +} diff --git a/devtools/client/framework/test/browser_toolbox_options_disable_js.html b/devtools/client/framework/test/browser_toolbox_options_disable_js.html new file mode 100644 index 0000000000..a8c70c2e4d --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_js.html @@ -0,0 +1,47 @@ + + + + browser_toolbox_options_disablejs.html + + + + + +

Test in page

+ + +
+
No output
+

Test in iframe

+ + + diff --git a/devtools/client/framework/test/browser_toolbox_options_disable_js.js b/devtools/client/framework/test/browser_toolbox_options_disable_js.js new file mode 100644 index 0000000000..4f08838c9f --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_js.js @@ -0,0 +1,141 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that disabling JavaScript for a tab works as it should. + +const TEST_URI = URL_ROOT_SSL + "browser_toolbox_options_disable_js.html"; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + // Start on the options panel from where we will toggle the disabling javascript + // option. + const toolbox = await gDevTools.showToolboxForTab(tab, { toolId: "options" }); + + await testJSEnabled(); + await testJSEnabledIframe(); + + // Disable JS. + await toggleJS(toolbox); + + await testJSDisabled(); + await testJSDisabledIframe(); + + // Navigate and check JS is still disabled + for (let i = 0; i < 10; i++) { + await navigateTo(`${TEST_URI}?nocache=${i}`); + await testJSDisabled(); + await testJSDisabledIframe(); + } + + // Re-enable JS. + await toggleJS(toolbox); + + await testJSEnabled(); + await testJSEnabledIframe(); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); + +async function testJSEnabled() { + info("Testing that JS is enabled"); + + // We use waitForTick here because switching browsingContext.allowJavascript + // to true takes a while to become live. + await waitForTick(); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + const doc = content.document; + const output = doc.getElementById("output"); + doc.querySelector("#logJSEnabled").click(); + is( + output.textContent, + "JavaScript Enabled", + 'Output is "JavaScript Enabled"' + ); + }); +} + +async function testJSEnabledIframe() { + info("Testing that JS is enabled in the iframe"); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + const doc = content.document; + const iframe = doc.querySelector("iframe"); + const iframeDoc = iframe.contentDocument; + const output = iframeDoc.getElementById("output"); + iframeDoc.querySelector("#logJSEnabled").click(); + is( + output.textContent, + "JavaScript Enabled", + 'Output is "JavaScript Enabled" in iframe' + ); + }); +} + +async function toggleJS(toolbox) { + const panel = toolbox.getCurrentPanel(); + const cbx = panel.panelDoc.getElementById("devtools-disable-javascript"); + + if (cbx.checked) { + info("Clearing checkbox to re-enable JS"); + } else { + info("Checking checkbox to disable JS"); + } + + let javascriptEnabled = + await toolbox.commands.targetConfigurationCommand.isJavascriptEnabled(); + is( + javascriptEnabled, + !cbx.checked, + "targetConfigurationCommand.isJavascriptEnabled is correct before the toggle" + ); + + const browserLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + cbx.click(); + await browserLoaded; + + javascriptEnabled = + await toolbox.commands.targetConfigurationCommand.isJavascriptEnabled(); + is( + javascriptEnabled, + !cbx.checked, + "targetConfigurationCommand.isJavascriptEnabled is correctly updated" + ); +} + +async function testJSDisabled() { + info("Testing that JS is disabled"); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + const doc = content.document; + const output = doc.getElementById("output"); + doc.querySelector("#logJSDisabled").click(); + + Assert.notStrictEqual( + output.textContent, + "JavaScript Disabled", + 'output is not "JavaScript Disabled"' + ); + }); +} + +async function testJSDisabledIframe() { + info("Testing that JS is disabled in the iframe"); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + const doc = content.document; + const iframe = doc.querySelector("iframe"); + const iframeDoc = iframe.contentDocument; + const output = iframeDoc.getElementById("output"); + iframeDoc.querySelector("#logJSDisabled").click(); + Assert.notStrictEqual( + output.textContent, + "JavaScript Disabled", + 'output is not "JavaScript Disabled" in iframe' + ); + }); +} diff --git a/devtools/client/framework/test/browser_toolbox_options_disable_js_iframe.html b/devtools/client/framework/test/browser_toolbox_options_disable_js_iframe.html new file mode 100644 index 0000000000..5006d24c9a --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_disable_js_iframe.html @@ -0,0 +1,34 @@ + + + browser_toolbox_options_disablejs.html + + + + + + + +
+
No output
+ + diff --git a/devtools/client/framework/test/browser_toolbox_options_enable_serviceworkers_testing.html b/devtools/client/framework/test/browser_toolbox_options_enable_serviceworkers_testing.html new file mode 100644 index 0000000000..4065aabc2b --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_enable_serviceworkers_testing.html @@ -0,0 +1,81 @@ + + + + browser_toolbox_options_enable_serviceworkers_testing.html + + + +

SW-test

+ + + diff --git a/devtools/client/framework/test/browser_toolbox_options_enable_serviceworkers_testing.js b/devtools/client/framework/test/browser_toolbox_options_enable_serviceworkers_testing.js new file mode 100644 index 0000000000..152f64f835 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_enable_serviceworkers_testing.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that enabling Service Workers testing option enables the +// mServiceWorkersTestingEnabled attribute added to nsPIDOMWindow. + +// We explicitly want to test that service worker testing allows to use service +// workers on non-https, so we use mochi.test:8888 to avoid the automatic upgrade +// to https when dom.security.https_first is true. +const TEST_URI = + URL_ROOT_MOCHI_8888 + + "browser_toolbox_options_enable_serviceworkers_testing.html"; +const ELEMENT_ID = "devtools-enable-serviceWorkersTesting"; + +add_task(async function () { + await pushPref("dom.serviceWorkers.exemptFromPerDomainMax", true); + await pushPref("dom.serviceWorkers.enabled", true); + await pushPref("dom.serviceWorkers.testing.enabled", false); + // Force the test to start without service worker testing enabled + await pushPref("devtools.serviceWorkers.testing.enabled", false); + + const tab = await addTab(TEST_URI); + const toolbox = await openToolboxForTab(tab, "options"); + + let data = await register(); + is(data.success, false, "Register should fail with security error"); + + const panel = toolbox.getCurrentPanel(); + const cbx = panel.panelDoc.getElementById(ELEMENT_ID); + is(cbx.checked, false, "The checkbox shouldn't be checked"); + + info(`Checking checkbox to enable service workers testing`); + cbx.scrollIntoView(); + cbx.click(); + + await reloadBrowser(); + + data = await register(); + is(data.success, true, "Register should success"); + + await unregister(); + data = await registerAndUnregisterInFrame(); + is(data.success, true, "Register should success"); + + info("Workers should be turned back off when we closes the toolbox"); + await toolbox.destroy(); + + await reloadBrowser(); + data = await register(); + is(data.success, false, "Register should fail with security error"); +}); + +function sendMessage(name) { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [name], nameChild => { + return new Promise(resolve => { + const channel = new content.MessageChannel(); + content.postMessage(nameChild, "*", [channel.port2]); + channel.port1.onmessage = function (msg) { + resolve(msg.data); + channel.port1.close(); + }; + }); + }); +} + +function register() { + return sendMessage("devtools:sw-test:register"); +} + +function unregister(swr) { + return sendMessage("devtools:sw-test:unregister"); +} + +function registerAndUnregisterInFrame() { + return sendMessage("devtools:sw-test:iframe:register-and-unregister"); +} diff --git a/devtools/client/framework/test/browser_toolbox_options_frames_button.js b/devtools/client/framework/test/browser_toolbox_options_frames_button.js new file mode 100644 index 0000000000..50adeda39b --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_frames_button.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the frames button is always visible when the user is on the options panel. +// Test that the button is disabled if the current target has no frames. +// Test that the button is enabled otherwise. + +const TEST_URL = "data:text/html;charset=utf8,test frames button visibility"; +const TEST_IFRAME_URL = "data:text/plain,iframe"; +const TEST_IFRAME_URL2 = "data:text/plain,iframe2"; +const TEST_URL_FRAMES = + TEST_URL + + `` + + ``; +const FRAME_BUTTON_PREF = "devtools.command-button-frames.enabled"; + +add_task(async function () { + // Hide the button by default. + await pushPref(FRAME_BUTTON_PREF, false); + + const tab = await addTab(TEST_URL); + + info("Open the toolbox on the Options panel"); + const toolbox = await gDevTools.showToolboxForTab(tab, { toolId: "options" }); + const doc = toolbox.doc; + + const optionsPanel = toolbox.getCurrentPanel(); + + let framesButton = doc.getElementById("command-button-frames"); + ok(!framesButton, "Frames button is not rendered."); + + const optionsDoc = optionsPanel.panelWin.document; + const framesButtonCheckbox = optionsDoc.getElementById( + "command-button-frames" + ); + framesButtonCheckbox.click(); + + info("Wait for the frame button to be rendered"); + framesButton = await waitFor(() => + doc.getElementById("command-button-frames") + ); + ok(framesButton.disabled, "Frames button is disabled."); + + info("Leave the options panel, the frames button should not be rendered."); + await toolbox.selectTool("webconsole"); + framesButton = doc.getElementById("command-button-frames"); + ok(!framesButton, "Frames button is no longer rendered."); + + info("Go back to the options panel, the frames button should rendered."); + await toolbox.selectTool("options"); + framesButton = doc.getElementById("command-button-frames"); + ok(framesButton, "Frames button is rendered again."); + + // Do not run the rest of this test when both fission and EFT is disabled as + // it prevents creating a target for the iframe + if (!isFissionEnabled() || !isEveryFrameTargetEnabled()) { + return; + } + + info("Navigate to a page with frames, the frames button should be enabled."); + await navigateTo(TEST_URL_FRAMES); + + framesButton = doc.getElementById("command-button-frames"); + ok(framesButton, "Frames button is still rendered."); + + await waitFor(() => { + framesButton = doc.getElementById("command-button-frames"); + return framesButton && !framesButton.disabled; + }); + + const { targetCommand } = toolbox.commands; + const iframeTarget = targetCommand + .getAllTargets([targetCommand.TYPES.FRAME]) + .find(target => target.url == TEST_IFRAME_URL); + ok(iframeTarget, "Found the target for the iframe"); + + ok( + !framesButton.classList.contains("checked"), + "Before selecting an iframe, the button is not checked" + ); + await toolbox.commands.targetCommand.selectTarget(iframeTarget); + ok( + framesButton.classList.contains("checked"), + "After selecting an iframe, the button is checked" + ); + + info("Remove this first iframe, which is currently selected"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + content.document.querySelector("iframe").remove(); + }); + + await waitFor(() => { + return targetCommand.selectedTargetFront == targetCommand.targetFront; + }, "Wait for the selected target to be back on the top target"); + + ok( + !framesButton.classList.contains("checked"), + "The button is back unchecked after having removed the selected iframe" + ); + + Services.prefs.clearUserPref(FRAME_BUTTON_PREF); +}); diff --git a/devtools/client/framework/test/browser_toolbox_options_multiple_tabs.js b/devtools/client/framework/test/browser_toolbox_options_multiple_tabs.js new file mode 100644 index 0000000000..74c0983d4e --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_multiple_tabs.js @@ -0,0 +1,130 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = + "data:text/html;charset=utf8,test for dynamically registering " + + "and unregistering tools across multiple tabs"; + +let tab1, tab2, modifiedPref; + +add_task(async function () { + tab1 = await openToolboxOptionsInNewTab(); + tab2 = await openToolboxOptionsInNewTab(); + + await testToggleTools(); + await cleanup(); +}); + +async function openToolboxOptionsInNewTab() { + const tab = await addTab(URL); + const toolbox = await gDevTools.showToolboxForTab(tab); + const doc = toolbox.doc; + const panel = await toolbox.selectTool("options"); + const { id } = panel.panelDoc.querySelector( + "#default-tools-box input[type=checkbox]:not([data-unsupported], [checked])" + ); + + return { + tab, + toolbox, + doc, + panelWin: panel.panelWin, + // This is a getter becuse toolbox tools list gets re-setup every time there + // is a tool-registered or tool-undregistered event. + get checkbox() { + return panel.panelDoc.getElementById(id); + }, + }; +} + +async function testToggleTools() { + is(tab1.checkbox.id, tab2.checkbox.id, "Default tool box should be in sync."); + + const toolId = tab1.checkbox.id; + const testTool = gDevTools.getDefaultTools().find(tool => tool.id === toolId); + // Store modified pref names so that they can be cleared on error. + modifiedPref = testTool.visibilityswitch; + + info(`Registering tool ${toolId} in the first tab.`); + await toggleTool(tab1, toolId); + + info(`Unregistering tool ${toolId} in the first tab.`); + await toggleTool(tab1, toolId); + + info(`Registering tool ${toolId} in the second tab.`); + await toggleTool(tab2, toolId); + + info(`Unregistering tool ${toolId} in the second tab.`); + await toggleTool(tab2, toolId); + + info(`Registering tool ${toolId} in the first tab.`); + await toggleTool(tab1, toolId); + + info(`Unregistering tool ${toolId} in the second tab.`); + await toggleTool(tab2, toolId); +} + +async function toggleTool({ doc, panelWin, checkbox, tab }, toolId) { + const prevChecked = checkbox.checked; + + (prevChecked ? checkRegistered : checkUnregistered)(toolId); + + const onToggleTool = gDevTools.once( + `tool-${prevChecked ? "unregistered" : "registered"}` + ); + EventUtils.sendMouseEvent({ type: "click" }, checkbox, panelWin); + const id = await onToggleTool; + + is(id, toolId, `Correct event for ${toolId} was fired`); + // await new Promise(resolve => setTimeout(resolve, 60000)); + (prevChecked ? checkUnregistered : checkRegistered)(toolId); +} + +async function checkUnregistered(toolId) { + ok( + !getToolboxTab(tab1.doc, toolId), + `Tab for unregistered tool ${toolId} is not present in first toolbox` + ); + ok( + !tab1.checkbox.checked, + `Checkbox for unregistered tool ${toolId} is not checked in first toolbox` + ); + ok( + !getToolboxTab(tab2.doc, toolId), + `Tab for unregistered tool ${toolId} is not present in second toolbox` + ); + ok( + !tab2.checkbox.checked, + `Checkbox for unregistered tool ${toolId} is not checked in second toolbox` + ); +} + +function checkRegistered(toolId) { + ok( + getToolboxTab(tab1.doc, toolId), + `Tab for registered tool ${toolId} is present in first toolbox` + ); + ok( + tab1.checkbox.checked, + `Checkbox for registered tool ${toolId} is checked in first toolbox` + ); + ok( + getToolboxTab(tab2.doc, toolId), + `Tab for registered tool ${toolId} is present in second toolbox` + ); + ok( + tab2.checkbox.checked, + `Checkbox for registered tool ${toolId} is checked in second toolbox` + ); +} + +async function cleanup() { + await tab1.toolbox.destroy(); + await tab2.toolbox.destroy(); + gBrowser.removeCurrentTab(); + gBrowser.removeCurrentTab(); + Services.prefs.clearUserPref(modifiedPref); + tab1 = tab2 = modifiedPref = null; +} diff --git a/devtools/client/framework/test/browser_toolbox_options_panel_toggle.js b/devtools/client/framework/test/browser_toolbox_options_panel_toggle.js new file mode 100644 index 0000000000..d94f7c14fb --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_options_panel_toggle.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether options panel toggled by key event and "Settings" on the meatball menu. + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function () { + const tab = await addTab("about:blank"); + const toolbox = await openToolboxForTab( + tab, + "webconsole", + Toolbox.HostType.BOTTOM + ); + + info("Check the option panel was selected after sending F1 key event"); + await sendOptionsKeyEvent(toolbox); + is(toolbox.currentToolId, "options", "The options panel should be selected"); + + info("Check the last selected panel was selected after sending F1 key event"); + await sendOptionsKeyEvent(toolbox); + is( + toolbox.currentToolId, + "webconsole", + "The webconsole panel should be selected" + ); + + info("Check the option panel was selected after clicking 'Settings' menu"); + await clickSettingsMenu(toolbox); + is(toolbox.currentToolId, "options", "The options panel should be selected"); + + info( + "Check the last selected panel was selected after clicking 'Settings' menu" + ); + await sendOptionsKeyEvent(toolbox); + is( + toolbox.currentToolId, + "webconsole", + "The webconsole panel should be selected" + ); + + info("Check the combination of key event and 'Settings' menu"); + await sendOptionsKeyEvent(toolbox); + await clickSettingsMenu(toolbox); + is( + toolbox.currentToolId, + "webconsole", + "The webconsole panel should be selected" + ); + await clickSettingsMenu(toolbox); + await sendOptionsKeyEvent(toolbox); + is( + toolbox.currentToolId, + "webconsole", + "The webconsole panel should be selected" + ); +}); + +async function sendOptionsKeyEvent(toolbox) { + const onReady = toolbox.once("select"); + EventUtils.synthesizeKey("VK_F1", {}, toolbox.win); + await onReady; +} + +async function clickSettingsMenu(toolbox) { + const onPopupShown = () => { + toolbox.doc.removeEventListener("popupshown", onPopupShown); + const menuItem = toolbox.doc.getElementById( + "toolbox-meatball-menu-settings" + ); + EventUtils.synthesizeMouseAtCenter(menuItem, {}, menuItem.ownerGlobal); + }; + toolbox.doc.addEventListener("popupshown", onPopupShown); + + const button = toolbox.doc.getElementById("toolbox-meatball-menu-button"); + await waitUntil(() => button.style.pointerEvents !== "none"); + EventUtils.synthesizeMouseAtCenter(button, {}, button.ownerGlobal); + + await toolbox.once("select"); +} diff --git a/devtools/client/framework/test/browser_toolbox_popups_debugging.js b/devtools/client/framework/test/browser_toolbox_popups_debugging.js new file mode 100644 index 0000000000..28b2603e80 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_popups_debugging.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test opening toolboxes against a tab and its popup + +const TEST_URL = "data:text/html,test for debugging popups"; +const POPUP_URL = "data:text/html,popup"; + +const POPUP_DEBUG_PREF = "devtools.popups.debug"; + +add_task(async function () { + const isPopupDebuggingEnabled = Services.prefs.getBoolPref(POPUP_DEBUG_PREF); + + info("Open a tab and debug it"); + const tab = await addTab(TEST_URL); + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "webconsole", + }); + + info("Open a popup"); + const onTabOpened = once(gBrowser.tabContainer, "TabOpen"); + const onToolboxSwitchedToTab = toolbox.once("switched-host-to-tab"); + await SpecialPowers.spawn(tab.linkedBrowser, [POPUP_URL], url => { + content.open(url); + }); + const tabOpenEvent = await onTabOpened; + const popupTab = tabOpenEvent.target; + + const popupToolbox = await gDevTools.showToolboxForTab(popupTab); + if (isPopupDebuggingEnabled) { + ok( + !popupToolbox, + "When popup debugging is enabled, the popup should be debugged via the same toolbox as the original tab" + ); + info("Wait for internal event notifying about the toolbox being moved"); + await onToolboxSwitchedToTab; + const browserContainer = gBrowser.getBrowserContainer( + popupTab.linkedBrowser + ); + const iframe = browserContainer.querySelector( + ".devtools-toolbox-bottom-iframe" + ); + ok(iframe, "The original tab's toolbox moved to the popup tab"); + } else { + ok(popupToolbox, "We were able to spawn a toolbox for the popup"); + info("Close the popup toolbox and its tab"); + await popupToolbox.destroy(); + } + + info("Close the popup tab"); + gBrowser.removeCurrentTab(); + + info("Close the original tab toolbox and itself"); + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/client/framework/test/browser_toolbox_races.js b/devtools/client/framework/test/browser_toolbox_races.js new file mode 100644 index 0000000000..ede038e716 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_races.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Toggling the toolbox three time can take more than 45s on slow test machine +requestLongerTimeout(2); + +// Test toggling the toolbox quickly and see if there is any race breaking it. + +const URL = "data:text/html;charset=utf-8,Toggling devtools quickly"; +const { + gDevToolsBrowser, +} = require("resource://devtools/client/framework/devtools-browser.js"); + +add_task(async function () { + // Make sure this test starts with the selectedTool pref cleared. Previous + // tests select various tools, and that sets this pref. + Services.prefs.clearUserPref("devtools.toolbox.selectedTool"); + + await addTab(URL); + + let ready = 0, + destroy = 0, + destroyed = 0; + const onReady = () => { + ready++; + }; + const onDestroy = () => { + destroy++; + }; + const onDestroyed = () => { + destroyed++; + }; + gDevTools.on("toolbox-ready", onReady); + gDevTools.on("toolbox-destroy", onDestroy); + gDevTools.on("toolbox-destroyed", onDestroyed); + + // The current implementation won't toggle the toolbox many times, + // instead it will ignore toggles that happens while the toolbox is still + // creating or still destroying. + + info("Toggle the toolbox many times in a row"); + toggle(); + toggle(); + toggle(); + toggle(); + toggle(); + await wait(500); + + await waitFor(() => ready == 1); + is( + ready, + 1, + "No matter how many times we called toggle, it will only open the toolbox once" + ); + is( + destroy, + 0, + "All subsequent, synchronous call to toggle will be ignored and the toolbox won't be destroyed" + ); + is(destroyed, 0); + + info("Retoggle the toolbox many times in a row"); + toggle(); + toggle(); + toggle(); + toggle(); + toggle(); + await wait(500); + + await waitFor(() => destroyed == 1); + is(destroyed, 1, "Similarly, the toolbox will be closed"); + is(destroy, 1); + is( + ready, + 1, + "and no other toolbox will be opened. The subsequent toggle will be ignored." + ); + + gDevTools.off("toolbox-ready", onReady); + gDevTools.off("toolbox-destroy", onDestroy); + gDevTools.off("toolbox-destroyed", onDestroyed); + await wait(1000); + + gBrowser.removeCurrentTab(); +}); + +function toggle() { + // When enabling the input event prioritization, we'll reserve some time to + // process input events in each frame. In that case, the synthesized input + // events may delay the normal events. Replace synthesized key events by + // toggleToolboxCommand to prevent the synthesized input events jam the + // content process and cause the test timeout. + gDevToolsBrowser.toggleToolboxCommand(window.gBrowser); +} diff --git a/devtools/client/framework/test/browser_toolbox_raise.js b/devtools/client/framework/test/browser_toolbox_raise.js new file mode 100644 index 0000000000..1912d349d4 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_raise.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URL = "data:text/html,test for opening toolbox in different hosts"; + +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function () { + const tab1 = await addTab(TEST_URL); + const tab2 = BrowserTestUtils.addTab(gBrowser); + + const toolbox = await gDevTools.showToolboxForTab(tab1); + await testBottomHost(toolbox, tab1, tab2); + + await testWindowHost(toolbox); + + Services.prefs.setCharPref("devtools.toolbox.host", Toolbox.HostType.BOTTOM); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); + gBrowser.removeCurrentTab(); +}); + +async function testBottomHost(toolbox, tab1, tab2) { + // switch to another tab and test toolbox.raise() + gBrowser.selectedTab = tab2; + await new Promise(executeSoon); + is( + gBrowser.selectedTab, + tab2, + "Correct tab is selected before calling raise" + ); + + await toolbox.raise(); + is( + gBrowser.selectedTab, + tab1, + "Correct tab was selected after calling raise" + ); +} + +async function testWindowHost(toolbox) { + await toolbox.switchHost(Toolbox.HostType.WINDOW); + + info("Wait for the toolbox to be focused when switching to window host"); + // We can't wait for the "focus" event on toolbox.win.parent as this document is created while calling switchHost. + await waitFor(() => { + return Services.focus.activeWindow == toolbox.topWindow; + }); + + const onBrowserWindowFocused = new Promise(resolve => + window.addEventListener("focus", resolve, { once: true, capture: true }) + ); + + info("Focusing the browser window"); + window.focus(); + + info("Wait for the browser window to be focused"); + await onBrowserWindowFocused; + + // Now raise toolbox. + await toolbox.raise(); + is( + Services.focus.activeWindow, + toolbox.topWindow, + "the toolbox window is immediately focused after raise resolution" + ); +} diff --git a/devtools/client/framework/test/browser_toolbox_ready.js b/devtools/client/framework/test/browser_toolbox_ready.js new file mode 100644 index 0000000000..5d7d6be258 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_ready.js @@ -0,0 +1,22 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URL = "data:text/html,test for toolbox being ready"; + +add_task(async function () { + const tab = await addTab(TEST_URL); + + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "webconsole", + }); + ok(toolbox.isReady, "toolbox isReady is set"); + ok(toolbox.threadFront, "toolbox has a thread front"); + + const toolbox2 = await gDevTools.showToolboxForTab(tab, { + toolId: toolbox.toolId, + }); + is(toolbox2, toolbox, "same toolbox"); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/client/framework/test/browser_toolbox_remoteness_change.js b/devtools/client/framework/test/browser_toolbox_remoteness_change.js new file mode 100644 index 0000000000..af5f105214 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_remoteness_change.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const URL_1 = "about:robots"; +const URL_2 = + "data:text/html;charset=UTF-8," + + encodeURIComponent('
foo
'); + +// Testing navigation between processes +add_task(async function () { + info(`Testing navigation between processes`); + + info("Open a tab on a URL supporting only running in parent process"); + const tab = await addTab(URL_1); + is( + tab.linkedBrowser.currentURI.spec, + URL_1, + "We really are on the expected document" + ); + is( + tab.linkedBrowser.getAttribute("remote"), + "", + "And running in parent process" + ); + + const toolbox = await openToolboxForTab(tab); + + info("Navigate to a URL supporting remote process"); + await navigateTo(URL_2); + + is( + tab.linkedBrowser.getAttribute("remote"), + "true", + "Navigated to a data: URI and switching to remote" + ); + + info("Veryify we are inspecting the new document"); + const console = await toolbox.selectTool("webconsole"); + const { ui } = console.hud; + ui.wrapper.dispatchEvaluateExpression("document.location.href"); + await waitUntil(() => ui.outputNode.querySelector(".result")); + const url = ui.outputNode.querySelector(".result"); + + ok( + url.textContent.includes(URL_2), + "The console inspects the second document" + ); + + const { client } = toolbox.target; + await toolbox.destroy(); + ok(client._transportClosed, "The client is closed after closing the toolbox"); +}); diff --git a/devtools/client/framework/test/browser_toolbox_screenshot_tool.js b/devtools/client/framework/test/browser_toolbox_screenshot_tool.js new file mode 100644 index 0000000000..63c8b9fd58 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_screenshot_tool.js @@ -0,0 +1,126 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const exampleOrgDocument = `https://example.org/document-builder.sjs`; +const exampleComDocument = `https://example.com/document-builder.sjs`; + +const TEST_URL = `${exampleOrgDocument}?html= + + + `; + +add_task(async function () { + await pushPref("devtools.command-button-screenshot.enabled", true); + + await addTab(TEST_URL); + + info("Open the toolbox"); + const toolbox = await gDevTools.showToolboxForTab(gBrowser.selectedTab); + + const onScreenshotDownloaded = waitUntilScreenshot(); + toolbox.doc.querySelector("#command-button-screenshot").click(); + const filePath = await onScreenshotDownloaded; + + ok(!!filePath, "The screenshot was taken"); + + info("Create an image using the downloaded file as source"); + const image = new Image(); + const onImageLoad = once(image, "load"); + image.src = PathUtils.toFileURI(filePath); + await onImageLoad; + + const dpr = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => content.wrappedJSObject.devicePixelRatio + ); + + info("Check that the same-origin iframe is rendered in the screenshot"); + await checkImageColorAt({ + image, + y: 10 * dpr, + expectedColor: `rgb(255, 0, 0)`, + label: "The same-origin iframe is rendered properly in the screenshot", + }); + + info("Check that the remote iframe is rendered in the screenshot"); + await checkImageColorAt({ + image, + y: 60 * dpr, + expectedColor: `rgb(0, 255, 0)`, + label: "The remote iframe is rendered properly in the screenshot", + }); + + info( + "Check that a warning message was displayed to indicate the screenshot was truncated" + ); + const notificationBox = await waitFor(() => + toolbox.doc.querySelector(".notificationbox") + ); + + const message = notificationBox.querySelector(".notification").textContent; + ok( + message.startsWith("The image was cut off"), + `The warning message is rendered as expected (${message})` + ); + + // Remove the downloaded screenshot file + await IOUtils.remove(filePath); + + info( + "Check that taking a screenshot in a private window doesn't appear in the non-private window" + ); + const privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + ok(PrivateBrowsingUtils.isWindowPrivate(privateWindow), "window is private"); + const privateBrowser = privateWindow.gBrowser; + privateBrowser.selectedTab = BrowserTestUtils.addTab( + privateBrowser, + TEST_URL + ); + + info("private tab opened"); + ok( + PrivateBrowsingUtils.isBrowserPrivate(privateBrowser.selectedBrowser), + "tab window is private" + ); + + const privateToolbox = await gDevTools.showToolboxForTab( + privateBrowser.selectedTab + ); + + const onPrivateScreenshotDownloaded = waitUntilScreenshot({ + isWindowPrivate: true, + }); + privateToolbox.doc.querySelector("#command-button-screenshot").click(); + const privateScreenshotFilePath = await onPrivateScreenshotDownloaded; + ok( + !!privateScreenshotFilePath, + "The screenshot was taken in the private window" + ); + + // Remove the downloaded screenshot file + await IOUtils.remove(privateScreenshotFilePath); + + // cleanup the downloads + await resetDownloads(); + + const closePromise = BrowserTestUtils.windowClosed(privateWindow); + privateWindow.BrowserTryToCloseWindow(); + await closePromise; +}); diff --git a/devtools/client/framework/test/browser_toolbox_select_event.js b/devtools/client/framework/test/browser_toolbox_select_event.js new file mode 100644 index 0000000000..ebdae9af13 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_select_event.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const PAGE_URL = "data:text/html;charset=utf-8,test select events"; + +requestLongerTimeout(2); + +add_task(async function () { + const tab = await addTab(PAGE_URL); + + let toolbox = await openToolboxForTab(tab, "webconsole", "bottom"); + await testSelectEvent("inspector"); + await testSelectEvent("webconsole"); + await testSelectEvent("styleeditor"); + await testSelectEvent("inspector"); + await testSelectEvent("webconsole"); + await testSelectEvent("styleeditor"); + + await testToolSelectEvent("inspector"); + await testToolSelectEvent("webconsole"); + await testToolSelectEvent("styleeditor"); + await toolbox.destroy(); + + toolbox = await openToolboxForTab(tab, "webconsole", "right"); + await testSelectEvent("inspector"); + await testSelectEvent("webconsole"); + await testSelectEvent("styleeditor"); + await testSelectEvent("inspector"); + await testSelectEvent("webconsole"); + await testSelectEvent("styleeditor"); + await toolbox.destroy(); + + toolbox = await openToolboxForTab(tab, "webconsole", "window"); + await testSelectEvent("inspector"); + await testSelectEvent("webconsole"); + await testSelectEvent("styleeditor"); + await testSelectEvent("inspector"); + await testSelectEvent("webconsole"); + await testSelectEvent("styleeditor"); + await toolbox.destroy(); + + await testSelectToolRace(); + + /** + * Assert that selecting the given toolId raises a select event + * @param {toolId} Id of the tool to test + */ + async function testSelectEvent(toolId) { + const onSelect = toolbox.once("select"); + toolbox.selectTool(toolId); + const id = await onSelect; + is(id, toolId, toolId + " selected"); + } + + /** + * Assert that selecting the given toolId raises its corresponding + * selected event + * @param {toolId} Id of the tool to test + */ + async function testToolSelectEvent(toolId) { + const onSelected = toolbox.once(toolId + "-selected"); + toolbox.selectTool(toolId); + await onSelected; + is(toolbox.currentToolId, toolId, toolId + " tool selected"); + } + + /** + * Assert that two calls to selectTool won't race + */ + async function testSelectToolRace() { + const toolbox = await openToolboxForTab(tab, "webconsole"); + let selected = false; + const onSelect = (event, id) => { + if (selected) { + ok(false, "Got more than one 'select' event"); + } else { + selected = true; + } + }; + toolbox.once("select", onSelect); + const p1 = toolbox.selectTool("inspector"); + const p2 = toolbox.selectTool("inspector"); + // Check that both promises don't resolve too early + const checkSelectToolResolution = panel => { + ok(selected, "selectTool resolves only after 'select' event is fired"); + const inspector = toolbox.getPanel("inspector"); + is(panel, inspector, "selecTool resolves to the panel instance"); + }; + p1.then(checkSelectToolResolution); + p2.then(checkSelectToolResolution); + await p1; + await p2; + + await toolbox.destroy(); + } +}); diff --git a/devtools/client/framework/test/browser_toolbox_selected_tool_unavailable.js b/devtools/client/framework/test/browser_toolbox_selected_tool_unavailable.js new file mode 100644 index 0000000000..c55ad5867c --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_selected_tool_unavailable.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that opening the toolbox doesn't throw when the previously selected +// tool is not supported. + +const testToolDefinition = { + id: "testTool", + isToolSupported: () => true, + visibilityswitch: "devtools.test-tool.enabled", + url: "about:blank", + label: "someLabel", + build: (iframeWindow, toolbox) => { + return { + target: toolbox.target, + toolbox, + isReady: true, + destroy: () => {}, + panelDoc: iframeWindow.document, + }; + }, +}; + +add_task(async function () { + gDevTools.registerTool(testToolDefinition); + let tab = await addTab("about:blank"); + + let toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: testToolDefinition.id, + }); + is(toolbox.currentToolId, "testTool", "test-tool was selected"); + await toolbox.destroy(); + + // Make the previously selected tool unavailable. + testToolDefinition.isToolSupported = () => false; + + toolbox = await gDevTools.showToolboxForTab(tab); + is(toolbox.currentToolId, "webconsole", "web console was selected"); + + await toolbox.destroy(); + gDevTools.unregisterTool(testToolDefinition.id); + tab = toolbox = null; + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/client/framework/test/browser_toolbox_selectionchanged_event.js b/devtools/client/framework/test/browser_toolbox_selectionchanged_event.js new file mode 100644 index 0000000000..e4e0dcc446 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_selectionchanged_event.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const PAGE_URL = "data:text/html;charset=utf-8,
"; + +add_task(async function () { + const tab = await addTab(PAGE_URL); + const toolbox = await openToolboxForTab(tab, "inspector", "bottom"); + const inspector = toolbox.getCurrentPanel(); + + const root = await inspector.walker.getRootNode(); + const body = await inspector.walker.querySelector(root, "body"); + const node = await inspector.walker.querySelector(root, "div"); + + is(inspector.selection.nodeFront, body, "Body is selected by default"); + + // Listen to selection changed + const onSelectionChanged = toolbox.once("selection-changed"); + + info("Select the div and wait for the selection-changed event to be fired."); + inspector.selection.setNodeFront(node, { reason: "browser-context-menu" }); + + await onSelectionChanged; + + is(inspector.selection.nodeFront, node, "Div is now selected"); + + // Listen to cleared selection changed + const onClearSelectionChanged = toolbox.once("selection-changed"); + + info( + "Clear the selection and wait for the selection-changed event to be fired." + ); + inspector.selection.setNodeFront(null); + + await onClearSelectionChanged; + + is(inspector.selection.nodeFront, null, "The selection is null as expected"); +}); diff --git a/devtools/client/framework/test/browser_toolbox_show_toolbox_tool_ready.js b/devtools/client/framework/test/browser_toolbox_show_toolbox_tool_ready.js new file mode 100644 index 0000000000..d24f8cfedf --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_show_toolbox_tool_ready.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = + "data:text/html;charset=utf8,test for showToolbox called while tool is opened"; +const lazyToolId = "testtool1"; + +registerCleanupFunction(() => { + gDevTools.unregisterTool(lazyToolId); +}); + +// Delay to wait before the lazy tool should finish +const TOOL_OPEN_DELAY = 3000; + +class LazyDevToolsPanel extends DevToolPanel { + constructor(iframeWindow, toolbox) { + super(iframeWindow, toolbox); + } + + async open() { + await wait(TOOL_OPEN_DELAY); + return this; + } +} + +function isPanelReady(toolbox, toolId) { + return !!toolbox.getPanel(toolId); +} + +/** + * Test that showToolbox will wait until the specified tool is completely read before + * returning. See Bug 1543907. + */ +add_task(async function automaticallyBindTexbox() { + info( + "Registering a tool with an input field and making sure the context menu works" + ); + + gDevTools.registerTool({ + id: lazyToolId, + isToolSupported: () => true, + url: CHROME_URL_ROOT + "doc_lazy_tool.html", + label: "Lazy", + build(iframeWindow, toolbox) { + this.panel = new LazyDevToolsPanel(iframeWindow, toolbox); + return this.panel.open(); + }, + }); + + const tab = await addTab(URL); + const toolbox = await openToolboxForTab(tab, "inspector"); + const onLazyToolReady = toolbox.once(lazyToolId + "-ready"); + toolbox.selectTool(lazyToolId); + + info("Wait until toolbox considers the current tool is the lazy tool"); + await waitUntil(() => toolbox.currentToolId == lazyToolId); + + ok(!isPanelReady(toolbox, lazyToolId), "lazyTool should not be ready yet"); + await gDevTools.showToolboxForTab(tab, { toolId: lazyToolId }); + ok( + isPanelReady(toolbox, lazyToolId), + "lazyTool should not ready after showToolbox" + ); + + // Make sure lazyTool is ready before leaving the test. + await onLazyToolReady; +}); diff --git a/devtools/client/framework/test/browser_toolbox_split_console.js b/devtools/client/framework/test/browser_toolbox_split_console.js new file mode 100644 index 0000000000..e69a493df9 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_split_console.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that these toolbox split console APIs work: +// * toolbox.useKeyWithSplitConsole() +// * toolbox.isSplitConsoleFocused + +let gToolbox = null; +let panelWin = null; + +const URL = "data:text/html;charset=utf8,test split console key delegation"; + +add_task(async function () { + const tab = await addTab(URL); + gToolbox = await gDevTools.showToolboxForTab(tab, { toolId: "jsdebugger" }); + panelWin = gToolbox.getPanel("jsdebugger").panelWin; + + await gToolbox.openSplitConsole(); + await testIsSplitConsoleFocused(); + await testUseKeyWithSplitConsole(); + await testUseKeyWithSplitConsoleWrongTool(); + + await cleanup(); +}); + +async function testIsSplitConsoleFocused() { + await gToolbox.openSplitConsole(); + // The newly opened split console should have focus + ok(gToolbox.isSplitConsoleFocused(), "Split console is focused"); + panelWin.focus(); + ok(!gToolbox.isSplitConsoleFocused(), "Split console is no longer focused"); +} + +// A key bound to the selected tool should trigger it's command +function testUseKeyWithSplitConsole() { + let commandCalled = false; + + info("useKeyWithSplitConsole on debugger while debugger is focused"); + gToolbox.useKeyWithSplitConsole( + "F3", + () => { + commandCalled = true; + }, + "jsdebugger" + ); + + info("synthesizeKey with the console focused"); + focusConsoleInput(); + synthesizeKeyShortcut("F3", panelWin); + + ok(commandCalled, "Shortcut key should trigger the command"); +} + +// A key bound to a *different* tool should not trigger it's command +function testUseKeyWithSplitConsoleWrongTool() { + let commandCalled = false; + + info("useKeyWithSplitConsole on inspector while debugger is focused"); + gToolbox.useKeyWithSplitConsole( + "F4", + () => { + commandCalled = true; + }, + "inspector" + ); + + info("synthesizeKey with the console focused"); + focusConsoleInput(); + synthesizeKeyShortcut("F4", panelWin); + + ok(!commandCalled, "Shortcut key shouldn't trigger the command"); +} + +async function cleanup() { + await gToolbox.destroy(); + gBrowser.removeCurrentTab(); + gToolbox = panelWin = null; +} + +function focusConsoleInput() { + gToolbox.getPanel("webconsole").hud.jsterm.focus(); +} diff --git a/devtools/client/framework/test/browser_toolbox_tabsswitch_shortcuts.js b/devtools/client/framework/test/browser_toolbox_tabsswitch_shortcuts.js new file mode 100644 index 0000000000..06123e8d68 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_tabsswitch_shortcuts.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); + +add_task(async function () { + const tab = await addTab("about:blank"); + + const toolIDs = (await getSupportedToolIds(tab)).filter( + id => id != "options" + ); + const toolbox = await gDevTools.showToolboxForTab(tab, { + hostType: Toolbox.HostType.BOTTOM, + toolId: toolIDs[0], + }); + const nextShortcut = L10N.getStr("toolbox.nextTool.key"); + const prevShortcut = L10N.getStr("toolbox.previousTool.key"); + + // Iterate over all tools, starting from options to netmonitor, in normal + // order. + for (let i = 1; i < toolIDs.length; i++) { + await testShortcuts(toolbox, i, nextShortcut, toolIDs); + } + + // Iterate again, in the same order, starting from netmonitor (so next one is + // 0: options). + for (let i = 0; i < toolIDs.length; i++) { + await testShortcuts(toolbox, i, nextShortcut, toolIDs); + } + + // Iterate over all tools in reverse order, starting from netmonitor to + // options. + for (let i = toolIDs.length - 2; i >= 0; i--) { + await testShortcuts(toolbox, i, prevShortcut, toolIDs); + } + + // Iterate again, in reverse order again, starting from options (so next one + // is length-1: netmonitor). + for (let i = toolIDs.length - 1; i >= 0; i--) { + await testShortcuts(toolbox, i, prevShortcut, toolIDs); + } + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); + +async function testShortcuts(toolbox, index, shortcut, toolIDs) { + info( + "Testing shortcut to switch to tool " + + index + + ":" + + toolIDs[index] + + " using shortcut " + + shortcut + ); + + const onToolSelected = toolbox.once("select"); + synthesizeKeyShortcut(shortcut); + const id = await onToolSelected; + + info("toolbox-select event from " + id); + + is( + toolIDs.indexOf(id), + index, + "Correct tool is selected on pressing the shortcut for " + id + ); +} diff --git a/devtools/client/framework/test/browser_toolbox_telemetry_activate_splitconsole.js b/devtools/client/framework/test/browser_toolbox_telemetry_activate_splitconsole.js new file mode 100644 index 0000000000..cd1a478f5c --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_telemetry_activate_splitconsole.js @@ -0,0 +1,108 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = + "data:text/html;charset=utf8,browser_toolbox_telemetry_activate_splitconsole.js"; +const ALL_CHANNELS = Ci.nsITelemetry.DATASET_ALL_CHANNELS; +const DATA = [ + { + timestamp: null, + category: "devtools.main", + method: "activate", + object: "split_console", + value: null, + extra: { + host: "bottom", + width: "1300", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "deactivate", + object: "split_console", + value: null, + extra: { + host: "bottom", + width: "1300", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "activate", + object: "split_console", + value: null, + extra: { + host: "bottom", + width: "1300", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "deactivate", + object: "split_console", + value: null, + extra: { + host: "bottom", + width: "1300", + }, + }, +]; + +add_task(async function () { + // See Bug 1500141: this test frequently fails on beta because some highlighter + // requests made by the BoxModel component in the layout view come back when the + // connection between the client and the server has been destroyed. We are forcing + // the computed view here to avoid the failures but ideally we should have an event + // or a promise on the inspector we can wait for to be sure the initialization is over. + // Logged Bug 1500918 to investigate this. + await pushPref("devtools.inspector.activeSidebar", "computedview"); + + // 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"); + + const tab = await addTab(URL); + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "inspector", + }); + + await toolbox.openSplitConsole(); + await toolbox.closeSplitConsole(); + await toolbox.openSplitConsole(); + await toolbox.closeSplitConsole(); + + await checkResults(); +}); + +async function checkResults() { + const snapshot = Services.telemetry.snapshotEvents(ALL_CHANNELS, true); + const events = snapshot.parent.filter( + event => + event[1] === "devtools.main" && + (event[2] === "activate" || event[2] === "deactivate") + ); + + 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"); + + // extras + is(extra.host, expected.extra.host, "host is correct"); + ok(extra.width > 0, "width is greater than 0"); + } +} diff --git a/devtools/client/framework/test/browser_toolbox_telemetry_close.js b/devtools/client/framework/test/browser_toolbox_telemetry_close.js new file mode 100644 index 0000000000..47aa1c056b --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_telemetry_close.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const URL = "data:text/html;charset=utf8,browser_toolbox_telemetry_close.js"; +const { RIGHT, BOTTOM } = Toolbox.HostType; +const DATA = [ + { + category: "devtools.main", + method: "close", + object: "tools", + value: null, + extra: { + host: "right", + width: w => w > 0, + }, + }, + { + category: "devtools.main", + method: "close", + object: "tools", + value: null, + extra: { + host: "bottom", + width: w => w > 0, + }, + }, +]; + +add_task(async function () { + // Let's reset the counts. + Services.telemetry.clearEvents(); + + // Ensure no events have been logged + TelemetryTestUtils.assertNumberOfEvents(0); + + await openAndCloseToolbox("webconsole", RIGHT); + await openAndCloseToolbox("webconsole", BOTTOM); + + checkResults(); +}); + +async function openAndCloseToolbox(toolId, host) { + const tab = await addTab(URL); + const toolbox = await gDevTools.showToolboxForTab(tab, { toolId }); + + await toolbox.switchHost(host); + await toolbox.destroy(); +} + +function checkResults() { + TelemetryTestUtils.assertEvents(DATA, { + category: "devtools.main", + method: "close", + object: "tools", + }); +} diff --git a/devtools/client/framework/test/browser_toolbox_telemetry_enter.js b/devtools/client/framework/test/browser_toolbox_telemetry_enter.js new file mode 100644 index 0000000000..4cb4611a97 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_telemetry_enter.js @@ -0,0 +1,152 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = "data:text/html;charset=utf8,browser_toolbox_telemetry_enter.js"; +const ALL_CHANNELS = Ci.nsITelemetry.DATASET_ALL_CHANNELS; +const DATA = [ + { + timestamp: null, + category: "devtools.main", + method: "enter", + object: "inspector", + value: null, + extra: { + host: "bottom", + width: "1300", + start_state: "initial_panel", + panel_name: "inspector", + cold: "true", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "enter", + object: "jsdebugger", + value: null, + extra: { + host: "bottom", + width: "1300", + start_state: "toolbox_show", + panel_name: "jsdebugger", + cold: "true", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "enter", + object: "styleeditor", + value: null, + extra: { + host: "bottom", + width: "1300", + start_state: "toolbox_show", + panel_name: "styleeditor", + cold: "true", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "enter", + object: "netmonitor", + value: null, + extra: { + host: "bottom", + width: "1300", + start_state: "toolbox_show", + panel_name: "netmonitor", + cold: "true", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "enter", + object: "storage", + value: null, + extra: { + host: "bottom", + width: "1300", + start_state: "toolbox_show", + panel_name: "storage", + cold: "true", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "enter", + object: "netmonitor", + value: null, + extra: { + host: "bottom", + width: "1300", + start_state: "toolbox_show", + panel_name: "netmonitor", + cold: "false", + }, + }, +]; + +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"); + + const tab = await addTab(URL); + + // Set up some cached messages for the web console. + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.console.log("test 1"); + content.console.log("test 2"); + content.console.log("test 3"); + content.console.log("test 4"); + content.console.log("test 5"); + }); + + // Open the toolbox + await gDevTools.showToolboxForTab(tab, { toolId: "inspector" }); + + // Switch between a few tools + await gDevTools.showToolboxForTab(tab, { toolId: "jsdebugger" }); + await gDevTools.showToolboxForTab(tab, { toolId: "styleeditor" }); + await gDevTools.showToolboxForTab(tab, { toolId: "netmonitor" }); + await gDevTools.showToolboxForTab(tab, { toolId: "storage" }); + await gDevTools.showToolboxForTab(tab, { toolId: "netmonitor" }); + + await checkResults(); +}); + +async function checkResults() { + const snapshot = Services.telemetry.snapshotEvents(ALL_CHANNELS, true); + const events = snapshot.parent.filter( + event => + event[1] === "devtools.main" && event[2] === "enter" && 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"); + + // extras + is(extra.host, expected.extra.host, "host is correct"); + ok(extra.width > 0, "width is greater than 0"); + is(extra.start_state, expected.extra.start_state, "start_state is correct"); + is(extra.panel_name, expected.extra.panel_name, "panel_name is correct"); + is(extra.cold, expected.extra.cold, "cold is correct"); + } +} diff --git a/devtools/client/framework/test/browser_toolbox_telemetry_exit.js b/devtools/client/framework/test/browser_toolbox_telemetry_exit.js new file mode 100644 index 0000000000..3056b9af8c --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_telemetry_exit.js @@ -0,0 +1,129 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = "data:text/html;charset=utf8,browser_toolbox_telemetry_enter.js"; +const ALL_CHANNELS = Ci.nsITelemetry.DATASET_ALL_CHANNELS; +const DATA = [ + { + timestamp: null, + category: "devtools.main", + method: "exit", + object: "inspector", + value: null, + extra: { + host: "bottom", + width: 1300, + panel_name: "inspector", + next_panel: "jsdebugger", + reason: "toolbox_show", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "exit", + object: "jsdebugger", + value: null, + extra: { + host: "bottom", + width: 1300, + panel_name: "jsdebugger", + next_panel: "styleeditor", + reason: "toolbox_show", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "exit", + object: "styleeditor", + value: null, + extra: { + host: "bottom", + width: 1300, + panel_name: "styleeditor", + next_panel: "netmonitor", + reason: "toolbox_show", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "exit", + object: "netmonitor", + value: null, + extra: { + host: "bottom", + width: 1300, + panel_name: "netmonitor", + next_panel: "storage", + reason: "toolbox_show", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "exit", + object: "storage", + value: null, + extra: { + host: "bottom", + width: 1300, + panel_name: "storage", + next_panel: "netmonitor", + reason: "toolbox_show", + }, + }, +]; + +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"); + + const tab = await addTab(URL); + + // Open the toolbox + await gDevTools.showToolboxForTab(tab, { toolId: "inspector" }); + + // Switch between a few tools + await gDevTools.showToolboxForTab(tab, { toolId: "jsdebugger" }); + await gDevTools.showToolboxForTab(tab, { toolId: "styleeditor" }); + await gDevTools.showToolboxForTab(tab, { toolId: "netmonitor" }); + await gDevTools.showToolboxForTab(tab, { toolId: "storage" }); + await gDevTools.showToolboxForTab(tab, { toolId: "netmonitor" }); + + await checkResults(); +}); + +async function checkResults() { + const snapshot = Services.telemetry.snapshotEvents(ALL_CHANNELS, true); + const events = snapshot.parent.filter( + event => + event[1] === "devtools.main" && event[2] === "exit" && 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"); + + // extras + is(extra.host, expected.extra.host, "host is correct"); + ok(extra.width > 0, "width is greater than 0"); + is(extra.panel_name, expected.extra.panel_name, "panel_name is correct"); + is(extra.next_panel, expected.extra.next_panel, "next_panel is correct"); + is(extra.reason, expected.extra.reason, "reason is correct"); + } +} diff --git a/devtools/client/framework/test/browser_toolbox_telemetry_open_event.js b/devtools/client/framework/test/browser_toolbox_telemetry_open_event.js new file mode 100644 index 0000000000..aa2f7ea2ed --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_telemetry_open_event.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the "open" telemetry event is correctly logged when opening the +// toolbox. +const ALL_CHANNELS = Ci.nsITelemetry.DATASET_ALL_CHANNELS; + +add_task(async function () { + Services.prefs.clearUserPref("devtools.toolbox.selectedTool"); + const tab = await addTab("data:text/html;charset=utf-8,Test open event"); + + info("Open the toolbox with a shortcut to trigger the open event"); + const onToolboxReady = gDevTools.once("toolbox-ready"); + EventUtils.synthesizeKey("VK_F12", {}); + await onToolboxReady; + + const snapshot = Services.telemetry.snapshotEvents(ALL_CHANNELS, true); + // The telemetry is sent by DevToolsStartup and so isn't flaged against any session id + const events = snapshot.parent.filter( + event => + event[1] === "devtools.main" && + event[2] === "open" && + event[5].session_id == -1 + ); + + is(events.length, 1, "Telemetry open event was logged"); + + const extras = events[0][5]; + is(extras.entrypoint, "KeyShortcut", "entrypoint extra is correct"); + // The logged shortcut is `${modifiers}+${shortcut}`, which adds an + // extra `+` before F12 here. + // See https://searchfox.org/mozilla-central/rev/c7e8bc4996f979e5876b33afae3de3b1ab4f3ae1/devtools/startup/DevToolsStartup.jsm#1070 + is(extras.shortcut, "+F12", "entrypoint shortcut is correct"); + + gBrowser.removeTab(tab); +}); diff --git a/devtools/client/framework/test/browser_toolbox_textbox_context_menu.js b/devtools/client/framework/test/browser_toolbox_textbox_context_menu.js new file mode 100644 index 0000000000..903d0c9912 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_textbox_context_menu.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// HTML inputs don't automatically get the 'edit' context menu, so we have +// a helper on the toolbox to do so. Make sure that shows menu items in the +// right state, and that it works for an input inside of a panel. + +const URL = "data:text/html;charset=utf8,test for textbox context menu"; +const textboxToolId = "testtool1"; + +registerCleanupFunction(() => { + gDevTools.unregisterTool(textboxToolId); +}); + +add_task(async function checkMenuEntryStates() { + info("Checking the state of edit menuitems with an empty clipboard"); + const toolbox = await openNewTabAndToolbox(URL, "inspector"); + + emptyClipboard(); + + // Make sure the focus is predictable. + const inspector = toolbox.getPanel("inspector"); + const onFocus = once(inspector.searchBox, "focus"); + inspector.searchBox.focus(); + await onFocus; + + info("Opening context menu"); + const onContextMenuPopup = toolbox.once("menu-open"); + synthesizeContextMenuEvent(inspector.searchBox); + await onContextMenuPopup; + + const textboxContextMenu = toolbox.getTextBoxContextMenu(); + ok(textboxContextMenu, "The textbox context menu is loaded in the toolbox"); + + const cmdUndo = textboxContextMenu.querySelector("#editmenu-undo"); + const cmdDelete = textboxContextMenu.querySelector("#editmenu-delete"); + const cmdSelectAll = textboxContextMenu.querySelector("#editmenu-selectAll"); + const cmdCut = textboxContextMenu.querySelector("#editmenu-cut"); + const cmdCopy = textboxContextMenu.querySelector("#editmenu-copy"); + const cmdPaste = textboxContextMenu.querySelector("#editmenu-paste"); + + is(cmdUndo.getAttribute("disabled"), "true", "cmdUndo is disabled"); + is(cmdDelete.getAttribute("disabled"), "true", "cmdDelete is disabled"); + is(cmdSelectAll.getAttribute("disabled"), "true", "cmdSelectAll is disabled"); + is(cmdCut.getAttribute("disabled"), "true", "cmdCut is disabled"); + is(cmdCopy.getAttribute("disabled"), "true", "cmdCopy is disabled"); + + if (isWindows()) { + // emptyClipboard only works on Windows (666254), assert paste only for this OS. + is(cmdPaste.getAttribute("disabled"), "true", "cmdPaste is disabled"); + } + + const onContextMenuHidden = toolbox.once("menu-close"); + if (Services.prefs.getBoolPref("widget.macos.native-context-menus", false)) { + info("Using hidePopup semantics because of macOS native context menus."); + textboxContextMenu.hidePopup(); + } else { + EventUtils.sendKey("ESCAPE", toolbox.win); + } + await onContextMenuHidden; +}); + +add_task(async function automaticallyBindTexbox() { + info( + "Registering a tool with an input field and making sure the context menu works" + ); + gDevTools.registerTool({ + id: textboxToolId, + isToolSupported: () => true, + url: CHROME_URL_ROOT + "doc_textbox_tool.html", + label: "Context menu works without tool intervention", + build(iframeWindow, toolbox) { + this.panel = createTestPanel(iframeWindow, toolbox); + return this.panel.open(); + }, + }); + + const toolbox = await openNewTabAndToolbox(URL, textboxToolId); + is(toolbox.currentToolId, textboxToolId, "The custom tool has been opened"); + + const doc = toolbox.getCurrentPanel().document; + await checkTextBox(doc.querySelector("input[type=text]"), toolbox); + await checkTextBox(doc.querySelector("textarea"), toolbox); + await checkTextBox(doc.querySelector("input[type=search]"), toolbox); + await checkTextBox(doc.querySelector("input:not([type])"), toolbox); + await checkNonTextInput(doc.querySelector("input[type=radio]"), toolbox); +}); + +async function checkNonTextInput(input, toolbox) { + let textboxContextMenu = toolbox.getTextBoxContextMenu(); + ok(!textboxContextMenu, "The menu is closed"); + + info( + "Simulating context click on the non text input and expecting no menu to open" + ); + const eventBubbledUp = new Promise(resolve => { + input.ownerDocument.addEventListener("contextmenu", resolve, { + once: true, + }); + }); + synthesizeContextMenuEvent(input); + info("Waiting for event"); + await eventBubbledUp; + + textboxContextMenu = toolbox.getTextBoxContextMenu(); + ok(!textboxContextMenu, "The menu is still closed"); +} + +async function checkTextBox(textBox, toolbox) { + let textboxContextMenu = toolbox.getTextBoxContextMenu(); + ok(!textboxContextMenu, "The menu is closed"); + + info( + "Simulating context click on the textbox and expecting the menu to open" + ); + const onContextMenu = toolbox.once("menu-open"); + synthesizeContextMenuEvent(textBox); + await onContextMenu; + + textboxContextMenu = toolbox.getTextBoxContextMenu(); + ok(textboxContextMenu, "The menu is now visible"); + + info("Closing the menu"); + const onContextMenuHidden = toolbox.once("menu-close"); + if (Services.prefs.getBoolPref("widget.macos.native-context-menus", false)) { + info("Using hidePopup semantics because of macOS native context menus."); + textboxContextMenu.hidePopup(); + } else { + EventUtils.sendKey("ESCAPE", toolbox.win); + } + await onContextMenuHidden; + + textboxContextMenu = toolbox.getTextBoxContextMenu(); + ok(!textboxContextMenu, "The menu is closed again"); +} diff --git a/devtools/client/framework/test/browser_toolbox_theme.js b/devtools/client/framework/test/browser_toolbox_theme.js new file mode 100644 index 0000000000..63d83e8312 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_theme.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const PREF_DEVTOOLS_THEME = "devtools.theme"; + +registerCleanupFunction(() => { + // Set preferences back to their original values + Services.prefs.clearUserPref(PREF_DEVTOOLS_THEME); +}); + +add_task(async function testDevtoolsTheme() { + info("Checking stylesheet and :root attributes based on devtools theme."); + Services.prefs.setCharPref(PREF_DEVTOOLS_THEME, "light"); + is( + document.getElementById("appcontent").getAttribute("devtoolstheme"), + "light", + "The element has an attribute based on devtools theme." + ); + + Services.prefs.setCharPref(PREF_DEVTOOLS_THEME, "dark"); + is( + document.getElementById("appcontent").getAttribute("devtoolstheme"), + "dark", + "The element has an attribute based on devtools theme." + ); + + Services.prefs.setCharPref(PREF_DEVTOOLS_THEME, "unknown"); + is( + document.getElementById("appcontent").getAttribute("devtoolstheme"), + "light", + "The element has 'light' as a default for the devtoolstheme attribute." + ); +}); diff --git a/devtools/client/framework/test/browser_toolbox_theme_registration.js b/devtools/client/framework/test/browser_toolbox_theme_registration.js new file mode 100644 index 0000000000..6f5d2bc679 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_theme_registration.js @@ -0,0 +1,166 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for dynamically registering and unregistering themes +const CHROME_URL = + "chrome://mochitests/content/browser/devtools/client/framework/test/"; +const TEST_THEME_NAME = "test-theme"; +const LIGHT_THEME_NAME = "light"; + +var toolbox; + +add_task(async function themeRegistration() { + const tab = await addTab("data:text/html,test"); + toolbox = await gDevTools.showToolboxForTab(tab, { toolId: "options" }); + + const themeId = await new Promise(resolve => { + gDevTools.once("theme-registered", registeredThemeId => { + resolve(registeredThemeId); + }); + + gDevTools.registerTheme({ + id: TEST_THEME_NAME, + label: "Test theme", + stylesheets: [CHROME_URL + "doc_theme.css"], + classList: ["theme-test"], + }); + }); + + is(themeId, TEST_THEME_NAME, "theme-registered event handler sent theme id"); + + ok(gDevTools.getThemeDefinitionMap().has(themeId), "theme added to map"); +}); + +add_task(async function themeInOptionsPanel() { + const panelWin = toolbox.getCurrentPanel().panelWin; + const doc = panelWin.frameElement.contentDocument; + const themeBox = doc.getElementById("devtools-theme-box"); + const testThemeOption = themeBox.querySelector( + `input[type=radio][value=${TEST_THEME_NAME}]` + ); + const eventsRecorded = []; + + function onThemeChanged(theme) { + eventsRecorded.push(theme); + } + gDevTools.on("theme-changed", onThemeChanged); + + ok(testThemeOption, "new theme exists in the Options panel"); + + const lightThemeOption = themeBox.querySelector( + `input[type=radio][value=${LIGHT_THEME_NAME}]` + ); + + let color = panelWin.getComputedStyle(themeBox).color; + isnot(color, "rgb(255, 0, 0)", "style unapplied"); + + let onThemeSwithComplete = once(panelWin, "theme-switch-complete"); + + // Select test theme. + testThemeOption.click(); + + info("Waiting for theme to finish loading"); + await onThemeSwithComplete; + + is( + gDevTools.getTheme(), + TEST_THEME_NAME, + "getTheme returns the expected theme" + ); + is( + eventsRecorded.pop(), + TEST_THEME_NAME, + "theme-changed fired with the expected theme" + ); + + color = panelWin.getComputedStyle(themeBox).color; + is(color, "rgb(255, 0, 0)", "style applied"); + + onThemeSwithComplete = once(panelWin, "theme-switch-complete"); + + // Select light theme + lightThemeOption.click(); + + info("Waiting for theme to finish loading"); + await onThemeSwithComplete; + + is( + gDevTools.getTheme(), + LIGHT_THEME_NAME, + "getTheme returns the expected theme" + ); + is( + eventsRecorded.pop(), + LIGHT_THEME_NAME, + "theme-changed fired with the expected theme" + ); + + color = panelWin.getComputedStyle(themeBox).color; + isnot(color, "rgb(255, 0, 0)", "style unapplied"); + + onThemeSwithComplete = once(panelWin, "theme-switch-complete"); + // Select test theme again. + testThemeOption.click(); + await onThemeSwithComplete; + is( + gDevTools.getTheme(), + TEST_THEME_NAME, + "getTheme returns the expected theme" + ); + is( + eventsRecorded.pop(), + TEST_THEME_NAME, + "theme-changed fired with the expected theme" + ); + + gDevTools.off("theme-changed", onThemeChanged); +}); + +add_task(async function themeUnregistration() { + const panelWin = toolbox.getCurrentPanel().panelWin; + const onUnRegisteredTheme = once(gDevTools, "theme-unregistered"); + const onThemeSwitchComplete = once(panelWin, "theme-switch-complete"); + const eventsRecorded = []; + + function onThemeChanged(theme) { + eventsRecorded.push(theme); + } + gDevTools.on("theme-changed", onThemeChanged); + + gDevTools.unregisterTheme(TEST_THEME_NAME); + await onUnRegisteredTheme; + await onThemeSwitchComplete; + + is( + gDevTools.getTheme(), + gDevTools.getAutoTheme(), + "getTheme returns the expected theme" + ); + is( + eventsRecorded.pop(), + gDevTools.getAutoTheme(), + "theme-changed fired with the expected theme" + ); + ok( + !gDevTools.getThemeDefinitionMap().has(TEST_THEME_NAME), + "theme removed from map" + ); + + const doc = panelWin.frameElement.contentDocument; + const themeBox = doc.getElementById("devtools-theme-box"); + + // The default theme must be selected now. + ok( + themeBox.querySelector(`#devtools-theme-box [value=auto]`).checked, + `auto theme must be selected` + ); + + gDevTools.off("theme-changed", onThemeChanged); +}); + +add_task(async function cleanup() { + await toolbox.destroy(); + toolbox = null; +}); diff --git a/devtools/client/framework/test/browser_toolbox_toggle.js b/devtools/client/framework/test/browser_toolbox_toggle.js new file mode 100644 index 0000000000..c3848ae5ad --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_toggle.js @@ -0,0 +1,111 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test toggling the toolbox with ACCEL+SHIFT+I / ACCEL+ALT+I and F12 in docked +// and detached (window) modes. + +const URL = "data:text/html;charset=utf-8,Toggling devtools using shortcuts"; + +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function () { + // Make sure this test starts with the selectedTool pref cleared. Previous + // tests select various tools, and that sets this pref. + Services.prefs.clearUserPref("devtools.toolbox.selectedTool"); + + // Test with ACCEL+SHIFT+I / ACCEL+ALT+I (MacOSX) ; modifiers should match : + // - toolbox-key-toggle in devtools/client/framework/toolbox-window.xhtml + // - key_devToolboxMenuItem in browser/base/content/browser.xhtml + info("Test toggle using CTRL+SHIFT+I/CMD+ALT+I"); + await testToggle("I", { + accelKey: true, + shiftKey: !navigator.userAgent.match(/Mac/), + altKey: navigator.userAgent.match(/Mac/), + }); + + // Test with F12 ; no modifiers + info("Test toggle using F12"); + await testToggle("VK_F12", {}); +}); + +async function testToggle(key, modifiers) { + const tab = await addTab(URL + " ; key : '" + key + "'"); + await gDevTools.showToolboxForTab(tab); + + await testToggleDockedToolbox(tab, key, modifiers); + await testToggleDetachedToolbox(tab, key, modifiers); + + await cleanup(); +} + +async function testToggleDockedToolbox(tab, key, modifiers) { + const toolbox = gDevTools.getToolboxForTab(tab); + + isnot( + toolbox.hostType, + Toolbox.HostType.WINDOW, + "Toolbox is docked in the main window" + ); + + info("verify docked toolbox is destroyed when using toggle key"); + const onToolboxDestroyed = gDevTools.once("toolbox-destroyed"); + EventUtils.synthesizeKey(key, modifiers); + await onToolboxDestroyed; + ok(true, "Docked toolbox is destroyed when using a toggle key"); + + info("verify new toolbox is created when using toggle key"); + const onToolboxReady = gDevTools.once("toolbox-ready"); + EventUtils.synthesizeKey(key, modifiers); + await onToolboxReady; + ok(true, "Toolbox is created by using when toggle key"); +} + +async function testToggleDetachedToolbox(tab, key, modifiers) { + const toolbox = gDevTools.getToolboxForTab(tab); + + info("change the toolbox hostType to WINDOW"); + + await toolbox.switchHost(Toolbox.HostType.WINDOW); + is( + toolbox.hostType, + Toolbox.HostType.WINDOW, + "Toolbox opened on separate window" + ); + + info("Wait for focus on the toolbox window"); + await new Promise(res => waitForFocus(res, toolbox.win)); + + info("Focus main window to put the toolbox window in the background"); + + const onMainWindowFocus = once(window, "focus"); + window.focus(); + await onMainWindowFocus; + ok(true, "Main window focused"); + + info( + "Verify windowed toolbox is focused instead of closed when using " + + "toggle key from the main window" + ); + const toolboxWindow = toolbox.topWindow; + const onToolboxWindowFocus = once(toolboxWindow, "focus", true); + EventUtils.synthesizeKey(key, modifiers); + await onToolboxWindowFocus; + ok(true, "Toolbox focused and not destroyed"); + + info( + "Verify windowed toolbox is destroyed when using toggle key from its " + + "own window" + ); + + const onToolboxDestroyed = gDevTools.once("toolbox-destroyed"); + EventUtils.synthesizeKey(key, modifiers, toolboxWindow); + await onToolboxDestroyed; + ok(true, "Toolbox destroyed"); +} + +function cleanup() { + Services.prefs.setCharPref("devtools.toolbox.host", Toolbox.HostType.BOTTOM); + gBrowser.removeCurrentTab(); +} diff --git a/devtools/client/framework/test/browser_toolbox_tool_ready.js b/devtools/client/framework/test/browser_toolbox_tool_ready.js new file mode 100644 index 0000000000..306e4598af --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_tool_ready.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(5); + +async function performChecks(tab) { + let toolbox; + const toolIds = await getSupportedToolIds(tab); + for (const toolId of toolIds) { + info("About to open " + toolId); + toolbox = await gDevTools.showToolboxForTab(tab, { toolId }); + ok(toolbox, "toolbox exists for " + toolId); + is(toolbox.currentToolId, toolId, "currentToolId should be " + toolId); + + const panel = toolbox.getCurrentPanel(); + ok(panel, toolId + " panel has been registered in the toolbox"); + } + + await toolbox.destroy(); +} + +function test() { + (async function () { + toggleAllTools(true); + const tab = await addTab("about:blank"); + await performChecks(tab); + gBrowser.removeCurrentTab(); + toggleAllTools(false); + finish(); + })(); +} diff --git a/devtools/client/framework/test/browser_toolbox_tool_remote_reopen.js b/devtools/client/framework/test/browser_toolbox_tool_remote_reopen.js new file mode 100644 index 0000000000..52a6ea0655 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_tool_remote_reopen.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); + +// Bug 1277805: Too slow for debug runs +requestLongerTimeout(2); + +/** + * Bug 979536: Ensure fronts are destroyed after toolbox close. + * + * The fronts need to be destroyed manually to unbind their onPacket handlers. + * + * When you initialize a front and call |this.manage|, it adds a client actor + * pool that the DevToolsClient uses to route packet replies to that actor. + * + * Most (all?) tools create a new front when they are opened. When the destroy + * step is skipped and the tool is reopened, a second front is created and also + * added to the client actor pool. When a packet reply is received, is ends up + * being routed to the first (now unwanted) front that is still in the client + * actor pool. Since this is not the same front that was used to make the + * request, an error occurs. + * + * This problem does not occur with the toolbox for a local tab because the + * toolbox target creates its own DevToolsClient for the local tab, and the + * client is destroyed when the toolbox is closed, which removes the client + * actor pools, and avoids this issue. + * + * In remote debugging, we do not destroy the DevToolsClient on toolbox close + * because it can still used for other targets. + * Thus, the same client gets reused across multiple toolboxes, + * which leads to the tools failing if they don't destroy their fronts. + */ + +function runTools(tab) { + return (async function () { + let toolbox; + const toolIds = await getSupportedToolIds(tab); + for (const toolId of toolIds) { + info("About to open " + toolId); + toolbox = await gDevTools.showToolboxForTab(tab, { + toolId, + hostType: "window", + }); + ok(toolbox, "toolbox exists for " + toolId); + is(toolbox.currentToolId, toolId, "currentToolId should be " + toolId); + + const panel = toolbox.getCurrentPanel(); + ok(panel, toolId + " panel has been registered in the toolbox"); + } + + const client = toolbox.commands.client; + await toolbox.destroy(); + + // We need to check the client after the toolbox destruction. + return client; + })(); +} + +function test() { + (async function () { + toggleAllTools(true); + const tab = await addTab("about:blank"); + + const client = await runTools(tab); + + const rootFronts = [...client.mainRoot.fronts.values()]; + + // Actor fronts should be destroyed now that the toolbox has closed, but + // look for any that remain. + for (const pool of client.__pools) { + if (!pool.__poolMap) { + continue; + } + + // Ignore the root fronts, which are top-level pools and aren't released + // on toolbox destroy, but on client close. + if (rootFronts.includes(pool)) { + continue; + } + + for (const actor of pool.__poolMap.keys()) { + // Ignore the root front as it is only release on client close + if (actor == "root") { + continue; + } + ok(false, "Front for " + actor + " still held in pool!"); + } + } + + gBrowser.removeCurrentTab(); + DevToolsServer.destroy(); + toggleAllTools(false); + finish(); + })(); +} diff --git a/devtools/client/framework/test/browser_toolbox_toolbar_minimum_width.js b/devtools/client/framework/test/browser_toolbox_toolbar_minimum_width.js new file mode 100644 index 0000000000..cdd6678e6f --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_toolbar_minimum_width.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that all of buttons of tool tab go to the overflowed menu when the devtool's +// width is narrow. + +const SIDEBAR_WIDTH_PREF = "devtools.toolbox.sidebar.width"; + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function (pickerEnable, commandsEnable) { + // 74px is Chevron(26px) + Meatball(24px) + Close(24px) + // devtools-browser.css defined this minimum width by using min-width. + Services.prefs.setIntPref(SIDEBAR_WIDTH_PREF, 74); + registerCleanupFunction(function () { + Services.prefs.clearUserPref(SIDEBAR_WIDTH_PREF); + }); + const tab = await addTab("about:blank"); + + info("Open devtools on the Inspector in a side dock"); + const toolbox = await openToolboxForTab( + tab, + "inspector", + Toolbox.HostType.RIGHT + ); + await waitUntil(() => toolbox.doc.querySelector(".tools-chevron-menu")); + + await openChevronMenu(toolbox); + + // Check that all of tools is overflowed. + toolbox.panelDefinitions.forEach(({ id }) => { + const menuItem = toolbox.doc.getElementById( + "tools-chevron-menupopup-" + id + ); + const tab = toolbox.doc.getElementById("toolbox-tab-" + id); + ok(menuItem, id + " is in the overflowed menu"); + ok(!tab, id + " tab does not exist"); + }); + + await closeChevronMenu(toolbox); +}); diff --git a/devtools/client/framework/test/browser_toolbox_toolbar_overflow.js b/devtools/client/framework/test/browser_toolbox_toolbar_overflow.js new file mode 100644 index 0000000000..9f964af18e --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_toolbar_overflow.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that a button to access tools hidden by toolbar overflow is displayed when the +// toolbar starts to present an overflow. +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function () { + const tab = await addTab("about:blank"); + + info("Open devtools on the Inspector in a bottom dock"); + const toolbox = await openToolboxForTab( + tab, + "inspector", + Toolbox.HostType.BOTTOM + ); + + const hostWindow = toolbox.topWindow; + const originalWidth = hostWindow.outerWidth; + const originalHeight = hostWindow.outerHeight; + + info( + "Resize devtools window to a width that should not trigger any overflow" + ); + let onResize = once(hostWindow, "resize"); + hostWindow.resizeTo(1350, 300); + await onResize; + + info("Wait for all buttons to be displayed"); + await waitUntil(() => { + return ( + toolbox.panelDefinitions.length === + toolbox.doc.querySelectorAll(".devtools-tab").length + ); + }); + + let chevronMenuButton = toolbox.doc.querySelector(".tools-chevron-menu"); + ok(!chevronMenuButton, "The chevron menu button is not displayed"); + + info("Resize devtools window to a width that should trigger an overflow"); + onResize = once(hostWindow, "resize"); + hostWindow.resizeTo(800, 300); + await onResize; + await waitUntil(() => !toolbox.doc.querySelector(".tools-chevron-menu")); + + info("Wait until the chevron menu button is available"); + await waitUntil(() => toolbox.doc.querySelector(".tools-chevron-menu")); + + chevronMenuButton = toolbox.doc.querySelector(".tools-chevron-menu"); + ok(chevronMenuButton, "The chevron menu button is displayed"); + + info( + "Open the tools-chevron-menupopup and verify that the inspector button is checked" + ); + await openChevronMenu(toolbox); + + const inspectorButton = toolbox.doc.querySelector( + "#tools-chevron-menupopup-inspector" + ); + ok(!inspectorButton, "The chevron menu doesn't have the inspector button."); + + const consoleButton = toolbox.doc.querySelector( + "#tools-chevron-menupopup-webconsole" + ); + ok(!consoleButton, "The chevron menu doesn't have the console button."); + + const storageButton = toolbox.doc.querySelector( + "#tools-chevron-menupopup-storage" + ); + ok(storageButton, "The chevron menu has the storage button."); + + info("Switch to the performance using the tools-chevron-menupopup popup"); + const onSelected = toolbox.once("storage-selected"); + storageButton.click(); + await onSelected; + + info("Restore the original window size"); + onResize = once(hostWindow, "resize"); + hostWindow.resizeTo(originalWidth, originalHeight); + await onResize; +}); diff --git a/devtools/client/framework/test/browser_toolbox_toolbar_overflow_button_visibility.js b/devtools/client/framework/test/browser_toolbox_toolbar_overflow_button_visibility.js new file mode 100644 index 0000000000..f266991109 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_toolbar_overflow_button_visibility.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the toolbox tabs rearrangement when the visibility of toolbox buttons were changed. + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function () { + const tab = await addTab("about:blank"); + const toolbox = await openToolboxForTab( + tab, + "options", + Toolbox.HostType.BOTTOM + ); + const toolboxButtonPreferences = toolbox.toolbarButtons.map( + button => button.visibilityswitch + ); + + const win = getWindow(toolbox); + const { outerWidth: originalWindowWidth, outerHeight: originalWindowHeight } = + win; + registerCleanupFunction(() => { + for (const preference of toolboxButtonPreferences) { + Services.prefs.clearUserPref(preference); + } + + win.resizeTo(originalWindowWidth, originalWindowHeight); + }); + + const optionsTool = toolbox.getCurrentPanel(); + const checkButtons = optionsTool.panelWin.document.querySelectorAll( + "#enabled-toolbox-buttons-box input[type=checkbox]" + ); + + info( + "Test the count of shown devtools tab after making all buttons to be visible" + ); + await resizeWindow(toolbox, 800); + // Once, make all toolbox button to be invisible. + setToolboxButtonsVisibility(checkButtons, false); + // Get count of shown devtools tab elements. + const initialTabCount = toolbox.doc.querySelectorAll(".devtools-tab").length; + // Make all toolbox button to be visible. + setToolboxButtonsVisibility(checkButtons, true); + Assert.less( + toolbox.doc.querySelectorAll(".devtools-tab").length, + initialTabCount, + "Count of shown devtools tab should decreased" + ); + + info( + "Test the count of shown devtools tab after making all buttons to be invisible" + ); + setToolboxButtonsVisibility(checkButtons, false); + is( + toolbox.doc.querySelectorAll(".devtools-tab").length, + initialTabCount, + "Count of shown devtools tab should be same to 1st count" + ); +}); + +function setToolboxButtonsVisibility(checkButtons, doVisible) { + for (const checkButton of checkButtons) { + if (checkButton.checked === doVisible) { + continue; + } + + checkButton.click(); + } +} diff --git a/devtools/client/framework/test/browser_toolbox_toolbar_reorder_by_dnd.js b/devtools/client/framework/test/browser_toolbox_toolbar_reorder_by_dnd.js new file mode 100644 index 0000000000..9ef82ca6e9 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_toolbar_reorder_by_dnd.js @@ -0,0 +1,188 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following reordering operation: +// * DragAndDrop the target component to back +// * DragAndDrop the target component to front +// * DragAndDrop the target component over the starting of the tab +// * DragAndDrop the target component over the ending of the tab +// * Mouse was out from the document while dragging +// * Select overflowed item, then DnD that +// +// This test is on the assumption which default toolbar has following tools. +// * inspector +// * webconsole +// * jsdebugger +// * styleeditor +// * performance +// * memory +// * netmonitor +// * storage +// * accessibility +// * application + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +const TEST_STARTING_ORDER = [ + "inspector", + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", +]; +const TEST_DATA = [ + { + description: "DragAndDrop the target component to back", + dragTarget: "webconsole", + dropTarget: "jsdebugger", + expectedOrder: [ + "inspector", + "jsdebugger", + "webconsole", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ], + }, + { + description: "DragAndDrop the target component to front", + dragTarget: "webconsole", + dropTarget: "inspector", + expectedOrder: [ + "webconsole", + "inspector", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ], + }, + { + description: + "DragAndDrop the target component over the starting of the tab", + dragTarget: "netmonitor", + passedTargets: [ + "memory", + "performance", + "styleeditor", + "jsdebugger", + "webconsole", + "inspector", + ], + dropTarget: "#toolbox-buttons-start", + expectedOrder: [ + "netmonitor", + "inspector", + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "storage", + "accessibility", + "application", + ], + }, + { + description: "DragAndDrop the target component over the ending of the tab", + dragTarget: "webconsole", + passedTargets: [ + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + ], + dropTarget: "#toolbox-buttons-end", + expectedOrder: [ + "inspector", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + "webconsole", + ], + }, +]; + +add_task(async function () { + // Enable the Application panel (atm it's only available on Nightly) + await pushPref("devtools.application.enabled", true); + + const tab = await addTab("about:blank"); + const toolbox = await openToolboxForTab( + tab, + "inspector", + Toolbox.HostType.BOTTOM + ); + + const originalPreference = Services.prefs.getCharPref( + "devtools.toolbox.tabsOrder" + ); + const win = getWindow(toolbox); + const { outerWidth: originalWindowWidth, outerHeight: originalWindowHeight } = + win; + registerCleanupFunction(() => { + Services.prefs.setCharPref( + "devtools.toolbox.tabsOrder", + originalPreference + ); + win.resizeTo(originalWindowWidth, originalWindowHeight); + }); + + for (const testData of TEST_DATA) { + info(`Test for '${testData.description}'`); + prepareToolTabReorderTest(toolbox, TEST_STARTING_ORDER); + await dndToolTab( + toolbox, + testData.dragTarget, + testData.dropTarget, + testData.passedTargets + ); + assertToolTabOrder(toolbox, testData.expectedOrder); + assertToolTabSelected(toolbox, testData.dragTarget); + assertToolTabPreferenceOrder(testData.expectedOrder); + } + + info("Test with overflowing tabs"); + prepareToolTabReorderTest(toolbox, TEST_STARTING_ORDER); + await resizeWindow(toolbox, 800); + await toolbox.selectTool("storage"); + const dragTarget = "storage"; + const dropTarget = "inspector"; + const expectedOrder = [ + "storage", + "inspector", + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "accessibility", + "application", + ]; + await dndToolTab(toolbox, dragTarget, dropTarget); + assertToolTabSelected(toolbox, dragTarget); + assertToolTabPreferenceOrder(expectedOrder); +}); diff --git a/devtools/client/framework/test/browser_toolbox_toolbar_reorder_by_width.js b/devtools/client/framework/test/browser_toolbox_toolbar_reorder_by_width.js new file mode 100644 index 0000000000..3a8cd61d12 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_toolbar_reorder_by_width.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// This test will: +// +// * Confirm that currently selected button to access tools will not hide due to overflow. +// In this case, a button which is located on the left of a currently selected will hide. +// * Confirm that a button to access tool will hide when registering a new panel. +// +// Note that this test is based on the tab ordinal is fixed. +// i.e. After changed by Bug 1226272, this test might fail. + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +add_task(async function () { + const tab = await addTab("about:blank"); + + info("Open devtools on the Storage in a sidebar."); + const toolbox = await openToolboxForTab( + tab, + "storage", + Toolbox.HostType.BOTTOM + ); + + const win = getWindow(toolbox); + const { outerWidth: originalWindowWidth, outerHeight: originalWindowHeight } = + win; + registerCleanupFunction(() => { + win.resizeTo(originalWindowWidth, originalWindowHeight); + }); + + info("Waiting for the window to be resized"); + await resizeWindow(toolbox, 800); + + info("Wait until the tools menu button is available"); + await waitUntil(() => toolbox.doc.querySelector(".tools-chevron-menu")); + + const toolsMenuButton = toolbox.doc.querySelector(".tools-chevron-menu"); + ok(toolsMenuButton, "The tools menu button is displayed"); + + info("Confirm that selected tab is not hidden."); + const storageButton = toolbox.doc.querySelector("#toolbox-tab-storage"); + ok(storageButton, "The storage tab is on toolbox."); + + // Reset window size for 2nd test. + await resizeWindow(toolbox, originalWindowWidth); +}); + +add_task(async function () { + const tab = await addTab("about:blank"); + + info("Open devtools on the Storage in a sidebar."); + const toolbox = await openToolboxForTab( + tab, + "storage", + Toolbox.HostType.BOTTOM + ); + + info("Resize devtools window to a width that should trigger an overflow"); + await resizeWindow(toolbox, 800); + + info("Regist a new tab"); + const onRegistered = toolbox.once("tool-registered"); + gDevTools.registerTool({ + id: "test-tools", + label: "Test Tools", + isMenu: true, + isToolSupported: () => true, + build() {}, + }); + await onRegistered; + + info("Open the tools menu button."); + await openChevronMenu(toolbox); + + info("The registered new tool tab should be in the tools menu."); + let testToolsButton = toolbox.doc.querySelector( + "#tools-chevron-menupopup-test-tools" + ); + ok(testToolsButton, "The tools menu has a registered new tool button."); + + await closeChevronMenu(toolbox); + + info("Unregistering test-tools"); + const onUnregistered = toolbox.once("tool-unregistered"); + gDevTools.unregisterTool("test-tools"); + await onUnregistered; + + info("Open the tools menu button."); + await openChevronMenu(toolbox); + + info("An unregistered new tool tab should not be in the tools menu."); + testToolsButton = toolbox.doc.querySelector( + "#tools-chevron-menupopup-test-tools" + ); + ok( + !testToolsButton, + "The tools menu doesn't have a unregistered new tool button." + ); + + await closeChevronMenu(toolbox); +}); diff --git a/devtools/client/framework/test/browser_toolbox_toolbar_reorder_with_extension.js b/devtools/client/framework/test/browser_toolbox_toolbar_reorder_with_extension.js new file mode 100644 index 0000000000..d00aca4b0f --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_toolbar_reorder_with_extension.js @@ -0,0 +1,150 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for reordering with an extension installed. + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +const EXTENSION = "@reorder.test"; + +const TEST_STARTING_ORDER = [ + "inspector", + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + EXTENSION, +]; + +add_task(async function () { + // Enable the Application panel (atm it's only available on Nightly) + await pushPref("devtools.application.enabled", true); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + devtools_page: "extension.html", + browser_specific_settings: { + gecko: { id: EXTENSION }, + }, + }, + files: { + "extension.html": ` + + + + + + + + `, + "extension.js": async () => { + // eslint-disable-next-line no-undef + await browser.devtools.panels.create( + "extension", + "fake-icon.png", + "empty.html" + ); + // eslint-disable-next-line no-undef + browser.test.sendMessage("devtools-page-ready"); + }, + "empty.html": "", + }, + }); + + await extension.startup(); + + const tab = await addTab("about:blank"); + const toolbox = await openToolboxForTab( + tab, + "webconsole", + Toolbox.HostType.BOTTOM + ); + await extension.awaitMessage("devtools-page-ready"); + + const originalPreference = Services.prefs.getCharPref( + "devtools.toolbox.tabsOrder" + ); + const win = getWindow(toolbox); + const { outerWidth: originalWindowWidth, outerHeight: originalWindowHeight } = + win; + registerCleanupFunction(() => { + Services.prefs.setCharPref( + "devtools.toolbox.tabsOrder", + originalPreference + ); + win.resizeTo(originalWindowWidth, originalWindowHeight); + }); + + info("Test for DragAndDrop the extension tab"); + let dragTarget = EXTENSION; + let dropTarget = "webconsole"; + let expectedOrder = [ + "inspector", + EXTENSION, + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ]; + prepareToolTabReorderTest(toolbox, TEST_STARTING_ORDER); + await dndToolTab(toolbox, dragTarget, dropTarget); + assertToolTabOrder(toolbox, expectedOrder); + assertToolTabSelected(toolbox, dragTarget); + assertToolTabPreferenceOrder(expectedOrder); + + info("Test the case of that the extension tab is overflowed"); + prepareToolTabReorderTest(toolbox, TEST_STARTING_ORDER); + await resizeWindow(toolbox, 800); + await toolbox.selectTool("storage"); + dragTarget = "storage"; + dropTarget = "inspector"; + expectedOrder = [ + "storage", + "inspector", + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "accessibility", + "application", + EXTENSION, + ]; + await dndToolTab(toolbox, dragTarget, dropTarget); + assertToolTabPreferenceOrder(expectedOrder); + await resizeWindow(toolbox, originalWindowWidth, originalWindowHeight); + + info("Test the preference after uninstalling extension"); + prepareToolTabReorderTest(toolbox, TEST_STARTING_ORDER); + await extension.unload(); + dragTarget = "webconsole"; + dropTarget = "inspector"; + expectedOrder = [ + "webconsole", + "inspector", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ]; + await dndToolTab(toolbox, dragTarget, dropTarget); + assertToolTabPreferenceOrder(expectedOrder); +}); diff --git a/devtools/client/framework/test/browser_toolbox_toolbar_reorder_with_hidden_extension.js b/devtools/client/framework/test/browser_toolbox_toolbar_reorder_with_hidden_extension.js new file mode 100644 index 0000000000..6e7b44d5d3 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_toolbar_reorder_with_hidden_extension.js @@ -0,0 +1,248 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for reordering with an hidden extension installed. + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +const EXTENSION = "@reorder.test"; + +const TEST_DATA = [ + { + description: "Test that drags a tab to left beyond the extension's tab", + startingOrder: [ + "inspector", + EXTENSION, + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ], + dragTarget: "webconsole", + dropTarget: "inspector", + expectedOrder: [ + "webconsole", + "inspector", + EXTENSION, + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ], + }, + { + description: "Test that drags a tab to right beyond the extension's tab", + startingOrder: [ + "inspector", + EXTENSION, + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ], + dragTarget: "inspector", + dropTarget: "webconsole", + expectedOrder: [ + EXTENSION, + "webconsole", + "inspector", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ], + }, + { + description: + "Test that drags a tab to left end, but hidden tab is left end", + startingOrder: [ + EXTENSION, + "inspector", + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ], + dragTarget: "webconsole", + dropTarget: "inspector", + expectedOrder: [ + EXTENSION, + "webconsole", + "inspector", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ], + }, + { + description: + "Test that drags a tab to right end, but hidden tab is right end", + startingOrder: [ + "inspector", + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + EXTENSION, + ], + dragTarget: "webconsole", + dropTarget: "application", + expectedOrder: [ + "inspector", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + EXTENSION, + "webconsole", + ], + }, +]; + +add_task(async function () { + // Enable the Application panel (atm it's only available on Nightly) + await pushPref("devtools.application.enabled", true); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("devtools.toolbox.tabsOrder"); + }); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + devtools_page: "extension.html", + browser_specific_settings: { + gecko: { id: EXTENSION }, + }, + }, + files: { + "extension.html": ` + + + + + + + + `, + "extension.js": async () => { + // Don't call browser.devtools.panels.create since this need to be as hidden. + // eslint-disable-next-line + browser.test.sendMessage("devtools-page-ready"); + }, + }, + }); + + await extension.startup(); + + const tab = await addTab("about:blank"); + const toolbox = await openToolboxForTab( + tab, + "webconsole", + Toolbox.HostType.BOTTOM + ); + await extension.awaitMessage("devtools-page-ready"); + + for (const { + description, + startingOrder, + dragTarget, + dropTarget, + expectedOrder, + } of TEST_DATA) { + info(description); + prepareTestWithHiddenExtension(toolbox, startingOrder); + await dndToolTab(toolbox, dragTarget, dropTarget); + assertToolTabPreferenceOrder(expectedOrder); + } + + info("Test ordering preference after uninstalling hidden addon"); + const startingOrder = [ + "inspector", + EXTENSION, + "webconsole", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ]; + const dragTarget = "webconsole"; + const dropTarget = "inspector"; + const expectedOrder = [ + "webconsole", + "inspector", + "jsdebugger", + "styleeditor", + "performance", + "memory", + "netmonitor", + "storage", + "accessibility", + "application", + ]; + prepareTestWithHiddenExtension(toolbox, startingOrder); + await extension.unload(); + await dndToolTab(toolbox, dragTarget, dropTarget); + assertToolTabPreferenceOrder(expectedOrder); +}); + +function prepareTestWithHiddenExtension(toolbox, startingOrder) { + Services.prefs.setCharPref( + "devtools.toolbox.tabsOrder", + startingOrder.join(",") + ); + + for (const id of startingOrder) { + if (id === EXTENSION) { + ok( + !getElementByToolId(toolbox, id), + "Hidden extension tab should not exist" + ); + } else { + ok(getElementByToolId(toolbox, id), `Tab element should exist for ${id}`); + } + } +} diff --git a/devtools/client/framework/test/browser_toolbox_tools_per_toolbox_registration.js b/devtools/client/framework/test/browser_toolbox_tools_per_toolbox_registration.js new file mode 100644 index 0000000000..0e9009497f --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_tools_per_toolbox_registration.js @@ -0,0 +1,138 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URL = `data:text/html, + + + + + + test for registering and unregistering tools to a specific toolbox + + `; + +const TOOL_ID = "test-toolbox-tool"; +var toolbox; + +function test() { + addTab(TEST_URL).then(async tab => { + gDevTools + .showToolboxForTab(tab) + .then(toolboxRegister) + .then(testToolRegistered); + }); +} + +var resolveToolInstanceBuild; +var waitForToolInstanceBuild = new Promise(resolve => { + resolveToolInstanceBuild = resolve; +}); + +var resolveToolInstanceDestroyed; +var waitForToolInstanceDestroyed = new Promise(resolve => { + resolveToolInstanceDestroyed = resolve; +}); + +function toolboxRegister(aToolbox) { + toolbox = aToolbox; + + waitForToolInstanceBuild = new Promise(resolve => { + resolveToolInstanceBuild = resolve; + }); + + info("add per-toolbox tool in the opened toolbox."); + + toolbox.addAdditionalTool({ + id: TOOL_ID, + // The size of the label can make the test fail if it's too long. + // See ok(tab, ...) assert below and Bug 1596345. + label: "Test Tool", + inMenu: true, + isToolSupported: () => true, + build() { + info("per-toolbox tool has been built."); + resolveToolInstanceBuild(); + + return { + destroy: () => { + info("per-toolbox tool has been destroyed."); + resolveToolInstanceDestroyed(); + }, + }; + }, + key: "t", + }); +} + +function testToolRegistered() { + ok( + !gDevTools.getToolDefinitionMap().has(TOOL_ID), + "per-toolbox tool is not registered globally" + ); + ok( + toolbox.hasAdditionalTool(TOOL_ID), + "per-toolbox tool registered to the specific toolbox" + ); + + // Test that the tool appeared in the UI. + const doc = toolbox.doc; + const tab = getToolboxTab(doc, TOOL_ID); + + ok(tab, "new tool's tab exists in toolbox UI"); + + const panel = doc.getElementById("toolbox-panel-" + TOOL_ID); + ok(panel, "new tool's panel exists in toolbox UI"); + + for (const win of getAllBrowserWindows()) { + const key = win.document.getElementById("key_" + TOOL_ID); + if (win.document == doc) { + continue; + } + ok(!key, "key for new tool should not exists in the other browser windows"); + const menuitem = win.document.getElementById("menuitem_" + TOOL_ID); + ok(!menuitem, "menu item should not exists in the other browser window"); + } + + // Test that the tool is built once selected and then test its unregistering. + info("select per-toolbox tool in the opened toolbox."); + gDevTools + .showToolboxForTab(gBrowser.selectedTab, { toolId: TOOL_ID }) + .then(waitForToolInstanceBuild) + .then(testUnregister); +} + +function getAllBrowserWindows() { + return Array.from(Services.wm.getEnumerator("navigator:browser")); +} + +function testUnregister() { + info("remove per-toolbox tool in the opened toolbox."); + toolbox.removeAdditionalTool(TOOL_ID); + + Promise.all([waitForToolInstanceDestroyed]).then(toolboxToolUnregistered); +} + +function toolboxToolUnregistered() { + ok( + !toolbox.hasAdditionalTool(TOOL_ID), + "per-toolbox tool unregistered from the specific toolbox" + ); + + // test that it disappeared from the UI + const doc = toolbox.doc; + const tab = getToolboxTab(doc, TOOL_ID); + ok(!tab, "tool's tab was removed from the toolbox UI"); + + const panel = doc.getElementById("toolbox-panel-" + TOOL_ID); + ok(!panel, "tool's panel was removed from toolbox UI"); + + cleanup(); +} + +function cleanup() { + toolbox.destroy().then(() => { + toolbox = null; + gBrowser.removeCurrentTab(); + finish(); + }); +} diff --git a/devtools/client/framework/test/browser_toolbox_view_source_01.js b/devtools/client/framework/test/browser_toolbox_view_source_01.js new file mode 100644 index 0000000000..f1a0924cf9 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_view_source_01.js @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that Toolbox#viewSourceInDebugger works when debugger is not + * yet opened. + */ + +var URL = `${URL_ROOT_SSL}doc_viewsource.html`; +var JS_URL = `${URL_ROOT_SSL}code_math.js`; + +async function viewSource() { + const toolbox = await openNewTabAndToolbox(URL); + + await toolbox.viewSourceInDebugger(JS_URL, 2); + + const debuggerPanel = toolbox.getPanel("jsdebugger"); + ok(debuggerPanel, "The debugger panel was opened."); + is(toolbox.currentToolId, "jsdebugger", "The debugger panel was selected."); + + assertSelectedLocationInDebugger(debuggerPanel, 2, undefined); + await closeToolboxAndTab(toolbox); + finish(); +} + +function test() { + viewSource().then(finish, aError => { + ok(false, "Got an error: " + aError.message + "\n" + aError.stack); + finish(); + }); +} diff --git a/devtools/client/framework/test/browser_toolbox_view_source_02.js b/devtools/client/framework/test/browser_toolbox_view_source_02.js new file mode 100644 index 0000000000..25bf0c2717 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_view_source_02.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that Toolbox#viewSourceInDebugger works when debugger is already loaded. + */ + +var URL = `${URL_ROOT_SSL}doc_viewsource.html`; +var JS_URL = `${URL_ROOT_SSL}code_math.js`; + +async function viewSource() { + const toolbox = await openNewTabAndToolbox(URL); + await toolbox.selectTool("jsdebugger"); + + await toolbox.viewSourceInDebugger(JS_URL, 2); + + const debuggerPanel = toolbox.getPanel("jsdebugger"); + ok(debuggerPanel, "The debugger panel was opened."); + is(toolbox.currentToolId, "jsdebugger", "The debugger panel was selected."); + + assertSelectedLocationInDebugger(debuggerPanel, 2, undefined); + + // See Bug 1637793 and Bug 1621337. + // Ideally the debugger should only resolve when the worker targets have been + // retrieved, which should be fixed by Bug 1621337 or a followup. + info("Wait for all pending requests to settle on the DevToolsClient"); + await toolbox.commands.client.waitForRequestsToSettle(); + + await closeToolboxAndTab(toolbox); + finish(); +} + +function test() { + viewSource().then(finish, aError => { + ok(false, "Got an error: " + aError.message + "\n" + aError.stack); + finish(); + }); +} diff --git a/devtools/client/framework/test/browser_toolbox_view_source_03.js b/devtools/client/framework/test/browser_toolbox_view_source_03.js new file mode 100644 index 0000000000..dce9ff8840 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_view_source_03.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that Toolbox#viewSourceInStyleEditor works when style editor is not + * yet opened. + */ + +var URL = `${URL_ROOT_SSL}doc_viewsource.html`; +var CSS_URL = `${URL_ROOT_SSL}doc_theme.css`; + +async function viewSource() { + const toolbox = await openNewTabAndToolbox(URL); + + const fileFound = await toolbox.viewSourceInStyleEditorByURL(CSS_URL, 2); + ok( + fileFound, + "viewSourceInStyleEditorByURL should resolve to true if source found." + ); + + const stylePanel = toolbox.getPanel("styleeditor"); + ok(stylePanel, "The style editor panel was opened."); + is( + toolbox.currentToolId, + "styleeditor", + "The style editor panel was selected." + ); + + const { UI } = stylePanel; + + is( + UI.selectedEditor.styleSheet.href, + CSS_URL, + "The correct source is shown in the style editor." + ); + is( + UI.selectedEditor.sourceEditor.getCursor().line + 1, + 2, + "The correct line is highlighted in the style editor's source editor." + ); + + await closeToolboxAndTab(toolbox); + finish(); +} + +function test() { + viewSource().then(finish, aError => { + ok(false, "Got an error: " + aError.message + "\n" + aError.stack); + finish(); + }); +} diff --git a/devtools/client/framework/test/browser_toolbox_view_source_style_editor_fallback.js b/devtools/client/framework/test/browser_toolbox_view_source_style_editor_fallback.js new file mode 100644 index 0000000000..b895d0a80e --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_view_source_style_editor_fallback.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that Toolbox#viewSourceInStyleEditor does fall back to view-source + */ + +const TEST_URL = `data:text/html,Got no style`; +const CSS_URL = `${URL_ROOT_SSL}doc_theme.css`; + +add_task(async function () { + // start on webconsole since it doesn't have much activity so we're less vulnerable + // to pending promises. + const toolbox = await openNewTabAndToolbox(TEST_URL, "webconsole"); + + const onTabOpen = BrowserTestUtils.waitForNewTab( + gBrowser, + url => url == `view-source:${CSS_URL}`, + true + ); + + info("View source of an existing file that isn't used by the page"); + const fileFound = await toolbox.viewSourceInStyleEditorByURL(CSS_URL, 0); + ok( + !fileFound, + "viewSourceInStyleEditorByURL should resolve to false if source isn't found." + ); + + info("Waiting for view-source tab to open"); + const viewSourceTab = await onTabOpen; + ok(true, "The view source tab was opened"); + await removeTab(viewSourceTab); + + info("Check that the current panel is the console"); + is(toolbox.currentToolId, "webconsole", "Console is still selected"); + + await closeToolboxAndTab(toolbox); +}); diff --git a/devtools/client/framework/test/browser_toolbox_watchedByDevTools.js b/devtools/client/framework/test/browser_toolbox_watchedByDevTools.js new file mode 100644 index 0000000000..a58b57885d --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_watchedByDevTools.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that the "watchedByDevTools" flag is properly handled. + */ +const EXAMPLE_HTTP_URI = + "http://mochi.test:8888/document-builder.sjs?html=
http"; +const EXAMPLE_COM_URI = + "https://example.com/document-builder.sjs?html=
com"; +const EXAMPLE_ORG_URI = + "https://example.org/document-builder.sjs?headers=Cross-Origin-Opener-Policy:same-origin&html=
org
"; + +add_task(async function () { + const tab = await addTab(EXAMPLE_HTTP_URI); + + is( + tab.linkedBrowser.browsingContext.watchedByDevTools, + false, + "watchedByDevTools isn't set when DevTools aren't opened" + ); + + info( + "Open a toolbox for the opened tab and check that watchedByDevTools is set" + ); + await gDevTools.showToolboxForTab(tab, { toolId: "options" }); + + is( + tab.linkedBrowser.browsingContext.watchedByDevTools, + true, + "watchedByDevTools is set after opening a toolbox" + ); + + info( + "Check that watchedByDevTools persist when the tab navigates to a different origin" + ); + await navigateTo(EXAMPLE_COM_URI); + + is( + tab.linkedBrowser.browsingContext.watchedByDevTools, + true, + "watchedByDevTools is still set after navigating to a different origin" + ); + + info( + "Check that watchedByDevTools persist when navigating to a page that creates a new browsing context" + ); + const previousBrowsingContextId = tab.linkedBrowser.browsingContext.id; + await navigateTo(EXAMPLE_ORG_URI); + + isnot( + tab.linkedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + is( + tab.linkedBrowser.browsingContext.watchedByDevTools, + true, + "watchedByDevTools is still set after navigating to a new browsing context" + ); + + info("Check that the flag is reset when the toolbox is closed"); + await gDevTools.closeToolboxForTab(tab); + is( + tab.linkedBrowser.browsingContext.watchedByDevTools, + false, + "watchedByDevTools is reset after closing the toolbox" + ); +}); diff --git a/devtools/client/framework/test/browser_toolbox_window_reload_target.js b/devtools/client/framework/test/browser_toolbox_window_reload_target.js new file mode 100644 index 0000000000..d9a4eb34c1 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_window_reload_target.js @@ -0,0 +1,130 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that pressing various page reload keyboard shortcuts always works when devtools +// has focus, no matter if it's undocked or docked, and whatever the tool selected (this +// is to avoid tools from overriding the page reload shortcuts). +// This test also serves as a safety net checking that tools just don't explode when the +// page is reloaded. +// It is therefore quite long to run. + +requestLongerTimeout(10); +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); + +// allow a context error because it is harmless. This could likely be removed in the next patch because it is a symptom of events coming from the target-list and debugger targets module... +PromiseTestUtils.allowMatchingRejectionsGlobally(/Page has navigated/); + +const TEST_URL = + "data:text/html;charset=utf-8," + + "Test reload" + + "

Testing reload from devtools

"; + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); + +// Track how many page reloads we've sent to the page. +var reloadsSent = 0; + +add_task(async function () { + await addTab(TEST_URL); + const tab = gBrowser.selectedTab; + const toolIDs = await getSupportedToolIds(tab); + + info( + "Display the toolbox, docked at the bottom, with the first tool selected" + ); + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: toolIDs[0], + hostType: Toolbox.HostType.BOTTOM, + }); + + info( + "Listen to page reloads to check that they are indeed sent by the toolbox" + ); + let reloadDetected = 0; + const reloadCounter = msg => { + reloadDetected++; + info("Detected reload #" + reloadDetected); + is( + reloadDetected, + reloadsSent, + "Detected the right number of reloads in the page" + ); + }; + + const removeLoadListener = BrowserTestUtils.addContentEventListener( + gBrowser.selectedBrowser, + "load", + reloadCounter, + {} + ); + + info("Start testing with the toolbox docked"); + // Note that we actually only test 1 tool in docked mode, to cut down on test time. + await testOneTool(toolbox, toolIDs[toolIDs.length - 1]); + + info("Switch to undocked mode"); + await toolbox.switchHost(Toolbox.HostType.WINDOW); + toolbox.win.focus(); + + info("Now test with the toolbox undocked"); + for (const toolID of toolIDs) { + await testOneTool(toolbox, toolID); + } + + info("Switch back to docked mode"); + await toolbox.switchHost(Toolbox.HostType.BOTTOM); + + removeLoadListener(); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); + +async function testOneTool(toolbox, toolID) { + info(`Select tool ${toolID}`); + await toolbox.selectTool(toolID); + + assertThemeStyleSheet(toolbox, toolID); + + await testReload("toolbox.reload.key", toolbox); + await testReload("toolbox.reload2.key", toolbox); + await testReload("toolbox.forceReload.key", toolbox); + await testReload("toolbox.forceReload2.key", toolbox); +} + +async function testReload(shortcut, toolbox) { + info(`Reload with ${shortcut}`); + + await sendToolboxReloadShortcut(L10N.getStr(shortcut), toolbox); + reloadsSent++; +} + +/** + * As opening all panels is an expensive operation, reuse this test in order + * to add a few assertions around panel's stylesheets. + * Ensure the proper ordering of the theme stylesheet. `global.css` should come + * first if it exists, then the theme. + */ +function assertThemeStyleSheet(toolbox, toolID) { + const iframe = toolbox.doc.getElementById("toolbox-panel-iframe-" + toolID); + const styleSheets = iframe.contentDocument.querySelectorAll( + `link[rel="stylesheet"]` + ); + ok( + !!styleSheets.length, + `The panel ${toolID} should have at least have one stylesheet` + ); + + // In the web console, we have a special case where global.css is registered very first + if (styleSheets[0].href === "chrome://global/skin/global.css") { + is(styleSheets[1].href, "chrome://devtools/skin/light-theme.css"); + } else { + // Otherwise, in all other panels, the theme file is registered very first + is(styleSheets[0].href, "chrome://devtools/skin/light-theme.css"); + } +} diff --git a/devtools/client/framework/test/browser_toolbox_window_reload_target_force.js b/devtools/client/framework/test/browser_toolbox_window_reload_target_force.js new file mode 100644 index 0000000000..d563fb7d37 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_window_reload_target_force.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Simple test page which writes the value of the cache-control header. +const TEST_URL = URL_ROOT + "sjs_cache_controle_header.sjs"; + +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); + +// Test that "forceReload" shorcuts send requests with the correct cache-control +// header value: no-cache. +add_task(async function () { + await addTab(TEST_URL); + const tab = gBrowser.selectedTab; + + info("Open the toolbox with the inspector selected"); + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "inspector", + }); + + // The VALIDATE_ALWAYS flag isn’t going to be applied when we only revalidate + // the top level document, thus the expectedHeader is empty. + const expectedHeader = Services.prefs.getBoolPref( + "browser.soft_reload.only_force_validate_top_level_document", + false + ) + ? "" + : "max-age=0"; + await testReload("toolbox.reload.key", toolbox, expectedHeader); + await testReload("toolbox.reload2.key", toolbox, expectedHeader); + await testReload("toolbox.forceReload.key", toolbox, "no-cache"); + await testReload("toolbox.forceReload2.key", toolbox, "no-cache"); +}); + +async function testReload(shortcut, toolbox, expectedHeader) { + info(`Reload with ${shortcut}`); + await sendToolboxReloadShortcut(L10N.getStr(shortcut), toolbox); + + info("Retrieve the text content of the test page"); + const textContent = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + function () { + return content.document.body.textContent; + } + ); + + // See sjs_cache_controle_header.sjs + is( + textContent, + "cache-control:" + expectedHeader, + "cache-control header for the page request had the expected value" + ); +} diff --git a/devtools/client/framework/test/browser_toolbox_window_shortcuts.js b/devtools/client/framework/test/browser_toolbox_window_shortcuts.js new file mode 100644 index 0000000000..53cea3a55a --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_window_shortcuts.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var Startup = Cc["@mozilla.org/devtools/startup-clh;1"].getService( + Ci.nsISupports +).wrappedJSObject; +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +var toolbox, + toolIDs, + toolShortcuts = [], + idIndex, + modifiedPrefs = []; + +async function test() { + addTab("about:blank").then(async function () { + toolIDs = []; + for (const [id, definition] of gDevTools._tools) { + const shortcut = Startup.KeyShortcuts.filter(s => s.toolId == id)[0]; + if (!shortcut) { + continue; + } + toolIDs.push(id); + toolShortcuts.push(shortcut); + + // Enable disabled tools + const pref = definition.visibilityswitch; + if (pref) { + const prefValue = Services.prefs.getBoolPref(pref, false); + if (!prefValue) { + modifiedPrefs.push(pref); + Services.prefs.setBoolPref(pref, true); + } + } + } + const tab = gBrowser.selectedTab; + idIndex = 0; + gDevTools + .showToolboxForTab(tab, { + toolId: toolIDs[0], + hostType: Toolbox.HostType.WINDOW, + }) + .then(testShortcuts); + }); +} + +function testShortcuts(aToolbox, aIndex) { + if (aIndex === undefined) { + aIndex = 1; + } else if (aIndex == toolIDs.length) { + tidyUp(); + return; + } + + toolbox = aToolbox; + info("Toolbox fired a `ready` event"); + + toolbox.once("select", selectCB); + + const shortcut = toolShortcuts[aIndex]; + const key = shortcut.shortcut; + const toolModifiers = shortcut.modifiers; + const modifiers = { + accelKey: toolModifiers.includes("accel"), + altKey: toolModifiers.includes("alt"), + shiftKey: toolModifiers.includes("shift"), + }; + idIndex = aIndex; + info( + "Testing shortcut for tool " + + aIndex + + ":" + + toolIDs[aIndex] + + " using key " + + key + ); + EventUtils.synthesizeKey(key, modifiers, toolbox.win.parent); +} + +function selectCB(id) { + info("toolbox-select event from " + id); + + is( + toolIDs.indexOf(id), + idIndex, + "Correct tool is selected on pressing the shortcut for " + id + ); + + testShortcuts(toolbox, idIndex + 1); +} + +function tidyUp() { + toolbox.destroy().then(function () { + gBrowser.removeCurrentTab(); + + for (const pref of modifiedPrefs) { + Services.prefs.clearUserPref(pref); + } + toolbox = toolIDs = idIndex = modifiedPrefs = Toolbox = null; + finish(); + }); +} diff --git a/devtools/client/framework/test/browser_toolbox_window_title_changes.js b/devtools/client/framework/test/browser_toolbox_window_title_changes.js new file mode 100644 index 0000000000..176b0b0f65 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_window_title_changes.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +requestLongerTimeout(5); + +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +const NAME_1 = ""; +const NAME_2 = "Toolbox test for title update"; +const NAME_3 = NAME_2; +const NAME_4 = "Toolbox test for another title update"; + +const URL_1 = "data:text/plain;charset=UTF-8,abcde"; +const URL_2 = + URL_ROOT_ORG_SSL + "browser_toolbox_window_title_changes_page.html"; +const URL_3 = + URL_ROOT_COM_SSL + "browser_toolbox_window_title_changes_page.html"; +const URL_4 = `https://example.com/document-builder.sjs?html=${NAME_4}

Hello`; + +add_task(async function test() { + await addTab(URL_1); + + const tab = gBrowser.selectedTab; + let toolbox = await gDevTools.showToolboxForTab(tab, { + hostType: Toolbox.HostType.BOTTOM, + }); + await toolbox.selectTool("webconsole"); + + info("Undock toolbox and check title"); + // We have to first switch the host in order to spawn the new top level window + // on which we are going to listen from title change event + await toolbox.switchHost(Toolbox.HostType.WINDOW); + await checkTitle(NAME_1, URL_1, "toolbox undocked"); + + info("switch to different tool and check title again"); + await toolbox.selectTool("jsdebugger"); + await checkTitle(NAME_1, URL_1, "tool changed"); + + info("navigate to different local url and check title"); + + await navigateTo(URL_2); + info("wait for title change"); + await checkTitle(NAME_2, URL_2, "url changed"); + + info("navigate to a real url and check title"); + await navigateTo(URL_3); + + info("wait for title change"); + await checkTitle(NAME_3, URL_3, "url changed"); + + info("navigate to another page on the same domain"); + await navigateTo(URL_4); + await checkTitle(NAME_4, URL_4, "title changed"); + + info( + "destroy toolbox, create new one hosted in a window (with a different tool id), and check title" + ); + // Give the tools a chance to handle the navigation event before + // destroying the toolbox. + await new Promise(resolve => executeSoon(resolve)); + await toolbox.destroy(); + + // After destroying the toolbox, open a new one. + toolbox = await gDevTools.showToolboxForTab(tab, { + hostType: Toolbox.HostType.WINDOW, + }); + toolbox.selectTool("webconsole"); + await checkTitle(NAME_4, URL_4, "toolbox destroyed and recreated"); + + info("clean up"); + await toolbox.destroy(); + gBrowser.removeCurrentTab(); + Services.prefs.clearUserPref("devtools.toolbox.host"); + Services.prefs.clearUserPref("devtools.toolbox.selectedTool"); +}); + +function getExpectedTitle(name, url) { + if (name) { + return `Developer Tools — ${name} — ${url}`; + } + return `Developer Tools — ${url}`; +} + +async function checkTitle(name, url, context) { + info("Check title - " + context); + await waitFor( + () => getToolboxWindowTitle() === getExpectedTitle(name, url), + `Didn't get the expected title ("${getExpectedTitle(name, url)}"`, + 200, + 50 + ); + const expectedTitle = getExpectedTitle(name, url); + is(getToolboxWindowTitle(), expectedTitle, context); +} + +function getToolboxWindowTitle() { + return Services.wm.getMostRecentWindow("devtools:toolbox").document.title; +} diff --git a/devtools/client/framework/test/browser_toolbox_window_title_changes_page.html b/devtools/client/framework/test/browser_toolbox_window_title_changes_page.html new file mode 100644 index 0000000000..8678469ee5 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_window_title_changes_page.html @@ -0,0 +1,10 @@ + + + + + Toolbox test for title update + + + + diff --git a/devtools/client/framework/test/browser_toolbox_window_title_frame_select.js b/devtools/client/framework/test/browser_toolbox_window_title_frame_select.js new file mode 100644 index 0000000000..06905fbd3b --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_window_title_frame_select.js @@ -0,0 +1,171 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that the detached devtools window title is not updated when switching + * the selected frame. Also check that frames command button has 'open' + * attribute set when the list of frames is opened. + */ + +var { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); +const URL = + URL_ROOT_SSL + "browser_toolbox_window_title_frame_select_page.html"; +const IFRAME_URL = + URL_ROOT_SSL + "browser_toolbox_window_title_changes_page.html"; +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); + +/** + * Wait for a given toolbox to get its title updated. + */ +function waitForTitleChange(toolbox) { + return new Promise(resolve => { + toolbox.topWindow.addEventListener("message", function onmessage(event) { + if (event.data.name == "set-host-title") { + toolbox.topWindow.removeEventListener("message", onmessage); + resolve(); + } + }); + }); +} + +add_task(async function () { + Services.prefs.setBoolPref("devtools.command-button-frames.enabled", true); + + await addTab(URL); + const tab = gBrowser.selectedTab; + let toolbox = await gDevTools.showToolboxForTab(tab, { + hostType: Toolbox.HostType.BOTTOM, + }); + + await toolbox.switchHost(Toolbox.HostType.WINDOW); + // Wait for title change event *after* switch host, in order to listen + // for the event on the WINDOW host window, which only exists after switchHost + await waitForTitleChange(toolbox); + + is( + getTitle(), + `Developer Tools — Page title — ${URL}`, + "Devtools title correct after switching to detached window host" + ); + + // Wait for tick to avoid unexpected 'popuphidden' event, which + // blocks the frame popup menu opened below. See also bug 1276873 + await waitForTick(); + + const btn = toolbox.doc.getElementById("command-button-frames"); + + await testShortcutToOpenFrames(btn, toolbox); + + // Open frame menu and wait till it's available on the screen. + // Also check 'aria-expanded' attribute on the command button. + is( + btn.getAttribute("aria-expanded"), + "false", + "The aria-expanded attribute must be set to false" + ); + btn.click(); + + const panel = toolbox.doc.getElementById("command-button-frames-panel"); + ok(panel, "popup panel has created."); + await waitUntil(() => panel.classList.contains("tooltip-visible")); + + is( + btn.getAttribute("aria-expanded"), + "true", + "The aria-expanded attribute must be set to true" + ); + + // Verify that the frame list menu is populated + const menuList = toolbox.doc.getElementById("toolbox-frame-menu"); + const frames = Array.from(menuList.querySelectorAll(".command")); + is(frames.length, 2, "We have both frames in the list"); + + const topFrameBtn = frames.filter( + b => b.querySelector(".label").textContent == URL + )[0]; + const iframeBtn = frames.filter( + b => b.querySelector(".label").textContent == IFRAME_URL + )[0]; + ok(topFrameBtn, "Got top level document in the list"); + ok(iframeBtn, "Got iframe document in the list"); + + // Listen to will-navigate to check if the view is empty + const { resourceCommand } = toolbox.commands; + const { onResource: willNavigate } = + await resourceCommand.waitForNextResource( + resourceCommand.TYPES.DOCUMENT_EVENT, + { + ignoreExistingResources: true, + predicate(resource) { + return resource.name == "will-navigate"; + }, + } + ); + + // Only select the iframe after we are able to select an element from the top + // level document. + const onInspectorReloaded = toolbox.getPanel("inspector").once("reloaded"); + info("Select the iframe"); + iframeBtn.click(); + + // will-navigate isn't emitted in the targetCommand-based iframe picker. + if (!isEveryFrameTargetEnabled()) { + await willNavigate; + } + await onInspectorReloaded; + // wait a bit more in case an eventual title update would happen later + await wait(1000); + + info("Navigation to the iframe is done, the inspector should be back up"); + is( + getTitle(), + `Developer Tools — Page title — ${URL}`, + "Devtools title was not updated after changing inspected frame" + ); + + info("Cleanup toolbox and test preferences."); + await toolbox.destroy(); + toolbox = null; + gBrowser.removeCurrentTab(); + Services.prefs.clearUserPref("devtools.toolbox.host"); + Services.prefs.clearUserPref("devtools.toolbox.selectedTool"); + Services.prefs.clearUserPref("devtools.command-button-frames.enabled"); + finish(); +}); + +function getTitle() { + return Services.wm.getMostRecentWindow("devtools:toolbox").document.title; +} + +async function testShortcutToOpenFrames(btn, toolbox) { + info("Tests if shortcut Alt+Down opens the frames"); + // focus the button so that keyPress can be performed + btn.focus(); + // perform keyPress - Alt+Down + const shortcut = L10N.getStr("toolbox.showFrames.key"); + synthesizeKeyShortcut(shortcut, toolbox.win); + + const panel = toolbox.doc.getElementById("command-button-frames-panel"); + ok(panel, "popup panel has created."); + await waitUntil(() => panel.classList.contains("tooltip-visible")); + + is( + btn.getAttribute("aria-expanded"), + "true", + "The aria-expanded attribute must be set to true" + ); + + // pressing Esc should hide the menu again + EventUtils.sendKey("ESCAPE", toolbox.win); + await waitUntil(() => !panel.classList.contains("tooltip-visible")); + + is( + btn.getAttribute("aria-expanded"), + "false", + "The aria-expanded attribute must be set to false" + ); +} diff --git a/devtools/client/framework/test/browser_toolbox_window_title_frame_select_page.html b/devtools/client/framework/test/browser_toolbox_window_title_frame_select_page.html new file mode 100644 index 0000000000..1eda94a9cf --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_window_title_frame_select_page.html @@ -0,0 +1,11 @@ + + + + + Page title + + + + + diff --git a/devtools/client/framework/test/browser_toolbox_zoom.js b/devtools/client/framework/test/browser_toolbox_zoom.js new file mode 100644 index 0000000000..6db94900f9 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_zoom.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); + +add_task(async function () { + registerCleanupFunction(function () { + Services.prefs.clearUserPref("devtools.toolbox.zoomValue"); + }); + + // This test assume that zoom value will be default value. i.e. x1.0. + Services.prefs.setCharPref("devtools.toolbox.zoomValue", "1.0"); + await addTab("about:blank"); + const toolbox = await gDevTools.showToolboxForTab(gBrowser.selectedTab, { + toolId: "styleeditor", + hostType: Toolbox.HostType.BOTTOM, + }); + + info("testing zoom keys"); + + testZoomLevel("In", 2, 1.2, toolbox); + testZoomLevel("Out", 3, 0.9, toolbox); + testZoomLevel("Reset", 1, 1, toolbox); + + await toolbox.destroy(); + gBrowser.removeCurrentTab(); +}); + +function testZoomLevel(type, times, expected, toolbox) { + sendZoomKey("toolbox.zoom" + type + ".key", times); + + const zoom = getCurrentZoom(toolbox); + is( + zoom.toFixed(1), + expected.toFixed(1), + "zoom level correct after zoom " + type + ); + + const savedZoom = parseFloat( + Services.prefs.getCharPref("devtools.toolbox.zoomValue") + ); + is( + savedZoom.toFixed(1), + expected.toFixed(1), + "saved zoom level is correct after zoom " + type + ); +} + +function sendZoomKey(shortcut, times) { + for (let i = 0; i < times; i++) { + synthesizeKeyShortcut(L10N.getStr(shortcut)); + } +} + +function getCurrentZoom(toolbox) { + return toolbox.win.browsingContext.fullZoom; +} diff --git a/devtools/client/framework/test/browser_toolbox_zoom_popup.js b/devtools/client/framework/test/browser_toolbox_zoom_popup.js new file mode 100644 index 0000000000..7b764bf703 --- /dev/null +++ b/devtools/client/framework/test/browser_toolbox_zoom_popup.js @@ -0,0 +1,213 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the popup menu position when zooming in the devtools panel. + +const { Toolbox } = require("resource://devtools/client/framework/toolbox.js"); + +// Use a simple URL in order to prevent displacing the left position of the +// frames menu. +const TEST_URL = "data:text/html;charset=utf-8, + + + diff --git a/devtools/client/framework/test/doc_cached-resource_iframe.html b/devtools/client/framework/test/doc_cached-resource_iframe.html new file mode 100644 index 0000000000..0fc5bb2263 --- /dev/null +++ b/devtools/client/framework/test/doc_cached-resource_iframe.html @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/devtools/client/framework/test/doc_empty-tab-01.html b/devtools/client/framework/test/doc_empty-tab-01.html new file mode 100644 index 0000000000..28398f7768 --- /dev/null +++ b/devtools/client/framework/test/doc_empty-tab-01.html @@ -0,0 +1,14 @@ + + + + + + + Empty test page 1 + + + + + + diff --git a/devtools/client/framework/test/doc_lazy_tool.html b/devtools/client/framework/test/doc_lazy_tool.html new file mode 100644 index 0000000000..3f1f1b7d01 --- /dev/null +++ b/devtools/client/framework/test/doc_lazy_tool.html @@ -0,0 +1,6 @@ + + + + Lazy tool + + diff --git a/devtools/client/framework/test/doc_textbox_tool.html b/devtools/client/framework/test/doc_textbox_tool.html new file mode 100644 index 0000000000..6f0c32ade0 --- /dev/null +++ b/devtools/client/framework/test/doc_textbox_tool.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/devtools/client/framework/test/doc_theme.css b/devtools/client/framework/test/doc_theme.css new file mode 100644 index 0000000000..5ed6e866a0 --- /dev/null +++ b/devtools/client/framework/test/doc_theme.css @@ -0,0 +1,3 @@ +.theme-test #devtools-theme-box { + color: red !important; +} diff --git a/devtools/client/framework/test/doc_viewsource.html b/devtools/client/framework/test/doc_viewsource.html new file mode 100644 index 0000000000..7094eb87eb --- /dev/null +++ b/devtools/client/framework/test/doc_viewsource.html @@ -0,0 +1,13 @@ + + + + + Toolbox test for View Source methods + + + + + + + diff --git a/devtools/client/framework/test/head.js b/devtools/client/framework/test/head.js new file mode 100644 index 0000000000..2001d5e8c4 --- /dev/null +++ b/devtools/client/framework/test/head.js @@ -0,0 +1,490 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// shared-head.js handles imports, constants, and utility functions +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js", + this +); + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +/** + * Retrieve all tool ids compatible with a target created for the provided tab. + * + * @param {XULTab} tab + * The tab for which we want to get the list of supported toolIds + * @return {Array} array of tool ids + */ +async function getSupportedToolIds(tab) { + info("Getting the entire list of tools supported in this tab"); + + let shouldDestroyToolbox = false; + + // Get the toolbox for this tab, or create one if needed. + let toolbox = gDevTools.getToolboxForTab(tab); + if (!toolbox) { + toolbox = await gDevTools.showToolboxForTab(tab); + shouldDestroyToolbox = true; + } + + const toolIds = gDevTools + .getToolDefinitionArray() + .filter(def => def.isToolSupported(toolbox)) + .map(def => def.id); + + if (shouldDestroyToolbox) { + // Only close the toolbox if it was explicitly created here. + await toolbox.destroy(); + } + + return toolIds; +} + +function toggleAllTools(state) { + for (const [, tool] of gDevTools._tools) { + if (!tool.visibilityswitch) { + continue; + } + if (state) { + Services.prefs.setBoolPref(tool.visibilityswitch, true); + } else { + Services.prefs.clearUserPref(tool.visibilityswitch); + } + } +} + +async function getParentProcessActors(callback) { + const commands = await CommandsFactory.forMainProcess(); + const mainProcessTargetFront = await commands.descriptorFront.getTarget(); + + callback(commands.client, mainProcessTargetFront); +} + +function getSourceActor(aSources, aURL) { + const item = aSources.getItemForAttachment(a => a.source.url === aURL); + return item && item.value; +} + +/** + * Synthesize a keypress from a element, taking into account + * any modifiers. + * @param {Element} el the element to synthesize + */ +function synthesizeKeyElement(el) { + const key = el.getAttribute("key") || el.getAttribute("keycode"); + const mod = {}; + el.getAttribute("modifiers") + .split(" ") + .forEach(m => (mod[m + "Key"] = true)); + info(`Synthesizing: key=${key}, mod=${JSON.stringify(mod)}`); + EventUtils.synthesizeKey(key, mod, el.ownerDocument.defaultView); +} + +/* Check the toolbox host type and prefs to make sure they match the + * expected values + * @param {Toolbox} + * @param {HostType} hostType + * One of {SIDE, BOTTOM, WINDOW} from Toolbox.HostType + * @param {HostType} Optional previousHostType + * The host that will be switched to when calling switchToPreviousHost + */ +function checkHostType(toolbox, hostType, previousHostType) { + is(toolbox.hostType, hostType, "host type is " + hostType); + + const pref = Services.prefs.getCharPref("devtools.toolbox.host"); + is(pref, hostType, "host pref is " + hostType); + + if (previousHostType) { + is( + Services.prefs.getCharPref("devtools.toolbox.previousHost"), + previousHostType, + "The previous host is correct" + ); + } +} + +/** + * Create a new + + + Empty test page 1 + + + + + + diff --git a/devtools/client/framework/test/reload/v2/code_bundle_reload.js b/devtools/client/framework/test/reload/v2/code_bundle_reload.js new file mode 100644 index 0000000000..c21166ba58 --- /dev/null +++ b/devtools/client/framework/test/reload/v2/code_bundle_reload.js @@ -0,0 +1,19 @@ +/******/ (() => { + // webpackBootstrap + /******/ "use strict"; + var __webpack_exports__ = {}; + /*!*****************************!*\ + !*** ./v2/code_reload_2.js ***! + \*****************************/ + /* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + + function f() { + console.log("The second version of the script"); + } + + f(); + + /******/ +})(); +//# sourceMappingURL=code_bundle_reload.js.map diff --git a/devtools/client/framework/test/reload/v2/code_bundle_reload.js.map b/devtools/client/framework/test/reload/v2/code_bundle_reload.js.map new file mode 100644 index 0000000000..d28bd1e30c --- /dev/null +++ b/devtools/client/framework/test/reload/v2/code_bundle_reload.js.map @@ -0,0 +1 @@ +{"version":3,"file":"v2/code_bundle_reload.js","mappings":";;;;;;AAAA;AACA;;AAEa;;AAEb;AACA;AACA;;AAEA","sources":["webpack://code-reload/./v2/code_reload_2.js"],"sourcesContent":["/* Any copyright is dedicated to the Public Domain.\n http://creativecommons.org/publicdomain/zero/1.0/ */\n\n\"use strict\";\n\nfunction f() {\n console.log(\"The second version of the script\");\n}\n\nf();\n"],"names":[],"sourceRoot":""} \ No newline at end of file diff --git a/devtools/client/framework/test/reload/v2/code_reload_2.js b/devtools/client/framework/test/reload/v2/code_reload_2.js new file mode 100644 index 0000000000..f4690279b4 --- /dev/null +++ b/devtools/client/framework/test/reload/v2/code_reload_2.js @@ -0,0 +1,10 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function f() { + console.log("The second version of the script"); +} + +f(); diff --git a/devtools/client/framework/test/reload/v2/doc_reload.html b/devtools/client/framework/test/reload/v2/doc_reload.html new file mode 100644 index 0000000000..164e2cd26c --- /dev/null +++ b/devtools/client/framework/test/reload/v2/doc_reload.html @@ -0,0 +1,15 @@ + + + + + + + + Empty test page 2 + + + + + + diff --git a/devtools/client/framework/test/reload/webpack.config.js b/devtools/client/framework/test/reload/webpack.config.js new file mode 100644 index 0000000000..e26b42cd4c --- /dev/null +++ b/devtools/client/framework/test/reload/webpack.config.js @@ -0,0 +1,13 @@ +const path = require("path"); + +module.exports = [1, 2].map(version => { + return { + devtool: "source-map", + mode: "development", + entry: [path.join(__dirname, `/v${version}/code_reload_${version}.js`)], + output: { + path: __dirname, + filename: `v${version}/code_bundle_reload.js`, + }, + }; +}); diff --git a/devtools/client/framework/test/serviceworker.js b/devtools/client/framework/test/serviceworker.js new file mode 100644 index 0000000000..db1b339fe6 --- /dev/null +++ b/devtools/client/framework/test/serviceworker.js @@ -0,0 +1,4 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// empty service worker, always succeed! diff --git a/devtools/client/framework/test/sjs_cache_controle_header.sjs b/devtools/client/framework/test/sjs_cache_controle_header.sjs new file mode 100644 index 0000000000..af58a3fc89 --- /dev/null +++ b/devtools/client/framework/test/sjs_cache_controle_header.sjs @@ -0,0 +1,19 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* exported handleRequest */ + +"use strict"; + +// Simple server that writes a text response displaying the value of the +// cache-control header: +// - if the header is missing, the text will be `cache-control:` +// - if the header is available, the text will be `cache-control:${value}` +function handleRequest(request, response) { + response.setHeader("Content-Type", "text/plain; charset=utf-8", false); + if (request.hasHeader("cache-control")) { + response.write(`cache-control:${request.getHeader("cache-control")}`); + } else { + response.write(`cache-control:`); + } +} diff --git a/devtools/client/framework/test/test_chrome_page.html b/devtools/client/framework/test/test_chrome_page.html new file mode 100644 index 0000000000..688b9de1d6 --- /dev/null +++ b/devtools/client/framework/test/test_chrome_page.html @@ -0,0 +1,9 @@ + + +Chrome page + diff --git a/devtools/client/framework/test/xpcshell/.eslintrc.js b/devtools/client/framework/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..7f6b62a9e5 --- /dev/null +++ b/devtools/client/framework/test/xpcshell/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the shared list of defined globals for mochitests. + extends: "../../../../.eslintrc.xpcshell.js", +}; diff --git a/devtools/client/framework/test/xpcshell/test_tabs_absolute_order.js b/devtools/client/framework/test/xpcshell/test_tabs_absolute_order.js new file mode 100644 index 0000000000..23755d5e8d --- /dev/null +++ b/devtools/client/framework/test/xpcshell/test_tabs_absolute_order.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); + +const TEST_DATA = [ + { + description: "Test for no order in preference", + preferenceOrder: [], + currentTabsOrder: ["T1", "T2", "T3", "T4", "T5"], + dragTarget: "T1", + expectedOrder: ["T1", "T2", "T3", "T4", "T5"], + }, + { + description: "Test for drag a tab to left with hidden tab", + preferenceOrder: ["T1", "T2", "T3", "E1", "T4", "T5"], + currentTabsOrder: ["T1", "T2", "T4", "T3", "T5"], + dragTarget: "T4", + expectedOrder: ["T1", "T2", "T4", "T3", "E1", "T5"], + }, + { + description: "Test for drag a tab to right with hidden tab", + preferenceOrder: ["T1", "T2", "T3", "E1", "T4", "T5"], + currentTabsOrder: ["T1", "T3", "T4", "T2", "T5"], + dragTarget: "T2", + expectedOrder: ["T1", "T3", "E1", "T4", "T2", "T5"], + }, + { + description: + "Test for drag a tab to left end in case hidden tab was left end", + preferenceOrder: ["E1", "T1", "T2", "T3", "T4", "T5"], + currentTabsOrder: ["T4", "T1", "T2", "T3", "T5"], + dragTarget: "T4", + expectedOrder: ["E1", "T4", "T1", "T2", "T3", "T5"], + }, + { + description: + "Test for drag a tab to right end in case hidden tab was right end", + preferenceOrder: ["T1", "T2", "T3", "T4", "T5", "E1"], + currentTabsOrder: ["T2", "T3", "T4", "T5", "T1"], + dragTarget: "T1", + expectedOrder: ["T2", "T3", "T4", "T5", "E1", "T1"], + }, + { + description: "Test for multiple hidden tabs", + preferenceOrder: ["T1", "T2", "E1", "E2", "E3", "E4"], + currentTabsOrder: ["T2", "T1"], + dragTarget: "T1", + expectedOrder: ["T2", "E1", "E2", "E3", "E4", "T1"], + }, +]; + +function run_test() { + const { + toAbsoluteOrder, + } = require("resource://devtools/client/framework/toolbox-tabs-order-manager.js"); + + for (const { + description, + preferenceOrder, + currentTabsOrder, + dragTarget, + expectedOrder, + } of TEST_DATA) { + info(description); + const resultOrder = toAbsoluteOrder( + preferenceOrder, + currentTabsOrder, + dragTarget + ); + equal( + resultOrder.join(","), + expectedOrder.join(","), + "Result should be correct" + ); + } +} diff --git a/devtools/client/framework/test/xpcshell/xpcshell.toml b/devtools/client/framework/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..569a4b1e71 --- /dev/null +++ b/devtools/client/framework/test/xpcshell/xpcshell.toml @@ -0,0 +1,6 @@ +[DEFAULT] +tags = "devtools" +firefox-appdir = "browser" +skip-if = ["os == 'android'"] + +["test_tabs_absolute_order.js"] diff --git a/devtools/client/framework/toolbox-context-menu.js b/devtools/client/framework/toolbox-context-menu.js new file mode 100644 index 0000000000..b02aed14d0 --- /dev/null +++ b/devtools/client/framework/toolbox-context-menu.js @@ -0,0 +1,119 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const Menu = require("resource://devtools/client/framework/menu.js"); +const MenuItem = require("resource://devtools/client/framework/menu-item.js"); + +// This WeakMap will be used to know if strings have already been loaded in a given +// window, which will be used as key. +const stringsLoaded = new WeakMap(); + +/** + * Lazily load strings for the edit menu. + */ +function loadEditMenuStrings(win) { + if (stringsLoaded.has(win)) { + return; + } + + if (win.MozXULElement) { + stringsLoaded.set(win, true); + win.MozXULElement.insertFTLIfNeeded("toolkit/global/textActions.ftl"); + } +} + +/** + * Return an 'edit' menu for a input field. This integrates directly + * with docshell commands to provide the right enabled state and editor + * functionality. + * + * You'll need to call menu.popup() yourself, this just returns the Menu instance. + * + * @param {Window} win parent window reference + * @param {String} id menu ID + * + * @returns {Menu} + */ +function createEditContextMenu(win, id) { + // Localized strings for the menu are loaded lazily. + loadEditMenuStrings(win); + + const docshell = win.docShell; + const menu = new Menu({ id }); + menu.append( + new MenuItem({ + id: "editmenu-undo", + l10nID: "text-action-undo", + disabled: !docshell.isCommandEnabled("cmd_undo"), + click: () => { + docshell.doCommand("cmd_undo"); + }, + }) + ); + menu.append( + new MenuItem({ + type: "separator", + }) + ); + menu.append( + new MenuItem({ + id: "editmenu-cut", + l10nID: "text-action-cut", + disabled: !docshell.isCommandEnabled("cmd_cut"), + click: () => { + docshell.doCommand("cmd_cut"); + }, + }) + ); + menu.append( + new MenuItem({ + id: "editmenu-copy", + l10nID: "text-action-copy", + disabled: !docshell.isCommandEnabled("cmd_copy"), + click: () => { + docshell.doCommand("cmd_copy"); + }, + }) + ); + menu.append( + new MenuItem({ + id: "editmenu-paste", + l10nID: "text-action-paste", + disabled: !docshell.isCommandEnabled("cmd_paste"), + click: () => { + docshell.doCommand("cmd_paste"); + }, + }) + ); + menu.append( + new MenuItem({ + id: "editmenu-delete", + l10nID: "text-action-delete", + disabled: !docshell.isCommandEnabled("cmd_delete"), + click: () => { + docshell.doCommand("cmd_delete"); + }, + }) + ); + menu.append( + new MenuItem({ + type: "separator", + }) + ); + menu.append( + new MenuItem({ + id: "editmenu-selectAll", + l10nID: "text-action-select-all", + disabled: !docshell.isCommandEnabled("cmd_selectAll"), + click: () => { + docshell.doCommand("cmd_selectAll"); + }, + }) + ); + return menu; +} + +module.exports.createEditContextMenu = createEditContextMenu; diff --git a/devtools/client/framework/toolbox-host-manager.js b/devtools/client/framework/toolbox-host-manager.js new file mode 100644 index 0000000000..6c1a0e645d --- /dev/null +++ b/devtools/client/framework/toolbox-host-manager.js @@ -0,0 +1,358 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); +const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); +const { DOMHelpers } = require("resource://devtools/shared/dom-helpers.js"); + +// The min-width of toolbox and browser toolbox. +const WIDTH_CHEVRON_AND_MEATBALL = 50; +const WIDTH_CHEVRON_AND_MEATBALL_AND_CLOSE = 74; +const ZOOM_VALUE_PREF = "devtools.toolbox.zoomValue"; + +loader.lazyRequireGetter( + this, + "Toolbox", + "resource://devtools/client/framework/toolbox.js", + true +); +loader.lazyRequireGetter( + this, + "Hosts", + "resource://devtools/client/framework/toolbox-hosts.js", + true +); + +/** + * Implement a wrapper on the chrome side to setup a Toolbox within Firefox UI. + * + * This component handles iframe creation within Firefox, in which we are loading + * the toolbox document. Then both the chrome and the toolbox document communicate + * via "message" events. + * + * Messages sent by the toolbox to the chrome: + * - switch-host: + * Order to display the toolbox in another host (side, bottom, window, or the + * previously used one) + * - raise-host: + * Focus the tools + * - set-host-title: + * When using the window host, update the window title + * + * Messages sent by the chrome to the toolbox: + * - switched-host: + * The `switch-host` command sent by the toolbox is done + */ + +const LAST_HOST = "devtools.toolbox.host"; +const PREVIOUS_HOST = "devtools.toolbox.previousHost"; +let ID_COUNTER = 1; + +function ToolboxHostManager(commands, hostType, hostOptions) { + this.commands = commands; + + // When debugging a local tab, we keep a reference of the current tab into which the toolbox is displayed. + // This will only change from the descriptor's localTab when we start debugging popups (i.e. window.open). + this.currentTab = this.commands.descriptorFront.localTab; + + // Keep the previously instantiated Host for all tabs where we displayed the Toolbox. + // This will only be useful when we start debugging popups (i.e. window.open). + // This is used to re-use the previous host instance when we re-select the original tab + // we were debugging before the popup opened. + this.hostPerTab = new Map(); + + this.frameId = ID_COUNTER++; + + if (!hostType) { + hostType = Services.prefs.getCharPref(LAST_HOST); + if (!Hosts[hostType]) { + // If the preference value is unexpected, restore to the default value. + Services.prefs.clearUserPref(LAST_HOST); + hostType = Services.prefs.getCharPref(LAST_HOST); + } + } + this.eventController = new AbortController(); + this.host = this.createHost(hostType, hostOptions); + this.hostType = hostType; + this.setMinWidthWithZoom = this.setMinWidthWithZoom.bind(this); + this._onMessage = this._onMessage.bind(this); + Services.prefs.addObserver(ZOOM_VALUE_PREF, this.setMinWidthWithZoom); +} + +ToolboxHostManager.prototype = { + async create(toolId) { + await this.host.create(); + if (this.currentTab) { + this.hostPerTab.set(this.currentTab, this.host); + } + + this.host.frame.setAttribute("aria-label", L10N.getStr("toolbox.label")); + this.host.frame.ownerDocument.defaultView.addEventListener( + "message", + this._onMessage, + { signal: this.eventController.signal } + ); + + const toolbox = new Toolbox( + this.commands, + toolId, + this.host.type, + this.host.frame.contentWindow, + this.frameId + ); + toolbox.once("destroyed", this._onToolboxDestroyed.bind(this)); + + // Prevent reloading the toolbox when loading the tools in a tab + // (e.g. from about:debugging) + const location = this.host.frame.contentWindow.location; + if (!location.href.startsWith("about:devtools-toolbox")) { + this.host.frame.setAttribute("src", "about:devtools-toolbox"); + } + + this.setMinWidthWithZoom(); + return toolbox; + }, + + setMinWidthWithZoom() { + const zoomValue = parseFloat(Services.prefs.getCharPref(ZOOM_VALUE_PREF)); + + if (isNaN(zoomValue)) { + return; + } + + if ( + this.hostType === Toolbox.HostType.LEFT || + this.hostType === Toolbox.HostType.RIGHT + ) { + this.host.frame.style.minWidth = + WIDTH_CHEVRON_AND_MEATBALL_AND_CLOSE * zoomValue + "px"; + } else if ( + this.hostType === Toolbox.HostType.WINDOW || + this.hostType === Toolbox.HostType.PAGE || + this.hostType === Toolbox.HostType.BROWSERTOOLBOX + ) { + this.host.frame.style.minWidth = + WIDTH_CHEVRON_AND_MEATBALL * zoomValue + "px"; + } + }, + + _onToolboxDestroyed() { + // Delay self-destruction to let the debugger complete async destruction. + // Otherwise it throws when running browser_dbg-breakpoints-in-evaled-sources.js + // because the promise middleware delay each promise action using setTimeout... + DevToolsUtils.executeSoon(() => { + this.destroy(); + }); + }, + + _onMessage(event) { + if (!event.data) { + return; + } + const msg = event.data; + // Toolbox document is still chrome and disallow identifying message + // origin via event.source as it is null. So use a custom id. + if (msg.frameId != this.frameId) { + return; + } + switch (msg.name) { + case "switch-host": + this.switchHost(msg.hostType); + break; + case "switch-host-to-tab": + this.switchHostToTab(msg.tabBrowsingContextID); + break; + case "raise-host": + this.host.raise(); + this.postMessage({ + name: "host-raised", + }); + break; + case "set-host-title": + this.host.setTitle(msg.title); + break; + } + }, + + postMessage(data) { + const window = this.host.frame.contentWindow; + window.postMessage(data, "*"); + }, + + destroy() { + Services.prefs.removeObserver(ZOOM_VALUE_PREF, this.setMinWidthWithZoom); + this.eventController.abort(); + this.eventController = null; + this.destroyHost(); + // When we are debugging popup, we created host for each popup opened + // in some other tabs. Ensure destroying them here. + for (const host of this.hostPerTab.values()) { + host.destroy(); + } + this.hostPerTab.clear(); + this.host = null; + this.hostType = null; + this.commands = null; + }, + + /** + * Create a host object based on the given host type. + * + * Warning: bottom and sidebar hosts require that the toolbox target provides + * a reference to the attached tab. Not all Targets have a tab property - + * make sure you correctly mix and match hosts and targets. + * + * @param {string} hostType + * The host type of the new host object + * + * @return {Host} host + * The created host object + */ + createHost(hostType, options) { + if (!Hosts[hostType]) { + throw new Error("Unknown hostType: " + hostType); + } + const newHost = new Hosts[hostType](this.currentTab, options); + return newHost; + }, + + /** + * Migrate the toolbox to a new host, while keeping it fully functional. + * The toolbox's iframe will be moved as-is to the new host. + * + * @param {String} hostType + * The new type of host to spawn + * @param {Boolean} destroyPreviousHost + * Defaults to true. If false is passed, we will avoid destroying + * the previous host. This is helpful for popup debugging, + * where we migrate the toolbox between two tabs. In this scenario + * we are reusing previously instantiated hosts. This is especially + * useful when we close the current tab and have to have an + * already instantiated host to migrate to. If we don't have one, + * the toolbox iframe will already be destroyed before we have a chance + * to migrate it. + */ + async switchHost(hostType, destroyPreviousHost = true) { + if (hostType == "previous") { + // Switch to the last used host for the toolbox UI. + // This is determined by the devtools.toolbox.previousHost pref. + hostType = Services.prefs.getCharPref(PREVIOUS_HOST); + + // Handle the case where the previous host happens to match the current + // host. If so, switch to bottom if it's not already used, and right side if not. + if (hostType === this.hostType) { + if (hostType === Toolbox.HostType.BOTTOM) { + hostType = Toolbox.HostType.RIGHT; + } else { + hostType = Toolbox.HostType.BOTTOM; + } + } + } + const iframe = this.host.frame; + const newHost = this.createHost(hostType); + const newIframe = await newHost.create(); + + // Load a blank document in the host frame. The new iframe must have a valid + // document before using swapFrameLoaders(). + await new Promise(resolve => { + newIframe.setAttribute("src", "about:blank"); + DOMHelpers.onceDOMReady(newIframe.contentWindow, resolve); + }); + + // change toolbox document's parent to the new host + newIframe.swapFrameLoaders(iframe); + if (destroyPreviousHost) { + this.destroyHost(); + } + + if ( + this.hostType !== Toolbox.HostType.BROWSERTOOLBOX && + this.hostType !== Toolbox.HostType.PAGE + ) { + Services.prefs.setCharPref(PREVIOUS_HOST, this.hostType); + } + + this.host = newHost; + if (this.currentTab) { + this.hostPerTab.set(this.currentTab, newHost); + } + this.hostType = hostType; + this.host.setTitle(this.host.frame.contentWindow.document.title); + this.host.frame.ownerDocument.defaultView.addEventListener( + "message", + this._onMessage, + { signal: this.eventController.signal } + ); + + this.setMinWidthWithZoom(); + + if ( + hostType !== Toolbox.HostType.BROWSERTOOLBOX && + hostType !== Toolbox.HostType.PAGE + ) { + Services.prefs.setCharPref(LAST_HOST, hostType); + } + + // Tell the toolbox the host changed + this.postMessage({ + name: "switched-host", + hostType, + }); + }, + + /** + * When we are debugging popup, we are moving around the toolbox between original tab + * and popup tabs. This method will only move the host to a new tab, while + * keeping the same host type. + * + * @param {String} tabBrowsingContextID + * The ID of the browsing context of the tab we want to move to. + */ + async switchHostToTab(tabBrowsingContextID) { + const { gBrowser } = this.host.frame.ownerDocument.defaultView; + + const previousTab = this.currentTab; + const newTab = gBrowser.tabs.find( + tab => tab.linkedBrowser.browsingContext.id == tabBrowsingContextID + ); + // Note that newTab will be undefined when the popup opens in a new top level window. + if (newTab && newTab != previousTab) { + this.currentTab = newTab; + const newHost = this.hostPerTab.get(this.currentTab); + if (newHost) { + newHost.frame.swapFrameLoaders(this.host.frame); + this.host = newHost; + } else { + await this.switchHost(this.hostType, false); + } + previousTab.addEventListener( + "TabSelect", + event => { + this.switchHostToTab(event.target.linkedBrowser.browsingContext.id); + }, + { once: true, signal: this.eventController.signal } + ); + } + + this.postMessage({ + name: "switched-host-to-tab", + browsingContextID: tabBrowsingContextID, + }); + }, + + /** + * Destroy the current host, and remove event listeners from its frame. + * + * @return {promise} to be resolved when the host is destroyed. + */ + destroyHost() { + return this.host.destroy(); + }, +}; +exports.ToolboxHostManager = ToolboxHostManager; diff --git a/devtools/client/framework/toolbox-hosts.js b/devtools/client/framework/toolbox-hosts.js new file mode 100644 index 0000000000..59d113848f --- /dev/null +++ b/devtools/client/framework/toolbox-hosts.js @@ -0,0 +1,460 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +loader.lazyRequireGetter( + this, + "gDevToolsBrowser", + "resource://devtools/client/framework/devtools-browser.js", + true +); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +/* A host should always allow this much space for the page to be displayed. + * There is also a min-height on the browser, but we still don't want to set + * frame.style.height to be larger than that, since it can cause problems with + * resizing the toolbox and panel layout. */ +const MIN_PAGE_SIZE = 25; + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +/** + * A toolbox host represents an object that contains a toolbox (e.g. the + * sidebar or a separate window). Any host object should implement the + * following functions: + * + * create() - create the UI + * destroy() - destroy the host's UI + */ + +/** + * Host object for the dock on the bottom of the browser + */ +function BottomHost(hostTab) { + this.hostTab = hostTab; + + EventEmitter.decorate(this); +} + +BottomHost.prototype = { + type: "bottom", + + heightPref: "devtools.toolbox.footer.height", + + /** + * Create a box at the bottom of the host tab. + */ + async create() { + await gDevToolsBrowser.loadBrowserStyleSheet(this.hostTab.ownerGlobal); + + const gBrowser = this.hostTab.ownerDocument.defaultView.gBrowser; + const ownerDocument = gBrowser.ownerDocument; + this._browserContainer = gBrowser.getBrowserContainer( + this.hostTab.linkedBrowser + ); + + this._splitter = ownerDocument.createXULElement("splitter"); + this._splitter.setAttribute("class", "devtools-horizontal-splitter"); + this._splitter.setAttribute("resizebefore", "none"); + this._splitter.setAttribute("resizeafter", "sibling"); + + this.frame = createDevToolsFrame( + ownerDocument, + "devtools-toolbox-bottom-iframe" + ); + this.frame.style.height = + Math.min( + Services.prefs.getIntPref(this.heightPref), + this._browserContainer.clientHeight - MIN_PAGE_SIZE + ) + "px"; + + this._browserContainer.appendChild(this._splitter); + this._browserContainer.appendChild(this.frame); + + focusTab(this.hostTab); + return this.frame; + }, + + /** + * Raise the host. + */ + raise() { + focusTab(this.hostTab); + }, + + /** + * Set the toolbox title. + * Nothing to do for this host type. + */ + setTitle() {}, + + /** + * Destroy the bottom dock. + */ + destroy() { + if (!this._destroyed) { + this._destroyed = true; + + const height = parseInt(this.frame.style.height, 10); + if (!isNaN(height)) { + Services.prefs.setIntPref(this.heightPref, height); + } + + this._browserContainer.removeChild(this._splitter); + this._browserContainer.removeChild(this.frame); + this.frame = null; + this._browserContainer = null; + this._splitter = null; + } + + return Promise.resolve(null); + }, +}; + +/** + * Base Host object for the in-browser sidebar + */ +class SidebarHost { + constructor(hostTab, type) { + this.hostTab = hostTab; + this.type = type; + this.widthPref = "devtools.toolbox.sidebar.width"; + + EventEmitter.decorate(this); + } + + /** + * Create a box in the sidebar of the host tab. + */ + async create() { + await gDevToolsBrowser.loadBrowserStyleSheet(this.hostTab.ownerGlobal); + const gBrowser = this.hostTab.ownerDocument.defaultView.gBrowser; + const ownerDocument = gBrowser.ownerDocument; + this._browserContainer = gBrowser.getBrowserContainer( + this.hostTab.linkedBrowser + ); + this._browserPanel = gBrowser.getPanel(this.hostTab.linkedBrowser); + + this._splitter = ownerDocument.createXULElement("splitter"); + this._splitter.setAttribute("class", "devtools-side-splitter"); + + this.frame = createDevToolsFrame( + ownerDocument, + "devtools-toolbox-side-iframe" + ); + this.frame.style.width = + Math.min( + Services.prefs.getIntPref(this.widthPref), + this._browserPanel.clientWidth - MIN_PAGE_SIZE + ) + "px"; + + // We should consider the direction when changing the dock position. + const topWindow = this.hostTab.ownerDocument.defaultView.top; + const topDoc = topWindow.document.documentElement; + const isLTR = topWindow.getComputedStyle(topDoc).direction === "ltr"; + + this._splitter.setAttribute("resizebefore", "none"); + this._splitter.setAttribute("resizeafter", "none"); + + if ((isLTR && this.type == "right") || (!isLTR && this.type == "left")) { + this._splitter.setAttribute("resizeafter", "sibling"); + this._browserPanel.appendChild(this._splitter); + this._browserPanel.appendChild(this.frame); + } else { + this._splitter.setAttribute("resizebefore", "sibling"); + this._browserPanel.insertBefore(this.frame, this._browserContainer); + this._browserPanel.insertBefore(this._splitter, this._browserContainer); + } + + focusTab(this.hostTab); + return this.frame; + } + + /** + * Raise the host. + */ + raise() { + focusTab(this.hostTab); + } + + /** + * Set the toolbox title. + * Nothing to do for this host type. + */ + setTitle() {} + + /** + * Destroy the sidebar. + */ + destroy() { + if (!this._destroyed) { + this._destroyed = true; + + const width = parseInt(this.frame.style.width, 10); + if (!isNaN(width)) { + Services.prefs.setIntPref(this.widthPref, width); + } + + this._browserPanel.removeChild(this._splitter); + this._browserPanel.removeChild(this.frame); + } + + return Promise.resolve(null); + } +} + +/** + * Host object for the in-browser left sidebar + */ +class LeftHost extends SidebarHost { + constructor(hostTab) { + super(hostTab, "left"); + } +} + +/** + * Host object for the in-browser right sidebar + */ +class RightHost extends SidebarHost { + constructor(hostTab) { + super(hostTab, "right"); + } +} + +/** + * Host object for the toolbox in a separate window + */ +function WindowHost(hostTab, options) { + this._boundUnload = this._boundUnload.bind(this); + this.hostTab = hostTab; + this.options = options; + EventEmitter.decorate(this); +} + +WindowHost.prototype = { + type: "window", + + WINDOW_URL: "chrome://devtools/content/framework/toolbox-window.xhtml", + + /** + * Create a new xul window to contain the toolbox. + */ + create() { + return new Promise(resolve => { + let flags = "chrome,centerscreen,resizable,dialog=no"; + + // If we are debugging a tab which is in a Private window, we must also + // set the private flag on the DevTools host window. Otherwise switching + // hosts between docked and window modes can fail due to incompatible + // docshell origin attributes. See 1581093. + const owner = this.hostTab?.ownerGlobal; + if (owner && lazy.PrivateBrowsingUtils.isWindowPrivate(owner)) { + flags += ",private"; + } + + // If the current window is a non-fission window, force the non-fission + // flag. Otherwise switching to window host from a non-fission window in + // a fission Firefox (!) will attempt to swapFrameLoaders between fission + // and non-fission frames. See Bug 1650963. + if (this.hostTab && !this.hostTab.ownerGlobal.gFissionBrowser) { + flags += ",non-fission"; + } + + // When debugging local Web Extension, the toolbox is opened in an + // always foremost top level window in order to be kept visible + // when interacting with the Firefox Window. + if (this.options?.alwaysOnTop) { + flags += ",alwaysontop"; + } + + const win = Services.ww.openWindow( + null, + this.WINDOW_URL, + "_blank", + flags, + null + ); + + const frameLoad = () => { + win.removeEventListener("load", frameLoad, true); + win.focus(); + + this.frame = createDevToolsFrame( + win.document, + "devtools-toolbox-window-iframe" + ); + win.document + .getElementById("devtools-toolbox-window") + .appendChild(this.frame); + + // The forceOwnRefreshDriver attribute is set to avoid Windows only issues with + // CSS transitions when switching from docked to window hosts. + // Added in Bug 832920, should be reviewed in Bug 1542468. + this.frame.setAttribute("forceOwnRefreshDriver", ""); + resolve(this.frame); + }; + + win.addEventListener("load", frameLoad, true); + win.addEventListener("unload", this._boundUnload); + + this._window = win; + }); + }, + + /** + * Catch the user closing the window. + */ + _boundUnload(event) { + if (event.target.location != this.WINDOW_URL) { + return; + } + this._window.removeEventListener("unload", this._boundUnload); + + this.emit("window-closed"); + }, + + /** + * Raise the host. + */ + raise() { + this._window.focus(); + }, + + /** + * Set the toolbox title. + */ + setTitle(title) { + this._window.document.title = title; + }, + + /** + * Destroy the window. + */ + destroy() { + if (!this._destroyed) { + this._destroyed = true; + + this._window.removeEventListener("unload", this._boundUnload); + this._window.close(); + } + + return Promise.resolve(null); + }, +}; + +/** + * Host object for the Browser Toolbox + */ +function BrowserToolboxHost(hostTab, options) { + this.doc = options.doc; + EventEmitter.decorate(this); +} + +BrowserToolboxHost.prototype = { + type: "browsertoolbox", + + async create() { + this.frame = createDevToolsFrame( + this.doc, + "devtools-toolbox-browsertoolbox-iframe" + ); + + this.doc.body.appendChild(this.frame); + + return this.frame; + }, + + /** + * Raise the host. + */ + raise() { + this.doc.defaultView.focus(); + }, + + /** + * Set the toolbox title. + */ + setTitle(title) { + this.doc.title = title; + }, + + // Do nothing. The BrowserToolbox is destroyed by quitting the application. + destroy() { + return Promise.resolve(null); + }, +}; + +/** + * Host object for the toolbox as a page. + * This is typically used by `about:debugging`, when opening toolbox in a new tab, + * via `about:devtools-toolbox` URLs. + * The `iframe` ends up being the tab's browser element. + */ +function PageHost(hostTab, options) { + this.frame = options.customIframe; +} + +PageHost.prototype = { + type: "page", + + create() { + return Promise.resolve(this.frame); + }, + + // Do nothing. + raise() {}, + + // Do nothing. + setTitle(title) {}, + + // Do nothing. + destroy() { + return Promise.resolve(null); + }, +}; + +/** + * Switch to the given tab in a browser and focus the browser window + */ +function focusTab(tab) { + const browserWindow = tab.ownerDocument.defaultView; + browserWindow.focus(); + browserWindow.gBrowser.selectedTab = tab; +} + +/** + * Create an iframe that can be used to load DevTools via about:devtools-toolbox. + */ +function createDevToolsFrame(doc, className) { + const frame = doc.createXULElement("browser"); + frame.setAttribute("type", "content"); + frame.style.flex = "1 auto"; // Required to be able to shrink when the window shrinks + frame.className = className; + + const inXULDocument = doc.documentElement.namespaceURI === XUL_NS; + if (inXULDocument) { + // When the toolbox frame is loaded in a XUL document, tooltips rely on a + // special XUL element. + // This attribute should not be set when the frame is loaded in a HTML + // document (for instance: Browser Toolbox). + frame.tooltip = "aHTMLTooltip"; + } + return frame; +} + +exports.Hosts = { + bottom: BottomHost, + left: LeftHost, + right: RightHost, + window: WindowHost, + browsertoolbox: BrowserToolboxHost, + page: PageHost, +}; diff --git a/devtools/client/framework/toolbox-init.js b/devtools/client/framework/toolbox-init.js new file mode 100644 index 0000000000..de2bce080a --- /dev/null +++ b/devtools/client/framework/toolbox-init.js @@ -0,0 +1,135 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env browser */ + +"use strict"; + +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); + +// URL constructor doesn't support about: scheme +const href = window.location.href.replace("about:", "http://"); +const url = new window.URL(href); + +// `host` is the frame element loading the toolbox. +let host = window.browsingContext.embedderElement; + +// If there's no containerElement (which happens when loading about:devtools-toolbox as +// a top level document), use the current window. +if (!host) { + host = { + contentWindow: window, + contentDocument: document, + // toolbox-host-manager.js wants to set attributes on the frame that contains it, + // but that is fine to skip and doesn't make sense when using the current window. + setAttribute() {}, + ownerDocument: document, + // toolbox-host-manager.js wants to listen for unload events from outside the frame, + // but this is fine to skip since the toolbox code listens inside the frame as well, + // and there is no outer document in this case. + addEventListener() {}, + }; +} + +const onLoad = new Promise(r => { + host.contentWindow.addEventListener("DOMContentLoaded", r, { once: true }); +}); + +async function showErrorPage(doc, errorMessage) { + const win = doc.defaultView; + const { BrowserLoader } = ChromeUtils.import( + "resource://devtools/shared/loader/browser-loader.js" + ); + const browserRequire = BrowserLoader({ + window: win, + useOnlyShared: true, + }).require; + + const React = browserRequire("devtools/client/shared/vendor/react"); + const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom"); + const DebugTargetErrorPage = React.createFactory( + require("resource://devtools/client/framework/components/DebugTargetErrorPage.js") + ); + const { LocalizationHelper } = browserRequire("devtools/shared/l10n"); + const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" + ); + + // mount the React component into our XUL container once the DOM is ready + await onLoad; + + // Update the tab title. + document.title = L10N.getStr("toolbox.debugTargetInfo.tabTitleError"); + + const mountEl = doc.querySelector("#toolbox-error-mount"); + const element = DebugTargetErrorPage({ + errorMessage, + L10N, + }); + ReactDOM.render(element, mountEl); + + // make sure we unmount the component when the page is destroyed + win.addEventListener( + "unload", + () => { + ReactDOM.unmountComponentAtNode(mountEl); + }, + { once: true } + ); +} + +async function initToolbox(url, host) { + const { + gDevTools, + } = require("resource://devtools/client/framework/devtools.js"); + + const { + commandsFromURL, + } = require("resource://devtools/client/framework/commands-from-url.js"); + const { + Toolbox, + } = require("resource://devtools/client/framework/toolbox.js"); + + // Specify the default tool to open + const tool = url.searchParams.get("tool"); + + try { + const commands = await commandsFromURL(url); + const toolbox = gDevTools.getToolboxForCommands(commands); + if (toolbox && toolbox.isDestroying()) { + // If a toolbox already exists for the commands, wait for current + // toolbox destroy to be finished. + await toolbox.destroy(); + } + + // Display an error page if we are connected to a remote target and we lose it + commands.descriptorFront.once("descriptor-destroyed", function () { + // Prevent trying to display the error page if the toolbox tab is being destroyed + if (host.contentDocument) { + const error = new Error("Debug target was disconnected"); + showErrorPage(host.contentDocument, `${error}`); + } + }); + + const options = { customIframe: host }; + await gDevTools.showToolbox(commands, { + toolId: tool, + hostType: Toolbox.HostType.PAGE, + hostOptions: options, + }); + } catch (error) { + // When an error occurs, show error page with message. + console.error("Exception while loading the toolbox", error); + showErrorPage(host.contentDocument, `${error}`); + } +} + +// Only use this method to attach the toolbox if some query parameters are given +if (url.search.length > 1) { + initToolbox(url, host); +} +// TODO: handle no params in about:devtool-toolbox +// https://bugzilla.mozilla.org/show_bug.cgi?id=1526996 diff --git a/devtools/client/framework/toolbox-options.html b/devtools/client/framework/toolbox-options.html new file mode 100644 index 0000000000..2ff33a581f --- /dev/null +++ b/devtools/client/framework/toolbox-options.html @@ -0,0 +1,275 @@ + + + + + Toolbox option + + + + + + +
+
+
+ + +
+ +
+ +
+ +
+ +
+
+ +
+
+ +
+ +
+ + + + + + + +
+ +
+ + +
+ +
+ + + +
+
+ +
+
+ + + + + + +
+ +
+ + + + + + + + + +
+
+
+ + diff --git a/devtools/client/framework/toolbox-options.js b/devtools/client/framework/toolbox-options.js new file mode 100644 index 0000000000..57fa1202b7 --- /dev/null +++ b/devtools/client/framework/toolbox-options.js @@ -0,0 +1,613 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + gDevTools, +} = require("resource://devtools/client/framework/devtools.js"); + +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const L10N = new LocalizationHelper( + "devtools/client/locales/toolbox.properties" +); + +loader.lazyRequireGetter( + this, + "openDocLink", + "resource://devtools/client/shared/link.js", + true +); + +exports.OptionsPanel = OptionsPanel; + +function GetPref(name) { + const type = Services.prefs.getPrefType(name); + switch (type) { + case Services.prefs.PREF_STRING: + return Services.prefs.getCharPref(name); + case Services.prefs.PREF_INT: + return Services.prefs.getIntPref(name); + case Services.prefs.PREF_BOOL: + return Services.prefs.getBoolPref(name); + default: + throw new Error("Unknown type"); + } +} + +function SetPref(name, value) { + const type = Services.prefs.getPrefType(name); + switch (type) { + case Services.prefs.PREF_STRING: + return Services.prefs.setCharPref(name, value); + case Services.prefs.PREF_INT: + return Services.prefs.setIntPref(name, value); + case Services.prefs.PREF_BOOL: + return Services.prefs.setBoolPref(name, value); + default: + throw new Error("Unknown type"); + } +} + +function InfallibleGetBoolPref(key) { + try { + return Services.prefs.getBoolPref(key); + } catch (ex) { + return true; + } +} + +/** + * Represents the Options Panel in the Toolbox. + */ +function OptionsPanel(iframeWindow, toolbox, commands) { + this.panelDoc = iframeWindow.document; + this.panelWin = iframeWindow; + + this.toolbox = toolbox; + this.commands = commands; + this.telemetry = toolbox.telemetry; + + this.setupToolsList = this.setupToolsList.bind(this); + this._prefChanged = this._prefChanged.bind(this); + this._themeRegistered = this._themeRegistered.bind(this); + this._themeUnregistered = this._themeUnregistered.bind(this); + this._disableJSClicked = this._disableJSClicked.bind(this); + + this.disableJSNode = this.panelDoc.getElementById( + "devtools-disable-javascript" + ); + + this._addListeners(); + + const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + EventEmitter.decorate(this); +} + +OptionsPanel.prototype = { + get target() { + return this.toolbox.target; + }, + + async open() { + this.setupToolsList(); + this.setupToolbarButtonsList(); + this.setupThemeList(); + this.setupAdditionalOptions(); + await this.populatePreferences(); + return this; + }, + + _addListeners() { + Services.prefs.addObserver("devtools.cache.disabled", this._prefChanged); + Services.prefs.addObserver("devtools.theme", this._prefChanged); + Services.prefs.addObserver( + "devtools.source-map.client-service.enabled", + this._prefChanged + ); + gDevTools.on("theme-registered", this._themeRegistered); + gDevTools.on("theme-unregistered", this._themeUnregistered); + + // Refresh the tools list when a new tool or webextension has been + // registered to the toolbox. + this.toolbox.on("tool-registered", this.setupToolsList); + this.toolbox.on("webextension-registered", this.setupToolsList); + // Refresh the tools list when a new tool or webextension has been + // unregistered from the toolbox. + this.toolbox.on("tool-unregistered", this.setupToolsList); + this.toolbox.on("webextension-unregistered", this.setupToolsList); + }, + + _removeListeners() { + Services.prefs.removeObserver("devtools.cache.disabled", this._prefChanged); + Services.prefs.removeObserver("devtools.theme", this._prefChanged); + Services.prefs.removeObserver( + "devtools.source-map.client-service.enabled", + this._prefChanged + ); + + this.toolbox.off("tool-registered", this.setupToolsList); + this.toolbox.off("tool-unregistered", this.setupToolsList); + this.toolbox.off("webextension-registered", this.setupToolsList); + this.toolbox.off("webextension-unregistered", this.setupToolsList); + + gDevTools.off("theme-registered", this._themeRegistered); + gDevTools.off("theme-unregistered", this._themeUnregistered); + }, + + _prefChanged(subject, topic, prefName) { + if (prefName === "devtools.cache.disabled") { + const cacheDisabled = GetPref(prefName); + const cbx = this.panelDoc.getElementById("devtools-disable-cache"); + cbx.checked = cacheDisabled; + } else if (prefName === "devtools.theme") { + this.updateCurrentTheme(); + } else if (prefName === "devtools.source-map.client-service.enabled") { + this.updateSourceMapPref(); + } + }, + + _themeRegistered(themeId) { + this.setupThemeList(); + }, + + _themeUnregistered(theme) { + const themeBox = this.panelDoc.getElementById("devtools-theme-box"); + const themeInput = themeBox.querySelector(`[value=${theme.id}]`); + + if (themeInput) { + themeInput.parentNode.remove(); + } + }, + + async setupToolbarButtonsList() { + // Ensure the toolbox is open, and the buttons are all set up. + await this.toolbox.isOpen; + + const enabledToolbarButtonsBox = this.panelDoc.getElementById( + "enabled-toolbox-buttons-box" + ); + + const toolbarButtons = this.toolbox.toolbarButtons; + + if (!toolbarButtons) { + console.warn("The command buttons weren't initiated yet."); + return; + } + + const onCheckboxClick = checkbox => { + const commandButton = toolbarButtons.filter( + toggleableButton => toggleableButton.id === checkbox.id + )[0]; + + Services.prefs.setBoolPref( + commandButton.visibilityswitch, + checkbox.checked + ); + this.toolbox.updateToolboxButtonsVisibility(); + }; + + const createCommandCheckbox = button => { + const checkboxLabel = this.panelDoc.createElement("label"); + const checkboxSpanLabel = this.panelDoc.createElement("span"); + checkboxSpanLabel.textContent = button.description; + const checkboxInput = this.panelDoc.createElement("input"); + checkboxInput.setAttribute("type", "checkbox"); + checkboxInput.setAttribute("id", button.id); + + if (Services.prefs.getBoolPref(button.visibilityswitch, true)) { + checkboxInput.setAttribute("checked", true); + } + checkboxInput.addEventListener( + "change", + onCheckboxClick.bind(this, checkboxInput) + ); + + checkboxLabel.appendChild(checkboxInput); + checkboxLabel.appendChild(checkboxSpanLabel); + + return checkboxLabel; + }; + + for (const button of toolbarButtons) { + if (!button.isToolSupported(this.toolbox)) { + continue; + } + + enabledToolbarButtonsBox.appendChild(createCommandCheckbox(button)); + } + }, + + setupToolsList() { + const defaultToolsBox = this.panelDoc.getElementById("default-tools-box"); + const additionalToolsBox = this.panelDoc.getElementById( + "additional-tools-box" + ); + const toolsNotSupportedLabel = this.panelDoc.getElementById( + "tools-not-supported-label" + ); + let atleastOneToolNotSupported = false; + + // Signal tool registering/unregistering globally (for the tools registered + // globally) and per toolbox (for the tools registered to a single toolbox). + // This event handler expect this to be binded to the related checkbox element. + const onCheckboxClick = function (telemetry, tool) { + // Set the kill switch pref boolean to true + Services.prefs.setBoolPref(tool.visibilityswitch, this.checked); + + if (!tool.isWebExtension) { + gDevTools.emit( + this.checked ? "tool-registered" : "tool-unregistered", + tool.id + ); + // Record which tools were registered and unregistered. + telemetry.keyedScalarSet( + "devtools.tool.registered", + tool.id, + this.checked + ); + } + }; + + const createToolCheckbox = tool => { + const checkboxLabel = this.panelDoc.createElement("label"); + const checkboxInput = this.panelDoc.createElement("input"); + checkboxInput.setAttribute("type", "checkbox"); + checkboxInput.setAttribute("id", tool.id); + checkboxInput.setAttribute("title", tool.tooltip || ""); + + const checkboxSpanLabel = this.panelDoc.createElement("span"); + if (tool.isToolSupported(this.toolbox)) { + checkboxSpanLabel.textContent = tool.label; + } else { + atleastOneToolNotSupported = true; + checkboxSpanLabel.textContent = L10N.getFormatStr( + "options.toolNotSupportedMarker", + tool.label + ); + checkboxInput.setAttribute("data-unsupported", "true"); + checkboxInput.setAttribute("disabled", "true"); + } + + if (InfallibleGetBoolPref(tool.visibilityswitch)) { + checkboxInput.setAttribute("checked", "true"); + } + + checkboxInput.addEventListener( + "change", + onCheckboxClick.bind(checkboxInput, this.telemetry, tool) + ); + + checkboxLabel.appendChild(checkboxInput); + checkboxLabel.appendChild(checkboxSpanLabel); + + // We shouldn't have deprecated tools anymore, but we might have one in the future, + // when migrating the storage inspector to the application panel (Bug 1681059). + // Let's keep this code for now so we keep the l10n property around and avoid + // unnecessary translation work if we need it again in the future. + if (tool.deprecated) { + const deprecationURL = this.panelDoc.createElement("a"); + deprecationURL.title = deprecationURL.href = tool.deprecationURL; + deprecationURL.textContent = L10N.getStr("options.deprecationNotice"); + // Cannot use a real link when we are in the Browser Toolbox. + deprecationURL.addEventListener("click", e => { + e.preventDefault(); + openDocLink(tool.deprecationURL, { relatedToCurrent: true }); + }); + + const checkboxSpanDeprecated = this.panelDoc.createElement("span"); + checkboxSpanDeprecated.className = "deprecation-notice"; + checkboxLabel.appendChild(checkboxSpanDeprecated); + checkboxSpanDeprecated.appendChild(deprecationURL); + } + + return checkboxLabel; + }; + + // Clean up any existent default tools content. + for (const label of defaultToolsBox.querySelectorAll("label")) { + label.remove(); + } + + // Populating the default tools lists + const toggleableTools = gDevTools.getDefaultTools().filter(tool => { + return tool.visibilityswitch && !tool.hiddenInOptions; + }); + + const fragment = this.panelDoc.createDocumentFragment(); + for (const tool of toggleableTools) { + fragment.appendChild(createToolCheckbox(tool)); + } + + const toolsNotSupportedLabelNode = this.panelDoc.getElementById( + "tools-not-supported-label" + ); + defaultToolsBox.insertBefore(fragment, toolsNotSupportedLabelNode); + + // Clean up any existent additional tools content. + for (const label of additionalToolsBox.querySelectorAll("label")) { + label.remove(); + } + + // Populating the additional tools list. + let atleastOneAddon = false; + for (const tool of gDevTools.getAdditionalTools()) { + atleastOneAddon = true; + additionalToolsBox.appendChild(createToolCheckbox(tool)); + } + + // Populating the additional tools that came from the installed WebExtension add-ons. + for (const { uuid, name, pref } of this.toolbox.listWebExtensions()) { + atleastOneAddon = true; + + additionalToolsBox.appendChild( + createToolCheckbox({ + isWebExtension: true, + + // Use the preference as the unified webextensions tool id. + id: `webext-${uuid}`, + tooltip: name, + label: name, + // Disable the devtools extension using the given pref name: + // the toolbox options for the WebExtensions are not related to a single + // tool (e.g. a devtools panel created from the extension devtools_page) + // but to the entire devtools part of a webextension which is enabled + // by the Addon Manager (but it may be disabled by its related + // devtools about:config preference), and so the following + visibilityswitch: pref, + + // Only local tabs are currently supported as targets. + isToolSupported: toolbox => + toolbox.commands.descriptorFront.isLocalTab, + }) + ); + } + + if (!atleastOneAddon) { + additionalToolsBox.style.display = "none"; + } else { + additionalToolsBox.style.display = ""; + } + + if (!atleastOneToolNotSupported) { + toolsNotSupportedLabel.style.display = "none"; + } else { + toolsNotSupportedLabel.style.display = ""; + } + + this.panelWin.focus(); + }, + + setupThemeList() { + const themeBox = this.panelDoc.getElementById("devtools-theme-box"); + const themeLabels = themeBox.querySelectorAll("label"); + for (const label of themeLabels) { + label.remove(); + } + + const createThemeOption = theme => { + const inputLabel = this.panelDoc.createElement("label"); + const inputRadio = this.panelDoc.createElement("input"); + inputRadio.setAttribute("type", "radio"); + inputRadio.setAttribute("value", theme.id); + inputRadio.setAttribute("name", "devtools-theme-item"); + inputRadio.addEventListener("change", function (e) { + SetPref(themeBox.getAttribute("data-pref"), e.target.value); + }); + + const inputSpanLabel = this.panelDoc.createElement("span"); + inputSpanLabel.textContent = theme.label; + inputLabel.appendChild(inputRadio); + inputLabel.appendChild(inputSpanLabel); + + return inputLabel; + }; + + // Populating the default theme list + themeBox.appendChild( + createThemeOption({ + id: "auto", + label: L10N.getStr("options.autoTheme.label"), + }) + ); + + const themes = gDevTools.getThemeDefinitionArray(); + for (const theme of themes) { + themeBox.appendChild(createThemeOption(theme)); + } + + this.updateCurrentTheme(); + }, + + /** + * Add extra checkbox options bound to a boolean preference. + */ + setupAdditionalOptions() { + const prefDefinitions = [ + { + pref: "devtools.custom-formatters.enabled", + l10nLabelId: "options-enable-custom-formatters-label", + l10nTooltipId: "options-enable-custom-formatters-tooltip", + id: "devtools-custom-formatters", + parentId: "context-options", + }, + ]; + + const createPreferenceOption = ({ + pref, + label, + l10nLabelId, + l10nTooltipId, + id, + onChange, + }) => { + const inputLabel = this.panelDoc.createElement("label"); + if (l10nTooltipId) { + this.panelDoc.l10n.setAttributes(inputLabel, l10nTooltipId); + } + const checkbox = this.panelDoc.createElement("input"); + checkbox.setAttribute("type", "checkbox"); + if (GetPref(pref)) { + checkbox.setAttribute("checked", "checked"); + } + checkbox.setAttribute("id", id); + checkbox.addEventListener("change", e => { + SetPref(pref, e.target.checked); + if (onChange) { + onChange(e.target.checked); + } + }); + + const inputSpanLabel = this.panelDoc.createElement("span"); + if (l10nLabelId) { + this.panelDoc.l10n.setAttributes(inputSpanLabel, l10nLabelId); + } else if (label) { + inputSpanLabel.textContent = label; + } + inputLabel.appendChild(checkbox); + inputLabel.appendChild(inputSpanLabel); + + return inputLabel; + }; + + for (const prefDefinition of prefDefinitions) { + const parent = this.panelDoc.getElementById(prefDefinition.parentId); + // We want to insert the new definition after the last existing + // definition, but before any other element. + // For example in the "Advanced Settings" column there's indeed a + // text at the end, and we want that it stays at the end. + // The reference element can be `null` if there's no label or if there's + // no element after the last label. But that's OK and it will do what we + // want. + const referenceElement = parent.querySelector("label:last-of-type + *"); + parent.insertBefore( + createPreferenceOption(prefDefinition), + referenceElement + ); + } + }, + + async populatePreferences() { + const prefCheckboxes = this.panelDoc.querySelectorAll( + "input[type=checkbox][data-pref]" + ); + for (const prefCheckbox of prefCheckboxes) { + if (GetPref(prefCheckbox.getAttribute("data-pref"))) { + prefCheckbox.setAttribute("checked", true); + } + prefCheckbox.addEventListener("change", function (e) { + const checkbox = e.target; + SetPref(checkbox.getAttribute("data-pref"), checkbox.checked); + }); + } + // Themes radio inputs are handled in setupThemeList + const prefRadiogroups = this.panelDoc.querySelectorAll( + ".radiogroup[data-pref]:not(#devtools-theme-box)" + ); + for (const radioGroup of prefRadiogroups) { + const selectedValue = GetPref(radioGroup.getAttribute("data-pref")); + + for (const radioInput of radioGroup.querySelectorAll( + "input[type=radio]" + )) { + if (radioInput.getAttribute("value") == selectedValue) { + radioInput.setAttribute("checked", true); + } + + radioInput.addEventListener("change", function (e) { + SetPref(radioGroup.getAttribute("data-pref"), e.target.value); + }); + } + } + const prefSelects = this.panelDoc.querySelectorAll("select[data-pref]"); + for (const prefSelect of prefSelects) { + const pref = GetPref(prefSelect.getAttribute("data-pref")); + const options = [...prefSelect.options]; + options.some(function (option) { + const value = option.value; + // non strict check to allow int values. + if (value == pref) { + prefSelect.selectedIndex = options.indexOf(option); + return true; + } + return false; + }); + + prefSelect.addEventListener("change", function (e) { + const select = e.target; + SetPref( + select.getAttribute("data-pref"), + select.options[select.selectedIndex].value + ); + }); + } + + if (this.commands.descriptorFront.isTabDescriptor) { + const isJavascriptEnabled = + await this.commands.targetConfigurationCommand.isJavascriptEnabled(); + this.disableJSNode.checked = !isJavascriptEnabled; + this.disableJSNode.addEventListener("click", this._disableJSClicked); + } else { + // Hide the checkbox and label + this.disableJSNode.parentNode.style.display = "none"; + + const triggersPageRefreshLabel = this.panelDoc.getElementById( + "triggers-page-refresh-label" + ); + triggersPageRefreshLabel.style.display = "none"; + } + }, + + updateCurrentTheme() { + const currentTheme = GetPref("devtools.theme"); + const themeBox = this.panelDoc.getElementById("devtools-theme-box"); + const themeRadioInput = themeBox.querySelector(`[value=${currentTheme}]`); + + if (themeRadioInput) { + themeRadioInput.checked = true; + } else { + // If the current theme does not exist anymore, switch to auto theme + const autoThemeInputRadio = themeBox.querySelector("[value=auto]"); + autoThemeInputRadio.checked = true; + } + }, + + updateSourceMapPref() { + const prefName = "devtools.source-map.client-service.enabled"; + const enabled = GetPref(prefName); + const box = this.panelDoc.querySelector(`[data-pref="${prefName}"]`); + box.checked = enabled; + }, + + /** + * Disables JavaScript for the currently loaded tab. We force a page refresh + * here because setting browsingContext.allowJavascript to true fails to block + * JS execution from event listeners added using addEventListener(), AJAX + * calls and timers. The page refresh prevents these things from being added + * in the first place. + * + * @param {Event} event + * The event sent by checking / unchecking the disable JS checkbox. + */ + _disableJSClicked(event) { + const checked = event.target.checked; + + this.commands.targetConfigurationCommand.updateConfiguration({ + javascriptEnabled: !checked, + }); + }, + + destroy() { + if (this.destroyed) { + return; + } + this.destroyed = true; + + this._removeListeners(); + + this.disableJSNode.removeEventListener("click", this._disableJSClicked); + + this.panelWin = this.panelDoc = this.disableJSNode = this.toolbox = null; + }, +}; diff --git a/devtools/client/framework/toolbox-tabs-order-manager.js b/devtools/client/framework/toolbox-tabs-order-manager.js new file mode 100644 index 0000000000..0eec0c935f --- /dev/null +++ b/devtools/client/framework/toolbox-tabs-order-manager.js @@ -0,0 +1,285 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs", + // AddonManager is a singleton, never create two instances of it. + { loadInDevToolsLoader: false } +); +const { + gDevTools, +} = require("resource://devtools/client/framework/devtools.js"); +const TABS_REORDERED_SCALAR = "devtools.toolbox.tabs_reordered"; +const PREFERENCE_NAME = "devtools.toolbox.tabsOrder"; + +/** + * Manage the order of devtools tabs. + */ +class ToolboxTabsOrderManager { + constructor(toolbox, onOrderUpdated, panelDefinitions) { + this.toolbox = toolbox; + this.onOrderUpdated = onOrderUpdated; + this.currentPanelDefinitions = panelDefinitions || []; + + this.onMouseDown = this.onMouseDown.bind(this); + this.onMouseMove = this.onMouseMove.bind(this); + this.onMouseUp = this.onMouseUp.bind(this); + + Services.prefs.addObserver(PREFERENCE_NAME, this.onOrderUpdated); + } + + async destroy() { + Services.prefs.removeObserver(PREFERENCE_NAME, this.onOrderUpdated); + + // Call mouseUp() to clear the state to prepare for in case a dragging was in progress + // when the destroy() was called. + await this.onMouseUp(); + } + + insertBefore(target) { + const xBefore = this.dragTarget.offsetLeft; + this.toolboxTabsElement.insertBefore(this.dragTarget, target); + const xAfter = this.dragTarget.offsetLeft; + this.dragStartX += xAfter - xBefore; + this.isOrderUpdated = true; + } + + isFirstTab(tabElement) { + return !tabElement.previousSibling; + } + + isLastTab(tabElement) { + return ( + !tabElement.nextSibling || + tabElement.nextSibling.id === "tools-chevron-menu-button" + ); + } + + isRTL() { + return this.toolbox.direction === "rtl"; + } + + async saveOrderPreference() { + const tabs = [...this.toolboxTabsElement.querySelectorAll(".devtools-tab")]; + const tabIds = tabs.map(tab => tab.dataset.extensionId || tab.dataset.id); + // Concat the overflowed tabs id since they are not contained in visible tabs. + // The overflowed tabs cannot be reordered so we just append the id from current + // panel definitions on their order. + const overflowedTabIds = this.currentPanelDefinitions + .filter(definition => !tabs.some(tab => tab.dataset.id === definition.id)) + .map(definition => definition.extensionId || definition.id); + const currentTabIds = tabIds.concat(overflowedTabIds); + const dragTargetId = + this.dragTarget.dataset.extensionId || this.dragTarget.dataset.id; + const prefIds = getTabsOrderFromPreference(); + const absoluteIds = toAbsoluteOrder(prefIds, currentTabIds, dragTargetId); + + // Remove panel id which is not in panel definitions and addons list. + const extensions = await AddonManager.getAllAddons(); + const definitions = gDevTools.getToolDefinitionArray(); + const result = absoluteIds.filter( + id => + definitions.find(d => id === (d.extensionId || d.id)) || + extensions.find(e => id === e.id) + ); + + Services.prefs.setCharPref(PREFERENCE_NAME, result.join(",")); + } + + setCurrentPanelDefinitions(currentPanelDefinitions) { + this.currentPanelDefinitions = currentPanelDefinitions; + } + + onMouseDown(e) { + if (!e.target.classList.contains("devtools-tab")) { + return; + } + + this.dragStartX = e.pageX; + this.dragTarget = e.target; + this.previousPageX = e.pageX; + this.toolboxContainerElement = + this.dragTarget.closest("#toolbox-container"); + this.toolboxTabsElement = this.dragTarget.closest(".toolbox-tabs"); + this.isOrderUpdated = false; + this.eventTarget = this.dragTarget.ownerGlobal.top; + + this.eventTarget.addEventListener("mousemove", this.onMouseMove); + this.eventTarget.addEventListener("mouseup", this.onMouseUp); + + this.toolboxContainerElement.classList.add("tabs-reordering"); + } + + onMouseMove(e) { + const diffPageX = e.pageX - this.previousPageX; + let dragTargetCenterX = + this.dragTarget.offsetLeft + diffPageX + this.dragTarget.clientWidth / 2; + let isDragTargetPreviousSibling = false; + + const tabElements = + this.toolboxTabsElement.querySelectorAll(".devtools-tab"); + + // Calculate the minimum and maximum X-offset that can be valid for the drag target. + const firstElement = tabElements[0]; + const firstElementCenterX = + firstElement.offsetLeft + firstElement.clientWidth / 2; + const lastElement = tabElements[tabElements.length - 1]; + const lastElementCenterX = + lastElement.offsetLeft + lastElement.clientWidth / 2; + const max = Math.max(firstElementCenterX, lastElementCenterX); + const min = Math.min(firstElementCenterX, lastElementCenterX); + + // Normalize the target center X so to remain between the first and last tab. + dragTargetCenterX = Math.min(max, dragTargetCenterX); + dragTargetCenterX = Math.max(min, dragTargetCenterX); + + for (const tabElement of tabElements) { + if (tabElement === this.dragTarget) { + isDragTargetPreviousSibling = true; + continue; + } + + // Is the dragTarget near the center of the other tab? + const anotherCenterX = tabElement.offsetLeft + tabElement.clientWidth / 2; + const distanceWithDragTarget = Math.abs( + dragTargetCenterX - anotherCenterX + ); + const isReplaceable = distanceWithDragTarget < tabElement.clientWidth / 3; + + if (isReplaceable) { + const replaceableElement = isDragTargetPreviousSibling + ? tabElement.nextSibling + : tabElement; + this.insertBefore(replaceableElement); + break; + } + } + + let distance = e.pageX - this.dragStartX; + + // To accomodate for RTL locales, we cannot rely on the first/last element of the + // NodeList. We cannot have negative distances for the leftmost tab, and we cannot + // have positive distances for the rightmost tab. + const isFirstTab = this.isFirstTab(this.dragTarget); + const isLastTab = this.isLastTab(this.dragTarget); + const isLeftmostTab = this.isRTL() ? isLastTab : isFirstTab; + const isRightmostTab = this.isRTL() ? isFirstTab : isLastTab; + + if ((isLeftmostTab && distance < 0) || (isRightmostTab && distance > 0)) { + // If the drag target is already edge of the tabs and the mouse will make the + // element to move to same direction more, keep the position. + distance = 0; + } + + this.dragTarget.style.left = `${distance}px`; + this.previousPageX = e.pageX; + } + + async onMouseUp() { + if (!this.dragTarget) { + // The case in here has two type: + // 1. Although destroy method was called, it was not during reordering. + // 2. Although mouse event occur, destroy method was called during reordering. + return; + } + + if (this.isOrderUpdated) { + await this.saveOrderPreference(); + + // Log which tabs reordered. The question we want to answer is: + // "How frequently are the tabs re-ordered, also which tabs get re-ordered?" + const toolId = + this.dragTarget.dataset.extensionId || this.dragTarget.dataset.id; + this.toolbox.telemetry.keyedScalarAdd(TABS_REORDERED_SCALAR, toolId, 1); + } + + this.eventTarget.removeEventListener("mousemove", this.onMouseMove); + this.eventTarget.removeEventListener("mouseup", this.onMouseUp); + + this.toolboxContainerElement.classList.remove("tabs-reordering"); + this.dragTarget.style.left = null; + this.dragTarget = null; + this.toolboxContainerElement = null; + this.toolboxTabsElement = null; + this.eventTarget = null; + } +} + +function getTabsOrderFromPreference() { + const pref = Services.prefs.getCharPref(PREFERENCE_NAME, ""); + return pref ? pref.split(",") : []; +} + +function sortPanelDefinitions(definitions) { + const toolIds = getTabsOrderFromPreference(); + + return definitions.sort((a, b) => { + let orderA = toolIds.indexOf(a.extensionId || a.id); + let orderB = toolIds.indexOf(b.extensionId || b.id); + orderA = orderA < 0 ? Number.MAX_VALUE : orderA; + orderB = orderB < 0 ? Number.MAX_VALUE : orderB; + return orderA - orderB; + }); +} + +/* + * This function returns absolute tab ids that were merged the both ids that are in + * preference and tabs. + * Some tabs added with add-ons etc show/hide depending on conditions. + * However, all of tabs that include hidden tab always keep the relationship with + * left side tab, except in case the left tab was target of dragging. If the left + * tab has been moved, it keeps its relationship with the tab next to it. + * + * Case 1: Drag a tab to left + * currentTabIds: [T1, T2, T3, T4, T5] + * prefIds : [T1, T2, T3, E1(hidden), T4, T5] + * drag T4 : [T1, T2, T4, T3, T5] + * result : [T1, T2, T4, T3, E1, T5] + * + * Case 2: Drag a tab to right + * currentTabIds: [T1, T2, T3, T4, T5] + * prefIds : [T1, T2, T3, E1(hidden), T4, T5] + * drag T2 : [T1, T3, T4, T2, T5] + * result : [T1, T3, E1, T4, T2, T5] + * + * Case 3: Hidden tab was left end and drag a tab to left end + * currentTabIds: [T1, T2, T3, T4, T5] + * prefIds : [E1(hidden), T1, T2, T3, T4, T5] + * drag T4 : [T4, T1, T2, T3, T5] + * result : [E1, T4, T1, T2, T3, T5] + * + * Case 4: Hidden tab was right end and drag a tab to right end + * currentTabIds: [T1, T2, T3, T4, T5] + * prefIds : [T1, T2, T3, T4, T5, E1(hidden)] + * drag T1 : [T2, T3, T4, T5, T1] + * result : [T2, T3, T4, T5, E1, T1] + * + * @param Array - prefIds: id array of preference + * @param Array - currentTabIds: id array of appearanced tabs + * @param String - dragTargetId: id of dragged target + * @return Array + */ +function toAbsoluteOrder(prefIds, currentTabIds, dragTargetId) { + currentTabIds = [...currentTabIds]; + let indexAtCurrentTabs = 0; + + for (const prefId of prefIds) { + if (prefId === dragTargetId) { + // do nothing + } else if (currentTabIds.includes(prefId)) { + indexAtCurrentTabs = currentTabIds.indexOf(prefId) + 1; + } else { + currentTabIds.splice(indexAtCurrentTabs, 0, prefId); + indexAtCurrentTabs += 1; + } + } + + return currentTabIds; +} + +module.exports.ToolboxTabsOrderManager = ToolboxTabsOrderManager; +module.exports.sortPanelDefinitions = sortPanelDefinitions; +module.exports.toAbsoluteOrder = toAbsoluteOrder; diff --git a/devtools/client/framework/toolbox-window.js b/devtools/client/framework/toolbox-window.js new file mode 100644 index 0000000000..0a179ad80f --- /dev/null +++ b/devtools/client/framework/toolbox-window.js @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* eslint-disable no-unused-vars */ + +"use strict"; + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +// Make some `topBrowsingContext.topChromeWindow` properties available in +// separate devtools window. +// +// (see bug 1659618) +XPCOMUtils.defineLazyScriptGetter( + this, + "ZoomManager", + "chrome://global/content/viewZoomOverlay.js" +); diff --git a/devtools/client/framework/toolbox-window.xhtml b/devtools/client/framework/toolbox-window.xhtml new file mode 100644 index 0000000000..3943ac0568 --- /dev/null +++ b/devtools/client/framework/toolbox-window.xhtml @@ -0,0 +1,21 @@ + + + + + + + + +