Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/commander/commander.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -38,6 +39,7 @@ program
.addCommand(uploadWebFigmaCommand)
.addCommand(uploadAppFigmaCommand)
.addCommand(pingTest)
.addCommand(uploadPdf)



Expand Down
61 changes: 61 additions & 0 deletions src/commander/uploadPdf.ts
Original file line number Diff line number Diff line change
@@ -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('<directory>', 'Path of the directory containing PDFs')
.option('--fetch-results [filename]', 'Fetch results and optionally specify an output file, e.g., <filename>.json')
.option('--buildName <string>', '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.`);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add process.exit(1);

process.exit(1);
}

ctx.uploadFilePath = directory;

let tasks = new Listr<Context>(
[
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/');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add process.exit(1);

process.exit(1);
}
});

export default command;
2 changes: 2 additions & 0 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
78 changes: 78 additions & 0 deletions src/lib/httpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '';
Expand Down Expand Up @@ -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);
}
}
);
Expand Down Expand Up @@ -644,4 +658,68 @@ export default class httpClient {
}
}, ctx.log);
}

async uploadPdf(ctx: Context, form: FormData, buildName?: string): Promise<any> {
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<any> {
const params: Record<string, string> = {
projectToken: this.projectToken
};

// Use buildId if available, otherwise use buildName
if (ctx.build.id) {
params.buildId = ctx.build.id;
} else if (ctx.build.name) {
params.buildName = ctx.build.name;
}

const auth = Buffer.from(`${this.username}:${this.accessKey}`).toString('base64');

try {
const response = await axios.request({
url: ctx.env.SMARTUI_UPLOAD_URL + '/automation/smart-ui/screenshot/build/status',
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);
}
}
}

166 changes: 165 additions & 1 deletion src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,4 +509,168 @@ export function calculateVariantCountFromSnapshot(snapshot: any, globalConfig?:
}

return variantCount;
}
}

export function startPdfPolling(ctx: Context) {
console.log(chalk.yellow('\nFetching PDF test results...'));

ctx.log.debug(`Starting fetching results for build: ${ctx.build.id || ctx.build.name}`);
if (!ctx.build.id && !ctx.build.name) {
ctx.log.error(chalk.red('Error: Build information not found for fetching results'));
return
}

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(ctx, ctx.log);

if (response.status === 'success' && response.data && response.data.Screenshots) {
clearInterval(interval);

const pdfGroups = groupScreenshotsByPdf(response.data.Screenshots);

const pdfsWithMismatches = countPdfsWithMismatches(pdfGroups);
const pagesWithMismatches = countPagesWithMismatches(response.data.Screenshots);

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.'));
}

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}%)`));
});
});

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
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;
} else if (response.status === 'error') {
clearInterval(interval);
console.log(chalk.red(`\nError fetching results: ${response.message || 'Unknown error'}`));
return;
} else {
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) {
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<string, any[]> {
const pdfGroups: Record<string, any[]> = {};

screenshots.forEach(screenshot => {
// screenshot name format: "pdf-name.pdf#page-number"
const pdfName = screenshot.screenshotName.split('#')[0];

if (!pdfGroups[pdfName]) {
pdfGroups[pdfName] = [];
}

pdfGroups[pdfName].push(screenshot);
});

return pdfGroups;
}

function countPdfsWithMismatches(pdfGroups: Record<string, any[]>): number {
let count = 0;

Object.values(pdfGroups).forEach(pages => {
if (pages.some(page => page.mismatchPercentage > 0)) {
count++;
}
});

return count;
}

function countPagesWithMismatches(screenshots: any[]): number {
return screenshots.filter(screenshot => screenshot.mismatchPercentage > 0).length;
}

function formatPdfsForOutput(pdfGroups: Record<string, any[]>): 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
}))
};
});
}

function getPageNumber(screenshotName: string): string {
const parts = screenshotName.split('#');
return parts.length > 1 ? parts[1] : '1';
}
Loading