Skip to content

Commit 78f07be

Browse files
authored
Merge pull request #11 from LambdaTest/stage
Release v1.1.0
2 parents 7a63f81 + d340198 commit 78f07be

File tree

10 files changed

+555
-54
lines changed

10 files changed

+555
-54
lines changed

README.md

+4-2
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,14 @@ smartui config create .smartui.json
4646
```
4747

4848
## Step 3: Execute tests
49-
Run the following command to run tests on your Storybook stories. Provide your storybook url, build name and config file (Default config used if no config file provided)
49+
Run the following command to run visual regression tests on your Storybook components.
5050

5151
```bash
52-
smartui storybook http://localhost:6006 --buildname Build1 --config .smartui.json
52+
smartui storybook http://localhost:6006 --config .smartui.json
5353
```
5454

55+
You can also provide path to the storybook-static directory instead of the local Storybook URL. Use `--help` for more information on usage.
56+
5557
# About LambdaTest
5658

5759
[LambdaTest](https://www.lambdatest.com/) is a cloud based selenium grid infrastructure that can help you run automated cross browser compatibility tests on 2000+ different browser and operating system environments. LambdaTest supports all programming languages and frameworks that are supported with Selenium, and have easy integrations with all popular CI/CD platforms. It's a perfect solution to bring your [selenium automation testing](https://www.lambdatest.com/selenium-automation) to cloud based infrastructure that not only helps you increase your test coverage over multiple desktop and mobile browsers, but also allows you to cut down your test execution time by running tests on parallel.

commands/storybook.js

+20-8
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
const { default: axios } = require('axios')
22
const fs = require('fs');
33
const { sendDoM } = require('./utils/dom')
4-
const { validateStorybookUrl } = require('./utils/validate')
4+
const { validateStorybookUrl, validateStorybookDir } = require('./utils/validate')
55
const { defaultSmartUIConfig } = require('./utils/config')
66
const { skipStory } = require('./utils/story')
7+
const { createStaticServer } = require('./utils/server')
78

8-
async function storybook(url, options) {
9-
// Check connection with storybook url
10-
await validateStorybookUrl(url);
9+
async function storybook(serve, options) {
10+
let server, url;
11+
let type = /^https?:\/\//.test(serve) ? 'url' : 'dir'
12+
if (type === 'url') {
13+
await validateStorybookUrl(serve);
14+
url = serve;
15+
} else {
16+
await validateStorybookDir(serve);
17+
server = await createStaticServer(serve);
18+
url = `http://localhost:${server.address().port}`;
19+
}
1120

