UNPKG

@ognjenvladisavljevic/wdio-slack-reporter

Version:

Reporter from WebdriverIO using Web API to send results to Slack.

529 lines (528 loc) 21.4 kB
"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);