| import { BenchmarkRunner, SubdomainBenchmarkRunner, EmbeddedBenchmarkRunner } 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; |
| _totalSubtestsCount = 0; |
| _progressCompleted = null; |
| _isRunning = false; |
| _hasResults = false; |
| _developerModeContainer = null; |
| _metrics = Object.create(null); |
| _runner = 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); |
| this._totalSubtestsCount = enabledSuites.reduce((testsCount, suite) => { |
| return testsCount + suite.tests.length; |
| }, 0); |
| this.stepCount = params.iterationCount * this._totalSubtestsCount; |
| this._progressCompleted.max = this.stepCount; |
| this.suitesCount = enabledSuites.length; |
| if (params.embedded) |
| this._runner = new EmbeddedBenchmarkRunner(Suites, this); |
| else if (params.domainPerIteration) |
| this._runner = new SubdomainBenchmarkRunner(Suites, this); |
| else |
| this._runner = new BenchmarkRunner(Suites, this); |
| this._runner.runMultipleIterations(params.iterationCount); |
| return true; |
| } |
| |
| get metrics() { |
| return this._metrics; |
| } |
| |
| willAddTestFrame(frame) { |
| } |
| |
| 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 = params.currentIteration * this._totalSubtestsCount; |
| } |
| |
| didFinishLastIteration(metrics) { |
| console.assert(this._isRunning); |
| this._isRunning = false; |
| this._hasResults = true; |
| this._metrics = metrics; |
| // Don't display the results if the runnier is embedded. |
| if (params.embedded) |
| return; |
| |
| const scoreResults = this._computeResults(this._measuredValuesList, "score"); |
| if (scoreResults.isValid) |
| this._populateValidScore(scoreResults); |
| else |
| this._populateInvalidScore(); |
| |
| this._populateDetailedResults(metrics); |
| if (params.developerMode) |
| this.showResultsDetails(); |
| else |
| this.showResultsSummary(); |
| } |
| |
| handleError(error) { |
| console.assert(this._isRunning); |
| this._isRunning = false; |
| this._hasResults = true; |
| this._metrics = Object.create(null); |
| this._populateInvalidScore(); |
| this.showResultsSummary(); |
| } |
| |
| _populateValidScore(scoreResults) { |
| document.getElementById("summary").className = "valid"; |
| |
| this._updateGaugeNeedle(scoreResults.mean); |
| document.getElementById("result-number").textContent = scoreResults.formattedMean; |
| if (scoreResults.formattedDelta) |
| document.getElementById("confidence-number").textContent = `\u00b1 ${scoreResults.formattedDelta}`; |
| } |
| |
| _populateInvalidScore() { |
| document.getElementById("summary").className = "invalid"; |
| document.getElementById("result-number").textContent = "Error"; |
| document.getElementById("confidence-number").textContent = ""; |
| } |
| |
| _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) => 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})` : ""), |
| isValid: values.length > 0 && isFinite(sum) && sum > 0, |
| }; |
| } |
| |
| _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; |
| const singleToplevelMetrics = Object.values(metrics).filter((each) => each && each.unit === "ms" && !each.parent && !each.children.length); |
| document.getElementById("toplevel-chart").innerHTML = renderMetricView({ |
| metrics: singleToplevelMetrics, |
| 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() { |
| if (params.embedded) |
| document.body.className = "embedded"; |
| 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(); |