From 071f79d459deeccc07bcc9a7ab58c4c356fe2825 Mon Sep 17 00:00:00 2001 From: RafaelGSS Date: Thu, 28 Nov 2024 00:57:35 -0300 Subject: [PATCH] feat: add html reporter This commit introduces a new `reporter` to bench-node htmlReporter, which will create an HTML file with benchmark results animated by a circle visualizer. --- examples/html/node.js | 21 +++++++ examples/html/result.html | 115 +++++++++++++++++++++++++++++++++++++ lib/index.js | 3 +- lib/report.js | 2 + lib/reporter/html.js | 72 +++++++++++++++++++++++ lib/reporter/template.html | 85 +++++++++++++++++++++++++++ test/reporter.js | 66 ++++++++++++++++++++- 7 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 examples/html/node.js create mode 100644 examples/html/result.html create mode 100644 lib/reporter/html.js create mode 100644 lib/reporter/template.html diff --git a/examples/html/node.js b/examples/html/node.js new file mode 100644 index 0000000..07c8044 --- /dev/null +++ b/examples/html/node.js @@ -0,0 +1,21 @@ +const { Suite, htmlReport } = require('../../lib'); +const assert = require('node:assert'); + +const suite = new Suite({ + reporter: htmlReport, +}); + +suite + .add('single with matcher', function () { + const pattern = /[123]/g + const replacements = { 1: 'a', 2: 'b', 3: 'c' } + const subject = '123123123123123123123123123123123123123123123123' + const r = subject.replace(pattern, m => replacements[m]) + assert.ok(r); + }) + .add('multiple replaces', function () { + const subject = '123123123123123123123123123123123123123123123123' + const r = subject.replace(/1/g, 'a').replace(/2/g, 'b').replace(/3/g, 'c') + assert.ok(r); + }) + .run(); diff --git a/examples/html/result.html b/examples/html/result.html new file mode 100644 index 0000000..fd7e9b0 --- /dev/null +++ b/examples/html/result.html @@ -0,0 +1,115 @@ + + + + + + Benchmark Visualizer + + + +
+ +
+ +
+ + + +
single-with-matcher + + (504,104.31 ops/sec)
+ +
multiple-replaces + + (358,487.94 ops/sec)
+ +
+ + + + diff --git a/lib/index.js b/lib/index.js index e4c3c99..80701c2 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,4 +1,4 @@ -const { textReport, chartReport } = require('./report'); +const { textReport, chartReport, htmlReport } = require('./report'); const { getInitialIterations, runBenchmark, runWarmup } = require('./lifecycle'); const { debugBench, timer } = require('./clock'); const { @@ -131,4 +131,5 @@ module.exports = { V8OptimizeOnNextCallPlugin, chartReport, textReport, + htmlReport, }; diff --git a/lib/report.js b/lib/report.js index f142250..5b6d526 100644 --- a/lib/report.js +++ b/lib/report.js @@ -1,7 +1,9 @@ const { textReport } = require('./reporter/text'); const { chartReport } = require('./reporter/chart'); +const { htmlReport } = require('./reporter/html'); module.exports = { chartReport, textReport, + htmlReport, }; diff --git a/lib/reporter/html.js b/lib/reporter/html.js new file mode 100644 index 0000000..9310599 --- /dev/null +++ b/lib/reporter/html.js @@ -0,0 +1,72 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const formatter = Intl.NumberFormat(undefined, { + notation: 'standard', + maximumFractionDigits: 2, +}); + +const opsToDuration = (maxOps, ops, scalingFactor = 10) => { + const baseSpeed = (maxOps / ops) * scalingFactor; + return Math.max(baseSpeed, 2); // Normalize speed with a minimum of 2 seconds +}; + +const generateHTML = (template, durations) => { + let css = '' + let circleDiv = '' + let labelDiv = '' + let position = 20; + const colors = ['red', 'blue', 'green', 'pink', 'grey', 'purple'] + for (const d of durations) { + css += ` + #label-${d.name} { + top: ${position}px; + } + + #circle-${d.name} { + background-color: ${colors.shift()}; + top: ${position}px; + } + ` + circleDiv += ` +
${d.name} + + (${d.opsSecFormatted} ops/sec)
+ ` + labelDiv += ` +
+ ` + + position += 80; + } + + return template + .replaceAll('{{DURATIONS}}', JSON.stringify(durations)) + .replaceAll('{{CSS}}', css) + .replaceAll('{{LABEL_DIV}}', labelDiv) + .replaceAll('{{CIRCLE_DIV}}', circleDiv) + .replaceAll('{{CONTAINER_HEIGHT}}', `${durations.length * 100}px;`); +}; + +const templatePath = path.join(__dirname, 'template.html'); +const template = fs.readFileSync(templatePath, 'utf8'); + +function htmlReport(results) { + const maxOpsSec = Math.max(...results.map(b => b.opsSec)); + + const durations = results.map((r) => ({ + name: r.name.replaceAll(' ', '-'), + duration: opsToDuration(maxOpsSec, r.opsSec), + opsSecFormatted: formatter.format(r.opsSec) + })); + + const htmlContent = generateHTML(template, durations); + fs.writeFileSync('result.html', htmlContent, 'utf8'); + process.stdout.write('HTML file has been generated: result.html'); +} + + +module.exports = { + htmlReport, +} + diff --git a/lib/reporter/template.html b/lib/reporter/template.html new file mode 100644 index 0000000..f66080b --- /dev/null +++ b/lib/reporter/template.html @@ -0,0 +1,85 @@ + + + + + + Benchmark Visualizer + + + +
+ {{LABEL_DIV}} + + {{CIRCLE_DIV}} +
+ + + + diff --git a/test/reporter.js b/test/reporter.js index ead331d..d8465a4 100644 --- a/test/reporter.js +++ b/test/reporter.js @@ -1,11 +1,14 @@ const { describe, it, before } = require('node:test'); const assert = require('node:assert'); -const { Suite, chartReport } = require('../lib'); +const fs = require('node:fs'); + +const { Suite, chartReport, htmlReport } = require('../lib'); describe('chartReport outputs benchmark results as a bar chart', async (t) => { let output = ''; before(async () => { + const originalStdoutWrite = process.stdout.write; process.stdout.write = function (data) { output += data; }; @@ -28,6 +31,8 @@ describe('chartReport outputs benchmark results as a bar chart', async (t) => { assert.ok(r); }) await suite.run(); + + process.stdout.write = originalStdoutWrite; }); it('should include bar chart chars', () => { @@ -38,3 +43,62 @@ describe('chartReport outputs benchmark results as a bar chart', async (t) => { assert.ok(output.includes('ops/sec')); }) }); + +describe('htmlReport should create a file', async (t) => { + let output = ''; + let htmlName = ''; + let htmlContent = ''; + + before(async () => { + const originalStdoutWrite = process.stdout.write; + const originalWriteFileSync = fs.writeFileSync; + + fs.writeFileSync = function (name, content) { + htmlName = name; + htmlContent = content; + }; + + process.stdout.write = function (data) { + output += data; + }; + + const suite = new Suite({ + reporter: htmlReport, + }); + + suite + .add('single with matcher', function () { + const pattern = /[123]/g + const replacements = { 1: 'a', 2: 'b', 3: 'c' } + const subject = '123123123123123123123123123123123123123123123123' + const r = subject.replace(pattern, m => replacements[m]) + assert.ok(r); + }) + .add('multiple replaces', function () { + const subject = '123123123123123123123123123123123123123123123123' + const r = subject.replace(/1/g, 'a').replace(/2/g, 'b').replace(/3/g, 'c') + assert.ok(r); + }) + await suite.run(); + + fs.writeFileSync = originalWriteFileSync; + process.stdout.write = originalStdoutWrite; + }); + + it('should print that a HTML file has been generated', () => { + assert.ok(output.includes('HTML file has been generated')); + }); + + it('htmlName should be result.html', () => { + assert.strictEqual(htmlName, 'result.html'); + }); + + it('htmlContent should not be empty', () => { + assert.ok(htmlContent.length > 100); + }); + + it('htmlContent should not contain replace tags {{}}', () => { + assert.ok(htmlContent.includes('{{') === false); + assert.ok(htmlContent.includes('}}') === false); + }); +});