@ognjenvladisavljevic/wdio-slack-reporter
Version:
Reporter from WebdriverIO using Web API to send results to Slack.
676 lines (621 loc) • 19.8 kB
text/typescript
import {
Block,
ChatPostMessageArguments,
FilesUploadArguments,
KnownBlock,
WebAPICallResult,
WebClient,
} from '@slack/web-api';
import getLogger from '@wdio/logger';
import WDIOReporter, {
HookStats,
RunnerStats,
SuiteStats,
TestStats,
} from '@wdio/reporter';
import { Capabilities } from '@wdio/types';
import util from 'util';
import {
DEFAULT_COLOR,
DEFAULT_INDENT,
EMOJI_SYMBOLS,
FAILED_COLOR,
ERROR_MESSAGES,
EVENTS,
SLACK_REQUEST_TYPE,
SUCCESS_COLOR,
TEST_TYPES,
FEATURE_FAILED,
FEATURE_PASSED
} from './constants';
import {
SlackRequestType,
SlackReporterOptions,
EmojiSymbols,
StateCount,
FeatureOutput,
} from './types';
const log = getLogger('@moroo/wdio-slack-reporter');
class SlackReporter extends WDIOReporter {
private static resultsUrl?: string;
private _slackRequestQueue: SlackRequestType[] = [];
private _lastSlackWebAPICallResult?: WebAPICallResult;
private _pendingSlackRequestCount = 0;
private _stateCounts: StateCount = {
passed: 0,
failed: 0,
skipped: 0,
};
private _client?: WebClient;
private _channel?: string;
private _symbols: EmojiSymbols;
private _title?: string;
private _notifyTestStartMessage: boolean = true;
private _username: string;
private _env: string;
private _notifyTestFinishMessage: boolean = true;
private _uploadScreenshotOfFailedCase: boolean = true;
private _isSynchronizing: boolean = false;
private _interval: NodeJS.Timeout;
private _hasRunnerEnd = false;
private _suites = Array<SuiteStats>();
private _suiteIndents: Record<string, number> = {};
private _currentFeature?: SuiteStats;
private _currentScenario: SuiteStats;
private _runnerStats: RunnerStats;
private _screenshotBuffers?: Object = {};
payload: FeatureOutput;
constructor(options: SlackReporterOptions) {
super(Object.assign({ stdout: true }, options));
if (!options.slackOptions) {
log.error(ERROR_MESSAGES.UNDEFINED_SLACK_OPTION);
log.debug(options.slackOptions);
throw new Error(ERROR_MESSAGES.UNDEFINED_SLACK_OPTION);
}
if (options.slackOptions.type === 'web-api') {
this._client = new 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: options.emojiSymbols?.passed || EMOJI_SYMBOLS.PASSED,
skipped: options.emojiSymbols?.skipped || EMOJI_SYMBOLS.SKIPPED,
failed: options.emojiSymbols?.failed || EMOJI_SYMBOLS.FAILED,
pending: options.emojiSymbols?.pending || EMOJI_SYMBOLS.PENDING,
start: options.emojiSymbols?.start || EMOJI_SYMBOLS.ROKET,
watch: options.emojiSymbols?.watch || 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(EVENTS.POST_MESSAGE, this.postMessage.bind(this));
process.on(EVENTS.SCREENSHOT, this.uploadFailedTestScreenshot.bind(this));
}
static getResultsUrl(): string | undefined {
return SlackReporter.resultsUrl;
}
static setResultsUrl(url: string | undefined): void {
SlackReporter.resultsUrl = url;
}
/**
* Post message from Slack web-api
* @param {ChatPostMessageArguments} payload Parameters used by Slack web-api
* @return {Promise<WebAPICallResult>}
*/
static postMessage(
payload: ChatPostMessageArguments
): Promise<WebAPICallResult> {
return new Promise((resolve, reject) => {
process.emit(EVENTS.POST_MESSAGE, payload);
process.once(EVENTS.RESULT, ({ result, error }) => {
if (result) {
resolve(result as WebAPICallResult);
}
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: string | Buffer): void {
let buffer: Buffer;
if (typeof data === 'string') {
buffer = Buffer.from(data, 'base64');
} else {
buffer = data;
}
process.emit(EVENTS.SCREENSHOT, step, buffer);
}
private async postMessage(
payload: ChatPostMessageArguments
): Promise<WebAPICallResult> {
if (this._client) {
try {
log.debug('COMMAND', `postMessage(${payload})`);
this._pendingSlackRequestCount++;
const result = await this._client.chat.postMessage(payload);
log.debug('RESULT', util.inspect(result));
process.emit(EVENTS.RESULT, { result, error: undefined });
return result;
} catch (error) {
log.error(error);
process.emit(EVENTS.RESULT, { result: undefined, error });
throw error;
} finally {
this._pendingSlackRequestCount--;
}
}
log.error(ERROR_MESSAGES.NOT_USING_WEB_API);
throw new Error(ERROR_MESSAGES.NOT_USING_WEB_API);
}
get isSynchronised(): boolean {
return (
this._pendingSlackRequestCount === 0 && this._isSynchronizing === false
);
}
private async sync(): Promise<void> {
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...');
await this.next();
} catch (error) {
log.error(error);
throw error;
} finally {
this._isSynchronizing = false;
log.info('End Synchronising!!!');
}
}
private async next() {
const request = this._slackRequestQueue.shift();
let result: WebAPICallResult;
log.info('POST', `Slack Request ${request?.type}`);
log.debug('DATA', util.inspect(request?.payload));
if (request) {
try {
this._pendingSlackRequestCount++;
switch (request.type) {
case SLACK_REQUEST_TYPE.WEB_API_POST_MESSAGE: {
if (this._client) {
result = await this._client.chat.postMessage({
...request.payload,
thread_ts: request.isDetailResult
? (this._lastSlackWebAPICallResult?.ts as string)
: undefined,
});
this._lastSlackWebAPICallResult = request.isDetailResult ? this._lastSlackWebAPICallResult : result
log.debug('RESULT', util.inspect(result))
}
break;
}
case SLACK_REQUEST_TYPE.WEB_API_UPLOAD: {
if (this._client) {
result = await this._client.files.upload({
...request.payload,
thread_ts: this._lastSlackWebAPICallResult?.ts as string,
});
}
break;
}
}
} catch (error) {
log.error(error);
} finally {
this._pendingSlackRequestCount--;
}
if (this._slackRequestQueue.length > 0) {
await this.next();
}
}
}
private convertErrorStack(stack: string): string {
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,
''
);
}
private getEnviromentCombo(
capability: Capabilities.RemoteCapability,
isMultiremote = false
): string {
let output = '';
const capabilities: Capabilities.RemoteCapability =
((capability as Capabilities.W3CCapabilities)
.alwaysMatch as Capabilities.DesiredCapabilities) ||
(capability as Capabilities.DesiredCapabilities);
const drivers: {
driverName?: string;
capability: Capabilities.RemoteCapability;
}[] = [];
if (isMultiremote) {
output += '*MultiRemote*: \n';
Object.keys(capabilities).forEach((key) => {
drivers.push({
driverName: key,
capability: (capabilities as Capabilities.MultiRemoteCapabilities)[
key
],
});
});
} else {
drivers.push({
capability: capabilities,
});
}
drivers.forEach(({ driverName, capability }, index, array) => {
const isLastIndex = array.length - 1 === index;
let env = '';
const caps =
((capability as Capabilities.W3CCapabilities)
.alwaysMatch as Capabilities.DesiredCapabilities) ||
(capability as Capabilities.DesiredCapabilities);
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
*/
private indent(uid: string): string {
const indents = this._suiteIndents[uid];
return indents === 0 ? '' : Array(indents).join(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
*/
private getCounts(stateCounts: StateCount): string {
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
*/
private getSuiteOutput(suite): ChatPostMessageArguments {
const text = this.getScenarioOutput(suite)
let item = {
type: 'section',
text: {
type: 'mrkdwn',
text: `${text}`
},
}
const payload: ChatPostMessageArguments = {
channel: this._channel,
text: `Results`,
blocks: [
item
],
}
return payload
}
private getFeatureResult(suites): any {
let result = Object.values(suites).find(suite => {
const tests = this.getEventsToReport(suite)
if (tests.find((item) => item.state === 'failed')) {
return true
}
})
return result
}
private getScenarioOutput(suite): string {
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'
}
private getEventsToReport(suite) : Array<HookStats | TestStats>{
return [
/**
* report all tests and only hooks that failed
*/
...suite.hooksAndTests
.filter((item) => {
return item.type === 'test' || Boolean(item.error);
})
];
}
private getOrderedSuites() : Object {
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
}
private createFailedTestPayload(
hookAndTest: HookStats | TestStats
): ChatPostMessageArguments {
const stack = hookAndTest.error?.stack ? '```' + this.convertErrorStack(hookAndTest.error.stack) + '```' : '';
const payload: ChatPostMessageArguments = {
channel: this._channel,
text: `${this._symbols.failed} Error`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `${this._symbols.failed} Error`
},
},
],
attachments: [
{
color: FAILED_COLOR,
title: `${
this._currentScenario ? this._currentScenario.title : hookAndTest.parent
}`,
text: `* » ${hookAndTest.title}*\n${stack}`,
},
],
};
return payload;
}
private createResultPayload(
runnerStats: RunnerStats,
stateCounts: StateCount
): ChatPostMessageArguments {
const resultsUrl = SlackReporter.getResultsUrl();
const counts = this.getCounts(stateCounts);
const suites = this.getOrderedSuites();
const failedTest = this.getFeatureResult(suites)
const result = failedTest ? FEATURE_FAILED : FEATURE_PASSED
const title = `*${this._currentFeature.title}*`
const driver = `${this.getEnviromentCombo(
this._runnerStats.capabilities,
this._runnerStats.isMultiremote
)}`
const payload: ChatPostMessageArguments = {
channel: this._channel,
text: '',
blocks: [],
attachments: [
{
color: DEFAULT_COLOR,
text: `${title} | Environment: *${this._env}*`
},
{
color: failedTest ? FAILED_COLOR : SUCCESS_COLOR,
text: `*${result}* ${counts} <${resultsUrl}|results>\n`,
footer: `Duration: ${runnerStats.duration / 1000}s Started by: ${this._username} ${driver}`,
},
],
};
return payload;
}
private sendResultThreadPayload() {
const suites = this.getOrderedSuites();
let result = []
Object.values(suites).forEach(suite => {
const output = this.getSuiteOutput(suite)
if (output) {
this._slackRequestQueue.push({
type: SLACK_REQUEST_TYPE.WEB_API_POST_MESSAGE,
payload: output as ChatPostMessageArguments,
isDetailResult: true,
});
}
if (this._uploadScreenshotOfFailedCase && this._screenshotBuffers[suite.uid]) {
this._slackRequestQueue.push({
type: SLACK_REQUEST_TYPE.WEB_API_UPLOAD,
payload: this.createScreenshotPayload(
suite as SuiteStats,
this._screenshotBuffers[suite.uid]
) as FilesUploadArguments,
isDetailResult: true
});
this._screenshotBuffers[suite.uid] = null;
}
});
this._screenshotBuffers = {}
}
onRunnerStart(runnerStats: RunnerStats): void {
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: SuiteStats): void {
switch (suiteStats.type) {
case TEST_TYPES.FEATURE: {
this._currentFeature = suiteStats
break
} case 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: HookStats): void {
}
// Run for every step
onTestPass(stepStats: TestStats): void {
this._stateCounts.passed++;
}
// Run for every step
onTestFail(stepStats: TestStats): void {
this._stateCounts.failed++;
}
// onTestRetry(testStats: TestStats): void {}
onTestSkip(stepStats: TestStats): void {
this._stateCounts.skipped++;
}
// onTestEnd(testStats: TestStats): void {}
// onSuiteEnd(suiteStats: SuiteStats): void {}
onRunnerEnd(runnerStats: RunnerStats): void {
if (this._notifyTestFinishMessage) {
try {
if (this._client) {
this._slackRequestQueue.push({
type: SLACK_REQUEST_TYPE.WEB_API_POST_MESSAGE,
payload: this.createResultPayload(
runnerStats,
this._stateCounts
) as ChatPostMessageArguments,
});
// Send results in thread
this.sendResultThreadPayload()
}
} catch (error) {
log.error(error);
throw error;
}
}
this._hasRunnerEnd = true;
}
private uploadFailedTestScreenshot(suite, buffer: Buffer): void {
if (this._client) {
if (this._uploadScreenshotOfFailedCase) {
this._screenshotBuffers[suite.id]=buffer;
return;
} else {
log.warn(ERROR_MESSAGES.DISABLED_OPTIONS);
}
} else {
log.warn(ERROR_MESSAGES.NOT_USING_WEB_API);
}
}
private createScreenshotPayload(
suiteStats: SuiteStats,
screenshotBuffer: Buffer
): FilesUploadArguments {
const payload: FilesUploadArguments = {
channels: this._channel,
filename: `${suiteStats.title}.png`,
filetype: 'png',
file: screenshotBuffer,
};
return payload;
}
}
export default SlackReporter;
export { SlackReporterOptions };
export * from './types';
declare global {
namespace WebdriverIO {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface ReporterOption extends SlackReporterOptions {}
}
namespace NodeJS {
interface Process {
emit(
event: typeof EVENTS.POST_MESSAGE,
payload: ChatPostMessageArguments
): boolean;
emit(
event: typeof EVENTS.UPLOAD,
payload: FilesUploadArguments
): Promise<WebAPICallResult>;
emit(
event: typeof EVENTS.RESULT,
args: {
result: WebAPICallResult | undefined;
error: any;
}
): boolean;
emit(event: typeof EVENTS.SCREENSHOT, step, buffer: Buffer): boolean;
on(
event: typeof EVENTS.POST_MESSAGE,
listener: (
payload: ChatPostMessageArguments
) => Promise<WebAPICallResult>
): this;
on(
event: typeof EVENTS.UPLOAD,
listener: (payload: FilesUploadArguments) => Promise<WebAPICallResult>
): this;
on(
event: typeof EVENTS.SCREENSHOT,
listener: (buffer: Buffer) => void
): this;
once(
event: typeof EVENTS.RESULT,
listener: (args: {
result: WebAPICallResult | undefined;
error: any;
}) => Promise<void>
): this;
}
}
}