@armandabric/pdf-snapshot-matcher
Version:
PDF snapshot matcher
237 lines (200 loc) • 5.67 kB
text/typescript
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as process from 'node:process';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import _ from 'lodash';
import dedent from 'dedent';
import type { MatcherContext, MatcherFunctionWithContext } from 'expect';
import type { AppRouter } from '@armandabric/pdf-snapshot-service';
export type Options = {
scale?: number;
failureThreshold?: number;
};
export type Result = {
pass: boolean;
message: () => string;
};
/**
* Expect matcher for visual regression testing of PDF document.
* Behaves just like tradition Jest snapshots.
*
* @param pdf PDF content as Buffer.
* @param options Comparison options.
*/
const toMatchPdfSnapshot: MatcherFunctionWithContext<
MatcherContext & { snapshotState?: any },
[Options | undefined]
> = async function (
pdf?: Buffer | unknown,
options?: Options,
): Promise<Result> {
const { testPath, currentTestName, isNot, snapshotState } = this;
if (isNot) {
throw new Error(
'Expect: `.not` cannot be used with `.toMatchPdfSnapshot()`.',
);
}
if (!Buffer.isBuffer(pdf)) {
throw new Error(
'`.toMatchPdfSnapshot()` expect a PDF buffer as first arguments',
);
}
const snapshotsDirectory = path.join(
path.dirname(testPath ?? ''),
'__pdf_snapshots__',
);
await fs.promises.mkdir(snapshotsDirectory, {
recursive: true,
});
// remove old diff files
const diffFilesToRemove = (
await fs.promises.readdir(snapshotsDirectory)
).filter((file) => file.endsWith('-diff.png'));
await Promise.all(
diffFilesToRemove.map((file) =>
fs.promises.rm(path.join(snapshotsDirectory, file)),
),
);
/**
* Create snapshot file identifier.
*
* @param pageNumber The page of the pdf file.
*/
function snapshotIdentifier(pageNumber: number): string {
return `${pageNumber.toString().padStart(2, '0')}_${_.kebabCase(
currentTestName,
)}-snap`;
}
/**
* Write file from base64 encoded content.
*
* @param data Base64 encoded data.
* @param filePath File path.
*/
async function writeFile(data: string, filePath: string): Promise<void> {
const decodedData = Buffer.from(data, 'base64');
await fs.promises.writeFile(filePath, decodedData);
}
/**
* Get the list of existing snapshots.
*/
async function getSnapshots(): Promise<Buffer[]> {
const result: Buffer[] = [];
let i = 1;
do {
const snapshotPath = path.join(
snapshotsDirectory,
`${snapshotIdentifier(i)}.png`,
);
try {
result.push(await fs.promises.readFile(snapshotPath));
} catch (e) {
break;
}
i++;
} while (true);
return result;
}
const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: process.env.PDF_SNAPSHOT_SERVER_URL ?? 'http://localhost:3000',
}),
],
});
const snapshots = (await getSnapshots()).map((snapshot) =>
snapshot.toString('base64'),
);
const { results } = await trpc.matchPdfSnapshot.mutate({
pdf: pdf.toString('base64'),
snapshots,
options: {
scale: options?.scale,
failureThreshold: options?.failureThreshold,
},
});
if (_.every(results, 'pass')) {
snapshotState.matched++;
return {
pass: true,
message: () => '',
};
}
if (_.every(results, 'added')) {
snapshotState.added++;
await Promise.all(
results.map(async (result, index) => {
if ('newPage' in result) {
const snapshotPath = path.join(
snapshotsDirectory,
`${snapshotIdentifier(index + 1)}.png`,
);
await writeFile(result.newPage, snapshotPath);
}
}),
);
return {
pass: true,
message: () => '',
};
}
if (snapshotState._updateSnapshot === 'all') {
snapshotState.updated++;
await Promise.all(
results.map(async (result, index) => {
if ('pass' in result && result.pass) {
return;
}
const pageNumber = index + 1;
const snapshotPath = path.join(
snapshotsDirectory,
`${snapshotIdentifier(pageNumber)}.png`,
);
if ('deleted' in result && result.deleted) {
await fs.promises.rm(snapshotPath);
return;
}
if ('newPage' in result) {
await writeFile(result.newPage, snapshotPath);
return;
}
}),
);
return {
pass: true,
message: () => '',
};
}
snapshotState.unmatched++;
const resultDetails = await Promise.all(
results.map(async (result, index) => {
const pageNumber = index + 1;
const diffOutputPath = path.join(
snapshotsDirectory,
`${snapshotIdentifier(pageNumber)}-diff.png`,
);
if ('added' in result && result.added) {
return `[Page ${pageNumber}] - Added`;
}
if ('deleted' in result && result.deleted) {
return `[Page ${pageNumber}] - Deleted`;
}
if ('pass' in result && result.pass) {
return `[Page ${pageNumber}] - Pass - diffRatio ${result.diffRatio}`;
}
if ('pass' in result && !result.pass) {
await writeFile(result.diffImage, diffOutputPath);
return `[Page ${pageNumber}] - Does not match snapshot - diffRatio ${result.diffRatio}`;
}
return `[Page ${pageNumber}] - Unhandled result`;
}),
);
return {
pass: false,
message: () => dedent`
Does not match with snapshot.
${resultDetails.join('\n')}
`,
};
};
export { toMatchPdfSnapshot };