Skip to content

Commit

Permalink
Merge pull request #167 from LambdaTest/stage
Browse files Browse the repository at this point in the history
Add support for concurrent execution in capture command
  • Loading branch information
parthlambdatest authored Nov 11, 2024
2 parents d7cb77b + 9a443eb commit 27e0bd1
Show file tree
Hide file tree
Showing 11 changed files with 206 additions and 21 deletions.
1 change: 0 additions & 1 deletion README.md

This file was deleted.

68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# SmartUI-CLI

<img height="400" src="https://user-images.githubusercontent.com/126776938/232535511-8d51cf1b-1a33-48fc-825c-b13e7a9ec388.png">

<p align="center">
<a href="https://www.lambdatest.com/blog/?utm_source=github&utm_medium=repo&utm_campaign=playwright-sample" target="_bank">Blog</a>
&nbsp; &#8901; &nbsp;
<a href="https://www.lambdatest.com/support/docs/?utm_source=github&utm_medium=repo&utm_campaign=playwright-sample" target="_bank">Docs</a>
&nbsp; &#8901; &nbsp;
<a href="https://www.lambdatest.com/learning-hub/?utm_source=github&utm_medium=repo&utm_campaign=playwright-sample" target="_bank">Learning Hub</a>
&nbsp; &#8901; &nbsp;
<a href="https://www.lambdatest.com/newsletter/?utm_source=github&utm_medium=repo&utm_campaign=playwright-sample" target="_bank">Newsletter</a>
&nbsp; &#8901; &nbsp;
<a href="https://www.lambdatest.com/certifications/?utm_source=github&utm_medium=repo&utm_campaign=playwright-sample" target="_bank">Certifications</a>
&nbsp; &#8901; &nbsp;
<a href="https://www.youtube.com/c/LambdaTest" target="_bank">YouTube</a>
</p>
&emsp;
&emsp;
&emsp;



