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 --- .../customizableui/CustomizableUI.sys.mjs | 6285 ++++++++++++++++++++ .../customizableui/CustomizableWidgets.sys.mjs | 615 ++ .../customizableui/CustomizeMode.sys.mjs | 2971 +++++++++ .../customizableui/DragPositionManager.sys.mjs | 313 + .../customizableui/PanelMultiView.sys.mjs | 1894 ++++++ .../customizableui/SearchWidgetTracker.sys.mjs | 134 + .../components/customizableui/content/.eslintrc.js | 13 + .../customizableui/content/customizeMode.inc.xhtml | 121 + browser/components/customizableui/content/jar.mn | 6 + .../components/customizableui/content/moz.build | 7 + .../customizableui/content/panelUI.inc.xhtml | 329 + .../components/customizableui/content/panelUI.js | 1072 ++++ browser/components/customizableui/moz.build | 28 + .../test/CustomizableUITestUtils.sys.mjs | 156 + .../components/customizableui/test/browser.toml | 368 ++ .../test/browser_1003588_no_specials_in_panel.js | 133 + .../test/browser_1008559_anchor_undo_restore.js | 102 + .../browser_1042100_default_placements_update.js | 241 + .../test/browser_1058573_showToolbarsDropdown.js | 29 + .../test/browser_1087303_button_fullscreen.js | 55 + .../test/browser_1087303_button_preferences.js | 59 + ...owser_1089591_still_customizable_after_reset.js | 24 + .../browser_1096763_seen_widgets_post_reset.js | 41 + ...browser_1161838_inserted_new_default_buttons.js | 109 + ...84275_PanelMultiView_toggle_with_other_popup.js | 69 + ...browser_1701883_restore_defaults_pocket_pref.js | 28 + ...wser_1702200_PanelMultiView_header_separator.js | 76 + .../browser_1795260_searchbar_overflow_toolbar.js | 30 + ...1856572_ensure_Fluent_works_in_customizeMode.js | 62 + .../test/browser_694291_searchbar_preference.js | 48 + .../test/browser_873501_handle_specials.js | 89 + .../test/browser_876926_customize_mode_wrapping.js | 295 + ...browser_876944_customize_mode_create_destroy.js | 44 + .../test/browser_877006_missing_view.js | 46 + .../test/browser_877178_unregisterArea.js | 70 + .../test/browser_877447_skip_missing_ids.js | 35 + .../test/browser_878452_drag_to_panel.js | 90 + .../test/browser_884402_customize_from_overflow.js | 117 + ...wser_885052_customize_mode_observers_disabed.js | 73 + .../test/browser_885530_showInPrivateBrowsing.js | 141 + .../browser_886323_buildArea_removable_nodes.js | 58 + ...wser_890262_destroyWidget_after_add_to_panel.js | 74 + ...892955_isWidgetRemovable_for_removed_widgets.js | 30 + ...owser_892956_destroyWidget_defaultPlacements.js | 30 + .../test/browser_901207_searchbar_in_panel.js | 139 + .../browser_909779_overflow_toolbars_new_window.js | 49 + .../test/browser_913972_currentset_overflow.js | 92 + ...owser_914138_widget_API_overflowable_toolbar.js | 347 ++ .../test/browser_918049_skipintoolbarset_dnd.js | 46 + ...7_customize_mode_event_wrapping_during_reset.js | 27 + .../browser_927717_customize_drag_empty_toolbar.js | 29 + .../test/browser_934113_menubar_removable.js | 43 + .../test/browser_934951_zoom_in_toolbar.js | 94 + .../test/browser_938980_navbar_collapsed.js | 214 + .../browser_938995_indefaultstate_nonremovable.js | 45 + ...40013_registerToolbarNode_calls_registerArea.js | 64 + .../browser_940307_panel_click_closure_handling.js | 141 + ...r_940946_removable_from_navbar_customizemode.js | 30 + ...941083_invalidate_wrapper_cache_createWidget.js | 49 + ...owser_942581_unregisterArea_keeps_placements.js | 119 + ...4887_destroyWidget_should_destroy_in_palette.js | 26 + ..._945739_showInPrivateBrowsing_customize_mode.js | 43 + .../test/browser_947914_button_copy.js | 64 + .../test/browser_947914_button_cut.js | 58 + .../test/browser_947914_button_find.js | 37 + .../test/browser_947914_button_history.js | 68 + .../test/browser_947914_button_newPrivateWindow.js | 62 + .../test/browser_947914_button_newWindow.js | 62 + .../test/browser_947914_button_paste.js | 55 + .../test/browser_947914_button_print.js | 54 + .../test/browser_947914_button_zoomIn.js | 60 + .../test/browser_947914_button_zoomOut.js | 61 + .../test/browser_947914_button_zoomReset.js | 75 + .../test/browser_947987_removable_default.js | 94 + .../browser_948985_non_removable_defaultArea.js | 47 + .../test/browser_952963_areaType_getter_no_area.js | 70 + .../test/browser_956602_remove_special_widget.js | 37 + .../browser_962069_drag_to_overflow_chevron.js | 79 + ...stomizing_attribute_non_customizable_toolbar.js | 48 + .../browser_968565_insert_before_hidden_items.js | 60 + ...969427_recreate_destroyed_widget_after_reset.js | 47 + ...er_969661_character_encoding_navbar_disabled.js | 28 + .../test/browser_970511_undo_restore_default.js | 274 + .../browser_972267_customizationchange_events.js | 39 + .../test/browser_976792_insertNodeInWindow.js | 597 ++ .../test/browser_978084_dragEnd_after_move.js | 52 + .../test/browser_980155_add_overflow_toolbar.js | 97 + .../test/browser_981305_separator_insertion.js | 89 + ...rowser_981418-widget-onbeforecreated-handler.js | 66 + ...wser_982656_restore_defaults_builtin_widgets.js | 82 + .../browser_984455_bookmarks_items_reparenting.js | 328 + ...rowser_985815_propagate_setToolbarVisibility.js | 56 + .../test/browser_987177_destroyWidget_xul.js | 35 + .../test/browser_987177_xul_wrapper_updating.js | 142 + .../test/browser_987492_window_api.js | 83 + .../test/browser_987640_charEncoding.js | 78 + .../browser_989338_saved_placements_not_resaved.js | 70 + .../test/browser_989751_subviewbutton_class.js | 91 + ...rowser_992747_toggle_noncustomizable_toolbar.js | 25 + .../test/browser_993322_widget_notoolbar.js | 59 + ...er_995164_registerArea_during_customize_mode.js | 278 + ...ser_996364_registerArea_different_properties.js | 142 + .../test/browser_996635_remove_non_widgets.js | 54 + .../customizableui/test/browser_PanelMultiView.js | 566 ++ .../test/browser_PanelMultiView_focus.js | 170 + .../test/browser_PanelMultiView_keyboard.js | 583 ++ .../customizableui/test/browser_addons_area.js | 76 + .../test/browser_allow_dragging_removable_false.js | 42 + .../test/browser_backfwd_enabled_post_customize.js | 79 + .../test/browser_bookmarks_empty_message.js | 83 + ..._bookmarks_toolbar_collapsed_restore_default.js | 35 + .../test/browser_bookmarks_toolbar_shown_newtab.js | 34 + .../test/browser_bootstrapped_custom_toolbar.js | 81 + .../test/browser_check_tooltips_in_navbar.js | 21 + .../test/browser_create_button_widget.js | 90 + .../test/browser_ctrl_click_panel_opening.js | 56 + .../test/browser_currentset_post_reset.js | 37 + .../test/browser_customization_context_menus.js | 632 ++ ...er_customizemode_contextmenu_menubuttonstate.js | 71 + .../test/browser_customizemode_lwthemes.js | 25 + .../test/browser_customizemode_uidensity.js | 230 + .../test/browser_disable_commands_customize.js | 86 + .../test/browser_drag_outside_palette.js | 53 + .../test/browser_editcontrols_update.js | 307 + .../test/browser_exit_background_customize_mode.js | 44 + .../test/browser_flexible_space_area.js | 48 + .../test/browser_help_panel_cloning.js | 90 + .../test/browser_hidden_widget_overflow.js | 115 + .../test/browser_history_after_appMenu.js | 35 + .../test/browser_history_recently_closed.js | 430 ++ .../browser_history_recently_closed_middleclick.js | 106 + .../test/browser_history_restore_session.js | 52 + .../test/browser_insert_before_moved_node.js | 51 + .../test/browser_menubar_visibility.js | 66 + .../test/browser_newtab_button_customizemode.js | 181 + .../customizableui/test/browser_open_from_popup.js | 24 + .../test/browser_open_in_lazy_tab.js | 42 + .../test/browser_overflow_use_subviews.js | 88 + .../customizableui/test/browser_palette_labels.js | 66 + .../test/browser_panelUINotifications.js | 597 ++ ...rowser_panelUINotifications_bannerVisibility.js | 151 + .../browser_panelUINotifications_fullscreen.js | 92 + ...UINotifications_fullscreen_noAutoHideToolbar.js | 145 + .../test/browser_panelUINotifications_modals.js | 87 + .../browser_panelUINotifications_multiWindow.js | 214 + .../test/browser_panel_keyboard_navigation.js | 326 + .../test/browser_panel_locationSpecific.js | 78 + .../customizableui/test/browser_panel_menulist.js | 50 + .../customizableui/test/browser_panel_toggle.js | 53 + .../test/browser_proton_moreTools_panel.js | 54 + .../browser_proton_toolbar_hide_toolbarbuttons.js | 285 + .../customizableui/test/browser_registerArea.js | 28 + .../customizableui/test/browser_reload_tab.js | 103 + .../test/browser_remote_attribute.js | 73 + .../test/browser_remote_tabs_button.js | 100 + .../test/browser_remove_customized_specials.js | 35 + .../browser_reset_builtin_widget_currentArea.js | 26 + .../test/browser_reset_dom_events.js | 34 + .../test/browser_screenshot_button_disabled.js | 22 + .../test/browser_searchbar_removal.js | 36 + .../customizableui/test/browser_sidebar_toggle.js | 58 + .../test/browser_switch_to_customize_mode.js | 53 + .../test/browser_synced_tabs_menu.js | 523 ++ .../test/browser_tabbar_big_widgets.js | 32 + .../test/browser_toolbar_collapsed_states.js | 112 + .../test/browser_touchbar_customization.js | 21 + .../test/browser_unified_extensions_reset.js | 91 + .../test/browser_widget_animation.js | 84 + .../test/browser_widget_recreate_events.js | 99 + .../customizableui/test/dummy_history_item.html | 2 + browser/components/customizableui/test/head.js | 530 ++ .../support/test_967000_charEncoding_page.html | 11 + .../test/unit/test_unified_extensions_migration.js | 373 ++ .../customizableui/test/unit/xpcshell.toml | 6 + 174 files changed, 31489 insertions(+) create mode 100644 browser/components/customizableui/CustomizableUI.sys.mjs create mode 100644 browser/components/customizableui/CustomizableWidgets.sys.mjs create mode 100644 browser/components/customizableui/CustomizeMode.sys.mjs create mode 100644 browser/components/customizableui/DragPositionManager.sys.mjs create mode 100644 browser/components/customizableui/PanelMultiView.sys.mjs create mode 100644 browser/components/customizableui/SearchWidgetTracker.sys.mjs create mode 100644 browser/components/customizableui/content/.eslintrc.js create mode 100644 browser/components/customizableui/content/customizeMode.inc.xhtml create mode 100644 browser/components/customizableui/content/jar.mn create mode 100644 browser/components/customizableui/content/moz.build create mode 100644 browser/components/customizableui/content/panelUI.inc.xhtml create mode 100644 browser/components/customizableui/content/panelUI.js create mode 100644 browser/components/customizableui/moz.build create mode 100644 browser/components/customizableui/test/CustomizableUITestUtils.sys.mjs create mode 100644 browser/components/customizableui/test/browser.toml create mode 100644 browser/components/customizableui/test/browser_1003588_no_specials_in_panel.js create mode 100644 browser/components/customizableui/test/browser_1008559_anchor_undo_restore.js create mode 100644 browser/components/customizableui/test/browser_1042100_default_placements_update.js create mode 100644 browser/components/customizableui/test/browser_1058573_showToolbarsDropdown.js create mode 100644 browser/components/customizableui/test/browser_1087303_button_fullscreen.js create mode 100644 browser/components/customizableui/test/browser_1087303_button_preferences.js create mode 100644 browser/components/customizableui/test/browser_1089591_still_customizable_after_reset.js create mode 100644 browser/components/customizableui/test/browser_1096763_seen_widgets_post_reset.js create mode 100644 browser/components/customizableui/test/browser_1161838_inserted_new_default_buttons.js create mode 100644 browser/components/customizableui/test/browser_1484275_PanelMultiView_toggle_with_other_popup.js create mode 100644 browser/components/customizableui/test/browser_1701883_restore_defaults_pocket_pref.js create mode 100644 browser/components/customizableui/test/browser_1702200_PanelMultiView_header_separator.js create mode 100644 browser/components/customizableui/test/browser_1795260_searchbar_overflow_toolbar.js create mode 100644 browser/components/customizableui/test/browser_1856572_ensure_Fluent_works_in_customizeMode.js create mode 100644 browser/components/customizableui/test/browser_694291_searchbar_preference.js create mode 100644 browser/components/customizableui/test/browser_873501_handle_specials.js create mode 100644 browser/components/customizableui/test/browser_876926_customize_mode_wrapping.js create mode 100644 browser/components/customizableui/test/browser_876944_customize_mode_create_destroy.js create mode 100644 browser/components/customizableui/test/browser_877006_missing_view.js create mode 100644 browser/components/customizableui/test/browser_877178_unregisterArea.js create mode 100644 browser/components/customizableui/test/browser_877447_skip_missing_ids.js create mode 100644 browser/components/customizableui/test/browser_878452_drag_to_panel.js create mode 100644 browser/components/customizableui/test/browser_884402_customize_from_overflow.js create mode 100644 browser/components/customizableui/test/browser_885052_customize_mode_observers_disabed.js create mode 100644 browser/components/customizableui/test/browser_885530_showInPrivateBrowsing.js create mode 100644 browser/components/customizableui/test/browser_886323_buildArea_removable_nodes.js create mode 100644 browser/components/customizableui/test/browser_890262_destroyWidget_after_add_to_panel.js create mode 100644 browser/components/customizableui/test/browser_892955_isWidgetRemovable_for_removed_widgets.js create mode 100644 browser/components/customizableui/test/browser_892956_destroyWidget_defaultPlacements.js create mode 100644 browser/components/customizableui/test/browser_901207_searchbar_in_panel.js create mode 100644 browser/components/customizableui/test/browser_909779_overflow_toolbars_new_window.js create mode 100644 browser/components/customizableui/test/browser_913972_currentset_overflow.js create mode 100644 browser/components/customizableui/test/browser_914138_widget_API_overflowable_toolbar.js create mode 100644 browser/components/customizableui/test/browser_918049_skipintoolbarset_dnd.js create mode 100644 browser/components/customizableui/test/browser_923857_customize_mode_event_wrapping_during_reset.js create mode 100644 browser/components/customizableui/test/browser_927717_customize_drag_empty_toolbar.js create mode 100644 browser/components/customizableui/test/browser_934113_menubar_removable.js create mode 100644 browser/components/customizableui/test/browser_934951_zoom_in_toolbar.js create mode 100644 browser/components/customizableui/test/browser_938980_navbar_collapsed.js create mode 100644 browser/components/customizableui/test/browser_938995_indefaultstate_nonremovable.js create mode 100644 browser/components/customizableui/test/browser_940013_registerToolbarNode_calls_registerArea.js create mode 100644 browser/components/customizableui/test/browser_940307_panel_click_closure_handling.js create mode 100644 browser/components/customizableui/test/browser_940946_removable_from_navbar_customizemode.js create mode 100644 browser/components/customizableui/test/browser_941083_invalidate_wrapper_cache_createWidget.js create mode 100644 browser/components/customizableui/test/browser_942581_unregisterArea_keeps_placements.js create mode 100644 browser/components/customizableui/test/browser_944887_destroyWidget_should_destroy_in_palette.js create mode 100644 browser/components/customizableui/test/browser_945739_showInPrivateBrowsing_customize_mode.js create mode 100644 browser/components/customizableui/test/browser_947914_button_copy.js create mode 100644 browser/components/customizableui/test/browser_947914_button_cut.js create mode 100644 browser/components/customizableui/test/browser_947914_button_find.js create mode 100644 browser/components/customizableui/test/browser_947914_button_history.js create mode 100644 browser/components/customizableui/test/browser_947914_button_newPrivateWindow.js create mode 100644 browser/components/customizableui/test/browser_947914_button_newWindow.js create mode 100644 browser/components/customizableui/test/browser_947914_button_paste.js create mode 100644 browser/components/customizableui/test/browser_947914_button_print.js create mode 100644 browser/components/customizableui/test/browser_947914_button_zoomIn.js create mode 100644 browser/components/customizableui/test/browser_947914_button_zoomOut.js create mode 100644 browser/components/customizableui/test/browser_947914_button_zoomReset.js create mode 100644 browser/components/customizableui/test/browser_947987_removable_default.js create mode 100644 browser/components/customizableui/test/browser_948985_non_removable_defaultArea.js create mode 100644 browser/components/customizableui/test/browser_952963_areaType_getter_no_area.js create mode 100644 browser/components/customizableui/test/browser_956602_remove_special_widget.js create mode 100644 browser/components/customizableui/test/browser_962069_drag_to_overflow_chevron.js create mode 100644 browser/components/customizableui/test/browser_963639_customizing_attribute_non_customizable_toolbar.js create mode 100644 browser/components/customizableui/test/browser_968565_insert_before_hidden_items.js create mode 100644 browser/components/customizableui/test/browser_969427_recreate_destroyed_widget_after_reset.js create mode 100644 browser/components/customizableui/test/browser_969661_character_encoding_navbar_disabled.js create mode 100644 browser/components/customizableui/test/browser_970511_undo_restore_default.js create mode 100644 browser/components/customizableui/test/browser_972267_customizationchange_events.js create mode 100644 browser/components/customizableui/test/browser_976792_insertNodeInWindow.js create mode 100644 browser/components/customizableui/test/browser_978084_dragEnd_after_move.js create mode 100644 browser/components/customizableui/test/browser_980155_add_overflow_toolbar.js create mode 100644 browser/components/customizableui/test/browser_981305_separator_insertion.js create mode 100644 browser/components/customizableui/test/browser_981418-widget-onbeforecreated-handler.js create mode 100644 browser/components/customizableui/test/browser_982656_restore_defaults_builtin_widgets.js create mode 100644 browser/components/customizableui/test/browser_984455_bookmarks_items_reparenting.js create mode 100644 browser/components/customizableui/test/browser_985815_propagate_setToolbarVisibility.js create mode 100644 browser/components/customizableui/test/browser_987177_destroyWidget_xul.js create mode 100644 browser/components/customizableui/test/browser_987177_xul_wrapper_updating.js create mode 100644 browser/components/customizableui/test/browser_987492_window_api.js create mode 100644 browser/components/customizableui/test/browser_987640_charEncoding.js create mode 100644 browser/components/customizableui/test/browser_989338_saved_placements_not_resaved.js create mode 100644 browser/components/customizableui/test/browser_989751_subviewbutton_class.js create mode 100644 browser/components/customizableui/test/browser_992747_toggle_noncustomizable_toolbar.js create mode 100644 browser/components/customizableui/test/browser_993322_widget_notoolbar.js create mode 100644 browser/components/customizableui/test/browser_995164_registerArea_during_customize_mode.js create mode 100644 browser/components/customizableui/test/browser_996364_registerArea_different_properties.js create mode 100644 browser/components/customizableui/test/browser_996635_remove_non_widgets.js create mode 100644 browser/components/customizableui/test/browser_PanelMultiView.js create mode 100644 browser/components/customizableui/test/browser_PanelMultiView_focus.js create mode 100644 browser/components/customizableui/test/browser_PanelMultiView_keyboard.js create mode 100644 browser/components/customizableui/test/browser_addons_area.js create mode 100644 browser/components/customizableui/test/browser_allow_dragging_removable_false.js create mode 100644 browser/components/customizableui/test/browser_backfwd_enabled_post_customize.js create mode 100644 browser/components/customizableui/test/browser_bookmarks_empty_message.js create mode 100644 browser/components/customizableui/test/browser_bookmarks_toolbar_collapsed_restore_default.js create mode 100644 browser/components/customizableui/test/browser_bookmarks_toolbar_shown_newtab.js create mode 100644 browser/components/customizableui/test/browser_bootstrapped_custom_toolbar.js create mode 100644 browser/components/customizableui/test/browser_check_tooltips_in_navbar.js create mode 100644 browser/components/customizableui/test/browser_create_button_widget.js create mode 100644 browser/components/customizableui/test/browser_ctrl_click_panel_opening.js create mode 100644 browser/components/customizableui/test/browser_currentset_post_reset.js create mode 100644 browser/components/customizableui/test/browser_customization_context_menus.js create mode 100644 browser/components/customizableui/test/browser_customizemode_contextmenu_menubuttonstate.js create mode 100644 browser/components/customizableui/test/browser_customizemode_lwthemes.js create mode 100644 browser/components/customizableui/test/browser_customizemode_uidensity.js create mode 100644 browser/components/customizableui/test/browser_disable_commands_customize.js create mode 100644 browser/components/customizableui/test/browser_drag_outside_palette.js create mode 100644 browser/components/customizableui/test/browser_editcontrols_update.js create mode 100644 browser/components/customizableui/test/browser_exit_background_customize_mode.js create mode 100644 browser/components/customizableui/test/browser_flexible_space_area.js create mode 100644 browser/components/customizableui/test/browser_help_panel_cloning.js create mode 100644 browser/components/customizableui/test/browser_hidden_widget_overflow.js create mode 100644 browser/components/customizableui/test/browser_history_after_appMenu.js create mode 100644 browser/components/customizableui/test/browser_history_recently_closed.js create mode 100644 browser/components/customizableui/test/browser_history_recently_closed_middleclick.js create mode 100644 browser/components/customizableui/test/browser_history_restore_session.js create mode 100644 browser/components/customizableui/test/browser_insert_before_moved_node.js create mode 100644 browser/components/customizableui/test/browser_menubar_visibility.js create mode 100644 browser/components/customizableui/test/browser_newtab_button_customizemode.js create mode 100644 browser/components/customizableui/test/browser_open_from_popup.js create mode 100644 browser/components/customizableui/test/browser_open_in_lazy_tab.js create mode 100644 browser/components/customizableui/test/browser_overflow_use_subviews.js create mode 100644 browser/components/customizableui/test/browser_palette_labels.js create mode 100644 browser/components/customizableui/test/browser_panelUINotifications.js create mode 100644 browser/components/customizableui/test/browser_panelUINotifications_bannerVisibility.js create mode 100644 browser/components/customizableui/test/browser_panelUINotifications_fullscreen.js create mode 100644 browser/components/customizableui/test/browser_panelUINotifications_fullscreen_noAutoHideToolbar.js create mode 100644 browser/components/customizableui/test/browser_panelUINotifications_modals.js create mode 100644 browser/components/customizableui/test/browser_panelUINotifications_multiWindow.js create mode 100644 browser/components/customizableui/test/browser_panel_keyboard_navigation.js create mode 100644 browser/components/customizableui/test/browser_panel_locationSpecific.js create mode 100644 browser/components/customizableui/test/browser_panel_menulist.js create mode 100644 browser/components/customizableui/test/browser_panel_toggle.js create mode 100644 browser/components/customizableui/test/browser_proton_moreTools_panel.js create mode 100644 browser/components/customizableui/test/browser_proton_toolbar_hide_toolbarbuttons.js create mode 100644 browser/components/customizableui/test/browser_registerArea.js create mode 100644 browser/components/customizableui/test/browser_reload_tab.js create mode 100644 browser/components/customizableui/test/browser_remote_attribute.js create mode 100644 browser/components/customizableui/test/browser_remote_tabs_button.js create mode 100644 browser/components/customizableui/test/browser_remove_customized_specials.js create mode 100644 browser/components/customizableui/test/browser_reset_builtin_widget_currentArea.js create mode 100644 browser/components/customizableui/test/browser_reset_dom_events.js create mode 100644 browser/components/customizableui/test/browser_screenshot_button_disabled.js create mode 100644 browser/components/customizableui/test/browser_searchbar_removal.js create mode 100644 browser/components/customizableui/test/browser_sidebar_toggle.js create mode 100644 browser/components/customizableui/test/browser_switch_to_customize_mode.js create mode 100644 browser/components/customizableui/test/browser_synced_tabs_menu.js create mode 100644 browser/components/customizableui/test/browser_tabbar_big_widgets.js create mode 100644 browser/components/customizableui/test/browser_toolbar_collapsed_states.js create mode 100644 browser/components/customizableui/test/browser_touchbar_customization.js create mode 100644 browser/components/customizableui/test/browser_unified_extensions_reset.js create mode 100644 browser/components/customizableui/test/browser_widget_animation.js create mode 100644 browser/components/customizableui/test/browser_widget_recreate_events.js create mode 100644 browser/components/customizableui/test/dummy_history_item.html create mode 100644 browser/components/customizableui/test/head.js create mode 100644 browser/components/customizableui/test/support/test_967000_charEncoding_page.html create mode 100644 browser/components/customizableui/test/unit/test_unified_extensions_migration.js create mode 100644 browser/components/customizableui/test/unit/xpcshell.toml (limited to 'browser/components/customizableui') diff --git a/browser/components/customizableui/CustomizableUI.sys.mjs b/browser/components/customizableui/CustomizableUI.sys.mjs new file mode 100644 index 0000000000..d748b93a92 --- /dev/null +++ b/browser/components/customizableui/CustomizableUI.sys.mjs @@ -0,0 +1,6285 @@ +/* This Source Code Form is subject to the terms of 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { SearchWidgetTracker } from "resource:///modules/SearchWidgetTracker.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", + BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.sys.mjs", + CustomizableWidgets: "resource:///modules/CustomizableWidgets.sys.mjs", + HomePage: "resource:///modules/HomePage.sys.mjs", + PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "gWidgetsBundle", function () { + const kUrl = + "chrome://browser/locale/customizableui/customizableWidgets.properties"; + return Services.strings.createBundle(kUrl); +}); + +const kDefaultThemeID = "default-theme@mozilla.org"; + +const kSpecialWidgetPfx = "customizableui-special-"; + +const kPrefCustomizationState = "browser.uiCustomization.state"; +const kPrefCustomizationAutoAdd = "browser.uiCustomization.autoAdd"; +const kPrefCustomizationDebug = "browser.uiCustomization.debug"; +const kPrefDrawInTitlebar = "browser.tabs.inTitlebar"; +const kPrefUIDensity = "browser.uidensity"; +const kPrefAutoTouchMode = "browser.touchmode.auto"; +const kPrefAutoHideDownloadsButton = "browser.download.autohideButton"; +const kPrefProtonToolbarVersion = "browser.proton.toolbar.version"; +const kPrefHomeButtonUsed = "browser.engagement.home-button.has-used"; +const kPrefLibraryButtonUsed = "browser.engagement.library-button.has-used"; +const kPrefSidebarButtonUsed = "browser.engagement.sidebar-button.has-used"; + +const kExpectedWindowURL = AppConstants.BROWSER_CHROME_URL; + +var gDefaultTheme; +var gSelectedTheme; + +/** + * The keys are the handlers that are fired when the event type (the value) + * is fired on the subview. A widget that provides a subview has the option + * of providing onViewShowing and onViewHiding event handlers. + */ +const kSubviewEvents = ["ViewShowing", "ViewHiding"]; + +/** + * The current version. We can use this to auto-add new default widgets as necessary. + * (would be const but isn't because of testing purposes) + */ +var kVersion = 20; + +/** + * Buttons removed from built-ins by version they were removed. kVersion must be + * bumped any time a new id is added to this. Use the button id as key, and + * version the button is removed in as the value. e.g. "pocket-button": 5 + */ +var ObsoleteBuiltinButtons = { + "feed-button": 15, +}; + +/** + * gPalette is a map of every widget that CustomizableUI.sys.mjs knows about, keyed + * on their IDs. + */ +var gPalette = new Map(); + +/** + * gAreas maps area IDs to Sets of properties about those areas. An area is a + * place where a widget can be put. + */ +var gAreas = new Map(); + +/** + * gPlacements maps area IDs to Arrays of widget IDs, indicating that the widgets + * are placed within that area (either directly in the area node, or in the + * customizationTarget of the node). + */ +var gPlacements = new Map(); + +/** + * gFuturePlacements represent placements that will happen for areas that have + * not yet loaded (due to lazy-loading). This can occur when add-ons register + * widgets. + */ +var gFuturePlacements = new Map(); + +var gSupportedWidgetTypes = new Set([ + // A button that does a command. + "button", + + // A button that opens a view in a panel (or in a subview of the panel). + "view", + + // A combination of the above, which looks different depending on whether it's + // located in the toolbar or in the panel: When located in the toolbar, shown + // as a combined item of a button and a dropmarker button. The button triggers + // the command and the dropmarker button opens the view. When located in the + // panel, shown as one item which opens the view, and the button command + // cannot be triggered separately. + "button-and-view", + + // A custom widget that defines its own markup. + "custom", +]); + +/** + * gPanelsForWindow is a list of known panels in a window which we may need to close + * should command events fire which target them. + */ +var gPanelsForWindow = new WeakMap(); + +/** + * gSeenWidgets remembers which widgets the user has seen for the first time + * before. This way, if a new widget is created, and the user has not seen it + * before, it can be put in its default location. Otherwise, it remains in the + * palette. + */ +var gSeenWidgets = new Set(); + +/** + * gDirtyAreaCache is a set of area IDs for areas where items have been added, + * moved or removed at least once. This set is persisted, and is used to + * optimize building of toolbars in the default case where no toolbars should + * be "dirty". + */ +var gDirtyAreaCache = new Set(); + +/** + * gPendingBuildAreas is a map from area IDs to map from build nodes to their + * existing children at the time of node registration, that are waiting + * for the area to be registered + */ +var gPendingBuildAreas = new Map(); + +var gSavedState = null; +var gRestoring = false; +var gDirty = false; +var gInBatchStack = 0; +var gResetting = false; +var gUndoResetting = false; + +/** + * gBuildAreas maps area IDs to actual area nodes within browser windows. + */ +var gBuildAreas = new Map(); + +/** + * gBuildWindows is a map of windows that have registered build areas, mapped + * to a Set of known toolboxes in that window. + */ +var gBuildWindows = new Map(); + +var gNewElementCount = 0; +var gGroupWrapperCache = new Map(); +var gSingleWrapperCache = new WeakMap(); +var gListeners = new Set(); + +var gUIStateBeforeReset = { + uiCustomizationState: null, + drawInTitlebar: null, + currentTheme: null, + uiDensity: null, + autoTouchMode: null, +}; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "gDebuggingEnabled", + kPrefCustomizationDebug, + false, + (pref, oldVal, newVal) => { + if (typeof lazy.log != "undefined") { + lazy.log.maxLogLevel = newVal ? "all" : "log"; + } + } +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "resetPBMToolbarButtonEnabled", + "browser.privatebrowsing.resetPBM.enabled", + false +); + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + let consoleOptions = { + maxLogLevel: lazy.gDebuggingEnabled ? "all" : "log", + prefix: "CustomizableUI", + }; + return new ConsoleAPI(consoleOptions); +}); + +var CustomizableUIInternal = { + initialize() { + lazy.log.debug("Initializing"); + + lazy.AddonManagerPrivate.databaseReady.then(async () => { + lazy.AddonManager.addAddonListener(this); + + let addons = await lazy.AddonManager.getAddonsByTypes(["theme"]); + gDefaultTheme = addons.find(addon => addon.id == kDefaultThemeID); + gSelectedTheme = addons.find(addon => addon.isActive) || gDefaultTheme; + }); + + this.addListener(this); + this._defineBuiltInWidgets(); + this.loadSavedState(); + this._updateForNewVersion(); + this._updateForNewProtonVersion(); + this._markObsoleteBuiltinButtonsSeen(); + + this.registerArea( + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, + { + type: CustomizableUI.TYPE_PANEL, + defaultPlacements: [], + anchor: "nav-bar-overflow-button", + }, + true + ); + + this.registerArea( + CustomizableUI.AREA_ADDONS, + { + type: CustomizableUI.TYPE_PANEL, + defaultPlacements: [], + anchor: "unified-extensions-button", + }, + false + ); + + let navbarPlacements = [ + "back-button", + "forward-button", + "stop-reload-button", + Services.policies.isAllowed("removeHomeButtonByDefault") + ? null + : "home-button", + "spring", + "urlbar-container", + "spring", + "save-to-pocket-button", + "downloads-button", + AppConstants.MOZ_DEV_EDITION ? "developer-button" : null, + "fxa-toolbar-menu-button", + lazy.resetPBMToolbarButtonEnabled ? "reset-pbm-toolbar-button" : null, + ].filter(name => name); + + this.registerArea( + CustomizableUI.AREA_NAVBAR, + { + type: CustomizableUI.TYPE_TOOLBAR, + overflowable: true, + defaultPlacements: navbarPlacements, + defaultCollapsed: false, + }, + true + ); + + if (AppConstants.MENUBAR_CAN_AUTOHIDE) { + this.registerArea( + CustomizableUI.AREA_MENUBAR, + { + type: CustomizableUI.TYPE_TOOLBAR, + defaultPlacements: ["menubar-items"], + defaultCollapsed: true, + }, + true + ); + } + + this.registerArea( + CustomizableUI.AREA_TABSTRIP, + { + type: CustomizableUI.TYPE_TOOLBAR, + defaultPlacements: [ + "firefox-view-button", + "tabbrowser-tabs", + "new-tab-button", + "alltabs-button", + ], + defaultCollapsed: null, + }, + true + ); + this.registerArea( + CustomizableUI.AREA_BOOKMARKS, + { + type: CustomizableUI.TYPE_TOOLBAR, + defaultPlacements: ["personal-bookmarks"], + defaultCollapsed: "newtab", + }, + true + ); + + SearchWidgetTracker.init(); + + Services.obs.addObserver(this, "browser-set-toolbar-visibility"); + }, + + onEnabled(addon) { + if (addon.type == "theme") { + gSelectedTheme = addon; + } + }, + + get _builtinAreas() { + return new Set([ + ...this._builtinToolbars, + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, + CustomizableUI.AREA_ADDONS, + ]); + }, + + get _builtinToolbars() { + let toolbars = new Set([ + CustomizableUI.AREA_NAVBAR, + CustomizableUI.AREA_BOOKMARKS, + CustomizableUI.AREA_TABSTRIP, + ]); + if (AppConstants.platform != "macosx") { + toolbars.add(CustomizableUI.AREA_MENUBAR); + } + return toolbars; + }, + + _defineBuiltInWidgets() { + for (let widgetDefinition of lazy.CustomizableWidgets) { + this.createBuiltinWidget(widgetDefinition); + } + }, + + // eslint-disable-next-line complexity + _updateForNewVersion() { + // We should still enter even if gSavedState.currentVersion >= kVersion + // because the per-widget pref facility is independent of versioning. + if (!gSavedState) { + // Flip all the prefs so we don't try to re-introduce later: + for (let [, widget] of gPalette) { + if (widget.defaultArea && widget._introducedInVersion === "pref") { + let prefId = "browser.toolbarbuttons.introduced." + widget.id; + Services.prefs.setBoolPref(prefId, true); + } + } + return; + } + + let currentVersion = gSavedState.currentVersion; + for (let [id, widget] of gPalette) { + if (widget.defaultArea) { + let shouldAdd = false; + let shouldSetPref = false; + let prefId = "browser.toolbarbuttons.introduced." + widget.id; + if (widget._introducedInVersion === "pref") { + try { + shouldAdd = !Services.prefs.getBoolPref(prefId); + } catch (ex) { + // Pref doesn't exist: + shouldAdd = true; + } + shouldSetPref = shouldAdd; + } else if (widget._introducedInVersion > currentVersion) { + shouldAdd = true; + } + + if (shouldAdd) { + let futurePlacements = gFuturePlacements.get(widget.defaultArea); + if (futurePlacements) { + futurePlacements.add(id); + } else { + gFuturePlacements.set(widget.defaultArea, new Set([id])); + } + if (shouldSetPref) { + Services.prefs.setBoolPref(prefId, true); + } + } + } + } + + // Nothing to migrate now if we don't have placements. + if (!gSavedState.placements) { + return; + } + + if ( + currentVersion < 7 && + gSavedState.placements[CustomizableUI.AREA_NAVBAR] + ) { + let placements = gSavedState.placements[CustomizableUI.AREA_NAVBAR]; + let newPlacements = [ + "back-button", + "forward-button", + "stop-reload-button", + "home-button", + ]; + for (let button of placements) { + if (!newPlacements.includes(button)) { + newPlacements.push(button); + } + } + + if (!newPlacements.includes("sidebar-button")) { + newPlacements.push("sidebar-button"); + } + + gSavedState.placements[CustomizableUI.AREA_NAVBAR] = newPlacements; + } + + if (currentVersion < 8 && gSavedState.placements["PanelUI-contents"]) { + let savedPanelPlacements = gSavedState.placements["PanelUI-contents"]; + delete gSavedState.placements["PanelUI-contents"]; + let defaultPlacements = [ + "edit-controls", + "zoom-controls", + "new-window-button", + "privatebrowsing-button", + "save-page-button", + "print-button", + "history-panelmenu", + "fullscreen-button", + "find-button", + "preferences-button", + // This widget no longer exists as of 2023, see Bug 1799009. + "add-ons-button", + "sync-button", + ]; + + if (!AppConstants.MOZ_DEV_EDITION) { + defaultPlacements.splice(-1, 0, "developer-button"); + } + + let showCharacterEncoding = Services.prefs.getComplexValue( + "browser.menu.showCharacterEncoding", + Ci.nsIPrefLocalizedString + ).data; + if (showCharacterEncoding == "true") { + defaultPlacements.push("characterencoding-button"); + } + + savedPanelPlacements = savedPanelPlacements.filter( + id => !defaultPlacements.includes(id) + ); + + if (savedPanelPlacements.length) { + gSavedState.placements[CustomizableUI.AREA_FIXED_OVERFLOW_PANEL] = + savedPanelPlacements; + } + } + + if (currentVersion < 9 && gSavedState.placements["nav-bar"]) { + let placements = gSavedState.placements["nav-bar"]; + if (placements.includes("urlbar-container")) { + let urlbarIndex = placements.indexOf("urlbar-container"); + let secondSpringIndex = urlbarIndex + 1; + // Insert if there isn't already a spring before the urlbar + if ( + urlbarIndex == 0 || + !placements[urlbarIndex - 1].startsWith(kSpecialWidgetPfx + "spring") + ) { + placements.splice(urlbarIndex, 0, "spring"); + // The url bar is now 1 index later, so increment the insertion point for + // the second spring. + secondSpringIndex++; + } + // If the search container is present, insert after the search container + // instead of after the url bar + let searchContainerIndex = placements.indexOf("search-container"); + if (searchContainerIndex != -1) { + secondSpringIndex = searchContainerIndex + 1; + } + if ( + secondSpringIndex == placements.length || + !placements[secondSpringIndex].startsWith( + kSpecialWidgetPfx + "spring" + ) + ) { + placements.splice(secondSpringIndex, 0, "spring"); + } + } + + // Finally, replace the bookmarks menu button with the library one if present + if (placements.includes("bookmarks-menu-button")) { + let bmbIndex = placements.indexOf("bookmarks-menu-button"); + placements.splice(bmbIndex, 1); + let downloadButtonIndex = placements.indexOf("downloads-button"); + let libraryIndex = + downloadButtonIndex == -1 ? bmbIndex : downloadButtonIndex + 1; + placements.splice(libraryIndex, 0, "library-button"); + } + } + + if (currentVersion < 10) { + for (let placements of Object.values(gSavedState.placements)) { + if (placements.includes("webcompat-reporter-button")) { + placements.splice(placements.indexOf("webcompat-reporter-button"), 1); + break; + } + } + } + + // Move the downloads button to the default position in the navbar if it's + // not there already. + if (currentVersion < 11) { + let navbarPlacements = gSavedState.placements[CustomizableUI.AREA_NAVBAR]; + // First remove from wherever it currently lives, if anywhere: + for (let placements of Object.values(gSavedState.placements)) { + let existingIndex = placements.indexOf("downloads-button"); + if (existingIndex != -1) { + placements.splice(existingIndex, 1); + break; // It can only be in 1 place, so no point looking elsewhere. + } + } + + // Now put the button in the navbar in the correct spot: + if (navbarPlacements) { + let insertionPoint = navbarPlacements.indexOf("urlbar-container"); + // Deliberately iterate to 1 past the end of the array to insert at the + // end if need be. + while (++insertionPoint < navbarPlacements.length) { + let widget = navbarPlacements[insertionPoint]; + // If we find a non-searchbar, non-spacer node, break out of the loop: + if ( + widget != "search-container" && + !this.matchingSpecials(widget, "spring") + ) { + break; + } + } + // We either found the right spot, or reached the end of the + // placements, so insert here: + navbarPlacements.splice(insertionPoint, 0, "downloads-button"); + } + } + + if (currentVersion < 12) { + const removedButtons = [ + "loop-call-button", + "loop-button-throttled", + "pocket-button", + ]; + for (let placements of Object.values(gSavedState.placements)) { + for (let button of removedButtons) { + let buttonIndex = placements.indexOf(button); + if (buttonIndex != -1) { + placements.splice(buttonIndex, 1); + } + } + } + } + + // Remove the old placements from the now-gone Nightly-only + // "New non-e10s window" button. + if (currentVersion < 13) { + for (let placements of Object.values(gSavedState.placements)) { + let buttonIndex = placements.indexOf("e10s-button"); + if (buttonIndex != -1) { + placements.splice(buttonIndex, 1); + } + } + } + + // Remove unsupported custom toolbar saved placements + if (currentVersion < 14) { + for (let area in gSavedState.placements) { + if (!this._builtinAreas.has(area)) { + delete gSavedState.placements[area]; + } + } + } + + // Add the FxA toolbar menu as the right most button item + if (currentVersion < 16) { + let navbarPlacements = gSavedState.placements[CustomizableUI.AREA_NAVBAR]; + // Place the menu item as the first item to the left of the hamburger menu + if (navbarPlacements) { + navbarPlacements.push("fxa-toolbar-menu-button"); + } + } + + // Add the save to Pocket button left of downloads button. + if (currentVersion < 17) { + let navbarPlacements = gSavedState.placements[CustomizableUI.AREA_NAVBAR]; + let persistedPageActionsPref = Services.prefs.getCharPref( + "browser.pageActions.persistedActions", + "" + ); + let pocketPreviouslyInUrl = true; + try { + let persistedPageActionsData = JSON.parse(persistedPageActionsPref); + // If Pocket was previously not in the url bar, let's not put it in the toolbar. + // It'll still be an option to add from the customization page. + pocketPreviouslyInUrl = + persistedPageActionsData.idsInUrlbar.includes("pocket"); + } catch (e) {} + if (navbarPlacements && pocketPreviouslyInUrl) { + // Pocket's new home is next to the downloads button, or the next best spot. + let newPosition = + navbarPlacements.indexOf("downloads-button") ?? + navbarPlacements.indexOf("fxa-toolbar-menu-button") ?? + navbarPlacements.length; + + navbarPlacements.splice(newPosition, 0, "save-to-pocket-button"); + } + } + + // Add firefox-view if not present + if (currentVersion < 18) { + let tabstripPlacements = + gSavedState.placements[CustomizableUI.AREA_TABSTRIP]; + if ( + tabstripPlacements && + !tabstripPlacements.includes("firefox-view-button") + ) { + tabstripPlacements.unshift("firefox-view-button"); + } + } + + // Unified Extensions addon button migration, which puts any browser action + // buttons in the overflow menu into the addons panel instead. + if (currentVersion < 19) { + let overflowPlacements = + gSavedState.placements[CustomizableUI.AREA_FIXED_OVERFLOW_PANEL] || []; + // The most likely case is that there are no AREA_ADDONS placements, in which case the + // array won't exist. + let addonsPlacements = + gSavedState.placements[CustomizableUI.AREA_ADDONS] || []; + + // Migration algorithm for transitioning to Unified Extensions: + // + // 1. Create two arrays, one for extension widgets, one for built-in widgets. + // 2. Iterate all items in the overflow panel, and push them into the + // appropriate array based on whether or not its an extension widget. + // 3. Overwrite the overflow panel placements with the built-in widgets array. + // 4. Prepend the extension widgets to the addonsPlacements array. Note that this + // does not overwrite this array as a precaution because it's possible + // (though pretty unlikely) that some widgets are already there. + // + // For extension widgets that were in the palette, they will be appended to the + // addons area when they're created within createWidget. + let extWidgets = []; + let builtInWidgets = []; + for (let widgetId of overflowPlacements) { + if (CustomizableUI.isWebExtensionWidget(widgetId)) { + extWidgets.push(widgetId); + } else { + builtInWidgets.push(widgetId); + } + } + gSavedState.placements[CustomizableUI.AREA_FIXED_OVERFLOW_PANEL] = + builtInWidgets; + gSavedState.placements[CustomizableUI.AREA_ADDONS] = [ + ...extWidgets, + ...addonsPlacements, + ]; + } + + // Add the PBM reset button as the right most button item + if (currentVersion < 20) { + let navbarPlacements = gSavedState.placements[CustomizableUI.AREA_NAVBAR]; + // Place the button as the first item to the left of the hamburger menu + if ( + navbarPlacements && + !navbarPlacements.includes("reset-pbm-toolbar-button") + ) { + navbarPlacements.push("reset-pbm-toolbar-button"); + } + } + }, + + _updateForNewProtonVersion() { + const VERSION = 3; + let currentVersion = Services.prefs.getIntPref( + kPrefProtonToolbarVersion, + 0 + ); + if (currentVersion >= VERSION) { + return; + } + + let placements = gSavedState?.placements?.[CustomizableUI.AREA_NAVBAR]; + + if (!placements) { + // The profile was created with this version, so no need to migrate. + Services.prefs.setIntPref(kPrefProtonToolbarVersion, VERSION); + return; + } + + // Remove the home button if it hasn't been used and is set to about:home + if (currentVersion < 1) { + let homePage = lazy.HomePage.get(); + if ( + placements.includes("home-button") && + !Services.prefs.getBoolPref(kPrefHomeButtonUsed) && + (homePage == "about:home" || homePage == "about:blank") && + Services.policies.isAllowed("removeHomeButtonByDefault") + ) { + placements.splice(placements.indexOf("home-button"), 1); + } + } + + // Remove the library button if it hasn't been used + if (currentVersion < 2) { + if ( + placements.includes("library-button") && + !Services.prefs.getBoolPref(kPrefLibraryButtonUsed) + ) { + placements.splice(placements.indexOf("library-button"), 1); + } + } + + // Remove the library button if it hasn't been used + if (currentVersion < 3) { + if ( + placements.includes("sidebar-button") && + !Services.prefs.getBoolPref(kPrefSidebarButtonUsed) + ) { + placements.splice(placements.indexOf("sidebar-button"), 1); + } + } + + Services.prefs.setIntPref(kPrefProtonToolbarVersion, VERSION); + }, + + /** + * _markObsoleteBuiltinButtonsSeen + * when upgrading, ensure obsoleted buttons are in seen state. + */ + _markObsoleteBuiltinButtonsSeen() { + if (!gSavedState) { + return; + } + let currentVersion = gSavedState.currentVersion; + if (currentVersion >= kVersion) { + return; + } + // we're upgrading, update state if necessary + for (let id in ObsoleteBuiltinButtons) { + let version = ObsoleteBuiltinButtons[id]; + if (version == kVersion) { + gSeenWidgets.add(id); + gDirty = true; + } + } + }, + + _placeNewDefaultWidgetsInArea(aArea) { + let futurePlacedWidgets = gFuturePlacements.get(aArea); + let savedPlacements = + gSavedState && gSavedState.placements && gSavedState.placements[aArea]; + let defaultPlacements = gAreas.get(aArea).get("defaultPlacements"); + if ( + !savedPlacements || + !savedPlacements.length || + !futurePlacedWidgets || + !defaultPlacements || + !defaultPlacements.length + ) { + return; + } + let defaultWidgetIndex = -1; + + for (let widgetId of futurePlacedWidgets) { + let widget = gPalette.get(widgetId); + if ( + !widget || + widget.source !== CustomizableUI.SOURCE_BUILTIN || + !widget.defaultArea || + !widget._introducedInVersion || + savedPlacements.includes(widget.id) + ) { + continue; + } + defaultWidgetIndex = defaultPlacements.indexOf(widget.id); + if (defaultWidgetIndex === -1) { + continue; + } + // Now we know that this widget should be here by default, was newly introduced, + // and we have a saved state to insert into, and a default state to work off of. + // Try introducing after widgets that come before it in the default placements: + for (let i = defaultWidgetIndex; i >= 0; i--) { + // Special case: if the defaults list this widget as coming first, insert at the beginning: + if (i === 0 && i === defaultWidgetIndex) { + savedPlacements.splice(0, 0, widget.id); + // Before you ask, yes, deleting things inside a let x of y loop where y is a Set is + // safe, and we won't skip any items. + futurePlacedWidgets.delete(widget.id); + gDirty = true; + break; + } + // Otherwise, if we're somewhere other than the beginning, check if the previous + // widget is in the saved placements. + if (i) { + let previousWidget = defaultPlacements[i - 1]; + let previousWidgetIndex = savedPlacements.indexOf(previousWidget); + if (previousWidgetIndex != -1) { + savedPlacements.splice(previousWidgetIndex + 1, 0, widget.id); + futurePlacedWidgets.delete(widget.id); + gDirty = true; + break; + } + } + } + // The loop above either inserts the item or doesn't - either way, we can get away + // with doing nothing else now; if the item remains in gFuturePlacements, we'll + // add it at the end in restoreStateForArea. + } + this.saveState(); + }, + + getCustomizationTarget(aElement) { + if (!aElement) { + return null; + } + + if ( + !aElement._customizationTarget && + aElement.hasAttribute("customizable") + ) { + let id = aElement.getAttribute("customizationtarget"); + if (id) { + aElement._customizationTarget = + aElement.ownerDocument.getElementById(id); + } + + if (!aElement._customizationTarget) { + aElement._customizationTarget = aElement; + } + } + + return aElement._customizationTarget; + }, + + wrapWidget(aWidgetId) { + if (gGroupWrapperCache.has(aWidgetId)) { + return gGroupWrapperCache.get(aWidgetId); + } + + let provider = this.getWidgetProvider(aWidgetId); + if (!provider) { + return null; + } + + if (provider == CustomizableUI.PROVIDER_API) { + let widget = gPalette.get(aWidgetId); + if (!widget.wrapper) { + widget.wrapper = new WidgetGroupWrapper(widget); + gGroupWrapperCache.set(aWidgetId, widget.wrapper); + } + return widget.wrapper; + } + + // PROVIDER_SPECIAL gets treated the same as PROVIDER_XUL. + // XXXgijs: this causes bugs in code that depends on widgetWrapper.provider + // giving an accurate answer... filed as bug 1379821 + let wrapper = new XULWidgetGroupWrapper(aWidgetId); + gGroupWrapperCache.set(aWidgetId, wrapper); + return wrapper; + }, + + registerArea(aName, aProperties, aInternalCaller) { + if (typeof aName != "string" || !/^[a-z0-9-_]{1,}$/i.test(aName)) { + throw new Error("Invalid area name"); + } + + let areaIsKnown = gAreas.has(aName); + let props = areaIsKnown ? gAreas.get(aName) : new Map(); + const kImmutableProperties = new Set(["type", "overflowable"]); + for (let key in aProperties) { + if ( + areaIsKnown && + kImmutableProperties.has(key) && + props.get(key) != aProperties[key] + ) { + throw new Error("An area cannot change the property for '" + key + "'"); + } + props.set(key, aProperties[key]); + } + // Default to a toolbar: + if (!props.has("type")) { + props.set("type", CustomizableUI.TYPE_TOOLBAR); + } + if (props.get("type") == CustomizableUI.TYPE_TOOLBAR) { + // Check aProperties instead of props because this check is only interested + // in the passed arguments, not the state of a potentially pre-existing area. + if (!aInternalCaller && aProperties.defaultCollapsed) { + throw new Error( + "defaultCollapsed is only allowed for default toolbars." + ); + } + if (!props.has("defaultCollapsed")) { + props.set("defaultCollapsed", true); + } + } else if (props.has("defaultCollapsed")) { + throw new Error("defaultCollapsed only applies for TYPE_TOOLBAR areas."); + } + // Sanity check type: + let allTypes = [CustomizableUI.TYPE_TOOLBAR, CustomizableUI.TYPE_PANEL]; + if (!allTypes.includes(props.get("type"))) { + throw new Error("Invalid area type " + props.get("type")); + } + + // And to no placements: + if (!props.has("defaultPlacements")) { + props.set("defaultPlacements", []); + } + // Sanity check default placements array: + if (!Array.isArray(props.get("defaultPlacements"))) { + throw new Error("Should provide an array of default placements"); + } + + if (!areaIsKnown) { + gAreas.set(aName, props); + + // Reconcile new default widgets. Have to do this before we start restoring things. + this._placeNewDefaultWidgetsInArea(aName); + + if ( + props.get("type") == CustomizableUI.TYPE_TOOLBAR && + !gPlacements.has(aName) + ) { + // Guarantee this area exists in gFuturePlacements, to avoid checking it in + // various places elsewhere. + if (!gFuturePlacements.has(aName)) { + gFuturePlacements.set(aName, new Set()); + } + } else { + this.restoreStateForArea(aName); + } + + // If we have pending build area nodes, register all of them + if (gPendingBuildAreas.has(aName)) { + let pendingNodes = gPendingBuildAreas.get(aName); + for (let pendingNode of pendingNodes) { + this.registerToolbarNode(pendingNode); + } + gPendingBuildAreas.delete(aName); + } + } + }, + + unregisterArea(aName, aDestroyPlacements) { + if (typeof aName != "string" || !/^[a-z0-9-_]{1,}$/i.test(aName)) { + throw new Error("Invalid area name"); + } + if (!gAreas.has(aName) && !gPlacements.has(aName)) { + throw new Error("Area not registered"); + } + + // Move all the widgets out + this.beginBatchUpdate(); + try { + let placements = gPlacements.get(aName); + if (placements) { + // Need to clone this array so removeWidgetFromArea doesn't modify it + placements = [...placements]; + placements.forEach(this.removeWidgetFromArea, this); + } + + // Delete all remaining traces. + gAreas.delete(aName); + // Only destroy placements when necessary: + if (aDestroyPlacements) { + gPlacements.delete(aName); + } else { + // Otherwise we need to re-set them, as removeFromArea will have emptied + // them out: + gPlacements.set(aName, placements); + } + gFuturePlacements.delete(aName); + let existingAreaNodes = gBuildAreas.get(aName); + if (existingAreaNodes) { + for (let areaNode of existingAreaNodes) { + this.notifyListeners( + "onAreaNodeUnregistered", + aName, + this.getCustomizationTarget(areaNode), + CustomizableUI.REASON_AREA_UNREGISTERED + ); + } + } + gBuildAreas.delete(aName); + } finally { + this.endBatchUpdate(true); + } + }, + + registerToolbarNode(aToolbar) { + let area = aToolbar.id; + if (gBuildAreas.has(area) && gBuildAreas.get(area).has(aToolbar)) { + return; + } + let areaProperties = gAreas.get(area); + + // If this area is not registered, try to do it automatically: + if (!areaProperties) { + if (!gPendingBuildAreas.has(area)) { + gPendingBuildAreas.set(area, []); + } + gPendingBuildAreas.get(area).push(aToolbar); + return; + } + + this.beginBatchUpdate(); + try { + let placements = gPlacements.get(area); + if ( + !placements && + areaProperties.get("type") == CustomizableUI.TYPE_TOOLBAR + ) { + this.restoreStateForArea(area); + placements = gPlacements.get(area); + } + + // For toolbars that need it, mark as dirty. + let defaultPlacements = areaProperties.get("defaultPlacements"); + if ( + !this._builtinToolbars.has(area) || + placements.length != defaultPlacements.length || + !placements.every((id, i) => id == defaultPlacements[i]) + ) { + gDirtyAreaCache.add(area); + } + + if (areaProperties.get("overflowable")) { + aToolbar.overflowable = new OverflowableToolbar(aToolbar); + } + + this.registerBuildArea(area, aToolbar); + + // We only build the toolbar if it's been marked as "dirty". Dirty means + // one of the following things: + // 1) Items have been added, moved or removed from this toolbar before. + // 2) The number of children of the toolbar does not match the length of + // the placements array for that area. + // + // This notion of being "dirty" is stored in a cache which is persisted + // in the saved state. + // + // Secondly, if the list of placements contains an API-provided widget, + // we need to call `buildArea` or it won't be built and put in the toolbar. + if ( + gDirtyAreaCache.has(area) || + placements.some(id => gPalette.has(id)) + ) { + this.buildArea(area, placements, aToolbar); + } else { + // We must have a builtin toolbar that's in the default state. We need + // to only make sure that all the special nodes are correct. + let specials = placements.filter(p => this.isSpecialWidget(p)); + if (specials.length) { + this.updateSpecialsForBuiltinToolbar(aToolbar, specials); + } + } + this.notifyListeners( + "onAreaNodeRegistered", + area, + this.getCustomizationTarget(aToolbar) + ); + } finally { + this.endBatchUpdate(); + } + }, + + updateSpecialsForBuiltinToolbar(aToolbar, aSpecialIDs) { + // Nodes are going to be in the correct order, so we can do this straightforwardly: + let { children } = this.getCustomizationTarget(aToolbar); + for (let kid of children) { + if ( + this.matchingSpecials(aSpecialIDs[0], kid) && + kid.getAttribute("skipintoolbarset") != "true" + ) { + kid.id = aSpecialIDs.shift(); + } + if (!aSpecialIDs.length) { + return; + } + } + }, + + buildArea(aArea, aPlacements, aAreaNode) { + let document = aAreaNode.ownerDocument; + let window = document.defaultView; + let inPrivateWindow = lazy.PrivateBrowsingUtils.isWindowPrivate(window); + let container = this.getCustomizationTarget(aAreaNode); + let areaIsPanel = + gAreas.get(aArea).get("type") == CustomizableUI.TYPE_PANEL; + + if (!container) { + throw new Error( + "Expected area " + aArea + " to have a customizationTarget attribute." + ); + } + + // Restore nav-bar visibility since it may have been hidden + // through a migration path (bug 938980) or an add-on. + if (aArea == CustomizableUI.AREA_NAVBAR) { + aAreaNode.collapsed = false; + } + + this.beginBatchUpdate(); + + try { + let currentNode = container.firstElementChild; + let placementsToRemove = new Set(); + for (let id of aPlacements) { + while ( + currentNode && + currentNode.getAttribute("skipintoolbarset") == "true" + ) { + currentNode = currentNode.nextElementSibling; + } + + // Fix ids for specials and continue, for correctly placed specials. + if ( + currentNode && + (!currentNode.id || CustomizableUI.isSpecialWidget(currentNode)) && + this.matchingSpecials(id, currentNode) + ) { + currentNode.id = id; + } + if (currentNode && currentNode.id == id) { + currentNode = currentNode.nextElementSibling; + continue; + } + + if (this.isSpecialWidget(id) && areaIsPanel) { + placementsToRemove.add(id); + continue; + } + + let [provider, node] = this.getWidgetNode(id, window); + if (!node) { + lazy.log.debug("Unknown widget: " + id); + continue; + } + + let widget = null; + // If the placements have items in them which are (now) no longer removable, + // we shouldn't be moving them: + if (provider == CustomizableUI.PROVIDER_API) { + widget = gPalette.get(id); + if (!widget.removable && aArea != widget.defaultArea) { + placementsToRemove.add(id); + continue; + } + } else if ( + provider == CustomizableUI.PROVIDER_XUL && + node.parentNode != container && + !this.isWidgetRemovable(node) + ) { + placementsToRemove.add(id); + continue; + } // Special widgets are always removable, so no need to check them + + if (inPrivateWindow && widget && !widget.showInPrivateBrowsing) { + continue; + } + + this.ensureButtonContextMenu(node, aAreaNode); + + // This needs updating in case we're resetting / undoing a reset. + if (widget) { + widget.currentArea = aArea; + } + this.insertWidgetBefore(node, currentNode, container, aArea); + if (gResetting) { + this.notifyListeners("onWidgetReset", node, container); + } else if (gUndoResetting) { + this.notifyListeners("onWidgetUndoMove", node, container); + } + } + + if (currentNode) { + let palette = window.gNavToolbox ? window.gNavToolbox.palette : null; + let limit = currentNode.previousElementSibling; + let node = container.lastElementChild; + while (node && node != limit) { + let previousSibling = node.previousElementSibling; + // Nodes opt-in to removability. If they're removable, and we haven't + // seen them in the placements array, then we toss them into the palette + // if one exists. If no palette exists, we just remove the node. If the + // node is not removable, we leave it where it is. However, we can only + // safely touch elements that have an ID - both because we depend on + // IDs (or are specials), and because such elements are not intended to + // be widgets (eg, titlebar-spacer elements). + if ( + (node.id || this.isSpecialWidget(node)) && + node.getAttribute("skipintoolbarset") != "true" + ) { + if (this.isWidgetRemovable(node)) { + if (node.id && (gResetting || gUndoResetting)) { + let widget = gPalette.get(node.id); + if (widget) { + widget.currentArea = null; + } + } + this.notifyDOMChange(node, null, container, true, () => { + if (palette && !this.isSpecialWidget(node.id)) { + palette.appendChild(node); + this.removeLocationAttributes(node); + } else { + container.removeChild(node); + } + }); + } else { + node.setAttribute("removable", false); + lazy.log.debug( + "Adding non-removable widget to placements of " + + aArea + + ": " + + node.id + ); + gPlacements.get(aArea).push(node.id); + gDirty = true; + } + } + node = previousSibling; + } + } + + // If there are placements in here which aren't removable from their original area, + // we remove them from this area's placement array. They will (have) be(en) added + // to their original area's placements array in the block above this one. + if (placementsToRemove.size) { + let placementAry = gPlacements.get(aArea); + for (let id of placementsToRemove) { + let index = placementAry.indexOf(id); + placementAry.splice(index, 1); + } + } + + if (gResetting) { + this.notifyListeners("onAreaReset", aArea, container); + } + } finally { + this.endBatchUpdate(); + } + }, + + addPanelCloseListeners(aPanel) { + Services.els.addSystemEventListener(aPanel, "click", this, false); + Services.els.addSystemEventListener(aPanel, "keypress", this, false); + let win = aPanel.ownerGlobal; + if (!gPanelsForWindow.has(win)) { + gPanelsForWindow.set(win, new Set()); + } + gPanelsForWindow.get(win).add(this._getPanelForNode(aPanel)); + }, + + removePanelCloseListeners(aPanel) { + Services.els.removeSystemEventListener(aPanel, "click", this, false); + Services.els.removeSystemEventListener(aPanel, "keypress", this, false); + let win = aPanel.ownerGlobal; + let panels = gPanelsForWindow.get(win); + if (panels) { + panels.delete(this._getPanelForNode(aPanel)); + } + }, + + ensureButtonContextMenu(aNode, aAreaNode, forcePanel) { + const kPanelItemContextMenu = "customizationPanelItemContextMenu"; + + let currentContextMenu = + aNode.getAttribute("context") || aNode.getAttribute("contextmenu"); + let contextMenuForPlace; + + if ( + CustomizableUI.isWebExtensionWidget(aNode.id) && + (aAreaNode?.id == CustomizableUI.AREA_ADDONS || + aNode.getAttribute("overflowedItem") == "true") + ) { + contextMenuForPlace = null; + } else { + contextMenuForPlace = + forcePanel || "panel" == CustomizableUI.getPlaceForItem(aAreaNode) + ? kPanelItemContextMenu + : null; + } + if (contextMenuForPlace && !currentContextMenu) { + aNode.setAttribute("context", contextMenuForPlace); + } else if ( + currentContextMenu == kPanelItemContextMenu && + contextMenuForPlace != kPanelItemContextMenu + ) { + aNode.removeAttribute("context"); + aNode.removeAttribute("contextmenu"); + } + }, + + getWidgetProvider(aWidgetId) { + if (this.isSpecialWidget(aWidgetId)) { + return CustomizableUI.PROVIDER_SPECIAL; + } + if (gPalette.has(aWidgetId)) { + return CustomizableUI.PROVIDER_API; + } + // If this was an API widget that was destroyed, return null: + if (gSeenWidgets.has(aWidgetId)) { + return null; + } + + // We fall back to the XUL provider, but we don't know for sure (at this + // point) whether it exists there either. So the API is technically lying. + // Ideally, it would be able to return an error value (or throw an + // exception) if it really didn't exist. Our code calling this function + // handles that fine, but this is a public API. + return CustomizableUI.PROVIDER_XUL; + }, + + getWidgetNode(aWidgetId, aWindow) { + let document = aWindow.document; + + if (this.isSpecialWidget(aWidgetId)) { + let widgetNode = + document.getElementById(aWidgetId) || + this.createSpecialWidget(aWidgetId, document); + return [CustomizableUI.PROVIDER_SPECIAL, widgetNode]; + } + + let widget = gPalette.get(aWidgetId); + if (widget) { + // If we have an instance of this widget already, just use that. + if (widget.instances.has(document)) { + lazy.log.debug( + "An instance of widget " + + aWidgetId + + " already exists in this " + + "document. Reusing." + ); + return [CustomizableUI.PROVIDER_API, widget.instances.get(document)]; + } + + return [CustomizableUI.PROVIDER_API, this.buildWidget(document, widget)]; + } + + lazy.log.debug("Searching for " + aWidgetId + " in toolbox."); + let node = this.findWidgetInWindow(aWidgetId, aWindow); + if (node) { + return [CustomizableUI.PROVIDER_XUL, node]; + } + + lazy.log.debug("No node for " + aWidgetId + " found."); + return [null, null]; + }, + + registerPanelNode(aNode, aArea) { + if (gBuildAreas.has(aArea) && gBuildAreas.get(aArea).has(aNode)) { + return; + } + + aNode._customizationTarget = aNode; + this.addPanelCloseListeners(this._getPanelForNode(aNode)); + + let placements = gPlacements.get(aArea); + this.buildArea(aArea, placements, aNode); + this.notifyListeners("onAreaNodeRegistered", aArea, aNode); + + for (let child of aNode.children) { + if (child.localName != "toolbarbutton") { + if (child.localName == "toolbaritem") { + this.ensureButtonContextMenu(child, aNode, true); + } + continue; + } + this.ensureButtonContextMenu(child, aNode, true); + } + + this.registerBuildArea(aArea, aNode); + }, + + onWidgetAdded(aWidgetId, aArea, aPosition) { + this.insertNode(aWidgetId, aArea, aPosition, true); + + if (!gResetting) { + this._clearPreviousUIState(); + } + }, + + onWidgetRemoved(aWidgetId, aArea) { + let areaNodes = gBuildAreas.get(aArea); + if (!areaNodes) { + return; + } + + let area = gAreas.get(aArea); + let isToolbar = area.get("type") == CustomizableUI.TYPE_TOOLBAR; + let isOverflowable = isToolbar && area.get("overflowable"); + let showInPrivateBrowsing = gPalette.has(aWidgetId) + ? gPalette.get(aWidgetId).showInPrivateBrowsing + : true; + + for (let areaNode of areaNodes) { + let window = areaNode.ownerGlobal; + if ( + !showInPrivateBrowsing && + lazy.PrivateBrowsingUtils.isWindowPrivate(window) + ) { + continue; + } + + let container = this.getCustomizationTarget(areaNode); + let widgetNode = window.document.getElementById(aWidgetId); + if (widgetNode && isOverflowable) { + container = areaNode.overflowable.getContainerFor(widgetNode); + } + + if (!widgetNode || !container.contains(widgetNode)) { + lazy.log.info( + "Widget " + aWidgetId + " not found, unable to remove from " + aArea + ); + continue; + } + + this.notifyDOMChange(widgetNode, null, container, true, () => { + // We remove location attributes here to make sure they're gone too when a + // widget is removed from a toolbar to the palette. See bug 930950. + this.removeLocationAttributes(widgetNode); + // We also need to remove the panel context menu if it's there: + this.ensureButtonContextMenu(widgetNode); + if (gPalette.has(aWidgetId) || this.isSpecialWidget(aWidgetId)) { + container.removeChild(widgetNode); + } else { + window.gNavToolbox.palette.appendChild(widgetNode); + } + }); + + let windowCache = gSingleWrapperCache.get(window); + if (windowCache) { + windowCache.delete(aWidgetId); + } + } + if (!gResetting) { + this._clearPreviousUIState(); + } + }, + + onWidgetMoved(aWidgetId, aArea, aOldPosition, aNewPosition) { + this.insertNode(aWidgetId, aArea, aNewPosition); + if (!gResetting) { + this._clearPreviousUIState(); + } + }, + + onCustomizeEnd(aWindow) { + this._clearPreviousUIState(); + }, + + registerBuildArea(aArea, aNode) { + // We ensure that the window is registered to have its customization data + // cleaned up when unloading. + let window = aNode.ownerGlobal; + if (window.closed) { + return; + } + this.registerBuildWindow(window); + + // Also register this build area's toolbox. + if (window.gNavToolbox) { + gBuildWindows.get(window).add(window.gNavToolbox); + } + + if (!gBuildAreas.has(aArea)) { + gBuildAreas.set(aArea, new Set()); + } + + gBuildAreas.get(aArea).add(aNode); + + // Give a class to all customize targets to be used for styling in Customize Mode + let customizableNode = this.getCustomizeTargetForArea(aArea, window); + customizableNode.classList.add("customization-target"); + }, + + registerBuildWindow(aWindow) { + if (!gBuildWindows.has(aWindow)) { + gBuildWindows.set(aWindow, new Set()); + + aWindow.addEventListener("unload", this); + aWindow.addEventListener("command", this, true); + + this.notifyListeners("onWindowOpened", aWindow); + } + }, + + unregisterBuildWindow(aWindow) { + aWindow.removeEventListener("unload", this); + aWindow.removeEventListener("command", this, true); + gPanelsForWindow.delete(aWindow); + gBuildWindows.delete(aWindow); + gSingleWrapperCache.delete(aWindow); + let document = aWindow.document; + + for (let [areaId, areaNodes] of gBuildAreas) { + let areaProperties = gAreas.get(areaId); + for (let node of areaNodes) { + if (node.ownerDocument == document) { + this.notifyListeners( + "onAreaNodeUnregistered", + areaId, + this.getCustomizationTarget(node), + CustomizableUI.REASON_WINDOW_CLOSED + ); + if (areaProperties.get("overflowable")) { + node.overflowable.uninit(); + node.overflowable = null; + } + areaNodes.delete(node); + } + } + } + + for (let [, widget] of gPalette) { + widget.instances.delete(document); + this.notifyListeners("onWidgetInstanceRemoved", widget.id, document); + } + + for (let [, pendingNodes] of gPendingBuildAreas) { + for (let i = pendingNodes.length - 1; i >= 0; i--) { + if (pendingNodes[i].ownerDocument == document) { + pendingNodes.splice(i, 1); + } + } + } + + this.notifyListeners("onWindowClosed", aWindow); + }, + + setLocationAttributes(aNode, aArea) { + let props = gAreas.get(aArea); + if (!props) { + throw new Error( + "Expected area " + + aArea + + " to have a properties Map " + + "associated with it." + ); + } + + aNode.setAttribute("cui-areatype", props.get("type") || ""); + let anchor = props.get("anchor"); + if (anchor) { + aNode.setAttribute("cui-anchorid", anchor); + } else { + aNode.removeAttribute("cui-anchorid"); + } + }, + + removeLocationAttributes(aNode) { + aNode.removeAttribute("cui-areatype"); + aNode.removeAttribute("cui-anchorid"); + }, + + insertNode(aWidgetId, aArea, aPosition, isNew) { + let areaNodes = gBuildAreas.get(aArea); + if (!areaNodes) { + return; + } + + let placements = gPlacements.get(aArea); + if (!placements) { + lazy.log.error( + "Could not find any placements for " + aArea + " when moving a widget." + ); + return; + } + + // Go through each of the nodes associated with this area and move the + // widget to the requested location. + for (let areaNode of areaNodes) { + this.insertNodeInWindow(aWidgetId, areaNode, isNew); + } + }, + + insertNodeInWindow(aWidgetId, aAreaNode, isNew) { + let window = aAreaNode.ownerGlobal; + let showInPrivateBrowsing = gPalette.has(aWidgetId) + ? gPalette.get(aWidgetId).showInPrivateBrowsing + : true; + + if ( + !showInPrivateBrowsing && + lazy.PrivateBrowsingUtils.isWindowPrivate(window) + ) { + return; + } + + let [, widgetNode] = this.getWidgetNode(aWidgetId, window); + if (!widgetNode) { + lazy.log.error("Widget '" + aWidgetId + "' not found, unable to move"); + return; + } + + let areaId = aAreaNode.id; + if (isNew) { + this.ensureButtonContextMenu(widgetNode, aAreaNode); + } + + let [insertionContainer, nextNode] = this.findInsertionPoints( + widgetNode, + aAreaNode + ); + this.insertWidgetBefore(widgetNode, nextNode, insertionContainer, areaId); + }, + + findInsertionPoints(aNode, aAreaNode) { + let areaId = aAreaNode.id; + let props = gAreas.get(areaId); + + // For overflowable toolbars, rely on them (because the work is more complicated): + if ( + props.get("type") == CustomizableUI.TYPE_TOOLBAR && + props.get("overflowable") + ) { + return aAreaNode.overflowable.findOverflowedInsertionPoints(aNode); + } + + let container = this.getCustomizationTarget(aAreaNode); + let placements = gPlacements.get(areaId); + let nodeIndex = placements.indexOf(aNode.id); + + while (++nodeIndex < placements.length) { + let nextNodeId = placements[nodeIndex]; + // We use aAreaNode here, because if aNode is in a template, its + // `ownerDocument` is *not* going to be the browser.xhtml document, + // so we cannot rely on it. + let nextNode = aAreaNode.ownerDocument.getElementById(nextNodeId); + // If the next placed widget exists, and is a direct child of the + // container, or wrapped in a customize mode wrapper (toolbarpaletteitem) + // inside the container, insert beside it. + // We have to check the parent to avoid errors when the placement ids + // are for nodes that are no longer customizable. + if ( + nextNode && + (nextNode.parentNode == container || + (nextNode.parentNode.localName == "toolbarpaletteitem" && + nextNode.parentNode.parentNode == container)) + ) { + return [container, nextNode]; + } + } + + return [container, null]; + }, + + insertWidgetBefore(aNode, aNextNode, aContainer, aArea) { + this.notifyDOMChange(aNode, aNextNode, aContainer, false, () => { + this.setLocationAttributes(aNode, aArea); + aContainer.insertBefore(aNode, aNextNode); + }); + }, + + notifyDOMChange(aNode, aNextNode, aContainer, aIsRemove, aCallback) { + this.notifyListeners( + "onWidgetBeforeDOMChange", + aNode, + aNextNode, + aContainer, + aIsRemove + ); + aCallback(); + this.notifyListeners( + "onWidgetAfterDOMChange", + aNode, + aNextNode, + aContainer, + aIsRemove + ); + }, + + handleEvent(aEvent) { + switch (aEvent.type) { + case "command": + if (!this._originalEventInPanel(aEvent)) { + break; + } + aEvent = aEvent.sourceEvent; + // Fall through + case "click": + case "keypress": + this.maybeAutoHidePanel(aEvent); + break; + case "unload": + this.unregisterBuildWindow(aEvent.currentTarget); + break; + } + }, + + _originalEventInPanel(aEvent) { + let e = aEvent.sourceEvent; + if (!e) { + return false; + } + let node = this._getPanelForNode(e.target); + if (!node) { + return false; + } + let win = e.view; + let panels = gPanelsForWindow.get(win); + return !!panels && panels.has(node); + }, + + _getSpecialIdForNode(aNode) { + if (typeof aNode == "object" && aNode.localName) { + if (aNode.id) { + return aNode.id; + } + if (aNode.localName.startsWith("toolbar")) { + return aNode.localName.substring(7); + } + return ""; + } + return aNode; + }, + + isSpecialWidget(aId) { + aId = this._getSpecialIdForNode(aId); + return ( + aId.startsWith(kSpecialWidgetPfx) || + aId.startsWith("separator") || + aId.startsWith("spring") || + aId.startsWith("spacer") + ); + }, + + matchingSpecials(aId1, aId2) { + aId1 = this._getSpecialIdForNode(aId1); + aId2 = this._getSpecialIdForNode(aId2); + + return ( + this.isSpecialWidget(aId1) && + this.isSpecialWidget(aId2) && + aId1.match(/spring|spacer|separator/)[0] == + aId2.match(/spring|spacer|separator/)[0] + ); + }, + + ensureSpecialWidgetId(aId) { + let nodeType = aId.match(/spring|spacer|separator/)[0]; + // If the ID we were passed isn't a generated one, generate one now: + if (nodeType == aId) { + // Ids are differentiated through a unique count suffix. + return kSpecialWidgetPfx + aId + ++gNewElementCount; + } + return aId; + }, + + createSpecialWidget(aId, aDocument) { + let nodeName = "toolbar" + aId.match(/spring|spacer|separator/)[0]; + let node = aDocument.createXULElement(nodeName); + node.className = "chromeclass-toolbar-additional"; + node.id = this.ensureSpecialWidgetId(aId); + return node; + }, + + /* Find a XUL-provided widget in a window. Don't try to use this + * for an API-provided widget or a special widget. + */ + findWidgetInWindow(aId, aWindow) { + if (!gBuildWindows.has(aWindow)) { + throw new Error("Build window not registered"); + } + + if (!aId) { + lazy.log.error("findWidgetInWindow was passed an empty string."); + return null; + } + + let document = aWindow.document; + + // look for a node with the same id, as the node may be + // in a different toolbar. + let node = document.getElementById(aId); + if (node) { + let parent = node.parentNode; + while ( + parent && + !( + this.getCustomizationTarget(parent) || + parent == aWindow.gNavToolbox.palette + ) + ) { + parent = parent.parentNode; + } + + if (parent) { + let nodeInArea = + node.parentNode.localName == "toolbarpaletteitem" + ? node.parentNode + : node; + // Check if we're in a customization target, or in the palette: + if ( + (this.getCustomizationTarget(parent) == nodeInArea.parentNode && + gBuildWindows.get(aWindow).has(aWindow.gNavToolbox)) || + aWindow.gNavToolbox.palette == nodeInArea.parentNode + ) { + // Normalize the removable attribute. For backwards compat, if + // the widget is not located in a toolbox palette then absence + // of the "removable" attribute means it is not removable. + if (!node.hasAttribute("removable")) { + // If we first see this in customization mode, it may be in the + // customization palette instead of the toolbox palette. + node.setAttribute( + "removable", + !this.getCustomizationTarget(parent) + ); + } + return node; + } + } + } + + let toolboxes = gBuildWindows.get(aWindow); + for (let toolbox of toolboxes) { + if (toolbox.palette) { + // Attempt to locate an element with a matching ID within + // the palette. + let element = toolbox.palette.getElementsByAttribute("id", aId)[0]; + if (element) { + // Normalize the removable attribute. For backwards compat, this + // is optional if the widget is located in the toolbox palette, + // and defaults to *true*, unlike if it was located elsewhere. + if (!element.hasAttribute("removable")) { + element.setAttribute("removable", true); + } + return element; + } + } + } + return null; + }, + + buildWidget(aDocument, aWidget) { + if (aDocument.documentURI != kExpectedWindowURL) { + throw new Error("buildWidget was called for a non-browser window!"); + } + if (typeof aWidget == "string") { + aWidget = gPalette.get(aWidget); + } + if (!aWidget) { + throw new Error("buildWidget was passed a non-widget to build."); + } + if ( + !aWidget.showInPrivateBrowsing && + lazy.PrivateBrowsingUtils.isWindowPrivate(aDocument.defaultView) + ) { + return null; + } + + lazy.log.debug("Building " + aWidget.id + " of type " + aWidget.type); + + let node; + let button; + if (aWidget.type == "custom") { + if (aWidget.onBuild) { + node = aWidget.onBuild(aDocument); + } + if ( + !node || + !aDocument.defaultView.XULElement.isInstance(node) || + (aWidget.viewId && !node.viewButton) + ) { + lazy.log.error( + "Custom widget with id " + + aWidget.id + + " does not return a valid node" + ); + } + // A custom widget can define a viewId for the panel and a viewButton + // property for the panel anchor. With that, it will be treated as a view + // type where necessary to hook up the view panel. + if (aWidget.viewId) { + button = node.viewButton; + } + } + // Button and view widget types, plus custom widgets that have a viewId and thus a button. + if (button || aWidget.type != "custom") { + if ( + aWidget.onBeforeCreated && + aWidget.onBeforeCreated(aDocument) === false + ) { + return null; + } + + if (!button) { + button = aDocument.createXULElement("toolbarbutton"); + node = button; + } + button.classList.add("toolbarbutton-1"); + button.setAttribute("delegatesanchor", "true"); + + let viewbutton = null; + if (aWidget.type == "button-and-view") { + button.setAttribute("id", aWidget.id + "-button"); + let dropmarker = aDocument.createXULElement("toolbarbutton"); + dropmarker.setAttribute("id", aWidget.id + "-dropmarker"); + dropmarker.setAttribute("delegatesanchor", "true"); + dropmarker.classList.add( + "toolbarbutton-1", + "toolbarbutton-combined-buttons-dropmarker" + ); + node = aDocument.createXULElement("toolbaritem"); + node.classList.add("toolbaritem-combined-buttons"); + node.append(button, dropmarker); + viewbutton = dropmarker; + } else if (aWidget.viewId) { + // Also set viewbutton for anything with a view + viewbutton = button; + } + + node.setAttribute("id", aWidget.id); + node.setAttribute("widget-id", aWidget.id); + node.setAttribute("widget-type", aWidget.type); + if (aWidget.disabled) { + node.setAttribute("disabled", true); + } + node.setAttribute("removable", aWidget.removable); + node.setAttribute("overflows", aWidget.overflows); + if (aWidget.tabSpecific) { + node.setAttribute("tabspecific", aWidget.tabSpecific); + } + if (aWidget.locationSpecific) { + node.setAttribute("locationspecific", aWidget.locationSpecific); + } + if (aWidget.keepBroadcastAttributesWhenCustomizing) { + node.setAttribute( + "keepbroadcastattributeswhencustomizing", + aWidget.keepBroadcastAttributesWhenCustomizing + ); + } + + let shortcut; + if (aWidget.shortcutId) { + let keyEl = aDocument.getElementById(aWidget.shortcutId); + if (keyEl) { + shortcut = lazy.ShortcutUtils.prettifyShortcut(keyEl); + } else { + lazy.log.error( + "Key element with id '" + + aWidget.shortcutId + + "' for widget '" + + aWidget.id + + "' not found!" + ); + } + } + + if (aWidget.l10nId) { + aDocument.l10n.setAttributes(node, aWidget.l10nId); + if (button != node) { + // This is probably a "button-and-view" widget, such as the Profiler + // button. In that case, "node" is the "toolbaritem" container, and + // "button" the main button (see above). + // In this case, the values on the "node" is used in the Customize + // view, as well as the tooltips over both buttons; the values on the + // "button" are used in the overflow menu. + aDocument.l10n.setAttributes(button, aWidget.l10nId); + } + + if (shortcut) { + node.setAttribute("data-l10n-args", JSON.stringify({ shortcut })); + if (button != node) { + // This is probably a "button-and-view" widget. + button.setAttribute("data-l10n-args", JSON.stringify({ shortcut })); + } + } + } else { + node.setAttribute("label", this.getLocalizedProperty(aWidget, "label")); + if (button != node) { + // This is probably a "button-and-view" widget. + button.setAttribute("label", node.getAttribute("label")); + } + + let tooltip = this.getLocalizedProperty( + aWidget, + "tooltiptext", + shortcut ? [shortcut] : [] + ); + if (tooltip) { + node.setAttribute("tooltiptext", tooltip); + if (button != node) { + // This is probably a "button-and-view" widget. + button.setAttribute("tooltiptext", tooltip); + } + } + } + + let commandHandler = this.handleWidgetCommand.bind(this, aWidget, node); + node.addEventListener("command", commandHandler); + let clickHandler = this.handleWidgetClick.bind(this, aWidget, node); + node.addEventListener("click", clickHandler); + + node.classList.add("chromeclass-toolbar-additional"); + + // If the widget has a view, register a keypress handler because opening + // a view with the keyboard has slightly different focus handling than + // opening a view with the mouse. (When opened with the keyboard, the + // first item in the view should be focused after opening.) + if (viewbutton) { + lazy.log.debug( + "Widget " + + aWidget.id + + " has a view. Auto-registering event handlers." + ); + + if (aWidget.source == CustomizableUI.SOURCE_BUILTIN) { + node.classList.add("subviewbutton-nav"); + } + } + + if (aWidget.onCreated) { + aWidget.onCreated(node); + } + } + + aWidget.instances.set(aDocument, node); + return node; + }, + + ensureSubviewListeners(viewNode) { + if (viewNode._addedEventListeners) { + return; + } + let viewId = viewNode.id; + let widget = [...gPalette.values()].find(w => w.viewId == viewId); + if (!widget) { + return; + } + for (let eventName of kSubviewEvents) { + let handler = "on" + eventName; + if (typeof widget[handler] == "function") { + viewNode.addEventListener(eventName, widget[handler]); + } + } + viewNode._addedEventListeners = true; + lazy.log.debug( + "Widget " + widget.id + " showing and hiding event handlers set." + ); + }, + + getLocalizedProperty(aWidget, aProp, aFormatArgs, aDef) { + const kReqStringProps = ["label"]; + + if (typeof aWidget == "string") { + aWidget = gPalette.get(aWidget); + } + if (!aWidget) { + throw new Error( + "getLocalizedProperty was passed a non-widget to work with." + ); + } + let def, name; + // Let widgets pass their own string identifiers or strings, so that + // we can use strings which aren't the default (in case string ids change) + // and so that non-builtin-widgets can also provide labels, tooltips, etc. + if (aWidget[aProp] != null) { + name = aWidget[aProp]; + // By using this as the default, if a widget provides a full string rather + // than a string ID for localization, we will fall back to that string + // and return that. + def = aDef || name; + } else { + name = aWidget.id + "." + aProp; + def = aDef || ""; + } + if (aWidget.localized === false) { + return def; + } + try { + if (Array.isArray(aFormatArgs) && aFormatArgs.length) { + return ( + lazy.gWidgetsBundle.formatStringFromName(name, aFormatArgs) || def + ); + } + return lazy.gWidgetsBundle.GetStringFromName(name) || def; + } catch (ex) { + // If an empty string was explicitly passed, treat it as an actual + // value rather than a missing property. + if (!def && (name != "" || kReqStringProps.includes(aProp))) { + lazy.log.error("Could not localize property '" + name + "'."); + } + } + return def; + }, + + addShortcut(aShortcutNode, aTargetNode = aShortcutNode) { + // Detect if we've already been here before. + if (aTargetNode.hasAttribute("shortcut")) { + return; + } + + // Use ownerGlobal.document to ensure we get the right doc even for + // elements in template tags. + let { document } = aShortcutNode.ownerGlobal; + let shortcutId = aShortcutNode.getAttribute("key"); + let shortcut; + if (shortcutId) { + shortcut = document.getElementById(shortcutId); + } else { + let commandId = aShortcutNode.getAttribute("command"); + if (commandId) { + shortcut = lazy.ShortcutUtils.findShortcut( + document.getElementById(commandId) + ); + } + } + if (!shortcut) { + return; + } + + aTargetNode.setAttribute( + "shortcut", + lazy.ShortcutUtils.prettifyShortcut(shortcut) + ); + }, + + doWidgetCommand(aWidget, aNode, aEvent) { + if (aWidget.onCommand) { + try { + aWidget.onCommand.call(null, aEvent); + } catch (e) { + lazy.log.error(e); + } + } else { + // XXXunf Need to think this through more, and formalize. + Services.obs.notifyObservers( + aNode, + "customizedui-widget-command", + aWidget.id + ); + } + }, + + showWidgetView(aWidget, aNode, aEvent) { + let ownerWindow = aNode.ownerGlobal; + let area = this.getPlacementOfWidget(aNode.id).area; + let areaType = CustomizableUI.getAreaType(area); + let anchor = aNode; + + if ( + aWidget.disallowSubView && + (areaType == CustomizableUI.TYPE_PANEL || + aNode.hasAttribute("overflowedItem")) + ) { + // Close the containing panel (e.g. overflow), PanelUI will reopen. + let wrapper = this.wrapWidget(aWidget.id).forWindow(ownerWindow); + if (wrapper?.anchor) { + this.hidePanelForNode(aNode); + anchor = wrapper.anchor; + } + } else if (areaType != CustomizableUI.TYPE_PANEL) { + let wrapper = this.wrapWidget(aWidget.id).forWindow(ownerWindow); + + let hasMultiView = !!aNode.closest("panelmultiview"); + if (!hasMultiView && wrapper?.anchor) { + this.hidePanelForNode(aNode); + anchor = wrapper.anchor; + } + } + ownerWindow.PanelUI.showSubView(aWidget.viewId, anchor, aEvent); + }, + + handleWidgetCommand(aWidget, aNode, aEvent) { + // Note that aEvent can be a keypress event for widgets of type "view". + lazy.log.debug("handleWidgetCommand"); + + let action; + if (aWidget.onBeforeCommand) { + try { + action = aWidget.onBeforeCommand.call(null, aEvent, aNode); + } catch (e) { + lazy.log.error(e); + } + } + + if (aWidget.type == "button" || action == "command") { + this.doWidgetCommand(aWidget, aNode, aEvent); + } else if (aWidget.type == "view" || action == "view") { + this.showWidgetView(aWidget, aNode, aEvent); + } else if (aWidget.type == "button-and-view") { + // Do the command if we're in the toolbar and the button was clicked. + // Otherwise, including when we have currently overflowed out of the + // toolbar, open the view. There is no way to trigger the command while + // the widget is in the panel, by design. + let button = aNode.firstElementChild; + let area = this.getPlacementOfWidget(aNode.id).area; + let areaType = CustomizableUI.getAreaType(area); + if ( + areaType == CustomizableUI.TYPE_TOOLBAR && + button.contains(aEvent.target) && + !aNode.hasAttribute("overflowedItem") + ) { + this.doWidgetCommand(aWidget, aNode, aEvent); + } else { + this.showWidgetView(aWidget, aNode, aEvent); + } + } + }, + + handleWidgetClick(aWidget, aNode, aEvent) { + lazy.log.debug("handleWidgetClick"); + if (aWidget.onClick) { + try { + aWidget.onClick.call(null, aEvent); + } catch (e) { + console.error(e); + } + } else { + // XXXunf Need to think this through more, and formalize. + Services.obs.notifyObservers( + aNode, + "customizedui-widget-click", + aWidget.id + ); + } + }, + + _getPanelForNode(aNode) { + return aNode.closest("panel"); + }, + + /* + * If people put things in the panel which need more than single-click interaction, + * we don't want to close it. Right now we check for text inputs and menu buttons. + * We also check for being outside of any toolbaritem/toolbarbutton, ie on a blank + * part of the menu, or on another menu (like a context menu inside the panel). + */ + _isOnInteractiveElement(aEvent) { + let panel = this._getPanelForNode(aEvent.currentTarget); + // This can happen in e.g. customize mode. If there's no panel, + // there's clearly nothing for us to close; pretend we're interactive. + if (!panel) { + return true; + } + + function getNextTarget(target) { + if (target.nodeType == target.DOCUMENT_NODE) { + if (!target.defaultView) { + // Err, we're done. + return null; + } + // Find containing browser or iframe element in the parent doc. + return target.defaultView.docShell.chromeEventHandler; + } + // Skip any parent shadow roots + return target.parentNode?.host?.parentNode || target.parentNode; + } + + // While keeping track of that, we go from the original target back up, + // to the panel if we have to. We bail as soon as we find an input, + // a toolbarbutton/item, or a menuItem. + for ( + let target = aEvent.originalTarget; + target && target != panel; + target = getNextTarget(target) + ) { + if (target.nodeType == target.DOCUMENT_NODE) { + // Skip out of iframes etc: + continue; + } + + // Break out of the loop immediately for disabled items, as we need to + // keep the menu open in that case. + if (target.getAttribute("disabled") == "true") { + return true; + } + + let tagName = target.localName; + if (tagName == "input" || tagName == "searchbar") { + return true; + } + if (tagName == "toolbaritem" || tagName == "toolbarbutton") { + // If we are in a type="menu" toolbarbutton, we'll now interact with + // the menu. + return target.getAttribute("type") == "menu"; + } + if (tagName == "menuitem") { + // If we're in a nested menu we don't need to close this panel. + return true; + } + } + + // We don't know what we interacted with, assume interactive. + return true; + }, + + hidePanelForNode(aNode) { + let panel = this._getPanelForNode(aNode); + if (panel) { + lazy.PanelMultiView.hidePopup(panel); + } + }, + + maybeAutoHidePanel(aEvent) { + let eventType = aEvent.type; + if (eventType == "keypress" && aEvent.keyCode != aEvent.DOM_VK_RETURN) { + return; + } + + if (eventType == "click" && aEvent.button != 0) { + return; + } + + // We don't check preventDefault - it makes sense that this was prevented, + // but we probably still want to close the panel. If consumers don't want + // this to happen, they should specify the closemenu attribute. + if (eventType != "command" && this._isOnInteractiveElement(aEvent)) { + return; + } + + // We can't use event.target because we might have passed an anonymous + // content boundary as well, and so target points to the outer element in + // that case. Unfortunately, this means we get anonymous child nodes instead + // of the real ones, so looking for the 'stoooop, don't close me' attributes + // is more involved. + let target = aEvent.originalTarget; + while (target.parentNode && target.localName != "panel") { + if ( + target.getAttribute("closemenu") == "none" || + target.getAttribute("widget-type") == "view" || + target.getAttribute("widget-type") == "button-and-view" || + target.hasAttribute("view-button-id") + ) { + return; + } + target = target.parentNode; + } + + // If we get here, we can actually hide the popup: + this.hidePanelForNode(aEvent.target); + }, + + getUnusedWidgets(aWindowPalette) { + let window = aWindowPalette.ownerGlobal; + let isWindowPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate(window); + // We use a Set because there can be overlap between the widgets in + // gPalette and the items in the palette, especially after the first + // customization, since programmatically generated widgets will remain + // in the toolbox palette. + let widgets = new Set(); + + // It's possible that some widgets have been defined programmatically and + // have not been overlayed into the palette. We can find those inside + // gPalette. + for (let [id, widget] of gPalette) { + if (!widget.currentArea) { + if (widget.showInPrivateBrowsing || !isWindowPrivate) { + widgets.add(id); + } + } + } + + lazy.log.debug("Iterating the actual nodes of the window palette"); + for (let node of aWindowPalette.children) { + lazy.log.debug("In palette children: " + node.id); + if (node.id && !this.getPlacementOfWidget(node.id)) { + widgets.add(node.id); + } + } + + return [...widgets]; + }, + + getPlacementOfWidget(aWidgetId, aOnlyRegistered, aDeadAreas) { + if (aOnlyRegistered && !this.widgetExists(aWidgetId)) { + return null; + } + + for (let [area, placements] of gPlacements) { + if (!gAreas.has(area) && !aDeadAreas) { + continue; + } + let index = placements.indexOf(aWidgetId); + if (index != -1) { + return { area, position: index }; + } + } + + return null; + }, + + widgetExists(aWidgetId) { + if (gPalette.has(aWidgetId) || this.isSpecialWidget(aWidgetId)) { + return true; + } + + // Destroyed API widgets are in gSeenWidgets, but not in gPalette: + // The Pocket button is a default API widget that acts like a custom widget. + // If it's not in gPalette, it doesn't exist. + if (gSeenWidgets.has(aWidgetId) || aWidgetId === "save-to-pocket-button") { + return false; + } + + // We're assuming XUL widgets always exist, as it's much harder to check, + // and checking would be much more error prone. + return true; + }, + + addWidgetToArea(aWidgetId, aArea, aPosition, aInitialAdd) { + if (aArea == CustomizableUI.AREA_NO_AREA) { + throw new Error( + "AREA_NO_AREA is only used as an argument for " + + "canWidgetMoveToArea. Use removeWidgetFromArea instead." + ); + } + if (!gAreas.has(aArea)) { + throw new Error("Unknown customization area: " + aArea); + } + + // Hack: don't want special widgets in the panel (need to check here as well + // as in canWidgetMoveToArea because the menu panel is lazy): + if ( + gAreas.get(aArea).get("type") == CustomizableUI.TYPE_PANEL && + this.isSpecialWidget(aWidgetId) + ) { + return; + } + + // If this is a lazy area that hasn't been restored yet, we can't yet modify + // it - would would at least like to add to it. So we keep track of it in + // gFuturePlacements, and use that to add it when restoring the area. We + // throw away aPosition though, as that can only be bogus if the area hasn't + // yet been restorted (caller can't possibly know where its putting the + // widget in relation to other widgets). + if (this.isAreaLazy(aArea)) { + gFuturePlacements.get(aArea).add(aWidgetId); + return; + } + + if (this.isSpecialWidget(aWidgetId)) { + aWidgetId = this.ensureSpecialWidgetId(aWidgetId); + } + + let oldPlacement = this.getPlacementOfWidget(aWidgetId, false, true); + if (oldPlacement && oldPlacement.area == aArea) { + this.moveWidgetWithinArea(aWidgetId, aPosition); + return; + } + + // Do nothing if the widget is not allowed to move to the target area. + if (!this.canWidgetMoveToArea(aWidgetId, aArea)) { + return; + } + + if (oldPlacement) { + this.removeWidgetFromArea(aWidgetId); + } + + if (!gPlacements.has(aArea)) { + gPlacements.set(aArea, [aWidgetId]); + aPosition = 0; + } else { + let placements = gPlacements.get(aArea); + if (typeof aPosition != "number") { + aPosition = placements.length; + } + if (aPosition < 0) { + aPosition = 0; + } + placements.splice(aPosition, 0, aWidgetId); + } + + let widget = gPalette.get(aWidgetId); + if (widget) { + widget.currentArea = aArea; + widget.currentPosition = aPosition; + } + + // We initially set placements with addWidgetToArea, so in that case + // we don't consider the area "dirtied". + if (!aInitialAdd) { + gDirtyAreaCache.add(aArea); + } + + gDirty = true; + this.saveState(); + + this.notifyListeners("onWidgetAdded", aWidgetId, aArea, aPosition); + }, + + removeWidgetFromArea(aWidgetId) { + let oldPlacement = this.getPlacementOfWidget(aWidgetId, false, true); + if (!oldPlacement) { + return; + } + + if (!this.isWidgetRemovable(aWidgetId)) { + return; + } + + let placements = gPlacements.get(oldPlacement.area); + let position = placements.indexOf(aWidgetId); + if (position != -1) { + placements.splice(position, 1); + } + + let widget = gPalette.get(aWidgetId); + if (widget) { + widget.currentArea = null; + widget.currentPosition = null; + } + + gDirty = true; + this.saveState(); + gDirtyAreaCache.add(oldPlacement.area); + + this.notifyListeners("onWidgetRemoved", aWidgetId, oldPlacement.area); + }, + + moveWidgetWithinArea(aWidgetId, aPosition) { + let oldPlacement = this.getPlacementOfWidget(aWidgetId); + if (!oldPlacement) { + return; + } + + let placements = gPlacements.get(oldPlacement.area); + if (typeof aPosition != "number") { + aPosition = placements.length; + } else if (aPosition < 0) { + aPosition = 0; + } else if (aPosition > placements.length) { + aPosition = placements.length; + } + + let widget = gPalette.get(aWidgetId); + if (widget) { + widget.currentPosition = aPosition; + widget.currentArea = oldPlacement.area; + } + + if (aPosition == oldPlacement.position) { + return; + } + + placements.splice(oldPlacement.position, 1); + // If we just removed the item from *before* where it is now added, + // we need to compensate the position offset for that: + if (oldPlacement.position < aPosition) { + aPosition--; + } + placements.splice(aPosition, 0, aWidgetId); + + gDirty = true; + gDirtyAreaCache.add(oldPlacement.area); + + this.saveState(); + + this.notifyListeners( + "onWidgetMoved", + aWidgetId, + oldPlacement.area, + oldPlacement.position, + aPosition + ); + }, + + // Note that this does not populate gPlacements, which is done lazily. + // The panel area is an exception here. + loadSavedState() { + let state = Services.prefs.getCharPref(kPrefCustomizationState, ""); + if (!state) { + lazy.log.debug("No saved state found"); + // Nothing has been customized, so silently fall back to the defaults. + return; + } + try { + gSavedState = JSON.parse(state); + if (typeof gSavedState != "object" || gSavedState === null) { + throw new Error("Invalid saved state"); + } + } catch (e) { + Services.prefs.clearUserPref(kPrefCustomizationState); + gSavedState = {}; + lazy.log.debug( + "Error loading saved UI customization state, falling back to defaults." + ); + } + + if (!("placements" in gSavedState)) { + gSavedState.placements = {}; + } + + if (!("currentVersion" in gSavedState)) { + gSavedState.currentVersion = 0; + } + + gSeenWidgets = new Set(gSavedState.seen || []); + gDirtyAreaCache = new Set(gSavedState.dirtyAreaCache || []); + gNewElementCount = gSavedState.newElementCount || 0; + }, + + restoreStateForArea(aArea) { + let placementsPreexisted = gPlacements.has(aArea); + + this.beginBatchUpdate(); + try { + gRestoring = true; + + let restored = false; + if (placementsPreexisted) { + lazy.log.debug("Restoring " + aArea + " from pre-existing placements"); + for (let [position, id] of gPlacements.get(aArea).entries()) { + this.moveWidgetWithinArea(id, position); + } + gDirty = false; + restored = true; + } else { + gPlacements.set(aArea, []); + } + + if (!restored && gSavedState && aArea in gSavedState.placements) { + lazy.log.debug("Restoring " + aArea + " from saved state"); + let placements = gSavedState.placements[aArea]; + for (let id of placements) { + this.addWidgetToArea(id, aArea); + } + gDirty = false; + restored = true; + } + + if (!restored) { + lazy.log.debug("Restoring " + aArea + " from default state"); + let defaults = gAreas.get(aArea).get("defaultPlacements"); + if (defaults) { + for (let id of defaults) { + this.addWidgetToArea(id, aArea, null, true); + } + } + gDirty = false; + } + + // Finally, add widgets to the area that were added before the it was able + // to be restored. This can occur when add-ons register widgets for a + // lazily-restored area before it's been restored. + if (gFuturePlacements.has(aArea)) { + let areaPlacements = gPlacements.get(aArea); + for (let id of gFuturePlacements.get(aArea)) { + if (areaPlacements.includes(id)) { + continue; + } + this.addWidgetToArea(id, aArea); + } + gFuturePlacements.delete(aArea); + } + + lazy.log.debug( + "Placements for " + + aArea + + ":\n\t" + + gPlacements.get(aArea).join("\n\t") + ); + + gRestoring = false; + } finally { + this.endBatchUpdate(); + } + }, + + saveState() { + if (gInBatchStack || !gDirty) { + return; + } + // Clone because we want to modify this map: + let state = { + placements: new Map(gPlacements), + seen: gSeenWidgets, + dirtyAreaCache: gDirtyAreaCache, + currentVersion: kVersion, + newElementCount: gNewElementCount, + }; + + // Merge in previously saved areas if not present in gPlacements. + // This way, state is still persisted for e.g. temporarily disabled + // add-ons - see bug 989338. + if (gSavedState && gSavedState.placements) { + for (let area of Object.keys(gSavedState.placements)) { + if (!state.placements.has(area)) { + let placements = gSavedState.placements[area]; + state.placements.set(area, placements); + } + } + } + + lazy.log.debug("Saving state."); + let serialized = JSON.stringify(state, this.serializerHelper); + lazy.log.debug("State saved as: " + serialized); + Services.prefs.setCharPref(kPrefCustomizationState, serialized); + gDirty = false; + }, + + serializerHelper(aKey, aValue) { + if (typeof aValue == "object" && aValue.constructor.name == "Map") { + let result = {}; + for (let [mapKey, mapValue] of aValue) { + result[mapKey] = mapValue; + } + return result; + } + + if (typeof aValue == "object" && aValue.constructor.name == "Set") { + return [...aValue]; + } + + return aValue; + }, + + beginBatchUpdate() { + gInBatchStack++; + }, + + endBatchUpdate(aForceDirty) { + gInBatchStack--; + if (aForceDirty === true) { + gDirty = true; + } + if (gInBatchStack == 0) { + this.saveState(); + } else if (gInBatchStack < 0) { + throw new Error( + "The batch editing stack should never reach a negative number." + ); + } + }, + + addListener(aListener) { + gListeners.add(aListener); + }, + + removeListener(aListener) { + if (aListener == this) { + return; + } + + gListeners.delete(aListener); + }, + + notifyListeners(aEvent, ...aArgs) { + if (gRestoring) { + return; + } + + for (let listener of gListeners) { + try { + if (typeof listener[aEvent] == "function") { + listener[aEvent].apply(listener, aArgs); + } + } catch (e) { + lazy.log.error(e + " -- " + e.fileName + ":" + e.lineNumber); + } + } + }, + + _dispatchToolboxEventToWindow(aEventType, aDetails, aWindow) { + let evt = new aWindow.CustomEvent(aEventType, { + bubbles: true, + cancelable: true, + detail: aDetails, + }); + aWindow.gNavToolbox.dispatchEvent(evt); + }, + + dispatchToolboxEvent(aEventType, aDetails = {}, aWindow = null) { + if (aWindow) { + this._dispatchToolboxEventToWindow(aEventType, aDetails, aWindow); + return; + } + for (let [win] of gBuildWindows) { + this._dispatchToolboxEventToWindow(aEventType, aDetails, win); + } + }, + + createWidget(aProperties) { + let widget = this.normalizeWidget( + aProperties, + CustomizableUI.SOURCE_EXTERNAL + ); + // XXXunf This should probably throw. + if (!widget) { + lazy.log.error("unable to normalize widget"); + return undefined; + } + + gPalette.set(widget.id, widget); + + // Clear our caches: + gGroupWrapperCache.delete(widget.id); + for (let [win] of gBuildWindows) { + let cache = gSingleWrapperCache.get(win); + if (cache) { + cache.delete(widget.id); + } + } + + this.notifyListeners("onWidgetCreated", widget.id); + + if (widget.defaultArea) { + let addToDefaultPlacements = false; + let area = gAreas.get(widget.defaultArea); + if ( + !CustomizableUI.isBuiltinToolbar(widget.defaultArea) && + widget.defaultArea != CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ) { + addToDefaultPlacements = true; + } + + if (addToDefaultPlacements) { + if (area.has("defaultPlacements")) { + area.get("defaultPlacements").push(widget.id); + } else { + area.set("defaultPlacements", [widget.id]); + } + } + } + + // Look through previously saved state to see if we're restoring a widget. + let seenAreas = new Set(); + let widgetMightNeedAutoAdding = true; + for (let [area] of gPlacements) { + seenAreas.add(area); + let areaIsRegistered = gAreas.has(area); + let index = gPlacements.get(area).indexOf(widget.id); + if (index != -1) { + widgetMightNeedAutoAdding = false; + if (areaIsRegistered) { + widget.currentArea = area; + widget.currentPosition = index; + } + break; + } + } + + // Also look at saved state data directly in areas that haven't yet been + // restored. Can't rely on this for restored areas, as they may have + // changed. + if (widgetMightNeedAutoAdding && gSavedState) { + for (let area of Object.keys(gSavedState.placements)) { + if (seenAreas.has(area)) { + continue; + } + + let areaIsRegistered = gAreas.has(area); + let index = gSavedState.placements[area].indexOf(widget.id); + if (index != -1) { + widgetMightNeedAutoAdding = false; + if (areaIsRegistered) { + widget.currentArea = area; + widget.currentPosition = index; + } + break; + } + } + } + + // If we're restoring the widget to it's old placement, fire off the + // onWidgetAdded event - our own handler will take care of adding it to + // any build areas. + this.beginBatchUpdate(); + try { + if (widget.currentArea) { + this.notifyListeners( + "onWidgetAdded", + widget.id, + widget.currentArea, + widget.currentPosition + ); + } else if (widgetMightNeedAutoAdding) { + let autoAdd = Services.prefs.getBoolPref( + kPrefCustomizationAutoAdd, + true + ); + + // If the widget doesn't have an existing placement, and it hasn't been + // seen before, then add it to its default area so it can be used. + // If the widget is not removable, we *have* to add it to its default + // area here. + let canBeAutoAdded = autoAdd && !gSeenWidgets.has(widget.id); + if (!widget.currentArea && (!widget.removable || canBeAutoAdded)) { + if (widget.defaultArea) { + if (this.isAreaLazy(widget.defaultArea)) { + gFuturePlacements.get(widget.defaultArea).add(widget.id); + } else { + this.addWidgetToArea(widget.id, widget.defaultArea); + } + } + } + + // Extension widgets cannot enter the customization palette, so if + // at this point, we haven't found an area for them, move them into + // AREA_ADDONS. + if ( + !widget.currentArea && + CustomizableUI.isWebExtensionWidget(widget.id) + ) { + this.addWidgetToArea(widget.id, CustomizableUI.AREA_ADDONS); + } + } + } finally { + // Ensure we always have this widget in gSeenWidgets, and save + // state in case this needs to be done here. + gSeenWidgets.add(widget.id); + this.endBatchUpdate(true); + } + + this.notifyListeners( + "onWidgetAfterCreation", + widget.id, + widget.currentArea + ); + return widget.id; + }, + + createBuiltinWidget(aData) { + // This should only ever be called on startup, before any windows are + // opened - so we know there's no build areas to handle. Also, builtin + // widgets are expected to be (mostly) static, so shouldn't affect the + // current placement settings. + + // This allows a widget to be both built-in by default but also able to be + // destroyed and removed from the area based on criteria that may not be + // available when the widget is created -- for example, because some other + // feature in the browser supersedes the widget. + let conditionalDestroyPromise = aData.conditionalDestroyPromise || null; + delete aData.conditionalDestroyPromise; + + let widget = this.normalizeWidget(aData, CustomizableUI.SOURCE_BUILTIN); + if (!widget) { + lazy.log.error("Error creating builtin widget: " + aData.id); + return; + } + + lazy.log.debug("Creating built-in widget with id: " + widget.id); + gPalette.set(widget.id, widget); + + if (conditionalDestroyPromise) { + conditionalDestroyPromise.then( + shouldDestroy => { + if (shouldDestroy) { + this.destroyWidget(widget.id); + this.removeWidgetFromArea(widget.id); + } + }, + err => { + console.error(err); + } + ); + } + }, + + // Returns true if the area will eventually lazily restore (but hasn't yet). + isAreaLazy(aArea) { + if (gPlacements.has(aArea)) { + return false; + } + return gAreas.get(aArea).get("type") == CustomizableUI.TYPE_TOOLBAR; + }, + + // XXXunf Log some warnings here, when the data provided isn't up to scratch. + normalizeWidget(aData, aSource) { + let widget = { + implementation: aData, + source: aSource || CustomizableUI.SOURCE_EXTERNAL, + instances: new Map(), + currentArea: null, + localized: true, + removable: true, + overflows: true, + defaultArea: null, + shortcutId: null, + tabSpecific: false, + locationSpecific: false, + tooltiptext: null, + l10nId: null, + showInPrivateBrowsing: true, + _introducedInVersion: -1, + keepBroadcastAttributesWhenCustomizing: false, + disallowSubView: false, + webExtension: false, + }; + + if (typeof aData.id != "string" || !/^[a-z0-9-_]{1,}$/i.test(aData.id)) { + lazy.log.error("Given an illegal id in normalizeWidget: " + aData.id); + return null; + } + + delete widget.implementation.currentArea; + widget.implementation.__defineGetter__( + "currentArea", + () => widget.currentArea + ); + + const kReqStringProps = ["id"]; + for (let prop of kReqStringProps) { + if (typeof aData[prop] != "string") { + lazy.log.error( + "Missing required property '" + + prop + + "' in normalizeWidget: " + + aData.id + ); + return null; + } + widget[prop] = aData[prop]; + } + + const kOptStringProps = ["l10nId", "label", "tooltiptext", "shortcutId"]; + for (let prop of kOptStringProps) { + if (typeof aData[prop] == "string") { + widget[prop] = aData[prop]; + } + } + + const kOptBoolProps = [ + "removable", + "showInPrivateBrowsing", + "overflows", + "tabSpecific", + "locationSpecific", + "localized", + "keepBroadcastAttributesWhenCustomizing", + "disallowSubView", + "webExtension", + ]; + for (let prop of kOptBoolProps) { + if (typeof aData[prop] == "boolean") { + widget[prop] = aData[prop]; + } + } + + // When we normalize builtin widgets, areas have not yet been registered: + if ( + aData.defaultArea && + (aSource == CustomizableUI.SOURCE_BUILTIN || + gAreas.has(aData.defaultArea)) + ) { + widget.defaultArea = aData.defaultArea; + } else if (!widget.removable) { + lazy.log.error( + "Widget '" + + widget.id + + "' is not removable but does not specify " + + "a valid defaultArea. That's not possible; it must specify a " + + "valid defaultArea as well." + ); + return null; + } + + if ("type" in aData && gSupportedWidgetTypes.has(aData.type)) { + widget.type = aData.type; + } else { + widget.type = "button"; + } + + widget.disabled = aData.disabled === true; + + if (aSource == CustomizableUI.SOURCE_BUILTIN) { + widget._introducedInVersion = aData.introducedInVersion || 0; + } + + this.wrapWidgetEventHandler("onBeforeCreated", widget); + this.wrapWidgetEventHandler("onClick", widget); + this.wrapWidgetEventHandler("onCreated", widget); + this.wrapWidgetEventHandler("onDestroyed", widget); + + if (typeof aData.onBeforeCommand == "function") { + widget.onBeforeCommand = aData.onBeforeCommand; + } + + if (typeof aData.onCommand == "function") { + widget.onCommand = aData.onCommand; + } + if ( + widget.type == "view" || + widget.type == "button-and-view" || + aData.viewId + ) { + if (typeof aData.viewId != "string") { + lazy.log.error( + "Expected a string for widget " + + widget.id + + " viewId, but got " + + aData.viewId + ); + return null; + } + widget.viewId = aData.viewId; + + this.wrapWidgetEventHandler("onViewShowing", widget); + this.wrapWidgetEventHandler("onViewHiding", widget); + } + if (widget.type == "custom") { + this.wrapWidgetEventHandler("onBuild", widget); + } + + if (gPalette.has(widget.id)) { + return null; + } + + return widget; + }, + + wrapWidgetEventHandler(aEventName, aWidget) { + if (typeof aWidget.implementation[aEventName] != "function") { + aWidget[aEventName] = null; + return; + } + aWidget[aEventName] = function (...aArgs) { + try { + // Don't copy the function to the normalized widget object, instead + // keep it on the original object provided to the API so that + // additional methods can be implemented and used by the event + // handlers. + return aWidget.implementation[aEventName].apply( + aWidget.implementation, + aArgs + ); + } catch (e) { + console.error(e); + return undefined; + } + }; + }, + + destroyWidget(aWidgetId) { + let widget = gPalette.get(aWidgetId); + if (!widget) { + gGroupWrapperCache.delete(aWidgetId); + for (let [window] of gBuildWindows) { + let windowCache = gSingleWrapperCache.get(window); + if (windowCache) { + windowCache.delete(aWidgetId); + } + } + return; + } + + // Remove it from the default placements of an area if it was added there: + if (widget.defaultArea) { + let area = gAreas.get(widget.defaultArea); + if (area) { + let defaultPlacements = area.get("defaultPlacements"); + // We can assume this is present because if a widget has a defaultArea, + // we automatically create a defaultPlacements array for that area. + let widgetIndex = defaultPlacements.indexOf(aWidgetId); + if (widgetIndex != -1) { + defaultPlacements.splice(widgetIndex, 1); + } + } + } + + // This will not remove the widget from gPlacements - we want to keep the + // setting so the widget gets put back in it's old position if/when it + // returns. + for (let [window] of gBuildWindows) { + let windowCache = gSingleWrapperCache.get(window); + if (windowCache) { + windowCache.delete(aWidgetId); + } + let widgetNode = + window.document.getElementById(aWidgetId) || + window.gNavToolbox.palette.getElementsByAttribute("id", aWidgetId)[0]; + if (widgetNode) { + let container = widgetNode.parentNode; + this.notifyListeners( + "onWidgetBeforeDOMChange", + widgetNode, + null, + container, + true + ); + widgetNode.remove(); + this.notifyListeners( + "onWidgetAfterDOMChange", + widgetNode, + null, + container, + true + ); + } + if ( + widget.type == "view" || + widget.type == "button-and-view" || + widget.viewId + ) { + let viewNode = window.document.getElementById(widget.viewId); + if (viewNode) { + for (let eventName of kSubviewEvents) { + let handler = "on" + eventName; + if (typeof widget[handler] == "function") { + viewNode.removeEventListener(eventName, widget[handler]); + } + } + viewNode._addedEventListeners = false; + } + } + if (widgetNode && widget.onDestroyed) { + widget.onDestroyed(window.document); + } + } + + gPalette.delete(aWidgetId); + gGroupWrapperCache.delete(aWidgetId); + + this.notifyListeners("onWidgetDestroyed", aWidgetId); + }, + + getCustomizeTargetForArea(aArea, aWindow) { + let buildAreaNodes = gBuildAreas.get(aArea); + if (!buildAreaNodes) { + return null; + } + + for (let node of buildAreaNodes) { + if (node.ownerGlobal == aWindow) { + return this.getCustomizationTarget(node) || node; + } + } + + return null; + }, + + reset() { + gResetting = true; + this._resetUIState(); + + // Rebuild each registered area (across windows) to reflect the state that + // was reset above. + this._rebuildRegisteredAreas(); + + for (let [widgetId, widget] of gPalette) { + if (widget.source == CustomizableUI.SOURCE_EXTERNAL) { + gSeenWidgets.add(widgetId); + } + } + if (gSeenWidgets.size || gNewElementCount) { + gDirty = true; + this.saveState(); + } + + gResetting = false; + }, + + _resetUIState() { + try { + gUIStateBeforeReset.drawInTitlebar = + Services.prefs.getIntPref(kPrefDrawInTitlebar); + gUIStateBeforeReset.uiCustomizationState = Services.prefs.getCharPref( + kPrefCustomizationState + ); + gUIStateBeforeReset.uiDensity = Services.prefs.getIntPref(kPrefUIDensity); + gUIStateBeforeReset.autoTouchMode = + Services.prefs.getBoolPref(kPrefAutoTouchMode); + gUIStateBeforeReset.currentTheme = gSelectedTheme; + gUIStateBeforeReset.autoHideDownloadsButton = Services.prefs.getBoolPref( + kPrefAutoHideDownloadsButton + ); + gUIStateBeforeReset.newElementCount = gNewElementCount; + } catch (e) {} + + Services.prefs.clearUserPref(kPrefCustomizationState); + Services.prefs.clearUserPref(kPrefDrawInTitlebar); + Services.prefs.clearUserPref(kPrefUIDensity); + Services.prefs.clearUserPref(kPrefAutoTouchMode); + Services.prefs.clearUserPref(kPrefAutoHideDownloadsButton); + gDefaultTheme.enable(); + gNewElementCount = 0; + lazy.log.debug("State reset"); + + // Later in the function, we're going to add any area-less extension + // buttons to the AREA_ADDONS area. We'll remember the old placements + // for that area so that we don't need to re-add widgets that are already + // in there in the DOM. + let oldAddonPlacements = gPlacements[CustomizableUI.AREA_ADDONS] || []; + + // Reset placements to make restoring default placements possible. + gPlacements = new Map(); + gDirtyAreaCache = new Set(); + gSeenWidgets = new Set(); + // Clear the saved state to ensure that defaults will be used. + gSavedState = null; + // Restore the state for each area to its defaults + for (let [areaId] of gAreas) { + // If the Unified Extensions UI is enabled, we'll be adding any + // extension buttons that aren't already in AREA_ADDONS there, + // so we can skip restoring the state for it. + if (areaId != CustomizableUI.AREA_ADDONS) { + this.restoreStateForArea(areaId); + } + } + + // restoreStateForArea will have normally set an array for the placements + // for each area, but since we skip AREA_ADDONS intentionally, that array + // doesn't get set, so we do that manually here. + gPlacements.set(CustomizableUI.AREA_ADDONS, []); + + for (let [widgetId] of gPalette) { + if ( + CustomizableUI.isWebExtensionWidget(widgetId) && + !oldAddonPlacements.includes(widgetId) + ) { + this.addWidgetToArea(widgetId, CustomizableUI.AREA_ADDONS); + } + } + }, + + _rebuildRegisteredAreas() { + for (let [areaId, areaNodes] of gBuildAreas) { + let placements = gPlacements.get(areaId); + let isFirstChangedToolbar = true; + for (let areaNode of areaNodes) { + this.buildArea(areaId, placements, areaNode); + + let area = gAreas.get(areaId); + if (area.get("type") == CustomizableUI.TYPE_TOOLBAR) { + let defaultCollapsed = area.get("defaultCollapsed"); + let win = areaNode.ownerGlobal; + if (defaultCollapsed !== null) { + win.setToolbarVisibility( + areaNode, + typeof defaultCollapsed == "string" + ? defaultCollapsed + : !defaultCollapsed, + isFirstChangedToolbar + ); + } + } + isFirstChangedToolbar = false; + } + } + }, + + /** + * Undoes a previous reset, restoring the state of the UI to the state prior to the reset. + */ + undoReset() { + if ( + gUIStateBeforeReset.uiCustomizationState == null || + gUIStateBeforeReset.drawInTitlebar == null + ) { + return; + } + gUndoResetting = true; + + const { + uiCustomizationState, + drawInTitlebar, + currentTheme, + uiDensity, + autoTouchMode, + autoHideDownloadsButton, + } = gUIStateBeforeReset; + gNewElementCount = gUIStateBeforeReset.newElementCount; + + // Need to clear the previous state before setting the prefs + // because pref observers may check if there is a previous UI state. + this._clearPreviousUIState(); + + Services.prefs.setCharPref(kPrefCustomizationState, uiCustomizationState); + Services.prefs.setIntPref(kPrefDrawInTitlebar, drawInTitlebar); + Services.prefs.setIntPref(kPrefUIDensity, uiDensity); + Services.prefs.setBoolPref(kPrefAutoTouchMode, autoTouchMode); + Services.prefs.setBoolPref( + kPrefAutoHideDownloadsButton, + autoHideDownloadsButton + ); + currentTheme.enable(); + this.loadSavedState(); + // If the user just customizes toolbar/titlebar visibility, gSavedState will be null + // and we don't need to do anything else here: + if (gSavedState) { + for (let areaId of Object.keys(gSavedState.placements)) { + let placements = gSavedState.placements[areaId]; + gPlacements.set(areaId, placements); + } + this._rebuildRegisteredAreas(); + } + + gUndoResetting = false; + }, + + _clearPreviousUIState() { + Object.getOwnPropertyNames(gUIStateBeforeReset).forEach(prop => { + gUIStateBeforeReset[prop] = null; + }); + }, + + /** + * @param {String|Node} aWidget - widget ID or a widget node (preferred for performance). + * @return {Boolean} whether the widget is removable + */ + isWidgetRemovable(aWidget) { + let widgetId; + let widgetNode; + if (typeof aWidget == "string") { + widgetId = aWidget; + } else { + // Skipped items could just not have ids. + if (!aWidget.id && aWidget.getAttribute("skipintoolbarset") == "true") { + return false; + } + if ( + !aWidget.id && + !["toolbarspring", "toolbarspacer", "toolbarseparator"].includes( + aWidget.nodeName + ) + ) { + throw new Error( + "No nodes without ids that aren't special widgets should ever come into contact with CUI" + ); + } + // Use "spring" / "spacer" / "separator" for special widgets without ids + widgetId = + aWidget.id || aWidget.nodeName.substring(7 /* "toolbar".length */); + widgetNode = aWidget; + } + let provider = this.getWidgetProvider(widgetId); + + if (provider == CustomizableUI.PROVIDER_API) { + return gPalette.get(widgetId).removable; + } + + if (provider == CustomizableUI.PROVIDER_XUL) { + if (gBuildWindows.size == 0) { + // We don't have any build windows to look at, so just assume for now + // that its removable. + return true; + } + + if (!widgetNode) { + // Pick any of the build windows to look at. + let [window] = [...gBuildWindows][0]; + [, widgetNode] = this.getWidgetNode(widgetId, window); + } + // If we don't have a node, we assume it's removable. This can happen because + // getWidgetProvider returns PROVIDER_XUL by default, but this will also happen + // for API-provided widgets which have been destroyed. + if (!widgetNode) { + return true; + } + return widgetNode.getAttribute("removable") == "true"; + } + + // Otherwise this is either a special widget, which is always removable, or + // an API widget which has already been removed from gPalette. Returning true + // here allows us to then remove its ID from any placements where it might + // still occur. + return true; + }, + + canWidgetMoveToArea(aWidgetId, aArea) { + // Special widgets can't move to the menu panel. + if ( + this.isSpecialWidget(aWidgetId) && + gAreas.has(aArea) && + gAreas.get(aArea).get("type") == CustomizableUI.TYPE_PANEL + ) { + return false; + } + + if ( + aArea == CustomizableUI.AREA_ADDONS && + !CustomizableUI.isWebExtensionWidget(aWidgetId) + ) { + return false; + } + + if (CustomizableUI.isWebExtensionWidget(aWidgetId)) { + // Extension widgets cannot move to the customization palette. + if (aArea == CustomizableUI.AREA_NO_AREA) { + return false; + } + + // Extension widgets cannot move to panels, with the exception of the + // AREA_ADDONS area. + if ( + gAreas.get(aArea).get("type") == CustomizableUI.TYPE_PANEL && + aArea != CustomizableUI.AREA_ADDONS + ) { + return false; + } + } + + let placement = this.getPlacementOfWidget(aWidgetId); + // Items in the palette can move, and items can move within their area: + if (!placement || placement.area == aArea) { + return true; + } + // For everything else, just return whether the widget is removable. + return this.isWidgetRemovable(aWidgetId); + }, + + ensureWidgetPlacedInWindow(aWidgetId, aWindow) { + let placement = this.getPlacementOfWidget(aWidgetId); + if (!placement) { + return false; + } + let areaNodes = gBuildAreas.get(placement.area); + if (!areaNodes) { + return false; + } + let container = [...areaNodes].filter(n => n.ownerGlobal == aWindow); + if (!container.length) { + return false; + } + let existingNode = container[0].getElementsByAttribute("id", aWidgetId)[0]; + if (existingNode) { + return true; + } + + this.insertNodeInWindow(aWidgetId, container[0], true); + return true; + }, + + _getCurrentWidgetsInContainer(container) { + // Get a list of all the widget IDs in this container, including any that + // are overflown. + let currentWidgets = new Set(); + function addUnskippedChildren(parent) { + for (let node of parent.children) { + let realNode = + node.localName == "toolbarpaletteitem" + ? node.firstElementChild + : node; + if (realNode.getAttribute("skipintoolbarset") != "true") { + currentWidgets.add(realNode.id); + } + } + } + addUnskippedChildren(this.getCustomizationTarget(container)); + if (container.getAttribute("overflowing") == "true") { + let overflowTarget = container.getAttribute("default-overflowtarget"); + addUnskippedChildren( + container.ownerDocument.getElementById(overflowTarget) + ); + let webExtOverflowTarget = container.getAttribute( + "addon-webext-overflowtarget" + ); + addUnskippedChildren( + container.ownerDocument.getElementById(webExtOverflowTarget) + ); + } + // Then get the sorted list of placements, and filter based on the nodes + // that are present. This avoids including items that don't exist (e.g. ids + // of add-on items that the user has uninstalled). + let orderedPlacements = CustomizableUI.getWidgetIdsInArea(container.id); + return orderedPlacements.filter(w => currentWidgets.has(w)); + }, + + get inDefaultState() { + for (let [areaId, props] of gAreas) { + let defaultPlacements = props + .get("defaultPlacements") + .filter(item => this.widgetExists(item)); + let currentPlacements = gPlacements.get(areaId); + // We're excluding all of the placement IDs for items that do not exist, + // and items that have removable="false", + // because we don't want to consider them when determining if we're + // in the default state. This way, if an add-on introduces a widget + // and is then uninstalled, the leftover placement doesn't cause us to + // automatically assume that the buttons are not in the default state. + let buildAreaNodes = gBuildAreas.get(areaId); + if (buildAreaNodes && buildAreaNodes.size) { + let container = [...buildAreaNodes][0]; + let removableOrDefault = itemNodeOrItem => { + let item = (itemNodeOrItem && itemNodeOrItem.id) || itemNodeOrItem; + let isRemovable = this.isWidgetRemovable(itemNodeOrItem); + let isInDefault = defaultPlacements.includes(item); + return isRemovable || isInDefault; + }; + // Toolbars need to deal with overflown widgets (if any) - so + // specialcase them: + if (props.get("type") == CustomizableUI.TYPE_TOOLBAR) { + currentPlacements = + this._getCurrentWidgetsInContainer(container).filter( + removableOrDefault + ); + } else { + currentPlacements = currentPlacements.filter(item => { + let itemNode = container.getElementsByAttribute("id", item)[0]; + return itemNode && removableOrDefault(itemNode || item); + }); + } + + if (props.get("type") == CustomizableUI.TYPE_TOOLBAR) { + let collapsed = null; + let defaultCollapsed = props.get("defaultCollapsed"); + let nondefaultState = false; + if (areaId == CustomizableUI.AREA_BOOKMARKS) { + collapsed = Services.prefs.getCharPref( + "browser.toolbars.bookmarks.visibility" + ); + nondefaultState = Services.prefs.prefHasUserValue( + "browser.toolbars.bookmarks.visibility" + ); + } else { + let attribute = + container.getAttribute("type") == "menubar" + ? "autohide" + : "collapsed"; + collapsed = container.getAttribute(attribute) == "true"; + nondefaultState = collapsed != defaultCollapsed; + } + if (defaultCollapsed !== null && nondefaultState) { + lazy.log.debug( + "Found " + + areaId + + " had non-default toolbar visibility" + + "(expected " + + defaultCollapsed + + ", was " + + collapsed + + ")" + ); + return false; + } + } + } + lazy.log.debug( + "Checking default state for " + + areaId + + ":\n" + + currentPlacements.join(",") + + "\nvs.\n" + + defaultPlacements.join(",") + ); + + if (currentPlacements.length != defaultPlacements.length) { + return false; + } + + for (let i = 0; i < currentPlacements.length; ++i) { + if ( + currentPlacements[i] != defaultPlacements[i] && + !this.matchingSpecials(currentPlacements[i], defaultPlacements[i]) + ) { + lazy.log.debug( + "Found " + + currentPlacements[i] + + " in " + + areaId + + " where " + + defaultPlacements[i] + + " was expected!" + ); + return false; + } + } + } + + if (Services.prefs.prefHasUserValue(kPrefUIDensity)) { + lazy.log.debug(kPrefUIDensity + " pref is non-default"); + return false; + } + + if (Services.prefs.prefHasUserValue(kPrefAutoTouchMode)) { + lazy.log.debug(kPrefAutoTouchMode + " pref is non-default"); + return false; + } + + if (Services.prefs.prefHasUserValue(kPrefDrawInTitlebar)) { + lazy.log.debug(kPrefDrawInTitlebar + " pref is non-default"); + return false; + } + + // This should just be `gDefaultTheme.isActive`, but bugs... + if (gDefaultTheme && gDefaultTheme.id != gSelectedTheme.id) { + lazy.log.debug(gSelectedTheme.id + " theme is non-default"); + return false; + } + + return true; + }, + + getCollapsedToolbarIds(window) { + let collapsedToolbars = new Set(); + for (let toolbarId of CustomizableUIInternal._builtinToolbars) { + let toolbar = window.document.getElementById(toolbarId); + + // Menubar toolbars are special in that they're hidden with the autohide + // attribute. + let hidingAttribute = + toolbar.getAttribute("type") == "menubar" ? "autohide" : "collapsed"; + + if (toolbar.getAttribute(hidingAttribute) == "true") { + collapsedToolbars.add(toolbarId); + } + } + + return collapsedToolbars; + }, + + setToolbarVisibility(aToolbarId, aIsVisible) { + // We only persist the attribute the first time. + let isFirstChangedToolbar = true; + for (let window of CustomizableUI.windows) { + let toolbar = window.document.getElementById(aToolbarId); + if (toolbar) { + window.setToolbarVisibility(toolbar, aIsVisible, isFirstChangedToolbar); + isFirstChangedToolbar = false; + } + } + }, + + observe(aSubject, aTopic, aData) { + if (aTopic == "browser-set-toolbar-visibility") { + let [toolbar, visibility] = JSON.parse(aData); + CustomizableUI.setToolbarVisibility(toolbar, visibility == "true"); + } + }, +}; +Object.freeze(CustomizableUIInternal); + +export var CustomizableUI = { + /** + * Constant reference to the ID of the navigation toolbar. + */ + AREA_NAVBAR: "nav-bar", + /** + * Constant reference to the ID of the menubar's toolbar. + */ + AREA_MENUBAR: "toolbar-menubar", + /** + * Constant reference to the ID of the tabstrip toolbar. + */ + AREA_TABSTRIP: "TabsToolbar", + /** + * Constant reference to the ID of the bookmarks toolbar. + */ + AREA_BOOKMARKS: "PersonalToolbar", + /** + * Constant reference to the ID of the non-dymanic (fixed) list in the overflow panel. + */ + AREA_FIXED_OVERFLOW_PANEL: "widget-overflow-fixed-list", + /** + * Constant reference to the ID of the addons area. + */ + AREA_ADDONS: "unified-extensions-area", + /** + * Constant reference to the ID of the customization palette, which is + * where widgets go when they're not assigned to an area. Note that this + * area is "virtual" in that it's never set as a value for a widgets + * currentArea or defaultArea. It's only used for the `canWidgetMoveToArea` + * function to check if widgets can be moved to the palette. Callers who + * wish to move items to the palette should use `removeWidgetFromArea`. + */ + AREA_NO_AREA: "customization-palette", + /** + * Constant indicating the area is a panel. + */ + TYPE_PANEL: "panel", + /** + * Constant indicating the area is a toolbar. + */ + TYPE_TOOLBAR: "toolbar", + + /** + * Constant indicating a XUL-type provider. + */ + PROVIDER_XUL: "xul", + /** + * Constant indicating an API-type provider. + */ + PROVIDER_API: "api", + /** + * Constant indicating dynamic (special) widgets: spring, spacer, and separator. + */ + PROVIDER_SPECIAL: "special", + + /** + * Constant indicating the widget is built-in + */ + SOURCE_BUILTIN: "builtin", + /** + * Constant indicating the widget is externally provided + * (e.g. by add-ons or other items not part of the builtin widget set). + */ + SOURCE_EXTERNAL: "external", + + /** + * Constant indicating the reason the event was fired was a window closing + */ + REASON_WINDOW_CLOSED: "window-closed", + /** + * Constant indicating the reason the event was fired was an area being + * unregistered separately from window closing mechanics. + */ + REASON_AREA_UNREGISTERED: "area-unregistered", + + /** + * An iteratable property of windows managed by CustomizableUI. + * Note that this can *only* be used as an iterator. ie: + * for (let window of CustomizableUI.windows) { ... } + */ + windows: { + *[Symbol.iterator]() { + for (let [window] of gBuildWindows) { + yield window; + } + }, + }, + + /** + * Add a listener object that will get fired for various events regarding + * customization. + * + * @param aListener the listener object to add + * + * Not all event handler methods need to be defined. + * CustomizableUI will catch exceptions. Events are dispatched + * synchronously on the UI thread, so if you can delay any/some of your + * processing, that is advisable. The following event handlers are supported: + * - onWidgetAdded(aWidgetId, aArea, aPosition) + * Fired when a widget is added to an area. aWidgetId is the widget that + * was added, aArea the area it was added to, and aPosition the position + * in which it was added. + * - onWidgetMoved(aWidgetId, aArea, aOldPosition, aNewPosition) + * Fired when a widget is moved within its area. aWidgetId is the widget + * that was moved, aArea the area it was moved in, aOldPosition its old + * position, and aNewPosition its new position. + * - onWidgetRemoved(aWidgetId, aArea) + * Fired when a widget is removed from its area. aWidgetId is the widget + * that was removed, aArea the area it was removed from. + * + * - onWidgetBeforeDOMChange(aNode, aNextNode, aContainer, aIsRemoval) + * Fired *before* a widget's DOM node is acted upon by CustomizableUI + * (to add, move or remove it). aNode is the DOM node changed, aNextNode + * the DOM node (if any) before which a widget will be inserted, + * aContainer the *actual* DOM container (could be an overflow panel in + * case of an overflowable toolbar), and aWasRemoval is true iff the + * action about to happen is the removal of the DOM node. + * - onWidgetAfterDOMChange(aNode, aNextNode, aContainer, aWasRemoval) + * Like onWidgetBeforeDOMChange, but fired after the change to the DOM + * node of the widget. + * + * - onWidgetReset(aNode, aContainer) + * Fired after a reset to default placements moves a widget's node to a + * different location. aNode is the widget's node, aContainer is the + * area it was moved into (NB: it might already have been there and been + * moved to a different position!) + * - onWidgetUndoMove(aNode, aContainer) + * Fired after undoing a reset to default placements moves a widget's + * node to a different location. aNode is the widget's node, aContainer + * is the area it was moved into (NB: it might already have been there + * and been moved to a different position!) + * - onAreaReset(aArea, aContainer) + * Fired after a reset to default placements is complete on an area's + * DOM node. Note that this is fired for each DOM node. aArea is the area + * that was reset, aContainer the DOM node that was reset. + * + * - onWidgetCreated(aWidgetId) + * Fired when a widget with id aWidgetId has been created, but before it + * is added to any placements or any DOM nodes have been constructed. + * Only fired for API-based widgets. + * - onWidgetAfterCreation(aWidgetId, aArea) + * Fired after a widget with id aWidgetId has been created, and has been + * added to either its default area or the area in which it was placed + * previously. If the widget has no default area and/or it has never + * been placed anywhere, aArea may be null. Only fired for API-based + * widgets. + * - onWidgetDestroyed(aWidgetId) + * Fired when widgets are destroyed. aWidgetId is the widget that is + * being destroyed. Only fired for API-based widgets. + * - onWidgetInstanceRemoved(aWidgetId, aDocument) + * Fired when a window is unloaded and a widget's instance is destroyed + * because of this. Only fired for API-based widgets. + * + * - onWidgetDrag(aWidgetId, aArea) + * Fired both when and after customize mode drag handling system tries + * to determine the width and height of widget aWidgetId when dragged to a + * different area. aArea will be the area the item is dragged to, or + * undefined after the measurements have been done and the node has been + * moved back to its 'regular' area. + * + * - onCustomizeStart(aWindow) + * Fired when opening customize mode in aWindow. + * - onCustomizeEnd(aWindow) + * Fired when exiting customize mode in aWindow. + * + * - onWidgetOverflow(aNode, aContainer) + * Fired when a widget's DOM node is overflowing its container, a toolbar, + * and will be displayed in the overflow panel. + * - onWidgetUnderflow(aNode, aContainer) + * Fired when a widget's DOM node is *not* overflowing its container, a + * toolbar, anymore. + * - onWindowOpened(aWindow) + * Fired when a window has been opened that is managed by CustomizableUI, + * once all of the prerequisite setup has been done. + * - onWindowClosed(aWindow) + * Fired when a window that has been managed by CustomizableUI has been + * closed. + * - onAreaNodeRegistered(aArea, aContainer) + * Fired after an area node is first built when it is registered. This + * is often when the window has opened, but in the case of add-ons, + * could fire when the node has just been registered with CustomizableUI + * after an add-on update or disable/enable sequence. + * - onAreaNodeUnregistered(aArea, aContainer, aReason) + * Fired when an area node is explicitly unregistered by an API caller, + * or by a window closing. The aReason parameter indicates which of + * these is the case. + */ + addListener(aListener) { + CustomizableUIInternal.addListener(aListener); + }, + /** + * Remove a listener added with addListener + * @param aListener the listener object to remove + */ + removeListener(aListener) { + CustomizableUIInternal.removeListener(aListener); + }, + + /** + * Register a customizable area with CustomizableUI. + * @param aName the name of the area to register. Can only contain + * alphanumeric characters, dashes (-) and underscores (_). + * @param aProps the properties of the area. The following properties are + * recognized: + * - type: the type of area. Either TYPE_TOOLBAR (default) or + * TYPE_PANEL; + * - anchor: for a menu panel or overflowable toolbar, the + * anchoring node for the panel. + * - overflowable: set to true if your toolbar is overflowable. + * This requires an anchor, and only has an + * effect for toolbars. + * - defaultPlacements: an array of widget IDs making up the + * default contents of the area + * - defaultCollapsed: (INTERNAL ONLY) applies if the type is TYPE_TOOLBAR, specifies + * if toolbar is collapsed by default (default to true). + * Specify null to ensure that reset/inDefaultArea don't care + * about a toolbar's collapsed state + */ + registerArea(aName, aProperties) { + CustomizableUIInternal.registerArea(aName, aProperties); + }, + /** + * Register a concrete node for a registered area. This method needs to be called + * with any toolbar in the main browser window that has its "customizable" attribute + * set to true. + * + * Note that ideally, you should register your toolbar using registerArea + * before calling this. If you don't, the node will be saved for processing when + * you call registerArea. Note that CustomizableUI won't restore state in the area, + * allow the user to customize it in customize mode, or otherwise deal + * with it, until the area has been registered. + */ + registerToolbarNode(aToolbar) { + CustomizableUIInternal.registerToolbarNode(aToolbar); + }, + /** + * Register a panel node. A panel treated slightly differently from a toolbar in + * terms of what items can be moved into it. For example, a panel cannot have a + * spacer or a spring put into it. + * + * @param aPanelContents the panel contents DOM node being registered. + * @param aArea the area for which to register this node. + */ + registerPanelNode(aNode, aArea) { + CustomizableUIInternal.registerPanelNode(aNode, aArea); + }, + /** + * Unregister a customizable area. The inverse of registerArea. + * + * Unregistering an area will remove all the (removable) widgets in the + * area, which will return to the panel, and destroy all other traces + * of the area within CustomizableUI. Note that this means the *contents* + * of the area's DOM nodes will be moved to the panel or removed, but + * the area's DOM nodes *themselves* will stay. + * + * Furthermore, by default the placements of the area will be kept in the + * saved state (!) and restored if you re-register the area at a later + * point. This is useful for e.g. add-ons that get disabled and then + * re-enabled (e.g. when they update). + * + * You can override this last behaviour (and destroy the placements + * information in the saved state) by passing true for aDestroyPlacements. + * + * @param aName the name of the area to unregister + * @param aDestroyPlacements whether to destroy the placements information + * for the area, too. + */ + unregisterArea(aName, aDestroyPlacements) { + CustomizableUIInternal.unregisterArea(aName, aDestroyPlacements); + }, + /** + * Add a widget to an area. + * If the area to which you try to add is not known to CustomizableUI, + * this will throw. + * If the area to which you try to add is the same as the area in which + * the widget is currently placed, this will do the same as + * moveWidgetWithinArea. + * If the widget cannot be removed from its original location, this will + * no-op. + * + * This will fire an onWidgetAdded notification, + * and an onWidgetBeforeDOMChange and onWidgetAfterDOMChange notification + * for each window CustomizableUI knows about. + * + * @param aWidgetId the ID of the widget to add + * @param aArea the ID of the area to add the widget to + * @param aPosition the position at which to add the widget. If you do not + * pass a position, the widget will be added to the end + * of the area. + */ + addWidgetToArea(aWidgetId, aArea, aPosition) { + CustomizableUIInternal.addWidgetToArea(aWidgetId, aArea, aPosition); + }, + /** + * Remove a widget from its area. If the widget cannot be removed from its + * area, or is not in any area, this will no-op. Otherwise, this will fire an + * onWidgetRemoved notification, and an onWidgetBeforeDOMChange and + * onWidgetAfterDOMChange notification for each window CustomizableUI knows + * about. + * + * @param aWidgetId the ID of the widget to remove + */ + removeWidgetFromArea(aWidgetId) { + CustomizableUIInternal.removeWidgetFromArea(aWidgetId); + }, + /** + * Move a widget within an area. + * If the widget is not in any area, this will no-op. + * If the widget is already at the indicated position, this will no-op. + * + * Otherwise, this will move the widget and fire an onWidgetMoved notification, + * and an onWidgetBeforeDOMChange and onWidgetAfterDOMChange notification for + * each window CustomizableUI knows about. + * + * @param aWidgetId the ID of the widget to move + * @param aPosition the position to move the widget to. + * Negative values or values greater than the number of + * widgets will be interpreted to mean moving the widget to + * respectively the first or last position. + */ + moveWidgetWithinArea(aWidgetId, aPosition) { + CustomizableUIInternal.moveWidgetWithinArea(aWidgetId, aPosition); + }, + /** + * Ensure a XUL-based widget created in a window after areas were + * initialized moves to its correct position. + * Always prefer this over moving items in the DOM yourself. + * + * @param aWidgetId the ID of the widget that was just created + * @param aWindow the window in which you want to ensure it was added. + * + * NB: why is this API per-window, you wonder? Because if you need this, + * presumably you yourself need to create the widget in all the windows + * and need to loop through them anyway. + */ + ensureWidgetPlacedInWindow(aWidgetId, aWindow) { + return CustomizableUIInternal.ensureWidgetPlacedInWindow( + aWidgetId, + aWindow + ); + }, + /** + * Start a batch update of items. + * During a batch update, the customization state is not saved to the user's + * preferences file, in order to reduce (possibly sync) IO. + * Calls to begin/endBatchUpdate may be nested. + * + * Callers should ensure that NO MATTER WHAT they call endBatchUpdate once + * for each call to beginBatchUpdate, even if there are exceptions in the + * code in the batch update. Otherwise, for the duration of the + * Firefox session, customization state is never saved. Typically, you + * would do this using a try...finally block. + */ + beginBatchUpdate() { + CustomizableUIInternal.beginBatchUpdate(); + }, + /** + * End a batch update. See the documentation for beginBatchUpdate above. + * + * State is not saved if we believe it is identical to the last known + * saved state. State is only ever saved when all batch updates have + * finished (ie there has been 1 endBatchUpdate call for each + * beginBatchUpdate call). If any of the endBatchUpdate calls pass + * aForceDirty=true, we will flush to the prefs file. + * + * @param aForceDirty force CustomizableUI to flush to the prefs file when + * all batch updates have finished. + */ + endBatchUpdate(aForceDirty) { + CustomizableUIInternal.endBatchUpdate(aForceDirty); + }, + /** + * Create a widget. + * + * To create a widget, you should pass an object with its desired + * properties. The following properties are supported: + * + * - id: the ID of the widget (required). + * - type: a string indicating the type of widget. Possible types + * are: + * 'button' - for simple button widgets (the default) + * 'view' - for buttons that open a panel or subview, + * depending on where they are placed. + * 'button-and-view' - A combination of 'button' and 'view', + * which looks different depending on whether it's + * located in the toolbar or in the panel: When + * located in the toolbar, the widget is shown as + * a combined item of a button and a dropmarker + * button. The button triggers the command and the + * dropmarker button opens the view. When located + * in the panel, shown as one item which opens the + * view, and the button command cannot be + * triggered separately. + * 'custom' - for fine-grained control over the creation + * of the widget. + * - viewId: Only useful for views and button-and-view widgets (and + * required there): the id of the that should be + * shown when clicking the widget. If used with a custom + * widget, the widget must also provide a toolbaritem where + * the first child is the view button. + * - onBuild(aDoc): Only useful for custom widgets (and required there); a + * function that will be invoked with the document in which + * to build a widget. Should return the DOM node that has + * been constructed. + * - onBeforeCreated(aDoc): Attached to all non-custom widgets; a function + * that will be invoked before the widget gets a DOM node + * constructed, passing the document in which that will happen. + * This is useful especially for 'view' type widgets that need + * to construct their views on the fly (e.g. from bootstrapped + * add-ons). If the function returns `false`, the widget will + * not be created. + * - onCreated(aNode): Attached to all widgets; a function that will be invoked + * whenever the widget has a DOM node constructed, passing the + * constructed node as an argument. + * - onDestroyed(aDoc): Attached to all non-custom widgets; a function that + * will be invoked after the widget has a DOM node destroyed, + * passing the document from which it was removed. This is + * useful especially for 'view' type widgets that need to + * cleanup after views that were constructed on the fly. + * - onBeforeCommand(aEvt, aNode): A function that will be invoked when the user + * activates the button but before the command + * is evaluated. Useful if code needs to run to + * change the button's icon in preparation to the + * pending command action. Called for any type that + * supports the handler. The command type, either + * "view" or "command", may be returned to force the + * action that will occur. View will open the panel + * and command will result in calling onCommand. + * - onCommand(aEvt): Useful for custom, button and button-and-view widgets; a + * function that will be invoked when the user activates + * the button. A custom widget with a view should + * return "view" or "command" to continue processing + * the command per the needs of the widget. + * - onClick(aEvt): Attached to all widgets; a function that will be invoked + * when the user clicks the widget. + * - onViewShowing(aEvt): Only useful for views and button-and-view widgets; a + * function that will be invoked when a user shows your view. + * If any event handler calls aEvt.preventDefault(), the view + * will not be shown. + * + * The event's `detail` property is an object with an + * `addBlocker` method. Handlers which need to + * perform asynchronous operations before the view is + * shown may pass this method a Promise, which will + * prevent the view from showing until it resolves. + * Additionally, if the promise resolves to the exact + * value `false`, the view will not be shown. + * - onViewHiding(aEvt): Only useful for views; a function that will be + * invoked when a user hides your view. + * - l10nId: fluent string identifier to use for localizing attributes + * on the widget. If present, preferred over the + * label/tooltiptext. + * - tooltiptext: string to use for the tooltip of the widget + * - label: string to use for the label of the widget + * - localized: If true, or undefined, attempt to retrieve the + * widget's string properties from the customizable + * widgets string bundle. + * - removable: whether the widget is removable (optional, default: true) + * NB: if you specify false here, you must provide a + * defaultArea, too. + * - overflows: whether widget can overflow when in an overflowable + * toolbar (optional, default: true) + * - defaultArea: default area to add the widget to + * (optional, default: none; required if non-removable) + * - shortcutId: id of an element that has a shortcut for this widget + * (optional, default: null). This is only used to display + * the shortcut as part of the tooltip for builtin widgets + * (which have strings inside + * customizableWidgets.properties). If you're in an add-on, + * you should not set this property. + * If l10nId is provided, the resulting shortcut is passed + * as the "$shortcut" variable to the fluent message. + * - showInPrivateBrowsing: whether to show the widget in private browsing + * mode (optional, default: true) + * - tabSpecific: If true, closes the panel if the tab changes. + * - locationSpecific: If true, closes the panel if the location changes. + * This is similar to tabSpecific, but also if the location + * changes in the same tab, we may want to close the panel. + * - webExtension: Set to true if this widget is being created on behalf of an + * extension. + * + * @param aProperties the specifications for the widget. + * @return a wrapper around the created widget (see getWidget) + */ + createWidget(aProperties) { + return CustomizableUIInternal.wrapWidget( + CustomizableUIInternal.createWidget(aProperties) + ); + }, + /** + * Destroy a widget + * + * If the widget is part of the default placements in an area, this will + * remove it from there. It will also remove any DOM instances. However, + * it will keep the widget in the placements for whatever area it was + * in at the time. You can remove it from there yourself by calling + * CustomizableUI.removeWidgetFromArea(aWidgetId). + * + * @param aWidgetId the ID of the widget to destroy + */ + destroyWidget(aWidgetId) { + CustomizableUIInternal.destroyWidget(aWidgetId); + }, + /** + * Get a wrapper object with information about the widget. + * The object provides the following properties + * (all read-only unless otherwise indicated): + * + * - id: the widget's ID; + * - type: the type of widget (button, view, custom). For + * XUL-provided widgets, this is always 'custom'; + * - provider: the provider type of the widget, id est one of + * PROVIDER_API or PROVIDER_XUL; + * - forWindow(w): a method to obtain a single window wrapper for a widget, + * in the window w passed as the only argument; + * - instances: an array of all instances (single window wrappers) + * of the widget. This array is NOT live; + * - areaType: the type of the widget's current area + * - isGroup: true; will be false for wrappers around single widget nodes; + * - source: for API-provided widgets, whether they are built-in to + * Firefox or add-on-provided; + * - disabled: for API-provided widgets, whether the widget is currently + * disabled. NB: this property is writable, and will toggle + * all the widgets' nodes' disabled states; + * - label: for API-provied widgets, the label of the widget; + * - tooltiptext: for API-provided widgets, the tooltip of the widget; + * - showInPrivateBrowsing: for API-provided widgets, whether the widget is + * visible in private browsing; + * + * Single window wrappers obtained through forWindow(someWindow) or from the + * instances array have the following properties + * (all read-only unless otherwise indicated): + * + * - id: the widget's ID; + * - type: the type of widget (button, view, custom). For + * XUL-provided widgets, this is always 'custom'; + * - provider: the provider type of the widget, id est one of + * PROVIDER_API or PROVIDER_XUL; + * - node: reference to the corresponding DOM node; + * - anchor: the anchor on which to anchor panels opened from this + * node. This will point to the overflow chevron on + * overflowable toolbars if and only if your widget node + * is overflowed, to the anchor for the panel menu + * if your widget is inside the panel menu, and to the + * node itself in all other cases; + * - overflowed: boolean indicating whether the node is currently in the + * overflow panel of the toolbar; + * - isGroup: false; will be true for the group widget; + * - label: for API-provided widgets, convenience getter for the + * label attribute of the DOM node; + * - tooltiptext: for API-provided widgets, convenience getter for the + * tooltiptext attribute of the DOM node; + * - disabled: for API-provided widgets, convenience getter *and setter* + * for the disabled state of this single widget. Note that + * you may prefer to use the group wrapper's getter/setter + * instead. + * + * @param aWidgetId the ID of the widget whose information you need + * @return a wrapper around the widget as described above, or null if the + * widget is known not to exist (anymore). NB: non-null return + * is no guarantee the widget exists because we cannot know in + * advance if a XUL widget exists or not. + */ + getWidget(aWidgetId) { + return CustomizableUIInternal.wrapWidget(aWidgetId); + }, + /** + * Get an array of widget wrappers (see getWidget) for all the widgets + * which are currently not in any area (so which are in the palette). + * + * @param aWindowPalette the palette (and by extension, the window) in which + * CustomizableUI should look. This matters because of + * course XUL-provided widgets could be available in + * some windows but not others, and likewise + * API-provided widgets might not exist in a private + * window (because of the showInPrivateBrowsing + * property). + * + * @return an array of widget wrappers (see getWidget) + */ + getUnusedWidgets(aWindowPalette) { + return CustomizableUIInternal.getUnusedWidgets(aWindowPalette).map( + CustomizableUIInternal.wrapWidget, + CustomizableUIInternal + ); + }, + /** + * Get an array of all the widget IDs placed in an area. + * Modifying the array will not affect CustomizableUI. + * + * @param aArea the ID of the area whose placements you want to obtain. + * @return an array containing the widget IDs that are in the area. + * + * NB: will throw if called too early (before placements have been fetched) + * or if the area is not currently known to CustomizableUI. + */ + getWidgetIdsInArea(aArea) { + if (!gAreas.has(aArea)) { + throw new Error("Unknown customization area: " + aArea); + } + if (!gPlacements.has(aArea)) { + throw new Error("Area not yet restored"); + } + + // We need to clone this, as we don't want to let consumers muck with placements + return [...gPlacements.get(aArea)]; + }, + /** + * Get an array of widget wrappers for all the widgets in an area. This is + * the same as calling getWidgetIdsInArea and .map() ing the result through + * CustomizableUI.getWidget. Careful: this means that if there are IDs in there + * which don't have corresponding DOM nodes, there might be nulls in this array, + * or items for which wrapper.forWindow(win) will return null. + * + * @param aArea the ID of the area whose widgets you want to obtain. + * @return an array of widget wrappers and/or null values for the widget IDs + * placed in an area. + * + * NB: will throw if called too early (before placements have been fetched) + * or if the area is not currently known to CustomizableUI. + */ + getWidgetsInArea(aArea) { + return this.getWidgetIdsInArea(aArea).map( + CustomizableUIInternal.wrapWidget, + CustomizableUIInternal + ); + }, + + /** + * Ensure the customizable widget that matches up with this view node + * will get the right subview showing/shown/hiding/hidden events when + * they fire. + * @param aViewNode the view node to add listeners to if they haven't + * been added already. + */ + ensureSubviewListeners(aViewNode) { + return CustomizableUIInternal.ensureSubviewListeners(aViewNode); + }, + /** + * Obtain an array of all the area IDs known to CustomizableUI. + * This array is created for you, so is modifiable without CustomizableUI + * being affected. + */ + get areas() { + return [...gAreas.keys()]; + }, + /** + * Check what kind of area (toolbar or menu panel) an area is. This is + * useful if you have a widget that needs to behave differently depending + * on its location. Note that widget wrappers have a convenience getter + * property (areaType) for this purpose. + * + * @param aArea the ID of the area whose type you want to know + * @return TYPE_TOOLBAR or TYPE_PANEL depending on the area, null if + * the area is unknown. + */ + getAreaType(aArea) { + let area = gAreas.get(aArea); + return area ? area.get("type") : null; + }, + /** + * Check if a toolbar is collapsed by default. + * + * @param aArea the ID of the area whose default-collapsed state you want to know. + * @return `true` or `false` depending on the area, null if the area is unknown, + * or its collapsed state cannot normally be controlled by the user + */ + isToolbarDefaultCollapsed(aArea) { + let area = gAreas.get(aArea); + return area ? area.get("defaultCollapsed") : null; + }, + /** + * Obtain the DOM node that is the customize target for an area in a + * specific window. + * + * Areas can have a customization target that does not correspond to the + * node itself. In particular, toolbars that have a customizationtarget + * attribute set will have their customization target set to that node. + * This means widgets will end up in the customization target, not in the + * DOM node with the ID that corresponds to the area ID. This is useful + * because it lets you have fixed content in a toolbar (e.g. the panel + * menu item in the navbar) and have all the customizable widgets use + * the customization target. + * + * Using this API yourself is discouraged; you should generally not need + * to be asking for the DOM container node used for a particular area. + * In particular, if you're wanting to check it in relation to a widget's + * node, your DOM node might not be a direct child of the customize target + * in a window if, for instance, the window is in customization mode, or if + * this is an overflowable toolbar and the widget has been overflowed. + * + * @param aArea the ID of the area whose customize target you want to have + * @param aWindow the window where you want to fetch the DOM node. + * @return the customize target DOM node for aArea in aWindow + */ + getCustomizeTargetForArea(aArea, aWindow) { + return CustomizableUIInternal.getCustomizeTargetForArea(aArea, aWindow); + }, + /** + * Reset the customization state back to its default. + * + * This is the nuclear option. You should never call this except if the user + * explicitly requests it. Firefox does this when the user clicks the + * "Restore Defaults" button in customize mode. + */ + reset() { + CustomizableUIInternal.reset(); + }, + + /** + * Undo the previous reset, can only be called immediately after a reset. + * @return a promise that will be resolved when the operation is complete. + */ + undoReset() { + CustomizableUIInternal.undoReset(); + }, + + /** + * Remove a custom toolbar added in a previous version of Firefox or using + * an add-on. NB: only works on the customizable toolbars generated by + * the toolbox itself. Intended for use from CustomizeMode, not by + * other consumers. + * @param aToolbarId the ID of the toolbar to remove + */ + removeExtraToolbar(aToolbarId) { + CustomizableUIInternal.removeExtraToolbar(aToolbarId); + }, + + /** + * Can the last Restore Defaults operation be undone. + * + * @return A boolean stating whether an undo of the + * Restore Defaults can be performed. + */ + get canUndoReset() { + return ( + gUIStateBeforeReset.uiCustomizationState != null || + gUIStateBeforeReset.drawInTitlebar != null || + gUIStateBeforeReset.currentTheme != null || + gUIStateBeforeReset.autoTouchMode != null || + gUIStateBeforeReset.uiDensity != null + ); + }, + + /** + * Get the placement of a widget. This is by far the best way to obtain + * information about what the state of your widget is. The internals of + * this call are cheap (no DOM necessary) and you will know where the user + * has put your widget. + * + * @param aWidgetId the ID of the widget whose placement you want to know + * @return + * { + * area: "somearea", // The ID of the area where the widget is placed + * position: 42 // the index in the placements array corresponding to + * // your widget. + * } + * + * OR + * + * null // if the widget is not placed anywhere (ie in the palette) + */ + getPlacementOfWidget(aWidgetId, aOnlyRegistered = true, aDeadAreas = false) { + return CustomizableUIInternal.getPlacementOfWidget( + aWidgetId, + aOnlyRegistered, + aDeadAreas + ); + }, + /** + * Check if a widget can be removed from the area it's in. + * + * Note that if you're wanting to move the widget somewhere, you should + * generally be checking canWidgetMoveToArea, because that will return + * true if the widget is already in the area where you want to move it (!). + * + * NB: oh, also, this method might lie if the widget in question is a + * XUL-provided widget and there are no windows open, because it + * can obviously not check anything in this case. It will return + * true. You will be able to move the widget elsewhere. However, + * once the user reopens a window, the widget will move back to its + * 'proper' area automagically. + * + * @param aWidgetId a widget ID or DOM node to check + * @return true if the widget can be removed from its area, + * false otherwise. + */ + isWidgetRemovable(aWidgetId) { + return CustomizableUIInternal.isWidgetRemovable(aWidgetId); + }, + /** + * Check if a widget can be moved to a particular area. Like + * isWidgetRemovable but better, because it'll return true if the widget + * is already in the right area. + * + * @param aWidgetId the widget ID or DOM node you want to move somewhere + * @param aArea the area ID you want to move it to. This can also be + * AREA_NO_AREA to see if the widget can move to the + * customization palette, whether it's removable or not. + * @return true if this is possible, false if it is not. The same caveats as + * for isWidgetRemovable apply, however, if no windows are open. + */ + canWidgetMoveToArea(aWidgetId, aArea) { + return CustomizableUIInternal.canWidgetMoveToArea(aWidgetId, aArea); + }, + /** + * Whether we're in a default state. Note that non-removable non-default + * widgets and non-existing widgets are not taken into account in determining + * whether we're in the default state. + * + * NB: this is a property with a getter. The getter is NOT cheap, because + * it does smart things with non-removable non-default items, non-existent + * items, and so forth. Please don't call unless necessary. + */ + get inDefaultState() { + return CustomizableUIInternal.inDefaultState; + }, + + /** + * Set a toolbar's visibility state in all windows. + * @param aToolbarId the toolbar whose visibility should be adjusted + * @param aIsVisible whether the toolbar should be visible + */ + setToolbarVisibility(aToolbarId, aIsVisible) { + CustomizableUIInternal.setToolbarVisibility(aToolbarId, aIsVisible); + }, + + /** + * Returns a Set with the IDs of any registered toolbar areas that are + * currently collapsed in a particular window. Menubars that are set to + * autohide and are in the temporary "open" state are still considered + * collapsed by default. + * + * @param {Window} window The browser window to check for collapsed toolbars. + * @return {Set} + */ + getCollapsedToolbarIds(window) { + return CustomizableUIInternal.getCollapsedToolbarIds(window); + }, + + /** + * DEPRECATED! Use fluent instead. + * + * Get a localized property off a (widget?) object. + * + * NB: this is unlikely to be useful unless you're in Firefox code, because + * this code uses the builtin widget stringbundle, and can't be told + * to use add-on-provided strings. It's mainly here as convenience for + * custom builtin widgets that build their own DOM but use the same + * stringbundle as the other builtin widgets. + * + * @param aWidget the object whose property we should use to fetch a + * localizable string; + * @param aProp the property on the object to use for the fetching; + * @param aFormatArgs (optional) any extra arguments to use for a formatted + * string; + * @param aDef (optional) the default to return if we don't find the + * string in the stringbundle; + * + * @return the localized string, or aDef if the string isn't in the bundle. + * If no default is provided, + * if aProp exists on aWidget, we'll return that, + * otherwise we'll return the empty string + * + */ + getLocalizedProperty(aWidget, aProp, aFormatArgs, aDef) { + return CustomizableUIInternal.getLocalizedProperty( + aWidget, + aProp, + aFormatArgs, + aDef + ); + }, + /** + * Utility function to detect, find and set a keyboard shortcut for a menuitem + * or (toolbar)button. + * + * @param aShortcutNode the XUL node where the shortcut will be derived from; + * @param aTargetNode (optional) the XUL node on which the `shortcut` + * attribute will be set. If NULL, the shortcut will be + * set on aShortcutNode; + */ + addShortcut(aShortcutNode, aTargetNode) { + return CustomizableUIInternal.addShortcut(aShortcutNode, aTargetNode); + }, + /** + * Given a node, walk up to the first panel in its ancestor chain, and + * close it. + * + * @param aNode a node whose panel should be closed; + */ + hidePanelForNode(aNode) { + CustomizableUIInternal.hidePanelForNode(aNode); + }, + /** + * Check if a widget is a "special" widget: a spring, spacer or separator. + * + * @param aWidgetId the widget ID to check. + * @return true if the widget is 'special', false otherwise. + */ + isSpecialWidget(aWidgetId) { + return CustomizableUIInternal.isSpecialWidget(aWidgetId); + }, + /** + * Check if a widget is provided by an extension. This effectively checks + * whether `webExtension: true` passed when the widget was being created. + * + * If the widget being referred to hasn't yet been created, or has been + * destroyed, we fallback to checking the ID for the "-browser-action" + * suffix. + * + * @param aWidgetId the widget ID to check. + * @return true if the widget was provided by an extension, false otherwise. + */ + isWebExtensionWidget(aWidgetId) { + let widget = CustomizableUI.getWidget(aWidgetId); + return widget?.webExtension || aWidgetId.endsWith("-browser-action"); + }, + /** + * Add listeners to a panel that will close it. For use from the menu panel + * and overflowable toolbar implementations, unlikely to be useful for + * consumers. + * + * @param aPanel the panel to which listeners should be attached. + */ + addPanelCloseListeners(aPanel) { + CustomizableUIInternal.addPanelCloseListeners(aPanel); + }, + /** + * Remove close listeners that have been added to a panel with + * addPanelCloseListeners. For use from the menu panel and overflowable + * toolbar implementations, unlikely to be useful for consumers. + * + * @param aPanel the panel from which listeners should be removed. + */ + removePanelCloseListeners(aPanel) { + CustomizableUIInternal.removePanelCloseListeners(aPanel); + }, + /** + * Notify listeners a widget is about to be dragged to an area. For use from + * Customize Mode only, do not use otherwise. + * + * @param aWidgetId the ID of the widget that is being dragged to an area. + * @param aArea the ID of the area to which the widget is being dragged. + */ + onWidgetDrag(aWidgetId, aArea) { + CustomizableUIInternal.notifyListeners("onWidgetDrag", aWidgetId, aArea); + }, + /** + * Notify listeners that a window is entering customize mode. For use from + * Customize Mode only, do not use otherwise. + * @param aWindow the window entering customize mode + */ + notifyStartCustomizing(aWindow) { + CustomizableUIInternal.notifyListeners("onCustomizeStart", aWindow); + }, + /** + * Notify listeners that a window is exiting customize mode. For use from + * Customize Mode only, do not use otherwise. + * @param aWindow the window exiting customize mode + */ + notifyEndCustomizing(aWindow) { + CustomizableUIInternal.notifyListeners("onCustomizeEnd", aWindow); + }, + + /** + * Notify toolbox(es) of a particular event. If you don't pass aWindow, + * all toolboxes will be notified. For use from Customize Mode only, + * do not use otherwise. + * @param aEvent the name of the event to send. + * @param aDetails optional, the details of the event. + * @param aWindow optional, the window in which to send the event. + */ + dispatchToolboxEvent(aEvent, aDetails = {}, aWindow = null) { + CustomizableUIInternal.dispatchToolboxEvent(aEvent, aDetails, aWindow); + }, + + /** + * Check whether an area is overflowable. + * + * @param aAreaId the ID of an area to check for overflowable-ness + * @return true if the area is overflowable, false otherwise. + */ + isAreaOverflowable(aAreaId) { + let area = gAreas.get(aAreaId); + return area + ? area.get("type") == this.TYPE_TOOLBAR && area.get("overflowable") + : false; + }, + /** + * Obtain a string indicating the place of an element. This is intended + * for use from customize mode; You should generally use getPlacementOfWidget + * instead, which is cheaper because it does not use the DOM. + * + * @param aElement the DOM node whose place we need to check + * @return "toolbar" if the node is in a toolbar, "panel" if it is in the + * menu panel, "palette" if it is in the (visible!) customization + * palette, undefined otherwise. + */ + getPlaceForItem(aElement) { + let place; + let node = aElement; + while (node && !place) { + if (node.localName == "toolbar") { + place = "toolbar"; + } else if (node.id == CustomizableUI.AREA_FIXED_OVERFLOW_PANEL) { + place = "panel"; + } else if (node.id == "customization-palette") { + place = "palette"; + } + + node = node.parentNode; + } + return place; + }, + + /** + * Check if a toolbar is builtin or not. + * @param aToolbarId the ID of the toolbar you want to check + */ + isBuiltinToolbar(aToolbarId) { + return CustomizableUIInternal._builtinToolbars.has(aToolbarId); + }, + + /** + * Create an instance of a spring, spacer or separator. + * @param aId the type of special widget (spring, spacer or separator) + * @param aDocument the document in which to create it. + */ + createSpecialWidget(aId, aDocument) { + return CustomizableUIInternal.createSpecialWidget(aId, aDocument); + }, + + /** + * Fills a submenu with menu items. + * @param aMenuItems the menu items to display. + * @param aSubview the subview to fill. + */ + fillSubviewFromMenuItems(aMenuItems, aSubview) { + let attrs = [ + "oncommand", + "onclick", + "label", + "key", + "disabled", + "command", + "observes", + "hidden", + "class", + "origin", + "image", + "checked", + "style", + ]; + + // Use ownerGlobal.document to ensure we get the right doc even for + // elements in template tags. + let doc = aSubview.ownerGlobal.document; + let fragment = doc.createDocumentFragment(); + for (let menuChild of aMenuItems) { + if (menuChild.hidden) { + continue; + } + + let subviewItem; + if (menuChild.localName == "menuseparator") { + // Don't insert duplicate or leading separators. This can happen if there are + // menus (which we don't copy) above the separator. + if ( + !fragment.lastElementChild || + fragment.lastElementChild.localName == "toolbarseparator" + ) { + continue; + } + subviewItem = doc.createXULElement("toolbarseparator"); + } else if (menuChild.localName == "menuitem") { + subviewItem = doc.createXULElement("toolbarbutton"); + CustomizableUI.addShortcut(menuChild, subviewItem); + + let item = menuChild; + if (!item.hasAttribute("onclick")) { + subviewItem.addEventListener("click", event => { + let newEvent = new doc.defaultView.MouseEvent(event.type, event); + + // Telemetry should only pay attention to the original event. + lazy.BrowserUsageTelemetry.ignoreEvent(newEvent); + item.dispatchEvent(newEvent); + }); + } + + if (!item.hasAttribute("oncommand")) { + subviewItem.addEventListener("command", event => { + let newEvent = doc.createEvent("XULCommandEvent"); + newEvent.initCommandEvent( + event.type, + event.bubbles, + event.cancelable, + event.view, + event.detail, + event.ctrlKey, + event.altKey, + event.shiftKey, + event.metaKey, + 0, + event.sourceEvent, + 0 + ); + + // Telemetry should only pay attention to the original event. + lazy.BrowserUsageTelemetry.ignoreEvent(newEvent); + item.dispatchEvent(newEvent); + }); + } + } else { + continue; + } + for (let attr of attrs) { + let attrVal = menuChild.getAttribute(attr); + if (attrVal) { + subviewItem.setAttribute(attr, attrVal); + } + } + // We do this after so the .subviewbutton class doesn't get overriden. + if (menuChild.localName == "menuitem") { + subviewItem.classList.add("subviewbutton"); + } + + // We make it possible to supply an alternative Fluent key when cloning + // this menuitem into the AppMenu or panel contexts. This is because + // we often use Title Case in menuitems in native menus, but want to use + // Sentence case in the AppMenu / panels. + let l10nId = menuChild.getAttribute("appmenu-data-l10n-id"); + if (l10nId) { + doc.l10n.setAttributes(subviewItem, l10nId); + } + + fragment.appendChild(subviewItem); + } + aSubview.appendChild(fragment); + }, + + /** + * A helper function for clearing subviews. + * @param aSubview the subview to clear. + */ + clearSubview(aSubview) { + let parent = aSubview.parentNode; + // We'll take the container out of the document before cleaning it out + // to avoid reflowing each time we remove something. + parent.removeChild(aSubview); + + while (aSubview.firstChild) { + aSubview.firstChild.remove(); + } + + parent.appendChild(aSubview); + }, + + getCustomizationTarget(aElement) { + return CustomizableUIInternal.getCustomizationTarget(aElement); + }, + + getTestOnlyInternalProp(aProp) { + if (!Cu.isInAutomation) { + return null; + } + switch (aProp) { + case "CustomizableUIInternal": + return CustomizableUIInternal; + case "gAreas": + return gAreas; + case "gFuturePlacements": + return gFuturePlacements; + case "gPalette": + return gPalette; + case "gPlacements": + return gPlacements; + case "gSavedState": + return gSavedState; + case "gSeenWidgets": + return gSeenWidgets; + case "kVersion": + return kVersion; + } + return null; + }, + setTestOnlyInternalProp(aProp, aValue) { + if (!Cu.isInAutomation) { + return; + } + switch (aProp) { + case "gSavedState": + gSavedState = aValue; + break; + case "kVersion": + kVersion = aValue; + break; + case "gDirty": + gDirty = aValue; + break; + } + }, +}; + +Object.freeze(CustomizableUI); +Object.freeze(CustomizableUI.windows); + +/** + * All external consumers of widgets are really interacting with these wrappers + * which provide a common interface. + */ + +/** + * WidgetGroupWrapper is the common interface for interacting with an entire + * widget group - AKA, all instances of a widget across a series of windows. + * This particular wrapper is only used for widgets created via the provider + * API. + */ +function WidgetGroupWrapper(aWidget) { + this.isGroup = true; + + const kBareProps = [ + "id", + "source", + "type", + "disabled", + "label", + "tooltiptext", + "showInPrivateBrowsing", + "viewId", + "disallowSubView", + "webExtension", + ]; + for (let prop of kBareProps) { + let propertyName = prop; + this.__defineGetter__(propertyName, () => aWidget[propertyName]); + } + + this.__defineGetter__("provider", () => CustomizableUI.PROVIDER_API); + + this.__defineSetter__("disabled", function (aValue) { + aValue = !!aValue; + aWidget.disabled = aValue; + for (let [, instance] of aWidget.instances) { + instance.disabled = aValue; + } + }); + + this.forWindow = function WidgetGroupWrapper_forWindow(aWindow) { + let wrapperMap; + if (!gSingleWrapperCache.has(aWindow)) { + wrapperMap = new Map(); + gSingleWrapperCache.set(aWindow, wrapperMap); + } else { + wrapperMap = gSingleWrapperCache.get(aWindow); + } + if (wrapperMap.has(aWidget.id)) { + return wrapperMap.get(aWidget.id); + } + + let instance = aWidget.instances.get(aWindow.document); + if (!instance) { + instance = CustomizableUIInternal.buildWidget(aWindow.document, aWidget); + } + + let wrapper = new WidgetSingleWrapper(aWidget, instance); + wrapperMap.set(aWidget.id, wrapper); + return wrapper; + }; + + this.__defineGetter__("instances", function () { + // Can't use gBuildWindows here because some areas load lazily: + let placement = CustomizableUIInternal.getPlacementOfWidget(aWidget.id); + if (!placement) { + return []; + } + let area = placement.area; + let buildAreas = gBuildAreas.get(area); + if (!buildAreas) { + return []; + } + return Array.from(buildAreas, node => this.forWindow(node.ownerGlobal)); + }); + + this.__defineGetter__("areaType", function () { + let areaProps = gAreas.get(aWidget.currentArea); + return areaProps && areaProps.get("type"); + }); + + Object.freeze(this); +} + +/** + * A WidgetSingleWrapper is a wrapper around a single instance of a widget in + * a particular window. + */ +function WidgetSingleWrapper(aWidget, aNode) { + this.isGroup = false; + + this.node = aNode; + this.provider = CustomizableUI.PROVIDER_API; + + const kGlobalProps = ["id", "type"]; + for (let prop of kGlobalProps) { + this[prop] = aWidget[prop]; + } + + const kNodeProps = ["label", "tooltiptext"]; + for (let prop of kNodeProps) { + let propertyName = prop; + // Look at the node for these, instead of the widget data, to ensure the + // wrapper always reflects this live instance. + this.__defineGetter__(propertyName, () => aNode.getAttribute(propertyName)); + } + + this.__defineGetter__("disabled", () => aNode.disabled); + this.__defineSetter__("disabled", function (aValue) { + aNode.disabled = !!aValue; + }); + + this.__defineGetter__("anchor", function () { + let anchorId; + // First check for an anchor for the area: + let placement = CustomizableUIInternal.getPlacementOfWidget(aWidget.id); + if (placement) { + anchorId = gAreas.get(placement.area).get("anchor"); + } + if (!anchorId) { + anchorId = aNode.getAttribute("cui-anchorid"); + } + if (!anchorId) { + anchorId = aNode.getAttribute("view-button-id"); + } + if (anchorId) { + return aNode.ownerDocument.getElementById(anchorId); + } + if (aWidget.type == "button-and-view") { + return aNode.lastElementChild; + } + return aNode; + }); + + this.__defineGetter__("overflowed", function () { + return aNode.getAttribute("overflowedItem") == "true"; + }); + + Object.freeze(this); +} + +/** + * XULWidgetGroupWrapper is the common interface for interacting with an entire + * widget group - AKA, all instances of a widget across a series of windows. + * This particular wrapper is only used for widgets created via the old-school + * XUL method (overlays, or programmatically injecting toolbaritems, or other + * such things). + */ +// XXXunf Going to need to hook this up to some events to keep it all live. +function XULWidgetGroupWrapper(aWidgetId) { + this.isGroup = true; + this.id = aWidgetId; + this.type = "custom"; + // XUL Widgets can never be provided by extensions. + this.webExtension = false; + this.provider = CustomizableUI.PROVIDER_XUL; + + this.forWindow = function XULWidgetGroupWrapper_forWindow(aWindow) { + let wrapperMap; + if (!gSingleWrapperCache.has(aWindow)) { + wrapperMap = new Map(); + gSingleWrapperCache.set(aWindow, wrapperMap); + } else { + wrapperMap = gSingleWrapperCache.get(aWindow); + } + if (wrapperMap.has(aWidgetId)) { + return wrapperMap.get(aWidgetId); + } + + let instance = aWindow.document.getElementById(aWidgetId); + if (!instance) { + // Toolbar palettes aren't part of the document, so elements in there + // won't be found via document.getElementById(). + instance = aWindow.gNavToolbox.palette.getElementsByAttribute( + "id", + aWidgetId + )[0]; + } + + let wrapper = new XULWidgetSingleWrapper( + aWidgetId, + instance, + aWindow.document + ); + wrapperMap.set(aWidgetId, wrapper); + return wrapper; + }; + + this.__defineGetter__("areaType", function () { + let placement = CustomizableUIInternal.getPlacementOfWidget(aWidgetId); + if (!placement) { + return null; + } + + let areaProps = gAreas.get(placement.area); + return areaProps && areaProps.get("type"); + }); + + this.__defineGetter__("instances", function () { + return Array.from(gBuildWindows, wins => this.forWindow(wins[0])); + }); + + Object.freeze(this); +} + +/** + * A XULWidgetSingleWrapper is a wrapper around a single instance of a XUL + * widget in a particular window. + */ +function XULWidgetSingleWrapper(aWidgetId, aNode, aDocument) { + this.isGroup = false; + + this.id = aWidgetId; + this.type = "custom"; + this.provider = CustomizableUI.PROVIDER_XUL; + + let weakDoc = Cu.getWeakReference(aDocument); + // If we keep a strong ref, the weak ref will never die, so null it out: + aDocument = null; + + this.__defineGetter__("node", function () { + // If we've set this to null (further down), we're sure there's nothing to + // be gotten here, so bail out early: + if (!weakDoc) { + return null; + } + if (aNode) { + // Return the last known node if it's still in the DOM... + if (aNode.isConnected) { + return aNode; + } + // ... or the toolbox + let toolbox = aNode.ownerGlobal.gNavToolbox; + if (toolbox && toolbox.palette && aNode.parentNode == toolbox.palette) { + return aNode; + } + // If it isn't, clear the cached value and fall through to the "slow" case: + aNode = null; + } + + let doc = weakDoc.get(); + if (doc) { + // Store locally so we can cache the result: + aNode = CustomizableUIInternal.findWidgetInWindow( + aWidgetId, + doc.defaultView + ); + return aNode; + } + // The weakref to the document is dead, we're done here forever more: + weakDoc = null; + return null; + }); + + this.__defineGetter__("anchor", function () { + let anchorId; + // First check for an anchor for the area: + let placement = CustomizableUIInternal.getPlacementOfWidget(aWidgetId); + if (placement) { + anchorId = gAreas.get(placement.area).get("anchor"); + } + + let node = this.node; + if (!anchorId && node) { + anchorId = node.getAttribute("cui-anchorid"); + } + + return anchorId && node + ? node.ownerDocument.getElementById(anchorId) + : node; + }); + + this.__defineGetter__("overflowed", function () { + let node = this.node; + if (!node) { + return false; + } + return node.getAttribute("overflowedItem") == "true"; + }); + + Object.freeze(this); +} + +/** + * OverflowableToolbar is a class that gives a the ability to send + * toolbar items that are "overflowable" to lists in separate panels if and + * when the toolbar shrinks enough so that those items overflow out of bounds. + * Secondly, this class manages moving things out from those panels and back + * into the toolbar once it underflows and has the space to accommodate the + * items that had originally overflowed out. + * + * There are two panels that toolbar items can be overflowed to: + * + * 1. The default items overflow panel + * This is where built-in default toolbar items will go to. + * 2. The Unified Extensions panel + * This is where browser_action toolbar buttons created by extensions will + * go to if the Unified Extensions UI is enabled - otherwise, those items will + * go to the default items overflow panel. + * + * Finally, OverflowableToolbar manages the showing of the default items + * overflow panel when the associated anchor is clicked or dragged over. The + * Unified Extensions panel is managed separately by the extension code. + * + * In theory, we could have multiple overflowable toolbars, but in practice, + * only the nav-bar (CustomizableUI.AREA_NAVBAR) makes use of this class. + */ +class OverflowableToolbar { + /** + * The OverflowableToolbar class is constructed during browser window + * creation, but to optimize for window painting, we defer most work until + * after the window has painted. This property is set to true once + * initialization has completed. + * + * @type {boolean} + */ + #initialized = false; + + /** + * A reference to the that is overflowable. + * + * @type {Element} + */ + #toolbar = null; + + /** + * A reference to the part of the that accepts CustomizableUI + * widgets. + * + * @type {Element} + */ + #target = null; + + /** + * A mapping from the ID of a toolbar item that has overflowed to the width + * that the toolbar item occupied in the toolbar at the time of overflow. Any + * item that is currently overflowed will have an entry in this map. + * + * @type {Map} + */ + #overflowedInfo = new Map(); + + /** + * The set of overflowed DOM nodes that were hidden at the time of overflowing. + */ + #hiddenOverflowedNodes = new WeakSet(); + + /** + * True if the overflowable toolbar is actively handling overflows and + * underflows. This value is set internally by the private #enable() and + * #disable() methods. + * + * @type {boolean} + */ + #enabled = true; + + /** + * A reference to the element that overflowed toolbar items will be + * appended to as children upon overflow. + * + * @type {Element} + */ + #defaultList = null; + + /** + * A reference to the button that opens the overflow panel. This is also + * the element that the panel will anchor to. + * + * @type {Element} + */ + #defaultListButton = null; + + /** + * A reference to the overflow panel that contains the #defaultList + * element. + * + * @type {Element} + */ + #defaultListPanel = null; + + /** + * A reference to the the element that overflowed extension browser action + * toolbar items will be appended to as children upon overflow if the + * Unified Extension UI is enabled. This is created lazily and might be null, + * so you should use the #webExtList memoizing getter instead to get this. + * + * @type {Element|null} + */ + #webExtListRef = null; + + /** + * An empty object that is created in #checkOverflow to identify individual + * calls to #checkOverflow and avoid re-entrancy (since #checkOverflow is + * asynchronous, and in theory, could be called multiple times before any of + * those times have a chance to fully exit). + * + * @type {Object} + */ + #checkOverflowHandle = null; + + /** + * A timeout ID returned by setTimeout that identifies a timeout function that + * runs to hide the #defaultListPanel if the user happened to open the panel by dragging + * over the #defaultListButton and then didn't hover any part of the #defaultListPanel. + * + * @type {number} + */ + #hideTimeoutId = null; + + /** + * Public methods start here. + */ + + /** + * OverflowableToolbar constructor. This is run very early on in the lifecycle + * of a browser window, so it tries to defer most work to the init() method + * instead after first paint. + * + * Upon construction, a "overflowable" attribute will be set on the + * toolbar, set to the value of "true". + * + * Part of the API for OverflowableToolbar is declarative, in that it expects + * certain attributes to be set on the that is overflowable. + * Those attributes are: + * + * default-overflowbutton: + * The ID of the button that is used to open and anchor the overflow panel. + * default-overflowtarget: + * The ID of the element that overflowed items will be appended to as + * children. Note that the overflowed toolbar items are moved into and out + * of this overflow target, so it is definitely advisable to let + * OverflowableToolbar own managing the children of default-overflowtarget, + * and to not modify it outside of this class. + * default-overflowpanel: + * The ID of the that contains the default-overflowtarget. + * addon-webext-overflowbutton: + * The ID of the button that is used to open and anchor the Unified + * Extensions panel. + * addon-webext-overflowtarget: + * The ID of the element that overflowed extension toolbar buttons will + * be appended to as children if the Unified Extensions UI is enabled. + * Note that the overflowed toolbar items are moved into and out of this + * overflow target, so it is definitely advisable to let OverflowableToolbar + * own managing the children of addon-webext-overflowtarget, and to not + * modify it outside of this class. + * + * @param {Element} aToolbarNode The that will be overflowable. + * @throws {Error} Throws if the customization target of the toolbar somehow + * isn't a direct descendent of the toolbar. + */ + constructor(aToolbarNode) { + this.#toolbar = aToolbarNode; + this.#target = CustomizableUI.getCustomizationTarget(this.#toolbar); + if (this.#target.parentNode != this.#toolbar) { + throw new Error( + "Customization target must be a direct child of an overflowable toolbar." + ); + } + + this.#toolbar.setAttribute("overflowable", "true"); + let doc = this.#toolbar.ownerDocument; + this.#defaultList = doc.getElementById( + this.#toolbar.getAttribute("default-overflowtarget") + ); + this.#defaultList._customizationTarget = this.#defaultList; + + let window = this.#toolbar.ownerGlobal; + + if (window.gBrowserInit.delayedStartupFinished) { + this.init(); + } else { + Services.obs.addObserver(this, "browser-delayed-startup-finished"); + } + } + + /** + * Does final initialization of the OverflowableToolbar after the window has + * first painted. This will also kick off the first check to see if overflow + * has already occurred at the time of initialization. + */ + init() { + let doc = this.#toolbar.ownerDocument; + let window = doc.defaultView; + window.addEventListener("resize", this); + window.gNavToolbox.addEventListener("customizationstarting", this); + window.gNavToolbox.addEventListener("aftercustomization", this); + + let defaultListButton = this.#toolbar.getAttribute( + "default-overflowbutton" + ); + this.#defaultListButton = doc.getElementById(defaultListButton); + this.#defaultListButton.addEventListener("mousedown", this); + this.#defaultListButton.addEventListener("keypress", this); + this.#defaultListButton.addEventListener("dragover", this); + this.#defaultListButton.addEventListener("dragend", this); + + let panelId = this.#toolbar.getAttribute("default-overflowpanel"); + this.#defaultListPanel = doc.getElementById(panelId); + this.#defaultListPanel.addEventListener("popuphiding", this); + CustomizableUIInternal.addPanelCloseListeners(this.#defaultListPanel); + + CustomizableUI.addListener(this); + + this.#checkOverflow(); + + this.#initialized = true; + } + + /** + * Almost the exact reverse of init(). This is called when the browser window + * is unloading. + */ + uninit() { + this.#toolbar.removeAttribute("overflowable"); + + if (!this.#initialized) { + Services.obs.removeObserver(this, "browser-delayed-startup-finished"); + return; + } + + this.#disable(); + + let window = this.#toolbar.ownerGlobal; + window.removeEventListener("resize", this); + window.gNavToolbox.removeEventListener("customizationstarting", this); + window.gNavToolbox.removeEventListener("aftercustomization", this); + this.#defaultListButton.removeEventListener("mousedown", this); + this.#defaultListButton.removeEventListener("keypress", this); + this.#defaultListButton.removeEventListener("dragover", this); + this.#defaultListButton.removeEventListener("dragend", this); + this.#defaultListPanel.removeEventListener("popuphiding", this); + + CustomizableUI.removeListener(this); + CustomizableUIInternal.removePanelCloseListeners(this.#defaultListPanel); + } + + /** + * Opens the overflow #defaultListPanel if it's not already open. If the panel is in + * the midst of hiding when this is called, the panel will be re-opened. + * + * @returns {Promise} + * @resolves {undefined} once the panel is open. + */ + show(aEvent) { + if (this.#defaultListPanel.state == "open") { + return Promise.resolve(); + } + return new Promise(resolve => { + let doc = this.#defaultListPanel.ownerDocument; + this.#defaultListPanel.hidden = false; + let multiview = this.#defaultListPanel.querySelector("panelmultiview"); + let mainViewId = multiview.getAttribute("mainViewId"); + let mainView = doc.getElementById(mainViewId); + let contextMenu = doc.getElementById(mainView.getAttribute("context")); + Services.els.addSystemEventListener(contextMenu, "command", this, true); + let anchor = this.#defaultListButton.icon; + + let popupshown = false; + this.#defaultListPanel.addEventListener( + "popupshown", + () => { + popupshown = true; + this.#defaultListPanel.addEventListener("dragover", this); + this.#defaultListPanel.addEventListener("dragend", this); + // Wait until the next tick to resolve so all popupshown + // handlers have a chance to run before our promise resolution + // handlers do. + Services.tm.dispatchToMainThread(resolve); + }, + { once: true } + ); + + let openPanel = () => { + // Ensure we update the gEditUIVisible flag when opening the popup, in + // case the edit controls are in it. + this.#defaultListPanel.addEventListener( + "popupshowing", + () => { + doc.defaultView.updateEditUIVisibility(); + }, + { once: true } + ); + + this.#defaultListPanel.addEventListener( + "popuphidden", + () => { + if (!popupshown) { + // The panel was hidden again before it was shown. This can break + // consumers waiting for the panel to show. So we try again. + openPanel(); + } + }, + { once: true } + ); + + lazy.PanelMultiView.openPopup( + this.#defaultListPanel, + anchor || this.#defaultListButton, + { + triggerEvent: aEvent, + } + ); + this.#defaultListButton.open = true; + }; + + openPanel(); + }); + } + + /** + * Exposes whether #checkOverflow is currently running. + * + * @returns {boolean} True if #checkOverflow is currently running. + */ + isHandlingOverflow() { + return !!this.#checkOverflowHandle; + } + + /** + * Finds the most appropriate place to insert toolbar item aNode if we've been + * asked to put it into the overflowable toolbar without being told exactly + * where. + * + * @param {Element} aNode The toolbar item being inserted. + * @returns {Array} [parent, nextNode] + * parent: {Element} The parent element that should contain aNode. + * nextNode: {Element|null} The node that should follow aNode after + * insertion, if any. If this is null, aNode should be placed at the end + * of parent. + */ + findOverflowedInsertionPoints(aNode) { + let newNodeCanOverflow = aNode.getAttribute("overflows") != "false"; + let areaId = this.#toolbar.id; + let placements = gPlacements.get(areaId); + let nodeIndex = placements.indexOf(aNode.id); + let nodeBeforeNewNodeIsOverflown = false; + + let loopIndex = -1; + // Loop through placements to find where to insert this item. + // As soon as we find an overflown widget, we will only + // insert in the overflow panel (this is why we check placements + // before the desired location for the new node). Once we pass + // the desired location of the widget, we look for placement ids + // that actually have DOM equivalents to insert before. If all + // else fails, we insert at the end of either the overflow list + // or the toolbar target. + while (++loopIndex < placements.length) { + let nextNodeId = placements[loopIndex]; + if (loopIndex > nodeIndex) { + // Note that if aNode is in a template, its `ownerDocument` is *not* + // going to be the browser.xhtml document, so we cannot rely on it. + let nextNode = this.#toolbar.ownerDocument.getElementById(nextNodeId); + // If the node we're inserting can overflow, and the next node + // in the toolbar is overflown, we should insert this node + // in the overflow panel before it. + if ( + newNodeCanOverflow && + this.#overflowedInfo.has(nextNodeId) && + nextNode && + nextNode.parentNode == this.#defaultList + ) { + return [this.#defaultList, nextNode]; + } + // Otherwise (if either we can't overflow, or the previous node + // wasn't overflown), and the next node is in the toolbar itself, + // insert the node in the toolbar. + if ( + (!nodeBeforeNewNodeIsOverflown || !newNodeCanOverflow) && + nextNode && + (nextNode.parentNode == this.#target || + // Also check if the next node is in a customization wrapper + // (toolbarpaletteitem). We don't need to do this for the + // overflow case because overflow is disabled in customize mode. + (nextNode.parentNode.localName == "toolbarpaletteitem" && + nextNode.parentNode.parentNode == this.#target)) + ) { + return [this.#target, nextNode]; + } + } else if ( + loopIndex < nodeIndex && + this.#overflowedInfo.has(nextNodeId) + ) { + nodeBeforeNewNodeIsOverflown = true; + } + } + + let overflowList = CustomizableUI.isWebExtensionWidget(aNode.id) + ? this.#webExtList + : this.#defaultList; + + let containerForAppending = + this.#overflowedInfo.size && newNodeCanOverflow + ? overflowList + : this.#target; + return [containerForAppending, null]; + } + + /** + * Allows callers to query for the current parent of a toolbar item that may + * or may not be overflowed. That parent will either be #defaultList, + * #webExtList (if it's an extension button) or #target. + * + * Note: It is assumed that the caller has verified that aNode is placed + * within the toolbar customizable area according to CustomizableUI. + * + * @param {Element} aNode the node that can be overflowed by this + * OverflowableToolbar. + * @returns {Element} The current containing node for aNode. + */ + getContainerFor(aNode) { + if (aNode.getAttribute("overflowedItem") == "true") { + return CustomizableUI.isWebExtensionWidget(aNode.id) + ? this.#webExtList + : this.#defaultList; + } + return this.#target; + } + + /** + * Private methods start here. + */ + + /** + * Handle overflow in the toolbar by moving items to the overflow menu. + */ + async #onOverflow() { + if (!this.#enabled) { + return; + } + + let win = this.#target.ownerGlobal; + let checkOverflowHandle = this.#checkOverflowHandle; + let webExtButtonID = this.#toolbar.getAttribute( + "addon-webext-overflowbutton" + ); + + let { isOverflowing, targetContentWidth } = await this.#getOverflowInfo(); + + // Stop if the window has closed or if we re-enter while waiting for + // layout. + if (win.closed || this.#checkOverflowHandle != checkOverflowHandle) { + lazy.log.debug("Window closed or another overflow handler started."); + return; + } + + let webExtList = this.#webExtList; + + let child = this.#target.lastElementChild; + while (child && isOverflowing) { + let prevChild = child.previousElementSibling; + + if (child.getAttribute("overflows") != "false") { + this.#overflowedInfo.set(child.id, targetContentWidth); + let { width: childWidth } = + win.windowUtils.getBoundsWithoutFlushing(child); + if (!childWidth) { + this.#hiddenOverflowedNodes.add(child); + } + + child.setAttribute("overflowedItem", true); + CustomizableUIInternal.ensureButtonContextMenu( + child, + this.#toolbar, + true + ); + CustomizableUIInternal.notifyListeners( + "onWidgetOverflow", + child, + this.#target + ); + + if (webExtList && CustomizableUI.isWebExtensionWidget(child.id)) { + child.setAttribute("cui-anchorid", webExtButtonID); + webExtList.insertBefore(child, webExtList.firstElementChild); + } else { + child.setAttribute("cui-anchorid", this.#defaultListButton.id); + this.#defaultList.insertBefore( + child, + this.#defaultList.firstElementChild + ); + if (!CustomizableUI.isSpecialWidget(child.id) && childWidth) { + this.#toolbar.setAttribute("overflowing", "true"); + } + } + } + child = prevChild; + ({ isOverflowing, targetContentWidth } = await this.#getOverflowInfo()); + // Stop if the window has closed or if we re-enter while waiting for + // layout. + if (win.closed || this.#checkOverflowHandle != checkOverflowHandle) { + lazy.log.debug("Window closed or another overflow handler started."); + return; + } + } + + win.UpdateUrlbarSearchSplitterState(); + } + + /** + * Returns a Promise that resolves to a an object that describes the state + * that this OverflowableToolbar is currently in. + * + * @returns {Promise} + * @resolves {Object} + * An object with the following properties: + * + * isOverflowing: {boolean} True if at least one toolbar item has overflowed + * into an overflow panel. + * targetContentWidth: {number} The total width of the items within the + * customization target area of the toolbar. + * totalAvailWidth: {number} The maximum width items in the toolbar may + * occupy before causing an overflow. + */ + async #getOverflowInfo() { + function getInlineSize(aElement) { + return aElement.getBoundingClientRect().width; + } + + function sumChildrenInlineSize(aParent, aExceptChild = null) { + let sum = 0; + for (let child of aParent.children) { + let style = win.getComputedStyle(child); + if ( + style.display == "none" || + win.XULPopupElement.isInstance(child) || + (style.position != "static" && style.position != "relative") + ) { + continue; + } + sum += parseFloat(style.marginLeft) + parseFloat(style.marginRight); + if (child != aExceptChild) { + sum += getInlineSize(child); + } + } + return sum; + } + + let win = this.#target.ownerGlobal; + let totalAvailWidth; + let targetWidth; + let targetChildrenWidth; + + await win.promiseDocumentFlushed(() => { + let style = win.getComputedStyle(this.#toolbar); + let toolbarChildrenWidth = sumChildrenInlineSize( + this.#toolbar, + this.#target + ); + totalAvailWidth = + getInlineSize(this.#toolbar) - + parseFloat(style.paddingLeft) - + parseFloat(style.paddingRight) - + toolbarChildrenWidth; + targetWidth = getInlineSize(this.#target); + targetChildrenWidth = + this.#target == this.#toolbar + ? toolbarChildrenWidth + : sumChildrenInlineSize(this.#target); + }); + + lazy.log.debug( + `Getting overflow info: target width: ${targetWidth} (${targetChildrenWidth}); avail: ${totalAvailWidth}` + ); + + // If the target has min-width: 0, their children might actually overflow + // it, so check for both cases explicitly. + let targetContentWidth = Math.max(targetWidth, targetChildrenWidth); + let isOverflowing = Math.floor(targetContentWidth) > totalAvailWidth; + return { isOverflowing, targetContentWidth, totalAvailWidth }; + } + + /** + * Tries to move toolbar items back to the toolbar from the overflow panel. + * + * @param {boolean} shouldMoveAllItems + * Whether we should move everything (e.g. because we're being + * disabled) + * @param {number} [totalAvailWidth=undefined] + * Optional; the width of the toolbar area in which we can put things. + * Some consumers pass this to avoid reflows. + * + * While there are items in the list, this width won't change, and so + * we can avoid flushing layout by providing it and/or caching it. + * Note that if `shouldMoveAllItems` is true, we never need the width + * anyway, and this value is ignored. + * @returns {Promise} + * @resolves {undefined} Once moving of items has completed. + */ + async #moveItemsBackToTheirOrigin(shouldMoveAllItems, totalAvailWidth) { + lazy.log.debug( + `Attempting to move ${shouldMoveAllItems ? "all" : "some"} items back` + ); + let placements = gPlacements.get(this.#toolbar.id); + let win = this.#target.ownerGlobal; + let doc = this.#target.ownerDocument; + let checkOverflowHandle = this.#checkOverflowHandle; + + let overflowedItemStack = Array.from(this.#overflowedInfo.entries()); + + for (let i = overflowedItemStack.length - 1; i >= 0; --i) { + let [childID, minSize] = overflowedItemStack[i]; + + // The item may have been placed inside of a that is lazily + // loaded and still in the view cache. PanelMultiView.getViewNode will + // do the work of checking the DOM for the child, and then falling back to + // the cache if that is the case. + let child = lazy.PanelMultiView.getViewNode(doc, childID); + + if (!child) { + this.#overflowedInfo.delete(childID); + continue; + } + + lazy.log.debug( + `Considering moving ${child.id} back, minSize: ${minSize}` + ); + + if (!shouldMoveAllItems && minSize) { + if (!totalAvailWidth) { + ({ totalAvailWidth } = await this.#getOverflowInfo()); + + // If the window has closed or if we re-enter because we were waiting + // for layout, stop. + if (win.closed || this.#checkOverflowHandle != checkOverflowHandle) { + lazy.log.debug("Window closed or #checkOverflow called again."); + return; + } + } + if (totalAvailWidth <= minSize) { + lazy.log.debug( + `Need ${minSize} but width is ${totalAvailWidth} so bailing` + ); + break; + } + } + + lazy.log.debug(`Moving ${child.id} back`); + this.#overflowedInfo.delete(child.id); + let beforeNodeIndex = placements.indexOf(child.id) + 1; + // If this is a skipintoolbarset item, meaning it doesn't occur in the placements list, + // we're inserting it at the end. This will mean first-in, first-out (more or less) + // leading to as little change in order as possible. + if (beforeNodeIndex == 0) { + beforeNodeIndex = placements.length; + } + let inserted = false; + for (; beforeNodeIndex < placements.length; beforeNodeIndex++) { + let beforeNode = this.#target.getElementsByAttribute( + "id", + placements[beforeNodeIndex] + )[0]; + // Unfortunately, XUL add-ons can mess with nodes after they are inserted, + // and this breaks the following code if the button isn't where we expect + // it to be (ie not a child of the target). In this case, ignore the node. + if (beforeNode && this.#target == beforeNode.parentElement) { + this.#target.insertBefore(child, beforeNode); + inserted = true; + break; + } + } + if (!inserted) { + this.#target.appendChild(child); + } + child.removeAttribute("cui-anchorid"); + child.removeAttribute("overflowedItem"); + CustomizableUIInternal.ensureButtonContextMenu(child, this.#target); + CustomizableUIInternal.notifyListeners( + "onWidgetUnderflow", + child, + this.#target + ); + } + + win.UpdateUrlbarSearchSplitterState(); + + let defaultListItems = Array.from(this.#defaultList.children); + if ( + defaultListItems.every( + item => + CustomizableUI.isSpecialWidget(item.id) || + this.#hiddenOverflowedNodes.has(item) + ) + ) { + this.#toolbar.removeAttribute("overflowing"); + } + } + + /** + * Checks to see if there are overflowable items within the customization + * target of the toolbar that should be moved into the overflow panel, and + * if there are, moves them. + * + * Note that since this is an async function that can be called in bursts + * by resize events on the window, this function is often re-called even + * when a prior call hasn't yet resolved. In that situation, the older calls + * resolve early without doing any work and leave any DOM manipulation to the + * most recent call. + * + * This function is a no-op if the OverflowableToolbar is disabled or the + * DOM fullscreen UI is currently being used. + * + * @returns {Promise} + * @resolves {undefined} Once any movement of toolbar items has completed. + */ + async #checkOverflow() { + if (!this.#enabled) { + return; + } + + let win = this.#target.ownerGlobal; + if (win.document.documentElement.hasAttribute("inDOMFullscreen")) { + // Toolbars are hidden and cannot be made visible in DOM fullscreen mode + // so there's nothing to do here. + return; + } + + let checkOverflowHandle = (this.#checkOverflowHandle = {}); + + lazy.log.debug("Checking overflow"); + let { isOverflowing, totalAvailWidth } = await this.#getOverflowInfo(); + if (win.closed || this.#checkOverflowHandle != checkOverflowHandle) { + return; + } + + if (isOverflowing) { + await this.#onOverflow(); + } else { + await this.#moveItemsBackToTheirOrigin(false, totalAvailWidth); + } + + if (checkOverflowHandle == this.#checkOverflowHandle) { + this.#checkOverflowHandle = null; + } + } + + /** + * Makes the OverflowableToolbar inert and moves all overflowable items back + * into the customization target of the toolbar. + */ + #disable() { + // Abort any ongoing overflow check. #enable() will #checkOverflow() + // anyways, so this is enough. + this.#checkOverflowHandle = {}; + this.#moveItemsBackToTheirOrigin(true); + this.#enabled = false; + } + + /** + * Puts the OverflowableToolbar into the enabled state and then checks to see + * if any of the items in the customization target should be overflowed into + * the overflow panel list. + */ + #enable() { + this.#enabled = true; + this.#checkOverflow(); + } + + /** + * Shows the overflow panel and sets a timeout to automatically re-hide the + * panel if it is not being hovered. + */ + #showWithTimeout() { + const OVERFLOW_PANEL_HIDE_DELAY_MS = 500; + + this.show().then(() => { + let window = this.#toolbar.ownerGlobal; + if (this.#hideTimeoutId) { + window.clearTimeout(this.#hideTimeoutId); + } + this.#hideTimeoutId = window.setTimeout(() => { + if (!this.#defaultListPanel.firstElementChild.matches(":hover")) { + lazy.PanelMultiView.hidePopup(this.#defaultListPanel); + } + }, OVERFLOW_PANEL_HIDE_DELAY_MS); + }); + } + + /** + * Gets and caches a reference to the DOM node with the ID set as the value + * of addon-webext-overflowtarget. If a cache already exists, that's returned + * instead. If addon-webext-overflowtarget has no value, null is returned. + * + * @returns {Element|null} the list that overflowed extension toolbar + * buttons should go to if the Unified Extensions UI is enabled, or null + * if no such list exists. + */ + get #webExtList() { + if (!this.#webExtListRef) { + let targetID = this.#toolbar.getAttribute("addon-webext-overflowtarget"); + if (!targetID) { + throw new Error( + "addon-webext-overflowtarget was not defined on the " + + `overflowable toolbar with id: ${this.#toolbar.id}` + ); + } + let win = this.#toolbar.ownerGlobal; + let { panel } = win.gUnifiedExtensions; + this.#webExtListRef = panel.querySelector(`#${targetID}`); + } + return this.#webExtListRef; + } + + /** + * Returns true if aNode is not null and is one of either this.#webExtList or + * this.#defaultList. + * + * @param {DOMElement} aNode The node to test. + * @returns {boolean} + */ + #isOverflowList(aNode) { + return aNode == this.#defaultList || aNode == this.#webExtList; + } + + /** + * Private event handlers start here. + */ + + /** + * Handles clicks on the #defaultListButton element. + * + * @param {MouseEvent} aEvent the click event. + */ + #onClickDefaultListButton(aEvent) { + if (this.#defaultListButton.open) { + this.#defaultListButton.open = false; + lazy.PanelMultiView.hidePopup(this.#defaultListPanel); + } else if ( + this.#defaultListPanel.state != "hiding" && + !this.#defaultListButton.disabled + ) { + this.show(aEvent); + } + } + + /** + * Handles the popuphiding event firing on the #defaultListPanel. + * + * @param {WidgetMouseEvent} aEvent the popuphiding event that fired on the + * #defaultListPanel. + */ + #onPanelHiding(aEvent) { + if (aEvent.target != this.#defaultListPanel) { + // Ignore context menus, '; + +let gAnchor; +let gPanel; +let gPanelMultiView; +let gMainView; +let gMainContext; +let gMainButton1; +let gMainMenulist; +let gMainRadiogroup; +let gMainTextbox; +let gMainButton2; +let gMainButton3; +let gCheckbox; +let gNamespacedLink; +let gLink; +let gMainTabOrder; +let gMainArrowOrder; +let gSubView; +let gSubButton; +let gSubTextarea; +let gBrowserView; +let gBrowserBrowser; +let gIframeView; +let gIframeIframe; +let gToggle; + +async function openPopup() { + let shown = BrowserTestUtils.waitForEvent(gMainView, "ViewShown"); + PanelMultiView.openPopup(gPanel, gAnchor, "bottomright topright"); + await shown; +} + +async function hidePopup() { + let hidden = BrowserTestUtils.waitForEvent(gPanel, "popuphidden"); + PanelMultiView.hidePopup(gPanel); + await hidden; +} + +async function showSubView(view = gSubView) { + let shown = BrowserTestUtils.waitForEvent(view, "ViewShown"); + // We must show with an anchor so the Back button is generated. + gPanelMultiView.showSubView(view, gMainButton1); + await shown; +} + +async function expectFocusAfterKey(aKey, aFocus) { + let res = aKey.match(/^(Shift\+)?(.+)$/); + let shift = Boolean(res[1]); + let key; + if (res[2].length == 1) { + key = res[2]; // Character. + } else { + key = "KEY_" + res[2]; // Tab, ArrowRight, etc. + } + info("Waiting for focus on " + aFocus.id); + let focused = BrowserTestUtils.waitForEvent(aFocus, "focus"); + EventUtils.synthesizeKey(key, { shiftKey: shift }); + await focused; + ok(true, aFocus.id + " focused after " + aKey + " pressed"); +} + +add_setup(async function () { + // This shouldn't be necessary - but it is, because we use same-process frames. + // https://bugzilla.mozilla.org/show_bug.cgi?id=1565276 covers improving this. + await SpecialPowers.pushPrefEnv({ + set: [["security.allow_unsafe_parent_loads", true]], + }); + let navBar = document.getElementById("nav-bar"); + gAnchor = document.createXULElement("toolbarbutton"); + navBar.appendChild(gAnchor); + gPanel = document.createXULElement("panel"); + navBar.appendChild(gPanel); + gPanelMultiView = document.createXULElement("panelmultiview"); + gPanelMultiView.setAttribute("mainViewId", "testMainView"); + gPanel.appendChild(gPanelMultiView); + + gMainView = document.createXULElement("panelview"); + gMainView.id = "testMainView"; + gPanelMultiView.appendChild(gMainView); + gMainContext = document.createXULElement("menupopup"); + gMainContext.id = "gMainContext"; + gMainView.appendChild(gMainContext); + gMainContext.appendChild(document.createXULElement("menuitem")); + gMainButton1 = document.createXULElement("button"); + gMainButton1.id = "gMainButton1"; + gMainView.appendChild(gMainButton1); + // We use this for anchoring subviews, so it must have a label. + gMainButton1.setAttribute("label", "gMainButton1"); + gMainButton1.setAttribute("context", "gMainContext"); + gMainMenulist = document.createXULElement("menulist"); + gMainMenulist.id = "gMainMenulist"; + gMainView.appendChild(gMainMenulist); + let menuPopup = document.createXULElement("menupopup"); + gMainMenulist.appendChild(menuPopup); + let item = document.createXULElement("menuitem"); + item.setAttribute("value", "1"); + item.setAttribute("selected", "true"); + menuPopup.appendChild(item); + item = document.createXULElement("menuitem"); + item.setAttribute("value", "2"); + menuPopup.appendChild(item); + gMainRadiogroup = document.createXULElement("radiogroup"); + gMainRadiogroup.id = "gMainRadiogroup"; + gMainView.appendChild(gMainRadiogroup); + let radio = document.createXULElement("radio"); + radio.setAttribute("value", "1"); + radio.setAttribute("selected", "true"); + gMainRadiogroup.appendChild(radio); + radio = document.createXULElement("radio"); + radio.setAttribute("value", "2"); + gMainRadiogroup.appendChild(radio); + gMainTextbox = document.createElement("input"); + gMainTextbox.id = "gMainTextbox"; + gMainView.appendChild(gMainTextbox); + gMainTextbox.setAttribute("value", "value"); + gMainButton2 = document.createXULElement("button"); + gMainButton2.id = "gMainButton2"; + gMainView.appendChild(gMainButton2); + gMainButton3 = document.createXULElement("button"); + gMainButton3.id = "gMainButton3"; + gMainView.appendChild(gMainButton3); + gCheckbox = document.createXULElement("checkbox"); + gCheckbox.id = "gCheckbox"; + gMainView.appendChild(gCheckbox); + + // moz-support-links in XUL documents are created with the + // tag and so we need to test this separately from + // tags. + gNamespacedLink = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "html:a" + ); + gNamespacedLink.href = "www.mozilla.org"; + gNamespacedLink.innerText = "gNamespacedLink"; + gNamespacedLink.id = "gNamespacedLink"; + gMainView.appendChild(gNamespacedLink); + gLink = document.createElement("a"); + gLink.href = "www.mozilla.org"; + gLink.innerText = "gLink"; + gLink.id = "gLink"; + gMainView.appendChild(gLink); + await window.ensureCustomElements("moz-toggle"); + gToggle = document.createElement("moz-toggle"); + gToggle.label = "Test label"; + gMainView.appendChild(gToggle); + + gMainTabOrder = [ + gMainButton1, + gMainMenulist, + gMainRadiogroup, + gMainTextbox, + gMainButton2, + gMainButton3, + gCheckbox, + gNamespacedLink, + gLink, + gToggle, + ]; + gMainArrowOrder = [ + gMainButton1, + gMainButton2, + gMainButton3, + gCheckbox, + gNamespacedLink, + gLink, + gToggle, + ]; + + gSubView = document.createXULElement("panelview"); + gSubView.id = "testSubView"; + gPanelMultiView.appendChild(gSubView); + gSubButton = document.createXULElement("button"); + gSubView.appendChild(gSubButton); + gSubTextarea = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "textarea" + ); + gSubTextarea.id = "gSubTextarea"; + gSubView.appendChild(gSubTextarea); + gSubTextarea.value = "value"; + + gBrowserView = document.createXULElement("panelview"); + gBrowserView.id = "testBrowserView"; + gPanelMultiView.appendChild(gBrowserView); + gBrowserBrowser = document.createXULElement("browser"); + gBrowserBrowser.id = "GBrowserBrowser"; + gBrowserBrowser.setAttribute("type", "content"); + gBrowserBrowser.setAttribute("src", kEmbeddedDocUrl); + gBrowserBrowser.style.minWidth = gBrowserBrowser.style.minHeight = "100px"; + gBrowserView.appendChild(gBrowserBrowser); + + gIframeView = document.createXULElement("panelview"); + gIframeView.id = "testIframeView"; + gPanelMultiView.appendChild(gIframeView); + gIframeIframe = document.createXULElement("iframe"); + gIframeIframe.id = "gIframeIframe"; + gIframeIframe.setAttribute("src", kEmbeddedDocUrl); + gIframeView.appendChild(gIframeIframe); + + registerCleanupFunction(() => { + gAnchor.remove(); + gPanel.remove(); + }); +}); + +// Test that the tab key focuses all expected controls. +add_task(async function testTab() { + await openPopup(); + for (let elem of gMainTabOrder) { + await expectFocusAfterKey("Tab", elem); + } + // Wrap around. + await expectFocusAfterKey("Tab", gMainTabOrder[0]); + await hidePopup(); +}); + +// Test that the shift+tab key focuses all expected controls. +add_task(async function testShiftTab() { + await openPopup(); + for (let i = gMainTabOrder.length - 1; i >= 0; --i) { + await expectFocusAfterKey("Shift+Tab", gMainTabOrder[i]); + } + // Wrap around. + await expectFocusAfterKey( + "Shift+Tab", + gMainTabOrder[gMainTabOrder.length - 1] + ); + await hidePopup(); +}); + +// Test that the down arrow key skips menulists and textboxes. +add_task(async function testDownArrow() { + await openPopup(); + for (let elem of gMainArrowOrder) { + await expectFocusAfterKey("ArrowDown", elem); + } + // Wrap around. + await expectFocusAfterKey("ArrowDown", gMainArrowOrder[0]); + await hidePopup(); +}); + +// Test that the up arrow key skips menulists and textboxes. +add_task(async function testUpArrow() { + await openPopup(); + for (let i = gMainArrowOrder.length - 1; i >= 0; --i) { + await expectFocusAfterKey("ArrowUp", gMainArrowOrder[i]); + } + // Wrap around. + await expectFocusAfterKey( + "ArrowUp", + gMainArrowOrder[gMainArrowOrder.length - 1] + ); + await hidePopup(); +}); + +// Test that the home/end keys move to the first/last controls. +add_task(async function testHomeEnd() { + await openPopup(); + await expectFocusAfterKey("Home", gMainArrowOrder[0]); + await expectFocusAfterKey("End", gMainArrowOrder[gMainArrowOrder.length - 1]); + await hidePopup(); +}); + +// Test that the up/down arrow keys work as expected in menulists. +add_task(async function testArrowsMenulist() { + await openPopup(); + gMainMenulist.focus(); + is(document.activeElement, gMainMenulist, "menulist focused"); + is(gMainMenulist.value, "1", "menulist initial value 1"); + if (AppConstants.platform == "macosx") { + // On Mac, down/up arrows just open the menulist. + let popup = gMainMenulist.menupopup; + for (let key of ["ArrowDown", "ArrowUp"]) { + let shown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + EventUtils.synthesizeKey("KEY_" + key); + await shown; + ok(gMainMenulist.open, "menulist open after " + key); + let hidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + EventUtils.synthesizeKey("KEY_Escape"); + await hidden; + ok(!gMainMenulist.open, "menulist closed after Escape"); + } + } else { + // On other platforms, down/up arrows change the value without opening the + // menulist. + EventUtils.synthesizeKey("KEY_ArrowDown"); + is( + document.activeElement, + gMainMenulist, + "menulist still focused after ArrowDown" + ); + is(gMainMenulist.value, "2", "menulist value 2 after ArrowDown"); + EventUtils.synthesizeKey("KEY_ArrowUp"); + is( + document.activeElement, + gMainMenulist, + "menulist still focused after ArrowUp" + ); + is(gMainMenulist.value, "1", "menulist value 1 after ArrowUp"); + } + await hidePopup(); +}); + +// Test that the tab key closes an open menu list. +add_task(async function testTabOpenMenulist() { + await openPopup(); + gMainMenulist.focus(); + is(document.activeElement, gMainMenulist, "menulist focused"); + let popup = gMainMenulist.menupopup; + let shown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + gMainMenulist.open = true; + await shown; + ok(gMainMenulist.open, "menulist open"); + let menuHidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + EventUtils.synthesizeKey("KEY_Tab"); + await menuHidden; + ok(!gMainMenulist.open, "menulist closed after Tab"); + is(gPanel.state, "open", "Panel should be open"); + await hidePopup(); +}); + +if (AppConstants.platform == "macosx") { + // Test that using the mouse to open a menulist still allows keyboard navigation + // inside it. + add_task(async function testNavigateMouseOpenedMenulist() { + await openPopup(); + let popup = gMainMenulist.menupopup; + let shown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + gMainMenulist.open = true; + await shown; + ok(gMainMenulist.open, "menulist open"); + let oldFocus = document.activeElement; + let oldSelectedItem = gMainMenulist.selectedItem; + ok( + oldSelectedItem.hasAttribute("_moz-menuactive"), + "Selected item should show up as active" + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + await TestUtils.waitForCondition( + () => !oldSelectedItem.hasAttribute("_moz-menuactive") + ); + is(oldFocus, document.activeElement, "Focus should not move on mac"); + ok( + !oldSelectedItem.hasAttribute("_moz-menuactive"), + "Selected item should change" + ); + + let menuHidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + EventUtils.synthesizeKey("KEY_Tab"); + await menuHidden; + ok(!gMainMenulist.open, "menulist closed after Tab"); + is(gPanel.state, "open", "Panel should be open"); + await hidePopup(); + }); +} + +// Test that the up/down arrow keys work as expected in radiogroups. +add_task(async function testArrowsRadiogroup() { + await openPopup(); + gMainRadiogroup.focus(); + is(document.activeElement, gMainRadiogroup, "radiogroup focused"); + is(gMainRadiogroup.value, "1", "radiogroup initial value 1"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + is( + document.activeElement, + gMainRadiogroup, + "radiogroup still focused after ArrowDown" + ); + is(gMainRadiogroup.value, "2", "radiogroup value 2 after ArrowDown"); + EventUtils.synthesizeKey("KEY_ArrowUp"); + is( + document.activeElement, + gMainRadiogroup, + "radiogroup still focused after ArrowUp" + ); + is(gMainRadiogroup.value, "1", "radiogroup value 1 after ArrowUp"); + await hidePopup(); +}); + +// Test that pressing space in a textbox inserts a space (instead of trying to +// activate the control). +add_task(async function testSpaceTextbox() { + await openPopup(); + gMainTextbox.focus(); + gMainTextbox.selectionStart = gMainTextbox.selectionEnd = 0; + EventUtils.synthesizeKey(" "); + is(gMainTextbox.value, " value", "Space typed into textbox"); + gMainTextbox.value = "value"; + await hidePopup(); +}); + +// Tests that the left arrow key normally moves back to the previous view. +add_task(async function testLeftArrow() { + await openPopup(); + await showSubView(); + let shown = BrowserTestUtils.waitForEvent(gMainView, "ViewShown"); + EventUtils.synthesizeKey("KEY_ArrowLeft"); + await shown; + ok("Moved to previous view after ArrowLeft"); + await hidePopup(); +}); + +// Tests that the left arrow key moves the caret in a textarea in a subview +// (instead of going back to the previous view). +add_task(async function testLeftArrowTextarea() { + await openPopup(); + await showSubView(); + gSubTextarea.focus(); + is(document.activeElement, gSubTextarea, "textarea focused"); + EventUtils.synthesizeKey("KEY_End"); + is(gSubTextarea.selectionStart, 5, "selectionStart 5 after End"); + EventUtils.synthesizeKey("KEY_ArrowLeft"); + is(gSubTextarea.selectionStart, 4, "selectionStart 4 after ArrowLeft"); + is(document.activeElement, gSubTextarea, "textarea still focused"); + await hidePopup(); +}); + +// Test navigation to a button which is initially disabled and later enabled. +add_task(async function testDynamicButton() { + gMainButton2.disabled = true; + await openPopup(); + await expectFocusAfterKey("ArrowDown", gMainButton1); + await expectFocusAfterKey("ArrowDown", gMainButton3); + gMainButton2.disabled = false; + await expectFocusAfterKey("ArrowUp", gMainButton2); + await hidePopup(); +}); + +add_task(async function testActivation() { + function checkActivated(elem, activationFn, reason) { + let activated = false; + elem.onclick = function () { + activated = true; + }; + activationFn(); + ok(activated, "Should have activated button after " + reason); + elem.onclick = null; + } + await openPopup(); + await expectFocusAfterKey("ArrowDown", gMainButton1); + checkActivated( + gMainButton1, + () => EventUtils.synthesizeKey("KEY_Enter"), + "pressing enter" + ); + checkActivated( + gMainButton1, + () => EventUtils.synthesizeKey(" "), + "pressing space" + ); + checkActivated( + gMainButton1, + () => EventUtils.synthesizeKey("KEY_Enter", { code: "NumpadEnter" }), + "pressing numpad enter" + ); + await hidePopup(); +}); + +// Test that keyboard activation works for buttons responding to mousedown +// events (instead of command or click). The Library button does this, for +// example. +add_task(async function testActivationMousedown() { + await openPopup(); + await expectFocusAfterKey("ArrowDown", gMainButton1); + let activated = false; + gMainButton1.onmousedown = function () { + activated = true; + }; + EventUtils.synthesizeKey(" "); + ok(activated, "mousedown activated after space"); + gMainButton1.onmousedown = null; + await hidePopup(); +}); + +// Test that tab and the arrow keys aren't overridden in embedded documents. +async function testTabArrowsEmbeddedDoc(aView, aEmbedder) { + await openPopup(); + await showSubView(aView); + let doc = aEmbedder.contentDocument; + if (doc.readyState != "complete" || doc.location.href != kEmbeddedDocUrl) { + info(`Embedded doc readyState ${doc.readyState}, location ${doc.location}`); + info("Waiting for load on embedder"); + // Browsers don't fire load events, and iframes don't fire load events in + // typeChrome windows. We can handle both by using a capturing event + // listener to capture the load event from the child document. + await BrowserTestUtils.waitForEvent(aEmbedder, "load", true); + // The original doc might have been a temporary about:blank, so fetch it + // again. + doc = aEmbedder.contentDocument; + } + is(doc.location.href, kEmbeddedDocUrl, "Embedded doc has correct URl"); + let backButton = aView.querySelector(".subviewbutton-back"); + backButton.id = "docBack"; + await expectFocusAfterKey("Tab", backButton); + // Documents don't have an id property, but expectFocusAfterKey wants one. + doc.id = "doc"; + await expectFocusAfterKey("Tab", doc); + // Make sure tab/arrows aren't overridden within the embedded document. + let textarea = doc.getElementById("docTextarea"); + // Tab should really focus the textarea, but default tab handling seems to + // skip everything inside the embedder element when run in this test. This + // behaves as expected in real panels, though. Force focus to the textarea + // and then test from there. + textarea.focus(); + is(doc.activeElement, textarea, "textarea focused"); + is(textarea.selectionStart, 0, "selectionStart initially 0"); + EventUtils.synthesizeKey("KEY_ArrowRight"); + is(textarea.selectionStart, 1, "selectionStart 1 after ArrowRight"); + EventUtils.synthesizeKey("KEY_ArrowLeft"); + is(textarea.selectionStart, 0, "selectionStart 0 after ArrowLeft"); + is(doc.activeElement, textarea, "textarea still focused"); + let docButton = doc.getElementById("docButton"); + await expectFocusAfterKey("Tab", docButton); + await hidePopup(); +} + +// Test that tab and the arrow keys aren't overridden in embedded browsers. +add_task(async function testTabArrowsBrowser() { + await testTabArrowsEmbeddedDoc(gBrowserView, gBrowserBrowser); +}); + +// Test that tab and the arrow keys aren't overridden in embedded iframes. +add_task(async function testTabArrowsIframe() { + await testTabArrowsEmbeddedDoc(gIframeView, gIframeIframe); +}); + +// Test that the arrow keys aren't overridden in context menus. +add_task(async function testArowsContext() { + await openPopup(); + await expectFocusAfterKey("ArrowDown", gMainButton1); + let shown = BrowserTestUtils.waitForEvent(gMainContext, "popupshown"); + // There's no cross-platform way to open a context menu from the keyboard. + gMainContext.openPopup(gMainButton1); + await shown; + let item = gMainContext.children[0]; + ok( + !item.getAttribute("_moz-menuactive"), + "First context menu item initially inactive" + ); + let active = BrowserTestUtils.waitForEvent(item, "DOMMenuItemActive"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + await active; + ok( + item.getAttribute("_moz-menuactive"), + "First context menu item active after ArrowDown" + ); + is( + document.activeElement, + gMainButton1, + "gMainButton1 still focused after ArrowDown" + ); + let hidden = BrowserTestUtils.waitForEvent(gMainContext, "popuphidden"); + gMainContext.hidePopup(); + await hidden; + await hidePopup(); +}); + +add_task(async function testMozToggle() { + await openPopup(); + is(gToggle.pressed, false, "The toggle is not pressed initially."); + // Focus the toggle via keyboard navigation. + while (document.activeElement !== gToggle) { + EventUtils.synthesizeKey("KEY_Tab"); + } + EventUtils.synthesizeKey(" "); + await gToggle.updateComplete; + is(gToggle.pressed, true, "Toggle pressed state changes via spacebar."); + EventUtils.synthesizeKey("KEY_Enter"); + await gToggle.updateComplete; + is(gToggle.pressed, false, "Toggle pressed state changes via enter."); + await hidePopup(); +}); diff --git a/browser/components/customizableui/test/browser_addons_area.js b/browser/components/customizableui/test/browser_addons_area.js new file mode 100644 index 0000000000..533d48b238 --- /dev/null +++ b/browser/components/customizableui/test/browser_addons_area.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test that widgets provided by extensions can be added to the + * ADDONS area, but all other widgets cannot. + */ +add_task(async function test_only_extension_widgets_in_addons_area() { + registerCleanupFunction(async () => { + await CustomizableUI.reset(); + }); + + Assert.ok( + !CustomizableUI.canWidgetMoveToArea( + "home-button", + CustomizableUI.AREA_ADDONS + ), + "Cannot move a built-in button to the ADDONS area." + ); + + // Now double-check that we cannot accidentally default a non-extension + // widget into the ADDONS area. + const kTestDynamicWidget = "a-test-widget"; + CustomizableUI.createWidget({ + id: kTestDynamicWidget, + label: "Test widget", + defaultArea: CustomizableUI.AREA_ADDONS, + }); + Assert.equal( + CustomizableUI.getPlacementOfWidget(kTestDynamicWidget), + null, + "An attempt to put a non-extension widget into the ADDONS area by default should fail." + ); + CustomizableUI.destroyWidget(kTestDynamicWidget); + + const kWebExtensionButtonID1 = "a-test-extension-button"; + + CustomizableUI.createWidget({ + id: kWebExtensionButtonID1, + label: "Test extension widget", + defaultArea: CustomizableUI.AREA_NAVBAR, + webExtension: true, + }); + + Assert.ok( + CustomizableUI.canWidgetMoveToArea( + kWebExtensionButtonID1, + CustomizableUI.AREA_ADDONS + ), + "Can move extension button to the addons area." + ); + + CustomizableUI.destroyWidget(kWebExtensionButtonID1); + + // Now check that extension buttons can default to the ADDONS area, if need + // be. + + const kWebExtensionButtonID2 = "a-test-extension-button-2"; + + CustomizableUI.createWidget({ + id: kWebExtensionButtonID2, + label: "Test extension widget 2", + defaultArea: CustomizableUI.AREA_ADDONS, + webExtension: true, + }); + + Assert.equal( + CustomizableUI.getPlacementOfWidget(kWebExtensionButtonID2)?.area, + CustomizableUI.AREA_ADDONS, + "An attempt to put an extension widget into the ADDONS area by default should work." + ); + + CustomizableUI.destroyWidget(kWebExtensionButtonID2); +}); diff --git a/browser/components/customizableui/test/browser_allow_dragging_removable_false.js b/browser/components/customizableui/test/browser_allow_dragging_removable_false.js new file mode 100644 index 0000000000..76269f44ae --- /dev/null +++ b/browser/components/customizableui/test/browser_allow_dragging_removable_false.js @@ -0,0 +1,42 @@ +"use strict"; + +/** + * Test dragging a removable=false widget within its own area as well as to the palette. + */ +add_task(async function () { + await startCustomizing(); + let forwardButton = document.getElementById("forward-button"); + is( + forwardButton.getAttribute("removable"), + "false", + "forward-button should not be removable" + ); + ok(CustomizableUI.inDefaultState, "Should start in default state."); + + let urlbarContainer = document.getElementById("urlbar-container"); + let placementsAfterDrag = getAreaWidgetIds(CustomizableUI.AREA_NAVBAR); + placementsAfterDrag.splice(placementsAfterDrag.indexOf("forward-button"), 1); + placementsAfterDrag.splice( + placementsAfterDrag.indexOf("urlbar-container"), + 0, + "forward-button" + ); + + // Force layout flush to ensure the drag completes as expected + urlbarContainer.clientWidth; + + simulateItemDrag(forwardButton, urlbarContainer, "start"); + assertAreaPlacements(CustomizableUI.AREA_NAVBAR, placementsAfterDrag); + ok(!CustomizableUI.inDefaultState, "Should no longer be in default state."); + let palette = document.getElementById("customization-palette"); + simulateItemDrag(forwardButton, palette); + is( + CustomizableUI.getPlacementOfWidget("forward-button").area, + CustomizableUI.AREA_NAVBAR, + "forward-button was not able to move to palette" + ); + + await endCustomizing(); + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "Should be in default state again."); +}); diff --git a/browser/components/customizableui/test/browser_backfwd_enabled_post_customize.js b/browser/components/customizableui/test/browser_backfwd_enabled_post_customize.js new file mode 100644 index 0000000000..93d88c7c55 --- /dev/null +++ b/browser/components/customizableui/test/browser_backfwd_enabled_post_customize.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://example.com" +); + +/** + * Back/fwd buttons should be re-enabled after customizing. + */ +add_task(async function test_back_forward_buttons() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.navigation.requireUserInteraction", false]], + }); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PATH); + let loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + "data:text/html,A separate page" + ); + await loaded; + loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + "data:text/html,Another separate page" + ); + await loaded; + gBrowser.goBack(); + await BrowserTestUtils.waitForCondition(() => gBrowser.canGoForward); + + let backButton = document.getElementById("back-button"); + let forwardButton = document.getElementById("forward-button"); + + await BrowserTestUtils.waitForCondition( + () => + !backButton.hasAttribute("disabled") && + !forwardButton.hasAttribute("disabled") + ); + + ok(!backButton.hasAttribute("disabled"), "Back button shouldn't be disabled"); + ok( + !forwardButton.hasAttribute("disabled"), + "Forward button shouldn't be disabled" + ); + await startCustomizing(); + + is( + backButton.getAttribute("disabled"), + "true", + "Back button should be disabled in customize mode" + ); + is( + forwardButton.getAttribute("disabled"), + "true", + "Forward button should be disabled in customize mode" + ); + + await endCustomizing(); + + await BrowserTestUtils.waitForCondition( + () => + !backButton.hasAttribute("disabled") && + !forwardButton.hasAttribute("disabled") + ); + + ok( + !backButton.hasAttribute("disabled"), + "Back button shouldn't be disabled after customize mode" + ); + ok( + !forwardButton.hasAttribute("disabled"), + "Forward button shouldn't be disabled after customize mode" + ); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/customizableui/test/browser_bookmarks_empty_message.js b/browser/components/customizableui/test/browser_bookmarks_empty_message.js new file mode 100644 index 0000000000..f4497cefdb --- /dev/null +++ b/browser/components/customizableui/test/browser_bookmarks_empty_message.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +async function emptyToolbarMessageVisible(visible, win = window) { + info("Empty toolbar message should be " + (visible ? "visible" : "hidden")); + let emptyMessage = win.document.getElementById("personal-toolbar-empty"); + await BrowserTestUtils.waitForMutationCondition( + emptyMessage, + { attributes: true, attributeFilter: ["hidden"] }, + () => emptyMessage.hidden != visible + ); +} + +add_task(async function empty_message_on_non_empty_bookmarks_toolbar() { + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "Default state to begin"); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.toolbars.bookmarks.visibility", "always"]], + }); + + CustomizableUI.removeWidgetFromArea("import-button"); + CustomizableUI.removeWidgetFromArea("personal-bookmarks"); + CustomizableUI.addWidgetToArea( + "bookmarks-menu-button", + CustomizableUI.AREA_BOOKMARKS, + 0 + ); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + let doc = newWin.document; + ok( + BrowserTestUtils.isVisible(doc.getElementById("PersonalToolbar")), + "Personal toolbar should be visible" + ); + ok( + doc.getElementById("personal-toolbar-empty").hidden, + "Empty message should be hidden" + ); + + await BrowserTestUtils.closeWindow(newWin); + await resetCustomization(); +}); + +add_task(async function empty_message_after_customization() { + // ensure There's something on the toolbar. + let bm = await PlacesUtils.bookmarks.insert({ + url: "https://mozilla.org/", + title: "test", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + registerCleanupFunction(() => PlacesUtils.bookmarks.remove(bm)); + ok(CustomizableUI.inDefaultState, "Default state to begin"); + + // Open window with a visible toolbar. + await SpecialPowers.pushPrefEnv({ + set: [["browser.toolbars.bookmarks.visibility", "always"]], + }); + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + let doc = newWin.document; + let toolbar = doc.getElementById("PersonalToolbar"); + ok(BrowserTestUtils.isVisible(toolbar), "Personal toolbar should be visible"); + await emptyToolbarMessageVisible(false, newWin); + + // Force a Places view uninit through customization. + CustomizableUI.removeWidgetFromArea("personal-bookmarks"); + await resetCustomization(); + // Show the toolbar again. + setToolbarVisibility(toolbar, true, false, false); + ok(BrowserTestUtils.isVisible(toolbar), "Personal toolbar should be visible"); + // Wait for bookmarks to be visible. + let placesItems = doc.getElementById("PlacesToolbarItems"); + await BrowserTestUtils.waitForMutationCondition( + placesItems, + { childList: true }, + () => placesItems.childNodes.length + ); + await emptyToolbarMessageVisible(false, newWin); + + await BrowserTestUtils.closeWindow(newWin); +}); diff --git a/browser/components/customizableui/test/browser_bookmarks_toolbar_collapsed_restore_default.js b/browser/components/customizableui/test/browser_bookmarks_toolbar_collapsed_restore_default.js new file mode 100644 index 0000000000..84ddc37d29 --- /dev/null +++ b/browser/components/customizableui/test/browser_bookmarks_toolbar_collapsed_restore_default.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +// Restoring default should set Bookmarks Toolbar back to "newtab" +add_task(async function () { + let prefName = "browser.toolbars.bookmarks.visibility"; + let toolbar = document.querySelector("#PersonalToolbar"); + for (let state of ["always", "never"]) { + info(`Testing setting toolbar state to '${state}'`); + + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "Default state to begin"); + + setToolbarVisibility(toolbar, state, true, false); + + is( + Services.prefs.getCharPref(prefName), + state, + "Pref updated to: " + state + ); + ok(!CustomizableUI.inDefaultState, "Not in default state"); + + await resetCustomization(); + + ok(CustomizableUI.inDefaultState, "Back in default state after reset"); + is( + Services.prefs.getCharPref(prefName), + "newtab", + "Pref should get reset to 'newtab'" + ); + } +}); diff --git a/browser/components/customizableui/test/browser_bookmarks_toolbar_shown_newtab.js b/browser/components/customizableui/test/browser_bookmarks_toolbar_shown_newtab.js new file mode 100644 index 0000000000..38f385e38c --- /dev/null +++ b/browser/components/customizableui/test/browser_bookmarks_toolbar_shown_newtab.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +// Entering customize mode should show the toolbar as long as it's not set to "never" +add_task(async function () { + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "Default state to begin"); + + let toolbar = document.querySelector("#PersonalToolbar"); + for (let state of ["always", "never", "newtab"]) { + info(`Testing setting toolbar state to '${state}'`); + + setToolbarVisibility(toolbar, state, true, false); + + await startCustomizing(); + + let expected = state != "never"; + await TestUtils.waitForCondition( + () => !toolbar.collapsed == expected, + `Waiting for toolbar visibility, state=${state}, visible=${!toolbar.collapsed}, expected=${expected}` + ); + is( + !toolbar.collapsed, + expected, + "The toolbar should be visible when state isn't 'never'" + ); + + await endCustomizing(); + } + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_bootstrapped_custom_toolbar.js b/browser/components/customizableui/test/browser_bootstrapped_custom_toolbar.js new file mode 100644 index 0000000000..67325b7b36 --- /dev/null +++ b/browser/components/customizableui/test/browser_bootstrapped_custom_toolbar.js @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of 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"; + +requestLongerTimeout(2); + +const kTestBarID = "testBar"; +const kWidgetID = "characterencoding-button"; + +function createTestBar() { + let testBar = document.createXULElement("toolbar"); + testBar.id = kTestBarID; + testBar.setAttribute("customizable", "true"); + CustomizableUI.registerArea(kTestBarID, { + type: CustomizableUI.TYPE_TOOLBAR, + }); + gNavToolbox.appendChild(testBar); + CustomizableUI.registerToolbarNode(testBar); + return testBar; +} + +/** + * Helper function that does the following: + * + * 1) Creates a custom toolbar and registers it + * with CustomizableUI. + * 2) Adds the widget with ID aWidgetID to that new + * toolbar. + * 3) Enters customize mode and makes sure that the + * widget is still in the right toolbar. + * 4) Exits customize mode, then removes and deregisters + * the custom toolbar. + * 5) Checks that the widget has no placement. + * 6) Re-adds and re-registers a custom toolbar with the same + * ID and options as the first one. + * 7) Enters customize mode and checks that the widget is + * properly back in the toolbar. + * 8) Exits customize mode, removes and de-registers the + * toolbar, and resets the toolbars to default. + */ +function checkRestoredPresence(aWidgetID) { + return (async function () { + let testBar = createTestBar(); + CustomizableUI.addWidgetToArea(aWidgetID, kTestBarID); + let placement = CustomizableUI.getPlacementOfWidget(aWidgetID); + is( + placement.area, + kTestBarID, + "Expected " + aWidgetID + " to be in the test toolbar" + ); + + CustomizableUI.unregisterArea(testBar.id); + testBar.remove(); + + placement = CustomizableUI.getPlacementOfWidget(aWidgetID); + is(placement, null, "Expected " + aWidgetID + " to be in the palette"); + + testBar = createTestBar(); + + await startCustomizing(); + placement = CustomizableUI.getPlacementOfWidget(aWidgetID); + is( + placement.area, + kTestBarID, + "Expected " + aWidgetID + " to be in the test toolbar" + ); + await endCustomizing(); + + CustomizableUI.unregisterArea(testBar.id); + testBar.remove(); + + await resetCustomization(); + })(); +} + +add_task(async function () { + await checkRestoredPresence("downloads-button"); + await checkRestoredPresence("characterencoding-button"); +}); diff --git a/browser/components/customizableui/test/browser_check_tooltips_in_navbar.js b/browser/components/customizableui/test/browser_check_tooltips_in_navbar.js new file mode 100644 index 0000000000..f36b55032d --- /dev/null +++ b/browser/components/customizableui/test/browser_check_tooltips_in_navbar.js @@ -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/. */ + +"use strict"; + +add_task(async function check_tooltips_in_navbar() { + await startCustomizing(); + let homeButtonWrapper = document.getElementById("wrapper-home-button"); + let homeButton = document.getElementById("home-button"); + is( + homeButtonWrapper.getAttribute("tooltiptext"), + homeButton.getAttribute("label"), + "the wrapper's tooltip should match the button's label" + ); + ok( + homeButtonWrapper.getAttribute("tooltiptext"), + "the button should have tooltip text" + ); + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_create_button_widget.js b/browser/components/customizableui/test/browser_create_button_widget.js new file mode 100644 index 0000000000..3e90011453 --- /dev/null +++ b/browser/components/customizableui/test/browser_create_button_widget.js @@ -0,0 +1,90 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kButton = "test_dynamically_created_button"; +var initialLocation = gBrowser.currentURI.spec; + +add_task(async function () { + info("Check dynamically created button functionality"); + + // Let's create a simple button that will open about:addons. + let widgetSpec = { + id: kButton, + type: "button", + tooltiptext: "I am an accessible name", + onClick() { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:addons"); + }, + }; + CustomizableUI.createWidget(widgetSpec); + CustomizableUI.addWidgetToArea(kButton, CustomizableUI.AREA_NAVBAR); + ok( + !CustomizableUI.isWebExtensionWidget(kButton), + "This button should not be considered an extension widget." + ); + + // check the button's functionality in navigation bar + let button = document.getElementById(kButton); + let navBar = document.getElementById("nav-bar"); + ok(button, "Dynamically created button exists"); + ok(navBar.contains(button), "Dynamically created button is in the navbar"); + await checkButtonFunctionality(button); + + resetTabs(); + + // move the add-on button in the Panel Menu + CustomizableUI.addWidgetToArea( + kButton, + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + ok( + !navBar.contains(button), + "Dynamically created button was removed from the browser bar" + ); + + await waitForOverflowButtonShown(); + + // check the button's functionality in the Overflow Panel. + await document.getElementById("nav-bar").overflowable.show(); + var panelMenu = document.getElementById("widget-overflow-mainView"); + let buttonInPanel = panelMenu.getElementsByAttribute("id", kButton); + ok( + panelMenu.contains(button), + "Dynamically created button was added to the Panel Menu" + ); + await checkButtonFunctionality(buttonInPanel[0]); +}); + +add_task(async function asyncCleanup() { + resetTabs(); + + // reset the UI to the default state + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "The UI is in default state again."); + + // destroy the widget + CustomizableUI.destroyWidget(kButton); +}); + +function resetTabs() { + // close all opened tabs + while (gBrowser.tabs.length > 1) { + gBrowser.removeTab(gBrowser.selectedTab); + } + + // restore the initial tab + BrowserTestUtils.addTab(gBrowser, initialLocation); + gBrowser.removeTab(gBrowser.selectedTab); +} + +async function checkButtonFunctionality(aButton) { + aButton.click(); + await BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + "about:addons" + ); +} diff --git a/browser/components/customizableui/test/browser_ctrl_click_panel_opening.js b/browser/components/customizableui/test/browser_ctrl_click_panel_opening.js new file mode 100644 index 0000000000..9377c28950 --- /dev/null +++ b/browser/components/customizableui/test/browser_ctrl_click_panel_opening.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_appMenu_mainView() { + // On macOS, ctrl-click shouldn't open the panel because this normally opens + // the context menu. This happens via the `contextmenu` event which is created + // by widget code, so our simulated clicks do not do so, so we can't test + // anything on macOS: + if (AppConstants.platform == "macosx") { + ok(true, "The test is ignored on Mac"); + return; + } + + let mainViewID = "appMenu-protonMainView"; + const mainView = document.getElementById(mainViewID); + + let shownPromise = BrowserTestUtils.waitForEvent(mainView, "ViewShown"); + // Should still open the panel when Ctrl key is pressed. + EventUtils.synthesizeMouseAtCenter(PanelUI.menuButton, { ctrlKey: true }); + await shownPromise; + ok(true, "Main menu shown after button pressed"); + + // Close the main panel. + let hiddenPromise = BrowserTestUtils.waitForEvent(document, "popuphidden"); + mainView.closest("panel").hidePopup(); + await hiddenPromise; +}); + +add_task(async function test_appMenu_libraryView() { + // On macOS, ctrl-click shouldn't open the panel because this normally opens + // the context menu. This happens via the `contextmenu` event which is created + // by widget code, so our simulated clicks do not do so, so we can't test + // anything on macOS: + if (AppConstants.platform == "macosx") { + ok(true, "The test is ignored on Mac"); + return; + } + + CustomizableUI.addWidgetToArea("library-button", "nav-bar"); + const button = document.getElementById("library-button"); + await waitForElementShown(button); + + // Should still open the panel when Ctrl key is pressed. + EventUtils.synthesizeMouseAtCenter(button, { ctrlKey: true }); + const libraryView = document.getElementById("appMenu-libraryView"); + let shownPromise = BrowserTestUtils.waitForEvent(libraryView, "ViewShown"); + await shownPromise; + ok(true, "Library menu shown after button pressed"); + + // Close the Library panel. + let hiddenPromise = BrowserTestUtils.waitForEvent(document, "popuphidden"); + libraryView.closest("panel").hidePopup(); + await hiddenPromise; +}); diff --git a/browser/components/customizableui/test/browser_currentset_post_reset.js b/browser/components/customizableui/test/browser_currentset_post_reset.js new file mode 100644 index 0000000000..31bcd150b1 --- /dev/null +++ b/browser/components/customizableui/test/browser_currentset_post_reset.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function checkSpacers() { + let navbarWidgets = CustomizableUI.getWidgetIdsInArea("nav-bar"); + let currentSetWidgets = CustomizableUI.getTestOnlyInternalProp( + "CustomizableUIInternal" + )._getCurrentWidgetsInContainer(document.getElementById("nav-bar")); + navbarWidgets = navbarWidgets.filter(w => CustomizableUI.isSpecialWidget(w)); + currentSetWidgets = currentSetWidgets.filter(w => + CustomizableUI.isSpecialWidget(w) + ); + Assert.deepEqual( + navbarWidgets, + currentSetWidgets, + "Should have the same 'special' widgets in currentset and placements" + ); +} + +/** + * Check that after a reset, CUI's internal bookkeeping correctly deals with flexible spacers. + */ +add_task(async function () { + await startCustomizing(); + checkSpacers(); + + CustomizableUI.addWidgetToArea( + "spring", + "nav-bar", + 4 /* Insert before the last extant spacer */ + ); + await gCustomizeMode.reset(); + checkSpacers(); + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_customization_context_menus.js b/browser/components/customizableui/test/browser_customization_context_menus.js new file mode 100644 index 0000000000..526b3abd1b --- /dev/null +++ b/browser/components/customizableui/test/browser_customization_context_menus.js @@ -0,0 +1,632 @@ +/* This Source Code Form is subject to the terms of 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"; + +requestLongerTimeout(2); + +const isOSX = Services.appinfo.OS === "Darwin"; + +const overflowButton = document.getElementById("nav-bar-overflow-button"); +const overflowPanel = document.getElementById("widget-overflow"); + +// Right-click on the stop/reload button should +// show a context menu with options to move it. +add_task(async function home_button_context() { + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + let stopReloadButton = document.getElementById("stop-reload-button"); + EventUtils.synthesizeMouse(stopReloadButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToPanel", true], + [".customize-context-removeFromToolbar", true], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", true] + ); + checkContextMenu(contextMenu, expectedEntries); + + let hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; +}); + +// Right-click on an empty bit of tabstrip should +// show a context menu without options to move it, +// but with tab-specific options instead. +add_task(async function tabstrip_context() { + // ensure there are tabs to reload/bookmark: + let extraTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + let tabstrip = document.getElementById("tabbrowser-tabs"); + let rect = tabstrip.getBoundingClientRect(); + EventUtils.synthesizeMouse(tabstrip, rect.width - 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let closedTabsAvailable = SessionStore.getClosedTabCount() == 0; + info("Closed tabs: " + closedTabsAvailable); + let expectedEntries = [ + ["#toolbar-context-openANewTab", true], + ["---"], + ["#toolbar-context-reloadSelectedTab", true], + ["#toolbar-context-bookmarkSelectedTab", true], + ["#toolbar-context-selectAllTabs", true], + ["#toolbar-context-undoCloseTab", !closedTabsAvailable], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", true] + ); + checkContextMenu(contextMenu, expectedEntries); + + let hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; + BrowserTestUtils.removeTab(extraTab); +}); + +// Right-click on the title bar spacer before the tabstrip should show a +// context menu without options to move it and no tab-specific options. +add_task(async function titlebar_spacer_context() { + if (!TabsInTitlebar.enabled) { + info("Skipping test that requires tabs in the title bar."); + return; + } + + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + let spacer = document.querySelector( + "#TabsToolbar .titlebar-spacer[type='pre-tabs']" + ); + EventUtils.synthesizeMouseAtCenter(spacer, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToPanel", false], + [".customize-context-removeFromToolbar", false], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", true] + ); + checkContextMenu(contextMenu, expectedEntries); + + let hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; +}); + +// Right-click on an empty bit of extra toolbar should +// show a context menu with moving options disabled, +// and a toggle option for the extra toolbar +add_task(async function empty_toolbar_context() { + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + let toolbar = createToolbarWithPlacements("880164_empty_toolbar", []); + toolbar.setAttribute("context", "toolbar-context-menu"); + toolbar.setAttribute("toolbarname", "Fancy Toolbar for Context Menu"); + EventUtils.synthesizeMouseAtCenter(toolbar, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToPanel", false], + [".customize-context-removeFromToolbar", false], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["#toggle_880164_empty_toolbar", true], + ["---"], + [".viewCustomizeToolbar", true] + ); + checkContextMenu(contextMenu, expectedEntries); + + let hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; + removeCustomToolbars(); +}); + +// Right-click on the urlbar-container should +// show a context menu with disabled options to move it. +add_task(async function urlbar_context() { + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + let urlBarContainer = document.getElementById("urlbar-container"); + // Need to make sure not to click within an edit field. + EventUtils.synthesizeMouse(urlBarContainer, 100, 1, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToPanel", false], + [".customize-context-removeFromToolbar", false], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", true] + ); + checkContextMenu(contextMenu, expectedEntries); + + let hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; +}); + +// Right-click on the searchbar and moving it to the menu +// and back should move the search-container instead. +add_task(async function searchbar_context_move_to_panel_and_back() { + // This is specifically testing the addToPanel function for the search bar, so + // we have to move it to its correct position in the navigation toolbar first. + // The preference will be restored when the customizations are reset later. + Services.prefs.setBoolPref("browser.search.widget.inNavBar", true); + + let searchbar = document.getElementById("searchbar"); + // This fails if the screen resolution is small and the search bar overflows + // from the nav bar. + await gCustomizeMode.addToPanel(searchbar); + let placement = CustomizableUI.getPlacementOfWidget("search-container"); + is( + placement.area, + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, + "Should be in panel" + ); + + await waitForOverflowButtonShown(); + + let shownPanelPromise = popupShown(overflowPanel); + overflowButton.click(); + await shownPanelPromise; + let hiddenPanelPromise = popupHidden(overflowPanel); + overflowPanel.hidePopup(); + await hiddenPanelPromise; + + gCustomizeMode.addToToolbar(searchbar); + placement = CustomizableUI.getPlacementOfWidget("search-container"); + is(placement.area, CustomizableUI.AREA_NAVBAR, "Should be in navbar"); + await gCustomizeMode.removeFromArea(searchbar); + placement = CustomizableUI.getPlacementOfWidget("search-container"); + is(placement, null, "Should be in palette"); + CustomizableUI.reset(); + placement = CustomizableUI.getPlacementOfWidget("search-container"); + is(placement, null, "Should be in palette"); +}); + +// Right-click on an item within the panel should +// show a context menu with options to move it. +add_task(async function context_within_panel() { + CustomizableUI.addWidgetToArea( + "new-window-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + await waitForOverflowButtonShown(); + + let shownPanelPromise = popupShown(overflowPanel); + overflowButton.click(); + await shownPanelPromise; + + let contextMenu = document.getElementById( + "customizationPanelItemContextMenu" + ); + let shownContextPromise = popupShown(contextMenu); + let newWindowButton = document.getElementById("new-window-button"); + ok(newWindowButton, "new-window-button was found"); + EventUtils.synthesizeMouse(newWindowButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownContextPromise; + + is(overflowPanel.state, "open", "The overflow panel should still be open."); + + let expectedEntries = [ + [".customize-context-moveToToolbar", true], + [".customize-context-removeFromPanel", true], + ["---"], + [".viewCustomizeToolbar", true], + ]; + checkContextMenu(contextMenu, expectedEntries); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; + + let hiddenPromise = popupHidden(overflowPanel); + overflowPanel.hidePopup(); + await hiddenPromise; + + CustomizableUI.removeWidgetFromArea("new-window-button"); +}); + +// Right-click on the stop/reload button while in customization mode +// should show a context menu with options to move it. +add_task(async function context_home_button_in_customize_mode() { + await startCustomizing(); + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + let stopReloadButton = document.getElementById("wrapper-stop-reload-button"); + EventUtils.synthesizeMouse(stopReloadButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToPanel", true], + [".customize-context-removeFromToolbar", true], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", false] + ); + checkContextMenu(contextMenu, expectedEntries); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; +}); + +// Right-click on an item in the palette should +// show a context menu with options to move it. +add_task(async function context_click_in_palette() { + let contextMenu = document.getElementById( + "customizationPaletteItemContextMenu" + ); + let shownPromise = popupShown(contextMenu); + let openFileButton = document.getElementById("wrapper-open-file-button"); + EventUtils.synthesizeMouse(openFileButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-addToToolbar", true], + [".customize-context-addToPanel", true], + ]; + checkContextMenu(contextMenu, expectedEntries); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; +}); + +// Right-click on an item in the panel while in customization mode +// should show a context menu with options to move it. +add_task(async function context_click_in_customize_mode() { + CustomizableUI.addWidgetToArea( + "new-window-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + let contextMenu = document.getElementById( + "customizationPanelItemContextMenu" + ); + let shownPromise = popupShown(contextMenu); + let newWindowButton = document.getElementById("wrapper-new-window-button"); + EventUtils.synthesizeMouse(newWindowButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToToolbar", true], + [".customize-context-removeFromPanel", true], + ["---"], + [".viewCustomizeToolbar", false], + ]; + checkContextMenu(contextMenu, expectedEntries); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; + CustomizableUI.removeWidgetFromArea("new-window-button"); + await endCustomizing(); +}); + +// Test the toolbarbutton panel context menu in customization mode +// without opening the panel before customization mode +add_task(async function context_click_customize_mode_panel_not_opened() { + CustomizableUI.addWidgetToArea( + "new-window-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + this.otherWin = await openAndLoadWindow(null, true); + + await new Promise(resolve => waitForFocus(resolve, this.otherWin)); + + await startCustomizing(this.otherWin); + + let contextMenu = this.otherWin.document.getElementById( + "customizationPanelItemContextMenu" + ); + let shownPromise = popupShown(contextMenu); + let newWindowButton = this.otherWin.document.getElementById( + "wrapper-new-window-button" + ); + EventUtils.synthesizeMouse( + newWindowButton, + 2, + 2, + { type: "contextmenu", button: 2 }, + this.otherWin + ); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToToolbar", true], + [".customize-context-removeFromPanel", true], + ["---"], + [".viewCustomizeToolbar", false], + ]; + checkContextMenu(contextMenu, expectedEntries, this.otherWin); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; + await endCustomizing(this.otherWin); + CustomizableUI.removeWidgetFromArea("new-window-button"); + await promiseWindowClosed(this.otherWin); + this.otherWin = null; + + await new Promise(resolve => waitForFocus(resolve, window)); +}); + +// Bug 945191 - Combined buttons show wrong context menu options +// when they are in the toolbar. +add_task(async function context_combined_buttons_toolbar() { + CustomizableUI.addWidgetToArea( + "zoom-controls", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + await startCustomizing(); + let contextMenu = document.getElementById( + "customizationPanelItemContextMenu" + ); + let shownPromise = popupShown(contextMenu); + let zoomControls = document.getElementById("wrapper-zoom-controls"); + EventUtils.synthesizeMouse(zoomControls, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + // Execute the command to move the item from the panel to the toolbar. + let moveToToolbar = contextMenu.querySelector( + ".customize-context-moveToToolbar" + ); + moveToToolbar.doCommand(); + let hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; + await endCustomizing(); + + zoomControls = document.getElementById("zoom-controls"); + is( + zoomControls.parentNode.id, + "nav-bar-customization-target", + "Zoom-controls should be on the nav-bar" + ); + + contextMenu = document.getElementById("toolbar-context-menu"); + shownPromise = popupShown(contextMenu); + EventUtils.synthesizeMouse(zoomControls, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + [".customize-context-moveToPanel", true], + [".customize-context-removeFromToolbar", true], + ["---"], + ]; + if (!isOSX) { + expectedEntries.push(["#toggle_toolbar-menubar", true]); + } + expectedEntries.push( + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", true] + ); + checkContextMenu(contextMenu, expectedEntries); + + hiddenPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenPromise; + await resetCustomization(); +}); + +// Bug 947586 - After customization, panel items show wrong context menu options +add_task(async function context_after_customization_panel() { + info("Check panel context menu is correct after customization"); + await startCustomizing(); + await endCustomizing(); + + CustomizableUI.addWidgetToArea( + "new-window-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + let shownPanelPromise = popupShown(overflowPanel); + overflowButton.click(); + await shownPanelPromise; + + let contextMenu = document.getElementById( + "customizationPanelItemContextMenu" + ); + let shownContextPromise = popupShown(contextMenu); + let newWindowButton = document.getElementById("new-window-button"); + ok(newWindowButton, "new-window-button was found"); + EventUtils.synthesizeMouse(newWindowButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownContextPromise; + + is(overflowPanel.state, "open", "The panel should still be open."); + + let expectedEntries = [ + [".customize-context-moveToToolbar", true], + [".customize-context-removeFromPanel", true], + ["---"], + [".viewCustomizeToolbar", true], + ]; + checkContextMenu(contextMenu, expectedEntries); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; + + let hiddenPromise = popupHidden(overflowPanel); + overflowPanel.hidePopup(); + await hiddenPromise; + CustomizableUI.removeWidgetFromArea("new-window-button"); +}); + +// Bug 982027 - moving icon around removes custom context menu. +add_task(async function custom_context_menus() { + let widgetId = "custom-context-menu-toolbarbutton"; + let expectedContext = "myfancycontext"; + let widget = createDummyXULButton(widgetId, "Test ctxt menu"); + widget.setAttribute("context", expectedContext); + CustomizableUI.addWidgetToArea(widgetId, CustomizableUI.AREA_NAVBAR); + is( + widget.getAttribute("context"), + expectedContext, + "Should have context menu when added to the toolbar." + ); + + await startCustomizing(); + is( + widget.getAttribute("context"), + "", + "Should not have own context menu in the toolbar now that we're customizing." + ); + is( + widget.getAttribute("wrapped-context"), + expectedContext, + "Should keep own context menu wrapped when in toolbar." + ); + + let panel = document.getElementById("widget-overflow-fixed-list"); + simulateItemDrag(widget, panel); + is( + widget.getAttribute("context"), + "", + "Should not have own context menu when in the panel." + ); + is( + widget.getAttribute("wrapped-context"), + expectedContext, + "Should keep own context menu wrapped now that we're in the panel." + ); + + simulateItemDrag( + widget, + CustomizableUI.getCustomizationTarget(document.getElementById("nav-bar")) + ); + is( + widget.getAttribute("context"), + "", + "Should not have own context menu when back in toolbar because we're still customizing." + ); + is( + widget.getAttribute("wrapped-context"), + expectedContext, + "Should keep own context menu wrapped now that we're back in the toolbar." + ); + + await endCustomizing(); + is( + widget.getAttribute("context"), + expectedContext, + "Should have context menu again now that we're out of customize mode." + ); + CustomizableUI.removeWidgetFromArea(widgetId); + widget.remove(); + ok( + CustomizableUI.inDefaultState, + "Should be in default state after removing button." + ); +}); + +// Bug 1690575 - 'pin to overflow menu' and 'remove from toolbar' should be hidden +// for flexible spaces +add_task(async function flexible_space_context_menu() { + CustomizableUI.addWidgetToArea("spring", "nav-bar"); + let springs = document.querySelectorAll("#nav-bar toolbarspring"); + let lastSpring = springs[springs.length - 1]; + ok(lastSpring, "we added a spring"); + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + EventUtils.synthesizeMouse(lastSpring, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + let expectedEntries = [ + ["#toggle_PersonalToolbar", true], + ["---"], + [".viewCustomizeToolbar", true], + ]; + + if (!isOSX) { + expectedEntries.unshift(["#toggle_toolbar-menubar", true]); + } + + checkContextMenu(contextMenu, expectedEntries); + contextMenu.hidePopup(); + gCustomizeMode.removeFromArea(lastSpring); + ok(!lastSpring.parentNode, "Spring should have been removed successfully."); +}); diff --git a/browser/components/customizableui/test/browser_customizemode_contextmenu_menubuttonstate.js b/browser/components/customizableui/test/browser_customizemode_contextmenu_menubuttonstate.js new file mode 100644 index 0000000000..78b621054c --- /dev/null +++ b/browser/components/customizableui/test/browser_customizemode_contextmenu_menubuttonstate.js @@ -0,0 +1,71 @@ +"use strict"; + +add_task(async function () { + ok( + !PanelUI.menuButton.hasAttribute("open"), + "Menu button should not be 'pressed' outside customize mode" + ); + ok( + !PanelUI.menuButton.hasAttribute("disabled"), + "Menu button should not be disabled outside of customize mode" + ); + await startCustomizing(); + + ok( + !PanelUI.menuButton.hasAttribute("open"), + "Menu button should still not be 'pressed' when in customize mode" + ); + is( + PanelUI.menuButton.getAttribute("disabled"), + "true", + "Menu button should be disabled in customize mode" + ); + + let contextMenu = document.getElementById( + "customizationPaletteItemContextMenu" + ); + let shownPromise = popupShown(contextMenu); + let newWindowButton = document.getElementById("wrapper-new-window-button"); + EventUtils.synthesizeMouse(newWindowButton, 2, 2, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + ok( + !PanelUI.menuButton.hasAttribute("open"), + "Menu button should still not be 'pressed' when in customize mode after opening a context menu" + ); + is( + PanelUI.menuButton.getAttribute("disabled"), + "true", + "Menu button should still be disabled in customize mode" + ); + is( + PanelUI.menuButton.getAttribute("disabled"), + "true", + "Menu button should still be disabled in customize mode after opening context menu" + ); + + let hiddenContextPromise = popupHidden(contextMenu); + contextMenu.hidePopup(); + await hiddenContextPromise; + ok( + !PanelUI.menuButton.hasAttribute("open"), + "Menu button should still not be 'pressed' when in customize mode after hiding a context menu" + ); + is( + PanelUI.menuButton.getAttribute("disabled"), + "true", + "Menu button should still be disabled in customize mode after hiding context menu" + ); + await endCustomizing(); + + ok( + !PanelUI.menuButton.hasAttribute("open"), + "Menu button should not be 'pressed' after ending customize mode" + ); + ok( + !PanelUI.menuButton.hasAttribute("disabled"), + "Menu button should not be disabled after ending customize mode" + ); +}); diff --git a/browser/components/customizableui/test/browser_customizemode_lwthemes.js b/browser/components/customizableui/test/browser_customizemode_lwthemes.js new file mode 100644 index 0000000000..3b19566ac0 --- /dev/null +++ b/browser/components/customizableui/test/browser_customizemode_lwthemes.js @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function () { + await startCustomizing(); + // Find the footer buttons to test. + let manageLink = document.querySelector("#customization-lwtheme-link"); + + let waitForNewTab = BrowserTestUtils.waitForNewTab(gBrowser, "about:addons"); + manageLink.click(); + let addonsTab = await waitForNewTab; + + is(gBrowser.currentURI.spec, "about:addons", "Manage opened about:addons"); + BrowserTestUtils.removeTab(addonsTab); + + // Wait for customize mode to be re-entered now that the customize tab is + // active. This is needed for endCustomizing() to work properly. + await TestUtils.waitForCondition( + () => document.documentElement.getAttribute("customizing") == "true" + ); + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_customizemode_uidensity.js b/browser/components/customizableui/test/browser_customizemode_uidensity.js new file mode 100644 index 0000000000..9f22341f56 --- /dev/null +++ b/browser/components/customizableui/test/browser_customizemode_uidensity.js @@ -0,0 +1,230 @@ +/* This Source Code Form is subject to the terms of 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 PREF_UI_DENSITY = "browser.uidensity"; +const PREF_AUTO_TOUCH_MODE = "browser.touchmode.auto"; + +async function testModeMenuitem(mode, modePref) { + await startCustomizing(); + + let win = document.getElementById("main-window"); + let popupButton = document.getElementById("customization-uidensity-button"); + let popup = document.getElementById("customization-uidensity-menu"); + + // Show the popup. + let popupShownPromise = popupShown(popup); + EventUtils.synthesizeMouseAtCenter(popupButton, {}); + await popupShownPromise; + + let item = document.getElementById( + "customization-uidensity-menuitem-" + mode + ); + let normalItem = document.getElementById( + "customization-uidensity-menuitem-normal" + ); + + is( + normalItem.getAttribute("active"), + "true", + "Normal mode menuitem should be active by default" + ); + + // Hover over the mode menuitem and wait for the event that updates the UI + // density. + let mouseoverPromise = BrowserTestUtils.waitForEvent(item, "mouseover"); + EventUtils.synthesizeMouseAtCenter(item, { type: "mouseover" }); + await mouseoverPromise; + + is( + win.getAttribute("uidensity"), + mode, + `UI Density should be set to ${mode} on ${mode} menuitem hover` + ); + + is( + Services.prefs.getIntPref(PREF_UI_DENSITY), + window.gUIDensity.MODE_NORMAL, + `UI Density pref should still be set to normal on ${mode} menuitem hover` + ); + + // Hover the normal menuitem again and check that the UI density reset to normal. + EventUtils.synthesizeMouseAtCenter(normalItem, { type: "mouseover" }); + await BrowserTestUtils.waitForCondition(() => !win.hasAttribute("uidensity")); + + ok( + !win.hasAttribute("uidensity"), + `UI Density should be reset when no longer hovering the ${mode} menuitem` + ); + + // Select the custom UI density and wait for the popup to be hidden. + let popupHiddenPromise = popupHidden(popup); + EventUtils.synthesizeMouseAtCenter(item, {}); + await popupHiddenPromise; + + // Check that the click permanently changed the UI density. + is( + win.getAttribute("uidensity"), + mode, + `UI Density should be set to ${mode} on ${mode} menuitem click` + ); + is( + Services.prefs.getIntPref(PREF_UI_DENSITY), + modePref, + `UI Density pref should be set to ${mode} when clicking the ${mode} menuitem` + ); + + // Open the popup again. + popupShownPromise = popupShown(popup); + EventUtils.synthesizeMouseAtCenter(popupButton, {}); + await popupShownPromise; + + // Check that the menuitem is still active after opening and closing the popup. + is( + item.getAttribute("active"), + "true", + `${mode} mode menuitem should be active` + ); + + // Hide the popup again. + popupHiddenPromise = popupHidden(popup); + EventUtils.synthesizeMouseAtCenter(popupButton, {}); + await popupHiddenPromise; + + // Check that the menuitem is still active after re-opening customize mode. + await endCustomizing(); + await startCustomizing(); + + popupShownPromise = popupShown(popup); + EventUtils.synthesizeMouseAtCenter(popupButton, {}); + await popupShownPromise; + + is( + item.getAttribute("active"), + "true", + `${mode} mode menuitem should be active after entering and exiting customize mode` + ); + + // Click the normal menuitem and check that the density is reset. + popupHiddenPromise = popupHidden(popup); + EventUtils.synthesizeMouseAtCenter(normalItem, {}); + await popupHiddenPromise; + + ok( + !win.hasAttribute("uidensity"), + "UI Density should be reset when clicking the normal menuitem" + ); + + is( + Services.prefs.getIntPref(PREF_UI_DENSITY), + window.gUIDensity.MODE_NORMAL, + "UI Density pref should be set to normal." + ); + + // Show the popup and click on the mode menuitem again to test the + // reset default feature. + popupShownPromise = popupShown(popup); + EventUtils.synthesizeMouseAtCenter(popupButton, {}); + await popupShownPromise; + + popupHiddenPromise = popupHidden(popup); + EventUtils.synthesizeMouseAtCenter(item, {}); + await popupHiddenPromise; + + is( + win.getAttribute("uidensity"), + mode, + `UI Density should be set to ${mode} on ${mode} menuitem click` + ); + + is( + Services.prefs.getIntPref(PREF_UI_DENSITY), + modePref, + `UI Density pref should be set to ${mode} when clicking the ${mode} menuitem` + ); + + await gCustomizeMode.reset(); + + ok( + !win.hasAttribute("uidensity"), + "UI Density should be reset when clicking the normal menuitem" + ); + + is( + Services.prefs.getIntPref(PREF_UI_DENSITY), + window.gUIDensity.MODE_NORMAL, + "UI Density pref should be set to normal." + ); + + await endCustomizing(); +} + +add_task(async function test_touch_mode_menuitem() { + // OSX doesn't get touch mode for now. + if (AppConstants.platform == "macosx") { + is( + document.getElementById("customization-uidensity-menuitem-touch"), + null, + "There's no touch option on Mac OSX" + ); + return; + } + + await testModeMenuitem("touch", window.gUIDensity.MODE_TOUCH); + + // Test the checkbox for automatic Touch Mode transition + // in Windows Tablet Mode. + if (AppConstants.platform == "win") { + await startCustomizing(); + + let popupButton = document.getElementById("customization-uidensity-button"); + let popup = document.getElementById("customization-uidensity-menu"); + let popupShownPromise = popupShown(popup); + EventUtils.synthesizeMouseAtCenter(popupButton, {}); + await popupShownPromise; + + let checkbox = document.getElementById( + "customization-uidensity-autotouchmode-checkbox" + ); + ok(checkbox.checked, "Checkbox should be checked by default"); + + // Test toggling the checkbox. + EventUtils.synthesizeMouseAtCenter(checkbox, {}); + is( + Services.prefs.getBoolPref(PREF_AUTO_TOUCH_MODE), + false, + "Automatic Touch Mode is off when the checkbox is unchecked." + ); + + EventUtils.synthesizeMouseAtCenter(checkbox, {}); + is( + Services.prefs.getBoolPref(PREF_AUTO_TOUCH_MODE), + true, + "Automatic Touch Mode is on when the checkbox is checked." + ); + + // Test reset to defaults. + EventUtils.synthesizeMouseAtCenter(checkbox, {}); + is( + Services.prefs.getBoolPref(PREF_AUTO_TOUCH_MODE), + false, + "Automatic Touch Mode is off when the checkbox is unchecked." + ); + + await gCustomizeMode.reset(); + is( + Services.prefs.getBoolPref(PREF_AUTO_TOUCH_MODE), + true, + "Automatic Touch Mode is on when the checkbox is checked." + ); + } +}); + +add_task(async function cleanup() { + await endCustomizing(); + + Services.prefs.clearUserPref(PREF_UI_DENSITY); + Services.prefs.clearUserPref(PREF_AUTO_TOUCH_MODE); +}); diff --git a/browser/components/customizableui/test/browser_disable_commands_customize.js b/browser/components/customizableui/test/browser_disable_commands_customize.js new file mode 100644 index 0000000000..f3eb06efbe --- /dev/null +++ b/browser/components/customizableui/test/browser_disable_commands_customize.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Most commands don't make sense in customize mode. Check that they're + * disabled, so shortcuts can't activate them either. Also check that + * some basic commands (close tab/window, quit, new tab, new window) + * remain functional. + */ +add_task(async function test_disable_commands() { + let disabledCommands = ["cmd_print", "Browser:SavePage", "Browser:SendLink"]; + let enabledCommands = [ + "cmd_newNavigatorTab", + "cmd_newNavigator", + "cmd_quitApplication", + "cmd_close", + "cmd_closeWindow", + ]; + + function checkDisabled() { + for (let cmd of disabledCommands) { + is( + document.getElementById(cmd).getAttribute("disabled"), + "true", + `Command ${cmd} should be disabled` + ); + } + for (let cmd of enabledCommands) { + ok( + !document.getElementById(cmd).hasAttribute("disabled"), + `Command ${cmd} should NOT be disabled` + ); + } + } + await startCustomizing(); + + checkDisabled(); + + // Do a reset just for fun, making sure we don't accidentally + // break things: + await gCustomizeMode.reset(); + + checkDisabled(); + + await endCustomizing(); + for (let cmd of disabledCommands.concat(enabledCommands)) { + ok( + !document.getElementById(cmd).hasAttribute("disabled"), + `Command ${cmd} should NOT be disabled after customize mode` + ); + } +}); + +/** + * When buttons are connected to a command, they should not get + * disabled just because we move them. + */ +add_task(async function test_dont_disable_when_moving() { + let button = gNavToolbox.palette.querySelector("#print-button"); + ok(button.hasAttribute("command"), "Button should have a command attribute."); + await startCustomizing(); + CustomizableUI.addWidgetToArea("print-button", "nav-bar"); + await endCustomizing(); + ok( + !button.hasAttribute("disabled"), + "Should not have disabled attribute after adding the button." + ); + ok( + button.hasAttribute("command"), + "Button should still have a command attribute." + ); + + await startCustomizing(); + await gCustomizeMode.reset(); + await endCustomizing(); + ok( + !button.hasAttribute("disabled"), + "Should not have disabled attribute when resetting in customize mode" + ); + ok( + button.hasAttribute("command"), + "Button should still have a command attribute." + ); +}); diff --git a/browser/components/customizableui/test/browser_drag_outside_palette.js b/browser/components/customizableui/test/browser_drag_outside_palette.js new file mode 100644 index 0000000000..2785a08896 --- /dev/null +++ b/browser/components/customizableui/test/browser_drag_outside_palette.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that moving items from the toolbar or panel to the palette by + * dropping on the panel container (not inside the visible panel) works. + */ +add_task(async function () { + await startCustomizing(); + let panelContainer = document.getElementById("customization-panel-container"); + // Try dragging an item from the navbar: + let stopReloadButton = document.getElementById("stop-reload-button"); + let oldNavbarPlacements = CustomizableUI.getWidgetIdsInArea("nav-bar"); + simulateItemDrag(stopReloadButton, panelContainer); + assertAreaPlacements( + CustomizableUI.AREA_NAVBAR, + oldNavbarPlacements.filter(w => w != "stop-reload-button") + ); + ok( + stopReloadButton.closest("#customization-palette"), + "Button should be in the palette" + ); + + // Put it in the panel and try again from there: + let panelHolder = document.getElementById("customization-panelHolder"); + simulateItemDrag(stopReloadButton, panelHolder); + assertAreaPlacements(CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, [ + "stop-reload-button", + ]); + + simulateItemDrag(stopReloadButton, panelContainer); + assertAreaPlacements(CustomizableUI.AREA_FIXED_OVERFLOW_PANEL, []); + + ok( + stopReloadButton.closest("#customization-palette"), + "Button should be in the palette" + ); + + // Check we can't move non-removable items like this: + let urlbar = document.getElementById("urlbar-container"); + simulateItemDrag(urlbar, panelContainer); + assertAreaPlacements( + CustomizableUI.AREA_NAVBAR, + oldNavbarPlacements.filter(w => w != "stop-reload-button") + ); +}); + +registerCleanupFunction(async function () { + await gCustomizeMode.reset(); + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_editcontrols_update.js b/browser/components/customizableui/test/browser_editcontrols_update.js new file mode 100644 index 0000000000..9f064e521a --- /dev/null +++ b/browser/components/customizableui/test/browser_editcontrols_update.js @@ -0,0 +1,307 @@ +// This test checks that the edit command enabled state (cut/paste) is updated +// properly when the edit controls are on the toolbar, popup and not present. +// It also verifies that the performance optimiation implemented by +// updateEditUIVisibility in browser.js is applied. + +let isMac = navigator.platform.indexOf("Mac") == 0; + +function checkState(allowCut, desc, testWindow = window) { + is( + testWindow.document.getElementById("cmd_cut").getAttribute("disabled") == + "true", + !allowCut, + desc + " - cut" + ); + is( + testWindow.document.getElementById("cmd_paste").getAttribute("disabled") == + "true", + false, + desc + " - paste" + ); +} + +// Add a special controller to the urlbar and browser to listen in on when +// commands are being updated. Return a promise that resolves when 'count' +// updates have occurred. +function expectCommandUpdate(count, testWindow = window) { + return new Promise((resolve, reject) => { + let overrideController = { + supportsCommand(cmd) { + return cmd == "cmd_delete"; + }, + isCommandEnabled(cmd) { + if (!count) { + ok(false, "unexpected update"); + reject(); + } + + if (!--count) { + testWindow.gURLBar.inputField.controllers.removeControllerAt( + 0, + overrideController + ); + testWindow.gBrowser.selectedBrowser.controllers.removeControllerAt( + 0, + overrideController + ); + resolve(true); + } + }, + }; + + if (!count) { + SimpleTest.executeSoon(() => { + testWindow.gURLBar.inputField.controllers.removeControllerAt( + 0, + overrideController + ); + testWindow.gBrowser.selectedBrowser.controllers.removeControllerAt( + 0, + overrideController + ); + resolve(false); + }); + } + + testWindow.gURLBar.inputField.controllers.insertControllerAt( + 0, + overrideController + ); + testWindow.gBrowser.selectedBrowser.controllers.insertControllerAt( + 0, + overrideController + ); + }); +} + +// Call this between `.select()` to make sure the selection actually changes +// and thus TextInputListener::UpdateTextInputCommands() is called. +function deselectURLBarAndSpin() { + gURLBar.inputField.setSelectionRange(0, 0); + return new Promise(setTimeout); +} + +add_task(async function test_init() { + // Put something on the clipboard to verify that the paste button is properly enabled during the test. + let clipboardHelper = Cc["@mozilla.org/widget/clipboardhelper;1"].getService( + Ci.nsIClipboardHelper + ); + await new Promise(resolve => { + SimpleTest.waitForClipboard( + "Sample", + function () { + clipboardHelper.copyString("Sample"); + }, + resolve + ); + }); + + // Open and close the panel first so that it is fully initialized. + await gCUITestUtils.openMainMenu(); + await gCUITestUtils.hideMainMenu(); +}); + +// Test updating when the panel is open with the edit-controls on the panel. +// Updates should occur. +add_task(async function test_panelui_opened() { + document.commandDispatcher.unlock(); + gURLBar.focus(); + gURLBar.value = "test"; + + await gCUITestUtils.openMainMenu(); + + checkState(false, "Update when edit-controls is on panel and visible"); + + let overridePromise = expectCommandUpdate(1); + gURLBar.select(); + await overridePromise; + + checkState( + true, + "Update when edit-controls is on panel and selection changed" + ); + + overridePromise = expectCommandUpdate(0); + await gCUITestUtils.hideMainMenu(); + await overridePromise; + + // Check that updates do not occur after the panel has been closed. + checkState(true, "Update when edit-controls is on panel and hidden"); + + // Mac will update the enabled state even when the panel is closed so that + // main menubar shortcuts will work properly. + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(isMac ? 1 : 0); + gURLBar.select(); + await overridePromise; + checkState( + true, + "Update when edit-controls is on panel, hidden and selection changed" + ); +}); + +// Test updating when the edit-controls are moved to the toolbar. +add_task(async function test_panelui_customize_to_toolbar() { + await startCustomizing(); + let navbar = document.getElementById("nav-bar"); + simulateItemDrag( + document.getElementById("edit-controls"), + CustomizableUI.getCustomizationTarget(navbar), + "end" + ); + await endCustomizing(); + + // updateEditUIVisibility should be called when customization ends but isn't. See bug 1359790. + updateEditUIVisibility(); + + // The URL bar may have been focused to begin with, which means + // that subsequent calls to focus it won't result in command + // updates, so we'll make sure to blur it. + gURLBar.blur(); + + let overridePromise = expectCommandUpdate(1); + gURLBar.select(); + gURLBar.focus(); + gURLBar.value = "other"; + await overridePromise; + checkState(false, "Update when edit-controls on toolbar and focused"); + + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(1); + gURLBar.select(); + await overridePromise; + checkState( + true, + "Update when edit-controls on toolbar and selection changed" + ); + + const kOverflowPanel = document.getElementById("widget-overflow"); + + let originalWidth = window.outerWidth; + registerCleanupFunction(async function () { + kOverflowPanel.removeAttribute("animate"); + window.resizeTo(originalWidth, window.outerHeight); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); + CustomizableUI.reset(); + }); + + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + await TestUtils.waitForCondition( + () => + navbar.hasAttribute("overflowing") && + !navbar.querySelector("edit-controls") + ); + + // Mac will update the enabled state even when the buttons are overflowing, + // so main menubar shortcuts will work properly. + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(isMac ? 1 : 0); + gURLBar.select(); + await overridePromise; + checkState( + true, + "Update when edit-controls is on overflow panel, hidden and selection changed" + ); + + // Check that we get an update if we select content while the panel is open. + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(1); + await navbar.overflowable.show(); + gURLBar.select(); + await overridePromise; + + // And that we don't (except on mac) when the panel is hidden. + kOverflowPanel.hidePopup(); + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(isMac ? 1 : 0); + gURLBar.select(); + await overridePromise; + + window.resizeTo(originalWidth, window.outerHeight); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); + + CustomizableUI.addWidgetToArea( + "edit-controls", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + // updateEditUIVisibility should be called when customization happens but isn't. See bug 1359790. + updateEditUIVisibility(); + + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(isMac ? 1 : 0); + gURLBar.select(); + await overridePromise; + + // Check that we get an update if we select content while the panel is open. + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(1); + await navbar.overflowable.show(); + gURLBar.select(); + await overridePromise; + + // And that we don't (except on mac) when the panel is hidden. + kOverflowPanel.hidePopup(); + await deselectURLBarAndSpin(); + overridePromise = expectCommandUpdate(isMac ? 1 : 0); + gURLBar.select(); + await overridePromise; +}); + +// Test updating when the edit-controls are moved to the palette. +add_task(async function test_panelui_customize_to_palette() { + await startCustomizing(); + let palette = document.getElementById("customization-palette"); + simulateItemDrag(document.getElementById("edit-controls"), palette); + await endCustomizing(); + + // updateEditUIVisibility should be called when customization ends but isn't. See bug 1359790. + updateEditUIVisibility(); + + let overridePromise = expectCommandUpdate(isMac ? 1 : 0); + gURLBar.focus(); + gURLBar.value = "other"; + gURLBar.select(); + await overridePromise; + + // If the UI isn't found, the command is set to be enabled. + checkState( + true, + "Update when edit-controls is on palette, hidden and selection changed" + ); +}); + +add_task(async function finish() { + await resetCustomization(); +}); + +// Test updating in the initial state when the edit-controls are on the panel but +// have not yet been created. This needs to be done in a new window to ensure that +// other tests haven't opened the panel. +add_task(async function test_initial_state() { + let testWindow = await BrowserTestUtils.openNewBrowserWindow(); + await SimpleTest.promiseFocus(testWindow); + + // For focusing the URL bar to have an effect, we need to ensure the URL bar isn't + // initially focused: + testWindow.gBrowser.selectedTab.focus(); + await TestUtils.waitForCondition(() => !testWindow.gURLBar.focused); + + let overridePromise = expectCommandUpdate(isMac, testWindow); + + testWindow.gURLBar.focus(); + testWindow.gURLBar.value = "test"; + + await overridePromise; + + // Commands won't update when no edit UI is present. They default to being + // enabled so that keyboard shortcuts will work. The real enabled state will + // be checked when shortcut is pressed. + checkState( + !isMac, + "No update when edit-controls is on panel and not visible", + testWindow + ); + + await BrowserTestUtils.closeWindow(testWindow); + await SimpleTest.promiseFocus(window); +}); diff --git a/browser/components/customizableui/test/browser_exit_background_customize_mode.js b/browser/components/customizableui/test/browser_exit_background_customize_mode.js new file mode 100644 index 0000000000..2b53405256 --- /dev/null +++ b/browser/components/customizableui/test/browser_exit_background_customize_mode.js @@ -0,0 +1,44 @@ +"use strict"; + +/** + * Tests that if customize mode is currently attached to a background + * tab, and that tab browses to a new location, that customize mode + * is detached from that tab. + */ +add_task(async function test_exit_background_customize_mode() { + let nonCustomizingTab = gBrowser.selectedTab; + + Assert.equal( + gBrowser.tabContainer.querySelector("tab[customizemode=true]"), + null, + "Should not have a tab marked as being the customize tab now." + ); + + await startCustomizing(); + is(gBrowser.tabs.length, 2, "Should have 2 tabs"); + + let custTab = gBrowser.selectedTab; + + let finishedCustomizing = BrowserTestUtils.waitForEvent( + gNavToolbox, + "aftercustomization" + ); + await BrowserTestUtils.switchTab(gBrowser, nonCustomizingTab); + await finishedCustomizing; + + let newURL = "http://example.com/"; + BrowserTestUtils.startLoadingURIString(custTab.linkedBrowser, newURL); + await BrowserTestUtils.browserLoaded(custTab.linkedBrowser, false, newURL); + + Assert.equal( + gBrowser.tabContainer.querySelector("tab[customizemode=true]"), + null, + "Should not have a tab marked as being the customize tab now." + ); + + await startCustomizing(); + is(gBrowser.tabs.length, 3, "Should have 3 tabs now"); + + await endCustomizing(); + BrowserTestUtils.removeTab(custTab); +}); diff --git a/browser/components/customizableui/test/browser_flexible_space_area.js b/browser/components/customizableui/test/browser_flexible_space_area.js new file mode 100644 index 0000000000..f3189096de --- /dev/null +++ b/browser/components/customizableui/test/browser_flexible_space_area.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function getSpringCount(area) { + return CustomizableUI.getWidgetIdsInArea(area).filter(id => + id.includes("spring") + ).length; +} + +/** + * Check that no matter where we add a flexible space, we + * never end up without a flexible space in the palette. + */ +add_task(async function test_flexible_space_addition() { + await startCustomizing(); + let palette = document.getElementById("customization-palette"); + // Make the bookmarks toolbar visible: + CustomizableUI.setToolbarVisibility(CustomizableUI.AREA_BOOKMARKS, true); + let areas = [CustomizableUI.AREA_NAVBAR, CustomizableUI.AREA_BOOKMARKS]; + if (AppConstants.platform != "macosx") { + areas.push(CustomizableUI.AREA_MENUBAR); + } + + for (let area of areas) { + let spacer = palette.querySelector("toolbarspring"); + let toolbar = document.getElementById(area); + toolbar = CustomizableUI.getCustomizationTarget(toolbar); + + let springCount = getSpringCount(area); + simulateItemDrag(spacer, toolbar); + // Check we added the spring: + is( + springCount + 1, + getSpringCount(area), + "Should now have an extra spring" + ); + + // Check there's still one in the palette: + let newSpacer = palette.querySelector("toolbarspring"); + ok(newSpacer, "Should have created a new spring"); + } +}); +registerCleanupFunction(async function asyncCleanup() { + await endCustomizing(); + await resetCustomization(); +}); diff --git a/browser/components/customizableui/test/browser_help_panel_cloning.js b/browser/components/customizableui/test/browser_help_panel_cloning.js new file mode 100644 index 0000000000..4234a52cd8 --- /dev/null +++ b/browser/components/customizableui/test/browser_help_panel_cloning.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* global PanelUI */ + +let gAppMenuStrings = new Localization( + ["branding/brand.ftl", "browser/appmenu.ftl"], + true +); + +const CLONED_ATTRS = ["command", "oncommand", "onclick", "key", "disabled"]; + +/** + * Tests that the Help panel inside of the AppMenu properly clones + * the items from the Help menupopup. Also ensures that the AppMenu + * string variants for those menuitems exist inside of appmenu.ftl. + */ +add_task(async function test_help_panel_cloning() { + await gCUITestUtils.openMainMenu(); + registerCleanupFunction(async () => { + await gCUITestUtils.hideMainMenu(); + }); + + // Showing the Help panel should be enough to get the menupopup to + // populate itself. + let anchor = document.getElementById("PanelUI-menu-button"); + PanelUI.showHelpView(anchor); + + let appMenuHelpSubview = document.getElementById("PanelUI-helpView"); + await BrowserTestUtils.waitForEvent(appMenuHelpSubview, "ViewShowing"); + + let helpMenuPopup = document.getElementById("menu_HelpPopup"); + let helpMenuPopupItems = helpMenuPopup.querySelectorAll("menuitem"); + + for (let helpMenuPopupItem of helpMenuPopupItems) { + if (helpMenuPopupItem.hidden) { + continue; + } + + let appMenuHelpId = "appMenu_" + helpMenuPopupItem.id; + info(`Checking ${appMenuHelpId}`); + + let appMenuHelpItem = appMenuHelpSubview.querySelector(`#${appMenuHelpId}`); + Assert.ok(appMenuHelpItem, "Should have found a cloned AppMenu help item"); + + let appMenuHelpItemL10nId = appMenuHelpItem.dataset.l10nId; + // There is a convention that the Help menu item should have an + // appmenu-data-l10n-id attribute set as the AppMenu-specific localization + // id. + Assert.equal( + helpMenuPopupItem.getAttribute("appmenu-data-l10n-id"), + appMenuHelpItemL10nId, + "Help menuitem supplied a data-l10n-id for the AppMenu Help item" + ); + + let [strings] = gAppMenuStrings.formatMessagesSync([ + { id: appMenuHelpItemL10nId }, + ]); + Assert.ok(strings, "Should have found strings for the AppMenu help item"); + + // Make sure the CLONED_ATTRs are actually cloned. + for (let attr of CLONED_ATTRS) { + if (attr == "oncommand" && helpMenuPopupItem.hasAttribute("command")) { + // If the original element had a "command" attribute set, then the + // cloned element will have its "oncommand" attribute set to equal + // the "oncommand" attribute of the pointed to via the + // original's "command" attribute once it is inserted into the DOM. + // + // This is by virtue of the broadcasting ability of XUL + // elements. + let commandNode = document.getElementById( + helpMenuPopupItem.getAttribute("command") + ); + Assert.equal( + commandNode.getAttribute("oncommand"), + appMenuHelpItem.getAttribute("oncommand"), + "oncommand was properly cloned." + ); + } else { + Assert.equal( + helpMenuPopupItem.getAttribute(attr), + appMenuHelpItem.getAttribute(attr), + `${attr} attribute was cloned.` + ); + } + } + } +}); diff --git a/browser/components/customizableui/test/browser_hidden_widget_overflow.js b/browser/components/customizableui/test/browser_hidden_widget_overflow.js new file mode 100644 index 0000000000..eff0bff4b0 --- /dev/null +++ b/browser/components/customizableui/test/browser_hidden_widget_overflow.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if only hidden widgets are overflowed that the + * OverflowableToolbar won't show the overflow panel anchor. + */ + +const kHiddenButtonID = "fake-hidden-button"; +const kDisplayNoneButtonID = "display-none-button"; +const kWebExtensionButtonID1 = "fake-webextension-button-1"; +const kWebExtensionButtonID2 = "fake-webextension-button-2"; +let gWin = null; + +add_setup(async function () { + gWin = await BrowserTestUtils.openNewBrowserWindow(); + + // To make it easier to write a test where we can control overflowing + // for a test that can run in a bunch of environments with slightly + // different rules on when things will overflow, we'll go ahead and + // just remove everything removable from the nav-bar by default. Then + // we'll add our hidden item, and a single WebExtension item, and + // force toolbar overflow. + let widgetIDs = CustomizableUI.getWidgetIdsInArea(CustomizableUI.AREA_NAVBAR); + for (let widgetID of widgetIDs) { + if (CustomizableUI.isWidgetRemovable(widgetID)) { + CustomizableUI.removeWidgetFromArea(widgetID); + } + } + + CustomizableUI.createWidget({ + id: kWebExtensionButtonID1, + label: "Test WebExtension widget 1", + defaultArea: CustomizableUI.AREA_NAVBAR, + webExtension: true, + }); + + CustomizableUI.createWidget({ + id: kWebExtensionButtonID2, + label: "Test WebExtension widget 2", + defaultArea: CustomizableUI.AREA_NAVBAR, + webExtension: true, + }); + + // Let's force the WebExtension widgets to be significantly wider. This + // just makes it easier to ensure that both of these (which are to the left + // of the hidden widget) get overflowed. + for (let webExtID of [kWebExtensionButtonID1, kWebExtensionButtonID2]) { + let webExtNode = CustomizableUI.getWidget(webExtID).forWindow(gWin).node; + webExtNode.style.minWidth = "100px"; + } + + CustomizableUI.createWidget({ + id: kHiddenButtonID, + label: "Test hidden=true widget", + defaultArea: CustomizableUI.AREA_NAVBAR, + }); + + // Now hide the button with hidden=true so that it has no dimensions. + let hiddenButtonNode = + CustomizableUI.getWidget(kHiddenButtonID).forWindow(gWin).node; + hiddenButtonNode.hidden = true; + + CustomizableUI.createWidget({ + id: kDisplayNoneButtonID, + label: "Test display:none widget", + defaultArea: CustomizableUI.AREA_NAVBAR, + }); + + // Now hide the button with display: none so that it has no dimensions. + let displayNoneButtonNode = + CustomizableUI.getWidget(kDisplayNoneButtonID).forWindow(gWin).node; + displayNoneButtonNode.style.display = "none"; + + registerCleanupFunction(async () => { + CustomizableUI.destroyWidget(kWebExtensionButtonID1); + CustomizableUI.destroyWidget(kWebExtensionButtonID2); + CustomizableUI.destroyWidget(kHiddenButtonID); + CustomizableUI.destroyWidget(kDisplayNoneButtonID); + await BrowserTestUtils.closeWindow(gWin); + await CustomizableUI.reset(); + }); +}); + +add_task(async function test_hidden_widget_overflow() { + gWin.resizeTo(kForceOverflowWidthPx, window.outerHeight); + + // Wait until the left-most fake WebExtension button is overflowing. + let webExtNode = CustomizableUI.getWidget(kWebExtensionButtonID1).forWindow( + gWin + ).node; + await BrowserTestUtils.waitForMutationCondition( + webExtNode, + { attributes: true }, + () => { + return webExtNode.hasAttribute("overflowedItem"); + } + ); + + let hiddenButtonNode = + CustomizableUI.getWidget(kHiddenButtonID).forWindow(gWin).node; + Assert.ok( + hiddenButtonNode.hasAttribute("overflowedItem"), + "Hidden button should be overflowed." + ); + + let overflowButton = gWin.document.getElementById("nav-bar-overflow-button"); + + Assert.ok( + !BrowserTestUtils.isVisible(overflowButton), + "Overflow panel button should be hidden." + ); +}); diff --git a/browser/components/customizableui/test/browser_history_after_appMenu.js b/browser/components/customizableui/test/browser_history_after_appMenu.js new file mode 100644 index 0000000000..89c4b467a2 --- /dev/null +++ b/browser/components/customizableui/test/browser_history_after_appMenu.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Checks that opening the History view using the default toolbar button works + * also while the view is displayed in the main menu. + */ +add_task(async function test_history_after_appMenu() { + // First add the button to the toolbar and wait for it to show up: + CustomizableUI.addWidgetToArea("history-panelmenu", "nav-bar"); + registerCleanupFunction(() => + CustomizableUI.removeWidgetFromArea("history-panelmenu") + ); + await waitForElementShown(document.getElementById("history-panelmenu")); + + let historyView = PanelMultiView.getViewNode(document, "PanelUI-history"); + // Open the main menu. + await gCUITestUtils.openMainMenu(); + + // Show the History view as a subview of the main menu. + document.getElementById("appMenu-history-button").click(); + await BrowserTestUtils.waitForEvent(historyView, "ViewShown"); + + // Show the History view as the main view of the History panel. + document.getElementById("history-panelmenu").click(); + await BrowserTestUtils.waitForEvent(historyView, "ViewShown"); + + // Close the history panel. + let historyPanel = historyView.closest("panel"); + let promise = BrowserTestUtils.waitForEvent(historyPanel, "popuphidden"); + historyPanel.hidePopup(); + await promise; +}); diff --git a/browser/components/customizableui/test/browser_history_recently_closed.js b/browser/components/customizableui/test/browser_history_recently_closed.js new file mode 100644 index 0000000000..32c75ec8e2 --- /dev/null +++ b/browser/components/customizableui/test/browser_history_recently_closed.js @@ -0,0 +1,430 @@ +/* This Source Code Form is subject to the terms of 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 { SessionStoreTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/SessionStoreTestUtils.sys.mjs" +); +const triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL; + +SessionStoreTestUtils.init(this, window); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://example.com" +); + +let panelMenuWidgetAdded = false; +function prepareHistoryPanel() { + if (panelMenuWidgetAdded) { + return; + } + CustomizableUI.addWidgetToArea( + "history-panelmenu", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + registerCleanupFunction(() => CustomizableUI.reset()); +} + +async function openRecentlyClosedTabsMenu() { + prepareHistoryPanel(); + await openHistoryPanel(); + + let recentlyClosedTabs = document.getElementById("appMenuRecentlyClosedTabs"); + Assert.ok( + !recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs button enabled" + ); + let closeTabsPanel = document.getElementById( + "appMenu-library-recentlyClosedTabs" + ); + let panelView = closeTabsPanel && PanelView.forNode(closeTabsPanel); + if (!panelView?.active) { + recentlyClosedTabs.click(); + closeTabsPanel = document.getElementById( + "appMenu-library-recentlyClosedTabs" + ); + await BrowserTestUtils.waitForEvent(closeTabsPanel, "ViewShown"); + ok( + PanelView.forNode(closeTabsPanel)?.active, + "Opened 'Recently closed tabs' panel" + ); + } + + return closeTabsPanel; +} + +function resetClosedTabsAndWindows() { + // Clear the lists of closed windows and tabs. + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is(SessionStore.getClosedWindowCount(), 0, "Expect 0 closed windows"); + for (const win of BrowserWindowTracker.orderedWindows) { + is( + SessionStore.getClosedTabCountForWindow(win), + 0, + "Expect 0 closed tabs for this window" + ); + } +} + +registerCleanupFunction(async () => { + await resetClosedTabsAndWindows(); +}); + +add_task(async function testRecentlyClosedDisabled() { + info("Check history recently closed tabs/windows section"); + + prepareHistoryPanel(); + // We need to make sure the history is cleared before starting the test + await Sanitizer.sanitize(["history"]); + + await openHistoryPanel(); + + let recentlyClosedTabs = document.getElementById("appMenuRecentlyClosedTabs"); + let recentlyClosedWindows = document.getElementById( + "appMenuRecentlyClosedWindows" + ); + + // Wait for the disabled attribute to change, as we receive + // the "viewshown" event before this changes + await BrowserTestUtils.waitForCondition( + () => recentlyClosedTabs.getAttribute("disabled"), + "Waiting for button to become disabled" + ); + Assert.ok( + recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs button disabled" + ); + Assert.ok( + recentlyClosedWindows.getAttribute("disabled"), + "Recently closed windows button disabled" + ); + + await hideHistoryPanel(); + + gBrowser.selectedTab.focus(); + await SessionStoreTestUtils.openAndCloseTab( + window, + TEST_PATH + "dummy_history_item.html" + ); + + await openHistoryPanel(); + + await BrowserTestUtils.waitForCondition( + () => !recentlyClosedTabs.getAttribute("disabled"), + "Waiting for button to be enabled" + ); + Assert.ok( + !recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs is available" + ); + Assert.ok( + recentlyClosedWindows.getAttribute("disabled"), + "Recently closed windows button disabled" + ); + + await hideHistoryPanel(); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + let loadedPromise = BrowserTestUtils.browserLoaded( + newWin.gBrowser.selectedBrowser + ); + BrowserTestUtils.startLoadingURIString( + newWin.gBrowser.selectedBrowser, + "about:mozilla" + ); + await loadedPromise; + await BrowserTestUtils.closeWindow(newWin); + + await openHistoryPanel(); + + await BrowserTestUtils.waitForCondition( + () => !recentlyClosedWindows.getAttribute("disabled"), + "Waiting for button to be enabled" + ); + Assert.ok( + !recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs is available" + ); + Assert.ok( + !recentlyClosedWindows.getAttribute("disabled"), + "Recently closed windows is available" + ); + + await hideHistoryPanel(); +}); + +add_task(async function testRecentlyClosedTabsDisabledPersists() { + info("Check history recently closed tabs/windows section"); + + prepareHistoryPanel(); + + // We need to make sure the history is cleared before starting the test + await Sanitizer.sanitize(["history"]); + + await openHistoryPanel(); + + let recentlyClosedTabs = document.getElementById("appMenuRecentlyClosedTabs"); + Assert.ok( + recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs button disabled" + ); + + await hideHistoryPanel(); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + await openHistoryPanel(newWin.document); + recentlyClosedTabs = newWin.document.getElementById( + "appMenuRecentlyClosedTabs" + ); + Assert.ok( + recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs is disabled" + ); + + // We close the window without hiding the panel first, which used to interfere + // with populating the view subsequently. + await BrowserTestUtils.closeWindow(newWin); + + newWin = await BrowserTestUtils.openNewBrowserWindow(); + await openHistoryPanel(newWin.document); + recentlyClosedTabs = newWin.document.getElementById( + "appMenuRecentlyClosedTabs" + ); + Assert.ok( + recentlyClosedTabs.getAttribute("disabled"), + "Recently closed tabs is disabled" + ); + await hideHistoryPanel(newWin.document); + await BrowserTestUtils.closeWindow(newWin); +}); + +add_task(async function testRecentlyClosedRestoreAllTabs() { + // We need to make sure the history is cleared before starting the test + await Sanitizer.sanitize(["history"]); + await resetClosedTabsAndWindows(); + const initialTabCount = gBrowser.visibleTabs.length; + + const closedTabUrls = [ + "about:robots", + "https://example.com/", + "https://example.org/", + ]; + const windowState = { + tabs: [ + { + entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }], + }, + ], + _closedTabs: closedTabUrls.map(url => { + return { + title: url, + state: { + entries: [ + { + url, + triggeringPrincipal_base64, + }, + ], + }, + }; + }), + }; + await SessionStoreTestUtils.promiseBrowserState({ + windows: [windowState], + }); + + is(gBrowser.visibleTabs.length, 1, "We start with one tab open"); + // Open the "Recently closed tabs" panel. + let closeTabsPanel = await openRecentlyClosedTabsMenu(); + + // Click the first toolbar button in the panel. + let toolbarButton = closeTabsPanel.querySelector( + ".panel-subview-body toolbarbutton" + ); + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true); + EventUtils.sendMouseEvent({ type: "click" }, toolbarButton, window); + + info( + "We should reopen the first of closedTabUrls: " + + JSON.stringify(closedTabUrls) + ); + let reopenedTab = await newTabPromise; + is( + reopenedTab.linkedBrowser.currentURI.spec, + closedTabUrls[0], + "Opened the first URL" + ); + info(`restored tab, total open tabs: ${gBrowser.tabs.length}`); + + info("waiting for closeTab"); + await SessionStoreTestUtils.closeTab(reopenedTab); + + await openRecentlyClosedTabsMenu(); + let restoreAllItem = closeTabsPanel.querySelector(".restoreallitem"); + ok( + restoreAllItem && !restoreAllItem.hidden, + "Restore all menu item is not hidden" + ); + + // Click the restore-all toolbar button in the panel. + EventUtils.sendMouseEvent({ type: "click" }, restoreAllItem, window); + + info("waiting for restored tabs"); + await BrowserTestUtils.waitForCondition( + () => SessionStore.getClosedTabCount() === 0, + "Waiting for all the closed tabs to be opened" + ); + + is( + gBrowser.tabs.length, + initialTabCount + closedTabUrls.length, + "The expected number of closed tabs were restored" + ); + + // clean up extra tabs + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } +}); + +add_task(async function testRecentlyClosedWindows() { + // We need to make sure the history is cleared before starting the test + await Sanitizer.sanitize(["history"]); + await resetClosedTabsAndWindows(); + + // Open and close a new window. + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + let loadedPromise = BrowserTestUtils.browserLoaded( + newWin.gBrowser.selectedBrowser + ); + BrowserTestUtils.startLoadingURIString( + newWin.gBrowser.selectedBrowser, + "https://example.com" + ); + await loadedPromise; + let closedObjectsChangePromise = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + await BrowserTestUtils.closeWindow(newWin); + await closedObjectsChangePromise; + + prepareHistoryPanel(); + await openHistoryPanel(); + + // Open the "Recently closed windows" panel. + document.getElementById("appMenuRecentlyClosedWindows").click(); + + let winPanel = document.getElementById( + "appMenu-library-recentlyClosedWindows" + ); + await BrowserTestUtils.waitForEvent(winPanel, "ViewShown"); + ok(true, "Opened 'Recently closed windows' panel"); + + // Click the first toolbar button in the panel. + let panelBody = winPanel.querySelector(".panel-subview-body"); + let toolbarButton = panelBody.querySelector("toolbarbutton"); + let newWindowPromise = BrowserTestUtils.waitForNewWindow({ + url: "https://example.com/", + }); + closedObjectsChangePromise = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + EventUtils.sendMouseEvent({ type: "click" }, toolbarButton, window); + + newWin = await newWindowPromise; + await closedObjectsChangePromise; + is(gBrowser.tabs.length, 1, "Did not open new tabs"); + + await BrowserTestUtils.closeWindow(newWin); +}); + +add_task(async function testRecentlyClosedTabsFromClosedWindows() { + await resetClosedTabsAndWindows(); + const closedTabUrls = [ + "about:robots", + "https://example.com/", + "https://example.org/", + ]; + const closedWindowState = { + tabs: [ + { + entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }], + }, + ], + _closedTabs: closedTabUrls.map(url => { + return { + title: url, + state: { + entries: [ + { + url, + triggeringPrincipal_base64, + }, + ], + }, + }; + }), + }; + await SessionStoreTestUtils.promiseBrowserState({ + windows: [ + { + tabs: [ + { + entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }], + }, + ], + }, + ], + _closedWindows: [closedWindowState], + }); + Assert.equal( + SessionStore.getClosedTabCountFromClosedWindows(), + closedTabUrls.length, + "Sanity check number of closed tabs from closed windows" + ); + + prepareHistoryPanel(); + let closeTabsPanel = await openRecentlyClosedTabsMenu(); + // make sure we can actually restore one of these closed tabs + const closedTabItems = closeTabsPanel.querySelectorAll( + "toolbarbutton[targetURI]" + ); + Assert.equal( + closedTabItems.length, + closedTabUrls.length, + "We have expected number of closed tab items" + ); + + const newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true); + const closedObjectsChangePromise = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + EventUtils.sendMouseEvent({ type: "click" }, closedTabItems[0], window); + await newTabPromise; + await closedObjectsChangePromise; + + // flip the pref so none of the closed tabs from closed window are included + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.closedTabsFromClosedWindows", false]], + }); + await openHistoryPanel(); + + // verify the recently-closed-tabs menu item is disabled + let recentlyClosedTabsItem = document.getElementById( + "appMenuRecentlyClosedTabs" + ); + Assert.ok( + recentlyClosedTabsItem.hasAttribute("disabled"), + "Recently closed tabs button is now disabled" + ); + SpecialPowers.popPrefEnv(); + while (gBrowser.tabs.length > 1) { + await SessionStoreTestUtils.closeTab( + gBrowser.tabs[gBrowser.tabs.length - 1] + ); + } +}); diff --git a/browser/components/customizableui/test/browser_history_recently_closed_middleclick.js b/browser/components/customizableui/test/browser_history_recently_closed_middleclick.js new file mode 100644 index 0000000000..ee2656ebca --- /dev/null +++ b/browser/components/customizableui/test/browser_history_recently_closed_middleclick.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Verifies that middle-clicking "Recently Closed Tabs" in both history +// menus works as expected. + +const URLS = [ + "http://example.com/", + "http://example.org/", + "http://example.net/", +]; + +async function setupTest() { + // Navigate the initial tab to ensure that it won't be reused for the tab + // that will be reopened. + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "https://example.com" + ); + await loadPromise; + + // Populate the recently closed tabs list. + for (let url of URLS) { + await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + } + for (let i = 0; i < URLS.length; i++) { + gBrowser.removeTab(gBrowser.selectedTab); + } + + return gBrowser.tabs.length; +} + +add_task(async function testMenubar() { + if (AppConstants.platform === "macosx") { + ok(true, "Can't open menu items on macOS"); + return; + } + + let nOpenTabs = await setupTest(); + + // Open the "History" menu. + let menu = document.getElementById("history-menu"); + let popupPromise = BrowserTestUtils.waitForEvent(menu, "popupshown"); + menu.open = true; + await popupPromise; + ok(true, "Opened 'History' menu"); + + // Open the "Recently Closed Tabs" submenu. + let undoMenu = document.getElementById("historyUndoMenu"); + popupPromise = BrowserTestUtils.waitForEvent(undoMenu, "popupshown"); + undoMenu.open = true; + let popupEvent = await popupPromise; + ok(true, "Opened 'Recently Closed Tabs' menu"); + + // And now middle-click the first item in that menu, and ensure that we're + // only opening a single new tab. + let menuitems = popupEvent.target.querySelectorAll("menuitem"); + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true); + popupEvent.target.activateItem(menuitems[0], { button: 1 }); + + let newTab = await newTabPromise; + is(newTab.linkedBrowser.currentURI.spec, URLS[0], "Opened correct URL"); + is(gBrowser.tabs.length, nOpenTabs + 1, "Only opened 1 new tab"); + + gBrowser.removeTab(newTab); +}); + +add_task(async function testHistoryPanel() { + let nOpenTabs = await setupTest(); + + // Setup history panel. + CustomizableUI.addWidgetToArea( + "history-panelmenu", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + registerCleanupFunction(() => CustomizableUI.reset()); + await openHistoryPanel(); + + // Open the "Recently closed tabs" panel. + let recentlyClosedTabs = document.getElementById("appMenuRecentlyClosedTabs"); + recentlyClosedTabs.click(); + + let recentlyClosedTabsPanel = document.getElementById( + "appMenu-library-recentlyClosedTabs" + ); + await BrowserTestUtils.waitForEvent(recentlyClosedTabsPanel, "ViewShown"); + ok(true, "Opened 'Recently closed tabs' panel"); + + let panelBody = recentlyClosedTabsPanel.querySelector(".panel-subview-body"); + let toolbarButtons = panelBody.querySelectorAll("toolbarbutton"); + + // Middle-click the first toolbar button in the panel. + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true); + EventUtils.sendMouseEvent( + { type: "click", button: 1 }, + toolbarButtons[0], + window + ); + + let newTab = await newTabPromise; + is(newTab.linkedBrowser.currentURI.spec, URLS[0], "Opened correct URL"); + is(gBrowser.tabs.length, nOpenTabs + 1, "Only opened 1 new tab"); + + gBrowser.removeTab(newTab); +}); diff --git a/browser/components/customizableui/test/browser_history_restore_session.js b/browser/components/customizableui/test/browser_history_restore_session.js new file mode 100644 index 0000000000..a8b9529209 --- /dev/null +++ b/browser/components/customizableui/test/browser_history_restore_session.js @@ -0,0 +1,52 @@ +/* This Source Code Form is subject to the terms of 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"; + +add_task(async function testRestoreSession() { + info("Check history panel's restore previous session button"); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + CustomizableUI.addWidgetToArea( + "history-panelmenu", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + registerCleanupFunction(() => CustomizableUI.reset()); + + // We need to make sure the history is cleared before starting the test + await Sanitizer.sanitize(["history"]); + + await openHistoryPanel(win.document); + + let restorePrevSessionBtn = win.document.getElementById( + "appMenu-restoreSession" + ); + + Assert.ok( + restorePrevSessionBtn.hidden, + "Restore previous session button is not visible" + ); + await hideHistoryPanel(win.document); + + BrowserTestUtils.addTab(win.gBrowser, "about:mozilla"); + await BrowserTestUtils.closeWindow(win); + + win = await BrowserTestUtils.openNewBrowserWindow(); + + let lastSession = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionStore.sys.mjs" + )._LastSession; + lastSession.setState(true); + + await openHistoryPanel(win.document); + + restorePrevSessionBtn = win.document.getElementById("appMenu-restoreSession"); + Assert.ok( + !restorePrevSessionBtn.hidden, + "Restore previous session button is visible" + ); + + await hideHistoryPanel(win.document); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/customizableui/test/browser_insert_before_moved_node.js b/browser/components/customizableui/test/browser_insert_before_moved_node.js new file mode 100644 index 0000000000..611f4e3ce0 --- /dev/null +++ b/browser/components/customizableui/test/browser_insert_before_moved_node.js @@ -0,0 +1,51 @@ +"use strict"; + +/** + * Check inserting before a node that has moved from the toolbar into a + * non-customizable bit of the browser works. + */ +add_task(async function () { + for (let toolbar of ["nav-bar", "TabsToolbar"]) { + CustomizableUI.createWidget({ + id: "real-button", + label: "test real button", + }); + CustomizableUI.addWidgetToArea("real-button", toolbar); + CustomizableUI.addWidgetToArea("moved-button-not-here", toolbar); + let placements = CustomizableUI.getWidgetIdsInArea(toolbar); + Assert.deepEqual( + placements.slice(-2), + ["real-button", "moved-button-not-here"], + "Should have correct placements" + ); + let otherButton = document.createXULElement("toolbarbutton"); + otherButton.id = "moved-button-not-here"; + if (toolbar == "nav-bar") { + gURLBar.textbox.parentNode.appendChild(otherButton); + } else { + gBrowser.tabContainer.appendChild(otherButton); + } + CustomizableUI.destroyWidget("real-button"); + CustomizableUI.createWidget({ + id: "real-button", + label: "test real button", + }); + + let button = document.getElementById("real-button"); + ok(button, "Button should exist"); + if (button) { + let expectedContainer = CustomizableUI.getCustomizationTarget( + document.getElementById(toolbar) + ); + is( + button.parentNode, + expectedContainer, + "Button should be in the toolbar" + ); + } + + CustomizableUI.destroyWidget("real-button"); + otherButton.remove(); + CustomizableUI.reset(); + } +}); diff --git a/browser/components/customizableui/test/browser_menubar_visibility.js b/browser/components/customizableui/test/browser_menubar_visibility.js new file mode 100644 index 0000000000..82f2959905 --- /dev/null +++ b/browser/components/customizableui/test/browser_menubar_visibility.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that menubar visibility is propagated correctly to new windows. + */ +add_task(async function test_menubar_visbility() { + let menubar = document.getElementById("toolbar-menubar"); + is(menubar.getAttribute("autohide"), "true", "Menubar should be autohiding"); + registerCleanupFunction(() => { + Services.xulStore.removeValue( + AppConstants.BROWSER_CHROME_URL, + menubar.id, + "autohide" + ); + menubar.setAttribute("autohide", "true"); + }); + + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + EventUtils.synthesizeMouse( + document.getElementById("stop-reload-button"), + 2, + 2, + { + type: "contextmenu", + button: 2, + } + ); + await shownPromise; + let attrChanged = BrowserTestUtils.waitForAttribute( + "autohide", + menubar, + "false" + ); + EventUtils.synthesizeMouseAtCenter( + document.getElementById("toggle_toolbar-menubar"), + {} + ); + await attrChanged; + contextMenu.hidePopup(); // to be safe. + + is( + menubar.getAttribute("autohide"), + "false", + "Menubar should now be permanently visible." + ); + let persistedValue = Services.xulStore.getValue( + AppConstants.BROWSER_CHROME_URL, + menubar.id, + "autohide" + ); + is(persistedValue, "false", "New value should be persisted"); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + + is( + win.document.getElementById("toolbar-menubar").getAttribute("autohide"), + "false", + "Menubar should also be permanently visible in the new window." + ); + + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/customizableui/test/browser_newtab_button_customizemode.js b/browser/components/customizableui/test/browser_newtab_button_customizemode.js new file mode 100644 index 0000000000..c30616f3a3 --- /dev/null +++ b/browser/components/customizableui/test/browser_newtab_button_customizemode.js @@ -0,0 +1,181 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests in this file check that user customizations to the tabstrip show + * the correct type of new tab button while the tabstrip isn't overflowing. + */ + +const kGlobalNewTabButton = document.getElementById("new-tab-button"); +const kInnerNewTabButton = gBrowser.tabContainer.newTabButton; + +function assertNewTabButton(which) { + if (which == "global") { + isnot( + kGlobalNewTabButton.getBoundingClientRect().width, + 0, + "main new tab button should be visible" + ); + is( + kInnerNewTabButton.getBoundingClientRect().width, + 0, + "inner new tab button should be hidden" + ); + } else if (which == "inner") { + is( + kGlobalNewTabButton.getBoundingClientRect().width, + 0, + "main new tab button should be hidden" + ); + isnot( + kInnerNewTabButton.getBoundingClientRect().width, + 0, + "inner new tab button should be visible" + ); + } else { + ok(false, "Unexpected button: " + which); + } +} + +/** + * Add and remove items *after* the new tab button in customize mode. + */ +add_task(async function addremove_after_newtab_customizemode() { + await startCustomizing(); + await waitForElementShown(kGlobalNewTabButton); + simulateItemDrag( + document.getElementById("stop-reload-button"), + kGlobalNewTabButton, + "end" + ); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should have the adjacent newtab attribute" + ); + await endCustomizing(); + assertNewTabButton("inner"); + + await startCustomizing(); + let dropTarget = document.getElementById("forward-button"); + await waitForElementShown(dropTarget); + simulateItemDrag( + document.getElementById("stop-reload-button"), + dropTarget, + "end" + ); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should still have the adjacent newtab attribute" + ); + await endCustomizing(); + assertNewTabButton("inner"); + ok(CustomizableUI.inDefaultState, "Should be in default state"); +}); + +/** + * Add and remove items *before* the new tab button in customize mode. + */ +add_task(async function addremove_before_newtab_customizemode() { + await startCustomizing(); + await waitForElementShown(kGlobalNewTabButton); + simulateItemDrag( + document.getElementById("stop-reload-button"), + kGlobalNewTabButton, + "start" + ); + ok( + !gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should no longer have the adjacent newtab attribute" + ); + await endCustomizing(); + assertNewTabButton("global"); + await startCustomizing(); + let dropTarget = document.getElementById("forward-button"); + await waitForElementShown(dropTarget); + simulateItemDrag( + document.getElementById("stop-reload-button"), + dropTarget, + "end" + ); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should have the adjacent newtab attribute again" + ); + await endCustomizing(); + assertNewTabButton("inner"); + ok(CustomizableUI.inDefaultState, "Should be in default state"); +}); + +/** + * Add and remove items *after* the new tab button outside of customize mode. + */ +add_task(async function addremove_after_newtab_api() { + CustomizableUI.addWidgetToArea("stop-reload-button", "TabsToolbar"); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should have the adjacent newtab attribute" + ); + assertNewTabButton("inner"); + + CustomizableUI.reset(); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should still have the adjacent newtab attribute" + ); + assertNewTabButton("inner"); + ok(CustomizableUI.inDefaultState, "Should be in default state"); +}); + +/** + * Add and remove items *before* the new tab button outside of customize mode. + */ +add_task(async function addremove_before_newtab_api() { + let index = + CustomizableUI.getWidgetIdsInArea("TabsToolbar").indexOf("new-tab-button"); + CustomizableUI.addWidgetToArea("stop-reload-button", "TabsToolbar", index); + ok( + !gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should no longer have the adjacent newtab attribute" + ); + assertNewTabButton("global"); + + CustomizableUI.removeWidgetFromArea("stop-reload-button"); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should have the adjacent newtab attribute again" + ); + assertNewTabButton("inner"); + + CustomizableUI.reset(); + ok(CustomizableUI.inDefaultState, "Should be in default state"); +}); + +/** + * Reset to defaults in customize mode to see if that doesn't break things. + */ +add_task(async function reset_before_newtab_customizemode() { + await startCustomizing(); + await waitForElementShown(kGlobalNewTabButton); + simulateItemDrag( + document.getElementById("stop-reload-button"), + kGlobalNewTabButton, + "start" + ); + ok( + !gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should no longer have the adjacent newtab attribute" + ); + await endCustomizing(); + assertNewTabButton("global"); + await startCustomizing(); + await gCustomizeMode.reset(); + ok( + gBrowser.tabContainer.hasAttribute("hasadjacentnewtabbutton"), + "tabs should have the adjacent newtab attribute again" + ); + await endCustomizing(); + assertNewTabButton("inner"); + ok(CustomizableUI.inDefaultState, "Should be in default state"); +}); diff --git a/browser/components/customizableui/test/browser_open_from_popup.js b/browser/components/customizableui/test/browser_open_from_popup.js new file mode 100644 index 0000000000..bf140fde79 --- /dev/null +++ b/browser/components/customizableui/test/browser_open_from_popup.js @@ -0,0 +1,24 @@ +"use strict"; + +/** + * Check that opening customize mode in a popup opens it in the main window. + */ +add_task(async function open_customize_mode_from_popup() { + let promiseWindow = BrowserTestUtils.waitForNewWindow(); + SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + content.window.open("about:blank", "_blank", "height=300,toolbar=no"); + }); + let win = await promiseWindow; + let customizePromise = BrowserTestUtils.waitForEvent( + gNavToolbox, + "customizationready" + ); + win.gCustomizeMode.enter(); + await customizePromise; + ok( + document.documentElement.hasAttribute("customizing"), + "Should have opened customize mode in the parent window" + ); + await endCustomizing(); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/customizableui/test/browser_open_in_lazy_tab.js b/browser/components/customizableui/test/browser_open_in_lazy_tab.js new file mode 100644 index 0000000000..c18de67698 --- /dev/null +++ b/browser/components/customizableui/test/browser_open_in_lazy_tab.js @@ -0,0 +1,42 @@ +"use strict"; + +/** + * Check that customize mode can be loaded in a lazy tab. + */ +add_task(async function open_customize_mode_in_lazy_tab() { + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank", { + createLazyBrowser: true, + }); + gCustomizeMode.setTab(tab); + + is(tab.linkedPanel, "", "Tab should be lazy"); + + let title = gNavigatorBundle.getFormattedString("customizeMode.tabTitle", [ + document.getElementById("bundle_brand").getString("brandShortName"), + ]); + is(tab.label, title, "Tab should have correct title"); + + let customizePromise = BrowserTestUtils.waitForEvent( + gNavToolbox, + "customizationready" + ); + gCustomizeMode.enter(); + await customizePromise; + + is( + tab.getAttribute("customizemode"), + "true", + "Tab should be in customize mode" + ); + + let customizationContainer = document.getElementById( + "customization-container" + ); + is( + customizationContainer.hidden, + false, + "Customization container should be visible" + ); + + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_overflow_use_subviews.js b/browser/components/customizableui/test/browser_overflow_use_subviews.js new file mode 100644 index 0000000000..1e6e227364 --- /dev/null +++ b/browser/components/customizableui/test/browser_overflow_use_subviews.js @@ -0,0 +1,88 @@ +"use strict"; + +const kOverflowPanel = document.getElementById("widget-overflow"); + +var gOriginalWidth; +async function stopOverflowing() { + kOverflowPanel.removeAttribute("animate"); + window.resizeTo(gOriginalWidth, window.outerHeight); + await TestUtils.waitForCondition( + () => !document.getElementById("nav-bar").hasAttribute("overflowing") + ); + CustomizableUI.reset(); +} + +registerCleanupFunction(stopOverflowing); + +/** + * This checks that subview-compatible items show up as subviews rather than + * re-anchored panels. If we ever remove the library widget, please + * replace this test with another subview - don't remove it. + */ +add_task(async function check_library_subview_in_overflow() { + kOverflowPanel.setAttribute("animate", "false"); + gOriginalWidth = window.outerWidth; + + CustomizableUI.addWidgetToArea("library-button", CustomizableUI.AREA_NAVBAR); + + let navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); + ok( + !navbar.hasAttribute("overflowing"), + "Should start with a non-overflowing toolbar." + ); + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + + await TestUtils.waitForCondition(() => navbar.hasAttribute("overflowing")); + + let chevron = document.getElementById("nav-bar-overflow-button"); + let shownPanelPromise = BrowserTestUtils.waitForEvent( + kOverflowPanel, + "ViewShown" + ); + chevron.click(); + await shownPanelPromise; + + let button = document.getElementById("library-button"); + button.click(); + + let libraryView = document.getElementById("appMenu-libraryView"); + await BrowserTestUtils.waitForEvent(libraryView, "ViewShown"); + let hasSubviews = !!kOverflowPanel.querySelector("panelmultiview"); + let expectedPanel = hasSubviews + ? kOverflowPanel + : document.getElementById("customizationui-widget-panel"); + is(libraryView.closest("panel"), expectedPanel, "Should be inside the panel"); + expectedPanel.hidePopup(); + await Promise.resolve(); // wait for popup to hide fully. + await stopOverflowing(); +}); + +/** + * This checks that non-subview-compatible items still work correctly. + * Ideally we should make the downloads panel and bookmarks/library item + * proper subview items, then this test can go away, and potentially we can + * simplify some of the subview anchoring code. + */ +add_task(async function check_downloads_panel_in_overflow() { + let button = document.getElementById("downloads-button"); + await gCustomizeMode.addToPanel(button); + await waitForOverflowButtonShown(); + + let chevron = document.getElementById("nav-bar-overflow-button"); + let shownPanelPromise = promisePanelElementShown(window, kOverflowPanel); + chevron.click(); + await shownPanelPromise; + + button.click(); + await TestUtils.waitForCondition(() => { + let panel = document.getElementById("downloadsPanel"); + return panel && panel.state != "closed"; + }); + let downloadsPanel = document.getElementById("downloadsPanel"); + isnot( + downloadsPanel.state, + "closed", + "Should be attempting to show the downloads panel." + ); + downloadsPanel.hidePopup(); +}); diff --git a/browser/components/customizableui/test/browser_palette_labels.js b/browser/components/customizableui/test/browser_palette_labels.js new file mode 100644 index 0000000000..42767a8ee2 --- /dev/null +++ b/browser/components/customizableui/test/browser_palette_labels.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that all customizable buttons have labels and icons. + * + * This is primarily designed to ensure we don't end up with items without + * labels in customize mode. In the past, this has happened due to race + * conditions, where labels would be correct if and only if the item had + * already been moved into a toolbar or panel in the main UI before + * (forcing it to be constructed and any fluent identifiers to be localized + * and applied). + * We use a new window to ensure that earlier tests using some of the widgets + * in the palette do not influence our checks to see that such items get + * labels, "even" if the first time they're rendered is in customize mode's + * palette. + */ +add_task(async function test_all_buttons_have_labels() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + registerCleanupFunction(async () => { + await endCustomizing(win); + return BrowserTestUtils.closeWindow(win); + }); + await startCustomizing(win); + let { palette } = win.gNavToolbox; + // Wait for things to paint. + await TestUtils.waitForCondition(() => { + return !!Array.from(palette.querySelectorAll(".toolbarbutton-icon")).filter( + n => { + let rect = n.getBoundingClientRect(); + return rect.height > 0 && rect.width > 0; + } + ).length; + }, "Must start rendering icons."); + + for (let wrapper of palette.children) { + if (wrapper.hasAttribute("title")) { + ok(true, wrapper.firstElementChild.id + " has a label."); + } else { + info( + `${wrapper.firstElementChild.id} doesn't seem to have a label, waiting.` + ); + await BrowserTestUtils.waitForAttribute("title", wrapper); + ok( + wrapper.hasAttribute("title"), + wrapper.firstElementChild.id + " has a label." + ); + } + let icons = Array.from(wrapper.querySelectorAll(".toolbarbutton-icon")); + // If there are icons, at least one must be visible + // (not everything necessarily has one, e.g. the search bar has no icon) + if (icons.length) { + let visibleIcons = icons.filter(n => { + let rect = n.getBoundingClientRect(); + return rect.height > 0 && rect.width > 0; + }); + Assert.greater( + visibleIcons.length, + 0, + `${wrapper.firstElementChild.id} should have at least one visible icon.` + ); + } + } +}); diff --git a/browser/components/customizableui/test/browser_panelUINotifications.js b/browser/components/customizableui/test/browser_panelUINotifications.js new file mode 100644 index 0000000000..818fcbad39 --- /dev/null +++ b/browser/components/customizableui/test/browser_panelUINotifications.js @@ -0,0 +1,597 @@ +"use strict"; + +const { AppMenuNotifications } = ChromeUtils.importESModule( + "resource://gre/modules/AppMenuNotifications.sys.mjs" +); + +/** + * Tests that when we click on the main call-to-action of the doorhanger, the provided + * action is called, and the doorhanger removed. + */ +add_task(async function testMainActionCalled() { + let options = { + gBrowser: window.gBrowser, + url: "about:blank", + }; + + await BrowserTestUtils.withNewTab(options, function (browser) { + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let button = doorhanger.button; + button.click(); + + ok(mainActionCalled, "Main action callback was called"); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + }); +}); + +/** + * This tests that when we click the secondary action for a notification, + * it will display the badge for that notification on the PanelUI menu button. + * Once we click on this button, we should see an item in the menu which will + * call our main action. + */ +add_task(async function testSecondaryActionWorkflow() { + let options = { + gBrowser: window.gBrowser, + url: "about:blank", + }; + + await BrowserTestUtils.withNewTab(options, async function (browser) { + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let secondaryActionButton = doorhanger.secondaryButton; + secondaryActionButton.click(); + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is displaying on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is displaying on PanelUI button." + ); + let menuItem = PanelUI.mainView.querySelector(".panel-banner-item"); + is( + menuItem.getAttribute("data-l10n-id"), + "appmenuitem-banner-update-manual", + "Showing correct label" + ); + is(menuItem.hidden, false, "update-manual menu item is showing."); + + await gCUITestUtils.hideMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is shown on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + menuItem.click(); + ok(mainActionCalled, "Main action callback was called"); + + AppMenuNotifications.removeNotification(/.*/); + }); +}); + +/** + * This tests that the PanelUI update downloading badge and banner + * notification are correctly displayed and that clicking the banner + * item calls the main action. + */ +add_task(async function testDownloadingBadge() { + let options = { + gBrowser: window.gBrowser, + url: "about:blank", + }; + + await BrowserTestUtils.withNewTab(options, async function (browser) { + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + // The downloading notification is always displayed in a dismissed state. + AppMenuNotifications.showNotification( + "update-downloading", + mainAction, + undefined, + { dismissed: true } + ); + is(PanelUI.notificationPanel.state, "closed", "doorhanger is closed."); + + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-downloading", + "Downloading badge is displaying on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-downloading", + "Downloading badge is displaying on PanelUI button." + ); + let menuItem = PanelUI.mainView.querySelector(".panel-banner-item"); + is( + menuItem.getAttribute("data-l10n-id"), + "appmenuitem-banner-update-downloading", + "Showing correct label (downloading)" + ); + is(menuItem.hidden, false, "update-downloading menu item is showing."); + + await gCUITestUtils.hideMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-downloading", + "Downloading badge is shown on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + menuItem.click(); + ok(mainActionCalled, "Main action callback was called"); + + AppMenuNotifications.removeNotification(/.*/); + }); +}); + +/** + * We want to ensure a few things with this: + * - Adding a doorhanger will make a badge disappear + * - once the notification for the doorhanger is resolved (removed, not just dismissed), + * then we display any other badges that are remaining. + */ +add_task(async function testInteractionWithBadges() { + await BrowserTestUtils.withNewTab("about:blank", async function (browser) { + // Remove the fxa toolbar button from the navbar to ensure the notification + // is displayed on the app menu button. + let { CustomizableUI } = ChromeUtils.importESModule( + "resource:///modules/CustomizableUI.sys.mjs" + ); + CustomizableUI.removeWidgetFromArea("fxa-toolbar-menu-button"); + + AppMenuNotifications.showBadgeOnlyNotification("fxa-needs-authentication"); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Fxa badge is shown on PanelUI button." + ); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + + isnot( + PanelUI.menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Fxa badge is hidden on PanelUI button." + ); + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let secondaryActionButton = doorhanger.secondaryButton; + secondaryActionButton.click(); + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is displaying on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is displaying on PanelUI button." + ); + let menuItem = PanelUI.mainView.querySelector(".panel-banner-item"); + is( + menuItem.getAttribute("data-l10n-id"), + "appmenuitem-banner-update-manual", + "Showing correct label" + ); + is(menuItem.hidden, false, "update-manual menu item is showing."); + + menuItem.click(); + ok(mainActionCalled, "Main action callback was called"); + + is( + PanelUI.menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Fxa badge is shown on PanelUI button." + ); + AppMenuNotifications.removeNotification(/.*/); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + }); +}); + +/** + * This tests that adding a badge will not dismiss any existing doorhangers. + */ +add_task(async function testAddingBadgeWhileDoorhangerIsShowing() { + await BrowserTestUtils.withNewTab("about:blank", function (browser) { + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + AppMenuNotifications.showBadgeOnlyNotification("fxa-needs-authentication"); + + isnot( + PanelUI.menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Fxa badge is hidden on PanelUI button." + ); + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let mainActionButton = doorhanger.button; + mainActionButton.click(); + + ok(mainActionCalled, "Main action callback was called"); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Fxa badge is shown on PanelUI button." + ); + AppMenuNotifications.removeNotification(/.*/); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + }); +}); + +/** + * Tests that badges operate like a stack. + */ +add_task(async function testMultipleBadges() { + await BrowserTestUtils.withNewTab("about:blank", async function (browser) { + let doc = browser.ownerDocument; + let menuButton = doc.getElementById("PanelUI-menu-button"); + + is( + menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + is( + menuButton.hasAttribute("badge"), + false, + "Should not have the badge attribute set" + ); + + AppMenuNotifications.showBadgeOnlyNotification("fxa-needs-authentication"); + is( + menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Should have fxa-needs-authentication badge status" + ); + + AppMenuNotifications.showBadgeOnlyNotification("update-succeeded"); + is( + menuButton.getAttribute("badge-status"), + "update-succeeded", + "Should have update-succeeded badge status (update > fxa)" + ); + + AppMenuNotifications.showBadgeOnlyNotification("update-failed"); + is( + menuButton.getAttribute("badge-status"), + "update-failed", + "Should have update-failed badge status" + ); + + AppMenuNotifications.removeNotification(/^update-/); + is( + menuButton.getAttribute("badge-status"), + "fxa-needs-authentication", + "Should have fxa-needs-authentication badge status" + ); + + AppMenuNotifications.removeNotification(/^fxa-/); + is( + menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + + await gCUITestUtils.openMainMenu(); + is( + menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status (Hamburger menu opened)" + ); + await gCUITestUtils.hideMainMenu(); + + AppMenuNotifications.showBadgeOnlyNotification("fxa-needs-authentication"); + AppMenuNotifications.showBadgeOnlyNotification("update-succeeded"); + AppMenuNotifications.removeNotification(/.*/); + is( + menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + }); +}); + +/** + * Tests that non-badges also operate like a stack. + */ +add_task(async function testMultipleNonBadges() { + await BrowserTestUtils.withNewTab("about:blank", async function (browser) { + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + let updateManualAction = { + called: false, + callback: () => { + updateManualAction.called = true; + }, + }; + let updateRestartAction = { + called: false, + callback: () => { + updateRestartAction.called = true; + }, + }; + + AppMenuNotifications.showNotification("update-manual", updateManualAction); + + let notifications; + let doorhanger; + + isnot(PanelUI.notificationPanel.state, "closed", "Doorhanger is showing."); + notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + AppMenuNotifications.showNotification( + "update-restart", + updateRestartAction + ); + + isnot(PanelUI.notificationPanel.state, "closed", "Doorhanger is showing."); + notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-restart-notification", + "PanelUI is displaying the update-restart notification." + ); + + let secondaryActionButton = doorhanger.secondaryButton; + secondaryActionButton.click(); + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-restart", + "update-restart badge is displaying on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-restart", + "update-restart badge is displaying on PanelUI button." + ); + let menuItem = PanelUI.mainView.querySelector(".panel-banner-item"); + is( + menuItem.getAttribute("data-l10n-id"), + "appmenuitem-banner-update-restart", + "Showing correct label" + ); + is(menuItem.hidden, false, "update-restart menu item is showing."); + + menuItem.click(); + ok( + updateRestartAction.called, + "update-restart main action callback was called" + ); + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "update-manual badge is displaying on PanelUI button." + ); + + await gCUITestUtils.openMainMenu(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "update-manual badge is displaying on PanelUI button." + ); + is( + menuItem.getAttribute("data-l10n-id"), + "appmenuitem-banner-update-manual", + "Showing correct label" + ); + is(menuItem.hidden, false, "update-manual menu item is showing."); + + menuItem.click(); + ok( + updateManualAction.called, + "update-manual main action callback was called" + ); + }); +}); diff --git a/browser/components/customizableui/test/browser_panelUINotifications_bannerVisibility.js b/browser/components/customizableui/test/browser_panelUINotifications_bannerVisibility.js new file mode 100644 index 0000000000..df856dd4cf --- /dev/null +++ b/browser/components/customizableui/test/browser_panelUINotifications_bannerVisibility.js @@ -0,0 +1,151 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AppMenuNotifications } = ChromeUtils.importESModule( + "resource://gre/modules/AppMenuNotifications.sys.mjs" +); + +/** + * The update banner should become visible when the badge-only notification is + * shown before opening the menu. + */ +add_task(async function testBannerVisibilityBeforeOpen() { + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + AppMenuNotifications.showBadgeOnlyNotification("update-restart"); + + let menuButton = newWin.document.getElementById("PanelUI-menu-button"); + let shown = BrowserTestUtils.waitForEvent( + newWin.PanelUI.mainView, + "ViewShown" + ); + menuButton.click(); + await shown; + + let banner = newWin.document.getElementById("appMenu-proton-update-banner"); + + let labelPromise = BrowserTestUtils.waitForMutationCondition( + banner, + { attributes: true, attributeFilter: ["label"] }, + () => banner.hasAttribute("label") + ); + + ok(!banner.hidden, "Update banner should be shown"); + + await labelPromise; + + Assert.notEqual( + banner.getAttribute("label"), + "", + "Update banner should contain text" + ); + + AppMenuNotifications.removeNotification(/.*/); + + await BrowserTestUtils.closeWindow(newWin); +}); + +/** + * The update banner should become visible when the badge-only notification is + * shown during the menu is opened. + */ +add_task(async function testBannerVisibilityDuringOpen() { + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + let menuButton = newWin.document.getElementById("PanelUI-menu-button"); + let shown = BrowserTestUtils.waitForEvent( + newWin.PanelUI.mainView, + "ViewShown" + ); + menuButton.click(); + await shown; + + let banner = newWin.document.getElementById("appMenu-proton-update-banner"); + ok( + !banner.hasAttribute("label"), + "Update banner shouldn't contain text before notification" + ); + + let labelPromise = BrowserTestUtils.waitForMutationCondition( + banner, + { attributes: true, attributeFilter: ["label"] }, + () => banner.hasAttribute("label") + ); + + AppMenuNotifications.showNotification("update-restart"); + + ok(!banner.hidden, "Update banner should be shown"); + + await labelPromise; + + Assert.notEqual( + banner.getAttribute("label"), + "", + "Update banner should contain text" + ); + + AppMenuNotifications.removeNotification(/.*/); + + await BrowserTestUtils.closeWindow(newWin); +}); + +/** + * The update banner should become visible when the badge-only notification is + * shown after opening/closing the menu, so that the DOM tree is there but + * the menu is closed. + */ +add_task(async function testBannerVisibilityAfterClose() { + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + let menuButton = newWin.document.getElementById("PanelUI-menu-button"); + let shown = BrowserTestUtils.waitForEvent( + newWin.PanelUI.mainView, + "ViewShown" + ); + menuButton.click(); + await shown; + + ok(newWin.PanelUI.mainView.hasAttribute("visible")); + + let banner = newWin.document.getElementById("appMenu-proton-update-banner"); + + ok(banner.hidden, "Update banner should be hidden before notification"); + ok( + !banner.hasAttribute("label"), + "Update banner shouldn't contain text before notification" + ); + + let labelPromise = BrowserTestUtils.waitForMutationCondition( + banner, + { attributes: true, attributeFilter: ["label"] }, + () => banner.hasAttribute("label") + ); + + let hidden = BrowserTestUtils.waitForCondition(() => { + return !newWin.PanelUI.mainView.hasAttribute("visible"); + }); + menuButton.click(); + await hidden; + + AppMenuNotifications.showBadgeOnlyNotification("update-restart"); + + shown = BrowserTestUtils.waitForEvent(newWin.PanelUI.mainView, "ViewShown"); + menuButton.click(); + await shown; + + ok(!banner.hidden, "Update banner should be shown"); + + await labelPromise; + + Assert.notEqual( + banner.getAttribute("label"), + "", + "Update banner should contain text" + ); + + AppMenuNotifications.removeNotification(/.*/); + + await BrowserTestUtils.closeWindow(newWin); +}); diff --git a/browser/components/customizableui/test/browser_panelUINotifications_fullscreen.js b/browser/components/customizableui/test/browser_panelUINotifications_fullscreen.js new file mode 100644 index 0000000000..4b3340696b --- /dev/null +++ b/browser/components/customizableui/test/browser_panelUINotifications_fullscreen.js @@ -0,0 +1,92 @@ +"use strict"; + +const { AppMenuNotifications } = ChromeUtils.importESModule( + "resource://gre/modules/AppMenuNotifications.sys.mjs" +); + +add_task(async function testFullscreen() { + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let popuphiddenPromise = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popuphidden" + ); + document.documentElement.focus(); + EventUtils.synthesizeKey("KEY_F11"); + await popuphiddenPromise; + await new Promise(executeSoon); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + FullScreen.showNavToolbox(); + is( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is displaying on PanelUI button." + ); + + let popupshownPromise = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popupshown" + ); + EventUtils.synthesizeKey("KEY_F11"); + await popupshownPromise; + await new Promise(executeSoon); + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + isnot( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is not displaying on PanelUI button." + ); + + doorhanger.button.click(); + ok(mainActionCalled, "Main action callback was called"); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); +}); diff --git a/browser/components/customizableui/test/browser_panelUINotifications_fullscreen_noAutoHideToolbar.js b/browser/components/customizableui/test/browser_panelUINotifications_fullscreen_noAutoHideToolbar.js new file mode 100644 index 0000000000..853c39e89f --- /dev/null +++ b/browser/components/customizableui/test/browser_panelUINotifications_fullscreen_noAutoHideToolbar.js @@ -0,0 +1,145 @@ +"use strict"; + +// This test tends to trigger a race in the fullscreen time telemetry, +// where the fullscreen enter and fullscreen exit events (which use the +// same histogram ID) overlap. That causes TelemetryStopwatch to log an +// error. +SimpleTest.ignoreAllUncaughtExceptions(true); + +const { AppMenuNotifications } = ChromeUtils.importESModule( + "resource://gre/modules/AppMenuNotifications.sys.mjs" +); + +function waitForDocshellActivated() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + // Setting docshell activated/deactivated will trigger visibility state + // changes to relevant state ("visible" or "hidden"). AFAIK, there is no + // such event notifying docshell is being activated, so I use + // "visibilitychange" event rather than polling the isActive flag. + await ContentTaskUtils.waitForEvent( + content.document, + "visibilitychange", + true /* capture */, + aEvent => { + return content.browsingContext.isActive; + } + ); + }); +} + +function waitForFullscreen() { + return Promise.all([ + BrowserTestUtils.waitForEvent(window, "fullscreen"), + // In the platforms that support reporting occlusion state (e.g. Mac), + // enter/exit fullscreen mode will trigger docshell being set to non-activate + // and then set to activate back again. For those platforms, we should wait + // until the docshell has been activated again before starting next test, + // otherwise, the fullscreen request might be denied. + Services.appinfo.OS === "Darwin" + ? waitForDocshellActivated() + : Promise.resolve(), + ]); +} + +add_task(async function testFullscreen() { + if (Services.appinfo.OS !== "Darwin") { + await SpecialPowers.pushPrefEnv({ + set: [["browser.fullscreen.autohide", false]], + }); + } + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + await BrowserTestUtils.waitForEvent(PanelUI.notificationPanel, "popupshown"); + + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let fullscreenPromise = waitForFullscreen(); + EventUtils.synthesizeKey("KEY_F11"); + await fullscreenPromise; + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is still showing after entering fullscreen." + ); + + let popuphiddenPromise = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popuphidden" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + content.document.documentElement.requestFullscreen(); + }); + await popuphiddenPromise; + await new Promise(executeSoon); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is hidden after entering DOM fullscreen." + ); + + let popupshownPromise = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popupshown" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + content.document.exitFullscreen(); + }); + await popupshownPromise; + await new Promise(executeSoon); + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is shown after exiting DOM fullscreen." + ); + isnot( + PanelUI.menuButton.getAttribute("badge-status"), + "update-manual", + "Badge is not displaying on PanelUI button." + ); + + doorhanger.button.click(); + ok(mainActionCalled, "Main action callback was called"); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + + fullscreenPromise = BrowserTestUtils.waitForEvent(window, "fullscreen"); + EventUtils.synthesizeKey("KEY_F11"); + await fullscreenPromise; +}); diff --git a/browser/components/customizableui/test/browser_panelUINotifications_modals.js b/browser/components/customizableui/test/browser_panelUINotifications_modals.js new file mode 100644 index 0000000000..87be14fcee --- /dev/null +++ b/browser/components/customizableui/test/browser_panelUINotifications_modals.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AppMenuNotifications } = ChromeUtils.importESModule( + "resource://gre/modules/AppMenuNotifications.sys.mjs" +); + +add_task(async function testModals() { + await SpecialPowers.pushPrefEnv({ + set: [["prompts.windowPromptSubDialog", true]], + }); + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let popuphiddenPromise = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popuphidden" + ); + + let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen("accept"); + Services.prompt.asyncAlert( + window.browsingContext, + Services.prompt.MODAL_TYPE_INTERNAL_WINDOW, + "Test alert", + "Test alert description" + ); + await popuphiddenPromise; + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + + let popupshownPromise = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popupshown" + ); + + await dialogPromise; + await popupshownPromise; + isnot( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is showing." + ); + + doorhanger.button.click(); + ok(mainActionCalled, "Main action callback was called"); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); +}); diff --git a/browser/components/customizableui/test/browser_panelUINotifications_multiWindow.js b/browser/components/customizableui/test/browser_panelUINotifications_multiWindow.js new file mode 100644 index 0000000000..fd75763857 --- /dev/null +++ b/browser/components/customizableui/test/browser_panelUINotifications_multiWindow.js @@ -0,0 +1,214 @@ +"use strict"; + +const { AppMenuNotifications } = ChromeUtils.importESModule( + "resource://gre/modules/AppMenuNotifications.sys.mjs" +); + +/** + * Tests that when we try to show a notification in a background window, it + * does not display until the window comes back into the foreground. However, + * it should display a badge. + */ +add_task(async function testDoesNotShowDoorhangerForBackgroundWindow() { + let options = { + gBrowser: window.gBrowser, + url: "about:blank", + }; + + await BrowserTestUtils.withNewTab(options, async function (browser) { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await SimpleTest.promiseFocus(win); + let mainActionCalled = false; + let mainAction = { + callback: () => { + mainActionCalled = true; + }, + }; + AppMenuNotifications.showNotification("update-manual", mainAction); + is( + PanelUI.notificationPanel.state, + "closed", + "The background window's doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + true, + "The background window has a badge." + ); + + let popupShown = BrowserTestUtils.waitForEvent( + PanelUI.notificationPanel, + "popupshown" + ); + + await BrowserTestUtils.closeWindow(win); + await SimpleTest.promiseFocus(window); + await popupShown; + + let notifications = [...PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + is( + doorhanger.id, + "appMenu-update-manual-notification", + "PanelUI is displaying the update-manual notification." + ); + + let button = doorhanger.button; + + Assert.equal( + PanelUI.notificationPanel.state, + "open", + "Expect panel state to be open when clicking panel buttons" + ); + button.click(); + + ok(mainActionCalled, "Main action callback was called"); + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + }); +}); + +/** + * Tests that when we try to show a notification in a background window and in + * a foreground window, if the foreground window's main action is called, the + * background window's doorhanger will be removed. + */ +add_task( + async function testBackgroundWindowNotificationsAreRemovedByForeground() { + let options = { + gBrowser: window.gBrowser, + url: "about:blank", + }; + + await BrowserTestUtils.withNewTab(options, async function (browser) { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await SimpleTest.promiseFocus(win); + AppMenuNotifications.showNotification("update-manual", { callback() {} }); + let notifications = [...win.PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + doorhanger.button.click(); + + await BrowserTestUtils.closeWindow(win); + await SimpleTest.promiseFocus(window); + + is( + PanelUI.notificationPanel.state, + "closed", + "update-manual doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + false, + "Should not have a badge status" + ); + }); + } +); + +/** + * Tests that when we try to show a notification in a background window and in + * a foreground window, if the foreground window's doorhanger is dismissed, + * the background window's doorhanger will also be dismissed once the window + * regains focus. + */ +add_task( + async function testBackgroundWindowNotificationsAreDismissedByForeground() { + let options = { + gBrowser: window.gBrowser, + url: "about:blank", + }; + + await BrowserTestUtils.withNewTab(options, async function (browser) { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await SimpleTest.promiseFocus(win); + AppMenuNotifications.showNotification("update-manual", { callback() {} }); + let notifications = [...win.PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + let button = doorhanger.secondaryButton; + button.click(); + + await BrowserTestUtils.closeWindow(win); + await SimpleTest.promiseFocus(window); + + is( + PanelUI.notificationPanel.state, + "closed", + "The background window's doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + true, + "The dismissed notification should still have a badge status" + ); + + AppMenuNotifications.removeNotification(/.*/); + }); + } +); + +/** + * Tests that when we open a new window while a notification is showing, the + * notification also shows on the new window. + */ +add_task(async function testOpenWindowAfterShowingNotification() { + AppMenuNotifications.showNotification("update-manual", { callback() {} }); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + await SimpleTest.promiseFocus(win); + let notifications = [...win.PanelUI.notificationPanel.children].filter( + n => !n.hidden + ); + is( + notifications.length, + 1, + "PanelUI doorhanger is only displaying one notification." + ); + let doorhanger = notifications[0]; + let button = doorhanger.secondaryButton; + button.click(); + + await BrowserTestUtils.closeWindow(win); + await SimpleTest.promiseFocus(window); + + is( + PanelUI.notificationPanel.state, + "closed", + "The background window's doorhanger is closed." + ); + is( + PanelUI.menuButton.hasAttribute("badge-status"), + true, + "The dismissed notification should still have a badge status" + ); + + AppMenuNotifications.removeNotification(/.*/); +}); diff --git a/browser/components/customizableui/test/browser_panel_keyboard_navigation.js b/browser/components/customizableui/test/browser_panel_keyboard_navigation.js new file mode 100644 index 0000000000..9b5ad48cce --- /dev/null +++ b/browser/components/customizableui/test/browser_panel_keyboard_navigation.js @@ -0,0 +1,326 @@ +"use strict"; + +/** + * Test keyboard navigation in the app menu panel. + */ + +const kHelpButtonId = "appMenu-help-button2"; + +function getEnabledNavigableElementsForView(panelView) { + return Array.from( + panelView.querySelectorAll("button,toolbarbutton,menulist,.text-link") + ).filter(element => { + let bounds = element.getBoundingClientRect(); + return !element.disabled && bounds.width > 0 && bounds.height > 0; + }); +} + +add_task(async function testUpDownKeys() { + await gCUITestUtils.openMainMenu(); + + let buttons = getEnabledNavigableElementsForView(PanelUI.mainView); + + for (let button of buttons) { + if (button.disabled) { + continue; + } + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + document.commandDispatcher.focusedElement, + button, + "The correct button should be focused after navigating downward" + ); + } + + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + document.commandDispatcher.focusedElement, + buttons[0], + "Pressing upwards should cycle around and select the first button again" + ); + + for (let i = buttons.length - 1; i >= 0; --i) { + let button = buttons[i]; + if (button.disabled) { + continue; + } + EventUtils.synthesizeKey("KEY_ArrowUp"); + Assert.equal( + document.commandDispatcher.focusedElement, + button, + "The first button should be focused after navigating upward" + ); + } + + await gCUITestUtils.hideMainMenu(); +}); + +add_task(async function testHomeEndKeys() { + await gCUITestUtils.openMainMenu(); + + let buttons = getEnabledNavigableElementsForView(PanelUI.mainView); + let enabledButtons = buttons.filter(btn => !btn.disabled); + let firstButton = enabledButtons[0]; + let lastButton = enabledButtons.pop(); + + Assert.ok(firstButton != lastButton, "There is more than one button"); + + EventUtils.synthesizeKey("KEY_End"); + Assert.equal( + document.commandDispatcher.focusedElement, + lastButton, + "The last button should be focused after pressing End" + ); + + EventUtils.synthesizeKey("KEY_Home"); + Assert.equal( + document.commandDispatcher.focusedElement, + firstButton, + "The first button should be focused after pressing Home" + ); + + await gCUITestUtils.hideMainMenu(); +}); + +add_task(async function testEnterKeyBehaviors() { + await gCUITestUtils.openMainMenu(); + + let buttons = getEnabledNavigableElementsForView(PanelUI.mainView); + + // Navigate to the 'Help' button, which points to a subview. + EventUtils.synthesizeKey("KEY_ArrowUp"); + let focusedElement = document.commandDispatcher.focusedElement; + Assert.equal( + focusedElement, + buttons[buttons.length - 1], + "The last button should be focused after navigating upward" + ); + + // Make sure the Help button is in focus. + while ( + !focusedElement || + !focusedElement.id || + focusedElement.id != kHelpButtonId + ) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + focusedElement = document.commandDispatcher.focusedElement; + } + EventUtils.synthesizeKey("KEY_Enter"); + + let helpView = document.getElementById("PanelUI-helpView"); + await BrowserTestUtils.waitForEvent(helpView, "ViewShown"); + + let helpButtons = getEnabledNavigableElementsForView(helpView); + Assert.ok( + helpButtons[0].classList.contains("subviewbutton-back"), + "First button in help view should be a back button" + ); + + // For posterity, check navigating the subview using up/ down arrow keys as well. + // When opening a subview, the first control *after* the Back button gets + // focus. + EventUtils.synthesizeKey("KEY_ArrowUp"); + focusedElement = document.commandDispatcher.focusedElement; + Assert.equal( + focusedElement, + helpButtons[0], + "The Back button should be focused after navigating upward" + ); + for (let i = helpButtons.length - 1; i >= 0; --i) { + let button = helpButtons[i]; + if (button.disabled) { + continue; + } + EventUtils.synthesizeKey("KEY_ArrowUp"); + focusedElement = document.commandDispatcher.focusedElement; + Assert.equal( + focusedElement, + button, + "The previous button should be focused after navigating upward" + ); + } + + // Make sure the back button is in focus again. + while (focusedElement != helpButtons[0]) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + focusedElement = document.commandDispatcher.focusedElement; + } + + // The first button is the back button. Hittin Enter should navigate us back. + let promise = BrowserTestUtils.waitForEvent(PanelUI.mainView, "ViewShown"); + EventUtils.synthesizeKey("KEY_Enter"); + await promise; + + // Let's test a 'normal' command button. + focusedElement = document.commandDispatcher.focusedElement; + const kFindButtonId = "appMenu-find-button2"; + while ( + !focusedElement || + !focusedElement.id || + focusedElement.id != kFindButtonId + ) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + focusedElement = document.commandDispatcher.focusedElement; + } + let findBarPromise = gBrowser.isFindBarInitialized() + ? null + : BrowserTestUtils.waitForEvent(gBrowser.selectedTab, "TabFindInitialized"); + Assert.equal( + focusedElement.id, + kFindButtonId, + "Find button should be selected" + ); + + await gCUITestUtils.hidePanelMultiView(PanelUI.panel, () => + EventUtils.synthesizeKey("KEY_Enter") + ); + + await findBarPromise; + Assert.ok(!gFindBar.hidden, "Findbar should have opened"); + gFindBar.close(); +}); + +add_task(async function testLeftRightKeys() { + await gCUITestUtils.openMainMenu(); + + // Navigate to the 'Help' button, which points to a subview. + let focusedElement = document.commandDispatcher.focusedElement; + while ( + !focusedElement || + !focusedElement.id || + focusedElement.id != kHelpButtonId + ) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + focusedElement = document.commandDispatcher.focusedElement; + } + Assert.equal( + focusedElement.id, + kHelpButtonId, + "The last button should be focused after navigating upward" + ); + + // Hitting ArrowRight on a button that points to a subview should navigate us + // there. + EventUtils.synthesizeKey("KEY_ArrowRight"); + let helpView = document.getElementById("PanelUI-helpView"); + await BrowserTestUtils.waitForEvent(helpView, "ViewShown"); + + // Hitting ArrowLeft should navigate us back. + let promise = BrowserTestUtils.waitForEvent(PanelUI.mainView, "ViewShown"); + EventUtils.synthesizeKey("KEY_ArrowLeft"); + await promise; + + focusedElement = document.commandDispatcher.focusedElement; + Assert.equal( + focusedElement.id, + kHelpButtonId, + "Help button should be focused again now that we're back in the main view" + ); + + await gCUITestUtils.hideMainMenu(); +}); + +add_task(async function testTabKey() { + await gCUITestUtils.openMainMenu(); + + let buttons = getEnabledNavigableElementsForView(PanelUI.mainView); + + for (let button of buttons) { + if (button.disabled) { + continue; + } + EventUtils.synthesizeKey("KEY_Tab"); + Assert.equal( + document.commandDispatcher.focusedElement, + button, + "The correct button should be focused after tabbing" + ); + } + + EventUtils.synthesizeKey("KEY_Tab"); + Assert.equal( + document.commandDispatcher.focusedElement, + buttons[0], + "Pressing tab should cycle around and select the first button again" + ); + + for (let i = buttons.length - 1; i >= 0; --i) { + let button = buttons[i]; + if (button.disabled) { + continue; + } + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + Assert.equal( + document.commandDispatcher.focusedElement, + button, + "The correct button should be focused after shift + tabbing" + ); + } + + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + Assert.equal( + document.commandDispatcher.focusedElement, + buttons[buttons.length - 1], + "Pressing shift + tab should cycle around and select the last button again" + ); + + await gCUITestUtils.hideMainMenu(); +}); + +add_task(async function testInterleavedTabAndArrowKeys() { + await gCUITestUtils.openMainMenu(); + + let buttons = getEnabledNavigableElementsForView(PanelUI.mainView); + let tab = false; + + for (let button of buttons) { + if (button.disabled) { + continue; + } + if (tab) { + EventUtils.synthesizeKey("KEY_Tab"); + } else { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + tab = !tab; + } + + Assert.equal( + document.commandDispatcher.focusedElement, + buttons[buttons.length - 1], + "The last button should be focused after a mix of Tab and ArrowDown" + ); + + await gCUITestUtils.hideMainMenu(); +}); + +add_task(async function testSpaceDownAfterTabNavigation() { + await gCUITestUtils.openMainMenu(); + + let buttons = getEnabledNavigableElementsForView(PanelUI.mainView); + let button; + + for (button of buttons) { + if (button.disabled) { + continue; + } + EventUtils.synthesizeKey("KEY_Tab"); + if (button.id == kHelpButtonId) { + break; + } + } + + Assert.equal( + document.commandDispatcher.focusedElement, + button, + "Help button should be focused after tabbing to it." + ); + + // Pressing down space on a button that points to a subview should navigate us + // there, before keyup. + EventUtils.synthesizeKey(" ", { type: "keydown" }); + let helpView = document.getElementById("PanelUI-helpView"); + await BrowserTestUtils.waitForEvent(helpView, "ViewShown"); + + await gCUITestUtils.hideMainMenu(); +}); diff --git a/browser/components/customizableui/test/browser_panel_locationSpecific.js b/browser/components/customizableui/test/browser_panel_locationSpecific.js new file mode 100644 index 0000000000..9965a141b2 --- /dev/null +++ b/browser/components/customizableui/test/browser_panel_locationSpecific.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* + * This test creates multiple panels, one that has been tagged as location specific + * and one that isn't. When the location changes, the specific panel should close. + * The non-specific panel should remain open. + * + */ + +add_task(async function () { + let specificPanel = document.createXULElement("panel"); + specificPanel.setAttribute("locationspecific", "true"); + specificPanel.setAttribute("noautohide", "true"); + specificPanel.style.height = "100px"; + specificPanel.style.width = "100px"; + + let generalPanel = document.createXULElement("panel"); + generalPanel.setAttribute("noautohide", "true"); + generalPanel.style.height = "100px"; + generalPanel.style.width = "100px"; + + let anchor = document.getElementById(CustomizableUI.AREA_NAVBAR); + + anchor.appendChild(specificPanel); + anchor.appendChild(generalPanel); + is(specificPanel.state, "closed", "specificPanel starts as closed"); + is(generalPanel.state, "closed", "generalPanel starts as closed"); + + let specificPanelPromise = BrowserTestUtils.waitForEvent( + specificPanel, + "popupshown" + ); + + specificPanel.openPopupAtScreen(0, 0); + + await specificPanelPromise; + is(specificPanel.state, "open", "specificPanel has been opened"); + + let generalPanelPromise = BrowserTestUtils.waitForEvent( + generalPanel, + "popupshown" + ); + + generalPanel.openPopupAtScreen(100, 0); + + await generalPanelPromise; + is(generalPanel.state, "open", "generalPanel has been opened"); + + let specificPanelHiddenPromise = BrowserTestUtils.waitForEvent( + specificPanel, + "popuphidden" + ); + + // Simulate a location change, and check which panel closes. + let browser = gBrowser.selectedBrowser; + let loaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.startLoadingURIString(browser, "http://mochi.test:8888/#0"); + await loaded; + + await specificPanelHiddenPromise; + + is( + specificPanel.state, + "closed", + "specificPanel panel is closed after location change" + ); + is( + generalPanel.state, + "open", + "generalPanel is still open after location change" + ); + + specificPanel.remove(); + generalPanel.remove(); +}); diff --git a/browser/components/customizableui/test/browser_panel_menulist.js b/browser/components/customizableui/test/browser_panel_menulist.js new file mode 100644 index 0000000000..c863a872ee --- /dev/null +++ b/browser/components/customizableui/test/browser_panel_menulist.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const kViewID = "panelview-with-menulist"; + +/** + * When there's a menulist inside a panelview, closing it shouldn't close the panel. + */ +add_task(async function test_closing_menulist_should_not_close_panel() { + let viewCache = document.getElementById("appMenu-viewCache"); + let panelview = document.createXULElement("panelview"); + panelview.id = kViewID; + let menulist = document.createXULElement("menulist"); + let popup = document.createXULElement("menupopup"); + for (let item of ["one", "two"]) { + let menuitem = document.createXULElement("menuitem"); + menuitem.id = `menuitem-${item}`; + menuitem.setAttribute("label", item); + popup.append(menuitem); + } + menulist.append(popup); + panelview.append(menulist); + viewCache.append(panelview); + await PanelUI.showSubView(kViewID, PanelUI.menuButton); + let panel = panelview.closest("panel"); + + // Ensure that not only has the subview started showing, the panel is + // all the way open: + await BrowserTestUtils.waitForPopupEvent(panel, "shown"); + + registerCleanupFunction(async () => { + if (panel && panel.state != "closed") { + let panelGone = BrowserTestUtils.waitForPopupEvent(panel, "hidden"); + panel.hidePopup(); + await panelGone; + } + panelview.remove(); + }); + + let shown = BrowserTestUtils.waitForPopupEvent(popup, "shown"); + menulist.openMenu(true); + await shown; + let hidden = BrowserTestUtils.waitForPopupEvent(popup, "hidden"); + popup.activateItem(popup.firstElementChild); + await hidden; + + Assert.equal(panel?.state, "open", "Panel should still be open."); +}); diff --git a/browser/components/customizableui/test/browser_panel_toggle.js b/browser/components/customizableui/test/browser_panel_toggle.js new file mode 100644 index 0000000000..cba441e8e5 --- /dev/null +++ b/browser/components/customizableui/test/browser_panel_toggle.js @@ -0,0 +1,53 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Test opening and closing the menu panel UI. + */ + +// Show and hide the menu panel programmatically without an event (like UITour.sys.mjs would) +add_task(async function () { + await gCUITestUtils.openMainMenu(); + + is( + PanelUI.panel.getAttribute("panelopen"), + "true", + "Check that panel has panelopen attribute" + ); + is(PanelUI.panel.state, "open", "Check that panel state is 'open'"); + + await gCUITestUtils.hideMainMenu(); + + ok( + !PanelUI.panel.hasAttribute("panelopen"), + "Check that panel doesn't have the panelopen attribute" + ); + is(PanelUI.panel.state, "closed", "Check that panel state is 'closed'"); +}); + +// Toggle the menu panel open and closed +add_task(async function () { + await gCUITestUtils.openPanelMultiView(PanelUI.panel, PanelUI.mainView, () => + PanelUI.toggle({ type: "command" }) + ); + + is( + PanelUI.panel.getAttribute("panelopen"), + "true", + "Check that panel has panelopen attribute" + ); + is(PanelUI.panel.state, "open", "Check that panel state is 'open'"); + + await gCUITestUtils.hidePanelMultiView(PanelUI.panel, () => + PanelUI.toggle({ type: "command" }) + ); + + ok( + !PanelUI.panel.hasAttribute("panelopen"), + "Check that panel doesn't have the panelopen attribute" + ); + is(PanelUI.panel.state, "closed", "Check that panel state is 'closed'"); +}); diff --git a/browser/components/customizableui/test/browser_proton_moreTools_panel.js b/browser/components/customizableui/test/browser_proton_moreTools_panel.js new file mode 100644 index 0000000000..8104b8920e --- /dev/null +++ b/browser/components/customizableui/test/browser_proton_moreTools_panel.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineLazyGetter(this, "DevToolsStartup", () => { + return Cc["@mozilla.org/devtools/startup-clh;1"].getService( + Ci.nsICommandLineHandler + ).wrappedJSObject; +}); + +// Test activating the developer button shows the More Tools panel. +add_task(async function testDevToolsPanelInToolbar() { + // We need to force DevToolsStartup to rebuild the developer tool toggle so that + // proton prefs are applied to the new browser window for this test. + DevToolsStartup.developerToggleCreated = false; + CustomizableUI.destroyWidget("developer-button"); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + + CustomizableUI.addWidgetToArea( + "developer-button", + CustomizableUI.AREA_NAVBAR + ); + + // Test the developer tools panel is showing. + let button = document.getElementById("developer-button"); + let devToolsView = PanelMultiView.getViewNode( + document, + "PanelUI-developer-tools" + ); + let devToolsShownPromise = BrowserTestUtils.waitForEvent( + devToolsView, + "ViewShown" + ); + + EventUtils.synthesizeMouseAtCenter(button, {}); + await devToolsShownPromise; + ok(true, "Dev Tools view is showing"); + is( + devToolsView.children.length, + 1, + "Dev tools subview is the only child of panel" + ); + is( + devToolsView.children[0].id, + "PanelUI-developer-tools-view", + "Dev tools child has correct id" + ); + + // Cleanup + await BrowserTestUtils.closeWindow(win); + CustomizableUI.reset(); +}); diff --git a/browser/components/customizableui/test/browser_proton_toolbar_hide_toolbarbuttons.js b/browser/components/customizableui/test/browser_proton_toolbar_hide_toolbarbuttons.js new file mode 100644 index 0000000000..ac46fd12ae --- /dev/null +++ b/browser/components/customizableui/test/browser_proton_toolbar_hide_toolbarbuttons.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"; + +ChromeUtils.defineESModuleGetters(this, { + HomePage: "resource:///modules/HomePage.sys.mjs", +}); + +const kPrefProtonToolbarVersion = "browser.proton.toolbar.version"; +const kPrefHomeButtonUsed = "browser.engagement.home-button.has-used"; +const kPrefLibraryButtonUsed = "browser.engagement.library-button.has-used"; +const kPrefSidebarButtonUsed = "browser.engagement.sidebar-button.has-used"; + +async function testToolbarButtons(aActions) { + let { + shouldRemoveHomeButton, + shouldRemoveLibraryButton, + shouldRemoveSidebarButton, + shouldUpdateVersion, + } = aActions; + const defaultPlacements = [ + "back-button", + "forward-button", + "stop-reload-button", + "home-button", + "customizableui-special-spring1", + "urlbar-container", + "customizableui-special-spring2", + "downloads-button", + "library-button", + "sidebar-button", + "fxa-toolbar-menu-button", + ]; + let oldState = CustomizableUI.getTestOnlyInternalProp("gSavedState"); + + Assert.equal( + Services.prefs.getIntPref(kPrefProtonToolbarVersion), + 0, + "Toolbar proton version is 0" + ); + + let CustomizableUIInternal = CustomizableUI.getTestOnlyInternalProp( + "CustomizableUIInternal" + ); + + CustomizableUI.setTestOnlyInternalProp("gSavedState", { + placements: { + "nav-bar": defaultPlacements, + }, + }); + CustomizableUIInternal._updateForNewProtonVersion(); + + let navbarPlacements = + CustomizableUI.getTestOnlyInternalProp("gSavedState").placements["nav-bar"]; + let includesHomeButton = navbarPlacements.includes("home-button"); + let includesLibraryButton = navbarPlacements.includes("library-button"); + let includesSidebarButton = navbarPlacements.includes("sidebar-button"); + + Assert.equal( + !includesHomeButton, + shouldRemoveHomeButton, + "Correctly handles home button" + ); + Assert.equal( + !includesLibraryButton, + shouldRemoveLibraryButton, + "Correctly handles library button" + ); + Assert.equal( + !includesSidebarButton, + shouldRemoveSidebarButton, + "Correctly handles sidebar button" + ); + + let toolbarVersion = Services.prefs.getIntPref(kPrefProtonToolbarVersion); + if (shouldUpdateVersion) { + Assert.ok(toolbarVersion >= 1, "Toolbar proton version updated"); + } else { + Assert.ok(toolbarVersion == 0, "Toolbar proton version not updated"); + } + + // Cleanup + CustomizableUI.setTestOnlyInternalProp("gSavedState", oldState); +} + +/** + * Checks that the home button is removed from the nav-bar under + * these conditions: proton must be enabled, the toolbar engagement + * pref is false, and the homepage is about:home or about:blank. + * Otherwise, the home button should remain if it was previously + * in the navbar. + * Also checks that the library button is removed from the nav-bar + * if proton is enabled and the toolbar engagement pref is false. + */ +add_task(async function testButtonRemoval() { + // Ensure the engagement prefs are set to their default values + await SpecialPowers.pushPrefEnv({ + set: [ + [kPrefHomeButtonUsed, false], + [kPrefLibraryButtonUsed, false], + [kPrefSidebarButtonUsed, false], + ], + }); + + let tests = [ + // Proton enabled without home and library engagement + { + prefs: [], + actions: { + shouldRemoveHomeButton: true, + shouldRemoveLibraryButton: true, + shouldRemoveSidebarButton: true, + shouldUpdateVersion: true, + }, + }, + // Proton enabled with home engagement + { + prefs: [[kPrefHomeButtonUsed, true]], + actions: { + shouldRemoveHomeButton: false, + shouldRemoveLibraryButton: true, + shouldRemoveSidebarButton: true, + shouldUpdateVersion: true, + }, + }, + // Proton enabled with custom homepage + { + prefs: [], + actions: { + shouldRemoveHomeButton: false, + shouldRemoveLibraryButton: true, + shouldRemoveSidebarButton: true, + shouldUpdateVersion: true, + }, + async fn() { + HomePage.safeSet("https://example.com"); + }, + }, + // Proton enabled with library engagement + { + prefs: [[kPrefLibraryButtonUsed, true]], + actions: { + shouldRemoveHomeButton: true, + shouldRemoveLibraryButton: false, + shouldRemoveSidebarButton: true, + shouldUpdateVersion: true, + }, + }, + // Proton enabled with sidebar engagement + { + prefs: [[kPrefSidebarButtonUsed, true]], + actions: { + shouldRemoveHomeButton: true, + shouldRemoveLibraryButton: true, + shouldRemoveSidebarButton: false, + shouldUpdateVersion: true, + }, + }, + ]; + + for (let test of tests) { + await SpecialPowers.pushPrefEnv({ + set: [[kPrefProtonToolbarVersion, 0], ...test.prefs], + }); + if (test.fn) { + await test.fn(); + } + testToolbarButtons(test.actions); + HomePage.reset(); + await SpecialPowers.popPrefEnv(); + } +}); + +/** + * Checks that a null saved state (new profile) does not prevent migration. + */ +add_task(async function testNullSavedState() { + await SpecialPowers.pushPrefEnv({ + set: [[kPrefProtonToolbarVersion, 0]], + }); + let oldState = CustomizableUI.getTestOnlyInternalProp("gSavedState"); + + Assert.equal( + Services.prefs.getIntPref(kPrefProtonToolbarVersion), + 0, + "Toolbar proton version is 0" + ); + + let CustomizableUIInternal = CustomizableUI.getTestOnlyInternalProp( + "CustomizableUIInternal" + ); + CustomizableUIInternal.initialize(); + + Assert.ok( + Services.prefs.getIntPref(kPrefProtonToolbarVersion) >= 1, + "Toolbar proton version updated" + ); + let navbarPlacements = CustomizableUI.getTestOnlyInternalProp("gAreas") + .get("nav-bar") + .get("defaultPlacements"); + Assert.ok( + !navbarPlacements.includes("home-button"), + "Home button isn't included by default" + ); + Assert.ok( + !navbarPlacements.includes("library-button"), + "Library button isn't included by default" + ); + Assert.ok( + !navbarPlacements.includes("sidebar-button"), + "Sidebar button isn't included by default" + ); + + // Cleanup + CustomizableUI.setTestOnlyInternalProp("gSavedState", oldState); + await SpecialPowers.popPrefEnv(); + // Re-initialize to prevent future test failures + CustomizableUIInternal.initialize(); +}); + +/** + * Checks that a saved state that is missing nav-bar placements does not prevent migration. + */ +add_task(async function testNoNavbarPlacements() { + await SpecialPowers.pushPrefEnv({ + set: [[kPrefProtonToolbarVersion, 0]], + }); + + let oldState = CustomizableUI.getTestOnlyInternalProp("gSavedState"); + + Assert.equal( + Services.prefs.getIntPref(kPrefProtonToolbarVersion), + 0, + "Toolbar proton version is 0" + ); + + let CustomizableUIInternal = CustomizableUI.getTestOnlyInternalProp( + "CustomizableUIInternal" + ); + + CustomizableUI.setTestOnlyInternalProp("gSavedState", { + placements: { "widget-overflow-fixed-list": [] }, + }); + CustomizableUIInternal._updateForNewProtonVersion(); + + Assert.ok(true, "_updateForNewProtonVersion didn't throw"); + + // Cleanup + CustomizableUI.setTestOnlyInternalProp("gSavedState", oldState); + + await SpecialPowers.popPrefEnv(); +}); + +/** + * Checks that a saved state that is missing the placements value does not prevent migration. + */ +add_task(async function testNullPlacements() { + await SpecialPowers.pushPrefEnv({ + set: [[kPrefProtonToolbarVersion, 0]], + }); + + let oldState = CustomizableUI.getTestOnlyInternalProp("gSavedState"); + + Assert.equal( + Services.prefs.getIntPref(kPrefProtonToolbarVersion), + 0, + "Toolbar proton version is 0" + ); + + let CustomizableUIInternal = CustomizableUI.getTestOnlyInternalProp( + "CustomizableUIInternal" + ); + + CustomizableUI.setTestOnlyInternalProp("gSavedState", {}); + CustomizableUIInternal._updateForNewProtonVersion(); + + Assert.ok(true, "_updateForNewProtonVersion didn't throw"); + + // Cleanup + CustomizableUI.setTestOnlyInternalProp("gSavedState", oldState); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/customizableui/test/browser_registerArea.js b/browser/components/customizableui/test/browser_registerArea.js new file mode 100644 index 0000000000..2900c9eb8b --- /dev/null +++ b/browser/components/customizableui/test/browser_registerArea.js @@ -0,0 +1,28 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that a toolbar area can be registered with overflowable: false + * as one of its properties, and this results in a non-overflowable + * toolbar. + */ +add_task(async function test_overflowable_false() { + registerCleanupFunction(removeCustomToolbars); + + const kToolbarId = "no-overflow-toolbar"; + createToolbarWithPlacements(kToolbarId, ["spring"], { + overflowable: false, + }); + + let node = CustomizableUI.getWidget(kToolbarId).forWindow(window).node; + Assert.ok( + !node.hasAttribute("overflowable"), + "Toolbar should not be overflowable" + ); + Assert.ok( + !node.overflowable, + "OverflowableToolbar instance should not have been created." + ); +}); diff --git a/browser/components/customizableui/test/browser_reload_tab.js b/browser/components/customizableui/test/browser_reload_tab.js new file mode 100644 index 0000000000..ba44ba1e34 --- /dev/null +++ b/browser/components/customizableui/test/browser_reload_tab.js @@ -0,0 +1,103 @@ +"use strict"; + +/** + * Check that customize mode doesn't break when its tab is reloaded. + */ +add_task(async function reload_tab() { + let initialTab = gBrowser.selectedTab; + let customizeTab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + gCustomizeMode.setTab(customizeTab); + let customizationContainer = document.getElementById( + "customization-container" + ); + + is( + customizationContainer.clientWidth, + 0, + "Customization container shouldn't be visible (X)" + ); + is( + customizationContainer.clientHeight, + 0, + "Customization container shouldn't be visible (Y)" + ); + + let customizePromise = BrowserTestUtils.waitForEvent( + gNavToolbox, + "customizationready" + ); + gCustomizeMode.enter(); + await customizePromise; + + let tabReloaded = new Promise(resolve => { + gBrowser.addTabsProgressListener({ + async onLocationChange(aBrowser) { + if (customizeTab.linkedBrowser == aBrowser) { + gBrowser.removeTabsProgressListener(this); + await Promise.resolve(); + resolve(); + } + }, + }); + }); + gBrowser.reloadTab(customizeTab); + await tabReloaded; + + is( + gBrowser.getIcon(customizeTab), + "chrome://browser/skin/customize.svg", + "Tab should have customize icon" + ); + is( + customizeTab.getAttribute("customizemode"), + "true", + "Tab should be in customize mode" + ); + Assert.greater( + customizationContainer.clientWidth, + 0, + "Customization container should be visible (X)" + ); + Assert.greater( + customizationContainer.clientHeight, + 0, + "Customization container should be visible (Y)" + ); + + customizePromise = BrowserTestUtils.waitForEvent( + gNavToolbox, + "aftercustomization" + ); + await BrowserTestUtils.switchTab(gBrowser, initialTab); + await customizePromise; + + customizePromise = BrowserTestUtils.waitForEvent( + gNavToolbox, + "customizationready" + ); + await BrowserTestUtils.switchTab(gBrowser, customizeTab); + await customizePromise; + + is( + gBrowser.getIcon(customizeTab), + "chrome://browser/skin/customize.svg", + "Tab should still have customize icon" + ); + is( + customizeTab.getAttribute("customizemode"), + "true", + "Tab should still be in customize mode" + ); + Assert.greater( + customizationContainer.clientWidth, + 0, + "Customization container should still be visible (X)" + ); + Assert.greater( + customizationContainer.clientHeight, + 0, + "Customization container should still be visible (Y)" + ); + + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_remote_attribute.js b/browser/components/customizableui/test/browser_remote_attribute.js new file mode 100644 index 0000000000..543e62e2bc --- /dev/null +++ b/browser/components/customizableui/test/browser_remote_attribute.js @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * These tests check that the remote attribute is true for remote panels. + * This attribute is needed for Mac to properly render the panel. + */ +add_task(async function check_remote_attribute() { + // The panel is created on the fly, so we can't simply wait for focus + // inside it. + //let pocketPanelShown = BrowserTestUtils.waitForEvent( + // document, + // "popupshown", + // true + //); + let pocketPanelShown = popupShown(document); + // Using Pocket panel as it's an available remote panel. + let pocketButton = document.getElementById("save-to-pocket-button"); + pocketButton.click(); + await pocketPanelShown; + + let pocketPanel = document.getElementById("customizationui-widget-panel"); + is( + pocketPanel.getAttribute("remote"), + "true", + "Pocket panel has remote attribute" + ); + + // Close panel and cleanup. + let pocketPanelHidden = popupHidden(pocketPanel); + pocketPanel.hidePopup(); + await pocketPanelHidden; +}); + +add_task(async function check_remote_attribute_overflow() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + let overflowPanel = win.document.getElementById("widget-overflow"); + overflowPanel.setAttribute("animate", "false"); + + // Force a narrow window to get an overflow toolbar. + win.resizeTo(kForceOverflowWidthPx, win.outerHeight); + let navbar = win.document.getElementById(CustomizableUI.AREA_NAVBAR); + await TestUtils.waitForCondition(() => navbar.hasAttribute("overflowing")); + + // Open the overflow panel view. + let overflowPanelShown = popupShown(overflowPanel); + let overflowPanelButton = win.document.getElementById( + "nav-bar-overflow-button" + ); + overflowPanelButton.click(); + await overflowPanelShown; + + // Using Pocket panel as it's an available remote panel. + let pocketButton = win.document.getElementById("save-to-pocket-button"); + pocketButton.click(); + await BrowserTestUtils.waitForEvent(win.document, "ViewShown"); + + is( + overflowPanel.getAttribute("remote"), + "true", + "Pocket overflow panel has remote attribute" + ); + + // Close panel and cleanup. + let overflowPanelHidden = popupHidden(overflowPanel); + overflowPanel.hidePopup(); + await overflowPanelHidden; + overflowPanel.removeAttribute("animate"); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/customizableui/test/browser_remote_tabs_button.js b/browser/components/customizableui/test/browser_remote_tabs_button.js new file mode 100644 index 0000000000..094335d4b1 --- /dev/null +++ b/browser/components/customizableui/test/browser_remote_tabs_button.js @@ -0,0 +1,100 @@ +/* This Source Code Form is subject to the terms of 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"; + +let { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { UIState } = ChromeUtils.importESModule( + "resource://services-sync/UIState.sys.mjs" +); + +let getState; +let originalSync; +let syncWasCalled = false; + +// TODO: This test should probably be re-written, we don't really test much here. +add_task(async function testSyncRemoteTabsButtonFunctionality() { + info("Test the Sync Remote Tabs button in the panel"); + storeInitialValues(); + mockFunctions(); + + // Force UI update. + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + // add the sync remote tabs button to the panel + CustomizableUI.addWidgetToArea( + "sync-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + await waitForOverflowButtonShown(); + + // check the button's functionality + await document.getElementById("nav-bar").overflowable.show(); + info("The panel menu was opened"); + + let syncRemoteTabsBtn = document.getElementById("sync-button"); + ok( + syncRemoteTabsBtn, + "The sync remote tabs button was added to the Panel Menu" + ); + // click the button - the panel should open. + syncRemoteTabsBtn.click(); + let remoteTabsPanel = document.getElementById("PanelUI-remotetabs"); + let viewShown = BrowserTestUtils.waitForEvent(remoteTabsPanel, "ViewShown"); + await viewShown; + ok(remoteTabsPanel.getAttribute("visible"), "Sync Panel is in view"); + + // Find and click the "setup" button. + let syncNowButton = document.getElementById("PanelUI-remotetabs-syncnow"); + syncNowButton.click(); + info("The sync now button was clicked"); + + await TestUtils.waitForCondition(() => syncWasCalled); + + // We need to stop the Syncing animation manually otherwise the button + // will be disabled at the beginning of a next test. + gSync._onActivityStop(); +}); + +add_task(async function asyncCleanup() { + // reset the panel UI to the default state + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "The panel UI is in default state again."); + + if (isOverflowOpen()) { + let panelHidePromise = promiseOverflowHidden(window); + PanelUI.overflowPanel.hidePopup(); + await panelHidePromise; + } + + restoreValues(); +}); + +function mockFunctions() { + // mock UIState.get() + UIState.get = () => ({ + status: UIState.STATUS_SIGNED_IN, + lastSync: new Date(), + email: "user@mozilla.com", + }); + + Service.sync = mocked_sync; +} + +function mocked_sync() { + syncWasCalled = true; +} + +function restoreValues() { + UIState.get = getState; + Service.sync = originalSync; +} + +function storeInitialValues() { + getState = UIState.get; + originalSync = Service.sync; +} diff --git a/browser/components/customizableui/test/browser_remove_customized_specials.js b/browser/components/customizableui/test/browser_remove_customized_specials.js new file mode 100644 index 0000000000..1f123d10cb --- /dev/null +++ b/browser/components/customizableui/test/browser_remove_customized_specials.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that after a reset, we can still drag special nodes in customize mode + */ +add_task(async function () { + await startCustomizing(); + CustomizableUI.addWidgetToArea("spring", "nav-bar", 5); + await gCustomizeMode.reset(); + let springs = document.querySelectorAll("#nav-bar toolbarspring"); + let lastSpring = springs[springs.length - 1]; + let expectedPlacements = CustomizableUI.getWidgetIdsInArea("nav-bar"); + info("Placements before drag: " + expectedPlacements.join(",")); + let lastItem = document.getElementById( + expectedPlacements[expectedPlacements.length - 1] + ); + await waitForElementShown(lastItem); + simulateItemDrag(lastSpring, lastItem, "end"); + expectedPlacements.splice(expectedPlacements.indexOf(lastSpring.id), 1); + expectedPlacements.push(lastSpring.id); + let actualPlacements = CustomizableUI.getWidgetIdsInArea("nav-bar"); + // Log these separately because Assert.deepEqual truncates the stringified versions... + info("Actual placements: " + actualPlacements.join(",")); + info("Expected placements: " + expectedPlacements.join(",")); + Assert.deepEqual( + expectedPlacements, + actualPlacements, + "Should be able to move spring" + ); + await gCustomizeMode.reset(); + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_reset_builtin_widget_currentArea.js b/browser/components/customizableui/test/browser_reset_builtin_widget_currentArea.js new file mode 100644 index 0000000000..fa9e497734 --- /dev/null +++ b/browser/components/customizableui/test/browser_reset_builtin_widget_currentArea.js @@ -0,0 +1,26 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that if we move a non-default, but builtin, widget to another area, +// and then reset things, the currentArea is updated correctly. +add_task(async function reset_should_not_keep_currentArea() { + CustomizableUI.addWidgetToArea( + "save-page-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + // We can't check currentArea directly; check areaType which is based on it: + is( + CustomizableUI.getWidget("save-page-button").areaType, + CustomizableUI.TYPE_PANEL, + "Button should know it's in the overflow panel" + ); + CustomizableUI.reset(); + ok( + !CustomizableUI.getWidget("save-page-button").areaType, + "Button should know it's not in the overflow panel anymore" + ); +}); + +registerCleanupFunction(() => CustomizableUI.reset()); diff --git a/browser/components/customizableui/test/browser_reset_dom_events.js b/browser/components/customizableui/test/browser_reset_dom_events.js new file mode 100644 index 0000000000..2922fe481d --- /dev/null +++ b/browser/components/customizableui/test/browser_reset_dom_events.js @@ -0,0 +1,34 @@ +"use strict"; + +const widgetId = "import-button"; +const listener = { + _beforeCount: 0, + _afterCount: 0, + onWidgetBeforeDOMChange(node) { + if (node.id == widgetId) { + this._beforeCount++; + } + }, + onWidgetAfterDOMChange(node) { + if (node.id == widgetId) { + this._afterCount++; + } + }, +}; + +add_task(async function test_reset_dom_events() { + await startCustomizing(); + + CustomizableUI.addWidgetToArea(widgetId, CustomizableUI.AREA_BOOKMARKS); + CustomizableUI.addListener(listener); + + info("Resetting"); + await gCustomizeMode.reset(); + + is(listener._beforeCount, 1, "Should've been notified of the mutation"); + is(listener._afterCount, 1, "Should've been notified of the mutation"); + + CustomizableUI.removeListener(listener); + + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_screenshot_button_disabled.js b/browser/components/customizableui/test/browser_screenshot_button_disabled.js new file mode 100644 index 0000000000..b8eca2b3d3 --- /dev/null +++ b/browser/components/customizableui/test/browser_screenshot_button_disabled.js @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +"use strict"; + +add_task(async function testScreenshotButtonPrefDisabled() { + info("Test the Screenshots widget not available"); + + Assert.ok( + Services.prefs.getBoolPref("extensions.screenshots.disabled"), + "Sceenshots feature is disabled" + ); + + CustomizableUI.addWidgetToArea( + "screenshot-button", + CustomizableUI.AREA_NAVBAR + ); + + let screenshotBtn = document.getElementById("screenshot-button"); + Assert.ok(!screenshotBtn, "Screenshot button is unavailable"); +}); diff --git a/browser/components/customizableui/test/browser_searchbar_removal.js b/browser/components/customizableui/test/browser_searchbar_removal.js new file mode 100644 index 0000000000..ac847c8a4c --- /dev/null +++ b/browser/components/customizableui/test/browser_searchbar_removal.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SearchWidgetTracker } = ChromeUtils.importESModule( + "resource:///modules/SearchWidgetTracker.sys.mjs" +); + +const SEARCH_BAR_PREF_NAME = "browser.search.widget.inNavBar"; +const SEARCH_BAR_LAST_USED_PREF_NAME = "browser.search.widget.lastUsed"; + +add_task(async function checkSearchBarPresent() { + Services.prefs.setBoolPref(SEARCH_BAR_PREF_NAME, true); + Services.prefs.setStringPref( + SEARCH_BAR_LAST_USED_PREF_NAME, + new Date("2022").toISOString() + ); + + Assert.ok( + BrowserSearch.searchBar, + "Search bar should be present in the Nav bar" + ); + SearchWidgetTracker._updateSearchBarVisibilityBasedOnUsage(); + Assert.ok( + !BrowserSearch.searchBar, + "Search bar should not be present in the Nav bar" + ); + Assert.equal( + Services.prefs.getBoolPref(SEARCH_BAR_PREF_NAME), + false, + "Should remove the search bar" + ); + Services.prefs.clearUserPref(SEARCH_BAR_LAST_USED_PREF_NAME); + Services.prefs.clearUserPref(SEARCH_BAR_PREF_NAME); +}); diff --git a/browser/components/customizableui/test/browser_sidebar_toggle.js b/browser/components/customizableui/test/browser_sidebar_toggle.js new file mode 100644 index 0000000000..5742f368ee --- /dev/null +++ b/browser/components/customizableui/test/browser_sidebar_toggle.js @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of 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"; + +registerCleanupFunction(async function () { + await resetCustomization(); + + // Ensure sidebar is hidden after each test: + if (!document.getElementById("sidebar-box").hidden) { + SidebarUI.hide(); + } +}); + +var showSidebar = async function (win = window) { + let button = win.document.getElementById("sidebar-button"); + let sidebarFocusedPromise = BrowserTestUtils.waitForEvent( + win.document, + "SidebarFocused" + ); + EventUtils.synthesizeMouseAtCenter(button, {}, win); + await sidebarFocusedPromise; + ok(win.SidebarUI.isOpen, "Sidebar is opened"); + ok(button.hasAttribute("checked"), "Toolbar button is checked"); +}; + +var hideSidebar = async function (win = window) { + let button = win.document.getElementById("sidebar-button"); + EventUtils.synthesizeMouseAtCenter(button, {}, win); + ok(!win.SidebarUI.isOpen, "Sidebar is closed"); + ok(!button.hasAttribute("checked"), "Toolbar button isn't checked"); +}; + +// Check the sidebar widget shows the default items +add_task(async function () { + CustomizableUI.addWidgetToArea("sidebar-button", "nav-bar"); + + await showSidebar(); + is(SidebarUI.currentID, "viewBookmarksSidebar", "Default sidebar selected"); + await SidebarUI.show("viewHistorySidebar"); + + await hideSidebar(); + await showSidebar(); + is(SidebarUI.currentID, "viewHistorySidebar", "Selected sidebar remembered"); + + await hideSidebar(); + let otherWin = await BrowserTestUtils.openNewBrowserWindow(); + await showSidebar(otherWin); + is( + otherWin.SidebarUI.currentID, + "viewHistorySidebar", + "Selected sidebar remembered across windows" + ); + await hideSidebar(otherWin); + + await BrowserTestUtils.closeWindow(otherWin); +}); diff --git a/browser/components/customizableui/test/browser_switch_to_customize_mode.js b/browser/components/customizableui/test/browser_switch_to_customize_mode.js new file mode 100644 index 0000000000..55e80d3517 --- /dev/null +++ b/browser/components/customizableui/test/browser_switch_to_customize_mode.js @@ -0,0 +1,53 @@ +"use strict"; + +add_task(async function () { + await startCustomizing(); + is(gBrowser.tabs.length, 2, "Should have 2 tabs"); + + let paletteKidCount = document.getElementById( + "customization-palette" + ).childElementCount; + let nonCustomizingTab = gBrowser.tabContainer.querySelector( + "tab:not([customizemode=true])" + ); + let finishedCustomizing = BrowserTestUtils.waitForEvent( + gNavToolbox, + "aftercustomization" + ); + await BrowserTestUtils.switchTab(gBrowser, nonCustomizingTab); + await finishedCustomizing; + + let startedCount = 0; + let handler = e => startedCount++; + gNavToolbox.addEventListener("customizationstarting", handler); + await startCustomizing(); + CustomizableUI.removeWidgetFromArea("stop-reload-button"); + await gCustomizeMode.reset().catch(e => { + ok( + false, + "Threw an exception trying to reset after making modifications in customize mode: " + + e + ); + }); + + let newKidCount = document.getElementById( + "customization-palette" + ).childElementCount; + is( + newKidCount, + paletteKidCount, + "Should have just as many items in the palette as before." + ); + await endCustomizing(); + is(startedCount, 1, "Should have only started once"); + gNavToolbox.removeEventListener("customizationstarting", handler); + let customizableToolbars = document.querySelectorAll( + "toolbar[customizable=true]:not([autohide=true])" + ); + for (let toolbar of customizableToolbars) { + ok( + !toolbar.hasAttribute("customizing"), + "Toolbar " + toolbar.id + " is no longer customizing" + ); + } +}); diff --git a/browser/components/customizableui/test/browser_synced_tabs_menu.js b/browser/components/customizableui/test/browser_synced_tabs_menu.js new file mode 100644 index 0000000000..ff60167fea --- /dev/null +++ b/browser/components/customizableui/test/browser_synced_tabs_menu.js @@ -0,0 +1,523 @@ +/* This Source Code Form is subject to the terms of 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"; + +requestLongerTimeout(2); + +const { FxAccounts } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); +let { SyncedTabs } = ChromeUtils.importESModule( + "resource://services-sync/SyncedTabs.sys.mjs" +); +let { UIState } = ChromeUtils.importESModule( + "resource://services-sync/UIState.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + UITour: "resource:///modules/UITour.sys.mjs", +}); + +const DECKINDEX_TABS = 0; +const DECKINDEX_FETCHING = 1; +const DECKINDEX_TABSDISABLED = 2; +const DECKINDEX_NOCLIENTS = 3; + +const SAMPLE_TAB_URL = "https://example.com/"; + +var initialLocation = gBrowser.currentURI.spec; +var newTab = null; + +// A helper to notify there are new tabs. Returns a promise that is resolved +// once the UI has been updated. +function updateTabsPanel() { + let promiseTabsUpdated = promiseObserverNotified( + "synced-tabs-menu:test:tabs-updated" + ); + Services.obs.notifyObservers(null, SyncedTabs.TOPIC_TABS_CHANGED); + return promiseTabsUpdated; +} + +// This is the mock we use for SyncedTabs.jsm - tests may override various +// functions. +let mockedInternal = { + get isConfiguredToSyncTabs() { + return true; + }, + getTabClients() { + return Promise.resolve([]); + }, + syncTabs() { + return Promise.resolve(); + }, + hasSyncedThisSession: false, +}; + +add_setup(async function () { + const getSignedInUser = FxAccounts.config.getSignedInUser; + FxAccounts.config.getSignedInUser = async () => + Promise.resolve({ uid: "uid", email: "foo@bar.com" }); + Services.prefs.setCharPref( + "identity.fxaccounts.remote.root", + "https://example.com/" + ); + + let oldInternal = SyncedTabs._internal; + SyncedTabs._internal = mockedInternal; + + let origNotifyStateUpdated = UIState._internal.notifyStateUpdated; + // Sync start-up will interfere with our tests, don't let UIState send UI updates. + UIState._internal.notifyStateUpdated = () => {}; + + // Force gSync initialization + gSync.init(); + + registerCleanupFunction(() => { + FxAccounts.config.getSignedInUser = getSignedInUser; + Services.prefs.clearUserPref("identity.fxaccounts.remote.root"); + UIState._internal.notifyStateUpdated = origNotifyStateUpdated; + SyncedTabs._internal = oldInternal; + }); +}); + +// The test expects the about:preferences#sync page to open in the current tab +async function openPrefsFromMenuPanel(expectedPanelId, entryPoint) { + info("Check Sync button functionality"); + CustomizableUI.addWidgetToArea( + "sync-button", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + await waitForOverflowButtonShown(); + + // check the button's functionality + await document.getElementById("nav-bar").overflowable.show(); + + if (entryPoint == "uitour") { + UITour.tourBrowsersByWindow.set(window, new Set()); + UITour.tourBrowsersByWindow.get(window).add(gBrowser.selectedBrowser); + } + + let syncButton = document.getElementById("sync-button"); + ok(syncButton, "The Sync button was added to the Panel Menu"); + + let tabsUpdatedPromise = promiseObserverNotified( + "synced-tabs-menu:test:tabs-updated" + ); + syncButton.click(); + let syncPanel = document.getElementById("PanelUI-remotetabs"); + let viewShownPromise = BrowserTestUtils.waitForEvent(syncPanel, "ViewShown"); + await Promise.all([tabsUpdatedPromise, viewShownPromise]); + ok(syncPanel.getAttribute("visible"), "Sync Panel is in view"); + + // Sync is not configured - verify that state is reflected. + let subpanel = document.getElementById(expectedPanelId); + ok(!subpanel.hidden, "sync setup element is visible"); + + // Find and click the "setup" button. + let setupButton = subpanel.querySelector(".PanelUI-remotetabs-button"); + setupButton.click(); + + await new Promise(resolve => { + let handler = async e => { + if ( + e.originalTarget != gBrowser.selectedBrowser.contentDocument || + e.target.location.href == "about:blank" + ) { + info("Skipping spurious 'load' event for " + e.target.location.href); + return; + } + gBrowser.selectedBrowser.removeEventListener("load", handler, true); + resolve(); + }; + gBrowser.selectedBrowser.addEventListener("load", handler, true); + }); + newTab = gBrowser.selectedTab; + + is( + gBrowser.currentURI.spec, + "about:preferences?entrypoint=" + entryPoint + "#sync", + "Firefox Sync preference page opened with `menupanel` entrypoint" + ); + ok(!isOverflowOpen(), "The panel closed"); + + if (isOverflowOpen()) { + await hideOverflow(); + } +} + +function hideOverflow() { + let panelHidePromise = promiseOverflowHidden(window); + PanelUI.overflowPanel.hidePopup(); + return panelHidePromise; +} + +async function asyncCleanup() { + // reset the panel UI to the default state + await resetCustomization(); + ok(CustomizableUI.inDefaultState, "The panel UI is in default state again."); + + // restore the tabs + BrowserTestUtils.addTab(gBrowser, initialLocation); + gBrowser.removeTab(newTab); + UITour.tourBrowsersByWindow.delete(window); +} + +// When Sync is not setup. +add_task(async function () { + gSync.updateAllUI({ status: UIState.STATUS_NOT_CONFIGURED }); + await openPrefsFromMenuPanel("PanelUI-remotetabs-setupsync", "synced-tabs"); +}); +add_task(asyncCleanup); + +// When an account is connected by Sync is not enabled. +add_task(async function () { + gSync.updateAllUI({ status: UIState.STATUS_SIGNED_IN, syncEnabled: false }); + await openPrefsFromMenuPanel( + "PanelUI-remotetabs-syncdisabled", + "synced-tabs" + ); +}); +add_task(asyncCleanup); + +// When Sync is configured in an unverified state. +add_task(async function () { + gSync.updateAllUI({ + status: UIState.STATUS_NOT_VERIFIED, + email: "foo@bar.com", + }); + await openPrefsFromMenuPanel("PanelUI-remotetabs-unverified", "synced-tabs"); +}); +add_task(asyncCleanup); + +// When Sync is configured in a "needs reauthentication" state. +add_task(async function () { + gSync.updateAllUI({ + status: UIState.STATUS_LOGIN_FAILED, + email: "foo@bar.com", + }); + await openPrefsFromMenuPanel("PanelUI-remotetabs-reauthsync", "synced-tabs"); +}); + +// Test the Connect Another Device button +add_task(async function () { + gSync.updateAllUI({ + status: UIState.STATUS_SIGNED_IN, + syncEnabled: true, + email: "foo@bar.com", + lastSync: new Date(), + }); + + let button = document.getElementById( + "PanelUI-remotetabs-connect-device-button" + ); + ok(button, "found the button"); + + await document.getElementById("nav-bar").overflowable.show(); + let expectedUrl = + "https://example.com/connect_another_device?context=" + + "fx_desktop_v3&entrypoint=synced-tabs&service=sync&uid=uid&email=foo%40bar.com"; + let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, expectedUrl); + button.click(); + // the panel should have been closed. + ok(!isOverflowOpen(), "click closed the panel"); + await promiseTabOpened; + + gBrowser.removeTab(gBrowser.selectedTab); +}); + +// Test the "Sync Now" button +add_task(async function () { + gSync.updateAllUI({ + status: UIState.STATUS_SIGNED_IN, + syncEnabled: true, + email: "foo@bar.com", + lastSync: new Date(), + }); + + await document.getElementById("nav-bar").overflowable.show(); + let tabsUpdatedPromise = promiseObserverNotified( + "synced-tabs-menu:test:tabs-updated" + ); + let syncPanel = document.getElementById("PanelUI-remotetabs"); + let viewShownPromise = BrowserTestUtils.waitForEvent(syncPanel, "ViewShown"); + let syncButton = document.getElementById("sync-button"); + syncButton.click(); + await Promise.all([tabsUpdatedPromise, viewShownPromise]); + ok(syncPanel.getAttribute("visible"), "Sync Panel is in view"); + + let subpanel = document.getElementById("PanelUI-remotetabs-main"); + ok(!subpanel.hidden, "main pane is visible"); + let deck = document.getElementById("PanelUI-remotetabs-deck"); + + // The widget is still fetching tabs, as we've neutered everything that + // provides them + is(deck.selectedIndex, DECKINDEX_FETCHING, "first deck entry is visible"); + + // Tell the widget there are tabs available, but with zero clients. + mockedInternal.getTabClients = () => { + return Promise.resolve([]); + }; + mockedInternal.hasSyncedThisSession = true; + await updateTabsPanel(); + // The UI should be showing the "no clients" pane. + is( + deck.selectedIndex, + DECKINDEX_NOCLIENTS, + "no-clients deck entry is visible" + ); + + // Tell the widget there are tabs available - we have 3 clients, one with no + // tabs. + mockedInternal.getTabClients = () => { + return Promise.resolve([ + { + id: "guid_mobile", + type: "client", + name: "My Phone", + lastModified: 1492201200, + tabs: [], + }, + { + id: "guid_desktop", + type: "client", + name: "My Desktop", + lastModified: 1492201200, + tabs: [ + { + title: "http://example.com/10", + lastUsed: 10, // the most recent + }, + { + title: "http://example.com/1", + lastUsed: 1, // the least recent. + }, + { + title: "http://example.com/5", + lastUsed: 5, + }, + ], + }, + { + id: "guid_second_desktop", + name: "My Other Desktop", + lastModified: 1492201200, + tabs: [ + { + title: "http://example.com/6", + lastUsed: 6, + }, + ], + }, + ]); + }; + await updateTabsPanel(); + + // The UI should be showing tabs! + is(deck.selectedIndex, DECKINDEX_TABS, "no-clients deck entry is visible"); + let tabList = document.getElementById("PanelUI-remotetabs-tabslist"); + let node = tabList.firstElementChild; + // First entry should be the client with the most-recent tab. + is(node.nodeName, "vbox"); + let currentClient = node; + node = node.firstElementChild; + is(node.getAttribute("itemtype"), "client", "node is a client entry"); + is(node.textContent, "My Desktop", "correct client"); + // Next entry is the most-recent tab + node = node.nextElementSibling; + is(node.getAttribute("itemtype"), "tab", "node is a tab"); + is(node.getAttribute("label"), "http://example.com/10"); + + // Next entry is the next-most-recent tab + node = node.nextElementSibling; + is(node.getAttribute("itemtype"), "tab", "node is a tab"); + is(node.getAttribute("label"), "http://example.com/5"); + + // Next entry is the least-recent tab from the first client. + node = node.nextElementSibling; + is(node.getAttribute("itemtype"), "tab", "node is a tab"); + is(node.getAttribute("label"), "http://example.com/1"); + node = node.nextElementSibling; + is(node, null, "no more siblings"); + + // Next is a toolbarseparator between the clients. + node = currentClient.nextElementSibling; + is(node.nodeName, "toolbarseparator"); + + // Next is the container for client 2. + node = node.nextElementSibling; + is(node.nodeName, "vbox"); + currentClient = node; + + // Next is the client with 1 tab. + node = node.firstElementChild; + is(node.getAttribute("itemtype"), "client", "node is a client entry"); + is(node.textContent, "My Other Desktop", "correct client"); + // Its single tab + node = node.nextElementSibling; + is(node.getAttribute("itemtype"), "tab", "node is a tab"); + is(node.getAttribute("label"), "http://example.com/6"); + node = node.nextElementSibling; + is(node, null, "no more siblings"); + + // Next is a toolbarseparator between the clients. + node = currentClient.nextElementSibling; + is(node.nodeName, "toolbarseparator"); + + // Next is the container for client 3. + node = node.nextElementSibling; + is(node.nodeName, "vbox"); + currentClient = node; + + // Next is the client with no tab. + node = node.firstElementChild; + is(node.getAttribute("itemtype"), "client", "node is a client entry"); + is(node.textContent, "My Phone", "correct client"); + // There is a single node saying there's no tabs for the client. + node = node.nextElementSibling; + is(node.nodeName, "label", "node is a label"); + is(node.getAttribute("itemtype"), "", "node is neither a tab nor a client"); + + node = node.nextElementSibling; + is(node, null, "no more siblings"); + is(currentClient.nextElementSibling, null, "no more clients"); + + // Check accessibility. There should be containers for each client, with an + // aria attribute that identifies the client name. + let clientContainers = [ + ...tabList.querySelectorAll("[aria-labelledby]").values(), + ]; + let labelIds = clientContainers.map(container => + container.getAttribute("aria-labelledby") + ); + let labels = labelIds.map(id => document.getElementById(id).textContent); + Assert.deepEqual(labels.sort(), [ + "My Desktop", + "My Other Desktop", + "My Phone", + ]); + + let didSync = false; + let oldDoSync = gSync.doSync; + gSync.doSync = function () { + didSync = true; + gSync.doSync = oldDoSync; + }; + + let syncNowButton = document.getElementById("PanelUI-remotetabs-syncnow"); + is(syncNowButton.disabled, false); + syncNowButton.click(); + ok(didSync, "clicking the button called the correct function"); + + await hideOverflow(); +}); + +// Test the pagination capabilities (Show More/All tabs) +add_task(async function () { + mockedInternal.getTabClients = () => { + return Promise.resolve([ + { + id: "guid_desktop", + type: "client", + name: "My Desktop", + lastModified: 1492201200, + tabs: (function () { + let allTabsDesktop = []; + // We choose 77 tabs, because TABS_PER_PAGE is 25, which means + // on the second to last page we should have 22 items shown + // (because we have to show at least NEXT_PAGE_MIN_TABS=5 tabs on the last page) + for (let i = 1; i <= 77; i++) { + allTabsDesktop.push({ title: "Tab #" + i, url: SAMPLE_TAB_URL }); + } + return allTabsDesktop; + })(), + }, + ]); + }; + + gSync.updateAllUI({ + status: UIState.STATUS_SIGNED_IN, + syncEnabled: true, + lastSync: new Date(), + email: "foo@bar.com", + }); + + await document.getElementById("nav-bar").overflowable.show(); + let tabsUpdatedPromise = promiseObserverNotified( + "synced-tabs-menu:test:tabs-updated" + ); + let syncPanel = document.getElementById("PanelUI-remotetabs"); + let viewShownPromise = BrowserTestUtils.waitForEvent(syncPanel, "ViewShown"); + let syncButton = document.getElementById("sync-button"); + syncButton.click(); + await Promise.all([tabsUpdatedPromise, viewShownPromise]); + + // Check pre-conditions + ok(syncPanel.getAttribute("visible"), "Sync Panel is in view"); + let subpanel = document.getElementById("PanelUI-remotetabs-main"); + ok(!subpanel.hidden, "main pane is visible"); + let deck = document.getElementById("PanelUI-remotetabs-deck"); + is(deck.selectedIndex, DECKINDEX_TABS, "we should be showing tabs"); + + function checkTabsPage(tabsShownCount, showMoreLabel) { + let tabList = document.getElementById("PanelUI-remotetabs-tabslist"); + let node = tabList.firstElementChild.firstElementChild; + is(node.getAttribute("itemtype"), "client", "node is a client entry"); + is(node.textContent, "My Desktop", "correct client"); + for (let i = 0; i < tabsShownCount; i++) { + node = node.nextElementSibling; + is(node.getAttribute("itemtype"), "tab", "node is a tab"); + is( + node.getAttribute("label"), + "Tab #" + (i + 1), + "the tab is the correct one" + ); + is( + node.getAttribute("targetURI"), + SAMPLE_TAB_URL, + "url is the correct one" + ); + } + let showMoreButton; + if (showMoreLabel) { + node = showMoreButton = node.nextElementSibling; + is( + node.getAttribute("itemtype"), + "showmorebutton", + "node is a show more button" + ); + is(node.getAttribute("label"), showMoreLabel); + } + node = node.nextElementSibling; + is(node, null, "no more entries"); + + return showMoreButton; + } + + async function checkCanOpenURL() { + let tabList = document.getElementById("PanelUI-remotetabs-tabslist"); + let node = tabList.firstElementChild.firstElementChild.nextElementSibling; + let promiseTabOpened = BrowserTestUtils.waitForLocationChange( + gBrowser, + SAMPLE_TAB_URL + ); + node.click(); + await promiseTabOpened; + } + + let showMoreButton; + function clickShowMoreButton() { + let promise = promiseObserverNotified("synced-tabs-menu:test:tabs-updated"); + showMoreButton.click(); + return promise; + } + + showMoreButton = checkTabsPage(25, "Show More Tabs"); + await clickShowMoreButton(); + + checkTabsPage(77, null); + /* calling this will close the overflow menu */ + await checkCanOpenURL(); +}); diff --git a/browser/components/customizableui/test/browser_tabbar_big_widgets.js b/browser/components/customizableui/test/browser_tabbar_big_widgets.js new file mode 100644 index 0000000000..2eae968656 --- /dev/null +++ b/browser/components/customizableui/test/browser_tabbar_big_widgets.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +const kButtonId = "test-tabbar-size-with-large-buttons"; + +function test() { + registerCleanupFunction(cleanup); + let titlebar = document.getElementById("titlebar"); + let originalHeight = titlebar.getBoundingClientRect().height; + let button = document.createXULElement("toolbarbutton"); + button.id = kButtonId; + button.setAttribute("style", "min-height: 100px"); + gNavToolbox.palette.appendChild(button); + CustomizableUI.addWidgetToArea(kButtonId, CustomizableUI.AREA_TABSTRIP); + let currentHeight = titlebar.getBoundingClientRect().height; + Assert.greater(currentHeight, originalHeight, "Titlebar should have grown"); + CustomizableUI.removeWidgetFromArea(kButtonId); + currentHeight = titlebar.getBoundingClientRect().height; + is( + currentHeight, + originalHeight, + "Titlebar should have gone back to its original size." + ); +} + +function cleanup() { + let btn = document.getElementById(kButtonId); + if (btn) { + btn.remove(); + } +} diff --git a/browser/components/customizableui/test/browser_toolbar_collapsed_states.js b/browser/components/customizableui/test/browser_toolbar_collapsed_states.js new file mode 100644 index 0000000000..23da60a7d5 --- /dev/null +++ b/browser/components/customizableui/test/browser_toolbar_collapsed_states.js @@ -0,0 +1,112 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Checks that CustomizableUI reports the expected collapsed toolbar IDs. + * + * Note: on macOS, expectations for CustomizableUI.AREA_MENUBAR are + * automatically skipped since that area isn't available on that platform. + * + * @param {string[]} The IDs of the expected collapsed toolbars. + */ +function assertCollapsedToolbarIds(expected) { + if (AppConstants.platform == "macosx") { + let menubarIndex = expected.indexOf(CustomizableUI.AREA_MENUBAR); + if (menubarIndex != -1) { + expected.splice(menubarIndex, 1); + } + } + + let collapsedIds = CustomizableUI.getCollapsedToolbarIds(window); + Assert.equal(collapsedIds.size, expected.length); + for (let expectedId of expected) { + Assert.ok( + collapsedIds.has(expectedId), + `${expectedId} should be collapsed` + ); + } +} + +registerCleanupFunction(async () => { + await CustomizableUI.reset(); +}); + +/** + * Tests that CustomizableUI.getCollapsedToolbarIds will return the IDs of + * toolbars that are collapsed, or menubars that are autohidden. + */ +add_task(async function test_toolbar_collapsed_states() { + // By default, we expect the menubar and the bookmarks toolbar to be + // collapsed. + assertCollapsedToolbarIds([ + CustomizableUI.AREA_BOOKMARKS, + CustomizableUI.AREA_MENUBAR, + ]); + + let bookmarksToolbar = document.getElementById(CustomizableUI.AREA_BOOKMARKS); + // Make sure we're configured to show the bookmarks toolbar on about:newtab. + setToolbarVisibility(bookmarksToolbar, "newtab"); + + let newTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: "about:newtab", + waitForLoad: false, + }); + // Now that we've opened about:newtab, the bookmarks toolbar should now + // be visible. + assertCollapsedToolbarIds([CustomizableUI.AREA_MENUBAR]); + await BrowserTestUtils.removeTab(newTab); + + // And with about:newtab closed again, the bookmarks toolbar should be + // reported as collapsed. + assertCollapsedToolbarIds([ + CustomizableUI.AREA_BOOKMARKS, + CustomizableUI.AREA_MENUBAR, + ]); + + // Make sure we're configured to show the bookmarks toolbar on about:newtab. + setToolbarVisibility(bookmarksToolbar, "always"); + assertCollapsedToolbarIds([CustomizableUI.AREA_MENUBAR]); + + setToolbarVisibility(bookmarksToolbar, "never"); + assertCollapsedToolbarIds([ + CustomizableUI.AREA_BOOKMARKS, + CustomizableUI.AREA_MENUBAR, + ]); + + if (AppConstants.platform != "macosx") { + // We'll still consider the menubar collapsed by default, even if it's being temporarily + // shown via the alt key. + let menubarActive = BrowserTestUtils.waitForEvent( + window, + "DOMMenuBarActive" + ); + EventUtils.synthesizeKey("VK_ALT", {}); + await menubarActive; + assertCollapsedToolbarIds([ + CustomizableUI.AREA_BOOKMARKS, + CustomizableUI.AREA_MENUBAR, + ]); + let menubarInactive = BrowserTestUtils.waitForEvent( + window, + "DOMMenuBarInactive" + ); + EventUtils.synthesizeKey("VK_ESCAPE", {}); + await menubarInactive; + assertCollapsedToolbarIds([ + CustomizableUI.AREA_BOOKMARKS, + CustomizableUI.AREA_MENUBAR, + ]); + + let menubar = document.getElementById(CustomizableUI.AREA_MENUBAR); + setToolbarVisibility(menubar, true); + assertCollapsedToolbarIds([CustomizableUI.AREA_BOOKMARKS]); + setToolbarVisibility(menubar, false); + assertCollapsedToolbarIds([ + CustomizableUI.AREA_BOOKMARKS, + CustomizableUI.AREA_MENUBAR, + ]); + } +}); diff --git a/browser/components/customizableui/test/browser_touchbar_customization.js b/browser/components/customizableui/test/browser_touchbar_customization.js new file mode 100644 index 0000000000..106c202ad9 --- /dev/null +++ b/browser/components/customizableui/test/browser_touchbar_customization.js @@ -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/. */ + +"use strict"; + +// Checks if the Customize Touch Bar button appears when a Touch Bar is +// initialized. +add_task(async function customizeTouchBarButtonAppears() { + let updater = Cc["@mozilla.org/widget/touchbarupdater;1"].getService( + Ci.nsITouchBarUpdater + ); + // This value will be reset to its default the next time a window is opened. + updater.setTouchBarInitialized(true); + await startCustomizing(); + let touchbarButton = document.querySelector("#customization-touchbar-button"); + ok(!touchbarButton.hidden, "Customize Touch Bar button is not hidden."); + let touchbarSpacer = document.querySelector("#customization-touchbar-spacer"); + ok(!touchbarSpacer.hidden, "Customize Touch Bar spacer is not hidden."); + await endCustomizing(); +}); diff --git a/browser/components/customizableui/test/browser_unified_extensions_reset.js b/browser/components/customizableui/test/browser_unified_extensions_reset.js new file mode 100644 index 0000000000..fdee8cf76a --- /dev/null +++ b/browser/components/customizableui/test/browser_unified_extensions_reset.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if Unified Extensions UI is enabled that resetting the toolbars + * puts all browser action buttons into the AREA_ADDONS area. + */ +add_task(async function test_reset_with_unified_extensions_ui() { + const kWebExtensionWidgetIDs = [ + "ext0-browser-action", + "ext1-browser-action", + "ext2-browser-action", + "ext3-browser-action", + "ext4-browser-action", + "ext5-browser-action", + "ext6-browser-action", + "ext7-browser-action", + "ext8-browser-action", + "ext9-browser-action", + "ext10-browser-action", + ]; + + for (let widgetID of kWebExtensionWidgetIDs) { + CustomizableUI.createWidget({ + id: widgetID, + label: "Test extension widget", + defaultArea: CustomizableUI.AREA_NAVBAR, + webExtension: true, + }); + } + + // Now let's put these browser actions in a bunch of different places. + // Regardless of where they go, we're going to expect them in AREA_ADDONS + // after we reset. + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[0], + CustomizableUI.AREA_TABSTRIP + ); + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[1], + CustomizableUI.AREA_TABSTRIP + ); + + // macOS doesn't have AREA_MENUBAR registered, so we'll leave these widgets + // behind in the AREA_NAVBAR there, and put them into the menubar on the + // other platforms. + if (AppConstants.platform != "macosx") { + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[2], + CustomizableUI.AREA_MENUBAR + ); + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[3], + CustomizableUI.AREA_MENUBAR + ); + } + + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[4], + CustomizableUI.AREA_BOOKMARKS + ); + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[5], + CustomizableUI.AREA_BOOKMARKS + ); + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[6], + CustomizableUI.AREA_ADDONS + ); + CustomizableUI.addWidgetToArea( + kWebExtensionWidgetIDs[7], + CustomizableUI.AREA_ADDONS + ); + + CustomizableUI.reset(); + + // Let's force the Unified Extensions panel to register itself now if it + // wasn't already done. Using the getter should be sufficient. + Assert.ok(gUnifiedExtensions.panel, "Should have found the panel."); + + for (let widgetID of kWebExtensionWidgetIDs) { + let { area } = CustomizableUI.getPlacementOfWidget(widgetID); + Assert.equal(area, CustomizableUI.AREA_ADDONS); + // Let's double-check that they're actually in there in the DOM too. + let widget = CustomizableUI.getWidget(widgetID).forWindow(window); + Assert.equal(widget.node.parentElement.id, CustomizableUI.AREA_ADDONS); + CustomizableUI.destroyWidget(widgetID); + } +}); diff --git a/browser/components/customizableui/test/browser_widget_animation.js b/browser/components/customizableui/test/browser_widget_animation.js new file mode 100644 index 0000000000..514e3f763b --- /dev/null +++ b/browser/components/customizableui/test/browser_widget_animation.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +gReduceMotionOverride = false; + +function promiseWidgetAnimationOut(aNode) { + let animationNode = aNode; + if ( + animationNode.tagName != "toolbaritem" && + animationNode.tagName != "toolbarbutton" + ) { + animationNode = animationNode.closest("toolbaritem"); + } + if (animationNode.parentNode.id.startsWith("wrapper-")) { + animationNode = animationNode.parentNode; + } + return new Promise(resolve => { + animationNode.addEventListener( + "animationend", + function cleanupWidgetAnimationOut(e) { + if ( + e.animationName == "widget-animate-out" && + e.target.id == animationNode.id + ) { + animationNode.removeEventListener( + "animationend", + cleanupWidgetAnimationOut + ); + ok(true, "The widget`s animationend should have happened"); + resolve(); + } + } + ); + }); +} + +function promiseOverflowAnimationEnd() { + return new Promise(resolve => { + let overflowButton = document.getElementById("nav-bar-overflow-button"); + overflowButton.addEventListener( + "animationend", + function cleanupOverflowAnimationOut(event) { + if (event.animationName == "overflow-animation") { + overflowButton.removeEventListener( + "animationend", + cleanupOverflowAnimationOut + ); + ok( + true, + "The overflow button`s animationend event should have happened" + ); + resolve(); + } + } + ); + }); +} + +// Right-click on the stop/reload button, use the context menu to move it to the overflow menu. +// The button should animate out, and the overflow menu should animate upon adding. +add_task(async function () { + let stopReloadButton = document.getElementById("stop-reload-button"); + let contextMenu = document.getElementById("toolbar-context-menu"); + let shownPromise = popupShown(contextMenu); + EventUtils.synthesizeMouseAtCenter(stopReloadButton, { + type: "contextmenu", + button: 2, + }); + await shownPromise; + + contextMenu.activateItem( + contextMenu.querySelector(".customize-context-moveToPanel") + ); + + await Promise.all([ + promiseWidgetAnimationOut(stopReloadButton), + promiseOverflowAnimationEnd(), + ]); + ok(true, "The widget and overflow animations should have both happened."); +}); + +registerCleanupFunction(CustomizableUI.reset); diff --git a/browser/components/customizableui/test/browser_widget_recreate_events.js b/browser/components/customizableui/test/browser_widget_recreate_events.js new file mode 100644 index 0000000000..3eca9231a8 --- /dev/null +++ b/browser/components/customizableui/test/browser_widget_recreate_events.js @@ -0,0 +1,99 @@ +"use strict"; + +const widgetData = { + id: "test-widget", + type: "view", + viewId: "PanelUI-testbutton", + label: "test widget label", + onViewShowing() {}, + onViewHiding() {}, +}; + +async function simulateWidgetOpen() { + let testWidgetButton = document.getElementById("test-widget"); + let testWidgetShowing = BrowserTestUtils.waitForEvent( + document, + "popupshowing", + true + ); + testWidgetButton.click(); + await testWidgetShowing; +} + +async function simulateWidgetClose() { + let panel = document.getElementById("customizationui-widget-panel"); + let panelHidden = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + + panel.hidePopup(); + await panelHidden; +} + +function createPanelView() { + let panelView = document.createXULElement("panelview"); + panelView.id = "PanelUI-testbutton"; + let vbox = document.createXULElement("vbox"); + panelView.appendChild(vbox); + return panelView; +} + +/** + * Check that panel view/hide events are added back, + * if widget is destroyed and created again in one session. + */ +add_task(async function () { + let viewCache = document.getElementById("appMenu-viewCache"); + let panelView = createPanelView(); + viewCache.appendChild(panelView); + + CustomizableUI.createWidget(widgetData); + CustomizableUI.addWidgetToArea("test-widget", "nav-bar"); + + // Simulate clicking and wait for the open + // so we ensure the lazy event creation is done. + await simulateWidgetOpen(); + + let listeners = Services.els.getListenerInfoFor(panelView); + ok( + listeners.some(info => info.type == "ViewShowing"), + "ViewShowing event added" + ); + ok( + listeners.some(info => info.type == "ViewHiding"), + "ViewHiding event added" + ); + + await simulateWidgetClose(); + CustomizableUI.destroyWidget("test-widget"); + + listeners = Services.els.getListenerInfoFor(panelView); + // Ensure the events got removed after destorying the widget. + ok( + !listeners.some(info => info.type == "ViewShowing"), + "ViewShowing event removed" + ); + ok( + !listeners.some(info => info.type == "ViewHiding"), + "ViewHiding event removed" + ); + + CustomizableUI.createWidget(widgetData); + // Simulate clicking and wait for the open + // so we ensure the lazy event creation is done. + // We need to do this again because we destroyed the widget. + await simulateWidgetOpen(); + + listeners = Services.els.getListenerInfoFor(panelView); + ok( + listeners.some(info => info.type == "ViewShowing"), + "ViewShowing event added again" + ); + ok( + listeners.some(info => info.type == "ViewHiding"), + "ViewHiding event added again" + ); + + await simulateWidgetClose(); + CustomizableUI.destroyWidget("test-widget"); + panelView.remove(); + CustomizableUI.reset(); +}); diff --git a/browser/components/customizableui/test/dummy_history_item.html b/browser/components/customizableui/test/dummy_history_item.html new file mode 100644 index 0000000000..23a6992923 --- /dev/null +++ b/browser/components/customizableui/test/dummy_history_item.html @@ -0,0 +1,2 @@ +Happy History Hero +

I am a page for the history books.

diff --git a/browser/components/customizableui/test/head.js b/browser/components/customizableui/test/head.js new file mode 100644 index 0000000000..f8c0d02a12 --- /dev/null +++ b/browser/components/customizableui/test/head.js @@ -0,0 +1,530 @@ +/* This Source Code Form is subject to the terms of 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"; + +ChromeUtils.defineESModuleGetters(this, { + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", + CustomizableUITestUtils: + "resource://testing-common/CustomizableUITestUtils.sys.mjs", +}); + +/** + * Instance of CustomizableUITestUtils for the current browser window. + */ +var gCUITestUtils = new CustomizableUITestUtils(window); + +Services.prefs.setBoolPref("browser.uiCustomization.skipSourceNodeCheck", true); +registerCleanupFunction(() => + Services.prefs.clearUserPref("browser.uiCustomization.skipSourceNodeCheck") +); + +var { synthesizeDrop, synthesizeMouseAtCenter } = EventUtils; + +const kForceOverflowWidthPx = 450; + +function createDummyXULButton(id, label, win = window) { + let btn = win.document.createXULElement("toolbarbutton"); + btn.id = id; + btn.setAttribute("label", label || id); + btn.className = "toolbarbutton-1 chromeclass-toolbar-additional"; + win.gNavToolbox.palette.appendChild(btn); + return btn; +} + +var gAddedToolbars = new Set(); + +function createToolbarWithPlacements(id, placements = [], properties = {}) { + gAddedToolbars.add(id); + let tb = document.createXULElement("toolbar"); + tb.id = id; + tb.setAttribute("customizable", "true"); + + properties.type = CustomizableUI.TYPE_TOOLBAR; + properties.defaultPlacements = placements; + CustomizableUI.registerArea(id, properties); + gNavToolbox.appendChild(tb); + CustomizableUI.registerToolbarNode(tb); + return tb; +} + +function createOverflowableToolbarWithPlacements(id, placements) { + gAddedToolbars.add(id); + + let tb = document.createXULElement("toolbar"); + tb.id = id; + tb.setAttribute("customizationtarget", id + "-target"); + + let customizationtarget = document.createXULElement("hbox"); + customizationtarget.id = id + "-target"; + customizationtarget.setAttribute("flex", "1"); + tb.appendChild(customizationtarget); + + let overflowPanel = document.createXULElement("panel"); + overflowPanel.id = id + "-overflow"; + document.getElementById("mainPopupSet").appendChild(overflowPanel); + + let overflowList = document.createXULElement("vbox"); + overflowList.id = id + "-overflow-list"; + overflowPanel.appendChild(overflowList); + + let chevron = document.createXULElement("toolbarbutton"); + chevron.id = id + "-chevron"; + tb.appendChild(chevron); + + CustomizableUI.registerArea(id, { + type: CustomizableUI.TYPE_TOOLBAR, + defaultPlacements: placements, + overflowable: true, + }); + + tb.setAttribute("customizable", "true"); + tb.setAttribute("overflowable", "true"); + tb.setAttribute("default-overflowpanel", overflowPanel.id); + tb.setAttribute("default-overflowtarget", overflowList.id); + tb.setAttribute("default-overflowbutton", chevron.id); + tb.setAttribute("addon-webext-overflowbutton", "unified-extensions-button"); + tb.setAttribute("addon-webext-overflowtarget", "overflowed-extensions-list"); + + gNavToolbox.appendChild(tb); + CustomizableUI.registerToolbarNode(tb); + return tb; +} + +function removeCustomToolbars() { + CustomizableUI.reset(); + for (let toolbarId of gAddedToolbars) { + CustomizableUI.unregisterArea(toolbarId, true); + let tb = document.getElementById(toolbarId); + if (tb.hasAttribute("overflowpanel")) { + let panel = document.getElementById(tb.getAttribute("overflowpanel")); + if (panel) { + panel.remove(); + } + } + tb.remove(); + } + gAddedToolbars.clear(); +} + +function resetCustomization() { + return CustomizableUI.reset(); +} + +function isInDevEdition() { + return AppConstants.MOZ_DEV_EDITION; +} + +function removeNonReleaseButtons(areaPanelPlacements) { + if (isInDevEdition() && areaPanelPlacements.includes("developer-button")) { + areaPanelPlacements.splice( + areaPanelPlacements.indexOf("developer-button"), + 1 + ); + } +} + +function removeNonOriginalButtons() { + CustomizableUI.removeWidgetFromArea("sync-button"); +} + +function assertAreaPlacements(areaId, expectedPlacements) { + let actualPlacements = getAreaWidgetIds(areaId); + placementArraysEqual(areaId, actualPlacements, expectedPlacements); +} + +function placementArraysEqual(areaId, actualPlacements, expectedPlacements) { + info("Actual placements: " + actualPlacements.join(", ")); + info("Expected placements: " + expectedPlacements.join(", ")); + is( + actualPlacements.length, + expectedPlacements.length, + "Area " + areaId + " should have " + expectedPlacements.length + " items." + ); + let minItems = Math.min(expectedPlacements.length, actualPlacements.length); + for (let i = 0; i < minItems; i++) { + if (typeof expectedPlacements[i] == "string") { + is( + actualPlacements[i], + expectedPlacements[i], + "Item " + i + " in " + areaId + " should match expectations." + ); + } else if (expectedPlacements[i] instanceof RegExp) { + ok( + expectedPlacements[i].test(actualPlacements[i]), + "Item " + + i + + " (" + + actualPlacements[i] + + ") in " + + areaId + + " should match " + + expectedPlacements[i] + ); + } else { + ok( + false, + "Unknown type of expected placement passed to " + + " assertAreaPlacements. Is your test broken?" + ); + } + } +} + +function todoAssertAreaPlacements(areaId, expectedPlacements) { + let actualPlacements = getAreaWidgetIds(areaId); + let isPassing = actualPlacements.length == expectedPlacements.length; + let minItems = Math.min(expectedPlacements.length, actualPlacements.length); + for (let i = 0; i < minItems; i++) { + if (typeof expectedPlacements[i] == "string") { + isPassing = isPassing && actualPlacements[i] == expectedPlacements[i]; + } else if (expectedPlacements[i] instanceof RegExp) { + isPassing = isPassing && expectedPlacements[i].test(actualPlacements[i]); + } else { + ok( + false, + "Unknown type of expected placement passed to " + + " assertAreaPlacements. Is your test broken?" + ); + } + } + todo( + isPassing, + "The area placements for " + + areaId + + " should equal the expected placements." + ); +} + +function getAreaWidgetIds(areaId) { + return CustomizableUI.getWidgetIdsInArea(areaId); +} + +function simulateItemDrag(aToDrag, aTarget, aEvent = {}, aOffset = 2) { + let ev = aEvent; + if (ev == "end" || ev == "start") { + let win = aTarget.ownerGlobal; + const dwu = win.windowUtils; + let bounds = dwu.getBoundsWithoutFlushing(aTarget); + if (ev == "end") { + ev = { + clientX: bounds.right - aOffset, + clientY: bounds.bottom - aOffset, + }; + } else { + ev = { clientX: bounds.left + aOffset, clientY: bounds.top + aOffset }; + } + } + ev._domDispatchOnly = true; + synthesizeDrop( + aToDrag.parentNode, + aTarget, + null, + null, + aToDrag.ownerGlobal, + aTarget.ownerGlobal, + ev + ); + // Ensure dnd suppression is cleared. + synthesizeMouseAtCenter(aTarget, { type: "mouseup" }, aTarget.ownerGlobal); +} + +function endCustomizing(aWindow = window) { + if (aWindow.document.documentElement.getAttribute("customizing") != "true") { + return true; + } + let afterCustomizationPromise = BrowserTestUtils.waitForEvent( + aWindow.gNavToolbox, + "aftercustomization" + ); + aWindow.gCustomizeMode.exit(); + return afterCustomizationPromise; +} + +function startCustomizing(aWindow = window) { + if (aWindow.document.documentElement.getAttribute("customizing") == "true") { + return null; + } + let customizationReadyPromise = BrowserTestUtils.waitForEvent( + aWindow.gNavToolbox, + "customizationready" + ); + aWindow.gCustomizeMode.enter(); + return customizationReadyPromise; +} + +function promiseObserverNotified(aTopic) { + return new Promise(resolve => { + Services.obs.addObserver(function onNotification(subject, topic, data) { + Services.obs.removeObserver(onNotification, topic); + resolve({ subject, data }); + }, aTopic); + }); +} + +function openAndLoadWindow(aOptions, aWaitForDelayedStartup = false) { + return new Promise(resolve => { + let win = OpenBrowserWindow(aOptions); + if (aWaitForDelayedStartup) { + Services.obs.addObserver(function onDS(aSubject, aTopic, aData) { + if (aSubject != win) { + return; + } + Services.obs.removeObserver(onDS, "browser-delayed-startup-finished"); + resolve(win); + }, "browser-delayed-startup-finished"); + } else { + win.addEventListener( + "load", + function () { + resolve(win); + }, + { once: true } + ); + } + }); +} + +function promiseWindowClosed(win) { + return new Promise(resolve => { + win.addEventListener( + "unload", + function () { + resolve(); + }, + { once: true } + ); + win.close(); + }); +} + +function promiseOverflowShown(win) { + let panelEl = win.document.getElementById("widget-overflow"); + return promisePanelElementShown(win, panelEl); +} + +function promisePanelElementShown(win, aPanel) { + return new Promise((resolve, reject) => { + let timeoutId = win.setTimeout(() => { + reject("Panel did not show within 20 seconds."); + }, 20000); + function onPanelOpen(e) { + aPanel.removeEventListener("popupshown", onPanelOpen); + win.clearTimeout(timeoutId); + resolve(); + } + aPanel.addEventListener("popupshown", onPanelOpen); + }); +} + +function promiseOverflowHidden(win) { + let panelEl = win.PanelUI.overflowPanel; + return promisePanelElementHidden(win, panelEl); +} + +function promisePanelElementHidden(win, aPanel) { + return new Promise((resolve, reject) => { + let timeoutId = win.setTimeout(() => { + reject("Panel did not hide within 20 seconds."); + }, 20000); + function onPanelClose(e) { + aPanel.removeEventListener("popuphidden", onPanelClose); + win.clearTimeout(timeoutId); + executeSoon(resolve); + } + aPanel.addEventListener("popuphidden", onPanelClose); + }); +} + +function isPanelUIOpen() { + return PanelUI.panel.state == "open" || PanelUI.panel.state == "showing"; +} + +function isOverflowOpen() { + let panel = document.getElementById("widget-overflow"); + return panel.state == "open" || panel.state == "showing"; +} + +function subviewShown(aSubview) { + return new Promise((resolve, reject) => { + let win = aSubview.ownerGlobal; + let timeoutId = win.setTimeout(() => { + reject("Subview (" + aSubview.id + ") did not show within 20 seconds."); + }, 20000); + function onViewShown(e) { + aSubview.removeEventListener("ViewShown", onViewShown); + win.clearTimeout(timeoutId); + resolve(); + } + aSubview.addEventListener("ViewShown", onViewShown); + }); +} + +function subviewHidden(aSubview) { + return new Promise((resolve, reject) => { + let win = aSubview.ownerGlobal; + let timeoutId = win.setTimeout(() => { + reject("Subview (" + aSubview.id + ") did not hide within 20 seconds."); + }, 20000); + function onViewHiding(e) { + aSubview.removeEventListener("ViewHiding", onViewHiding); + win.clearTimeout(timeoutId); + resolve(); + } + aSubview.addEventListener("ViewHiding", onViewHiding); + }); +} + +function waitFor(aTimeout = 100) { + return new Promise(resolve => { + setTimeout(() => resolve(), aTimeout); + }); +} + +/** + * Starts a load in an existing tab and waits for it to finish (via some event). + * + * @param aTab The tab to load into. + * @param aUrl The url to load. + * @param aEventType The load event type to wait for. Defaults to "load". + * @return {Promise} resolved when the event is handled. + */ +function promiseTabLoadEvent(aTab, aURL) { + let browser = aTab.linkedBrowser; + + BrowserTestUtils.startLoadingURIString(browser, aURL); + return BrowserTestUtils.browserLoaded(browser); +} + +/** + * Wait for an attribute on a node to change + * + * @param aNode Node on which the mutation is expected + * @param aAttribute The attribute we're interested in + * @param aFilterFn A function to check if the new value is what we want. + * @return {Promise} resolved when the requisite mutation shows up. + */ +function promiseAttributeMutation(aNode, aAttribute, aFilterFn) { + return new Promise((resolve, reject) => { + info("waiting for mutation of attribute '" + aAttribute + "'."); + let obs = new MutationObserver(mutations => { + for (let mut of mutations) { + let attr = mut.attributeName; + let newValue = mut.target.getAttribute(attr); + if (aFilterFn(newValue)) { + ok( + true, + "mutation occurred: attribute '" + + attr + + "' changed to '" + + newValue + + "' from '" + + mut.oldValue + + "'." + ); + obs.disconnect(); + resolve(); + } else { + info( + "Ignoring mutation that produced value " + + newValue + + " because of filter." + ); + } + } + }); + obs.observe(aNode, { attributeFilter: [aAttribute] }); + }); +} + +function popupShown(aPopup) { + return BrowserTestUtils.waitForPopupEvent(aPopup, "shown"); +} + +function popupHidden(aPopup) { + return BrowserTestUtils.waitForPopupEvent(aPopup, "hidden"); +} + +// This is a simpler version of the context menu check that +// exists in contextmenu_common.js. +function checkContextMenu(aContextMenu, aExpectedEntries, aWindow = window) { + let children = [...aContextMenu.children]; + // Ignore hidden nodes: + children = children.filter(n => !n.hidden); + + for (let i = 0; i < children.length; i++) { + let menuitem = children[i]; + try { + if (aExpectedEntries[i][0] == "---") { + is(menuitem.localName, "menuseparator", "menuseparator expected"); + continue; + } + + let selector = aExpectedEntries[i][0]; + ok( + menuitem.matches(selector), + "menuitem should match " + selector + " selector" + ); + let commandValue = menuitem.getAttribute("command"); + let relatedCommand = commandValue + ? aWindow.document.getElementById(commandValue) + : null; + let menuItemDisabled = relatedCommand + ? relatedCommand.getAttribute("disabled") == "true" + : menuitem.getAttribute("disabled") == "true"; + is( + menuItemDisabled, + !aExpectedEntries[i][1], + "disabled state for " + selector + ); + } catch (e) { + ok(false, "Exception when checking context menu: " + e); + } + } +} + +function waitForOverflowButtonShown(win = window) { + info("Waiting for overflow button to show"); + let ov = win.document.getElementById("nav-bar-overflow-button"); + return waitForElementShown(ov.icon); +} +function waitForElementShown(element) { + return BrowserTestUtils.waitForCondition(() => { + info("Checking if element has non-0 size"); + // We intentionally flush layout to ensure the element is actually shown. + let rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); +} + +/** + * Opens the history panel through the history toolbarbutton in the + * navbar and returns a promise that resolves as soon as the panel is open + * is showing. + */ +async function openHistoryPanel(doc = document) { + await waitForOverflowButtonShown(); + await doc.getElementById("nav-bar").overflowable.show(); + info("Menu panel was opened"); + + let historyButton = doc.getElementById("history-panelmenu"); + Assert.ok(historyButton, "History button appears in Panel Menu"); + + historyButton.click(); + + let historyPanel = doc.getElementById("PanelUI-history"); + return BrowserTestUtils.waitForEvent(historyPanel, "ViewShown"); +} + +/** + * Closes the history panel and returns a promise that resolves as sooon + * as the panel is closed. + */ +async function hideHistoryPanel(doc = document) { + let historyView = doc.getElementById("PanelUI-history"); + let historyPanel = historyView.closest("panel"); + let promise = BrowserTestUtils.waitForEvent(historyPanel, "popuphidden"); + historyPanel.hidePopup(); + return promise; +} diff --git a/browser/components/customizableui/test/support/test_967000_charEncoding_page.html b/browser/components/customizableui/test/support/test_967000_charEncoding_page.html new file mode 100644 index 0000000000..7932b16f12 --- /dev/null +++ b/browser/components/customizableui/test/support/test_967000_charEncoding_page.html @@ -0,0 +1,11 @@ + + + + + Test page + + + + This is a test page + + diff --git a/browser/components/customizableui/test/unit/test_unified_extensions_migration.js b/browser/components/customizableui/test/unit/test_unified_extensions_migration.js new file mode 100644 index 0000000000..022b783d70 --- /dev/null +++ b/browser/components/customizableui/test/unit/test_unified_extensions_migration.js @@ -0,0 +1,373 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// We're in an xpcshell test but have an eslint browser test env applied; +// We definitely do need to manually import CustomizableUI. +// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix +const { CustomizableUI } = ChromeUtils.importESModule( + "resource:///modules/CustomizableUI.sys.mjs" +); + +do_get_profile(); + +// Make Cu.isInAutomation true. This is necessary so that we can use +// CustomizableUIInternal. +Services.prefs.setBoolPref( + "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer", + true +); + +const CustomizableUIInternal = CustomizableUI.getTestOnlyInternalProp( + "CustomizableUIInternal" +); + +// Migration 19 was the Unified Extensions migration version introduced +// in 109, so we'll run tests by artificially setting the migration version +// to one value earlier. +const PRIOR_MIGRATION_VERSION = 18; + +/** + * Writes customization state into CustomizableUI and then performs the forward migration + * for Unified Extensions. + * + * @param {object|null} stateObj An object that will be structure-cloned and + * written into CustomizableUI's internal `gSavedState` state variable. Should + * not include the currentVersion property, as this will be set automatically by + * function if stateObj is not null. + * @returns {object} + * the saved state object (minus the currentVersion property). + */ +function migrateForward(stateObj) { + // We make sure to use structuredClone here so that we don't end up comparing + // SAVED_STATE against itself. + let stateToSave = structuredClone(stateObj); + if (stateToSave) { + stateToSave.currentVersion = PRIOR_MIGRATION_VERSION; + } + + CustomizableUI.setTestOnlyInternalProp("gSavedState", stateToSave); + CustomizableUIInternal._updateForNewVersion(); + + let migratedState = CustomizableUI.getTestOnlyInternalProp("gSavedState"); + if (migratedState) { + delete migratedState.currentVersion; + } + return migratedState; +} + +/** + * Test that attempting a migration on a new profile with no saved + * state exits safely. + */ +add_task(async function test_no_saved_state() { + let migratedState = migrateForward(null); + + Assert.deepEqual( + migratedState, + null, + "gSavedState should not have been modified" + ); +}); + +/** + * Test that attempting a migration on a new profile with no saved + * state exits safely. + */ +add_task(async function test_no_saved_placements() { + let migratedState = migrateForward({}); + + Assert.deepEqual( + migratedState, + {}, + "gSavedState should not have been modified" + ); +}); + +/** + * Test that placements that don't involve any extension buttons are + * not changed during the migration. + */ +add_task(async function test_no_extensions() { + const SAVED_STATE = { + placements: { + "nav-bar": [ + "back-button", + "forward-button", + "spring", + "urlbar-container", + "save-to-pocket-button", + "reset-pbm-toolbar-button", + ], + "toolbar-menubar": [ + "home-button", + "menubar-items", + "spring", + "downloads-button", + ], + TabsToolbar: [ + "firefox-view-button", + "tabbrowser-tabs", + "new-tab-button", + "alltabs-button", + "developer-button", + ], + PersonalToolbar: ["personal-bookmarks", "fxa-toolbar-menu-button"], + "widget-overflow-fixed-list": ["privatebrowsing-button", "panic-button"], + }, + }; + + // ADDONS_AREA should end up with an empty array as its set of placements. + const EXPECTED_STATE = structuredClone(SAVED_STATE); + EXPECTED_STATE.placements[CustomizableUI.AREA_ADDONS] = []; + + let migratedState = migrateForward(SAVED_STATE); + + Assert.deepEqual( + migratedState, + EXPECTED_STATE, + "Got the expected state after the migration." + ); +}); + +/** + * Test that if there's an existing set of items in CustomizableUI.AREA_ADDONS, + * and no extension buttons to migrate from the overflow menu, then we don't + * change the state at all. + */ +add_task(async function test_existing_browser_actions_no_movement() { + const SAVED_STATE = { + placements: { + "nav-bar": [ + "back-button", + "forward-button", + "spring", + "urlbar-container", + "save-to-pocket-button", + "reset-pbm-toolbar-button", + ], + "toolbar-menubar": [ + "home-button", + "menubar-items", + "spring", + "downloads-button", + ], + TabsToolbar: [ + "firefox-view-button", + "tabbrowser-tabs", + "new-tab-button", + "alltabs-button", + "developer-button", + ], + PersonalToolbar: ["personal-bookmarks", "fxa-toolbar-menu-button"], + "widget-overflow-fixed-list": ["privatebrowsing-button", "panic-button"], + "unified-extensions-area": ["ext0-browser-action", "ext1-browser-action"], + }, + }; + + let migratedState = migrateForward(SAVED_STATE); + + Assert.deepEqual( + migratedState, + SAVED_STATE, + "The saved state should not have changed after migration." + ); +}); + +/** + * Test that we can migrate extension buttons out from the overflow panel + * into the addons panel. + */ +add_task(async function test_migrate_extension_buttons() { + const SAVED_STATE = { + placements: { + "nav-bar": [ + "back-button", + "forward-button", + "spring", + "urlbar-container", + "save-to-pocket-button", + "reset-pbm-toolbar-button", + ], + "toolbar-menubar": [ + "home-button", + "menubar-items", + "spring", + "downloads-button", + ], + TabsToolbar: [ + "firefox-view-button", + "tabbrowser-tabs", + "new-tab-button", + "alltabs-button", + "developer-button", + ], + PersonalToolbar: ["personal-bookmarks", "fxa-toolbar-menu-button"], + "widget-overflow-fixed-list": [ + "ext0-browser-action", + "privatebrowsing-button", + "ext1-browser-action", + "panic-button", + "ext2-browser-action", + ], + }, + }; + const EXPECTED_STATE = structuredClone(SAVED_STATE); + EXPECTED_STATE.placements[CustomizableUI.AREA_FIXED_OVERFLOW_PANEL] = [ + "privatebrowsing-button", + "panic-button", + ]; + EXPECTED_STATE.placements[CustomizableUI.AREA_ADDONS] = [ + "ext0-browser-action", + "ext1-browser-action", + "ext2-browser-action", + ]; + + let migratedState = migrateForward(SAVED_STATE); + + Assert.deepEqual( + migratedState, + EXPECTED_STATE, + "The saved state should not have changed after migration." + ); +}); + +/** + * Test that we won't overwrite existing placements within the addons panel + * if we migrate things over from the overflow panel. We'll prepend the + * migrated items to the addons panel instead. + */ +add_task(async function test_migrate_extension_buttons_no_overwrite() { + const SAVED_STATE = { + placements: { + "nav-bar": [ + "back-button", + "forward-button", + "spring", + "urlbar-container", + "save-to-pocket-button", + "reset-pbm-toolbar-button", + ], + "toolbar-menubar": [ + "home-button", + "menubar-items", + "spring", + "downloads-button", + ], + TabsToolbar: [ + "firefox-view-button", + "tabbrowser-tabs", + "new-tab-button", + "alltabs-button", + "developer-button", + ], + PersonalToolbar: ["personal-bookmarks", "fxa-toolbar-menu-button"], + "widget-overflow-fixed-list": [ + "ext0-browser-action", + "privatebrowsing-button", + "ext1-browser-action", + "panic-button", + "ext2-browser-action", + ], + "unified-extensions-area": ["ext3-browser-action", "ext4-browser-action"], + }, + }; + const EXPECTED_STATE = structuredClone(SAVED_STATE); + EXPECTED_STATE.placements[CustomizableUI.AREA_FIXED_OVERFLOW_PANEL] = [ + "privatebrowsing-button", + "panic-button", + ]; + EXPECTED_STATE.placements[CustomizableUI.AREA_ADDONS] = [ + "ext0-browser-action", + "ext1-browser-action", + "ext2-browser-action", + "ext3-browser-action", + "ext4-browser-action", + ]; + + let migratedState = migrateForward(SAVED_STATE); + + Assert.deepEqual( + migratedState, + EXPECTED_STATE, + "The saved state should not have changed after migration." + ); +}); + +/** + * Test that extension buttons from areas other than the overflow panel + * won't be moved. + */ +add_task(async function test_migrate_extension_buttons_elsewhere() { + const SAVED_STATE = { + placements: { + "nav-bar": [ + "back-button", + "ext0-browser-action", + "forward-button", + "ext1-browser-action", + "spring", + "ext2-browser-action", + "urlbar-container", + "ext3-browser-action", + "save-to-pocket-button", + "reset-pbm-toolbar-button", + "ext4-browser-action", + ], + "toolbar-menubar": [ + "home-button", + "ext5-browser-action", + "menubar-items", + "ext6-browser-action", + "spring", + "ext7-browser-action", + "downloads-button", + "ext8-browser-action", + ], + TabsToolbar: [ + "firefox-view-button", + "ext9-browser-action", + "tabbrowser-tabs", + "ext10-browser-action", + "new-tab-button", + "ext11-browser-action", + "alltabs-button", + "ext12-browser-action", + "developer-button", + "ext13-browser-action", + ], + PersonalToolbar: [ + "personal-bookmarks", + "ext14-browser-action", + "fxa-toolbar-menu-button", + "ext15-browser-action", + ], + "widget-overflow-fixed-list": [ + "ext16-browser-action", + "privatebrowsing-button", + "ext17-browser-action", + "panic-button", + "ext18-browser-action", + ], + }, + }; + const EXPECTED_STATE = structuredClone(SAVED_STATE); + EXPECTED_STATE.placements[CustomizableUI.AREA_FIXED_OVERFLOW_PANEL] = [ + "privatebrowsing-button", + "panic-button", + ]; + EXPECTED_STATE.placements[CustomizableUI.AREA_ADDONS] = [ + "ext16-browser-action", + "ext17-browser-action", + "ext18-browser-action", + ]; + + let migratedState = migrateForward(SAVED_STATE); + + Assert.deepEqual( + migratedState, + EXPECTED_STATE, + "The saved state should not have changed after migration." + ); +}); diff --git a/browser/components/customizableui/test/unit/xpcshell.toml b/browser/components/customizableui/test/unit/xpcshell.toml new file mode 100644 index 0000000000..029a8a962d --- /dev/null +++ b/browser/components/customizableui/test/unit/xpcshell.toml @@ -0,0 +1,6 @@ +[DEFAULT] +head = '' +skip-if = ["os == 'android'"] # bug 1730213 +firefox-appdir = "browser" + +["test_unified_extensions_migration.js"] -- cgit v1.2.3