[MM-46305] Add reporting code for E2E tests (#2222)

* E2E reporting

* Test report

* Fix artifacts.js

* Revert "Test report"

This reverts commit f4d44b881a19c0e9d63066807f5cb6b9fe9017ee.

* PR feedback
This commit is contained in:
Devin Binnie 2022-08-15 09:07:22 -04:00 committed by GitHub
parent 1270859d39
commit 9faaa61e48
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 2207 additions and 221 deletions

1
.gitignore vendored
View file

@ -10,6 +10,7 @@ npm-debug.log*
build/ build/
coverage/ coverage/
dist/ dist/
mochawesome-report/
test-results.xml test-results.xml
test_config.json test_config.json

106
e2e/save_report.js Normal file
View file

@ -0,0 +1,106 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-console */
/*
* This is used for saving artifacts to AWS S3, sending data to automation dashboard and
* publishing quick summary to community channels.
*
* Usage: [ENV] node save_report.js
*
* Environment variables:
* BRANCH=[branch] : Branch identifier from CI
* BUILD_ID=[build_id] : Build identifier from CI
* BUILD_TAG=[build_tag] : Docker image used to run the test
*
* For saving artifacts to AWS S3
* - AWS_S3_BUCKET, AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY
* For saving test cases to Test Management
* - ZEPHYR_ENABLE=true|false
* - ZEPHYR_API_KEY=[api_key]
* - JIRA_PROJECT_KEY=[project_key], e.g. "MM",
* - ZEPHYR_FOLDER_ID=[folder_id], e.g. 847997
* For sending hooks to Mattermost channels
* - FULL_REPORT, WEBHOOK_URL and DIAGNOSTIC_WEBHOOK_URL
* Test type
* - TYPE=[type], e.g. "MASTER", "PR", "RELEASE", "CLOUD"
*/
const path = require('path');
const chai = require('chai');
const generator = require('mochawesome-report-generator');
const {
generateShortSummary,
generateTestReport,
removeOldGeneratedReports,
sendReport,
readJsonFromFile,
writeJsonToFile,
} = require('./utils/report');
const {saveArtifacts} = require('./utils/artifacts');
const {MOCHAWESOME_REPORT_DIR} = require('./utils/constants');
const {createTestCycle, createTestExecutions} = require('./utils/test_cases');
require('dotenv').config();
const saveReport = async () => {
const {
BRANCH,
BUILD_ID,
BUILD_TAG,
FAILURE_MESSAGE,
ZEPHYR_ENABLE,
ZEPHYR_CYCLE_KEY,
TYPE,
WEBHOOK_URL,
} = process.env;
removeOldGeneratedReports();
// Import
const jsonReport = readJsonFromFile(path.join(MOCHAWESOME_REPORT_DIR, 'mochawesome.json'));
// Generate the html report file
await generator.create(
jsonReport,
{
reportDir: MOCHAWESOME_REPORT_DIR,
reportTitle: `Desktop E2E - Build: ${BUILD_ID} Branch: ${BRANCH} Tag: ${BUILD_TAG}`,
},
);
// Generate short summary, write to file and then send report via webhook
const summary = generateShortSummary(jsonReport);
console.log(summary);
writeJsonToFile(summary, 'summary.json', MOCHAWESOME_REPORT_DIR);
const result = await saveArtifacts();
if (result && result.success) {
console.log('Successfully uploaded artifacts to S3:', result.reportLink);
}
// Create or use an existing test cycle
let testCycle = {};
if (ZEPHYR_ENABLE === 'true') {
const {start, end} = jsonReport.stats;
testCycle = ZEPHYR_CYCLE_KEY ? {key: ZEPHYR_CYCLE_KEY} : await createTestCycle(start, end);
}
// Send test report to "QA: UI Test Automation" channel via webhook
if (TYPE && TYPE !== 'NONE' && WEBHOOK_URL) {
const data = generateTestReport(summary, result && result.success, result && result.reportLink, testCycle.key);
await sendReport('summary report to Community channel', WEBHOOK_URL, data);
}
// Save test cases to Test Management
if (ZEPHYR_ENABLE === 'true') {
await createTestExecutions(jsonReport, testCycle);
}
chai.expect(Boolean(jsonReport.stats.failures), FAILURE_MESSAGE).to.be.false;
};
saveReport();

90
e2e/utils/artifacts.js Normal file
View file

@ -0,0 +1,90 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-console,consistent-return */
const fs = require('fs');
const path = require('path');
const async = require('async');
const AWS = require('aws-sdk');
const mime = require('mime-types');
const readdir = require('recursive-readdir');
const {MOCHAWESOME_REPORT_DIR} = require('./constants');
require('dotenv').config();
const {
AWS_S3_BUCKET,
AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY,
BUILD_ID,
BRANCH,
BUILD_TAG,
} = process.env;
const s3 = new AWS.S3({
signatureVersion: 'v4',
accessKeyId: AWS_ACCESS_KEY_ID,
secretAccessKey: AWS_SECRET_ACCESS_KEY,
});
function getFiles(dirPath) {
return fs.existsSync(dirPath) ? readdir(dirPath) : [];
}
async function saveArtifacts() {
if (!AWS_S3_BUCKET || !AWS_ACCESS_KEY_ID || !AWS_SECRET_ACCESS_KEY) {
console.log('No AWS credentials found. Test artifacts not uploaded to S3.');
return;
}
const s3Folder = `${BUILD_ID}-${BRANCH}-${BUILD_TAG}`.replace(/\./g, '-');
const uploadPath = path.resolve(__dirname, `../../${MOCHAWESOME_REPORT_DIR}`);
const filesToUpload = await getFiles(uploadPath);
return new Promise((resolve, reject) => {
async.eachOfLimit(
filesToUpload,
10,
async.asyncify(async (file) => {
const Key = file.replace(uploadPath, s3Folder);
const contentType = mime.lookup(file);
const charset = mime.charset(contentType);
return new Promise((res, rej) => {
s3.upload(
{
Key,
Bucket: AWS_S3_BUCKET,
Body: fs.readFileSync(file),
ContentType: `${contentType}${charset ? '; charset=' + charset : ''}`,
},
(err) => {
if (err) {
console.log('Failed to upload artifact:', file);
return rej(new Error(err));
}
res({success: true});
},
);
});
}),
(err) => {
if (err) {
console.log('Failed to upload artifacts');
return reject(new Error(err));
}
const reportLink = `https://${AWS_S3_BUCKET}.s3.amazonaws.com/${s3Folder}/mochawesome.html`;
resolve({success: true, reportLink});
},
);
});
}
module.exports = {saveArtifacts};

8
e2e/utils/constants.js Normal file
View file

@ -0,0 +1,8 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
const MOCHAWESOME_REPORT_DIR = './mochawesome-report';
module.exports = {
MOCHAWESOME_REPORT_DIR,
};

306
e2e/utils/report.js Normal file
View file

@ -0,0 +1,306 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-console, camelcase */
const os = require('os');
const axios = require('axios');
const fse = require('fs-extra');
const package = require('../../package.json');
const {MOCHAWESOME_REPORT_DIR} = require('./constants');
const MAX_FAILED_TITLES = 5;
let incrementalDuration = 0;
function getAllTests(results) {
const tests = [];
results.forEach((result) => {
result.tests.forEach((test) => {
incrementalDuration += test.duration;
tests.push({...test, incrementalDuration});
});
if (result.suites.length > 0) {
getAllTests(result.suites).forEach((test) => tests.push(test));
}
});
return tests;
}
function generateStatsFieldValue(stats, failedFullTitles) {
let statsFieldValue = `
| Key | Value |
|:---|:---|
| Passing Rate | ${stats.passPercent.toFixed(2)}% |
| Duration | ${(stats.duration / (60 * 1000)).toFixed(2)} mins |
| Suites | ${stats.suites} |
| Tests | ${stats.tests} |
| :white_check_mark: Passed | ${stats.passes} |
| :x: Failed | ${stats.failures} |
| :fast_forward: Skipped | ${stats.skipped} |
`;
// If present, add full title of failing tests.
// Only show per maximum number of failed titles with the last item as "more..." if failing tests are more than that.
let failedTests;
if (failedFullTitles && failedFullTitles.length > 0) {
const re = /[:'"\\]/gi;
const failed = failedFullTitles;
if (failed.length > MAX_FAILED_TITLES) {
failedTests = failed.slice(0, MAX_FAILED_TITLES - 1).map((f) => `- ${f.replace(re, '')}`).join('\n');
failedTests += '\n- more...';
} else {
failedTests = failed.map((f) => `- ${f.replace(re, '')}`).join('\n');
}
}
if (failedTests) {
statsFieldValue += '###### Failed Tests:\n' + failedTests;
}
return statsFieldValue;
}
function generateShortSummary(report) {
const {results, stats} = report;
const tests = getAllTests(results);
const failedFullTitles = tests.filter((t) => t.fail).map((t) => t.fullTitle);
const statsFieldValue = generateStatsFieldValue(stats, failedFullTitles);
return {
stats,
statsFieldValue,
};
}
function removeOldGeneratedReports() {
[
'all.json',
'summary.json',
'mochawesome.html',
].forEach((file) => fse.removeSync(`${MOCHAWESOME_REPORT_DIR}/${file}`));
}
function writeJsonToFile(jsonObject, filename, dir) {
fse.writeJson(`${dir}/${filename}`, jsonObject).
then(() => console.log('Successfully written:', filename)).
catch((err) => console.error(err));
}
function readJsonFromFile(file) {
try {
return fse.readJsonSync(file);
} catch (err) {
return {err};
}
}
function getOS() {
switch (process.platform) {
case 'darwin':
return 'macOS';
case 'win32':
return 'Windows';
case 'linux':
return 'Linux';
default:
return 'Unknown';
}
}
function getEnvironmentValues() {
return {
playwright_version: package.devDependencies.playwright,
electron_version: package.devDependencies.electron,
os_name: getOS(),
os_version: os.release(),
node_version: process.version,
};
}
const result = [
{status: 'Passed', priority: 'none', cutOff: 100, color: '#43A047'},
{status: 'Failed', priority: 'low', cutOff: 98, color: '#FFEB3B'},
{status: 'Failed', priority: 'medium', cutOff: 95, color: '#FF9800'},
{status: 'Failed', priority: 'high', cutOff: 0, color: '#F44336'},
];
function generateTestReport(summary, isUploadedToS3, reportLink, testCycleKey) {
const {
FULL_REPORT,
TEST_CYCLE_LINK_PREFIX,
} = process.env;
const {statsFieldValue, stats} = summary;
const {
playwright_version,
electron_version,
os_name,
os_version,
node_version,
} = getEnvironmentValues();
let testResult;
for (let i = 0; i < result.length; i++) {
if (stats.passPercent >= result[i].cutOff) {
testResult = result[i];
break;
}
}
const title = generateTitle();
const envValue = `playwright@${playwright_version} | node@${node_version} | electron@${electron_version} | ${os_name}@${os_version}`;
if (FULL_REPORT === 'true') {
let reportField;
if (isUploadedToS3) {
reportField = {
short: false,
title: 'Test Report',
value: `[Link to the report](${reportLink})`,
};
}
let testCycleField;
if (testCycleKey) {
testCycleField = {
short: false,
title: 'Test Execution',
value: `[Recorded test executions](${TEST_CYCLE_LINK_PREFIX}${testCycleKey})`,
};
}
return {
username: 'Playwright UI Test',
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
attachments: [{
color: testResult.color,
author_name: 'Desktop End-to-end Testing',
author_icon: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
author_link: 'https://www.mattermost.com',
title,
fields: [
{
short: false,
title: 'Environment',
value: envValue,
},
reportField,
testCycleField,
{
short: false,
title: `Key metrics (required support: ${testResult.priority})`,
value: statsFieldValue,
},
],
}],
};
}
let quickSummary = `${stats.passPercent.toFixed(2)}% (${stats.passes}/${stats.tests}) in ${stats.suites} suites`;
if (isUploadedToS3) {
quickSummary = `[${quickSummary}](${reportLink})`;
}
let testCycleLink = '';
if (testCycleKey) {
testCycleLink = testCycleKey ? `| [Recorded test executions](${TEST_CYCLE_LINK_PREFIX}${testCycleKey})` : '';
}
return {
username: 'Playwright UI Test',
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
attachments: [{
color: testResult.color,
author_name: 'Desktop End-to-end Testing',
author_icon: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
author_link: 'https://www.mattermost.com/',
title,
text: `${quickSummary} | ${(stats.duration / (60 * 1000)).toFixed(2)} mins ${testCycleLink}\n${envValue}`,
}],
};
}
function generateTitle() {
const {
BRANCH,
PULL_REQUEST,
RELEASE_VERSION,
TYPE,
} = process.env;
let releaseVersion = '';
if (RELEASE_VERSION) {
releaseVersion = ` for ${RELEASE_VERSION}`;
}
let title;
switch (TYPE) {
case 'PR':
title = `E2E for Pull Request Build: [${BRANCH}](${PULL_REQUEST})`;
break;
case 'RELEASE':
title = `E2E for Release Build${releaseVersion}`;
break;
case 'NIGHTLY':
title = 'E2E for Master Nightly Build';
break;
default:
title = 'E2E for Build$';
}
return title;
}
function generateDiagnosticReport(summary, serverInfo) {
const {BRANCH, BUILD_ID} = process.env;
return {
username: 'Cypress UI Test',
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
attachments: [{
color: '#43A047',
author_name: 'Cypress UI Test',
author_icon: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
author_link: 'https://community.mattermost.com/core/channels/ui-test-automation',
title: `Cypress UI Test Automation #${BUILD_ID}, **${BRANCH}** branch`,
fields: [{
short: false,
value: `Start: **${summary.stats.start}**\nEnd: **${summary.stats.end}**\nUser ID: **${serverInfo.userId}**\nTeam ID: **${serverInfo.teamId}**`,
}],
}],
};
}
async function sendReport(name, url, data) {
const requestOptions = {method: 'POST', url, data};
try {
const response = await axios(requestOptions);
if (response.data) {
console.log(`Successfully sent ${name}.`);
}
return response;
} catch (er) {
console.log(`Something went wrong while sending ${name}.`, er);
return false;
}
}
module.exports = {
generateDiagnosticReport,
generateShortSummary,
generateTestReport,
getAllTests,
removeOldGeneratedReports,
sendReport,
readJsonFromFile,
writeJsonToFile,
};

202
e2e/utils/test_cases.js Normal file
View file

@ -0,0 +1,202 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-console */
// See reference: https://support.smartbear.com/tm4j-cloud/api-docs/
const axios = require('axios');
const chalk = require('chalk');
const {getAllTests} = require('./report');
const status = {
passed: 'Pass',
failed: 'Fail',
pending: 'Pending',
skipped: 'Skip',
};
const environment = {
chrome: 'Chrome',
firefox: 'Firefox',
};
function getStepStateResult(steps = []) {
return steps.reduce((acc, item) => {
if (acc[item.state]) {
acc[item.state] += 1;
} else {
acc[item.state] = 1;
}
return acc;
}, {});
}
function getStepStateSummary(steps = []) {
const result = getStepStateResult(steps);
return Object.entries(result).map(([key, value]) => `${value} ${key}`).join(',');
}
function getZEPHYRTestCases(report) {
return getAllTests(report.results).
filter((item) => /^(MM-T)\w+/g.test(item.title)). // eslint-disable-line wrap-regex
map((item) => {
return {
title: item.title,
duration: item.duration,
incrementalDuration: item.incrementalDuration,
state: item.state,
pass: item.pass,
fail: item.fail,
pending: item.pending,
};
}).
reduce((acc, item) => {
// Extract the key to exactly match with "MM-T[0-9]+"
const key = item.title.match(/(MM-T\d+)/)[0];
if (acc[key]) {
acc[key].push(item);
} else {
acc[key] = [item];
}
return acc;
}, {});
}
function saveToEndpoint(url, data) {
return axios({
method: 'POST',
url,
headers: {
'Content-Type': 'application/json; charset=utf-8',
Authorization: process.env.ZEPHYR_API_KEY,
},
data,
}).catch((error) => {
console.log('Something went wrong:', error.response.data.message);
return error.response.data;
});
}
async function createTestCycle(startDate, endDate) {
const {
BRANCH,
BUILD_ID,
JIRA_PROJECT_KEY,
ZEPHYR_CYCLE_NAME,
ZEPHYR_FOLDER_ID,
} = process.env;
const testCycle = {
projectKey: JIRA_PROJECT_KEY,
name: ZEPHYR_CYCLE_NAME ? `${ZEPHYR_CYCLE_NAME} (${BUILD_ID}-${BRANCH})` : `${BUILD_ID}-${BRANCH}`,
description: `Cypress automated test with ${BRANCH}`,
plannedStartDate: startDate,
plannedEndDate: endDate,
statusName: 'Done',
folderId: ZEPHYR_FOLDER_ID,
};
const response = await saveToEndpoint('https://api.zephyrscale.smartbear.com/v2/testcycles', testCycle);
return response.data;
}
async function createTestExecutions(report, testCycle) {
const {
BROWSER,
JIRA_PROJECT_KEY,
ZEPHYR_ENVIRONMENT_NAME,
} = process.env;
const testCases = getZEPHYRTestCases(report);
const startDate = new Date(report.stats.start);
const startTime = startDate.getTime();
const promises = [];
Object.entries(testCases).forEach(([key, steps], index) => {
const testScriptResults = steps.
sort((a, b) => a.title.localeCompare(b.title)).
map((item) => {
return {
statusName: status[item.state],
actualEndDate: new Date(startTime + item.incrementalDuration).toISOString(),
actualResult: 'Cypress automated test completed',
};
});
const stateResult = getStepStateResult(steps);
const testExecution = {
projectKey: JIRA_PROJECT_KEY,
testCaseKey: key,
testCycleKey: testCycle.key,
statusName: stateResult.passed && stateResult.passed === steps.length ? 'Pass' : 'Fail',
testScriptResults,
environmentName: ZEPHYR_ENVIRONMENT_NAME || environment[BROWSER] || 'Chrome',
actualEndDate: testScriptResults[testScriptResults.length - 1].actualEndDate,
executionTime: steps.reduce((acc, prev) => {
acc += prev.duration; // eslint-disable-line no-param-reassign
return acc;
}, 0),
comment: `Cypress automated test - ${getStepStateSummary(steps)}`,
};
// Temporarily log to verify cases that were being saved.
console.log(index, key); // eslint-disable-line no-console
promises.push(saveTestExecution(testExecution, index));
});
await Promise.all(promises);
console.log('Successfully saved test cases into the Test Management System');
}
const saveTestCases = async (allReport) => {
const {start, end} = allReport.stats;
const testCycle = await createTestCycle(start, end);
await createTestExecutions(allReport, testCycle);
};
const RETRY = [];
async function saveTestExecution(testExecution, index) {
await axios({
method: 'POST',
url: 'https://api.zephyrscale.smartbear.com/v2/testexecutions',
headers: {
'Content-Type': 'application/json; charset=utf-8',
Authorization: process.env.ZEPHYR_API_KEY,
},
data: testExecution,
}).then(() => {
console.log(chalk.green('Success:', index, testExecution.testCaseKey));
}).catch((error) => {
// Retry on 500 error code / internal server error
if (!error.response || error.response.data.errorCode === 500) {
if (RETRY[testExecution.testCaseKey]) {
RETRY[testExecution.testCaseKey] += 1;
} else {
RETRY[testExecution.testCaseKey] = 1;
}
saveTestExecution(testExecution, index);
console.log(chalk.magenta('Retry:', index, testExecution.testCaseKey, `(${RETRY[testExecution.testCaseKey]}x)`));
} else {
console.log(chalk.red('Error:', index, testExecution.testCaseKey, error.response.data.message));
}
});
}
module.exports = {
createTestCycle,
saveTestCases,
createTestExecutions,
};

1709
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -42,7 +42,8 @@
"test:e2e": "cross-env NODE_ENV=test npm-run-all build build-robotjs test:e2e:build test:e2e:run", "test:e2e": "cross-env NODE_ENV=test npm-run-all build build-robotjs test:e2e:build test:e2e:run",
"test:e2e:nobuild": "cross-env NODE_ENV=test npm-run-all test:e2e:build test:e2e:run", "test:e2e:nobuild": "cross-env NODE_ENV=test npm-run-all test:e2e:build test:e2e:run",
"test:e2e:build": "webpack-cli --config webpack.config.test.js", "test:e2e:build": "webpack-cli --config webpack.config.test.js",
"test:e2e:run": "electron-mocha -r @babel/register --reporter mocha-circleci-reporter dist/tests/e2e_bundle.js", "test:e2e:run": "electron-mocha -r @babel/register --reporter mochawesome dist/tests/e2e_bundle.js",
"test:e2e:send-report": "node ./e2e/save_report.js",
"test:unit": "jest", "test:unit": "jest",
"test:unit-ci": "jest --runInBand", "test:unit-ci": "jest --runInBand",
"test:coverage": "jest --coverage", "test:coverage": "jest --coverage",
@ -136,6 +137,7 @@
"@typescript-eslint/eslint-plugin": "5.18.0", "@typescript-eslint/eslint-plugin": "5.18.0",
"@typescript-eslint/parser": "5.18.0", "@typescript-eslint/parser": "5.18.0",
"7zip-bin": "5.1.1", "7zip-bin": "5.1.1",
"aws-sdk": "^2.1190.0",
"axios": "0.26.1", "axios": "0.26.1",
"babel-eslint": "10.1.0", "babel-eslint": "10.1.0",
"babel-loader": "8.2.4", "babel-loader": "8.2.4",
@ -164,11 +166,13 @@
"mini-css-extract-plugin": "2.6.0", "mini-css-extract-plugin": "2.6.0",
"mmjstool": "github:mattermost/mattermost-utilities#3b4506b0f6b14fbb402f9f8ef932370e459e3773", "mmjstool": "github:mattermost/mattermost-utilities#3b4506b0f6b14fbb402f9f8ef932370e459e3773",
"mocha-circleci-reporter": "0.0.3", "mocha-circleci-reporter": "0.0.3",
"mochawesome": "7.1.3",
"node-gyp": "9.0.0", "node-gyp": "9.0.0",
"node-loader": "2.0.0", "node-loader": "2.0.0",
"npm-run-all": "4.1.5", "npm-run-all": "4.1.5",
"playwright": "1.23.4", "playwright": "1.23.4",
"ps-node": "^0.1.6", "ps-node": "^0.1.6",
"recursive-readdir": "^2.2.2",
"robotjs": "0.6.0", "robotjs": "0.6.0",
"sass-loader": "12.6.0", "sass-loader": "12.6.0",
"shebang-loader": "0.0.1", "shebang-loader": "0.0.1",