diff options
author | jiayl@chromium.org <jiayl@chromium.org@0039d316-1c4b-4281-b951-d872f2087c98> | 2013-03-23 22:11:48 +0000 |
---|---|---|
committer | jiayl@chromium.org <jiayl@chromium.org@0039d316-1c4b-4281-b951-d872f2087c98> | 2013-03-23 22:11:48 +0000 |
commit | 03bf84adf8081592e527fdd781391c1ad9765a98 (patch) | |
tree | ebd76832dc1e58adbb92ee3d1212f4e5a25543fa /content | |
parent | d82b38b7f6f7c98aea83c3b194bd6fa5f58f522e (diff) | |
download | chromium_src-03bf84adf8081592e527fdd781391c1ad9765a98.zip chromium_src-03bf84adf8081592e527fdd781391c1ad9765a98.tar.gz chromium_src-03bf84adf8081592e527fdd781391c1ad9765a98.tar.bz2 |
Add graphs for PeerConnection stats
BUG=188643
Review URL: https://chromiumcodereview.appspot.com/12619006
git-svn-id: svn://svn.chromium.org/chrome/trunk/src@190095 0039d316-1c4b-4281-b951-d872f2087c98
Diffstat (limited to 'content')
-rw-r--r-- | content/browser/media/webrtc_internals_browsertest.cc | 438 | ||||
-rw-r--r-- | content/browser/resources/media/data_series.js | 109 | ||||
-rw-r--r-- | content/browser/resources/media/stats_graph_helper.js | 220 | ||||
-rw-r--r-- | content/browser/resources/media/timeline_graph_view.js | 556 | ||||
-rw-r--r-- | content/browser/resources/media/webrtc_internals.css | 32 | ||||
-rw-r--r-- | content/browser/resources/media/webrtc_internals.js | 17 | ||||
-rw-r--r-- | content/content_tests.gypi | 1 |
7 files changed, 1366 insertions, 7 deletions
diff --git a/content/browser/media/webrtc_internals_browsertest.cc b/content/browser/media/webrtc_internals_browsertest.cc new file mode 100644 index 0000000..a497c3d --- /dev/null +++ b/content/browser/media/webrtc_internals_browsertest.cc @@ -0,0 +1,438 @@ +// Copyright (c) 2013 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/time.h" +#include "base/utf_string_conversions.h" +#include "content/public/test/browser_test_utils.h" +#include "content/shell/shell.h" +#include "content/test/content_browser_test.h" +#include "content/test/content_browser_test_utils.h" + +using std::string; +namespace content { + +struct EventEntry { + string type; + string value; +}; + +struct StatsUnit { + string GetString() const { + std::stringstream ss; + ss << "{timestamp:" << timestamp << ", values:["; + std::map<string, string>::const_iterator iter; + for (iter = values.begin(); iter != values.end(); iter++) { + ss << "'" << iter->first << "','" << iter->second << "',"; + } + ss << "]}"; + return ss.str(); + } + + int64 timestamp; + std::map<string, string> values; +}; + +struct StatsEntry { + string type; + string id; + StatsUnit local; + StatsUnit remote; +}; + +typedef std::map<string, std::vector<string> > StatsMap; + +class PeerConnectionEntry { + public: + PeerConnectionEntry(int pid, int lid) : pid_(pid), lid_(lid) {} + + void AddEvent(const string& type, const string& value) { + EventEntry entry = {type, value}; + events_.push_back(entry); + } + + string getIdString() const { + std::stringstream ss; + ss << pid_ << "-" << lid_; + return ss.str(); + } + + string getLogIdString() const { + std::stringstream ss; + ss << pid_ << "-" << lid_ << "-log"; + return ss.str(); + } + + string getAllUpdateString() const { + std::stringstream ss; + ss << "{pid:" << pid_ << ", lid:" << lid_ << ", log:["; + for (size_t i = 0; i < events_.size(); ++i) { + ss << "{type:'" << events_[i].type << + "', value:'" << events_[i].value << "'},"; + } + ss << "]}"; + return ss.str(); + } + + int pid_; + int lid_; + std::vector<EventEntry> events_; + // This is a record of the history of stats value reported for each stats + // report id (e.g. ssrc-1234) for each stats name (e.g. framerate). + // It a 2-D map with each map entry is a vector of reported values. + // It is used to verify the graph data series. + std::map<string, StatsMap> stats_; +}; + +static const int64 FAKE_TIME_STAMP = 0; + +class WebRTCInternalsBrowserTest: public ContentBrowserTest { + public: + WebRTCInternalsBrowserTest() {} + virtual ~WebRTCInternalsBrowserTest() {} + + protected: + bool ExecuteJavascript(const string& javascript) { + return ExecuteScript(shell()->web_contents(), javascript); + } + + // Execute the javascript of addPeerConnection. + void ExecuteAddPeerConnectionJs(const PeerConnectionEntry& pc) { + std::stringstream ss; + ss << "{pid:" << pc.pid_ <<", lid:" << pc.lid_ << ", " << + "url:'u', servers:'s', constraints:'c'}"; + ASSERT_TRUE(ExecuteJavascript("addPeerConnection(" + ss.str() + ");")); + } + + // Execute the javascript of removePeerConnection. + void ExecuteRemovePeerConnectionJs(const PeerConnectionEntry& pc) { + std::stringstream ss; + ss << "{pid:" << pc.pid_ <<", lid:" << pc.lid_ << "}"; + + ASSERT_TRUE(ExecuteJavascript("removePeerConnection(" + ss.str() + ");")); + } + + // Verifies that the DOM element with id |id| exists. + void VerifyElementWithId(const string& id) { + bool result = false; + ASSERT_TRUE(ExecuteScriptAndExtractBool( + shell()->web_contents(), + "window.domAutomationController.send($('" + id + "') != null);", + &result)); + EXPECT_TRUE(result); + } + + // Verifies that the DOM element with id |id| does not exist. + void VerifyNoElementWithId(const string& id) { + bool result = false; + ASSERT_TRUE(ExecuteScriptAndExtractBool( + shell()->web_contents(), + "window.domAutomationController.send($('" + id + "') == null);", + &result)); + EXPECT_TRUE(result); + } + + // Verifies that DOM for |pc| is correctly created with the right content. + void VerifyPeerConnectionEntry(const PeerConnectionEntry& pc) { + VerifyElementWithId(pc.getIdString()); + if (pc.events_.size() == 0) + return; + + string log_id = pc.getLogIdString(); + VerifyElementWithId(log_id); + string result; + for (size_t i = 0; i < pc.events_.size(); ++i) { + std::stringstream ss; + ss << "var row = $('" << log_id << "').rows[" << (i + 1) << "];" + "var cell = row.lastChild;" + "var type = cell.firstChild.textContent;" + "var value = cell.lastChild.lastChild.value;" + "window.domAutomationController.send(type + ':' + value);"; + ASSERT_TRUE(ExecuteScriptAndExtractString( + shell()->web_contents(), ss.str(), &result)); + EXPECT_EQ(pc.events_[i].type + ":" + pc.events_[i].value, result); + } + } + + // Executes the javascript of updatePeerConnection and verifies the result. + void ExecuteAndVerifyUpdatePeerConnection( + PeerConnectionEntry& pc, const string& type, const string& value) { + pc.AddEvent(type, value); + + std::stringstream ss; + ss << "{pid:" << pc.pid_ <<", lid:" << pc.lid_ << + ", type:'" << type << "', value:'" << value << "'}"; + ASSERT_TRUE(ExecuteJavascript("updatePeerConnection(" + ss.str() + ")")); + + VerifyPeerConnectionEntry(pc); + } + + // Execute addStats and verifies that the stats table has the right content. + void ExecuteAndVerifyAddStats( + PeerConnectionEntry& pc, const string& type, const string& id, + StatsUnit& local, StatsUnit& remote) { + StatsEntry entry = {type, id, local, remote}; + + // Adds each new value to the map of stats history. + std::map<string, string>::iterator iter; + for (iter = local.values.begin(); iter != local.values.end(); iter++) { + pc.stats_[type + "-" + id][iter->first].push_back(iter->second); + } + for (iter = remote.values.begin(); iter != remote.values.end(); iter++) { + pc.stats_[type + "-" + id][iter->first].push_back(iter->second); + } + + std::stringstream ss; + ss << "{pid:" << pc.pid_ << ", lid:" << pc.lid_ << "," + "reports:[" << "{id:'" << id << "', type:'" << type << "', " + "local:" << local.GetString() << ", " + "remote:" << remote.GetString() << "}]}"; + + ASSERT_TRUE(ExecuteJavascript("addStats(" + ss.str() + ")")); + VerifyStatsTable(pc, entry); + } + + + // Verifies that the stats table has the right content. + void VerifyStatsTable(const PeerConnectionEntry& pc, + const StatsEntry& report) { + string table_id = + pc.getIdString() + "-table-" + report.type + "-" + report.id; + VerifyElementWithId(table_id); + + std::map<string, string>::const_iterator iter; + for (iter = report.local.values.begin(); + iter != report.local.values.end(); iter++) { + VerifyStatsTableRow(table_id, iter->first, iter->second); + } + for (iter = report.remote.values.begin(); + iter != report.remote.values.end(); iter++) { + VerifyStatsTableRow(table_id, iter->first, iter->second); + } + } + + // Verifies that the row named as |name| of the stats table |table_id| has + // the correct content as |name| : |value|. + void VerifyStatsTableRow(const string& table_id, + const string& name, + const string& value) { + VerifyElementWithId(table_id + "-" + name); + + string result; + ASSERT_TRUE(ExecuteScriptAndExtractString( + shell()->web_contents(), + "var row = $('" + table_id + "-" + name + "');" + "var name = row.cells[0].textContent;" + "var value = row.cells[1].textContent;" + "window.domAutomationController.send(name + ':' + value)", + &result)); + EXPECT_EQ(name + ":" + value, result); + } + + // Verifies that the graph data series consistent with pc.stats_. + void VerifyStatsGraph(const PeerConnectionEntry& pc) { + std::map<string, StatsMap>::const_iterator stream_iter; + for (stream_iter = pc.stats_.begin(); + stream_iter != pc.stats_.end(); stream_iter++) { + StatsMap::const_iterator stats_iter; + for (stats_iter = stream_iter->second.begin(); + stats_iter != stream_iter->second.end(); + stats_iter++) { + for (size_t i = 0; i < stats_iter->second.size(); ++i) { + string graph_id = pc.getIdString() + "-" + + stream_iter->first + "-" + stats_iter->first; + VerifyGraphDataPoint(graph_id, i, stats_iter->second[i]); + } + } + } + } + + // Verifies that the graph data point at index |index| has value |value|. + void VerifyGraphDataPoint( + const string& graph_id, int index, const string& value) { + bool result = false; + ASSERT_TRUE(ExecuteScriptAndExtractBool( + shell()->web_contents(), + "window.domAutomationController.send(" + " graphViews['" + graph_id + "'] != null)", + &result)); + EXPECT_TRUE(result); + + std::stringstream ss; + ss << "var dp = dataSeries['" << graph_id << "']" + ".dataPoints_[" << index << "];" + "window.domAutomationController.send(dp.value.toString())"; + string actual_value; + ASSERT_TRUE(ExecuteScriptAndExtractString( + shell()->web_contents(), ss.str(), &actual_value)); + EXPECT_EQ(value, actual_value); + } +}; + +IN_PROC_BROWSER_TEST_F(WebRTCInternalsBrowserTest, AddAndRemovePeerConnection) { + GURL url("chrome://webrtc-internals"); + NavigateToURL(shell(), url); + + // Add two PeerConnections and then remove them. + PeerConnectionEntry pc_1(1, 0); + ExecuteAddPeerConnectionJs(pc_1); + VerifyPeerConnectionEntry(pc_1); + + PeerConnectionEntry pc_2(2, 1); + ExecuteAddPeerConnectionJs(pc_2); + VerifyPeerConnectionEntry(pc_2); + + ExecuteRemovePeerConnectionJs(pc_1); + VerifyNoElementWithId(pc_1.getIdString()); + VerifyPeerConnectionEntry(pc_2); + + ExecuteRemovePeerConnectionJs(pc_2); + VerifyNoElementWithId(pc_2.getIdString()); +} + +IN_PROC_BROWSER_TEST_F(WebRTCInternalsBrowserTest, UpdateAllPeerConnections) { + GURL url("chrome://webrtc-internals"); + NavigateToURL(shell(), url); + + PeerConnectionEntry pc_0(1, 0); + pc_0.AddEvent("e1", "v1"); + pc_0.AddEvent("e2", "v2"); + PeerConnectionEntry pc_1(1, 1); + pc_1.AddEvent("e3", "v3"); + pc_1.AddEvent("e4", "v4"); + string pc_array = "[" + pc_0.getAllUpdateString() + ", " + + pc_1.getAllUpdateString() + "]"; + EXPECT_TRUE(ExecuteJavascript("updateAllPeerConnections(" + pc_array + ");")); + VerifyPeerConnectionEntry(pc_0); + VerifyPeerConnectionEntry(pc_1); +} + +IN_PROC_BROWSER_TEST_F(WebRTCInternalsBrowserTest, UpdatePeerConnection) { + GURL url("chrome://webrtc-internals"); + NavigateToURL(shell(), url); + + // Add one PeerConnection and send one update. + PeerConnectionEntry pc_1(1, 0); + ExecuteAddPeerConnectionJs(pc_1); + + ExecuteAndVerifyUpdatePeerConnection(pc_1, "e1", "v1"); + + // Add another PeerConnection and send two updates. + PeerConnectionEntry pc_2(1, 1); + ExecuteAddPeerConnectionJs(pc_2); + + ExecuteAndVerifyUpdatePeerConnection(pc_2, "e2", "v2"); + ExecuteAndVerifyUpdatePeerConnection(pc_2, "e3", "v3"); +} + +// Tests that adding random named stats updates the dataSeries and graphs. +IN_PROC_BROWSER_TEST_F(WebRTCInternalsBrowserTest, AddStats) { + GURL url("chrome://webrtc-internals"); + NavigateToURL(shell(), url); + + PeerConnectionEntry pc(1, 0); + ExecuteAddPeerConnectionJs(pc); + + const string type = "ssrc"; + const string id = "1234"; + StatsUnit local = {FAKE_TIME_STAMP}; + local.values["bitrate"] = "2000"; + local.values["framerate"] = "30"; + StatsUnit remote = {FAKE_TIME_STAMP}; + remote.values["jitter"] = "1"; + remote.values["rtt"] = "20"; + + // Add new stats and verify the stats table and graphs. + ExecuteAndVerifyAddStats(pc, type, id, local, remote); + VerifyStatsGraph(pc); + + // Update existing stats and verify the stats table and graphs. + local.values["bitrate"] = "2001"; + local.values["framerate"] = "31"; + ExecuteAndVerifyAddStats(pc, type, id, local, remote); + VerifyStatsGraph(pc); +} + +// Tests that the bandwidth estimation values are drawn on a single graph. +IN_PROC_BROWSER_TEST_F(WebRTCInternalsBrowserTest, BweCompoundGraph) { + GURL url("chrome://webrtc-internals"); + NavigateToURL(shell(), url); + + PeerConnectionEntry pc(1, 0); + ExecuteAddPeerConnectionJs(pc); + + StatsUnit local = {FAKE_TIME_STAMP}; + local.values["googAvailableSendBandwidth"] = "1000000"; + local.values["googTargetEncBitrate"] = "1000"; + local.values["googActualEncBitrate"] = "1000000"; + local.values["googRetransmitBitrate"] = "10"; + local.values["googTransmitBitrate"] = "1000000"; + const string stats_type = "bwe"; + const string stats_id = "videobwe"; + ExecuteAndVerifyAddStats(pc, stats_type, stats_id, local, local); + + string graph_id = + pc.getIdString() + "-" + stats_type + "-" + stats_id + "-bweCompound"; + bool result = false; + // Verify that the bweCompound graph exists. + ASSERT_TRUE(ExecuteScriptAndExtractBool( + shell()->web_contents(), + "window.domAutomationController.send(" + " graphViews['" + graph_id + "'] != null)", + &result)); + EXPECT_TRUE(result); + + // Verify that the bweCompound graph contains multiple dataSeries. + int count = 0; + ASSERT_TRUE(ExecuteScriptAndExtractInt( + shell()->web_contents(), + "window.domAutomationController.send(" + " graphViews['" + graph_id + "'].getDataSeriesCount())", + &count)); + EXPECT_EQ((int)local.values.size(), count); +} + +// Tests that the total packet/byte count is converted to count per second, +// and the converted data is drawn. +IN_PROC_BROWSER_TEST_F(WebRTCInternalsBrowserTest, ConvertedGraphs) { + GURL url("chrome://webrtc-internals"); + NavigateToURL(shell(), url); + + PeerConnectionEntry pc(1, 0); + ExecuteAddPeerConnectionJs(pc); + + const string stats_type = "s"; + const string stats_id = "1"; + const int num_converted_stats = 4; + const string stats_names[] = + {"packetsSent", "bytesSent", "packetsReceived", "bytesReceived"}; + const string converted_names[] = + {"packetsSentPerSecond", "bitsSentPerSecond", + "packetsReceivedPerSecond", "bitsReceivedPerSecond"}; + const string first_value = "1000"; + const string second_value = "2000"; + const string converted_values[] = {"1000", "8000", "1000", "8000"}; + + // Send the first data point. + StatsUnit remote = {FAKE_TIME_STAMP}; + StatsUnit local = {FAKE_TIME_STAMP}; + for (int i = 0; i < num_converted_stats; ++i) + local.values[stats_names[i]] = first_value; + + ExecuteAndVerifyAddStats(pc, stats_type, stats_id, local, remote); + + // Send the second data point at 1000ms after the first data point. + local.timestamp += 1000; + for (int i = 0; i < num_converted_stats; ++i) + local.values[stats_names[i]] = second_value; + ExecuteAndVerifyAddStats(pc, stats_type, stats_id, local, remote); + + // Verifies the graph data matches converted_values. + string graph_id_prefix = pc.getIdString() + "-" + stats_type + "-" + stats_id; + for (int i = 0; i < num_converted_stats; ++i) { + VerifyGraphDataPoint( + graph_id_prefix + "-" + converted_names[i], 1, converted_values[i]); + } +} + +} // namespace content diff --git a/content/browser/resources/media/data_series.js b/content/browser/resources/media/data_series.js new file mode 100644 index 0000000..f32c822 --- /dev/null +++ b/content/browser/resources/media/data_series.js @@ -0,0 +1,109 @@ +// Copyright (c) 2013 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * A TimelineDataSeries collects an ordered series of (time, value) pairs, + * and converts them to graph points. It also keeps track of its color and + * current visibility state. + */ +var TimelineDataSeries = (function() { + 'use strict'; + + /** + * @constructor + */ + function TimelineDataSeries() { + // List of DataPoints in chronological order. + this.dataPoints_ = []; + + // Default color. Should always be overridden prior to display. + this.color_ = 'red'; + // Whether or not the data series should be drawn. + this.isVisible_ = true; + + this.cacheStartTime_ = null; + this.cacheStepSize_ = 0; + this.cacheValues_ = []; + } + + TimelineDataSeries.prototype = { + /** + * Adds a DataPoint to |this| with the specified time and value. + * DataPoints are assumed to be received in chronological order. + */ + addPoint: function(timeTicks, value) { + var time = new Date(timeTicks); + this.dataPoints_.push(new DataPoint(time, value)); + }, + + isVisible: function() { + return this.isVisible_; + }, + + show: function(isVisible) { + this.isVisible_ = isVisible; + }, + + getColor: function() { + return this.color_; + }, + + setColor: function(color) { + this.color_ = color; + }, + + /** + * Returns a list containing the values of the data series at |count| + * points, starting at |startTime|, and |stepSize| milliseconds apart. + * Caches values, so showing/hiding individual data series is fast. + */ + getValues: function(startTime, stepSize, count) { + // Use cached values, if we can. + if (this.cacheStartTime_ == startTime && + this.cacheStepSize_ == stepSize && + this.cacheValues_.length == count) { + return this.cacheValues_; + } + + // Do all the work. + this.cacheValues_ = this.getValuesInternal_(startTime, stepSize, count); + this.cacheStartTime_ = startTime; + this.cacheStepSize_ = stepSize; + + return this.cacheValues_; + }, + + /** + * Returns the cached |values| in the specified time period. + */ + getValuesInternal_: function(startTime, stepSize, count) { + var values = []; + var nextPoint = 0; + var currentValue = 0; + var time = startTime; + for (var i = 0; i < count; ++i) { + while (nextPoint < this.dataPoints_.length && + this.dataPoints_[nextPoint].time < time) { + currentValue = this.dataPoints_[nextPoint].value; + ++nextPoint; + } + values[i] = currentValue; + time += stepSize; + } + return values; + } + }; + + /** + * A single point in a data series. Each point has a time, in the form of + * milliseconds since the Unix epoch, and a numeric value. + * @constructor + */ + function DataPoint(time, value) { + this.time = time; + this.value = value; + } + + return TimelineDataSeries; +})(); diff --git a/content/browser/resources/media/stats_graph_helper.js b/content/browser/resources/media/stats_graph_helper.js new file mode 100644 index 0000000..c56ab72 --- /dev/null +++ b/content/browser/resources/media/stats_graph_helper.js @@ -0,0 +1,220 @@ +// Copyright (c) 2013 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// +// This file contains helper methods to draw the stats timeline graphs. +// Each graph represents a series of stats report for a PeerConnection, +// e.g. 1234-0-ssrc-abcd123-bytesSent is the graph for the series of bytesSent +// for ssrc-abcd123 of PeerConnection 0 in process 1234. +// The graphs are drawn as CANVAS, grouped per report type per PeerConnection. +// Each group has an expand/collapse button and is collapsed initially. +// + +<include src="data_series.js"/> +<include src="timeline_graph_view.js"/> + +// Specifies which stats should be drawn on the 'bweCompound' graph and how. +var bweCompoundGraphConfig = { + googAvailableSendBandwidth: {color: 'red'}, + googTargetEncBitrateCorrected: {color: 'purple'}, + googActualEncBitrate: {color: 'orange'}, + googRetransmitBitrate: {color: 'blue'}, + googTransmitBitrate: {color: 'green'}, +}; + +// Converts the last entry of |srcDataSeries| from the total amount to the +// amount per second. +var totalToPerSecond = function(srcDataSeries) { + var length = srcDataSeries.dataPoints_.length; + if (length >= 2) { + var lastDataPoint = srcDataSeries.dataPoints_[length - 1]; + var secondLastDataPoint = srcDataSeries.dataPoints_[length - 2]; + return (lastDataPoint.value - secondLastDataPoint.value) * 1000 / + (lastDataPoint.time - secondLastDataPoint.time); + } + + return 0; +}; + +// Converts the value of total bytes to bits per second. +var totalBytesToBitsPerSecond = function(srcDataSeries) { + return totalToPerSecond(srcDataSeries) * 8; +}; + +// Specifies which stats should be converted before drawn and how. +// |convertedName| is the name of the converted value, |convertFunction| +// is the function used to calculate the new converted value based on the +// original dataSeries. +var dataConversionConfig = { + packetsSent: { + convertedName: 'packetsSentPerSecond', + convertFunction: totalToPerSecond, + }, + bytesSent: { + convertedName: 'bitsSentPerSecond', + convertFunction: totalBytesToBitsPerSecond, + }, + packetsReceived: { + convertedName: 'packetsReceivedPerSecond', + convertFunction: totalToPerSecond, + }, + bytesReceived: { + convertedName: 'bitsReceivedPerSecond', + convertFunction: totalBytesToBitsPerSecond, + }, + // This is due to a bug of wrong units reported for googTargetEncBitrate. + // TODO (jiayl): remove this when the unit bug is fixed. + googTargetEncBitrate: { + convertedName: 'googTargetEncBitrateCorrected', + convertFunction: function (srcDataSeries) { + var length = srcDataSeries.dataPoints_.length; + var lastDataPoint = srcDataSeries.dataPoints_[length - 1]; + if (lastDataPoint.value < 5000) + return lastDataPoint.value * 1000; + return lastDataPoint.value; + } + } +}; + +var graphViews = {}; +var dataSeries = {}; + +// Adds the stats report |singleReport| to the timeline graph for the given +// |peerConnectionElement| and |reportName|. +function drawSingleReport(peerConnectionElement, reportName, singleReport) { + if (!singleReport || !singleReport.values) + return; + for (var i = 0; i < singleReport.values.length - 1; i = i + 2) { + var rawLabel = singleReport.values[i]; + var rawValue = parseInt(singleReport.values[i + 1]); + var rawDataSeriesId = + peerConnectionElement.id + '-' + reportName + '-' + rawLabel; + + var finalDataSeriesId = rawDataSeriesId; + var finalLabel = rawLabel; + var finalValue = rawValue; + // We need to convert the value if dataConversionConfig[rawLabel] exists. + if (dataConversionConfig[rawLabel]) { + // Updates the original dataSeries before the conversion. + addDataSeriesPoint(rawDataSeriesId, singleReport.timestamp, + rawLabel, rawValue); + + // Convert to another value to draw on graph, using the original + // dataSeries as input. + finalValue = dataConversionConfig[rawLabel].convertFunction( + dataSeries[rawDataSeriesId]); + finalLabel = dataConversionConfig[rawLabel].convertedName; + finalDataSeriesId = + peerConnectionElement.id + '-' + reportName + '-' + finalLabel; + } + + // Updates the final dataSeries to draw. + addDataSeriesPoint( + finalDataSeriesId, singleReport.timestamp, finalLabel, finalValue); + + // Updates the graph. + var graphType = bweCompoundGraphConfig[finalLabel] ? + 'bweCompound' : finalLabel; + var graphViewId = + peerConnectionElement.id + '-' + reportName + '-' + graphType; + + if (!graphViews[graphViewId]) { + graphViews[graphViewId] = createStatsGraphView(peerConnectionElement, + reportName, + graphType); + var date = new Date(singleReport.timestamp); + graphViews[graphViewId].setDateRange(date, date); + } + // Adds the new dataSeries to the graphView. We have to do it here to cover + // both the simple and compound graph cases. + if (!graphViews[graphViewId].hasDataSeries(dataSeries[finalDataSeriesId])) + graphViews[graphViewId].addDataSeries(dataSeries[finalDataSeriesId]); + graphViews[graphViewId].updateEndDate(); + } +} + +// Makes sure the TimelineDataSeries with id |dataSeriesId| is created, +// and adds the new data point to it. +function addDataSeriesPoint(dataSeriesId, time, label, value) { + if (!dataSeries[dataSeriesId]) { + dataSeries[dataSeriesId] = new TimelineDataSeries(); + if (bweCompoundGraphConfig[label]) { + dataSeries[dataSeriesId].setColor( + bweCompoundGraphConfig[label].color); + } + } + dataSeries[dataSeriesId].addPoint(time, value); +} + +// Ensures a div container to hold all stats graphs for a report type +// is created as a child of |peerConnectionElement|. +function ensureStatsGraphTopContainer(peerConnectionElement, reportName) { + var containerId = peerConnectionElement.id + '-' + + reportName + '-graph-container'; + var container = $(containerId); + if (!container) { + container = document.createElement('div'); + container.id = containerId; + container.className = 'stats-graph-container-collapsed'; + + peerConnectionElement.appendChild(container); + container.innerHTML = + '<button>Expand/Collapse Graphs for ' + reportName + '</button>'; + + // Expands or collapses the graphs on click. + container.childNodes[0].addEventListener('click', function(event) { + var element = event.target.parentNode; + if (element.className == 'stats-graph-container-collapsed') + element.className = 'stats-graph-container'; + else + element.className = 'stats-graph-container-collapsed'; + }); + } + return container; +} + +// Creates the container elements holding a timeline graph +// and the TimelineGraphView object. +function createStatsGraphView(peerConnectionElement, reportName, statsName) { + var topContainer = ensureStatsGraphTopContainer(peerConnectionElement, + reportName); + + var graphViewId = + peerConnectionElement.id + '-' + reportName + '-' + statsName; + var divId = graphViewId + '-div'; + var canvasId = graphViewId + '-canvas'; + var container = document.createElement("div"); + container.className = 'stats-graph-sub-container'; + + topContainer.appendChild(container); + container.innerHTML = '<div>' + statsName + '</div>' + + '<div id=' + divId + '><canvas id=' + canvasId + '></canvas></div>'; + if (statsName == 'bweCompound') { + container.insertBefore(createBweCompoundLegend(peerConnectionElement, + reportName), + $(divId)); + } + return new TimelineGraphView(divId, canvasId); +} + +// Creates the legend section for the bweCompound graph. +// Returns the legend element. +function createBweCompoundLegend(peerConnectionElement, reportName) { + var legend = document.createElement('div'); + for (var prop in bweCompoundGraphConfig) { + var div = document.createElement('div'); + legend.appendChild(div); + div.innerHTML = '<input type=checkbox checked></input>' + prop; + div.style.color = bweCompoundGraphConfig[prop].color; + div.dataSeriesId = peerConnectionElement.id + '-' + reportName + '-' + prop; + div.graphViewId = + peerConnectionElement.id + '-' + reportName + '-bweCompound'; + div.firstChild.addEventListener('click', function(event) { + var target = dataSeries[event.target.parentNode.dataSeriesId]; + target.show(event.target.checked); + graphViews[event.target.parentNode.graphViewId].repaint(); + }); + } + return legend; +} diff --git a/content/browser/resources/media/timeline_graph_view.js b/content/browser/resources/media/timeline_graph_view.js new file mode 100644 index 0000000..bb3557d --- /dev/null +++ b/content/browser/resources/media/timeline_graph_view.js @@ -0,0 +1,556 @@ +// Copyright (c) 2013 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * A TimelineGraphView displays a timeline graph on a canvas element. + */ +var TimelineGraphView = (function() { + 'use strict'; + + // Default starting scale factor, in terms of milliseconds per pixel. + var DEFAULT_SCALE = 1000; + + // Maximum number of labels placed vertically along the sides of the graph. + var MAX_VERTICAL_LABELS = 6; + + // Vertical spacing between labels and between the graph and labels. + var LABEL_VERTICAL_SPACING = 4; + // Horizontal spacing between vertically placed labels and the edges of the + // graph. + var LABEL_HORIZONTAL_SPACING = 3; + // Horizintal spacing between two horitonally placed labels along the bottom + // of the graph. + var LABEL_LABEL_HORIZONTAL_SPACING = 25; + + // Length of ticks, in pixels, next to y-axis labels. The x-axis only has + // one set of labels, so it can use lines instead. + var Y_AXIS_TICK_LENGTH = 10; + + var GRID_COLOR = '#CCC'; + var TEXT_COLOR = '#000'; + var BACKGROUND_COLOR = '#FFF'; + + /** + * @constructor + */ + function TimelineGraphView(divId, canvasId) { + this.scrollbar_ = {position_: 0, range_: 0}; + + this.graphDiv_ = $(divId); + this.canvas_ = $(canvasId); + + // Set the range and scale of the graph. Times are in milliseconds since + // the Unix epoch. + + // All measurements we have must be after this time. + this.startTime_ = 0; + // The current rightmost position of the graph is always at most this. + this.endTime_ = 1; + + this.graph_ = null; + + // Initialize the scrollbar. + this.updateScrollbarRange_(true); + } + + TimelineGraphView.prototype = { + // Returns the total length of the graph, in pixels. + getLength_: function() { + var timeRange = this.endTime_ - this.startTime_; + // Math.floor is used to ignore the last partial area, of length less + // than DEFAULT_SCALE. + return Math.floor(timeRange / DEFAULT_SCALE); + }, + + /** + * Returns true if the graph is scrolled all the way to the right. + */ + graphScrolledToRightEdge_: function() { + return this.scrollbar_.position_ == this.scrollbar_.range_; + }, + + /** + * Update the range of the scrollbar. If |resetPosition| is true, also + * sets the slider to point at the rightmost position and triggers a + * repaint. + */ + updateScrollbarRange_: function(resetPosition) { + var scrollbarRange = this.getLength_() - this.canvas_.width; + if (scrollbarRange < 0) + scrollbarRange = 0; + + // If we've decreased the range to less than the current scroll position, + // we need to move the scroll position. + if (this.scrollbar_.position_ > scrollbarRange) + resetPosition = true; + + this.scrollbar_.range_ = scrollbarRange; + if (resetPosition) { + this.scrollbar_.position_ = scrollbarRange; + this.repaint(); + } + }, + + /** + * Sets the date range displayed on the graph, switches to the default + * scale factor, and moves the scrollbar all the way to the right. + */ + setDateRange: function(startDate, endDate) { + this.startTime_ = startDate.getTime(); + this.endTime_ = endDate.getTime(); + + // Safety check. + if (this.endTime_ <= this.startTime_) + this.startTime_ = this.endTime_ - 1; + + this.updateScrollbarRange_(true); + }, + + /** + * Updates the end time at the right of the graph to be the current time. + * Specifically, updates the scrollbar's range, and if the scrollbar is + * all the way to the right, keeps it all the way to the right. Otherwise, + * leaves the view as-is and doesn't redraw anything. + */ + updateEndDate: function() { + this.endTime_ = (new Date()).getTime(); + this.updateScrollbarRange_(this.graphScrolledToRightEdge_()); + }, + + getStartDate: function() { + return new Date(this.startTime_); + }, + + /** + * Replaces the current TimelineDataSeries with |dataSeries|. + */ + setDataSeries: function(dataSeries) { + // Simply recreates the Graph. + this.graph_ = new Graph(); + for (var i = 0; i < dataSeries.length; ++i) + this.graph_.addDataSeries(dataSeries[i]); + this.repaint(); + }, + + /** + * Adds |dataSeries| to the current graph. + */ + addDataSeries: function(dataSeries) { + if (!this.graph_) + this.graph_ = new Graph(); + this.graph_.addDataSeries(dataSeries); + this.repaint(); + }, + + /** + * Draws the graph on |canvas_|. + */ + repaint: function() { + this.repaintTimerRunning_ = false; + + var width = this.canvas_.width; + var height = this.canvas_.height; + var context = this.canvas_.getContext('2d'); + + // Clear the canvas. + context.fillStyle = BACKGROUND_COLOR; + context.fillRect(0, 0, width, height); + + // Try to get font height in pixels. Needed for layout. + var fontHeightString = context.font.match(/([0-9]+)px/)[1]; + var fontHeight = parseInt(fontHeightString); + + // Safety check, to avoid drawing anything too ugly. + if (fontHeightString.length == 0 || fontHeight <= 0 || + fontHeight * 4 > height || width < 50) { + return; + } + + // Save current transformation matrix so we can restore it later. + context.save(); + + // The center of an HTML canvas pixel is technically at (0.5, 0.5). This + // makes near straight lines look bad, due to anti-aliasing. This + // translation reduces the problem a little. + context.translate(0.5, 0.5); + + // Figure out what time values to display. + var position = this.scrollbar_.position_; + // If the entire time range is being displayed, align the right edge of + // the graph to the end of the time range. + if (this.scrollbar_.range_ == 0) + position = this.getLength_() - this.canvas_.width; + var visibleStartTime = this.startTime_ + position * DEFAULT_SCALE; + + // Make space at the bottom of the graph for the time labels, and then + // draw the labels. + var textHeight = height; + height -= fontHeight + LABEL_VERTICAL_SPACING; + this.drawTimeLabels(context, width, height, textHeight, visibleStartTime); + + // Draw outline of the main graph area. + context.strokeStyle = GRID_COLOR; + context.strokeRect(0, 0, width - 1, height - 1); + + if (this.graph_) { + // Layout graph and have them draw their tick marks. + this.graph_.layout( + width, height, fontHeight, visibleStartTime, DEFAULT_SCALE); + this.graph_.drawTicks(context); + + // Draw the lines of all graphs, and then draw their labels. + this.graph_.drawLines(context); + this.graph_.drawLabels(context); + } + + // Restore original transformation matrix. + context.restore(); + }, + + /** + * Draw time labels below the graph. Takes in start time as an argument + * since it may not be |startTime_|, when we're displaying the entire + * time range. + */ + drawTimeLabels: function(context, width, height, textHeight, startTime) { + // Text for a time string to use in determining how far apart + // to place text labels. + var sampleText = (new Date(startTime)).toLocaleTimeString(); + + // The desired spacing for text labels. + var targetSpacing = context.measureText(sampleText).width + + LABEL_LABEL_HORIZONTAL_SPACING; + + // The allowed time step values between adjacent labels. Anything much + // over a couple minutes isn't terribly realistic, given how much memory + // we use, and how slow a lot of the net-internals code is. + var timeStepValues = [ + 1000, // 1 second + 1000 * 5, + 1000 * 30, + 1000 * 60, // 1 minute + 1000 * 60 * 5, + 1000 * 60 * 30, + 1000 * 60 * 60, // 1 hour + 1000 * 60 * 60 * 5 + ]; + + // Find smallest time step value that gives us at least |targetSpacing|, + // if any. + var timeStep = null; + for (var i = 0; i < timeStepValues.length; ++i) { + if (timeStepValues[i] / DEFAULT_SCALE >= targetSpacing) { + timeStep = timeStepValues[i]; + break; + } + } + + // If no such value, give up. + if (!timeStep) + return; + + // Find the time for the first label. This time is a perfect multiple of + // timeStep because of how UTC times work. + var time = Math.ceil(startTime / timeStep) * timeStep; + + context.textBaseline = 'bottom'; + context.textAlign = 'center'; + context.fillStyle = TEXT_COLOR; + context.strokeStyle = GRID_COLOR; + + // Draw labels and vertical grid lines. + while (true) { + var x = Math.round((time - startTime) / DEFAULT_SCALE); + if (x >= width) + break; + var text = (new Date(time)).toLocaleTimeString(); + context.fillText(text, x, textHeight); + context.beginPath(); + context.lineTo(x, 0); + context.lineTo(x, height); + context.stroke(); + time += timeStep; + } + }, + + getDataSeriesCount: function() { + if (this.graph_) + return this.graph_.dataSeries_.length; + return 0; + }, + + hasDataSeries: function(dataSeries) { + if (this.graph_) + return this.graph_.hasDataSeries(dataSeries); + return false; + }, + + }; + + /** + * A Graph is responsible for drawing all the TimelineDataSeries that have + * the same data type. Graphs are responsible for scaling the values, laying + * out labels, and drawing both labels and lines for its data series. + */ + var Graph = (function() { + /** + * @constructor + */ + function Graph() { + this.dataSeries_ = []; + + // Cached properties of the graph, set in layout. + this.width_ = 0; + this.height_ = 0; + this.fontHeight_ = 0; + this.startTime_ = 0; + this.scale_ = 0; + + // At least the highest value in the displayed range of the graph. + // Used for scaling and setting labels. Set in layoutLabels. + this.max_ = 0; + + // Cached text of equally spaced labels. Set in layoutLabels. + this.labels_ = []; + } + + /** + * A Label is the label at a particular position along the y-axis. + * @constructor + */ + function Label(height, text) { + this.height = height; + this.text = text; + } + + Graph.prototype = { + addDataSeries: function(dataSeries) { + this.dataSeries_.push(dataSeries); + }, + + hasDataSeries: function(dataSeries) { + for (var i = 0; i < this.dataSeries_.length; ++i) { + if (this.dataSeries_[i] == dataSeries) + return true; + } + return false; + }, + + /** + * Returns a list of all the values that should be displayed for a given + * data series, using the current graph layout. + */ + getValues: function(dataSeries) { + if (!dataSeries.isVisible()) + return null; + return dataSeries.getValues(this.startTime_, this.scale_, this.width_); + }, + + /** + * Updates the graph's layout. In particular, both the max value and + * label positions are updated. Must be called before calling any of the + * drawing functions. + */ + layout: function(width, height, fontHeight, startTime, scale) { + this.width_ = width; + this.height_ = height; + this.fontHeight_ = fontHeight; + this.startTime_ = startTime; + this.scale_ = scale; + + // Find largest value. + var max = 0; + for (var i = 0; i < this.dataSeries_.length; ++i) { + var values = this.getValues(this.dataSeries_[i]); + if (!values) + continue; + for (var j = 0; j < values.length; ++j) { + if (values[j] > max) + max = values[j]; + } + } + + this.layoutLabels_(max); + }, + + /** + * Lays out labels and sets |max_|, taking the time units into + * consideration. |maxValue| is the actual maximum value, and + * |max_| will be set to the value of the largest label, which + * will be at least |maxValue|. + */ + layoutLabels_: function(maxValue) { + if (maxValue < 1024) { + this.layoutLabelsBasic_(maxValue, 0); + return; + } + + // Find appropriate units to use. + var units = ['', 'k', 'M', 'G', 'T', 'P']; + // Units to use for labels. 0 is '1', 1 is K, etc. + // We start with 1, and work our way up. + var unit = 1; + maxValue /= 1024; + while (units[unit + 1] && maxValue >= 1024) { + maxValue /= 1024; + ++unit; + } + + // Calculate labels. + this.layoutLabelsBasic_(maxValue, 1); + + // Append units to labels. + for (var i = 0; i < this.labels_.length; ++i) + this.labels_[i] += ' ' + units[unit]; + + // Convert |max_| back to unit '1'. + this.max_ *= Math.pow(1024, unit); + }, + + /** + * Same as layoutLabels_, but ignores units. |maxDecimalDigits| is the + * maximum number of decimal digits allowed. The minimum allowed + * difference between two adjacent labels is 10^-|maxDecimalDigits|. + */ + layoutLabelsBasic_: function(maxValue, maxDecimalDigits) { + this.labels_ = []; + // No labels if |maxValue| is 0. + if (maxValue == 0) { + this.max_ = maxValue; + return; + } + + // The maximum number of equally spaced labels allowed. |fontHeight_| + // is doubled because the top two labels are both drawn in the same + // gap. + var minLabelSpacing = 2 * this.fontHeight_ + LABEL_VERTICAL_SPACING; + + // The + 1 is for the top label. + var maxLabels = 1 + this.height_ / minLabelSpacing; + if (maxLabels < 2) { + maxLabels = 2; + } else if (maxLabels > MAX_VERTICAL_LABELS) { + maxLabels = MAX_VERTICAL_LABELS; + } + + // Initial try for step size between conecutive labels. + var stepSize = Math.pow(10, -maxDecimalDigits); + // Number of digits to the right of the decimal of |stepSize|. + // Used for formating label strings. + var stepSizeDecimalDigits = maxDecimalDigits; + + // Pick a reasonable step size. + while (true) { + // If we use a step size of |stepSize| between labels, we'll need: + // + // Math.ceil(maxValue / stepSize) + 1 + // + // labels. The + 1 is because we need labels at both at 0 and at + // the top of the graph. + + // Check if we can use steps of size |stepSize|. + if (Math.ceil(maxValue / stepSize) + 1 <= maxLabels) + break; + // Check |stepSize| * 2. + if (Math.ceil(maxValue / (stepSize * 2)) + 1 <= maxLabels) { + stepSize *= 2; + break; + } + // Check |stepSize| * 5. + if (Math.ceil(maxValue / (stepSize * 5)) + 1 <= maxLabels) { + stepSize *= 5; + break; + } + stepSize *= 10; + if (stepSizeDecimalDigits > 0) + --stepSizeDecimalDigits; + } + + // Set the max so it's an exact multiple of the chosen step size. + this.max_ = Math.ceil(maxValue / stepSize) * stepSize; + + // Create labels. + for (var label = this.max_; label >= 0; label -= stepSize) + this.labels_.push(label.toFixed(stepSizeDecimalDigits)); + }, + + /** + * Draws tick marks for each of the labels in |labels_|. + */ + drawTicks: function(context) { + var x1; + var x2; + x1 = this.width_ - 1; + x2 = this.width_ - 1 - Y_AXIS_TICK_LENGTH; + + context.fillStyle = GRID_COLOR; + context.beginPath(); + for (var i = 1; i < this.labels_.length - 1; ++i) { + // The rounding is needed to avoid ugly 2-pixel wide anti-aliased + // lines. + var y = Math.round(this.height_ * i / (this.labels_.length - 1)); + context.moveTo(x1, y); + context.lineTo(x2, y); + } + context.stroke(); + }, + + /** + * Draws a graph line for each of the data series. + */ + drawLines: function(context) { + // Factor by which to scale all values to convert them to a number from + // 0 to height - 1. + var scale = 0; + var bottom = this.height_ - 1; + if (this.max_) + scale = bottom / this.max_; + + // Draw in reverse order, so earlier data series are drawn on top of + // subsequent ones. + for (var i = this.dataSeries_.length - 1; i >= 0; --i) { + var values = this.getValues(this.dataSeries_[i]); + if (!values) + continue; + context.strokeStyle = this.dataSeries_[i].getColor(); + context.beginPath(); + for (var x = 0; x < values.length; ++x) { + // The rounding is needed to avoid ugly 2-pixel wide anti-aliased + // horizontal lines. + context.lineTo(x, bottom - Math.round(values[x] * scale)); + } + context.stroke(); + } + }, + + /** + * Draw labels in |labels_|. + */ + drawLabels: function(context) { + if (this.labels_.length == 0) + return; + var x = this.width_ - LABEL_HORIZONTAL_SPACING; + + // Set up the context. + context.fillStyle = TEXT_COLOR; + context.textAlign = 'right'; + + // Draw top label, which is the only one that appears below its tick + // mark. + context.textBaseline = 'top'; + context.fillText(this.labels_[0], x, 0); + + // Draw all the other labels. + context.textBaseline = 'bottom'; + var step = (this.height_ - 1) / (this.labels_.length - 1); + for (var i = 1; i < this.labels_.length; ++i) + context.fillText(this.labels_[i], x, step * i); + } + }; + + return Graph; + })(); + + return TimelineGraphView; +})(); diff --git a/content/browser/resources/media/webrtc_internals.css b/content/browser/resources/media/webrtc_internals.css index f48a722..83d1f39 100644 --- a/content/browser/resources/media/webrtc_internals.css +++ b/content/browser/resources/media/webrtc_internals.css @@ -6,6 +6,36 @@ float: left; } +.stats-graph-container, +.stats-graph-container-collapsed { + clear: both; + margin: 0.5em 0 0.5em 0; +} + +.stats-graph-container button, +.stats-graph-container-collapsed button { + display: block; + margin: 0; + padding: 0; +} + +.stats-graph-container-collapsed .stats-graph-sub-container { + display: none; +} + +.stats-graph-sub-container { + float: left; + margin: 0.5em; +} + +.stats-graph-sub-container > div { + float: left; +} + +.stats-graph-sub-container > div:first-child { + float: none; +} + .stats-table-container { float: left; padding: 0 0 0 0; @@ -36,7 +66,7 @@ h2 { h3 { color: #666; font-size: 0.9em; - margin: 0 0 0.5em 0; + margin: 1em 0 0.5em 0; } li { diff --git a/content/browser/resources/media/webrtc_internals.js b/content/browser/resources/media/webrtc_internals.js index af29ac9..77faaf8 100644 --- a/content/browser/resources/media/webrtc_internals.js +++ b/content/browser/resources/media/webrtc_internals.js @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +<include src="stats_graph_helper.js"/> + var peerConnectionsListElem = null; function initialize() { @@ -169,13 +171,14 @@ function updatePeerConnection(data) { addToPeerConnectionLog(logElement, data); } -// data is an array and each entry is in the same format as the input of -// updatePeerConnection. +// data is an array and each entry is +// {pid:|integer|, lid:|integer|, +// url:|string|, servers:|string|, constraints:|string|, log:|array|}, +// each entry of log is {type:|string|, value:|string|}. function updateAllPeerConnections(data) { for (var i = 0; i < data.length; ++i) { var peerConnection = addPeerConnection(data[i]); var logElement = ensurePeerConnectionLog(peerConnection); - logElement.value = ''; var log = data[i].log; for (var j = 0; j < log.length; ++j) { @@ -186,7 +189,7 @@ function updateAllPeerConnections(data) { // data = {pid:|integer|, lid:|integer|, reports:|array|}. // Each entry of reports = -// {id:|string|, type:|string|, local:|array|, remote:|array|}. +// {id:|string|, type:|string|, local:|object|, remote:|object|}. // reports.local or reports.remote = // {timestamp: |double|, values: |array|}, // where values is an array of strings, whose even index entry represents @@ -197,11 +200,13 @@ function addStats(data) { getPeerConnectionId(data)); for (var i = 0; i < data.reports.length; ++i) { var report = data.reports[i]; - var statsTable = ensureStatsTable(peerConnectionElement, - report.type + '-' + report.id); + var reportName = report.type + '-' + report.id; + var statsTable = ensureStatsTable(peerConnectionElement, reportName); addSingleReportToTable(statsTable, report.local); + drawSingleReport(peerConnectionElement, reportName, report.local); addSingleReportToTable(statsTable, report.remote); + drawSingleReport(peerConnectionElement, reportName, report.remote); } } diff --git a/content/content_tests.gypi b/content/content_tests.gypi index 8336e0f..6f6bf82 100644 --- a/content/content_tests.gypi +++ b/content/content_tests.gypi @@ -734,6 +734,7 @@ 'browser/media/audio_browsertest.cc', 'browser/media/encrypted_media_browsertest.cc', 'browser/media/media_browsertest.cc', + 'browser/media/webrtc_internals_browsertest.cc', 'browser/plugin_data_remover_impl_browsertest.cc', 'browser/plugin_browsertest.cc', 'browser/plugin_service_impl_browsertest.cc', |