chromium/chrome/browser/web_applications/app_service/web_apps_chromeos_browsertest.cc

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

#include <memory>
#include <optional>
#include <string>

#include "base/run_loop.h"
#include "base/test/bind.h"
#include "base/test/run_until.h"
#include "base/test/test_future.h"
#include "chrome/browser/apps/app_service/app_registry_cache_waiter.h"
#include "chrome/browser/apps/app_service/app_service_proxy.h"
#include "chrome/browser/apps/app_service/app_service_proxy_factory.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/ash/shelf/chrome_shelf_controller_util.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/browser_commands.h"
#include "chrome/browser/ui/web_applications/test/isolated_web_app_test_utils.h"
#include "chrome/browser/ui/web_applications/test/web_app_browsertest_util.h"
#include "chrome/browser/ui/web_applications/web_app_browsertest_base.h"
#include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_trust_checker.h"
#include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_server_mixin.h"
#include "chrome/browser/web_applications/isolated_web_apps/policy/isolated_web_app_policy_constants.h"
#include "chrome/browser/web_applications/isolated_web_apps/test/isolated_web_app_builder.h"
#include "chrome/browser/web_applications/isolated_web_apps/test/test_signed_web_bundle_builder.h"
#include "chrome/browser/web_applications/policy/web_app_policy_constants.h"
#include "chrome/browser/web_applications/test/web_app_install_test_utils.h"
#include "chrome/browser/web_applications/test/web_app_test_observers.h"
#include "chrome/browser/web_applications/web_app_command_manager.h"
#include "chrome/browser/web_applications/web_app_id_constants.h"
#include "chrome/browser/web_applications/web_app_provider.h"
#include "chrome/browser/web_applications/web_app_registrar.h"
#include "chrome/common/pref_names.h"
#include "chrome/test/base/ui_test_utils.h"
#include "components/prefs/pref_service.h"
#include "components/webapps/common/web_app_id.h"
#include "content/public/test/browser_test.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/skia/include/core/SkBitmap.h"
#include "ui/base/models/image_model.h"
#include "ui/base/models/menu_model.h"
#include "ui/base/models/simple_menu_model.h"
#include "ui/display/display.h"
#include "ui/display/screen.h"
#include "ui/events/event_constants.h"
#include "ui/gfx/image/image.h"
#include "url/gurl.h"
#include "url/origin.h"

#if BUILDFLAG(IS_CHROMEOS_ASH)
#include "ash/public/cpp/app_menu_constants.h"
#include "ash/public/cpp/shelf_item_delegate.h"
#include "ash/public/cpp/shelf_model.h"
#include "ash/public/cpp/system/toast_manager.h"
#include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_url_info.h"
#include "chrome/browser/web_applications/isolated_web_apps/test/isolated_web_app_builder.h"
#endif  // BUILDFLAG(IS_CHROMEOS_ASH)

#if BUILDFLAG(IS_CHROMEOS_LACROS)
#include "chromeos/crosapi/mojom/test_controller.mojom.h"
#include "chromeos/lacros/lacros_service.h"
#endif  // BUILDFLAG(IS_CHROMEOS_LACROS)

#if BUILDFLAG(IS_CHROMEOS_ASH)
namespace {

void CheckShortcut(const ui::SimpleMenuModel& model,
                   size_t index,
                   int shortcut_index,
                   const std::u16string& label,
                   std::optional<SkColor> color) {
  EXPECT_EQ(model.GetTypeAt(index), ui::MenuModel::TYPE_COMMAND);
  EXPECT_EQ(model.GetCommandIdAt(index),
            ash::LAUNCH_APP_SHORTCUT_FIRST + shortcut_index);
  EXPECT_EQ(model.GetLabelAt(index), label);

  ui::ImageModel icon = model.GetIconAt(index);
  if (color.has_value()) {
    EXPECT_FALSE(icon.GetImage().IsEmpty());
    EXPECT_EQ(icon.GetImage().AsImageSkia().bitmap()->getColor(15, 15), color);
  } else {
    EXPECT_TRUE(icon.IsEmpty());
  }
}

void CheckSeparator(const ui::SimpleMenuModel& model, size_t index) {
  EXPECT_EQ(model.GetTypeAt(index), ui::MenuModel::TYPE_SEPARATOR);
  EXPECT_EQ(model.GetCommandIdAt(index), -1);
}

}  // namespace

