Commit 206ce368 authored by Becca Hughes's avatar Becca Hughes Committed by Commit Bot

[Media History] Add sessions tab to UI

Add the media sessions tab to the WebUI
for debugging.

Change-Id: Iecbe717a118e44b05a95fd5432b1384f3d32f6fa
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2055597Reviewed-by: default avatarChrome Cunningham <chcunningham@chromium.org>
Reviewed-by: default avatarSam McNally <sammc@chromium.org>
Reviewed-by: default avatarTheresa  <twellington@chromium.org>
Reviewed-by: default avatarTommy Steimel <steimel@chromium.org>
Reviewed-by: default avatarEsmael Elmoslimany <aee@chromium.org>
Commit-Queue: Becca Hughes <beccahughes@chromium.org>
Cr-Commit-Position: refs/heads/master@{#742226}
parent 19baa3ef
......@@ -54,6 +54,8 @@ This file specifies browser resources for developer-facing chrome:// pages
<include name="IDR_NOTIFICATIONS_INTERNALS_BROWSER_PROXY_JS" file="resources\notifications_internals\notifications_internals_browser_proxy.js" type="BINDATA" compress="gzip" />
<include name="IDR_PREDICTORS_HTML" file="resources\predictors\predictors.html" flattenhtml="true" allowexternalscript="true" type="BINDATA" compress="gzip" />
<include name="IDR_PREDICTORS_JS" file="resources\predictors\predictors.js" flattenhtml="true" type="BINDATA" compress="gzip" />
<include name="IDR_MEDIA_SESSION_MOJOM_LITE_JS" file="${root_gen_dir}\services\media_session\public\mojom\media_session.mojom-lite.js" use_base_dir="false" type="BINDATA" compress="gzip" />
<include name="IDR_UI_GEOMETRY_MOJOM_LITE_JS" file="${root_gen_dir}\ui\gfx\geometry\mojom\geometry.mojom-lite.js" use_base_dir="false" type="BINDATA" compress="gzip" />
<if expr="is_android or is_linux">
<include name="IDR_SANDBOX_INTERNALS_HTML" file="resources\sandbox_internals\sandbox_internals.html" flattenhtml="true" allowexternalscript="true" type="BINDATA" compress="gzip" />
......
......@@ -11,7 +11,10 @@ mojom("mojo_bindings") {
"media_engagement_score_details.mojom",
]
public_deps = [ "//url/mojom:url_mojom_origin" ]
public_deps = [
"//services/media_session/public/mojom",
"//url/mojom:url_mojom_origin",
]
}
proto_library("media_engagement_preload_proto") {
......
......@@ -76,7 +76,7 @@ class MediaHistoryBrowserTest : public InProcessBrowserTest {
return content::ExecuteScript(GetWebContents(), "finishPlaying();");
}
MediaHistoryStore::MediaPlaybackSessionList GetPlaybackSessionsSync(
std::vector<mojom::MediaHistoryPlaybackSessionRowPtr> GetPlaybackSessionsSync(
int max_sessions) {
return GetPlaybackSessionsSync(
max_sessions, base::BindRepeating([](const base::TimeDelta& duration,
......@@ -85,18 +85,18 @@ class MediaHistoryBrowserTest : public InProcessBrowserTest {
}));
}
MediaHistoryStore::MediaPlaybackSessionList GetPlaybackSessionsSync(
std::vector<mojom::MediaHistoryPlaybackSessionRowPtr> GetPlaybackSessionsSync(
int max_sessions,
MediaHistoryStore::GetPlaybackSessionsFilter filter) {
base::RunLoop run_loop;
MediaHistoryStore::MediaPlaybackSessionList out;
std::vector<mojom::MediaHistoryPlaybackSessionRowPtr> out;
GetMediaHistoryStore()->GetPlaybackSessions(
max_sessions, std::move(filter),
base::BindLambdaForTesting(
[&](base::Optional<MediaHistoryStore::MediaPlaybackSessionList>
[&](std::vector<mojom::MediaHistoryPlaybackSessionRowPtr>
sessions) {
out = std::move(*sessions);
out = std::move(sessions);
run_loop.Quit();
}));
......
......@@ -113,32 +113,31 @@ base::Optional<int64_t> MediaHistorySessionTable::SavePlaybackSession(
return base::nullopt;
}
base::Optional<MediaHistoryStore::MediaPlaybackSessionList>
std::vector<mojom::MediaHistoryPlaybackSessionRowPtr>
MediaHistorySessionTable::GetPlaybackSessions(
unsigned int num_sessions,
MediaHistoryStore::GetPlaybackSessionsFilter filter) {
base::Optional<unsigned int> num_sessions,
base::Optional<MediaHistoryStore::GetPlaybackSessionsFilter> filter) {
std::vector<mojom::MediaHistoryPlaybackSessionRowPtr> sessions;
if (!CanAccessDatabase())
return base::nullopt;
return sessions;
sql::Statement statement(DB()->GetCachedStatement(
SQL_FROM_HERE,
base::StringPrintf(
"SELECT id, url, duration_ms, position_ms, title, artist, "
"album, source_title FROM %s ORDER BY id DESC",
"album, source_title, last_updated_time_s FROM %s ORDER BY id DESC",
kTableName)
.c_str()));
MediaHistoryStore::MediaPlaybackSessionList sessions;
while (statement.Step()) {
auto duration = base::TimeDelta::FromMilliseconds(statement.ColumnInt64(2));
auto position = base::TimeDelta::FromMilliseconds(statement.ColumnInt64(3));
// Skip any that should not be shown.
if (!filter.Run(duration, position))
if (filter.has_value() && !filter->Run(duration, position))
continue;
auto session = std::make_unique<MediaHistoryStore::MediaPlaybackSession>();
auto session(mojom::MediaHistoryPlaybackSessionRow::New());
session->id = statement.ColumnInt64(0);
session->url = GURL(statement.ColumnString(1));
session->duration = duration;
......@@ -147,12 +146,16 @@ MediaHistorySessionTable::GetPlaybackSessions(
session->metadata.artist = statement.ColumnString16(5);
session->metadata.album = statement.ColumnString16(6);
session->metadata.source_title = statement.ColumnString16(7);
session->last_updated_time =
base::Time::FromDeltaSinceWindowsEpoch(
base::TimeDelta::FromSeconds(statement.ColumnInt64(8)))
.ToJsTime();
sessions.push_back(std::move(session));
// If we have all the sessions we want we can stop loading data from the
// database.
if (sessions.size() >= num_sessions)
if (num_sessions.has_value() && sessions.size() >= *num_sessions)
break;
}
......
......@@ -49,9 +49,9 @@ class MediaHistorySessionTable : public MediaHistoryTableBase {
const media_session::MediaMetadata& metadata,
const base::Optional<media_session::MediaPosition>& position);
base::Optional<MediaHistoryStore::MediaPlaybackSessionList>
GetPlaybackSessions(unsigned int num_sessions,
MediaHistoryStore::GetPlaybackSessionsFilter filter);
std::vector<mojom::MediaHistoryPlaybackSessionRowPtr> GetPlaybackSessions(
base::Optional<unsigned int> num_sessions,
base::Optional<MediaHistoryStore::GetPlaybackSessionsFilter> filter);
};
} // namespace media_history
......
......@@ -77,9 +77,9 @@ class MediaHistoryStoreInternal
const base::Optional<media_session::MediaPosition>& position,
const std::vector<media_session::MediaImage>& artwork);
base::Optional<MediaHistoryStore::MediaPlaybackSessionList>
GetPlaybackSessions(unsigned int num_sessions,
MediaHistoryStore::GetPlaybackSessionsFilter filter);
std::vector<mojom::MediaHistoryPlaybackSessionRowPtr> GetPlaybackSessions(
base::Optional<unsigned int> num_sessions,
base::Optional<MediaHistoryStore::GetPlaybackSessionsFilter> filter);
void RazeAndClose();
......@@ -353,18 +353,19 @@ void MediaHistoryStoreInternal::SavePlaybackSession(
DB()->CommitTransaction();
}
base::Optional<MediaHistoryStore::MediaPlaybackSessionList>
std::vector<mojom::MediaHistoryPlaybackSessionRowPtr>
MediaHistoryStoreInternal::GetPlaybackSessions(
unsigned int num_sessions,
MediaHistoryStore::GetPlaybackSessionsFilter filter) {
base::Optional<unsigned int> num_sessions,
base::Optional<MediaHistoryStore::GetPlaybackSessionsFilter> filter) {
DCHECK(db_task_runner_->RunsTasksInCurrentSequence());
if (!initialization_successful_)
return base::nullopt;
return std::vector<mojom::MediaHistoryPlaybackSessionRowPtr>();
auto sessions =
session_table_->GetPlaybackSessions(num_sessions, std::move(filter));
for (auto& session : *sessions) {
for (auto& session : sessions) {
session->artwork = session_images_table_->GetImagesForSession(session->id);
}
......@@ -391,10 +392,6 @@ MediaHistoryStore::MediaHistoryStore(
MediaHistoryStore::~MediaHistoryStore() {}
MediaHistoryStore::MediaPlaybackSession::MediaPlaybackSession() = default;
MediaHistoryStore::MediaPlaybackSession::~MediaPlaybackSession() = default;
void MediaHistoryStore::SavePlayback(
const content::MediaPlayerWatchTime& watch_time) {
if (!db_->initialization_successful_)
......@@ -472,14 +469,10 @@ void MediaHistoryStore::SavePlaybackSession(
}
void MediaHistoryStore::GetPlaybackSessions(
unsigned int num_sessions,
GetPlaybackSessionsFilter filter,
GetPlaybackSessionsCallback callback) {
if (!db_->initialization_successful_) {
std::move(callback).Run(base::nullopt);
return;
}
base::Optional<unsigned int> num_sessions,
base::Optional<GetPlaybackSessionsFilter> filter,
base::OnceCallback<
void(std::vector<mojom::MediaHistoryPlaybackSessionRowPtr>)> callback) {
base::PostTaskAndReplyWithResult(
db_->db_task_runner_.get(), FROM_HERE,
base::BindOnce(&MediaHistoryStoreInternal::GetPlaybackSessions, db_,
......
......@@ -44,23 +44,6 @@ class MediaHistoryStore {
scoped_refptr<base::UpdateableSequencedTaskRunner> db_task_runner);
~MediaHistoryStore();
// Represents a single playback session stored in the database.
struct MediaPlaybackSession {
int64_t id;
GURL url;
base::TimeDelta duration;
base::TimeDelta position;
media_session::MediaMetadata metadata;
std::vector<media_session::MediaImage> artwork;
MediaPlaybackSession();
~MediaPlaybackSession();
MediaPlaybackSession(const MediaPlaybackSession&) = delete;
MediaPlaybackSession& operator=(const MediaPlaybackSession&) = delete;
};
using MediaPlaybackSessionList =
std::vector<std::unique_ptr<MediaPlaybackSession>>;
// Saves a playback from a single player in the media history store.
void SavePlayback(const content::MediaPlayerWatchTime& watch_time);
......@@ -83,14 +66,14 @@ class MediaHistoryStore {
// be ordered by most recent first and be limited to the first |num_sessions|.
// For each session it calls |filter| and if that returns |true| then that
// session will be included in the results.
using GetPlaybackSessionsCallback =
base::OnceCallback<void(base::Optional<MediaPlaybackSessionList>)>;
using GetPlaybackSessionsFilter =
base::RepeatingCallback<bool(const base::TimeDelta& duration,
const base::TimeDelta& position)>;
void GetPlaybackSessions(unsigned int num_sessions,
GetPlaybackSessionsFilter filter,
GetPlaybackSessionsCallback callback);
void GetPlaybackSessions(
base::Optional<unsigned int> num_sessions,
base::Optional<GetPlaybackSessionsFilter> filter,
base::OnceCallback<void(
std::vector<mojom::MediaHistoryPlaybackSessionRowPtr>)> callback);
// Saves a playback session in the media history store.
void SavePlaybackSession(
......
......@@ -5,6 +5,7 @@
module media_history.mojom;
import "mojo/public/mojom/base/time.mojom";
import "services/media_session/public/mojom/media_session.mojom";
import "url/mojom/origin.mojom";
import "url/mojom/url.mojom";
......@@ -27,6 +28,8 @@ struct MediaHistoryOriginRow {
double last_updated_time;
};
// A playback is recorded for each individual player on a page. Child frame
// playbacks are recorded but we use the top frame url.
struct MediaHistoryPlaybackRow {
// The top frame URL of the page that had the playback.
url.mojom.Url url;
......@@ -42,6 +45,30 @@ struct MediaHistoryPlaybackRow {
double last_updated_time;
};
// There is a single playback session for each web contents that plays videos
// with audio tracks. It is not recorded for audio-only and video-only sessions.
// It shares a 1:1 relationship with the media session of the page.
struct MediaHistoryPlaybackSessionRow {
// The id of the session.
int64 id;
// The top frame URL of the page that had the playback session.
url.mojom.Url url;
// The duration and position of the playback session.
mojo_base.mojom.TimeDelta duration;
mojo_base.mojom.TimeDelta position;
// The metadata associated with the media session.
media_session.mojom.MediaMetadata metadata;
// The artwork associated with the media session.
array<media_session.mojom.MediaImage> artwork;
// The last updated time of the row in JS time.
double last_updated_time;
};
// MediaHistoryStore allows the Media History WebUI to access data from the
// Media History database for diagnostic purposes.
interface MediaHistoryStore {
......@@ -53,4 +80,8 @@ interface MediaHistoryStore {
// Returns the playback from the playbacks table.
GetMediaHistoryPlaybackRows() => (array<MediaHistoryPlaybackRow> rows);
// Returns the playback sessions from the sessions table.
GetMediaHistoryPlaybackSessionRows() =>
(array<MediaHistoryPlaybackSessionRow> rows);
};
......@@ -9,10 +9,14 @@
<script src="chrome://resources/mojo/mojo/public/js/mojo_bindings_lite.js"></script>
<script src="chrome://resources/js/promise_resolver.js"></script>
<script src="chrome://resources/js/util.js"></script>
<script src="chrome://resources/mojo/mojo/public/mojom/base/big_buffer.mojom-lite.js"></script>
<script src="chrome://resources/mojo/mojo/public/mojom/base/string16.mojom-lite.js"></script>
<script src="chrome://resources/mojo/mojo/public/mojom/base/unguessable_token.mojom-lite.js"></script>
<script src="chrome://resources/mojo/mojo/public/mojom/base/time.mojom-lite.js"></script>
<script src="chrome://resources/mojo/url/mojom/url.mojom-lite.js"></script>
<script src="chrome://resources/mojo/url/mojom/origin.mojom-lite.js"></script>
<script src="ui/gfx/geometry/mojom/geometry.mojom-lite.js"></script>
<script src="services/media_session/public/mojom/media_session.mojom-lite.js"></script>
<script src="chrome/browser/media/history/media_history_store.mojom-lite.js"></script>
<script src="chrome://resources/js/cr.js"></script>
......@@ -102,6 +106,7 @@
<tab id="stats">Stats</tab>
<tab id="origins">Origins</tab>
<tab id="playbacks">Playbacks</tab>
<tab id="sessions">Sessions</tab>
</tabs>
<tabpanels>
<tabpanel>
......@@ -148,6 +153,11 @@
</tabpanel>
<tabpanel>
<h1>Playbacks</h1>
<p>
The playbacks table stores playbacks that happened on a page. These
can be of any type and there is one playback stored for each player
per page.
</p>
<button class="copy-all-to-clipboard">Copy all to clipboard</button>
<table id="playbacks-table">
<thead>
......@@ -173,6 +183,50 @@
</tbody>
</table>
</tabpanel>
<tabpanel>
<h1>Sessions</h1>
<p>
The sessions table stores media sessions that had media playback that
had both an audio and video track. There is only one session recorded
for the lifetime of the page.
</p>
<button class="copy-all-to-clipboard">Copy all to clipboard</button>
<table id="sessions-table">
<thead>
<tr>
<th sort-key="url">
URL
</th>
<th sort-key="lastUpdatedTime" class="sort-column" sort-reverse>
Last Updated
</th>
<th sort-key="position">
Position (secs)
</th>
<th sort-key="duration">
Duration (secs)
</th>
<th sort-key="metadata.title">
Title
</th>
<th sort-key="metadata.artist">
Artist
</th>
<th sort-key="metadata.album">
Album
</th>
<th sort-key="metadata.sourceTitle">
Source Title
</th>
<th data-key="artwork">
Artwork
</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</tabpanel>
</tabpanels>
</tabbox>
......
......@@ -17,6 +17,7 @@ let store = null;
let statsTableBody = null;
let originsTable = null;
let playbacksTable = null;
let sessionsTable = null;
/**
* Creates a single row in the stats table.
......@@ -58,10 +59,24 @@ function compareTableItem(sortKey, a, b) {
// Compare mojo_base.mojom.TimeDelta microseconds value.
if (sortKey == 'cachedAudioVideoWatchtime' ||
sortKey == 'actualAudioVideoWatchtime' || sortKey == 'watchtime') {
sortKey == 'actualAudioVideoWatchtime' || sortKey == 'watchtime' ||
sortKey == 'duration' || sortKey == 'position') {
return val1.microseconds - val2.microseconds;
}
if (sortKey.startsWith('metadata.')) {
// Keys with a period denote nested objects.
let nestedA = a;
let nestedB = b;
const expandedKey = sortKey.split('.');
expandedKey.forEach((k) => {
nestedA = nestedA[k];
nestedB = nestedB[k];
});
return nestedA > nestedB;
}
if (sortKey == 'lastUpdatedTime') {
return val1 - val2;
}
......@@ -71,39 +86,68 @@ function compareTableItem(sortKey, a, b) {
}
/**
* Formats a field to be displayed in the data table.
* Parses utf16 coded string.
* @param {!mojoBase.mojom.String16} arr
* @return {string}
*/
function decodeString16(arr) {
return arr.data.map(ch => String.fromCodePoint(ch)).join('');
}
/**
* Formats a field to be displayed in the data table and inserts it into the
* element.
* @param {HTMLTableRowElement} td
* @param {?object} data
* @param {string} key
* @returns {?string}
*/
function formatField(data, key) {
function insertDataField(td, data, key) {
if (data === undefined || data === null) {
return;
}
if (key == 'origin') {
let origin = data.scheme + '://' + data.host;
// Format a mojo origin.
td.textContent = data.scheme + '://' + data.host;
if (data.scheme == 'http' && data.port != '80') {
origin += ':' + data.port;
td.textContent += ':' + data.port;
} else if (data.scheme == 'https' && data.port != '443') {
origin += ':' + data.port;
td.textContent += ':' + data.port;
}
return origin;
} else if (key == 'lastUpdatedTime') {
return data ? new Date(data).toISOString() : '';
// Format a JS timestamp.
td.textContent = data ? new Date(data).toISOString() : '';
} else if (
key == 'cachedAudioVideoWatchtime' ||
key == 'actualAudioVideoWatchtime' || key == 'watchtime') {
key == 'actualAudioVideoWatchtime' || key == 'watchtime' ||
key == 'duration' || key == 'position') {
// Format a mojo timedelta.
const secs = (data.microseconds / 1000000);
return secs.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,');
td.textContent = secs.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,');
} else if (key == 'url') {
return data.url;
// Format a mojo GURL.
td.textContent = data.url;
} else if (key == 'hasAudio' || key == 'hasVideo') {
return data ? 'Yes' : 'No';
// Format a boolean.
td.textContent = data ? 'Yes' : 'No';
} else if (
key == 'title' || key == 'artist' || key == 'album' ||
key == 'sourceTitle') {
// Format a mojo string16.
td.textContent = decodeString16(data);
} else if (key == 'artwork') {
// Format an array of mojo media images.
data.forEach((image) => {
const a = document.createElement('a');
a.href = image.src.url;
a.textContent = image.src.url;
a.target = '_blank';
td.appendChild(a);
td.appendChild(document.createElement('br'));
});
} else {
td.textContent = data;
}
return data;
}
class DataTable {
......@@ -153,7 +197,10 @@ class DataTable {
// Get the sort key from the columns to determine which data should be in
// which column.
const headerCells = Array.from(this.table_.querySelectorAll('thead th'));
const sortKeys = headerCells.map((e) => e.getAttribute('sort-key'));
const dataAndSortKeys = headerCells.map((e) => {
return e.getAttribute('sort-key') ? e.getAttribute('sort-key') :
e.getAttribute('data-key');
});
const currentSortCol = this.table_.querySelectorAll('.sort-column')[0];
const currentSortKey = currentSortCol.getAttribute('sort-key');
......@@ -170,9 +217,18 @@ class DataTable {
const tr = document.createElement('tr');
body.appendChild(tr);
sortKeys.forEach((key) => {
dataAndSortKeys.forEach((key) => {
const td = document.createElement('td');
td.textContent = formatField(dataRow[key], key);
// Keys with a period denote nested objects.
let data = dataRow;
const expandedKey = key.split('.');
expandedKey.forEach((k) => {
data = data[k];
key = k;
});
insertDataField(td, data, key);
tr.appendChild(td);
});
});
......@@ -217,6 +273,10 @@ function showTab(name) {
return store.getMediaHistoryPlaybackRows().then(response => {
playbacksTable.setData(response.rows);
});
case 'sessions':
return store.getMediaHistoryPlaybackSessionRows().then(response => {
sessionsTable.setData(response.rows);
});
}
// Return an empty promise if there is no tab.
......@@ -230,6 +290,7 @@ document.addEventListener('DOMContentLoaded', function() {
originsTable = new DataTable($('origins-table'));
playbacksTable = new DataTable($('playbacks-table'));
sessionsTable = new DataTable($('sessions-table'));
cr.ui.decorate('tabbox', cr.ui.TabBox);
......
......@@ -26,6 +26,11 @@ MediaHistoryUI::MediaHistoryUI(content::WebUI* web_ui)
std::unique_ptr<content::WebUIDataSource> source(
content::WebUIDataSource::Create(chrome::kChromeUIMediaHistoryHost));
source->AddResourcePath("media-history.js", IDR_MEDIA_HISTORY_JS);
source->AddResourcePath(
"services/media_session/public/mojom/media_session.mojom-lite.js",
IDR_MEDIA_SESSION_MOJOM_LITE_JS);
source->AddResourcePath("ui/gfx/geometry/mojom/geometry.mojom-lite.js",
IDR_UI_GEOMETRY_MOJOM_LITE_JS);
source->AddResourcePath(
"chrome/browser/media/history/media_history_store.mojom-lite.js",
IDR_MEDIA_HISTORY_STORE_MOJOM_LITE_JS);
......@@ -58,6 +63,12 @@ void MediaHistoryUI::GetMediaHistoryPlaybackRows(
std::move(callback));
}
void MediaHistoryUI::GetMediaHistoryPlaybackSessionRows(
GetMediaHistoryPlaybackSessionRowsCallback callback) {
return GetMediaHistoryStore()->GetPlaybackSessions(
base::nullopt, base::nullopt, std::move(callback));
}
media_history::MediaHistoryStore* MediaHistoryUI::GetMediaHistoryStore() {
Profile* profile = Profile::FromWebUI(web_ui());
DCHECK(profile);
......
......@@ -34,6 +34,8 @@ class MediaHistoryUI : public ui::MojoWebUIController,
GetMediaHistoryOriginRowsCallback callback) override;
void GetMediaHistoryPlaybackRows(
GetMediaHistoryPlaybackRowsCallback callback) override;
void GetMediaHistoryPlaybackSessionRows(
GetMediaHistoryPlaybackSessionRowsCallback callback) override;
private:
media_history::MediaHistoryStore* GetMediaHistoryStore();
......
......@@ -132,3 +132,36 @@ TEST_F('MediaHistoryPlaybacksWebUIBrowserTest', 'MAYBE_All', function() {
mocha.run();
});
/**
* Tests for the sessions tab.
* @extends {MediaHistoryWebUIBrowserTest}
*/
function MediaHistorySessionsWebUIBrowserTest() {}
MediaHistorySessionsWebUIBrowserTest.prototype = {
__proto__: MediaHistoryWebUIBrowserTest.prototype,
/** @override */
browsePreload: 'chrome://media-history#tab-sessions',
};
TEST_F('MediaHistorySessionsWebUIBrowserTest', 'MAYBE_All', function() {
suiteSetup(function() {
return whenPageIsPopulatedForTest();
});
test('check data table is loaded', () => {
let dataHeaderRows =
Array.from(document.querySelector('#sessions-table thead tr').children);
assertDeepEquals(
[
'URL', 'Last Updated', 'Position (secs)', 'Duration (secs)', 'Title',
'Artist', 'Album', 'Source Title', 'Artwork'
],
dataHeaderRows.map(x => x.textContent.trim()));
});
mocha.run();
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment