diff --git a/lib/plugins/webhook/cli.js b/lib/plugins/webhook/cli.js new file mode 100644 index 0000000000..4ad2987730 --- /dev/null +++ b/lib/plugins/webhook/cli.js @@ -0,0 +1,20 @@ +'use strict'; + +module.exports = { + url: { + describe: 'The URL where to send the webhook.', + group: 'WebHook' + }, + messages: { + describe: 'Choose what type of message to send', + choices: ['budget', 'errors', 'summary'], + default: 'summary', + group: 'WebHook' + }, + style: { + describe: 'How to format the content of the webhook.', + choices: ['html', 'markdown', 'text'], + default: 'text', + group: 'WebHook' + } +}; diff --git a/lib/plugins/webhook/format.js b/lib/plugins/webhook/format.js new file mode 100644 index 0000000000..4a7b12b933 --- /dev/null +++ b/lib/plugins/webhook/format.js @@ -0,0 +1,107 @@ +'use strict'; + +const newLine = '\n'; +class Format { + constructor(style) { + this.style = style; + } + + link(url, name) { + switch (this.style) { + case 'html': + return `${name ? name : url}`; + case 'markdown': + return `[${name ? name : url}](${url})`; + default: + return url; + } + } + + heading(text) { + switch (this.style) { + case 'html': + return `

${text}

`; + case 'markdown': + return `# ${text}`; + default: + return text; + } + } + + image(url, altText) { + switch (this.style) { + case 'html': + return ``; + case 'markdown': + return `![${altText}](${url})`; + default: + return url; + } + } + + bold(text) { + switch (this.style) { + case 'html': + return `${text}"`; + case 'markdown': + return `**${text})**`; + default: + return text; + } + } + + pre(text) { + switch (this.style) { + case 'html': + return `
${text}"
`; + case 'markdown': + return `${text})`; + default: + return text; + } + } + + p(text) { + switch (this.style) { + case 'html': + return `

${text}"

`; + case 'markdown': + return `${newLine}${newLine}${text}`; + default: + return `${newLine}${newLine}${text}`; + } + } + + list(text) { + switch (this.style) { + case 'html': + return ``; + default: + return text; + } + } + + listItem(text) { + switch (this.style) { + case 'html': + return `
  • ${text}"
  • `; + case 'markdown': + return `* ${text})`; + default: + return `* ${text} ${newLine})`; + } + } + + hr() { + switch (this.style) { + case 'html': + return `
    `; + case 'markdown': + return `---`; + default: + return `${newLine}`; + } + } +} + +module.exports = Format; diff --git a/lib/plugins/webhook/index.js b/lib/plugins/webhook/index.js new file mode 100644 index 0000000000..f7def98bdf --- /dev/null +++ b/lib/plugins/webhook/index.js @@ -0,0 +1,300 @@ +'use strict'; + +const throwIfMissing = require('../../support/util').throwIfMissing; +const log = require('intel').getLogger('sitespeedio.plugin.webhook'); +const path = require('path'); +const get = require('lodash.get'); +const cliUtil = require('../../cli/util'); +const send = require('./send'); +const Format = require('./format'); +const friendlynames = require('../../support/friendlynames'); + +function getPageSummary(data, format, resultUrls, alias, screenshotType) { + let text = format.heading( + 'Tested data for ' + + data.info.url + + (resultUrls.hasBaseUrl() + ? format.link( + resultUrls.absoluteSummaryPagePath( + data.info.url, + alias[data.info.url] + ), + '(result)' + ) + : '') + ); + + if (resultUrls.hasBaseUrl()) { + text += format.image( + resultUrls.absoluteSummaryPagePath(data.info.url, alias[data.info.url]) + + 'data/screenshots/1/afterPageCompleteCheck.' + + screenshotType + ); + } + + if (data.statistics.visualMetrics) { + let f = friendlynames['browsertime']['timnings']['FirstVisualChange']; + text += format.p( + f.name + + ' ' + + f.format(data.statistics.visualMetrics['FirstVisualChange'].median) + ); + f = friendlynames['browsertime']['timnings']['SpeedIndex']; + text += format.p( + f.name + + ' ' + + f.format(data.statistics.visualMetrics['SpeedIndex'].median) + ); + + f = friendlynames['browsertime']['timnings']['LastVisualChange']; + text += format.p( + f.name + + ' ' + + f.format(data.statistics.visualMetrics['LastVisualChange'].median) + ); + } + + if (data.statistics.googleWebVitals) { + for (let metric of Object.keys(data.statistics.googleWebVitals)) { + let f = + friendlynames.browsertime.timings[metric] || + friendlynames.browsertime.cpu[metric] || + friendlynames.browsertime.pageinfo[metric]; + + if (f) { + text += format.p( + f.name + + ' ' + + f.format(data.statistics.googleWebVitals[metric].median) + ); + } else { + // We do not have a mapping for FID + } + } + } + return text; +} + +function getBrowserData(data) { + if (data && data.browser) { + return `${data.browser.name} ${data.browser.version} ${get( + data, + 'android.model', + '' + )} ${get(data, 'android.androidVersion', '')} ${get( + data, + 'android.id', + '' + )} `; + } else return ''; +} + +module.exports = { + name() { + return path.basename(__dirname); + }, + get cliOptions() { + return require(path.resolve(__dirname, 'cli.js')); + }, + open(context, options = {}) { + this.webHookOptions = options.webhook || {}; + this.options = options; + log.info('Starting the webhook plugin'); + throwIfMissing(options.webhook, ['url'], 'webhook'); + this.format = new Format(this.webHookOptions.style); + this.resultUrls = context.resultUrls; + this.waitForUpload = false; + this.alias = {}; + this.data = {}; + this.errorTexts = {}; + this.message = { text: '' }; + if (options.webhook) { + for (let key of Object.keys(options.webhook)) { + if (key !== 'url' && key !== 'messages' && key !== 'style') { + this.message[key] = options.webhook[key]; + } + } + } + }, + async processMessage(message) { + const options = this.webHookOptions; + const format = this.format; + switch (message.type) { + case 'browsertime.browser': { + this.browserData = message.data; + break; + } + case 'gcs.setup': + case 'ftp.setup': + case 's3.setup': { + this.waitForUpload = true; + break; + } + case 'browsertime.alias': { + this.alias[message.url] = message.data; + break; + } + case 'browsertime.config': { + if (message.data.screenshot === true) { + this.screenshotType = message.data.screenshotType; + } + break; + } + case 'browsertime.pageSummary': { + if (this.waitForUpload && options.messages.indexOf('pageSumary') > -1) { + this.data[message.url] = message.data; + } else if (options.messages.indexOf('pageSumary') > -1) { + await send(options.url, { + text: `Test finished ${format.link(message.data.info.url)}` + }); + } + break; + } + + case 'error': { + // We can send too many messages to Matrix and get 429 so instead + // we bulk send them all one time + if (options.messages.indexOf('error') > -1) { + this.errorTexts += `${format.hr()} ⚠️ Error from ${format.bold( + message.source + )} testing ${ + message.url ? format.link(message.url) : '' + } ${format.pre(message.data)}`; + } + break; + } + + case 'budget.result': { + if (options.messages.indexOf('budget') > -1) { + let text = ''; + // We have failing URLs in the budget + if (Object.keys(message.data.failing).length > 0) { + const failingURLs = Object.keys(message.data.failing); + text += format.heading( + `${'⚠️ Budget failing (' + + failingURLs.length + + ' URLs)'}` + ); + text += format.p( + `${get(this.options, 'name', '') + + ' ' + + getBrowserData(this.browserData)}` + ); + for (let url of failingURLs) { + text += format.bold( + `❌ ${url}` + + (this.resultUrls.hasBaseUrl() + ? ` (${format.link( + this.resultUrls.absoluteSummaryPagePath( + url, + this.alias[url] + ) + 'index.html', + 'result' + )} - ${format.link( + this.resultUrls.absoluteSummaryPagePath( + url, + this.alias[url] + ) + + 'data/screenshots/1/afterPageCompleteCheck.' + + this.screenshotType, + 'screenshot' + )}` + : '') + ); + + let items = ''; + for (let failing of message.data.failing[url]) { + items += format.listItem( + `${failing.metric} : ${failing.friendlyValue} (${ + failing.friendlyLimit + })` + ); + } + text += format.list(items); + } + } + if (Object.keys(message.data.error).length > 0) { + const errorURLs = Object.keys(message.data.error); + text += format.heading( + `⚠️ Budget errors testing ${errorURLs.length} URLs` + ); + for (let url of errorURLs) { + text += format.p(`❌ ${url}`); + text += format.pre(`${message.data.error[url]}`); + } + } + if ( + Object.keys(message.data.error).length === 0 && + Object.keys(message.data.failing).length === 0 + ) { + text += format.p( + `🎉 All (${ + Object.keys(message.data.working).length + }) URL(s) passed the budget using ${get( + this.options, + 'name', + '' + )} ${getBrowserData(this.browserData)}` + ); + } + if (!this.waitForUpload) { + await send(options.url, { text }); + } else { + this.budgetText = text; + } + } + break; + } + + case 'gcs.finished': + case 'ftp.finished': + case 's3.finished': { + if ( + this.waitForUpload && + options.messages.indexOf('pageSummary') > -1 + ) { + const message = { + text: '' + }; + for (let url of Object.keys(this.data)) { + message.text += getPageSummary( + this.data[url], + format, + this.resultUrls, + this.alias, + this.screenshotType + ); + } + + if (this.resultUrls.reportSummaryUrl()) { + message.text += format.p( + format.link( + this.resultUrls.reportSummaryUrl() + '/index.html', + 'Summary' + ) + ); + } + + await send(options.url, message); + } else if ( + this.waitForUpload && + options.messages.indexOf('budget') > -1 + ) { + await send(options.url, { text: this.budgetText }); + } + + break; + } + + case 'sitespeedio.render': { + if (this.errorTexts !== '') { + await send(options.url, { message: this.errorTexts }); + } + break; + } + } + }, + get config() { + return cliUtil.pluginDefaults(this.cliOptions); + } +}; diff --git a/lib/plugins/webhook/send.js b/lib/plugins/webhook/send.js new file mode 100644 index 0000000000..7905e77efd --- /dev/null +++ b/lib/plugins/webhook/send.js @@ -0,0 +1,57 @@ +'use strict'; + +const https = require('https'); +const http = require('http'); +const log = require('intel').getLogger('sitespeedio.plugin.webhook'); + +function send(url, message, retries = 3, backoff = 5000) { + const parsedUrl = new URL(url); + const send = parsedUrl.protocol === 'https' ? https : http; + + const retryCodes = [408, 429, 500, 503]; + return new Promise((resolve, reject) => { + const req = send.request( + { + host: parsedUrl.hostname, + port: parsedUrl.port, + path: parsedUrl.pathname, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(JSON.stringify(message), 'utf8') + }, + method: 'POST' + }, + res => { + const { statusCode } = res; + if (statusCode < 200 || statusCode > 299) { + if (retries > 0 && retryCodes.includes(statusCode)) { + setTimeout(() => { + return send(url, message, retries - 1, backoff * 2); + }, backoff); + } else { + log.error( + `Got error from the webhook server. Error Code: ${ + res.statusCode + } Message: ${res.statusMessage}` + ); + reject(new Error(`Status Code: ${res.statusCode}`)); + } + } else { + const data = []; + res.on('data', chunk => { + data.push(chunk); + }); + res.on('end', () => { + resolve(Buffer.concat(data).toString()); + }); + } + } + ); + req.write(JSON.stringify(message)); + req.end(); + }); +} + +module.exports = async (url, message) => { + return send(url, message); +};