import { AssertionError } from 'assert';
import { promises as fs } from 'fs';
import { platform, EOL } from 'os';
import { join } from 'path';
import { Readable } from 'stream';
import { describe, it } from 'mocha';
import { Stream } from 'stream';
// @ts-expect-error moduleResolution:nodenext issue 54523
import { ChartConfiguration } from 'chart.js/auto';
import resemble /*, { ResembleSingleCallbackComparisonOptions, ResembleSingleCallbackComparisonResult }*/ from 'resemblejs';
import { ChartJSNodeCanvas, ChartCallback } from './';
describe(ChartJSNodeCanvas.name, () => {
const chartColors = {
red: 'rgb(255, 99, 132)',
orange: 'rgb(255, 159, 64)',
yellow: 'rgb(255, 205, 86)',
green: 'rgb(75, 192, 192)',
blue: 'rgb(54, 162, 235)',
purple: 'rgb(153, 102, 255)',
grey: 'rgb(201, 203, 207)'
};
const width = 400;
const height = 400;
const configuration: ChartConfiguration = {
type: 'bar',
data: {
labels: ['Red', 'Blue', 'Yellow', 'Green', 'Purple', 'Orange'],
datasets: [{
label: '# of Votes',
data: [12, 19, 3, 5, 2, 3],
backgroundColor: [
'rgba(255, 99, 132, 0.2)',
'rgba(54, 162, 235, 0.2)',
'rgba(255, 206, 86, 0.2)',
'rgba(75, 192, 192, 0.2)',
'rgba(153, 102, 255, 0.2)',
'rgba(255, 159, 64, 0.2)'
],
borderColor: [
'rgba(255,99,132,1)',
'rgba(54, 162, 235, 1)',
'rgba(255, 206, 86, 1)',
'rgba(75, 192, 192, 1)',
'rgba(153, 102, 255, 1)',
'rgba(255, 159, 64, 1)'
],
borderWidth: 1
}]
},
options: {
scales: {
y: {
beginAtZero: true,
ticks: {
callback: (tickValue, _index, _ticks) => '$' + tickValue
}
}
},
plugins: {
annotation: {
}
} as any
}
};
const chartCallback: ChartCallback = (ChartJS) => {
ChartJS.defaults.responsive = true;
ChartJS.defaults.maintainAspectRatio = false;
};
it('works with render to buffer', async () => {
const chartJSNodeCanvas = new ChartJSNodeCanvas({ width, height, chartCallback, backgroundColour: 'white' });
const actual = await chartJSNodeCanvas.renderToBuffer(configuration);
await assertImage(actual, 'render-to-buffer');
});
it('works with render to data url', async () => {
const chartJSNodeCanvas = new ChartJSNodeCanvas({ width, height, chartCallback, backgroundColour: 'white' });
const actual = await chartJSNodeCanvas.renderToDataURL(configuration);
const extension = '.txt';
const fileName = 'render-to-data-URL';
const fileNameWithExtension = fileName + extension;
const expectedDataPath = join(process.cwd(), 'testData', platform(), fileName + extension);
const expected = await fs.readFile(expectedDataPath, 'utf8');
// const result = actual === expected;
const compareData = await compareImages(actual, expected, { output: { useCrossOrigin: false } });
const misMatchPercentage = Number(compareData.misMatchPercentage);
const result = misMatchPercentage > 0;
if (result) {
const actualDataPath = expectedDataPath.replace(fileNameWithExtension, fileName + '-actual' + extension);
await fs.writeFile(actualDataPath, actual);
const compare = `
Actual:
${EOL}
${EOL}Expected:
`;
const compareDataPath = expectedDataPath.replace(fileNameWithExtension, fileName + '-compare.html');
await fs.writeFile(compareDataPath, compare);
const diffPng = compareData.getBuffer();
await writeDiff(expectedDataPath.replace(fileNameWithExtension, fileName + '-diff' + extension), diffPng);
throw new AssertionError({
message: `Expected data urls to match, mismatch was ${misMatchPercentage}%${EOL}See '${compareDataPath}'`,
actual: actualDataPath,
expected: expectedDataPath,
});
}
});
it('works with render to stream', async () => {
const chartJSNodeCanvas = new ChartJSNodeCanvas({ width, height, chartCallback, backgroundColour: 'white' });
const stream = chartJSNodeCanvas.renderToStream(configuration);
const actual = await streamToBuffer(stream);
await assertImage(actual, 'render-to-stream');
});
it('works with registering plugin', async () => {
const chartJSNodeCanvas = new ChartJSNodeCanvas({
width, height, backgroundColour: 'white', plugins: {
modern: ['chartjs-plugin-annotation']
}
});
const actual = await chartJSNodeCanvas.renderToBuffer({
type: 'bar',
data: {
labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'],
datasets: [
{
type: 'line',
label: 'Dataset 1',
borderColor: chartColors.blue,
borderWidth: 2,
fill: false,
data: [-39, 44, -22, -45, -27, 12, 18]
},
{
type: 'bar',
label: 'Dataset 2',
backgroundColor: chartColors.red,
data: [-18, -43, 36, -37, 1, -1, 26],
borderColor: 'white',
borderWidth: 2
},
{
type: 'bar',
label: 'Dataset 3',
backgroundColor: chartColors.green,
data: [-7, 21, 1, 7, 34, -29, -36]
}
]
},
options: {
responsive: true,
plugins: {
annotation: {
annotations: {
label1: {
type: 'label',
xValue: 3,
yValue: 20,
backgroundColor: 'rgba(245,245,245)',
content: ['This is my text', 'This is my text, second line'],
font: {
size: 18
}
}
}
}
} as any
}
});
await assertImage(actual, 'chartjs-plugin-annotation');
});
it('works with self registering plugin', async () => {
const chartJSNodeCanvas = new ChartJSNodeCanvas({
width, height, backgroundColour: 'white', plugins: {
requireLegacy: [
'chartjs-plugin-datalabels'
]
}
});
const actual = await chartJSNodeCanvas.renderToBuffer({
type: 'bar',
data: {
labels: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as any,
datasets: [{
label: 'Red',
backgroundColor: chartColors.red,
data: [12, 19, 3, 5, 2, 3],
datalabels: {
align: 'end',
anchor: 'start'
}
}, {
label: 'Blue',
backgroundColor: chartColors.blue,
data: [3, 5, 2, 3, 30, 15, 19, 2],
datalabels: {
align: 'center',
anchor: 'center'
}
}, {
label: 'Green',
backgroundColor: chartColors.green,
data: [12, 19, 3, 5, 2, 3],
datalabels: {
anchor: 'end',
align: 'start',
}
}] as any
},
options: {
plugins: {
datalabels: {
color: 'white',
display(context: any): boolean {
return context.dataset.data[context.dataIndex] > 15;
},
font: {
weight: 'bold'
},
formatter: Math.round
}
} as any, // TODO: resolve type
scales: {
x: {
stacked: true
},
y: {
stacked: true
}
}
}
});
await assertImage(actual, 'chartjs-plugin-datalabels');
});
// it.skip('works with global variable plugin', async () => {
// const chartJSNodeCanvas = new ChartJSNodeCanvas({
// width, height, backgroundColour: 'white', plugins: {
// globalVariableLegacy: [
// 'chartjs-plugin-crosshair'
// ]
// }
// });
// const actual = await chartJSNodeCanvas.renderToBuffer({
// });
// await assertImage(actual, 'chartjs-plugin-funnel');
// });
it('works with custom font', async () => {
const chartJSNodeCanvas = new ChartJSNodeCanvas({
width, height, backgroundColour: 'white', chartCallback: (ChartJS) => {
ChartJS.defaults.font.family = 'Anthrope';
}
});
chartJSNodeCanvas.registerFont('./testData/Anthrope.ttf', { family: 'Anthrope' });
const actual = await chartJSNodeCanvas.renderToBuffer({
type: 'bar',
data: {
labels: ['Red', 'Blue', 'Yellow', 'Green', 'Purple', 'Orange'],
datasets: [{
label: '# of Votes',
data: [12, 19, 3, 5, 2, 3],
backgroundColor: [
'rgba(255, 99, 132, 0.2)',
'rgba(54, 162, 235, 0.2)',
'rgba(255, 206, 86, 0.2)',
'rgba(75, 192, 192, 0.2)',
'rgba(153, 102, 255, 0.2)',
'rgba(255, 159, 64, 0.2)'
],
borderColor: [
'rgba(255,99,132,1)',
'rgba(54, 162, 235, 1)',
'rgba(255, 206, 86, 1)',
'rgba(75, 192, 192, 1)',
'rgba(153, 102, 255, 1)',
'rgba(255, 159, 64, 1)'
],
borderWidth: 1,
}]
},
options: {
scales: {
y: {
ticks: {
beginAtZero: true,
callback: (value: number) => '$' + value
} as any
}
}
},
plugins: {
annotation: {
}
} as any
});
await assertImage(actual, 'font');
});
it('works without background color', async () => {
const chartJSNodeCanvas = new ChartJSNodeCanvas({ width, height, chartCallback });
const actual = await chartJSNodeCanvas.renderToBuffer(configuration);
await assertImage(actual, 'no-background-color');
});
/*
function hashCode(string: string): number {
let hash = 0;
if (string.length === 0) {
return hash;
}
for (let i = 0; i < string.length; i++) {
const chr = string.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0; // Convert to 32bit integer
}
return hash;
}
*/
async function assertImage(actual: Buffer, fileName: string): Promise {
const extension = '.png';
const fileNameWithExtension = fileName + extension;
const testDataPath = join(process.cwd(), 'testData', platform(), fileNameWithExtension);
const exists = await pathExists(testDataPath);
if (!exists) {
console.error(`Warning: expected image path does not exist!, creating '${testDataPath}'`);
await fs.writeFile(testDataPath, actual, 'base64');
return;
}
const expected = await fs.readFile(testDataPath);
const compareData = await compareImages(actual, expected);
// const compareData = await new Promise((resolve) => {
// const diff = resemble(actual)
// .compareTo(expected)
// .onComplete((data) => {
// resolve(data);
// });
// });
const misMatchPercentage = Number(compareData.misMatchPercentage);
// const result = actual.equals(expected);
const result = misMatchPercentage > 0;
// const actual = hashCode(image.toString('base64'));
// const expected = -1377895140;
// assert.equal(actual, expected);
if (result) {
await fs.writeFile(testDataPath.replace(fileNameWithExtension, fileName + '-actual' + extension), actual);
const diffPng = compareData.getBuffer();
await writeDiff(testDataPath.replace(fileNameWithExtension, fileName + '-diff' + extension), diffPng);
throw new AssertionError({
message: `Expected image to match '${testDataPath}', mismatch was ${misMatchPercentage}%'`,
// actual: JSON.stringify(actual),
// expected: JSON.stringify(expected),
operator: 'to equal',
stackStartFn: assertImage,
});
}
}
// resemblejs/compareImages
//function compareImages(image1: string | Buffer, image2: string | Buffer, options?: ResembleSingleCallbackComparisonOptions): Promise {
function compareImages(image1: string | Buffer, image2: string | Buffer, options?: any): Promise {
return new Promise((resolve, reject) => {
//resemble.compare(image1, image2, options || {}, (err, data) => {
resemble.compare(image1, image2, options || {}, (err: any, data: any) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
}
function streamToBuffer(stream: Readable): Promise {
const data: Array = [];
return new Promise((resolve, reject) => {
stream.on('data', (chunk: Buffer) => {
data.push(chunk);
});
stream.on('end', () => {
const buffer = Buffer.concat(data);
resolve(buffer);
});
stream.on('error', (error) => {
reject(error);
});
});
}
function writeDiff(filepath: string, png: Stream | Buffer): Promise {
return new Promise((resolve, reject) => {
if (Buffer.isBuffer(png)) {
fs.writeFile(filepath, png.toString('base64'), 'base64')
.then(() => resolve())
.catch(reject);
return;
}
const chunks: Array = [];
png.on('data', (chunk: Uint8Array) => {
chunks.push(chunk);
});
png.on('end', () => {
const buffer = Buffer.concat(chunks);
fs.writeFile(filepath, buffer.toString('base64'), 'base64')
.then(() => resolve())
.catch(reject);
});
png.on('error', (err) => reject(err));
});
}
function pathExists(path: string): Promise {
return fs.access(path).then(() => true).catch(() => false);
}
describe('Memory tests', () => {
const count = 20;
// TODO: Replace node-memwatch with a new lib!
it('does not leak with new instance parallel', async () => {
const memoryUsages = new Array(count + 2);
memoryUsages[0] = process.memoryUsage();
const diffs = await Promise.all([...Array(count).keys()].map((iteration) => {
console.log('generated heap for iteration ' + (iteration + 1));
const chartJSNodeCanvas = new ChartJSNodeCanvas({ width, height, chartCallback });
return chartJSNodeCanvas.renderToBuffer(configuration, 'image/png')
.then(() => {
memoryUsages[iteration + 1] = process.memoryUsage();
return 1;
});
}));
memoryUsages[count + 1] = process.memoryUsage();
const config = generateMemoryUsageChartConfig(memoryUsages, 'New Instance Test');
const buffer = await new ChartJSNodeCanvas({ width: 800, height: 600 }).renderToBuffer(config);
await fs.writeFile('./resources/memory-usage-new-instance-parallel.png', buffer);
});
it('does not leak with new instance sequential', async () => {
const memoryUsages = new Array(count + 2);
memoryUsages[0] = process.memoryUsage();
for (let index = 0; index < count; index++) {
const chartJSNodeCanvas = new ChartJSNodeCanvas({ width, height, chartCallback });
await chartJSNodeCanvas.renderToBuffer(configuration, 'image/png');
memoryUsages[index + 1] = process.memoryUsage();
}
memoryUsages[count + 1] = process.memoryUsage();
const config = generateMemoryUsageChartConfig(memoryUsages, 'New Instance Test');
const buffer = await new ChartJSNodeCanvas({ width: 800, height: 600 }).renderToBuffer(config);
await fs.writeFile('./resources/memory-usage-new-instance-sequential.png', buffer);
});
it('does not leak with same instance', async () => {
const memoryUsages = new Array(count + 2);
memoryUsages[0] = process.memoryUsage();
const chartJSNodeCanvas = new ChartJSNodeCanvas({ width, height, chartCallback });
const diffs = await Promise.all([...Array(count).keys()].map((iteration) => {
return chartJSNodeCanvas.renderToBuffer(configuration, 'image/png')
.then(() => {
memoryUsages[iteration + 1] = process.memoryUsage();
return 1;
});
}));
memoryUsages[count + 1] = process.memoryUsage();
const config = generateMemoryUsageChartConfig(memoryUsages, 'Same Instance Test');
const buffer = await new ChartJSNodeCanvas({ width: 800, height: 600 }).renderToBuffer(config);
await fs.writeFile('./resources/memory-usage-same-instance.png', buffer);
});
function generateMemoryUsageChartConfig(memoryUsages: NodeJS.MemoryUsage[], _testName: string): ChartConfiguration {
const labels = memoryUsages.map((_, index) => `Iteration ${index}`);
const data = {
labels,
datasets: [
{
label: 'RSS',
data: memoryUsages.map(usage => usage.rss / 1000000), // Convert to MB
backgroundColor: 'rgba(255, 99, 132, 0.2)',
borderColor: 'rgba(255, 99, 132, 1)',
borderWidth: 1
},
{
label: 'Heap Total',
data: memoryUsages.map(usage => usage.heapTotal / 1000000), // Convert to MB
backgroundColor: 'rgba(54, 162, 235, 0.2)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1
},
{
label: 'Heap Used',
data: memoryUsages.map(usage => usage.heapUsed / 1000000), // Convert to MB
backgroundColor: 'rgba(75, 192, 192, 0.2)',
borderColor: 'rgba(75, 192, 192, 1)',
borderWidth: 1
},
{
label: 'External',
data: memoryUsages.map(usage => usage.external / 1000000), // Convert to MB
backgroundColor: 'rgba(153, 102, 255, 0.2)',
borderColor: 'rgba(153, 102, 255, 1)',
borderWidth: 1
}
]
};
return {
type: 'line',
data,
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
callback: (value: number) => `${value} MB`
} as any
}
}
}
};
}
});
});