Skip to content

Commit 2d92bdc

Browse files
authored
Merge pull request #44 from LambdaTest/stage
Release v2.0.5
2 parents ef0253d + 8ed72cd commit 2d92bdc

File tree

8 files changed

+223
-15
lines changed

8 files changed

+223
-15
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@lambdatest/smartui-cli",
3-
"version": "2.0.4",
3+
"version": "2.0.5",
44
"description": "A command line interface (CLI) to run SmartUI tests on LambdaTest",
55
"files": [
66
"dist/**/*"

src/commander/exec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ command
5353
console.log('\nRefer docs: https://www.lambdatest.com/support/docs/smart-visual-regression-testing/')
5454
} finally {
5555
await ctx.server?.close();
56+
await ctx.browser?.close();
5657
}
5758
})
5859

src/lib/httpClient.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from 'fs';
22
import FormData from 'form-data';
33
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
4-
import { Env, Snapshot, Git, Build, Context } from '../types.js';
4+
import { Env, ProcessedSnapshot, Git, Build } from '../types.js';
55
import { delDir } from './utils.js';
66
import type { Logger } from 'winston'
77

@@ -78,7 +78,7 @@ export default class httpClient {
7878
}, log)
7979
}
8080

81-
uploadSnapshot(buildId: string, snapshot: Snapshot, testType: string, log: Logger) {
81+
uploadSnapshot(buildId: string, snapshot: ProcessedSnapshot, testType: string, log: Logger) {
8282
return this.request({
8383
url: `/builds/${buildId}/snapshot`,
8484
method: 'POST',

src/lib/processSnapshot.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { Snapshot, Context, ProcessedSnapshot } from "../types.js";
2+
import { chromium, Locator } from "@playwright/test"
3+
4+
const MIN_VIEWPORT_HEIGHT = 1080;
5+
6+
export default async (snapshot: Snapshot, ctx: Context): Promise<Record<string, any>> => {
7+
// Process snapshot options
8+
let options = snapshot.options;
9+
let warnings: Array<string> = [];
10+
let processedOptions: Record<string, any> = {};
11+
if (options && Object.keys(options).length !== 0) {
12+
ctx.log.debug(`Processing options: ${JSON.stringify(options)}`);
13+
14+
if ((options.ignoreDOM && Object.keys(options.ignoreDOM).length !== 0) || (options.selectDOM && Object.keys(options.selectDOM).length !== 0)) {
15+
if (!ctx.browser) ctx.browser = await chromium.launch({ headless: true });
16+
17+
let ignoreOrSelectDOM: string;
18+
let ignoreOrSelectBoxes: string;
19+
if (options.ignoreDOM && Object.keys(options.ignoreDOM).length !== 0) {
20+
processedOptions.ignoreBoxes = {};
21+
ignoreOrSelectDOM = 'ignoreDOM';
22+
ignoreOrSelectBoxes = 'ignoreBoxes';
23+
} else {
24+
processedOptions.selectBoxes = {};
25+
ignoreOrSelectDOM = 'selectDOM';
26+
ignoreOrSelectBoxes = 'selectBoxes';
27+
}
28+
29+
let selectors: Array<string> = [];
30+
for (const [key, value] of Object.entries(options[ignoreOrSelectDOM])) {
31+
switch (key) {
32+
case 'id':
33+
selectors.push(...value.map(e => '#' + e));
34+
break;
35+
case 'class':
36+
selectors.push(...value.map(e => '.' + e));
37+
break;
38+
case 'xpath':
39+
selectors.push(...value.map(e => 'xpath=' + e));
40+
break;
41+
case 'cssSelector':
42+
selectors.push(...value);
43+
break;
44+
}
45+
}
46+
47+
for (const vp of ctx.webConfig.viewports) {
48+
const page = await ctx.browser.newPage({ viewport: { width: vp.width, height: vp.height || MIN_VIEWPORT_HEIGHT}});
49+
await page.setContent(snapshot.dom.html);
50+
51+
let viewport: string = `${vp.width}${vp.height ? 'x'+vp.height : ''}`;
52+
if (!Array.isArray(processedOptions[ignoreOrSelectBoxes][viewport])) processedOptions[ignoreOrSelectBoxes][viewport] = []
53+
54+
let locators: Array<Locator> = [];
55+
let boxes: Array<Record<string, number>> = [];
56+
for (const selector of selectors) {
57+
let l = await page.locator(selector).all()
58+
if (l.length === 0) {
59+
warnings.push(`For snapshot ${snapshot.name}, no element found for selector ${selector}`);
60+
continue;
61+
}
62+
locators.push(...l);
63+
}
64+
for (const locator of locators) {
65+
let bb = await locator.boundingBox();
66+
if (bb) boxes.push({
67+
left: bb.x,
68+
top: bb.y,
69+
right: bb.x + bb.width,
70+
bottom: bb.y + bb.height
71+
});
72+
}
73+
74+
processedOptions[ignoreOrSelectBoxes][viewport].push(...boxes);
75+
await page.close();
76+
}
77+
}
78+
}
79+
80+
warnings.push(...snapshot.dom.warnings);
81+
return {
82+
processedSnapshot: {
83+
name: snapshot.name,
84+
url: snapshot.url,
85+
dom: Buffer.from(snapshot.dom.html).toString('base64'),
86+
options: processedOptions
87+
},
88+
warnings
89+
}
90+
}

src/lib/schemaValidation.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { WebStaticConfig } from '../types.js'
1+
import { Snapshot, WebStaticConfig } from '../types.js'
22
import Ajv, { JSONSchemaType } from 'ajv'
33
import addErrors from 'ajv-errors'
44

@@ -105,5 +105,92 @@ const WebStaticConfigSchema: JSONSchemaType<WebStaticConfig> = {
105105
uniqueItems: true
106106
}
107107

108+
const SnapshotSchema: JSONSchemaType<Snapshot> = {
109+
type: "object",
110+
properties: {
111+
name: {
112+
type: "string",
113+
minLength: 1,
114+
errorMessage: "Invalid snapshot; name is mandatory and cannot be empty"
115+
},
116+
url: {
117+
type: "string",
118+
format: "web-url",
119+
errorMessage: "Invalid snapshot; url is mandatory and must be a valid web URL"
120+
},
121+
dom: {
122+
type: "object",
123+
},
124+
options: {
125+
type: "object",
126+
properties: {
127+
ignoreDOM: {
128+
type: "object",
129+
properties: {
130+
id: {
131+
type: "array",
132+
items: { type: "string", minLength: 1, pattern: "^[^;]*$", errorMessage: "Invalid snapshot options; id cannot be empty or have semicolon" },
133+
uniqueItems: true,
134+
errorMessage: "Invalid snapshot options; id array must have unique items"
135+
},
136+
class: {
137+
type: "array",
138+
items: { type: "string", minLength: 1, pattern: "^[^;]*$", errorMessage: "Invalid snapshot options; class cannot be empty or have semicolon" },
139+
uniqueItems: true,
140+
errorMessage: "Invalid snapshot options; class array must have unique items"
141+
},
142+
cssSelector: {
143+
type: "array",
144+
items: { type: "string", minLength: 1, pattern: "^[^;]*$", errorMessage: "Invalid snapshot options; cssSelector cannot be empty or have semicolon" },
145+
uniqueItems: true,
146+
errorMessage: "Invalid snapshot options; cssSelector array must have unique items"
147+
},
148+
xpath: {
149+
type: "array",
150+
items: { type: "string", minLength: 1 },
151+
uniqueItems: true,
152+
errorMessage: "Invalid snapshot options; xpath array must have unique and non-empty items"
153+
},
154+
}
155+
},
156+
selectDOM: {
157+
type: "object",
158+
properties: {
159+
id: {
160+
type: "array",
161+
items: { type: "string", minLength: 1, pattern: "^[^;]*$", errorMessage: "Invalid snapshot options; id cannot be empty or have semicolon" },
162+
uniqueItems: true,
163+
errorMessage: "Invalid snapshot options; id array must have unique items"
164+
},
165+
class: {
166+
type: "array",
167+
items: { type: "string", minLength: 1, pattern: "^[^;]*$", errorMessage: "Invalid snapshot options; class cannot be empty or have semicolon" },
168+
uniqueItems: true,
169+
errorMessage: "Invalid snapshot options; class array must have unique items"
170+
},
171+
cssSelector: {
172+
type: "array",
173+
items: { type: "string", minLength: 1, pattern: "^[^;]*$", errorMessage: "Invalid snapshot options; cssSelector cannot be empty or have semicolon" },
174+
uniqueItems: true,
175+
errorMessage: "Invalid snapshot options; cssSelector array must have unique items"
176+
},
177+
xpath: {
178+
type: "array",
179+
items: { type: "string", minLength: 1 },
180+
uniqueItems: true,
181+
errorMessage: "Invalid snapshot options; xpath array must have unique and non-empty items"
182+
},
183+
}
184+
}
185+
},
186+
additionalProperties: false
187+
}
188+
},
189+
required: ["name", "url", "dom", "options"],
190+
additionalProperties: false,
191+
errorMessage: "Invalid snapshot"
192+
}
193+
108194
export const validateConfig = ajv.compile(ConfigSchema);
109195
export const validateWebStaticConfig = ajv.compile(WebStaticConfigSchema);
196+
export const validateSnapshot = ajv.compile(SnapshotSchema);

src/lib/screenshot.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const BROWSER_FIREFOX = 'firefox';
99
const BROWSER_EDGE = 'edge';
1010
const EDGE_CHANNEL = 'msedge';
1111
const PW_WEBKIT = 'webkit';
12-
const MIN_RESOLUTION_HEIGHT = 320;
12+
const MIN_VIEWPORT_HEIGHT = 1080;
1313

1414
export async function captureScreenshots(ctx: Context, screenshots: WebStaticConfig): Promise<number> {
1515
// Clean up directory to store screenshots
@@ -60,7 +60,7 @@ export async function captureScreenshots(ctx: Context, screenshots: WebStaticCon
6060
let { width, height } = ctx.webConfig.viewports[k];
6161
let ssName = `${browserName}-${width}x${height}-${screenshotId}.png`
6262
let ssPath = `screenshots/${screenshotId}/${ssName}.png`
63-
await page.setViewportSize({ width, height: height || MIN_RESOLUTION_HEIGHT })
63+
await page.setViewportSize({ width, height: height || MIN_VIEWPORT_HEIGHT });
6464
if (height === 0) await page.evaluate(scrollToBottomAndBackToTop);
6565
await page.waitForTimeout(screenshot.waitForTimeout || 0);
6666
await page.screenshot({ path: ssPath, fullPage: height ? false: true });

src/lib/server.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import path from 'path';
33
import fastify, { FastifyInstance, RouteShorthandOptions } from 'fastify';
44
import { readFileSync } from 'fs'
55
import { Context } from '../types.js'
6+
import processSnapshot from './processSnapshot.js'
7+
import { validateSnapshot } from './schemaValidation.js'
68

79
export default async (ctx: Context): Promise<FastifyInstance<Server, IncomingMessage, ServerResponse>> => {
810

@@ -22,16 +24,17 @@ export default async (ctx: Context): Promise<FastifyInstance<Server, IncomingMes
2224

2325
// upload snpashot
2426
server.post('/snapshot', opts, async (request, reply) => {
25-
let { snapshot, testType } = request.body;
26-
snapshot.dom = Buffer.from(snapshot.dom).toString('base64');
2727
try {
28-
await ctx.client.uploadSnapshot(ctx.build.id, snapshot, testType, ctx.log)
28+
let { snapshot, testType } = request.body;
29+
if (!validateSnapshot(snapshot)) throw new Error(validateSnapshot.errors[0].message);
30+
let { processedSnapshot, warnings } = await processSnapshot(snapshot, ctx);
31+
await ctx.client.uploadSnapshot(ctx.build.id, processedSnapshot, testType, ctx.log);
32+
33+
ctx.totalSnapshots++
34+
reply.code(200).send({data: { message: "success", warnings }});
2935
} catch (error: any) {
30-
reply.code(500).send({ error: { message: error.message}})
36+
return reply.code(500).send({ error: { message: error.message}});
3137
}
32-
33-
ctx.totalSnapshots++
34-
reply.code(200).send({data: { message: "success" }});
3538
});
3639

3740

src/types.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import { FastifyInstance } from 'fastify'
33
import httpClient from './lib/httpClient.js'
44
import type { Logger } from 'winston'
55
import { ListrTaskWrapper, ListrRenderer } from "listr2";
6+
import { Browser } from '@playwright/test';
67

78
export interface Context {
89
env: Env;
910
log: Logger;
10-
task: ListrTaskWrapper<Context, typeof ListrRenderer, typeof ListrRenderer>
11+
task?: ListrTaskWrapper<Context, typeof ListrRenderer, typeof ListrRenderer>;
1112
server?: FastifyInstance<Server, IncomingMessage, ServerResponse>;
1213
client: httpClient;
14+
browser?: Browser;
1315
webConfig: {
1416
browsers: Array<string>;
1517
viewports: Array<{width: number, height: number}>;
@@ -34,8 +36,33 @@ export interface Env {
3436
}
3537

3638
export interface Snapshot {
39+
url: string;
3740
name: string;
38-
dom: string;
41+
dom: Record<string, any>;
42+
options: {
43+
ignoreDOM?: {
44+
id?: Array<string>,
45+
class?: Array<string>,
46+
cssSelector?: Array<string>,
47+
xpath?: Array<string>
48+
},
49+
selectDOM?: {
50+
id?: Array<string>,
51+
class?: Array<string>,
52+
cssSelector?: Array<string>,
53+
xpath?: Array<string>
54+
}
55+
}
56+
}
57+
58+
export interface ProcessedSnapshot {
59+
url: string,
60+
name: string,
61+
dom: string,
62+
options: {
63+
ignoreBoxes?: Record<string, Array<Record<string, number>>>,
64+
selectBoxes?: Record<string, Array<Record<string, number>>>
65+
}
3966
}
4067

4168
export interface Git {

0 commit comments

Comments
 (0)