Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/commander/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ command
console.log('\nRefer docs: https://www.lambdatest.com/support/docs/smart-visual-regression-testing/')
} finally {
await ctx.server?.close();
await ctx.browser?.close();
}
})

Expand Down
4 changes: 2 additions & 2 deletions src/lib/httpClient.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from 'fs';
import FormData from 'form-data';
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import { Env, Snapshot, Git, Build, Context } from '../types.js';
import { Env, ProcessedSnapshot, Git, Build } from '../types.js';
import { delDir } from './utils.js';
import type { Logger } from 'winston'

Expand Down Expand Up @@ -78,7 +78,7 @@ export default class httpClient {
}, log)
}

uploadSnapshot(buildId: string, snapshot: Snapshot, testType: string, log: Logger) {
uploadSnapshot(buildId: string, snapshot: ProcessedSnapshot, testType: string, log: Logger) {
return this.request({
url: `/builds/${buildId}/snapshot`,
method: 'POST',
Expand Down
80 changes: 80 additions & 0 deletions src/lib/processSnapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Snapshot, Context, ProcessedSnapshot } from "../types.js";
import { chromium, Locator } from "@playwright/test"

const MIN_VIEWPORT_HEIGHT = 1080;

export default async (snapshot: Snapshot, ctx: Context): Promise<ProcessedSnapshot> => {
// Process snapshot options
let options = snapshot.options;
let processedOptions: Record<string, any> = {};
if (options && Object.keys(options).length !== 0) {
ctx.log.debug(`Processing options: ${JSON.stringify(options)}`);

if ((options.ignoreDOM && Object.keys(options.ignoreDOM).length !== 0) || (options.selectDOM && Object.keys(options.selectDOM).length !== 0)) {
if (!ctx.browser) ctx.browser = await chromium.launch({ headless: true });
const page = await ctx.browser.newPage();
await page.setContent(snapshot.dom.html);

let ignoreOrSelectDOM: string;
let ignoreOrSelectBoxes: string;
if (options.ignoreDOM && Object.keys(options.ignoreDOM).length !== 0) {
processedOptions.ignoreBoxes = {};
ignoreOrSelectDOM = 'ignoreDOM';
ignoreOrSelectBoxes = 'ignoreBoxes';
} else {
processedOptions.selectBoxes = {};
ignoreOrSelectDOM = 'selectDOM';
ignoreOrSelectBoxes = 'selectBoxes';
}

let selectors: Array<string> = [];
for (const [key, value] of Object.entries(options[ignoreOrSelectDOM])) {
switch (key) {
case 'id':
selectors.push(...value.map(e => '#' + e));
break;
case 'class':
selectors.push(...value.map(e => '.' + e));
break;
case 'xpath':
selectors.push(...value.map(e => 'xpath=' + e));
break;
case 'cssSelectors':
selectors.push(...value);
break;
}
}

for (const vp of ctx.webConfig.viewports) {
await page.setViewportSize({ width: vp.width, height: vp.height || MIN_VIEWPORT_HEIGHT});
let viewport: string = `${vp.width}${vp.height ? 'x'+vp.height : ''}`;
if (!Array.isArray(processedOptions[ignoreOrSelectBoxes][viewport])) processedOptions[ignoreOrSelectBoxes][viewport] = []

let locators: Array<Locator> = [];
let boxes: Array<Record<string, number>> = [];
for (const selector of selectors) {
let l = await page.locator(selector).all()
locators.push(...l);
}
for (const locator of locators) {
let bb = await locator.boundingBox();
if (bb) boxes.push({
left: bb.x,
top: bb.y,
right: bb.x + bb.width,
bottom: bb.y + bb.height
});
}

processedOptions[ignoreOrSelectBoxes][viewport].push(...boxes);
}
}
}

return {
name: snapshot.name,
url: snapshot.url,
dom: Buffer.from(snapshot.dom.html).toString('base64'),
options: processedOptions
}
}
89 changes: 88 additions & 1 deletion src/lib/schemaValidation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { WebStaticConfig } from '../types.js'
import { Snapshot, WebStaticConfig } from '../types.js'
import Ajv, { JSONSchemaType } from 'ajv'
import addErrors from 'ajv-errors'

Expand Down Expand Up @@ -105,5 +105,92 @@ const WebStaticConfigSchema: JSONSchemaType<WebStaticConfig> = {
uniqueItems: true
}

const SnapshotSchema: JSONSchemaType<Snapshot> = {
type: "object",
properties: {
name: {
type: "string",
minLength: 1,
errorMessage: "Invalid snapshot; name is mandatory and cannot be empty"
},
url: {
type: "string",
format: "web-url",
errorMessage: "Invalid snapshot; url is mandatory and must be a valid web URL"
},
dom: {
type: "object",
},
options: {
type: "object",
properties: {
ignoreDOM: {
type: "object",
properties: {
id: {
type: "array",
items: { type: "string", minLength: 1 },
uniqueItems: true,
errorMessage: "Invalid snapshot options; id array must be of unique and non-empty items"
},
class: {
type: "array",
items: { type: "string", minLength: 1 },
uniqueItems: true,
errorMessage: "Invalid snapshot options; class array must be of unique and non-empty items"
},
cssSelector: {
type: "array",
items: { type: "string", minLength: 1 },
uniqueItems: true,
errorMessage: "Invalid snapshot options; cssSelector array must be of unique and non-empty items"
},
xpath: {
type: "array",
items: { type: "string", minLength: 1 },
uniqueItems: true,
errorMessage: "Invalid snapshot options; xpath array must be of unique and non-empty items"
},
}
},
selectDOM: {
type: "object",
properties: {
id: {
type: "array",
items: { type: "string", minLength: 1 },
uniqueItems: true,
errorMessage: "Invalid snapshot options; id array must be of unique and non-empty items"
},
class: {
type: "array",
items: { type: "string", minLength: 1 },
uniqueItems: true,
errorMessage: "Invalid snapshot options; class array must be of unique and non-empty items"
},
cssSelector: {
type: "array",
items: { type: "string", minLength: 1 },
uniqueItems: true,
errorMessage: "Invalid snapshot options; cssSelector array must be of unique and non-empty items"
},
xpath: {
type: "array",
items: { type: "string", minLength: 1 },
uniqueItems: true,
errorMessage: "Invalid snapshot options; xpath array must be of unique and non-empty items"
},
}
}
},
additionalProperties: false
}
},
required: ["name", "url", "dom", "options"],
additionalProperties: false,
errorMessage: "Invalid snapshot"
}

export const validateConfig = ajv.compile(ConfigSchema);
export const validateWebStaticConfig = ajv.compile(WebStaticConfigSchema);
export const validateSnapshot = ajv.compile(SnapshotSchema);
4 changes: 2 additions & 2 deletions src/lib/screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const BROWSER_FIREFOX = 'firefox';
const BROWSER_EDGE = 'edge';
const EDGE_CHANNEL = 'msedge';
const PW_WEBKIT = 'webkit';
const MIN_RESOLUTION_HEIGHT = 320;
const MIN_VIEWPORT_HEIGHT = 1080;

export async function captureScreenshots(ctx: Context, screenshots: WebStaticConfig): Promise<number> {
// Clean up directory to store screenshots
Expand Down Expand Up @@ -60,7 +60,7 @@ export async function captureScreenshots(ctx: Context, screenshots: WebStaticCon
let { width, height } = ctx.webConfig.viewports[k];
let ssName = `${browserName}-${width}x${height}-${screenshotId}.png`
let ssPath = `screenshots/${screenshotId}/${ssName}.png`
await page.setViewportSize({ width, height: height || MIN_RESOLUTION_HEIGHT })
await page.setViewportSize({ width, height: height || MIN_VIEWPORT_HEIGHT });
if (height === 0) await page.evaluate(scrollToBottomAndBackToTop);
await page.waitForTimeout(screenshot.waitForTimeout || 0);
await page.screenshot({ path: ssPath, fullPage: height ? false: true });
Expand Down
9 changes: 6 additions & 3 deletions src/lib/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import path from 'path';
import fastify, { FastifyInstance, RouteShorthandOptions } from 'fastify';
import { readFileSync } from 'fs'
import { Context } from '../types.js'
import processSnapshot from './processSnapshot.js'
import { validateSnapshot } from './schemaValidation.js'

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

Expand All @@ -22,10 +24,11 @@ export default async (ctx: Context): Promise<FastifyInstance<Server, IncomingMes

// upload snpashot
server.post('/snapshot', opts, async (request, reply) => {
let { snapshot, testType } = request.body;
snapshot.dom = Buffer.from(snapshot.dom).toString('base64');
try {
await ctx.client.uploadSnapshot(ctx.build.id, snapshot, testType, ctx.log)
let { snapshot, testType } = request.body;
if (!validateSnapshot(snapshot)) throw new Error(validateSnapshot.errors[0].message);
let processedSnapshot = await processSnapshot(snapshot, ctx);
await ctx.client.uploadSnapshot(ctx.build.id, processedSnapshot, testType, ctx.log)
} catch (error: any) {
reply.code(500).send({ error: { message: error.message}})
}
Expand Down
31 changes: 29 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import { FastifyInstance } from 'fastify'
import httpClient from './lib/httpClient.js'
import type { Logger } from 'winston'
import { ListrTaskWrapper, ListrRenderer } from "listr2";
import { Browser } from '@playwright/test';

export interface Context {
env: Env;
log: Logger;
task: ListrTaskWrapper<Context, typeof ListrRenderer, typeof ListrRenderer>
task?: ListrTaskWrapper<Context, typeof ListrRenderer, typeof ListrRenderer>;
server?: FastifyInstance<Server, IncomingMessage, ServerResponse>;
client: httpClient;
browser?: Browser;
webConfig: {
browsers: Array<string>;
viewports: Array<{width: number, height: number}>;
Expand All @@ -34,8 +36,33 @@ export interface Env {
}

export interface Snapshot {
url: string;
name: string;
dom: string;
dom: Record<string, any>;
options: {
ignoreDOM?: {
id?: Array<string>,
class?: Array<string>,
cssSelector?: Array<string>,
xpath?: Array<string>
},
selectDOM?: {
id?: Array<string>,
class?: Array<string>,
cssSelector?: Array<string>,
xpath?: Array<string>
}
}
}

export interface ProcessedSnapshot {
url: string,
name: string,
dom: string,
options: {
ignoreBoxes?: Record<string, Array<Record<string, number>>>,
selectBoxes?: Record<string, Array<Record<string, number>>>
}
}

export interface Git {
Expand Down