import JiraApiUtil from "@impl/API/JiraApiUtil"; import type { FullResult, Reporter, TestCase, TestResult } from "@playwright/test/reporter"; import { formatDate } from "@impl/utils/DateUtils"; import { GetIssueResponse, JiraIssue, JiraIssueFields } from "@business-logic/types/JiraApi"; const jiraCardNumber = process.env.JIRA_CARD_NUMBER!; const jiraProjectKey = 'INSR'; const passTransitionId = '111'; const failTransitionId = '101'; export default class JiraWritebackReporter implements Reporter { readonly jiraApiUtil: JiraApiUtil = new JiraApiUtil(); 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. 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; 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 }; } } /** * Grabs parent card and existing subtasks. */ async loadReporterData() { await this.loadJiraIssueTypes(); console.log('JiraWritebackReporter >> Searching for parent card...'); const parentRes = await this.jiraApiUtil.getIssue(jiraCardNumber); const 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.'); // TODO: Handle multiple matches for bug/test case. Currently picks one in that case. } } } 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 { // this.ortoniReport.onEnd(result); return this.loadReporterData().then(async () => { this.loadIssueCalls.map(fn => fn()); await this.writeResults(); console.log(`JiraWritebackReporter >> Posted necessary changes for '${jiraCardNumber}'`); }); } // These overloads will become necessary if we move the ortoni report upload into this reporter // onBegin(config: FullConfig, suite: Suite): void { // this.ortoniReport.onBegin(config, suite); // } // onError(error: TestError): void { // this.ortoniReport.onError(error); // } // onExit(): Promise { // return this.ortoniReport.onExit(); // } // onStdOut(chunk: string | Buffer, test: void | TestCase, result: void | TestResult): void { // this.ortoniReport.onStdOut(chunk, test, result); // } }