diff --git a/package.json b/package.json index cf4a8ea..1e064d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lambdatest/smartui-cli", - "version": "4.1.42-beta.0", + "version": "4.1.42", "description": "A command line interface (CLI) to run SmartUI tests on LambdaTest", "files": [ "dist/**/*" diff --git a/src/lib/ctx.ts b/src/lib/ctx.ts index af5c0bc..2c971ce 100644 --- a/src/lib/ctx.ts +++ b/src/lib/ctx.ts @@ -163,6 +163,8 @@ export default (options: Record): Context => { ignoreHTTPSErrors: config.ignoreHTTPSErrors ?? false, skipBuildCreation: config.skipBuildCreation ?? false, tunnel: tunnelObj, + dedicatedProxyURL: config.dedicatedProxyURL || '', + geolocation: config.geolocation || '', userAgent: config.userAgent || '', requestHeaders: config.requestHeaders || {}, allowDuplicateSnapshotNames: allowDuplicateSnapshotNames, diff --git a/src/lib/httpClient.ts b/src/lib/httpClient.ts index e293457..00de738 100644 --- a/src/lib/httpClient.ts +++ b/src/lib/httpClient.ts @@ -154,7 +154,7 @@ export default class httpClient { }) } - async auth(log: Logger, env: Env): Promise { + async auth(log: Logger, env: Env): Promise<{ authResult: number, orgId: number, userId: number }> { let result = 1; if (this.projectToken) { result = 0; @@ -168,12 +168,20 @@ export default class httpClient { } }, log); if (response && response.projectToken) { + let orgId = 0; + let userId = 0; this.projectToken = response.projectToken; env.PROJECT_TOKEN = response.projectToken; if (response.message && response.message.includes('Project created successfully')) { result = 2; } - return result; + if (response.orgId) { + orgId = response.orgId + } + if (response.userId) { + userId = response.userId + } + return { authResult : result, orgId, userId }; } else { throw new Error('Authentication failed, project token not received'); } @@ -701,6 +709,19 @@ export default class httpClient { }, ctx.log); } + async getGeolocationProxy(geoLocation: string, log: Logger): Promise<{ data?: { proxy: string, username: string, password: string }, statusCode?: number }> { + try { + const resp = await this.request({ + url: '/geolocation', + method: 'GET', + params: { geoLocation } + }, log); + return resp; + } catch (error: any) { + this.handleHttpError(error, log); + } + } + async uploadPdf(ctx: Context, form: FormData, buildName?: string): Promise { form.append('projectToken', this.projectToken); if (ctx.build.name !== undefined && ctx.build.name !== '') { diff --git a/src/lib/schemaValidation.ts b/src/lib/schemaValidation.ts index cbdc3c8..10d5e95 100644 --- a/src/lib/schemaValidation.ts +++ b/src/lib/schemaValidation.ts @@ -264,6 +264,14 @@ const ConfigSchema = { uniqueItems: "Invalid config; duplicates in requestHeaders" } }, + dedicatedProxyURL: { + type: "string", + errorMessage: "Invalid config; dedicatedProxyURL must be a string" + }, + geolocation: { + type: "string", + errorMessage: "Invalid config; geolocation must be a string like 'lat,lon'" + }, allowDuplicateSnapshotNames: { type: "boolean", errorMessage: "Invalid config; allowDuplicateSnapshotNames must be true/false" @@ -341,6 +349,9 @@ const WebStaticConfigSchema: JSONSchemaType = { execute: { type: "object", properties: { + beforeNavigation: { + type: "string", + }, afterNavigation : { type: "string", }, diff --git a/src/lib/screenshot.ts b/src/lib/screenshot.ts index 2c0646d..7798314 100644 --- a/src/lib/screenshot.ts +++ b/src/lib/screenshot.ts @@ -17,6 +17,7 @@ async function captureScreenshotsForConfig( ctx.log.debug(`*** urlConfig ${JSON.stringify(urlConfig)}`); let {name, url, waitForTimeout, execute, pageEvent, userAgent} = urlConfig; + let beforeNavigationScript = execute?.beforeNavigation; let afterNavigationScript = execute?.afterNavigation; let beforeSnapshotScript = execute?.beforeSnapshot; let waitUntilEvent = pageEvent || process.env.SMARTUI_PAGE_WAIT_UNTIL_EVENT || 'load'; @@ -28,6 +29,77 @@ async function captureScreenshotsForConfig( let contextOptions: Record = { ignoreHTTPSErrors: ctx.config.ignoreHTTPSErrors }; + + + + // Resolve proxy/tunnel/geolocation-proxy from global config + try { + if (ctx.config.tunnel && ctx.config.tunnel.tunnelName) { + if (ctx.tunnelDetails && ctx.tunnelDetails.tunnelPort != -1 && ctx.tunnelDetails.tunnelHost) { + const tunnelServer = `http://${ctx.tunnelDetails.tunnelHost}:${ctx.tunnelDetails.tunnelPort}`; + ctx.log.info(`URL Capture :: Using tunnel address: ${tunnelServer}`); + contextOptions.proxy = { server: tunnelServer }; + } else { + let tunnelResp = await ctx.client.getTunnelDetails(ctx, ctx.log); + ctx.log.debug(`Tunnel Response: ${JSON.stringify(tunnelResp)}`) + if (tunnelResp && tunnelResp.data && tunnelResp.data.host && tunnelResp.data.port) { + ctx.tunnelDetails = { + tunnelHost: tunnelResp.data.host, + tunnelPort: tunnelResp.data.port, + tunnelName: tunnelResp.data.tunnel_name + } as any; + const tunnelServer = `http://${ctx.tunnelDetails.tunnelHost}:${ctx.tunnelDetails.tunnelPort}`; + ctx.log.info(`URL Capture :: Using tunnel address: ${tunnelServer}`); + contextOptions.proxy = { server: tunnelServer }; + } else if (tunnelResp && tunnelResp.error) { + if (tunnelResp.error.message) { + ctx.log.warn(`Error while fetching tunnel details: ${tunnelResp.error.message}`) + } + } + } + } else if (ctx.config.geolocation && ctx.config.geolocation !== '') { + // Use cached geolocation proxy if available for the same geolocation key + if (ctx.geolocationData && ctx.geolocationData.proxy && ctx.geolocationData.username && ctx.geolocationData.password && ctx.geolocationData.geoCode === ctx.config.geolocation) { + ctx.log.info(`URL Capture :: Using cached geolocation proxy for ${ctx.config.geolocation}`); + contextOptions.proxy = { + server: ctx.geolocationData.proxy, + username: ctx.geolocationData.username, + password: ctx.geolocationData.password + }; + } else { + const geoResp = await ctx.client.getGeolocationProxy(ctx.config.geolocation, ctx.log); + ctx.log.debug(`Geolocation proxy response: ${JSON.stringify(geoResp)}`); + if (geoResp && geoResp.data && geoResp.data.proxy && geoResp.data.username && geoResp.data.password) { + ctx.log.info(`URL Capture :: Using geolocation proxy for ${ctx.config.geolocation}`); + ctx.geolocationData = { + proxy: geoResp.data.proxy, + username: geoResp.data.username, + password: geoResp.data.password, + geoCode: ctx.config.geolocation + } as any; + contextOptions.proxy = { + server: geoResp.data.proxy, + username: geoResp.data.username, + password: geoResp.data.password + }; + } else { + ctx.log.warn(`Geolocation proxy not available for '${ctx.config.geolocation}', falling back if dedicatedProxyURL present`); + if (ctx.config.dedicatedProxyURL && ctx.config.dedicatedProxyURL !== '') { + ctx.log.info(`URL Capture :: Using dedicated proxy: ${ctx.config.dedicatedProxyURL}`); + contextOptions.proxy = { server: ctx.config.dedicatedProxyURL }; + } + } + } + } else if (ctx.config.dedicatedProxyURL && ctx.config.dedicatedProxyURL !== '') { + ctx.log.info(`URL Capture :: Using dedicated proxy: ${ctx.config.dedicatedProxyURL}`); + contextOptions.proxy = { server: ctx.config.dedicatedProxyURL }; + } + + // Note: when using IP-based geolocation via proxy, browser geolocation permission is not required + + } catch (e) { + ctx.log.debug(`Failed resolving tunnel/proxy details: ${e}`); + } let page: Page; if (browserName == constants.CHROME) contextOptions.userAgent = constants.CHROME_USER_AGENT; else if (browserName == constants.FIREFOX) contextOptions.userAgent = constants.FIREFOX_USER_AGENT; @@ -46,6 +118,16 @@ async function captureScreenshotsForConfig( const browser = browsers[browserName]; context = await browser?.newContext(contextOptions); page = await context?.newPage(); + + if (beforeNavigationScript && beforeNavigationScript !== "") { + const wrappedScript = new Function('page', ` + return (async () => { + ${beforeNavigationScript} + })(); + `); + ctx.log.debug(`Executing before navigation script: ${wrappedScript}`); + await wrappedScript(page); + } const headersObject: Record = {}; if (ctx.config.requestHeaders && Array.isArray(ctx.config.requestHeaders)) { ctx.config.requestHeaders.forEach((headerObj) => { @@ -62,6 +144,12 @@ async function captureScreenshotsForConfig( }); } + if (ctx.config.basicAuthorization) { + ctx.log.debug(`Adding basic authorization to the headers for root url`); + let token = Buffer.from(`${ctx.config.basicAuthorization.username}:${ctx.config.basicAuthorization.password}`).toString('base64'); + headersObject['Authorization'] = `Basic ${token}`; + } + ctx.log.debug(`Combined headers: ${JSON.stringify(headersObject)}`); if (Object.keys(headersObject).length > 0) { await page.setExtraHTTPHeaders(headersObject); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 06d19b4..aff9ff5 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -459,7 +459,7 @@ export async function startPollingForTunnel(ctx: Context, build_id: string, base export async function stopTunnelHelper(ctx: Context) { ctx.log.debug('stop-tunnel:: Stopping the tunnel now'); - const tunnelRunningStatus = await tunnelInstance.isRunning(); + const tunnelRunningStatus = await tunnelInstance?.isRunning(); ctx.log.debug('stop-tunnel:: Running status of tunnel before stopping ? ' + tunnelRunningStatus); const status = await tunnelInstance.stop(); diff --git a/src/tasks/auth.ts b/src/tasks/auth.ts index 3ca9c1b..59576d8 100644 --- a/src/tasks/auth.ts +++ b/src/tasks/auth.ts @@ -10,7 +10,7 @@ export default (ctx: Context): ListrTask>; allowDuplicateSnapshotNames?: boolean; @@ -58,6 +60,12 @@ export interface Context { tunnelHost: string; tunnelName: string; } + geolocationData?: { + proxy: string; + username: string; + password: string; + geoCode: string; + } options: { parallel?: number, force?: boolean,