mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 01:15:49 +10:00
feat: add new field overflow methods (#2715)
This commit is contained in:
@@ -31,6 +31,9 @@ const baseUrl = `${WEBAPP_BASE_URL}/api/v2`;
|
||||
|
||||
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
||||
|
||||
/**
|
||||
* DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
|
||||
*/
|
||||
test.skip('seed alignment test document', async ({ page }) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -232,6 +235,47 @@ test('field placement visual regression', async ({ page, request }, testInfo) =>
|
||||
}),
|
||||
);
|
||||
|
||||
// Override email fields with test values after distribution.
|
||||
// Email fields are auto-inserted with the signer's email during distribution,
|
||||
// so we override customText directly to test with specific values.
|
||||
const emailFields = await prisma.field.findMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
type: FieldType.EMAIL,
|
||||
},
|
||||
include: {
|
||||
envelopeItem: {
|
||||
select: {
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
emailFields.map(async (field) => {
|
||||
const testFields =
|
||||
field.envelopeItem.title === 'alignment-pdf'
|
||||
? ALIGNMENT_TEST_FIELDS
|
||||
: FIELD_META_TEST_FIELDS;
|
||||
|
||||
const foundField = testFields.find(
|
||||
(f) =>
|
||||
f.type === FieldType.EMAIL &&
|
||||
field.page === f.page &&
|
||||
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
|
||||
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2),
|
||||
);
|
||||
|
||||
if (foundField) {
|
||||
await prisma.field.update({
|
||||
where: { id: field.id },
|
||||
data: { customText: foundField.customText },
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const recipientToken = envelope.recipients[0].token;
|
||||
const signUrl = `/sign/${recipientToken}`;
|
||||
|
||||
@@ -335,6 +379,45 @@ test.skip('download envelope images', async ({ page, request }) => {
|
||||
|
||||
expect(distributeEnvelopeRequest.ok()).toBeTruthy();
|
||||
|
||||
// Override email fields with test values after distribution.
|
||||
const emailFields = await prisma.field.findMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
type: FieldType.EMAIL,
|
||||
},
|
||||
include: {
|
||||
envelopeItem: {
|
||||
select: {
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
emailFields.map(async (field) => {
|
||||
const testFields =
|
||||
field.envelopeItem.title === 'alignment-pdf'
|
||||
? ALIGNMENT_TEST_FIELDS
|
||||
: FIELD_META_TEST_FIELDS;
|
||||
|
||||
const foundField = testFields.find(
|
||||
(f) =>
|
||||
f.type === FieldType.EMAIL &&
|
||||
field.page === f.page &&
|
||||
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
|
||||
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2),
|
||||
);
|
||||
|
||||
if (foundField) {
|
||||
await prisma.field.update({
|
||||
where: { id: field.id },
|
||||
data: { customText: foundField.customText },
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const token = envelope.recipients[0].token;
|
||||
|
||||
const signUrl = `/sign/${token}`;
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
import { createCanvas } from '@napi-rs/canvas';
|
||||
import type { TestInfo } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, EnvelopeType, FieldType } from '@prisma/client';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
import pixelMatch from 'pixelmatch';
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedOverflowTestDocument } from '@documenso/prisma/seed/initial-seed';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app';
|
||||
import { isBase64Image } from '../../../lib/constants/signatures';
|
||||
import { createApiToken } from '../../../lib/server-only/public-api/create-api-token';
|
||||
import { RecipientRole } from '../../../prisma/generated/types';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '../../../trpc/server/envelope-router/create-envelope.types';
|
||||
import type { TDistributeEnvelopeRequest } from '../../../trpc/server/envelope-router/distribute-envelope.types';
|
||||
import { OVERFLOW_TEST_FIELDS } from '../../constants/field-overflow-pdf';
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2`;
|
||||
|
||||
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
||||
|
||||
/**
|
||||
* DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
|
||||
*/
|
||||
test.skip('seed overflow test document', async ({ page }) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
email: 'example@documenso.com',
|
||||
},
|
||||
include: {
|
||||
ownedOrganisations: {
|
||||
include: {
|
||||
teams: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const userId = user.id;
|
||||
const teamId = user.ownedOrganisations[0].teams[0].id;
|
||||
|
||||
await seedOverflowTestDocument({
|
||||
userId,
|
||||
teamId,
|
||||
recipientName: user.name || '',
|
||||
recipientEmail: user.email,
|
||||
insertFields: false,
|
||||
status: DocumentStatus.DRAFT,
|
||||
});
|
||||
});
|
||||
|
||||
test('overflow visual regression', async ({ page, request }, testInfo) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// Step 1: Create initial envelope with overflow PDF
|
||||
const overflowPdf = fs.readFileSync(
|
||||
path.join(__dirname, '../../../../assets/field-overflow.pdf'),
|
||||
);
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
const overflowFields = OVERFLOW_TEST_FIELDS.map((field) => ({
|
||||
identifier: 'field-overflow',
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
fieldMeta: field.fieldMeta,
|
||||
}));
|
||||
|
||||
const createEnvelopePayload: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Overflow Test',
|
||||
recipients: [
|
||||
{
|
||||
email: user.email,
|
||||
name: user.name || '',
|
||||
role: RecipientRole.SIGNER,
|
||||
fields: overflowFields,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
formData.append('payload', JSON.stringify(createEnvelopePayload));
|
||||
formData.append('files', new File([overflowPdf], 'field-overflow', { type: 'application/pdf' }));
|
||||
|
||||
const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createEnvelopeRequest.ok()).toBeTruthy();
|
||||
expect(createEnvelopeRequest.status()).toBe(200);
|
||||
|
||||
const { id: createdEnvelopeId }: TCreateEnvelopeResponse = await createEnvelopeRequest.json();
|
||||
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: {
|
||||
id: createdEnvelopeId,
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
envelopeItems: true,
|
||||
},
|
||||
});
|
||||
|
||||
const recipientId = envelope.recipients[0].id;
|
||||
const overflowItem = envelope.envelopeItems.find((item: { order: number }) => item.order === 1);
|
||||
|
||||
expect(recipientId).toBeDefined();
|
||||
expect(overflowItem).toBeDefined();
|
||||
|
||||
if (!overflowItem) {
|
||||
throw new Error('Envelope item not found');
|
||||
}
|
||||
|
||||
const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
} satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
expect(distributeEnvelopeRequest.ok()).toBeTruthy();
|
||||
|
||||
const uninsertedFields = await prisma.field.findMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
OR: [
|
||||
{
|
||||
inserted: false,
|
||||
},
|
||||
{
|
||||
// Include email fields because they are automatically inserted during envelope distribution.
|
||||
// We need to extract it to override their values for accurate comparison in tests.
|
||||
type: FieldType.EMAIL,
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
envelopeItem: {
|
||||
select: {
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
uninsertedFields.map(async (field) => {
|
||||
const foundField = OVERFLOW_TEST_FIELDS.find(
|
||||
(f) =>
|
||||
field.page === f.page &&
|
||||
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
|
||||
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2) &&
|
||||
Number(field.width).toFixed(2) === f.width.toFixed(2) &&
|
||||
Number(field.height).toFixed(2) === f.height.toFixed(2),
|
||||
);
|
||||
|
||||
if (!foundField) {
|
||||
throw new Error('Field not found');
|
||||
}
|
||||
|
||||
await prisma.field.update({
|
||||
where: {
|
||||
id: field.id,
|
||||
},
|
||||
data: {
|
||||
inserted: true,
|
||||
customText: foundField.customText,
|
||||
signature: foundField.signature
|
||||
? {
|
||||
create: {
|
||||
recipientId: envelope.recipients[0].id,
|
||||
signatureImageAsBase64: isBase64Image(foundField.signature)
|
||||
? foundField.signature
|
||||
: null,
|
||||
typedSignature: isBase64Image(foundField.signature) ? null : foundField.signature,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Override email fields with test values after distribution.
|
||||
// Email fields are auto-inserted with the signer's email during distribution,
|
||||
// so we override customText directly to test overflow with specific text lengths.
|
||||
const emailFields = await prisma.field.findMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
type: FieldType.EMAIL,
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
emailFields.map(async (field) => {
|
||||
const foundField = OVERFLOW_TEST_FIELDS.find(
|
||||
(f) =>
|
||||
f.type === FieldType.EMAIL &&
|
||||
field.page === f.page &&
|
||||
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
|
||||
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2),
|
||||
);
|
||||
|
||||
if (foundField) {
|
||||
await prisma.field.update({
|
||||
where: { id: field.id },
|
||||
data: { customText: foundField.customText },
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const recipientToken = envelope.recipients[0].token;
|
||||
const signUrl = `/sign/${recipientToken}`;
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: signUrl,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
|
||||
await expect(async () => {
|
||||
const { status } = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toBe(DocumentStatus.COMPLETED);
|
||||
}).toPass({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const completedDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
},
|
||||
include: {
|
||||
envelopeItems: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const storedImages = fs.readdirSync(path.join(__dirname, '../../visual-regression'));
|
||||
|
||||
await Promise.all(
|
||||
completedDocument.envelopeItems.map(async (item) => {
|
||||
const documentUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'download',
|
||||
envelopeItem: item,
|
||||
token: recipientToken,
|
||||
version: 'signed',
|
||||
});
|
||||
|
||||
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||
|
||||
const loadedImages = storedImages
|
||||
.filter((image) => image.startsWith(`field-overflow-`))
|
||||
.sort((leftImage, rightImage) => {
|
||||
return (
|
||||
getVisualRegressionImageIndex(leftImage) - getVisualRegressionImageIndex(rightImage)
|
||||
);
|
||||
})
|
||||
.map((image) => fs.readFileSync(path.join(__dirname, '../../visual-regression', image)));
|
||||
|
||||
await compareSignedPdfWithImages({
|
||||
id: 'field-overflow',
|
||||
pdfData: new Uint8Array(pdfData),
|
||||
images: loadedImages,
|
||||
testInfo,
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Used to download the envelope images when updating the visual regression test.
|
||||
*
|
||||
* DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
|
||||
*/
|
||||
test.skip('download overflow images', async ({ page, request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token: apiToken } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const envelope = await seedOverflowTestDocument({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
recipientName: user.name || '',
|
||||
recipientEmail: user.email,
|
||||
insertFields: true,
|
||||
status: DocumentStatus.DRAFT,
|
||||
});
|
||||
|
||||
const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
} satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
expect(distributeEnvelopeRequest.ok()).toBeTruthy();
|
||||
|
||||
// Override email fields with test values after distribution.
|
||||
const emailFields = await prisma.field.findMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
type: FieldType.EMAIL,
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
emailFields.map(async (field) => {
|
||||
const foundField = OVERFLOW_TEST_FIELDS.find(
|
||||
(f) =>
|
||||
f.type === FieldType.EMAIL &&
|
||||
field.page === f.page &&
|
||||
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
|
||||
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2),
|
||||
);
|
||||
|
||||
if (foundField) {
|
||||
await prisma.field.update({
|
||||
where: { id: field.id },
|
||||
data: { customText: foundField.customText },
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const token = envelope.recipients[0].token;
|
||||
|
||||
const signUrl = `/sign/${token}`;
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: signUrl,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
|
||||
await expect(async () => {
|
||||
const { status } = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toBe(DocumentStatus.COMPLETED);
|
||||
}).toPass({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const completedDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
},
|
||||
include: {
|
||||
envelopeItems: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
completedDocument.envelopeItems.map(async (item) => {
|
||||
const documentUrl = getEnvelopeItemPdfUrl({
|
||||
type: 'download',
|
||||
envelopeItem: item,
|
||||
token,
|
||||
version: 'signed',
|
||||
});
|
||||
|
||||
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||
|
||||
const pdfImages = await renderPdfToImage(new Uint8Array(pdfData));
|
||||
|
||||
for (const [index, { image }] of pdfImages.entries()) {
|
||||
fs.writeFileSync(
|
||||
path.join(__dirname, '../../visual-regression', `field-overflow-${index}.png`),
|
||||
new Uint8Array(image),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Helper functions
|
||||
// ============================================================================
|
||||
|
||||
async function renderPdfToImage(pdfBytes: Uint8Array) {
|
||||
const loadingTask = pdfjsLib.getDocument({ data: pdfBytes });
|
||||
const pdf = await loadingTask.promise;
|
||||
|
||||
// Increase for higher resolution
|
||||
const scale = 4;
|
||||
|
||||
return await Promise.all(
|
||||
Array.from({ length: pdf.numPages }, async (_, index) => {
|
||||
const page = await pdf.getPage(index + 1);
|
||||
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = createCanvas(viewport.width, viewport.height);
|
||||
const canvasContext = canvas.getContext('2d');
|
||||
canvasContext.imageSmoothingEnabled = false;
|
||||
|
||||
await page.render({
|
||||
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
|
||||
canvas,
|
||||
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
|
||||
canvasContext,
|
||||
viewport,
|
||||
}).promise;
|
||||
|
||||
return {
|
||||
image: await canvas.encode('png'),
|
||||
|
||||
// Rounded down because the certificate page somehow gives dimensions with decimals
|
||||
width: Math.floor(viewport.width),
|
||||
height: Math.floor(viewport.height),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
type CompareSignedPdfWithImagesOptions = {
|
||||
id: string;
|
||||
pdfData: Uint8Array;
|
||||
images: Buffer[];
|
||||
testInfo: TestInfo;
|
||||
};
|
||||
|
||||
const compareSignedPdfWithImages = async ({
|
||||
id,
|
||||
pdfData,
|
||||
images,
|
||||
testInfo,
|
||||
}: CompareSignedPdfWithImagesOptions) => {
|
||||
const renderedImages = await renderPdfToImage(pdfData);
|
||||
|
||||
expect(images).toHaveLength(renderedImages.length);
|
||||
|
||||
for (const [index, { image, width, height }] of renderedImages.entries()) {
|
||||
const isCertificate = index === renderedImages.length - 1;
|
||||
|
||||
// Skip certificate page comparison.
|
||||
if (isCertificate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const diff = new PNG({ width, height });
|
||||
|
||||
const storedImage = PNG.sync.read(images[index]).data;
|
||||
|
||||
const newImage = PNG.sync.read(image).data;
|
||||
|
||||
const comparison = pixelMatch(
|
||||
new Uint8Array(storedImage),
|
||||
new Uint8Array(newImage),
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
diff.data as unknown as Uint8Array,
|
||||
width,
|
||||
height,
|
||||
{
|
||||
threshold: 0.25,
|
||||
// includeAA: true, // This allows stricter testing.
|
||||
},
|
||||
);
|
||||
console.log(`${id}-${index}: ${comparison}`);
|
||||
|
||||
const diffFilePath = path.join(testInfo.outputPath(), `${id}-${index}-diff.png`);
|
||||
const oldFilePath = path.join(testInfo.outputPath(), `${id}-${index}-old.png`);
|
||||
const newFilePath = path.join(testInfo.outputPath(), `${id}-${index}-new.png`);
|
||||
|
||||
fs.writeFileSync(diffFilePath, new Uint8Array(PNG.sync.write(diff)));
|
||||
fs.writeFileSync(oldFilePath, new Uint8Array(images[index]));
|
||||
fs.writeFileSync(newFilePath, new Uint8Array(image));
|
||||
|
||||
expect.soft(comparison).toBeLessThan(2);
|
||||
}
|
||||
};
|
||||
|
||||
const getVisualRegressionImageIndex = (image: string) => {
|
||||
const match = image.match(/-(\d+)\.png$/);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`Unexpected visual regression image name: ${image}`);
|
||||
}
|
||||
|
||||
return Number(match[1]);
|
||||
};
|
||||
Reference in New Issue
Block a user