diff --git a/package.json b/package.json index 0f31840..50c433e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lambdatest/smartui-cli", - "version": "4.1.34", + "version": "4.1.37", "description": "A command line interface (CLI) to run SmartUI tests on LambdaTest", "files": [ "dist/**/*" diff --git a/src/commander/exec.ts b/src/commander/exec.ts index ddef0fd..f4911d0 100644 --- a/src/commander/exec.ts +++ b/src/commander/exec.ts @@ -25,6 +25,7 @@ command .option('--scheduled ', 'Specify the schedule ID') .option('--userName ', 'Specify the LT username') .option('--accessKey ', 'Specify the LT accesskey') + .option('--show-render-errors', 'Show render errors from SmartUI build') .action(async function(execCommand, _, command) { const options = command.optsWithGlobals(); if (options.buildName === '') { diff --git a/src/commander/uploadPdf.ts b/src/commander/uploadPdf.ts index 07c319f..2b9eacb 100644 --- a/src/commander/uploadPdf.ts +++ b/src/commander/uploadPdf.ts @@ -14,6 +14,7 @@ command .argument('', 'Path of the directory containing PDFs') .option('--fetch-results [filename]', 'Fetch results and optionally specify an output file, e.g., .json') .option('--buildName ', 'Specify the build name') + .option('--markBaseline', 'Mark this build baseline') .action(async function(directory, _, command) { const options = command.optsWithGlobals(); if (options.buildName === '') { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index ebcb81c..5f5fb4b 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -25,7 +25,8 @@ export default { waitForTimeout: 1000, enableJavaScript: false, allowedHostnames: [], - smartIgnore: false + smartIgnore: false, + showRenderErrors: false }, DEFAULT_WEB_STATIC_CONFIG: [ { @@ -358,6 +359,22 @@ export default { 'Aquos Sense 5G': { os: 'android', viewport: { width: 393, height: 731 } }, 'Xperia 10 IV': { os: 'android', viewport: { width: 412, height: 832 } }, 'Honeywell CT40': { os: 'android', viewport: { width: 360, height: 512 } }, + 'Galaxy S25': { os: 'android', viewport: { width: 370, height: 802 } }, + 'Galaxy S25 Plus': { os: 'android', viewport: { width: 393, height: 888 } }, + 'Galaxy S25 Ultra': { os: 'android', viewport: { width: 432, height: 941 } }, + 'iPhone 17': { os: 'ios', viewport: { width: 393, height: 852 } }, + 'iPhone 17 Pro': { os: 'ios', viewport: { width: 393, height: 852 } }, + 'iPhone 17 Pro Max': { os: 'ios', viewport: { width: 430, height: 932 } }, + 'Galaxy Z Fold7': { os: 'android', viewport: { width: 373, height: 873 } }, + 'Galaxy Z Flip7': { os: 'android', viewport: { width: 299, height: 723 } }, + 'Galaxy Z Fold6': { os: 'android', viewport: { width: 373, height: 873 } }, + 'Galaxy Z Flip6': { os: 'android', viewport: { width: 298, height: 713 } }, + 'Pixel 10 Pro': { os: 'android', viewport: { width: 393, height: 852 } }, + 'Pixel 10 Pro XL': { os: 'android', viewport: { width: 412, height: 915 } }, + 'Motorola Edge 50 Pro': { os: 'android', viewport: { width: 384, height: 864 } }, + 'OnePlus 12': { os: 'android', viewport: { width: 384, height: 884 } }, + 'Nothing Phone 1': { os: 'android', viewport: { width: 393, height: 853 } }, + 'Nothing Phone 2': { os: 'android', viewport: { width: 393, height: 878 } }, }, FIGMA_API: 'https://api.figma.com/v1/', diff --git a/src/lib/ctx.ts b/src/lib/ctx.ts index f891142..ef6e209 100644 --- a/src/lib/ctx.ts +++ b/src/lib/ctx.ts @@ -155,6 +155,7 @@ export default (options: Record): Context => { loadDomContent: loadDomContent, approvalThreshold: config.approvalThreshold, rejectionThreshold: config.rejectionThreshold, + showRenderErrors: config.showRenderErrors ?? false }, uploadFilePath: '', webStaticConfig: [], @@ -192,7 +193,8 @@ export default (options: Record): Context => { fetchResultsFileName: fetchResultsFileObj, baselineBranch: options.baselineBranch || '', baselineBuild: options.baselineBuild || '', - githubURL : options.githubURL || '' + githubURL : options.githubURL || '', + showRenderErrors: options.showRenderErrors ? true : false }, cliVersion: version, totalSnapshots: -1, diff --git a/src/lib/env.ts b/src/lib/env.ts index 8e8cf1d..2ce8d69 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -22,7 +22,9 @@ export default (): Env => { SMARTUI_API_PROXY, SMARTUI_API_SKIP_CERTIFICATES, USE_REMOTE_DISCOVERY, - SMART_GIT + SMART_GIT, + SHOW_RENDER_ERRORS, + SMARTUI_SSE_URL='https://server-events.lambdatest.com' } = process.env return { @@ -46,6 +48,8 @@ export default (): Env => { SMARTUI_API_PROXY, SMARTUI_API_SKIP_CERTIFICATES: SMARTUI_API_SKIP_CERTIFICATES === 'true', USE_REMOTE_DISCOVERY: USE_REMOTE_DISCOVERY === 'true', - SMART_GIT: SMART_GIT === 'true' + SMART_GIT: SMART_GIT === 'true', + SHOW_RENDER_ERRORS: SHOW_RENDER_ERRORS === 'true', + SMARTUI_SSE_URL } } diff --git a/src/lib/httpClient.ts b/src/lib/httpClient.ts index 2df51b6..bb125b3 100644 --- a/src/lib/httpClient.ts +++ b/src/lib/httpClient.ts @@ -375,7 +375,26 @@ export default class httpClient { }, ctx.log) } - processSnapshotCaps(ctx: Context, snapshot: ProcessedSnapshot, snapshotUuid: string, capsBuildId: string, capsProjectToken: string, discoveryErrors: DiscoveryErrors) { + processSnapshotCaps(ctx: Context, snapshot: ProcessedSnapshot, snapshotUuid: string, capsBuildId: string, capsProjectToken: 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' + }, + doRemoteDiscovery: snapshot.options.doRemoteDiscovery, + discoveryErrors: discoveryErrors, + sync: sync + } + if (approvalThreshold !== undefined) { + requestData.approvalThreshold = approvalThreshold; + } + if (rejectionThreshold !== undefined) { + requestData.rejectionThreshold = rejectionThreshold; + } return this.request({ url: `/build/${capsBuildId}/snapshot`, method: 'POST', @@ -383,24 +402,31 @@ export default class httpClient { 'Content-Type': 'application/json', projectToken: capsProjectToken !== '' ? capsProjectToken : this.projectToken }, - data: { - name: snapshot.name, - url: snapshot.url, - snapshotUuid: snapshotUuid, - test: { - type: ctx.testType, - source: 'cli' - }, - doRemoteDiscovery: snapshot.options.doRemoteDiscovery, - discoveryErrors: discoveryErrors, - } + data: requestData }, ctx.log) } - uploadSnapshotForCaps(ctx: Context, snapshot: ProcessedSnapshot, capsBuildId: string, capsProjectToken: string, discoveryErrors: DiscoveryErrors) { + uploadSnapshotForCaps(ctx: Context, snapshot: ProcessedSnapshot, capsBuildId: string, capsProjectToken: string, discoveryErrors: DiscoveryErrors, variantCount: number, sync: boolean = false, approvalThreshold: number| undefined, rejectionThreshold: number| undefined) { // Use capsBuildId if provided, otherwise fallback to ctx.build.id const buildId = capsBuildId !== '' ? capsBuildId : ctx.build.id; - + + const requestData: any = { + snapshot, + test: { + type: ctx.testType, + source: 'cli' + }, + discoveryErrors: discoveryErrors, + variantCount: variantCount, + sync: sync + } + if (approvalThreshold !== undefined) { + requestData.approvalThreshold = approvalThreshold; + } + if (rejectionThreshold !== undefined) { + requestData.rejectionThreshold = rejectionThreshold; + } + return this.request({ url: `/builds/${buildId}/snapshot`, method: 'POST', @@ -408,14 +434,7 @@ export default class httpClient { 'Content-Type': 'application/json', projectToken: capsProjectToken !== '' ? capsProjectToken : this.projectToken // Use capsProjectToken dynamically }, - data: { - snapshot, - test: { - type: ctx.testType, - source: 'cli' - }, - discoveryErrors: discoveryErrors, - } + data: requestData }, ctx.log); } @@ -660,9 +679,9 @@ export default class httpClient { }, ctx.log) } - getSnapshotStatus(snapshotName: string, snapshotUuid: string, ctx: Context): Promise> { + getSnapshotStatus(buildId: string, snapshotName: string, snapshotUuid: string, ctx: Context): Promise> { return this.request({ - url: `/snapshot/status?buildId=${ctx.build.id}&snapshotName=${snapshotName}&snapshotUUID=${snapshotUuid}`, + url: `/snapshot/status?buildId=${buildId}&snapshotName=${snapshotName}&snapshotUUID=${snapshotUuid}`, method: 'GET', headers: { 'Content-Type': 'application/json', @@ -675,6 +694,9 @@ export default class httpClient { if (ctx.build.name !== undefined && ctx.build.name !== '') { form.append('buildName', buildName); } + if (ctx.options.markBaseline) { + form.append('markBaseline', ctx.options.markBaseline.toString()); + } try { const response = await this.axiosInstance.request({ diff --git a/src/lib/processSnapshot.ts b/src/lib/processSnapshot.ts index 0fe827d..263141c 100644 --- a/src/lib/processSnapshot.ts +++ b/src/lib/processSnapshot.ts @@ -243,6 +243,47 @@ export default async function processSnapshot(snapshot: Snapshot, ctx: Context): ctx.log.debug('No valid cookies to add'); } } + + let options = snapshot.options; + + // Custom cookies include those which cannot be captured by javascript function `document.cookie` like httpOnly, secure, sameSite etc. + // These custom cookies will be captured by the user in their automation browser and sent to CLI through the snapshot options using `customCookies` field. + if (options?.customCookies && Array.isArray(options.customCookies) && options.customCookies.length > 0) { + ctx.log.debug(`Setting ${options.customCookies.length} custom cookies`); + + const validCustomCookies = options.customCookies.filter(cookie => { + if (!cookie.name || !cookie.value || !cookie.domain) { + ctx.log.debug(`Skipping invalid custom cookie: missing required fields (name, value, or domain)`); + return false; + } + + if (cookie.sameSite && !['Strict', 'Lax', 'None'].includes(cookie.sameSite)) { + ctx.log.debug(`Skipping invalid custom cookie: invalid sameSite value '${cookie.sameSite}'`); + return false; + } + + return true; + }).map(cookie => ({ + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: cookie.path || '/', + httpOnly: cookie.httpOnly || false, + secure: cookie.secure || false, + sameSite: cookie.sameSite || 'Lax' + })); + + if (validCustomCookies.length > 0) { + try { + await context.addCookies(validCustomCookies); + ctx.log.debug(`Successfully added ${validCustomCookies.length} custom cookies`); + } catch (error) { + ctx.log.debug(`Failed to add custom cookies: ${error}`); + } + } else { + ctx.log.debug('No valid custom cookies to add'); + } + } const page = await context.newPage(); // populate cache with already captured resources @@ -415,8 +456,6 @@ export default async function processSnapshot(snapshot: Snapshot, ctx: Context): route.abort(); } }); - - let options = snapshot.options; let optionWarnings: Set = new Set(); let selectors: Array = []; let ignoreOrSelectDOM: string; @@ -582,6 +621,7 @@ export default async function processSnapshot(snapshot: Snapshot, ctx: Context): // adding extra timeout since domcontentloaded event is fired pretty quickly await new Promise(r => setTimeout(r, 1250)); if (ctx.config.waitForTimeout) await page.waitForTimeout(ctx.config.waitForTimeout); + await page.waitForLoadState("networkidle", { timeout: 10000 }).catch(() => { ctx.log.debug('networkidle event failed to fire within 10s') }); navigated = true; ctx.log.debug(`Navigated to ${snapshot.url}`); } catch (error: any) { @@ -605,7 +645,7 @@ export default async function processSnapshot(snapshot: Snapshot, ctx: Context): if (ctx.config.cliEnableJavaScript && fullPage) await page.evaluate(scrollToBottomAndBackToTop, { frequency: 100, timing: ctx.config.scrollTime }); try { - await page.waitForLoadState('networkidle', { timeout: 5000 }); + await page.waitForLoadState('networkidle', { timeout: 15000 }); ctx.log.debug('Network idle 500ms'); } catch (error) { ctx.log.debug(`Network idle failed due to ${error}`); @@ -815,7 +855,6 @@ export default async function processSnapshot(snapshot: Snapshot, ctx: Context): if (hasBrowserErrors) { discoveryErrors.timestamp = new Date().toISOString(); - // ctx.log.warn(discoveryErrors); } if (ctx.config.useGlobalCache) { diff --git a/src/lib/schemaValidation.ts b/src/lib/schemaValidation.ts index 518a404..6b0efe1 100644 --- a/src/lib/schemaValidation.ts +++ b/src/lib/schemaValidation.ts @@ -295,6 +295,10 @@ const ConfigSchema = { minimum: 0, maximum: 100, errorMessage: "Invalid config; rejectionThreshold must be a number" + }, + showRenderErrors: { + type: "boolean", + errorMessage: "Invalid config; showRenderErrors must be true/false" } }, anyOf: [ @@ -582,6 +586,14 @@ const SnapshotSchema: JSONSchemaType = { minimum: 0, maximum: 100, errorMessage: "Invalid snapshot options; rejectionThreshold must be a number between 0 and 100" + }, + customCookies: { + type: "array", + items: { + type: "object", + minProperties: 1, + }, + errorMessage: "Invalid snapshot options; customCookies must be an array of objects with string properties" } }, additionalProperties: false diff --git a/src/lib/screenshot.ts b/src/lib/screenshot.ts index 6c785ed..2c0646d 100644 --- a/src/lib/screenshot.ts +++ b/src/lib/screenshot.ts @@ -25,7 +25,9 @@ async function captureScreenshotsForConfig( ctx.log.debug(`url: ${url} pageOptions: ${JSON.stringify(pageOptions)}`); let ssId = name.toLowerCase().replace(/\s/g, '_'); let context: BrowserContext; - let contextOptions: Record = {}; + let contextOptions: Record = { + ignoreHTTPSErrors: ctx.config.ignoreHTTPSErrors + }; let page: Page; if (browserName == constants.CHROME) contextOptions.userAgent = constants.CHROME_USER_AGENT; else if (browserName == constants.FIREFOX) contextOptions.userAgent = constants.FIREFOX_USER_AGENT; diff --git a/src/lib/server.ts b/src/lib/server.ts index cc03eb9..a286e2f 100644 --- a/src/lib/server.ts +++ b/src/lib/server.ts @@ -118,7 +118,7 @@ export default async (ctx: Context): Promise setTimeout(resolve, 5000)); - - contextStatus = ctx.contextToSnapshotMap.get(contextId); + counter--; } - if(contextStatus==2){ + if(contextStatus==='2'){ throw new Error("Snapshot Failed"); } ctx.log.debug("Snapshot uploaded successfully"); + const buildId = contextStatus; + if (!buildId) { + throw new Error(`No buildId found for contextId: ${contextId}`); + } + // Poll external API until it returns 200 or timeout is reached let lastExternalResponse: any = null; const startTime = Date.now(); @@ -272,6 +281,7 @@ export default async (ctx: Context): Promise void +): Promise<{ abort: () => void }> { + const url = `${baseURL}/api/v1/sse/smartui`; + + const abortController = new AbortController(); + + try { + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Cookie': `stageAccessToken=Basic ${accessToken}` + }, + signal: abortController.signal + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + onEvent?.('open', { status: 'connected' }); + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('No response body reader available'); + } + + const decoder = new TextDecoder(); + let buffer = ''; + let currentEvent = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + + buffer += chunk; + const lines = buffer.split('\n'); + + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('event:')) { + currentEvent = line.substring(6).trim(); + } + else if (line.startsWith('data:')) { + const data = line.substring(5).trim(); + + if (data) { + try { + const parsedData = JSON.parse(data); + onEvent?.(currentEvent, parsedData); + } catch (parseError) { + if (currentEvent === 'connection' && data === 'connected') { + onEvent?.(currentEvent, { status: 'connected', message: data }); + } else { + onEvent?.(currentEvent, data); + } + } + } + } + else if (line.trim() === '') { + currentEvent = ''; + } + } + } + } catch (streamError: any) { + ctx.log.debug('SSE Streaming error:', streamError); + onEvent?.('error', streamError); + } finally { + reader.releaseLock(); + } + + } catch (error) { + ctx.log.debug('SSE Connection error:', error); + onEvent?.('error', error); + } + + return { + abort: () => abortController.abort() + }; +} + +export async function startSSEListener(ctx: Context) { + let currentConnection: { abort: () => void } | null = null; + let errorCount = 0; + + try { + ctx.log.debug('Attempting SSE connection'); + const accessKey = ctx.env.LT_ACCESS_KEY; + const username = ctx.env.LT_USERNAME; + + const basicAuthToken = createBasicAuthToken(username, accessKey); + ctx.log.debug(`Basic auth token: ${basicAuthToken}`); + currentConnection = await listenToSmartUISSE( + ctx.env.SMARTUI_SSE_URL, + basicAuthToken, + ctx, + (eventType, data) => { + switch (eventType) { + case 'open': + ctx.log.debug('Connected to SSE server'); + break; + + case 'connection': + ctx.log.debug('Connection confirmed:', data); + break; + + case 'Dot_buildCompleted': + ctx.log.debug('Build completed'); + ctx.log.info(chalk.green.bold('Build completed')); + process.exit(0); + case 'DOTUIError': + if (data.buildId== ctx.build.id) { + errorCount++; + ctx.log.info(chalk.red.bold(`Error: ${data.message}`)); + } + break; + case 'DOTUIWarning': + if (data.buildId== ctx.build.id) { + ctx.log.info(chalk.yellow.bold(`Warning: ${data.message}`)); + } + break; + case 'error': + ctx.log.debug('SSE Error occurred:', data); + currentConnection?.abort(); + return; + } + } + ); + + } catch (error) { + ctx.log.debug('Failed to start SSE listener:', error); + } } \ No newline at end of file diff --git a/src/tasks/exec.ts b/src/tasks/exec.ts index d65abee..ecfd9be 100644 --- a/src/tasks/exec.ts +++ b/src/tasks/exec.ts @@ -3,7 +3,7 @@ import { Context } from '../types.js' import chalk from 'chalk' import spawn from 'cross-spawn' import { updateLogContext } from '../lib/logger.js' -import { startPolling } from '../lib/utils.js' +import { startPolling, startSSEListener } from '../lib/utils.js' export default (ctx: Context): ListrTask => { return { @@ -16,6 +16,14 @@ export default (ctx: Context): ListrTask { diff --git a/src/types.ts b/src/types.ts index e647b0c..099b1c9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,6 +43,7 @@ export interface Context { loadDomContent?: boolean; approvalThreshold?: number; rejectionThreshold?: number; + showRenderErrors?: boolean }; uploadFilePath: string; webStaticConfig: WebStaticConfig; @@ -71,7 +72,8 @@ export interface Context { fetchResultsFileName?: string, baselineBranch?: string, baselineBuild?: string, - githubURL?: string + githubURL?: string, + showRenderErrors?: boolean } cliVersion: string; totalSnapshots: number; @@ -93,7 +95,7 @@ export interface Context { mergeBuildTargetId?: string; mergeByBranch?: boolean; mergeByBuild?: boolean; - contextToSnapshotMap?: Map; + contextToSnapshotMap?: Map; sourceCommand?: string; autoTunnelStarted?: boolean; } @@ -120,6 +122,8 @@ export interface Env { SMARTUI_API_SKIP_CERTIFICATES: boolean; USE_REMOTE_DISCOVERY: boolean; SMART_GIT: boolean; + SHOW_RENDER_ERRORS: boolean; + SMARTUI_SSE_URL: string; } export interface Snapshot { @@ -164,6 +168,7 @@ export interface Snapshot { useExtendedViewport?: boolean; approvalThreshold?: number; rejectionThreshold?: number; + customCookies?: CustomCookie[]; } } @@ -251,6 +256,16 @@ export interface FigmaWebConfig { configs: Array<{ figma_file_token: string, figma_ids: Array, screenshot_names:Array }>; } +export interface CustomCookie { + name: string; + value: string; + domain: string; + path: string; + httpOnly: boolean; + secure: boolean; + sameSite: 'Strict' | 'Lax' | 'None'; +} + export interface ViewportErrors { statusCode: "aborted" | "404" | string;