chromium/chrome/browser/ui/ash/wm/snap_group_browsertest.cc

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "ash/wm/snap_group/snap_group.h"

#include "ash/constants/ash_features.h"
#include "ash/display/display_move_window_util.h"
#include "ash/screen_util.h"
#include "ash/shell.h"
#include "ash/style/icon_button.h"
#include "ash/wm/desks/desk_action_context_menu.h"
#include "ash/wm/desks/desks_test_api.h"
#include "ash/wm/desks/desks_test_util.h"
#include "ash/wm/desks/overview_desk_bar_view.h"
#include "ash/wm/overview/overview_controller.h"
#include "ash/wm/overview/overview_grid.h"
#include "ash/wm/overview/overview_session.h"
#include "ash/wm/overview/overview_test_util.h"
#include "ash/wm/overview/overview_utils.h"
#include "ash/wm/snap_group/snap_group_controller.h"
#include "ash/wm/snap_group/snap_group_test_util.h"
#include "ash/wm/splitview/split_view_constants.h"
#include "ash/wm/splitview/split_view_setup_view.h"
#include "ash/wm/window_state.h"
#include "ash/wm/wm_event.h"
#include "base/memory/raw_ptr.h"
#include "base/test/scoped_feature_list.h"
#include "chrome/browser/ash/system_web_apps/system_web_app_manager.h"
#include "chrome/browser/profiles/profile_manager.h"
#include "chrome/browser/ui/ash/new_window/chrome_new_window_client.h"
#include "chrome/browser/ui/ash/system_web_apps/system_web_app_ui_utils.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/browser_finder.h"
#include "chrome/browser/ui/browser_list.h"
#include "chrome/browser/ui/browser_tabstrip.h"
#include "chrome/browser/ui/browser_window.h"
#include "chrome/browser/ui/views/frame/browser_view.h"
#include "chrome/browser/ui/views/tabs/tab_strip.h"
#include "chrome/browser/ui/views/tabs/tab_strip_observer.h"
#include "chrome/test/base/ash/util/ash_test_util.h"
#include "chrome/test/base/in_process_browser_test.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/test_navigation_observer.h"
#include "ui/display/manager/display_manager.h"
#include "ui/display/test/display_manager_test_api.h"
#include "ui/events/test/event_generator.h"
#include "ui/views/controls/button/button.h"
#include "ui/views/controls/menu/menu_item_view.h"
#include "ui/views/view_utils.h"
#include "ui/wm/core/coordinate_conversion.h"
#include "ui/wm/core/window_util.h"

namespace {

void ClickButton(const views::Button* button) {
  CHECK(button);
  CHECK(button->GetVisible());
  aura::Window* root_window =
      button->GetWidget()->GetNativeWindow()->GetRootWindow();
  ui::test::EventGenerator event_generator(root_window);
  event_generator.MoveMouseToInHost(button->GetBoundsInScreen().CenterPoint());
  event_generator.ClickLeftButton();
}

const GURL& GetActiveUrl(Browser* browser) {
  return browser->tab_strip_model()
      ->GetActiveWebContents()
      ->GetLastCommittedURL();
}

// This class observes the `TabStripModelObserver` and reacts with predetermined
// actions to manage tab behavior. In particular, it finalizes tab detachment
// by releasing the left mouse button when a tab strip is removed from a window.
class TabRemoveObserver : public TabStripModelObserver {
 public:
  TabRemoveObserver(Browser* browser, ui::test::EventGenerator* event_generator)
      : browser_(browser), event_generator_(event_generator) {
    browser_->tab_strip_model()->AddObserver(this);
  }
  TabRemoveObserver(const TabRemoveObserver&) = delete;
  TabRemoveObserver& operator=(const TabRemoveObserver&) = delete;
  ~TabRemoveObserver() override {
    browser_->tab_strip_model()->RemoveObserver(this);
  }

  // TabStripModelObserver:
  void OnTabWillBeRemoved(content::WebContents* contents, int index) override {
    // Tab detachment is asynchronous. Release the mouse button after the tab
    // move is done.
    event_generator_->ReleaseLeftButton();
  }

