Implements a custom Playwright reporter to integrate with Jira. This reporter automatically creates/updates test case and bug subtasks in Jira based on Playwright test results. It also uploads the ortoni HTML report to the parent Jira card. Key features: - Creates a regression card if none are found - Transitions test subtasks based on test results. - Creates bug subtasks for failed tests. - Uploads the Ortoni HTML report as an attachment to the Jira card. - Configurable through environment variables (Jira server, username, API key, project key, etc.). Also adds axios-retry to handle rate limiting errors from Jira API
449 lines
No EOL
18 KiB
TypeScript
449 lines
No EOL
18 KiB
TypeScript
import JiraApiUtil from "@impl/API/JiraApiUtil";
|
|
import type { FullConfig, FullResult, Reporter, Suite, TestCase, TestError, TestResult } from "@playwright/test/reporter";
|
|
import { formatDate, formatDateForFilename } from "@impl/utils/DateUtils";
|
|
import { GetIssueResponse, JiraIssue, JiraIssueFields } from "@business-logic/types/JiraApi";
|
|
import OrtoniReport, { OrtoniReportConfig } from "ortoni-report";
|
|
|
|
let jiraCardNumber = process.env.JIRA_CARD_NUMBER || '';
|
|
const isRegressionRun = process.env.IS_REGRESSION === 'true'? true: false;
|
|
const jiraProjectKey = process.env.JIRA_PROJECT_KEY!;
|
|
const jiraEpicKey = process.env.JIRA_EPIC_KEY!;
|
|
const passTransitionId = '111';
|
|
const failTransitionId = '101';
|
|
const inTestTransitionId = '271';
|
|
const currentDate = formatDateForFilename(new Date());
|
|
|
|
// Ortoni config
|
|
const reportName = `ortoni_report_${currentDate}.html`;
|
|
const reportConfig: OrtoniReportConfig = {
|
|
port: 1994,
|
|
open: "never",
|
|
folderPath: "ortoni-report",
|
|
filename: reportName,
|
|
logo: 'playwright-tests/business-logic/data/logo.png',
|
|
title: "Test Report",
|
|
showProject: false,
|
|
projectName: "ISS-Nextgen-Playwright-Report",
|
|
testType: `E2E- Environment: ${process.env.NODE_ENV} `,
|
|
preferredTheme: "light",
|
|
base64Image: true,
|
|
};
|
|
|
|
export default class JiraWritebackReporter implements Reporter {
|
|
readonly jiraApiUtil: JiraApiUtil = new JiraApiUtil();
|
|
readonly ortoniReport: OrtoniReport = new OrtoniReport(reportConfig);
|
|
readonly loadIssueCalls: (() => void)[] = []; // Calls to loadIssue must be deferred because they depend on information from the API.
|
|
readonly issuesToPass: string[] = []; // Issues to transition to "Pass".
|
|
readonly issuesToFail: string[] = []; // Issues to transition to "Fail".
|
|
readonly issuesToCreate: JiraIssue[] = []; // Issues to be batch created.
|
|
existingSubtasks: JiraIssue[] = []; // Array to hold existing subtasks of the dev card.
|
|
testCaseTypeId: string|undefined = undefined; // ID of the test case subtask type in Jira. Will be filled by API call.
|
|
bugTypeId:string|undefined = undefined; // ID of the bug subtask type.
|
|
userStoryTypeId:string|undefined = undefined;
|
|
parentCard: GetIssueResponse | undefined = undefined; // Variable to hold the parent card. Will be filled by API call.
|
|
|
|
// Will need this if we want to move ortoni report upload into this reporter.
|
|
// readonly ortoniReport = new OrtoniReport(reportConfig);
|
|
|
|
/**
|
|
* This function loads the IDs for the Jira Issue Types we use.
|
|
*
|
|
*/
|
|
async loadJiraIssueTypes() {
|
|
console.log(`JiraWritebackReporter >> Loading Jira Issue Types for project '${jiraProjectKey}'...`);
|
|
const issueTypesRes = await this.jiraApiUtil.getIssueTypes(jiraProjectKey);
|
|
const issueTypes = issueTypesRes.data;
|
|
this.testCaseTypeId = issueTypes.issueTypes.find(issueType => {
|
|
return issueType.subtask === true && issueType.name === 'Test Case Sub-task'
|
|
})?.id;
|
|
this.bugTypeId = issueTypes.issueTypes.find(issueType => {
|
|
return issueType.subtask === true && issueType.name === 'Bug Sub-task'
|
|
})?.id;
|
|
this.userStoryTypeId = issueTypes.issueTypes.find(issueType => {
|
|
return issueType.name === 'Story';
|
|
})?.id;
|
|
console.log('JiraWritebackReporter >> Loaded issue types.');
|
|
}
|
|
|
|
/**
|
|
* This function generates a test subtask based on the test results we pass in.
|
|
* @param test TestCase from onTestEnd()
|
|
* @param result TestResult from onTestEnd()
|
|
* @returns Jira Test Subtask based on test result.
|
|
*/
|
|
getCreateTestSubtask(test: TestCase, result: TestResult) {
|
|
if (result.status === 'skipped') {
|
|
return undefined;
|
|
}
|
|
const testStepTitles = result.steps.map(step => {
|
|
return `-\t${step.title}`;
|
|
}).join('\n');
|
|
const allErrors = result.errors.map(value => {
|
|
return value.message
|
|
}).join('\n');
|
|
|
|
const description = `Most recent test status: ${result.status}.\nDuration: ${result.duration/1000} seconds.\nErrors:\n${allErrors}`
|
|
const testSubtaskBody: JiraIssueFields = {
|
|
summary: test.title,
|
|
description: {
|
|
content: [
|
|
{
|
|
"content": [
|
|
{
|
|
"text": description,
|
|
"type": "text"
|
|
}
|
|
],
|
|
"type": "paragraph"
|
|
}
|
|
],
|
|
"type": "doc",
|
|
"version": 1
|
|
},
|
|
project: {
|
|
key: jiraProjectKey
|
|
},
|
|
issuetype: {
|
|
id: this.testCaseTypeId!
|
|
},
|
|
parent: {
|
|
key: jiraCardNumber
|
|
},
|
|
customfield_14857: {
|
|
type: 'doc',
|
|
version: 1,
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: testStepTitles
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
};
|
|
return testSubtaskBody;
|
|
}
|
|
|
|
/**
|
|
* This function generates a bug subtask based on the test results we pass in.
|
|
* @param test TestCase from onTestEnd()
|
|
* @param result TestResult from onTestEnd()
|
|
* @returns Jira Bug Subtask based on test result.
|
|
*/
|
|
getCreateBug(test: TestCase, result: TestResult) {
|
|
if (result.status === 'passed' || result.status === 'skipped') {
|
|
return undefined;
|
|
}
|
|
const allErrors = result.errors.map(value => {
|
|
return value.message
|
|
}).join('\n');
|
|
|
|
const bug: JiraIssueFields = {
|
|
summary: `TEST FAILED ${formatDate(new Date())}: ${test.title}`,
|
|
description: {
|
|
content: [
|
|
{
|
|
"content": [
|
|
{
|
|
"text": allErrors,
|
|
"type": "text"
|
|
}
|
|
],
|
|
"type": "paragraph"
|
|
}
|
|
],
|
|
"type": "doc",
|
|
"version": 1
|
|
},
|
|
project: {
|
|
key: jiraProjectKey
|
|
},
|
|
issuetype: {
|
|
id: this.bugTypeId!
|
|
},
|
|
parent: {
|
|
key: jiraCardNumber
|
|
}
|
|
};
|
|
return bug;
|
|
}
|
|
|
|
/**
|
|
* This function gives us the correct transition for a test subtask based on this test result.
|
|
* @param result TestResult from onTestEnd()
|
|
* @returns Correct transition to pass to the Jira API.
|
|
*/
|
|
getSubtaskTransition(result: TestResult): { id: string } | undefined {
|
|
if (result.status === 'skipped') {
|
|
return undefined;
|
|
}
|
|
|
|
if (result.status === 'passed') {
|
|
return {
|
|
id: passTransitionId
|
|
};
|
|
} else {
|
|
return {
|
|
id: failTransitionId
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param cardName
|
|
* @param sprintId
|
|
* @returns
|
|
*/
|
|
getCreateRegressionCardRequestBody(cardName: string, sprintId: number) {
|
|
const body: JiraIssueFields = {
|
|
summary: cardName,
|
|
description: {
|
|
content: [
|
|
{
|
|
"content": [
|
|
{
|
|
"text": 'Card to hold regression test results for this sprint.',
|
|
"type": "text"
|
|
}
|
|
],
|
|
"type": "paragraph"
|
|
}
|
|
],
|
|
"type": "doc",
|
|
"version": 1
|
|
},
|
|
project: {
|
|
key: jiraProjectKey
|
|
},
|
|
issuetype: { id: this.userStoryTypeId!},
|
|
parent: { key: jiraEpicKey },
|
|
customfield_10007: sprintId,
|
|
customfield_13100: { id: '557058:a314fc5b-aed9-4472-90f8-00f106e06207' } // Mark UAT Tester as N/A
|
|
}
|
|
return body;
|
|
}
|
|
|
|
/**
|
|
* Grabs parent card and existing subtasks.
|
|
*/
|
|
async loadReporterData() {
|
|
let parentCard: GetIssueResponse;
|
|
await this.loadJiraIssueTypes();
|
|
if (isRegressionRun) {
|
|
console.log('JiraWritebackReporter >> Searching for current regression card...');
|
|
const sprint = await this.jiraApiUtil.getCurrentSprint();
|
|
if (!sprint) {
|
|
console.error('JiraWritebackReporter >> Could not find current sprint');
|
|
} else {
|
|
console.log(`JiraWritebackReporter >> Found sprint with id '${sprint.id}' called '${sprint.name}'`);
|
|
}
|
|
const regressionCardName = `Playwright Automated Regression Tests ${sprint?.name}`;
|
|
const parentRes = await this.jiraApiUtil.getJqlSearchIssue({ jql: `sprint = ${sprint?.id} and summary ~ "${regressionCardName}"` });
|
|
if (parentRes.data.issues.length > 1) {
|
|
console.log('JiraWritebackReporter >> WARNING: Multiple regression cards found. Using first one.');
|
|
parentCard = (await this.jiraApiUtil.getIssue(parentRes.data.issues[0].id!)).data;
|
|
await this.jiraApiUtil.postTransitionIssue(parentCard.id!, {
|
|
transition: {
|
|
id: inTestTransitionId
|
|
}
|
|
});
|
|
} else if (parentRes.data.issues.length < 1) {
|
|
// NO regression card found. Make one.
|
|
console.log('JiraWritebackReporter >> No regression card was found for this sprint. Creating one.');
|
|
const fields = this.getCreateRegressionCardRequestBody(regressionCardName, sprint!.id);
|
|
const regressionCard: JiraIssue = {
|
|
fields: fields,
|
|
transition: {
|
|
id: inTestTransitionId
|
|
}
|
|
};
|
|
const createRes = await this.jiraApiUtil.postCreateIssue(regressionCard);
|
|
regressionCard.id = createRes.data.id;
|
|
regressionCard.key = createRes.data.key;
|
|
parentCard = regressionCard;
|
|
} else {
|
|
// Found one
|
|
console.log('JiraWritebackReporter >> Found one regression card. Using it.');
|
|
parentCard = (await this.jiraApiUtil.getIssue(parentRes.data.issues[0].id!)).data;
|
|
await this.jiraApiUtil.postTransitionIssue(parentCard.id!, {
|
|
transition: {
|
|
id: inTestTransitionId
|
|
}
|
|
});
|
|
}
|
|
jiraCardNumber = parentCard.key!;
|
|
console.log(`JiraWritebackReporter >> Retrieved current regression card: ${parentCard.key}: ${parentCard.fields.summary}`);
|
|
} else {
|
|
console.log('JiraWritebackReporter >> Searching for parent card...');
|
|
const parentRes = await this.jiraApiUtil.getIssue(jiraCardNumber);
|
|
parentCard = parentRes.data;
|
|
console.log(`JiraWritebackReporter >> Retrieved parent card: ${parentCard.key}: ${parentCard.fields.summary}`);
|
|
}
|
|
|
|
console.log(`JiraWritebackReporter >> Searching for all subtasks of parent card '${jiraCardNumber}'...`);
|
|
const subtaskIds = parentCard.fields.subtasks?.map(subtask => {
|
|
return subtask.id;
|
|
});
|
|
|
|
if (subtaskIds && subtaskIds.length > 0) {
|
|
for (let i = 0; i < subtaskIds.length; i+=50) {
|
|
const batch = subtaskIds.slice(i, i+50);
|
|
const subTasksRes = await this.jiraApiUtil.postBulkFetchIssues({ issueIdsOrKeys: batch });
|
|
this.existingSubtasks.push(...subTasksRes.data.issues);
|
|
}
|
|
console.log('JiraWritebackReporter >> Subtasks retrieved.');
|
|
} else {
|
|
console.log('JiraWritebackReporter >> No subtasks were found.');
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* Bulk creates test cases and bugs in batches of up to 50. Bulk transitions existing test case subtasks.
|
|
*/
|
|
async writeResults() {
|
|
for (let i = 0; i < this.issuesToCreate.length; i+=50) {
|
|
const batch = this.issuesToCreate.slice(i, i + 50);
|
|
console.log('JiraWritebackReporter >> Creating issue batch...');
|
|
// fire request
|
|
await this.jiraApiUtil.postBulkCreateIssue({ issueUpdates: batch });
|
|
console.log('JiraWritebackReporter >> Issue batch created.');
|
|
}
|
|
|
|
if (this.issuesToPass.length > 0) {
|
|
console.log('JiraWritebackReporter >> Transitioning passed test cases to "Pass"...')
|
|
await this.jiraApiUtil.postBulkTransitionIssues( {
|
|
bulkTransitionInputs: [{
|
|
selectedIssueIdsOrKeys: this.issuesToPass,
|
|
transitionId: passTransitionId
|
|
}],
|
|
sendBulkNotification: false
|
|
});
|
|
console.log('JiraWritebackReporter >> Transition success.');
|
|
}
|
|
|
|
if (this.issuesToFail.length > 0) {
|
|
console.log('JiraWritebackReporter >> Transitioning failed test cases to "Fail"...')
|
|
await this.jiraApiUtil.postBulkTransitionIssues( {
|
|
bulkTransitionInputs: [{
|
|
selectedIssueIdsOrKeys: this.issuesToFail,
|
|
transitionId: failTransitionId
|
|
}],
|
|
sendBulkNotification: false
|
|
});
|
|
console.log('JiraWritebackReporter >> Transition success.')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds and loads appropriate response based on test result. This can be a test subtask/bug subtask or a call to transition a test subtask.
|
|
* @param test TestCase from onTestEnd()
|
|
* @param result TestResult from onTestEnd()
|
|
* @returns Void
|
|
*/
|
|
loadIssue(test: TestCase, result: TestResult) {
|
|
if (result.status === 'skipped') return;
|
|
if (result.status !== 'passed' && result.retry < test.retries) return; // Skip if test case failed and this isn't the last retry.
|
|
|
|
let existingTestSubtask: JiraIssue | undefined = undefined;
|
|
let existingBug: JiraIssue | undefined = undefined;
|
|
const subtaskTransition = this.getSubtaskTransition(result)!; // Get either a Pass or Fail transition depending on test results.
|
|
|
|
console.log('JiraWritebackReporter >> Checking for existing subtasks for this test...');
|
|
for (const card of this.existingSubtasks) {
|
|
if (card.fields.issuetype?.id === this.testCaseTypeId) {
|
|
if (card.fields.summary === test.title) {
|
|
existingTestSubtask = card;
|
|
console.log('JiraWritebackReporter >> Found existing test subtask.');
|
|
}
|
|
} else if (card.fields.issuetype?.id === this.bugTypeId) {
|
|
if (new RegExp(`^TEST FAILED [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]: ${test.title}$`).test(card.fields.summary)) {
|
|
existingBug = card;
|
|
console.log('JiraWritebackReporter >> Found existing bug.');
|
|
}
|
|
}
|
|
}
|
|
if (!(existingTestSubtask || existingBug)) {
|
|
console.log('JiraWritebackReporter >> No existing test subtask or bug was found.')
|
|
}
|
|
|
|
const subTask = this.getCreateTestSubtask(test, result); // Will return undefined if test status is 'skipped'
|
|
const bug = this.getCreateBug(test, result); // Will return undefined if we don't need one
|
|
if (subTask) {
|
|
// Create or Edit Subtask
|
|
if (existingTestSubtask) {
|
|
if (result.status === 'passed') {
|
|
this.issuesToPass.push(existingTestSubtask.key!);
|
|
} else {
|
|
this.issuesToFail.push(existingTestSubtask.key!);
|
|
}
|
|
} else {
|
|
this.issuesToCreate.push({
|
|
fields: subTask,
|
|
transition: subtaskTransition
|
|
});
|
|
}
|
|
}
|
|
|
|
if (bug) {
|
|
// Create or Edit Bug
|
|
if (existingBug) {
|
|
// Leave it alone. This reporter should not modify existing bugs.
|
|
} else {
|
|
this.issuesToCreate.push({
|
|
fields: bug
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This function loads calls to loadIssue() in an array to be executed when the necessary information from the Jira API is available.
|
|
* @param test
|
|
* @param result
|
|
*/
|
|
onTestEnd(test: TestCase, result: TestResult) {
|
|
this.ortoniReport.onTestEnd(test, result);
|
|
this.loadIssueCalls.push(() => { this.loadIssue(test, result); });
|
|
}
|
|
|
|
/**
|
|
* Makes calls to load and execute API calls to Jira.
|
|
* @param result
|
|
* @returns Promise to write results to Jira.
|
|
*/
|
|
onEnd(result: FullResult): Promise<{ status?: FullResult["status"]; } | undefined | void>|void {
|
|
return this.ortoniReport.onEnd(result).then(async () => {
|
|
await this.loadReporterData().then(async () => {
|
|
this.loadIssueCalls.map(fn => fn());
|
|
await this.writeResults();
|
|
}).then(async () => {
|
|
console.log('JiraWritebackReporter >> Uploading Ortoni HTML Report to Jira...');
|
|
await this.jiraApiUtil.postUploadAttachment(jiraCardNumber, `${reportConfig.folderPath}/${reportConfig.filename}`);
|
|
console.log('JiraWritebackReporter >> Uploaded Ortoni HTML Report to Jira.');
|
|
console.log(`JiraWritebackReporter >> Posted necessary changes for '${jiraCardNumber}'`);
|
|
});
|
|
});
|
|
}
|
|
|
|
onBegin(config: FullConfig, suite: Suite): Promise<void> {
|
|
return this.ortoniReport.onBegin(config, suite);
|
|
}
|
|
|
|
onError(error: TestError): void {
|
|
return this.ortoniReport.onError(error);
|
|
}
|
|
|
|
onExit(): Promise<void> {
|
|
return this.ortoniReport.onExit();
|
|
}
|
|
|
|
onStdOut(chunk: string | Buffer, test: void | TestCase, result: void | TestResult): void {
|
|
return this.ortoniReport.onStdOut(chunk, test, result);
|
|
}
|
|
} |