using WebAppsChromeOsBrowserTest = web_app::WebAppBrowserTestBase;

IN_PROC_BROWSER_TEST_F(WebAppsChromeOsBrowserTest, ShortcutIcons) {
  const GURL app_url =
      https_server()->GetURL("/web_app_shortcuts/shortcuts.html");
  const webapps::AppId app_id =
      web_app::InstallWebAppFromPage(browser(), app_url);
  LaunchWebAppBrowser(app_id);

  std::unique_ptr<ui::SimpleMenuModel> menu_model;
  {
    ash::ShelfModel* const shelf_model = ash::ShelfModel::Get();
    PinAppWithIDToShelf(app_id);
    ash::ShelfItemDelegate* const delegate =
        shelf_model->GetShelfItemDelegate(ash::ShelfID(app_id));
    base::RunLoop run_loop;
    delegate->GetContextMenu(
        display::Display::GetDefaultDisplay().id(),
        base::BindLambdaForTesting(
            [&run_loop,
             &menu_model](std::unique_ptr<ui::SimpleMenuModel> model) {
              menu_model = std::move(model);
              run_loop.Quit();
            }));
    run_loop.Run();
  }

  // Shortcuts appear last in the context menu.
  // See /web_app_shortcuts/shortcuts.json for shortcut icon definitions.
  size_t index = menu_model->GetItemCount() - 11;

  // Purpose |any| by default.
  CheckShortcut(*menu_model, index++, 0, u"One", SK_ColorGREEN);
  CheckSeparator(*menu_model, index++);
  // Purpose |maskable| takes precedence over |any|.
  CheckShortcut(*menu_model, index++, 1, u"Two", SK_ColorBLUE);
  CheckSeparator(*menu_model, index++);
  // Purpose |any|.
  CheckShortcut(*menu_model, index++, 2, u"Three", SK_ColorYELLOW);
  CheckSeparator(*menu_model, index++);
  // Purpose |any| and |maskable|.
  CheckShortcut(*menu_model, index++, 3, u"Four", SK_ColorCYAN);
  CheckSeparator(*menu_model, index++);
  // Purpose |maskable|.
  CheckShortcut(*menu_model, index++, 4, u"Five", SK_ColorMAGENTA);
  CheckSeparator(*menu_model, index++);
  // No icons.
  CheckShortcut(*menu_model, index++, 5, u"Six", std::nullopt);
  EXPECT_EQ(index, menu_model->GetItemCount());

  const int command_id = ash::LAUNCH_APP_SHORTCUT_FIRST + 3;
  ui_test_utils::UrlLoadObserver url_observer(
      https_server()->GetURL("/web_app_shortcuts/shortcuts.html#four"));
  menu_model->ActivatedAt(menu_model->GetIndexOfCommandId(command_id).value(),
                          ui::EF_LEFT_MOUSE_BUTTON);
  url_observer.Wait();
}

namespace {

bool HasMenuModelCommandId(ui::MenuModel* model, ash::CommandId command_id) {
  size_t index = 0;
  return ui::MenuModel::GetModelAndIndexForCommandId(command_id, &model,
                                                     &index);
}

}  // namespace

#endif  // BUILDFLAG(IS_CHROMEOS_ASH)

namespace {
constexpr char kCalculatorAppUrl[] = "https://calculator.apps.chrome/";

enum class AppType { kProgressiveWebApp, kIsolatedWebApp };
struct PreventCloseTestParams {
  bool is_prevent_close_enabled;
  AppType app_type;
};

}  // namespace

