Skip to content

Commit 0b570ae

Browse files
committed
Added Utility and steps to compare and validate PDF files
1 parent 2e4f581 commit 0b570ae

6 files changed

Lines changed: 233 additions & 45 deletions

File tree

src/advantage/constants/CommonConstants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export default class CommonConstants {
44
static readonly TEN = 10;
55
static readonly TWO = 2;
66
static readonly DOWNLOADS_PATH = "./test-results/downloads/";
7+
static readonly PDF_RESOURCE_PATH = './src/resources/pdf';
78
}

src/advantage/steps/ConfigurationSteps.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,29 @@ export default class ConfigurationSteps {
2222
return fileName;
2323
}
2424

25-
public async verifyPDFFilePageCount(fileName: string, pages: number) {
25+
public async verifyPDFDetails(fileName: string, pages: number, content: string) {
26+
const pdf = await PDFUtil.getPdfTextDetails(CommonConstants.DOWNLOADS_PATH, fileName);
27+
await this.verifyPDFFilePageCount(fileName, pages, pdf.pageCount);
28+
await this.verifyPDFFileText(fileName, content, pdf.content);
29+
}
30+
31+
private async verifyPDFFilePageCount(fileName: string, pages: number , actual: number) {
2632
await test.step(`Verify that ${fileName} file has ${pages} pages`, async () => {
27-
await Assert.assertEquals(await PDFUtil.getNumberOfPages(CommonConstants.DOWNLOADS_PATH + fileName),
28-
pages, fileName);
33+
await Assert.assertEquals(actual, pages, fileName);
2934
});
3035
}
3136

32-
public async verifyPDFFileText(fileName: string, content: string) {
37+
private async verifyPDFFileText(fileName: string, content: string, actual: string) {
3338
await test.step(`Verify that ${fileName} has content ${content}`, async () => {
34-
await Assert.assertContains(await PDFUtil.getText(CommonConstants.DOWNLOADS_PATH + fileName),
35-
content, fileName);
39+
await Assert.assertContains(actual, content, fileName);
40+
});
41+
}
42+
43+
public async verifyPdfByComparing(actualFileName: string, baselineFileName: string, pages: string , maskText: string) {
44+
await test.step(`Verify that ${actualFileName} matches with baseline file ${baselineFileName}`, async () => {
45+
const isMatched = await PDFUtil.maskAndComparePdf(CommonConstants.PDF_RESOURCE_PATH, baselineFileName,
46+
CommonConstants.DOWNLOADS_PATH, actualFileName, pages, maskText);
47+
await Assert.assertTrue(isMatched, `${actualFileName} matches with baseline file ${baselineFileName}`);
3648
});
3749
}
3850
}

src/framework/reporter/Allure.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { allure } from "allure-playwright";
1+
import * as allure from "allure-js-commons";
2+
import { ContentType } from "allure-js-commons";
23
import os from "os";
34

45
export default class Allure {
@@ -9,4 +10,18 @@ export default class Allure {
910
allure.link(`${process.env.LINK}${issue}`, `ISSUE-${issue}`);
1011
}
1112
}
13+
14+
public static async attachPNG(name: string, path: string) {
15+
await allure.attachmentPath(name, path, {
16+
contentType: ContentType.PNG,
17+
fileExtension: "png"
18+
});
19+
}
20+
21+
public static async attachPDF(name: string, path: string) {
22+
await allure.attachmentPath(name, path, {
23+
contentType: 'application/pdf',
24+
fileExtension: "pdf"
25+
});
26+
}
1227
}

src/framework/utils/PDFUtil.ts

Lines changed: 192 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,207 @@
1-
import fs from "fs";
2-
import pdfParse from "pdf-parse";
1+
import fs from 'fs';
2+
import { PDFExtract } from 'pdf.js-extract';
3+
import { PDFDocument, rgb } from 'pdf-lib';
4+
import { ComparePdf } from "compare-pdf-plus";
5+
import Allure from '@allure';
36