 private:
  raw_ptr<Browser> browser_;
  raw_ptr<ui::test::EventGenerator> event_generator_;
};

}  // namespace

// -----------------------------------------------------------------------------
// FasterSplitScreenBrowserTest:

using FasterSplitScreenBrowserTest = InProcessBrowserTest;

// Tests that if partial overview is active, and a window gets session
// restore'd, partial overview auto-snaps the window. See b/314816288.
IN_PROC_BROWSER_TEST_F(FasterSplitScreenBrowserTest,
                       AutoSnapWhileInSessionRestore) {
  // Create two browser windows and snap `window1` to start partial overview.
  aura::Window* window1 = browser()->window()->GetNativeWindow();
  ash::WindowState* window_state = ash::WindowState::Get(window1);
  CreateBrowser(browser()->profile());

  const ash::WindowSnapWMEvent primary_snap_event(
      ash::WM_EVENT_SNAP_PRIMARY, ash::WindowSnapActionSource::kTest);
  window_state->OnWMEvent(&primary_snap_event);
  ash::WaitForOverviewEntered();
  ASSERT_TRUE(ash::OverviewController::Get()->InOverviewSession());

  // Open a new browser window. Test it gets auto-snapped.
  Browser* browser3 = CreateBrowser(browser()->profile());
  aura::Window* window3 = browser3->window()->GetNativeWindow();
  EXPECT_TRUE(ash::WindowState::Get(window3)->IsSnapped());
  EXPECT_FALSE(ash::OverviewController::Get()->InOverviewSession());
}

class FasterSplitScreenWithNewSettingsBrowserTest
    : public FasterSplitScreenBrowserTest {
 public:
  FasterSplitScreenWithNewSettingsBrowserTest() {
    scoped_feature_list_.InitWithFeatures(
        /*enabled_features=*/{ash::features::kOsSettingsRevampWayfinding},
        /*disabled_features=*/{});
  }
  FasterSplitScreenWithNewSettingsBrowserTest(
      const FasterSplitScreenWithNewSettingsBrowserTest&) = delete;
  FasterSplitScreenWithNewSettingsBrowserTest& operator=(
      const FasterSplitScreenWithNewSettingsBrowserTest&) = delete;
  ~FasterSplitScreenWithNewSettingsBrowserTest() override = default;

 private:
  base::test::ScopedFeatureList scoped_feature_list_;
};

IN_PROC_BROWSER_TEST_F(FasterSplitScreenWithNewSettingsBrowserTest,
                       SnapWindowWithNewSettings) {
  // Install the Settings App.
  ash::SystemWebAppManager::GetForTest(browser()->profile())
      ->InstallSystemAppsForTesting();

  // Create two browser windows and snap `window` to start partial overview.
  aura::Window* window = browser()->window()->GetNativeWindow();
  CreateBrowser(browser()->profile());
  ash::WindowState* window_state = ash::WindowState::Get(window);
  const ash::WindowSnapWMEvent primary_snap_event(
      ash::WM_EVENT_SNAP_PRIMARY, ash::WindowSnapActionSource::kTest);
  window_state->OnWMEvent(&primary_snap_event);
  ash::WaitForOverviewEntered();
  ASSERT_TRUE(ash::OverviewController::Get()->InOverviewSession());

  // Partial overview contains the settings button.
  auto* overview_grid =
      ash::OverviewController::Get()->overview_session()->GetGridWithRootWindow(
          window->GetRootWindow());
  ASSERT_TRUE(overview_grid);

  auto* split_view_setup_view = overview_grid->GetSplitViewSetupView();
  ASSERT_TRUE(split_view_setup_view);
  views::Button* settings_button = const_cast<views::Button*>(
      views::AsViewClass<views::Button>(split_view_setup_view->GetViewByID(
          ash::SplitViewSetupView::kSettingsButtonIDForTest)));
  ASSERT_TRUE(settings_button);

  // Setup navigation observer to wait for the OS Settings page.
  constexpr char kOsSettingsUrl[] =
      "chrome://os-settings/systemPreferences?settingId=1900";
  GURL os_settings(kOsSettingsUrl);
  content::TestNavigationObserver navigation_observer(os_settings);
  navigation_observer.StartWatchingNewWebContents();

  // Click the overview settings button.
  ClickButton(settings_button);

  // Wait for OS Settings to open.
  navigation_observer.Wait();

  // Verify correct OS Settings page is opened.
  Browser* settings_browser = ash::FindSystemWebAppBrowser(
      browser()->profile(), ash::SystemWebAppType::SETTINGS);
  ASSERT_TRUE(settings_browser);
  ASSERT_EQ(os_settings, GetActiveUrl(settings_browser));
}