class WebAppsPreventCloseChromeOsBrowserTest
    : public web_app::IsolatedWebAppBrowserTestHarness,
      public ::testing::WithParamInterface<PreventCloseTestParams> {
 public:
  WebAppsPreventCloseChromeOsBrowserTest() = default;

  WebAppsPreventCloseChromeOsBrowserTest(
      const WebAppsPreventCloseChromeOsBrowserTest&) = delete;
  WebAppsPreventCloseChromeOsBrowserTest& operator=(
      const WebAppsPreventCloseChromeOsBrowserTest&) = delete;
  ~WebAppsPreventCloseChromeOsBrowserTest() override = default;

  void TearDownOnMainThread() override {
    // Clear policy values, otherwise we won't be able to gracefully close stop
    // browser test.
    ResetPolicies();
    web_app::IsolatedWebAppBrowserTestHarness::TearDownOnMainThread();
  }

  bool IsPreventCloseEnabled() const {
    return GetParam().is_prevent_close_enabled;
  }

  AppType GetAppType() const { return GetParam().app_type; }

  void InstallApp() {
    const webapps::AppId installed_app_id = GetInstalledAppId();
    web_app::WebAppTestInstallObserver observer(profile());
    observer.BeginListening({installed_app_id});
    switch (GetAppType()) {
      case AppType::kProgressiveWebApp:
        installed_app_url_ = kCalculatorAppUrl;
        profile()->GetPrefs()->SetList(
            prefs::kWebAppInstallForceList,
            base::Value::List().Append(
                base::Value::Dict()
                    .Set(web_app::kUrlKey, kCalculatorAppUrl)
                    .Set(web_app::kDefaultLaunchContainerKey,
                         web_app::kDefaultLaunchContainerWindowValue)));
        break;

      case AppType::kIsolatedWebApp:
        auto web_bundle_id = web_app::test::GetDefaultEd25519WebBundleId();

        isolated_web_app_update_server_mixin_.AddBundle(
            web_app::IsolatedWebAppBuilder(
                web_app::ManifestBuilder().SetVersion("1.0.0"))
                .BuildBundle(web_bundle_id,
                             {web_app::test::GetDefaultEd25519KeyPair()}));

        installed_app_url_ =
            web_app::IsolatedWebAppUrlInfo::CreateFromSignedWebBundleId(
                web_bundle_id)
                .origin()
                .GetURL()
                .spec();
        profile()->GetPrefs()->SetList(
            prefs::kIsolatedWebAppInstallForceList,
            base::Value::List().Append(
                base::Value::Dict()
                    .Set(web_app::kPolicyWebBundleIdKey, web_bundle_id.id())
                    .Set(web_app::kPolicyUpdateManifestUrlKey,
                         isolated_web_app_update_server_mixin_
                             .GetUpdateManifestUrl(web_bundle_id)
                             .spec())));
        break;
    }
    ASSERT_EQ(installed_app_id, observer.Wait());
  }

  webapps::AppId GetInstalledAppId() const {
    return (GetAppType() == AppType::kIsolatedWebApp)
               ? web_app::IsolatedWebAppUrlInfo::CreateFromSignedWebBundleId(
                     web_app::test::GetDefaultEd25519WebBundleId())
                     .app_id()
               : web_app::kCalculatorAppId;
  }

  void ResetPolicies() {
    profile()->GetPrefs()->SetList(prefs::kWebAppSettings, base::Value::List());
    profile()->GetPrefs()->SetList(prefs::kWebAppInstallForceList,
                                   base::Value::List());
    web_app::WebAppProvider::GetForTest(profile())
        ->command_manager()
        .AwaitAllCommandsCompleteForTesting();
  }

  bool IsToastShown(const std::string& toast_id) {
#if BUILDFLAG(IS_CHROMEOS_ASH)
    return ash::ToastManager::Get()->IsToastShown(toast_id);
#else
    base::test::TestFuture<bool> future;
    chromeos::LacrosService::Get()
        ->GetRemote<crosapi::mojom::TestController>()
        ->IsToastShown(toast_id, future.GetCallback());
    EXPECT_TRUE(future.Wait());
    return future.Get<bool>();
#endif
  }

 protected:
  std::optional<std::string> installed_app_url_;
  web_app::IsolatedWebAppUpdateServerMixin
      isolated_web_app_update_server_mixin_{&mixin_host_};
};

#if BUILDFLAG(IS_CHROMEOS_ASH)

