blob: d95442f7eb31b25036b682a70a47bfad60bb139d [file] [log] [blame]
// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include <optional>
#include <string_view>
#include "base/base64.h"
#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/files/scoped_temp_dir.h"
#include "base/functional/bind.h"
#include "base/strings/utf_string_conversions.h"
#include "base/test/test_future.h"
#include "base/unguessable_token.h"
#include "build/build_config.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/test/base/in_process_browser_test.h"
#include "chrome/test/base/ui_test_utils.h"
#include "components/paint_preview/browser/paint_preview_client.h"
#include "components/paint_preview/common/file_stream.h"
#include "components/paint_preview/common/mock_paint_preview_recorder.h"
#include "components/paint_preview/common/mojom/paint_preview_recorder.mojom-shared.h"
#include "components/paint_preview/common/proto/paint_preview.pb.h"
#include "components/paint_preview/common/recording_map.h"
#include "components/paint_preview/common/redaction_params.h"
#include "components/paint_preview/common/serialized_recording.h"
#include "components/paint_preview/common/test_utils.h"
#include "components/ukm/test_ukm_recorder.h"
#include "content/public/browser/render_process_host.h"
#include "content/public/common/content_switches.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "content/public/test/fenced_frame_test_util.h"
#include "net/dns/mock_host_resolver.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
#include "services/metrics/public/cpp/ukm_builders.h"
#include "third_party/blink/public/common/associated_interfaces/associated_interface_provider.h"
#include "third_party/skia/include/core/SkPicture.h"
#include "third_party/skia/include/core/SkStream.h"
#include "url/gurl.h"
namespace paint_preview {
class NoOpPaintPreviewRecorder : public MockPaintPreviewRecorder {
public:
NoOpPaintPreviewRecorder() = default;
~NoOpPaintPreviewRecorder() override = default;
NoOpPaintPreviewRecorder(const NoOpPaintPreviewRecorder&) = delete;
NoOpPaintPreviewRecorder& operator=(const NoOpPaintPreviewRecorder&) = delete;
protected:
void CheckParams(const mojom::PaintPreviewCaptureParamsPtr& params) override {
}
private:
};
// Test harness for a integration test of paint previews. In this test:
// - Each RenderFrame has an instance of PaintPreviewRecorder attached.
// - Each WebContents has an instance of PaintPreviewClient attached.
// This permits end-to-end testing of the flow of paint previews.
class PaintPreviewBrowserTest
: public InProcessBrowserTest,
public testing::WithParamInterface<RecordingPersistence> {
public:
PaintPreviewBrowserTest(const PaintPreviewBrowserTest&) = delete;
PaintPreviewBrowserTest& operator=(const PaintPreviewBrowserTest&) = delete;
protected:
PaintPreviewBrowserTest() = default;
~PaintPreviewBrowserTest() override = default;
void SetUp() override {
ASSERT_TRUE(temp_dir_.CreateUniqueTempDir());
InProcessBrowserTest::SetUp();
}
void SetUpOnMainThread() override {
host_resolver()->AddRule("*", "127.0.0.1");
http_server_.ServeFilesFromSourceDirectory("content/test/data");
ASSERT_TRUE(http_server_.Start());
}
void SetUpCommandLine(base::CommandLine* command_line) override {
content::IsolateAllSitesForTesting(command_line);
}
void CreateClient() {
PaintPreviewClient::CreateForWebContents(GetWebContents());
}
content::WebContents* GetWebContents() {
return browser()->tab_strip_model()->GetActiveWebContents();
}
void LoadPage(const GURL& url) const {
ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), url));
}
void LoadHtml(std::string_view html) const {
std::string base64_html = base::Base64Encode(html);
GURL url(std::string("data:text/html;base64,") + base64_html);
ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), url));
}
PaintPreviewClient::PaintPreviewParams MakeParams() const {
PaintPreviewClient::PaintPreviewParams params(GetParam());
params.inner.is_main_frame = true;
params.root_dir = temp_dir_.GetPath();
params.inner.capture_links = true;
params.inner.max_capture_size = 0;
return params;
}
void OverrideInterface(NoOpPaintPreviewRecorder* service,
content::RenderFrameHost* rfh) {
blink::AssociatedInterfaceProvider* remote_interfaces =
rfh->GetRemoteAssociatedInterfaces();
remote_interfaces->OverrideBinderForTesting(
mojom::PaintPreviewRecorder::Name_,
base::BindRepeating(&NoOpPaintPreviewRecorder::BindRequest,
base::Unretained(service)));
}
void WaitForLoadStopWithoutSuccessCheck() {
// In many cases, the load may have finished before we get here. Only wait
// if the tab still has a pending navigation.
auto* web_contents = GetWebContents();
if (web_contents->IsLoading()) {
content::LoadStopObserver load_stop_observer(web_contents);
load_stop_observer.Wait();
}
}
// Check that |recording_map| contains the frame |frame_proto| and is a valid
// SkPicture. Don't bother checking the contents as this is non-trivial and
// could change. Instead check that the SkPicture can be read correctly and
// has a cull rect of at least |size|.
//
// Consumes the recording from |recording_map|.
static void EnsureSkPictureIsValid(RecordingMap* recording_map,
const PaintPreviewFrameProto& frame_proto,
size_t expected_subframe_count,
const gfx::Size& size = gfx::Size(1, 1)) {
base::ScopedAllowBlockingForTesting scoped_blocking;
auto it = recording_map->find(
base::UnguessableToken::Deserialize(frame_proto.embedding_token_high(),
frame_proto.embedding_token_low())
.value());
ASSERT_NE(it, recording_map->end());
std::optional<SkpResult> result = std::move(it->second).Deserialize();
ASSERT_TRUE(result.has_value());
EXPECT_NE(result->skp, nullptr);
EXPECT_GE(result->skp->cullRect().width(), 0);
EXPECT_GE(result->skp->cullRect().height(), 0);
EXPECT_EQ(result->ctx.size(), expected_subframe_count);
recording_map->erase(it);
}
base::ScopedTempDir temp_dir_;
net::EmbeddedTestServer http_server_;
net::EmbeddedTestServer http_server_different_origin_;
};
IN_PROC_BROWSER_TEST_P(PaintPreviewBrowserTest, CaptureFrame) {
LoadPage(http_server_.GetURL("a.com", "/cross_site_iframe_factory.html?a"));
ukm::TestAutoSetUkmRecorder ukm_recorder;
auto params = MakeParams();
CreateClient();
auto* client = PaintPreviewClient::FromWebContents(GetWebContents());
WaitForLoadStopWithoutSuccessCheck();
base::test::TestFuture<base::UnguessableToken, mojom::PaintPreviewStatus,
std::unique_ptr<CaptureResult>>
future;
client->CapturePaintPreview(params.Clone(),
GetWebContents()->GetPrimaryMainFrame(),
future.GetCallback());
auto [guid, status, result] = future.Take();
EXPECT_EQ(guid, params.inner.get_document_guid());
EXPECT_EQ(status, mojom::PaintPreviewStatus::kOk);
EXPECT_TRUE(result->proto.has_root_frame());
EXPECT_EQ(result->proto.subframes_size(), 0);
EXPECT_EQ(result->proto.root_frame().content_id_to_embedding_tokens_size(),
0);
EXPECT_TRUE(result->proto.root_frame().is_main_frame());
{
base::ScopedAllowBlockingForTesting scoped_blocking;
auto pair = RecordingMapFromCaptureResult(std::move(*result));
EnsureSkPictureIsValid(&pair.first, pair.second.root_frame(), 0);
}
auto entries = ukm_recorder.GetEntriesByName(
ukm::builders::PaintPreviewCapture::kEntryName);
EXPECT_EQ(1u, entries.size());
}
IN_PROC_BROWSER_TEST_P(PaintPreviewBrowserTest,
CaptureMainFrameWithCrossProcessSubframe) {
LoadPage(
http_server_.GetURL("a.com", "/cross_site_iframe_factory.html?a(b)"));
auto params = MakeParams();
CreateClient();
auto* client = PaintPreviewClient::FromWebContents(GetWebContents());
WaitForLoadStopWithoutSuccessCheck();
base::test::TestFuture<base::UnguessableToken, mojom::PaintPreviewStatus,
std::unique_ptr<CaptureResult>>
future;
client->CapturePaintPreview(params.Clone(),
GetWebContents()->GetPrimaryMainFrame(),
future.GetCallback());
auto [guid, status, result] = future.Take();
EXPECT_EQ(guid, params.inner.get_document_guid());
EXPECT_EQ(status, mojom::PaintPreviewStatus::kOk);
EXPECT_TRUE(result->proto.has_root_frame());
EXPECT_EQ(result->proto.subframes_size(), 1);
EXPECT_EQ(result->proto.root_frame().content_id_to_embedding_tokens_size(),
1);
EXPECT_TRUE(result->proto.root_frame().is_main_frame());
EXPECT_EQ(result->proto.subframes(0).content_id_to_embedding_tokens_size(),
0);
EXPECT_FALSE(result->proto.subframes(0).is_main_frame());
{
base::ScopedAllowBlockingForTesting scoped_blocking;
auto pair = RecordingMapFromCaptureResult(std::move(*result));
EnsureSkPictureIsValid(&pair.first, pair.second.root_frame(), 1);
EnsureSkPictureIsValid(&pair.first, pair.second.subframes(0), 0);
}
}
IN_PROC_BROWSER_TEST_P(PaintPreviewBrowserTest,
CaptureMainFrameWithCrossProcessSubframeWithRedaction) {
LoadPage(
http_server_.GetURL("a.com", "/cross_site_iframe_factory.html?a(b)"));
auto params = MakeParams();
params.inner.redaction_params = RedactionParams(
{url::Origin::Create(http_server_.GetURL("b.com", "/"))}, {});
CreateClient();
auto* client = PaintPreviewClient::FromWebContents(GetWebContents());
WaitForLoadStopWithoutSuccessCheck();
base::test::TestFuture<base::UnguessableToken, mojom::PaintPreviewStatus,
std::unique_ptr<CaptureResult>>
future;
client->CapturePaintPreview(params.Clone(),
GetWebContents()->GetPrimaryMainFrame(),
future.GetCallback());
auto [guid, status, result] = future.Take();
EXPECT_EQ(guid, params.inner.get_document_guid());
EXPECT_EQ(status, mojom::PaintPreviewStatus::kOk);
EXPECT_TRUE(result->proto.has_root_frame());
EXPECT_EQ(result->proto.subframes_size(), 1);
EXPECT_EQ(result->proto.root_frame().content_id_to_embedding_tokens_size(),
1);
EXPECT_TRUE(result->proto.root_frame().is_main_frame());
EXPECT_EQ(result->proto.subframes(0).content_id_to_embedding_tokens_size(),
0);
EXPECT_FALSE(result->proto.subframes(0).is_main_frame());
{
base::ScopedAllowBlockingForTesting scoped_blocking;
auto [map, proto] = RecordingMapFromCaptureResult(std::move(*result));
EnsureSkPictureIsValid(&map, proto.root_frame(), 1);
EnsureSkPictureIsValid(&map, proto.subframes(0), 0);
}
}
class PaintPreviewFencedFrameBrowserTest : public PaintPreviewBrowserTest {
public:
PaintPreviewFencedFrameBrowserTest() = default;
~PaintPreviewFencedFrameBrowserTest() override = default;
content::test::FencedFrameTestHelper& fenced_frame_test_helper() {
return fenced_frame_test_helper_;
}
private:
content::test::FencedFrameTestHelper fenced_frame_test_helper_;
};
IN_PROC_BROWSER_TEST_P(PaintPreviewFencedFrameBrowserTest,
CaptureMainFrameWithCrossProcessFencedFrames) {
LoadPage(http_server_.GetURL("a.com", "/title1.html"));
content::RenderFrameHost* primary_main_rfh =
GetWebContents()->GetPrimaryMainFrame();
// Create two fenced frames.
fenced_frame_test_helper().CreateFencedFrame(
primary_main_rfh,
http_server_.GetURL("b.com", "/fenced_frames/title1.html"));
fenced_frame_test_helper().CreateFencedFrame(
primary_main_rfh,
http_server_.GetURL("c.com", "/fenced_frames/title1.html"));
CreateClient();
auto* client = PaintPreviewClient::FromWebContents(GetWebContents());
auto params = MakeParams();
base::test::TestFuture<base::UnguessableToken, mojom::PaintPreviewStatus,
std::unique_ptr<CaptureResult>>
future;
client->CapturePaintPreview(params.Clone(), primary_main_rfh,
future.GetCallback());
auto [guid, status, result] = future.Take();
// This callback should have a success result without any DCHECK
// error.
EXPECT_EQ(guid, params.inner.get_document_guid());
EXPECT_EQ(status, mojom::PaintPreviewStatus::kOk);
EXPECT_TRUE(result->proto.has_root_frame());
EXPECT_EQ(result->proto.subframes_size(), 2);
EXPECT_EQ(result->proto.root_frame().content_id_to_embedding_tokens_size(),
2);
EXPECT_TRUE(result->proto.root_frame().is_main_frame());
EXPECT_FALSE(result->proto.subframes(0).is_main_frame());
EXPECT_FALSE(result->proto.subframes(1).is_main_frame());
{
base::ScopedAllowBlockingForTesting scoped_blocking;
auto pair = RecordingMapFromCaptureResult(std::move(*result));
EnsureSkPictureIsValid(&pair.first, pair.second.root_frame(), 2);
EnsureSkPictureIsValid(&pair.first, pair.second.subframes(0), 0);
EnsureSkPictureIsValid(&pair.first, pair.second.subframes(1), 0);
}
}
IN_PROC_BROWSER_TEST_P(PaintPreviewFencedFrameBrowserTest,
DoNotAffectAnotherFrameWhenRemovingFencedFrame) {
base::ScopedAllowBlockingForTesting scope;
LoadPage(http_server_.GetURL("a.com", "/title1.html"));
content::RenderFrameHost* primary_main_rfh =
GetWebContents()->GetPrimaryMainFrame();
// Create two fenced frames.
content::RenderFrameHostWrapper fenced_rfh_wrapper(
fenced_frame_test_helper().CreateFencedFrame(
primary_main_rfh,
http_server_.GetURL("b.com", "/fenced_frames/title1.html")));
fenced_frame_test_helper().CreateFencedFrame(
primary_main_rfh,
http_server_.GetURL("c.com", "/fenced_frames/title1.html"));
// Override remote interfaces of the fenced frame with a no-op.
base::test::TestFuture<void> started_future;
NoOpPaintPreviewRecorder noop_recorder;
noop_recorder.SetReceivedRequestClosure(started_future.GetCallback());
OverrideInterface(&noop_recorder, fenced_rfh_wrapper.get());
CreateClient();
auto* client = PaintPreviewClient::FromWebContents(GetWebContents());
auto params = MakeParams();
base::test::TestFuture<base::UnguessableToken, mojom::PaintPreviewStatus,
std::unique_ptr<CaptureResult>>
future;
client->CapturePaintPreview(params.Clone(), primary_main_rfh,
future.GetCallback());
// Wait for the request to execute before removing the fenced frame.
ASSERT_TRUE(started_future.Wait());
// Remove the fenced frame.
EXPECT_TRUE(
ExecJs(primary_main_rfh,
"const ff = document.querySelector('fencedframe'); ff.remove();"));
ASSERT_TRUE(fenced_rfh_wrapper.WaitUntilRenderFrameDeleted());
auto [guid, status, result] = future.Take();
// This callback should have a partial success result since the
// fenced frame has been removed during running the capture.
EXPECT_EQ(guid, params.inner.get_document_guid());
EXPECT_EQ(status, mojom::PaintPreviewStatus::kPartialSuccess);
EXPECT_TRUE(result->proto.has_root_frame());
EXPECT_EQ(result->proto.subframes_size(), 1);
EXPECT_EQ(result->proto.root_frame().content_id_to_embedding_tokens_size(),
2);
EXPECT_TRUE(result->proto.root_frame().is_main_frame());
EXPECT_EQ(result->proto.subframes(0).content_id_to_embedding_tokens_size(),
0);
EXPECT_FALSE(result->proto.subframes(0).is_main_frame());
{
base::ScopedAllowBlockingForTesting scoped_blocking;
auto pair = RecordingMapFromCaptureResult(std::move(*result));
EnsureSkPictureIsValid(&pair.first, pair.second.root_frame(), 2);
EnsureSkPictureIsValid(&pair.first, pair.second.subframes(0), 0);
}
}
IN_PROC_BROWSER_TEST_P(PaintPreviewBrowserTest,
CaptureMainFrameWithScrollableSameProcessSubframe) {
std::string html = R"(<html>
<iframe
srcdoc="<div
style='width: 300px;
height: 300px;
background-color: #ff0000'>
&nbsp;
</div>"
title="subframe"
width="100px"
height="100px">
</iframe>
</html>)";
LoadHtml(html);
auto params = MakeParams();
CreateClient();
auto* client = PaintPreviewClient::FromWebContents(GetWebContents());
WaitForLoadStopWithoutSuccessCheck();
base::test::TestFuture<base::UnguessableToken, mojom::PaintPreviewStatus,
std::unique_ptr<CaptureResult>>
future;
client->CapturePaintPreview(params.Clone(),
GetWebContents()->GetPrimaryMainFrame(),
future.GetCallback());
auto [guid, status, result] = future.Take();
EXPECT_EQ(guid, params.inner.get_document_guid());
EXPECT_EQ(status, mojom::PaintPreviewStatus::kOk);
EXPECT_TRUE(result->proto.has_root_frame());
EXPECT_EQ(result->proto.subframes_size(), 1);
EXPECT_EQ(result->proto.root_frame().content_id_to_embedding_tokens_size(),
1);
EXPECT_TRUE(result->proto.root_frame().is_main_frame());
EXPECT_EQ(result->proto.subframes(0).content_id_to_embedding_tokens_size(),
0);
EXPECT_FALSE(result->proto.subframes(0).is_main_frame());
{
base::ScopedAllowBlockingForTesting scoped_blocking;
auto pair = RecordingMapFromCaptureResult(std::move(*result));
EnsureSkPictureIsValid(&pair.first, pair.second.root_frame(), 1);
EnsureSkPictureIsValid(&pair.first, pair.second.subframes(0), 0,
gfx::Size(300, 300));
}
}
IN_PROC_BROWSER_TEST_P(PaintPreviewBrowserTest,
CaptureMainFrameWithNonScrollableSameProcessSubframe) {
std::string html = R"(<html>
<iframe
srcdoc="<div
style='width: 50px;
height: 50px;
background-color: #ff0000'>
&nbsp;
</div>"
title="subframe"
width="100px"
height="100px">
</iframe>
</html>)";
LoadHtml(html);
auto params = MakeParams();
CreateClient();
auto* client = PaintPreviewClient::FromWebContents(GetWebContents());
WaitForLoadStopWithoutSuccessCheck();
base::test::TestFuture<base::UnguessableToken, mojom::PaintPreviewStatus,
std::unique_ptr<CaptureResult>>
future;
client->CapturePaintPreview(params.Clone(),
GetWebContents()->GetPrimaryMainFrame(),
future.GetCallback());
auto [guid, status, result] = future.Take();
EXPECT_EQ(guid, params.inner.get_document_guid());
EXPECT_EQ(status, mojom::PaintPreviewStatus::kOk);
EXPECT_TRUE(result->proto.has_root_frame());
EXPECT_EQ(result->proto.subframes_size(), 0);
EXPECT_EQ(result->proto.root_frame().content_id_to_embedding_tokens_size(),
0);
EXPECT_TRUE(result->proto.root_frame().is_main_frame());
{
base::ScopedAllowBlockingForTesting scoped_blocking;
auto pair = RecordingMapFromCaptureResult(std::move(*result));
EnsureSkPictureIsValid(&pair.first, pair.second.root_frame(), 0);
}
}
// https://crbug.com/1146573 reproduction. If a renderer crashes,
// WebContentsObserver::RenderFrameDeleted. Paint preview implements this in an
// observer which in turn releases the capture handle which can cause the
// WebContents to be reloaded on Android where we have auto-reload. This reload
// occurs *during* crash handling, leaving the frame in an invalid state and
// leading to a crash when it subsequently unloaded.
// This is fixed by deferring it to a PostTask.
IN_PROC_BROWSER_TEST_P(PaintPreviewBrowserTest, DontReloadInRenderProcessExit) {
// In the FileSystem variant of this test, blocking needs to be permitted to
// allow cleanup to work during the crash.
base::ScopedAllowBlockingForTesting scope;
LoadPage(http_server_.GetURL("a.com", "/title1.html"));
content::WebContents* web_contents = GetWebContents();
// Override remote interfaces with a no-op.
base::test::TestFuture<void> started_future;
NoOpPaintPreviewRecorder noop_recorder;
noop_recorder.SetReceivedRequestClosure(started_future.GetCallback());
OverrideInterface(&noop_recorder, GetWebContents()->GetPrimaryMainFrame());
CreateClient();
auto* client = PaintPreviewClient::FromWebContents(web_contents);
// Do this twice to simulate conditions for crash.
auto handle1 = web_contents->IncrementCapturerCount(
gfx::Size(), /*stay_hidden=*/true,
/*stay_awake=*/true, /*is_activity=*/true);
auto handle2 = web_contents->IncrementCapturerCount(
gfx::Size(), /*stay_hidden=*/true,
/*stay_awake=*/true, /*is_activity=*/true);
// A callback that causes the frame to reload and end up in an invalid state
// if it is allowed to run during crash handling.
auto params = MakeParams();
base::test::TestFuture<base::UnguessableToken, mojom::PaintPreviewStatus,
std::unique_ptr<CaptureResult>>
future;
client->CapturePaintPreview(params.Clone(),
web_contents->GetPrimaryMainFrame(),
future.GetCallback());
// This callback is now posted so it shouldn't cause a crash.
// Wait for the request to execute before crashing the renderer. Otherwise in
// the FileSystem variant it is possible there will be a race during creation
// of the file with the renderer crash. If this happens the callback for
// `finished_loop` will not be run as no request to capture succeeded leading
// to a timeout.
ASSERT_TRUE(started_future.Wait());
// Crash the renderer.
content::RenderProcessHost* process =
GetWebContents()->GetPrimaryMainFrame()->GetProcess();
content::RenderProcessHostWatcher crash_observer(
process, content::RenderProcessHostWatcher::WATCH_FOR_PROCESS_EXIT);
process->Shutdown(0);
crash_observer.Wait();
// The browser would have crashed before the loop exited if the callback was
// not posted.
auto [guid, status, result] = future.Take();
EXPECT_EQ(status, mojom::PaintPreviewStatus::kFailed);
EXPECT_EQ(result, nullptr);
// On Android crashed frames are marked as needing reload.
web_contents->GetController().SetNeedsReload();
handle1.RunAndReset();
handle2.RunAndReset();
// Now navigate away and ensure that the frame unloads successfully.
LoadPage(http_server_.GetURL("a.com", "/title2.html"));
}
INSTANTIATE_TEST_SUITE_P(
All,
PaintPreviewBrowserTest,
testing::Values(RecordingPersistence::kFileSystem,
RecordingPersistence::kMemoryBuffer),
[](const testing::TestParamInfo<RecordingPersistence>& info) {
return std::string(PersistenceToString(info.param));
});
INSTANTIATE_TEST_SUITE_P(
All,
PaintPreviewFencedFrameBrowserTest,
testing::Values(RecordingPersistence::kFileSystem,
RecordingPersistence::kMemoryBuffer),
[](const testing::TestParamInfo<RecordingPersistence>& info) {
return std::string(PersistenceToString(info.param));
});
} // namespace paint_preview