// -----------------------------------------------------------------------------
// FasterSplitScreenWithOldSettingsBrowserTest:

class FasterSplitScreenWithOldSettingsBrowserTest
    : public FasterSplitScreenBrowserTest {
 public:
  FasterSplitScreenWithOldSettingsBrowserTest() {
    scoped_feature_list_.InitWithFeatures(
        /*enabled_features=*/{},
        /*disabled_features=*/{ash::features::kOsSettingsRevampWayfinding});
  }
  FasterSplitScreenWithOldSettingsBrowserTest(
      const FasterSplitScreenWithOldSettingsBrowserTest&) = delete;
  FasterSplitScreenWithOldSettingsBrowserTest& operator=(
      const FasterSplitScreenWithOldSettingsBrowserTest&) = delete;
  ~FasterSplitScreenWithOldSettingsBrowserTest() override = default;

 private:
  base::test::ScopedFeatureList scoped_feature_list_;
};

IN_PROC_BROWSER_TEST_F(FasterSplitScreenWithOldSettingsBrowserTest,
                       SnapWindowWithOldSettings) {
  // Install the Settings App.
  ash::SystemWebAppManager::GetForTest(browser()->profile())
      ->InstallSystemAppsForTesting();

  // Create two browser windows and snap `window` to start partial overview.
  aura::Window* window = browser()->window()->GetNativeWindow();
  CreateBrowser(browser()->profile());
  ash::WindowState* window_state = ash::WindowState::Get(window);
  const ash::WindowSnapWMEvent primary_snap_event(
      ash::WM_EVENT_SNAP_PRIMARY, ash::WindowSnapActionSource::kTest);
  window_state->OnWMEvent(&primary_snap_event);
  ash::WaitForOverviewEntered();
  ASSERT_TRUE(ash::OverviewController::Get()->InOverviewSession());

  // Partial overview contains the settings button.
  auto* overview_grid =
      ash::OverviewController::Get()->overview_session()->GetGridWithRootWindow(
          window->GetRootWindow());
  auto* split_view_setup_view = overview_grid->GetSplitViewSetupView();
  ASSERT_TRUE(split_view_setup_view);
  views::Button* settings_button = const_cast<views::Button*>(
      views::AsViewClass<views::Button>(split_view_setup_view->GetViewByID(
          ash::SplitViewSetupView::kSettingsButtonIDForTest)));
  ASSERT_TRUE(settings_button);

  // Setup navigation observer to wait for the OS Settings page.
  constexpr char kOsSettingsUrl[] =
      "chrome://os-settings/personalization?settingId=1900";
  GURL os_settings(kOsSettingsUrl);
  content::TestNavigationObserver navigation_observer(os_settings);
  navigation_observer.StartWatchingNewWebContents();

  // Click the overview settings button.
  ClickButton(settings_button);

  // Wait for OS Settings to open.
  navigation_observer.Wait();

  // Verify correct OS Settings page is opened.
  Browser* settings_browser = ash::FindSystemWebAppBrowser(
      browser()->profile(), ash::SystemWebAppType::SETTINGS);
  ASSERT_TRUE(settings_browser);
  ASSERT_EQ(os_settings, GetActiveUrl(settings_browser));
}

// -----------------------------------------------------------------------------
// SnapGroupBrowserTest:

