UNPKG

aiotests-playwright-reporter

Version:

AIO Tests reporter for reporting results from Playwright to AIO Tests

717 lines (662 loc) 25.8 kB
import AioTestsLogger from "./AioTestsLogger"; import { AioConfig, BulkRequestBody, CreateCycleBody, CustomFieldUpdate, CustomFieldUpdateData, TestData, TestDataEntry, } from "./model"; import axios, { AxiosInstance, AxiosResponse, isAxiosError} from "axios"; import fs from "fs"; import FormData from "form-data"; import { TestCase, TestResult } from "@playwright/test/reporter"; const baseUrl = "https://tcms.aiojiraapps.com/aio-tcms/api/v1"; const restVersion = "/rest/aio-tcms-api/1.0"; const rateLimitWaitTime = 60 * 1000; const apiTimeout = 45 * 1000; const CREATE_IF_ABSENT = "CREATE_IF_ABSENT"; const createNewCycleOptions = [true, false, "true", "false", CREATE_IF_ABSENT]; const bulkRequestBody: BulkRequestBody = { testRuns: [] }; const browserKeyMap: Map<string, string[]> = new Map(); const browserBulkRequestMap: Map<String, BulkRequestBody> = new Map(); let debugMode = false; let aioAPIClient: AxiosInstance | null; let isAttachmentAPIAvailable: boolean | null = null; let stopReporting: boolean = false; let allCaseKeys: string[] = []; let allFailedRuns: string[] = []; let bulkTestRunUpdate = false; let multiBrowser = false; async function sleep(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } function initAPIClient(aioConfig: AioConfig) { debugMode = !!aioConfig.debugMode; AioTestsLogger.setDebugMode(debugMode); AioTestsLogger.debug("Debug Mode is set to true"); if (aioConfig.cloud || process.env.AIO_API_KEY) { if (!aioConfig.cloud.apiKey && !process.env.AIO_API_KEY) { AioTestsLogger.error( 'Cloud hosting config does not specify apiKey. Please add "cloud":{"apiKey":"auth token"}', debugMode ); } else { let apiKey = process.env.AIO_API_KEY ? process.env.AIO_API_KEY : aioConfig.cloud.apiKey; aioAPIClient = axios.create({ baseURL: baseUrl, timeout: apiTimeout, headers: { Authorization: `AioAuth ${apiKey}` }, }); } return; } else if (aioConfig.hosted) { if (!!!aioConfig.hosted.jiraUrl) { AioTestsLogger.error( 'Server hosting config does not specify jiraUrl. Please add "server":{"jiraUrl":"https://companyhostedjira.com/jira"}' ); } else { aioAPIClient = axios.create({ baseURL: aioConfig.hosted.jiraUrl + restVersion, timeout: apiTimeout, }); if (aioConfig.hosted.jiraUsername || process.env.JIRA_USERNAME) { let jUsername = process.env.JIRA_USERNAME ? process.env.JIRA_USERNAME : aioConfig.hosted.jiraUsername; let jPassword = process.env.JIRA_PASSWORD ? process.env.JIRA_PASSWORD : aioConfig.hosted.jiraPassword; aioAPIClient.defaults.auth = { username: jUsername, password: jPassword, }; } else if (aioConfig.hosted.jiraPAT || process.env.JIRA_PAT) { let jPAT = process.env.JIRA_PAT ? process.env.JIRA_PAT : aioConfig.hosted.jiraPAT; aioAPIClient.defaults.headers.common[ "Authorization" ] = `Bearer ${jPAT}`; } else { AioTestsLogger.error( "Server hosting config missing Jira username or PAT. " + ' Please set JIRA_USERNAME/JIRA_PASSWORD or JIRA_PAT as an environment variable or add "hosted":{"jiraUrl":"yoururl", "jiraUsername":"un", "jiraPassword":"pwd"}' + ' or "hosted":{"jiraUrl":"your url", "jiraPAT":"PAT token"} to config file' ); aioAPIClient = null; } } return; } AioTestsLogger.error( "No authentication information found. Please set AIO_API_KEY/JIRA_USERNAME/JIRA_PAT as an environment variable or " + 'add "cloud":{"apiKey":"auth token"} or "server":{"jiraServerUrl":"val"}) to config' ); } export const validateAIOConfig = (config: AioConfig, reportError: boolean): AioConfig | undefined => { if (!!config.enableReporting) { if (!!!config.jiraProjectId && !!reportError) { AioTestsLogger.error( "Jira Project Id is mandatory for AIO Tests Reporting.", true ); return; } initAPIClient(config); if (!aioAPIClient) { AioTestsLogger.error( "Please specify valid credentials to connect with AIO Tests." ); return; } return config; } else { if (reportError) AioTestsLogger.error( "AIO Tests reporting is not enabled. Please set env:{aioTests:{enableReporting:true}}", true ); } return undefined; }; const getOrCreateCycle = async (aioConfig: AioConfig): Promise<string | void> => { if (!aioAPIClient) { return Promise.resolve(); } AioTestsLogger.logStartEnd("Determining cycle to update"); if (!aioConfig.cycleDetails) { AioTestsLogger.error( 'Please specify cycleDetails in config. eg. "cycleDetails": {"cycleKey":"AT-CY-11"}', true ); return Promise.resolve(); } else { let aioCycleConfig = aioConfig.cycleDetails; if ( aioCycleConfig.createNewCycle && !createNewCycleOptions.includes(aioCycleConfig.createNewCycle) ) { return Promise.resolve( `Invalid value for createNewCycle "${aioCycleConfig.createNewCycle}". Valid values include ${createNewCycleOptions}.` ); } if ((!aioCycleConfig.createNewCycle || "false" === aioCycleConfig.createNewCycle) && aioCycleConfig.cycleKey) { aioCycleConfig["cycleKeyToReportTo"] = aioCycleConfig.cycleKey; return Promise.resolve(); } if (aioCycleConfig.createNewCycle === true || "true" === aioCycleConfig.createNewCycle || aioCycleConfig.createNewCycle === CREATE_IF_ABSENT) { let cycleTitle = aioCycleConfig.cycleName; let customFields = aioCycleConfig.customFields; if (!cycleTitle) { return Promise.resolve("createNewCycle is set to " + aioCycleConfig.createNewCycle + " in config. Please set cycleName."); } if (aioCycleConfig.createNewCycle === CREATE_IF_ABSENT) { let results: any = await findExistingCycleThroughName(aioConfig); if (results === true) { AioTestsLogger.debug("Existing cycle found"); //Cycle found and set. return; } if (results !== false) { //Error while finding cycle. AioTestsLogger.log(results); return; } } AioTestsLogger.log("Creating cycle : " + cycleTitle); let folderCreationPromise = getOrCreateFolder( aioConfig.jiraProjectId, aioCycleConfig ); return folderCreationPromise .then(async (folderCreationResponse) => { AioTestsLogger.debug("Folder task resolved. Creating cycle."); let createCycleBody: CreateCycleBody = { title: cycleTitle, customFields: customFields || null, }; if (folderCreationResponse) { createCycleBody.folder = folderCreationResponse.data; } if (aioCycleConfig.tasks && aioCycleConfig.tasks.length > 0 && Array.isArray(aioCycleConfig.tasks)) { let jiraTasks = aioCycleConfig.tasks.filter((f) => f && !!f.trim()); if (jiraTasks.length > 0) { createCycleBody.jiraTaskIDs = jiraTasks; } } AioTestsLogger.debug("Cycle endpoint " + "/project/" + aioConfig.jiraProjectId + "/testcycle/detail"); try { let response = await aioAPIClient! .post( "/project/" + aioConfig.jiraProjectId + "/testcycle/detail", createCycleBody ); aioCycleConfig["cycleKeyToReportTo"] = response.data.key; AioTestsLogger.log("Cycle created successfully : " + aioCycleConfig.cycleKeyToReportTo); } catch (error: any) { debugLogError(error); if (error.response) { if ( error.response.status === 401 || error.response.status === 403 ) { return Promise.resolve( "Authorization error. Please check credentials." ); } else { return Promise.resolve( error.response.status + " : " + error.response.data ); } } } }) .catch((error: any) => { debugLogError(error); if (error.response) { AioTestsLogger.error( error.response.status + " : " + error.response.data ); } return Promise.resolve( "Error in fetching or creating cycle folder. " + 'Please check format of folder, for eg. ["Cloud","Release1"]' ); }); } return Promise.resolve( 'createNewCycle is false in config. Please specify a cycle key (eg. AT-CY-11) as "cycleKey":"AT-CY=11" or cycle name as "cycleName":"Regression release 1" ' ); } }; async function getOrCreateFolder(jiraProjectId: string, aioCycleConfig: { folder?: string[] }): Promise<any> { if (aioCycleConfig.folder && Array.isArray(aioCycleConfig.folder) && aioCycleConfig.folder.length > 0) { const userFolderHierarchy = aioCycleConfig.folder.filter( (folder) => folder && folder.trim() ); if (userFolderHierarchy.length > 0) { AioTestsLogger.log(`Creating or fetching folder: ${userFolderHierarchy}`); return aioAPIClient!.put( `/project/${jiraProjectId}/testcycle/folder/hierarchy`, { folderHierarchy: userFolderHierarchy, } ); } } else { AioTestsLogger.debug("No cycle folder information set."); } return Promise.resolve(); } async function findExistingCycleThroughName(aioConfig: AioConfig): Promise<boolean | string> { if (aioConfig.parallelBuild && aioConfig.parallelBuild.masterBuild === false) { let to = aioConfig.parallelBuild.waitForSeconds ? aioConfig.parallelBuild.waitForSeconds : 2; AioTestsLogger.log(`Waiting for ${to} seconds for master build to finish`); await new Promise((resolve) => setTimeout(resolve, to * 1000)); } let body = { title: { comparisonType: "EXACT_MATCH", value: aioConfig.cycleDetails.cycleName!.trim(), }, }; AioTestsLogger.log("Finding cycle with name : " + aioConfig.cycleDetails.cycleName!.trim()); return aioAPIClient! .post(`/project/${aioConfig.jiraProjectId}/testcycle/search`, body) .then(function (response) { const items = response?.data?.items; if (items && items.length > 0) { aioConfig.cycleDetails["cycleKeyToReportTo"] = items[0]?.key; return true; } else { return false; } }) .catch(function (error) { debugLogError(error); if (error.response) { if (error.response.status === 401 || error.response.status === 403) { return Promise.resolve( "Authorization error. Please check credentials." ); } else { return Promise.resolve( error.response.status + " : " + error.response.data ); } } return Promise.resolve( "An unknown error occurred while searching for the cycle." ); }); } function getCustomFieldValueToUpdate(customFieldsToUpdate: CustomFieldUpdate[]): CustomFieldUpdateData[] { const cfUpdates: CustomFieldUpdateData[] = []; if (customFieldsToUpdate) { customFieldsToUpdate.forEach((cf) => { const data: CustomFieldUpdateData = { customValue: { name: cf.name, value: cf.value }, }; if (cf.operationType) { data.customFieldUpdateOperationType = cf.operationType; } cfUpdates.push(data); }); } return cfUpdates; } const replaceProjectNameForRunFields = (customFieldUpdates: CustomFieldUpdateData[] | null, projectName: string): CustomFieldUpdateData[] | null => { if (customFieldUpdates == null) { return null; } return customFieldUpdates.map((customFieldUpdate) => replaceProjectName(customFieldUpdate, projectName) ); }; const replaceProjectName = (customFieldUpdate: CustomFieldUpdateData, projectName: string): CustomFieldUpdateData => { const { value } = customFieldUpdate.customValue; let newValue; if (typeof value === "string") { // Case: value is a string newValue = value.replace(/\$project/g, projectName); } else if (Array.isArray(value)) { if (value.every((item) => typeof item === "string")) { // Case: value is a string[] newValue = value.map((item) => item.replace(/\$project/g, projectName)); } else if (value.every((item) => typeof item === "object" && item !== null && "value" in item)) { // Case: value is a { value: string }[] newValue = value.map((item) => ({ value: (item as { value: string }).value.replace(/\$project/g, projectName), })); } else { throw new Error("Invalid array structure in value field"); } } else if (typeof value === "object" && value !== null) { if ("value" in value) { // Case: value is { value: string } newValue = { value: value.value.replace(/\$project/g, projectName) }; } else { throw new Error("Invalid object structure in value field"); } } else { newValue = value; } return { customValue: { name: customFieldUpdate.customValue.name, value: newValue }, customFieldUpdateOperationType: customFieldUpdate.customFieldUpdateOperationType, }; }; function debugLogError(error: any): void { if (debugMode) { AioTestsLogger.error("*** AIO Debug Mode Error Reporting ***"); AioTestsLogger.errorObj(error.message); if (error.response) { const status = error.response.status; const data = error.response.data; AioTestsLogger.errorObj(`${status} ${data}`); } else { AioTestsLogger.errorObj(error); } AioTestsLogger.error("*** AIO Debug Mode Error Reporting End ***"); } } function getAIORunStatus(playwrightStatus: String): string { switch (playwrightStatus) { case "failed": return "Failed"; case "passed": return "Passed"; default: return "Not Run"; } } async function uploadScreenshot(path: string|undefined, jiraProjectId: string, cyclekey: string, runId: string, trialCounter: number = 0): Promise<void> { if(path) { const form = new FormData(); form.append("file", fs.createReadStream(path)); try { await aioAPIClient!.post( `/project/${jiraProjectId}/testcycle/${cyclekey}/testrun/${runId}/attachment`, form, { headers: form.getHeaders(), } ); AioTestsLogger.log(`Screenshot uploaded: ${path}`); } catch (error: any) { if (error.response) { if (error.response.status === 429 && trialCounter < 3) { AioTestsLogger.log("Reached AIO rate limits. Pausing..."); await sleep(rateLimitWaitTime); return uploadScreenshot( path, jiraProjectId, cyclekey, runId, trialCounter + 1 ); } else if (error.response.status === 404) { AioTestsLogger.error( "Attachment API is not supported in the current API version and hence attachments could not be uploaded. Please upgrade to the latest version of AIO Tests." ); isAttachmentAPIAvailable = false; } } else { if (error.data) { AioTestsLogger.error(error.data); } else { AioTestsLogger.error(error.code); } } } } } async function initCycleDetails(aioConfig: AioConfig) { try { const data = await getOrCreateCycle(aioConfig); if (aioConfig.cycleDetails.cycleKeyToReportTo) { AioTestsLogger.log("Reporting results to cycle : " + aioConfig.cycleDetails.cycleKeyToReportTo); } else { stopReporting = true; if (typeof data === "string") { AioTestsLogger.error(data); } } } catch (err) { if (err instanceof Error) { AioTestsLogger.error("An error occurred: " + err.message); } else { AioTestsLogger.error("An unknown error occurred"); } stopReporting = true; } } export const reportTestResults = async function (config: AioConfig, testSummary: TestData[], multiBrowsers: boolean): Promise<void> { if(testSummary && testSummary.length) { await initCycleDetails(config); if (!aioAPIClient || stopReporting) { return Promise.resolve(); } multiBrowser = multiBrowsers; const cfUpdates = config.runDetails? getCustomFieldValueToUpdate(config.runDetails.customFieldsToUpdate) : null; let index = 0; const total = testSummary.length; AioTestsLogger.logStartEnd("Reporting results"); for (const {result, test} of testSummary) { const isLast = index === total - 1; await reportSpecResults(config, result, test, cfUpdates); if (isLast && bulkTestRunUpdate) { await bulkUpdateResult(config); } index++; } }else { AioTestsLogger.log("No case keys found in specs"); } }; const reportSpecResults = async function (config: AioConfig, results: TestResult, testCase: TestCase, cfUpdates: CustomFieldUpdateData[]|null): Promise<void> { const testData = await findResults(results, testCase); const passedCaseKeys: string[] = []; const failedCaseKeys: string[] = []; const targetKeys = results.status === "passed" ? passedCaseKeys : failedCaseKeys; targetKeys.push(...testData.keys()); allCaseKeys.push(...targetKeys); if (failedCaseKeys.length > 0) { AioTestsLogger.log("*".repeat(5) + " Updating failed cases " + "*".repeat(5)); try { await postFailedResult(failedCaseKeys, testData, config, cfUpdates); } catch (err) { debugLogError(err); } } await addDataForBulkUpdateResult(passedCaseKeys, testData, config, cfUpdates); }; async function postFailedResult(failedCaseKeys: string[], testData: Map<string, TestDataEntry>, config: AioConfig, cfUpdates: CustomFieldUpdateData[]|null): Promise<void> { return failedCaseKeys.reduce<Promise<void>>((promiseChain, caseKey) => { const attemptData = testData.get(caseKey); return promiseChain.then(async () => { await postResult(config, caseKey, attemptData!, attemptData!.screenshot!, cfUpdates); }); }, Promise.resolve()); } async function findResults(results: TestResult, testCase: TestCase): Promise<Map<string, TestDataEntry>> { let testData: Map<string, TestDataEntry> = new Map<string, TestDataEntry>(); let pattern = new RegExp("\\w+-TC-\\d+", "gi"); let tcKeys: string[] = []; let match; testCase.tags.forEach((tag) => { do { match = pattern.exec(tag); if (match) { tcKeys.push(match[0]); } } while (match != null); }); if (tcKeys.length) { tcKeys.forEach((tcKey) => { // @ts-ignore const comments = results.error || multiBrowser ? generateComments(testCase._projectId ? testCase._projectId : "", results.error) : null; // @ts-ignore testData.set(tcKey, { testId: tcKey, duration: results.duration / 1000, testStatus: getAIORunStatus(results.status), retires: results.retry, comments: comments, screenshot: results.attachments .filter((attachment) => attachment.contentType.startsWith("image")) .map((attachment) => attachment.path) .filter((path) => path !== undefined), // @ts-ignore browser: testCase._projectId! }); }); } return testData; } function generateComments(browserName: string, error?: { message?: string; snippet?: string; stack?: string; value?: string; }): string[] { return [ multiBrowser ? `Browser: ${browserName}` : undefined, error?.message ? `Message: ${error.message}` : undefined, error?.stack ? `Stack: ${error.stack}` : undefined, ].filter(Boolean) as string[]; } async function addDataForBulkUpdateResult(passedCaseKeys: string[], testDataMap: Map<string, TestDataEntry>, config: AioConfig, cfUpdates: CustomFieldUpdateData[]|null): Promise<void> { for (const passedCaseKey of passedCaseKeys) { const testData = testDataMap.get(passedCaseKey); if (!testData) continue; const testRun = { testCaseKey: passedCaseKey, testRunStatus: testData.testStatus, effort: testData.duration, isAutomated: true, comments: testData.comments, customFieldValueToUpdate: config.runDetails ? replaceProjectNameForRunFields(cfUpdates, testData.browser) : cfUpdates, }; if (multiBrowser) { const existingRequest = browserBulkRequestMap.get(testData.browser); if (existingRequest) { existingRequest.testRuns.push(testRun); } else { browserBulkRequestMap.set(testData.browser, { testRuns: [testRun] }); } } else { bulkRequestBody.testRuns.push(testRun); } bulkTestRunUpdate = true; } } async function bulkUpdateResult(aioConfig: AioConfig, trialCounter = 0): Promise<void> { const { jiraProjectId, cycleDetails, addNewRun } = aioConfig; const url = `/project/${jiraProjectId}/testcycle/${cycleDetails.cycleKeyToReportTo}/bulk/testrun/update?createNewRun=${!!addNewRun}`; AioTestsLogger.log("*".repeat(5) + " Updating all passed cases " + "*".repeat(5)); AioTestsLogger.debug(`Reporting results in bulk: ${url}`); try { const processResponse = (response: AxiosResponse, browser?: String) => { const { successCount, errorCount, errors } = response.data; AioTestsLogger.log(`Successfully reported ${successCount} passed cases${browser ? ` for browser: ${browser}` : ""}.`); if (errorCount > 0) { AioTestsLogger.error("Failures in reporting passed cases:"); Object.entries(errors).forEach(([key, error]) => { const errorMessage = (error as { message?: string }).message || "Unknown error"; AioTestsLogger.error(`${key}: ${errorMessage}`); }); } }; if (multiBrowser) { for (const [browser, bulkRequestBodyBrowser] of browserBulkRequestMap) { const response = await aioAPIClient!.post(url, bulkRequestBodyBrowser); processResponse(response, browser); } } else { const response = await aioAPIClient!.post(url, bulkRequestBody); processResponse(response); } } catch (err: any) { debugLogError(err); if (err.response?.status === 429 && trialCounter < 3) { AioTestsLogger.log("Reached AIO rate limits. Retrying..."); await sleep(rateLimitWaitTime); return bulkUpdateResult(aioConfig, trialCounter + 1); } const errorMsg = err.response ? `Status Code - ${err.response.status}: ${JSON.stringify( err.response.data )}` : err.code; AioTestsLogger.error(`Error in bulk cases reporting: ${errorMsg}`); stopReporting = true; } } async function postResult(aioConfig: AioConfig, caseKey: string, results: TestDataEntry, screenshots: (string|undefined)[], cfUpdates:CustomFieldUpdateData[]|null, trialCounter: number = 0): Promise<void> { const data: Record<string, any> = { testRunStatus: results.testStatus, effort: results.duration, isAutomated: true, comments: results.comments, customFieldValueToUpdate: aioConfig.runDetails ? replaceProjectNameForRunFields(cfUpdates, results.browser) : cfUpdates, }; const createNewRun = aioConfig.addNewRun; AioTestsLogger.debug(`Posting results to /project/${aioConfig.jiraProjectId}/testcycle/${aioConfig.cycleDetails.cycleKeyToReportTo}/testcase/${caseKey}/testrun?createNewRun=${createNewRun}`); try { const response = await aioAPIClient!.post( `/project/${aioConfig.jiraProjectId}/testcycle/${aioConfig.cycleDetails.cycleKeyToReportTo}/testcase/${caseKey}/testrun?createNewRun=${createNewRun}`, data); if (multiBrowser) { if (browserKeyMap.has(results.browser)) { browserKeyMap.get(results.browser)?.push(response.data.ID); } else { browserKeyMap.set(results.browser, [response.data.ID]); } } else { allFailedRuns.push(response.data.ID); } AioTestsLogger.log(`Successfully reported ${caseKey} as ${data.testRunStatus} with runID ${response.data.ID}.`); const runId = response.data.ID; if (aioConfig.addAttachmentToFailedCases && data.testRunStatus.toLowerCase() === "failed" && (isAttachmentAPIAvailable || isAttachmentAPIAvailable == null)) { return await uploadAttachments(aioConfig.jiraProjectId, aioConfig.cycleDetails.cycleKeyToReportTo!, runId, screenshots); } } catch (err) { debugLogError(err); if (isAxiosError(err) && err.response) { if (err.response.status === 429 && trialCounter < 3) { AioTestsLogger.log("Reached AIO rate limits. Pausing..."); await sleep(rateLimitWaitTime); return postResult(aioConfig, caseKey, results, screenshots, cfUpdates,trialCounter + 1); } else { AioTestsLogger.error( `Error reporting ${caseKey} : Status Code - ${err.response.status} - ${err.response.data}` ); } } else { AioTestsLogger.error( `Error in bulk cases reporting : ${(err as Error).message}` ); } } } async function uploadAttachments(jiraProjectId: string, cycleKey: string, runId: string, resultScreenshots: (string|undefined)[]): Promise<void> { if (resultScreenshots) { return resultScreenshots.reduce(async (promise, screenshot) => { await promise; return uploadScreenshot(screenshot, jiraProjectId, cycleKey, runId); }, Promise.resolve()); } else { return Promise.resolve(); } }