Skip to content

Commit 938fec2

Browse files
Merge pull request #418 from LambdaTest/stage
Custom CSS manipulation Release PR
2 parents 3afab26 + fa4bf44 commit 938fec2

File tree

10 files changed

+422
-17
lines changed

10 files changed

+422
-17
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@lambdatest/smartui-cli",
3-
"version": "4.1.39",
3+
"version": "4.1.40",
44
"description": "A command line interface (CLI) to run SmartUI tests on LambdaTest",
55
"files": [
66
"dist/**/*"
@@ -43,6 +43,7 @@
4343
"json-stringify-safe": "^5.0.1",
4444
"listr2": "^7.0.1",
4545
"node-cache": "^5.1.2",
46+
"postcss": "^8.5.6",
4647
"sharp": "^0.33.4",
4748
"tsup": "^7.2.0",
4849
"uuid": "^11.0.3",

src/commander/exec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { color, Listr, ListrDefaultRendererLogLevels } from 'listr2'
55
import startServer from '../tasks/startServer.js'
66
import authExec from '../tasks/authExec.js'
77
import ctxInit from '../lib/ctx.js'
8+
import commandOptionsInit from '../lib/execCommandOptions.js'
89
import getGitInfo from '../tasks/getGitInfo.js'
910
import createBuildExec from '../tasks/createBuildExec.js'
1011
import exec from '../tasks/exec.js'
@@ -43,6 +44,8 @@ command
4344
ctx.totalSnapshots = 0
4445
ctx.sourceCommand = 'exec'
4546

47+
commandOptionsInit(ctx);
48+
4649
let tasks = new Listr<Context>(
4750
[
4851
authExec(ctx),

src/lib/ctx.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import logger from './logger.js'
66
import getEnv from './env.js'
77
import httpClient from './httpClient.js'
88
import fs from 'fs'
9+
import { resolveCustomCSS } from './utils.js'
910

1011
export default (options: Record<string, string>): Context => {
1112
let env: Env = getEnv();
@@ -52,6 +53,20 @@ export default (options: Record<string, string>): Context => {
5253
if (!validateConfigFn(config)) {
5354
throw new Error(validateConfigFn.errors[0].message);
5455
}
56+
57+
// Resolve customCSS if provided
58+
if ((config as any).customCSS) {
59+
try {
60+
(config as any).customCSS = resolveCustomCSS(
61+
(config as any).customCSS,
62+
options.config,
63+
logger
64+
);
65+
logger.debug('Successfully resolved and validated customCSS from config');
66+
} catch (error: any) {
67+
throw new Error(`customCSS error: ${error.message}`);
68+
}
69+
}
5570
} else {
5671
logger.info("## No config file provided. Using default config.");
5772
}
@@ -80,6 +95,8 @@ export default (options: Record<string, string>): Context => {
8095
if (options.userName && options.accessKey) {
8196
env.LT_USERNAME = options.userName
8297
env.LT_ACCESS_KEY = options.accessKey
98+
process.env.LT_USERNAME = options.userName
99+
process.env.LT_ACCESS_KEY = options.accessKey
83100
}
84101
} catch (error: any) {
85102
console.log(`[smartui] Error: ${error.message}`);
@@ -155,7 +172,8 @@ export default (options: Record<string, string>): Context => {
155172
loadDomContent: loadDomContent,
156173
approvalThreshold: config.approvalThreshold,
157174
rejectionThreshold: config.rejectionThreshold,
158-
showRenderErrors: config.showRenderErrors ?? false
175+
showRenderErrors: config.showRenderErrors ?? false,
176+
customCSS: (config as any).customCSS
159177
},
160178
uploadFilePath: '',
161179
webStaticConfig: [],
@@ -194,7 +212,9 @@ export default (options: Record<string, string>): Context => {
194212
baselineBranch: options.baselineBranch || '',
195213
baselineBuild: options.baselineBuild || '',
196214
githubURL : options.githubURL || '',
197-
showRenderErrors: options.showRenderErrors ? true : false
215+
showRenderErrors: options.showRenderErrors ? true : false,
216+
userName: options.userName || '',
217+
accessKey: options.accessKey || ''
198218
},
199219
cliVersion: version,
200220
totalSnapshots: -1,

src/lib/env.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ export default (): Env => {
2525
SMART_GIT,
2626
SHOW_RENDER_ERRORS,
2727
SMARTUI_SSE_URL='https://server-events.lambdatest.com',
28-
LT_SDK_SKIP_EXECUTION_LOGS
28+
LT_SDK_SKIP_EXECUTION_LOGS,
29+
MAX_CONCURRENT_PROCESSING
2930
} = process.env
3031

3132
return {
@@ -52,6 +53,7 @@ export default (): Env => {
5253
SMART_GIT: SMART_GIT === 'true',
5354
SHOW_RENDER_ERRORS: SHOW_RENDER_ERRORS === 'true',
5455
SMARTUI_SSE_URL,
55-
LT_SDK_SKIP_EXECUTION_LOGS: LT_SDK_SKIP_EXECUTION_LOGS === 'true'
56+
LT_SDK_SKIP_EXECUTION_LOGS: LT_SDK_SKIP_EXECUTION_LOGS === 'true',
57+
MAX_CONCURRENT_PROCESSING: MAX_CONCURRENT_PROCESSING ? parseInt(MAX_CONCURRENT_PROCESSING, 10) : 0,
5658
}
5759
}

src/lib/execCommandOptions.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Context } from '../types.js'
2+
3+
export default (ctx: Context): Context => {
4+
if(ctx.args.execCommand && !ctx.options.userName && !ctx.options.accessKey) {
5+
for(const arg of ctx.args.execCommand) {
6+
if(arg.includes('lambdaTestUserName')) {
7+
ctx.env.LT_USERNAME = arg.split('=')[1];
8+
}
9+
if(arg.includes('lambdaTestAccessKey')) {
10+
ctx.env.LT_ACCESS_KEY = arg.split('=')[1];
11+
}
12+
}
13+
}
14+
return ctx;
15+
}

src/lib/processSnapshot.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Snapshot, Context, DiscoveryErrors } from "../types.js";
2-
import { scrollToBottomAndBackToTop, getRenderViewports, getRenderViewportsForOptions, validateCoordinates } from "./utils.js"
2+
import { scrollToBottomAndBackToTop, getRenderViewports, getRenderViewportsForOptions, validateCoordinates, resolveCustomCSS, parseCSSFile, validateCSSSelectors, generateCSSInjectionReport } from "./utils.js"
33
import { chromium, Locator } from "@playwright/test"
44
import constants from "./constants.js";
55
import { updateLogContext } from '../lib/logger.js'
66
import NodeCache from 'node-cache';
7+
import chalk from "chalk";
78

89
const globalCache = new NodeCache({ stdTTL: 3600, checkperiod: 600 });
910
const MAX_RESOURCE_SIZE = 15 * (1024 ** 2); // 15MB
@@ -168,6 +169,20 @@ export async function prepareSnapshot(snapshot: Snapshot, ctx: Context): Promise
168169
processedOptions.useExtendedViewport = true;
169170
}
170171

172+
try {
173+
if (options?.customCSS) {
174+
const resolvedCSS = resolveCustomCSS(options.customCSS, '', ctx.log);
175+
processedOptions.customCSS = resolvedCSS;
176+
ctx.log.debug('Using per-snapshot customCSS (overriding config)');
177+
} else if (ctx.config.customCSS) {
178+
processedOptions.customCSS = ctx.config.customCSS;
179+
ctx.log.debug('Using config customCSS');
180+
}
181+
} catch (error: any) {
182+
ctx.log.warn(`customCSS warning: ${error.message}`);
183+
chalk.yellow(`[SmartUI] warning: ${error.message}`);
184+
}
185+
171186
processedOptions.allowedAssets = ctx.config.allowedAssets;
172187
processedOptions.selectors = selectors;
173188

@@ -611,6 +626,19 @@ export default async function processSnapshot(snapshot: Snapshot, ctx: Context):
611626
processedOptions.useExtendedViewport = true;
612627
}
613628

629+
try {
630+
if (options?.customCSS) {
631+
const resolvedCSS = resolveCustomCSS(options.customCSS, '', ctx.log);
632+
processedOptions.customCSS = resolvedCSS;
633+
} else if (ctx.config.customCSS) {
634+
processedOptions.customCSS = ctx.config.customCSS;
635+
}
636+
} catch (error: any) {
637+
optionWarnings.add(`${error.message}`);
638+
}
639+
640+
ctx.log.debug(`Processed options: ${JSON.stringify(processedOptions)}`);
641+
614642
// process for every viewport
615643
let navigated: boolean = false;
616644
let previousDeviceType: string | null = null;
@@ -896,6 +924,24 @@ export default async function processSnapshot(snapshot: Snapshot, ctx: Context):
896924
await checkPending();
897925
}
898926