class SnapGroupBrowserTest : public InProcessBrowserTest {
 public:
  SnapGroupBrowserTest() {
    scoped_feature_list_.InitWithFeatures(
        /*enabled_features=*/{ash::features::kSnapGroup,
                              ash::features::kForestFeature,
                              ash::features::kSavedDeskUiRevamp},
        /*disabled_features=*/{});
  }
  SnapGroupBrowserTest(const SnapGroupBrowserTest&) = delete;
  SnapGroupBrowserTest& operator=(const SnapGroupBrowserTest&) = delete;
  ~SnapGroupBrowserTest() override = default;

 private:
  base::test::ScopedFeatureList scoped_feature_list_;
};

// Tests that creating a snap group in a rotated display works correctly.
// Regression test for http://335323173.
// Test layout, where Display 2 is rotated such that Files is physically on top
// and Settings on bottom:
// +----------------+----------+
// |                |          |
// |    Display 1   |  Files   |
// |                |          |
// +----------------|----------|
//                  |          |
//                  | Settings |
//                  |          |
//                  +----------+
IN_PROC_BROWSER_TEST_F(SnapGroupBrowserTest, RotatedSnapGroup) {
  auto* display_manager = ash::Shell::Get()->display_manager();
  display::test::DisplayManagerTestApi(display_manager)
      .UpdateDisplay("0+0-800x600,800+0-1200x900/r");
  display::test::DisplayManagerTestApi display_manager_test(display_manager);

  const auto& displays = display_manager->active_display_list();
  const display::Display display2 = displays[1];
  ASSERT_EQ(display2.id(), display_manager_test.GetSecondaryDisplay().id());
  ASSERT_EQ(chromeos::OrientationType::kPortraitSecondary,
            chromeos::GetDisplayCurrentOrientation(display2));

  // Open Files and Settings app windows.
  Profile* profile = ProfileManager::GetActiveUserProfile();
  ash::test::InstallSystemAppsForTesting(profile);

  ash::test::CreateSystemWebApp(profile, ash::SystemWebAppType::FILE_MANAGER);
  aura::Window* w1 =
      BrowserList::GetInstance()->GetLastActive()->window()->GetNativeWindow();
  ash::display_move_window_util::HandleMoveActiveWindowBetweenDisplays();
  auto* root2 = ash::Shell::GetAllRootWindows()[1].get();
  ASSERT_EQ(root2, w1->GetRootWindow());

  ash::test::CreateSystemWebApp(profile, ash::SystemWebAppType::SETTINGS);
  aura::Window* w2 =
      BrowserList::GetInstance()->GetLastActive()->window()->GetNativeWindow();
  ash::display_move_window_util::HandleMoveActiveWindowBetweenDisplays();
  ASSERT_EQ(root2, w1->GetRootWindow());

  // Snap Files to secondary which is physically on top.
  ash::WindowState* window_state1 = ash::WindowState::Get(w1);
  const ash::WindowSnapWMEvent primary_snap_event(
      ash::WM_EVENT_SNAP_SECONDARY,
      ash::WindowSnapActionSource::kDragWindowToEdgeToSnap);
  window_state1->OnWMEvent(&primary_snap_event);
  ash::WaitForOverviewEntered();
  ASSERT_TRUE(ash::OverviewController::Get()->InOverviewSession());

  // Test the bounds are updated. Rects are also hardcoded for readability.
  // Note `display::Display::work_area()` is still relative to the screen, i.e.
  // not rotated.
  const gfx::Rect work_area2 =
      ash::screen_util::GetDisplayWorkAreaBoundsInScreenForActiveDeskContainer(
          w1);
  ASSERT_EQ(gfx::Rect(800, 0, 900, 1152), work_area2);
  gfx::Rect top_half, bottom_half;
  work_area2.SplitHorizontally(top_half, bottom_half);
  ASSERT_EQ(chromeos::WindowStateType::kSecondarySnapped,
            ash::WindowState::Get(w1)->GetStateType());
  EXPECT_EQ(top_half, w1->GetBoundsInScreen());

  // Activate `w2` to simulate clicking it from overview.
  wm::ActivateWindow(w2);
  ash::WaitForOverviewExitAnimation();
  ASSERT_EQ(chromeos::WindowStateType::kPrimarySnapped,
            ash::WindowState::Get(w2)->GetStateType());
  EXPECT_TRUE(ash::SnapGroupController::Get()->AreWindowsInSnapGroup(w1, w2));

  // Test the bounds are updated. Rects are also hardcoded for readability.
  const gfx::Rect divider_bounds(
      work_area2.x(),
      work_area2.CenterPoint().y() - ash::kSplitviewDividerShortSideLength / 2,
      work_area2.width(), ash::kSplitviewDividerShortSideLength);
  ASSERT_EQ(gfx::Rect(800, 573, 900, 6), divider_bounds);
  EXPECT_EQ(divider_bounds, ash::SnapGroupController::Get()
                                ->GetSnapGroupForGivenWindow(w1)
                                ->snap_group_divider()
                                ->GetDividerBoundsInScreen(
                                    /*is_dragging=*/false));
  top_half.Subtract(divider_bounds);
  bottom_half.Subtract(divider_bounds);
  ASSERT_EQ(gfx::Rect(800, 0, 900, 573), top_half);
  EXPECT_EQ(top_half, w1->GetBoundsInScreen());
  ASSERT_EQ(gfx::Rect(800, 579, 900, 573), bottom_half);
  EXPECT_EQ(bottom_half, w2->GetBoundsInScreen());
}

