blob: 4a52386e136a4c8b0fa79601d372371049fb3702 [file] [log] [blame]
// 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 "third_party/blink/renderer/core/speculation_rules/speculation_rule_set.h"
#include "base/ranges/algorithm.h"
#include "base/run_loop.h"
#include "base/test/bind.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/mock_callback.h"
#include "base/types/strong_alias.h"
#include "services/network/public/mojom/no_vary_search.mojom-blink.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/common/browser_interface_broker_proxy.h"
#include "third_party/blink/public/mojom/speculation_rules/speculation_rules.mojom-blink.h"
#include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_core.h"
#include "third_party/blink/renderer/bindings/core/v8/v8_union_urlpatterninit_usvstring.h"
#include "third_party/blink/renderer/core/css/style_rule.h"
#include "third_party/blink/renderer/core/dom/shadow_root.h"
#include "third_party/blink/renderer/core/execution_context/agent.h"
#include "third_party/blink/renderer/core/frame/local_dom_window.h"
#include "third_party/blink/renderer/core/frame/local_frame.h"
#include "third_party/blink/renderer/core/frame/settings.h"
#include "third_party/blink/renderer/core/frame/web_feature.h"
#include "third_party/blink/renderer/core/html/html_anchor_element.h"
#include "third_party/blink/renderer/core/html/html_area_element.h"
#include "third_party/blink/renderer/core/html/html_base_element.h"
#include "third_party/blink/renderer/core/html/html_div_element.h"
#include "third_party/blink/renderer/core/html/html_head_element.h"
#include "third_party/blink/renderer/core/html/html_meta_element.h"
#include "third_party/blink/renderer/core/html/html_script_element.h"
#include "third_party/blink/renderer/core/inspector/console_message_storage.h"
#include "third_party/blink/renderer/core/loader/empty_clients.h"
#include "third_party/blink/renderer/core/speculation_rules/document_rule_predicate.h"
#include "third_party/blink/renderer/core/speculation_rules/document_speculation_rules.h"
#include "third_party/blink/renderer/core/speculation_rules/speculation_rules_metrics.h"
#include "third_party/blink/renderer/core/speculation_rules/stub_speculation_host.h"
#include "third_party/blink/renderer/core/testing/dummy_page_holder.h"
#include "third_party/blink/renderer/core/testing/null_execution_context.h"
#include "third_party/blink/renderer/core/url_pattern/url_pattern.h"
#include "third_party/blink/renderer/platform/scheduler/public/event_loop.h"
#include "third_party/blink/renderer/platform/testing/runtime_enabled_features_test_helpers.h"
#include "third_party/blink/renderer/platform/testing/task_environment.h"
#include "third_party/blink/renderer/platform/weborigin/kurl.h"
#include "third_party/blink/renderer/platform/wtf/vector.h"
namespace blink {
namespace {
using ::testing::AllOf;
using ::testing::ElementsAre;
using ::testing::Not;
using ::testing::PrintToString;
// Convenience matcher for list rules that sub-matches on their URLs.
class ListRuleMatcher {
public:
explicit ListRuleMatcher(::testing::Matcher<const Vector<KURL>&> url_matcher)
: url_matcher_(std::move(url_matcher)) {}
bool MatchAndExplain(const Member<SpeculationRule>& rule,
::testing::MatchResultListener* listener) const {
return MatchAndExplain(*rule, listener);
}
bool MatchAndExplain(const SpeculationRule& rule,
::testing::MatchResultListener* listener) const {
::testing::StringMatchResultListener inner_listener;
const bool matches =
url_matcher_.MatchAndExplain(rule.urls(), &inner_listener);
std::string inner_explanation = inner_listener.str();
if (!inner_explanation.empty())
*listener << "whose URLs " << inner_explanation;
return matches;
}
void DescribeTo(::std::ostream* os) const {
*os << "is a list rule whose URLs ";
url_matcher_.DescribeTo(os);
}
void DescribeNegationTo(::std::ostream* os) const {
*os << "is not list rule whose URLs ";
url_matcher_.DescribeTo(os);
}
private:
::testing::Matcher<const Vector<KURL>&> url_matcher_;
};
class URLPatternMatcher {
public:
explicit URLPatternMatcher(v8::Isolate* isolate,
String pattern,
const KURL& base_url) {
auto* url_pattern_input = MakeGarbageCollected<V8URLPatternInput>(pattern);
url_pattern_ = URLPattern::Create(isolate, url_pattern_input, base_url,
ASSERT_NO_EXCEPTION);
}
bool MatchAndExplain(URLPattern* pattern,
::testing::MatchResultListener* listener) const {
if (!pattern) {
return false;
}
return MatchAndExplain(*pattern, listener);
}
bool MatchAndExplain(const URLPattern& pattern,
::testing::MatchResultListener* listener) const {
using Component = V8URLPatternComponent::Enum;
Component components[] = {Component::kProtocol, Component::kUsername,
Component::kPassword, Component::kHostname,
Component::kPort, Component::kPathname,
Component::kSearch, Component::kHash};
for (auto component : components) {
if (URLPattern::compareComponent(V8URLPatternComponent(component),
url_pattern_, &pattern) != 0) {
return false;
}
}
return true;
}
void DescribeTo(::std::ostream* os) const { *os << url_pattern_->ToString(); }
void DescribeNegationTo(::std::ostream* os) const { DescribeTo(os); }
private:
Persistent<URLPattern> url_pattern_;
};
template <typename... Matchers>
auto MatchesListOfURLs(Matchers&&... matchers) {
return ::testing::MakePolymorphicMatcher(
ListRuleMatcher(ElementsAre(std::forward<Matchers>(matchers)...)));
}
MATCHER(RequiresAnonymousClientIPWhenCrossOrigin,
negation ? "doesn't require anonymous client IP when cross origin"
: "requires anonymous client IP when cross origin") {
return arg->requires_anonymous_client_ip_when_cross_origin();
}
MATCHER(SetsReferrerPolicy,
std::string(negation ? "doesn't set" : "sets") + " a referrer policy") {
return arg->referrer_policy().has_value();
}
MATCHER_P(ReferrerPolicyIs,
policy,
std::string(negation ? "doesn't have" : "has") + " " +
PrintToString(policy) + " as the referrer policy") {
return arg->referrer_policy() == policy;
}
class SpeculationRuleSetTest : public ::testing::Test {
public:
SpeculationRuleSetTest()
: execution_context_(MakeGarbageCollected<NullExecutionContext>()) {}
~SpeculationRuleSetTest() override {
execution_context_->NotifyContextDestroyed();
}
SpeculationRuleSet* CreateRuleSet(const String& source_text,
const KURL& base_url,
ExecutionContext* context) {
return SpeculationRuleSet::Parse(
SpeculationRuleSet::Source::FromRequest(source_text, base_url,
/* request_id */ 0),
context);
}
SpeculationRuleSet* CreateSpeculationRuleSetWithTargetHint(
const char* target_hint) {
return CreateRuleSet(String::Format(R"({
"prefetch": [{
"source": "list",
"urls": ["https://example.com/hint.html"],
"target_hint": "%s"
}],
"prefetch_with_subresources": [{
"source": "list",
"urls": ["https://example.com/hint.html"],
"target_hint": "%s"
}],
"prerender": [{
"source": "list",
"urls": ["https://example.com/hint.html"],
"target_hint": "%s"
}]
})",
target_hint, target_hint, target_hint),
KURL("https://example.com/"), execution_context_);
}
NullExecutionContext* execution_context() {
return static_cast<NullExecutionContext*>(execution_context_.Get());
}
auto URLPattern(String pattern,
const KURL& base_url = KURL("https://example.com/")) {
return ::testing::MakePolymorphicMatcher(
URLPatternMatcher(execution_context_->GetIsolate(), pattern, base_url));
}
private:
ScopedSpeculationRulesRelativeToDocumentForTest enable_relative_to_{true};
ScopedPrerender2ForTest enable_prerender2_{true};
test::TaskEnvironment task_environment_;
Persistent<ExecutionContext> execution_context_;
};
// Matches a SpeculationCandidatePtr list with a KURL list (without requiring
// candidates to be in a specific order).
template <typename... Matchers>
auto HasURLs(Matchers&&... urls) {
return ::testing::ResultOf(
"urls",
[](const auto& candidates) {
Vector<KURL> urls;
base::ranges::transform(
candidates.begin(), candidates.end(), std::back_inserter(urls),
[](const auto& candidate) { return candidate->url; });
return urls;
},
::testing::UnorderedElementsAre(urls...));
}
// Matches a SpeculationCandidatePtr with an Eagerness.
auto HasEagerness(
::testing::Matcher<blink::mojom::SpeculationEagerness> matcher) {
return ::testing::Pointee(::testing::Field(
"eagerness", &mojom::blink::SpeculationCandidate::eagerness, matcher));
}
// Matches a SpeculationCandidatePtr with a KURL.
auto HasURL(::testing::Matcher<KURL> matcher) {
return ::testing::Pointee(::testing::Field(
"url", &mojom::blink::SpeculationCandidate::url, matcher));
}
// Matches a SpeculationCandidatePtr with a SpeculationAction.
auto HasAction(::testing::Matcher<mojom::blink::SpeculationAction> matcher) {
return ::testing::Pointee(::testing::Field(
"action", &mojom::blink::SpeculationCandidate::action, matcher));
}
// Matches a SpeculationCandidatePtr with a SpeculationTargetHint.
auto HasTargetHint(
::testing::Matcher<mojom::blink::SpeculationTargetHint> matcher) {
return ::testing::Pointee(::testing::Field(
"target_hint",
&mojom::blink::SpeculationCandidate::target_browsing_context_name_hint,
matcher));
}
// Matches a SpeculationCandidatePtr with a ReferrerPolicy.
auto HasReferrerPolicy(
::testing::Matcher<network::mojom::ReferrerPolicy> matcher) {
return ::testing::Pointee(::testing::Field(
"referrer", &mojom::blink::SpeculationCandidate::referrer,
::testing::Pointee(::testing::Field(
"policy", &mojom::blink::Referrer::policy, matcher))));
}
auto HasNoVarySearchHint() {
return ::testing::Pointee(
::testing::Field("no_vary_search_hint",
&mojom::blink::SpeculationCandidate::no_vary_search_hint,
::testing::IsTrue()));
}
auto NVSVariesOnKeyOrder() {
return ::testing::AllOf(
HasNoVarySearchHint(),
::testing::Pointee(::testing::Field(
"no_vary_search_hint",
&mojom::blink::SpeculationCandidate::no_vary_search_hint,
testing::Pointee(::testing::Field(
"vary_on_key_order",
&network::mojom::blink::NoVarySearch::vary_on_key_order,
::testing::IsTrue())))));
}
template <typename... Matchers>
auto NVSHasNoVaryParams(Matchers&&... params) {
return ::testing::ResultOf(
"no_vary_params",
[](const auto& nvs) {
if (!nvs->no_vary_search_hint ||
!nvs->no_vary_search_hint->search_variance ||
!nvs->no_vary_search_hint->search_variance->is_no_vary_params()) {
return Vector<String>();
}
return nvs->no_vary_search_hint->search_variance->get_no_vary_params();
},
::testing::UnorderedElementsAre(params...));
}
TEST_F(SpeculationRuleSetTest, Empty) {
auto* rule_set =
CreateRuleSet("{}", KURL("https://example.com/"), execution_context());
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(), SpeculationRuleSetErrorType::kNoError);
EXPECT_THAT(rule_set->prefetch_rules(), ElementsAre());
EXPECT_THAT(rule_set->prefetch_with_subresources_rules(), ElementsAre());
}
void AssertParseError(const SpeculationRuleSet* rule_set) {
EXPECT_EQ(rule_set->error_type(),
SpeculationRuleSetErrorType::kSourceIsNotJsonObject);
EXPECT_THAT(rule_set->prefetch_rules(), ElementsAre());
EXPECT_THAT(rule_set->prefetch_with_subresources_rules(), ElementsAre());
EXPECT_THAT(rule_set->prerender_rules(), ElementsAre());
}
TEST_F(SpeculationRuleSetTest, RejectsInvalidJSON) {
auto* rule_set = CreateRuleSet("[invalid]", KURL("https://example.com"),
execution_context());
ASSERT_TRUE(rule_set);
AssertParseError(rule_set);
EXPECT_TRUE(rule_set->error_message().Contains("Syntax error"))
<< rule_set->error_message();
}
TEST_F(SpeculationRuleSetTest, RejectsNonObject) {
auto* rule_set =
CreateRuleSet("42", KURL("https://example.com"), execution_context());
ASSERT_TRUE(rule_set);
AssertParseError(rule_set);
EXPECT_TRUE(rule_set->error_message().Contains("must be an object"))
<< rule_set->error_message();
}
TEST_F(SpeculationRuleSetTest, RejectsComments) {
auto* rule_set = CreateRuleSet(
"{ /* comments! */ }", KURL("https://example.com/"), execution_context());
ASSERT_TRUE(rule_set);
AssertParseError(rule_set);
EXPECT_TRUE(rule_set->error_message().Contains("Syntax error"))
<< rule_set->error_message();
}
TEST_F(SpeculationRuleSetTest, SimplePrefetchRule) {
auto* rule_set = CreateRuleSet(
R"({
"prefetch": [{
"source": "list",
"urls": ["https://example.com/index2.html"]
}]
})",
KURL("https://example.com/"), execution_context());
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(), SpeculationRuleSetErrorType::kNoError);
EXPECT_THAT(
rule_set->prefetch_rules(),
ElementsAre(MatchesListOfURLs("https://example.com/index2.html")));
EXPECT_THAT(rule_set->prefetch_with_subresources_rules(), ElementsAre());
EXPECT_THAT(rule_set->prerender_rules(), ElementsAre());
}
TEST_F(SpeculationRuleSetTest, SimplePrerenderRule) {
auto* rule_set = CreateRuleSet(
R"({
"prerender": [{
"source": "list",
"urls": ["https://example.com/index2.html"]
}]
})",
KURL("https://example.com/"), execution_context());
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(), SpeculationRuleSetErrorType::kNoError);
EXPECT_THAT(
rule_set->prerender_rules(),
ElementsAre(MatchesListOfURLs("https://example.com/index2.html")));
EXPECT_THAT(rule_set->prefetch_rules(), ElementsAre());
EXPECT_THAT(rule_set->prefetch_with_subresources_rules(), ElementsAre());
}
TEST_F(SpeculationRuleSetTest, SimplePrefetchWithSubresourcesRule) {
auto* rule_set = CreateRuleSet(
R"({
"prefetch_with_subresources": [{
"source": "list",
"urls": ["https://example.com/index2.html"]
}]
})",
KURL("https://example.com/"), execution_context());
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(), SpeculationRuleSetErrorType::kNoError);
EXPECT_THAT(rule_set->prefetch_rules(), ElementsAre());
EXPECT_THAT(
rule_set->prefetch_with_subresources_rules(),
ElementsAre(MatchesListOfURLs("https://example.com/index2.html")));
EXPECT_THAT(rule_set->prerender_rules(), ElementsAre());
}
TEST_F(SpeculationRuleSetTest, ResolvesURLs) {
auto* rule_set = CreateRuleSet(
R"({
"prefetch": [{
"source": "list",
"urls": [
"bar",
"/baz",
"//example.org/",
"http://example.net/"
]
}]
})",
KURL("https://example.com/foo/"), execution_context());
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(), SpeculationRuleSetErrorType::kNoError);
EXPECT_THAT(rule_set->prefetch_rules(),
ElementsAre(MatchesListOfURLs(
"https://example.com/foo/bar", "https://example.com/baz",
"https://example.org/", "http://example.net/")));
}
TEST_F(SpeculationRuleSetTest, ResolvesURLsWithRelativeTo) {
// Document base URL.
execution_context()->SetURL(KURL("https://document.com/foo/"));
// "relative_to": "ruleset" is an allowed value and results in default
// behaviour.
auto* rule_set = CreateRuleSet(
R"({
"prefetch": [{
"source": "list",
"urls": [
"bar",
"/baz",
"//example.org/",
"http://example.net/"
],
"relative_to": "ruleset"
}]
})",
KURL("https://example.com/foo/"), execution_context());
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(), SpeculationRuleSetErrorType::kNoError);
EXPECT_THAT(rule_set->prefetch_rules(),
ElementsAre(MatchesListOfURLs(
"https://example.com/foo/bar", "https://example.com/baz",
"https://example.org/", "http://example.net/")));
// "relative_to": "document" only affects relative URLs: "bar" and "/baz".
rule_set = CreateRuleSet(
R"({
"prefetch": [{
"source": "list",
"urls": [
"bar",
"/baz",
"//example.org/",
"http://example.net/"
],
"relative_to": "document"
}]
})",
KURL("https://example.com/foo/"), execution_context());
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(), SpeculationRuleSetErrorType::kNoError);
EXPECT_THAT(rule_set->prefetch_rules(),
ElementsAre(MatchesListOfURLs(
"https://document.com/foo/bar", "https://document.com/baz",
"https://example.org/", "http://example.net/")));
}
TEST_F(SpeculationRuleSetTest, RequiresAnonymousClientIPWhenCrossOrigin) {
auto* rule_set = CreateRuleSet(
R"({
"prefetch": [{
"source": "list",
"urls": ["//example.net/anonymous.html"],
"requires": ["anonymous-client-ip-when-cross-origin"]
}, {
"source": "list",
"urls": ["//example.net/direct.html"]
}]
})",
KURL("https://example.com/"), execution_context());
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(), SpeculationRuleSetErrorType::kNoError);
EXPECT_THAT(
rule_set->prefetch_rules(),
ElementsAre(AllOf(MatchesListOfURLs("https://example.net/anonymous.html"),
RequiresAnonymousClientIPWhenCrossOrigin()),
AllOf(MatchesListOfURLs("https://example.net/direct.html"),
Not(RequiresAnonymousClientIPWhenCrossOrigin()))));
}
TEST_F(SpeculationRuleSetTest, IgnoresUnknownOrDifferentlyTypedTopLevelKeys) {
auto* rule_set = CreateRuleSet(
R"({
"unrecognized_key": true,
"prefetch": 42,
"prefetch_with_subresources": false
})",
KURL("https://example.com/"), execution_context());
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(),
SpeculationRuleSetErrorType::kInvalidRulesSkipped);
EXPECT_THAT(rule_set->prefetch_rules(), ElementsAre());
EXPECT_THAT(rule_set->prefetch_with_subresources_rules(), ElementsAre());
}
TEST_F(SpeculationRuleSetTest, DropUnrecognizedRules) {
ScopedSpeculationRulesNoVarySearchHintForTest enable_no_vary_search_hint_{
true};
ScopedSpeculationRulesImplicitSourceForTest enable_implicit_source{true};
auto* rule_set = CreateRuleSet(
R"({"prefetch": [)"
// A rule of incorrect type.
R"("not an object",)"
// This used to be invalid, but now is, even with no source.
// TODO(crbug.com/1517696): Remove this when SpeculationRulesImplictSource
// is permanently shipped, so keep the test focused.
R"({"urls": ["no-source.html"]},)"
// A rule with an unrecognized source.
R"({"source": "magic-8-ball", "urls": ["no-source.html"]},)"
// A list rule with no "urls" key.
R"({"source": "list"},)"
// A list rule where some URL is not a string.
R"({"source": "list", "urls": [42]},)"
// A rule with an unrecognized requirement.
R"({"source": "list", "urls": ["/"], "requires": ["more-vespene-gas"]},)"
// A rule with requirements not given as an array.
R"({"source": "list", "urls": ["/"],
"requires": "anonymous-client-ip-when-cross-origin"},)"
// A rule with requirements of incorrect type.
R"({"source": "list", "urls": ["/"], "requires": [42]},)"
// A rule with a referrer_policy of incorrect type.
R"({"source": "list", "urls": ["/"], "referrer_policy": 42},)"
// A rule with an unrecognized referrer_policy.
R"({"source": "list", "urls": ["/"],
"referrer_policy": "no-referrrrrrrer"},)"
// A rule with a legacy value for referrer_policy.
R"({"source": "list", "urls": ["/"], "referrer_policy": "never"},)"
// Invalid value of "relative_to".
R"({"source": "list",
"urls": ["/no-source.html"],
"relative_to": 2022},)"
// Invalid string value of "relative_to".
R"({"source": "list",
"urls": ["/no-source.html"],
"relative_to": "not_document"},)"
// A rule with a "target_hint" of incorrect type (in addition to being
// invalid to use target_hint in a prefetch rule).
R"({"source": "list", "urls": ["/"], "target_hint": 42},)"
// Invalid URLs within a list rule should be discarded.
// This includes totally invalid ones and ones with unacceptable schemes.
R"({"source": "list",
"urls": [
"valid.html", "mailto:alice@example.com", "http://@:",
"blob:https://bar"
]},)"
// Invalid No-Vary-Search hint
R"nvs({
"source": "list",
"urls": ["no-source.html"],
"expects_no_vary_search": 0
}]})nvs",
KURL("https://example.com/"), execution_context());
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(),
SpeculationRuleSetErrorType::kInvalidRulesSkipped);
// The rule set itself is valid, however many of the individual rules are
// invalid. So we should have populated a warning message.
EXPECT_FALSE(rule_set->error_message().empty());
EXPECT_THAT(
rule_set->prefetch_rules(),
ElementsAre(MatchesListOfURLs("https://example.com/no-source.html"),
MatchesListOfURLs("https://example.com/valid.html")));
}
// Test that only prerender rule can process a "_blank" target hint.
TEST_F(SpeculationRuleSetTest, RulesWithTargetHint_Blank) {
auto* rule_set = CreateSpeculationRuleSetWithTargetHint("_blank");
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(),
SpeculationRuleSetErrorType::kInvalidRulesSkipped);
EXPECT_TRUE(rule_set->error_message().Contains(
"\"target_hint\" may not be set for prefetch"))
<< rule_set->error_message();
EXPECT_THAT(rule_set->prefetch_rules(), ElementsAre());
EXPECT_THAT(rule_set->prefetch_with_subresources_rules(), ElementsAre());
EXPECT_THAT(rule_set->prerender_rules(),
ElementsAre(MatchesListOfURLs("https://example.com/hint.html")));
EXPECT_EQ(rule_set->prerender_rules()[0]->target_browsing_context_name_hint(),
mojom::blink::SpeculationTargetHint::kBlank);
}
// Test that only prerender rule can process a "_self" target hint.
TEST_F(SpeculationRuleSetTest, RulesWithTargetHint_Self) {
auto* rule_set = CreateSpeculationRuleSetWithTargetHint("_self");
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(),
SpeculationRuleSetErrorType::kInvalidRulesSkipped);
EXPECT_TRUE(rule_set->error_message().Contains(
"\"target_hint\" may not be set for prefetch"))
<< rule_set->error_message();
EXPECT_THAT(rule_set->prefetch_rules(), ElementsAre());
EXPECT_THAT(rule_set->prefetch_with_subresources_rules(), ElementsAre());
EXPECT_THAT(rule_set->prerender_rules(),
ElementsAre(MatchesListOfURLs("https://example.com/hint.html")));
EXPECT_EQ(rule_set->prerender_rules()[0]->target_browsing_context_name_hint(),
mojom::blink::SpeculationTargetHint::kSelf);
}
// Test that only prerender rule can process a "_parent" target hint but treat
// it as no hint.
// TODO(https://crbug.com/1354049): Support the "_parent" keyword for
// prerendering.
TEST_F(SpeculationRuleSetTest, RulesWithTargetHint_Parent) {
auto* rule_set = CreateSpeculationRuleSetWithTargetHint("_parent");
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(),
SpeculationRuleSetErrorType::kInvalidRulesSkipped);
EXPECT_TRUE(rule_set->error_message().Contains(
"\"target_hint\" may not be set for prefetch"))
<< rule_set->error_message();
EXPECT_THAT(rule_set->prefetch_rules(), ElementsAre());
EXPECT_THAT(rule_set->prefetch_with_subresources_rules(), ElementsAre());
EXPECT_THAT(rule_set->prerender_rules(),
ElementsAre(MatchesListOfURLs("https://example.com/hint.html")));
EXPECT_EQ(rule_set->prerender_rules()[0]->target_browsing_context_name_hint(),
mojom::blink::SpeculationTargetHint::kNoHint);
}
// Test that only prerender rule can process a "_top" target hint but treat it
// as no hint.
// Test that rules with a "_top" hint are ignored.
// TODO(https://crbug.com/1354049): Support the "_top" keyword for prerendering.
TEST_F(SpeculationRuleSetTest, RulesWithTargetHint_Top) {
auto* rule_set = CreateSpeculationRuleSetWithTargetHint("_top");
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(),
SpeculationRuleSetErrorType::kInvalidRulesSkipped);
EXPECT_TRUE(rule_set->error_message().Contains(
"\"target_hint\" may not be set for prefetch"))
<< rule_set->error_message();
EXPECT_THAT(rule_set->prefetch_rules(), ElementsAre());
EXPECT_THAT(rule_set->prefetch_with_subresources_rules(), ElementsAre());
EXPECT_THAT(rule_set->prerender_rules(),
ElementsAre(MatchesListOfURLs("https://example.com/hint.html")));
EXPECT_EQ(rule_set->prerender_rules()[0]->target_browsing_context_name_hint(),
mojom::blink::SpeculationTargetHint::kNoHint);
}
// Test that rules with an empty target hint are ignored.
TEST_F(SpeculationRuleSetTest, RulesWithTargetHint_EmptyString) {
auto* rule_set = CreateSpeculationRuleSetWithTargetHint("");
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(),
SpeculationRuleSetErrorType::kInvalidRulesSkipped);
EXPECT_TRUE(rule_set->error_message().Contains("invalid \"target_hint\""))
<< rule_set->error_message();
EXPECT_THAT(rule_set->prefetch_rules(), ElementsAre());
EXPECT_THAT(rule_set->prefetch_with_subresources_rules(), ElementsAre());
EXPECT_THAT(rule_set->prerender_rules(), ElementsAre());
}
// Test that only prerender rule can process a browsing context name target hint
// but treat it as no hint.
// TODO(https://crbug.com/1354049): Support valid browsing context names.
TEST_F(SpeculationRuleSetTest, RulesWithTargetHint_ValidBrowsingContextName) {
auto* rule_set = CreateSpeculationRuleSetWithTargetHint("valid");
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(),
SpeculationRuleSetErrorType::kInvalidRulesSkipped);
EXPECT_TRUE(rule_set->error_message().Contains(
"\"target_hint\" may not be set for prefetch"))
<< rule_set->error_message();
EXPECT_THAT(rule_set->prefetch_rules(), ElementsAre());
EXPECT_THAT(rule_set->prefetch_with_subresources_rules(), ElementsAre());
EXPECT_THAT(rule_set->prerender_rules(),
ElementsAre(MatchesListOfURLs("https://example.com/hint.html")));
EXPECT_EQ(rule_set->prerender_rules()[0]->target_browsing_context_name_hint(),
mojom::blink::SpeculationTargetHint::kNoHint);
}
// Test that rules with an invalid browsing context name target hint are
// ignored.
TEST_F(SpeculationRuleSetTest, RulesWithTargetHint_InvalidBrowsingContextName) {
auto* rule_set = CreateSpeculationRuleSetWithTargetHint("_invalid");
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(),
SpeculationRuleSetErrorType::kInvalidRulesSkipped);
EXPECT_TRUE(rule_set->error_message().Contains("invalid \"target_hint\""))
<< rule_set->error_message();
EXPECT_THAT(rule_set->prefetch_rules(), ElementsAre());
EXPECT_THAT(rule_set->prefetch_with_subresources_rules(), ElementsAre());
EXPECT_THAT(rule_set->prerender_rules(), ElementsAre());
}
// Test that the the validation of the browsing context keywords runs an ASCII
// case-insensitive match.
TEST_F(SpeculationRuleSetTest, RulesWithTargetHint_CaseInsensitive) {
auto* rule_set = CreateSpeculationRuleSetWithTargetHint("_BlAnK");
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(),
SpeculationRuleSetErrorType::kInvalidRulesSkipped);
EXPECT_THAT(rule_set->prefetch_rules(), ElementsAre());
EXPECT_THAT(rule_set->prefetch_with_subresources_rules(), ElementsAre());
EXPECT_THAT(rule_set->prerender_rules(),
ElementsAre(MatchesListOfURLs("https://example.com/hint.html")));
EXPECT_EQ(rule_set->prerender_rules()[0]->target_browsing_context_name_hint(),
mojom::blink::SpeculationTargetHint::kBlank);
}
TEST_F(SpeculationRuleSetTest, ReferrerPolicy) {
auto* rule_set =
CreateRuleSet(R"({
"prefetch": [{
"source": "list",
"urls": ["https://example.com/index2.html"],
"referrer_policy": "strict-origin"
}, {
"source": "list",
"urls": ["https://example.com/index3.html"]
}]
})",
KURL("https://example.com/"), execution_context());
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(), SpeculationRuleSetErrorType::kNoError);
EXPECT_THAT(
rule_set->prefetch_rules(),
ElementsAre(AllOf(MatchesListOfURLs("https://example.com/index2.html"),
ReferrerPolicyIs(
network::mojom::ReferrerPolicy::kStrictOrigin)),
AllOf(MatchesListOfURLs("https://example.com/index3.html"),
Not(SetsReferrerPolicy()))));
}
TEST_F(SpeculationRuleSetTest, EmptyReferrerPolicy) {
// If an empty string is used for referrer_policy, treat this as if the key
// were omitted.
auto* rule_set = CreateRuleSet(
R"({
"prefetch": [{
"source": "list",
"urls": ["https://example.com/index2.html"],
"referrer_policy": ""
}]
})",
KURL("https://example.com/"), execution_context());
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(), SpeculationRuleSetErrorType::kNoError);
EXPECT_THAT(
rule_set->prefetch_rules(),
ElementsAre(AllOf(MatchesListOfURLs("https://example.com/index2.html"),
Not(SetsReferrerPolicy()))));
}
TEST_F(SpeculationRuleSetTest, PropagatesToDocument) {
// A <script> with a case-insensitive type match should be propagated to the
// document.
// TODO(jbroman): Should we need to enable script? Should that be bypassed?
DummyPageHolder page_holder;
page_holder.GetFrame().GetSettings()->SetScriptEnabled(true);
Document& document = page_holder.GetDocument();
HTMLScriptElement* script =
MakeGarbageCollected<HTMLScriptElement>(document, CreateElementFlags());
script->setAttribute(html_names::kTypeAttr, AtomicString("SpEcUlAtIoNrUlEs"));
script->setText(
R"({"prefetch": [
{"source": "list", "urls": ["https://example.com/foo"]}
],
"prerender": [
{"source": "list", "urls": ["https://example.com/bar"]}
]
})");
document.head()->appendChild(script);
auto* supplement = DocumentSpeculationRules::FromIfExists(document);
ASSERT_TRUE(supplement);
ASSERT_EQ(supplement->rule_sets().size(), 1u);
SpeculationRuleSet* rule_set = supplement->rule_sets()[0];
EXPECT_THAT(rule_set->prefetch_rules(),
ElementsAre(MatchesListOfURLs("https://example.com/foo")));
EXPECT_THAT(rule_set->prerender_rules(),
ElementsAre(MatchesListOfURLs("https://example.com/bar")));
}
HTMLScriptElement* InsertSpeculationRules(Document& document,
const String& speculation_script) {
HTMLScriptElement* script =
MakeGarbageCollected<HTMLScriptElement>(document, CreateElementFlags());
script->setAttribute(html_names::kTypeAttr, AtomicString("SpEcUlAtIoNrUlEs"));
script->setText(speculation_script);
document.head()->appendChild(script);
return script;
}
using IncludesStyleUpdate =
base::StrongAlias<class IncludesStyleUpdateTag, bool>;
// This runs the functor while observing any speculation rules sent by it.
// Since updates may be queued in a microtask or be blocked by style update,
// those are also awaited.
// At least one update is expected.
template <typename F>
void PropagateRulesToStubSpeculationHost(
DummyPageHolder& page_holder,
StubSpeculationHost& speculation_host,
const F& functor,
IncludesStyleUpdate includes_style_update = IncludesStyleUpdate{true}) {
// A <script> with a case-insensitive type match should be propagated to the
// browser via Mojo.
// TODO(jbroman): Should we need to enable script? Should that be bypassed?
LocalFrame& frame = page_holder.GetFrame();
frame.GetSettings()->SetScriptEnabled(true);
auto& broker = frame.DomWindow()->GetBrowserInterfaceBroker();
broker.SetBinderForTesting(
mojom::blink::SpeculationHost::Name_,
WTF::BindRepeating(&StubSpeculationHost::BindUnsafe,
WTF::Unretained(&speculation_host)));
base::RunLoop run_loop;
speculation_host.SetDoneClosure(run_loop.QuitClosure());
{
auto* script_state = ToScriptStateForMainWorld(&frame);
v8::MicrotasksScope microtasks_scope(script_state->GetIsolate(),
ToMicrotaskQueue(script_state),
v8::MicrotasksScope::kRunMicrotasks);
functor();
if (includes_style_update) {
page_holder.GetFrameView().UpdateAllLifecyclePhasesForTest();
}
}
run_loop.Run();
broker.SetBinderForTesting(mojom::blink::SpeculationHost::Name_, {});
}
void PropagateRulesToStubSpeculationHost(DummyPageHolder& page_holder,
StubSpeculationHost& speculation_host,
const String& speculation_script) {
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
InsertSpeculationRules(page_holder.GetDocument(), speculation_script);
});
}
template <typename F>
testing::AssertionResult NoRulesPropagatedToStubSpeculationHost(
DummyPageHolder& page_holder,
StubSpeculationHost& speculation_host,
const F& functor,
IncludesStyleUpdate includes_style_update = IncludesStyleUpdate{true}) {
LocalFrame& frame = page_holder.GetFrame();
auto& broker = frame.DomWindow()->GetBrowserInterfaceBroker();
broker.SetBinderForTesting(
mojom::blink::SpeculationHost::Name_,
WTF::BindRepeating(&StubSpeculationHost::BindUnsafe,
WTF::Unretained(&speculation_host)));
bool done_was_called = false;
base::RunLoop run_loop;
speculation_host.SetDoneClosure(base::BindLambdaForTesting(
[&done_was_called] { done_was_called = true; }));
{
auto* script_state = ToScriptStateForMainWorld(&frame);
v8::MicrotasksScope microtasks_scope(script_state->GetIsolate(),
ToMicrotaskQueue(script_state),
v8::MicrotasksScope::kRunMicrotasks);
functor();
if (includes_style_update) {
page_holder.GetFrameView().UpdateAllLifecyclePhasesForTest();
}
}
run_loop.RunUntilIdle();
broker.SetBinderForTesting(mojom::blink::SpeculationHost::Name_, {});
return done_was_called ? testing::AssertionFailure()
: testing::AssertionSuccess();
}
TEST_F(SpeculationRuleSetTest, PropagatesAllRulesToBrowser) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
const String speculation_script =
R"({"prefetch": [
{"source": "list",
"urls": ["https://example.com/foo", "https://example.com/bar"],
"requires": ["anonymous-client-ip-when-cross-origin"]}
],
"prerender": [
{"source": "list", "urls": ["https://example.com/prerender"]}
]
})";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
ASSERT_EQ(candidates.size(), 3u);
{
const auto& candidate = candidates[0];
EXPECT_EQ(candidate->action, mojom::blink::SpeculationAction::kPrefetch);
EXPECT_EQ(candidate->url, "https://example.com/foo");
EXPECT_TRUE(candidate->requires_anonymous_client_ip_when_cross_origin);
}
{
const auto& candidate = candidates[1];
EXPECT_EQ(candidate->action, mojom::blink::SpeculationAction::kPrefetch);
EXPECT_EQ(candidate->url, "https://example.com/bar");
EXPECT_TRUE(candidate->requires_anonymous_client_ip_when_cross_origin);
}
{
const auto& candidate = candidates[2];
EXPECT_EQ(candidate->action, mojom::blink::SpeculationAction::kPrerender);
EXPECT_EQ(candidate->url, "https://example.com/prerender");
}
}
// Tests that prefetch rules are ignored unless SpeculationRulesPrefetchProxy
// is enabled.
TEST_F(SpeculationRuleSetTest, PrerenderIgnorePrefetchRules) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
const String speculation_script =
R"({"prefetch_with_subresources": [
{"source": "list",
"urls": ["https://example.com/foo", "https://example.com/bar"],
"requires": ["anonymous-client-ip-when-cross-origin"]}
],
"prerender": [
{"source": "list", "urls": ["https://example.com/prerender"]}
]
})";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_EQ(candidates.size(), 1u);
EXPECT_FALSE(base::ranges::any_of(candidates, [](const auto& candidate) {
return candidate->action ==
mojom::blink::SpeculationAction::kPrefetchWithSubresources;
}));
}
// Tests that prerender rules are ignored unless Prerender2 is enabled.
TEST_F(SpeculationRuleSetTest, PrefetchIgnorePrerenderRules) {
// Overwrite the kPrerender2 flag.
ScopedPrerender2ForTest enable_prerender{false};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
const String speculation_script =
R"({"prefetch": [
{"source": "list",
"urls": ["https://example.com/foo", "https://example.com/bar"],
"requires": ["anonymous-client-ip-when-cross-origin"]}
],
"prerender": [
{"source": "list", "urls": ["https://example.com/prerender"]}
]
})";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_EQ(candidates.size(), 2u);
EXPECT_FALSE(base::ranges::any_of(candidates, [](const auto& candidate) {
return candidate->action == mojom::blink::SpeculationAction::kPrerender;
}));
}
// Tests that the presence of a speculationrules script is recorded.
TEST_F(SpeculationRuleSetTest, UseCounter) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
page_holder.GetFrame().GetSettings()->SetScriptEnabled(true);
EXPECT_FALSE(
page_holder.GetDocument().IsUseCounted(WebFeature::kSpeculationRules));
const String speculation_script =
R"({"prefetch": [{"source": "list", "urls": ["/foo"]}]})";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
EXPECT_TRUE(
page_holder.GetDocument().IsUseCounted(WebFeature::kSpeculationRules));
}
// Test helper method that returns if the No-Vary-Search hint use counter is
// properly counted during shipping.
// The use counter also acts as a proxy to check if the No-Vary-Search hint
// feature is enabled.
bool NoVarySearchHintUseCounterTestHelper() {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
page_holder.GetFrame().GetSettings()->SetScriptEnabled(true);
EXPECT_FALSE(page_holder.GetDocument().IsUseCounted(
WebFeature::kSpeculationRulesNoVarySearchHint));
const String speculation_script =
R"nvs({"prefetch": [{
"source": "list",
"urls": ["/foo"],
"expects_no_vary_search": "params=(\"a\")"
}]})nvs";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
return page_holder.GetDocument().IsUseCounted(
WebFeature::kSpeculationRulesNoVarySearchHint);
}
// Tests that the presence of a speculationrules No-Vary-Search hint is
// recorded.
TEST_F(SpeculationRuleSetTest, NoVarySearchHintUseCounter) {
{
// By default No-Vary-Search hint functionality is enabled without
// Origin Trial token.
ScopedSpeculationRulesNoVarySearchHintForTest enable_no_vary_search_hint{
false};
ScopedSpeculationRulesNoVarySearchHintShippedByDefaultForTest
ship_no_vary_search_hint{true};
EXPECT_TRUE(NoVarySearchHintUseCounterTestHelper())
<< "No-Vary-Search hint functionality is enabled "
"when shipped and without an Origin Trial token.";
}
{
// By default No-Vary-Search hint is enabled with Origin Trial token.
ScopedSpeculationRulesNoVarySearchHintForTest enable_no_vary_search_hint{
true};
ScopedSpeculationRulesNoVarySearchHintShippedByDefaultForTest
ship_no_vary_search_hint{true};
EXPECT_TRUE(NoVarySearchHintUseCounterTestHelper())
<< "No-Vary-Search hint functionality is enabled "
"when shipped and with an Origin Trial token.";
}
{
// No-Vary-Search hint is disabled when
// SpeculationRulesNoVarySearchHintControlShipping is set to false and
// there is no Origin Trial token.
ScopedSpeculationRulesNoVarySearchHintForTest enable_no_vary_search_hint{
false};
ScopedSpeculationRulesNoVarySearchHintShippedByDefaultForTest
ship_no_vary_search_hint{false};
EXPECT_FALSE(NoVarySearchHintUseCounterTestHelper())
<< "No-Vary-Search hint functionality is "
"disabled when unshipped and without "
"an Origin Trial token";
}
{
// No-Vary-Search hint is enabled when
// SpeculationRulesNoVarySearchHintControlShipping is set to false and
// there is an Origin Trial token.
ScopedSpeculationRulesNoVarySearchHintShippedByDefaultForTest
ship_no_vary_search_hint{false};
ScopedSpeculationRulesNoVarySearchHintForTest enable_no_vary_search_hint{
true};
EXPECT_TRUE(NoVarySearchHintUseCounterTestHelper())
<< "No-Vary-Search hint functionality is enabled when unshipped and "
"with "
"an Origin Trial token";
}
}
// Tests that the document's URL is excluded from candidates.
TEST_F(SpeculationRuleSetTest, ExcludesFragmentLinks) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
page_holder.GetDocument().SetURL(KURL("https://example.com/"));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
String(R"({"prefetch": [
{"source": "list", "urls":
["https://example.com/", "#foo", "/b#bar"]}]})"));
EXPECT_THAT(
speculation_host.candidates(),
HasURLs(KURL("https://example.com"), KURL("https://example.com/b#bar")));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&] {
page_holder.GetDocument().SetURL(KURL("https://example.com/b"));
});
EXPECT_THAT(speculation_host.candidates(),
HasURLs(KURL("https://example.com")));
}
// Tests that the document's URL is excluded from candidates, even when its
// changes do not affect the base URL.
TEST_F(SpeculationRuleSetTest, ExcludesFragmentLinksWithBase) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
page_holder.GetDocument().SetURL(KURL("https://example.com/"));
page_holder.GetDocument().head()->setInnerHTML(
"<base href=\"https://not-example.com/\">");
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
String(R"({"prefetch": [
{"source": "list", "urls":
["https://example.com/#baz", "#foo", "/b#bar"]}]})"));
EXPECT_THAT(speculation_host.candidates(),
HasURLs(KURL("https://not-example.com/#foo"),
KURL("https://not-example.com/b#bar")));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&] {
page_holder.GetDocument().SetURL(KURL("https://example.com/b"));
});
EXPECT_THAT(speculation_host.candidates(),
HasURLs(KURL("https://example.com/#baz"),
KURL("https://not-example.com/#foo"),
KURL("https://not-example.com/b#bar")));
}
// Tests that rules removed before the task to update speculation candidates
// runs are not reported.
TEST_F(SpeculationRuleSetTest, AddAndRemoveInSameTask) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
InsertSpeculationRules(page_holder.GetDocument(),
R"({"prefetch": [
{"source": "list", "urls": ["https://example.com/foo"]}]})");
HTMLScriptElement* to_remove =
InsertSpeculationRules(page_holder.GetDocument(),
R"({"prefetch": [
{"source": "list", "urls": ["https://example.com/bar"]}]})");
InsertSpeculationRules(page_holder.GetDocument(),
R"({"prefetch": [
{"source": "list", "urls": ["https://example.com/baz"]}]})");
to_remove->remove();
});
const auto& candidates = speculation_host.candidates();
ASSERT_EQ(candidates.size(), 2u);
EXPECT_EQ(candidates[0]->url, "https://example.com/foo");
EXPECT_EQ(candidates[1]->url, "https://example.com/baz");
}
// Tests that rules removed after being previously reported are reported as
// removed.
TEST_F(SpeculationRuleSetTest, AddAndRemoveAfterReport) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
HTMLScriptElement* to_remove = nullptr;
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
InsertSpeculationRules(page_holder.GetDocument(),
R"({"prefetch": [
{"source": "list", "urls": ["https://example.com/foo"]}]})");
to_remove = InsertSpeculationRules(page_holder.GetDocument(),
R"({"prefetch": [
{"source": "list", "urls": ["https://example.com/bar"]}]})");
InsertSpeculationRules(page_holder.GetDocument(),
R"({"prefetch": [
{"source": "list", "urls": ["https://example.com/baz"]}]})");
});
{
const auto& candidates = speculation_host.candidates();
ASSERT_EQ(candidates.size(), 3u);
EXPECT_EQ(candidates[0]->url, "https://example.com/foo");
EXPECT_EQ(candidates[1]->url, "https://example.com/bar");
EXPECT_EQ(candidates[2]->url, "https://example.com/baz");
}
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
[&]() { to_remove->remove(); });
{
const auto& candidates = speculation_host.candidates();
ASSERT_EQ(candidates.size(), 2u);
EXPECT_EQ(candidates[0]->url, "https://example.com/foo");
EXPECT_EQ(candidates[1]->url, "https://example.com/baz");
}
}
// Tests that removed candidates are reported in a microtask.
// This is somewhat difficult to observe in practice, but most sharply visible
// if a removal occurs and then in a subsequent microtask an addition occurs.
TEST_F(SpeculationRuleSetTest, RemoveInMicrotask) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
base::RunLoop run_loop;
base::MockCallback<base::RepeatingCallback<void(
const Vector<mojom::blink::SpeculationCandidatePtr>&)>>
mock_callback;
{
::testing::InSequence sequence;
EXPECT_CALL(mock_callback, Run(::testing::SizeIs(2)));
EXPECT_CALL(mock_callback, Run(::testing::SizeIs(1)));
EXPECT_CALL(mock_callback, Run(::testing::SizeIs(2)))
.WillOnce(::testing::Invoke([&]() { run_loop.Quit(); }));
}
speculation_host.SetCandidatesUpdatedCallback(mock_callback.Get());
LocalFrame& frame = page_holder.GetFrame();
frame.GetSettings()->SetScriptEnabled(true);
auto& broker = frame.DomWindow()->GetBrowserInterfaceBroker();
broker.SetBinderForTesting(
mojom::blink::SpeculationHost::Name_,
WTF::BindRepeating(&StubSpeculationHost::BindUnsafe,
WTF::Unretained(&speculation_host)));
// First simulated task adds the rule sets.
InsertSpeculationRules(page_holder.GetDocument(),
R"({"prefetch": [
{"source": "list", "urls": ["https://example.com/foo"]}]})");
HTMLScriptElement* to_remove =
InsertSpeculationRules(page_holder.GetDocument(),
R"({"prefetch": [
{"source": "list", "urls": ["https://example.com/bar"]}]})");
scoped_refptr<scheduler::EventLoop> event_loop =
frame.DomWindow()->GetAgent()->event_loop();
event_loop->PerformMicrotaskCheckpoint();
frame.View()->UpdateAllLifecyclePhasesForTest();
// Second simulated task removes the rule sets, then adds another one in a
// microtask which is queued later than any queued during the removal.
to_remove->remove();
event_loop->EnqueueMicrotask(base::BindLambdaForTesting([&] {
InsertSpeculationRules(page_holder.GetDocument(),
R"({"prefetch": [
{"source": "list", "urls": ["https://example.com/baz"]}]})");
}));
event_loop->PerformMicrotaskCheckpoint();
run_loop.Run();
broker.SetBinderForTesting(mojom::blink::SpeculationHost::Name_, {});
}
class ConsoleCapturingChromeClient : public EmptyChromeClient {
public:
void AddMessageToConsole(LocalFrame*,
mojom::ConsoleMessageSource,
mojom::ConsoleMessageLevel,
const String& message,
unsigned line_number,
const String& source_id,
const String& stack_trace) override {
messages_.push_back(message);
}
const Vector<String>& ConsoleMessages() const { return messages_; }
private:
Vector<String> messages_;
};
// Tests that parse errors are logged to the console.
TEST_F(SpeculationRuleSetTest, ConsoleWarning) {
auto* chrome_client = MakeGarbageCollected<ConsoleCapturingChromeClient>();
DummyPageHolder page_holder(/*initial_view_size=*/{}, chrome_client);
page_holder.GetFrame().GetSettings()->SetScriptEnabled(true);
Document& document = page_holder.GetDocument();
HTMLScriptElement* script =
MakeGarbageCollected<HTMLScriptElement>(document, CreateElementFlags());
script->setAttribute(html_names::kTypeAttr, AtomicString("speculationrules"));
script->setText("[invalid]");
document.head()->appendChild(script);
EXPECT_TRUE(base::ranges::any_of(
chrome_client->ConsoleMessages(),
[](const String& message) { return message.Contains("Syntax error"); }));
}
// Tests that errors of individual rules which cause them to be ignored are
// logged to the console.
TEST_F(SpeculationRuleSetTest, ConsoleWarningForInvalidRule) {
auto* chrome_client = MakeGarbageCollected<ConsoleCapturingChromeClient>();
DummyPageHolder page_holder(/*initial_view_size=*/{}, chrome_client);
page_holder.GetFrame().GetSettings()->SetScriptEnabled(true);
Document& document = page_holder.GetDocument();
HTMLScriptElement* script =
MakeGarbageCollected<HTMLScriptElement>(document, CreateElementFlags());
script->setAttribute(html_names::kTypeAttr, AtomicString("speculationrules"));
script->setText(
R"({
"prefetch": [{
"source": "list",
"urls": [["a", ".", "c", "o", "m"]]
}]
})");
document.head()->appendChild(script);
EXPECT_TRUE(base::ranges::any_of(
chrome_client->ConsoleMessages(), [](const String& message) {
return message.Contains("URLs must be given as strings");
}));
}
TEST_F(SpeculationRuleSetTest, DropNotArrayAtRuleSetPosition) {
auto* rule_set = CreateRuleSet(
R"({
"prefetch": "invalid"
})",
KURL("https://example.com/"), execution_context());
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(),
SpeculationRuleSetErrorType::kInvalidRulesSkipped);
EXPECT_TRUE(rule_set->error_message().Contains(
"A rule set for a key must be an array: path = [\"prefetch\"]"))
<< rule_set->error_message();
EXPECT_THAT(rule_set->prefetch_rules(), ElementsAre());
EXPECT_THAT(rule_set->prerender_rules(), ElementsAre());
EXPECT_THAT(rule_set->prefetch_with_subresources_rules(), ElementsAre());
}
TEST_F(SpeculationRuleSetTest, DropNotObjectAtRulePosition) {
auto* rule_set = CreateRuleSet(
R"({
"prefetch": ["invalid"]
})",
KURL("https://example.com/"), execution_context());
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(),
SpeculationRuleSetErrorType::kInvalidRulesSkipped);
EXPECT_TRUE(rule_set->error_message().Contains(
"A rule must be an object: path = [\"prefetch\"][0]"))
<< rule_set->error_message();
EXPECT_THAT(rule_set->prefetch_rules(), ElementsAre());
EXPECT_THAT(rule_set->prerender_rules(), ElementsAre());
EXPECT_THAT(rule_set->prefetch_with_subresources_rules(), ElementsAre());
}
TEST_F(SpeculationRuleSetTest, DropWhereClause) {
ScopedSpeculationRulesDocumentRulesForTest disable_document_rules{false};
auto* rule_set = CreateRuleSet(
R"({
"prefetch": [{
"source": "document",
"where": {}
}]
})",
KURL("https://example.com/"), execution_context());
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(),
SpeculationRuleSetErrorType::kInvalidRulesSkipped);
EXPECT_TRUE(rule_set->error_message().Contains(
"A rule has an unknown source: \"document\"."))
<< rule_set->error_message();
EXPECT_THAT(rule_set->prefetch_rules(), ElementsAre());
EXPECT_THAT(rule_set->prerender_rules(), ElementsAre());
EXPECT_THAT(rule_set->prefetch_with_subresources_rules(), ElementsAre());
}
MATCHER_P(MatchesPredicate,
matcher,
::testing::DescribeMatcher<DocumentRulePredicate>(matcher)) {
if (!arg->predicate()) {
*result_listener << "does not have a predicate";
return false;
}
return ExplainMatchResult(matcher, *(arg->predicate()), result_listener);
}
String GetTypeString(DocumentRulePredicate::Type type) {
switch (type) {
case DocumentRulePredicate::Type::kAnd:
return "And";
case DocumentRulePredicate::Type::kOr:
return "Or";
case DocumentRulePredicate::Type::kNot:
return "Not";
case DocumentRulePredicate::Type::kURLPatterns:
return "Href";
case DocumentRulePredicate::Type::kCSSSelectors:
return "Selector";
}
}
template <typename ItemType>
class PredicateMatcher {
public:
using DocumentRulePredicateGetter =
HeapVector<Member<ItemType>> (DocumentRulePredicate::*)() const;
explicit PredicateMatcher(Vector<::testing::Matcher<ItemType>> matchers,
DocumentRulePredicate::Type type,
DocumentRulePredicateGetter getter)
: matchers_(std::move(matchers)), type_(type), getter_(getter) {}
bool MatchAndExplain(DocumentRulePredicate* predicate,
::testing::MatchResultListener* listener) const {
if (!predicate) {
return false;
}
return MatchAndExplain(*predicate, listener);
}
bool MatchAndExplain(const DocumentRulePredicate& predicate,
::testing::MatchResultListener* listener) const {
if (predicate.GetTypeForTesting() != type_) {
*listener << predicate.ToString();
return false;
}
HeapVector<Member<ItemType>> items = ((predicate).*(getter_))();
if (items.size() != matchers_.size()) {
*listener << predicate.ToString();
return false;
}
::testing::StringMatchResultListener inner_listener;
for (wtf_size_t i = 0; i < matchers_.size(); i++) {
if (!matchers_[i].MatchAndExplain(*items[i], &inner_listener)) {
*listener << predicate.ToString();
return false;
}
}
return true;
}
void DescribeTo(::std::ostream* os) const {
*os << GetTypeString(type_) << "([";
for (wtf_size_t i = 0; i < matchers_.size(); i++) {
matchers_[i].DescribeTo(os);
if (i != matchers_.size() - 1) {
*os << ", ";
}
}
*os << "])";
}
void DescribeNegationTo(::std::ostream* os) const { DescribeTo(os); }
private:
Vector<::testing::Matcher<ItemType>> matchers_;
DocumentRulePredicate::Type type_;
DocumentRulePredicateGetter getter_;
};
template <typename ItemType>
auto MakePredicateMatcher(
Vector<::testing::Matcher<ItemType>> matchers,
DocumentRulePredicate::Type type,
typename PredicateMatcher<ItemType>::DocumentRulePredicateGetter getter) {
return testing::MakePolymorphicMatcher(
PredicateMatcher<ItemType>(std::move(matchers), type, getter));
}
auto MakeConditionMatcher(
Vector<::testing::Matcher<DocumentRulePredicate>> matchers,
DocumentRulePredicate::Type type) {
return MakePredicateMatcher(
std::move(matchers), type,
&DocumentRulePredicate::GetSubPredicatesForTesting);
}
auto And(Vector<::testing::Matcher<DocumentRulePredicate>> matchers = {}) {
return MakeConditionMatcher(std::move(matchers),
DocumentRulePredicate::Type::kAnd);
}
auto Or(Vector<::testing::Matcher<DocumentRulePredicate>> matchers = {}) {
return MakeConditionMatcher(std::move(matchers),
DocumentRulePredicate::Type::kOr);
}
auto Neg(::testing::Matcher<DocumentRulePredicate> matcher) {
return MakeConditionMatcher({matcher}, DocumentRulePredicate::Type::kNot);
}
auto Href(Vector<::testing::Matcher<URLPattern>> pattern_matchers = {}) {
return MakePredicateMatcher(std::move(pattern_matchers),
DocumentRulePredicate::Type::kURLPatterns,
&DocumentRulePredicate::GetURLPatternsForTesting);
}
auto Selector(Vector<::testing::Matcher<StyleRule>> style_rule_matchers = {}) {
return MakePredicateMatcher(std::move(style_rule_matchers),
DocumentRulePredicate::Type::kCSSSelectors,
&DocumentRulePredicate::GetStyleRulesForTesting);
}
class StyleRuleMatcher {
public:
explicit StyleRuleMatcher(String selector_text)
: selector_text_(std::move(selector_text)) {}
bool MatchAndExplain(StyleRule* style_rule,
::testing::MatchResultListener* listener) const {
if (!style_rule) {
return false;
}
return MatchAndExplain(*style_rule, listener);
}
bool MatchAndExplain(const StyleRule& style_rule,
::testing::MatchResultListener* listener) const {
return style_rule.SelectorsText() == selector_text_;
}
void DescribeTo(::std::ostream* os) const { *os << selector_text_; }
void DescribeNegationTo(::std::ostream* os) const { DescribeTo(os); }
private:
String selector_text_;
};
auto StyleRuleWithSelectorText(String selector_text) {
return ::testing::MakePolymorphicMatcher(StyleRuleMatcher(selector_text));
}
class DocumentRulesTest : public SpeculationRuleSetTest {
public:
~DocumentRulesTest() override = default;
DocumentRulePredicate* CreatePredicate(
String where_text,
KURL base_url = KURL("https://example.com/")) {
auto* rule_set = CreateRuleSetWithPredicate(where_text, base_url);
DCHECK(!rule_set->prefetch_rules().empty())
<< "Invalid predicate: " << rule_set->error_message();
return rule_set->prefetch_rules()[0]->predicate();
}
String CreateInvalidPredicate(String where_text) {
auto* rule_set =
CreateRuleSetWithPredicate(where_text, KURL("https://example.com"));
EXPECT_TRUE(!rule_set || rule_set->prefetch_rules().empty())
<< "Rule set is valid.";
return rule_set->error_message();
}
private:
SpeculationRuleSet* CreateRuleSetWithPredicate(String where_text,
KURL base_url) {
// clang-format off
auto* rule_set =
CreateRuleSet(
String::Format(
R"({
"prefetch": [{
"source": "document",
"where": {%s}
}]
})",
where_text.Latin1().c_str()),
base_url, execution_context());
// clang-format on
return rule_set;
}
ScopedSpeculationRulesDocumentRulesForTest enable_document_rules_{true};
};
TEST_F(DocumentRulesTest, ParseAnd) {
auto* rule_set = CreateRuleSet(
R"({
"prefetch": [{
"source": "document",
"where": { "and": [] }
}, {
"source": "document",
"where": {"and": [{"and": []}, {"and": []}]}
}]
})",
KURL("https://example.com/"), execution_context());
EXPECT_THAT(rule_set->prefetch_rules(),
ElementsAre(MatchesPredicate(And()),
MatchesPredicate(And({And(), And()}))));
}
TEST_F(DocumentRulesTest, ParseOr) {
auto* rule_set = CreateRuleSet(
R"({
"prefetch": [{
"source": "document",
"where": { "or": [] }
}, {
"source": "document",
"where": {"or": [{"and": []}, {"or": []}]}
}]
})",
KURL("https://example.com/"), execution_context());
EXPECT_THAT(
rule_set->prefetch_rules(),
ElementsAre(MatchesPredicate(Or()), MatchesPredicate(Or({And(), Or()}))));
}
TEST_F(DocumentRulesTest, ParseNot) {
auto* rule_set = CreateRuleSet(
R"({
"prefetch": [{
"source": "document",
"where": {"not": {"and": []}}
}, {
"source": "document",
"where": {"not": {"or": [{"and": []}, {"or": []}]}}
}]
})",
KURL("https://example.com/"), execution_context());
EXPECT_THAT(rule_set->prefetch_rules(),
ElementsAre(MatchesPredicate(Neg(And())),
MatchesPredicate(Neg(Or({And(), Or()})))));
}
TEST_F(DocumentRulesTest, ParseHref) {
auto* rule_set = CreateRuleSet(
R"({
"prefetch": [{
"source": "document",
"where": {"href_matches": "/foo#bar"}
}, {
"source": "document",
"where": {"href_matches": {"pathname": "/foo"}}
}, {
"source": "document",
"where": {"href_matches": [
{"pathname": "/buzz"},
"/fizz",
{"hostname": "bar.com"}
]}
}, {
"source": "document",
"where": {"or": [
{"href_matches": {"hostname": "foo.com"}},
{"not": {"href_matches": {"protocol": "http", "hostname": "*"}}}
]}
}]
})",
KURL("https://example.com/"), execution_context());
EXPECT_THAT(
rule_set->prefetch_rules(),
ElementsAre(
MatchesPredicate(Href({URLPattern("/foo#bar")})),
MatchesPredicate(Href({URLPattern("/foo")})),
MatchesPredicate(Href({URLPattern("/buzz"), URLPattern("/fizz"),
URLPattern("https://bar.com:*")})),
MatchesPredicate(Or({Href({URLPattern("https://foo.com:*")}),
Neg(Href({URLPattern("http://*:*")}))}))));
}
TEST_F(DocumentRulesTest, ParseHref_AllUrlPatternKeys) {
auto* href_matches = CreatePredicate(R"("href_matches": {
"username": "",
"password": "",
"port": "*",
"pathname": "/*",
"search": "*",
"hash": "",
"protocol": "https",
"hostname": "abc.xyz",
"baseURL": "https://example.com"
})");
EXPECT_THAT(href_matches, Href({URLPattern("https://:@abc.xyz:*/*\\?*#")}));
}
TEST_F(DocumentRulesTest, HrefMatchesWithBaseURL) {
auto* without_base_specified = CreatePredicate(
R"("href_matches": {"pathname": "/hello"})", KURL("http://foo.com"));
EXPECT_THAT(without_base_specified,
Href({URLPattern("http://foo.com/hello")}));
auto* with_base_specified = CreatePredicate(
R"("href_matches": {"pathname": "hello", "baseURL": "http://bar.com"})",
KURL("http://foo.com"));
EXPECT_THAT(with_base_specified, Href({URLPattern("http://bar.com/hello")}));
}
// Testing on http://bar.com requesting a ruleset from http://foo.com.
TEST_F(DocumentRulesTest, HrefMatchesWithBaseURLAndRelativeTo) {
execution_context()->SetURL(KURL{"http://bar.com"});
auto* with_relative_to = CreatePredicate(
R"(
"href_matches": "/hello",
"relative_to": "document"
)",
KURL("http://foo.com"));
EXPECT_THAT(with_relative_to, Href({URLPattern("http://bar.com/hello")}));
auto* relative_to_no_effect = CreatePredicate(
R"(
"href_matches": {"pathname": "/hello", "baseURL": "http://buz.com"},
"relative_to": "document"
)",
KURL("http://foo.com"));
EXPECT_THAT(relative_to_no_effect,
Href({URLPattern("http://buz.com/hello")}));
auto* nested_relative_to = CreatePredicate(
R"(
"or": [
{
"href_matches": {"pathname": "/hello"},
"relative_to": "document"
},
{"not": {"href_matches": "/world"}}
]
)",
KURL("http://foo.com/"));
EXPECT_THAT(nested_relative_to,
Or({Href({URLPattern("http://bar.com/hello")}),
Neg(Href({URLPattern("http://foo.com/world")}))}));
auto* relative_to_ruleset = CreatePredicate(R"(
"href_matches": {"pathname": "/hello"},
"relative_to": "ruleset"
)",
KURL("http://foo.com"));
EXPECT_THAT(relative_to_ruleset, Href({URLPattern("http://foo.com/hello")}));
}
TEST_F(DocumentRulesTest, DropInvalidRules) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
ScopedSpeculationRulesNoVarySearchHintForTest enable_no_vary_search_hint{
true};
ScopedSpeculationRulesImplicitSourceForTest enable_implicit_source{true};
auto* rule_set = CreateRuleSet(
R"({"prefetch": [)"
// A rule that doesn't elaborate on its source (previously disallowed).
// TODO(crbug.com/1517696): Remove this when SpeculationRulesImplictSource
// is permanently shipped, so keep the test focused.
R"({"where": {"and": []}},)"
// A rule with an unrecognized source.
R"({"source": "magic-8-ball", "where": {"and": []}},)"
// A list rule with a "where" key.
R"({"source": "list", "where": {"and": []}},)"
// A document rule with a "urls" key.
R"({"source": "document", "urls": ["foo.html"]},)"
// "where" clause is not a map.
R"({"source": "document", "where": [{"and": []}]},)"
// "where" clause does not contain one of "and", "or", "not",
// "href_matches" and "selector_matches"
R"({"source": "document", "where": {"foo": "bar"}},)"
// "where" clause has both "and" and "or" as keys
R"({"source": "document", "where": {"and": [], "or": []}},)"
// "and" key has object value.
R"({"source": "document", "where": {"and": {}}},)"
// "or" key has object value.
R"({"source": "document", "where": {"or": {}}},)"
// "and" key has invalid list value.
R"({"source": "document", "where": {"and": ["foo"]}},)"
// "not" key has list value.
R"({"source": "document", "where": {"not": [{"and": []}]}},)"
// "not" key has empty object value.
R"({"source": "document", "where": {"not": {}}},)"
// "not" key has invalid object value.
R"({"source": "document", "where": {"not": {"foo": "bar"}}},)"
// pattern is not a string or map value.
R"({"source": "document", "where": {"href_matches": false}},)"
// pattern string is invalid.
R"({"source": "document", "where": {"href_matches": "::"}},)"
// pattern object has invalid key.
R"({"source": "document", "where": {"href_matches": {"foo": "bar"}}},)"
// pattern object has invalid value.
R"({"source": "document",
"where": {"href_matches": {"protocol": "::"}}},)"
// Invalid key pairs.
R"({
"source": "document",
"where": {"href_matches": "/hello.html",
"invalid_key": "invalid_val"}
},)"
// Invalid values of "relative_to".
R"({
"source": "document",
"where": {"href_matches": "/hello.html",
"relative_to": 2022}
},)"
R"({
"source": "document",
"where": {"href_matches": "/hello.html",
"relative_to": "not_document"}
},)"
// "relative_to" appears at speculation rule level instead of the
// "href_matches" clause.
R"({
"source": "document",
"where": {"href_matches": "/hello"},
"relative_to": "document"
},)"
// Currently the spec does not allow three keys.
R"({"source": "document",
"where":{"href_matches": "/hello.html",
"relative_to": "document",
"world-cup": "2022"}},)"
// "selector_matches" paired with another key.
R"({"source": "document",
"where": {"selector_matches": ".valid", "second": "value"}
},)"
// "selector_matches" with an object value.
R"({"source": "document",
"where": {"selector_matches": {"selector": ".valid"}}
},)"
// "selector_matches" with an invalid CSS selector.
R"({"source": "document",
"where": {"selector_matches": "#invalid#"}
},)"
// "selector_matches" with a list with an object.
R"({"source": "document",
"where": {"selector_matches": [{"selector": ".valid"}]}
},)"
// "selector_matches" with a list with one valid and one invalid CSS
// selector.
R"({"source": "document",
"where": {"selector_matches": [".valid", "#invalid#"]}
},)"
// Invalid no-vary-search hint value.
R"({"source": "list",
"urls": ["/prefetch/list/page1.html"],
"expects_no_vary_search": 0
},)"
// Both "where" and "urls" with implicit source.
R"({"urls": ["/"], "where": {"selector_matches": "*"}},)"
// Neither "where" nor "urls" with implicit source.
R"({},)"
// valid document rule.
R"({"source": "document",
"where": {"and": [
{"or": [{"href_matches": "/hello.html"},
{"selector_matches": ".valid"}]},
{"not": {"and": [{"href_matches": {"hostname": "world.com"}}]}}
]}
}]})",
KURL("https://example.com/"), execution_context());
ASSERT_TRUE(rule_set);
EXPECT_EQ(rule_set->error_type(),
SpeculationRuleSetErrorType::kInvalidRulesSkipped);
EXPECT_THAT(
rule_set->prefetch_rules(),
ElementsAre(
MatchesPredicate(And({})),
MatchesPredicate(
And({Or({Href({URLPattern("/hello.html")}),
Selector({StyleRuleWithSelectorText(".valid")})}),
Neg(And({Href({URLPattern("https://world.com:*")})}))}))));
}
// Tests that errors of individual rules which cause them to be ignored are
// logged to the console.
TEST_F(DocumentRulesTest, ConsoleWarningForInvalidRule) {
auto* chrome_client = MakeGarbageCollected<ConsoleCapturingChromeClient>();
DummyPageHolder page_holder(/*initial_view_size=*/{}, chrome_client);
page_holder.GetFrame().GetSettings()->SetScriptEnabled(true);
Document& document = page_holder.GetDocument();
HTMLScriptElement* script =
MakeGarbageCollected<HTMLScriptElement>(document, CreateElementFlags());
script->setAttribute(html_names::kTypeAttr, AtomicString("speculationrules"));
script->setText(
R"({
"prefetch": [{
"source": "document",
"where": {"and": [], "or": []}
}]
})");
document.head()->appendChild(script);
EXPECT_TRUE(base::ranges::any_of(
chrome_client->ConsoleMessages(), [](const String& message) {
return message.Contains("Document rule predicate type is ambiguous");
}));
}
TEST_F(DocumentRulesTest, DocumentRuleParseErrors) {
auto* rule_set1 =
CreateRuleSet(R"({"prefetch": [{
"source": "document", "relative_to": "document"
}]})",
KURL("https://example.com"), execution_context());
EXPECT_THAT(
rule_set1->error_message().Utf8(),
::testing::HasSubstr("A document rule cannot have \"relative_to\" "
"outside the \"where\" clause"));
auto* rule_set2 =
CreateRuleSet(R"({"prefetch": [{
"source": "document",
"urls": ["/one", "/two"]
}]})",
KURL("https://example.com"), execution_context());
EXPECT_THAT(
rule_set2->error_message().Utf8(),
::testing::HasSubstr("A document rule cannot have a \"urls\" key"));
}
TEST_F(DocumentRulesTest, DocumentRulePredicateParseErrors) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
selector_matches_enabled{true};
String parse_error;
parse_error = CreateInvalidPredicate(R"("and": [], "not": {})");
EXPECT_THAT(
parse_error.Utf8(),
::testing::HasSubstr(
"Document rule predicate type is ambiguous, two types found"));
parse_error = CreateInvalidPredicate(R"()");
EXPECT_THAT(parse_error.Utf8(),
::testing::HasSubstr("Could not infer type of document rule "
"predicate, no valid type specified"));
parse_error =
CreateInvalidPredicate(R"("not": [{"href_matches": "foo.com"}])");
EXPECT_THAT(
parse_error.Utf8(),
::testing::HasSubstr("Document rule predicate must be an object"));
parse_error =
CreateInvalidPredicate(R"("and": [], "relative_to": "document")");
EXPECT_THAT(
parse_error.Utf8(),
::testing::HasSubstr(
"Document rule predicate with \"and\" key cannot have other keys."));
parse_error = CreateInvalidPredicate(R"("or": {})");
EXPECT_THAT(parse_error.Utf8(),
::testing::HasSubstr("\"or\" key should have a list value"));
parse_error = CreateInvalidPredicate(R"("href_matches": {"port": 1234})");
EXPECT_THAT(
parse_error.Utf8(),
::testing::HasSubstr("Values for a URL pattern object must be strings"));
parse_error =
CreateInvalidPredicate(R"("href_matches": {"path_name": "foo"})");
EXPECT_THAT(parse_error.Utf8(),
::testing::HasSubstr("Invalid key \"path_name\" for a URL "
"pattern object found"));
parse_error =
CreateInvalidPredicate(R"("href_matches": [["bar.com/foo.html"]])");
EXPECT_THAT(parse_error.Utf8(),
::testing::HasSubstr("Value for \"href_matches\" should "
"either be a string"));
parse_error = CreateInvalidPredicate(
R"("href_matches": "/home", "relative_to": "window")");
EXPECT_THAT(
parse_error.Utf8(),
::testing::HasSubstr("Unrecognized \"relative_to\" value: \"window\""));
parse_error = CreateInvalidPredicate(
R"("href_matches": "/home", "relativeto": "document")");
EXPECT_THAT(parse_error.Utf8(),
::testing::HasSubstr("Unrecognized key found: \"relativeto\""));
parse_error = CreateInvalidPredicate(R"("href_matches": "https//:")");
EXPECT_THAT(parse_error.Utf8(),
::testing::HasSubstr("URL Pattern for \"href_matches\" could not "
"be parsed: \"https//:\""));
parse_error = CreateInvalidPredicate(R"("selector_matches": {})");
EXPECT_THAT(
parse_error.Utf8(),
::testing::HasSubstr("Value for \"selector_matches\" must be a string"));
parse_error =
CreateInvalidPredicate(R"("selector_matches": "##bad_selector")");
EXPECT_THAT(
parse_error.Utf8(),
::testing::HasSubstr("\"##bad_selector\" is not a valid selector"));
}
TEST_F(DocumentRulesTest, DefaultPredicate) {
auto* rule_set = CreateRuleSet(
R"({
"prefetch": [{
"source": "document"
}]
})",
KURL("https://example.com/"), execution_context());
EXPECT_THAT(rule_set->prefetch_rules(), ElementsAre(MatchesPredicate(And())));
}
TEST_F(DocumentRulesTest, EvaluateCombinators) {
DummyPageHolder page_holder;
Document& document = page_holder.GetDocument();
HTMLAnchorElement* link = MakeGarbageCollected<HTMLAnchorElement>(document);
auto* empty_and = CreatePredicate(R"("and": [])");
EXPECT_THAT(empty_and, And());
EXPECT_TRUE(empty_and->Matches(*link));
auto* empty_or = CreatePredicate(R"("or": [])");
EXPECT_THAT(empty_or, Or());
EXPECT_FALSE(empty_or->Matches(*link));
auto* and_false_false_false =
CreatePredicate(R"("and": [{"or": []}, {"or": []}, {"or": []}])");
EXPECT_THAT(and_false_false_false, And({Or(), Or(), Or()}));
EXPECT_FALSE(and_false_false_false->Matches(*link));
auto* and_false_true_false =
CreatePredicate(R"("and": [{"or": []}, {"and": []}, {"or": []}])");
EXPECT_THAT(and_false_true_false, And({Or(), And(), Or()}));
EXPECT_FALSE(and_false_true_false->Matches(*link));
auto* and_true_true_true =
CreatePredicate(R"("and": [{"and": []}, {"and": []}, {"and": []}])");
EXPECT_THAT(and_true_true_true, And({And(), And(), And()}));
EXPECT_TRUE(and_true_true_true->Matches(*link));
auto* or_false_false_false =
CreatePredicate(R"("or": [{"or": []}, {"or": []}, {"or": []}])");
EXPECT_THAT(or_false_false_false, Or({Or(), Or(), Or()}));
EXPECT_FALSE(or_false_false_false->Matches(*link));
auto* or_false_true_false =
CreatePredicate(R"("or": [{"or": []}, {"and": []}, {"or": []}])");
EXPECT_THAT(or_false_true_false, Or({Or(), And(), Or()}));
EXPECT_TRUE(or_false_true_false->Matches(*link));
auto* or_true_true_true =
CreatePredicate(R"("or": [{"and": []}, {"and": []}, {"and": []}])");
EXPECT_THAT(or_true_true_true, Or({And(), And(), And()}));
EXPECT_TRUE(or_true_true_true->Matches(*link));
auto* not_true = CreatePredicate(R"("not": {"and": []})");
EXPECT_THAT(not_true, Neg(And()));
EXPECT_FALSE(not_true->Matches(*link));
auto* not_false = CreatePredicate(R"("not": {"or": []})");
EXPECT_THAT(not_false, Neg(Or()));
EXPECT_TRUE(not_false->Matches(*link));
}
TEST_F(DocumentRulesTest, EvaluateHrefMatches) {
DummyPageHolder page_holder;
Document& document = page_holder.GetDocument();
HTMLAnchorElement* link = MakeGarbageCollected<HTMLAnchorElement>(document);
link->setHref("https://foo.com/bar.html?fizz=buzz");
// No patterns specified, will not match any link.
auto* empty = CreatePredicate(R"("href_matches": [])");
EXPECT_FALSE(empty->Matches(*link));
// Single pattern (should match).
auto* single =
CreatePredicate(R"("href_matches": "https://foo.com/bar.html?*")");
EXPECT_TRUE(single->Matches(*link));
// Two patterns which don't match.
auto* double_fail = CreatePredicate(
R"("href_matches": ["http://foo.com/*", "https://bar.com/*"])");
EXPECT_FALSE(double_fail->Matches(*link));
// One pattern that matches, one that doesn't - should still pass due to
// an implicit or between patterns in a href_matches list.
auto* pass_fail = CreatePredicate(
R"("href_matches": ["https://foo.com/bar.html?*", "https://bar.com/*"])");
EXPECT_TRUE(pass_fail->Matches(*link));
}
HTMLAnchorElement* AddAnchor(ContainerNode& parent, const String& href) {
HTMLAnchorElement* link =
MakeGarbageCollected<HTMLAnchorElement>(parent.GetDocument());
link->setHref(href);
parent.appendChild(link);
return link;
}
HTMLAreaElement* AddAreaElement(ContainerNode& parent, const String& href) {
HTMLAreaElement* area =
MakeGarbageCollected<HTMLAreaElement>(parent.GetDocument());
area->setHref(href);
parent.appendChild(area);
return area;
}
// Tests that speculation candidates based of existing links are reported after
// a document rule is inserted.
TEST_F(DocumentRulesTest, SpeculationCandidatesReportedAfterInitialization) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
AddAnchor(*document.body(), "https://foo.com/doc.html");
AddAnchor(*document.body(), "https://bar.com/doc.html");
AddAnchor(*document.body(), "https://foo.com/doc2.html");
String speculation_script = R"(
{"prefetch": [
{"source": "document", "where": {"href_matches": "https://foo.com/*"}}
]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/doc.html"),
KURL("https://foo.com/doc2.html")));
}
// Tests that speculation candidates based of existing links are reported after
// a document rule is inserted. Test that the speculation candidates include
// No-Vary-Search hint.
TEST_F(DocumentRulesTest,
SpeculationCandidatesReportedAfterInitializationWithNVS) {
ScopedSpeculationRulesNoVarySearchHintForTest enable_no_vary_search_hint{
true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
AddAnchor(*document.body(), "https://foo.com/doc.html");
AddAnchor(*document.body(), "https://bar.com/doc.html");
AddAnchor(*document.body(), "https://foo.com/doc2.html");
String speculation_script = R"nvs(
{"prefetch": [{
"source": "document",
"where": {"href_matches": "https://foo.com/*"},
"expects_no_vary_search": "params=(\"a\")"
}]}
)nvs";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/doc.html"),
KURL("https://foo.com/doc2.html")));
// Check that the candidates have the correct No-Vary-Search hint.
EXPECT_THAT(candidates, ::testing::Each(::testing::AllOf(
HasNoVarySearchHint(), NVSVariesOnKeyOrder(),
NVSHasNoVaryParams("a"))));
}
// Tests that a new speculation candidate is reported after different
// modifications to a link.
TEST_F(DocumentRulesTest, SpeculationCandidatesUpdatedAfterLinkModifications) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
String speculation_script = R"(
{"prefetch": [
{"source": "document", "where": {"href_matches": "https://foo.com/*"}}
]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
ASSERT_TRUE(candidates.empty());
HTMLAnchorElement* link = nullptr;
// Add link with href that matches.
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
link = AddAnchor(*document.body(), "https://foo.com/action.html");
});
ASSERT_EQ(candidates.size(), 1u);
EXPECT_EQ(candidates[0]->url, KURL("https://foo.com/action.html"));
// Update link href to URL that doesn't match.
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
link->setHref("https://bar.com/document.html");
});
EXPECT_TRUE(candidates.empty());
// Update link href to URL that matches.
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
link->setHref("https://foo.com/document.html");
});
ASSERT_EQ(candidates.size(), 1u);
EXPECT_EQ(candidates[0]->url, KURL("https://foo.com/document.html"));
// Remove link.
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
[&]() { link->remove(); });
EXPECT_TRUE(candidates.empty());
}
// Tests that a new list of speculation candidates is reported after a rule set
// is added/removed.
TEST_F(DocumentRulesTest, SpeculationCandidatesUpdatedAfterRuleSetsChanged) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
KURL url_1 = KURL("https://foo.com/abc");
KURL url_2 = KURL("https://foo.com/xyz");
AddAnchor(*document.body(), url_1);
AddAnchor(*document.body(), url_2);
String speculation_script_1 = R"(
{"prefetch": [
{"source": "document", "where": {"href_matches": "https://foo.com/*"}}
]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script_1);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs(url_1, url_2));
// Add a new rule set; the number of candidates should double.
String speculation_script_2 = R"(
{"prerender": [
{"source": "document", "where": {"not":
{"href_matches": {"protocol": "https", "hostname": "bar.com"}}
}}
]}
)";
HTMLScriptElement* script_el = nullptr;
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
script_el = InsertSpeculationRules(document, speculation_script_2);
});
EXPECT_THAT(candidates, HasURLs(url_1, url_1, url_2, url_2));
EXPECT_THAT(candidates, ::testing::UnorderedElementsAre(
HasAction(mojom::SpeculationAction::kPrefetch),
HasAction(mojom::SpeculationAction::kPrefetch),
HasAction(mojom::SpeculationAction::kPrerender),
HasAction(mojom::SpeculationAction::kPrerender)));
// Remove the recently added rule set, the number of candidates should be
// halved.
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
[&]() { script_el->remove(); });
ASSERT_EQ(candidates.size(), 2u);
EXPECT_THAT(candidates, HasURLs(url_1, url_2));
EXPECT_THAT(candidates,
::testing::Each(HasAction(mojom::SpeculationAction::kPrefetch)));
}
// Tests that list and document speculation rules work in combination correctly.
TEST_F(DocumentRulesTest, ListRuleCombinedWithDocumentRule) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
AddAnchor(*document.body(), "https://foo.com/bar");
String speculation_script = R"(
{"prefetch": [
{"source": "document"},
{"source": "list", "urls": ["https://bar.com/foo"]}
]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/bar"),
KURL("https://bar.com/foo")));
}
// Tests that candidates created for document rules are correct when
// "anonymous-client-ip-when-cross-origin" is specified.
TEST_F(DocumentRulesTest, RequiresAnonymousClientIPWhenCrossOrigin) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
AddAnchor(*document.body(), "https://foo.com/bar");
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"requires": ["anonymous-client-ip-when-cross-origin"]
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
ASSERT_EQ(candidates.size(), 1u);
EXPECT_TRUE(candidates[0]->requires_anonymous_client_ip_when_cross_origin);
}
// Tests that a link inside a shadow tree is included when creating
// document-rule based speculation candidates. Also tests that an "unslotted"
// link (link inside shadow host that isn't assigned to a slot) is not included.
TEST_F(DocumentRulesTest, LinkInShadowTreeIncluded) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
if (!RuntimeEnabledFeatures::
SpeculationRulesDocumentRulesSelectorMatchesEnabled(
page_holder.GetFrame().DomWindow())) {
GTEST_SKIP() << "This test doesn't work correctly with selector_matches "
"disabled, due to a behavior change. Remove this skip "
"when selector_matches support is always on.";
}
Document& document = page_holder.GetDocument();
ShadowRoot& shadow_root =
document.body()->AttachShadowRootForTesting(ShadowRootMode::kOpen);
auto* shadow_tree_link = AddAnchor(shadow_root, "https://foo.com/bar.html");
AddAnchor(*document.body(), "https://foo.com/unslotted");
String speculation_script = R"(
{"prefetch": [
{"source": "document", "where": {"href_matches": "https://foo.com/*"}}
]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/bar.html")));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
shadow_tree_link->setHref("https://not-foo.com/");
});
EXPECT_TRUE(candidates.empty());
HTMLAnchorElement* shadow_tree_link_2 = nullptr;
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
shadow_tree_link_2 = AddAnchor(shadow_root, "https://foo.com/buzz");
});
ASSERT_EQ(candidates.size(), 1u);
EXPECT_EQ(candidates[0]->url, KURL("https://foo.com/buzz"));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
[&]() { shadow_tree_link_2->remove(); });
EXPECT_TRUE(candidates.empty());
}
// Tests that an anchor element with no href attribute is handled correctly.
TEST_F(DocumentRulesTest, LinkWithNoHrefAttribute) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
auto* link = MakeGarbageCollected<HTMLAnchorElement>(document);
document.body()->appendChild(link);
ASSERT_FALSE(link->FastHasAttribute(html_names::kHrefAttr));
String speculation_script = R"(
{"prefetch": [
{"source": "document", "where": {"href_matches": "https://foo.com/*"}}
]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
ASSERT_TRUE(candidates.empty());
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
link->setHref("https://foo.com/bar");
});
ASSERT_EQ(candidates.size(), 1u);
ASSERT_EQ(candidates[0]->url, "https://foo.com/bar");
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
link->removeAttribute(html_names::kHrefAttr);
});
ASSERT_TRUE(candidates.empty());
// Just to test that no DCHECKs are hit.
link->remove();
}
// Tests that links with non-HTTP(s) urls are ignored.
TEST_F(DocumentRulesTest, LinkWithNonHttpHref) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
auto* link = AddAnchor(*document.body(), "mailto:abc@xyz.com");
String speculation_script = R"({"prefetch": [{"source": "document"}]})";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
ASSERT_TRUE(candidates.empty());
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
link->setHref("https://foo.com/bar");
});
EXPECT_THAT(candidates, HasURLs("https://foo.com/bar"));
}
// Tests a couple of edge cases:
// 1) Removing a link that doesn't match any rules
// 2) Adding and removing a link before running microtasks (i.e. before calling
// UpdateSpeculationCandidates).
TEST_F(DocumentRulesTest, RemovingUnmatchedAndPendingLinks) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
auto* unmatched_link = AddAnchor(*document.body(), "https://bar.com/foo");
String speculation_script = R"(
{"prefetch": [
{"source": "document", "where": {"href_matches": "https://foo.com/*"}}
]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_TRUE(candidates.empty());
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
auto* pending_link = AddAnchor(*document.body(), "https://foo.com/bar");
unmatched_link->remove();
pending_link->remove();
});
EXPECT_TRUE(candidates.empty());
}
// Tests if things still work if we use <area> instead of <a>.
TEST_F(DocumentRulesTest, AreaElement) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
HTMLAreaElement* area =
AddAreaElement(*document.body(), "https://foo.com/action.html");
String speculation_script = R"(
{"prefetch": [
{"source": "document", "where": {"href_matches": "https://foo.com/*"}}
]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
ASSERT_EQ(candidates.size(), 1u);
EXPECT_EQ(candidates[0]->url, KURL("https://foo.com/action.html"));
// Update area href to URL that doesn't match.
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
area->setHref("https://bar.com/document.html");
});
EXPECT_TRUE(candidates.empty());
// Update area href to URL that matches.
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
area->setHref("https://foo.com/document.html");
});
ASSERT_EQ(candidates.size(), 1u);
EXPECT_EQ(candidates[0]->url, KURL("https://foo.com/document.html"));
// Remove area.
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
[&]() { area->remove(); });
EXPECT_TRUE(candidates.empty());
}
// Test that adding a link to an element that isn't connected doesn't DCHECK.
TEST_F(DocumentRulesTest, DisconnectedLink) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
String speculation_script = R"(
{"prefetch": [
{"source": "document", "where": {"href_matches": "https://foo.com/*"}}
]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
ASSERT_TRUE(candidates.empty());
HTMLDivElement* div = nullptr;
HTMLAnchorElement* link = nullptr;
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
div = MakeGarbageCollected<HTMLDivElement>(document);
link = AddAnchor(*div, "https://foo.com/blah.html");
document.body()->AppendChild(div);
});
EXPECT_EQ(candidates.size(), 1u);
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
div->remove();
link->remove();
});
EXPECT_TRUE(candidates.empty());
}
// Similar to test above, but now inside a shadow tree.
TEST_F(DocumentRulesTest, DisconnectedLinkInShadowTree) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
String speculation_script = R"(
{"prefetch": [
{"source": "document", "where": {"href_matches": "https://foo.com/*"}}
]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
ASSERT_TRUE(candidates.empty());
HTMLDivElement* div = nullptr;
HTMLAnchorElement* link = nullptr;
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
div = MakeGarbageCollected<HTMLDivElement>(document);
ShadowRoot& shadow_root =
div->AttachShadowRootForTesting(ShadowRootMode::kOpen);
link = AddAnchor(shadow_root, "https://foo.com/blah.html");
document.body()->AppendChild(div);
});
EXPECT_EQ(candidates.size(), 1u);
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
div->remove();
link->remove();
});
EXPECT_TRUE(candidates.empty());
}
// Tests that a document rule's specified referrer policy is used.
TEST_F(DocumentRulesTest, ReferrerPolicy) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
auto* link_with_referrer = AddAnchor(*document.body(), "https://foo.com/abc");
link_with_referrer->setAttribute(html_names::kReferrerpolicyAttr,
AtomicString("same-origin"));
auto* link_with_rel_no_referrer =
AddAnchor(*document.body(), "https://foo.com/def");
link_with_rel_no_referrer->setAttribute(html_names::kRelAttr,
AtomicString("noreferrer"));
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"href_matches": "https://foo.com/*"},
"referrer_policy": "strict-origin"
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, ::testing::Each(HasReferrerPolicy(
network::mojom::ReferrerPolicy::kStrictOrigin)));
}
// Tests that a link's referrer-policy value is used if one is not specified
// in the document rule.
TEST_F(DocumentRulesTest, LinkReferrerPolicy) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
page_holder.GetFrame().DomWindow()->SetReferrerPolicy(
network::mojom::ReferrerPolicy::kStrictOrigin);
auto* link_with_referrer = AddAnchor(*document.body(), "https://foo.com/abc");
link_with_referrer->setAttribute(html_names::kReferrerpolicyAttr,
AtomicString("same-origin"));
auto* link_with_no_referrer =
AddAnchor(*document.body(), "https://foo.com/xyz");
auto* link_with_rel_noreferrer =
AddAnchor(*document.body(), "https://foo.com/mno");
link_with_rel_noreferrer->setAttribute(html_names::kRelAttr,
AtomicString("noreferrer"));
auto* link_with_invalid_referrer =
AddAnchor(*document.body(), "https://foo.com/pqr");
link_with_invalid_referrer->setAttribute(html_names::kReferrerpolicyAttr,
AtomicString("invalid"));
auto* link_with_disallowed_referrer =
AddAnchor(*document.body(), "https://foo.com/aaa");
link_with_disallowed_referrer->setAttribute(html_names::kReferrerpolicyAttr,
AtomicString("unsafe-url"));
String speculation_script = R"(
{"prefetch": [
{"source": "document", "where": {"href_matches": "https://foo.com/*"}}
]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(
candidates,
::testing::UnorderedElementsAre(
::testing::AllOf(
HasURL(link_with_referrer->HrefURL()),
HasReferrerPolicy(network::mojom::ReferrerPolicy::kSameOrigin)),
::testing::AllOf(
HasURL(link_with_rel_noreferrer->HrefURL()),
HasReferrerPolicy(network::mojom::ReferrerPolicy::kNever)),
::testing::AllOf(
HasURL(link_with_no_referrer->HrefURL()),
HasReferrerPolicy(network::mojom::ReferrerPolicy::kStrictOrigin)),
::testing::AllOf(
HasURL(link_with_invalid_referrer->HrefURL()),
HasReferrerPolicy(
network::mojom::ReferrerPolicy::kStrictOrigin))));
// Console message should have been logged for
// |link_with_disallowed_referrer|.
const auto& console_message_storage =
page_holder.GetPage().GetConsoleMessageStorage();
EXPECT_EQ(console_message_storage.size(), 1u);
EXPECT_THAT(console_message_storage.at(0)->Nodes(),
testing::Contains(link_with_disallowed_referrer->GetDomNodeId()));
}
// Tests that changing the "referrerpolicy" attribute results in the
// corresponding speculation candidate updating.
TEST_F(DocumentRulesTest, ReferrerPolicyAttributeChangeCausesLinkInvalidation) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
auto* link_with_referrer = AddAnchor(*document.body(), "https://foo.com/abc");
link_with_referrer->setAttribute(html_names::kReferrerpolicyAttr,
AtomicString("same-origin"));
String speculation_script = R"(
{"prefetch": [
{"source": "document", "where": {"href_matches": "https://foo.com/*"}}
]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, ElementsAre(HasReferrerPolicy(
network::mojom::ReferrerPolicy::kSameOrigin)));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
link_with_referrer->setAttribute(html_names::kReferrerpolicyAttr,
AtomicString("strict-origin"));
});
EXPECT_THAT(candidates, ElementsAre(HasReferrerPolicy(
network::mojom::ReferrerPolicy::kStrictOrigin)));
}
// Tests that changing the "rel" attribute results in the corresponding
// speculation candidate updating. Also tests that "rel=noreferrer" overrides
// the referrerpolicy attribute.
TEST_F(DocumentRulesTest, RelAttributeChangeCausesLinkInvalidation) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
auto* link = AddAnchor(*document.body(), "https://foo.com/abc");
link->setAttribute(html_names::kReferrerpolicyAttr,
AtomicString("same-origin"));
String speculation_script = R"(
{"prefetch": [
{"source": "document", "where": {"href_matches": "https://foo.com/*"}}
]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, ElementsAre(HasReferrerPolicy(
network::mojom::ReferrerPolicy::kSameOrigin)));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
link->setAttribute(html_names::kRelAttr, AtomicString("noreferrer"));
});
EXPECT_THAT(
candidates,
ElementsAre(HasReferrerPolicy(network::mojom::ReferrerPolicy::kNever)));
}
TEST_F(DocumentRulesTest, ReferrerMetaChangeShouldInvalidateCandidates) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
AddAnchor(*document.body(), "https://foo.com/abc");
String speculation_script = R"(
{"prefetch": [
{"source": "document", "where": {"href_matches": "https://foo.com/*"}}
]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(
candidates,
ElementsAre(HasReferrerPolicy(
network::mojom::ReferrerPolicy::kStrictOriginWhenCrossOrigin)));
auto* meta =
MakeGarbageCollected<HTMLMetaElement>(document, CreateElementFlags());
meta->setAttribute(html_names::kNameAttr, AtomicString("referrer"));
meta->setAttribute(html_names::kContentAttr, AtomicString("strict-origin"));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
document.head()->appendChild(meta);
});
EXPECT_THAT(candidates, ElementsAre(HasReferrerPolicy(
network::mojom::ReferrerPolicy::kStrictOrigin)));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
meta->setAttribute(html_names::kContentAttr, AtomicString("same-origin"));
});
EXPECT_THAT(candidates, ElementsAre(HasReferrerPolicy(
network::mojom::ReferrerPolicy::kSameOrigin)));
}
TEST_F(DocumentRulesTest, BaseURLChanged) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.SetBaseURLOverride(KURL("https://foo.com"));
AddAnchor(*document.body(), "https://foo.com/bar");
AddAnchor(*document.body(), "/bart");
String speculation_script = R"(
{"prefetch": [
{"source": "document", "where": {"href_matches": "/bar*"}}
]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/bar"),
KURL("https://foo.com/bart")));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
document.SetBaseURLOverride(KURL("https://bar.com"));
});
// After the base URL changes, "https://foo.com/bar" is matched against
// "https://bar.com/bar*" and doesn't match. "/bart" is resolved to
// "https://bar.com/bart" and matches with "https://bar.com/bar*".
EXPECT_THAT(candidates, HasURLs("https://bar.com/bart"));
}
TEST_F(DocumentRulesTest, TargetHintFromLink) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
auto* anchor_1 = AddAnchor(*document.body(), "https://foo.com/bar");
anchor_1->setAttribute(html_names::kTargetAttr, AtomicString("_blank"));
auto* anchor_2 = AddAnchor(*document.body(), "https://fizz.com/buzz");
anchor_2->setAttribute(html_names::kTargetAttr, AtomicString("_self"));
AddAnchor(*document.body(), "https://hello.com/world");
String speculation_script = R"(
{
"prefetch": [{
"source": "document",
"where": {"href_matches": "https://foo.com/bar"}
}],
"prerender": [{"source": "document"}]
}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(
candidates,
::testing::UnorderedElementsAre(
::testing::AllOf(
HasAction(mojom::blink::SpeculationAction::kPrefetch),
HasTargetHint(mojom::blink::SpeculationTargetHint::kNoHint)),
::testing::AllOf(
HasURL(KURL("https://foo.com/bar")),
HasAction(mojom::blink::SpeculationAction::kPrerender),
HasTargetHint(mojom::blink::SpeculationTargetHint::kBlank)),
::testing::AllOf(
HasURL(KURL("https://fizz.com/buzz")),
HasAction(mojom::blink::SpeculationAction::kPrerender),
HasTargetHint(mojom::blink::SpeculationTargetHint::kSelf)),
::testing::AllOf(
HasURL(KURL("https://hello.com/world")),
HasAction(mojom::blink::SpeculationAction::kPrerender),
HasTargetHint(mojom::blink::SpeculationTargetHint::kNoHint))));
}
TEST_F(DocumentRulesTest, TargetHintFromSpeculationRuleOverridesLinkTarget) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
auto* anchor = AddAnchor(*document.body(), "https://foo.com/bar");
anchor->setAttribute(html_names::kTargetAttr, AtomicString("_blank"));
String speculation_script = R"(
{"prerender": [{"source": "document", "target_hint": "_self"}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, ::testing::ElementsAre(HasTargetHint(
mojom::blink::SpeculationTargetHint::kSelf)));
}
TEST_F(DocumentRulesTest, TargetHintFromLinkDynamic) {
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
auto* anchor = AddAnchor(*document.body(), "https://foo.com/bar");
String speculation_script = R"({"prerender": [{"source": "document"}]})";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, ::testing::ElementsAre(HasTargetHint(
mojom::blink::SpeculationTargetHint::kNoHint)));
HTMLBaseElement* base_element;
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
base_element = MakeGarbageCollected<HTMLBaseElement>(document);
base_element->setAttribute(html_names::kTargetAttr, AtomicString("_self"));
document.head()->appendChild(base_element);
});
EXPECT_THAT(candidates, ::testing::ElementsAre(HasTargetHint(
mojom::blink::SpeculationTargetHint::kSelf)));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
anchor->setAttribute(html_names::kTargetAttr, AtomicString("_blank"));
});
EXPECT_THAT(candidates, ::testing::ElementsAre(HasTargetHint(
mojom::blink::SpeculationTargetHint::kBlank)));
}
// Tests that "selector_matches" is not parsed without the RuntimeEnabledFeature
// enabled.
TEST_F(DocumentRulesTest, SelectorMatchesIsNotParsed) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
disabled_selector_matches_{false};
auto* rule_set =
CreateRuleSet(R"({"prefetch": [
{"source": "document", "where": {"selector_matches": ".valid"}}
]})",
KURL("https://example.com"), execution_context());
EXPECT_TRUE(rule_set->prefetch_rules().empty());
}
TEST_F(DocumentRulesTest, ParseSelectorMatches) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
auto* simple_selector_matches = CreatePredicate(R"(
"selector_matches": ".valid"
)");
EXPECT_THAT(simple_selector_matches,
Selector({StyleRuleWithSelectorText(".valid")}));
auto* simple_selector_matches_list = CreatePredicate(R"(
"selector_matches": [".one", "#two"]
)");
EXPECT_THAT(simple_selector_matches_list,
Selector({StyleRuleWithSelectorText(".one"),
StyleRuleWithSelectorText("#two")}));
auto* selector_matches_with_compound_selector = CreatePredicate(R"(
"selector_matches": ".interesting-section > a"
)");
EXPECT_THAT(
selector_matches_with_compound_selector,
Selector({StyleRuleWithSelectorText(".interesting-section > a")}));
}
TEST_F(DocumentRulesTest, GetStyleRules) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
auto* predicate = CreatePredicate(R"(
"and": [
{"or": [
{"not": {"selector_matches": "span.fizz > a"}},
{"selector_matches": "#bar a"}
]},
{"selector_matches": "a.foo"}
]
)");
EXPECT_THAT(
predicate,
And({Or({Neg(Selector({StyleRuleWithSelectorText("span.fizz > a")})),
Selector({StyleRuleWithSelectorText("#bar a")})}),
Selector({StyleRuleWithSelectorText("a.foo")})}));
EXPECT_THAT(predicate->GetStyleRules(),
UnorderedElementsAre(StyleRuleWithSelectorText("span.fizz > a"),
StyleRuleWithSelectorText("#bar a"),
StyleRuleWithSelectorText("a.foo")));
}
TEST_F(DocumentRulesTest, SelectorMatchesAddsCandidates) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.body()->setInnerHTML(R"HTML(
<div id="important-section"></div>
<div id="unimportant-section"></div>
)HTML");
auto* important_section =
document.getElementById(AtomicString("important-section"));
auto* unimportant_section =
document.getElementById(AtomicString("unimportant-section"));
AddAnchor(*important_section, "https://foo.com/foo");
AddAnchor(*unimportant_section, "https://foo.com/bar");
AddAnchor(*important_section, "https://foo.com/fizz");
AddAnchor(*unimportant_section, "https://foo.com/buzz");
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"selector_matches": "#important-section > a"}
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/foo"),
KURL("https://foo.com/fizz")));
}
TEST_F(DocumentRulesTest, SelectorMatchesIsDynamic) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.body()->setInnerHTML(R"HTML(
<div id="important-section"></div>
<div id="unimportant-section"></div>
)HTML");
auto* important_section =
document.getElementById(AtomicString("important-section"));
auto* unimportant_section =
document.getElementById(AtomicString("unimportant-section"));
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"or": [
{"selector_matches": "#important-section > a"},
{"selector_matches": ".important-link"}
]}
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_TRUE(candidates.empty());
HTMLAnchorElement* second_anchor = nullptr;
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
AddAnchor(*important_section, "https://foo.com/fizz");
second_anchor = AddAnchor(*unimportant_section, "https://foo.com/buzz");
});
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/fizz")));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
second_anchor->setAttribute(html_names::kClassAttr,
AtomicString("important-link"));
});
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/fizz"),
KURL("https://foo.com/buzz")));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
important_section->SetIdAttribute(AtomicString("random-section"));
});
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/buzz")));
}
TEST_F(DocumentRulesTest, AddingDocumentRulesInvalidatesStyle) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.body()->setInnerHTML(R"HTML(
<div id="important-section"></div>
<div id="unimportant-section"></div>
)HTML");
auto* important_section =
document.getElementById(AtomicString("important-section"));
auto* unimportant_section =
document.getElementById(AtomicString("unimportant-section"));
AddAnchor(*important_section, "https://foo.com/fizz");
AddAnchor(*unimportant_section, "https://foo.com/buzz");
page_holder.GetFrame().GetSettings()->SetScriptEnabled(true);
page_holder.GetFrameView().UpdateAllLifecyclePhasesForTest();
ASSERT_FALSE(document.NeedsLayoutTreeUpdate());
auto* script_without_selector_matches = InsertSpeculationRules(document, R"(
{"prefetch": [{"source": "document", "where": {"href_matches": "/foo"}}]}
)");
ASSERT_FALSE(important_section->ChildNeedsStyleRecalc());
auto* script_with_irrelevant_selector_matches =
InsertSpeculationRules(document, R"(
{"prefetch": [{
"source": "document",
"where": {"selector_matches": "#irrelevant a"}
}]}
)");
ASSERT_FALSE(important_section->ChildNeedsStyleRecalc());
auto* script_with_selector_matches = InsertSpeculationRules(document, R"(
{"prefetch": [{
"source": "document",
"where": {"selector_matches": "#important-section a"}
}]}
)");
EXPECT_TRUE(important_section->ChildNeedsStyleRecalc());
page_holder.GetFrameView().UpdateAllLifecyclePhasesForTest();
ASSERT_FALSE(important_section->ChildNeedsStyleRecalc());
// Test removing SpeculationRuleSets, removing a ruleset should also cause
// invalidations.
script_with_selector_matches->remove();
EXPECT_TRUE(important_section->ChildNeedsStyleRecalc());
page_holder.GetFrameView().UpdateAllLifecyclePhasesForTest();
script_without_selector_matches->remove();
ASSERT_FALSE(important_section->ChildNeedsStyleRecalc());
script_with_irrelevant_selector_matches->remove();
ASSERT_FALSE(important_section->ChildNeedsStyleRecalc());
}
TEST_F(DocumentRulesTest, BasicStyleInvalidation) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.body()->setInnerHTML(R"HTML(
<div id="important-section"></div>
<div id="unimportant-section"></div>
)HTML");
auto* important_section =
document.getElementById(AtomicString("important-section"));
auto* unimportant_section =
document.getElementById(AtomicString("unimportant-section"));
AddAnchor(*important_section, "https://foo.com/fizz");
AddAnchor(*unimportant_section, "https://foo.com/buzz");
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"selector_matches": "#important-section > a"}
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
EXPECT_FALSE(document.NeedsLayoutTreeUpdate());
unimportant_section->SetIdAttribute(AtomicString("random-section"));
EXPECT_FALSE(document.NeedsLayoutTreeUpdate());
unimportant_section->SetIdAttribute(AtomicString("important-section"));
EXPECT_TRUE(document.NeedsLayoutTreeUpdate());
}
TEST_F(DocumentRulesTest, IrrelevantDOMChangeShouldNotInvalidateCandidateList) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.body()->setInnerHTML(R"HTML(
<div id="important-section"></div>
<div id="unimportant-section"></div>
)HTML");
auto* important_section =
document.getElementById(AtomicString("important-section"));
auto* unimportant_section =
document.getElementById(AtomicString("unimportant-section"));
AddAnchor(*important_section, "https://foo.com/fizz");
AddAnchor(*unimportant_section, "https://foo.com/buzz");
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"selector_matches": "#important-section > a"}
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/fizz")));
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host, [&]() {
unimportant_section->SetIdAttribute(AtomicString("random-section"));
page_holder.GetFrameView().UpdateAllLifecyclePhasesForTest();
}));
}
TEST_F(DocumentRulesTest, SelectorMatchesInsideShadowTree) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
ShadowRoot& shadow_root =
document.body()->AttachShadowRootForTesting(ShadowRootMode::kOpen);
shadow_root.setInnerHTML(R"HTML(
<div id="important-section"></div>
<div id="unimportant-section"></div>
)HTML");
auto* important_section =
shadow_root.getElementById(AtomicString("important-section"));
auto* unimportant_section =
shadow_root.getElementById(AtomicString("unimportant-section"));
AddAnchor(*important_section, "https://foo.com/fizz");
AddAnchor(*unimportant_section, "https://foo.com/buzz");
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"selector_matches": "#important-section > a"}
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/fizz")));
}
TEST_F(DocumentRulesTest, SelectorMatchesWithScopePseudoSelector) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.body()->setAttribute(html_names::kClassAttr, AtomicString("foo"));
document.body()->setInnerHTML(R"HTML(
<a href="https://foo.com/fizz"></a>
<div class="foo">
<a href="https://foo.com/buzz"></a>
</div>
)HTML");
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"selector_matches": ":scope > .foo > a"}
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/fizz")));
}
// Basic test to check that we wait for UpdateStyle before sending a list of
// updated candidates to the browser process when "selector_matches" is
// enabled.
TEST_F(DocumentRulesTest, UpdateQueueingWithSelectorMatches_1) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.body()->setInnerHTML(R"HTML(
<div id="important-section"></div>
<div id="unimportant-section"></div>
)HTML");
auto* important_section =
document.getElementById(AtomicString("important-section"));
auto* unimportant_section =
document.getElementById(AtomicString("unimportant-section"));
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"href_matches": "https://bar.com/*"}
}]}
)";
// No update should be sent before running a style update after inserting
// the rules.
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host,
[&]() {
page_holder.GetFrame().GetSettings()->SetScriptEnabled(true);
InsertSpeculationRules(document, speculation_script);
},
IncludesStyleUpdate{false}));
ASSERT_TRUE(document.NeedsLayoutTreeUpdate());
// The list of candidates is updated after a style update.
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, []() {});
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs());
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host,
[&]() { AddAnchor(*document.body(), "https://bar.com/fizz.html"); },
IncludesStyleUpdate{false}));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, []() {});
EXPECT_THAT(candidates, HasURLs(KURL("https://bar.com/fizz.html")));
String speculation_script_with_selector_matches = R"(
{"prefetch": [{
"source": "document",
"where": {"selector_matches": "#important-section a"}
}]}
)";
// Insert a speculation ruleset with "selector_matches". This will not require
// a style update, as adding the ruleset itself will not cause any
// invalidations (there are no existing elements that match the selector in
// the new ruleset).
PropagateRulesToStubSpeculationHost(
page_holder, speculation_host,
[&]() {
InsertSpeculationRules(document,
speculation_script_with_selector_matches);
},
IncludesStyleUpdate{false});
ASSERT_FALSE(document.NeedsLayoutTreeUpdate());
EXPECT_THAT(candidates, HasURLs(KURL("https://bar.com/fizz.html")));
// Add two new links. We should not update speculation candidates until we run
// UpdateStyle.
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host,
[&]() {
AddAnchor(*important_section, "https://foo.com/fizz.html");
AddAnchor(*unimportant_section, "https://foo.com/buzz.html");
},
IncludesStyleUpdate{false}));
ASSERT_TRUE(document.NeedsLayoutTreeUpdate());
// Runs UpdateStyle; new speculation candidates should be sent.
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, []() {});
EXPECT_THAT(candidates, HasURLs(KURL("https://bar.com/fizz.html"),
KURL("https://foo.com/fizz.html")));
}
// This tests that we don't need to wait for a style update if an operation
// does not invalidate style.
TEST_F(DocumentRulesTest, UpdateQueueingWithSelectorMatches_2) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.body()->setInnerHTML(R"HTML(
<div id="important-section"></div>
)HTML");
auto* important_section =
document.getElementById(AtomicString("important-section"));
AddAnchor(*important_section, "https://foo.com/bar");
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"selector_matches": "#important-section a"}
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/bar")));
// We shouldn't have to wait for UpdateStyle if the update doesn't cause
// style invalidation.
PropagateRulesToStubSpeculationHost(
page_holder, speculation_host,
[&]() {
EXPECT_FALSE(document.NeedsLayoutTreeUpdate());
auto* referrer_meta = MakeGarbageCollected<HTMLMetaElement>(
document, CreateElementFlags());
referrer_meta->setAttribute(html_names::kNameAttr,
AtomicString("referrer"));
referrer_meta->setAttribute(html_names::kContentAttr,
AtomicString("strict-origin"));
document.head()->appendChild(referrer_meta);
EXPECT_FALSE(document.NeedsLayoutTreeUpdate());
},
IncludesStyleUpdate{false});
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/bar")));
}
// This tests a scenario where we queue an update microtask, invalidate style,
// update style, and then run the microtask.
TEST_F(DocumentRulesTest, UpdateQueueingWithSelectorMatches_3) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.body()->setInnerHTML(R"HTML(
<div id="important-section"></div>
)HTML");
auto* important_section =
document.getElementById(AtomicString("important-section"));
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"selector_matches": "#important-section a"}
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs());
// Note: AddAnchor below will queue a microtask before invalidating style
// (Node::InsertedInto is called before style invalidation).
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
AddAnchor(*important_section, "https://foo.com/bar.html");
});
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/bar.html")));
}
// This tests a scenario where we queue a microtask update, invalidate style,
// and then run the microtask.
TEST_F(DocumentRulesTest, UpdateQueueingWithSelectorMatches_4) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.body()->setInnerHTML(R"HTML(
<div id="important-section"></div>
)HTML");
auto* important_section =
document.getElementById(AtomicString("important-section"));
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"selector_matches": "#important-section a"}
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs());
// A microtask will be queued and run before a style update - but no list of
// candidates should be sent as style isn't clean. Note: AddAnchor below will
// queue a microtask before invalidating style (Node::InsertedInto is called
// before style invalidation).
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host,
[&]() { AddAnchor(*important_section, "https://foo.com/bar"); },
IncludesStyleUpdate{false}));
ASSERT_TRUE(document.NeedsLayoutTreeUpdate());
// Updating style should trigger UpdateSpeculationCandidates.
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, []() {});
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/bar")));
}
// Tests update queueing after making a DOM modification that doesn't directly
// affect a link.
TEST_F(DocumentRulesTest, UpdateQueueingWithSelectorMatches_5) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.body()->setInnerHTML(R"HTML(
<div id="important-section"></div>
)HTML");
auto* important_section =
document.getElementById(AtomicString("important-section"));
AddAnchor(*important_section, "https://foo.com/bar");
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"selector_matches": "#important-section a"}
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/bar")));
// Changing the link's container's ID will not queue a microtask on its own.
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host,
[&]() {
important_section->SetIdAttribute(AtomicString("unimportant-section"));
},
IncludesStyleUpdate{false}));
// After style updates, we should update the list of speculation candidates.
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, []() {});
EXPECT_THAT(candidates, HasURLs());
}
TEST_F(DocumentRulesTest, LinksWithoutComputedStyle) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.body()->setInnerHTML(R"HTML(
<div id="important-section"></div>
)HTML");
auto* important_section =
document.getElementById(AtomicString("important-section"));
AddAnchor(*important_section, "https://foo.com/bar");
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"selector_matches": "#important-section a"}
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
InsertSpeculationRules(document, speculation_script);
});
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/bar")));
// Changing a link's ancestor to display:none should trigger an update and
// remove it from the candidate list.
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
important_section->SetInlineStyleProperty(CSSPropertyID::kDisplay,
CSSValueID::kNone);
});
EXPECT_THAT(candidates, HasURLs());
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
important_section->RemoveInlineStyleProperty(CSSPropertyID::kDisplay);
});
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/bar")));
// Adding a shadow root will remove the anchor from the flat tree, and it will
// stop being rendered. It should trigger an update and be removed from
// the candidate list.
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
important_section->AttachShadowRootForTesting(ShadowRootMode::kOpen);
});
EXPECT_THAT(candidates, HasURLs());
}
TEST_F(DocumentRulesTest, LinksWithoutComputedStyle_HrefMatches) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.body()->setInnerHTML(R"HTML(
<div id="important-section"></div>
)HTML");
auto* important_section =
document.getElementById(AtomicString("important-section"));
auto* anchor = AddAnchor(*important_section, "https://foo.com/bar");
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"href_matches": "https://foo.com/*"}
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
InsertSpeculationRules(document, speculation_script);
});
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/bar")));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
anchor->SetInlineStyleProperty(CSSPropertyID::kDisplay, CSSValueID::kNone);
});
EXPECT_THAT(candidates, HasURLs());
}
// When "selector_matches" is disabled, we include "display: none" links.
// TODO(crbug.com/1371522): Remove this test when "selector_matches" is always
// enabled.
TEST_F(DocumentRulesTest, LinksWithoutComputedStyle_SelectorMatchesDisabled) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
disabled_selector_matches{false};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.body()->setInnerHTML(R"HTML(
<div id="important-section"></div>
)HTML");
auto* important_section =
document.getElementById(AtomicString("important-section"));
auto* anchor = AddAnchor(*important_section, "https://foo.com/bar");
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"href_matches": "https://foo.com/*"}
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
InsertSpeculationRules(document, speculation_script);
});
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/bar")));
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host, [&]() {
anchor->SetInlineStyleProperty(CSSPropertyID::kDisplay,
CSSValueID::kNone);
page_holder.GetFrameView().UpdateAllLifecyclePhasesForTest();
}));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
anchor->setHref("https://foo.com/two");
});
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/two")));
}
TEST_F(DocumentRulesTest, LinkInsideDisplayLockedElement) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.body()->setInnerHTML(R"HTML(
<div id="important-section"></div>
)HTML");
auto* important_section =
document.getElementById(AtomicString("important-section"));
AddAnchor(*important_section, "https://foo.com/bar");
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"selector_matches": "#important-section a"}
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/bar")));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
important_section->SetInlineStyleProperty(CSSPropertyID::kContentVisibility,
CSSValueID::kHidden);
});
EXPECT_THAT(candidates, HasURLs());
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
important_section->RemoveInlineStyleProperty(
CSSPropertyID::kContentVisibility);
});
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/bar")));
}
TEST_F(DocumentRulesTest, LinkInsideNestedDisplayLockedElement) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.body()->setInnerHTML(R"HTML(
<div id="important-section">
<div id="links"></div>
</div>
)HTML");
auto* important_section =
document.getElementById(AtomicString("important-section"));
auto* links = document.getElementById(AtomicString("links"));
AddAnchor(*links, "https://foo.com/bar");
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"selector_matches": "#important-section a"}
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/bar")));
// Scenario 1: Lock links, lock important-section, unlock important-section,
// unlock links.
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
links->SetInlineStyleProperty(CSSPropertyID::kContentVisibility,
CSSValueID::kHidden);
});
EXPECT_THAT(candidates, HasURLs());
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host, [&]() {
important_section->SetInlineStyleProperty(
CSSPropertyID::kContentVisibility, CSSValueID::kHidden);
page_holder.GetFrameView().UpdateAllLifecyclePhasesForTest();
}));
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host, [&]() {
important_section->RemoveInlineStyleProperty(
CSSPropertyID::kContentVisibility);
page_holder.GetFrameView().UpdateAllLifecyclePhasesForTest();
}));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
links->RemoveInlineStyleProperty(CSSPropertyID::kContentVisibility);
});
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/bar")));
// Scenario 2: Lock links, lock important-section, unlock links, unlock
// important-section.
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
links->SetInlineStyleProperty(CSSPropertyID::kContentVisibility,
CSSValueID::kHidden);
});
EXPECT_THAT(candidates, HasURLs());
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host, [&]() {
important_section->SetInlineStyleProperty(
CSSPropertyID::kContentVisibility, CSSValueID::kHidden);
page_holder.GetFrameView().UpdateAllLifecyclePhasesForTest();
}));
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host, [&]() {
links->RemoveInlineStyleProperty(CSSPropertyID::kContentVisibility);
page_holder.GetFrameView().UpdateAllLifecyclePhasesForTest();
}));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
important_section->RemoveInlineStyleProperty(
CSSPropertyID::kContentVisibility);
});
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/bar")));
// Scenario 3: Lock important-section, lock links, unlock important-section,
// unlock links.
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
important_section->SetInlineStyleProperty(CSSPropertyID::kContentVisibility,
CSSValueID::kHidden);
});
EXPECT_THAT(candidates, HasURLs());
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host, [&]() {
links->SetInlineStyleProperty(CSSPropertyID::kContentVisibility,
CSSValueID::kHidden);
page_holder.GetFrameView().UpdateAllLifecyclePhasesForTest();
}));
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host, [&]() {
important_section->RemoveInlineStyleProperty(
CSSPropertyID::kContentVisibility);
page_holder.GetFrameView().UpdateAllLifecyclePhasesForTest();
}));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
links->RemoveInlineStyleProperty(CSSPropertyID::kContentVisibility);
});
// Scenario 4: Lock links and important-section together, unlock links and
// important-section together.
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
important_section->SetInlineStyleProperty(CSSPropertyID::kContentVisibility,
CSSValueID::kHidden);
links->SetInlineStyleProperty(CSSPropertyID::kContentVisibility,
CSSValueID::kHidden);
});
EXPECT_THAT(candidates, HasURLs());
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
important_section->RemoveInlineStyleProperty(
CSSPropertyID::kContentVisibility);
links->RemoveInlineStyleProperty(CSSPropertyID::kContentVisibility);
});
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/bar")));
}
TEST_F(DocumentRulesTest, DisplayLockedLink) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.body()->setInnerHTML(R"HTML(
<div id="important-section"></div>
)HTML");
auto* important_section =
document.getElementById(AtomicString("important-section"));
auto* anchor = AddAnchor(*important_section, "https://foo.com/bar");
anchor->setInnerText("Bar");
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"selector_matches": "#important-section a"}
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/bar")));
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host, [&]() {
anchor->SetInlineStyleProperty(CSSPropertyID::kContentVisibility,
CSSValueID::kHidden);
}));
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host, [&]() {
anchor->RemoveInlineStyleProperty(CSSPropertyID::kContentVisibility);
}));
}
// Sanity test to make sure things work when display-locked elements are
// present but "selector_matches" isn't enabled.
TEST_F(DocumentRulesTest, DisplayLockedElementWithoutSelectorMatchesEnabled) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
disabled_selector_matches_{false};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.body()->setInnerHTML(R"HTML(
<div id="important-section">
</div>
)HTML");
auto* important_section =
document.getElementById(AtomicString("important-section"));
AddAnchor(*important_section, "https://bar.com/foo");
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"href_matches": "https://bar.com/*"}
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs(KURL("https://bar.com/foo")));
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host, [&]() {
important_section->SetInlineStyleProperty(
CSSPropertyID::kContentVisibility, CSSValueID::kHidden);
page_holder.GetFrameView().UpdateAllLifecyclePhasesForTest();
}));
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host, [&]() {
important_section->SetInlineStyleProperty(
CSSPropertyID::kContentVisibility, CSSValueID::kVisible);
page_holder.GetFrameView().UpdateAllLifecyclePhasesForTest();
}));
}
TEST_F(DocumentRulesTest, AddLinkToDisplayLockedContainer) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.body()->setInnerHTML(R"HTML(
<div id="important-section">
</div>
)HTML");
auto* important_section =
document.getElementById(AtomicString("important-section"));
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"selector_matches": "#important-section a"}
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs());
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host, [&]() {
important_section->SetInlineStyleProperty(
CSSPropertyID::kContentVisibility, CSSValueID::kHidden);
page_holder.GetFrameView().UpdateAllLifecyclePhasesForTest();
}));
HTMLAnchorElement* anchor = nullptr;
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
anchor = AddAnchor(*important_section, "https://foo.com/bar");
});
EXPECT_THAT(candidates, HasURLs());
// Tests removing a display-locked container with links.
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
[&]() { important_section->remove(); });
EXPECT_THAT(candidates, HasURLs());
}
TEST_F(DocumentRulesTest, DisplayLockedContainerTracking) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
document.body()->setInnerHTML(R"HTML(
<div id="important-section"></div>
<div id="irrelevant-section"><span></span></div>
)HTML");
auto* important_section =
document.getElementById(AtomicString("important-section"));
auto* irrelevant_section =
document.getElementById(AtomicString("irrelevant-section"));
auto* anchor_1 = AddAnchor(*important_section, "https://foo.com/bar");
AddAnchor(*important_section, "https://foo.com/logout");
AddAnchor(*document.body(), "https://foo.com/logout");
String speculation_script = R"(
{"prefetch": [{
"source": "document",
"where": {"and": [{
"selector_matches": "#important-section a"
}, {
"not": {"href_matches": "https://*/logout"}
}]}
}]}
)";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/bar")));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
important_section->SetInlineStyleProperty(CSSPropertyID::kContentVisibility,
CSSValueID::kHidden);
anchor_1->SetHref(AtomicString("https://foo.com/fizz.html"));
});
EXPECT_THAT(candidates, HasURLs());
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host, [&]() {
// Changing style of the display-locked container should not cause an
// update.
important_section->SetInlineStyleProperty(CSSPropertyID::kColor,
CSSValueID::kDarkviolet);
page_holder.GetFrameView().UpdateAllLifecyclePhasesForTest();
}));
PropagateRulesToStubSpeculationHost(page_holder, speculation_host, [&]() {
important_section->SetInlineStyleProperty(CSSPropertyID::kContentVisibility,
CSSValueID::kVisible);
});
EXPECT_THAT(candidates, HasURLs(KURL("https://foo.com/fizz.html")));
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host, [&]() {
// Changing style of the display-locked container should not cause an
// update.
important_section->SetInlineStyleProperty(CSSPropertyID::kColor,
CSSValueID::kDeepskyblue);
page_holder.GetFrameView().UpdateAllLifecyclePhasesForTest();
}));
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host, [&]() {
irrelevant_section->SetInlineStyleProperty(
CSSPropertyID::kContentVisibility, CSSValueID::kHidden);
page_holder.GetFrameView().UpdateAllLifecyclePhasesForTest();
}));
ASSERT_TRUE(NoRulesPropagatedToStubSpeculationHost(
page_holder, speculation_host, [&]() {
irrelevant_section->RemoveInlineStyleProperty(
CSSPropertyID::kContentVisibility);
page_holder.GetFrameView().UpdateAllLifecyclePhasesForTest();
}));
}
// Similar to SpeculationRulesTest.RemoveInMicrotask, but with relevant changes
// to style/layout which necessitate forcing a style update after removal.
TEST_F(DocumentRulesTest, RemoveForcesStyleUpdate) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
selector_matches_enabled{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
base::RunLoop run_loop;
base::MockCallback<base::RepeatingCallback<void(
const Vector<mojom::blink::SpeculationCandidatePtr>&)>>
mock_callback;
{
::testing::InSequence sequence;
EXPECT_CALL(mock_callback, Run(::testing::SizeIs(2)));
EXPECT_CALL(mock_callback, Run(::testing::SizeIs(3)))
.WillOnce(::testing::Invoke([&]() { run_loop.Quit(); }));
}
speculation_host.SetCandidatesUpdatedCallback(mock_callback.Get());
LocalFrame& frame = page_holder.GetFrame();
Document& doc = page_holder.GetDocument();
frame.GetSettings()->SetScriptEnabled(true);
auto& broker = frame.DomWindow()->GetBrowserInterfaceBroker();
broker.SetBinderForTesting(
mojom::blink::SpeculationHost::Name_,
WTF::BindRepeating(&StubSpeculationHost::BindUnsafe,
WTF::Unretained(&speculation_host)));
for (StringView path : {"/baz", "/quux"}) {
AddAnchor(*doc.body(), "https://example.com" + path);
}
// First simulated task adds the rule sets.
InsertSpeculationRules(doc,
R"({"prefetch": [
{"source": "list", "urls": ["https://example.com/foo"]}]})");
HTMLScriptElement* to_remove = InsertSpeculationRules(doc,
R"({"prefetch": [
{"source": "list", "urls": ["https://example.com/bar"]}]})");
scoped_refptr<scheduler::EventLoop> event_loop =
frame.DomWindow()->GetAgent()->event_loop();
event_loop->PerformMicrotaskCheckpoint();
frame.View()->UpdateAllLifecyclePhasesForTest();
// Second simulated task removes a rule set, then adds a new rule set which
// will match some newly added links. Since we are forced to update to handle
// the removal, these will be discovered during that microtask.
//
// There's some extra subtlety here -- the speculation rules update needs to
// propagate the new invalidation sets for this selector before the
// setAttribute call occurs. Otherwise this test fails because the change goes
// unnoticed.
to_remove->remove();
InsertSpeculationRules(doc,
R"({"prefetch": [{"source": "document",
"where": {"selector_matches": ".magic *"}}]})");
doc.body()->setAttribute(html_names::kClassAttr, AtomicString("magic"));
event_loop->PerformMicrotaskCheckpoint();
run_loop.Run();
broker.SetBinderForTesting(mojom::blink::SpeculationHost::Name_, {});
}
// Checks a subtle case, wherein a ruleset is removed while speculation
// candidate update is waiting for clean style. In this case there is a race
// between the style update and the new microtask. In the case where the
// microtask wins, care is needed to avoid re-entrantly updating speculation
// candidates once it forces style clean.
TEST_F(DocumentRulesTest, RemoveWhileWaitingForStyle) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
selector_matches_enabled{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
base::RunLoop run_loop;
::testing::StrictMock<base::MockCallback<base::RepeatingCallback<void(
const Vector<mojom::blink::SpeculationCandidatePtr>&)>>>
mock_callback;
EXPECT_CALL(mock_callback, Run(::testing::SizeIs(1)))
.WillOnce(::testing::Invoke([&]() { run_loop.Quit(); }));
speculation_host.SetCandidatesUpdatedCallback(mock_callback.Get());
LocalFrame& frame = page_holder.GetFrame();
Document& doc = page_holder.GetDocument();
frame.GetSettings()->SetScriptEnabled(true);
auto& broker = frame.DomWindow()->GetBrowserInterfaceBroker();
broker.SetBinderForTesting(
mojom::blink::SpeculationHost::Name_,
WTF::BindRepeating(&StubSpeculationHost::BindUnsafe,
WTF::Unretained(&speculation_host)));
auto event_loop = frame.DomWindow()->GetAgent()->event_loop();
// First, add the rule set and matching links. Style is not yet clean for the
// newly added links, even after the microtask. We also add a rule set with a
// fixed URL to avoid any optimizations that skip empty updates.
for (StringView path : {"/baz", "/quux"}) {
AddAnchor(*doc.body(), "https://example.com" + path);
}
HTMLScriptElement* to_remove = InsertSpeculationRules(doc,
R"({"prefetch": [
{"source": "document", "where": {"selector_matches": "*"}}]})");
InsertSpeculationRules(doc,
R"({"prefetch": [
{"source": "list", "urls": ["https://example.com/keep"]}]})");
event_loop->PerformMicrotaskCheckpoint();
EXPECT_TRUE(doc.NeedsLayoutTreeUpdate());
// Then, the rule set is removed, and we run another microtask checkpoint.
to_remove->remove();
event_loop->PerformMicrotaskCheckpoint();
// At this point, style should have been forced clean, and we should have
// received the mock update above.
EXPECT_FALSE(doc.NeedsLayoutTreeUpdate());
run_loop.Run();
broker.SetBinderForTesting(mojom::blink::SpeculationHost::Name_, {});
}
// Regression test, since the universal select sets rule set flags indicating
// that the rule set potentially invalidates all elements.
TEST_F(DocumentRulesTest, UniversalSelector) {
ScopedSpeculationRulesDocumentRulesSelectorMatchesForTest
enable_selector_matches{true};
DummyPageHolder page_holder;
page_holder.GetFrame().GetSettings()->SetScriptEnabled(true);
StubSpeculationHost speculation_host;
InsertSpeculationRules(
page_holder.GetDocument(),
R"({"prefetch": [{"source":"document", "where":{"selector_matches":"*"}}]})");
}
TEST_F(SpeculationRuleSetTest, EagernessRuntimeEnabledFlag) {
ScopedSpeculationRulesEagernessForTest enable_eagerness{false};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
String speculation_script = R"({
"prefetch": [
{
"source": "list",
"urls": ["https://example.com/prefetch/list/page1.html"],
"eagerness": "conservative"
}
]
})";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_TRUE(candidates.empty());
}
TEST_F(SpeculationRuleSetTest, Eagerness) {
ScopedSpeculationRulesEagernessForTest enable_eagerness{true};
ScopedSpeculationRulesDocumentRulesForTest enable_document_rules_{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
Document& document = page_holder.GetDocument();
const KURL kUrl1{"https://example.com/prefetch/list/page1.html"};
const KURL kUrl2{"https://example.com/prefetch/document/page1.html"};
const KURL kUrl3{"https://example.com/prerender/list/page1.html"};
const KURL kUrl4{"https://example.com/prerender/document/page1.html"};
const KURL kUrl5{"https://example.com/prefetch/list/page2.html"};
const KURL kUrl6{"https://example.com/prefetch/document/page2.html"};
const KURL kUrl7{"https://example.com/prerender/list/page2.html"};
const KURL kUrl8{"https://example.com/prerender/document/page2.html"};
const KURL kUrl9{"https://example.com/prefetch/list/page3.html"};
AddAnchor(*document.body(), kUrl2.GetString());
AddAnchor(*document.body(), kUrl4.GetString());
AddAnchor(*document.body(), kUrl6.GetString());
AddAnchor(*document.body(), kUrl8.GetString());
String speculation_script = R"({
"prefetch": [
{
"source": "list",
"urls": ["https://example.com/prefetch/list/page1.html"],
"eagerness": "conservative"
},
{
"source": "document",
"eagerness": "eager",
"where": {"href_matches": "https://example.com/prefetch/document/page1.html"}
},
{
"source": "list",
"urls": ["https://example.com/prefetch/list/page2.html"]
},
{
"source": "document",
"where": {"href_matches": "https://example.com/prefetch/document/page2.html"}
},
{
"source": "list",
"urls": ["https://example.com/prefetch/list/page3.html"],
"eagerness": "immediate"
}
],
"prerender": [
{
"eagerness": "moderate",
"source": "list",
"urls": ["https://example.com/prerender/list/page1.html"]
},
{
"source": "document",
"where": {"href_matches": "https://example.com/prerender/document/page1.html"},
"eagerness": "eager"
},
{
"source": "list",
"urls": ["https://example.com/prerender/list/page2.html"]
},
{
"source": "document",
"where": {"href_matches": "https://example.com/prerender/document/page2.html"}
}
]
})";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_THAT(
candidates,
UnorderedElementsAre(
AllOf(
HasURL(kUrl1),
HasEagerness(blink::mojom::SpeculationEagerness::kConservative)),
AllOf(HasURL(kUrl2),
HasEagerness(blink::mojom::SpeculationEagerness::kEager)),
AllOf(HasURL(kUrl3),
HasEagerness(blink::mojom::SpeculationEagerness::kModerate)),
AllOf(HasURL(kUrl4),
HasEagerness(blink::mojom::SpeculationEagerness::kEager)),
AllOf(HasURL(kUrl5),
HasEagerness(blink::mojom::SpeculationEagerness::kEager)),
AllOf(
HasURL(kUrl6),
HasEagerness(blink::mojom::SpeculationEagerness::kConservative)),
AllOf(HasURL(kUrl7),
HasEagerness(blink::mojom::SpeculationEagerness::kEager)),
AllOf(
HasURL(kUrl8),
HasEagerness(blink::mojom::SpeculationEagerness::kConservative)),
AllOf(HasURL(kUrl9),
HasEagerness(blink::mojom::SpeculationEagerness::kEager))));
}
TEST_F(SpeculationRuleSetTest, InvalidUseOfEagerness1) {
ScopedSpeculationRulesEagernessForTest enable_eagerness{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
const char* kUrl1 = "https://example.com/prefetch/list/page1.html";
String speculation_script = R"({
"eagerness": "conservative",
"prefetch": [
{
"source": "list",
"urls": ["https://example.com/prefetch/list/page1.html"]
}
]
})";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
// It should just ignore the "eagerness" key
EXPECT_THAT(candidates, HasURLs(KURL(kUrl1)));
}
TEST_F(SpeculationRuleSetTest, InvalidUseOfEagerness2) {
ScopedSpeculationRulesEagernessForTest enable_eagerness{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
const char* kUrl1 = "https://example.com/prefetch/list/page1.html";
String speculation_script = R"({
"prefetch": [
"eagerness",
{
"source": "list",
"urls": ["https://example.com/prefetch/list/page1.html"]
}
]
})";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
// It should just ignore the "eagerness" key
EXPECT_THAT(candidates, HasURLs(KURL(kUrl1)));
}
TEST_F(SpeculationRuleSetTest, InvalidEagernessValue) {
ScopedSpeculationRulesEagernessForTest enable_eagerness{true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
String speculation_script = R"({
"prefetch": [
{
"source": "list",
"urls": ["https://example.com/prefetch/list/page1.html"],
"eagerness": 0
},
{
"eagerness": 1.0,
"source": "list",
"urls": ["https://example.com/prefetch/list/page2.html"]
},
{
"source": "list",
"eagerness": true,
"urls": ["https://example.com/prefetch/list/page3.html"]
},
{
"source": "list",
"urls": ["https://example.com/prefetch/list/page4.html"],
"eagerness": "xyz"
}
]
})";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_TRUE(candidates.empty());
}
// Test that a valid No-Vary-Search hint will generate a speculation
// candidate.
TEST_F(SpeculationRuleSetTest, ValidNoVarySearchHintValueGeneratesCandidate) {
ScopedSpeculationRulesNoVarySearchHintForTest enable_no_vary_search_hint{
true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
String speculation_script = R"({
"prefetch": [{
"source": "list",
"urls": ["https://example.com/prefetch/list/page1.html"],
"expects_no_vary_search": "params=(\"a\") "
}]
})";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_EQ(candidates.size(), 1u);
// Check that the candidate has the correct No-Vary-Search hint.
EXPECT_THAT(candidates, ElementsAre(::testing::AllOf(
HasNoVarySearchHint(), NVSVariesOnKeyOrder(),
NVSHasNoVaryParams("a"))));
}
TEST_F(SpeculationRuleSetTest, InvalidNoVarySearchHintValueGeneratesCandidate) {
ScopedSpeculationRulesNoVarySearchHintForTest enable_no_vary_search_hint{
true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
String speculation_script = R"({
"prefetch": [{
"source": "list",
"urls": ["https://example.com/prefetch/list/page1.html"],
"expects_no_vary_search": "params=(a) "
}]
})";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_EQ(candidates.size(), 1u);
// Check that the candidate doesn't have No-Vary-Search hint.
EXPECT_THAT(candidates, ElementsAre(Not(HasNoVarySearchHint())));
}
// Test that an empty but valid No-Vary-Search hint will generate a speculation
// candidate.
TEST_F(SpeculationRuleSetTest, EmptyNoVarySearchHintValueGeneratesCandidate) {
ScopedSpeculationRulesNoVarySearchHintForTest enable_no_vary_search_hint{
true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
String speculation_script = R"({
"prefetch": [{
"source": "list",
"urls": ["https://example.com/prefetch/list/page1.html"],
"expects_no_vary_search": ""
}]
})";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_EQ(candidates.size(), 1u);
// Check that the candidate has the correct No-Vary-Search hint.
EXPECT_THAT(candidates[0], Not(HasNoVarySearchHint()));
}
// Test that a No-Vary-Search hint equivalent to the default
// will generate a speculation candidate.
TEST_F(SpeculationRuleSetTest, DefaultNoVarySearchHintValueGeneratesCandidate) {
ScopedSpeculationRulesNoVarySearchHintForTest enable_no_vary_search_hint{
true};
DummyPageHolder page_holder;
StubSpeculationHost speculation_host;
String speculation_script = R"({
"prefetch": [{
"source": "list",
"urls": ["https://example.com/prefetch/list/page1.html"],
"expects_no_vary_search": "key-order=?0"
}]
})";
PropagateRulesToStubSpeculationHost(page_holder, speculation_host,
speculation_script);
const auto& candidates = speculation_host.candidates();
EXPECT_EQ(candidates.size(), 1u);
// Check that the candidate has the correct No-Vary-Search hint.
EXPECT_THAT(candidates[0], Not(HasNoVarySearchHint()));
}
// Tests that No-Vary-Search errors that cause the speculation rules to be
// skipped are logged to the console.
TEST_F(SpeculationRuleSetTest, ConsoleWarningForNoVarySearchHintNotAString) {
ScopedSpeculationRulesNoVarySearchHintForTest enable_no_vary_search_hint{
true};
auto* chrome_client = MakeGarbageCollected<ConsoleCapturingChromeClient>();
DummyPageHolder page_holder(/*initial_view_size=*/{}, chrome_client);
page_holder.GetFrame().GetSettings()->SetScriptEnabled(true);
Document& document = page_holder.GetDocument();
HTMLScriptElement* script =
MakeGarbageCollected<HTMLScriptElement>(document, CreateElementFlags());
script->setAttribute(html_names::kTypeAttr, AtomicString("speculationrules"));
script->setText(
R"({
"prefetch": [{
"source": "list",
"urls": ["https://example.com/prefetch/list/page1.html"],
"expects_no_vary_search": 0
}]
})");
document.head()->appendChild(script);
EXPECT_TRUE(base::ranges::any_of(
chrome_client->ConsoleMessages(), [](const String& message) {
return message.Contains(
"expects_no_vary_search's value must be a string");
}));
}
// Tests that No-Vary-Search errors that cause the speculation rules to be
// skipped are logged to the console.
TEST_F(SpeculationRuleSetTest, NoVarySearchHintParseErrorRuleSkipped) {
ScopedSpeculationRulesNoVarySearchHintForTest enable_no_vary_search_hint{
true};
auto* rule_set =
CreateRuleSet(R"({
"prefetch": [{
"source": "list",
"urls": ["https://example.com/prefetch/list/page1.html"],
"expects_no_vary_search": 0
}]
})",
KURL("https://example.com"), execution_context());
ASSERT_TRUE(rule_set->HasError());
EXPECT_FALSE(rule_set->HasWarnings());
EXPECT_THAT(
rule_set->error_message().Utf8(),
::testing::HasSubstr("expects_no_vary_search's value must be a string"));
}
// Tests that No-Vary-Search parsing errors that cause the speculation rules
// to still be accepted are logged to the console.
TEST_F(SpeculationRuleSetTest, NoVarySearchHintParseErrorRuleAccepted) {
ScopedSpeculationRulesNoVarySearchHintForTest enable_no_vary_search_hint{
true};
{
auto* rule_set =
CreateRuleSet(R"({
"prefetch": [{
"source": "list",
"urls": ["https://example.com/prefetch/list/page1.html"],
"expects_no_vary_search": "?1"
}]
})",
KURL("https://example.com"), execution_context());
EXPECT_FALSE(rule_set->HasError());
ASSERT_TRUE(rule_set->HasWarnings());
EXPECT_THAT(
rule_set->warning_messages()[0].Utf8(),
::testing::HasSubstr("No-Vary-Search hint value is not a dictionary"));
}
{
auto* rule_set =
CreateRuleSet(R"({
"prefetch": [{
"source": "list",
"urls": ["https://example.com/prefetch/list/page1.html"],
"expects_no_vary_search": "para"
}
]
})",
KURL("https://example.com"), execution_context());
EXPECT_FALSE(rule_set->HasError());
ASSERT_TRUE(rule_set->HasWarnings());
EXPECT_THAT(
rule_set->warning_messages()[0].Utf8(),
::testing::HasSubstr(
"No-Vary-Search hint value contains unknown dictionary keys"));
}
{
auto* rule_set =
CreateRuleSet(R"({
"prefetch": [{
"source": "list",
"urls": ["https://example.com/prefetch/list/page1.html"],
"expects_no_vary_search": "key-order=a"
}
]
})",
KURL("https://example.com"), execution_context());
EXPECT_FALSE(rule_set->HasError());
ASSERT_TRUE(rule_set->HasWarnings());
EXPECT_THAT(
rule_set->warning_messages()[0].Utf8(),
::testing::HasSubstr(
"No-Vary-Search hint value contains a \"key-order\" dictionary"));
}
{
auto* rule_set =
CreateRuleSet(R"({
"prefetch": [
{
"source": "list",
"urls": ["https://example.com/prefetch/list/page1.html"],
"expects_no_vary_search": "params=a"
}
]
})",
KURL("https://example.com"), execution_context());
EXPECT_FALSE(rule_set->HasError());
ASSERT_TRUE(rule_set->HasWarnings());
EXPECT_THAT(
rule_set->warning_messages()[0].Utf8(),
::testing::HasSubstr("contains a \"params\" dictionary value"
" that is not a list of strings or a boolean"));
}
{
auto* rule_set =
CreateRuleSet(R"({
"prefetch": [{
"source": "list",
"urls": ["https://example.com/prefetch/list/page1.html"],
"expects_no_vary_search": "params,except=a"
}
]
})",
KURL("https://example.com"), execution_context());
EXPECT_FALSE(rule_set->HasError());
ASSERT_TRUE(rule_set->HasWarnings());
EXPECT_THAT(rule_set->warning_messages()[0].Utf8(),
::testing::HasSubstr("contains an \"except\" dictionary value"
" that is not a list of strings"));
}
{
auto* rule_set =
CreateRuleSet(R"({
"prefetch": [{
"source": "list",
"urls": ["https://example.com/prefetch/list/page1.html"],
"expects_no_vary_search": "except=(\"a\") "
}
]
})",
KURL("https://example.com"), execution_context());
EXPECT_FALSE(rule_set->HasError());
ASSERT_TRUE(rule_set->HasWarnings());
EXPECT_THAT(
rule_set->warning_messages()[0].Utf8(),
::testing::HasSubstr(
"contains an \"except\" dictionary key"
" without the \"params\" dictionary key being set to true."));
}
}
TEST_F(SpeculationRuleSetTest, ValidNoVarySearchHintNoErrorOrWarningMessages) {
ScopedSpeculationRulesNoVarySearchHintForTest enable_no_vary_search_hint{
true};
{
auto* rule_set =
CreateRuleSet(R"({
"prefetch": [{
"source": "list",
"urls": ["https://example.com/prefetch/list/page1.html"],
"expects_no_vary_search": "params=?0"
}
]
})",
KURL("https://example.com"), execution_context());
EXPECT_FALSE(rule_set->HasError());
EXPECT_FALSE(rule_set->HasWarnings());
}
{
auto* rule_set =
CreateRuleSet(R"({
"prefetch": [{
"source": "list",
"urls": ["https://example.com/prefetch/list/page1.html"],
"expects_no_vary_search": ""
}
]
})",
KURL("https://example.com"), execution_context());
EXPECT_FALSE(rule_set->HasError());
EXPECT_FALSE(rule_set->HasWarnings());
}
}
TEST_F(SpeculationRuleSetTest, DocumentReportsSuccessMetric) {
base::HistogramTester histogram_tester;
DummyPageHolder page_holder;
page_holder.GetFrame().GetSettings()->SetScriptEnabled(true);
Document& document = page_holder.GetDocument();
HTMLScriptElement* script =
MakeGarbageCollected<HTMLScriptElement>(document, CreateElementFlags());
script->setAttribute(html_names::kTypeAttr, AtomicString("speculationrules"));
script->setText("{}");
document.head()->appendChild(script);
histogram_tester.ExpectUniqueSample("Blink.SpeculationRules.LoadOutcome",
SpeculationRulesLoadOutcome::kSuccess, 1);
}
TEST_F(SpeculationRuleSetTest, DocumentReportsParseErrorFromScript) {
base::HistogramTester histogram_tester;
DummyPageHolder page_holder;
page_holder.GetFrame().GetSettings()->SetScriptEnabled(true);
Document& document = page_holder.GetDocument();
HTMLScriptElement* script =
MakeGarbageCollected<HTMLScriptElement>(document, CreateElementFlags());
script->setAttribute(html_names::kTypeAttr, AtomicString("speculationrules"));
script->setText("{---}");
document.head()->appendChild(script);
histogram_tester.ExpectUniqueSample(
"Blink.SpeculationRules.LoadOutcome",
SpeculationRulesLoadOutcome::kParseErrorInline, 1);
}
TEST_F(SpeculationRuleSetTest, DocumentReportsParseErrorFromRequest) {
base::HistogramTester histogram_tester;
DummyPageHolder page_holder;
Document& document = page_holder.GetDocument();
SpeculationRuleSet* rule_set = SpeculationRuleSet::Parse(
SpeculationRuleSet::Source::FromRequest(
"{---}", KURL("https://fake.test/sr.json"), 0),
document.GetExecutionContext());
DocumentSpeculationRules::From(document).AddRuleSet(rule_set);
histogram_tester.ExpectUniqueSample(
"Blink.SpeculationRules.LoadOutcome",
SpeculationRulesLoadOutcome::kParseErrorFetched, 1);
}
TEST_F(SpeculationRuleSetTest, DocumentReportsParseErrorFromBrowserInjection) {
base::HistogramTester histogram_tester;
DummyPageHolder page_holder;
Document& document = page_holder.GetDocument();
SpeculationRuleSet* rule_set = SpeculationRuleSet::Parse(
SpeculationRuleSet::Source::FromBrowserInjected("{---}", KURL()),
document.GetExecutionContext());
DocumentSpeculationRules::From(document).AddRuleSet(rule_set);
histogram_tester.ExpectUniqueSample(
"Blink.SpeculationRules.LoadOutcome",
SpeculationRulesLoadOutcome::kParseErrorBrowserInjected, 1);
}
TEST_F(SpeculationRuleSetTest, ImplicitSource) {
auto* rule_set = CreateRuleSet(
R"({
"prefetch": [{
"where": {"href_matches": "/foo"}
}, {
"urls": ["/bar"]
}]
})",
KURL("https://example.com/"), execution_context());
EXPECT_THAT(rule_set->prefetch_rules(),
ElementsAre(MatchesPredicate(Href({URLPattern("/foo")})),
MatchesListOfURLs("https://example.com/bar")));
}
} // namespace
} // namespace blink