@ognjenvladisavljevic/wdio-slack-reporter
Version:
Reporter from WebdriverIO using Web API to send results to Slack.
529 lines (528 loc) • 21.4 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const web_api_1 = require("@slack/web-api");
const logger_1 = __importDefault(require("@wdio/logger"));
const reporter_1 = __importDefault(require("@wdio/reporter"));
const util_1 = __importDefault(require("util"));
const constants_1 = require("./constants");
const log = (0, logger_1.default)('@moroo/wdio-slack-reporter');
class SlackReporter extends reporter_1.default {
constructor(options) {
var _a, _b, _c, _d, _e, _f;
super(Object.assign({ stdout: true }, options));
this._slackRequestQueue = [];
this._pendingSlackRequestCount = 0;
this._stateCounts = {
passed: 0,
failed: 0,
skipped: 0,
};
this._notifyTestStartMessage = true;
this._notifyTestFinishMessage = true;
this._uploadScreenshotOfFailedCase = true;
this._isSynchronizing = false;
this._hasRunnerEnd = false;
this._suites = Array();
this._suiteIndents = {};
this._screenshotBuffers = {};
if (!options.slackOptions) {
log.error(constants_1.ERROR_MESSAGES.UNDEFINED_SLACK_OPTION);
log.debug(options.slackOptions);
throw new Error(constants_1.ERROR_MESSAGES.UNDEFINED_SLACK_OPTION);
}
if (options.slackOptions.type === 'web-api') {
this._client = new web_api_1.WebClient(options.slackOptions.slackBotToken);
log.info('Created Slack Web API Client Instance.');
log.debug('Slack Web API Client', {
token: options.slackOptions.slackBotToken,
channel: options.slackOptions.channel,
});
this._channel = options.slackOptions.channel;
}
this._symbols = {
passed: ((_a = options.emojiSymbols) === null || _a === void 0 ? void 0 : _a.passed) || constants_1.EMOJI_SYMBOLS.PASSED,
skipped: ((_b = options.emojiSymbols) === null || _b === void 0 ? void 0 : _b.skipped) || constants_1.EMOJI_SYMBOLS.SKIPPED,
failed: ((_c = options.emojiSymbols) === null || _c === void 0 ? void 0 : _c.failed) || constants_1.EMOJI_SYMBOLS.FAILED,
pending: ((_d = options.emojiSymbols) === null || _d === void 0 ? void 0 : _d.pending) || constants_1.EMOJI_SYMBOLS.PENDING,
start: ((_e = options.emojiSymbols) === null || _e === void 0 ? void 0 : _e.start) || constants_1.EMOJI_SYMBOLS.ROKET,
watch: ((_f = options.emojiSymbols) === null || _f === void 0 ? void 0 : _f.watch) || constants_1.EMOJI_SYMBOLS.STOPWATCH,
};
this._title = options.title;
this._username = options.slackOptions.username;
this._env = options.slackOptions.env;
if (options.resultsUrl !== undefined) {
SlackReporter.setResultsUrl(options.resultsUrl);
}
if (options.notifyTestStartMessage !== undefined) {
this._notifyTestStartMessage = options.notifyTestStartMessage;
}
if (options.notifyTestFinishMessage !== undefined) {
this._notifyTestFinishMessage = options.notifyTestFinishMessage;
}
if (options.slackOptions.uploadScreenshotOfFailedCase !== undefined) {
this._uploadScreenshotOfFailedCase =
options.slackOptions.uploadScreenshotOfFailedCase;
}
this._interval = global.setInterval(this.sync.bind(this), 100);
process.on(constants_1.EVENTS.POST_MESSAGE, this.postMessage.bind(this));
process.on(constants_1.EVENTS.SCREENSHOT, this.uploadFailedTestScreenshot.bind(this));
}
static getResultsUrl() {
return SlackReporter.resultsUrl;
}
static setResultsUrl(url) {
SlackReporter.resultsUrl = url;
}
/**
* Post message from Slack web-api
* @param {ChatPostMessageArguments} payload Parameters used by Slack web-api
* @return {Promise<WebAPICallResult>}
*/
static postMessage(payload) {
return new Promise((resolve, reject) => {
process.emit(constants_1.EVENTS.POST_MESSAGE, payload);
process.once(constants_1.EVENTS.RESULT, ({ result, error }) => {
if (result) {
resolve(result);
}
reject(error);
});
});
}
/**
* Upload failed test scrteenshot
* @param {WebdriverIO.Browser} browser Parameters used by WebdriverIO.Browser
* @param {{page: Page, options: ScreenshotOptions}} puppeteer Parameters used by Puppeteer
* @return {Promise<Buffer>}
*/
static uploadFailedTestScreenshot(step, data) {
let buffer;
if (typeof data === 'string') {
buffer = Buffer.from(data, 'base64');
}
else {
buffer = data;
}
process.emit(constants_1.EVENTS.SCREENSHOT, step, buffer);
}
postMessage(payload) {
return __awaiter(this, void 0, void 0, function* () {
if (this._client) {
try {
log.debug('COMMAND', `postMessage(${payload})`);
this._pendingSlackRequestCount++;
const result = yield this._client.chat.postMessage(payload);
log.debug('RESULT', util_1.default.inspect(result));
process.emit(constants_1.EVENTS.RESULT, { result, error: undefined });
return result;
}
catch (error) {
log.error(error);
process.emit(constants_1.EVENTS.RESULT, { result: undefined, error });
throw error;
}
finally {
this._pendingSlackRequestCount--;
}
}
log.error(constants_1.ERROR_MESSAGES.NOT_USING_WEB_API);
throw new Error(constants_1.ERROR_MESSAGES.NOT_USING_WEB_API);
});
}
get isSynchronised() {
return (this._pendingSlackRequestCount === 0 && this._isSynchronizing === false);
}
sync() {
return __awaiter(this, void 0, void 0, function* () {
if (this._hasRunnerEnd &&
this._slackRequestQueue.length === 0 &&
this._pendingSlackRequestCount === 0) {
clearInterval(this._interval);
}
if (this._isSynchronizing ||
this._slackRequestQueue.length === 0 ||
this._pendingSlackRequestCount > 0) {
return;
}
try {
this._isSynchronizing = true;
log.info('Start Synchronising...');
yield this.next();
}
catch (error) {
log.error(error);
throw error;
}
finally {
this._isSynchronizing = false;
log.info('End Synchronising!!!');
}
});
}
next() {
var _a, _b;
return __awaiter(this, void 0, void 0, function* () {
const request = this._slackRequestQueue.shift();
let result;
log.info('POST', `Slack Request ${request === null || request === void 0 ? void 0 : request.type}`);
log.debug('DATA', util_1.default.inspect(request === null || request === void 0 ? void 0 : request.payload));
if (request) {
try {
this._pendingSlackRequestCount++;
switch (request.type) {
case constants_1.SLACK_REQUEST_TYPE.WEB_API_POST_MESSAGE: {
if (this._client) {
result = yield this._client.chat.postMessage(Object.assign(Object.assign({}, request.payload), { thread_ts: request.isDetailResult
? (_a = this._lastSlackWebAPICallResult) === null || _a === void 0 ? void 0 : _a.ts
: undefined }));
this._lastSlackWebAPICallResult = request.isDetailResult ? this._lastSlackWebAPICallResult : result;
log.debug('RESULT', util_1.default.inspect(result));
}
break;
}
case constants_1.SLACK_REQUEST_TYPE.WEB_API_UPLOAD: {
if (this._client) {
result = yield this._client.files.upload(Object.assign(Object.assign({}, request.payload), { thread_ts: (_b = this._lastSlackWebAPICallResult) === null || _b === void 0 ? void 0 : _b.ts }));
}
break;
}
}
}
catch (error) {
log.error(error);
}
finally {
this._pendingSlackRequestCount--;
}
if (this._slackRequestQueue.length > 0) {
yield this.next();
}
}
});
}
convertErrorStack(stack) {
return stack.replace(
// eslint-disable-next-line no-control-regex
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
}
getEnviromentCombo(capability, isMultiremote = false) {
let output = '';
const capabilities = capability
.alwaysMatch ||
capability;
const drivers = [];
if (isMultiremote) {
output += '*MultiRemote*: \n';
Object.keys(capabilities).forEach((key) => {
drivers.push({
driverName: key,
capability: capabilities[key],
});
});
}
else {
drivers.push({
capability: capabilities,
});
}
drivers.forEach(({ driverName, capability }, index, array) => {
const isLastIndex = array.length - 1 === index;
let env = '';
const caps = capability
.alwaysMatch ||
capability;
const device = caps.deviceName;
const browser = caps.browserName || caps.browser;
const version = caps.browserVersion ||
caps.version ||
caps.platformVersion ||
caps.browser_version;
const platform = caps.platformName ||
caps.platform ||
(caps.os
? caps.os + (caps.os_version ? ` ${caps.os_version}` : '')
: '(unknown)');
if (device) {
const program = (caps.app || '').replace('sauce-storage:', '') || caps.browserName;
const executing = program ? `executing ${program}` : '';
env = `${device} on ${platform} ${version} ${executing}`.trim();
}
else {
env = browser + (version ? ` (v${version})` : '') + ` on ${platform}`;
}
output += isMultiremote ? `- ${driverName}: ` : 'Driver: ';
output += env;
output += isLastIndex ? '' : '\n';
});
return output;
}
/**
* Indent a suite based on where how it's nested
* @param {String} uid Unique suite key
* @return {String} Spaces for indentation
*/
indent(uid) {
const indents = this._suiteIndents[uid];
return indents === 0 ? '' : Array(indents).join(constants_1.DEFAULT_INDENT);
}
/**
* Indent a suite based on where how it's nested
* @param {StateCount} stateCounts Stat count
* @return {String} String to the stat count to be displayed in Slack
*/
getCounts(stateCounts) {
return `[Passed: *${stateCounts.passed}* | Failed: *${stateCounts.failed}* | Skipped: *${stateCounts.skipped}*]`;
}
/**
* Indent a suite based on where how it's nested
* @param {string[]} tests Test titles to display
* @return {String} String to the test titles to be displayed in Slack
*/
getSuiteOutput(suite) {
const text = this.getScenarioOutput(suite);
let item = {
type: 'section',
text: {
type: 'mrkdwn',
text: `${text}`
},
};
const payload = {
channel: this._channel,
text: `Results`,
blocks: [
item
],
};
return payload;
}
getFeatureResult(suites) {
let result = Object.values(suites).find(suite => {
const tests = this.getEventsToReport(suite);
if (tests.find((item) => item.state === 'failed')) {
return true;
}
});
return result;
}
getScenarioOutput(suite) {
let tests = this.getEventsToReport(suite);
let text = '```' + suite.title + '\n';
tests.forEach((step) => {
const symbol = this._symbols[step.state];
text += ` ${symbol} ${step.title}\n`;
});
return text + '```\n';
}
getEventsToReport(suite) {
return [
/**
* report all tests and only hooks that failed
*/
...suite.hooksAndTests
.filter((item) => {
return item.type === 'test' || Boolean(item.error);
})
];
}
getOrderedSuites() {
let orderedSuites = {};
for (let suite of this._suites) {
for (const [suiteUid, s] of Object.entries(this.suites)) {
if (suite.uid !== suiteUid) {
continue;
}
orderedSuites[suite.uid] = suite;
}
}
return orderedSuites;
}
createFailedTestPayload(hookAndTest) {
var _a;
const stack = ((_a = hookAndTest.error) === null || _a === void 0 ? void 0 : _a.stack) ? '```' + this.convertErrorStack(hookAndTest.error.stack) + '```' : '';
const payload = {
channel: this._channel,
text: `${this._symbols.failed} Error`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `${this._symbols.failed} Error`
},
},
],
attachments: [
{
color: constants_1.FAILED_COLOR,
title: `${this._currentScenario ? this._currentScenario.title : hookAndTest.parent}`,
text: `* » ${hookAndTest.title}*\n${stack}`,
},
],
};
return payload;
}
createResultPayload(runnerStats, stateCounts) {
const resultsUrl = SlackReporter.getResultsUrl();
const counts = this.getCounts(stateCounts);
const suites = this.getOrderedSuites();
const failedTest = this.getFeatureResult(suites);
const result = failedTest ? constants_1.FEATURE_FAILED : constants_1.FEATURE_PASSED;
const title = `*${this._currentFeature.title}*`;
const driver = `${this.getEnviromentCombo(this._runnerStats.capabilities, this._runnerStats.isMultiremote)}`;
const payload = {
channel: this._channel,
text: '',
blocks: [],
attachments: [
{
color: constants_1.DEFAULT_COLOR,
text: `${title} | Environment: *${this._env}*`
},
{
color: failedTest ? constants_1.FAILED_COLOR : constants_1.SUCCESS_COLOR,
text: `*${result}* ${counts} <${resultsUrl}|results>\n`,
footer: `Duration: ${runnerStats.duration / 1000}s Started by: ${this._username} ${driver}`,
},
],
};
return payload;
}
sendResultThreadPayload() {
const suites = this.getOrderedSuites();
let result = [];
Object.values(suites).forEach(suite => {
const output = this.getSuiteOutput(suite);
if (output) {
this._slackRequestQueue.push({
type: constants_1.SLACK_REQUEST_TYPE.WEB_API_POST_MESSAGE,
payload: output,
isDetailResult: true,
});
}
if (this._uploadScreenshotOfFailedCase && this._screenshotBuffers[suite.uid]) {
this._slackRequestQueue.push({
type: constants_1.SLACK_REQUEST_TYPE.WEB_API_UPLOAD,
payload: this.createScreenshotPayload(suite, this._screenshotBuffers[suite.uid]),
isDetailResult: true
});
this._screenshotBuffers[suite.uid] = null;
}
});
this._screenshotBuffers = {};
}
onRunnerStart(runnerStats) {
this._runnerStats = runnerStats;
}
// onBeforeCommand(commandArgs: BeforeCommandArgs): void {}
// onAfterCommand(commandArgs: AfterCommandArgs): void {}
/**
* This hook is called twice:
* 1. create the feature
* 2. add the scenario to the feature
*/
onSuiteStart(suiteStats) {
switch (suiteStats.type) {
case constants_1.TEST_TYPES.FEATURE: {
this._currentFeature = suiteStats;
break;
}
case constants_1.TEST_TYPES.SCENARIO: {
this._currentScenario = suiteStats;
this._suites.push(suiteStats);
break;
}
}
}
// onHookStart(hookStat: HookStats): void {}
/**
* This one is for the end of the hook, it directly comes after the onHookStart
* A hook is the same as a 'normal' step, so use the update step
*/
onHookEnd(hookStats) {
}
// Run for every step
onTestPass(stepStats) {
this._stateCounts.passed++;
}
// Run for every step
onTestFail(stepStats) {
this._stateCounts.failed++;
}
// onTestRetry(testStats: TestStats): void {}
onTestSkip(stepStats) {
this._stateCounts.skipped++;
}
// onTestEnd(testStats: TestStats): void {}
// onSuiteEnd(suiteStats: SuiteStats): void {}
onRunnerEnd(runnerStats) {
if (this._notifyTestFinishMessage) {
try {
if (this._client) {
this._slackRequestQueue.push({
type: constants_1.SLACK_REQUEST_TYPE.WEB_API_POST_MESSAGE,
payload: this.createResultPayload(runnerStats, this._stateCounts),
});
// Send results in thread
this.sendResultThreadPayload();
}
}
catch (error) {
log.error(error);
throw error;
}
}
this._hasRunnerEnd = true;
}
uploadFailedTestScreenshot(suite, buffer) {
if (this._client) {
if (this._uploadScreenshotOfFailedCase) {
this._screenshotBuffers[suite.id] = buffer;
return;
}
else {
log.warn(constants_1.ERROR_MESSAGES.DISABLED_OPTIONS);
}
}
else {
log.warn(constants_1.ERROR_MESSAGES.NOT_USING_WEB_API);
}
}
createScreenshotPayload(suiteStats, screenshotBuffer) {
const payload = {
channels: this._channel,
filename: `${suiteStats.title}.png`,
filetype: 'png',
file: screenshotBuffer,
};
return payload;
}
}
exports.default = SlackReporter;
__exportStar(require("./types"), exports);