Skip to content

Commit f425d4a

Browse files
authored
Merge pull request #377 from LambdaTest/stage
Release PR: `4.1.34` update find selector logic, dynamically handle port in CLI, add useremotediscovery in cli config
2 parents 3153208 + 9ea1aee commit f425d4a

File tree

9 files changed

+172
-61
lines changed

9 files changed

+172
-61
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.33",
3+
"version": "4.1.34",
44
"description": "A command line interface (CLI) to run SmartUI tests on LambdaTest",
55
"files": [
66
"dist/**/*"
@@ -53,6 +53,7 @@
5353
"simple-swizzle": "0.2.2"
5454
},
5555
"devDependencies": {
56+
"find-free-port": "^2.0.0",
5657
"typescript": "^5.3.2"
5758
}
5859
}

src/lib/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export default {
4646
EDGE: 'edge',
4747
EDGE_CHANNEL: 'msedge',
4848
WEBKIT: 'webkit',
49+
MIN_PORT_RANGE: 49100,
50+
MAX_PORT_RANGE: 60000,
4951

5052
// discovery browser launch arguments
5153
LAUNCH_ARGS: [

src/lib/ctx.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export default (options: Record<string, string>): Context => {
2525
let buildNameObj: string;
2626
let allowDuplicateSnapshotNames: boolean = false;
2727
let useLambdaInternal: boolean = false;
28+
let useRemoteDiscovery: boolean = false;
2829
let useExtendedViewport: boolean = false;
2930
let loadDomContent: boolean = false;
3031
try {
@@ -108,6 +109,9 @@ export default (options: Record<string, string>): Context => {
108109
if (config.useLambdaInternal) {
109110
useLambdaInternal = true;
110111
}
112+
if (config.useRemoteDiscovery) {
113+
useRemoteDiscovery = true;
114+
}
111115
if (config.useExtendedViewport) {
112116
useExtendedViewport = true;
113117
}
@@ -146,6 +150,7 @@ export default (options: Record<string, string>): Context => {
146150
requestHeaders: config.requestHeaders || {},
147151
allowDuplicateSnapshotNames: allowDuplicateSnapshotNames,
148152
useLambdaInternal: useLambdaInternal,
153+
useRemoteDiscovery: useRemoteDiscovery,
149154
useExtendedViewport: useExtendedViewport,
150155
loadDomContent: loadDomContent,
151156
approvalThreshold: config.approvalThreshold,
@@ -195,6 +200,7 @@ export default (options: Record<string, string>): Context => {
195200
isSnapshotCaptured: false,
196201
sessionCapabilitiesMap: new Map<string, any[]>(),
197202
buildToSnapshotCountMap: new Map<string, number>(),
203+
sessionIdToSnapshotNameMap: new Map<string, string[]>(),
198204
fetchResultsForBuild: new Array<string>,
199205
orgId: 0,
200206
userId: 0,

src/lib/processSnapshot.ts

Lines changed: 87 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -501,19 +501,19 @@ export default async function processSnapshot(snapshot: Snapshot, ctx: Context):
501501
for (const [key, value] of Object.entries(options[ignoreOrSelectDOM])) {
502502
switch (key) {
503503
case 'id':
504-
selectors.push(...value.map(e => '#' + e));
504+
selectors.push(...value.map(e => e.startsWith('#') ? e : '#' + e));
505505
break;
506506
case 'class':
507-
selectors.push(...value.map(e => '.' + e));
507+
selectors.push(...value.map(e => e.startsWith('.') ? e : '.' + e));
508508
break;
509509
case 'xpath':
510-
selectors.push(...value.map(e => 'xpath=' + e));
510+
selectors.push(...value.map(e => e.startsWith('xpath=') ? e : 'xpath=' + e));
511511
break;
512512
case 'cssSelector':
513513
selectors.push(...value);
514514
break;
515515
case 'coordinates':
516-
selectors.push(...value.map(e => `coordinates=${e}`));
516+
selectors.push(...value.map(e =>`coordinates=${e}`));
517517
break;
518518
}
519519
}
@@ -648,14 +648,7 @@ export default async function processSnapshot(snapshot: Snapshot, ctx: Context):
648648
}
649649

650650
// snapshot options
651-
if (processedOptions.element) {
652-
let l = await page.locator(processedOptions.element).all()
653-
if (l.length === 0) {
654-
throw new Error(`for snapshot ${snapshot.name} viewport ${viewportString}, no element found for selector ${processedOptions.element}`);
655-
} else if (l.length > 1) {
656-
throw new Error(`for snapshot ${snapshot.name} viewport ${viewportString}, multiple elements found for selector ${processedOptions.element}`);
657-
}
658-
} else if (selectors.length) {
651+
if (selectors.length) {
659652
let height = 0;
660653
height = await page.evaluate(() => {
661654
const DEFAULT_HEIGHT = 16384;
@@ -707,52 +700,96 @@ export default async function processSnapshot(snapshot: Snapshot, ctx: Context):
707700
optionWarnings.add(`for snapshot ${snapshot.name} viewport ${viewportString}, coordinates may not be accurate for multiple viewports`);
708701
}
709702

710-
703+
711704
const coordinateElement = {
712705
type: 'coordinates',
713706
...validation.coords
714707
};
715708
locators.push(coordinateElement as any);
716709
continue;
717-
}
718-
719-
let l = await page.locator(selector).all()
720-
if (l.length === 0) {
721-
optionWarnings.add(`for snapshot ${snapshot.name} viewport ${viewportString}, no element found for selector ${selector}`);
722-
continue;
723-
}
724-
locators.push(...l);
725-
}
726710

727-
for (const locator of locators) {
728-
if (locator && typeof locator === 'object' && locator.hasOwnProperty('type') && (locator as any).type === 'coordinates') {
729-
const coordLocator = locator as any;
730-
const { top, bottom, left, right } = coordLocator;
731-
processedOptions[ignoreOrSelectBoxes][viewportString].push({
732-
left: left,
733-
top: top,
734-
right: right,
735-
bottom: bottom
736-
});
737-
continue;
738-
}
739-
740-
let bb = await locator.boundingBox();
741-
if (bb) {
742-
// Calculate top and bottom from the bounding box properties
743-
const top = bb.y;
744-
const bottom = bb.y + bb.height;
745-
746-
// Only push if top and bottom are within the calculated height
747-
if (top <= height && bottom <= height) {
748-
processedOptions[ignoreOrSelectBoxes][viewportString].push({
749-
left: bb.x,
750-
top: top,
751-
right: bb.x + bb.width,
752-
bottom: bottom
753-
});
711+
} else {
712+
const isXPath = selector.startsWith('xpath=');
713+
const selectorValue = isXPath ? selector.substring(6) : selector;
714+
715+
const boxes = await page.evaluate(({ selectorValue, isXPath }) => {
716+
try {
717+
// First, determine the page height
718+
const DEFAULT_HEIGHT = 16384;
719+
const DEFAULT_WIDTH = 7680;
720+
const body = document.body;
721+
const html = document.documentElement;
722+
723+
let pageHeight;
724+
let pageWidth;
725+
726+
if (!body || !html) {
727+
pageHeight = DEFAULT_HEIGHT;
728+
pageWidth = DEFAULT_WIDTH;
729+
} else {
730+
const measurements = [
731+
body?.scrollHeight || 0,
732+
body?.offsetHeight || 0,
733+
html?.clientHeight || 0,
734+
html?.scrollHeight || 0,
735+
html?.offsetHeight || 0
736+
];
737+
738+
const allMeasurementsInvalid = measurements.every(measurement => !measurement);
739+
740+
if (allMeasurementsInvalid) {
741+
pageHeight = DEFAULT_HEIGHT;
742+
} else {
743+
pageHeight = Math.max(...measurements);
744+
}
745+
746+
const measurementsWidth = [
747+
body?.scrollWidth || 0,
748+
body?.offsetWidth || 0,
749+
html?.clientWidth || 0,
750+
html?.scrollWidth || 0,
751+
html?.offsetWidth || 0
752+
];
753+
754+
const allMeasurementsInvalidWidth = measurementsWidth.every(measurement => !measurement);
755+
756+
if (allMeasurementsInvalidWidth) {
757+
pageWidth = DEFAULT_WIDTH;
758+
} else {
759+
pageWidth = Math.max(...measurementsWidth);
760+
}
761+
}
762+
763+
let elements = [];
764+
765+
if (isXPath) {
766+
// Use XPath evaluation
767+
const xpathResult = document.evaluate(
768+
selectorValue,
769+
document,
770+
null,
771+
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
772+
null
773+
);
774+
775+
for (let i = 0; i < xpathResult.snapshotLength; i++) {
776+
elements.push(xpathResult.snapshotItem(i));
777+
}
778+
} else {
779+
elements = Array.from(document.querySelectorAll(selectorValue));
780+
}
781+
782+
return elements;
783+
784+
} catch (error) {
785+
}
786+
787+
}, { selectorValue, isXPath });
788+
789+
if (boxes && boxes.length >= 1) {
790+
processedOptions[ignoreOrSelectBoxes][viewportString].push(...boxes);
754791
} else {
755-
ctx.log.debug(`Bounding box for selector skipped due to exceeding height: ${JSON.stringify({ top, bottom, height })}`);
792+
optionWarnings.add(`for snapshot ${snapshot.name} viewport ${viewportString}, no element found for selector ${selector}`);
756793
}
757794
}
758795
}

src/lib/schemaValidation.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,10 @@ const ConfigSchema = {
272272
type: "boolean",
273273
errorMessage: "Invalid config; useLambdaInternal must be true/false"
274274
},
275+
useRemoteDiscovery: {
276+
type: "boolean",
277+
errorMessage: "Invalid config; useRemoteDiscovery must be true/false"
278+
},
275279
useExtendedViewport: {
276280
type: "boolean",
277281
errorMessage: "Invalid config; useExtendedViewport must be true/false"

src/lib/server.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,43 @@ import path from 'path';
33
import fastify, { FastifyInstance, RouteShorthandOptions } from 'fastify';
44
import { readFileSync, truncate } from 'fs'
55
import { Context } from '../types.js'
6+
import { Logger } from 'winston'
67
import { validateSnapshot } from './schemaValidation.js'
78
import { pingIntervalId, startPollingForTunnel, stopTunnelHelper, isTunnelPolling } from './utils.js';
9+
import constants from './constants.js';
10+
var fp = require("find-free-port")
811

912
const uploadDomToS3ViaEnv = process.env.USE_LAMBDA_INTERNAL || false;
13+
14+
// Helper function to find an available port
15+
async function findAvailablePort(server: FastifyInstance, startPort: number, log: Logger): Promise<number> {
16+
let currentPort = startPort;
17+
18+
// If the default port gives error, use find-free-port with range 49100-60000
19+
try {
20+
await server.listen({ port: currentPort });
21+
return currentPort;
22+
} catch (error: any) {
23+
if (error.code === 'EADDRINUSE') {
24+
log.debug(`Port ${currentPort} is in use, finding available port in range 49100-60000`);
25+
26+
// Use find-free-port to get an available port in the specified range
27+
const availablePorts = await fp(constants.MIN_PORT_RANGE, constants.MAX_PORT_RANGE);
28+
if (availablePorts.length > 0) {
29+
const freePort = availablePorts[0];
30+
await server.listen({ port: freePort });
31+
log.debug(`Found and started server on port ${freePort}`);
32+
return freePort;
33+
} else {
34+
throw new Error('No available ports found in range 49100-60000');
35+
}
36+
} else {
37+
// If it's not a port conflict error, rethrow it
38+
throw error;
39+
}
40+
}
41+
}
42+
1043
export default async (ctx: Context): Promise<FastifyInstance<Server, IncomingMessage, ServerResponse>> => {
1144

1245
const server: FastifyInstance<Server, IncomingMessage, ServerResponse> = fastify({
@@ -307,12 +340,21 @@ export default async (ctx: Context): Promise<FastifyInstance<Server, IncomingMes
307340
}
308341
});
309342

310-
311-
await server.listen({ port: ctx.options.port });
312-
// store server's address for SDK
313-
let { port } = server.addresses()[0];
314-
process.env.SMARTUI_SERVER_ADDRESS = `http://localhost:${port}`;
315-
process.env.CYPRESS_SMARTUI_SERVER_ADDRESS = `http://localhost:${port}`;
343+
// Use the helper function to find and start server on available port
344+
if (ctx.sourceCommand && ctx.sourceCommand === 'exec-start') {
345+
346+
await server.listen({ port: ctx.options.port });
347+
let { port } = server.addresses()[0];
348+
process.env.SMARTUI_SERVER_ADDRESS = `http://localhost:${port}`;
349+
process.env.CYPRESS_SMARTUI_SERVER_ADDRESS = `http://localhost:${port}`;
350+
ctx.log.debug(`Server started successfully on port ${port}`);
351+
352+
} else {
353+
const actualPort = await findAvailablePort(server, ctx.options.port, ctx.log);
354+
process.env.SMARTUI_SERVER_ADDRESS = `http://localhost:${actualPort}`;
355+
process.env.CYPRESS_SMARTUI_SERVER_ADDRESS = `http://localhost:${actualPort}`;
356+
ctx.log.debug(`Server started successfully on port ${actualPort}`);
357+
}
316358

317359
return server;
318360
}

src/lib/snapshotQueue.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,8 +291,25 @@ export default class Queue {
291291
}
292292

293293
if (!this.ctx.config.delayedUpload && snapshot && snapshot.name && this.snapshotNames.includes(snapshot.name) && !this.ctx.config.allowDuplicateSnapshotNames) {
294-
drop = true;
295-
this.ctx.log.info(`Skipping duplicate SmartUI snapshot '${snapshot.name}'. To capture duplicate screenshots, please set the 'allowDuplicateSnapshotNames' or 'delayedUpload' configuration as true in your config file.`);
294+
// check if sessionIdToSnapshotNameMap has snapshot name for the sessionId
295+
if (this.ctx.sessionIdToSnapshotNameMap && snapshot.options && snapshot.options.sessionId) {
296+
if (this.ctx.sessionIdToSnapshotNameMap.has(snapshot.options.sessionId)) {
297+
console.log(`snapshot.options.sessionId`,snapshot.options.sessionId, `this.ctx.sessionIdToSnapshotNameMap`,JSON.stringify([...this.ctx.sessionIdToSnapshotNameMap]));
298+
const existingNames = this.ctx.sessionIdToSnapshotNameMap.get(snapshot.options.sessionId) || [];
299+
if (existingNames.includes(snapshot.name)) {
300+
drop = true;
301+
this.ctx.log.info(`Skipping123 duplicate SmartUI snapshot '${snapshot.name}'. To capture duplicate screenshots, please set the 'allowDuplicateSnapshotNames' or 'delayedUpload' configuration as true in your config file.`);
302+
} else {
303+
existingNames.push(snapshot.name);
304+
this.ctx.sessionIdToSnapshotNameMap.set(snapshot.options.sessionId, existingNames);
305+
}
306+
} else {
307+
this.ctx.sessionIdToSnapshotNameMap.set(snapshot.options.sessionId, [snapshot.name]);
308+
}
309+
} else {
310+
drop = true;
311+
this.ctx.log.info(`Skipping duplicate SmartUI snapshot '${snapshot.name}'. To capture duplicate screenshots, please set the 'allowDuplicateSnapshotNames' or 'delayedUpload' configuration as true in your config file.`);
312+
}
296313
}
297314

298315
if (this.ctx.config.delayedUpload && snapshot && snapshot.name && this.snapshotNames.includes(snapshot.name)) {
@@ -327,7 +344,7 @@ export default class Queue {
327344
}
328345

329346
let processedSnapshot, warnings, discoveryErrors;
330-
if (this.ctx.env.USE_REMOTE_DISCOVERY) {
347+
if (this.ctx.env.USE_REMOTE_DISCOVERY || this.ctx.config.useRemoteDiscovery) {
331348
this.ctx.log.debug(`Using remote discovery`);
332349
let result = await prepareSnapshot(snapshot, this.ctx);
333350

src/tasks/createBuildExec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export default (ctx: Context): ListrTask<Context, ListrRendererFactory, ListrRen
3636
}
3737
task.output = chalk.gray(`build id: ${resp.data.buildId}`);
3838
task.title = 'SmartUI build created'
39-
if (ctx.env.USE_REMOTE_DISCOVERY){
39+
if (ctx.env.USE_REMOTE_DISCOVERY || ctx.config.useRemoteDiscovery) {
4040
task.output += chalk.gray(`\n Using remote discovery for this build`);
4141
}
4242
} else {

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export interface Context {
3838
requestHeaders?: Array<Record<string, string>>;
3939
allowDuplicateSnapshotNames?: boolean;
4040
useLambdaInternal?: boolean;
41+
useRemoteDiscovery?: boolean;
4142
useExtendedViewport?: boolean;
4243
loadDomContent?: boolean;
4344
approvalThreshold?: number;
@@ -81,6 +82,7 @@ export interface Context {
8182
sessionCapabilitiesMap?: Map<string, any[]>;
8283
buildToSnapshotCountMap?: Map<string, number>;
8384
fetchResultsForBuild?: Array<string>;
85+
sessionIdToSnapshotNameMap?: Map<string, string[]>;
8486
orgId?: number;
8587
userId?: number;
8688
mergeBranchSource?: string;

0 commit comments

Comments
 (0)