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);
+};