// Verify that dragging a tab within a Snap Group window does not break the
// group.
IN_PROC_BROWSER_TEST_F(SnapGroupBrowserTest, DoNotBreakGroupOnTabDragging) {
  aura::Window* window1 = browser()->window()->GetNativeWindow();
  chrome::AddTabAt(browser(), GURL(chrome::kChromeUITabSearchURL), -1, true);
  ASSERT_EQ(2, browser()->tab_strip_model()->GetTabCount());

  aura::Window* window2 =
      CreateBrowser(browser()->profile())->window()->GetNativeWindow();

  aura::Window* root_window = ash::Shell::GetPrimaryRootWindow();
  ui::test::EventGenerator event_generator(root_window);
  ash::SnapTwoTestWindows(window1, window2, /*horizontal=*/true,
                          &event_generator);
  ASSERT_TRUE(
      ash::SnapGroupController::Get()->AreWindowsInSnapGroup(window1, window2));

  TabStrip* tap_strip =
      BrowserView::GetBrowserViewForBrowser(browser())->tabstrip();
  const auto start_point =
      tap_strip->tab_at(1)->GetBoundsInScreen().CenterPoint();
  const auto end_point =
      tap_strip->tab_at(0)->GetBoundsInScreen().left_center();
  event_generator.MoveMouseTo(start_point);
  event_generator.PressLeftButton();
  event_generator.MoveMouseTo(end_point);
  event_generator.ReleaseLeftButton();

  EXPECT_EQ(2u, chrome::GetTotalBrowserCount());
  EXPECT_TRUE(
      ash::SnapGroupController::Get()->AreWindowsInSnapGroup(window1, window2));
}

// Verify that detaching a tab from a window within a Snap Group doesn't break
// the group.
IN_PROC_BROWSER_TEST_F(SnapGroupBrowserTest, DoNotBreakGroupOnTabDetaching) {
  aura::Window* window1 = browser()->window()->GetNativeWindow();
  chrome::AddTabAt(browser(), GURL(chrome::kChromeUITabSearchURL), -1, true);
  ASSERT_EQ(2, browser()->tab_strip_model()->GetTabCount());

  aura::Window* window2 =
      CreateBrowser(browser()->profile())->window()->GetNativeWindow();

  ui::test::EventGenerator event_generator(ash::Shell::GetPrimaryRootWindow());
  ash::SnapTwoTestWindows(window1, window2, /*horizontal=*/true,
                          &event_generator);
  ASSERT_TRUE(
      ash::SnapGroupController::Get()->AreWindowsInSnapGroup(window1, window2));

  ASSERT_EQ(2u, chrome::GetTotalBrowserCount());

  TabStrip* tap_strip =
      BrowserView::GetBrowserViewForBrowser(browser())->tabstrip();
  const gfx::Point start_point =
      tap_strip->tab_at(1)->GetBoundsInScreen().CenterPoint();
  const gfx::Point end_point = window2->GetBoundsInScreen().CenterPoint();
  event_generator.MoveMouseTo(start_point);
  event_generator.PressLeftButton();
  TabRemoveObserver observer(browser(), &event_generator);
  event_generator.MoveMouseTo(end_point);

  // Verify that detaching a tab results in a new window being created and that
  // `window1` and `window2` still belong to the Snap Group.
  EXPECT_EQ(3u, chrome::GetTotalBrowserCount());
  EXPECT_TRUE(
      ash::SnapGroupController::Get()->AreWindowsInSnapGroup(window1, window2));
}

