diff --git a/package.json b/package.json index 682de4a..c95f5b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lambdatest/smartui-cli", - "version": "4.1.31", + "version": "4.1.32", "description": "A command line interface (CLI) to run SmartUI tests on LambdaTest", "files": [ "dist/**/*" @@ -49,6 +49,9 @@ "which": "^4.0.0", "winston": "^3.10.0" }, + "overrides": { + "simple-swizzle": "0.2.2" + }, "devDependencies": { "typescript": "^5.3.2" } diff --git a/src/commander/exec.ts b/src/commander/exec.ts index adf91e8..ddef0fd 100644 --- a/src/commander/exec.ts +++ b/src/commander/exec.ts @@ -40,6 +40,7 @@ command ctx.args.execCommand = execCommand ctx.snapshotQueue = new snapshotQueue(ctx) ctx.totalSnapshots = 0 + ctx.sourceCommand = 'exec' let tasks = new Listr( [ diff --git a/src/commander/server.ts b/src/commander/server.ts index 8178162..4c6a157 100644 --- a/src/commander/server.ts +++ b/src/commander/server.ts @@ -8,6 +8,7 @@ import getGitInfo from '../tasks/getGitInfo.js'; import createBuildExec from '../tasks/createBuildExec.js'; import snapshotQueue from '../lib/snapshotQueue.js'; import { startPolling, startPingPolling } from '../lib/utils.js'; +import startTunnel from '../tasks/startTunnel.js' const command = new Command(); @@ -27,12 +28,14 @@ command ctx.snapshotQueue = new snapshotQueue(ctx); ctx.totalSnapshots = 0 ctx.isStartExec = true - + ctx.sourceCommand = 'exec-start' + let tasks = new Listr( [ authExec(ctx), startServer(ctx), getGitInfo(ctx), + ...(ctx.config.tunnel && ctx.config.tunnel?.type === 'auto' ? [startTunnel(ctx)] : []), createBuildExec(ctx), ], @@ -50,15 +53,16 @@ command try { await tasks.run(ctx); - if (ctx.build && ctx.build.id) { + if (ctx.build && ctx.build.id && !ctx.autoTunnelStarted) { startPingPolling(ctx); } if (ctx.options.fetchResults && ctx.build && ctx.build.id) { startPolling(ctx, '', false, '') } - + } catch (error) { console.error('Error during server execution:', error); + process.exit(1); } }); diff --git a/src/commander/stopServer.ts b/src/commander/stopServer.ts index 3aae301..0ff3150 100644 --- a/src/commander/stopServer.ts +++ b/src/commander/stopServer.ts @@ -33,8 +33,10 @@ command } } catch (error: any) { // Handle any errors during the HTTP request - if (error.code === 'ECONNABORTED') { + if (error && error.code === 'ECONNABORTED') { console.error(chalk.red('Error: SmartUI server did not respond in 15 seconds')); + } if (error && error.code === 'ECONNREFUSED') { + console.error(chalk.red('Error: Looks like smartui cli server is already stopped')); } else { console.error(chalk.red('Error while stopping server')); } diff --git a/src/lib/ctx.ts b/src/lib/ctx.ts index 7afe0f5..dcd43c1 100644 --- a/src/lib/ctx.ts +++ b/src/lib/ctx.ts @@ -39,6 +39,12 @@ export default (options: Record): Context => { delete config.web.resolutions; } + if(config.approvalThreshold && config.rejectionThreshold) { + if(config.rejectionThreshold <= config.approvalThreshold) { + throw new Error('Invalid config; rejectionThreshold must be greater than approvalThreshold'); + } + } + let validateConfigFn = options.scheduled ? validateConfigForScheduled : validateConfig; // validate config @@ -142,6 +148,8 @@ export default (options: Record): Context => { useLambdaInternal: useLambdaInternal, useExtendedViewport: useExtendedViewport, loadDomContent: loadDomContent, + approvalThreshold: config.approvalThreshold, + rejectionThreshold: config.rejectionThreshold, }, uploadFilePath: '', webStaticConfig: [], diff --git a/src/lib/httpClient.ts b/src/lib/httpClient.ts index 9a1ae93..2df51b6 100644 --- a/src/lib/httpClient.ts +++ b/src/lib/httpClient.ts @@ -77,7 +77,7 @@ export default class httpClient { (response) => response, async (error) => { const { config } = error; - if (config && config.url === '/screenshot' && config.method === 'post') { + if (config && config.url === '/screenshot' && config.method === 'post' && error?.response?.status !== 401) { // Set default retry count and delay if not already defined if (!config.retryCount) { config.retryCount = 0; @@ -242,11 +242,12 @@ export default class httpClient { }, log) } - getScreenshotData(buildId: string, baseline: boolean, log: Logger, projectToken: string) { + getScreenshotData(buildId: string, baseline: boolean, log: Logger, projectToken: string, buildName: string) { + log.debug(`Fetching screenshot data for buildId: ${buildId} having buildName: ${buildName} with baseline: ${baseline}`); return this.request({ url: '/screenshot', method: 'GET', - params: { buildId, baseline }, + params: { buildId, baseline, buildName }, headers: {projectToken: projectToken} }, log); } @@ -281,7 +282,7 @@ export default class httpClient { } - getSmartUICapabilities(sessionId: string, config: any, git: any, log: Logger) { + getSmartUICapabilities(sessionId: string, config: any, git: any, log: Logger, isStartExec: boolean) { return this.request({ url: '/sessions/capabilities', method: 'GET', @@ -290,7 +291,8 @@ export default class httpClient { }, data: { git, - config + config, + isStartExec }, headers: { projectToken: '', @@ -343,24 +345,33 @@ export default class httpClient { }, ctx.log) } - processSnapshot(ctx: Context, snapshot: ProcessedSnapshot, snapshotUuid: string, discoveryErrors: DiscoveryErrors, variantCount: number, sync: boolean = false) { + processSnapshot(ctx: Context, snapshot: ProcessedSnapshot, snapshotUuid: string, discoveryErrors: DiscoveryErrors, variantCount: number, sync: boolean = false, approvalThreshold: number| undefined, rejectionThreshold: number| undefined) { + const requestData: any = { + name: snapshot.name, + url: snapshot.url, + snapshotUuid: snapshotUuid, + variantCount: variantCount, + test: { + type: ctx.testType, + source: 'cli' + }, + discoveryErrors: discoveryErrors, + doRemoteDiscovery: snapshot.options.doRemoteDiscovery, + sync: sync + }; + + if (approvalThreshold !== undefined) { + requestData.approvalThreshold = approvalThreshold; + } + if (rejectionThreshold !== undefined) { + requestData.rejectionThreshold = rejectionThreshold; + } + return this.request({ url: `/build/${ctx.build.id}/snapshot`, method: 'POST', headers: { 'Content-Type': 'application/json' }, - data: { - name: snapshot.name, - url: snapshot.url, - snapshotUuid: snapshotUuid, - variantCount: variantCount, - test: { - type: ctx.testType, - source: 'cli' - }, - doRemoteDiscovery: snapshot.options.doRemoteDiscovery, - discoveryErrors: discoveryErrors, - sync: sync - } + data: requestData }, ctx.log) } diff --git a/src/lib/schemaValidation.ts b/src/lib/schemaValidation.ts index a1e0e8c..f545901 100644 --- a/src/lib/schemaValidation.ts +++ b/src/lib/schemaValidation.ts @@ -239,6 +239,11 @@ const ConfigSchema = { type: "string", errorMessage: "Invalid config; logFile should be a string value" }, + environment: { + type: "string", + enum: ["stage", "prod"], + errorMessage: "Invalid config; environment should be a string value either stage or prod" + } }, required: ["type"], additionalProperties: false @@ -274,6 +279,18 @@ const ConfigSchema = { loadDomContent: { type: "boolean", errorMessage: "Invalid config; loadDomContent must be true/false" + }, + approvalThreshold: { + type: "number", + minimum: 0, + maximum: 100, + errorMessage: "Invalid config; approvalThreshold must be a number" + }, + rejectionThreshold: { + type: "number", + minimum: 0, + maximum: 100, + errorMessage: "Invalid config; rejectionThreshold must be a number" } }, anyOf: [ @@ -537,6 +554,18 @@ const SnapshotSchema: JSONSchemaType = { useExtendedViewport: { type: "boolean", errorMessage: "Invalid snapshot options; useExtendedViewport must be a boolean" + }, + approvalThreshold: { + type: "number", + minimum: 0, + maximum: 100, + errorMessage: "Invalid snapshot options; approvalThreshold must be a number between 0 and 100" + }, + rejectionThreshold: { + type: "number", + minimum: 0, + maximum: 100, + errorMessage: "Invalid snapshot options; rejectionThreshold must be a number between 0 and 100" } }, additionalProperties: false diff --git a/src/lib/server.ts b/src/lib/server.ts index 2914526..3eca0c9 100644 --- a/src/lib/server.ts +++ b/src/lib/server.ts @@ -4,8 +4,7 @@ import fastify, { FastifyInstance, RouteShorthandOptions } from 'fastify'; import { readFileSync, truncate } from 'fs' import { Context } from '../types.js' import { validateSnapshot } from './schemaValidation.js' -import { pingIntervalId } from './utils.js'; -import { startPolling } from './utils.js'; +import { pingIntervalId, startPollingForTunnel, stopTunnelHelper, isTunnelPolling } from './utils.js'; const uploadDomToS3ViaEnv = process.env.USE_LAMBDA_INTERNAL || false; export default async (ctx: Context): Promise> => { @@ -38,6 +37,12 @@ export default async (ctx: Context): Promise; try { + ctx.log.info('Received stop command. Finalizing build ...'); if(ctx.config.delayedUpload){ ctx.log.debug("started after processing because of delayedUpload") ctx.snapshotQueue?.startProcessingfunc() @@ -118,6 +124,7 @@ export default async (ctx: Context): Promise => { smartIgnore: ctx.config.smartIgnore, git: ctx.git, platformType: 'app', + markBaseline: ctx.options.markBaseline, }; const responseData = await ctx.client.processWebFigma(requestBody, ctx.log); diff --git a/src/lib/uploadWebFigma.ts b/src/lib/uploadWebFigma.ts index 061f064..7a58d12 100644 --- a/src/lib/uploadWebFigma.ts +++ b/src/lib/uploadWebFigma.ts @@ -18,6 +18,7 @@ export default async (ctx: Context): Promise => { figma: figmaConfig, smartIgnore: ctx.config.smartIgnore, git: ctx.git, + markBaseline: ctx.options.markBaseline, }; const responseData = await ctx.client.processWebFigma(requestBody, ctx.log); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 43f24cb..9a88614 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -7,6 +7,7 @@ import fs from 'fs'; import { globalAgent } from 'http'; import { promisify } from 'util' import { build } from 'tsup'; +const util = require('util'); // Import the util module var lambdaTunnel = require('@lambdatest/node-tunnel'); const sleep = promisify(setTimeout); @@ -248,9 +249,9 @@ export async function startPolling(ctx: Context, build_id: string, baseline: boo try { let resp; if (build_id) { - resp = await ctx.client.getScreenshotData(build_id, baseline, ctx.log, projectToken); + resp = await ctx.client.getScreenshotData(build_id, baseline, ctx.log, projectToken, ''); } else if (ctx.build && ctx.build.id) { - resp = await ctx.client.getScreenshotData(ctx.build.id, ctx.build.baseline, ctx.log, ''); + resp = await ctx.client.getScreenshotData(ctx.build.id, ctx.build.baseline, ctx.log, '', ''); } else { return; } @@ -333,12 +334,13 @@ export async function startPingPolling(ctx: Context): Promise { ctx.log.error(`Error during initial ping: ${error.message}`); } + let sourceCommand = ctx.sourceCommand? ctx.sourceCommand : ''; // Start the polling interval pingIntervalId = setInterval(async () => { try { - ctx.log.debug('Sending ping to server...'); + ctx.log.debug('Sending ping to server... '+ sourceCommand); await ctx.client.ping(ctx.build.id, ctx.log); - ctx.log.debug('Ping sent successfully.'); + ctx.log.debug('Ping sent successfully. '+ sourceCommand); } catch (error: any) { ctx.log.error(`Error during ping polling: ${error.message}`); } @@ -390,6 +392,11 @@ export async function startTunnelBinary(ctx: Context) { ctx.config.tunnel.tunnelName = randomTunnelName } + if (tunnelConfig?.environment) { + tunnelArguments.environment = tunnelConfig.environment + } + + ctx.log.debug(`tunnel config ${JSON.stringify(tunnelArguments)}`) if (ctx.config.tunnel?.type === 'auto') { @@ -401,59 +408,61 @@ export async function startTunnelBinary(ctx: Context) { } } -export async function startPollingForTunnel(ctx: Context, build_id: string, baseline: boolean, projectToken: string): Promise { +export let isTunnelPolling: NodeJS.Timeout | null = null; + +export async function startPollingForTunnel(ctx: Context, build_id: string, baseline: boolean, projectToken: string, buildName: string): Promise { + if (isTunnelPolling) { + ctx.log.debug('Tunnel polling is already active. Skipping for build_id: ' + build_id); + return; + } const intervalId = setInterval(async () => { try { let resp; if (build_id) { - resp = await ctx.client.getScreenshotData(build_id, baseline, ctx.log, projectToken); + resp = await ctx.client.getScreenshotData(build_id, baseline, ctx.log, projectToken, buildName); } else if (ctx.build && ctx.build.id) { - resp = await ctx.client.getScreenshotData(ctx.build.id, ctx.build.baseline, ctx.log, ''); + resp = await ctx.client.getScreenshotData(ctx.build.id, ctx.build.baseline, ctx.log, '', ''); } else { + ctx.log.debug('No build information available for polling tunnel status.'); + clearInterval(intervalId); + await stopTunnelHelper(ctx); return; } - + ctx.log.debug(' resp from polling for tunnel status: ' + JSON.stringify(resp)); if (!resp.build) { ctx.log.info("Error: Build data is null."); clearInterval(intervalId); - - const tunnelRunningStatus = await tunnelInstance.isRunning(); - ctx.log.debug('Running status of tunnel before stopping ? ' + tunnelRunningStatus); - - const status = await tunnelInstance.stop(); - ctx.log.debug('Tunnel is Stopped ? ' + status); - + await stopTunnelHelper(ctx); return; } if (resp.build.build_status_ind === constants.BUILD_COMPLETE || resp.build.build_status_ind === constants.BUILD_ERROR) { clearInterval(intervalId); - - const tunnelRunningStatus = await tunnelInstance.isRunning(); - ctx.log.debug('Running status of tunnel before stopping ? ' + tunnelRunningStatus); - - const status = await tunnelInstance.stop(); - ctx.log.debug('Tunnel is Stopped ? ' + status); - + await stopTunnelHelper(ctx); + return; } } catch (error: any) { - if (error.message.includes('ENOTFOUND')) { + if (error?.message.includes('ENOTFOUND')) { ctx.log.error('Error: Network error occurred while fetching build status while polling. Please check your connection and try again.'); clearInterval(intervalId); } else { - ctx.log.error(`Error fetching build status while polling: ${error.message}`); + // Log the error in a human-readable format + ctx.log.debug(util.inspect(error, { showHidden: false, depth: null })); + ctx.log.error(`Error fetching build status while polling: ${JSON.stringify(error)}`); } clearInterval(intervalId); } }, 5000); + isTunnelPolling = intervalId; } export async function stopTunnelHelper(ctx: Context) { + ctx.log.debug('stop-tunnel:: Stopping the tunnel now'); const tunnelRunningStatus = await tunnelInstance.isRunning(); - ctx.log.debug('Running status of tunnel before stopping ? ' + tunnelRunningStatus); + ctx.log.debug('stop-tunnel:: Running status of tunnel before stopping ? ' + tunnelRunningStatus); const status = await tunnelInstance.stop(); - ctx.log.debug('Tunnel is Stopped ? ' + status); + ctx.log.debug('stop-tunnel:: Tunnel is Stopped ? ' + status); } /** diff --git a/src/tasks/createBuildExec.ts b/src/tasks/createBuildExec.ts index 1e58fb0..9a469af 100644 --- a/src/tasks/createBuildExec.ts +++ b/src/tasks/createBuildExec.ts @@ -2,7 +2,7 @@ import { ListrTask, ListrRendererFactory } from 'listr2'; import { Context } from '../types.js' import chalk from 'chalk'; import { updateLogContext } from '../lib/logger.js'; -import { startTunnelBinary, startPollingForTunnel, stopTunnelHelper, startPingPolling } from '../lib/utils.js'; +import { stopTunnelHelper, startPingPolling } from '../lib/utils.js'; export default (ctx: Context): ListrTask => { return { @@ -36,18 +36,17 @@ export default (ctx: Context): ListrTask; + sourceCommand?: string; + autoTunnelStarted?: boolean; } export interface Env { @@ -156,6 +160,8 @@ export interface Snapshot { sync?: boolean; contextId?: string; useExtendedViewport?: boolean; + approvalThreshold?: number; + rejectionThreshold?: number; } } @@ -235,6 +241,7 @@ export interface tunnelConfig { dir: string; v: boolean; logFile: string; + environment:string; } export interface FigmaWebConfig {