1221
// TODO: modularize this and separate check for file exists
1322
let storybookConfig = defaultSmartUIConfig.storybook;
@@ -37,8 +46,8 @@ async function storybook(url, options) {
3746
storybookConfig.browsers = (!storybookConfig.browsers.length) ? 'all' : storybookConfig.browsers.toString();
3847

3948
// Get stories object from stories.json and add url corresponding to every story ID
40-
axios.get(new URL('stories.json', url).href)
41-
.then(function (response) {
49+
await axios.get(new URL('stories.json', url).href)
50+
.then(async function (response) {
4251
let stories = {}
4352
for (const [storyId, storyInfo] of Object.entries(response.data.stories)) {
4453
if (!skipStory(storyInfo.name, storybookConfig)) {
@@ -59,7 +68,7 @@ async function storybook(url, options) {
5968
}
6069
}
6170
// Capture DoM of every story and send it to renderer API
62-
sendDoM(url, stories, storybookConfig, options);
71+
await sendDoM(url, stories, storybookConfig, options);
6372
})
6473
.catch(function (error) {
6574
if (error.response) {
@@ -69,8 +78,11 @@ async function storybook(url, options) {
6978
} else {
7079
console.log('[smartui] Error: Cannot fetch Storybook stories');
7180
}
72-
process.exit(0);
7381
});
82+
83+
// Close static server
84+
if (server) server?.close();
85+
7486
};
7587

7688
module.exports = { storybook };

commands/utils/constants.js

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
var constants = {}
22

3-
constants.env = 'prod';
43
constants.stage = {
54
AUTH_URL: "https://stage-api.lambdatestinternal.com/storybook/auth",
6-
RENDER_API_URL: "https://stage-api.lambdatestinternal.com/storybook/render"
5+
RENDER_API_URL: "https://stage-api.lambdatestinternal.com/storybook/render",
6+
BUILD_STATUS_URL: "https://stage-api.lambdatestinternal.com/storybook/status",
7+
BASE_URL: "https://stage-api.lambdatestinternal.com",
8+
SB_BUILD_VALIDATE_PATH: "/storybook/validate"
79
};
810
constants.prod = {
911
AUTH_URL: "https://api.lambdatest.com/storybook/auth",

commands/utils/dom.js

+124-17
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ const fs = require('fs');
33
const path = require('path')
44
const formData = require('form-data');
55
const { JSDOM } = require("jsdom");
6+
const Table = require('cli-table3');
67
var { constants } = require('./constants');
8+
const { getLastCommit } = require('./git');
9+
10+
var INTERVAL = 2000
11+
const MAX_INTERVAL = 512000
712

813
async function sendDoM(storybookUrl, stories, storybookConfig, options) {
914
const createBrowser = require('browserless')
@@ -20,16 +25,24 @@ async function sendDoM(storybookUrl, stories, storybookConfig, options) {
2025
const browserless = await browser.createContext()
2126
const html = await browserless.html(storyInfo.url)
2227

23-
dom = new JSDOM(html);
24-
for(element of dom.window.document.querySelectorAll('img')) {
28+
dom = new JSDOM(html, {
29+
url: storybookUrl,
30+
resources: 'usable'
31+
});
32+
clone = new JSDOM(html);
33+
34+
// Serialize DOM
35+
for(element of clone.window.document.querySelectorAll('img')) {
2536
let image = new URL(element.getAttribute('src'), storybookUrl).href;
2637
let format = path.extname(image).replace(/^./, '');
2738
format = format === 'svg' ? 'svg+xml' : format
2839
let imageAsBase64 = await getBase64(image);
2940
element.setAttribute('src', 'data:image/'+format+';base64,'+imageAsBase64);
3041
}
42+
await serializeCSSOM(dom, clone);
43+
3144
try {
32-
fs.writeFileSync('doms/' + storyId + '.html', dom.serialize());
45+
fs.writeFileSync('doms/' + storyId + '.html', clone.serialize());
3346
} catch (err) {
3447
console.error(err);
3548
}
@@ -38,41 +51,120 @@ async function sendDoM(storybookUrl, stories, storybookConfig, options) {
3851
}
3952
await browser.close()
4053

41-
// Send html files to the renderer API
54+
// Create form
55+
// let commit = await getLastCommit();
4256
const form = new formData();
4357
for (const [storyId, storyInfo] of Object.entries(stories)) {
4458
const file = fs.readFileSync('doms/' + storyId + '.html');
45-
form.append('html', file, storyInfo.kind + ': ' + storyInfo.name + '.html');
59+
form.append('files', file, storyInfo.kind + ': ' + storyInfo.name + '.html');
4660
}
4761
form.append('resolution', storybookConfig.resolutions);
4862
form.append('browser', storybookConfig.browsers);
4963
form.append('projectToken', process.env.PROJECT_TOKEN);
50-
form.append('buildName', options.buildname);
51-
axios.post(constants[constants.env].RENDER_API_URL, form, {
64+
// form.append('branch', commit.branch);
65+
// form.append('commitId', commit.shortHash);
66+
// form.append('commitAuthor', commit.author.name);
67+
// form.append('commitMessage', commit.subject);
68+
69+
// Send DOM to render API
70+
await axios.post(constants[options.env].RENDER_API_URL, form, {
5271
headers: {
5372
...form.getHeaders()
5473
}
5574
})
56-
.then(function (response) {
57-
console.log('[smartui] Build successful')
75+
.then(async function (response) {
76+
console.log('[smartui] Build in progress...');
77+
await shortPolling(response.data.buildId, 0, options);
5878
})
5979
.catch(function (error) {
60-
fs.rm('doms', {recursive: true}, (err) => {
61-
if (err) {
62-
return console.error(err);
63-
}
64-
});
65-
console.log('[smartui] Build failed: Error: ', error.message);
66-
process.exit(0);
80+
if (error.response) {
81+
console.log('[smartui] Build failed: Error: ', error.response.data.message);
82+
} else {
83+
console.log('[smartui] Build failed: Error: ', error.message);
84+
}
6785
});
68-
86+
6987
fs.rm('doms', {recursive: true}, (err) => {
7088
if (err) {
7189
return console.error(err);
7290
}
7391
});
7492
};
7593

94+
async function shortPolling(buildId, retries = 0, options) {
95+
await axios.get(new URL('?buildId=' + buildId, constants[options.env].BUILD_STATUS_URL).href, {
96+
headers: {
97+
projectToken: process.env.PROJECT_TOKEN
98+
}})
99+
.then(function (response) {
100+
if (response.data) {
101+
if (response.data.buildStatus === 'completed') {
102+
console.log('[smartui] Build successful\n');
103+
console.log('[smartui] Build details:\n',
104+
'Build URL: ', response.data.buildURL, '\n',
105+
'Build Name: ', response.data.buildName, '\n',
106+
'Total Screenshots: ', response.data.totalScreenshots, '\n',
107+
'Approved: ', response.data.buildResults.approved, '\n',
108+
'Changes found: ', response.data.buildResults.changesFound, '\n'
109+
);
110+
111+
if (response.data.screenshots && response.data.screenshots.length > 0) {
112+
import('chalk').then((chalk) => {
113+
const table = new Table({
114+
head: [
115+
{content: chalk.default.white('Story'), hAlign: 'center'},
116+
{content: chalk.default.white('Mis-match %'), hAlign: 'center'},
117+
]
118+
});
119+
response.data.screenshots.forEach(screenshot => {
120+
let mismatch = screenshot.mismatchPercentage
121+
table.push([
122+
chalk.default.yellow(screenshot.storyName),
123+
mismatch > 0 ? chalk.default.red(mismatch) : chalk.default.green(mismatch)
124+
])
125+
});
126+
console.log(table.toString());
127+
})
128+
} else {
129+
if (response.data.baseline) {
130+
console.log('No comparisons run. This is a baseline build.');
131+
} else {
132+
console.log('No comparisons run. No screenshot in the current build has the corresponding screenshot in baseline build.');
133+
}
134+
}
135+
return;
136+
} else {
137+
if (response.data.screenshots && response.data.screenshots.length > 0) {
138+
// TODO: show Screenshots processed current/total
139+
console.log('[smartui] Screenshots compared: ', response.data.screenshots.length)
140+
}
141+
}
142+
}
143+
144+
// Double the INTERVAL, up to the maximum INTERVAL of 512 secs (so ~15 mins in total)
145+
INTERVAL = Math.min(INTERVAL * 2, MAX_INTERVAL);
146+
if (INTERVAL == MAX_INTERVAL) {
147+
console.log('[smartui] Please check the build status on LambdaTest SmartUI.');
148+
return;
149+
}
150+
151+
setTimeout(function () {
152+
shortPolling(buildId, 0, options)
153+
}, INTERVAL);
154+
})
155+
.catch(function (error) {
156+
if (retries >= 3) {
157+
console.log('[smartui] Error: Failed getting build status.', error.message);
158+
console.log('[smartui] Please check the build status on LambdaTest SmartUI.');
159+
return;
160+
}
161+
162+
setTimeout(function () {
163+
shortPolling(buildId, retries+1, options);
164+
}, 2000);
165+
});
166+
};
167+
76168
function getBase64(url) {
77169
return axios.get(url, {
78170
responseType: "text",
@@ -85,4 +177,19 @@ function getBase64(url) {
85177
});
86178
}
87179

180+
async function serializeCSSOM(dom, clone) {
181+
return new Promise(resolve => {
182+
dom.window.addEventListener("load", () => {
183+
for (let styleSheet of dom.window.document.styleSheets) {
184+
let style = clone.window.document.createElement('style');
185+
style.type = 'text/css';
186+
style.innerHTML = Array.from(styleSheet.cssRules)
187+
.map(cssRule => cssRule.cssText).join('\n');
188+
clone.window.document.head.appendChild(style);
189+
}
190+
resolve();
191+
});
192+
});
193+
}
194+
88195
module.exports = { sendDoM };

commands/utils/git.js

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
const cp = require('child_process');
2+
3+
function executeCommand(command, options) {
4+
let dst = process.cwd()
5+
6+
if(!!options && options.dst) {
7+
dst = options.dst
8+
}
9+
10+
try {
11+
return cp.execSync(command, {
12+
cwd: dst,
13+
stdio: ['ignore', 'pipe', 'ignore'],
14+
encoding: 'utf-8'
15+
});
16+
} catch (error) {
17+
return '';
18+
}
19+
}
20+
21+
async function getLastCommit(options) {
22+
const splitCharacter = '<##>';
23+
const prettyFormat = ["%h", "%H", "%s", "%f", "%b", "%at", "%ct", "%an", "%ae", "%cn", "%ce", "%N", ""];
24+
const command = 'git log -1 --pretty=format:"' + prettyFormat.join(splitCharacter) + '"' +
25+
' && git rev-parse --abbrev-ref HEAD' +
26+
' && git tag --contains HEAD'
27+
28+
res = executeCommand(command, options);
29+
res = res.split(splitCharacter);
30+
31+
// e.g. master\n or master\nv1.1\n or master\nv1.1\nv1.2\n
32+
var branchAndTags = res[res.length-1].split('\n').filter(n => n);
33+
var branch = branchAndTags[0];
34+
var tags = branchAndTags.slice(1);
35+
36+
return {
37+
shortHash: res[0] || '',
38+
hash: res[1] || '',
39+
subject: res[2] || '',
40+
sanitizedSubject: res[3] || '',
41+
body: res[4] || '',
42+
authoredOn: res[5] || '',
43+
committedOn: res[6] || '',
44+
author: {
45+
name: res[7] || '',
46+
email: res[8] || '',
47+
},
48+
committer: {
49+
name: res[9] || '',
50+
email: res[10] || ''
51+
},
52+
notes: res[11] || '',
53+
branch: branch || '',
54+
tags: tags || []
55+
};
56+
}
57+
58+
module.exports = { getLastCommit }

commands/utils/server.js

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
var http = require('http');
2+
var finalhandler = require('finalhandler');
3+
var serveStatic = require('serve-static');
4+
5+
async function createStaticServer(staticPath) {
6+
var serve = serveStatic(staticPath, {'index': false});
7+
var server = http.createServer(function(req, res){
8+
var done = finalhandler(req, res);
9+
serve(req, res, done);
10+
})
11+
12+
return server.listen();
13+
}
14+
15+
module.exports = { createStaticServer }

0 commit comments

Comments
 (0)