// Test that "Save Desk for Later" is not supported when both windows in a Snap
// Group are in incognito mode.
IN_PROC_BROWSER_TEST_F(SnapGroupBrowserTest,
                       SaveDeskForLaterWithTwoIncognitoWindows) {
  auto* desks_controller = ash::DesksController::Get();
  desks_controller->NewDesk(ash::DesksCreationRemovalSource::kButton);
  const auto& desks = desks_controller->desks();
  ASSERT_EQ(2u, desks.size());
  aura::Window* root_window = ash::Shell::GetPrimaryRootWindow();

  // Explicitly move the default non-incognito browser window to another desk.
  aura::Window* window = browser()->window()->GetNativeWindow();
  desks_controller->MoveWindowFromActiveDeskTo(
      window, desks[1].get(), root_window,
      ash::DesksMoveWindowFromActiveDeskSource::kShortcut);

  // Create a Snap Group with two incognito browser windows.
  Browser* incognito_browser1 = CreateIncognitoBrowser();
  aura::Window* window1 = incognito_browser1->window()->GetNativeWindow();
  Browser* incognito_browser2 = CreateIncognitoBrowser();
  aura::Window* window2 = incognito_browser2->window()->GetNativeWindow();

  ui::test::EventGenerator event_generator(root_window);
  ash::SnapTwoTestWindows(window1, window2, /*horizontal=*/true,
                          &event_generator);
  ash::OverviewController::Get()->StartOverview(
      ash::OverviewStartAction::kOverviewButton);
  ash::WaitForOverviewEntered();
  ASSERT_TRUE(ash::IsInOverviewSession());

  ASSERT_EQ(2u, ash::GetPrimaryRootDesksBarView()->mini_views().size());
  ash::OverviewGrid* overview_grid = ash::GetOverviewGridForRoot(root_window);
  EXPECT_EQ(1u, overview_grid->item_list().size());
  auto* desks_bar_view0 = overview_grid->desks_bar_view();
  ASSERT_TRUE(desks_bar_view0);
  ash::DeskMiniView* desk_mini_view0 = desks_bar_view0->mini_views()[0];
  ASSERT_TRUE(desk_mini_view0);

  event_generator.MoveMouseTo(
      desk_mini_view0->GetBoundsInScreen().CenterPoint());
  event_generator.ClickRightButton();

  // Activate the desk mini view to enable context menu.
  ash::DeskActionContextMenu* mini_view_menu0 = desk_mini_view0->context_menu();
  ASSERT_TRUE(mini_view_menu0);

  const views::MenuItemView* save_for_later_item =
      ash::DesksTestApi::GetDeskActionContextMenuItem(
          mini_view_menu0,
          ash::DeskActionContextMenu::CommandId::kSaveForLater);
  ASSERT_TRUE(save_for_later_item);

  // Click on the "Save Desk for Later" button in the context menu.
  event_generator.MoveMouseTo(
      save_for_later_item->GetBoundsInScreen().CenterPoint());
  event_generator.ClickLeftButton();

  // Verify that the "Save to Desk" feature is not supported as both windows
  // within a Snap Group are in incognito mode.
  EXPECT_TRUE(ash::IsInOverviewSession());
  EXPECT_EQ(1u, overview_grid->item_list().size());
  EXPECT_EQ(3u, chrome::GetTotalBrowserCount());
}