[<img height="58" width="200" src="https://user-images.githubusercontent.com/70570645/171866795-52c11b49-0728-4229-b073-4b704209ddde.png">](https://accounts.lambdatest.com/register?utm_source=github&utm_medium=repo&utm_campaign=playwright-sample)


The **SmartUI-CLI** allows you to capture visual snapshots of your web applications, upload images, and run visual regression tests using [LambdaTest's SmartUI](https://www.lambdatest.com/visual-regression-testing) platform directly from the command line.

- [Installation](#installation)
- [Commands](#commands)
- [Documentation](#documentation)
- [Issues](#issues)

## Installation

```sh-session
$ npm install smartui-cli
```

**Note:**
If you face any problems executing tests with SmartUI-CLI `versions >= v4.x.x`, upgrade your Node.js version to `v20.3` or above.

## Commands
- `npx smartui exec` - Capture DOM assets for visual testing across multiple browsers and resolutions.
- `npx smartui capture` - Bulk capture static URLs for visual testing.
- `npx smartui upload` - Upload custom images or screenshots for visual comparison.
- `npx smartui upload-figma` - Upload Figma design images for visual comparison.
- `npx smartui config` - Creates configuration file according to the usecase.

### Documentation

In addition to its core functionalities, the SmartUI CLI leverages LambdaTest's cloud infrastructure for robust, scalable visual regression testing across various browsers and devices.

- [SmartUI Selenium SDK](https://www.lambdatest.com/support/docs/smartui-selenium-java-sdk) - A complete SDK to capture DOM assets for visual tests.
- [LambdaTest Documentation](https://www.lambdatest.com/support/docs/) - Official LambdaTest documentation for SmartUI and other integrations.
- [Bulk capturing static URLs with SmartUI](https://www.lambdatest.com/support/docs/smartui-cli/) - Documentation for capturing satatic urls in bulk with SmartUI
- [Bring your own screenshots](https://www.lambdatest.com/support/docs/smartui-cli-upload/) - Documentation for capturing satatic urls in bulk
- [Figma CLI](https://www.lambdatest.com/support/docs/smartui-cli-figma/) - Documentation for uploading figma components to SmartUI

### Issues

If you encounter problems with SmartUI-CLI, [add an issue on GitHub](https://github.com/LambdaTest/smartui-cli/issues/new).

For other support issues, reach out via [LambdaTest Support](https://www.lambdatest.com/support).

------

[Know more](https://www.lambdatest.com/visual-regression-testing) about SmartUI and it's AI enabled comparison engines.
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
{
"name": "@lambdatest/smartui-cli",
"version": "4.0.8",
"version": "4.0.9",
"description": "A command line interface (CLI) to run SmartUI tests on LambdaTest",
"files": [
"dist/**/*"
],
"scripts": {
"build": "tsup",
"release": "pnpm run build && pnpm publish --access public --no-git-checks"
"release": "pnpm run build && pnpm publish --access public --no-git-checks",
"local-build": "pnpm run build && pnpm pack"
},
"bin": {
"smartui": "./dist/index.cjs"
Expand Down
15 changes: 11 additions & 4 deletions src/commander/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,29 @@ command
.name('capture')
.description('Capture screenshots of static sites')
.argument('<file>', 'Web static config file')
.option('--parallel', 'Capture parallely on all browsers')
.option('-C, --parallel [number]', 'Specify the number of instances per browser', parseInt)
.option('-F, --force', 'forcefully apply the specified parallel instances per browser')
.option('--fetch-results [filename]', 'Fetch results and optionally specify an output file, e.g., <filename>.json')
.action(async function(file, _, command) {
let ctx: Context = ctxInit(command.optsWithGlobals());

if (!fs.existsSync(file)) {
console.log(`Error: Web Static Config file ${file} not found.`);
ctx.log.error(`Web Static Config file ${file} not found.`);
return;
}
try {
ctx.webStaticConfig = JSON.parse(fs.readFileSync(file, 'utf8'));
if (!validateWebStaticConfig(ctx.webStaticConfig)) throw new Error(validateWebStaticConfig.errors[0].message);
if(ctx.webStaticConfig && ctx.webStaticConfig.length === 0) {
ctx.log.error(`No URLs found in the specified config file -> ${file}`);
return;
}
} catch (error: any) {
console.log(`[smartui] Error: Invalid Web Static Config; ${error.message}`);
ctx.log.error(`Invalid Web Static Config; ${error.message}`);
return;
}
//Print Config here in debug mode
ctx.log.debug(ctx.config);

let tasks = new Listr<Context>(
[
Expand Down
3 changes: 3 additions & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ export default {
// Default scrollTime
DEFAULT_SCROLL_TIME: 8,

// Default page load time
DEFAULT_PAGE_LOAD_TIMEOUT: 180000,

// Magic Numbers
MAGIC_NUMBERS: [
{ ext: 'jpg', magic: Buffer.from([0xFF, 0xD8, 0xFF]) },
Expand Down
7 changes: 5 additions & 2 deletions src/lib/ctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default (options: Record<string, string>): Context => {
let extensionFiles: string;
let ignoreStripExtension: Array<string>;
let ignoreFilePattern: Array<string>;
let parallelObj: number;
let fetchResultObj: boolean;
let fetchResultsFileObj: string;
try {
Expand All @@ -44,6 +45,7 @@ export default (options: Record<string, string>): Context => {
ignoreStripExtension = options.removeExtensions || false
ignoreFilePattern = options.ignoreDir || []

parallelObj = options.parallel ? options.parallel === true? 1 : options.parallel: 1;
if (options.fetchResults) {
if (options.fetchResults !== true && !options.fetchResults.endsWith('.json')) {
console.error("Error: The file extension for --fetch-results must be .json");
Expand Down Expand Up @@ -73,7 +75,7 @@ export default (options: Record<string, string>): Context => {
}
if (config.basicAuthorization) {
basicAuthObj = config.basicAuthorization
}
}

return {
env: env,
Expand Down Expand Up @@ -109,7 +111,8 @@ export default (options: Record<string, string>): Context => {
},
args: {},
options: {
parallel: options.parallel ? true : false,
parallel: parallelObj,
force: options.force ? true : false,
markBaseline: options.markBaseline ? true : false,
buildName: options.buildName || '',
port: port,
Expand Down
8 changes: 4 additions & 4 deletions src/lib/httpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,13 @@ export default class httpClient {
log.debug(`${ssName} for ${browserName} ${viewport} uploaded successfully`);
})
.catch(error => {
if (error.response) {
log.error(`Unable to upload screenshot ${JSON.stringify(error)}`)
if (error && error.response && error.response.data && error.response.data.error) {
throw new Error(error.response.data.error.message);
}
if (error.request) {
throw new Error(error.toJSON().message);
if (error) {
throw new Error(JSON.stringify(error));
}
throw new Error(error.message);
})
}

Expand Down
3 changes: 2 additions & 1 deletion src/lib/processSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ const MIN_VIEWPORT_HEIGHT = 1080;
export default async function processSnapshot(snapshot: Snapshot, ctx: Context): Promise<Record<string, any>> {
updateLogContext({ task: 'discovery' });
ctx.log.debug(`Processing snapshot ${snapshot.name} ${snapshot.url}`);
const isHeadless = process.env.HEADLESS?.toLowerCase() === 'false' ? false : true;

let launchOptions: Record<string, any> = {
headless: true,
headless: isHeadless,
args: constants.LAUNCH_ARGS
}
let contextOptions: Record<string, any> = {
Expand Down
96 changes: 95 additions & 1 deletion src/lib/screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ async function captureScreenshotsForConfig(
browserName: string,
renderViewports: Array<Record<string,any>>
): Promise<void> {
let pageOptions = { waitUntil: process.env.SMARTUI_PAGE_WAIT_UNTIL_EVENT || 'load' };
let pageOptions = { waitUntil: process.env.SMARTUI_PAGE_WAIT_UNTIL_EVENT || 'load', timeout: ctx.config.waitForPageRender || constants.DEFAULT_PAGE_LOAD_TIMEOUT };
let ssId = name.toLowerCase().replace(/\s/g, '_');
let context: BrowserContext;
let contextOptions: Record<string, any> = {};
Expand Down Expand Up @@ -250,3 +250,97 @@ export async function uploadScreenshots(ctx: Context): Promise<void> {
ctx.log.info(`${noOfScreenshots} screenshots uploaded successfully.`);
}
}

export async function captureScreenshotsConcurrent(ctx: Context): Promise<Record<string,any>> {
// Clean up directory to store screenshots
utils.delDir('screenshots');

let totalSnapshots = ctx.webStaticConfig && ctx.webStaticConfig.length;
let browserInstances = ctx.options.parallel || 1;
let optimizeBrowserInstances : number = 0
optimizeBrowserInstances = Math.floor(Math.log2(totalSnapshots));
if (optimizeBrowserInstances < 1) {
optimizeBrowserInstances = 1;
}

if (optimizeBrowserInstances > browserInstances) {
optimizeBrowserInstances = browserInstances;
}

// If force flag is set, use the requested browser instances
if (ctx.options.force && browserInstances > 1){
optimizeBrowserInstances = browserInstances;
}

let urlsPerInstance : number = 0;
if (optimizeBrowserInstances == 1) {
urlsPerInstance = totalSnapshots;
} else {
urlsPerInstance = Math.ceil(totalSnapshots / optimizeBrowserInstances);
}
ctx.log.debug(`*** browserInstances requested ${ctx.options.parallel} `);
ctx.log.debug(`*** optimizeBrowserInstances ${optimizeBrowserInstances} `);
ctx.log.debug(`*** urlsPerInstance ${urlsPerInstance}`);
ctx.task.output = `URLs : ${totalSnapshots} || Parallel Browser Instances: ${optimizeBrowserInstances}\n`;
//Divide the URLs into chunks
let staticURLChunks = splitURLs(ctx.webStaticConfig, urlsPerInstance);
let totalCapturedScreenshots: number = 0;
let output: any = '';

const responses = await Promise.all(staticURLChunks.map(async (urlConfig) => {
let { capturedScreenshots, finalOutput} = await processChunk(ctx, urlConfig);
return { capturedScreenshots, finalOutput };
}));

responses.forEach((response: Record<string, any>) => {
totalCapturedScreenshots += response.capturedScreenshots;
output += response.finalOutput;
});

utils.delDir('screenshots');

return { totalCapturedScreenshots, output };
}

function splitURLs(arr : any, chunkSize : number) {
const result = [];
for (let i = 0; i < arr.length; i += chunkSize) {
result.push(arr.slice(i, i + chunkSize));
}
return result;
}

async function processChunk(ctx: Context, urlConfig: Array<Record<string, any>>): Promise<Record<string,any>> {

let browsers: Record<string,Browser> = {};
let capturedScreenshots: number = 0;
let finalOutput: string = '';

try {
browsers = await utils.launchBrowsers(ctx);
} catch (error) {
await utils.closeBrowsers(browsers);
ctx.log.debug(error)
throw new Error(`Failed launching browsers ${error}`);
}

for (let staticConfig of urlConfig) {
try {
await captureScreenshotsAsync(ctx, staticConfig, browsers);

utils.delDir(`screenshots/${staticConfig.name.toLowerCase().replace(/\s/g, '_')}`);
let output = (`${chalk.gray(staticConfig.name)} ${chalk.green('\u{2713}')}\n`);
ctx.task.output = ctx.task.output? ctx.task.output +output : output;
finalOutput += output;
capturedScreenshots++;
} catch (error) {
ctx.log.debug(`screenshot capture failed for ${JSON.stringify(staticConfig)}; error: ${error}`);
let output = `${chalk.gray(staticConfig.name)} ${chalk.red('\u{2717}')}\n`;
ctx.task.output += output;
finalOutput += output;
}
}

await utils.closeBrowsers(browsers);
return { capturedScreenshots, finalOutput };
}
3 changes: 2 additions & 1 deletion src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ export function scrollToBottomAndBackToTop({

export async function launchBrowsers(ctx: Context): Promise<Record<string, Browser>> {
let browsers: Record<string, Browser> = {};
let launchOptions: Record<string, any> = { headless: true };
const isHeadless = process.env.HEADLESS?.toLowerCase() === 'false' ? false : true;
let launchOptions: Record<string, any> = { headless: isHeadless };

if (ctx.config.web) {
for (const browser of ctx.config.web.browsers) {
Expand Down
15 changes: 11 additions & 4 deletions src/tasks/captureScreenshots.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ListrTask, ListrRendererFactory } from 'listr2';
import { Context } from '../types.js'
import { captureScreenshots } from '../lib/screenshot.js'
import { captureScreenshots, captureScreenshotsConcurrent } from '../lib/screenshot.js'
import chalk from 'chalk';
import { updateLogContext } from '../lib/logger.js'
import { startPolling } from '../lib/utils.js';
Expand All @@ -16,9 +16,16 @@ export default (ctx: Context): ListrTask<Context, ListrRendererFactory, ListrRen
}
updateLogContext({task: 'capture'});

let { capturedScreenshots, output } = await captureScreenshots(ctx);
if (capturedScreenshots != ctx.webStaticConfig.length) {
throw new Error(output)
if (ctx.options.parallel) {
let { totalCapturedScreenshots, output } = await captureScreenshotsConcurrent(ctx);
if (totalCapturedScreenshots != ctx.webStaticConfig.length) {
throw new Error(output)
}
} else {
let { capturedScreenshots, output } = await captureScreenshots(ctx);
if (capturedScreenshots != ctx.webStaticConfig.length) {
throw new Error(output)
}
}
task.title = 'Screenshots captured successfully'
} catch (error: any) {
Expand Down
3 changes: 2 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ export interface Context {
execCommand?: Array<string>
}
options: {
parallel?: boolean,
parallel?: number,
force?: boolean,
markBaseline?: boolean,
buildName?: string,
port?: number,
Expand Down

0 comments on commit 27e0bd1

Please sign in to comment.