Files
ladybird/Tests/LibWebView/TestHistoryStore.cpp
Andreas Kling fe2cab9270 LibWebView: Add history-backed location autocomplete
Teach LibWebView autocomplete to query HistoryStore before falling back
to remote engines and move the wiring out of the AppKit frontend.
Refine matching so scheme and www. boilerplate do not dominate results,
short title and substring queries stay quiet, and history tracing can
explain what the ranking code is doing.
2026-04-16 21:01:28 +02:00

257 lines
10 KiB
C++

/*
* Copyright (c) 2026-present, the Ladybird developers.
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/Random.h>
#include <AK/ScopeGuard.h>
#include <LibCore/Directory.h>
#include <LibCore/StandardPaths.h>
#include <LibDatabase/Database.h>
#include <LibFileSystem/FileSystem.h>
#include <LibTest/TestCase.h>
#include <LibURL/Parser.h>
#include <LibWebView/HistoryStore.h>
static URL::URL parse_url(StringView url)
{
auto parsed_url = URL::Parser::basic_parse(url);
VERIFY(parsed_url.has_value());
return parsed_url.release_value();
}
static void populate_history_for_url_autocomplete_tests(WebView::HistoryStore& store)
{
store.record_visit(parse_url("https://www.google.com/"sv), {}, UnixDateTime::from_seconds_since_epoch(30));
store.record_visit(parse_url("https://x.com/"sv), {}, UnixDateTime::from_seconds_since_epoch(20));
store.record_visit(parse_url("https://github.com/LadybirdBrowser/ladybird"sv), {}, UnixDateTime::from_seconds_since_epoch(10));
}
static void expect_history_autocomplete_ignores_url_boilerplate(WebView::HistoryStore& store)
{
populate_history_for_url_autocomplete_tests(store);
EXPECT(store.autocomplete_suggestions("https://"sv, 8).is_empty());
EXPECT(store.autocomplete_suggestions("https://www."sv, 8).is_empty());
EXPECT(store.autocomplete_suggestions("www."sv, 8).is_empty());
auto git_suggestions = store.autocomplete_suggestions("git"sv, 8);
VERIFY(git_suggestions.size() == 1);
EXPECT_EQ(git_suggestions[0], "https://github.com/LadybirdBrowser/ladybird"_string);
auto https_goo_suggestions = store.autocomplete_suggestions("https://goo"sv, 8);
VERIFY(https_goo_suggestions.size() == 1);
EXPECT_EQ(https_goo_suggestions[0], "https://www.google.com/"_string);
}
static void expect_history_autocomplete_requires_three_characters_for_title_matches(WebView::HistoryStore& store)
{
store.record_visit(parse_url("https://example.com/"sv), "Foo bar baz wip wap wop"_string, UnixDateTime::from_seconds_since_epoch(10));
EXPECT(store.autocomplete_suggestions("w"sv, 8).is_empty());
EXPECT(store.autocomplete_suggestions("wi"sv, 8).is_empty());
auto suggestions = store.autocomplete_suggestions("wip"sv, 8);
VERIFY(suggestions.size() == 1);
EXPECT_EQ(suggestions[0], "https://example.com/"_string);
}
static void expect_history_autocomplete_requires_three_characters_for_non_prefix_url_matches(WebView::HistoryStore& store)
{
store.record_visit(parse_url("https://example.com/wip-path"sv), "Example"_string, UnixDateTime::from_seconds_since_epoch(10));
EXPECT(store.autocomplete_suggestions("w"sv, 8).is_empty());
EXPECT(store.autocomplete_suggestions("wi"sv, 8).is_empty());
auto suggestions = store.autocomplete_suggestions("wip"sv, 8);
VERIFY(suggestions.size() == 1);
EXPECT_EQ(suggestions[0], "https://example.com/wip-path"_string);
}
TEST_CASE(record_and_lookup_history_entries)
{
auto store = WebView::HistoryStore::create();
auto visited_at = UnixDateTime::from_seconds_since_epoch(1234);
store->record_visit(parse_url("https://example.com/path#fragment"sv), "Example page"_string, visited_at);
store->record_visit(parse_url("https://example.com/path"sv), {}, visited_at);
auto entry = store->entry_for_url(parse_url("https://example.com/path"sv));
VERIFY(entry.has_value());
EXPECT_EQ(entry->url, "https://example.com/path"_string);
EXPECT_EQ(entry->title, Optional<String> { "Example page"_string });
EXPECT_EQ(entry->visit_count, 2u);
EXPECT_EQ(entry->last_visited_time, visited_at);
}
TEST_CASE(history_autocomplete_prefers_url_prefix_then_recency)
{
auto store = WebView::HistoryStore::create();
store->record_visit(parse_url("https://beta.example.com/"sv), "Alpha reference"_string, UnixDateTime::from_seconds_since_epoch(10));
store->record_visit(parse_url("https://alpha.example.com/"sv), "Something else"_string, UnixDateTime::from_seconds_since_epoch(20));
store->record_visit(parse_url("https://docs.example.com/"sv), "Alpha docs"_string, UnixDateTime::from_seconds_since_epoch(30));
auto suggestions = store->autocomplete_suggestions("alpha"sv, 8);
VERIFY(suggestions.size() == 3);
EXPECT_EQ(suggestions[0], "https://alpha.example.com/"_string);
EXPECT_EQ(suggestions[1], "https://docs.example.com/"_string);
EXPECT_EQ(suggestions[2], "https://beta.example.com/"_string);
}
TEST_CASE(history_autocomplete_trims_whitespace)
{
auto store = WebView::HistoryStore::create();
store->record_visit(parse_url("https://ladybird.dev/"sv), "Ladybird"_string, UnixDateTime::from_seconds_since_epoch(10));
auto suggestions = store->autocomplete_suggestions(" ladybird "sv, 8);
VERIFY(suggestions.size() == 1);
EXPECT_EQ(suggestions[0], "https://ladybird.dev/"_string);
}
TEST_CASE(history_autocomplete_ignores_www_prefix_for_host_matches)
{
auto store = WebView::HistoryStore::create();
store->record_visit(parse_url("https://www.google.com/"sv), "Google"_string, UnixDateTime::from_seconds_since_epoch(20));
store->record_visit(parse_url("https://www.goodreads.com/"sv), "Goodreads"_string, UnixDateTime::from_seconds_since_epoch(10));
auto suggestions = store->autocomplete_suggestions("goo"sv, 8);
VERIFY(suggestions.size() == 2);
EXPECT_EQ(suggestions[0], "https://www.google.com/"_string);
EXPECT_EQ(suggestions[1], "https://www.goodreads.com/"_string);
}
TEST_CASE(history_autocomplete_ignores_scheme_and_www_boilerplate_prefixes)
{
auto store = WebView::HistoryStore::create();
expect_history_autocomplete_ignores_url_boilerplate(*store);
}
TEST_CASE(history_autocomplete_requires_three_characters_for_title_matches)
{
auto store = WebView::HistoryStore::create();
expect_history_autocomplete_requires_three_characters_for_title_matches(*store);
}
TEST_CASE(history_autocomplete_requires_three_characters_for_non_prefix_url_matches)
{
auto store = WebView::HistoryStore::create();
expect_history_autocomplete_requires_three_characters_for_non_prefix_url_matches(*store);
}
TEST_CASE(non_browsable_urls_are_not_recorded)
{
auto store = WebView::HistoryStore::create();
store->record_visit(parse_url("about:blank"sv));
store->record_visit(parse_url("data:text/plain,hello"sv));
EXPECT(!store->entry_for_url(parse_url("about:blank"sv)).has_value());
EXPECT(!store->entry_for_url(parse_url("data:text/plain,hello"sv)).has_value());
}
TEST_CASE(disabled_history_store_ignores_updates)
{
auto store = WebView::HistoryStore::create_disabled();
auto url = parse_url("https://example.com/"sv);
store->record_visit(url, "Example"_string, UnixDateTime::from_seconds_since_epoch(10));
store->update_title(url, "Example title"_string);
store->remove_entries_accessed_since(UnixDateTime::from_seconds_since_epoch(0));
store->clear();
EXPECT(!store->entry_for_url(url).has_value());
EXPECT(store->autocomplete_suggestions("example"sv, 8).is_empty());
}
TEST_CASE(persisted_history_survives_reopen)
{
auto database_directory = ByteString::formatted(
"{}/ladybird-history-store-test-{}",
Core::StandardPaths::tempfile_directory(),
generate_random_uuid());
TRY_OR_FAIL(Core::Directory::create(database_directory, Core::Directory::CreateDirectories::Yes));
auto cleanup = ScopeGuard([&] {
MUST(FileSystem::remove(database_directory, FileSystem::RecursionMode::Allowed));
});
{
auto database = TRY_OR_FAIL(Database::Database::create(database_directory, "HistoryStore"sv));
auto store = TRY_OR_FAIL(WebView::HistoryStore::create(*database));
store->record_visit(parse_url("https://persist.example.com/"sv), "Persisted title"_string, UnixDateTime::from_seconds_since_epoch(77));
}
{
auto database = TRY_OR_FAIL(Database::Database::create(database_directory, "HistoryStore"sv));
auto store = TRY_OR_FAIL(WebView::HistoryStore::create(*database));
auto entry = store->entry_for_url(parse_url("https://persist.example.com/"sv));
VERIFY(entry.has_value());
EXPECT_EQ(entry->title, Optional<String> { "Persisted title"_string });
EXPECT_EQ(entry->visit_count, 1u);
EXPECT_EQ(entry->last_visited_time, UnixDateTime::from_seconds_since_epoch(77));
}
}
TEST_CASE(persisted_history_autocomplete_ignores_scheme_and_www_boilerplate_prefixes)
{
auto database_directory = ByteString::formatted(
"{}/ladybird-history-store-autocomplete-test-{}",
Core::StandardPaths::tempfile_directory(),
generate_random_uuid());
TRY_OR_FAIL(Core::Directory::create(database_directory, Core::Directory::CreateDirectories::Yes));
auto cleanup = ScopeGuard([&] {
MUST(FileSystem::remove(database_directory, FileSystem::RecursionMode::Allowed));
});
auto database = TRY_OR_FAIL(Database::Database::create(database_directory, "HistoryStore"sv));
auto store = TRY_OR_FAIL(WebView::HistoryStore::create(*database));
expect_history_autocomplete_ignores_url_boilerplate(*store);
}
TEST_CASE(persisted_history_autocomplete_requires_three_characters_for_title_matches)
{
auto database_directory = ByteString::formatted(
"{}/ladybird-history-store-title-autocomplete-test-{}",
Core::StandardPaths::tempfile_directory(),
generate_random_uuid());
TRY_OR_FAIL(Core::Directory::create(database_directory, Core::Directory::CreateDirectories::Yes));
auto cleanup = ScopeGuard([&] {
MUST(FileSystem::remove(database_directory, FileSystem::RecursionMode::Allowed));
});
auto database = TRY_OR_FAIL(Database::Database::create(database_directory, "HistoryStore"sv));
auto store = TRY_OR_FAIL(WebView::HistoryStore::create(*database));
expect_history_autocomplete_requires_three_characters_for_title_matches(*store);
}
TEST_CASE(persisted_history_autocomplete_requires_three_characters_for_non_prefix_url_matches)
{
auto database_directory = ByteString::formatted(
"{}/ladybird-history-store-url-autocomplete-test-{}",
Core::StandardPaths::tempfile_directory(),
generate_random_uuid());
TRY_OR_FAIL(Core::Directory::create(database_directory, Core::Directory::CreateDirectories::Yes));
auto cleanup = ScopeGuard([&] {
MUST(FileSystem::remove(database_directory, FileSystem::RecursionMode::Allowed));
});
auto database = TRY_OR_FAIL(Database::Database::create(database_directory, "HistoryStore"sv));
auto store = TRY_OR_FAIL(WebView::HistoryStore::create(*database));
expect_history_autocomplete_requires_three_characters_for_non_prefix_url_matches(*store);
}
}