| // Copyright 2024 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include <memory> |
| #include <string_view> |
| |
| #include "base/strings/string_number_conversions.h" |
| #include "base/test/bind.h" |
| #include "base/test/scoped_feature_list.h" |
| #include "base/test/test_trace_processor.h" |
| #include "components/ukm/test_ukm_recorder.h" |
| #include "content/browser/web_contents/web_contents_impl.h" |
| #include "content/common/input/synthetic_gesture_controller.h" |
| #include "content/common/input/synthetic_smooth_scroll_gesture.h" |
| #include "content/public/test/browser_test.h" |
| #include "content/public/test/browser_test_utils.h" |
| #include "content/public/test/content_browser_test.h" |
| #include "content/public/test/content_browser_test_utils.h" |
| #include "content/shell/browser/shell.h" |
| #include "net/dns/mock_host_resolver.h" |
| #include "services/metrics/public/cpp/metrics_utils.h" |
| #include "services/metrics/public/cpp/ukm_builders.h" |
| #include "third_party/blink/public/common/input/synthetic_web_input_event_builders.h" |
| |
| namespace content { |
| |
| class ScrollTracingBrowserTest : public ContentBrowserTest { |
| public: |
| ScrollTracingBrowserTest() { |
| scoped_feature_list_.InitWithFeatures({ukm::kUkmFeature}, {}); |
| } |
| |
| ScrollTracingBrowserTest(const ScrollTracingBrowserTest&) = delete; |
| ScrollTracingBrowserTest& operator=(const ScrollTracingBrowserTest&) = delete; |
| |
| ~ScrollTracingBrowserTest() override = default; |
| |
| void PreRunTestOnMainThread() override { |
| ContentBrowserTest::PreRunTestOnMainThread(); |
| test_ukm_recorder_ = std::make_unique<ukm::TestAutoSetUkmRecorder>(); |
| } |
| |
| RenderWidgetHostImpl* GetRenderWidgetHostImpl() { |
| FrameTreeNode* root = static_cast<WebContentsImpl*>(shell()->web_contents()) |
| ->GetPrimaryFrameTree() |
| .root(); |
| return root->current_frame_host()->GetRenderWidgetHost(); |
| } |
| |
| void DoScroll(gfx::Point starting_point, |
| std::vector<gfx::Vector2d> distances, |
| content::mojom::GestureSourceType source) { |
| // Create and queue gestures |
| for (const auto distance : distances) { |
| SyntheticSmoothScrollGestureParams params; |
| params.gesture_source_type = source; |
| params.anchor = gfx::PointF(starting_point); |
| params.distances.push_back(-distance); |
| params.granularity = ui::ScrollGranularity::kScrollByPrecisePixel; |
| auto gesture = std::make_unique<SyntheticSmoothScrollGesture>(params); |
| |
| base::RunLoop run_loop; |
| GetRenderWidgetHostImpl()->QueueSyntheticGesture( |
| std::move(gesture), |
| base::BindLambdaForTesting([&](SyntheticGesture::Result result) { |
| EXPECT_EQ(SyntheticGesture::GESTURE_FINISHED, result); |
| run_loop.Quit(); |
| })); |
| run_loop.Run(); |
| |
| // Update the previous start point. |
| starting_point = gfx::Point(starting_point.x() + distance.x(), |
| starting_point.y() + distance.y()); |
| } |
| } |
| |
| void ValidateUkm(GURL url, |
| std::string_view entry_name, |
| std::map<std::string_view, int64_t> expected_values) { |
| const auto& entries = |
| test_ukm_recorder_->GetMergedEntriesByName(entry_name); |
| EXPECT_EQ(1u, entries.size()); |
| for (const auto& kv : entries) { |
| test_ukm_recorder_->ExpectEntrySourceHasUrl(kv.second.get(), url); |
| for (const auto& expected_kv : expected_values) { |
| EXPECT_TRUE(test_ukm_recorder_->EntryHasMetric(kv.second.get(), |
| expected_kv.first)); |
| if (*(test_ukm_recorder_->GetEntryMetric(kv.second.get(), |
| expected_kv.first)) != 0) { |
| test_ukm_recorder_->ExpectEntryMetric( |
| kv.second.get(), expected_kv.first, |
| ukm::GetExponentialBucketMinForCounts1000(expected_kv.second)); |
| } |
| } |
| } |
| } |
| |
| protected: |
| base::test::ScopedFeatureList scoped_feature_list_; |
| std::unique_ptr<base::HistogramTester> histogram_tester_; |
| std::unique_ptr<ukm::TestAutoSetUkmRecorder> test_ukm_recorder_; |
| }; |
| |
| std::optional<int64_t> ConvertToIntValue(std::string query_value) { |
| int64_t result; |
| if (base::StringToInt64(query_value, &result)) { |
| return result; |
| } |
| return std::nullopt; |
| } |
| |
| // NOTE: Mac doesn't support touch events, and will not record scrolls with |
| // touch input. Linux bots are inconsistent. |
| #if BUILDFLAG(IS_ANDROID) |
| // Basic parity matching test between trace events and UKM if both are recorded |
| // during a scroll. |
| IN_PROC_BROWSER_TEST_F(ScrollTracingBrowserTest, ScrollingMetricsParity) { |
| base::test::TestTraceProcessor ttp_; |
| ttp_.StartTrace("input.scrolling"); |
| |
| ASSERT_TRUE(embedded_test_server()->Start()); |
| |
| GURL url( |
| embedded_test_server()->GetURL("/scrollable_page_with_content.html")); |
| EXPECT_TRUE(NavigateToURL(shell(), url)); |
| ASSERT_TRUE(WaitForLoadStop(shell()->web_contents())); |
| SimulateEndOfPaintHoldingOnPrimaryMainFrame(shell()->web_contents()); |
| |
| // Scroll with 3 updates to ensure: |
| // 1) One frame is missed (maximum 2 may be missed). |
| // 2) Predictor jank occurs. |
| DoScroll(gfx::Point(10, 10), {gfx::Vector2d(0, 100), gfx::Vector2d(0, -100)}, |
| content::mojom::GestureSourceType::kTouchInput); |
| |
| RunUntilInputProcessed(GetRenderWidgetHostImpl()); |
| EXPECT_EQ(true, EvalJs(shell()->web_contents(), "did_scroll;")); |
| |
| absl::Status status = ttp_.StopAndParseTrace(); |
| ASSERT_TRUE(status.ok()) << status.message(); |
| |
| // Use the values in chrome_scroll_interactions to validate values in UKM. |
| auto result = ttp_.RunQuery(R"( |
| INCLUDE PERFETTO MODULE chrome.scroll_interactions; |
| |
| SELECT |
| frame_count, |
| vsync_count, |
| missed_vsync_max, |
| missed_vsync_sum, |
| delayed_frame_count, |
| predictor_janky_frame_count |
| FROM chrome_scroll_interactions |
| LIMIT 1; |
| )"); |
| |
| ASSERT_TRUE(result.has_value()) << result.error(); |
| auto scroll_metrics_result = result.value(); |
| |
| // Validate that there are two rows in the output. The first row is the |
| // column names, the second row is the values. If there is only one row, |
| // then the query produced no data (a.k.a the data model was not populated). |
| EXPECT_EQ(scroll_metrics_result.size(), 2u); |
| |
| // Validate that each column of the query is present. |
| ASSERT_TRUE(scroll_metrics_result[1].size() == 6); |
| |
| auto scroll_metrics = scroll_metrics_result[1]; |
| |
| // frame_count |
| std::optional<int64_t> metrics = ConvertToIntValue(scroll_metrics[0]); |
| EXPECT_TRUE(metrics.has_value()); |
| EXPECT_GE(metrics.value(), 1); |
| // vsync_count |
| metrics = ConvertToIntValue(scroll_metrics[1]); |
| EXPECT_TRUE(metrics.has_value()); |
| EXPECT_GE(metrics.value(), 1); |
| |
| ValidateUkm( |
| url, ukm::builders::Event_Scroll::kEntryName, |
| { |
| {ukm::builders::Event_Scroll::kFrameCountName, |
| ConvertToIntValue(scroll_metrics[0]).value()}, |
| {ukm::builders::Event_Scroll::kVsyncCountName, |
| ConvertToIntValue(scroll_metrics[1]).value()}, |
| {ukm::builders::Event_Scroll::kScrollJank_MissedVsyncsMaxName, |
| ConvertToIntValue(scroll_metrics[2]).value()}, |
| {ukm::builders::Event_Scroll::kScrollJank_MissedVsyncsSumName, |
| ConvertToIntValue(scroll_metrics[3]).value()}, |
| {ukm::builders::Event_Scroll::kScrollJank_DelayedFrameCountName, |
| ConvertToIntValue(scroll_metrics[4]).value()}, |
| {ukm::builders::Event_Scroll::kPredictorJankyFrameCountName, |
| ConvertToIntValue(scroll_metrics[5]).value()}, |
| }); |
| } |
| |
| /** |
| * Helper classes to make it more convenient to iterate on the results returned |
| * by `TestTraceProcessor::RunQuery()`. This is the basic version that is only |
| * used in one test at the moment. If more tests start using |
| *`TestTraceProcessor`, we can improve the helpers and move them to |
| *`TestTraceProcessor`. |
| **/ |
| class Column { |
| public: |
| explicit Column(std::vector<std::string> column) : column_(column) {} |
| |
| std::vector<std::optional<int64_t>> GetIntValuesOrNulls() const { |
| std::vector<std::optional<int64_t>> result; |
| for (const std::string& row : column_) { |
| result.push_back(ConvertToIntValue(row)); |
| } |
| return result; |
| } |
| |
| private: |
| const std::vector<std::string> column_; |
| }; |
| |
| class Row { |
| public: |
| Row(const std::vector<std::string>& row, |
| const std::map<std::string, int64_t>& column_name_to_index_) |
| : row_(row), column_name_to_index_(column_name_to_index_) {} |
| |
| bool HasValue(const std::string& column_name) { |
| const auto it = column_name_to_index_->find(column_name); |
| EXPECT_NE(it, column_name_to_index_->end()) |
| << "Column " << column_name << " not found"; |
| return (*row_)[it->second] != "[NULL]"; |
| } |
| |
| std::optional<int64_t> GetIntValueOrNull( |
| const std::string& column_name) const { |
| const auto it = column_name_to_index_->find(column_name); |
| EXPECT_NE(it, column_name_to_index_->end()) |
| << "Column " << column_name << " not found"; |
| return ConvertToIntValue((*row_)[it->second]); |
| } |
| |
| private: |
| const raw_ref<const std::vector<std::string>> row_; |
| const raw_ref<const std::map<std::string, int64_t>> column_name_to_index_; |
| }; |
| |
| class Result { |
| public: |
| explicit Result(std::vector<std::vector<std::string>>& results) |
| : results_(results) { |
| const std::vector<std::string>& column_names = results_[0]; |
| |
| for (size_t i = 0; i < column_names.size(); ++i) { |
| column_name_to_index_[column_names[i]] = i; |
| } |
| |
| // Remove the header with column names. |
| results_.erase(results_.begin()); |
| } |
| |
| Column GetColumn(const std::string& column_name) const { |
| std::vector<std::string> column; |
| auto it = column_name_to_index_.find(column_name); |
| EXPECT_NE(it, column_name_to_index_.end()) |
| << "Column " << column_name << " not found"; |
| for (const auto& result : results_) { |
| column.push_back(result[it->second]); |
| } |
| return Column(std::move(column)); |
| } |
| |
| std::vector<Row> GetRows() const { |
| std::vector<Row> result; |
| for (const std::vector<std::string>& row : results_) { |
| result.emplace_back(row, column_name_to_index_); |
| } |
| return result; |
| } |
| |
| private: |
| std::map<std::string, int64_t> column_name_to_index_; |
| std::vector<std::vector<std::string>> results_; |
| }; |
| |
| std::vector<std::optional<int64_t>> GetNullableValues( |
| const Row& row, |
| const std::vector<const char*>& column_names) { |
| std::vector<std::optional<int64_t>> nullable_values; |
| |
| for (const char* column_name : column_names) { |
| nullable_values.push_back(row.GetIntValueOrNull(column_name)); |
| } |
| |
| return nullable_values; |
| } |
| |
| std::vector<int64_t> FilterOutNulls( |
| const std::vector<std::optional<int64_t>>& nullable_values) { |
| std::vector<int64_t> result; |
| for (const auto& nullable_value : nullable_values) { |
| if (nullable_value) { |
| result.push_back(nullable_value.value()); |
| } |
| } |
| return result; |
| } |
| |
| // Verifies that the scroll updates in the tracing standard library |
| // have correct properties and expected sequence of steps. |
| IN_PROC_BROWSER_TEST_F(ScrollTracingBrowserTest, ScrollUpdateInfo) { |
| using base::test::TestTraceProcessor; |
| TestTraceProcessor ttp_; |
| ttp_.StartTrace("input"); |
| |
| ASSERT_TRUE(embedded_test_server()->Start()); |
| |
| GURL url( |
| embedded_test_server()->GetURL("/scrollable_page_with_content.html")); |
| ASSERT_TRUE(NavigateToURL(shell(), url)); |
| ASSERT_TRUE(WaitForLoadStop(shell()->web_contents())); |
| SimulateEndOfPaintHoldingOnPrimaryMainFrame(shell()->web_contents()); |
| |
| // Do a single scroll. |
| DoScroll(gfx::Point(10, 10), {gfx::Vector2d(0, 10), gfx::Vector2d(0, 10)}, |
| content::mojom::GestureSourceType::kTouchInput); |
| |
| RunUntilInputProcessed(GetRenderWidgetHostImpl()); |
| ASSERT_EQ(true, EvalJs(shell()->web_contents(), "did_scroll;")); |
| |
| absl::Status status = ttp_.StopAndParseTrace(); |
| ASSERT_TRUE(status.ok()) << status.message(); |
| |
| // Select chrome scroll updates. |
| base::expected<TestTraceProcessor::QueryResult, std::string> raw_result = |
| ttp_.RunQuery(R"( |
| INCLUDE PERFETTO MODULE chrome.chrome_scrolls; |
| |
| SELECT |
| id, |
| is_presented, |
| is_first_scroll_update_in_scroll, |
| is_first_scroll_update_in_frame, |
| previous_input_id, |
| presentation_timestamp, |
| generation_ts, |
| touch_move_received_ts, |
| scroll_update_created_ts, |
| compositor_dispatch_ts, |
| compositor_on_begin_frame_ts, |
| compositor_generate_compositor_frame_ts, |
| compositor_submit_compositor_frame_ts, |
| viz_receive_compositor_frame_ts, |
| viz_draw_and_swap_ts, |
| viz_swap_buffers_ts, |
| latch_timestamp, |
| generation_to_browser_main_dur, |
| touch_move_processing_dur, |
| scroll_update_processing_dur, |
| browser_to_compositor_delay_dur, |
| compositor_dispatch_dur, |
| compositor_dispatch_to_on_begin_frame_delay_dur, |
| compositor_on_begin_frame_dur, |
| compositor_on_begin_frame_to_generation_delay_dur, |
| compositor_generate_frame_to_submit_frame_dur, |
| compositor_submit_frame_dur, |
| compositor_to_viz_delay_dur, |
| viz_receive_compositor_frame_dur, |
| viz_wait_for_draw_dur, |
| viz_draw_and_swap_dur, |
| viz_to_gpu_delay_dur, |
| viz_swap_buffers_dur, |
| viz_swap_buffers_to_latch_dur, |
| viz_latch_to_presentation_dur |
| FROM |
| chrome_scroll_update_info |
| ORDER BY id |
| )"); |
| |
| ASSERT_TRUE(raw_result.has_value()) << raw_result.error(); |
| TestTraceProcessor::QueryResult result_value = raw_result.value(); |
| |
| Result result(result_value); |
| EXPECT_GE(result.GetRows().size(), 1u); |
| |
| std::vector<std::optional<int64_t>> nullable_scroll_update_ids = |
| result.GetColumn("id").GetIntValuesOrNulls(); |
| std::vector<int64_t> scroll_update_ids = |
| FilterOutNulls(nullable_scroll_update_ids); |
| |
| // Check that latency ids are not duplicated |
| std::set<int64_t> unique_ids(std::begin(scroll_update_ids), |
| std::end(scroll_update_ids)); |
| EXPECT_THAT(scroll_update_ids, |
| testing::UnorderedElementsAreArray(unique_ids)); |
| |
| // Check that at least one frame was presented and at least |
| // one update was the first in the frame. |
| EXPECT_THAT( |
| FilterOutNulls(result.GetColumn("is_presented").GetIntValuesOrNulls()), |
| testing::Contains(1)) |
| << "No rows with is_presented = 1"; |
| EXPECT_THAT(FilterOutNulls(result.GetColumn("is_first_scroll_update_in_frame") |
| .GetIntValuesOrNulls()), |
| testing::Contains(1)) |
| << "No rows with is_first_scroll_update_in_frame = 1"; |
| |
| size_t row_number = 0; |
| for (Row row : result.GetRows()) { |
| std::optional<int64_t> maybe_is_presented = |
| row.GetIntValueOrNull("is_presented"); |
| EXPECT_TRUE(maybe_is_presented.has_value()); |
| bool is_presented = maybe_is_presented.value() == 1; |
| LOG(ERROR) << "is presented = " << is_presented; |
| if (is_presented) { |
| EXPECT_TRUE(row.HasValue("presentation_timestamp")) |
| << "No presentation timestamp for update in row " << row_number |
| << ", result:\n" |
| << result_value; |
| } |
| |
| std::optional<int64_t> maybe_is_first_scroll_update_in_frame = |
| row.GetIntValueOrNull("is_first_scroll_update_in_frame"); |
| EXPECT_TRUE(maybe_is_first_scroll_update_in_frame.has_value()); |
| bool is_first_scroll_update_in_frame = |
| maybe_is_first_scroll_update_in_frame.value() == 1; |
| |
| std::optional<int64_t> maybe_is_first_scroll_update_in_scroll = |
| row.GetIntValueOrNull("is_first_scroll_update_in_scroll"); |
| EXPECT_TRUE(maybe_is_first_scroll_update_in_scroll.has_value()); |
| bool is_first_scroll_update_in_scroll = |
| maybe_is_first_scroll_update_in_scroll.value() == 1; |
| |
| std::optional<int64_t> maybe_previous_input_id = |
| row.GetIntValueOrNull("previous_input_id"); |
| |
| if (is_first_scroll_update_in_scroll) { |
| EXPECT_FALSE(maybe_previous_input_id.has_value()) |
| << "Previous input id is not null for the first update in a scroll " |
| "in row " |
| << row_number << ", result:\n" |
| << result_value; |
| EXPECT_TRUE(is_first_scroll_update_in_frame) |
| << "First update in scroll is not first update in frame in row " |
| << row_number << ", result:\n" |
| << result_value; |
| } |
| |
| // Static constant array of timestamp column names. |
| static const std::vector<const char*> kTimestampColumnNames = { |
| "generation_ts", |
| "touch_move_received_ts", |
| "scroll_update_created_ts", |
| "compositor_dispatch_ts", |
| "compositor_on_begin_frame_ts", |
| "compositor_generate_compositor_frame_ts", |
| "compositor_submit_compositor_frame_ts", |
| "viz_receive_compositor_frame_ts", |
| "viz_draw_and_swap_ts", |
| "viz_swap_buffers_ts", |
| "latch_timestamp"}; |
| |
| std::vector<std::optional<int64_t>> nullable_timestamps = |
| GetNullableValues(row, kTimestampColumnNames); |
| std::vector<int64_t> timestamps = FilterOutNulls(nullable_timestamps); |
| |
| std::vector<int64_t> expected_timestamps = timestamps; |
| |
| // The non-NULL timestamps for consecutive stages should be increasing. |
| std::sort(expected_timestamps.begin(), expected_timestamps.end()); |
| EXPECT_EQ(timestamps, expected_timestamps) |
| << "Timestamps for consecutive stages are not increasing in row " |
| << row_number << ", result:\n" |
| << result_value; |
| |
| static const std::vector<const char*> kDurationColumnNames = { |
| "generation_to_browser_main_dur", "touch_move_processing_dur", |
| "scroll_update_processing_dur", "browser_to_compositor_delay_dur", |
| "compositor_dispatch_dur", |
| // TODO(b:381273884): fix negative stage duration |
| // "compositor_dispatch_to_on_begin_frame_delay_dur", |
| "compositor_on_begin_frame_dur", |
| "compositor_on_begin_frame_to_generation_delay_dur", |
| "compositor_generate_frame_to_submit_frame_dur", |
| "compositor_submit_frame_dur", "compositor_to_viz_delay_dur", |
| "viz_receive_compositor_frame_dur", "viz_wait_for_draw_dur", |
| "viz_draw_and_swap_dur", "viz_to_gpu_delay_dur", "viz_swap_buffers_dur", |
| "viz_swap_buffers_to_latch_dur", "viz_latch_to_presentation_dur"}; |
| |
| std::vector<std::optional<int64_t>> nullable_durations = |
| GetNullableValues(row, kDurationColumnNames); |
| std::vector<int64_t> durations = FilterOutNulls(nullable_durations); |
| |
| EXPECT_THAT(durations, testing::Not(testing::Contains(testing::Lt(0)))) |
| << "Negative duration(s) in row " << row_number << ", result:\n" |
| << result_value; |
| row_number++; |
| } |
| } |
| |
| #endif // BUILDFLAG(IS_ANDROID) |
| |
| } // namespace content |