diff --git a/.eslintrc b/.eslintrc index 67846ac3..138ca6df 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, @@ -36,6 +39,12 @@ "@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, + "no-console": 0, + "no-unused-vars": 0, + "@typescript-eslint/no-unused-vars": 2, + "@typescript-eslint/no-explicit-any": 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..3c905d21 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', () => { @@ -46,7 +46,7 @@ describe('Helpers', () => { }); }); - describe('getSystemAttribute', () => { + describe('getSystemAttributes', () => { it('should return correct system attributes', () => { jest.spyOn(os, 'type').mockReturnValue('osType'); jest.spyOn(os, 'arch').mockReturnValue('osArchitecture'); @@ -75,7 +75,7 @@ describe('Helpers', () => { }, ]; - const attr = helpers.getSystemAttribute(); + const attr = helpers.getSystemAttributes(); expect(attr).toEqual(expectedAttr); }); diff --git a/__tests__/oauth.spec.js b/__tests__/oauth.spec.js index 156b7ffc..bd27ff2a 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' && error.isAxiosError === true), })); describe('OAuthInterceptor', () => { @@ -134,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' }, @@ -150,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({ @@ -231,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' }, @@ -286,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' }, @@ -347,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__/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 afb54795..67ed9f12 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(() => { @@ -338,7 +338,7 @@ describe('ReportPortal javascript client', () => { const myPromise = Promise.resolve({ id: 'testidlaunch' }); const time = 12345734; jest.spyOn(client.restClient, 'create').mockReturnValue(myPromise); - jest.spyOn(helpers, 'getSystemAttribute').mockReturnValue(fakeSystemAttr); + jest.spyOn(helpers, 'getSystemAttributes').mockReturnValue(fakeSystemAttr); client.startLaunch({ startTime: time, @@ -367,7 +367,7 @@ describe('ReportPortal javascript client', () => { const myPromise = Promise.resolve({ id: 'testidlaunch' }); const time = 12345734; jest.spyOn(client.restClient, 'create').mockReturnValue(myPromise); - jest.spyOn(helpers, 'getSystemAttribute').mockReturnValue(fakeSystemAttr); + jest.spyOn(helpers, 'getSystemAttributes').mockReturnValue(fakeSystemAttr); client.startLaunch({ startTime: time, diff --git a/__tests__/rest.spec.js b/__tests__/rest.spec.js index 7f6bb063..420c566a 100644 --- a/__tests__/rest.spec.js +++ b/__tests__/rest.spec.js @@ -1,8 +1,9 @@ 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 OAuthInterceptor = require('../src/lib/oauth'); +const logger = require('../src/lib/logger'); describe('RestClient', () => { const options = { @@ -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/__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 75dd7e1f..00000000 --- a/index.d.ts +++ /dev/null @@ -1,461 +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; - /** - * Set to true when this item is a retry of a previous attempt. - * The client will automatically populate `retry_of` with the UUID of the - * previous attempt. - */ - retry?: boolean; - /** - * UUID of the immediately-preceding retry attempt. - * Populated automatically by the client when `retry: true` and a previous - * attempt exists. Do not set manually. - */ - retry_of?: string; - codeRef?: string; - parameters?: Array<{ key: string; value: string }>; - testCaseId?: string; - } - - /** - * 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/proxyHelper.js b/lib/proxyHelper.js deleted file mode 100644 index f6e25d42..00000000 --- a/lib/proxyHelper.js +++ /dev/null @@ -1,232 +0,0 @@ -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'); - -/** - * 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] - */ -function sanitizeUrlForLogging(urlString) { - try { - const urlObj = new URL(urlString); - if (urlObj.username || urlObj.password) { - // Replace credentials with [REDACTED] - urlObj.username = '[REDACTED]'; - urlObj.password = ''; - return urlObj.toString(); - } - return urlString; - } catch (error) { - // If URL parsing fails, return as-is (likely not a URL) - return 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 - */ -function shouldBypassProxy(url, noProxy) { - 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; - }); - } catch (error) { - // If URL parsing fails, don't bypass proxy - return false; - } -} - -/** - * 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 - */ -function getProxyConfig(url, proxyConfig = {}) { - 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) { - console.log('[ProxyHelper] getProxyConfig called:'); - console.log(' URL:', url); - console.log(' Hostname:', urlObj.hostname); - console.log(' noProxy from config:', noProxyFromConfig); - console.log(' noProxy from env:', noProxyFromEnv); - console.log(' Final noProxy:', noProxy); - } - - // Check if URL should bypass proxy - const shouldBypass = shouldBypassProxy(url, noProxy); - if (proxyConfig.debug) { - console.log(' Should bypass proxy:', shouldBypass); - } - - if (shouldBypass) { - 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) { - let proxyUrl = `${proxyProtocol || 'http'}://${host}:${port}`; - if (auth) { - const { username, password } = auth; - proxyUrl = `${proxyProtocol || 'http'}://${username}:${password}@${host}:${port}`; - } - return { proxyUrl }; - } - } - - // 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 }; - } - - return null; -} - -// Cache for proxy agents to enable connection reuse -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) { - 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 - */ -function createProxyAgents(url, restClientConfig = {}) { - 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, - maxSockets: 50, - maxFreeSockets: 10, - }; - - if (!proxyConfig) { - if (restClientConfig.debug) { - console.log('[ProxyHelper] No proxy for URL (bypassed or not configured):', url); - 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); - } - - // 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 - ? { httpsAgent: new https.Agent(agentOptions) } - : { httpAgent: new http.Agent(agentOptions) }; - agentCache.set(cacheKey, agents); - return agents; - } - - const { proxyUrl } = proxyConfig; - - const cacheKey = getAgentCacheKey(proxyUrl, isHttps); - if (agentCache.has(cacheKey)) { - if (restClientConfig.debug) { - console.log('[ProxyHelper] Reusing cached proxy agent:', sanitizeUrlForLogging(proxyUrl)); - } - return agentCache.get(cacheKey); - } - - if (restClientConfig.debug) { - console.log('[ProxyHelper] Creating proxy agent:'); - console.log(' URL:', url); - console.log(' Proxy URL:', sanitizeUrlForLogging(proxyUrl)); - } - - const agents = isHttps - ? { httpsAgent: new HttpsProxyAgent(proxyUrl, agentOptions) } - : { httpAgent: new HttpProxyAgent(proxyUrl, agentOptions) }; - - agentCache.set(cacheKey, agents); - return agents; -} - -/** - * 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 - */ -function getProxyAgentForUrl(url, restClientConfig = {}) { - return createProxyAgents(url, restClientConfig); -} - -module.exports = { - shouldBypassProxy, - getProxyConfig, - createProxyAgents, - getProxyAgentForUrl, -}; 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-lock.json b/package-lock.json index 83405285..865fd85e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2195,9 +2195,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3615,9 +3615,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { @@ -3985,15 +3985,15 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { diff --git a/package.json b/package.json index 1f665ca3..502f9fc2 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,47 @@ "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", + "exports": { + ".": { + "types": "./lib/report-portal-client.d.ts", + "import": "./lib/report-portal-client.js", + "require": "./lib/report-portal-client.js" + }, + "./constants": { + "types": "./constants.d.ts", + "import": "./constants.js", + "require": "./constants.js" + }, + "./models": { + "types": "./models.d.ts", + "import": "./models.js", + "require": "./models.js" + }, + "./helpers": { + "types": "./helpers.d.ts", + "import": "./helpers.js", + "require": "./helpers.js" + }, + "./publicReportingAPI": { + "types": "./lib/publicReportingAPI.d.ts", + "import": "./lib/publicReportingAPI.js", + "require": "./lib/publicReportingAPI.js" + }, + "./package.json": "./package.json" + }, "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..8520432a --- /dev/null +++ b/src/helpers.ts @@ -0,0 +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 helpers; diff --git a/lib/commons/config.js b/src/lib/commons/config.ts similarity index 69% rename from lib/commons/config.js rename to src/lib/commons/config.ts index 0700b926..3f3bc2f0 100644 --- a/lib/commons/config.js +++ b/src/lib/commons/config.ts @@ -1,23 +1,32 @@ -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; @@ -31,8 +40,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 +72,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 +93,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 { @@ -124,10 +143,3 @@ const getClientConfig = (options) => { 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 64% rename from lib/constants/outputs.js rename to src/lib/constants/outputs.ts index b5818e37..56eab5ed 100644 --- a/lib/constants/outputs.js +++ b/src/lib/constants/outputs.ts @@ -1,13 +1,11 @@ -const helpers = require('../helpers'); +import * as helpers from '../helpers'; -const OUTPUT_TYPES = { - // eslint-disable-next-line no-console +export type OutputHandler = (launchUuid: string) => void; + +export const OUTPUT_TYPES: Record = { STDOUT: (launchUuid) => console.log(`Report Portal Launch UUID: ${launchUuid}`), - // eslint-disable-next-line no-console STDERR: (launchUuid) => console.error(`Report Portal Launch UUID: ${launchUuid}`), // eslint-disable-next-line no-return-assign 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 51% rename from lib/helpers.js rename to src/lib/helpers.ts index 4a7dcac9..96036906 100644 --- a/lib/helpers.js +++ b/src/lib/helpers.ts @@ -1,39 +1,47 @@ -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'; +import { Attribute } from './models/common'; const MIN = 3; const MAX = 256; -const PJSON_VERSION = pjson.version; -const PJSON_NAME = pjson.name; -const getUUIDFromFileName = (filename) => filename.match(/rplaunch-(.*)\.tmp/)[1]; +const getUUIDFromFileName = (filename: string): string => { + const match = filename.match(/rplaunch-(.*)\.tmp/); + return match ? match[1] : ''; +}; -const formatName = (name) => { +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 +50,12 @@ const saveLaunchIdToFile = (launchId) => { }); }; -const getSystemAttribute = () => { +export const getSystemAttributes = (): Attribute[] => { const osType = os.type(); const osArchitecture = os.arch(); const RAMSize = os.totalmem(); const nodeVersion = process.version; - const systemAttr = [ + const systemAttr: Attribute[] = [ { key: 'client', value: `${PJSON_NAME}|${PJSON_VERSION}`, @@ -60,7 +68,7 @@ const getSystemAttribute = () => { }, { key: 'RAMSize', - value: RAMSize, + value: `${RAMSize}`, system: true, }, { @@ -73,16 +81,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 +101,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,13 +110,15 @@ 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, readLaunchesFromFile, saveLaunchIdToFile, - getSystemAttribute, + getSystemAttributes, generateTestCaseId, saveLaunchUuidToFile, }; diff --git a/lib/logger.js b/src/lib/logger.ts similarity index 59% rename from lib/logger.js rename to src/lib/logger.ts index 9eb54e69..9cc6a45f 100644 --- a/lib/logger.js +++ b/src/lib/logger.ts @@ -1,5 +1,9 @@ -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(); @@ -16,26 +20,25 @@ const addLogger = (axiosInstance) => { 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; 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..c2c1af83 --- /dev/null +++ b/src/lib/models/config.ts @@ -0,0 +1,104 @@ +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; +} + +/** + * 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'; + +/** + * 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..5e31e20c --- /dev/null +++ b/src/lib/models/index.ts @@ -0,0 +1,5 @@ +export * from './common'; +export * from './config'; +export * from './reporting'; +export * from './requests'; +export * from './responses'; diff --git a/src/lib/models/reporting.ts b/src/lib/models/reporting.ts new file mode 100644 index 00000000..e348e91c --- /dev/null +++ b/src/lib/models/reporting.ts @@ -0,0 +1,12 @@ +import type { Attribute } from './common'; +import type { LogOptions } from './requests'; + +export interface PublicReportingAPIInterface { + setDescription(text: string, suiteName?: string): void; + addAttributes(attributes: Attribute[], suiteName?: string): void; + addLog(log: LogOptions, suiteName?: string): void; + addLaunchLog(log: LogOptions): void; + setTestCaseId(testCaseId: string, suiteName?: string): void; + setLaunchStatus(status: string): void; + setStatus(status: string, suiteName?: string): void; +} diff --git a/src/lib/models/requests.ts b/src/lib/models/requests.ts new file mode 100644 index 00000000..d99984c0 --- /dev/null +++ b/src/lib/models/requests.ts @@ -0,0 +1,78 @@ +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; + retry_of?: string; + 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 70% rename from lib/oauth.js rename to src/lib/oauth.ts index 048a05da..11c38a2e 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,16 @@ class OAuthInterceptor { this.tokenRenewPromise = null; } - logDebug(message, data = '') { + logDebug(message: string, data: unknown = ''): void { if (this.debug) { 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 +106,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, @@ -97,12 +124,11 @@ 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, + ); console.error(`[OAuth] ${errorMessage}`); throw new Error(errorMessage); @@ -110,11 +136,7 @@ class OAuthInterceptor { } // 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); console.error(`[OAuth] ${errorMessage}`); throw new Error(errorMessage); @@ -122,19 +144,16 @@ class OAuthInterceptor { } /** - * 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 +188,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 +216,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 +231,11 @@ 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) { + console.error( + '[OAuth] Failed to obtain access token, request may fail:', + error instanceof Error ? error.message : String(error), + ); return config; } }, @@ -223,4 +244,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/src/lib/proxyHelper.ts b/src/lib/proxyHelper.ts new file mode 100644 index 00000000..233f13c0 --- /dev/null +++ b/src/lib/proxyHelper.ts @@ -0,0 +1,209 @@ +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. + */ +function sanitizeUrlForLogging(urlString: string): string { + try { + const urlObj = new URL(urlString); + if (urlObj.username || urlObj.password) { + urlObj.username = '[REDACTED]'; + urlObj.password = ''; + return urlObj.toString(); + } + return urlString; + } catch (error) { + // If URL parsing fails, return as-is (likely not a URL) + return urlString; + } +} + +/** + * Checks if a URL should bypass proxy based on NO_PROXY patterns. + */ +export function shouldBypassProxy(url: string, noProxy?: string): boolean { + if (!noProxy) return false; + + try { + const urlObj = new URL(url); + const hostname = urlObj.hostname.toLowerCase(); + + const patterns = noProxy + .split(',') + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); + + return patterns.some((pattern) => { + if (pattern === '*') return true; + + if (pattern.startsWith('.')) { + const cleanPattern = pattern.slice(1); + return hostname.endsWith(`.${cleanPattern}`); + } + + if (hostname === pattern) return true; + + if (hostname.endsWith(`.${pattern}`)) return true; + + return false; + }); + } catch (error) { + // If URL parsing fails, don't bypass proxy + return false; + } +} + +/** + * Gets proxy configuration for a given URL, checking both environment variables and explicit config. + */ +export function getProxyConfig( + url: string, + proxyConfig: RestClientConfig = {}, +): { proxyUrl: string } | null { + const urlObj = new URL(url); + + const noProxyFromConfig = proxyConfig.noProxy; + const noProxyFromEnv = process.env.NO_PROXY || process.env.no_proxy || ''; + const noProxy = noProxyFromConfig || noProxyFromEnv; + + if (proxyConfig.debug) { + console.log( + `[ProxyHelper] getProxyConfig called:\n` + + ` URL: ${url}\n` + + ` Hostname: ${urlObj.hostname}\n` + + ` noProxy from config: ${noProxyFromConfig}\n` + + ` noProxy from env: ${noProxyFromEnv}\n` + + ` Final noProxy: ${noProxy}`, + ); + } + + const shouldBypass = shouldBypassProxy(url, noProxy); + if (proxyConfig.debug) { + console.log(` Should bypass proxy: ${shouldBypass}`); + } + + if (shouldBypass) { + return null; + } + + if (proxyConfig.proxy === false) { + return null; + } + + if (proxyConfig.proxy && typeof proxyConfig.proxy === 'object') { + const { protocol: proxyProtocol, host, port, auth } = proxyConfig.proxy; + if (host && port) { + let proxyUrl = `${proxyProtocol || 'http'}://${host}:${port}`; + if (auth) { + const { username, password } = auth; + proxyUrl = `${proxyProtocol || 'http'}://${username}:${password}@${host}:${port}`; + } + return { proxyUrl }; + } + } + + if (typeof proxyConfig.proxy === 'string') { + return { proxyUrl: proxyConfig.proxy }; + } + + const proxyUrlFromEnv = getProxyForUrl(url); + if (proxyUrlFromEnv) { + return { proxyUrl: proxyUrlFromEnv }; + } + + return null; +} + +// Cache for proxy agents to enable connection reuse +const agentCache = new Map(); + +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. + */ +export function createProxyAgents( + url: string, + restClientConfig: RestClientConfig = {}, +): ProxyAgents { + const urlObj = new URL(url); + const isHttps = urlObj.protocol === 'https:'; + const proxyConfig = getProxyConfig(url, restClientConfig); + + const agentOptions = { + keepAlive: true, + keepAliveMsecs: 3000, + maxSockets: 50, + maxFreeSockets: 10, + }; + + if (!proxyConfig) { + if (restClientConfig.debug) { + console.log( + `[ProxyHelper] No proxy for URL (bypassed or not configured): ${url}\n` + + ` Using default agent to prevent axios from using env proxy`, + ); + } + + const cacheKey = getAgentCacheKey('no-proxy', isHttps); + const cached = agentCache.get(cacheKey); + if (cached) { + return cached; + } + + const agents: ProxyAgents = isHttps + ? { httpsAgent: new https.Agent(agentOptions) } + : { httpAgent: new http.Agent(agentOptions) }; + agentCache.set(cacheKey, agents); + return agents; + } + + const { proxyUrl } = proxyConfig; + + const cacheKey = getAgentCacheKey(proxyUrl, isHttps); + const cached = agentCache.get(cacheKey); + if (cached) { + if (restClientConfig.debug) { + console.log('[ProxyHelper] Reusing cached proxy agent:', sanitizeUrlForLogging(proxyUrl)); + } + return cached; + } + + if (restClientConfig.debug) { + console.log( + `[ProxyHelper] Creating proxy agent:\n` + + ` URL: ${url}\n` + + ` Proxy URL: ${sanitizeUrlForLogging(proxyUrl)}`, + ); + } + + const agents: ProxyAgents = isHttps + ? { httpsAgent: new HttpsProxyAgent(proxyUrl, agentOptions) } + : { httpAgent: new HttpProxyAgent(proxyUrl, agentOptions) }; + + agentCache.set(cacheKey, agents); + return agents; +} + +/** + * Gets proxy agent for a specific request URL. This is the main function used in axios requests. + */ +export function getProxyAgentForUrl( + url: string, + restClientConfig: RestClientConfig = {}, +): ProxyAgents { + return createProxyAgents(url, restClientConfig); +} diff --git a/src/lib/publicReportingAPI.ts b/src/lib/publicReportingAPI.ts new file mode 100644 index 00000000..1d9a20af --- /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, suiteName?: string): void { + emit(EVENTS.SET_DESCRIPTION, { text, suite: suiteName }); + } + + /** + * Emit add attributes event. + */ + static addAttributes(attributes: Attribute[], suiteName?: string): void { + emit(EVENTS.ADD_ATTRIBUTES, { attributes, suite: suiteName }); + } + + /** + * Emit send log to test item event. + */ + static addLog(log: LogOptions, suiteName?: string): void { + emit(EVENTS.ADD_LOG, { log, suite: suiteName }); + } + + /** + * 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, suiteName?: string): void { + emit(EVENTS.SET_TEST_CASE_ID, { testCaseId, suite: suiteName }); + } + + /** + * 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, suiteName?: string): void { + emit(EVENTS.SET_STATUS, { status, suite: suiteName }); + } +} + +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 562d5a46..4cc17aa0 100644 --- a/lib/report-portal-client.js +++ b/src/lib/report-portal-client.ts @@ -1,35 +1,84 @@ -/* 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'); +import { randomUUID } from 'crypto'; +import { URLSearchParams } from 'url'; +import * as helpers 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, 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 +89,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 +116,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 +143,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?: unknown) => void; + let rejectFinish!: (reason?: unknown) => void; + const obj: ItemObj = { promiseStart: new Promise(startPromiseFunc), realId: '', children: [], @@ -122,40 +166,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 +211,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) { @@ -219,7 +222,7 @@ class RPClient { this.map[tempId].realId = launchDataRQ.id; this.launchUuid = launchDataRQ.id; } else { - const systemAttr = helpers.getSystemAttribute(); + const systemAttr = helpers.getSystemAttributes(); if (this.config.skippedIsNotIssue === true) { const skippedIsNotIssueAttribute = { key: 'skippedIssue', @@ -241,7 +244,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 +276,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 +295,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 +328,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 +348,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); 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 +396,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 +419,8 @@ class RPClient { new Error(`Launch with tempId "${launchTempId}" not found`), ); } - let resolvePromise; - let rejectPromise; + let resolvePromise!: (value?: unknown) => void; + let rejectPromise!: (reason?: unknown) => void; const promise = new Promise((resolve, reject) => { resolvePromise = resolve; rejectPromise = reject; @@ -486,33 +453,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 +479,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 }), @@ -569,12 +516,13 @@ class RPClient { const realParentId = this.map[parentTempId].realId; url += `${realParentId}`; } - if (executionItemPromise && prevResponse?.id) { - testItemData.retry_of = prevResponse.id; + const prevId = (prevResponse as StartTestItemResponse | undefined)?.id; + if (executionItemPromise && prevId) { + testItemData.retry_of = prevId; } 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; @@ -603,30 +551,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( @@ -635,16 +562,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' }; } @@ -689,7 +613,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( @@ -727,7 +651,7 @@ class RPClient { }; } - sendLog(itemTempId, saveLogRQ, fileObj) { + sendLog(itemTempId: string, saveLogRQ: LogOptions, fileObj?: Attachment): ClientResponse { const saveLogData = { time: this.helpers.now(), message: '', @@ -743,17 +667,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( @@ -762,7 +677,7 @@ class RPClient { ); } - const requestPromise = (itemUuid, launchUuid) => { + const requestPromise: RequestPromiseFunc = (itemUuid, launchUuid) => { const url = 'log'; const isItemUuid = itemUuid !== launchUuid; return this.restClient.create( @@ -774,27 +689,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( @@ -803,7 +700,7 @@ class RPClient { ); } - const requestPromise = (itemUuid, launchUuid) => { + const requestPromise: RequestPromiseFunc = (itemUuid, launchUuid) => { const isItemUuid = itemUuid !== launchUuid; return this.getRequestLogWithFile( @@ -815,10 +712,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), { @@ -836,12 +733,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 = [ @@ -873,13 +765,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('/'); @@ -905,4 +801,4 @@ class RPClient { } } -module.exports = RPClient; +export = RPClient; diff --git a/lib/rest.js b/src/lib/rest.ts similarity index 54% rename from lib/rest.js rename to src/lib/rest.ts index 5e3b002d..fa2d9693 100644 --- a/lib/rest.js +++ b/src/lib/rest.ts @@ -1,17 +1,25 @@ -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, RestClientOptions } 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; + // 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 => { if (!error) return false; const message = error.message ? error.message.toLowerCase() : ''; @@ -24,13 +32,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; }, @@ -41,7 +52,19 @@ const DEFAULT_RETRY_CONFIG = { const SKIPPED_REST_CONFIG_KEYS = ['agent', 'retry', 'proxy', 'noProxy']; 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 +74,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 +87,11 @@ class RestClient { restClientConfig: this.restClientConfig, }); oauthInterceptor.attach(this.axiosInstance); - } catch (error) { - console.error('[RestClient] Failed to initialize OAuth interceptor:', error.message); + } catch (error: unknown) { + console.error( + '[RestClient] Failed to initialize OAuth interceptor:', + error instanceof Error ? error.message : String(error), + ); } } @@ -76,15 +102,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 +134,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 +156,38 @@ 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}`); + console.log( + `[retry #${retryCount}] ${requestConfig.method?.toUpperCase()} ${requestConfig.url} -> ${ + error.code || error.message + }`, + ); } }; @@ -174,14 +210,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 +227,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 +251,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 56% rename from statistics/statistics.js rename to src/statistics/statistics.ts index 67c01333..61eb11ab 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,10 @@ 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) { + 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__"] }