diff --git a/package.json b/package.json index 9947f5bf..c6dc19d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lambdatest/smartui-cli", - "version": "4.1.28", + "version": "4.1.29", "description": "A command line interface (CLI) to run SmartUI tests on LambdaTest", "files": [ "dist/**/*" diff --git a/src/commander/commander.ts b/src/commander/commander.ts index 25f59e5b..5daf9357 100644 --- a/src/commander/commander.ts +++ b/src/commander/commander.ts @@ -10,6 +10,7 @@ import stopServer from './stopServer.js' import ping from './ping.js' import merge from './merge.js' import pingTest from './pingTest.js' +import uploadPdf from "./uploadPdf.js"; const program = new Command(); @@ -38,6 +39,7 @@ program .addCommand(uploadWebFigmaCommand) .addCommand(uploadAppFigmaCommand) .addCommand(pingTest) + .addCommand(uploadPdf) diff --git a/src/commander/server.ts b/src/commander/server.ts index 1fbea907..81781622 100644 --- a/src/commander/server.ts +++ b/src/commander/server.ts @@ -2,10 +2,10 @@ import { Command } from 'commander'; import { Context } from '../types.js'; import { color, Listr, ListrDefaultRendererLogLevels } from 'listr2'; import startServer from '../tasks/startServer.js'; -import auth from '../tasks/auth.js'; +import authExec from '../tasks/authExec.js'; import ctxInit from '../lib/ctx.js'; import getGitInfo from '../tasks/getGitInfo.js'; -import createBuild from '../tasks/createBuild.js'; +import createBuildExec from '../tasks/createBuildExec.js'; import snapshotQueue from '../lib/snapshotQueue.js'; import { startPolling, startPingPolling } from '../lib/utils.js'; @@ -30,10 +30,10 @@ command let tasks = new Listr( [ - auth(ctx), + authExec(ctx), startServer(ctx), getGitInfo(ctx), - createBuild(ctx), + createBuildExec(ctx), ], { @@ -50,11 +50,12 @@ command try { await tasks.run(ctx); - startPingPolling(ctx); - if (ctx.options.fetchResults) { + if (ctx.build && ctx.build.id) { + startPingPolling(ctx); + } + if (ctx.options.fetchResults && ctx.build && ctx.build.id) { startPolling(ctx, '', false, '') } - } catch (error) { console.error('Error during server execution:', error); diff --git a/src/commander/uploadPdf.ts b/src/commander/uploadPdf.ts new file mode 100644 index 00000000..07c319f3 --- /dev/null +++ b/src/commander/uploadPdf.ts @@ -0,0 +1,61 @@ +import {Command} from "commander"; +import { Context } from '../types.js'; +import ctxInit from '../lib/ctx.js'; +import { color, Listr, ListrDefaultRendererLogLevels, LoggerFormat } from 'listr2'; +import fs from 'fs'; +import auth from '../tasks/auth.js'; +import uploadPdfs from '../tasks/uploadPdfs.js'; +import {startPdfPolling} from "../lib/utils.js"; +const command = new Command(); + +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(); + 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.`); + process.exit(1); + } + + 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); + + if (ctx.options.fetchResults) { + startPdfPolling(ctx); + } + } catch (error) { + console.log('\nRefer docs: https://www.lambdatest.com/support/docs/smart-visual-regression-testing/'); + process.exit(1); + } + }); + +export default command; \ No newline at end of file diff --git a/src/lib/env.ts b/src/lib/env.ts index 5dd1bab1..8e8cf1d5 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -4,6 +4,7 @@ export default (): Env => { const { PROJECT_TOKEN = '', SMARTUI_CLIENT_API_URL = 'https://api.lambdatest.com/visualui/1.0', + SMARTUI_UPLOAD_URL = 'https://api.lambdatest.com', SMARTUI_GIT_INFO_FILEPATH, SMARTUI_DO_NOT_USE_CAPTURED_COOKIES, HTTP_PROXY, @@ -27,6 +28,7 @@ export default (): Env => { return { PROJECT_TOKEN, SMARTUI_CLIENT_API_URL, + SMARTUI_UPLOAD_URL: SMARTUI_UPLOAD_URL, SMARTUI_GIT_INFO_FILEPATH, HTTP_PROXY, HTTPS_PROXY, diff --git a/src/lib/git.ts b/src/lib/git.ts index 324e10b0..fc01ec6e 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -17,11 +17,12 @@ function executeCommand(command: string): string { } } -export function isGitRepo(): boolean { +export function isGitRepo(ctx: Context): boolean { try { executeCommand('git status') return true } catch (error) { + setNonGitInfo(ctx) return false } } @@ -82,3 +83,25 @@ export default (ctx: Context): Git => { }; } } + + +function setNonGitInfo(ctx: Context) { + let branch = ctx.env.CURRENT_BRANCH || 'unknown-branch' + if (ctx.options.markBaseline) { + ctx.env.BASELINE_BRANCH = branch + ctx.env.SMART_GIT = false + } + let githubURL; + if (ctx.options.githubURL && ctx.options.githubURL.startsWith('https://')) { + githubURL = ctx.options.githubURL; + } + + ctx.git = { + branch: branch, + commitId: '-', + commitAuthor: '-', + commitMessage: '-', + githubURL: githubURL? githubURL : '', + baselineBranch: ctx.options.baselineBranch || ctx.env.BASELINE_BRANCH || '' + } +} \ No newline at end of file diff --git a/src/lib/httpClient.ts b/src/lib/httpClient.ts index acf55626..9a1ae937 100644 --- a/src/lib/httpClient.ts +++ b/src/lib/httpClient.ts @@ -14,6 +14,18 @@ export default class httpClient { username: string; accessKey: string; + private handleHttpError(error: any, log: Logger): never { + if (error && error.response) { + log.debug(`http response error: ${JSON.stringify({ + status: error.response.status, + body: error.response.data + })}`); + throw new Error(error.response.data?.message || error.response.data || `HTTP ${error.response.status} error`); + } + log.debug(`http request failed: ${error.message}`); + throw new Error(error.message); + } + constructor({ SMARTUI_CLIENT_API_URL, PROJECT_TOKEN, PROJECT_NAME, LT_USERNAME, LT_ACCESS_KEY, SMARTUI_API_PROXY, SMARTUI_API_SKIP_CERTIFICATES }: Env) { this.projectToken = PROJECT_TOKEN || ''; this.projectName = PROJECT_NAME || ''; @@ -83,6 +95,8 @@ export default class httpClient { // If we've reached max retries, reject with the error return Promise.reject(error); + } else { + return Promise.reject(error); } } ); @@ -644,4 +658,66 @@ export default class httpClient { } }, ctx.log); } + + async uploadPdf(ctx: Context, form: FormData, buildName?: string): Promise { + form.append('projectToken', this.projectToken); + if (ctx.build.name !== undefined && ctx.build.name !== '') { + form.append('buildName', buildName); + } + + try { + const response = await this.axiosInstance.request({ + url: ctx.env.SMARTUI_UPLOAD_URL + '/pdf/upload', + method: 'POST', + headers: form.getHeaders(), + data: form, + }); + + ctx.log.debug(`http response: ${JSON.stringify({ + status: response.status, + headers: response.headers, + body: response.data + })}`); + + return response.data; + } catch (error: any) { + this.handleHttpError(error, ctx.log); + } + } + + async fetchPdfResults(ctx: Context): Promise { + const params: Record = {}; + + if (ctx.build.projectId) { + params.project_id = ctx.build.projectId; + } else { + throw new Error('Project ID not found to fetch PDF results'); + } + params.build_id = ctx.build.id; + + const auth = Buffer.from(`${this.username}:${this.accessKey}`).toString('base64'); + + try { + const response = await axios.request({ + url: ctx.env.SMARTUI_UPLOAD_URL + '/smartui/2.0/build/screenshots', + method: 'GET', + params: params, + headers: { + 'accept': 'application/json', + 'Authorization': `Basic ${auth}` + } + }); + + ctx.log.debug(`http response: ${JSON.stringify({ + status: response.status, + headers: response.headers, + body: response.data + })}`); + + return response.data; + } catch (error: any) { + this.handleHttpError(error, ctx.log); + } + } } + diff --git a/src/lib/server.ts b/src/lib/server.ts index e2a1955a..29145264 100644 --- a/src/lib/server.ts +++ b/src/lib/server.ts @@ -63,7 +63,7 @@ export default async (ctx: Context): Promise { + attempts++; + + try { + const response = await ctx.client.fetchPdfResults(ctx); + + if (response.screenshots) { + clearInterval(interval); + + const pdfGroups = groupScreenshotsByPdf(response.screenshots); + const pdfsWithMismatches = countPdfsWithMismatches(pdfGroups); + const pagesWithMismatches = countPagesWithMismatches(response.screenshots); + + console.log(chalk.green('\nāœ“ PDF Test Results:')); + console.log(chalk.green(`Build Name: ${response.build.name}`)); + console.log(chalk.green(`Project Name: ${response.project.name}`)); + console.log(chalk.green(`Total PDFs: ${Object.keys(pdfGroups).length}`)); + console.log(chalk.green(`Total Pages: ${response.screenshots.length}`)); + + if (pdfsWithMismatches > 0 || pagesWithMismatches > 0) { + console.log(chalk.yellow(`${pdfsWithMismatches} PDFs and ${pagesWithMismatches} Pages in build ${response.build.name} have changes present.`)); + } else { + console.log(chalk.green('All PDFs match the baseline.')); + } + + Object.entries(pdfGroups).forEach(([pdfName, pages]) => { + const hasMismatch = pages.some(page => page.mismatch_percentage > 0); + const statusColor = hasMismatch ? chalk.yellow : chalk.green; + + console.log(statusColor(`\nšŸ“„ ${pdfName} (${pages.length} pages)`)); + + pages.forEach(page => { + const pageStatusColor = page.mismatch_percentage > 0 ? chalk.yellow : chalk.green; + console.log(pageStatusColor(` - Page ${getPageNumber(page.screenshot_name)}: ${page.status} (Mismatch: ${page.mismatch_percentage}%)`)); + }); + }); + + const formattedResults = { + status: 'success', + data: { + buildId: response.build.id, + buildName: response.build.name, + projectName: response.project.name, + buildStatus: response.build.build_satus, + pdfs: formatPdfsForOutput(pdfGroups) + } + }; + + // Save results to file if filename provided + if (ctx.options.fetchResults && ctx.options.fetchResultsFileName) { + const filename = ctx.options.fetchResultsFileName !== '' ? ctx.options.fetchResultsFileName : 'pdf-results.json'; + + fs.writeFileSync(filename, JSON.stringify(formattedResults, null, 2)); + console.log(chalk.green(`\nResults saved to ${filename}`)); + } + + return; + } + + if (attempts >= maxAttempts) { + clearInterval(interval); + console.log(chalk.red('\nTimeout: Could not fetch PDF results after 5 minutes')); + return; + } + + } catch (error: any) { + 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); +} + +function groupScreenshotsByPdf(screenshots: any[]): Record { + const pdfGroups: Record = {}; + + screenshots.forEach(screenshot => { + // screenshot name format: "pdf-name.pdf#page-number" + const pdfName = screenshot.screenshot_name.split('#')[0]; + + if (!pdfGroups[pdfName]) { + pdfGroups[pdfName] = []; + } + + pdfGroups[pdfName].push(screenshot); + }); + + return pdfGroups; +} + +function countPdfsWithMismatches(pdfGroups: Record): number { + let count = 0; + + Object.values(pdfGroups).forEach(pages => { + if (pages.some(page => page.mismatch_percentage > 0)) { + count++; + } + }); + + return count; +} + +function countPagesWithMismatches(screenshots: any[]): number { + return screenshots.filter(screenshot => screenshot.mismatch_percentage > 0).length; +} + +function formatPdfsForOutput(pdfGroups: Record): any[] { + return Object.entries(pdfGroups).map(([pdfName, pages]) => { + return { + pdfName, + pageCount: pages.length, + pages: pages.map(page => ({ + pageNumber: getPageNumber(page.screenshot_name), + screenshotId: page.captured_image_id, + mismatchPercentage: page.mismatch_percentage, + status: page.status, + screenshotUrl: page.shareable_link + })) + }; + }); +} + +function getPageNumber(screenshotName: string): string { + const parts = screenshotName.split('#'); + return parts.length > 1 ? parts[1] : '1'; +} \ No newline at end of file diff --git a/src/tasks/getGitInfo.ts b/src/tasks/getGitInfo.ts index b382fc22..734fc890 100644 --- a/src/tasks/getGitInfo.ts +++ b/src/tasks/getGitInfo.ts @@ -7,7 +7,7 @@ export default (ctx: Context): ListrTask { - return (!isGitRepo() && !ctx.env.SMARTUI_GIT_INFO_FILEPATH) ? '[SKIPPED] Fetching git repo details; not a git repo' : ''; + return (!isGitRepo(ctx) && !ctx.env.SMARTUI_GIT_INFO_FILEPATH) ? '[SKIPPED] Fetching git repo details; not a git repo' : ''; }, task: async (ctx, task): Promise => { if (ctx.env.CURRENT_BRANCH && ctx.env.CURRENT_BRANCH.trim() === '') { diff --git a/src/tasks/uploadPdfs.ts b/src/tasks/uploadPdfs.ts new file mode 100644 index 00000000..c790b985 --- /dev/null +++ b/src/tasks/uploadPdfs.ts @@ -0,0 +1,64 @@ +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' }); + + await uploadPdfs(ctx, ctx.uploadFilePath); + + task.title = 'PDFs uploaded successfully'; + } catch (error: any) { + ctx.log.debug(error); + task.output = chalk.red(`${error.message}`); + throw new Error('PDF upload failed'); + } + }, + rendererOptions: { persistentOutput: true }, + exitOnError: false + }; +}; + +async function uploadPdfs(ctx: Context, pdfPath: string): Promise { + const formData = new FormData(); + + if (pdfPath.endsWith('.pdf')) { + formData.append('pathToFiles', fs.createReadStream(pdfPath)); + } else { + const files = fs.readdirSync(pdfPath); + const pdfFiles = files.filter(file => file.endsWith('.pdf')); + + pdfFiles.forEach(pdf => { + const filePath = path.join(pdfPath, pdf); + formData.append('pathToFiles', fs.createReadStream(filePath)); + }) + } + + const buildName = ctx.options.buildName; + + if (buildName) { + ctx.build.name = buildName; + } + + try { + const response = await ctx.client.uploadPdf(ctx, formData, buildName); + if (response && response.buildId) { + ctx.build.id = response.buildId; + ctx.log.debug(`PDF upload successful. Build ID: ${ctx.build.id}`); + } + if (response && response.projectId) { + ctx.build.projectId = response.projectId; + } + } catch (error : any) { + throw new Error(error.message); + } +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 96371158..0e34e6c0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -93,6 +93,7 @@ export interface Context { export interface Env { PROJECT_TOKEN: string; SMARTUI_CLIENT_API_URL: string; + SMARTUI_UPLOAD_URL: string; SMARTUI_DO_NOT_USE_CAPTURED_COOKIES: boolean; SMARTUI_GIT_INFO_FILEPATH: string | undefined; HTTP_PROXY: string | undefined; @@ -180,6 +181,7 @@ export interface Build { baseline: boolean; useKafkaFlow: boolean; hasDiscoveryError: boolean; + projectId?: string; } export interface WebConfig {