Skip to content

Instantly share code, notes, and snippets.

@SilentImp
Last active April 13, 2020 16:29
Show Gist options
  • Save SilentImp/691d7f8874361e422391e1ca3034f98a to your computer and use it in GitHub Desktop.
Save SilentImp/691d7f8874361e422391e1ca3034f98a to your computer and use it in GitHub Desktop.
require('dotenv').config();
const fs = require('fs');
const lighthouse = require('lighthouse');
const puppeteer = require('puppeteer');
const cluster = require('cluster');
const numCPUs = require('os').cpus().length;
const DOMAIN = process.env.DOMAIN;
const fetch = require('isomorphic-fetch');
const FormData = require('form-data');
const branchName = require('current-git-branch');
// Branch from which we are making merge request
// In the pipeline we have environment variable `CI_COMMIT_REF_NAME`,
// which contains name of this banch. Function `branchName`
// will return something like «HEAD detached» message in the pipeline.
// And name of the branch outside of pipeline
const CURRENT_BRANCH = process.env.CI_COMMIT_REF_NAME || branchName();
// Merge request target branch, usually it's master
const DEFAULT_BRANCH = process.env.CI_DEFAULT_BRANCH || 'master';
// ID of the project
const GITLAB_PROJECT_ID = process.env.CI_PROJECT_ID || '18090019';
// Token
const TOKEN = process.env.GITLAB_TOKEN;
// GitLab domain
const GITLAB_DOMAIN = process.env.CI_SERVER_HOST || process.env.GITLAB_DOMAIN || 'gitlab.com';
// User or organization name
const NAME_SPACE = process.env.CI_PROJECT_NAMESPACE || process.env.PROJECT_NAMESPACE || 'silentimp';
// Repo name
const PROJECT = process.env.CI_PROJECT_NAME || process.env.PROJECT_NAME || 'lighthouse-comments';
// Name of the job, which create an artifact
const JOB_NAME = process.env.CI_JOB_NAME || process.env.JOB_NAME || 'lighthouse';
/**
* Return part of report for specific page
*
* @param report {Object} — report
* @param subject {String} — subject, that allow find specific page
* @return {Object} — page report
*/
const getPage = (report, subject) => report.find(item => (item.subject === subject));
/**
* Return specific metric for the page
*
* @param page {Object} — page
* @param name {String} — metrics name
* @return {Object} — metric
*/
const getMetric = (page, name) => page.metrics.find(item => item.name === name);
/**
* Return table cell for desired metric
*
* @param branch {Object} - report from feature branch
* @param master {Object} - report from master branch
* @param name {String} - metrics name
*/
const buildCell = (branch, master, name) => {
const branchMetric = getMetric(branch, name);
const masterMetric = getMetric(master, name);
const branchValue = branchMetric.value;
const masterValue = masterMetric.value;
const desiredLarger = branchMetric.desiredSize === 'larger';
const isChanged = branchValue !== masterValue;
const larger = branchValue > masterValue;
if (!isChanged) return `${branchValue}`;
if (larger) return `${branchValue} ${desiredLarger ? '💚' : '💔' } **+${Math.abs(branchValue - masterValue).toFixed(2)}**`;
return `${branchValue} ${!desiredLarger ? '💚' : '💔' } **-${Math.abs(branchValue - masterValue).toFixed(2)}**`;
};
/**
* Returns text of the comment with table inside
* This table contain changes in all metrics
*
* @param branch {Object} report from feature branch
* @param master {Object} report from master branch
* @return {String} comment markdown
*/
const buildCommentText = (branch, master) =>{
const md = branch.map( page => {
const pageAtMaster = getPage(master, page.subject);
if (!pageAtMaster) return '';
const md = `|${page.subject}|${buildCell(page, pageAtMaster, 'Performance')}|${buildCell(page, pageAtMaster, 'Accessibility')}|${buildCell(page, pageAtMaster, 'Best Practices')}|${buildCell(page, pageAtMaster, 'SEO')}|
`;
return md;
}).join('');
return `
|Path|Performance|Accessibility|Best Practices|SEO|
|--- |--- |--- |--- |--- |
${md}
`;
};
/**
* Returns an artifact
*
* @param name {String} artifact file name
* @return {Object} object with performance artifact
* @throw {Error} thhrow an error, if artifact contain string, that can't be parsed as a JSON. Or in case of fetch errors.
*/
const getArtifact = async name => {
const response = await fetch(`https://${GITLAB_DOMAIN}/${NAME_SPACE}/${PROJECT}/-/jobs/artifacts/master/raw/${name}?job=${JOB_NAME}`);
if (!response.ok) throw new Error('Artifact not found');
const data = await response.json();
return data;
};
/**
* Returns iid of the merge request from feature branch to master
* @param from {String} — name of the feature branch
* @param to {String} — name of the master branch
* @return {Number} — iid of the merge request
*/
const getMRID = async (from, to) => {
const response = await fetch(`https://${GITLAB_DOMAIN}/api/v4/projects/${GITLAB_PROJECT_ID}/merge_requests?target_branch=${to}&source_branch=${from}`, {
method: 'GET',
headers: {
'PRIVATE-TOKEN': TOKEN,
}
});
if (!response.ok) throw new Error('Merge request not found');
const [{iid}] = await response.json();
return iid;
};
/**
* Adding comment to merege request
* @param md {String} — markdown text of the comment
*/
const addComment = async md => {
const iid = await getMRID(CURRENT_BRANCH, DEFAULT_BRANCH);
const commentPath = `https://${GITLAB_DOMAIN}/api/v4/projects/${GITLAB_PROJECT_ID}/merge_requests/${iid}/notes`;
const body = new FormData();
body.append('body', md);
await fetch(commentPath, {
method: 'POST',
headers: {
'PRIVATE-TOKEN': TOKEN,
},
body,
});
};
// Count of measurements we want to make
const MEASURES_COUNT = 3;
// build report for single url
const buildReport = browser => async url => {
const data = await lighthouse(
`${DOMAIN}${url}`,
{
port: new URL(browser.wsEndpoint()).port,
output: 'json',
},
{
extends: 'lighthouse:full',
}
);
const { report: reportJSON } = data;
const report = JSON.parse(reportJSON);
const metrics = [
{
name: report.categories.performance.title,
value: report.categories.performance.score,
desiredSize: 'larger',
},
{
name: report.categories.accessibility.title,
value: report.categories.accessibility.score,
desiredSize: 'larger',
},
{
name: report.categories['best-practices'].title,
value: report.categories['best-practices'].score,
desiredSize: 'larger',
},
{
name: report.categories.seo.title,
value: report.categories.seo.score,
desiredSize: 'larger',
},
{
name: report.categories.pwa.title,
value: report.categories.pwa.score,
desiredSize: 'larger',
},
];
return {
subject: url,
metrics: metrics,
};
};
/**
* Reducer which will calculate an avarage value of all page measurements
* @param pages {Object} — accumulator
* @param page {Object} — page
* @param page {Object} — page with avarage metrics values
*/
const mergeMetrics = (pages, page) => {
if (!pages) return page;
return {
subject: pages.subject,
metrics: pages.metrics.map((measure, index) => {
let value = (measure.value + page.metrics[index].value)/2;
value = +value.toFixed(2);
return {
...measure,
value,
}
}),
}
}
/**
* Returns urls array splited to chunks accordin to cors number
*
* @param urls {String[]} — URLs array
* @param cors {Number} — count of available cors
* @return {Array<String[]>} — URLs array splited to chunks
*/
function chunkArray(urls, cors) {
const chunks = [...Array(cors)].map(() => []);
let index = 0;
urls.forEach((url) => {
if (index > (chunks.length - 1)) {
index = 0;
}
chunks[index].push(url);
index += 1;
});
return chunks;
}
const urls = [
'/inloggen',
'/wachtwoord-herstellen-otp',
'/lp/service',
'/send-request-to/ww-tammer',
'/post-service-request/binnenschilderwerk',
];
(async () => {
if (cluster.isMaster) {
// Parent proccess
const chunks = chunkArray(urls, numCPUs);
chunks.map(chunk => {
const worker = cluster.fork();
worker.send(chunk);
});
let report = [];
let reportsCount = 0;
cluster.on('message', async (worker, msg) => {
report = [...report, ...msg];
worker.disconnect();
reportsCount++;
if (reportsCount === chunks.length) {
fs.writeFileSync(`./performance.json`, JSON.stringify(report));
if (CURRENT_BRANCH === DEFAULT_BRANCH) process.exit(0);
try {
const masterReport = await getArtifact('performance.json');
const md = buildCommentText(report, masterReport)
await addComment(md);
} catch (error) {
console.log(error);
}
process.exit(0);
}
});
} else {
// Child process
process.on('message', async (urls) => {
const browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox', '--headless'],
});
const builder = buildReport(browser);
const report = [];
for (let url of urls) {
// Let's measure MEASURES_COUNT times and calculate the avarage
let measures = [];
let index = MEASURES_COUNT;
while(index--){
const metric = await builder(url);
measures.push(metric);
}
const measure = measures.reduce(mergeMetrics);
report.push(measure);
}
cluster.worker.send(report);
await browser.close();
});
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment