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 { return this.ortoniReport.onBegin(config, suite); } onError(error: TestError): void { return this.ortoniReport.onError(error); } onExit(): Promise { return this.ortoniReport.onExit(); } onStdOut(chunk: string | Buffer, test: void | TestCase, result: void | TestResult): void { return this.ortoniReport.onStdOut(chunk, test, result); } }