mirror of
https://github.com/LadybirdBrowser/ladybird
synced 2026-05-05 06:32:30 +02:00
Replace the frontend-facing Vector<String> flow with structured AutocompleteSuggestion objects carrying source, section, title, and favicon metadata. Build merged history and literal-URL rows in LibWebView, deduplicate equivalent URL suggestions, move the autocomplete URL helpers out of URL.h, and update the history and URL tests around the new model.
315 lines
13 KiB
C++
315 lines
13 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_entries("https://"sv, 8).is_empty());
|
|
EXPECT(store.autocomplete_entries("https://www."sv, 8).is_empty());
|
|
EXPECT(store.autocomplete_entries("www."sv, 8).is_empty());
|
|
|
|
auto git_entries = store.autocomplete_entries("git"sv, 8);
|
|
VERIFY(git_entries.size() == 1);
|
|
EXPECT_EQ(git_entries[0].url, "https://github.com/LadybirdBrowser/ladybird"_string);
|
|
|
|
auto https_goo_entries = store.autocomplete_entries("https://goo"sv, 8);
|
|
VERIFY(https_goo_entries.size() == 1);
|
|
EXPECT_EQ(https_goo_entries[0].url, "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_entries("w"sv, 8).is_empty());
|
|
EXPECT(store.autocomplete_entries("wi"sv, 8).is_empty());
|
|
|
|
auto entries = store.autocomplete_entries("wip"sv, 8);
|
|
VERIFY(entries.size() == 1);
|
|
EXPECT_EQ(entries[0].url, "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_entries("w"sv, 8).is_empty());
|
|
EXPECT(store.autocomplete_entries("wi"sv, 8).is_empty());
|
|
|
|
auto entries = store.autocomplete_entries("wip"sv, 8);
|
|
VERIFY(entries.size() == 1);
|
|
EXPECT_EQ(entries[0].url, "https://example.com/wip-path"_string);
|
|
}
|
|
|
|
static void expect_history_autocomplete_entries_include_metadata(WebView::HistoryStore& store)
|
|
{
|
|
auto google_url = parse_url("https://www.google.com/"sv);
|
|
auto github_url = parse_url("https://github.com/LadybirdBrowser/ladybird"sv);
|
|
|
|
store.record_visit(google_url, "Google"_string, UnixDateTime::from_seconds_since_epoch(20));
|
|
store.update_favicon(google_url, "Zm9v"_string);
|
|
store.record_visit(github_url, "Ladybird repository"_string, UnixDateTime::from_seconds_since_epoch(10));
|
|
|
|
auto entries = store.autocomplete_entries("goo"sv, 8);
|
|
VERIFY(entries.size() == 1);
|
|
EXPECT_EQ(entries[0].url, "https://www.google.com/"_string);
|
|
EXPECT_EQ(entries[0].title, Optional<String> { "Google"_string });
|
|
EXPECT_EQ(entries[0].favicon_base64_png, Optional<String> { "Zm9v"_string });
|
|
EXPECT_EQ(entries[0].visit_count, 1u);
|
|
EXPECT_EQ(entries[0].last_visited_time, UnixDateTime::from_seconds_since_epoch(20));
|
|
}
|
|
|
|
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 entries = store->autocomplete_entries("alpha"sv, 8);
|
|
|
|
VERIFY(entries.size() == 3);
|
|
EXPECT_EQ(entries[0].url, "https://alpha.example.com/"_string);
|
|
EXPECT_EQ(entries[1].url, "https://docs.example.com/"_string);
|
|
EXPECT_EQ(entries[2].url, "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 entries = store->autocomplete_entries(" ladybird "sv, 8);
|
|
|
|
VERIFY(entries.size() == 1);
|
|
EXPECT_EQ(entries[0].url, "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 entries = store->autocomplete_entries("goo"sv, 8);
|
|
|
|
VERIFY(entries.size() == 2);
|
|
EXPECT_EQ(entries[0].url, "https://www.google.com/"_string);
|
|
EXPECT_EQ(entries[1].url, "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(history_favicon_updates_entry)
|
|
{
|
|
auto store = WebView::HistoryStore::create();
|
|
auto url = parse_url("https://ladybird.dev/"sv);
|
|
|
|
store->record_visit(url, "Ladybird"_string, UnixDateTime::from_seconds_since_epoch(10));
|
|
store->update_favicon(url, "Zm9v"_string);
|
|
|
|
auto entry = store->entry_for_url(url);
|
|
VERIFY(entry.has_value());
|
|
EXPECT_EQ(entry->favicon_base64_png, Optional<String> { "Zm9v"_string });
|
|
}
|
|
|
|
TEST_CASE(history_autocomplete_entries_include_metadata)
|
|
{
|
|
auto store = WebView::HistoryStore::create();
|
|
expect_history_autocomplete_entries_include_metadata(*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));
|
|
store->update_favicon(parse_url("https://persist.example.com/"sv), "Zm9v"_string);
|
|
}
|
|
|
|
{
|
|
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));
|
|
EXPECT_EQ(entry->favicon_base64_png, Optional<String> { "Zm9v"_string });
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
TEST_CASE(persisted_history_autocomplete_entries_include_metadata)
|
|
{
|
|
auto database_directory = ByteString::formatted(
|
|
"{}/ladybird-history-store-entry-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_entries_include_metadata(*store);
|
|
}
|