927+
// Validate and report CSS injection after selector processing
928+
if (processedOptions.customCSS) {
929+
try {
930+
const cssRules = parseCSSFile(processedOptions.customCSS);
931+
const validationResult = await validateCSSSelectors(page, cssRules, ctx.log);
932+
const report = generateCSSInjectionReport(validationResult, ctx.log);
933+
934+
if (validationResult.failedSelectors.length > 0) {
935+
validationResult.failedSelectors.forEach(selector => {
936+
optionWarnings.add(`customCSS selector not found: ${selector}`);
937+
});
938+
}
939+
} catch (error: any) {
940+
ctx.log.warn(`CSS validation failed: ${error.message}`);
941+
optionWarnings.add(`CSS validation error: ${error.message}`);
942+
}
943+
}
944+
899945

900946
let hasBrowserErrors = false;
901947
for (let browser in discoveryErrors.browsers) {

src/lib/schemaValidation.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,10 @@ const ConfigSchema = {
284284
type: "boolean",
285285
errorMessage: "Invalid config; loadDomContent must be true/false"
286286
},
287+
customCSS: {
288+
type: "string",
289+
errorMessage: "Invalid config; customCSS must be a string"
290+
},
287291
approvalThreshold: {
288292
type: "number",
289293
minimum: 0,
@@ -375,7 +379,9 @@ const SnapshotSchema: JSONSchemaType<Snapshot> = {
375379
name: {
376380
type: "string",
377381
minLength: 1,
378-
errorMessage: "Invalid snapshot; name is mandatory and cannot be empty"
382+
maxLength:255,
383+
pattern: "^.*\\S.*$",
384+
errorMessage: "Invalid snapshot: name is mandatory, cannot be empty, and must not exceed 255 characters."
379385
},
380386
url: {
381387
type: "string",
@@ -594,6 +600,10 @@ const SnapshotSchema: JSONSchemaType<Snapshot> = {
594600
minProperties: 1,
595601
},
596602
errorMessage: "Invalid snapshot options; customCookies must be an array of objects with string properties"
603+
},
604+
customCSS: {
605+
type: "string",
606+
errorMessage: "Invalid snapshot options; customCSS must be a string"
597607
}
598608
},
599609
additionalProperties: false

src/lib/snapshotQueue.ts

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export default class Queue {
1414
private ctx: Context;
1515
private snapshotNames: Array<string> = [];
1616
private variants: Array<string> = [];
17+
private activeProcessingCount: number = 0;
18+
private readonly MAX_CONCURRENT_PROCESSING = 5;
1719

1820
constructor(ctx: Context) {
1921
this.ctx = ctx;
@@ -275,15 +277,65 @@ export default class Queue {
275277

276278
private async processNext(): Promise<void> {
277279
if (!this.isEmpty()) {
280+
const useRemoteDiscovery = this.ctx.env.USE_REMOTE_DISCOVERY || this.ctx.config.useRemoteDiscovery;
281+
282+
if (useRemoteDiscovery && !this.ctx.config.delayedUpload && !this.ctx.config.allowDuplicateSnapshotNames) {
283+
let maxConcurrentProcessing = this.ctx.env.MAX_CONCURRENT_PROCESSING === 0 ? this.MAX_CONCURRENT_PROCESSING : this.ctx.env.MAX_CONCURRENT_PROCESSING;
284+
if (maxConcurrentProcessing > 15 || maxConcurrentProcessing < 1) {
285+
this.ctx.log.info(`Larger than 15 concurrent processing. Setting to 5.`);
286+
maxConcurrentProcessing = 5;
287+
}
288+
289+
this.ctx.log.info(`Max concurrent processing: ${maxConcurrentProcessing}`);
290+
const snapshotsToProcess: Array<Snapshot> = [];
291+
const maxSnapshots = Math.min(maxConcurrentProcessing - this.activeProcessingCount, this.snapshots.length);
292+
293+
for (let i = 0; i < maxSnapshots; i++) {
294+
let snapshot;
295+
if (this.ctx.config.delayedUpload) {
296+
snapshot = this.snapshots.pop();
297+
} else {
298+
snapshot = this.snapshots.shift();
299+
}
300+
if (snapshot) {
301+
snapshotsToProcess.push(snapshot);
302+
}
303+
}
304+
305+
if (snapshotsToProcess.length > 0) {
306+
this.activeProcessingCount += snapshotsToProcess.length;
307+
const processingPromises = snapshotsToProcess.map(snapshot => this.processSnapshot(snapshot));
308+
await Promise.allSettled(processingPromises);
309+
this.activeProcessingCount -= snapshotsToProcess.length;
310+
311+
if (!this.isEmpty()) {
312+
this.processNext();
313+
} else {
314+
this.processing = false;
315+
}
316+
return;
317+
}
318+
}
319+
278320
let snapshot;
279321
if (this.ctx.config.delayedUpload) {
280322
snapshot = this.snapshots.pop();
281323
} else {
282324
snapshot = this.snapshots.shift();
283325
}
284-
try {
285-
this.processingSnapshot = snapshot?.name;
286-
let drop = false;
326+
if (snapshot) {
327+
await this.processSnapshot(snapshot);
328+
this.processNext();
329+
}
330+
} else {
331+
this.processing = false;
332+
}
333+
}
334+
335+
private async processSnapshot(snapshot: Snapshot): Promise<void> {
336+
try {
337+
this.processingSnapshot = snapshot?.name;
338+
let drop = false;
287339

288340

289341
if (this.ctx.isStartExec) {
@@ -450,7 +502,6 @@ export default class Queue {
450502
if(snapshot?.options?.contextId){
451503
this.ctx.contextToSnapshotMap?.set(snapshot?.options?.contextId,'2');
452504
}
453-
this.processNext();
454505
} else {
455506
let approvalThreshold = snapshot?.options?.approvalThreshold || this.ctx.config.approvalThreshold;
456507
let rejectionThreshold = snapshot?.options?.rejectionThreshold || this.ctx.config.rejectionThreshold;
@@ -487,10 +538,6 @@ export default class Queue {
487538
this.ctx.log.debug(`Closed browser context for snapshot ${snapshot.name}`);
488539
}
489540
}
490-
this.processNext();
491-
} else {
492-
this.processing = false;
493-
}
494541
}
495542

496543
isProcessing(): boolean {

0 commit comments

Comments
 (0)