Skip to content

Commit 27e0bd1

Browse files
Merge pull request #167 from LambdaTest/stage
Add support for concurrent execution in capture command
2 parents d7cb77b + 9a443eb commit 27e0bd1

11 files changed

+206
-21
lines changed

README.md

-1
This file was deleted.

README.md

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# SmartUI-CLI
2+
3+
<img height="400" src="https://user-images.githubusercontent.com/126776938/232535511-8d51cf1b-1a33-48fc-825c-b13e7a9ec388.png">
4+
5+
<p align="center">
6+
<a href="https://www.lambdatest.com/blog/?utm_source=github&utm_medium=repo&utm_campaign=playwright-sample" target="_bank">Blog</a>
7+
&nbsp; &#8901; &nbsp;
8+
<a href="https://www.lambdatest.com/support/docs/?utm_source=github&utm_medium=repo&utm_campaign=playwright-sample" target="_bank">Docs</a>
9+
&nbsp; &#8901; &nbsp;
10+
<a href="https://www.lambdatest.com/learning-hub/?utm_source=github&utm_medium=repo&utm_campaign=playwright-sample" target="_bank">Learning Hub</a>
11+
&nbsp; &#8901; &nbsp;
12+
<a href="https://www.lambdatest.com/newsletter/?utm_source=github&utm_medium=repo&utm_campaign=playwright-sample" target="_bank">Newsletter</a>
13+
&nbsp; &#8901; &nbsp;
14+
<a href="https://www.lambdatest.com/certifications/?utm_source=github&utm_medium=repo&utm_campaign=playwright-sample" target="_bank">Certifications</a>
15+
&nbsp; &#8901; &nbsp;
16+
<a href="https://www.youtube.com/c/LambdaTest" target="_bank">YouTube</a>
17+
</p>
18+
&emsp;
19+
&emsp;
20+
&emsp;
21+
22+
23+
24+
[<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)
25+
26+
27+
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.
28+
29+
- [Installation](#installation)
30+
- [Commands](#commands)
31+
- [Documentation](#documentation)
32+
- [Issues](#issues)
33+
34+
## Installation
35+
36+
```sh-session
37+
$ npm install smartui-cli
38+
```
39+
40+
**Note:**
41+
If you face any problems executing tests with SmartUI-CLI `versions >= v4.x.x`, upgrade your Node.js version to `v20.3` or above.
42+
43+
## Commands
44+
- `npx smartui exec` - Capture DOM assets for visual testing across multiple browsers and resolutions.
45+
- `npx smartui capture` - Bulk capture static URLs for visual testing.
46+
- `npx smartui upload` - Upload custom images or screenshots for visual comparison.
47+
- `npx smartui upload-figma` - Upload Figma design images for visual comparison.
48+
- `npx smartui config` - Creates configuration file according to the usecase.
49+
50+
### Documentation
51+
52+
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.
53+
54+
- [SmartUI Selenium SDK](https://www.lambdatest.com/support/docs/smartui-selenium-java-sdk) - A complete SDK to capture DOM assets for visual tests.
55+
- [LambdaTest Documentation](https://www.lambdatest.com/support/docs/) - Official LambdaTest documentation for SmartUI and other integrations.
56+
- [Bulk capturing static URLs with SmartUI](https://www.lambdatest.com/support/docs/smartui-cli/) - Documentation for capturing satatic urls in bulk with SmartUI
57+
- [Bring your own screenshots](https://www.lambdatest.com/support/docs/smartui-cli-upload/) - Documentation for capturing satatic urls in bulk
58+
- [Figma CLI](https://www.lambdatest.com/support/docs/smartui-cli-figma/) - Documentation for uploading figma components to SmartUI
59+
60+
### Issues
61+
62+
If you encounter problems with SmartUI-CLI, [add an issue on GitHub](https://github.com/LambdaTest/smartui-cli/issues/new).
63+
64+
For other support issues, reach out via [LambdaTest Support](https://www.lambdatest.com/support).
65+
66+
------
67+
68+
[Know more](https://www.lambdatest.com/visual-regression-testing) about SmartUI and it's AI enabled comparison engines.

package.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
{
22
"name": "@lambdatest/smartui-cli",
3-
"version": "4.0.8",
3+
"version": "4.0.9",
44
"description": "A command line interface (CLI) to run SmartUI tests on LambdaTest",
55
"files": [
66
"dist/**/*"
77
],
88
"scripts": {
99
"build": "tsup",
10-
"release": "pnpm run build && pnpm publish --access public --no-git-checks"
10+
"release": "pnpm run build && pnpm publish --access public --no-git-checks",
11+
"local-build": "pnpm run build && pnpm pack"
1112
},
1213
"bin": {
1314
"smartui": "./dist/index.cjs"

src/commander/capture.ts

+11-4
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,29 @@ command
1616
.name('capture')
1717
.description('Capture screenshots of static sites')
1818
.argument('<file>', 'Web static config file')
19-
.option('--parallel', 'Capture parallely on all browsers')
19+
.option('-C, --parallel [number]', 'Specify the number of instances per browser', parseInt)
20+
.option('-F, --force', 'forcefully apply the specified parallel instances per browser')
2021
.option('--fetch-results [filename]', 'Fetch results and optionally specify an output file, e.g., <filename>.json')
2122
.action(async function(file, _, command) {
2223
let ctx: Context = ctxInit(command.optsWithGlobals());
23-
24+
2425
if (!fs.existsSync(file)) {
25-
console.log(`Error: Web Static Config file ${file} not found.`);
26+
ctx.log.error(`Web Static Config file ${file} not found.`);
2627
return;
2728
}
2829
try {
2930
ctx.webStaticConfig = JSON.parse(fs.readFileSync(file, 'utf8'));
3031
if (!validateWebStaticConfig(ctx.webStaticConfig)) throw new Error(validateWebStaticConfig.errors[0].message);
32+
if(ctx.webStaticConfig && ctx.webStaticConfig.length === 0) {
33+
ctx.log.error(`No URLs found in the specified config file -> ${file}`);
34+
return;
35+
}
3136
} catch (error: any) {
32-
console.log(`[smartui] Error: Invalid Web Static Config; ${error.message}`);
37+
ctx.log.error(`Invalid Web Static Config; ${error.message}`);
3338
return;
3439
}
40+
//Print Config here in debug mode
41+
ctx.log.debug(ctx.config);
3542

3643
let tasks = new Listr<Context>(
3744
[

src/lib/constants.ts

+3
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ export default {
139139
// Default scrollTime
140140
DEFAULT_SCROLL_TIME: 8,
141141

142+
// Default page load time
143+
DEFAULT_PAGE_LOAD_TIMEOUT: 180000,
144+
142145
// Magic Numbers
143146
MAGIC_NUMBERS: [
144147
{ ext: 'jpg', magic: Buffer.from([0xFF, 0xD8, 0xFF]) },

src/lib/ctx.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export default (options: Record<string, string>): Context => {
1818
let extensionFiles: string;
1919
let ignoreStripExtension: Array<string>;
2020
let ignoreFilePattern: Array<string>;
21+
let parallelObj: number;
2122
let fetchResultObj: boolean;
2223
let fetchResultsFileObj: string;
2324
try {
@@ -44,6 +45,7 @@ export default (options: Record<string, string>): Context => {
4445
ignoreStripExtension = options.removeExtensions || false
4546
ignoreFilePattern = options.ignoreDir || []
4647

48+
parallelObj = options.parallel ? options.parallel === true? 1 : options.parallel: 1;
4749
if (options.fetchResults) {
4850
if (options.fetchResults !== true && !options.fetchResults.endsWith('.json')) {
4951
console.error("Error: The file extension for --fetch-results must be .json");
@@ -73,7 +75,7 @@ export default (options: Record<string, string>): Context => {
7375
}
7476
if (config.basicAuthorization) {
7577
basicAuthObj = config.basicAuthorization
76-
}
78+
}
7779

7880
return {
7981
env: env,
@@ -109,7 +111,8 @@ export default (options: Record<string, string>): Context => {
109111
},
110112
args: {},
111113
options: {
112-
parallel: options.parallel ? true : false,
114+
parallel: parallelObj,
115+
force: options.force ? true : false,
113116
markBaseline: options.markBaseline ? true : false,
114117
buildName: options.buildName || '',
115118
port: port,

src/lib/httpClient.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -123,13 +123,13 @@ export default class httpClient {
123123
log.debug(`${ssName} for ${browserName} ${viewport} uploaded successfully`);
124124
})
125125
.catch(error => {
126-
if (error.response) {
126+
log.error(`Unable to upload screenshot ${JSON.stringify(error)}`)
127+
if (error && error.response && error.response.data && error.response.data.error) {
127128
throw new Error(error.response.data.error.message);
128129
}
129-
if (error.request) {
130-
throw new Error(error.toJSON().message);
130+
if (error) {
131+
throw new Error(JSON.stringify(error));
131132
}
132-
throw new Error(error.message);
133133
})
134134
}
135135

src/lib/processSnapshot.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ const MIN_VIEWPORT_HEIGHT = 1080;
1313
export default async function processSnapshot(snapshot: Snapshot, ctx: Context): Promise<Record<string, any>> {
1414
updateLogContext({ task: 'discovery' });
1515
ctx.log.debug(`Processing snapshot ${snapshot.name} ${snapshot.url}`);
16+
const isHeadless = process.env.HEADLESS?.toLowerCase() === 'false' ? false : true;
1617

1718
let launchOptions: Record<string, any> = {
18-
headless: true,
19+
headless: isHeadless,
1920
args: constants.LAUNCH_ARGS
2021
}
2122
let contextOptions: Record<string, any> = {

src/lib/screenshot.ts

+95-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ async function captureScreenshotsForConfig(
1414
browserName: string,
1515
renderViewports: Array<Record<string,any>>
1616
): Promise<void> {
17-
let pageOptions = { waitUntil: process.env.SMARTUI_PAGE_WAIT_UNTIL_EVENT || 'load' };
17+
let pageOptions = { waitUntil: process.env.SMARTUI_PAGE_WAIT_UNTIL_EVENT || 'load', timeout: ctx.config.waitForPageRender || constants.DEFAULT_PAGE_LOAD_TIMEOUT };
1818
let ssId = name.toLowerCase().replace(/\s/g, '_');
1919
let context: BrowserContext;
2020
let contextOptions: Record<string, any> = {};
@@ -250,3 +250,97 @@ export async function uploadScreenshots(ctx: Context): Promise<void> {
250250
ctx.log.info(`${noOfScreenshots} screenshots uploaded successfully.`);
251251
}
252252
}
253+
254+
export async function captureScreenshotsConcurrent(ctx: Context): Promise<Record<string,any>> {
255+
// Clean up directory to store screenshots
256+
utils.delDir('screenshots');
257+
258+
let totalSnapshots = ctx.webStaticConfig && ctx.webStaticConfig.length;
259+
let browserInstances = ctx.options.parallel || 1;
260+
let optimizeBrowserInstances : number = 0
261+
optimizeBrowserInstances = Math.floor(Math.log2(totalSnapshots));
262+
if (optimizeBrowserInstances < 1) {
263+
optimizeBrowserInstances = 1;
264+
}
265+
266+
if (optimizeBrowserInstances > browserInstances) {
267+
optimizeBrowserInstances = browserInstances;
268+
}
269+
270+
// If force flag is set, use the requested browser instances
271+
if (ctx.options.force && browserInstances > 1){
272+
optimizeBrowserInstances = browserInstances;
273+
}
274+
275+
let urlsPerInstance : number = 0;
276+
if (optimizeBrowserInstances == 1) {
277+
urlsPerInstance = totalSnapshots;
278+
} else {
279+
urlsPerInstance = Math.ceil(totalSnapshots / optimizeBrowserInstances);
280+
}
281+
ctx.log.debug(`*** browserInstances requested ${ctx.options.parallel} `);
282+
ctx.log.debug(`*** optimizeBrowserInstances ${optimizeBrowserInstances} `);
283+
ctx.log.debug(`*** urlsPerInstance ${urlsPerInstance}`);
284+
ctx.task.output = `URLs : ${totalSnapshots} || Parallel Browser Instances: ${optimizeBrowserInstances}\n`;
285+
//Divide the URLs into chunks
286+
let staticURLChunks = splitURLs(ctx.webStaticConfig, urlsPerInstance);
287+
let totalCapturedScreenshots: number = 0;
288+
let output: any = '';
289+
290+
const responses = await Promise.all(staticURLChunks.map(async (urlConfig) => {
291+
let { capturedScreenshots, finalOutput} = await processChunk(ctx, urlConfig);
292+
return { capturedScreenshots, finalOutput };
293+
}));
294+
295+
responses.forEach((response: Record<string, any>) => {
296+
totalCapturedScreenshots += response.capturedScreenshots;
297+
output += response.finalOutput;
298+
});
299+
300+
utils.delDir('screenshots');
301+
302+
return { totalCapturedScreenshots, output };
303+
}
304+
305+
function splitURLs(arr : any, chunkSize : number) {
306+
const result = [];
307+
for (let i = 0; i < arr.length; i += chunkSize) {
308+
result.push(arr.slice(i, i + chunkSize));
309+
}
310+
return result;
311+
}
312+
313+
async function processChunk(ctx: Context, urlConfig: Array<Record<string, any>>): Promise<Record<string,any>> {
314+
315+
let browsers: Record<string,Browser> = {};
316+
let capturedScreenshots: number = 0;
317+
let finalOutput: string = '';
318+
319+
try {
320+
browsers = await utils.launchBrowsers(ctx);
321+
} catch (error) {
322+
await utils.closeBrowsers(browsers);
323+
ctx.log.debug(error)
324+
throw new Error(`Failed launching browsers ${error}`);
325+
}
326+
327+
for (let staticConfig of urlConfig) {
328+
try {
329+
await captureScreenshotsAsync(ctx, staticConfig, browsers);
330+
331+
utils.delDir(`screenshots/${staticConfig.name.toLowerCase().replace(/\s/g, '_')}`);
332+
let output = (`${chalk.gray(staticConfig.name)} ${chalk.green('\u{2713}')}\n`);
333+
ctx.task.output = ctx.task.output? ctx.task.output +output : output;
334+
finalOutput += output;
335+
capturedScreenshots++;
336+
} catch (error) {
337+
ctx.log.debug(`screenshot capture failed for ${JSON.stringify(staticConfig)}; error: ${error}`);
338+
let output = `${chalk.gray(staticConfig.name)} ${chalk.red('\u{2717}')}\n`;
339+
ctx.task.output += output;
340+
finalOutput += output;
341+
}
342+
}
343+
344+
await utils.closeBrowsers(browsers);
345+
return { capturedScreenshots, finalOutput };
346+
}

src/lib/utils.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ export function scrollToBottomAndBackToTop({
4545

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

5051
if (ctx.config.web) {
5152
for (const browser of ctx.config.web.browsers) {

src/tasks/captureScreenshots.ts

+11-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ListrTask, ListrRendererFactory } from 'listr2';
22
import { Context } from '../types.js'
3-
import { captureScreenshots } from '../lib/screenshot.js'
3+
import { captureScreenshots, captureScreenshotsConcurrent } from '../lib/screenshot.js'
44
import chalk from 'chalk';
55
import { updateLogContext } from '../lib/logger.js'
66
import { startPolling } from '../lib/utils.js';
@@ -16,9 +16,16 @@ export default (ctx: Context): ListrTask<Context, ListrRendererFactory, ListrRen
1616
}
1717
updateLogContext({task: 'capture'});
1818

19-
let { capturedScreenshots, output } = await captureScreenshots(ctx);
20-
if (capturedScreenshots != ctx.webStaticConfig.length) {
21-
throw new Error(output)
19+
if (ctx.options.parallel) {
20+
let { totalCapturedScreenshots, output } = await captureScreenshotsConcurrent(ctx);
21+
if (totalCapturedScreenshots != ctx.webStaticConfig.length) {
22+
throw new Error(output)
23+
}
24+
} else {
25+
let { capturedScreenshots, output } = await captureScreenshots(ctx);
26+
if (capturedScreenshots != ctx.webStaticConfig.length) {
27+
throw new Error(output)
28+
}
2229
}
2330
task.title = 'Screenshots captured successfully'
2431
} catch (error: any) {

src/types.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ export interface Context {
3535
execCommand?: Array<string>
3636
}
3737
options: {
38-
parallel?: boolean,
38+
parallel?: number,
39+
force?: boolean,
3940
markBaseline?: boolean,
4041
buildName?: string,
4142
port?: number,

0 commit comments

Comments
 (0)