| // Copyright 2026 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "chrome/browser/skills/skills_ui_window_controller.h" |
| |
| #include "base/test/metrics/histogram_tester.h" |
| #include "base/test/metrics/user_action_tester.h" |
| #include "base/test/test_future.h" |
| #include "chrome/browser/glic/public/glic_enabling.h" |
| #include "chrome/browser/glic/public/glic_invoke_options.h" |
| #include "chrome/browser/signin/identity_manager_factory.h" |
| #include "chrome/browser/skills/skills_ui_tab_controller.h" |
| #include "chrome/browser/ui/browser_window.h" |
| #include "chrome/browser/ui/browser_window/public/browser_window_features.h" |
| #include "chrome/browser/ui/browser_window/public/browser_window_interface.h" |
| #include "chrome/browser/ui/navigator/browser_navigator_params.h" |
| #include "chrome/browser/ui/toasts/api/toast_id.h" |
| #include "chrome/browser/ui/toasts/toast_controller.h" |
| #include "chrome/browser/ui/toasts/toast_view.h" |
| #include "chrome/browser/ui/views/frame/browser_view.h" |
| #include "chrome/browser/ui/webui/constrained_web_dialog_ui.h" |
| #include "chrome/browser/ui/webui/skills/skills_dialog_view.h" |
| #include "chrome/common/webui_url_constants.h" |
| #include "chrome/test/base/in_process_browser_test.h" |
| #include "chrome/test/base/ui_test_utils.h" |
| #include "components/prefs/pref_service.h" |
| #include "components/signin/public/identity_manager/identity_test_utils.h" |
| #include "components/skills/features.h" |
| #include "components/skills/public/skill.h" |
| #include "components/skills/public/skill.mojom.h" |
| #include "components/skills/public/skills_metrics.h" |
| #include "components/skills/public/skills_prefs.h" |
| #include "components/skills/public/skills_service.h" |
| #include "content/public/test/browser_test.h" |
| #include "content/public/test/browser_test_utils.h" |
| #include "content/public/test/test_navigation_observer.h" |
| #include "testing/gtest/include/gtest/gtest.h" |
| #include "ui/base/page_transition_types.h" |
| #include "ui/events/base_event_utils.h" |
| #include "ui/views/controls/button/label_button.h" |
| #include "ui/views/controls/button/md_text_button.h" |
| #include "ui/views/interaction/element_tracker_views.h" |
| #include "ui/views/test/button_test_api.h" |
| |
| namespace skills { |
| |
| class SkillsUiWindowControllerBrowserTest : public InProcessBrowserTest { |
| public: |
| SkillsUiWindowControllerBrowserTest() { |
| feature_list_.InitWithFeatures({features::kSkillsEnabled}, |
| {features::kSkillsWebViewV2Enabled}); |
| } |
| |
| void SetUpOnMainThread() override { |
| InProcessBrowserTest::SetUpOnMainThread(); |
| skills::SkillsService* skills_service = |
| skills::SkillsServiceFactory::GetForProfile(browser()->GetProfile()); |
| ASSERT_TRUE(skills_service); |
| skills_service->SetServiceStatusForTesting( |
| skills::SkillsService::ServiceStatus::kReady); |
| skills_service->AddObserver(&skills_service_observer_); |
| } |
| |
| void TearDownOnMainThread() override { |
| skills::SkillsService* skills_service = |
| skills::SkillsServiceFactory::GetForProfile(browser()->GetProfile()); |
| skills_service->RemoveObserver(&skills_service_observer_); |
| InProcessBrowserTest::TearDownOnMainThread(); |
| } |
| |
| SkillsUiWindowController* window_controller() { |
| return SkillsUiWindowController::From(browser()); |
| } |
| |
| SkillsUiTabController* tab_controller() { |
| auto* tab = browser()->GetActiveTabInterface(); |
| if (!tab) { |
| return nullptr; |
| } |
| return static_cast<SkillsUiTabController*>( |
| SkillsUiTabControllerInterface::From(tab)); |
| } |
| |
| content::WebContents* GetDialogWebContents() { |
| BrowserView* browser_view = |
| BrowserView::GetBrowserViewForBrowser(browser()); |
| ui::ElementContext context = |
| views::ElementTrackerViews::GetContextForWidget( |
| browser_view->GetWidget()); |
| |
| auto* view = views::ElementTrackerViews::GetInstance()->GetUniqueView( |
| SkillsDialogView::kSkillsDialogElementId, context); |
| |
| if (!view) { |
| return nullptr; |
| } |
| return static_cast<views::WebView*>(view)->GetWebContents(); |
| } |
| |
| void ClickToastActionButton() { |
| auto* toast_controller = ToastController::From(browser()); |
| ASSERT_TRUE(toast_controller->IsShowingToast()); |
| auto* toast_view = toast_controller->GetToastViewForTesting(); |
| ASSERT_TRUE(toast_view); |
| auto* button = toast_view->action_button_for_testing(); |
| ASSERT_TRUE(button); |
| |
| views::test::ButtonTestApi(button).NotifyClick( |
| ui::MouseEvent(::ui::EventType::kMouseReleased, gfx::Point(), |
| gfx::Point(), ui::EventTimeForNow(), 0, 0)); |
| } |
| |
| class TestObserver : public skills::SkillsService::Observer { |
| public: |
| void OnTemporarySkillDisplay( |
| std::string_view skill_id, |
| skills::SkillsService::DisplayState display_state) override { |
| last_temporarily_displayed_skill_id_ = std::string(skill_id); |
| last_display_state_ = display_state; |
| } |
| |
| std::string last_temporarily_displayed_skill_id_; |
| skills::SkillsService::DisplayState last_display_state_; |
| }; |
| |
| protected: |
| TestObserver skills_service_observer_; |
| base::HistogramTester histogram_tester_; |
| base::UserActionTester user_action_tester_; |
| |
| private: |
| base::test::ScopedFeatureList feature_list_; |
| }; |
| |
| IN_PROC_BROWSER_TEST_F(SkillsUiWindowControllerBrowserTest, |
| OnSkillSavedShowToast) { |
| // Ensure no toast is initially showing. |
| const auto* toast_controller = ToastController::From(browser()); |
| EXPECT_FALSE(toast_controller->IsShowingToast()); |
| |
| // Call OnSkillSaved with an empty skill ID. |
| window_controller()->OnSkillSaved(""); |
| |
| // Verify that the toast is now showing. |
| EXPECT_TRUE(toast_controller->IsShowingToast()); |
| EXPECT_EQ(toast_controller->GetCurrentToastId(), ToastId::kSkillSaved); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(SkillsUiWindowControllerBrowserTest, |
| OnSkillDeletedShowsToastAndTemporarilyDeletesSkill) { |
| skills::SkillsService* skills_service = |
| skills::SkillsServiceFactory::GetForProfile(browser()->GetProfile()); |
| const skills::Skill* skill = skills_service->AddSkill( |
| /*source_skill_id=*/"", "Test Skill", "test-icon", "Test Prompt"); |
| |
| const auto* toast_controller = ToastController::From(browser()); |
| EXPECT_FALSE(toast_controller->IsShowingToast()); |
| |
| window_controller()->OnSkillDeleted(skill->id); |
| |
| // Verify that the toast is now showing. |
| EXPECT_TRUE(toast_controller->IsShowingToast()); |
| EXPECT_EQ(toast_controller->GetCurrentToastId(), ToastId::kSkillDeleted); |
| |
| EXPECT_EQ(skills_service_observer_.last_temporarily_displayed_skill_id_, |
| skill->id); |
| EXPECT_EQ(skills_service_observer_.last_display_state_, |
| skills::SkillsService::DisplayState::kDeleted); |
| |
| // Still exists since it's just temporarily deleted. |
| EXPECT_NE(skills_service->GetSkillById(skill->id), nullptr); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(SkillsUiWindowControllerBrowserTest, |
| UndoLastSkillRemovalReshowsSkill) { |
| skills::SkillsService* skills_service = |
| skills::SkillsServiceFactory::GetForProfile(browser()->GetProfile()); |
| const skills::Skill* skill = skills_service->AddSkill( |
| /*source_skill_id=*/"", "Test Skill", "test-icon", "Test Prompt"); |
| |
| window_controller()->OnSkillDeleted(skill->id); |
| EXPECT_EQ(skills_service_observer_.last_display_state_, |
| skills::SkillsService::DisplayState::kDeleted); |
| |
| window_controller()->UndoLastSkillRemoval(); |
| |
| EXPECT_EQ(skills_service_observer_.last_temporarily_displayed_skill_id_, |
| skill->id); |
| EXPECT_EQ(skills_service_observer_.last_display_state_, |
| skills::SkillsService::DisplayState::kReshown); |
| |
| // Verify that it still exists in the service. |
| EXPECT_NE(skills_service->GetSkillById(skill->id), nullptr); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(SkillsUiWindowControllerBrowserTest, |
| ToastCloseDeletesSkill) { |
| skills::SkillsService* skills_service = |
| skills::SkillsServiceFactory::GetForProfile(browser()->GetProfile()); |
| const skills::Skill* skill = skills_service->AddSkill( |
| /*source_skill_id=*/"", "Test Skill", "test-icon", "Test Prompt"); |
| |
| std::string skill_id = skill->id; |
| window_controller()->OnSkillDeleted(skill_id); |
| |
| auto* toast_controller = ToastController::From(browser()); |
| EXPECT_TRUE(toast_controller->IsShowingToast()); |
| |
| // Close the toast widget directly to simulate it being dismissed. |
| views::Widget* toast_widget = toast_controller->GetToastWidgetForTesting(); |
| ASSERT_TRUE(toast_widget); |
| toast_widget->CloseNow(); |
| |
| // Toast should not be visible anymore. |
| EXPECT_FALSE(toast_controller->IsShowingToast()); |
| |
| // Verify that it was actually deleted from the service. |
| EXPECT_EQ(skills_service->GetSkillById(skill_id), nullptr); |
| histogram_tester_.ExpectUniqueSample( |
| "Toast.TriggeredToShow", static_cast<int>(ToastId::kSkillDeleted), 1); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(SkillsUiWindowControllerBrowserTest, |
| InvokeLastSavedSkillRoutesToActiveTab) { |
| const std::string kSkillId = "skill-123"; |
| // Save skill on active tab. |
| window_controller()->OnSkillSaved(kSkillId); |
| // Verify Toast is visible |
| EXPECT_TRUE(ToastController::From(browser())->IsShowingToast()); |
| // Enable Glic late to avoid a crash in GlicTabIndicatorHelper during tab |
| // creation. |
| glic::GlicEnabling::ScopedBypassEnablementChecksForTesting scoped_glic_bypass; |
| |
| // Click toast "Try It". |
| ClickToastActionButton(); |
| // Verify Result |
| EXPECT_EQ(tab_controller()->GetLastInvokedSkillIdForTesting(), kSkillId); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(SkillsUiWindowControllerBrowserTest, |
| OnSkillSavedFromSkillsPage) { |
| NavigateParams params(browser(), GURL(chrome::kChromeUISkillsURL), |
| ui::PAGE_TRANSITION_TYPED); |
| ui_test_utils::NavigateToURL(¶ms); |
| const auto* toast_controller = ToastController::From(browser()); |
| EXPECT_FALSE(toast_controller->IsShowingToast()); |
| tab_controller()->OnSkillSaved(""); |
| EXPECT_TRUE(toast_controller->IsShowingToast()); |
| EXPECT_EQ(toast_controller->GetCurrentToastId(), |
| ToastId::kSkillSavedWithoutInvokeButton); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(SkillsUiWindowControllerBrowserTest, |
| UserFlow_CreateSkill_ThenInvoke) { |
| // Enable Glic late to avoid a crash in GlicTabIndicatorHelper during tab |
| // creation. |
| glic::GlicEnabling::ScopedBypassEnablementChecksForTesting scoped_glic_bypass; |
| |
| // Open Dialog. |
| skills::Skill initial_skill(/*id=*/"", |
| /*name=*/"", |
| /*icon=*/"", "Skill Prompt"); |
| tab_controller()->ShowDialog(std::move(initial_skill), |
| SkillsDialogEntryPoint::kWebClientPrefilled, |
| mojom::SkillsDialogType::kAdd, nullptr); |
| |
| // Get WebContents to inject JS. |
| content::WebContents* web_contents = GetDialogWebContents(); |
| ASSERT_TRUE(web_contents); |
| ASSERT_TRUE(content::WaitForLoadStop(web_contents)); |
| |
| // Setup Listener for "Dialog Closed". |
| base::test::TestFuture<void> close_future; |
| tab_controller()->SetOnDialogClosedCallbackForTesting( |
| close_future.GetCallback()); |
| |
| static constexpr char kSaveScript[] = R"( |
| (async () => { |
| const root = document.querySelector('skills-dialog-app').shadowRoot; |
| |
| for (let i = 0; i < 50; i++) { |
| const btn = root.querySelector('#saveButton'); |
| if (btn && !btn.disabled) { |
| setTimeout(() => btn.click(), 0); |
| return 'CLICKED'; |
| } |
| |
| // Fill inputs if found & empty |
| let el = root.querySelector('#nameText'); |
| if (el && !el.value) { |
| el.value = 'Test'; |
| el.dispatchEvent(new CustomEvent('value-changed', { |
| bubbles: true, |
| composed: true, |
| detail: { value: 'Test' } |
| })); |
| } |
| el = root.querySelector('#instructionsText'); |
| if (el && !el.value) { |
| el.value = 'Test'; |
| el.dispatchEvent(new Event('input', { |
| bubbles: true, |
| composed: true |
| })); |
| } |
| await new Promise(r => setTimeout(r, 100)); |
| } |
| return 'TIMEOUT'; |
| })(); |
| )"; |
| |
| EXPECT_EQ("CLICKED", content::EvalJs(web_contents, kSaveScript)); |
| |
| // Wait for the C++ backend to process the save and close the dialog. |
| ASSERT_TRUE(close_future.Wait()); |
| EXPECT_EQ(nullptr, GetDialogWebContents()); |
| |
| // Click the Toast "Try It" button. |
| ClickToastActionButton(); |
| |
| // Verify the Invoke happened by checking that some ID is pending (since the |
| // ID was auto-generated by the service) |
| EXPECT_FALSE(tab_controller()->GetLastInvokedSkillIdForTesting().empty()); |
| |
| histogram_tester_.ExpectBucketCount( |
| "Toast.TriggeredToShow", static_cast<int>(ToastId::kSkillSaved), 1); |
| histogram_tester_.ExpectUniqueSample("Toast.SkillSaved.Dismissed", |
| 1, // kActionButton |
| 1); |
| EXPECT_EQ(user_action_tester_.GetActionCount( |
| "Toast.ActionButtonClicked.SkillSaved"), |
| 1); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(SkillsUiWindowControllerBrowserTest, |
| StoreLastSavedSkillMetadataAndInvoke) { |
| const std::string kSkillId = "skill-123"; |
| const std::string kSkillName = "Skill Name"; |
| const std::string kSkillIcon = "Skill Icon"; |
| |
| window_controller()->StoreLastSavedSkillMetadata(kSkillId, kSkillName, |
| kSkillIcon); |
| window_controller()->ShowToast(ToastId::kSkillSaved); |
| EXPECT_TRUE(ToastController::From(browser())->IsShowingToast()); |
| |
| glic::GlicEnabling::ScopedBypassEnablementChecksForTesting scoped_glic_bypass; |
| |
| // Click toast action button ("Try It"). |
| ClickToastActionButton(); |
| |
| // Verify that the skill was invoked with name and icon parameters. |
| EXPECT_EQ(tab_controller()->GetLastInvokedSkillIdForTesting(), kSkillId); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(SkillsUiWindowControllerBrowserTest, |
| UserFlow_CreateSkill_DisableSkills_ThenInvoke) { |
| // Enable Glic late to avoid a crash in GlicTabIndicatorHelper during tab |
| // creation. |
| glic::GlicEnabling::ScopedBypassEnablementChecksForTesting scoped_glic_bypass; |
| |
| // Open Dialog. |
| skills::Skill initial_skill(/*id=*/"", |
| /*name=*/"", |
| /*icon=*/"", "Skill Prompt"); |
| tab_controller()->ShowDialog(std::move(initial_skill), |
| SkillsDialogEntryPoint::kWebClientPrefilled, |
| mojom::SkillsDialogType::kAdd, nullptr); |
| |
| // Get WebContents to inject JS. |
| content::WebContents* web_contents = GetDialogWebContents(); |
| ASSERT_TRUE(web_contents); |
| ASSERT_TRUE(content::WaitForLoadStop(web_contents)); |
| |
| // Setup Listener for "Dialog Closed". |
| base::test::TestFuture<void> close_future; |
| tab_controller()->SetOnDialogClosedCallbackForTesting( |
| close_future.GetCallback()); |
| |
| static constexpr char kSaveScript[] = R"( |
| (async () => { |
| const root = document.querySelector('skills-dialog-app').shadowRoot; |
| |
| for (let i = 0; i < 50; i++) { |
| const btn = root.querySelector('#saveButton'); |
| if (btn && !btn.disabled) { |
| setTimeout(() => btn.click(), 0); |
| return 'CLICKED'; |
| } |
| |
| // Fill inputs if found & empty |
| let el = root.querySelector('#nameText'); |
| if (el && !el.value) { |
| el.value = 'Test'; |
| el.dispatchEvent(new CustomEvent('value-changed', { |
| bubbles: true, |
| composed: true, |
| detail: { value: 'Test' } |
| })); |
| } |
| el = root.querySelector('#instructionsText'); |
| if (el && !el.value) { |
| el.value = 'Test'; |
| el.dispatchEvent(new Event('input', { |
| bubbles: true, |
| composed: true |
| })); |
| } |
| await new Promise(r => setTimeout(r, 100)); |
| } |
| return 'TIMEOUT'; |
| })(); |
| )"; |
| |
| EXPECT_EQ("CLICKED", content::EvalJs(web_contents, kSaveScript)); |
| |
| // Wait for the C++ backend to process the save and close the dialog. |
| ASSERT_TRUE(close_future.Wait()); |
| EXPECT_EQ(nullptr, GetDialogWebContents()); |
| |
| // Now disable skills dynamically! |
| browser()->GetProfile()->GetPrefs()->SetBoolean( |
| skills::prefs::kChromeSkillsEnabled, false); |
| |
| // Click the Toast "Try It" button. |
| ClickToastActionButton(); |
| |
| // Verify that nothing was invoked because skills are disabled. |
| EXPECT_TRUE(tab_controller()->GetLastInvokedSkillIdForTesting().empty()); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(SkillsUiWindowControllerBrowserTest, |
| PrefChange_ClosesDialogAndReloadsPage) { |
| // 1. Open chrome://skills page in the active tab. |
| ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), |
| GURL(chrome::kChromeUISkillsURL))); |
| content::WebContents* web_contents = |
| browser()->GetActiveTabInterface()->GetContents(); |
| ASSERT_TRUE(web_contents); |
| |
| // 2. Open a skills dialog on the active tab. |
| skills::Skill initial_skill(/*id=*/"", /*name=*/"", /*icon=*/"", |
| /*prompt=*/"Test Prompt"); |
| tab_controller()->ShowDialog(std::move(initial_skill), |
| SkillsDialogEntryPoint::kWebClientPrefilled, |
| mojom::SkillsDialogType::kAdd, nullptr); |
| EXPECT_TRUE(tab_controller()->IsShowing()); |
| |
| // 3. Flip the skills enabled pref to false and verify dialog closes and page |
| // reloads. |
| content::TestNavigationObserver reload_observer(web_contents); |
| browser()->GetProfile()->GetPrefs()->SetBoolean( |
| skills::prefs::kChromeSkillsEnabled, false); |
| reload_observer.Wait(); |
| EXPECT_FALSE(tab_controller()->IsShowing()); |
| EXPECT_TRUE(reload_observer.last_navigation_succeeded()); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(SkillsUiWindowControllerBrowserTest, |
| PrimaryAccountChanged_ClosesDialogAndReloadsPage) { |
| signin::IdentityManager* identity_manager = |
| IdentityManagerFactory::GetForProfile(browser()->GetProfile()); |
| ASSERT_TRUE(identity_manager); |
| |
| // 1. Open chrome://skills page in the active tab. |
| ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), |
| GURL(chrome::kChromeUISkillsURL))); |
| content::WebContents* web_contents = |
| browser()->GetActiveTabInterface()->GetContents(); |
| ASSERT_TRUE(web_contents); |
| |
| // 2. Open a skills dialog on the active tab. |
| skills::Skill initial_skill(/*id=*/"", /*name=*/"", /*icon=*/"", |
| /*prompt=*/"Test Prompt"); |
| tab_controller()->ShowDialog(std::move(initial_skill), |
| SkillsDialogEntryPoint::kWebClientPrefilled, |
| mojom::SkillsDialogType::kAdd, nullptr); |
| EXPECT_TRUE(tab_controller()->IsShowing()); |
| |
| // 3. User signs in, which fires OnPrimaryAccountChanged. |
| content::TestNavigationObserver reload_observer(web_contents); |
| signin::MakePrimaryAccountAvailable(identity_manager, "test@example.com", |
| signin::ConsentLevel::kSignin); |
| reload_observer.Wait(); |
| EXPECT_FALSE(tab_controller()->IsShowing()); |
| EXPECT_TRUE(reload_observer.last_navigation_succeeded()); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(SkillsUiWindowControllerBrowserTest, |
| AccountPaused_ClosesDialogAndReloadsPage) { |
| signin::IdentityManager* identity_manager = |
| IdentityManagerFactory::GetForProfile(browser()->GetProfile()); |
| ASSERT_TRUE(identity_manager); |
| |
| AccountInfo account_info = signin::MakePrimaryAccountAvailable( |
| identity_manager, "test@example.com", signin::ConsentLevel::kSignin); |
| |
| // 1. Open chrome://skills page in the active tab. |
| ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), |
| GURL(chrome::kChromeUISkillsURL))); |
| content::WebContents* web_contents = |
| browser()->GetActiveTabInterface()->GetContents(); |
| ASSERT_TRUE(web_contents); |
| |
| // 2. Open a skills dialog on the active tab. |
| skills::Skill initial_skill(/*id=*/"", /*name=*/"", /*icon=*/"", |
| /*prompt=*/"Test Prompt"); |
| tab_controller()->ShowDialog(std::move(initial_skill), |
| SkillsDialogEntryPoint::kWebClientPrefilled, |
| mojom::SkillsDialogType::kAdd, nullptr); |
| EXPECT_TRUE(tab_controller()->IsShowing()); |
| |
| // 3. Invalidate refresh token for the account (account paused), which fires |
| // OnErrorStateOfRefreshTokenUpdatedForAccount. |
| content::TestNavigationObserver reload_observer(web_contents); |
| signin::SetInvalidRefreshTokenForAccount(identity_manager, |
| account_info.GetAccountId()); |
| reload_observer.Wait(); |
| EXPECT_FALSE(tab_controller()->IsShowing()); |
| EXPECT_TRUE(reload_observer.last_navigation_succeeded()); |
| } |
| |
| } // namespace skills |