Skip to content

Commit 9f434eb

Browse files
authored
Merge pull request #349 from lt-zeeshan/DOT-5874
DOT-5874: Add pdf upload support in SmartUI-CLI
2 parents 137599b + 3cfd4a6 commit 9f434eb

File tree

7 files changed

+370
-1
lines changed

7 files changed

+370
-1
lines changed

src/commander/commander.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import stopServer from './stopServer.js'
1010
import ping from './ping.js'
1111
import merge from './merge.js'
1212
import pingTest from './pingTest.js'
13+
import uploadPdf from "./uploadPdf.js";
1314

1415
const program = new Command();
1516

@@ -38,6 +39,7 @@ program
3839
.addCommand(uploadWebFigmaCommand)
3940
.addCommand(uploadAppFigmaCommand)
4041
.addCommand(pingTest)
42+
.addCommand(uploadPdf)
4143

4244

4345

src/commander/uploadPdf.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import {Command} from "commander";
2+
import { Context } from '../types.js';
3+
import ctxInit from '../lib/ctx.js';
4+
import { color, Listr, ListrDefaultRendererLogLevels, LoggerFormat } from 'listr2';
5+
import fs from 'fs';
6+
import auth from '../tasks/auth.js';
7+
import uploadPdfs from '../tasks/uploadPdfs.js';
8+
import {startPdfPolling} from "../lib/utils.js";
9+
const command = new Command();
10+
11+
command
12+
.name('upload-pdf')
13+
.description('Upload PDFs for visual comparison')
14+
.argument('<directory>', 'Path of the directory containing PDFs')
15+
.option('--fetch-results [filename]', 'Fetch results and optionally specify an output file, e.g., <filename>.json')
16+
.option('--buildName <string>', 'Specify the build name')
17+
.action(async function(directory, _, command) {
18+
const options = command.optsWithGlobals();
19+
if (options.buildName === '') {
20+
console.log(`Error: The '--buildName' option cannot be an empty string.`);
21+
process.exit(1);
22+
}
23+
let ctx: Context = ctxInit(command.optsWithGlobals());
24+
25+
if (!fs.existsSync(directory)) {
26+
console.log(`Error: The provided directory ${directory} not found.`);
27+
process.exit(1);
28+
}
29+
30+
ctx.uploadFilePath = directory;
31+
32+
let tasks = new Listr<Context>(
33+
[
34+
auth(ctx),
35+
uploadPdfs(ctx)
36+
],
37+
{
38+
rendererOptions: {
39+
icon: {
40+
[ListrDefaultRendererLogLevels.OUTPUT]: `→`
41+
},
42+
color: {
43+
[ListrDefaultRendererLogLevels.OUTPUT]: color.gray as LoggerFormat
44+
}
45+
}
46+
}
47+
);
48+
49+
try {
50+
await tasks.run(ctx);
51+
52+
if (ctx.options.fetchResults) {
53+
startPdfPolling(ctx);
54+
}
55+
} catch (error) {
56+
console.log('\nRefer docs: https://www.lambdatest.com/support/docs/smart-visual-regression-testing/');
57+
process.exit(1);
58+
}
59+
});
60+
61+
export default command;

src/lib/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export default (): Env => {
44
const {
55
PROJECT_TOKEN = '',
66
SMARTUI_CLIENT_API_URL = 'https://api.lambdatest.com/visualui/1.0',
7+
SMARTUI_UPLOAD_URL = 'https://api.lambdatest.com',
78
SMARTUI_GIT_INFO_FILEPATH,
89
SMARTUI_DO_NOT_USE_CAPTURED_COOKIES,
910
HTTP_PROXY,
@@ -27,6 +28,7 @@ export default (): Env => {
2728
return {
2829
PROJECT_TOKEN,
2930
SMARTUI_CLIENT_API_URL,
31+
SMARTUI_UPLOAD_URL: SMARTUI_UPLOAD_URL,
3032
SMARTUI_GIT_INFO_FILEPATH,
3133
HTTP_PROXY,
3234
HTTPS_PROXY,

src/lib/httpClient.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ export default class httpClient {
1414
username: string;
1515
accessKey: string;
1616

17+
private handleHttpError(error: any, log: Logger): never {
18+
if (error && error.response) {
19+
log.debug(`http response error: ${JSON.stringify({
20+
status: error.response.status,
21+
body: error.response.data
22+
})}`);
23+
throw new Error(error.response.data?.message || error.response.data || `HTTP ${error.response.status} error`);
24+
}
25+
log.debug(`http request failed: ${error.message}`);
26+
throw new Error(error.message);
27+
}
28+
1729
constructor({ SMARTUI_CLIENT_API_URL, PROJECT_TOKEN, PROJECT_NAME, LT_USERNAME, LT_ACCESS_KEY, SMARTUI_API_PROXY, SMARTUI_API_SKIP_CERTIFICATES }: Env) {
1830
this.projectToken = PROJECT_TOKEN || '';
1931
this.projectName = PROJECT_NAME || '';
@@ -83,6 +95,8 @@ export default class httpClient {
8395

8496
// If we've reached max retries, reject with the error
8597
return Promise.reject(error);
98+
} else {
99+
return Promise.reject(error);
86100
}
87101
}
88102
);
@@ -644,4 +658,68 @@ export default class httpClient {
644658
}
645659
}, ctx.log);
646660
}
661+
662+
async uploadPdf(ctx: Context, form: FormData, buildName?: string): Promise<any> {
663+
form.append('projectToken', this.projectToken);
664+
if (ctx.build.name !== undefined && ctx.build.name !== '') {
665+
form.append('buildName', buildName);
666+
}
667+
668+
try {
669+
const response = await this.axiosInstance.request({
670+
url: ctx.env.SMARTUI_UPLOAD_URL + '/pdf/upload',
671+
method: 'POST',
672+
headers: form.getHeaders(),
673+
data: form,
674+
});
675+
676+
ctx.log.debug(`http response: ${JSON.stringify({
677+
status: response.status,
678+
headers: response.headers,
679+
body: response.data
680+
})}`);
681+
682+
return response.data;
683+
} catch (error: any) {
684+
this.handleHttpError(error, ctx.log);
685+
}
686+
}
687+
688+
async fetchPdfResults(ctx: Context): Promise<any> {
689+
const params: Record<string, string> = {
690+
projectToken: this.projectToken
691+
};
692+
693+
// Use buildId if available, otherwise use buildName
694+
if (ctx.build.id) {
695+
params.buildId = ctx.build.id;
696+
} else if (ctx.build.name) {
697+
params.buildName = ctx.build.name;
698+
}
699+
700+
const auth = Buffer.from(`${this.username}:${this.accessKey}`).toString('base64');
701+
702+
try {
703+
const response = await axios.request({
704+
url: ctx.env.SMARTUI_UPLOAD_URL + '/automation/smart-ui/screenshot/build/status',
705+
method: 'GET',
706+
params: params,
707+
headers: {
708+
'accept': 'application/json',
709+
'Authorization': `Basic ${auth}`
710+
}
711+
});
712+
713+
ctx.log.debug(`http response: ${JSON.stringify({
714+
status: response.status,
715+
headers: response.headers,
716+
body: response.data
717+
})}`);
718+
719+
return response.data;
720+
} catch (error: any) {
721+
this.handleHttpError(error, ctx.log);
722+
}
723+
}
647724
}
725+

src/lib/utils.ts

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,4 +509,168 @@ export function calculateVariantCountFromSnapshot(snapshot: any, globalConfig?:
509509
}
510510