IN_PROC_BROWSER_TEST_P(WebAppsPreventCloseChromeOsBrowserTest, CheckMenuModel) {
  // Set up policy values.
  InstallApp();
  profile()->GetPrefs()->SetList(
      prefs::kWebAppSettings,
      base::Value::List().Append(
          base::Value::Dict()
              .Set(web_app::kManifestId, *installed_app_url_)
              .Set(web_app::kRunOnOsLogin, web_app::kRunWindowed)
              .Set(web_app::kPreventClose, IsPreventCloseEnabled())));

  // Wait until prefs are propagated and App `allow_close` field is updated to
  // expected value.
  const webapps::AppId installed_app_id = GetInstalledAppId();
  apps::AppUpdateWaiter waiter(
      profile(), installed_app_id,
      base::BindRepeating(
          [](bool expected_allow_close, const apps::AppUpdate& update) {
            return update.AllowClose().has_value() &&
                   update.AllowClose().value() == expected_allow_close;
          },
          !IsPreventCloseEnabled()));
  waiter.Await();

  PinAppWithIDToShelf(installed_app_id);

  Browser* const browser = LaunchWebAppBrowser(installed_app_id);
  ASSERT_TRUE(browser);

  ash::ShelfModel* const shelf_model = ash::ShelfModel::Get();
  ASSERT_TRUE(shelf_model);

  ash::ShelfItemDelegate* const delegate =
      shelf_model->GetShelfItemDelegate(ash::ShelfID(installed_app_id));
  ASSERT_TRUE(delegate);

  base::test::TestFuture<std::unique_ptr<ui::SimpleMenuModel>> model_future;
  delegate->GetContextMenu(display::Display::GetDefaultDisplay().id(),
                           model_future.GetCallback());
  std::unique_ptr<ui::SimpleMenuModel> menu_model(model_future.Take());
  ASSERT_TRUE(menu_model);

  // Check close button.
  EXPECT_EQ(HasMenuModelCommandId(menu_model.get(), ash::MENU_CLOSE),
            !IsPreventCloseEnabled());

  // Check new window and new tab buttons.
  EXPECT_EQ(HasMenuModelCommandId(menu_model.get(), ash::LAUNCH_NEW),
            !IsPreventCloseEnabled());

  // Isolated web apps don't support this selection (they are always opened in
  // windows).
  if (GetAppType() != AppType::kIsolatedWebApp) {
    EXPECT_EQ(
        HasMenuModelCommandId(menu_model.get(), ash::USE_LAUNCH_TYPE_REGULAR),
        !IsPreventCloseEnabled());

    EXPECT_EQ(
        HasMenuModelCommandId(menu_model.get(), ash::USE_LAUNCH_TYPE_WINDOW),
        !IsPreventCloseEnabled());
  }
}

#endif  // BUILDFLAG(IS_CHROMEOS_ASH)

IN_PROC_BROWSER_TEST_P(WebAppsPreventCloseChromeOsBrowserTest,
                       CloseTabAttemptShowsToast) {
#if BUILDFLAG(IS_CHROMEOS_LACROS)
  // If ash does not contain the relevant test controller functionality,
  // then there's nothing to do for this test.
  auto* lacros_service = chromeos::LacrosService::Get();
  if (lacros_service->GetInterfaceVersion<crosapi::mojom::TestController>() <
      static_cast<int>(crosapi::mojom::TestController::MethodMinVersions::
                           kIsToastShownMinVersion)) {
    GTEST_SKIP() << "Unsupported ash version for IsToastShown";
  }
#endif  // BUILDFLAG(IS_CHROMEOS_LACROS)

  const webapps::AppId installed_app_id = GetInstalledAppId();
  InstallApp();

  profile()->GetPrefs()->SetList(
      prefs::kWebAppSettings,
      base::Value::List().Append(
          base::Value::Dict()
              .Set(web_app::kManifestId, *installed_app_url_)
              .Set(web_app::kRunOnOsLogin, web_app::kRunWindowed)
              .Set(web_app::kPreventClose, IsPreventCloseEnabled())));

  Browser* const browser = LaunchWebAppBrowserAndWait(installed_app_id);
  ASSERT_TRUE(browser);

  chrome::CloseTab(browser);

  if (IsPreventCloseEnabled()) {
    EXPECT_EQ(1, browser->tab_strip_model()->count());
    EXPECT_TRUE(base::test::RunUntil([&] {
      return IsToastShown(
          base::StrCat({"prevent_close_toast_id-", installed_app_id}));
    }));
  } else {
    EXPECT_EQ(0, browser->tab_strip_model()->count());
  }
}

IN_PROC_BROWSER_TEST_P(WebAppsPreventCloseChromeOsBrowserTest,
                       CloseWindowAttemptShowsToast) {
#if BUILDFLAG(IS_CHROMEOS_LACROS)
  // If ash does not contain the relevant test controller functionality,
  // then there's nothing to do for this test.
  auto* lacros_service = chromeos::LacrosService::Get();
  if (lacros_service->GetInterfaceVersion<crosapi::mojom::TestController>() <
      static_cast<int>(crosapi::mojom::TestController::MethodMinVersions::
                           kIsToastShownMinVersion)) {
    GTEST_SKIP() << "Unsupported ash version for IsToastShown";
  }
#endif  // BUILDFLAG(IS_CHROMEOS_LACROS)

  const webapps::AppId installed_app_id = GetInstalledAppId();
  InstallApp();

  profile()->GetPrefs()->SetList(
      prefs::kWebAppSettings,
      base::Value::List().Append(
          base::Value::Dict()
              .Set(web_app::kManifestId, *installed_app_url_)
              .Set(web_app::kRunOnOsLogin, web_app::kRunWindowed)
              .Set(web_app::kPreventClose, IsPreventCloseEnabled())));

  Browser* const browser = LaunchWebAppBrowserAndWait(installed_app_id);
  ASSERT_TRUE(browser);

  chrome::CloseWindow(browser);

  if (IsPreventCloseEnabled()) {
    EXPECT_EQ(1, browser->tab_strip_model()->count());
    EXPECT_TRUE(base::test::RunUntil([&] {
      return IsToastShown(
          base::StrCat({"prevent_close_toast_id-", installed_app_id}));
    }));
  } else {
    EXPECT_EQ(0, browser->tab_strip_model()->count());
  }
}

INSTANTIATE_TEST_SUITE_P(
    All,
    WebAppsPreventCloseChromeOsBrowserTest,
    testing::Values(
        PreventCloseTestParams{.is_prevent_close_enabled = true,
                               .app_type = AppType::kProgressiveWebApp},
        PreventCloseTestParams{.is_prevent_close_enabled = false,
                               .app_type = AppType::kProgressiveWebApp},
        PreventCloseTestParams{.is_prevent_close_enabled = true,
                               .app_type = AppType::kIsolatedWebApp},
        PreventCloseTestParams{.is_prevent_close_enabled = false,
                               .app_type = AppType::kIsolatedWebApp}));

#if BUILDFLAG(IS_CHROMEOS_ASH)
class IsolatedWebAppChromeOsBrowserTest
    : public web_app::IsolatedWebAppBrowserTestHarness {
 public:
  void SetUp() override {
    app_ = web_app::IsolatedWebAppBuilder(web_app::ManifestBuilder())
               .BuildBundle();
    InProcessBrowserTest::SetUp();
  }

  web_app::ScopedBundledIsolatedWebApp* app() { return app_.get(); }

 private:
  std::unique_ptr<web_app::ScopedBundledIsolatedWebApp> app_;
};

IN_PROC_BROWSER_TEST_F(IsolatedWebAppChromeOsBrowserTest,
                       ContextMenuOnlyHasLaunchNew) {
  app()->TrustSigningKey();
  web_app::IsolatedWebAppUrlInfo url_info =
      app()->InstallChecked(browser()->profile());

  PinAppWithIDToShelf(url_info.app_id());

  ash::ShelfModel* const shelf_model = ash::ShelfModel::Get();
  ASSERT_TRUE(shelf_model);

  ash::ShelfItemDelegate* const delegate =
      shelf_model->GetShelfItemDelegate(ash::ShelfID(url_info.app_id()));
  ASSERT_TRUE(delegate);

  base::test::TestFuture<std::unique_ptr<ui::SimpleMenuModel>> model_future;
  delegate->GetContextMenu(display::Display::GetDefaultDisplay().id(),
                           model_future.GetCallback());
  std::unique_ptr<ui::SimpleMenuModel> menu_model(model_future.Take());
  ASSERT_TRUE(menu_model);

  // Isolated web apps context menu should have an "Open in new Window" command
  // instead of a open mode selector submenu.
  EXPECT_NE(menu_model->GetTypeAt(0), ui::MenuModel::ItemType::TYPE_SUBMENU);
  EXPECT_EQ(menu_model->GetTypeAt(0), ui::MenuModel::ItemType::TYPE_COMMAND);
  EXPECT_EQ(menu_model->GetCommandIdAt(0), ash::LAUNCH_NEW);
}
#endif  // BUILDFLAG(IS_CHROMEOS_ASH)