Source code
Revision control
Copy as Markdown
Other Tools
import { BenchmarkRunner } from "./benchmark-runner.mjs";
import * as Statistics from "./statistics.mjs";
import { Suites } from "./tests.mjs";
import { renderMetricView } from "./metric-ui.mjs";
import { params } from "./params.mjs";
import { createDeveloperModeContainer } from "./developer-mode.mjs";
// FIXME(camillobruni): Add base class
class MainBenchmarkClient {
developerMode = false;
stepCount = null;
suitesCount = null;
_measuredValuesList = [];
_finishedTestCount = 0;
_progressCompleted = null;
_isRunning = false;
_hasResults = false;
_developerModeContainer = null;
_metrics = Object.create(null);
constructor() {
window.addEventListener("DOMContentLoaded", () => this.prepareUI());
this._showSection(window.location.hash);
}
start() {
if (this._startBenchmark())
this._showSection("#running");
}
_startBenchmark() {
if (this._isRunning)
return false;
if (Suites.every((suite) => suite.disabled)) {
const message = `No suites selected - "${params.suites}" does not exist.`;
alert(message);
console.error(
message,
params.suites,
"\nValid values:",
Suites.map((each) => each.name)
);
return false;
}
this._developerModeContainer?.remove();
this._progressCompleted = document.getElementById("progress-completed");
if (params.iterationCount < 50) {
const progressNode = document.getElementById("progress");
for (let i = 1; i < params.iterationCount; i++) {
const iterationMarker = progressNode.appendChild(document.createElement("div"));
iterationMarker.className = "iteration-marker";
iterationMarker.style.left = `${(i / params.iterationCount) * 100}%`;
}
}
this._metrics = Object.create(null);
this._isRunning = true;
const enabledSuites = Suites.filter((suite) => !suite.disabled);
const totalSubtestsCount = enabledSuites.reduce((testsCount, suite) => {
return testsCount + suite.tests.length;
}, 0);
this.stepCount = params.iterationCount * totalSubtestsCount;
this._progressCompleted.max = this.stepCount;
this.suitesCount = enabledSuites.length;
const runner = new BenchmarkRunner(Suites, this);
runner.runMultipleIterations(params.iterationCount);
return true;
}
get metrics() {
return this._metrics;
}
willAddTestFrame(frame) {
frame.style.left = "50%";
frame.style.top = "50%";
frame.style.transform = "translate(-50%, -50%)";
}
willRunTest(suite, test) {
document.getElementById("info-label").textContent = suite.name;
document.getElementById("info-progress").textContent = `${this._finishedTestCount} / ${this.stepCount}`;
}
didRunTest() {
this._finishedTestCount++;
this._progressCompleted.value = this._finishedTestCount;
}
didRunSuites(measuredValues) {
this._measuredValuesList.push(measuredValues);
}
willStartFirstIteration() {
this._measuredValuesList = [];
this._finishedTestCount = 0;
}
didFinishLastIteration(metrics) {
console.assert(this._isRunning);
this._isRunning = false;
this._hasResults = true;
this._metrics = metrics;
const scoreResults = this._computeResults(this._measuredValuesList, "score");
this._updateGaugeNeedle(scoreResults.mean);
document.getElementById("result-number").textContent = scoreResults.formattedMean;
if (scoreResults.formattedDelta)
document.getElementById("confidence-number").textContent = `\u00b1 ${scoreResults.formattedDelta}`;
this._populateDetailedResults(metrics);
if (params.developerMode)
this.showResultsDetails();
else
this.showResultsSummary();
}
_computeResults(measuredValuesList, displayUnit) {
function valueForUnit(measuredValues) {
if (displayUnit === "ms")
return measuredValues.geomean;
return measuredValues.score;
}
function sigFigFromPercentDelta(percentDelta) {
return Math.ceil(-Math.log(percentDelta) / Math.log(10)) + 3;
}
function toSigFigPrecision(number, sigFig) {
const nonDecimalDigitCount = number < 1 ? 0 : Math.floor(Math.log(number) / Math.log(10)) + 1;
return number.toPrecision(Math.max(nonDecimalDigitCount, Math.min(6, sigFig)));
}
const values = measuredValuesList.map(valueForUnit);
const sum = values.reduce((a, b) => {
return a + b;
}, 0);
const arithmeticMean = sum / values.length;
let meanSigFig = 4;
let formattedDelta;
let formattedPercentDelta;
const delta = Statistics.confidenceIntervalDelta(0.95, values.length, sum, Statistics.squareSum(values));
if (!isNaN(delta)) {
const percentDelta = (delta * 100) / arithmeticMean;
meanSigFig = sigFigFromPercentDelta(percentDelta);
formattedDelta = toSigFigPrecision(delta, 2);
formattedPercentDelta = `${toSigFigPrecision(percentDelta, 2)}%`;
}
const formattedMean = toSigFigPrecision(arithmeticMean, Math.max(meanSigFig, 3));
return {
formattedValues: values.map((value) => {
return `${toSigFigPrecision(value, 4)} ${displayUnit}`;
}),
mean: arithmeticMean,
formattedMean: formattedMean,
formattedDelta: formattedDelta,
formattedMeanAndDelta: formattedMean + (formattedDelta ? ` \xb1 ${formattedDelta} (${formattedPercentDelta})` : ""),
};
}
_addDetailedResultsRow(table, iterationNumber, value) {
const row = document.createElement("tr");
const th = document.createElement("th");
th.textContent = `Iteration ${iterationNumber + 1}`;
const td = document.createElement("td");
td.textContent = value;
row.appendChild(th);
row.appendChild(td);
table.appendChild(row);
}
_updateGaugeNeedle(score) {
const needleAngle = Math.max(0, Math.min(score, 140)) - 70;
const needleRotationValue = `rotate(${needleAngle}deg)`;
const gaugeNeedleElement = document.querySelector("#summary > .gauge .needle");
gaugeNeedleElement.style.setProperty("-webkit-transform", needleRotationValue);
gaugeNeedleElement.style.setProperty("-moz-transform", needleRotationValue);
gaugeNeedleElement.style.setProperty("-ms-transform", needleRotationValue);
gaugeNeedleElement.style.setProperty("transform", needleRotationValue);
}
_populateDetailedResults(metrics) {
const trackHeight = 24;
document.documentElement.style.setProperty("--metrics-line-height", `${trackHeight}px`);
const plotWidth = (params.viewport.width - 120) / 2;
document.getElementById("geomean-chart").innerHTML = renderMetricView({
metrics: [metrics.Geomean],
width: plotWidth,
trackHeight,
renderChildren: false,
colors: ["white"],
});
const toplevelMetrics = Object.values(metrics).filter((each) => !each.parent && each.children.length > 0);
document.getElementById("tests-chart").innerHTML = renderMetricView({
metrics: toplevelMetrics,
width: plotWidth,
trackHeight,
renderChildren: false,
});
let html = "";
for (const metric of toplevelMetrics) {
html += renderMetricView({
metrics: metric.children,
width: plotWidth,
trackHeight,
title: metric.name,
});
}
document.getElementById("metrics-results").innerHTML = html;
const filePrefix = `speedometer-3-${new Date().toISOString()}`;
let jsonData = this._formattedJSONResult({ modern: false });
let jsonLink = document.getElementById("download-classic-json");
jsonLink.href = URL.createObjectURL(new Blob([jsonData], { type: "application/json" }));
jsonLink.setAttribute("download", `${filePrefix}.json`);
jsonLink = document.getElementById("download-full-json");
jsonData = this._formattedJSONResult({ modern: true });
jsonLink.href = URL.createObjectURL(new Blob([jsonData], { type: "application/json" }));
jsonLink.setAttribute("download", `${filePrefix}.json`);
const csvData = this._formattedCSVResult();
const csvLink = document.getElementById("download-csv");
csvLink.href = URL.createObjectURL(new Blob([csvData], { type: "text/csv" }));
csvLink.setAttribute("download", `${filePrefix}.csv`);
}
prepareUI() {
window.addEventListener("hashchange", this._hashChangeHandler.bind(this));
window.addEventListener("resize", this._resizeScreeHandler.bind(this));
this._resizeScreeHandler();
document.querySelectorAll("logo").forEach((button) => {
button.onclick = this._logoClickHandler.bind(this);
});
document.getElementById("copy-full-json").onclick = this.copyJsonResults.bind(this);
document.getElementById("copy-csv").onclick = this.copyCSVResults.bind(this);
document.querySelectorAll(".start-tests-button").forEach((button) => {
button.onclick = this._startBenchmarkHandler.bind(this);
});
if (params.suites.length > 0 || params.tags.length > 0)
Suites.enable(params.suites, params.tags);
if (params.developerMode) {
this._developerModeContainer = createDeveloperModeContainer(Suites);
document.body.append(this._developerModeContainer);
}
if (params.startAutomatically)
this.start();
}
_hashChangeHandler() {
this._showSection(window.location.hash);
}
_resizeScreeHandler() {
// FIXME: Detect when the window size changes during the test.
const mainSize = document.querySelector("main").getBoundingClientRect();
const screenIsTooSmall = window.innerWidth < mainSize.width || window.innerHeight < mainSize.height;
document.getElementById("min-screen-width").textContent = `${params.viewport.width + 50}px`;
document.getElementById("min-screen-height").textContent = `${params.viewport.height + 50}px`;
document.getElementById("screen-size").textContent = `${window.innerWidth}px by ${window.innerHeight}px`;
document.getElementById("screen-size-warning").style.display = screenIsTooSmall ? null : "none";
}
_startBenchmarkHandler() {
this.start();
}
_logoClickHandler(event) {
// Prevent any accidental UI changes during benchmark runs.
if (!this._isRunning)
this._showSection("#home");
event.preventDefault();
return false;
}
showResultsSummary() {
this._showSection("#summary");
}
showResultsDetails() {
this._showSection("#details");
}
_formattedJSONResult({ modern = false }) {
const indent = " ";
if (modern)
return JSON.stringify(this._metrics, undefined, indent);
return JSON.stringify(this._measuredValuesList, undefined, indent);
}
_formattedCSVResult() {
// The CSV format is similar to the details view table. Each measurement is a row with
// the name and N columns with the measurement for each iteration:
// ```
// Measurement,#1,...,#N
// TodoMVC-JavaScript-ES5/Total,num,...,num
// TodoMVC-JavaScript-ES5/Adding100Items,num,...,num
// ...
const labels = ["Name"];
for (let i = 0; i < params.iterationCount; i++)
labels.push(`#${i + 1}`);
labels.push("Mean");
const metrics = Array.from(Object.values(this._metrics)).filter((metric) => !metric.name.startsWith("Iteration-"));
const metricsValues = metrics.map((metric) => [metric.name, ...metric.values, metric.mean].join(","));
const csv = [labels.join(","), ...metricsValues];
return csv.join("\n");
}
copyJsonResults() {
navigator.clipboard.writeText(this._formattedJSONResult({ modern: true }));
}
copyCSVResults() {
navigator.clipboard.writeText(this._formattedCSVResult());
}
_showSection(hash) {
if (this._isRunning) {
this._setLocationHash("#running");
return;
} else if (this._hasResults) {
if (hash !== "#summary" && hash !== "#details") {
this._setLocationHash("#summary");
return;
}
} else if (hash !== "#home" && hash !== "") {
// Redirect invalid views to #home directly.
this._setLocationHash("#home");
return;
}
this._setLocationHash(hash);
}
_setLocationHash(hash) {
if (hash === "#home" || hash === "") {
if (window.location.hash !== hash)
window.location.hash = "#home";
hash = "#home";
this._removeLocationHash();
} else {
window.location.hash = hash;
}
this._updateVisibleSectionAttribute(hash);
this._updateDocumentTitle(hash);
}
_updateVisibleSectionAttribute(hash) {
const sectionId = hash.substring(1);
document.documentElement.setAttribute("data-visible-section", sectionId);
}
_updateDocumentTitle(hash) {
const maybeSection = document.querySelector(hash);
const sectionTitle = maybeSection?.getAttribute("data-title") ?? "";
document.title = `Speedometer 3 ${sectionTitle}`.trimEnd();
}
_removeLocationHash() {
const location = window.location;
window.history.pushState("", document.title, location.pathname + location.search);
}
}
const rootStyle = document.documentElement.style;
rootStyle.setProperty("--viewport-width", `${params.viewport.width}px`);
rootStyle.setProperty("--viewport-height", `${params.viewport.height}px`);
globalThis.benchmarkClient = new MainBenchmarkClient();