From d483bf75d7d97741b24f0f478795cb8430be3130 Mon Sep 17 00:00:00 2001 From: JeeveshJ7 Date: Wed, 26 Feb 2025 01:46:12 +0530 Subject: [PATCH 1/2] add pdf support in cli --- src/commander/commander.ts | 2 ++ src/commander/uploadPdf.ts | 55 ++++++++++++++++++++++++++++++++++++ src/lib/httpClient.ts | 27 ++++++++++++++++++ src/tasks/uploadPdfs.ts | 58 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+) create mode 100644 src/commander/uploadPdf.ts create mode 100644 src/tasks/uploadPdfs.ts diff --git a/src/commander/commander.ts b/src/commander/commander.ts index 389f3d7..c59c3d0 100644 --- a/src/commander/commander.ts +++ b/src/commander/commander.ts @@ -8,6 +8,7 @@ import { uploadFigma, uploadWebFigmaCommand } from './uploadFigma.js' import startServer from './server.js'; import stopServer from './stopServer.js' import ping from './ping.js' +import uploadPdf from './uploadPdf.js' const program = new Command(); @@ -21,6 +22,7 @@ program .addCommand(configWeb) .addCommand(configStatic) .addCommand(upload) + .addCommand(uploadPdf) .addCommand(startServer) .addCommand(stopServer) .addCommand(ping) diff --git a/src/commander/uploadPdf.ts b/src/commander/uploadPdf.ts new file mode 100644 index 0000000..a498fc9 --- /dev/null +++ b/src/commander/uploadPdf.ts @@ -0,0 +1,55 @@ +import { Command } from 'commander'; +import { Context } from '../types.js'; +import { color, Listr, ListrDefaultRendererLogLevels, LoggerFormat } from 'listr2'; +import auth from '../tasks/auth.js'; +import ctxInit from '../lib/ctx.js'; +import uploadPdfs from '../tasks/uploadPdfs.js'; +import fs from 'fs'; + +const command = new Command(); + +command + .name('upload-pdf') + .description('Upload PDFs for visual comparison') + .argument('', 'Path of the directory containing PDFs') + .option('--buildName ', 'Specify the build name') + .action(async function(directory, _, command) { + const options = command.optsWithGlobals(); + if (options.buildName === '') { + console.log(`Error: The '--buildName' option cannot be an empty string.`); + process.exit(1); + } + let ctx: Context = ctxInit(command.optsWithGlobals()); + + if (!fs.existsSync(directory)) { + console.log(`Error: The provided directory ${directory} not found.`); + return; + } + + ctx.uploadFilePath = directory; + + let tasks = new Listr( + [ + auth(ctx), + uploadPdfs(ctx) + ], + { + rendererOptions: { + icon: { + [ListrDefaultRendererLogLevels.OUTPUT]: `→` + }, + color: { + [ListrDefaultRendererLogLevels.OUTPUT]: color.gray as LoggerFormat + } + } + } + ); + + try { + await tasks.run(ctx); + } catch (error) { + console.log('\nRefer docs: https://www.lambdatest.com/support/docs/smart-visual-regression-testing/'); + } + }); + +export default command; \ No newline at end of file diff --git a/src/lib/httpClient.ts b/src/lib/httpClient.ts index ffc441c..4550492 100644 --- a/src/lib/httpClient.ts +++ b/src/lib/httpClient.ts @@ -327,4 +327,31 @@ export default class httpClient { params: { buildId } }, log); } + + async uploadPdf(formData: FormData, buildName: string, log: Logger): Promise { + // Add required parameters to form data + formData.append('projectToken', this.projectToken); + formData.append('buildName', buildName); + formData.append('projectType', 'pdf'); // Add project type + + // Create a new axios instance for this specific request + const response = await axios.request({ + url: 'https://api.lambdatest.com/pdf/upload', + method: 'POST', + headers: { + ...formData.getHeaders(), + 'username': this.username, + 'accessKey': this.accessKey + }, + data: formData, + }); + + log.debug(`http response: ${JSON.stringify({ + status: response.status, + headers: response.headers, + body: response.data + })}`); + + return response.data; + } } diff --git a/src/tasks/uploadPdfs.ts b/src/tasks/uploadPdfs.ts new file mode 100644 index 0000000..326d02a --- /dev/null +++ b/src/tasks/uploadPdfs.ts @@ -0,0 +1,58 @@ +import { ListrTask, ListrRendererFactory } from 'listr2'; +import { Context } from '../types.js'; +import chalk from 'chalk'; +import { updateLogContext } from '../lib/logger.js'; +import path from 'path'; +import fs from 'fs'; +import FormData from 'form-data'; + +export default (ctx: Context): ListrTask => { + return { + title: 'Uploading PDFs', + task: async (ctx, task): Promise => { + try { + ctx.task = task; + updateLogContext({ task: 'upload-pdf' }); + + // Get all PDF files from the directory + const pdfs = await getPdfsFromDirectory(ctx.uploadFilePath); + if (pdfs.length === 0) { + throw new Error('No PDF files found in the specified directory'); + } + + // Upload each PDF + for (const pdf of pdfs) { + task.output = `Uploading ${path.basename(pdf)}...`; + await uploadPdf(ctx, pdf); + } + + task.title = 'PDFs uploaded successfully'; + } catch (error: any) { + ctx.log.debug(error); + task.output = chalk.gray(`${error.message}`); + throw new Error('Uploading PDFs failed'); + } + }, + rendererOptions: { persistentOutput: true }, + exitOnError: false + }; +}; + +async function getPdfsFromDirectory(directory: string): Promise { + const files = await fs.promises.readdir(directory); + return files + .filter(file => path.extname(file).toLowerCase() === '.pdf') + .map(file => path.join(directory, file)); +} + +async function uploadPdf(ctx: Context, pdfPath: string): Promise { + const formData = new FormData(); + formData.append('pathToFiles', fs.createReadStream(pdfPath)); + formData.append('name', path.basename(pdfPath, '.pdf')); + formData.append('type', 'pdf'); + + // Use buildName from options instead of ctx.build.name + const buildName = ctx.options.buildName || `pdf-build-${Date.now()}`; + + await ctx.client.uploadPdf(formData, buildName, ctx.log); +} \ No newline at end of file From 88bc85f82285e8b237ae3aa5a0ef0ce042c76c3d Mon Sep 17 00:00:00 2001 From: JeeveshJ7 Date: Wed, 26 Feb 2025 02:40:37 +0530 Subject: [PATCH 2/2] Added Fetch Results Support --- src/commander/uploadPdf.ts | 7 ++ src/lib/httpClient.ts | 47 ++++++++++ src/lib/utils.ts | 181 +++++++++++++++++++++++++++++++++++++ src/tasks/uploadPdfs.ts | 12 ++- src/types.ts | 2 + 5 files changed, 248 insertions(+), 1 deletion(-) diff --git a/src/commander/uploadPdf.ts b/src/commander/uploadPdf.ts index a498fc9..504727c 100644 --- a/src/commander/uploadPdf.ts +++ b/src/commander/uploadPdf.ts @@ -5,6 +5,7 @@ import auth from '../tasks/auth.js'; import ctxInit from '../lib/ctx.js'; import uploadPdfs from '../tasks/uploadPdfs.js'; import fs from 'fs'; +import { startPdfPolling } from '../lib/utils.js'; const command = new Command(); @@ -12,6 +13,7 @@ command .name('upload-pdf') .description('Upload PDFs for visual comparison') .argument('', 'Path of the directory containing PDFs') + .option('--fetch-results [filename]', 'Fetch results and optionally specify an output file, e.g., .json') .option('--buildName ', 'Specify the build name') .action(async function(directory, _, command) { const options = command.optsWithGlobals(); @@ -47,6 +49,11 @@ command try { await tasks.run(ctx); + + // Start polling for results if requested + if (ctx.options.fetchResults) { + startPdfPolling(ctx); + } } catch (error) { console.log('\nRefer docs: https://www.lambdatest.com/support/docs/smart-visual-regression-testing/'); } diff --git a/src/lib/httpClient.ts b/src/lib/httpClient.ts index 4550492..03fa02a 100644 --- a/src/lib/httpClient.ts +++ b/src/lib/httpClient.ts @@ -354,4 +354,51 @@ export default class httpClient { return response.data; } + + async fetchPdfResults(buildName: string, buildId: string | undefined, log: Logger): Promise { + // Create params object with required parameters + const params: Record = { + projectToken: this.projectToken + }; + + // Use buildId if available, otherwise use buildName + if (buildId) { + params.buildId = buildId; + } else if (buildName) { + params.buildName = buildName; + } + + // Create basic auth token + const auth = Buffer.from(`${this.username}:${this.accessKey}`).toString('base64'); + + try { + // Create a new axios instance for this specific request + const response = await axios.request({ + url: 'https://api.lambdatest.com/automation/smart-ui/screenshot/build/status', + method: 'GET', + params: params, + headers: { + 'accept': 'application/json', + 'Authorization': `Basic ${auth}` + } + }); + + log.debug(`http response: ${JSON.stringify({ + status: response.status, + headers: response.headers, + body: response.data + })}`); + + return response.data; + } catch (error: any) { + log.error(`Error fetching PDF results: ${error.message}`); + if (error.response) { + log.debug(`Response error: ${JSON.stringify({ + status: error.response.status, + data: error.response.data + })}`); + } + throw error; + } + } } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 968c60e..16653df 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -328,5 +328,186 @@ export async function startPingPolling(ctx: Context): Promise { }, 10 * 60 * 1000); // 10 minutes interval } +export function startPdfPolling(ctx: Context) { + console.log(chalk.yellow('\nFetching PDF test results...')); + + // Use buildId if available, otherwise use buildName + const buildName = ctx.options.buildName || ctx.pdfBuildName; + const buildId = ctx.pdfBuildId; + + if (!buildId && !buildName) { + console.log(chalk.red('Error: Build information not found for fetching results')); + return; + } + + // Verify authentication credentials + if (!ctx.env.LT_USERNAME || !ctx.env.LT_ACCESS_KEY) { + console.log(chalk.red('Error: LT_USERNAME and LT_ACCESS_KEY environment variables are required for fetching results')); + return; + } + + let attempts = 0; + const maxAttempts = 30; // 5 minutes (10 seconds * 30) + + console.log(chalk.yellow('Waiting for results...')); + + const interval = setInterval(async () => { + attempts++; + + try { + const response = await ctx.client.fetchPdfResults(buildName, buildId, ctx.log); + + if (response.status === 'success' && response.data && response.data.Screenshots) { + clearInterval(interval); + + // Group screenshots by PDF name + const pdfGroups = groupScreenshotsByPdf(response.data.Screenshots); + + // Count PDFs with mismatches + const pdfsWithMismatches = countPdfsWithMismatches(pdfGroups); + const pagesWithMismatches = countPagesWithMismatches(response.data.Screenshots); + + // Display summary in terminal + console.log(chalk.green('\nāœ“ PDF Test Results:')); + console.log(chalk.green(`Build Name: ${response.data.buildName}`)); + console.log(chalk.green(`Project Name: ${response.data.projectName}`)); + console.log(chalk.green(`Total PDFs: ${Object.keys(pdfGroups).length}`)); + console.log(chalk.green(`Total Pages: ${response.data.Screenshots.length}`)); + + if (pdfsWithMismatches > 0 || pagesWithMismatches > 0) { + console.log(chalk.yellow(`${pdfsWithMismatches} PDFs and ${pagesWithMismatches} Pages in build ${response.data.buildName} have changes present.`)); + } else { + console.log(chalk.green('All PDFs match the baseline.')); + } + + // Display each PDF and its pages + Object.entries(pdfGroups).forEach(([pdfName, pages]) => { + const hasMismatch = pages.some(page => page.mismatchPercentage > 0); + const statusColor = hasMismatch ? chalk.yellow : chalk.green; + + console.log(statusColor(`\nšŸ“„ ${pdfName} (${pages.length} pages)`)); + + pages.forEach(page => { + const pageStatusColor = page.mismatchPercentage > 0 ? chalk.yellow : chalk.green; + console.log(pageStatusColor(` - Page ${getPageNumber(page.screenshotName)}: ${page.status} (Mismatch: ${page.mismatchPercentage}%)`)); + }); + }); + + // Format the results for JSON output + const formattedResults = { + status: response.status, + data: { + buildId: response.data.buildId, + buildName: response.data.buildName, + projectName: response.data.projectName, + buildStatus: response.data.buildStatus, + pdfs: formatPdfsForOutput(pdfGroups) + } + }; + + // Save results to file if filename provided + const filename = typeof ctx.options.fetchResults === 'string' + ? ctx.options.fetchResults + : 'pdf-results.json'; + + fs.writeFileSync(filename, JSON.stringify(formattedResults, null, 2)); + console.log(chalk.green(`\nResults saved to ${filename}`)); + + return; + } else if (response.status === 'error') { + // Handle API error response + clearInterval(interval); + console.log(chalk.red(`\nError fetching results: ${response.message || 'Unknown error'}`)); + return; + } else { + // If we get a response but it's not complete yet + process.stdout.write(chalk.yellow('.')); + } + + if (attempts >= maxAttempts) { + clearInterval(interval); + console.log(chalk.red('\nTimeout: Could not fetch PDF results after 5 minutes')); + return; + } + + } catch (error: any) { + // Log the error but continue polling unless max attempts reached + ctx.log.debug(`Error during polling: ${error.message}`); + + if (attempts >= maxAttempts) { + clearInterval(interval); + console.log(chalk.red('\nTimeout: Could not fetch PDF results after 5 minutes')); + if (error.response && error.response.data) { + console.log(chalk.red(`Error details: ${JSON.stringify(error.response.data)}`)); + } else { + console.log(chalk.red(`Error details: ${error.message}`)); + } + return; + } + process.stdout.write(chalk.yellow('.')); + } + }, 10000); // Poll every 10 seconds +} + +// Helper function to group screenshots by PDF name +function groupScreenshotsByPdf(screenshots: any[]): Record { + const pdfGroups: Record = {}; + + screenshots.forEach(screenshot => { + // Extract PDF name from screenshot name (format: "pdfname.pdf#pagenumber") + const pdfName = screenshot.screenshotName.split('#')[0]; + + if (!pdfGroups[pdfName]) { + pdfGroups[pdfName] = []; + } + + pdfGroups[pdfName].push(screenshot); + }); + + return pdfGroups; +} + +// Helper function to count PDFs with mismatches +function countPdfsWithMismatches(pdfGroups: Record): number { + let count = 0; + + Object.values(pdfGroups).forEach(pages => { + if (pages.some(page => page.mismatchPercentage > 0)) { + count++; + } + }); + + return count; +} + +// Helper function to count pages with mismatches +function countPagesWithMismatches(screenshots: any[]): number { + return screenshots.filter(screenshot => screenshot.mismatchPercentage > 0).length; +} + +// Helper function to extract page number from screenshot name +function getPageNumber(screenshotName: string): string { + const parts = screenshotName.split('#'); + return parts.length > 1 ? parts[1] : '1'; +} + +// Helper function to format PDFs for JSON output +function formatPdfsForOutput(pdfGroups: Record): any[] { + return Object.entries(pdfGroups).map(([pdfName, pages]) => { + return { + pdfName, + pageCount: pages.length, + pages: pages.map(page => ({ + pageNumber: getPageNumber(page.screenshotName), + screenshotId: page.screenshotId, + mismatchPercentage: page.mismatchPercentage, + threshold: page.threshold, + status: page.status, + screenshotUrl: page.screenshotUrl + })) + }; + }); +} + diff --git a/src/tasks/uploadPdfs.ts b/src/tasks/uploadPdfs.ts index 326d02a..c9c5886 100644 --- a/src/tasks/uploadPdfs.ts +++ b/src/tasks/uploadPdfs.ts @@ -54,5 +54,15 @@ async function uploadPdf(ctx: Context, pdfPath: string): Promise { // Use buildName from options instead of ctx.build.name const buildName = ctx.options.buildName || `pdf-build-${Date.now()}`; - await ctx.client.uploadPdf(formData, buildName, ctx.log); + // Store the build name in context for polling + ctx.pdfBuildName = buildName; + + // Upload PDF and store response + const response = await ctx.client.uploadPdf(formData, buildName, ctx.log); + + // Store buildId from response for fetching results + if (response && response.data && response.data.buildId) { + ctx.pdfBuildId = response.data.buildId; + ctx.log.debug(`PDF upload successful. Build ID: ${ctx.pdfBuildId}`); + } } \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 875435f..6044563 100644 --- a/src/types.ts +++ b/src/types.ts @@ -54,6 +54,8 @@ export interface Context { testType?: string; isStartExec ?: boolean; isSnapshotCaptured ?: boolean; + pdfBuildName?: string; + pdfBuildId?: string; } export interface Env {