UNPKG

@reportportal/client-javascript

Version:
862 lines (807 loc) 27.7 kB
/* eslint-disable quotes,no-console,class-methods-use-this */ const UniqId = require('uniqid'); const { URLSearchParams } = require('url'); const helpers = require('./helpers'); const RestClient = require('./rest'); const { getClientConfig } = require('./commons/config'); const Statistics = require('../statistics/statistics'); const { EVENT_NAME } = require('../statistics/constants'); const { RP_STATUSES } = require('./constants/statuses'); const MULTIPART_BOUNDARY = Math.floor(Math.random() * 10000000000).toString(); class RPClient { /** * Create a client for RP. * @param {Object} options - config object. * options should look like this * { * apiKey: "reportportalApiKey", * endpoint: "http://localhost:8080/api/v1", * launch: "YOUR LAUNCH NAME", * project: "PROJECT NAME", * } * * @param {Object} agentParams - agent's info object. * agentParams should look like this * { * name: "AGENT NAME", * version: "AGENT VERSION", * } */ constructor(options, agentParams) { this.config = getClientConfig(options); this.debug = this.config.debug; this.isLaunchMergeRequired = this.config.isLaunchMergeRequired; this.apiKey = this.config.apiKey; // deprecated this.token = this.apiKey; this.map = {}; this.baseURL = [this.config.endpoint, this.config.project].join('/'); this.headers = { 'User-Agent': 'NodeJS', 'Content-Type': 'application/json; charset=UTF-8', Authorization: `Bearer ${this.apiKey}`, ...(this.config.headers || {}), }; this.helpers = helpers; this.restClient = new RestClient({ baseURL: this.baseURL, headers: this.headers, restClientConfig: this.config.restClientConfig, }); this.statistics = new Statistics(EVENT_NAME, agentParams); this.launchUuid = ''; this.itemRetriesChainMap = new Map(); this.itemRetriesChainKeyMapByTempId = new Map(); } // eslint-disable-next-line valid-jsdoc /** * * @Private */ logDebug(msg, dataMsg = '') { if (this.debug) { console.log(msg, dataMsg); } } calculateItemRetriesChainMapKey(launchId, parentId, name, itemId = '') { return `${launchId}__${parentId}__${name}__${itemId}`; } // eslint-disable-next-line valid-jsdoc /** * * @Private */ cleanItemRetriesChain(tempIds) { tempIds.forEach((id) => { const key = this.itemRetriesChainKeyMapByTempId.get(id); if (key) { this.itemRetriesChainMap.delete(key); } this.itemRetriesChainKeyMapByTempId.delete(id); }); } getUniqId() { return UniqId(); } getRejectAnswer(tempId, error) { return { tempId, promise: Promise.reject(error), }; } getNewItemObj(startPromiseFunc) { let resolveFinish; let rejectFinish; const obj = { promiseStart: new Promise(startPromiseFunc), realId: '', children: [], finishSend: false, promiseFinish: new Promise((resolve, reject) => { resolveFinish = resolve; rejectFinish = reject; }), }; obj.resolveFinish = resolveFinish; obj.rejectFinish = rejectFinish; return obj; } // eslint-disable-next-line valid-jsdoc /** * * @Private */ cleanMap(ids) { ids.forEach((id) => { delete this.map[id]; }); } checkConnect() { const url = [this.config.endpoint.replace('/v2', '/v1'), this.config.project, 'launch'] .join('/') .concat('?page.page=1&page.size=1'); return this.restClient.request('GET', url, {}); } async triggerStatisticsEvent() { if (process.env.REPORTPORTAL_CLIENT_JS_NO_ANALYTICS) { return; } await this.statistics.trackEvent(); } /** * Start launch and report it. * @param {Object} launchDataRQ - request object. * launchDataRQ should look like this * { "description": "string" (support markdown), "mode": "DEFAULT" or "DEBUG", "name": "string", "startTime": this.helper.now(), "attributes": [ { "key": "string", "value": "string" }, { "value": "string" } ] * } * @Returns an object which contains a tempID and a promise * * As system attributes, this method sends the following data (these data are not for public use): * client name, version; * agent name, version (if given); * browser name, version (if given); * OS type, architecture; * RAMSize; * nodeJS version; * * This method works in two ways: * First - If launchDataRQ object doesn't contain ID field, * it would create a new Launch instance at the Report Portal with it ID. * Second - If launchDataRQ would contain ID field, * client would connect to the existing Launch which ID * has been sent , and would send all data to it. * Notice that Launch which ID has been sent must be 'IN PROGRESS' state at the Report Portal * or it would throw an error. * @Returns {Object} - an object which contains a tempID and a promise */ startLaunch(launchDataRQ) { const tempId = this.getUniqId(); if (launchDataRQ.id) { this.map[tempId] = this.getNewItemObj((resolve) => resolve(launchDataRQ)); this.map[tempId].realId = launchDataRQ.id; this.launchUuid = launchDataRQ.id; } else { const systemAttr = helpers.getSystemAttribute(); const attributes = Array.isArray(launchDataRQ.attributes) ? launchDataRQ.attributes.concat(systemAttr) : systemAttr; const launchData = { name: this.config.launch || 'Test launch name', startTime: this.helpers.now(), ...launchDataRQ, attributes, }; this.map[tempId] = this.getNewItemObj((resolve, reject) => { const url = 'launch'; this.logDebug(`Start launch with tempId ${tempId}`, launchDataRQ); this.restClient.create(url, launchData).then( (response) => { this.map[tempId].realId = response.id; this.launchUuid = response.id; if (this.config.launchUuidPrint) { this.config.launchUuidPrintOutput(this.launchUuid); } if (this.isLaunchMergeRequired) { helpers.saveLaunchIdToFile(response.id); } this.logDebug(`Success start launch with tempId ${tempId}`, response); resolve(response); }, (error) => { this.logDebug(`Error start launch with tempId ${tempId}`, error); console.dir(error); reject(error); }, ); }); } this.triggerStatisticsEvent().catch(console.error); return { tempId, promise: this.map[tempId].promiseStart, }; } /** * Finish launch. * @param {string} launchTempId - temp launch id (returned in the query "startLaunch"). * @param {Object} finishExecutionRQ - finish launch info should include time and status. * finishExecutionRQ should look like this * { * "endTime": this.helper.now(), * "status": "passed" or one of ‘passed’, ‘failed’, ‘stopped’, ‘skipped’, ‘interrupted’, ‘cancelled’ * } * @Returns {Object} - an object which contains a tempID and a promise */ finishLaunch(launchTempId, finishExecutionRQ) { const launchObj = this.map[launchTempId]; if (!launchObj) { return this.getRejectAnswer( launchTempId, new Error(`Launch with tempId "${launchTempId}" not found`), ); } const finishExecutionData = { endTime: this.helpers.now(), ...finishExecutionRQ }; launchObj.finishSend = true; Promise.all(launchObj.children.map((itemId) => this.map[itemId].promiseFinish)).then( () => { launchObj.promiseStart.then( () => { this.logDebug(`Finish launch with tempId ${launchTempId}`, finishExecutionData); const url = ['launch', launchObj.realId, 'finish'].join('/'); this.restClient.update(url, finishExecutionData).then( (response) => { this.logDebug(`Success finish launch with tempId ${launchTempId}`, response); console.log(`\nReportPortal Launch Link: ${response.link}`); launchObj.resolveFinish(response); }, (error) => { this.logDebug(`Error finish launch with tempId ${launchTempId}`, error); console.dir(error); launchObj.rejectFinish(error); }, ); }, (error) => { console.dir(error); launchObj.rejectFinish(error); }, ); }, (error) => { console.dir(error); launchObj.rejectFinish(error); }, ); return { tempId: launchTempId, promise: launchObj.promiseFinish, }; } /* * This method is used to create data object for merge request to ReportPortal. * * @Returns {Object} - an object which contains a data for merge launches in ReportPortal. */ getMergeLaunchesRequest(launchIds, mergeOptions = {}) { return { launches: launchIds, mergeType: 'BASIC', description: this.config.description || 'Merged launch', mode: this.config.mode || 'DEFAULT', name: this.config.launch || 'Test launch name', attributes: this.config.attributes, endTime: this.helpers.now(), extendSuitesDescription: true, ...mergeOptions, }; } /** * This method is used for merge launches in ReportPortal. * @param {Object} mergeOptions - options for merge request, can override default options. * mergeOptions should look like this * { * "extendSuitesDescription": boolean, * "description": string, * "mergeType": 'BASIC' | 'DEEP', * "name": string * } * Please, keep in mind that this method is work only in case * the option isLaunchMergeRequired is true. * * @returns {Promise} - action promise */ mergeLaunches(mergeOptions = {}) { if (this.isLaunchMergeRequired) { const launchUUIds = helpers.readLaunchesFromFile(); const params = new URLSearchParams({ 'filter.in.uuid': launchUUIds, 'page.size': launchUUIds.length, }); const launchSearchUrl = this.config.mode === 'DEBUG' ? `launch/mode?${params.toString()}` : `launch?${params.toString()}`; this.logDebug(`Find launches with UUIDs to merge: ${launchUUIds}`); return this.restClient .retrieveSyncAPI(launchSearchUrl) .then( (response) => { const launchIds = response.content.map((launch) => launch.id); this.logDebug(`Found launches: ${launchIds}`, response.content); return launchIds; }, (error) => { this.logDebug(`Error during launches search with UUIDs: ${launchUUIds}`, error); console.dir(error); }, ) .then((launchIds) => { const request = this.getMergeLaunchesRequest(launchIds, mergeOptions); this.logDebug(`Merge launches with ids: ${launchIds}`, request); const mergeURL = 'launch/merge'; return this.restClient.create(mergeURL, request); }) .then((response) => { this.logDebug(`Launches with UUIDs: ${launchUUIds} were successfully merged!`); if (this.config.launchUuidPrint) { this.config.launchUuidPrintOutput(response.uuid); } }) .catch((error) => { this.logDebug(`Error merging launches with UUIDs: ${launchUUIds}`, error); console.dir(error); }); } this.logDebug( 'Option isLaunchMergeRequired is false, merge process cannot be done as no launch UUIDs where saved.', ); } /* * This method is used for frameworks as Jasmine. There is problem when * it doesn't wait for promise resolve and stop the process. So it better to call * this method at the spec's function as @afterAll() and manually resolve this promise. * * @return Promise */ getPromiseFinishAllItems(launchTempId) { const launchObj = this.map[launchTempId]; return Promise.all(launchObj.children.map((itemId) => this.map[itemId].promiseFinish)); } /** * Update launch. * @param {string} launchTempId - temp launch id (returned in the query "startLaunch"). * @param {Object} launchData - new launch data * launchData should look like this * { "description": "string" (support markdown), "mode": "DEFAULT" or "DEBUG", "attributes": [ { "key": "string", "value": "string" }, { "value": "string" } ] } * @Returns {Object} - an object which contains a tempId and a promise */ updateLaunch(launchTempId, launchData) { const launchObj = this.map[launchTempId]; if (!launchObj) { return this.getRejectAnswer( launchTempId, new Error(`Launch with tempId "${launchTempId}" not found`), ); } let resolvePromise; let rejectPromise; const promise = new Promise((resolve, reject) => { resolvePromise = resolve; rejectPromise = reject; }); launchObj.promiseFinish.then( () => { const url = ['launch', launchObj.realId, 'update'].join('/'); this.logDebug(`Update launch with tempId ${launchTempId}`, launchData); this.restClient.update(url, launchData).then( (response) => { this.logDebug(`Launch with tempId ${launchTempId} were successfully updated`, response); resolvePromise(response); }, (error) => { this.logDebug(`Error when updating launch with tempId ${launchTempId}`, error); console.dir(error); rejectPromise(error); }, ); }, (error) => { rejectPromise(error); }, ); return { tempId: launchTempId, promise, }; } /** * If there is no parentItemId starts Suite, else starts test or item. * @param {Object} testItemDataRQ - object with item parameters * testItemDataRQ should look like this * { "description": "string" (support markdown), "name": "string", "startTime": this.helper.now(), "attributes": [ { "key": "string", "value": "string" }, { "value": "string" } ], "type": 'SUITE' or one of 'SUITE', 'STORY', 'TEST', 'SCENARIO', 'STEP', 'BEFORE_CLASS', 'BEFORE_GROUPS', 'BEFORE_METHOD', 'BEFORE_SUITE', 'BEFORE_TEST', 'AFTER_CLASS', 'AFTER_GROUPS', 'AFTER_METHOD', 'AFTER_SUITE', 'AFTER_TEST' } * @param {string} launchTempId - temp launch id (returned in the query "startLaunch"). * @param {string} parentTempId (optional) - temp item id (returned in the query "startTestItem"). * @Returns {Object} - an object which contains a tempId and a promise */ startTestItem(testItemDataRQ, launchTempId, parentTempId) { let parentMapId = launchTempId; const launchObj = this.map[launchTempId]; if (!launchObj) { return this.getRejectAnswer( launchTempId, new Error(`Launch with tempId "${launchTempId}" not found`), ); } // TODO: Allow items reporting to finished launch if (launchObj.finishSend) { const err = new Error( `Launch with tempId "${launchTempId}" is already finished, you can not add an item to it`, ); return this.getRejectAnswer(launchTempId, err); } const testCaseId = testItemDataRQ.testCaseId || helpers.generateTestCaseId(testItemDataRQ.codeRef, testItemDataRQ.parameters); const testItemData = { startTime: this.helpers.now(), ...testItemDataRQ, ...(testCaseId && { testCaseId }), }; let parentPromise = launchObj.promiseStart; if (parentTempId) { parentMapId = parentTempId; const parentObj = this.map[parentTempId]; if (!parentObj) { return this.getRejectAnswer( launchTempId, new Error(`Item with tempId "${parentTempId}" not found`), ); } parentPromise = parentObj.promiseStart; } const itemKey = this.calculateItemRetriesChainMapKey( launchTempId, parentTempId, testItemDataRQ.name, testItemDataRQ.uniqueId, ); const executionItemPromise = testItemDataRQ.retry && this.itemRetriesChainMap.get(itemKey); const tempId = this.getUniqId(); this.map[tempId] = this.getNewItemObj((resolve, reject) => { (executionItemPromise || parentPromise).then( () => { const realLaunchId = this.map[launchTempId].realId; let url = 'item/'; if (parentTempId) { const realParentId = this.map[parentTempId].realId; url += `${realParentId}`; } testItemData.launchUuid = realLaunchId; this.logDebug(`Start test item with tempId ${tempId}`, testItemData); this.restClient.create(url, testItemData).then( (response) => { this.logDebug(`Success start item with tempId ${tempId}`, response); this.map[tempId].realId = response.id; resolve(response); }, (error) => { this.logDebug(`Error start item with tempId ${tempId}`, error); console.dir(error); reject(error); }, ); }, (error) => { reject(error); }, ); }); this.map[parentMapId].children.push(tempId); this.itemRetriesChainKeyMapByTempId.set(tempId, itemKey); this.itemRetriesChainMap.set(itemKey, this.map[tempId].promiseStart); return { tempId, promise: this.map[tempId].promiseStart, }; } /** * Finish Suite or Step level. * @param {string} itemTempId - temp item id (returned in the query "startTestItem"). * @param {Object} finishTestItemRQ - object with item parameters. * finishTestItemRQ should look like this { "endTime": this.helper.now(), "issue": { "comment": "string", "externalSystemIssues": [ { "submitDate": 0, "submitter": "string", "systemId": "string", "ticketId": "string", "url": "string" } ], "issueType": "string" }, "status": "passed" or one of 'passed', 'failed', 'stopped', 'skipped', 'interrupted', 'cancelled' } * @Returns {Object} - an object which contains a tempId and a promise */ finishTestItem(itemTempId, finishTestItemRQ) { const itemObj = this.map[itemTempId]; if (!itemObj) { return this.getRejectAnswer( itemTempId, new Error(`Item with tempId "${itemTempId}" not found`), ); } const finishTestItemData = { endTime: this.helpers.now(), ...(itemObj.children.length ? {} : { status: RP_STATUSES.PASSED }), ...finishTestItemRQ, }; itemObj.finishSend = true; this.logDebug(`Finish all children for test item with tempId ${itemTempId}`); Promise.allSettled( itemObj.children.map((itemId) => this.map[itemId] && this.map[itemId].promiseFinish), ) .then((results) => { if (this.debug) { results.forEach((result, index) => { if (result.status === 'fulfilled') { this.logDebug( `Successfully finish child with tempId ${itemObj.children[index]} of test item with tempId ${itemTempId}`, ); } else { this.logDebug( `Failed to finish child with tempId ${itemObj.children[index]} of test item with tempId ${itemTempId}`, ); } }); } this.cleanItemRetriesChain(itemObj.children); this.cleanMap(itemObj.children); this.logDebug(`Finish test item with tempId ${itemTempId}`, finishTestItemRQ); this.finishTestItemPromiseStart( itemObj, itemTempId, Object.assign(finishTestItemData, { launchUuid: this.launchUuid }), ); }) .catch(() => { this.logDebug(`Error finish children of test item with tempId ${itemTempId}`); }); return { tempId: itemTempId, promise: itemObj.promiseFinish, }; } saveLog(itemObj, requestPromiseFunc) { const tempId = this.getUniqId(); this.map[tempId] = this.getNewItemObj((resolve, reject) => { itemObj.promiseStart.then( () => { this.logDebug(`Save log with tempId ${tempId}`, itemObj); requestPromiseFunc(itemObj.realId, this.launchUuid).then( (response) => { this.logDebug(`Successfully save log with tempId ${tempId}`, response); resolve(response); }, (error) => { this.logDebug(`Error save log with tempId ${tempId}`, error); console.dir(error); reject(error); }, ); }, (error) => { reject(error); }, ); }); itemObj.children.push(tempId); const logObj = this.map[tempId]; logObj.finishSend = true; logObj.promiseStart.then( (response) => logObj.resolveFinish(response), (error) => logObj.rejectFinish(error), ); return { tempId, promise: this.map[tempId].promiseFinish, }; } sendLog(itemTempId, saveLogRQ, fileObj) { const saveLogData = { time: this.helpers.now(), message: '', level: '', ...saveLogRQ, }; if (fileObj) { return this.sendLogWithFile(itemTempId, saveLogData, fileObj); } return this.sendLogWithoutFile(itemTempId, saveLogData); } /** * Send log of test results. * @param {string} itemTempId - temp item id (returned in the query "startTestItem"). * @param {Object} saveLogRQ - object with data of test result. * saveLogRQ should look like this * { * level: 'error' or one of 'trace', 'debug', 'info', 'warn', 'error', '', * message: 'string' (support markdown), * time: this.helpers.now() * } * @Returns {Object} - an object which contains a tempId and a promise */ sendLogWithoutFile(itemTempId, saveLogRQ) { const itemObj = this.map[itemTempId]; if (!itemObj) { return this.getRejectAnswer( itemTempId, new Error(`Item with tempId "${itemTempId}" not found`), ); } const requestPromise = (itemUuid, launchUuid) => { const url = 'log'; const isItemUuid = itemUuid !== launchUuid; return this.restClient.create( url, Object.assign(saveLogRQ, { launchUuid }, isItemUuid && { itemUuid }), ); }; return this.saveLog(itemObj, requestPromise); } /** * Send log of test results with file. * @param {string} itemTempId - temp item id (returned in the query "startTestItem"). * @param {Object} saveLogRQ - object with data of test result. * saveLogRQ should look like this * { * level: 'error' or one of 'trace', 'debug', 'info', 'warn', 'error', '', * message: 'string' (support markdown), * time: this.helpers.now() * } * @param {Object} fileObj - object with file data. * fileObj should look like this * { name: 'string', type: "image/png" or your file mimeType (supported types: 'image/*', application/ ['xml', 'javascript', 'json', 'css', 'php'], another format will be opened in a new browser tab ), content: file * } * @Returns {Object} - an object which contains a tempId and a promise */ sendLogWithFile(itemTempId, saveLogRQ, fileObj) { const itemObj = this.map[itemTempId]; if (!itemObj) { return this.getRejectAnswer( itemTempId, new Error(`Item with tempId "${itemTempId}" not found`), ); } const requestPromise = (itemUuid, launchUuid) => { const isItemUuid = itemUuid !== launchUuid; return this.getRequestLogWithFile( Object.assign(saveLogRQ, { launchUuid }, isItemUuid && { itemUuid }), fileObj, ); }; return this.saveLog(itemObj, requestPromise); } getRequestLogWithFile(saveLogRQ, fileObj) { const url = 'log'; // eslint-disable-next-line no-param-reassign saveLogRQ.file = { name: fileObj.name }; this.logDebug(`Save log with file: ${fileObj.name}`, saveLogRQ); return this.restClient .create(url, this.buildMultiPartStream([saveLogRQ], fileObj, MULTIPART_BOUNDARY), { headers: { 'Content-Type': `multipart/form-data; boundary=${MULTIPART_BOUNDARY}`, }, }) .then((response) => { this.logDebug(`Success save log with file: ${fileObj.name}`, response); return response; }) .catch((error) => { this.logDebug(`Error save log with file: ${fileObj.name}`, error); console.dir(error); }); } // eslint-disable-next-line valid-jsdoc /** * * @Private */ buildMultiPartStream(jsonPart, filePart, boundary) { const eol = '\r\n'; const bx = `--${boundary}`; const buffers = [ // eslint-disable-next-line function-paren-newline Buffer.from( // eslint-disable-next-line prefer-template bx + eol + 'Content-Disposition: form-data; name="json_request_part"' + eol + 'Content-Type: application/json' + eol + eol + eol + JSON.stringify(jsonPart) + eol, ), // eslint-disable-next-line function-paren-newline Buffer.from( // eslint-disable-next-line prefer-template bx + eol + 'Content-Disposition: form-data; name="file"; filename="' + filePart.name + '"' + eol + 'Content-Type: ' + filePart.type + eol + eol, ), Buffer.from(filePart.content, 'base64'), Buffer.from(`${eol + bx}--${eol}`), ]; return Buffer.concat(buffers); } finishTestItemPromiseStart(itemObj, itemTempId, finishTestItemData) { itemObj.promiseStart.then( () => { const url = ['item', itemObj.realId].join('/'); this.logDebug(`Finish test item with tempId ${itemTempId}`, itemObj); this.restClient .update(url, Object.assign(finishTestItemData, { launchUuid: this.launchUuid })) .then( (response) => { this.logDebug(`Success finish item with tempId ${itemTempId}`, response); itemObj.resolveFinish(response); }, (error) => { this.logDebug(`Error finish test item with tempId ${itemTempId}`, error); console.dir(error); itemObj.rejectFinish(error); }, ); }, (error) => { itemObj.rejectFinish(error); }, ); } } module.exports = RPClient;