511511
return variantCount;
512-
}
512+
}
513+
514+
export function startPdfPolling(ctx: Context) {
515+
console.log(chalk.yellow('\nFetching PDF test results...'));
516+
517+
ctx.log.debug(`Starting fetching results for build: ${ctx.build.id || ctx.build.name}`);
518+
if (!ctx.build.id && !ctx.build.name) {
519+
ctx.log.error(chalk.red('Error: Build information not found for fetching results'));
520+
return
521+
}
522+
523+
if (!ctx.env.LT_USERNAME || !ctx.env.LT_ACCESS_KEY) {
524+
console.log(chalk.red('Error: LT_USERNAME and LT_ACCESS_KEY environment variables are required for fetching results'));
525+
return;
526+
}
527+
528+
let attempts = 0;
529+
const maxAttempts = 30; // 5 minutes (10 seconds * 30)
530+
531+
console.log(chalk.yellow('Waiting for results...'));
532+
533+
const interval = setInterval(async () => {
534+
attempts++;
535+
536+
try {
537+
const response = await ctx.client.fetchPdfResults(ctx, ctx.log);
538+
539+
if (response.status === 'success' && response.data && response.data.Screenshots) {
540+
clearInterval(interval);
541+
542+
const pdfGroups = groupScreenshotsByPdf(response.data.Screenshots);
543+
544+
const pdfsWithMismatches = countPdfsWithMismatches(pdfGroups);
545+
const pagesWithMismatches = countPagesWithMismatches(response.data.Screenshots);
546+
547+
console.log(chalk.green('\n✓ PDF Test Results:'));
548+
console.log(chalk.green(`Build Name: ${response.data.buildName}`));
549+
console.log(chalk.green(`Project Name: ${response.data.projectName}`));
550+
console.log(chalk.green(`Total PDFs: ${Object.keys(pdfGroups).length}`));
551+
console.log(chalk.green(`Total Pages: ${response.data.Screenshots.length}`));
552+
553+
if (pdfsWithMismatches > 0 || pagesWithMismatches > 0) {
554+
console.log(chalk.yellow(`${pdfsWithMismatches} PDFs and ${pagesWithMismatches} Pages in build ${response.data.buildName} have changes present.`));
555+
} else {
556+
console.log(chalk.green('All PDFs match the baseline.'));
557+
}
558+
559+
Object.entries(pdfGroups).forEach(([pdfName, pages]) => {
560+
const hasMismatch = pages.some(page => page.mismatchPercentage > 0);
561+
const statusColor = hasMismatch ? chalk.yellow : chalk.green;
562+
563+
console.log(statusColor(`\n📄 ${pdfName} (${pages.length} pages)`));
564+
565+
pages.forEach(page => {
566+
const pageStatusColor = page.mismatchPercentage > 0 ? chalk.yellow : chalk.green;
567+
console.log(pageStatusColor(` - Page ${getPageNumber(page.screenshotName)}: ${page.status} (Mismatch: ${page.mismatchPercentage}%)`));
568+
});
569+
});
570+
571+
const formattedResults = {
572+
status: response.status,
573+
data: {
574+
buildId: response.data.buildId,
575+
buildName: response.data.buildName,
576+
projectName: response.data.projectName,
577+
buildStatus: response.data.buildStatus,
578+
pdfs: formatPdfsForOutput(pdfGroups)
579+
}
580+
};
581+
582+
// Save results to file if filename provided
583+
if (ctx.options.fetchResults && ctx.options.fetchResultsFileName) {
584+
const filename = ctx.options.fetchResultsFileName !== '' ? ctx.options.fetchResultsFileName : 'pdf-results.json';
585+
586+
fs.writeFileSync(filename, JSON.stringify(formattedResults, null, 2));
587+
console.log(chalk.green(`\nResults saved to ${filename}`));
588+
}
589+
590+
return;
591+
} else if (response.status === 'error') {
592+
clearInterval(interval);
593+
console.log(chalk.red(`\nError fetching results: ${response.message || 'Unknown error'}`));
594+
return;
595+
} else {
596+
process.stdout.write(chalk.yellow('.'));
597+
}
598+
599+
if (attempts >= maxAttempts) {
600+
clearInterval(interval);
601+
console.log(chalk.red('\nTimeout: Could not fetch PDF results after 5 minutes'));
602+
return;
603+
}
604+
605+
} catch (error: any) {
606+
ctx.log.debug(`Error during polling: ${error.message}`);
607+
608+
if (attempts >= maxAttempts) {
609+
clearInterval(interval);
610+
console.log(chalk.red('\nTimeout: Could not fetch PDF results after 5 minutes'));
611+
if (error.response && error.response.data) {
612+
console.log(chalk.red(`Error details: ${JSON.stringify(error.response.data)}`));
613+
} else {
614+
console.log(chalk.red(`Error details: ${error.message}`));
615+
}
616+
return;
617+
}
618+
process.stdout.write(chalk.yellow('.'));
619+
}
620+
}, 10000);
621+
}
622+
623+
function groupScreenshotsByPdf(screenshots: any[]): Record<string, any[]> {
624+
const pdfGroups: Record<string, any[]> = {};
625+
626+
screenshots.forEach(screenshot => {
627+
// screenshot name format: "pdf-name.pdf#page-number"
628+
const pdfName = screenshot.screenshotName.split('#')[0];
629+
630+
if (!pdfGroups[pdfName]) {
631+
pdfGroups[pdfName] = [];
632+
}
633+
634+
pdfGroups[pdfName].push(screenshot);
635+
});
636+
637+
return pdfGroups;
638+
}
639+
640+
function countPdfsWithMismatches(pdfGroups: Record<string, any[]>): number {
641+
let count = 0;
642+
643+
Object.values(pdfGroups).forEach(pages => {
644+
if (pages.some(page => page.mismatchPercentage > 0)) {
645+
count++;
646+
}
647+
});
648+
649+
return count;
650+
}
651+
652+
function countPagesWithMismatches(screenshots: any[]): number {
653+
return screenshots.filter(screenshot => screenshot.mismatchPercentage > 0).length;
654+
}
655+
656+
function formatPdfsForOutput(pdfGroups: Record<string, any[]>): any[] {
657+
return Object.entries(pdfGroups).map(([pdfName, pages]) => {
658+
return {
659+
pdfName,
660+
pageCount: pages.length,
661+
pages: pages.map(page => ({
662+
pageNumber: getPageNumber(page.screenshotName),
663+
screenshotId: page.screenshotId,
664+
mismatchPercentage: page.mismatchPercentage,
665+
threshold: page.threshold,
666+
status: page.status,
667+
screenshotUrl: page.screenshotUrl
668+
}))
669+
};
670+
});
671+
}
672+
673+
function getPageNumber(screenshotName: string): string {
674+
const parts = screenshotName.split('#');
675+
return parts.length > 1 ? parts[1] : '1';
676+
}

0 commit comments

Comments
 (0)