47
export default class PDFUtil {
8+
private static readonly MASKED_PDF_DIR_BASELINE = './test-results/pdf/masked/baseline';
9+
private static readonly MASKED_PDF_DIR_ACTUAL = './test-results/pdf/masked/actual';
10+
private static readonly PNG_DIR_BASELINE = './test-results/pdf/png/baseline';
11+
private static readonly PNG_DIR_ACTUAL = './test-results/pdf/png/actual';
12+
private static readonly PNG_DIR_DIFF = './test-results/pdf/png/diff';
513
/**
6-
* Gets the text content of the pdf file
7-
* @param filePath File path
8-
* @returns PDF as text
14+
* Gets the text, number of pages and info from a PDF file
15+
* @param pathToFileDirectory path to file directory
16+
* @param fileName name of the PDF file
17+
* @returns
918
*/
10-
public static async getText(filePath: string): Promise<string> {
11-
const buffer = fs.readFileSync(filePath);
12-
try {
13-
const data = await pdfParse(buffer);
14-
return data.text;
15-
} catch (err) {
16-
throw new Error(err);
19+
public static async getPdfTextDetails(pathToFileDirectory: string, fileName: string) {
20+
const filePath = `${pathToFileDirectory}/${fileName}`;
21+
const pdfExtract = new PDFExtract();
22+
const data = await pdfExtract.extract(filePath, {});
23+
const fullText = data.pages.map(page => page.content.map(item => item.str).join(' ')).join('\n');
24+
return {
25+
pageCount: data.pages.length,
26+
content: fullText,
27+
info: data.meta.info
28+
};
29+
}
30+
/**
31+
* Extracts text positions from a specific page in a PDF file
32+
* @param pdfPath path to the PDF file
33+
* @param page page number (0-indexed)
34+
* @returns
35+
*/
36+
private static async extractTextPositions(pdfPath: string, page: number): Promise<any[]> {
37+
const pdfExtract = new PDFExtract();
38+
const data = await pdfExtract.extract(pdfPath, {});
39+
return data.pages[page].content.map(item => ({ text: item.str, x: item.x, y: item.y, width: item.width, height: item.height }));
40+
}
41+
/**
42+
* Finds text items that match any of the dynamic texts to be masked
43+
* @param textItems
44+
* @param dynamicTexts
45+
* @returns
46+
*/
47+
private static findDynamicMatches(textItems: any[], dynamicTexts: string[]) {
48+
const matches = [];
49+
for (const dynamicText of dynamicTexts) {
50+
for (const item of textItems) {
51+
if (item.text.includes(dynamicText)) {
52+
matches.push(item);
53+
}
54+
}
1755
}
56+
return matches;
1857
}
19-
2058
/**
21-
* Gets number of pages in pdf file
22-
* @param filePath File path
23-
* @returns Number of pages
59+
* Adjusts coordinates based on page rotation
60+
* @param x masked rectangle x coordinate
61+
* @param y masked rectangle y coordinate
62+
* @param w masked rectangle width
63+
* @param h masked rectangle height
64+
* @param pageWidth width of the PDF file
65+
* @param pageHeight height of the PDF file
66+
* @param rotation PDF page rotation
67+
* @returns
2468
*/
25-
public static async getNumberOfPages(filePath: string): Promise<number> {
26-
const buffer = fs.readFileSync(filePath);
27-
try {
28-
const data = await pdfParse(buffer);
29-
return data.numpages;
30-
} catch (err) {
31-
throw new Error(err);
69+
private static transformCoordinates(x: number, y: number, w: number, h: number, pageWidth: number, pageHeight: number, rotation: number) {
70+
switch (rotation) {
71+
case 0:
72+
return { x, y: pageHeight - y, width: w, height: h };
73+
case 90:
74+
return { x: y, y: x - h, width: h, height: w };
75+
case 180:
76+
return { x: pageWidth - x - w, y: y - h, width: w, height: h };
77+
case 270:
78+
return { x: pageHeight - y - h, y: pageWidth - x - w, width: h, height: w };
79+
default:
80+
return { x, y, width: w, height: h };
3281
}
3382
}
34-
3583
/**
36-
* Gets the information about the pdf file
37-
* @param filePath File path
38-
* @returns PDF document info
84+
* Masks the dynamic text in the specified pages of the baseline and actual PDF files, saves the masked files, and compares them
85+
* @param baselineDirPath path to the baseline PDF file directory
86+
* @param baselineFileName baseline PDF file name
87+
* @param actualDirPath path to the actual PDF file directory
88+
* @param actualFileName actual PDF file name
89+
* @param pageNumbers array of pages to be compared (0-indexed)
90+
* @param dynamicTexts array of text from baseline PDF file to be masked
3991
*/
40-
public static async getInfo(filePath: string): Promise<any> {
41-
const buffer = fs.readFileSync(filePath);
42-
try {
43-
const data = await pdfParse(buffer);
44-
return data.info;
45-
} catch (err) {
46-
throw new Error(err);
92+
private static async maskPdf(baselineDirPath: string, baselineFileName: string, actualDirPath: string, actualFileName: string, pageNumbers: number[], dynamicTexts: string[]) {
93+
const baselinePath = `${baselineDirPath}/${baselineFileName}`;
94+
const actualPath = `${actualDirPath}/${actualFileName}`;
95+
const baselinePdfDoc = await PDFDocument.load(fs.readFileSync(baselinePath));
96+
const actualPdfDoc = await PDFDocument.load(fs.readFileSync(actualPath));
97+
for (const pageNum of pageNumbers) {
98+
const textItems = await this.extractTextPositions(baselinePath, pageNum);
99+
const matchedItems = this.findDynamicMatches(textItems, dynamicTexts);
100+
const baselinePage = baselinePdfDoc.getPage(pageNum);
101+
const actualPage = actualPdfDoc.getPage(pageNum);
102+
const { width, height } = baselinePage.getSize();
103+
const rotation = baselinePage.getRotation().angle;
104+
for (const item of matchedItems) {
105+
const rect = this.transformCoordinates(item.x, item.y, item.width, item.height, width, height, rotation);
106+
const pdfRectOptions = {
107+
x: rect.x,
108+
y: rect.y,
109+
width: rect.width,
110+
height: rect.height,
111+
color: rgb(0, 0, 0),
112+
borderColor: rgb(0, 0, 0),
113+
borderWidth: 8,
114+
opacity: 1,
115+
}
116+
baselinePage.drawRectangle(pdfRectOptions);
117+
actualPage.drawRectangle(pdfRectOptions);
118+
}
47119
}
120+
const outputDirPathBaseline = this.MASKED_PDF_DIR_BASELINE;
121+
if (!fs.existsSync(outputDirPathBaseline)) {
122+
fs.mkdirSync(outputDirPathBaseline, { recursive: true });
123+
}
124+
const outputDirPathActual = this.MASKED_PDF_DIR_ACTUAL;
125+
if (!fs.existsSync(outputDirPathActual)) {
126+
fs.mkdirSync(outputDirPathActual, { recursive: true });
127+
}
128+
const timeStamp = new Date().valueOf();
129+
const outputBytesBaseline = await baselinePdfDoc.save();
130+
const maskedBaselineFileName = baselineFileName.replace('.pdf', `_${timeStamp}.pdf`);
131+
const maskedActualFileName = actualFileName.replace('.pdf', `_${timeStamp}.pdf`);
132+
fs.writeFileSync(`${outputDirPathBaseline}/${maskedBaselineFileName}`, outputBytesBaseline);
133+
const outputBytesActual = await actualPdfDoc.save();
134+
fs.writeFileSync(`${outputDirPathActual}/${maskedActualFileName}`, outputBytesActual);
135+
return { maskedBaselineDir: outputDirPathBaseline, maskedBaselineFileName, maskedActualDir: outputDirPathActual, maskedActualFileName };
136+
}
137+
/**
138+
* Compares the specified pages of the actual and baseline PDF files and returns the comparison results
139+
* @param actualPdfDir Path to the actual PDF file directory
140+
* @param actualPdfFileName Actual PDF file name
141+
* @param baselinePdfDir Path to the baseline PDF file directory
142+
* @param baselinePdfFileName Baseline PDF file name
143+
* @param pages Array of pages to be compared (0-indexed)
144+
* @param tolerance Pixel difference tolerance
145+
* @param threshold Similarity threshold (range is 0.00 to 1.00)
146+
* @returns
147+
*/
148+
private static async comparePdf(actualPdfDir: string, actualPdfFileName: string, baselinePdfDir: string, baselinePdfFileName: string, pages: number[], tolerance: number, threshold: number) {
149+
const comparer = new ComparePdf({
150+
paths: {
151+
actualPdfRootFolder: actualPdfDir,
152+
actualPngRootFolder: this.PNG_DIR_ACTUAL,
153+
baselinePdfRootFolder: baselinePdfDir,
154+
baselinePngRootFolder: this.PNG_DIR_BASELINE,
155+
diffPngRootFolder: this.PNG_DIR_DIFF,
156+
},
157+
settings: {
158+
imageEngine: 'native',
159+
density: 150,
160+
quality: 80,
161+
tolerance: tolerance,
162+
threshold: threshold,
163+
cleanPngPaths: false,
164+
matchPageCount: true,
165+
disableFontFace: true,
166+
},
167+
});
168+
const results = await comparer
169+
.actualPdfFile(actualPdfFileName)
170+
.baselinePdfFile(baselinePdfFileName)
171+
.onlyPageIndexes(pages)
172+
.compare();
173+
return results;
174+
}
175+
/**
176+
* Masks the dynamic text in the specified pages of the baseline and actual PDF files and compares them. If differences are found, attaches the actual, baseline, and diff files to the test report.
177+
* @param baselineDirPath Path to the baseline PDF file directory
178+
* @param baselineFileName Baseline PDF file name
179+
* @param actualDirPath Path to the actual PDF file directory
180+
* @param actualFileName Actual PDF file name
181+
* @param pageNumber Pages to be compared (1-indexed, comma-separated. E.g., "1,2,3")
182+
* @param maskTexts text from baseline PDF file to be masked (pipe-separated. E.g., "text1|text2")
183+
* @param tolerance Pixel difference tolerance (default is 0). Use 0 for strict comparison and higher values for more lenient comparison
184+
* @param threshold Similarity threshold (default is 0.00, range is 0.00 to 1.00). Use 0.00 for strict comparison and higher values for more lenient comparison
185+
*/
186+
public static async maskAndComparePdf(baselineDirPath: string, baselineFileName: string, actualDirPath: string, actualFileName: string, pageNumber: string, maskTexts: string, tolerance: number = 0, threshold: number = 0.00) {
187+
const pageNumbers = pageNumber.split(',').map(num => parseInt(num.trim(), 10) - 1);
188+
const dynamicText = maskTexts ? maskTexts.split('|').map(text => text.trim()) : []
189+
if (dynamicText.length > 0) {
190+
const maskDetails = await this.maskPdf(baselineDirPath, baselineFileName, actualDirPath, actualFileName, pageNumbers, dynamicText);
191+
baselineDirPath = maskDetails.maskedBaselineDir;
192+
baselineFileName = maskDetails.maskedBaselineFileName;
193+
actualDirPath = maskDetails.maskedActualDir;
194+
actualFileName = maskDetails.maskedActualFileName;
195+
}
196+
const result = await this.comparePdf(actualDirPath, actualFileName, baselineDirPath, baselineFileName, pageNumbers, tolerance, threshold);
197+
if (result.status !== 'passed') {
198+
await Allure.attachPDF('Actual PDF', `${actualDirPath}/${actualFileName}`);
199+
await Allure.attachPDF('Baseline PDF', `${baselineDirPath}/${baselineFileName}`);
200+
for (let i = 0; i < result.details.length; i++) {
201+
await Allure.attachPNG(`PDF Diff${i + 1}`, result.details[i].diffPng);
202+
}
203+
console.log(`PDF comparison failed.\n ${JSON.stringify(result.details, null, 2)}`);
204+
}
205+
return result.status === 'passed';
48206
}
49207
}

src/resources/pdf/baseline.pdf

514 KB
Binary file not shown.

src/tests/DownloadTest.spec.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@ import ConfigurationSteps from "@uiSteps/ConfigurationSteps";
22
import HomeSteps from "@uiSteps/HomeSteps";
33
import { test } from "@base-test";
44
import Allure from "@allure";
5+
import PDFUtil from "@utils/PDFUtil";
56

6-
test(`DownloadTest - To download pdf file from application`, async ({ page }) => {
7-
Allure.attachDetails("To download pdf file from application", null);
7+
test(`DownloadTest - To download pdf file from application and verify the PDF details`, async ({ page }) => {
8+
Allure.attachDetails("To download pdf file from application and verify the PDF details", null);
89
const home = new HomeSteps(page);
910
await home.launchApplication();
1011
const newPage = await home.navigateToManagementConsole();
1112
await page.close();
1213
const configStep = new ConfigurationSteps(newPage);
1314
const fileName = await configStep.downloadAOSBackendPDF();
14-
await configStep.verifyPDFFilePageCount(fileName, 4);
15-
await configStep.verifyPDFFileText(fileName, "Advantage Online Shopping (AOS)");
15+
await configStep.verifyPDFDetails(fileName, 4, "Advantage Online Shopping (AOS)");
16+
const maskText = '5134647|The Lawn, 22-30 Old Bath Road, Berkshire, RG14 1Q| Java 11';
17+
await configStep.verifyPdfByComparing(fileName, 'baseline.pdf', '1,2,3,4', maskText);
1618
});

0 commit comments

Comments
 (0)