Skip to content

add pdf support in cli #227

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: stage
Choose a base branch
from
Open
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 @@ -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();

Expand All @@ -21,6 +22,7 @@ program
.addCommand(configWeb)
.addCommand(configStatic)
.addCommand(upload)
.addCommand(uploadPdf)
.addCommand(startServer)
.addCommand(stopServer)
.addCommand(ping)
Expand Down
62 changes: 62 additions & 0 deletions src/commander/uploadPdf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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';
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.`);
return;
}

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);

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

export default command;
74 changes: 74 additions & 0 deletions src/lib/httpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,4 +327,78 @@ export default class httpClient {
params: { buildId }
}, log);
}

async uploadPdf(formData: FormData, buildName: string, log: Logger): Promise<any> {
// 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',
Copy link
Collaborator

Choose a reason for hiding this comment

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

Use Constants for Host and Routes, will be useful for stage and prod testing

Copy link
Author

Choose a reason for hiding this comment

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

ok

method: 'POST',
headers: {
...formData.getHeaders(),
'username': this.username,
'accessKey': this.accessKey
Copy link
Collaborator

Choose a reason for hiding this comment

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

username and accessKey not required here

},
data: formData,
});

log.debug(`http response: ${JSON.stringify({
status: response.status,
headers: response.headers,
body: response.data
})}`);

return response.data;
}

async fetchPdfResults(buildName: string, buildId: string | undefined, log: Logger): Promise<any> {
// Create params object with required parameters
const params: Record<string, string> = {
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',
Copy link
Collaborator

Choose a reason for hiding this comment

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

Use Constants for Host and Routes

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;
}
}
}
181 changes: 181 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,5 +328,186 @@ export async function startPingPolling(ctx: Context): Promise<void> {
}, 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'));
Copy link
Collaborator

Choose a reason for hiding this comment

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

use ctx.log.error() here

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'));
Copy link
Collaborator

Choose a reason for hiding this comment

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

use ctx.log.error() here

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<string, any[]> {
const pdfGroups: Record<string, any[]> = {};

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<string, any[]>): 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<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
}))
};
});
}



Loading