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);
+ });
+});