| // Copyright 2025 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #import "ios/chrome/browser/omnibox/model/omnibox_autocomplete_controller.h" |
| |
| #import "base/functional/callback.h" |
| #import "base/run_loop.h" |
| #import "base/test/metrics/histogram_tester.h" |
| #import "base/test/task_environment.h" |
| #import "base/time/time.h" |
| #import "components/omnibox/browser/autocomplete_classifier.h" |
| #import "components/omnibox/browser/autocomplete_controller.h" |
| #import "components/omnibox/browser/autocomplete_match.h" |
| #import "components/omnibox/browser/autocomplete_match_test_util.h" |
| #import "components/omnibox/browser/autocomplete_result.h" |
| #import "components/omnibox/browser/fake_autocomplete_provider_client.h" |
| #import "components/omnibox/browser/omnibox_client.h" |
| #import "components/omnibox/browser/omnibox_popup_selection.h" |
| #import "components/omnibox/browser/search_provider.h" |
| #import "components/omnibox/browser/test_omnibox_client.h" |
| #import "components/open_from_clipboard/fake_clipboard_recent_content.h" |
| #import "components/prefs/testing_pref_service.h" |
| #import "ios/chrome/browser/omnibox/model/omnibox_autocomplete_controller+Testing.h" |
| #import "ios/chrome/browser/omnibox/model/omnibox_autocomplete_controller_delegate.h" |
| #import "ios/chrome/browser/omnibox/model/omnibox_metrics_recorder.h" |
| #import "ios/chrome/browser/omnibox/model/omnibox_text_model.h" |
| #import "ios/chrome/browser/shared/model/prefs/browser_prefs.h" |
| #import "ios/chrome/browser/shared/model/prefs/pref_names.h" |
| #import "ios/chrome/test/testing_application_context.h" |
| #import "net/base/apple/url_conversions.h" |
| #import "testing/gmock/include/gmock/gmock.h" |
| #import "testing/gtest/include/gtest/gtest.h" |
| #import "testing/gtest_mac.h" |
| #import "testing/platform_test.h" |
| #import "third_party/ocmock/OCMock/OCMock.h" |
| #import "third_party/ocmock/gtest_support.h" |
| #import "ui/gfx/image/image.h" |
| #import "ui/gfx/image/image_skia.h" |
| #import "ui/gfx/image/image_unittest_util.h" |
| |
| using testing::_; |
| using testing::AtMost; |
| using testing::SaveArg; |
| |
| namespace { |
| |
| // A mock class for the AutocompleteController. |
| class MockAutocompleteController : public AutocompleteController { |
| public: |
| MockAutocompleteController() |
| : AutocompleteController( |
| std::make_unique<FakeAutocompleteProviderClient>(), |
| AutocompleteClassifier::DefaultOmniboxProviders()) {} |
| MockAutocompleteController(const MockAutocompleteController&) = delete; |
| MockAutocompleteController& operator=(const MockAutocompleteController&) = |
| delete; |
| ~MockAutocompleteController() override = default; |
| |
| void SetAutocompleteMatches(const ACMatches& matches) { |
| AutocompleteResult& results = |
| const_cast<AutocompleteResult&>(this->result()); |
| results.ClearMatches(); |
| results.AppendMatches(matches); |
| } |
| |
| void SetSteadyStateOmniboxPosition( |
| metrics::OmniboxEventProto::OmniboxPosition position) override { |
| omnibox_position = position; |
| } |
| |
| MOCK_METHOD(void, |
| GroupSuggestionsBySearchVsURL, |
| (size_t begin, size_t end), |
| (override)); |
| |
| metrics::OmniboxEventProto::OmniboxPosition omnibox_position; |
| }; |
| |
| } // namespace |
| |
| @interface TestOmniboxAutocompleteController : OmniboxAutocompleteController |
| @property(nonatomic, assign) NSUInteger lastOpenedSelectionLineIndex; |
| @property(nonatomic, assign) base::RepeatingClosure openSelectionClosure; |
| @end |
| |
| @implementation TestOmniboxAutocompleteController |
| |
| - (void)openSelection:(OmniboxPopupSelection)selection |
| timestamp:(base::TimeTicks)timestamp |
| disposition:(WindowOpenDisposition)disposition { |
| _lastOpenedSelectionLineIndex = selection.line; |
| if (_openSelectionClosure) { |
| _openSelectionClosure.Run(); |
| _openSelectionClosure.Reset(); |
| } |
| } |
| @end |
| |
| class OmniboxAutocompleteControllerTest : public PlatformTest { |
| public: |
| OmniboxAutocompleteControllerTest() { |
| auto clipboard = std::make_unique<FakeClipboardRecentContent>(); |
| clipboard_ = clipboard.get(); |
| ClipboardRecentContent::SetInstance(std::move(clipboard)); |
| |
| local_state_ = std::make_unique<TestingPrefServiceSimple>(); |
| RegisterLocalStatePrefs(local_state_->registry()); |
| TestingApplicationContext::GetGlobal()->SetLocalState(local_state_.get()); |
| |
| omnibox_client_ = std::make_unique<TestOmniboxClient>(); |
| |
| auto autocomplete = std::make_unique<MockAutocompleteController>(); |
| autocomplete_controller_ = autocomplete.get(); |
| |
| omnibox_text_model_ = |
| std::make_unique<OmniboxTextModel>(omnibox_client_.get()); |
| |
| controller_delegate_ = |
| OCMProtocolMock(@protocol(OmniboxAutocompleteControllerDelegate)); |
| |
| controller_ = [[TestOmniboxAutocompleteController alloc] |
| initWithOmniboxClient:omnibox_client_.get() |
| omniboxTextModel:omnibox_text_model_.get()]; |
| controller_.delegate = controller_delegate_; |
| [controller_ setAutocompleteController:std::move(autocomplete)]; |
| |
| omnibox_metrics_recorder_ = [[OmniboxMetricsRecorder alloc] |
| initWithClient:omnibox_client_.get() |
| textModel:omnibox_text_model_.get()]; |
| [omnibox_metrics_recorder_ |
| setAutocompleteController:controller_.autocompleteController]; |
| controller_.omniboxMetricsRecorder = omnibox_metrics_recorder_; |
| } |
| |
| ~OmniboxAutocompleteControllerTest() override { |
| [controller_ disconnect]; |
| clipboard_ = nullptr; |
| autocomplete_controller_ = nullptr; |
| omnibox_client_ = nullptr; |
| omnibox_text_model_ = nullptr; |
| controller_delegate_ = nil; |
| TestingApplicationContext::GetGlobal()->SetLocalState(nullptr); |
| local_state_.reset(); |
| [omnibox_metrics_recorder_ disconnect]; |
| omnibox_metrics_recorder_ = nil; |
| } |
| |
| ACMatches SampleMatches() const { |
| return {CreateSearchMatch(u"Clear History"), CreateSearchMatch(u"search 1"), |
| CreateSearchMatch(u"search 2"), |
| CreateHistoryURLMatch( |
| /*destination_url=*/"http://this-site-matches.com")}; |
| } |
| |
| /// Returns the match opened by OmniboxEditModel::OpenSelection. |
| const AutocompleteMatch& LastOpenedMatch() { |
| return autocomplete_controller_->result().match_at( |
| controller_.lastOpenedSelectionLineIndex); |
| } |
| |
| /// Simulates opening `url_text` from the text controller. |
| void OpenUrlFromEditBox(const std::u16string url_text, |
| bool is_autocompleted) { |
| AutocompleteMatch match(autocomplete_controller_->search_provider(), 0, |
| false, AutocompleteMatchType::OPEN_TAB); |
| match.destination_url = GURL(url_text); |
| match.allowed_to_be_default_match = true; |
| if (is_autocompleted) { |
| match.inline_autocompletion = url_text; |
| } else { |
| omnibox_text_model_->SetInputInProgressNoNotify(YES); |
| omnibox_text_model_->UpdateUserText(url_text); |
| } |
| omnibox_text_model_->OnSetFocus(); |
| [controller_ openMatch:match |
| popupSelection:OmniboxPopupSelection(0) |
| windowOpenDisposition:WindowOpenDisposition::CURRENT_TAB |
| alternateNavURL:GURL() |
| pastedText:u"" |
| matchSelectionTimestamp:base::TimeTicks()]; |
| } |
| |
| protected: |
| // Message loop for the main test thread. |
| base::test::TaskEnvironment environment_; |
| // Application pref service. |
| std::unique_ptr<TestingPrefServiceSimple> local_state_; |
| TestOmniboxAutocompleteController* controller_; |
| raw_ptr<MockAutocompleteController> autocomplete_controller_; |
| std::unique_ptr<TestOmniboxClient> omnibox_client_; |
| raw_ptr<FakeClipboardRecentContent> clipboard_; |
| std::unique_ptr<OmniboxTextModel> omnibox_text_model_; |
| OmniboxMetricsRecorder* omnibox_metrics_recorder_; |
| id controller_delegate_; |
| }; |
| |
| // Custom matcher for AutocompleteMatch |
| MATCHER_P(IsSameAsMatch, expected, "") { |
| return arg.destination_url == expected.destination_url && |
| arg.fill_into_edit == expected.fill_into_edit && |
| arg.additional_text == expected.additional_text && |
| arg.inline_autocompletion == expected.inline_autocompletion && |
| arg.contents == expected.contents && |
| arg.description == expected.description; |
| } |
| |
| // Tests that adding fake matches adds them to the results. |
| TEST_F(OmniboxAutocompleteControllerTest, AddFakeMatches) { |
| ACMatches sample_matches = SampleMatches(); |
| autocomplete_controller_->SetAutocompleteMatches(sample_matches); |
| EXPECT_EQ([controller_ autocompleteController]->result().size(), |
| sample_matches.size()); |
| } |
| |
| #pragma mark - Request suggestion |
| |
| // Tests requesting result when there are none still calls |
| // the delegate to update the suggestions groups. |
| TEST_F(OmniboxAutocompleteControllerTest, RequestResultEmpty) { |
| OCMExpect([controller_delegate_ omniboxAutocompleteController:[OCMArg any] |
| didUpdateSuggestionsGroups:[OCMArg any]]); |
| [controller_ requestSuggestionsWithVisibleSuggestionCount:0]; |
| |
| EXPECT_OCMOCK_VERIFY(controller_delegate_); |
| } |
| |
| // Tests requesting result with all of them visible. |
| TEST_F(OmniboxAutocompleteControllerTest, RequestResultsAllVisible) { |
| autocomplete_controller_->SetAutocompleteMatches(SampleMatches()); |
| |
| // Expect one group of suggestions. |
| EXPECT_CALL(*autocomplete_controller_, |
| GroupSuggestionsBySearchVsURL( |
| 1, [controller_ autocompleteController]->result().size())); |
| |
| OCMExpect([controller_delegate_ omniboxAutocompleteController:[OCMArg any] |
| didUpdateSuggestionsGroups:[OCMArg any]]); |
| |
| // Request results with everything visible. |
| [controller_ requestSuggestionsWithVisibleSuggestionCount:0]; |
| |
| EXPECT_OCMOCK_VERIFY(controller_delegate_); |
| } |
| |
| // Tests requesting result with more suggestions visible than available. |
| TEST_F(OmniboxAutocompleteControllerTest, RequestResultVisibleOverflow) { |
| autocomplete_controller_->SetAutocompleteMatches(SampleMatches()); |
| |
| // Expect one group of suggestions. |
| EXPECT_CALL(*autocomplete_controller_, |
| GroupSuggestionsBySearchVsURL( |
| 1, [controller_ autocompleteController]->result().size())); |
| |
| OCMExpect([controller_delegate_ omniboxAutocompleteController:[OCMArg any] |
| didUpdateSuggestionsGroups:[OCMArg any]]); |
| |
| // Request results with more visible than available. |
| [controller_ requestSuggestionsWithVisibleSuggestionCount:100]; |
| |
| EXPECT_OCMOCK_VERIFY(controller_delegate_); |
| } |
| |
| // Tests requesting result with part of them visible. |
| TEST_F(OmniboxAutocompleteControllerTest, RequestResultPartVisible) { |
| autocomplete_controller_->SetAutocompleteMatches(SampleMatches()); |
| |
| size_t result_size = [controller_ autocompleteController]->result().size(); |
| size_t visible_count = 2; |
| EXPECT_LT(visible_count, result_size); |
| |
| // Expect a first group of visible suggestions. |
| EXPECT_CALL(*autocomplete_controller_, |
| GroupSuggestionsBySearchVsURL(1, visible_count)); |
| |
| // Expect a second group of hidden suggestions. |
| EXPECT_CALL(*autocomplete_controller_, |
| GroupSuggestionsBySearchVsURL(visible_count, result_size)); |
| |
| OCMExpect([controller_delegate_ omniboxAutocompleteController:[OCMArg any] |
| didUpdateSuggestionsGroups:[OCMArg any]]); |
| |
| // Request results with everything visible. |
| [controller_ requestSuggestionsWithVisibleSuggestionCount:visible_count]; |
| |
| EXPECT_OCMOCK_VERIFY(controller_delegate_); |
| } |
| |
| #pragma mark - Logging |
| |
| // Tests that omnibox position update is forwarded to autocompleteController. |
| TEST_F(OmniboxAutocompleteControllerTest, OmniboxPositionUpdates) { |
| local_state_->SetBoolean(prefs::kBottomOmnibox, true); |
| EXPECT_EQ(autocomplete_controller_->omnibox_position, |
| metrics::OmniboxEventProto::BOTTOM_POSITION); |
| |
| local_state_->SetBoolean(prefs::kBottomOmnibox, false); |
| EXPECT_EQ(autocomplete_controller_->omnibox_position, |
| metrics::OmniboxEventProto::TOP_POSITION); |
| } |
| |
| #pragma mark - Open match |
| |
| // Tests opening a match that doesn't exist in autocomplete controller. |
| TEST_F(OmniboxAutocompleteControllerTest, OpenCreatedMatch) { |
| autocomplete_controller_->SetAutocompleteMatches(SampleMatches()); |
| AutocompleteMatch match = CreateSearchMatch(u"some match"); |
| |
| // Open match that doesn't come from the autocomplete controller. Row is |
| // higher than autocomplete_controller_->result().size(). |
| [controller_ selectMatchForOpening:match |
| inRow:10 |
| openIn:WindowOpenDisposition::CURRENT_TAB]; |
| |
| // Expect the match to be opened. |
| EXPECT_THAT(LastOpenedMatch(), IsSameAsMatch(match)); |
| |
| // Reset the last opened selection. |
| controller_.lastOpenedSelectionLineIndex = |
| OmniboxPopupSelection(UINT_MAX).line; |
| |
| // Open match that doesn't come from the autocomplete controller. Row is |
| // smaller than autocomplete_controller_->result().size(). |
| [controller_ selectMatchForOpening:match |
| inRow:1 |
| openIn:WindowOpenDisposition::CURRENT_TAB]; |
| // Expect the match to be opened. |
| EXPECT_THAT(LastOpenedMatch(), IsSameAsMatch(match)); |
| } |
| |
| // Tests opening a clipboard URL match. |
| TEST_F(OmniboxAutocompleteControllerTest, OpenClipboardURLMatch) { |
| // Create an empty clipboard match in autocompleteController. |
| AutocompleteMatch clipboard_match = CreateAutocompleteMatch( |
| "Clipboard match", AutocompleteMatchType::CLIPBOARD_URL, false, false, |
| 100, std::nullopt); |
| clipboard_match.destination_url = GURL(); |
| autocomplete_controller_->SetAutocompleteMatches({clipboard_match}); |
| |
| // Set the clipboard content. |
| GURL pasteboard_url = GURL("https://chromium.org"); |
| clipboard_->SetClipboardURL(pasteboard_url, base::TimeDelta::Min()); |
| |
| // Open the clipboard match. |
| [controller_ selectMatchForOpening:clipboard_match |
| inRow:0 |
| openIn:WindowOpenDisposition::CURRENT_TAB]; |
| |
| // Expect the clipboard content to be loaded. |
| EXPECT_EQ(LastOpenedMatch().destination_url, pasteboard_url); |
| } |
| |
| // Tests opening a clipboard Text match. |
| TEST_F(OmniboxAutocompleteControllerTest, OpenClipboardTextMatch) { |
| // Create an empty clipboard match in autocompleteController. |
| AutocompleteMatch clipboard_match = CreateAutocompleteMatch( |
| "Clipboard text match", AutocompleteMatchType::CLIPBOARD_TEXT, false, |
| false, 100, std::nullopt); |
| clipboard_match.destination_url = GURL(); |
| autocomplete_controller_->SetAutocompleteMatches({clipboard_match}); |
| |
| // Set the clipboard content. |
| std::u16string pasteboard_text = u"search terms"; |
| clipboard_->SetClipboardText(pasteboard_text, base::TimeDelta::Min()); |
| |
| // Open the clipboard match. |
| [controller_ selectMatchForOpening:clipboard_match |
| inRow:0 |
| openIn:WindowOpenDisposition::CURRENT_TAB]; |
| |
| // Expect the clipboard content to be loaded. |
| EXPECT_EQ(LastOpenedMatch().fill_into_edit, pasteboard_text); |
| } |
| |
| // Tests opening a clipboard Image match. |
| TEST_F(OmniboxAutocompleteControllerTest, OpenClipboardImageMatch) { |
| // Create an empty clipboard match in autocompleteController. |
| AutocompleteMatch clipboard_match = CreateAutocompleteMatch( |
| "Clipboard image match", AutocompleteMatchType::CLIPBOARD_IMAGE, false, |
| false, 100, std::nullopt); |
| clipboard_match.destination_url = GURL(); |
| autocomplete_controller_->SetAutocompleteMatches({clipboard_match}); |
| |
| // Set the clipboard content. |
| gfx::Image pasteboard_image = |
| gfx::test::CreateImage(/*width=*/10, /*height=*/10); |
| clipboard_->SetClipboardImage(pasteboard_image, base::TimeDelta::Min()); |
| |
| // Setup the OpenSelection waiter. |
| base::RunLoop open_selection_waiter; |
| controller_.openSelectionClosure = open_selection_waiter.QuitClosure(); |
| |
| // Open the clipboard match. |
| [controller_ selectMatchForOpening:clipboard_match |
| inRow:0 |
| openIn:WindowOpenDisposition::CURRENT_TAB]; |
| |
| // Wait for the image match. |
| open_selection_waiter.Run(); |
| |
| // Expect the clipboard content to be loaded. |
| EXPECT_EQ(LastOpenedMatch().type, AutocompleteMatchType::CLIPBOARD_IMAGE); |
| EXPECT_FALSE(LastOpenedMatch().post_content->first.empty()); |
| EXPECT_FALSE(LastOpenedMatch().post_content->second.empty()); |
| } |
| |
| // This verifies the fix for a bug where calling openMatch with a valid |
| // alternate nav URL would fail a DCHECK if the input began with "http://". |
| // The failure was due to erroneously trying to strip the scheme from the |
| // resulting fill_into_edit. Alternate nav matches are never shown, so there's |
| // no need to ever try and strip this scheme. |
| TEST_F(OmniboxAutocompleteControllerTest, AlternateNavHasHTTP) { |
| AutocompleteMatch match(autocomplete_controller_->search_provider(), 0, false, |
| AutocompleteMatchType::SEARCH_WHAT_YOU_TYPED); |
| // `match.destination_url` has to be set to ensure that OnAutocompleteAccept |
| // is called and `alternate_nav_match` is populated. |
| match.destination_url = GURL("https://foo/"); |
| const GURL alternate_nav_url("http://abcd/"); |
| |
| AutocompleteMatch alternate_nav_match; |
| EXPECT_CALL(*omnibox_client_, |
| OnAutocompleteAccept(_, _, _, _, _, _, _, _, _, _, _)) |
| .WillOnce(SaveArg<10>(&alternate_nav_match)); |
| |
| omnibox_text_model_->OnSetFocus(); // Avoids DCHECK in OpenMatch(). |
| omnibox_text_model_->SetInputInProgressNoNotify(YES); |
| omnibox_text_model_->UpdateUserText(u"http://abcd"); |
| [controller_ openMatch:match |
| popupSelection:OmniboxPopupSelection(0) |
| windowOpenDisposition:WindowOpenDisposition::CURRENT_TAB |
| alternateNavURL:alternate_nav_url |
| pastedText:u"" |
| matchSelectionTimestamp:base::TimeTicks()]; |
| EXPECT_TRUE( |
| AutocompleteInput::HasHTTPScheme(alternate_nav_match.fill_into_edit)); |
| |
| EXPECT_CALL(*omnibox_client_, |
| OnAutocompleteAccept(_, _, _, _, _, _, _, _, _, _, _)) |
| .WillOnce(SaveArg<10>(&alternate_nav_match)); |
| |
| omnibox_text_model_->SetInputInProgressNoNotify(YES); |
| omnibox_text_model_->UpdateUserText(u"abcd"); |
| [controller_ openMatch:match |
| popupSelection:OmniboxPopupSelection(0) |
| windowOpenDisposition:WindowOpenDisposition::CURRENT_TAB |
| alternateNavURL:alternate_nav_url |
| pastedText:u"" |
| matchSelectionTimestamp:base::TimeTicks()]; |
| |
| EXPECT_TRUE( |
| AutocompleteInput::HasHTTPScheme(alternate_nav_match.fill_into_edit)); |
| } |
| |
| #pragma mark - Histogram tests |
| |
| // Tests IPv4AddressPartsCount logging. |
| TEST_F(OmniboxAutocompleteControllerTest, IPv4AddressPartsCount) { |
| base::HistogramTester histogram_tester; |
| constexpr char kIPv4AddressPartsCountHistogramName[] = |
| "Omnibox.IPv4AddressPartsCount"; |
| // Hostnames shall not be recorded. |
| OpenUrlFromEditBox(u"http://example.com", false); |
| histogram_tester.ExpectTotalCount(kIPv4AddressPartsCountHistogramName, 0); |
| |
| // Autocompleted navigations shall not be recorded. |
| OpenUrlFromEditBox(u"http://127.0.0.1", true); |
| histogram_tester.ExpectTotalCount(kIPv4AddressPartsCountHistogramName, 0); |
| |
| // Test IPv4 parts are correctly counted. |
| OpenUrlFromEditBox(u"http://127.0.0.1", false); |
| OpenUrlFromEditBox(u"http://127.1/test.html", false); |
| OpenUrlFromEditBox(u"http://127.0.1", false); |
| EXPECT_THAT( |
| histogram_tester.GetAllSamples(kIPv4AddressPartsCountHistogramName), |
| testing::ElementsAre(base::Bucket(2, 1), base::Bucket(3, 1), |
| base::Bucket(4, 1))); |
| } |
| |
| // Tests AnswerInSuggest logging. |
| TEST_F(OmniboxAutocompleteControllerTest, LogAnswerUsed) { |
| base::HistogramTester histogram_tester; |
| AutocompleteMatch match(autocomplete_controller_->search_provider(), 0, false, |
| AutocompleteMatchType::SEARCH_WHAT_YOU_TYPED); |
| match.answer_type = omnibox::ANSWER_TYPE_WEATHER; |
| match.destination_url = GURL("https://foo"); |
| [controller_ openMatch:match |
| popupSelection:OmniboxPopupSelection(0) |
| windowOpenDisposition:WindowOpenDisposition::CURRENT_TAB |
| alternateNavURL:GURL() |
| pastedText:u"" |
| matchSelectionTimestamp:base::TimeTicks()]; |
| histogram_tester.ExpectUniqueSample("Omnibox.SuggestionUsed.AnswerInSuggest", |
| 8, 1); |
| } |