From e383ed9497ad49b9c14d67f048934fde11ceb1b1 Mon Sep 17 00:00:00 2001 From: maria-hambardzumian Date: Wed, 22 Jul 2026 13:35:02 +0400 Subject: [PATCH 1/2] EPMRPP-89496 || Migrate client-javascript to TypeScript --- .eslintrc | 5 +- .github/workflows/publish.yml | 2 + .gitignore | 11 + __tests__/client-id.spec.js | 2 +- __tests__/config.spec.js | 4 +- __tests__/helpers.spec.js | 2 +- __tests__/oauth.spec.js | 3 +- __tests__/proxyHelper.spec.js | 2 +- __tests__/publicReportingAPI.spec.js | 4 +- __tests__/report-portal-client.spec.js | 6 +- __tests__/rest.spec.js | 4 +- __tests__/statistics.spec.js | 4 +- index.d.ts | 443 ------------------ jest.config.js | 17 +- lib/constants/events.js | 11 - lib/constants/statuses.js | 12 - lib/publicReportingAPI.js | 92 ---- package.json | 21 +- src/constants.ts | 3 + src/helpers.ts | 4 + .../config.js => src/lib/commons/config.ts | 57 ++- .../errors.js => src/lib/commons/errors.ts | 18 +- src/lib/constants/events.ts | 9 + src/lib/constants/index.ts | 6 + src/lib/constants/launchModes.ts | 4 + src/lib/constants/logLevels.ts | 10 + .../lib/constants/outputs.ts | 8 +- src/lib/constants/statuses.ts | 15 + src/lib/constants/testItemTypes.ts | 17 + lib/helpers.js => src/lib/helpers.ts | 60 ++- lib/logger.js => src/lib/logger.ts | 22 +- src/lib/models/common.ts | 40 ++ src/lib/models/config.ts | 93 ++++ src/lib/models/index.ts | 4 + src/lib/models/requests.ts | 77 +++ src/lib/models/responses.ts | 45 ++ lib/oauth.js => src/lib/oauth.ts | 120 +++-- src/lib/pjson.ts | 29 ++ lib/proxyHelper.js => src/lib/proxyHelper.ts | 124 +++-- src/lib/publicReportingAPI.ts | 63 +++ .../lib/report-portal-client.ts | 423 +++++++---------- lib/rest.js => src/lib/rest.ts | 131 ++++-- src/models.ts | 3 + .../statistics/client-id.ts | 23 +- src/statistics/constants.ts | 33 ++ .../statistics/statistics.ts | 40 +- src/types/vendor.d.ts | 10 + statistics/constants.js | 49 -- tsconfig.json | 20 +- 49 files changed, 1051 insertions(+), 1154 deletions(-) delete mode 100644 index.d.ts delete mode 100644 lib/constants/events.js delete mode 100644 lib/constants/statuses.js delete mode 100644 lib/publicReportingAPI.js create mode 100644 src/constants.ts create mode 100644 src/helpers.ts rename lib/commons/config.js => src/lib/commons/config.ts (67%) rename lib/commons/errors.js => src/lib/commons/errors.ts (56%) create mode 100644 src/lib/constants/events.ts create mode 100644 src/lib/constants/index.ts create mode 100644 src/lib/constants/launchModes.ts create mode 100644 src/lib/constants/logLevels.ts rename lib/constants/outputs.js => src/lib/constants/outputs.ts (72%) create mode 100644 src/lib/constants/statuses.ts create mode 100644 src/lib/constants/testItemTypes.ts rename lib/helpers.js => src/lib/helpers.ts (52%) rename lib/logger.js => src/lib/logger.ts (54%) create mode 100644 src/lib/models/common.ts create mode 100644 src/lib/models/config.ts create mode 100644 src/lib/models/index.ts create mode 100644 src/lib/models/requests.ts create mode 100644 src/lib/models/responses.ts rename lib/oauth.js => src/lib/oauth.ts (68%) create mode 100644 src/lib/pjson.ts rename lib/proxyHelper.js => src/lib/proxyHelper.ts (57%) create mode 100644 src/lib/publicReportingAPI.ts rename lib/report-portal-client.js => src/lib/report-portal-client.ts (65%) rename lib/rest.js => src/lib/rest.ts (53%) create mode 100644 src/models.ts rename statistics/client-id.js => src/statistics/client-id.ts (66%) create mode 100644 src/statistics/constants.ts rename statistics/statistics.js => src/statistics/statistics.ts (55%) create mode 100644 src/types/vendor.d.ts delete mode 100644 statistics/constants.js diff --git a/.eslintrc b/.eslintrc index 67846ac3..d46f6c07 100644 --- a/.eslintrc +++ b/.eslintrc @@ -21,7 +21,10 @@ }, "ignorePatterns": ["__tests__/**/*"], "rules": { - "valid-jsdoc": ["error", { "requireReturn": false }], + "valid-jsdoc": 0, + "import/extensions": 0, + "import/no-unresolved": 0, + "camelcase": 0, "consistent-return": 0, "@typescript-eslint/no-plusplus": 0, "prettier/prettier": 2, diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7b343a3d..102eedff 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -51,6 +51,8 @@ jobs: registry-url: 'https://registry.npmjs.org' - name: Install dependencies run: npm install + - name: Build + run: npm run build - name: Publish to NPM run: | npm config list diff --git a/.gitignore b/.gitignore index 7a1c2746..ffc732a4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,14 @@ coverage.lcov coverage/ .npmrc build +/lib +/statistics +/helpers.js +/helpers.d.ts +/helpers.js.map +/models.js +/models.d.ts +/models.js.map +/constants.js +/constants.d.ts +/constants.js.map diff --git a/__tests__/client-id.spec.js b/__tests__/client-id.spec.js index de0ad4c1..ff9f8dde 100644 --- a/__tests__/client-id.spec.js +++ b/__tests__/client-id.spec.js @@ -5,7 +5,7 @@ const { randomUUID } = require('crypto'); const testHomeDir = path.join(__dirname, '__tmp__', 'rp-home'); process.env.RP_CLIENT_JS_HOME = testHomeDir; -const { getClientId } = require('../statistics/client-id'); +const { getClientId } = require('../src/statistics/client-id'); const uuidv4Validation = /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i; const clientIdFile = path.join(testHomeDir, '.rp', 'rp.properties'); diff --git a/__tests__/config.spec.js b/__tests__/config.spec.js index d566182a..7cf6c937 100644 --- a/__tests__/config.spec.js +++ b/__tests__/config.spec.js @@ -1,8 +1,8 @@ -const { getClientConfig, getRequiredOption, getApiKey } = require('../lib/commons/config'); +const { getClientConfig, getRequiredOption, getApiKey } = require('../src/lib/commons/config'); const { ReportPortalRequiredOptionError, ReportPortalValidationError, -} = require('../lib/commons/errors'); +} = require('../src/lib/commons/errors'); describe('Config commons test suite', () => { describe('getRequiredOption', () => { diff --git a/__tests__/helpers.spec.js b/__tests__/helpers.spec.js index fe650891..ffd8fb6a 100644 --- a/__tests__/helpers.spec.js +++ b/__tests__/helpers.spec.js @@ -1,7 +1,7 @@ const os = require('os'); const fs = require('fs'); const glob = require('glob'); -const helpers = require('../lib/helpers'); +const helpers = require('../src/lib/helpers'); const pjson = require('../package.json'); describe('Helpers', () => { diff --git a/__tests__/oauth.spec.js b/__tests__/oauth.spec.js index 156b7ffc..b6a2cfdb 100644 --- a/__tests__/oauth.spec.js +++ b/__tests__/oauth.spec.js @@ -1,9 +1,10 @@ const axios = require('axios'); const { HttpsProxyAgent } = require('https-proxy-agent'); -const OAuthInterceptor = require('../lib/oauth'); +const OAuthInterceptor = require('../src/lib/oauth'); jest.mock('axios', () => ({ post: jest.fn(), + isAxiosError: jest.fn((error) => !!error && typeof error === 'object' && 'response' in error), })); describe('OAuthInterceptor', () => { diff --git a/__tests__/proxyHelper.spec.js b/__tests__/proxyHelper.spec.js index cd0b8583..b50a6263 100644 --- a/__tests__/proxyHelper.spec.js +++ b/__tests__/proxyHelper.spec.js @@ -5,7 +5,7 @@ const { getProxyConfig, createProxyAgents, getProxyAgentForUrl, -} = require('../lib/proxyHelper'); +} = require('../src/lib/proxyHelper'); describe('proxyHelper', () => { const originalEnv = process.env; diff --git a/__tests__/publicReportingAPI.spec.js b/__tests__/publicReportingAPI.spec.js index d7755b5c..4f52ee6a 100644 --- a/__tests__/publicReportingAPI.spec.js +++ b/__tests__/publicReportingAPI.spec.js @@ -1,5 +1,5 @@ -const PublicReportingAPI = require('../lib/publicReportingAPI'); -const { EVENTS } = require('../lib/constants/events'); +const PublicReportingAPI = require('../src/lib/publicReportingAPI'); +const { EVENTS } = require('../src/lib/constants/events'); describe('PublicReportingAPI', () => { it('setDescription should trigger process.emit with correct parameters', () => { diff --git a/__tests__/report-portal-client.spec.js b/__tests__/report-portal-client.spec.js index 47542799..62e9fa37 100644 --- a/__tests__/report-portal-client.spec.js +++ b/__tests__/report-portal-client.spec.js @@ -1,7 +1,7 @@ const process = require('process'); -const RPClient = require('../lib/report-portal-client'); -const helpers = require('../lib/helpers'); -const { OUTPUT_TYPES } = require('../lib/constants/outputs'); +const RPClient = require('../src/lib/report-portal-client'); +const helpers = require('../src/lib/helpers'); +const { OUTPUT_TYPES } = require('../src/lib/constants/outputs'); describe('ReportPortal javascript client', () => { afterEach(() => { diff --git a/__tests__/rest.spec.js b/__tests__/rest.spec.js index 7f6bb063..b371826b 100644 --- a/__tests__/rest.spec.js +++ b/__tests__/rest.spec.js @@ -1,8 +1,8 @@ const nock = require('nock'); const isEqual = require('lodash/isEqual'); const http = require('http'); -const RestClient = require('../lib/rest'); -const logger = require('../lib/logger'); +const RestClient = require('../src/lib/rest'); +const logger = require('../src/lib/logger'); describe('RestClient', () => { const options = { diff --git a/__tests__/statistics.spec.js b/__tests__/statistics.spec.js index 23d66ee5..0f2047a0 100644 --- a/__tests__/statistics.spec.js +++ b/__tests__/statistics.spec.js @@ -1,6 +1,6 @@ const axios = require('axios'); -const Statistics = require('../statistics/statistics'); -const { MEASUREMENT_ID, API_KEY } = require('../statistics/constants'); +const Statistics = require('../src/statistics/statistics'); +const { MEASUREMENT_ID, API_KEY } = require('../src/statistics/constants'); const uuidv4Validation = /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i; diff --git a/index.d.ts b/index.d.ts deleted file mode 100644 index fedd35d0..00000000 --- a/index.d.ts +++ /dev/null @@ -1,443 +0,0 @@ -declare module '@reportportal/client-javascript' { - /** - * OAuth 2.0 configuration for password grant flow. - */ - export interface OAuthConfig { - /** - * OAuth 2.0 token endpoint URL for password grant flow. - */ - tokenEndpoint: string; - /** - * Username for OAuth 2.0 password grant. - */ - username: string; - /** - * Password for OAuth 2.0 password grant. - */ - password: string; - /** - * OAuth 2.0 client ID. - */ - clientId: string; - /** - * OAuth 2.0 client secret (optional, depending on your OAuth server configuration). - */ - clientSecret?: string; - /** - * OAuth 2.0 scope (optional, space-separated list of scopes). - */ - scope?: string; - } - - /** - * Proxy configuration object. - */ - export interface ProxyConfig { - /** - * Protocol for the proxy (http or https). - */ - protocol?: string; - /** - * Proxy host. - */ - host: string; - /** - * Proxy port. - */ - port: number; - /** - * Optional authentication for the proxy. - */ - auth?: { - username: string; - password: string; - }; - /** - * Optional debug logs for the proxy. - */ - debug?: boolean; - } - - /** - * REST client configuration options. - */ - export interface RestClientConfig { - /** - * Request timeout in milliseconds. - */ - timeout?: number; - /** - * Proxy configuration. Can be: - * - false: Disable proxy - * - string: Proxy URL (e.g., 'http://proxy.example.com:8080') - * - ProxyConfig object: Detailed proxy configuration - */ - proxy?: false | string | ProxyConfig; - /** - * Comma-separated list of domains to bypass proxy. - * Example: 'localhost,127.0.0.1,.example.com' - * This takes precedence over NO_PROXY environment variable. - */ - noProxy?: string; - /** - * Custom HTTP agent options. - */ - agent?: any; - /** - * Retry configuration. - */ - retry?: number | any; - /** - * Enable debug logging. - */ - debug?: boolean; - /** - * Any other axios configuration options. - */ - [key: string]: any; - } - - /** - * Configuration options for initializing the Report Portal client. - * - * @example API Key Authentication - * ```typescript - * const rp = new ReportPortalClient({ - * endpoint: 'https://your.reportportal.server/api/v1', - * project: 'your_project_name', - * apiKey: 'your_api_key', - * }); - * ``` - * - * @example OAuth 2.0 Authentication - * ```typescript - * const rp = new ReportPortalClient({ - * endpoint: 'https://your.reportportal.server/api/v1', - * project: 'your_project_name', - * oauth: { - * tokenEndpoint: 'https://your-oauth-server.com/oauth/token', - * username: 'your-username', - * password: 'your-password', - * clientId: 'your-client-id', - * clientSecret: 'your-client-secret', // optional - * scope: 'reportportal', // optional - * } - * }); - * ``` - * - * @example With Proxy Configuration - * ```typescript - * const rp = new ReportPortalClient({ - * endpoint: 'https://your.reportportal.server/api/v1', - * project: 'your_project_name', - * apiKey: 'your_api_key', - * restClientConfig: { - * proxy: { - * protocol: 'https', - * host: '127.0.0.1', - * port: 8080, - * }, - * noProxy: 'localhost,.local.domain', - * } - * }); - * ``` - */ - export interface ReportPortalConfig { - apiKey?: string; - endpoint: string; - launch: string; - project: string; - headers?: Record; - debug?: boolean; - isLaunchMergeRequired?: boolean; - launchUuidPrint?: boolean; - launchUuidPrintOutput?: string; - restClientConfig?: RestClientConfig; - token?: string; - skippedIsNotIssue?: boolean; - /** - * OAuth 2.0 configuration object. When provided, OAuth authentication will be used instead of API key. - */ - oauth?: OAuthConfig; - } - - /** - * Options to start a new launch. - * - * @example - * ```typescript - * const launch = rp.startLaunch({ - * name: 'My Test Launch', - * startTime: rp.helpers.now(), - * }); - * ``` - */ - export interface LaunchOptions { - name?: string; - startTime?: string | number; - description?: string; - attributes?: Array<{ key?: string; value?: string } | string>; - mode?: string; - id?: string; - } - - /** - * Options to start a new test item (e.g., test case or suite). - * - * @example - * ```typescript - * const testItem = rp.startTestItem({ - * name: 'My Test Case', - * type: 'TEST', - * startTime: rp.helpers.now(), - * }); - * ``` - */ - export interface StartTestItemOptions { - name: string; - type: string; - description?: string; - startTime?: string | number; - attributes?: Array<{ key?: string; value?: string } | string>; - hasStats?: boolean; - } - - /** - * Options to send logs to Report Portal. - * - * @example - * ```typescript - * await rp.sendLog(testItem.tempId, { - * level: 'INFO', - * message: 'Step executed successfully', - * time: rp.helpers.now(), - * }); - * ``` - */ - export interface LogOptions { - level?: string; - message?: string; - time?: string | number; - file?: { - name: string; - content: string; - type: string; - }; - } - - /** - * Options to finish a test item. - * - * @example - * ```typescript - * await rp.finishTestItem(testItem.tempId, { - * status: 'PASSED', - * endTime: rp.helpers.now(), - * }); - * ``` - */ - export interface FinishTestItemOptions { - status?: string; - endTime?: string | number; - issue?: { - issueType: string; - comment?: string; - externalSystemIssues?: Array; - }; - } - - /** - * Options to finish a launch. - * - * @example - * ```typescript - * await rp.finishLaunch(launch.tempId, { - * endTime: rp.helpers.now(), - * }); - * ``` - */ - export interface FinishLaunchOptions { - endTime?: string | number; - status?: string; - } - - /** - * Main Report Portal client for interacting with the API. - */ - export default class ReportPortalClient { - /** - * Initializes a new Report Portal client. - */ - constructor(config: ReportPortalConfig, agentInfo?: { name?: string; version?: string; framework_version?: string }); - - /** - * Starts a new launch. - * @example - * ```typescript - * const launchObj = rpClient.startLaunch({ - * name: 'Client test', - * startTime: rpClient.helpers.now(), - * description: 'description of the launch', - * attributes: [ - * { - * 'key': 'yourKey', - * 'value': 'yourValue' - * }, - * { - * 'value': 'yourValue' - * } - * ], - * //this param used only when you need client to send data into the existing launch - * id: 'id' - * }); - * await launchObj.promise; - * ``` - */ - startLaunch(options: LaunchOptions): { tempId: string; promise: Promise }; - - /** - * Finishes an active launch. - * @example - * ```typescript - * const launchFinishObj = rpClient.finishLaunch(launchObj.tempId, { - * endTime: rpClient.helpers.now() - * }); - * await launchFinishObj.promise; - * ``` - */ - finishLaunch( - launchId: string, - options?: FinishLaunchOptions, - ): { tempId: string; promise: Promise }; - - /** - * Update the launch data - * @example - * ```typescript - * const updateLunch = rpClient.updateLaunch( - * launchObj.tempId, - * { - * description: 'new launch description', - * attributes: [ - * { - * key: 'yourKey', - * value: 'yourValue' - * }, - * { - * value: 'yourValue' - * } - * ], - * mode: 'DEBUG' - * } - * ); - * await updateLaunch.promise; - * ``` - */ - updateLaunch( - launchId: string, - options: LaunchOptions, - ): { tempId: string; promise: Promise }; - - /** - * Starts a new test item under a launch or parent item. - * @example - * ```typescript - * const suiteObj = rpClient.startTestItem({ - * description: makeid(), - * name: makeid(), - * startTime: rpClient.helpers.now(), - * type: 'SUITE' - * }, launchObj.tempId); - * const stepObj = rpClient.startTestItem({ - * description: makeid(), - * name: makeid(), - * startTime: rpClient.helpers.now(), - * attributes: [ - * { - * key: 'yourKey', - * value: 'yourValue' - * }, - * { - * value: 'yourValue' - * } - * ], - * type: 'STEP' - * }, launchObj.tempId, suiteObj.tempId); - * ``` - */ - startTestItem( - options: StartTestItemOptions, - launchId: string, - parentId?: string, - ): { - tempId: string; - promise: Promise; - }; - - /** - * Finishes a test item. - * @example - * ```typescript - * const finishObj = rpClient.finishTestItem(itemObj.tempId, { - * status: 'failed' - * }); - * await finishObj.promise; - * ``` - */ - finishTestItem( - itemId: string, - options: FinishTestItemOptions, - ): { tempId: string; promise: Promise }; - - /** - * Sends a log entry to a test item. - * @example - * ```typescript - * const logObj = rpClient.sendLog(stepObj.tempId, { - * level: 'INFO', - * message: 'User clicks login button', - * time: rpClient.helpers.now() - * }); - * await logObj.promise; - * ``` - */ - sendLog( - itemId: string, - options: LogOptions, - file?: { name: string; content: string | Buffer; type: string }, - ): { tempId: string; promise: Promise }; - - /** - * Waits for all test items to be finished. - * @example - * ```typescript - * await agent.getPromiseFinishAllItems(agent.tempLaunchId); - * ``` - */ - getPromiseFinishAllItems(launchId: string): Promise; - - /** - * Check if connection is established - * @example - * ```typescript - * await agent.checkConnect(); - * ``` - */ - checkConnect(): Promise; - - helpers: { - /** - * Generate ISO timestamp - * @example - * ```typescript - * await rpClient.sendLog(stepObj.tempId, { - * level: 'INFO', - * message: 'User clicks login button', - * time: rpClient.helpers.now() - * }); - * ``` - */ - now(): string; - }; - } -} diff --git a/jest.config.js b/jest.config.js index 10700586..3273f319 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,8 +1,21 @@ module.exports = { - moduleFileExtensions: ['js'], + transform: { + '^.+\\.ts$': ['ts-jest', { diagnostics: false, tsconfig: 'tsconfig.json' }], + '^.+\\.js$': 'babel-jest', + }, + moduleFileExtensions: ['ts', 'js', 'json'], testRegex: '/__tests__/.*\\.(test|spec).js$', testEnvironment: 'node', - collectCoverageFrom: ['lib/**/*.js', '!lib/logger.js'], + collectCoverageFrom: [ + 'src/lib/**/*.ts', + '!src/lib/logger.ts', + '!src/lib/pjson.ts', + '!src/lib/models/**', + '!src/lib/constants/index.ts', + '!src/lib/constants/launchModes.ts', + '!src/lib/constants/logLevels.ts', + '!src/lib/constants/testItemTypes.ts', + ], coverageThreshold: { global: { branches: 80, diff --git a/lib/constants/events.js b/lib/constants/events.js deleted file mode 100644 index c9b5bd32..00000000 --- a/lib/constants/events.js +++ /dev/null @@ -1,11 +0,0 @@ -const EVENTS = { - SET_DESCRIPTION: 'rp:setDescription', - SET_TEST_CASE_ID: 'rp:setTestCaseId', - SET_STATUS: 'rp:setStatus', - SET_LAUNCH_STATUS: 'rp:setLaunchStatus', - ADD_ATTRIBUTES: 'rp:addAttributes', - ADD_LOG: 'rp:addLog', - ADD_LAUNCH_LOG: 'rp:addLaunchLog', -}; - -module.exports = { EVENTS }; diff --git a/lib/constants/statuses.js b/lib/constants/statuses.js deleted file mode 100644 index a7a7e789..00000000 --- a/lib/constants/statuses.js +++ /dev/null @@ -1,12 +0,0 @@ -const RP_STATUSES = { - PASSED: 'passed', - FAILED: 'failed', - SKIPPED: 'skipped', - STOPPED: 'stopped', - INTERRUPTED: 'interrupted', - CANCELLED: 'cancelled', - INFO: 'info', - WARN: 'warn', -}; - -module.exports = { RP_STATUSES }; diff --git a/lib/publicReportingAPI.js b/lib/publicReportingAPI.js deleted file mode 100644 index cdca715d..00000000 --- a/lib/publicReportingAPI.js +++ /dev/null @@ -1,92 +0,0 @@ -const { EVENTS } = require('./constants/events'); - -/** - * Public API to emit additional events to RP JS agents. - */ -class PublicReportingAPI { - /** - * Emit set description event. - * @param {String} text - description of current test/suite. - * @param {String} suite - suite description, optional. - */ - static setDescription(text, suite) { - process.emit(EVENTS.SET_DESCRIPTION, { text, suite }); - } - - /** - * Emit add attributes event. - * @param {Array} attributes - array of attributes, should looks like this: - * [{ - * key: "attrKey", - * value: "attrValue", - * }] - * - * @param {String} suite - suite description, optional. - */ - static addAttributes(attributes, suite) { - process.emit(EVENTS.ADD_ATTRIBUTES, { attributes, suite }); - } - - /** - * Emit send log to test item event. - * @param {Object} log - log object should looks like this: - * { - * level: "INFO", - * message: "log message", - * file: { - * name: "filename", - * type: "image/png", // media type - * content: data, // file content represented as 64base string - * }, - * } - * @param {String} suite - suite description, optional. - */ - static addLog(log, suite) { - process.emit(EVENTS.ADD_LOG, { log, suite }); - } - - /** - * Emit send log to current launch event. - * @param {Object} log - log object should looks like this: - * { - * level: "INFO", - * message: "log message", - * file: { - * name: "filename", - * type: "image/png", // media type - * content: data, // file content represented as 64base string - * }, - * } - */ - static addLaunchLog(log) { - process.emit(EVENTS.ADD_LAUNCH_LOG, log); - } - - /** - * Emit set testCaseId event. - * @param {String} testCaseId - testCaseId of current test/suite. - * @param {String} suite - suite description, optional. - */ - static setTestCaseId(testCaseId, suite) { - process.emit(EVENTS.SET_TEST_CASE_ID, { testCaseId, suite }); - } - - /** - * Emit set status to current launch event. - * @param {String} status - status of current launch. - */ - static setLaunchStatus(status) { - process.emit(EVENTS.SET_LAUNCH_STATUS, status); - } - - /** - * Emit set status event. - * @param {String} status - status of current test/suite. - * @param {String} suite - suite description, optional. - */ - static setStatus(status, suite) { - process.emit(EVENTS.SET_STATUS, { status, suite }); - } -} - -module.exports = PublicReportingAPI; diff --git a/package.json b/package.json index 1f665ca3..58b5327f 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,12 @@ "scripts": { "codegraph": "bash scripts/codegraph.sh", "build": "npm run clean && tsc", - "clean": "rimraf ./build", - "lint": "eslint ./statistics/**/* ./lib/**/*", + "clean": "rimraf ./lib ./statistics ./helpers.js ./helpers.d.ts ./helpers.js.map ./models.js ./models.d.ts ./models.js.map ./constants.js ./constants.d.ts ./constants.js.map", + "lint": "eslint \"src/**/*.ts\"", "format": "npm run lint -- --fix", "test": "jest", - "test:coverage": "jest --coverage" + "test:coverage": "jest --coverage", + "prepublishOnly": "npm run build" }, "directories": { "lib": "./lib" @@ -18,11 +19,19 @@ "files": [ "/lib", "/statistics", - "/VERSION", - "index.d.ts" + "/helpers.js", + "/helpers.d.ts", + "/helpers.js.map", + "/models.js", + "/models.d.ts", + "/models.js.map", + "/constants.js", + "/constants.d.ts", + "/constants.js.map", + "/VERSION" ], "main": "./lib/report-portal-client", - "types": "./index.d.ts", + "types": "./lib/report-portal-client.d.ts", "engines": { "node": ">=14.17.0" }, diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 00000000..965922cc --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,3 @@ +// Public entry point so consumers can import constants as +// `@reportportal/client-javascript/constants` instead of the internal `/lib/constants` path. +export * from './lib/constants'; diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 00000000..45710e4e --- /dev/null +++ b/src/helpers.ts @@ -0,0 +1,4 @@ +// Public entry point so consumers can import helpers as +// `@reportportal/client-javascript/helpers` instead of the internal `/lib/helpers` path. +export * from './lib/helpers'; +export { default } from './lib/helpers'; diff --git a/lib/commons/config.js b/src/lib/commons/config.ts similarity index 67% rename from lib/commons/config.js rename to src/lib/commons/config.ts index 0700b926..76c68af9 100644 --- a/lib/commons/config.js +++ b/src/lib/commons/config.ts @@ -1,29 +1,40 @@ -const { ReportPortalRequiredOptionError, ReportPortalValidationError } = require('./errors'); -const { OUTPUT_TYPES } = require('../constants/outputs'); - -const getOption = (options, optionName, defaultValue) => { - if (!Object.prototype.hasOwnProperty.call(options, optionName) || !options[optionName]) { +import { ReportPortalRequiredOptionError, ReportPortalValidationError } from './errors'; +import { OUTPUT_TYPES } from '../constants/outputs'; +import type { + NormalizedClientConfig, + OAuthConfig, + ReportPortalConfig, +} from '../models/config'; + +const getOption = ( + options: T, + optionName: K, + defaultValue: NonNullable, +): NonNullable => { + const value = options[optionName]; + if (!Object.prototype.hasOwnProperty.call(options, optionName) || !value) { return defaultValue; } - return options[optionName]; + return value as NonNullable; }; -const getRequiredOption = (options, optionName) => { +export const getRequiredOption = (options: T, optionName: K): T[K] => { if (!Object.prototype.hasOwnProperty.call(options, optionName) || !options[optionName]) { - throw new ReportPortalRequiredOptionError(optionName); + throw new ReportPortalRequiredOptionError(String(optionName)); } return options[optionName]; }; -const getApiKey = ({ apiKey, token }) => { +export const getApiKey = ({ apiKey, token }: Pick): string => { let calculatedApiKey = apiKey; if (!calculatedApiKey) { calculatedApiKey = token; if (!calculatedApiKey) { throw new ReportPortalRequiredOptionError('apiKey'); } else { + // eslint-disable-next-line no-console console.warn(`Option 'token' is deprecated. Use 'apiKey' instead.`); } } @@ -31,8 +42,8 @@ const getApiKey = ({ apiKey, token }) => { return calculatedApiKey; }; -const getOAuthConfig = (options) => { - const oauthParams = options.oauth || {}; +export const getOAuthConfig = (options: ReportPortalConfig): OAuthConfig | null => { + const oauthParams = options.oauth || ({} as Partial); const { tokenEndpoint, username, password, clientId, clientSecret, scope } = oauthParams; @@ -63,8 +74,18 @@ const getOAuthConfig = (options) => { }; }; -const getClientConfig = (options) => { - let calculatedOptions = options; +const DEFAULT_CLIENT_CONFIG: NormalizedClientConfig = { + apiKey: null, + oauth: null, + project: '', + endpoint: '', + isLaunchMergeRequired: false, + launchUuidPrintOutput: OUTPUT_TYPES.STDOUT, + skippedIsNotIssue: false, +}; + +export const getClientConfig = (options: ReportPortalConfig): NormalizedClientConfig => { + let calculatedOptions = DEFAULT_CLIENT_CONFIG; try { if (typeof options !== 'object') { throw new ReportPortalValidationError('`options` must be an object.'); @@ -74,7 +95,7 @@ const getClientConfig = (options) => { const oauthConfig = getOAuthConfig(options); // If OAuth is not configured, apiKey is required - let apiKey; + let apiKey: string | null; if (!oauthConfig) { apiKey = getApiKey(options); } else { @@ -119,15 +140,9 @@ const getClientConfig = (options) => { }; } catch (error) { // don't throw the error up to not break the entire process + // eslint-disable-next-line no-console console.dir(error); } return calculatedOptions; }; - -module.exports = { - getClientConfig, - getRequiredOption, - getApiKey, - getOAuthConfig, -}; diff --git a/lib/commons/errors.js b/src/lib/commons/errors.ts similarity index 56% rename from lib/commons/errors.js rename to src/lib/commons/errors.ts index a47a6a33..79125e57 100644 --- a/lib/commons/errors.js +++ b/src/lib/commons/errors.ts @@ -1,29 +1,23 @@ -class ReportPortalError extends Error { - constructor(message) { +export class ReportPortalError extends Error { + constructor(message: string) { const basicMessage = `\nReportPortal client error: ${message}`; super(basicMessage); this.name = 'ReportPortalError'; } } -class ReportPortalValidationError extends ReportPortalError { - constructor(message) { +export class ReportPortalValidationError extends ReportPortalError { + constructor(message: string) { const basicMessage = `\nValidation failed. Please, check the specified parameters: ${message}`; super(basicMessage); this.name = 'ReportPortalValidationError'; } } -class ReportPortalRequiredOptionError extends ReportPortalValidationError { - constructor(propertyName) { +export class ReportPortalRequiredOptionError extends ReportPortalValidationError { + constructor(propertyName: string) { const basicMessage = `\nProperty '${propertyName}' must not be empty.`; super(basicMessage); this.name = 'ReportPortalRequiredOptionError'; } } - -module.exports = { - ReportPortalError, - ReportPortalValidationError, - ReportPortalRequiredOptionError, -}; diff --git a/src/lib/constants/events.ts b/src/lib/constants/events.ts new file mode 100644 index 00000000..ca7ea9f6 --- /dev/null +++ b/src/lib/constants/events.ts @@ -0,0 +1,9 @@ +export enum EVENTS { + SET_DESCRIPTION = 'rp:setDescription', + SET_TEST_CASE_ID = 'rp:setTestCaseId', + SET_STATUS = 'rp:setStatus', + SET_LAUNCH_STATUS = 'rp:setLaunchStatus', + ADD_ATTRIBUTES = 'rp:addAttributes', + ADD_LOG = 'rp:addLog', + ADD_LAUNCH_LOG = 'rp:addLaunchLog', +} diff --git a/src/lib/constants/index.ts b/src/lib/constants/index.ts new file mode 100644 index 00000000..2a3e4739 --- /dev/null +++ b/src/lib/constants/index.ts @@ -0,0 +1,6 @@ +export { STATUSES, RP_STATUSES } from './statuses'; +export { TEST_ITEM_TYPES } from './testItemTypes'; +export { PREDEFINED_LOG_LEVELS, LOG_LEVELS } from './logLevels'; +export { LAUNCH_MODES } from './launchModes'; +export { EVENTS } from './events'; +export { OUTPUT_TYPES, OutputHandler } from './outputs'; diff --git a/src/lib/constants/launchModes.ts b/src/lib/constants/launchModes.ts new file mode 100644 index 00000000..fcc1b08c --- /dev/null +++ b/src/lib/constants/launchModes.ts @@ -0,0 +1,4 @@ +export enum LAUNCH_MODES { + DEFAULT = 'DEFAULT', + DEBUG = 'DEBUG', +} diff --git a/src/lib/constants/logLevels.ts b/src/lib/constants/logLevels.ts new file mode 100644 index 00000000..f78a49a6 --- /dev/null +++ b/src/lib/constants/logLevels.ts @@ -0,0 +1,10 @@ +export enum PREDEFINED_LOG_LEVELS { + TRACE = 'TRACE', + DEBUG = 'DEBUG', + INFO = 'INFO', + WARN = 'WARN', + ERROR = 'ERROR', + FATAL = 'FATAL', +} + +export type LOG_LEVELS = PREDEFINED_LOG_LEVELS | string; diff --git a/lib/constants/outputs.js b/src/lib/constants/outputs.ts similarity index 72% rename from lib/constants/outputs.js rename to src/lib/constants/outputs.ts index b5818e37..8e88bc39 100644 --- a/lib/constants/outputs.js +++ b/src/lib/constants/outputs.ts @@ -1,6 +1,8 @@ -const helpers = require('../helpers'); +import * as helpers from '../helpers'; -const OUTPUT_TYPES = { +export type OutputHandler = (launchUuid: string) => void; + +export const OUTPUT_TYPES: Record = { // eslint-disable-next-line no-console STDOUT: (launchUuid) => console.log(`Report Portal Launch UUID: ${launchUuid}`), // eslint-disable-next-line no-console @@ -9,5 +11,3 @@ const OUTPUT_TYPES = { ENVIRONMENT: (launchUuid) => (process.env.RP_LAUNCH_UUID = launchUuid), FILE: helpers.saveLaunchUuidToFile, }; - -module.exports = { OUTPUT_TYPES }; diff --git a/src/lib/constants/statuses.ts b/src/lib/constants/statuses.ts new file mode 100644 index 00000000..d146bfa4 --- /dev/null +++ b/src/lib/constants/statuses.ts @@ -0,0 +1,15 @@ +export enum STATUSES { + PASSED = 'passed', + FAILED = 'failed', + SKIPPED = 'skipped', + STOPPED = 'stopped', + INTERRUPTED = 'interrupted', + CANCELLED = 'cancelled', + INFO = 'info', + WARN = 'warn', +} + +/** + * @deprecated Use the `STATUSES` enum instead. + */ +export const RP_STATUSES = STATUSES; diff --git a/src/lib/constants/testItemTypes.ts b/src/lib/constants/testItemTypes.ts new file mode 100644 index 00000000..79e03e29 --- /dev/null +++ b/src/lib/constants/testItemTypes.ts @@ -0,0 +1,17 @@ +export enum TEST_ITEM_TYPES { + SUITE = 'SUITE', + STORY = 'STORY', + TEST = 'TEST', + SCENARIO = 'SCENARIO', + STEP = 'STEP', + BEFORE_CLASS = 'BEFORE_CLASS', + BEFORE_GROUPS = 'BEFORE_GROUPS', + BEFORE_METHOD = 'BEFORE_METHOD', + BEFORE_SUITE = 'BEFORE_SUITE', + BEFORE_TEST = 'BEFORE_TEST', + AFTER_CLASS = 'AFTER_CLASS', + AFTER_GROUPS = 'AFTER_GROUPS', + AFTER_METHOD = 'AFTER_METHOD', + AFTER_SUITE = 'AFTER_SUITE', + AFTER_TEST = 'AFTER_TEST', +} diff --git a/lib/helpers.js b/src/lib/helpers.ts similarity index 52% rename from lib/helpers.js rename to src/lib/helpers.ts index 4a7dcac9..e807f9a1 100644 --- a/lib/helpers.js +++ b/src/lib/helpers.ts @@ -1,39 +1,52 @@ -const fs = require('fs'); -const glob = require('glob'); -const os = require('os'); -const RestClient = require('./rest'); -const pjson = require('../package.json'); +import fs from 'fs'; +import { sync as globSync } from 'glob'; +import os from 'os'; +import RestClient from './rest'; +import { PJSON_NAME, PJSON_VERSION } from './pjson'; +import { TestItemParameter } from './models/requests'; const MIN = 3; const MAX = 256; -const PJSON_VERSION = pjson.version; -const PJSON_NAME = pjson.name; -const getUUIDFromFileName = (filename) => filename.match(/rplaunch-(.*)\.tmp/)[1]; +export interface SystemAttribute { + key: string; + value: string | number; + system: boolean; +} -const formatName = (name) => { +const getUUIDFromFileName = (filename: string): string => { + const match = filename.match(/rplaunch-(.*)\.tmp/); + return match ? match[1] : ''; +}; + +export const formatName = (name: string): string => { const len = name.length; // eslint-disable-next-line no-mixed-operators return (len < MIN ? name + new Array(MIN - len + 1).join('.') : name).slice(-MAX); }; -const now = () => { +export const now = (): number => { return new Date().valueOf(); }; // TODO: deprecate and remove -const getServerResult = (url, request, options, method) => { +export const getServerResult = ( + url: string, + request: unknown, + options: ConstructorParameters[0], + method: string, +): Promise => { return new RestClient(options).request(method, url, request, options); }; -const readLaunchesFromFile = () => { - const files = glob.sync('rplaunch-*.tmp'); +export const readLaunchesFromFile = (): string[] => { + const files = globSync('rplaunch-*.tmp'); const ids = files.map(getUUIDFromFileName); return ids; }; -const saveLaunchIdToFile = (launchId) => { +export const saveLaunchIdToFile = (launchId: string): void => { const filename = `rplaunch-${launchId}.tmp`; fs.open(filename, 'w', (err) => { if (err) { @@ -42,12 +55,12 @@ const saveLaunchIdToFile = (launchId) => { }); }; -const getSystemAttribute = () => { +export const getSystemAttribute = (): SystemAttribute[] => { const osType = os.type(); const osArchitecture = os.arch(); const RAMSize = os.totalmem(); const nodeVersion = process.version; - const systemAttr = [ + const systemAttr: SystemAttribute[] = [ { key: 'client', value: `${PJSON_NAME}|${PJSON_VERSION}`, @@ -73,16 +86,19 @@ const getSystemAttribute = () => { return systemAttr; }; -const generateTestCaseId = (codeRef, params) => { +export const generateTestCaseId = ( + codeRef?: string, + params?: TestItemParameter[], +): string | undefined => { if (!codeRef) { - return; + return undefined; } if (!params) { return codeRef; } - const parameters = params.reduce( + const parameters = params.reduce( (result, item) => (item.value ? result.concat(item.value) : result), [], ); @@ -90,7 +106,7 @@ const generateTestCaseId = (codeRef, params) => { return `${codeRef}[${parameters}]`; }; -const saveLaunchUuidToFile = (launchUuid) => { +export const saveLaunchUuidToFile = (launchUuid: string): void => { const filename = `rp-launch-uuid-${launchUuid}.tmp`; fs.open(filename, 'w', (err) => { if (err) { @@ -99,7 +115,9 @@ const saveLaunchUuidToFile = (launchUuid) => { }); }; -module.exports = { +// Default export preserves the historical CommonJS shape (`module.exports = { ... }`) +// so consumers importing `helpers` as a default still work. +export default { formatName, now, getServerResult, diff --git a/lib/logger.js b/src/lib/logger.ts similarity index 54% rename from lib/logger.js rename to src/lib/logger.ts index 9eb54e69..0fc61672 100644 --- a/lib/logger.js +++ b/src/lib/logger.ts @@ -1,9 +1,14 @@ -const addLogger = (axiosInstance) => { - axiosInstance.interceptors.request.use((config) => { +import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'; + +type TimedRequestConfig = InternalAxiosRequestConfig & { startTime?: number }; + +export const addLogger = (axiosInstance: AxiosInstance): void => { + axiosInstance.interceptors.request.use((config: TimedRequestConfig) => { const startDate = new Date(); // eslint-disable-next-line no-param-reassign config.startTime = startDate.valueOf(); + // eslint-disable-next-line no-console console.log(`Request method=${config.method} url=${config.url} [${startDate.toISOString()}]`); return config; @@ -14,28 +19,29 @@ const addLogger = (axiosInstance) => { const date = new Date(); const { status, config } = response; + // eslint-disable-next-line no-console console.log( `Response status=${status} url=${config.url} time=${ - date.valueOf() - config.startTime + date.valueOf() - ((config as TimedRequestConfig).startTime ?? 0) }ms [${date.toISOString()}]`, ); return response; }, - (error) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (error: any) => { const date = new Date(); const { response, config } = error; const status = response ? response.status : null; + // eslint-disable-next-line no-console console.log( `Response ${status ? `status=${status}` : `message='${error.message}'`} url=${ - config.url - } time=${date.valueOf() - config.startTime}ms [${date.toISOString()}]`, + config?.url + } time=${date.valueOf() - (config?.startTime ?? 0)}ms [${date.toISOString()}]`, ); return Promise.reject(error); }, ); }; - -module.exports = { addLogger }; diff --git a/src/lib/models/common.ts b/src/lib/models/common.ts new file mode 100644 index 00000000..e6a20cc8 --- /dev/null +++ b/src/lib/models/common.ts @@ -0,0 +1,40 @@ +export interface Attribute { + value: string; + key?: string; + system?: boolean; +} + +export interface ExternalSystemIssue { + submitDate?: number; + submitter?: string; + systemId?: string; + ticketId?: string; + url?: string; +} + +export interface Issue { + issueType: string; + comment?: string; + externalSystemIssues?: ExternalSystemIssue[]; +} + +export interface Attachment { + name: string; + type: string; + content: string | Buffer; +} + +/** + * The wrapper returned by every start/finish/send method: a temporary id used to + * reference the item in subsequent calls, and a promise resolved with the server response. + */ +export interface ClientResponse { + tempId: string; + promise: Promise; +} + +export interface AgentParams { + name?: string; + version?: string; + framework_version?: string; +} diff --git a/src/lib/models/config.ts b/src/lib/models/config.ts new file mode 100644 index 00000000..4a8776c3 --- /dev/null +++ b/src/lib/models/config.ts @@ -0,0 +1,93 @@ +import type { AxiosProxyConfig, AxiosRequestConfig } from 'axios'; +import type { IAxiosRetryConfig } from 'axios-retry'; +import type { AgentOptions } from 'https'; + +import { Attribute } from './common'; +import { LAUNCH_MODES } from '../constants/launchModes'; +import { OutputHandler } from '../constants/outputs'; + +/** + * OAuth 2.0 configuration for the password grant flow. + */ +export interface OAuthConfig { + tokenEndpoint: string; + username: string; + password: string; + clientId: string; + clientSecret?: string; + scope?: string; +} + +/** + * Detailed proxy configuration object. + */ +export interface ProxyConfig { + protocol?: string; + host: string; + port: number; + auth?: { + username: string; + password: string; + }; + debug?: boolean; +} + +/** + * REST client configuration. Extends axios request config with the extra options + * the client understands (`agent`, `retry`, `proxy`, `noProxy`). + */ +export interface RestClientConfig extends Omit { + agent?: AgentOptions; + retry?: number | IAxiosRetryConfig; + proxy?: false | string | ProxyConfig | AxiosProxyConfig; + noProxy?: string; + debug?: boolean; +} + +export type LaunchUuidPrintOutput = 'STDOUT' | 'STDERR' | 'ENVIRONMENT' | 'FILE'; + +/** + * Configuration options accepted by the `RPClient` constructor. + */ +export interface ReportPortalConfig { + apiKey?: string; + /** + * @deprecated Use `apiKey` instead. + */ + token?: string; + endpoint: string; + project: string; + launch?: string; + headers?: Record; + debug?: boolean; + isLaunchMergeRequired?: boolean; + launchUuidPrint?: boolean; + launchUuidPrintOutput?: LaunchUuidPrintOutput; + restClientConfig?: RestClientConfig; + skippedIsNotIssue?: boolean; + oauth?: OAuthConfig; + attributes?: Attribute[]; + mode?: LAUNCH_MODES; + description?: string; +} + +/** + * The normalized config produced by `getClientConfig` and stored on the client instance. + */ +export interface NormalizedClientConfig { + apiKey: string | null; + oauth: OAuthConfig | null; + project: string; + endpoint: string; + launch?: string; + debug?: boolean; + isLaunchMergeRequired: boolean; + headers?: Record; + restClientConfig?: RestClientConfig; + attributes?: Attribute[]; + mode?: LAUNCH_MODES; + description?: string; + launchUuidPrint?: boolean; + launchUuidPrintOutput: OutputHandler; + skippedIsNotIssue: boolean; +} diff --git a/src/lib/models/index.ts b/src/lib/models/index.ts new file mode 100644 index 00000000..c39ebacc --- /dev/null +++ b/src/lib/models/index.ts @@ -0,0 +1,4 @@ +export * from './common'; +export * from './config'; +export * from './requests'; +export * from './responses'; diff --git a/src/lib/models/requests.ts b/src/lib/models/requests.ts new file mode 100644 index 00000000..a91fe545 --- /dev/null +++ b/src/lib/models/requests.ts @@ -0,0 +1,77 @@ +import { Attachment, Attribute, Issue } from './common'; +import { LAUNCH_MODES } from '../constants/launchModes'; +import { LOG_LEVELS } from '../constants/logLevels'; +import { STATUSES } from '../constants/statuses'; +import { TEST_ITEM_TYPES } from '../constants/testItemTypes'; + +export interface StartLaunchOptions { + name?: string; + startTime?: string | number; + description?: string; + attributes?: Attribute[]; + mode?: LAUNCH_MODES; + rerun?: boolean; + rerunOf?: string; + /** + * When set, the client attaches to an existing launch with this id instead of creating a new one. + */ + id?: string; +} + +export interface FinishLaunchOptions { + endTime?: string | number; + status?: STATUSES; +} + +export interface UpdateLaunchOptions { + description?: string; + mode?: LAUNCH_MODES; + attributes?: Attribute[]; +} + +export interface TestItemParameter { + key?: string; + value: string; +} + +export interface StartTestItemOptions { + name: string; + type: TEST_ITEM_TYPES; + description?: string; + startTime?: string | number; + attributes?: Attribute[]; + hasStats?: boolean; + codeRef?: string; + testCaseId?: string; + parameters?: TestItemParameter[]; + retry?: boolean; + uniqueId?: string; +} + +export interface FinishTestItemOptions { + endTime?: string | number; + status?: STATUSES; + issue?: Issue; + attributes?: Attribute[]; + description?: string; + testCaseId?: string; +} + +export interface LogOptions { + level?: LOG_LEVELS; + message?: string; + time?: string | number; + file?: Attachment; +} + +export enum MERGE_TYPES { + BASIC = 'BASIC', + DEEP = 'DEEP', +} + +export interface MergeLaunchesOptions { + extendSuitesDescription?: boolean; + description?: string; + mergeType?: MERGE_TYPES; + name?: string; +} diff --git a/src/lib/models/responses.ts b/src/lib/models/responses.ts new file mode 100644 index 00000000..8995a111 --- /dev/null +++ b/src/lib/models/responses.ts @@ -0,0 +1,45 @@ +export interface StartLaunchResponse { + id: string; + number?: number; + [key: string]: unknown; +} + +export interface FinishLaunchResponse { + id?: string; + link?: string; + [key: string]: unknown; +} + +export interface StartTestItemResponse { + id: string; + [key: string]: unknown; +} + +export interface FinishTestItemResponse { + message?: string; + [key: string]: unknown; +} + +export interface LogResponse { + id?: string; + [key: string]: unknown; +} + +export interface MergeLaunchesResponse { + id?: string; + uuid?: string; + link?: string; + [key: string]: unknown; +} + +export interface LaunchSearchResponse { + content: Array<{ id: string | number }>; + [key: string]: unknown; +} + +export interface ServerInfoResponse { + extensions?: { + result?: Record; + }; + [key: string]: unknown; +} diff --git a/lib/oauth.js b/src/lib/oauth.ts similarity index 68% rename from lib/oauth.js rename to src/lib/oauth.ts index 048a05da..696c5808 100644 --- a/lib/oauth.js +++ b/src/lib/oauth.ts @@ -1,5 +1,6 @@ -const axios = require('axios'); -const { getProxyAgentForUrl } = require('./proxyHelper'); +import axios, { AxiosInstance } from 'axios'; +import { getProxyAgentForUrl } from './proxyHelper'; +import type { OAuthConfig, RestClientConfig } from './models/config'; const TOKEN_REFRESH_THRESHOLD_MS = 60000; const DEFAULT_TOKEN_EXPIRATION_MS = 3600000; // 1 hour in milliseconds @@ -7,20 +8,48 @@ const SECOND_IN_MS = 1000; const GRANT_TYPE_PASSWORD = 'password'; const GRANT_TYPE_REFRESH_TOKEN = 'refresh_token'; +interface OAuthInterceptorConfig extends OAuthConfig { + debug?: boolean; + restClientConfig?: RestClientConfig; +} + +function formatTokenError(prefix: string, error: unknown): string { + if (axios.isAxiosError(error) && error.response) { + return `${prefix}: ${error.response.status} - ${JSON.stringify(error.response.data)}`; + } + const message = error instanceof Error ? error.message : String(error); + return `${prefix}: ${message}`; +} + +/** + * OAuth 2.0 Password Grant Flow Interceptor. + */ class OAuthInterceptor { - /** - * OAuth 2.0 Password Grant Flow Interceptor - * @param {Object} config - OAuth configuration - * @param {string} config.tokenEndpoint - OAuth token endpoint URL - * @param {string} config.username - Username for password grant - * @param {string} config.password - Password for password grant - * @param {string} config.clientId - OAuth client ID - * @param {string} [config.clientSecret] - OAuth client secret (optional) - * @param {string} [config.scope] - OAuth scope (optional) - * @param {boolean} [config.debug] - Enable debug logging - * @param {Object} [config.restClientConfig] - REST client configuration for proxy support - */ - constructor(config) { + private tokenEndpoint: string; + + private username: string; + + private password: string; + + private clientId: string; + + private clientSecret?: string; + + private scope?: string; + + private restClientConfig: RestClientConfig; + + private debug: boolean; + + private accessToken: string | null; + + private refreshToken: string | null; + + private tokenExpiresAt: number | null; + + private tokenRenewPromise: Promise | null; + + constructor(config: OAuthInterceptorConfig) { this.tokenEndpoint = config.tokenEndpoint; this.username = config.username; this.password = config.password; @@ -36,17 +65,17 @@ class OAuthInterceptor { this.tokenRenewPromise = null; } - logDebug(message, data = '') { + logDebug(message: string, data: unknown = ''): void { if (this.debug) { + // eslint-disable-next-line no-console console.log(`[OAuth] ${message}`, data); } } /** - * Obtains or refreshes the access token - * @returns {Promise} Access token + * Obtains or refreshes the access token. */ - async getAccessToken() { + async getAccessToken(): Promise { if (this.tokenRenewPromise) { this.logDebug('Waiting for ongoing token refresh'); return this.tokenRenewPromise; @@ -78,10 +107,9 @@ class OAuthInterceptor { } /** - * Refreshes the access token using password grant or refresh token grant - * @returns {Promise} Access token + * Refreshes the access token using password grant or refresh token grant. */ - async renewToken() { + async renewToken(): Promise { try { return await this.requestToken( this.refreshToken ? GRANT_TYPE_REFRESH_TOKEN : GRANT_TYPE_PASSWORD, @@ -90,6 +118,7 @@ class OAuthInterceptor { // If refresh token grant failed, try password grant as fallback if (this.refreshToken) { this.logDebug('Refresh token failed, falling back to password grant'); + // eslint-disable-next-line no-console console.warn( '[OAuth] Refresh token expired or invalid, re-authenticating with password grant', ); @@ -97,44 +126,38 @@ class OAuthInterceptor { try { return await this.requestToken(GRANT_TYPE_PASSWORD); - } catch (fallbackError) { - const errorMessage = fallbackError.response - ? `OAuth password grant fallback failed: ${ - fallbackError.response.status - } - ${JSON.stringify(fallbackError.response.data)}` - : `OAuth password grant fallback failed: ${fallbackError.message}`; + } catch (fallbackError: unknown) { + const errorMessage = formatTokenError( + 'OAuth password grant fallback failed', + fallbackError, + ); + // eslint-disable-next-line no-console console.error(`[OAuth] ${errorMessage}`); throw new Error(errorMessage); } } // No fallback available, rethrow original error - const errorMessage = error.response - ? `OAuth token request failed: ${error.response.status} - ${JSON.stringify( - error.response.data, - )}` - : `OAuth token request failed: ${error.message}`; + const errorMessage = formatTokenError('OAuth token request failed', error); + // eslint-disable-next-line no-console console.error(`[OAuth] ${errorMessage}`); throw new Error(errorMessage); } } /** - * Requests a token using the specified grant type - * @param {string} grantType - Either 'password' or 'refresh_token' - * @returns {Promise} Access token - * @private + * Requests a token using the specified grant type. */ - async requestToken(grantType) { + private async requestToken(grantType: string): Promise { const params = new URLSearchParams(); params.append('client_id', this.clientId); params.append('grant_type', grantType); if (grantType === GRANT_TYPE_REFRESH_TOKEN) { this.logDebug('Requesting new access token using refresh_token'); - params.append('refresh_token', this.refreshToken); + params.append('refresh_token', this.refreshToken as string); } else { this.logDebug('Requesting access token using username and password'); params.append('username', this.username); @@ -169,7 +192,7 @@ class OAuthInterceptor { ...(this.restClientConfig.httpsAgent && { httpsAgent: this.restClientConfig.httpsAgent }), ...(this.restClientConfig.httpAgent && { httpAgent: this.restClientConfig.httpAgent }), // Explicitly disable axios built-in proxy when using custom agents - ...(usingProxyAgent && { proxy: false }), + ...(usingProxyAgent && { proxy: false as const }), }); const { @@ -197,14 +220,13 @@ class OAuthInterceptor { this.logDebug('Token obtained, no expiration provided, assuming 1 hour'); } - return this.accessToken; + return this.accessToken as string; } /** - * Attaches the interceptor to an axios instance - * @param {Object} axiosInstance - Axios instance to attach interceptor to + * Attaches the interceptor to an axios instance. */ - attach(axiosInstance) { + attach(axiosInstance: AxiosInstance): void { axiosInstance.interceptors.request.use( async (config) => { try { @@ -213,8 +235,12 @@ class OAuthInterceptor { config.headers.Authorization = `Bearer ${token}`; this.logDebug(`Request to ${config.url} with OAuth token`); return config; - } catch (error) { - console.error('[OAuth] Failed to obtain access token, request may fail:', error.message); + } catch (error: unknown) { + // eslint-disable-next-line no-console + console.error( + '[OAuth] Failed to obtain access token, request may fail:', + error instanceof Error ? error.message : String(error), + ); return config; } }, @@ -223,4 +249,4 @@ class OAuthInterceptor { } } -module.exports = OAuthInterceptor; +export = OAuthInterceptor; diff --git a/src/lib/pjson.ts b/src/lib/pjson.ts new file mode 100644 index 00000000..00c08a18 --- /dev/null +++ b/src/lib/pjson.ts @@ -0,0 +1,29 @@ +import fs from 'fs'; +import path from 'path'; + +interface PackageJson { + name: string; + version: string; +} + +// Resolve the package's own package.json by walking up from this module's directory. +// Works both from compiled output (`lib/`) and from source when run via ts-jest (`src/lib/`). +function findPackageJson(dir: string): PackageJson { + let current = dir; + for (;;) { + const candidate = path.join(current, 'package.json'); + if (fs.existsSync(candidate)) { + return JSON.parse(fs.readFileSync(candidate, 'utf-8')); + } + const parent = path.dirname(current); + if (parent === current) { + throw new Error('Unable to locate package.json for @reportportal/client-javascript'); + } + current = parent; + } +} + +const pjson = findPackageJson(__dirname); + +export const PJSON_NAME = pjson.name; +export const PJSON_VERSION = pjson.version; diff --git a/lib/proxyHelper.js b/src/lib/proxyHelper.ts similarity index 57% rename from lib/proxyHelper.js rename to src/lib/proxyHelper.ts index f6e25d42..4c4535fb 100644 --- a/lib/proxyHelper.js +++ b/src/lib/proxyHelper.ts @@ -1,19 +1,22 @@ -const { getProxyForUrl } = require('proxy-from-env'); -const { HttpsProxyAgent } = require('https-proxy-agent'); -const { HttpProxyAgent } = require('http-proxy-agent'); -const http = require('http'); -const https = require('https'); +import { getProxyForUrl } from 'proxy-from-env'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { HttpProxyAgent } from 'http-proxy-agent'; +import http from 'http'; +import https from 'https'; +import type { RestClientConfig } from './models/config'; + +export interface ProxyAgents { + httpAgent?: http.Agent; + httpsAgent?: https.Agent; +} /** - * Sanitizes a URL by removing credentials (username/password) for safe logging - * @param {string} urlString - The URL to sanitize - * @returns {string} - Sanitized URL with credentials replaced by [REDACTED] + * Sanitizes a URL by removing credentials (username/password) for safe logging. */ -function sanitizeUrlForLogging(urlString) { +function sanitizeUrlForLogging(urlString: string): string { try { const urlObj = new URL(urlString); if (urlObj.username || urlObj.password) { - // Replace credentials with [REDACTED] urlObj.username = '[REDACTED]'; urlObj.password = ''; return urlObj.toString(); @@ -26,40 +29,30 @@ function sanitizeUrlForLogging(urlString) { } /** - * Checks if a URL should bypass proxy based on NO_PROXY patterns - * @param {string} url - The URL to check - * @param {string} noProxy - Comma-separated list of domains/patterns to bypass - * @returns {boolean} - True if proxy should be bypassed + * Checks if a URL should bypass proxy based on NO_PROXY patterns. */ -function shouldBypassProxy(url, noProxy) { +export function shouldBypassProxy(url: string, noProxy?: string): boolean { if (!noProxy) return false; try { const urlObj = new URL(url); const hostname = urlObj.hostname.toLowerCase(); - // Split NO_PROXY entries and clean them const patterns = noProxy .split(',') .map((entry) => entry.trim().toLowerCase()) .filter(Boolean); return patterns.some((pattern) => { - // Special case: * means bypass all if (pattern === '*') return true; - // Pattern with leading dot (.example.com) - only matches subdomains if (pattern.startsWith('.')) { const cleanPattern = pattern.slice(1); - // Should match sub.example.com but NOT example.com return hostname.endsWith(`.${cleanPattern}`); } - // Pattern without leading dot (example.com) - matches domain and subdomains - // Exact match if (hostname === pattern) return true; - // Suffix match (example.com matches sub.example.com) if (hostname.endsWith(`.${pattern}`)) return true; return false; @@ -71,32 +64,36 @@ function shouldBypassProxy(url, noProxy) { } /** - * Gets proxy configuration for a given URL - * Checks both environment variables and explicit config - * @param {string} url - The target URL - * @param {object} proxyConfig - Explicit proxy configuration from restClientConfig - * @returns {object|null} - Proxy URL and bypass info, or null if no proxy + * Gets proxy configuration for a given URL, checking both environment variables and explicit config. */ -function getProxyConfig(url, proxyConfig = {}) { +export function getProxyConfig( + url: string, + proxyConfig: RestClientConfig = {}, +): { proxyUrl: string } | null { const urlObj = new URL(url); - // Check NO_PROXY from config or environment const noProxyFromConfig = proxyConfig.noProxy; const noProxyFromEnv = process.env.NO_PROXY || process.env.no_proxy || ''; const noProxy = noProxyFromConfig || noProxyFromEnv; if (proxyConfig.debug) { + // eslint-disable-next-line no-console console.log('[ProxyHelper] getProxyConfig called:'); + // eslint-disable-next-line no-console console.log(' URL:', url); + // eslint-disable-next-line no-console console.log(' Hostname:', urlObj.hostname); + // eslint-disable-next-line no-console console.log(' noProxy from config:', noProxyFromConfig); + // eslint-disable-next-line no-console console.log(' noProxy from env:', noProxyFromEnv); + // eslint-disable-next-line no-console console.log(' Final noProxy:', noProxy); } - // Check if URL should bypass proxy const shouldBypass = shouldBypassProxy(url, noProxy); if (proxyConfig.debug) { + // eslint-disable-next-line no-console console.log(' Should bypass proxy:', shouldBypass); } @@ -104,12 +101,10 @@ function getProxyConfig(url, proxyConfig = {}) { return null; } - // If proxy is explicitly disabled if (proxyConfig.proxy === false) { return null; } - // Priority 1: Explicit proxy configuration object if (proxyConfig.proxy && typeof proxyConfig.proxy === 'object') { const { protocol: proxyProtocol, host, port, auth } = proxyConfig.proxy; if (host && port) { @@ -122,12 +117,10 @@ function getProxyConfig(url, proxyConfig = {}) { } } - // Priority 2: Explicit proxy URL string if (typeof proxyConfig.proxy === 'string') { return { proxyUrl: proxyConfig.proxy }; } - // Priority 3: Environment variables (with NO_PROXY support via proxy-from-env) const proxyUrlFromEnv = getProxyForUrl(url); if (proxyUrlFromEnv) { return { proxyUrl: proxyUrlFromEnv }; @@ -137,31 +130,24 @@ function getProxyConfig(url, proxyConfig = {}) { } // Cache for proxy agents to enable connection reuse -const agentCache = new Map(); +const agentCache = new Map(); -/** - * Creates a cache key for proxy agents based on proxy URL and protocol - * @param {string} proxyUrl - The proxy URL - * @param {boolean} isHttps - Whether target is HTTPS - * @returns {string} - Cache key - */ -function getAgentCacheKey(proxyUrl, isHttps) { +function getAgentCacheKey(proxyUrl: string, isHttps: boolean): string { return `${isHttps ? 'https' : 'http'}:${proxyUrl}`; } /** - * Creates an HTTP/HTTPS agent with proxy configuration for a specific URL - * Agents are cached and reused to enable connection pooling and keepAlive - * @param {string} url - The target URL for the request - * @param {object} restClientConfig - The rest client configuration - * @returns {object} - Object with httpAgent and/or httpsAgent + * Creates an HTTP/HTTPS agent with proxy configuration for a specific URL. + * Agents are cached and reused to enable connection pooling and keepAlive. */ -function createProxyAgents(url, restClientConfig = {}) { +export function createProxyAgents( + url: string, + restClientConfig: RestClientConfig = {}, +): ProxyAgents { const urlObj = new URL(url); const isHttps = urlObj.protocol === 'https:'; const proxyConfig = getProxyConfig(url, restClientConfig); - // Agent options for connection reuse and keepAlive const agentOptions = { keepAlive: true, keepAliveMsecs: 3000, @@ -171,18 +157,19 @@ function createProxyAgents(url, restClientConfig = {}) { if (!proxyConfig) { if (restClientConfig.debug) { + // eslint-disable-next-line no-console console.log('[ProxyHelper] No proxy for URL (bypassed or not configured):', url); + // eslint-disable-next-line no-console console.log(' Using default agent to prevent axios from using env proxy'); } const cacheKey = getAgentCacheKey('no-proxy', isHttps); - if (agentCache.has(cacheKey)) { - return agentCache.get(cacheKey); + const cached = agentCache.get(cacheKey); + if (cached) { + return cached; } - // Return a default agent to prevent axios from using HTTP_PROXY/HTTPS_PROXY env vars - // This ensures that URLs in noProxy truly bypass the proxy - const agents = isHttps + const agents: ProxyAgents = isHttps ? { httpsAgent: new https.Agent(agentOptions) } : { httpAgent: new http.Agent(agentOptions) }; agentCache.set(cacheKey, agents); @@ -192,20 +179,25 @@ function createProxyAgents(url, restClientConfig = {}) { const { proxyUrl } = proxyConfig; const cacheKey = getAgentCacheKey(proxyUrl, isHttps); - if (agentCache.has(cacheKey)) { + const cached = agentCache.get(cacheKey); + if (cached) { if (restClientConfig.debug) { + // eslint-disable-next-line no-console console.log('[ProxyHelper] Reusing cached proxy agent:', sanitizeUrlForLogging(proxyUrl)); } - return agentCache.get(cacheKey); + return cached; } if (restClientConfig.debug) { + // eslint-disable-next-line no-console console.log('[ProxyHelper] Creating proxy agent:'); + // eslint-disable-next-line no-console console.log(' URL:', url); + // eslint-disable-next-line no-console console.log(' Proxy URL:', sanitizeUrlForLogging(proxyUrl)); } - const agents = isHttps + const agents: ProxyAgents = isHttps ? { httpsAgent: new HttpsProxyAgent(proxyUrl, agentOptions) } : { httpAgent: new HttpProxyAgent(proxyUrl, agentOptions) }; @@ -214,19 +206,11 @@ function createProxyAgents(url, restClientConfig = {}) { } /** - * Gets proxy agent for a specific request URL - * This is the main function to be used in axios requests - * @param {string} url - The target URL for the request - * @param {object} restClientConfig - The rest client configuration - * @returns {object} - Object with agent configuration for axios + * Gets proxy agent for a specific request URL. This is the main function used in axios requests. */ -function getProxyAgentForUrl(url, restClientConfig = {}) { +export function getProxyAgentForUrl( + url: string, + restClientConfig: RestClientConfig = {}, +): ProxyAgents { return createProxyAgents(url, restClientConfig); } - -module.exports = { - shouldBypassProxy, - getProxyConfig, - createProxyAgents, - getProxyAgentForUrl, -}; diff --git a/src/lib/publicReportingAPI.ts b/src/lib/publicReportingAPI.ts new file mode 100644 index 00000000..f29544c1 --- /dev/null +++ b/src/lib/publicReportingAPI.ts @@ -0,0 +1,63 @@ +import { EVENTS } from './constants/events'; +import type { Attribute } from './models/common'; +import type { LogOptions } from './models/requests'; + +function emit(event: string, ...args: unknown[]): boolean { + return (process.emit as (e: string, ...a: unknown[]) => boolean)(event, ...args); +} + +/** + * Public API to emit additional events to RP JS agents. + */ +class PublicReportingAPI { + /** + * Emit set description event. + */ + static setDescription(text: string, suite?: string): void { + emit(EVENTS.SET_DESCRIPTION, { text, suite }); + } + + /** + * Emit add attributes event. + */ + static addAttributes(attributes: Attribute[], suite?: string): void { + emit(EVENTS.ADD_ATTRIBUTES, { attributes, suite }); + } + + /** + * Emit send log to test item event. + */ + static addLog(log: LogOptions, suite?: string): void { + emit(EVENTS.ADD_LOG, { log, suite }); + } + + /** + * Emit send log to current launch event. + */ + static addLaunchLog(log: LogOptions): void { + emit(EVENTS.ADD_LAUNCH_LOG, log); + } + + /** + * Emit set testCaseId event. + */ + static setTestCaseId(testCaseId: string, suite?: string): void { + emit(EVENTS.SET_TEST_CASE_ID, { testCaseId, suite }); + } + + /** + * Emit set status to current launch event. + */ + static setLaunchStatus(status: string): void { + emit(EVENTS.SET_LAUNCH_STATUS, status); + } + + /** + * Emit set status event. + */ + static setStatus(status: string, suite?: string): void { + emit(EVENTS.SET_STATUS, { status, suite }); + } +} + +export = PublicReportingAPI; diff --git a/lib/report-portal-client.js b/src/lib/report-portal-client.ts similarity index 65% rename from lib/report-portal-client.js rename to src/lib/report-portal-client.ts index c8357725..1289e296 100644 --- a/lib/report-portal-client.js +++ b/src/lib/report-portal-client.ts @@ -1,35 +1,86 @@ -/* eslint-disable quotes,no-console,class-methods-use-this */ -const { randomUUID } = require('crypto'); -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'); +/* eslint-disable no-console, class-methods-use-this */ +import { randomUUID } from 'crypto'; +import { URLSearchParams } from 'url'; +import * as helpers from './helpers'; +import type { SystemAttribute } from './helpers'; +import RestClient from './rest'; +import { getClientConfig } from './commons/config'; +import Statistics from '../statistics/statistics'; +import { EVENT_NAME } from '../statistics/constants'; +import { STATUSES } from './constants/statuses'; +import type { AgentParams, Attribute, Attachment, ClientResponse } from './models/common'; +import type { NormalizedClientConfig, ReportPortalConfig } from './models/config'; +import type { + FinishLaunchOptions, + FinishTestItemOptions, + LogOptions, + MergeLaunchesOptions, + StartLaunchOptions, + StartTestItemOptions, + UpdateLaunchOptions, +} from './models/requests'; +import type { + FinishLaunchResponse, + LaunchSearchResponse, + MergeLaunchesResponse, + ServerInfoResponse, + StartLaunchResponse, + StartTestItemResponse, +} from './models/responses'; const MULTIPART_BOUNDARY = Math.floor(Math.random() * 10000000000).toString(); +type PromiseExecutor = ( + resolve: (value?: unknown) => void, + reject: (reason?: unknown) => void, +) => void; + +interface ItemObj { + promiseStart: Promise; + realId: string; + children: string[]; + finishSend: boolean; + promiseFinish: Promise; + resolveFinish: (value?: unknown) => void; + rejectFinish: (reason?: unknown) => void; +} + +type RequestPromiseFunc = (itemUuid: string, launchUuid: string) => Promise; + class RPClient { + private config: NormalizedClientConfig; + + private debug?: boolean; + + private isLaunchMergeRequired: boolean; + + private apiKey: string | null; + + // deprecated + private token: string | null; + + private map: Record; + + private baseURL: string; + + private headers: Record; + + public helpers: typeof helpers; + + private restClient: RestClient; + + private statistics: Statistics; + + private launchUuid: string; + + private itemRetriesChainMap: Map>; + + private itemRetriesChainKeyMapByTempId: Map; + /** * 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) { + constructor(options: ReportPortalConfig, agentParams?: AgentParams) { this.config = getClientConfig(options); this.debug = this.config.debug; this.isLaunchMergeRequired = this.config.isLaunchMergeRequired; @@ -40,7 +91,7 @@ class RPClient { this.map = {}; this.baseURL = [this.config.endpoint, this.config.project].join('/'); - const headers = { + const headers: Record = { 'User-Agent': 'NodeJS', 'Content-Type': 'application/json; charset=UTF-8', ...(this.config.headers || {}), @@ -67,27 +118,22 @@ class RPClient { this.itemRetriesChainKeyMapByTempId = new Map(); } - // eslint-disable-next-line valid-jsdoc - /** - * - * @Private - */ - logDebug(msg, dataMsg = '') { + logDebug(msg: unknown, dataMsg: unknown = ''): void { if (this.debug) { console.log(msg, dataMsg); } } - calculateItemRetriesChainMapKey(launchId, parentId, name, itemId = '') { + calculateItemRetriesChainMapKey( + launchId: string, + parentId: string | undefined, + name: string, + itemId = '', + ): string { return `${launchId}__${parentId}__${name}__${itemId}`; } - // eslint-disable-next-line valid-jsdoc - /** - * - * @Private - */ - cleanItemRetriesChain(tempIds) { + cleanItemRetriesChain(tempIds: string[]): void { tempIds.forEach((id) => { const key = this.itemRetriesChainKeyMapByTempId.get(id); @@ -99,21 +145,21 @@ class RPClient { }); } - getUniqId() { + getUniqId(): string { return randomUUID(); } - getRejectAnswer(tempId, error) { + getRejectAnswer(tempId: string, error: Error): ClientResponse { return { tempId, promise: Promise.reject(error), }; } - getNewItemObj(startPromiseFunc) { - let resolveFinish; - let rejectFinish; - const obj = { + getNewItemObj(startPromiseFunc: PromiseExecutor): ItemObj { + let resolveFinish!: (value?: any) => void; + let rejectFinish!: (reason?: any) => void; + const obj: ItemObj = { promiseStart: new Promise(startPromiseFunc), realId: '', children: [], @@ -122,40 +168,35 @@ class RPClient { resolveFinish = resolve; rejectFinish = reject; }), + resolveFinish, + rejectFinish, }; - obj.resolveFinish = resolveFinish; - obj.rejectFinish = rejectFinish; return obj; } - // eslint-disable-next-line valid-jsdoc - /** - * - * @Private - */ - cleanMap(ids) { + cleanMap(ids: string[]): void { ids.forEach((id) => { delete this.map[id]; }); } - checkConnect() { + checkConnect(): Promise { 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, {}); } - getServerInfoUrl() { + getServerInfoUrl(): string { return this.config.endpoint.replace('/v1', '/info').replace('/v2', '/info'); } - async fetchServerInfo() { + async fetchServerInfo(): Promise { const url = this.getServerInfoUrl(); - return this.restClient.request('GET', url, {}); + return this.restClient.request('GET', url, {}); } - async triggerStatisticsEvent() { + async triggerStatisticsEvent(): Promise { if (process.env.REPORTPORTAL_CLIENT_JS_NO_ANALYTICS) { return; } @@ -172,45 +213,9 @@ class RPClient { } /** - * 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) { + * Start launch and report it. + */ + startLaunch(launchDataRQ: StartLaunchOptions): ClientResponse { const tempId = this.getUniqId(); if (launchDataRQ.id) { @@ -229,7 +234,7 @@ class RPClient { systemAttr.push(skippedIsNotIssueAttribute); } const attributes = Array.isArray(launchDataRQ.attributes) - ? launchDataRQ.attributes.concat(systemAttr) + ? (launchDataRQ.attributes as any[]).concat(systemAttr) : systemAttr; const launchData = { name: this.config.launch || 'Test launch name', @@ -241,7 +246,7 @@ class RPClient { this.map[tempId] = this.getNewItemObj((resolve, reject) => { const url = 'launch'; this.logDebug(`Start launch with tempId ${tempId}`, launchData); - this.restClient.create(url, launchData).then( + this.restClient.create(url, launchData).then( (response) => { this.map[tempId].realId = response.id; this.launchUuid = response.id; @@ -273,16 +278,8 @@ class RPClient { /** * 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) { + finishLaunch(launchTempId: string, finishExecutionRQ: FinishLaunchOptions = {}): ClientResponse { const launchObj = this.map[launchTempId]; if (!launchObj) { return this.getRejectAnswer( @@ -300,7 +297,7 @@ class RPClient { () => { this.logDebug(`Finish launch with tempId ${launchTempId}`, finishExecutionData); const url = ['launch', launchObj.realId, 'finish'].join('/'); - this.restClient.update(url, finishExecutionData).then( + this.restClient.update(url, finishExecutionData).then( (response) => { this.logDebug(`Success finish launch with tempId ${launchTempId}`, response); console.log(`\nReportPortal Launch Link: ${response.link}`); @@ -333,10 +330,11 @@ class RPClient { /* * 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 = {}) { + getMergeLaunchesRequest( + launchIds: Array, + mergeOptions: MergeLaunchesOptions = {}, + ) { return { launches: launchIds, mergeType: 'BASIC', @@ -352,53 +350,43 @@ class RPClient { /** * 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 + * Please, keep in mind that this method work only in case the option isLaunchMergeRequired is true. */ - mergeLaunches(mergeOptions = {}) { + mergeLaunches(mergeOptions: MergeLaunchesOptions = {}): Promise | undefined { if (this.isLaunchMergeRequired) { const launchUUIds = helpers.readLaunchesFromFile(); const params = new URLSearchParams({ 'filter.in.uuid': launchUUIds, 'page.size': launchUUIds.length, - }); + } as unknown as Record); 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) + .retrieveSyncAPI(launchSearchUrl) .then( (response) => { - const launchIds = response.content.map((launch) => launch.id); + const launchIds = response.content.map((launch: any) => launch.id); this.logDebug(`Found launches: ${launchIds}`, response.content); return launchIds; }, - (error) => { + (error): Array => { this.logDebug(`Error during launches search with UUIDs: ${launchUUIds}`, error); console.dir(error); + return []; }, ) .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); + return this.restClient.create(mergeURL, request); }) .then((response) => { this.logDebug(`Launches with UUIDs: ${launchUUIds} were successfully merged!`); - if (this.config.launchUuidPrint) { + if (this.config.launchUuidPrint && response.uuid) { this.config.launchUuidPrintOutput(response.uuid); } }) @@ -410,41 +398,22 @@ class RPClient { this.logDebug( 'Option isLaunchMergeRequired is false, merge process cannot be done as no launch UUIDs where saved.', ); + return undefined; } /* * 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 + * it doesn't wait for promise resolve and stop the process. */ - getPromiseFinishAllItems(launchTempId) { + getPromiseFinishAllItems(launchTempId: string): Promise { 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) { + * Update launch. + */ + updateLaunch(launchTempId: string, launchData: UpdateLaunchOptions): ClientResponse { const launchObj = this.map[launchTempId]; if (!launchObj) { return this.getRejectAnswer( @@ -452,8 +421,8 @@ class RPClient { new Error(`Launch with tempId "${launchTempId}" not found`), ); } - let resolvePromise; - let rejectPromise; + let resolvePromise!: (value?: any) => void; + let rejectPromise!: (reason?: any) => void; const promise = new Promise((resolve, reject) => { resolvePromise = resolve; rejectPromise = reject; @@ -486,33 +455,13 @@ class RPClient { } /** - * 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) { + * If there is no parentItemId starts Suite, else starts test or item. + */ + startTestItem( + testItemDataRQ: StartTestItemOptions, + launchTempId: string, + parentTempId?: string, + ): ClientResponse { let parentMapId = launchTempId; const launchObj = this.map[launchTempId]; if (!launchObj) { @@ -532,7 +481,7 @@ class RPClient { const testCaseId = testItemDataRQ.testCaseId || helpers.generateTestCaseId(testItemDataRQ.codeRef, testItemDataRQ.parameters); - const testItemData = { + const testItemData: Record = { startTime: this.helpers.now(), ...testItemDataRQ, ...(testCaseId && { testCaseId }), @@ -571,7 +520,7 @@ class RPClient { } testItemData.launchUuid = realLaunchId; this.logDebug(`Start test item with tempId ${tempId}`, testItemData); - this.restClient.create(url, testItemData).then( + this.restClient.create(url, testItemData).then( (response) => { this.logDebug(`Success start item with tempId ${tempId}`, response); this.map[tempId].realId = response.id; @@ -600,30 +549,9 @@ class RPClient { } /** - * 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) { + * Finish Suite or Step level. + */ + finishTestItem(itemTempId: string, finishTestItemRQ: FinishTestItemOptions = {}): ClientResponse { const itemObj = this.map[itemTempId]; if (!itemObj) { return this.getRejectAnswer( @@ -632,16 +560,13 @@ class RPClient { ); } - const finishTestItemData = { + const finishTestItemData: Record = { endTime: this.helpers.now(), - ...(itemObj.children.length ? {} : { status: RP_STATUSES.PASSED }), + ...(itemObj.children.length ? {} : { status: STATUSES.PASSED }), ...finishTestItemRQ, }; - if ( - finishTestItemData.status === RP_STATUSES.SKIPPED && - this.config.skippedIsNotIssue === true - ) { + if (finishTestItemData.status === STATUSES.SKIPPED && this.config.skippedIsNotIssue === true) { finishTestItemData.issue = { issueType: 'NOT_ISSUE' }; } @@ -686,7 +611,7 @@ class RPClient { }; } - saveLog(itemObj, requestPromiseFunc) { + saveLog(itemObj: ItemObj, requestPromiseFunc: RequestPromiseFunc): ClientResponse { const tempId = this.getUniqId(); this.map[tempId] = this.getNewItemObj((resolve, reject) => { itemObj.promiseStart.then( @@ -724,7 +649,7 @@ class RPClient { }; } - sendLog(itemTempId, saveLogRQ, fileObj) { + sendLog(itemTempId: string, saveLogRQ: LogOptions, fileObj?: Attachment): ClientResponse { const saveLogData = { time: this.helpers.now(), message: '', @@ -740,17 +665,8 @@ class RPClient { /** * 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) { + sendLogWithoutFile(itemTempId: string, saveLogRQ: LogOptions): ClientResponse { const itemObj = this.map[itemTempId]; if (!itemObj) { return this.getRejectAnswer( @@ -759,7 +675,7 @@ class RPClient { ); } - const requestPromise = (itemUuid, launchUuid) => { + const requestPromise: RequestPromiseFunc = (itemUuid, launchUuid) => { const url = 'log'; const isItemUuid = itemUuid !== launchUuid; return this.restClient.create( @@ -771,27 +687,9 @@ class RPClient { } /** - * 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) { + * Send log of test results with file. + */ + sendLogWithFile(itemTempId: string, saveLogRQ: LogOptions, fileObj: Attachment): ClientResponse { const itemObj = this.map[itemTempId]; if (!itemObj) { return this.getRejectAnswer( @@ -800,7 +698,7 @@ class RPClient { ); } - const requestPromise = (itemUuid, launchUuid) => { + const requestPromise: RequestPromiseFunc = (itemUuid, launchUuid) => { const isItemUuid = itemUuid !== launchUuid; return this.getRequestLogWithFile( @@ -812,10 +710,10 @@ class RPClient { return this.saveLog(itemObj, requestPromise); } - getRequestLogWithFile(saveLogRQ, fileObj) { + getRequestLogWithFile(saveLogRQ: LogOptions, fileObj: Attachment): Promise { const url = 'log'; // eslint-disable-next-line no-param-reassign - saveLogRQ.file = { name: fileObj.name }; + saveLogRQ.file = { name: fileObj.name } as Attachment; this.logDebug(`Save log with file: ${fileObj.name}`, saveLogRQ); return this.restClient .create(url, this.buildMultiPartStream([saveLogRQ], fileObj, MULTIPART_BOUNDARY), { @@ -833,12 +731,7 @@ class RPClient { }); } - // eslint-disable-next-line valid-jsdoc - /** - * - * @Private - */ - buildMultiPartStream(jsonPart, filePart, boundary) { + buildMultiPartStream(jsonPart: unknown[], filePart: Attachment, boundary: string): Buffer { const eol = '\r\n'; const bx = `--${boundary}`; const buffers = [ @@ -870,13 +763,17 @@ class RPClient { eol + eol, ), - Buffer.from(filePart.content, 'base64'), + Buffer.from(filePart.content as string, 'base64'), Buffer.from(`${eol + bx}--${eol}`), ]; return Buffer.concat(buffers); } - finishTestItemPromiseStart(itemObj, itemTempId, finishTestItemData) { + finishTestItemPromiseStart( + itemObj: ItemObj, + itemTempId: string, + finishTestItemData: Record, + ): void { itemObj.promiseStart.then( () => { const url = ['item', itemObj.realId].join('/'); @@ -902,4 +799,4 @@ class RPClient { } } -module.exports = RPClient; +export = RPClient; diff --git a/lib/rest.js b/src/lib/rest.ts similarity index 53% rename from lib/rest.js rename to src/lib/rest.ts index 5e3b002d..916e05bb 100644 --- a/lib/rest.js +++ b/src/lib/rest.ts @@ -1,17 +1,24 @@ -const axios = require('axios'); -const axiosRetry = require('axios-retry').default; -const http = require('http'); -const https = require('https'); -const logger = require('./logger'); -const OAuthInterceptor = require('./oauth'); -const { getProxyAgentForUrl } = require('./proxyHelper'); +import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios'; +import axiosRetry, { IAxiosRetryConfig, isRetryableError } from 'axios-retry'; +import http from 'http'; +import https from 'https'; +import * as logger from './logger'; +import OAuthInterceptor from './oauth'; +import { getProxyAgentForUrl } from './proxyHelper'; +import type { OAuthConfig, RestClientConfig } from './models/config'; const DEFAULT_MAX_CONNECTION_TIME_MS = 30000; const DEFAULT_RETRY_ATTEMPTS = 6; const RETRY_BASE_DELAY_MS = 200; const RETRY_MAX_DELAY_MS = 5000; -const isTimeoutError = (error) => { +interface NetworkError { + message?: string; + code?: string; + cause?: { code?: string }; +} + +const isTimeoutError = (error: NetworkError | null | undefined): boolean => { if (!error) return false; const message = error.message ? error.message.toLowerCase() : ''; @@ -24,13 +31,16 @@ const isTimeoutError = (error) => { ); }; -const retryCondition = (error) => { - return axiosRetry.isRetryableError(error) || isTimeoutError(error); +const retryCondition = (error: AxiosError): boolean => { + return isRetryableError(error) || isTimeoutError(error); }; -const DEFAULT_RETRY_CONFIG = { +const DEFAULT_RETRY_CONFIG: IAxiosRetryConfig = { retryDelay: (retryCount = 1) => { - const base = Math.min(RETRY_BASE_DELAY_MS * 2 ** Math.max(retryCount - 1, 0), RETRY_MAX_DELAY_MS); + const base = Math.min( + RETRY_BASE_DELAY_MS * 2 ** Math.max(retryCount - 1, 0), + RETRY_MAX_DELAY_MS, + ); const jitter = Math.random() * 0.4 * base; // +/-40% return base - jitter; }, @@ -40,8 +50,28 @@ const DEFAULT_RETRY_CONFIG = { }; const SKIPPED_REST_CONFIG_KEYS = ['agent', 'retry', 'proxy', 'noProxy']; +interface RestClientOptions { + baseURL: string; + headers?: Record; + restClientConfig?: RestClientConfig; + oauthConfig?: OAuthConfig | null; + debug?: boolean; +} + class RestClient { - constructor(options) { + private baseURL: string; + + private headers?: Record; + + private restClientConfig?: RestClientConfig; + + private oauthConfig?: OAuthConfig | null; + + private debug?: boolean; + + private axiosInstance: AxiosInstance; + + constructor(options: RestClientOptions) { this.baseURL = options.baseURL; this.headers = options.headers; this.restClientConfig = options.restClientConfig; @@ -51,7 +81,7 @@ class RestClient { this.axiosInstance = axios.create({ timeout: DEFAULT_MAX_CONNECTION_TIME_MS, headers: this.headers, - ...this.getRestConfig(this.restClientConfig), + ...this.getRestConfig(), }); // Create and attach OAuth interceptor if OAuth config is provided @@ -64,8 +94,12 @@ class RestClient { restClientConfig: this.restClientConfig, }); oauthInterceptor.attach(this.axiosInstance); - } catch (error) { - console.error('[RestClient] Failed to initialize OAuth interceptor:', error.message); + } catch (error: unknown) { + // eslint-disable-next-line no-console + console.error( + '[RestClient] Failed to initialize OAuth interceptor:', + error instanceof Error ? error.message : String(error), + ); } } @@ -76,15 +110,20 @@ class RestClient { } } - buildPath(path) { + buildPath(path: string): string { return [this.baseURL, path].join('/'); } - buildPathToSyncAPI(path) { + buildPathToSyncAPI(path: string): string { return [this.baseURL.replace('/v2', '/v1'), path].join('/'); } - request(method, url, data, options = {}) { + request( + method: string, + url: string, + data: unknown, + options: AxiosRequestConfig = {}, + ): Promise { // Only apply proxy agents if custom agents are not explicitly provided // Priority: explicit httpsAgent/httpAgent/agent > proxy config > default const hasCustomAgents = @@ -103,16 +142,16 @@ class RestClient { ...options, ...proxyAgents, // Explicitly disable axios built-in proxy when using custom agents - ...(usingProxyAgent && { proxy: false }), + ...(usingProxyAgent && { proxy: false as const }), headers: { HOST: new URL(url).host, ...options.headers, }, }) .then((response) => response.data) - .catch((error) => { - const errorMessage = error.message; - const responseData = error.response && error.response.data; + .catch((error: unknown) => { + const errorMessage = error instanceof Error ? error.message : String(error); + const responseData = axios.isAxiosError(error) ? error.response?.data : undefined; throw new Error( `${errorMessage}${ responseData && typeof responseData === 'object' @@ -125,33 +164,39 @@ method: ${method}`, }); } - getRestConfig() { + getRestConfig(): AxiosRequestConfig { if (!this.restClientConfig) return {}; - const config = Object.keys(this.restClientConfig).reduce((acc, key) => { + const { restClientConfig } = this; + const config = Object.keys(restClientConfig).reduce>((acc, key) => { if (!SKIPPED_REST_CONFIG_KEYS.includes(key)) { - acc[key] = this.restClientConfig[key]; + acc[key] = (restClientConfig as Record)[key]; } return acc; }, {}); - if ('agent' in this.restClientConfig) { + if ('agent' in restClientConfig) { const { protocol } = new URL(this.baseURL); const isHttps = /https:?/; const isHttpsRequest = isHttps.test(protocol); config[isHttpsRequest ? 'httpsAgent' : 'httpAgent'] = isHttpsRequest - ? new https.Agent(this.restClientConfig.agent) - : new http.Agent(this.restClientConfig.agent); + ? new https.Agent(restClientConfig.agent) + : new http.Agent(restClientConfig.agent); } return config; } - getRetryConfig() { + getRetryConfig(): IAxiosRetryConfig { const retryOption = this.restClientConfig?.retry; - const onRetry = (retryCount, error, requestConfig) => { + const onRetry: IAxiosRetryConfig['onRetry'] = (retryCount, error, requestConfig) => { if (this.restClientConfig?.debug) { - console.log(`[retry #${retryCount}] ${requestConfig.method?.toUpperCase()} ${requestConfig.url} -> ${error.code || error.message}`); + // eslint-disable-next-line no-console + console.log( + `[retry #${retryCount}] ${requestConfig.method?.toUpperCase()} ${requestConfig.url} -> ${ + error.code || error.message + }`, + ); } }; @@ -174,14 +219,14 @@ method: ${method}`, return { onRetry, ...DEFAULT_RETRY_CONFIG }; } - create(path, data, options = {}) { - return this.request('POST', this.buildPath(path), data, { + create(path: string, data: unknown, options: AxiosRequestConfig = {}): Promise { + return this.request('POST', this.buildPath(path), data, { ...options, }); } - retrieve(path, options = {}) { - return this.request( + retrieve(path: string, options: AxiosRequestConfig = {}): Promise { + return this.request( 'GET', this.buildPath(path), {}, @@ -191,20 +236,20 @@ method: ${method}`, ); } - update(path, data, options = {}) { - return this.request('PUT', this.buildPath(path), data, { + update(path: string, data: unknown, options: AxiosRequestConfig = {}): Promise { + return this.request('PUT', this.buildPath(path), data, { ...options, }); } - delete(path, data, options = {}) { - return this.request('DELETE', this.buildPath(path), data, { + delete(path: string, data: unknown, options: AxiosRequestConfig = {}): Promise { + return this.request('DELETE', this.buildPath(path), data, { ...options, }); } - retrieveSyncAPI(path, options = {}) { - return this.request( + retrieveSyncAPI(path: string, options: AxiosRequestConfig = {}): Promise { + return this.request( 'GET', this.buildPathToSyncAPI(path), {}, @@ -215,4 +260,4 @@ method: ${method}`, } } -module.exports = RestClient; +export = RestClient; diff --git a/src/models.ts b/src/models.ts new file mode 100644 index 00000000..896004ec --- /dev/null +++ b/src/models.ts @@ -0,0 +1,3 @@ +// Public entry point so consumers can import models as +// `@reportportal/client-javascript/models` instead of the internal `/lib/models` path. +export * from './lib/models'; diff --git a/statistics/client-id.js b/src/statistics/client-id.ts similarity index 66% rename from statistics/client-id.js rename to src/statistics/client-id.ts index 0731c785..034ef201 100644 --- a/statistics/client-id.js +++ b/src/statistics/client-id.ts @@ -1,25 +1,26 @@ -const fs = require('fs'); -const util = require('util'); -const ini = require('ini'); -const { randomUUID } = require('crypto'); -const { ENCODING, CLIENT_ID_KEY, RP_FOLDER_PATH, RP_PROPERTIES_FILE_PATH } = require('./constants'); +import fs from 'fs'; +import util from 'util'; +import * as ini from 'ini'; +import { randomUUID } from 'crypto'; +import { ENCODING, CLIENT_ID_KEY, RP_FOLDER_PATH, RP_PROPERTIES_FILE_PATH } from './constants'; const exists = util.promisify(fs.exists); const readFile = util.promisify(fs.readFile); const mkdir = util.promisify(fs.mkdir); const writeFile = util.promisify(fs.writeFile); -async function readClientId() { +async function readClientId(): Promise { if (await exists(RP_PROPERTIES_FILE_PATH)) { const propertiesContent = await readFile(RP_PROPERTIES_FILE_PATH, ENCODING); const properties = ini.parse(propertiesContent); - return properties[CLIENT_ID_KEY]; + const value = properties[CLIENT_ID_KEY]; + return typeof value === 'string' ? value : null; } return null; } -async function storeClientId(clientId) { - const properties = {}; +async function storeClientId(clientId: string): Promise { + const properties: Record = {}; if (await exists(RP_PROPERTIES_FILE_PATH)) { const propertiesContent = await readFile(RP_PROPERTIES_FILE_PATH, ENCODING); Object.assign(properties, ini.parse(propertiesContent)); @@ -30,7 +31,7 @@ async function storeClientId(clientId) { await writeFile(RP_PROPERTIES_FILE_PATH, propertiesContent, ENCODING); } -async function getClientId() { +export async function getClientId(): Promise { let clientId = await readClientId(); if (!clientId) { clientId = randomUUID(); @@ -42,5 +43,3 @@ async function getClientId() { } return clientId; } - -module.exports = { getClientId }; diff --git a/src/statistics/constants.ts b/src/statistics/constants.ts new file mode 100644 index 00000000..2c4784f3 --- /dev/null +++ b/src/statistics/constants.ts @@ -0,0 +1,33 @@ +import os from 'os'; +import path from 'path'; +import { PJSON_NAME, PJSON_VERSION } from '../lib/pjson'; + +export const ENCODING = 'utf-8'; +export { PJSON_NAME, PJSON_VERSION }; +export const CLIENT_ID_KEY = 'client.id'; +export const RP_FOLDER = '.rp'; +export const RP_PROPERTIES_FILE = 'rp.properties'; +const HOME_DIRECTORY = process.env.RP_CLIENT_JS_HOME || os.homedir(); +export const RP_FOLDER_PATH = path.join(HOME_DIRECTORY, RP_FOLDER); +export const RP_PROPERTIES_FILE_PATH = path.join(RP_FOLDER_PATH, RP_PROPERTIES_FILE); +const CLIENT_INFO = Buffer.from( + 'Ry1XUDU3UlNHOFhMOmVFazhPMGJ0UXZ5MmI2VXVRT19TOFE=', + 'base64', +).toString('binary'); +export const [MEASUREMENT_ID, API_KEY] = CLIENT_INFO.split(':'); +export const EVENT_NAME = 'start_launch'; + +function getNodeVersion(): string | null { + // A workaround to avoid reference error in case this is not a Node.js application + if (typeof process !== 'undefined') { + if (process.versions) { + const version = process.versions.node; + if (version) { + return `Node.js ${version}`; + } + } + } + return null; +} + +export const INTERPRETER = getNodeVersion(); diff --git a/statistics/statistics.js b/src/statistics/statistics.ts similarity index 55% rename from statistics/statistics.js rename to src/statistics/statistics.ts index 67c01333..89933802 100644 --- a/statistics/statistics.js +++ b/src/statistics/statistics.ts @@ -1,19 +1,34 @@ -const axios = require('axios'); -const { MEASUREMENT_ID, API_KEY, PJSON_NAME, PJSON_VERSION, INTERPRETER } = require('./constants'); -const { getClientId } = require('./client-id'); +import axios from 'axios'; +import { MEASUREMENT_ID, API_KEY, PJSON_NAME, PJSON_VERSION, INTERPRETER } from './constants'; +import { getClientId } from './client-id'; +import type { AgentParams } from '../lib/models/common'; -const hasOption = (options, optionName) => { +interface EventParams { + interpreter: string | null; + client_name: string; + client_version: string; + agent_name?: string; + agent_version?: string; + framework_version?: string; + instanceID?: string; +} + +const hasOption = (options: AgentParams, optionName: keyof AgentParams): boolean => { return Object.prototype.hasOwnProperty.call(options, optionName); }; class Statistics { - constructor(eventName, agentParams) { + private eventName: string; + + private eventParams: EventParams; + + constructor(eventName: string, agentParams?: AgentParams) { this.eventName = eventName; this.eventParams = this.getEventParams(agentParams); } - getEventParams(agentParams) { - const params = { + getEventParams(agentParams?: AgentParams): EventParams { + const params: EventParams = { interpreter: INTERPRETER, client_name: PJSON_NAME, client_version: PJSON_VERSION, @@ -34,11 +49,11 @@ class Statistics { return params; } - setInstanceID(instanceID) { + setInstanceID(instanceID: string): void { this.eventParams.instanceID = instanceID; } - async trackEvent() { + async trackEvent(): Promise { try { const requestBody = { client_id: await getClientId(), @@ -54,10 +69,11 @@ class Statistics { `https://www.google-analytics.com/mp/collect?measurement_id=${MEASUREMENT_ID}&api_secret=${API_KEY}`, requestBody, ); - } catch (error) { - console.error(error.message); + } catch (error: unknown) { + // eslint-disable-next-line no-console + console.error(error instanceof Error ? error.message : String(error)); } } } -module.exports = Statistics; +export = Statistics; diff --git a/src/types/vendor.d.ts b/src/types/vendor.d.ts new file mode 100644 index 00000000..340df8ba --- /dev/null +++ b/src/types/vendor.d.ts @@ -0,0 +1,10 @@ +declare module 'proxy-from-env' { + export function getProxyForUrl(url: string): string; +} + +declare module 'ini' { + type IniValue = string | boolean | null | IniValue[] | { [key: string]: IniValue }; + + export function parse(str: string): Record; + export function stringify(obj: Record): string; +} diff --git a/statistics/constants.js b/statistics/constants.js deleted file mode 100644 index 8095e278..00000000 --- a/statistics/constants.js +++ /dev/null @@ -1,49 +0,0 @@ -const os = require('os'); -const path = require('path'); -const pjson = require('../package.json'); - -const ENCODING = 'utf-8'; -const PJSON_VERSION = pjson.version; -const PJSON_NAME = pjson.name; -const CLIENT_ID_KEY = 'client.id'; -const RP_FOLDER = '.rp'; -const RP_PROPERTIES_FILE = 'rp.properties'; -const HOME_DIRECTORY = process.env.RP_CLIENT_JS_HOME || os.homedir(); -const RP_FOLDER_PATH = path.join(HOME_DIRECTORY, RP_FOLDER); -const RP_PROPERTIES_FILE_PATH = path.join(RP_FOLDER_PATH, RP_PROPERTIES_FILE); -const CLIENT_INFO = Buffer.from( - 'Ry1XUDU3UlNHOFhMOmVFazhPMGJ0UXZ5MmI2VXVRT19TOFE=', - 'base64', -).toString('binary'); -const [MEASUREMENT_ID, API_KEY] = CLIENT_INFO.split(':'); -const EVENT_NAME = 'start_launch'; - -function getNodeVersion() { - // A workaround to avoid reference error in case this is not a Node.js application - if (typeof process !== 'undefined') { - if (process.versions) { - const version = process.versions.node; - if (version) { - return `Node.js ${version}`; - } - } - } - return null; -} - -const INTERPRETER = getNodeVersion(); - -module.exports = { - ENCODING, - EVENT_NAME, - PJSON_VERSION, - PJSON_NAME, - CLIENT_ID_KEY, - RP_FOLDER, - RP_FOLDER_PATH, - RP_PROPERTIES_FILE, - RP_PROPERTIES_FILE_PATH, - MEASUREMENT_ID, - API_KEY, - INTERPRETER, -}; diff --git a/tsconfig.json b/tsconfig.json index b655f001..6215b93e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,23 +2,23 @@ "compilerOptions": { "sourceMap": true, "esModuleInterop": true, - "allowJs": true, + "allowJs": false, "noImplicitAny": true, "moduleResolution": "node", "strictNullChecks": true, + "strictFunctionTypes": true, + "noImplicitThis": true, + "alwaysStrict": true, "downlevelIteration": true, "declaration": true, - "lib": ["es2016", "es2016.array.include"], + "lib": ["ES2020"], "module": "commonjs", - "target": "ES2015", + "target": "ES2020", + "rootDir": "src", + "outDir": ".", "baseUrl": ".", - "paths": { - "*": ["node_modules/*"] - }, - "typeRoots": ["node_modules/@types"], - "outDir": "./build" + "typeRoots": ["node_modules/@types", "src/types"] }, - "include": [ - "statistics/**/*", "lib/**/*", "__tests__/**/*"], + "include": ["src/**/*"], "exclude": ["node_modules", "__tests__"] } From 8a069ecb2a2da98d8172d8a5f5f811eeb2ddd2af Mon Sep 17 00:00:00 2001 From: maria-hambardzumian Date: Thu, 23 Jul 2026 13:16:46 +0400 Subject: [PATCH 2/2] EPMRPP-89496 || comments & tests fix --- .eslintrc | 4 +- __tests__/oauth.spec.js | 85 ++++++++++++++++++++++++++++++++++++++- __tests__/rest.spec.js | 18 +++++++++ src/helpers.ts | 4 +- src/lib/commons/config.ts | 11 +++-- src/lib/models/config.ts | 11 +++++ src/lib/rest.ts | 13 ++---- 7 files changed, 127 insertions(+), 19 deletions(-) diff --git a/.eslintrc b/.eslintrc index d46f6c07..54cc01db 100644 --- a/.eslintrc +++ b/.eslintrc @@ -39,6 +39,8 @@ "@typescript-eslint/ban-ts-comment": 0, "@typescript-eslint/no-var-requires": 0, "@typescript-eslint/no-floating-promises": 2, - "max-classes-per-file": 0 + "max-classes-per-file": 0, + "no-shadow": 0, + "@typescript-eslint/no-shadow": 2 } } diff --git a/__tests__/oauth.spec.js b/__tests__/oauth.spec.js index b6a2cfdb..bd27ff2a 100644 --- a/__tests__/oauth.spec.js +++ b/__tests__/oauth.spec.js @@ -4,7 +4,7 @@ const OAuthInterceptor = require('../src/lib/oauth'); jest.mock('axios', () => ({ post: jest.fn(), - isAxiosError: jest.fn((error) => !!error && typeof error === 'object' && 'response' in error), + isAxiosError: jest.fn((error) => !!error && typeof error === 'object' && error.isAxiosError === true), })); describe('OAuthInterceptor', () => { @@ -135,6 +135,7 @@ describe('OAuthInterceptor', () => { const oauthInterceptor = new OAuthInterceptor(baseConfig); const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); axios.post.mockRejectedValue({ + isAxiosError: true, response: { status: 400, data: { error: 'invalid_grant' }, @@ -151,6 +152,54 @@ describe('OAuthInterceptor', () => { consoleSpy.mockRestore(); }); + it('throws a descriptive error when the token response has no access token', async () => { + const oauthInterceptor = new OAuthInterceptor(baseConfig); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + // Response resolves successfully but is missing the access_token field. + axios.post.mockResolvedValue({ data: { expires_in: 120 } }); + + await expect(oauthInterceptor.getAccessToken()).rejects.toThrow( + 'OAuth token request failed: No access token received from OAuth server', + ); + expect(consoleSpy).toHaveBeenCalledWith( + '[OAuth] OAuth token request failed: No access token received from OAuth server', + ); + + consoleSpy.mockRestore(); + }); + + it('formats non-Axios errors using their message', async () => { + const oauthInterceptor = new OAuthInterceptor(baseConfig); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + // A plain Error (not an AxiosError, so no response payload to include). + axios.post.mockRejectedValue(new Error('network is unreachable')); + + await expect(oauthInterceptor.getAccessToken()).rejects.toThrow( + 'OAuth token request failed: network is unreachable', + ); + + consoleSpy.mockRestore(); + }); + + it('propagates request errors through the attached rejection handler', async () => { + const oauthInterceptor = new OAuthInterceptor(baseConfig); + let rejectionHandler; + const axiosInstance = { + interceptors: { + request: { + use: jest.fn((fulfilled, rejected) => { + rejectionHandler = rejected; + }), + }, + }, + }; + + oauthInterceptor.attach(axiosInstance); + const error = new Error('request setup failed'); + + await expect(rejectionHandler(error)).rejects.toBe(error); + }); + it('logs debug messages only when debug mode is enabled', () => { const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); const oauthInterceptor = new OAuthInterceptor({ @@ -232,6 +281,7 @@ describe('OAuthInterceptor', () => { // First call (refresh token) fails axios.post .mockRejectedValueOnce({ + isAxiosError: true, response: { status: 400, data: { error: 'invalid_grant', error_description: 'refresh token expired' }, @@ -287,12 +337,14 @@ describe('OAuthInterceptor', () => { // Both calls fail axios.post .mockRejectedValueOnce({ + isAxiosError: true, response: { status: 400, data: { error: 'invalid_grant' }, }, }) .mockRejectedValueOnce({ + isAxiosError: true, response: { status: 401, data: { error: 'invalid_credentials' }, @@ -348,6 +400,37 @@ describe('OAuthInterceptor', () => { nowSpy.mockRestore(); }); + it('logs the proxied token request when debug and proxy are both enabled', async () => { + const baseTime = 1700000750000; + const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime); + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + const oauthInterceptor = new OAuthInterceptor({ + ...baseConfig, + restClientConfig: { + debug: true, + proxy: { + protocol: 'https', + host: '127.0.0.1', + port: 9000, + }, + }, + }); + axios.post.mockResolvedValue({ + data: { access_token: 'token-debug-proxy', expires_in: 120 }, + }); + + const token = await oauthInterceptor.getAccessToken(); + + expect(token).toBe('token-debug-proxy'); + expect(consoleSpy).toHaveBeenCalledWith( + `[OAuth] Making token request to ${baseConfig.tokenEndpoint} with proxy agent`, + '', + ); + + consoleSpy.mockRestore(); + nowSpy.mockRestore(); + }); + it('bypasses proxy for token endpoint when in noProxy list', async () => { const baseTime = 1700000800000; const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime); diff --git a/__tests__/rest.spec.js b/__tests__/rest.spec.js index b371826b..420c566a 100644 --- a/__tests__/rest.spec.js +++ b/__tests__/rest.spec.js @@ -2,6 +2,7 @@ const nock = require('nock'); const isEqual = require('lodash/isEqual'); const http = require('http'); const RestClient = require('../src/lib/rest'); +const OAuthInterceptor = require('../src/lib/oauth'); const logger = require('../src/lib/logger'); describe('RestClient', () => { @@ -60,6 +61,23 @@ describe('RestClient', () => { expect(spyLogger).toHaveBeenCalledWith(client.axiosInstance); }); + + it('attaches an OAuth interceptor to the axios instance when oauthConfig is provided', () => { + const attachSpy = jest.spyOn(OAuthInterceptor.prototype, 'attach'); + const client = new RestClient({ + ...options, + oauthConfig: { + tokenEndpoint: 'https://auth.example.com/oauth/token', + username: 'user', + password: 'password', + clientId: 'client-id', + }, + }); + + expect(attachSpy).toHaveBeenCalledWith(client.axiosInstance); + + attachSpy.mockRestore(); + }); }); describe('retry configuration', () => { diff --git a/src/helpers.ts b/src/helpers.ts index 45710e4e..8520432a 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,4 +1,6 @@ // Public entry point so consumers can import helpers as // `@reportportal/client-javascript/helpers` instead of the internal `/lib/helpers` path. +import helpers from './lib/helpers'; + export * from './lib/helpers'; -export { default } from './lib/helpers'; +export default helpers; diff --git a/src/lib/commons/config.ts b/src/lib/commons/config.ts index 76c68af9..a97a5fe8 100644 --- a/src/lib/commons/config.ts +++ b/src/lib/commons/config.ts @@ -1,10 +1,6 @@ import { ReportPortalRequiredOptionError, ReportPortalValidationError } from './errors'; import { OUTPUT_TYPES } from '../constants/outputs'; -import type { - NormalizedClientConfig, - OAuthConfig, - ReportPortalConfig, -} from '../models/config'; +import type { NormalizedClientConfig, OAuthConfig, ReportPortalConfig } from '../models/config'; const getOption = ( options: T, @@ -27,7 +23,10 @@ export const getRequiredOption = (options: T, optionName: return options[optionName]; }; -export const getApiKey = ({ apiKey, token }: Pick): string => { +export const getApiKey = ({ + apiKey, + token, +}: Pick): string => { let calculatedApiKey = apiKey; if (!calculatedApiKey) { calculatedApiKey = token; diff --git a/src/lib/models/config.ts b/src/lib/models/config.ts index 4a8776c3..c2c1af83 100644 --- a/src/lib/models/config.ts +++ b/src/lib/models/config.ts @@ -44,6 +44,17 @@ export interface RestClientConfig extends Omit { debug?: boolean; } +/** + * Options accepted by the `RestClient` constructor. + */ +export interface RestClientOptions { + baseURL: string; + headers?: Record; + restClientConfig?: RestClientConfig; + oauthConfig?: OAuthConfig | null; + debug?: boolean; +} + export type LaunchUuidPrintOutput = 'STDOUT' | 'STDERR' | 'ENVIRONMENT' | 'FILE'; /** diff --git a/src/lib/rest.ts b/src/lib/rest.ts index 916e05bb..7524f026 100644 --- a/src/lib/rest.ts +++ b/src/lib/rest.ts @@ -5,7 +5,7 @@ import https from 'https'; import * as logger from './logger'; import OAuthInterceptor from './oauth'; import { getProxyAgentForUrl } from './proxyHelper'; -import type { OAuthConfig, RestClientConfig } from './models/config'; +import type { OAuthConfig, RestClientConfig, RestClientOptions } from './models/config'; const DEFAULT_MAX_CONNECTION_TIME_MS = 30000; const DEFAULT_RETRY_ATTEMPTS = 6; @@ -15,7 +15,8 @@ const RETRY_MAX_DELAY_MS = 5000; interface NetworkError { message?: string; code?: string; - cause?: { code?: string }; + // A nested cause may be a full Error (e.g. a Node system error carrying `code`). + cause?: { code?: string; message?: string }; } const isTimeoutError = (error: NetworkError | null | undefined): boolean => { @@ -50,14 +51,6 @@ const DEFAULT_RETRY_CONFIG: IAxiosRetryConfig = { }; const SKIPPED_REST_CONFIG_KEYS = ['agent', 'retry', 'proxy', 'noProxy']; -interface RestClientOptions { - baseURL: string; - headers?: Record; - restClientConfig?: RestClientConfig; - oauthConfig?: OAuthConfig | null; - debug?: boolean; -} - class RestClient { private baseURL: string;