From 3f2abeb351d16c22a546bfa4c5348b7f70a683f7 Mon Sep 17 00:00:00 2001 From: "reportportal.io" Date: Mon, 6 Jul 2026 19:50:17 +0000 Subject: [PATCH 1/9] 5.5.12 -> 5.5.13-SNAPSHOT --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index f1918b54..dc8bfb4f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.5.12 +5.5.13-SNAPSHOT From ce3a14d69799a29e465ea14c4d4daac13c22b40b Mon Sep 17 00:00:00 2001 From: maria-hambardzumian <164881199+maria-hambardzumian@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:20:06 +0400 Subject: [PATCH 2/9] Update CHANGELOG for version 5.5.12 changes --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b57a0da8..fc0a5e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,5 @@ ## [5.5.12] - 2026-07-06 - -## [Unreleased] ### Changed - Replaced the `uniqid` and `uuid` dependencies with the built-in `crypto.randomUUID()` for internal id generation, removing both external packages ([#210](https://github.com/reportportal/agent-js-playwright/issues/210)). - Bumped the minimum supported Node.js version to 14.17.0 (required by `crypto.randomUUID`). From bf8df9df31fa01f18bb3ee8067647f3de3a9816a Mon Sep 17 00:00:00 2001 From: Ilya_Hancharyk Date: Thu, 9 Jul 2026 16:49:22 +0200 Subject: [PATCH 3/9] Add CI step for deps vulnerabilities detection --- .github/workflows/CI-pipeline.yml | 3 +++ package-lock.json | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/CI-pipeline.yml b/.github/workflows/CI-pipeline.yml index dbbe32b1..402492dc 100644 --- a/.github/workflows/CI-pipeline.yml +++ b/.github/workflows/CI-pipeline.yml @@ -47,6 +47,9 @@ jobs: - name: Install dependencies run: npm install + - name: Audit for vulnerable prod dependencies + run: npm audit --audit-level=high --omit=dev + - name: Build the source code run: npm run build diff --git a/package-lock.json b/package-lock.json index c11aa894..83405285 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "@reportportal/client-javascript", - "version": "5.5.11", + "version": "5.5.12", "license": "Apache-2.0", "dependencies": { "axios": "^1.15.2", From 97b9c82ba5021d16ba1e0b49829e1edf9037bea0 Mon Sep 17 00:00:00 2001 From: maria-hambardzumian <164881199+maria-hambardzumian@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:02:28 +0400 Subject: [PATCH 4/9] EPMRPP-117079 || Introduce a code knowledge graph for search/read operations (#268) * EPMRPP-117079 || Introduce a code knowledge graph for search/read operations * EPMRPP-117079 || Add codegraph index generation command (npm run codegraph) * EPMRPP-117079 || Move codegraph readme section into DEV_GUIDE.md * EPMRPP-117079 || Remove code knowledge graph section from README --------- Co-authored-by: maria-hambardzumian --- .codegraph/.gitignore | 5 +++++ DEV_GUIDE.md | 20 ++++++++++++++++++++ package.json | 1 + scripts/codegraph.sh | 30 ++++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 .codegraph/.gitignore create mode 100644 DEV_GUIDE.md create mode 100755 scripts/codegraph.sh diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 00000000..d20c0fe4 --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/DEV_GUIDE.md b/DEV_GUIDE.md new file mode 100644 index 00000000..7e47ed35 --- /dev/null +++ b/DEV_GUIDE.md @@ -0,0 +1,20 @@ +# Dev Guide + +Internal notes for contributors. This content is intentionally kept out of +README.md, which is published to package registries. + +## Code knowledge graph + +This repo carries a local **code knowledge graph** ([colbymchenry/codegraph](https://github.com/colbymchenry/codegraph)) +that the ReportPortal AI agents (and your own tooling) use to resolve symbols and +references without scanning raw files. + +```bash +npm run codegraph # build it the first time, fast incremental sync after +npm run codegraph -- --force # rebuild from scratch +``` + +The graph lives in `.codegraph/codegraph.db` — it is **gitignored and local to your +machine** (only `.codegraph/.gitignore` is committed). It is a pure derivative of the +source, so regenerate it any time. The engine is fetched on demand via `npx`, so there +is no added project dependency. diff --git a/package.json b/package.json index ae86790b..1f665ca3 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "description": "ReportPortal client for Node.js", "author": "ReportPortal.io", "scripts": { + "codegraph": "bash scripts/codegraph.sh", "build": "npm run clean && tsc", "clean": "rimraf ./build", "lint": "eslint ./statistics/**/* ./lib/**/*", diff --git a/scripts/codegraph.sh b/scripts/codegraph.sh new file mode 100755 index 00000000..61d9b9ba --- /dev/null +++ b/scripts/codegraph.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Build or update this repo's local code knowledge graph (.codegraph/codegraph.db). +# +# Idempotent — safe to run any time: +# (no args) first run -> init (full index); after that -> sync (fast, incremental) +# --force rebuild the graph from scratch +# +# The DB is gitignored and local to your machine; only .codegraph/.gitignore is +# committed. Engine: https://github.com/colbymchenry/codegraph (run via npx, no +# global or project install required). +set -u +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENGINE="@colbymchenry/codegraph@^1.1.0" +export CODEGRAPH_TELEMETRY="${CODEGRAPH_TELEMETRY:-0}" + +case "${1:-}" in + --force) + echo "[codegraph] rebuild: $DIR" + npx -y "$ENGINE" index "$DIR" + ;; + *) + if [ -f "$DIR/.codegraph/codegraph.db" ]; then + echo "[codegraph] update (sync): $DIR" + npx -y "$ENGINE" sync "$DIR" + else + echo "[codegraph] init: $DIR" + npx -y "$ENGINE" init "$DIR" + fi + ;; +esac From 8789875133542850377ac54cd0bd449973cd5def Mon Sep 17 00:00:00 2001 From: maria-hambardzumian <164881199+maria-hambardzumian@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:21:57 +0400 Subject: [PATCH 5/9] EPMRPP-113709 || Introduce the retry_of property for JS agents (#264) * [EPMRPP-113709] [AGENT][Perf] Introduce the retry_of property for JS agents (ai) * TS. Enhance start test items types --------- Co-authored-by: reportportal-agents-ai Co-authored-by: Ilya_Hancharyk --- CHANGELOG.md | 5 ++ __tests__/report-portal-client.spec.js | 96 ++++++++++++++++++++++++++ index.d.ts | 20 +++++- lib/report-portal-client.js | 5 +- 4 files changed, 124 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc0a5e81..1c6d94fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +### Added +- `retry_of` property is now automatically included in the `startTestItem` + request payload when `retry: true` and a previous attempt exists in the + retry chain. This allows the ReportPortal backend to link retry chains + efficiently, improving query performance for large test runs. ## [5.5.12] - 2026-07-06 ### Changed diff --git a/__tests__/report-portal-client.spec.js b/__tests__/report-portal-client.spec.js index 47542799..afb54795 100644 --- a/__tests__/report-portal-client.spec.js +++ b/__tests__/report-portal-client.spec.js @@ -879,6 +879,102 @@ describe('ReportPortal javascript client', () => { expect(client.itemRetriesChainMap.get).toHaveBeenCalledWith('id1__name__'); }); + + it('should include retry_of with the previous item UUID when retry is true', async () => { + const client = new RPClient({ + apiKey: 'test', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }); + const prevRealId = 'prev-item-uuid-1234'; + const prevPromise = Promise.resolve({ id: prevRealId }); + + client.map = { + launchId: { + children: [], + finishSend: false, + promiseStart: Promise.resolve(), + }, + }; + + const itemKey = client.calculateItemRetriesChainMapKey( + 'launchId', undefined, 'My test', undefined, + ); + client.itemRetriesChainMap.set(itemKey, prevPromise); + + jest.spyOn(client.restClient, 'create').mockResolvedValue({ id: 'new-item-uuid' }); + jest.spyOn(client, 'getUniqId').mockReturnValue('newTempId'); + + await client.startTestItem( + { name: 'My test', type: 'STEP', retry: true }, + 'launchId', + ).promise; + + expect(client.restClient.create).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ retry_of: prevRealId }), + ); + }); + + it('should not include retry_of when retry is true but no previous entry exists', async () => { + const client = new RPClient({ + apiKey: 'test', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }); + client.map = { + launchId: { + children: [], + finishSend: false, + promiseStart: Promise.resolve(), + }, + }; + jest.spyOn(client.restClient, 'create').mockResolvedValue({ id: 'new-item-uuid' }); + jest.spyOn(client, 'getUniqId').mockReturnValue('newTempId'); + + await client.startTestItem( + { name: 'My test', type: 'STEP', retry: true }, + 'launchId', + ).promise; + + expect(client.restClient.create).toHaveBeenCalledWith( + expect.any(String), + expect.not.objectContaining({ retry_of: expect.anything() }), + ); + }); + + it('should not include retry_of when retry is false', async () => { + const client = new RPClient({ + apiKey: 'test', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }); + const prevPromise = Promise.resolve({ id: 'prev-item-uuid-1234' }); + client.map = { + launchId: { + children: [], + finishSend: false, + promiseStart: Promise.resolve(), + }, + }; + const itemKey = client.calculateItemRetriesChainMapKey( + 'launchId', undefined, 'My test', undefined, + ); + client.itemRetriesChainMap.set(itemKey, prevPromise); + + jest.spyOn(client.restClient, 'create').mockResolvedValue({ id: 'new-item-uuid' }); + jest.spyOn(client, 'getUniqId').mockReturnValue('newTempId'); + + await client.startTestItem( + { name: 'My test', type: 'STEP', retry: false }, + 'launchId', + ).promise; + + expect(client.restClient.create).toHaveBeenCalledWith( + expect.any(String), + expect.not.objectContaining({ retry_of: expect.anything() }), + ); + }); }); describe('finishTestItem', () => { diff --git a/index.d.ts b/index.d.ts index fedd35d0..75dd7e1f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -200,6 +200,21 @@ declare module '@reportportal/client-javascript' { 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; } /** @@ -268,7 +283,10 @@ declare module '@reportportal/client-javascript' { /** * Initializes a new Report Portal client. */ - constructor(config: ReportPortalConfig, agentInfo?: { name?: string; version?: string; framework_version?: string }); + constructor( + config: ReportPortalConfig, + agentInfo?: { name?: string; version?: string; framework_version?: string }, + ); /** * Starts a new launch. diff --git a/lib/report-portal-client.js b/lib/report-portal-client.js index c8357725..562d5a46 100644 --- a/lib/report-portal-client.js +++ b/lib/report-portal-client.js @@ -562,13 +562,16 @@ class RPClient { const tempId = this.getUniqId(); this.map[tempId] = this.getNewItemObj((resolve, reject) => { (executionItemPromise || parentPromise).then( - () => { + (prevResponse) => { const realLaunchId = this.map[launchTempId].realId; let url = 'item/'; if (parentTempId) { const realParentId = this.map[parentTempId].realId; url += `${realParentId}`; } + if (executionItemPromise && prevResponse?.id) { + testItemData.retry_of = prevResponse.id; + } testItemData.launchUuid = realLaunchId; this.logDebug(`Start test item with tempId ${tempId}`, testItemData); this.restClient.create(url, testItemData).then( From f88818d9cd9c7ddf6d29f34a57db6d86d042e040 Mon Sep 17 00:00:00 2001 From: maria-hambardzumian <164881199+maria-hambardzumian@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:49:22 +0400 Subject: [PATCH 6/9] Merge pull request #271 from reportportal/feature/EPMRPP-89496-migrate-to-typescript EPMRPP-89496 || Migrate client-javascript to TypeScript --- .eslintrc | 13 +- .github/workflows/publish.yml | 2 + __tests__/client-id.spec.js | 2 +- __tests__/config.spec.js | 4 +- __tests__/helpers.spec.js | 6 +- __tests__/oauth.spec.js | 86 +++- __tests__/proxyHelper.spec.js | 4 +- __tests__/publicReportingAPI.spec.js | 4 +- __tests__/report-portal-client.spec.js | 10 +- __tests__/rest.spec.js | 40 +- __tests__/statistics.spec.js | 4 +- index.d.ts | 461 ------------------ jest.config.js | 17 +- lib/constants/events.js | 11 - lib/constants/statuses.js | 12 - lib/proxyHelper.js | 232 --------- lib/publicReportingAPI.js | 92 ---- package-lock.json | 26 +- package.json | 65 ++- .../config.js => src/lib/commons/config.ts | 54 +- .../errors.js => src/lib/commons/errors.ts | 18 +- src/lib/constants/events.ts | 9 + src/lib/constants/index.ts | 6 + src/lib/constants/launchModes.ts | 4 + src/lib/constants/logLevels.ts | 10 + .../lib/constants/outputs.ts | 10 +- src/lib/constants/statuses.ts | 15 + src/lib/constants/testItemTypes.ts | 17 + lib/helpers.js => src/lib/helpers.ts | 59 ++- lib/logger.js => src/lib/logger.ts | 19 +- src/lib/models/common.ts | 40 ++ src/lib/models/config.ts | 104 ++++ src/lib/models/index.ts | 5 + src/lib/models/reporting.ts | 12 + src/lib/models/requests.ts | 98 ++++ src/lib/models/responses.ts | 45 ++ lib/oauth.js => src/lib/oauth.ts | 115 +++-- src/lib/pjson.ts | 29 ++ src/lib/proxyHelper.ts | 209 ++++++++ src/lib/publicReportingAPI.ts | 67 +++ .../lib/report-portal-client.ts | 426 ++++++---------- lib/rest.js => src/lib/rest.ts | 122 +++-- .../statistics/client-id.ts | 23 +- src/statistics/constants.ts | 33 ++ .../statistics/statistics.ts | 39 +- src/types/vendor.d.ts | 10 + statistics/constants.js | 49 -- tsconfig.json | 20 +- 48 files changed, 1398 insertions(+), 1360 deletions(-) delete mode 100644 index.d.ts delete mode 100644 lib/constants/events.js delete mode 100644 lib/constants/statuses.js delete mode 100644 lib/proxyHelper.js delete mode 100644 lib/publicReportingAPI.js rename lib/commons/config.js => src/lib/commons/config.ts (69%) rename lib/commons/errors.js => src/lib/commons/errors.ts (56%) create mode 100644 src/lib/constants/events.ts create mode 100644 src/lib/constants/index.ts create mode 100644 src/lib/constants/launchModes.ts create mode 100644 src/lib/constants/logLevels.ts rename lib/constants/outputs.js => src/lib/constants/outputs.ts (64%) create mode 100644 src/lib/constants/statuses.ts create mode 100644 src/lib/constants/testItemTypes.ts rename lib/helpers.js => src/lib/helpers.ts (51%) rename lib/logger.js => src/lib/logger.ts (59%) create mode 100644 src/lib/models/common.ts create mode 100644 src/lib/models/config.ts create mode 100644 src/lib/models/index.ts create mode 100644 src/lib/models/reporting.ts create mode 100644 src/lib/models/requests.ts create mode 100644 src/lib/models/responses.ts rename lib/oauth.js => src/lib/oauth.ts (70%) create mode 100644 src/lib/pjson.ts create mode 100644 src/lib/proxyHelper.ts create mode 100644 src/lib/publicReportingAPI.ts rename lib/report-portal-client.js => src/lib/report-portal-client.ts (65%) rename lib/rest.js => src/lib/rest.ts (54%) rename statistics/client-id.js => src/statistics/client-id.ts (66%) create mode 100644 src/statistics/constants.ts rename statistics/statistics.js => src/statistics/statistics.ts (56%) create mode 100644 src/types/vendor.d.ts delete mode 100644 statistics/constants.js 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/__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..c588d1fe 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; @@ -16,9 +16,11 @@ describe('proxyHelper', () => { delete process.env.HTTP_PROXY; delete process.env.HTTPS_PROXY; delete process.env.NO_PROXY; + delete process.env.ALL_PROXY; delete process.env.http_proxy; delete process.env.https_proxy; delete process.env.no_proxy; + delete process.env.all_proxy; }); afterAll(() => { 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..ec8e2222 100644 --- a/__tests__/rest.spec.js +++ b/__tests__/rest.spec.js @@ -1,10 +1,29 @@ 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 originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.NO_PROXY; + delete process.env.ALL_PROXY; + delete process.env.http_proxy; + delete process.env.https_proxy; + delete process.env.no_proxy; + delete process.env.all_proxy; + }); + + afterAll(() => { + process.env = originalEnv; + }); + const options = { baseURL: 'http://report-portal-host:8080/api/v1', headers: { @@ -60,6 +79,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..b348d265 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.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "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.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "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.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { @@ -5564,9 +5564,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 1f665ca3..e91fe887 100644 --- a/package.json +++ b/package.json @@ -7,22 +7,69 @@ "codegraph": "bash scripts/codegraph.sh", "build": "npm run clean && tsc", "clean": "rimraf ./build", - "lint": "eslint ./statistics/**/* ./lib/**/*", + "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" + "lib": "./build/lib" }, "files": [ - "/lib", - "/statistics", - "/VERSION", - "index.d.ts" + "/build", + "/VERSION" ], - "main": "./lib/report-portal-client", - "types": "./index.d.ts", + "main": "./build/lib/report-portal-client", + "types": "./build/lib/report-portal-client.d.ts", + "exports": { + ".": { + "types": "./build/lib/report-portal-client.d.ts", + "import": "./build/lib/report-portal-client.js", + "require": "./build/lib/report-portal-client.js" + }, + "./constants": { + "types": "./build/lib/constants/index.d.ts", + "import": "./build/lib/constants/index.js", + "require": "./build/lib/constants/index.js" + }, + "./models": { + "types": "./build/lib/models/index.d.ts", + "import": "./build/lib/models/index.js", + "require": "./build/lib/models/index.js" + }, + "./helpers": { + "types": "./build/lib/helpers.d.ts", + "import": "./build/lib/helpers.js", + "require": "./build/lib/helpers.js" + }, + "./publicReportingAPI": { + "types": "./build/lib/publicReportingAPI.d.ts", + "import": "./build/lib/publicReportingAPI.js", + "require": "./build/lib/publicReportingAPI.js" + }, + "./package.json": "./package.json", + "./lib/constants": { + "types": "./build/lib/constants/index.d.ts", + "import": "./build/lib/constants/index.js", + "require": "./build/lib/constants/index.js" + }, + "./lib/models": { + "types": "./build/lib/models/index.d.ts", + "import": "./build/lib/models/index.js", + "require": "./build/lib/models/index.js" + }, + "./lib/*": { + "types": "./build/lib/*.d.ts", + "import": "./build/lib/*.js", + "require": "./build/lib/*.js" + }, + "./lib/*.js": { + "types": "./build/lib/*.d.ts", + "import": "./build/lib/*.js", + "require": "./build/lib/*.js" + } + }, "engines": { "node": ">=14.17.0" }, 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..5c6b0b10 --- /dev/null +++ b/src/lib/models/reporting.ts @@ -0,0 +1,12 @@ +import type { Attribute } from './common'; +import type { LogOptions } from './requests'; + +export interface ReportingApiInterface { + 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..09413e34 --- /dev/null +++ b/src/lib/models/requests.ts @@ -0,0 +1,98 @@ +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; +} + +/** + * The actual start test item payload sent to the server. + * The client fills in the values omitted by the caller (startTime, launchUuid, retry_of). + */ +export interface StartTestItemRQ extends StartTestItemOptions { + startTime: string | number; + /** + * Set by the client right before the request, once the launch id is known. + */ + launchUuid?: string; +} + +export interface FinishTestItemOptions { + endTime?: string | number; + status?: STATUSES; + issue?: Issue; + attributes?: Attribute[]; + description?: string; + testCaseId?: string; +} + +export interface FinishTestItemRQ extends FinishTestItemOptions { + endTime: string | number; + /** + * Set by the client right before the request, once the launch id is known. + */ + launchUuid?: 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..69b1c282 --- /dev/null +++ b/src/lib/publicReportingAPI.ts @@ -0,0 +1,67 @@ +import { EVENTS } from './constants/events'; +import type { Attribute } from './models/common'; +import type { ReportingApiInterface } from './models/reporting'; +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 }); + } +} + +// checks static methods +const publicReportingAPI = PublicReportingAPI satisfies ReportingApiInterface; + +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..98cd0a9a 100644 --- a/lib/report-portal-client.js +++ b/src/lib/report-portal-client.ts @@ -1,35 +1,86 @@ -/* eslint-disable quotes,no-console,class-methods-use-this */ -const { randomUUID } = require('crypto'); -const { URLSearchParams } = require('url'); -const helpers = require('./helpers'); -const RestClient = require('./rest'); -const { getClientConfig } = require('./commons/config'); -const Statistics = require('../statistics/statistics'); -const { EVENT_NAME } = require('../statistics/constants'); -const { RP_STATUSES } = require('./constants/statuses'); +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, + FinishTestItemRQ, + LogOptions, + MergeLaunchesOptions, + StartLaunchOptions, + StartTestItemOptions, + StartTestItemRQ, + UpdateLaunchOptions, +} from './models/requests'; +import type { + FinishLaunchResponse, + LaunchSearchResponse, + MergeLaunchesResponse, + ServerInfoResponse, + StartLaunchResponse, + StartTestItemResponse, +} from './models/responses'; const MULTIPART_BOUNDARY = Math.floor(Math.random() * 10000000000).toString(); +type PromiseExecutor = ( + resolve: (value?: unknown) => void, + reject: (reason?: unknown) => void, +) => void; + +interface ItemObj { + promiseStart: Promise; + realId: string; + children: string[]; + finishSend: boolean; + promiseFinish: Promise; + resolveFinish: (value?: unknown) => void; + rejectFinish: (reason?: unknown) => void; +} + +type RequestPromiseFunc = (itemUuid: string, launchUuid: string) => Promise; + class RPClient { + private config: NormalizedClientConfig; + + private debug?: boolean; + + private isLaunchMergeRequired: boolean; + + private apiKey: string | null; + + // deprecated + private token: string | null; + + private map: Record; + + private baseURL: string; + + private headers: Record; + + public helpers: typeof helpers; + + private restClient: RestClient; + + private statistics: Statistics; + + private launchUuid: string; + + private itemRetriesChainMap: Map>; + + private itemRetriesChainKeyMapByTempId: Map; + /** * Create a client for RP. - * @param {Object} options - config object. - * options should look like this - * { - * apiKey: "reportportalApiKey", - * endpoint: "http://localhost:8080/api/v1", - * launch: "YOUR LAUNCH NAME", - * project: "PROJECT NAME", - * } - * - * @param {Object} agentParams - agent's info object. - * agentParams should look like this - * { - * name: "AGENT NAME", - * version: "AGENT VERSION", - * } */ - constructor(options, agentParams) { + constructor(options: ReportPortalConfig, agentParams?: AgentParams) { this.config = getClientConfig(options); this.debug = this.config.debug; this.isLaunchMergeRequired = this.config.isLaunchMergeRequired; @@ -40,7 +91,7 @@ class RPClient { this.map = {}; this.baseURL = [this.config.endpoint, this.config.project].join('/'); - const headers = { + const headers: Record = { 'User-Agent': 'NodeJS', 'Content-Type': 'application/json; charset=UTF-8', ...(this.config.headers || {}), @@ -67,27 +118,22 @@ class RPClient { this.itemRetriesChainKeyMapByTempId = new Map(); } - // eslint-disable-next-line valid-jsdoc - /** - * - * @Private - */ - logDebug(msg, dataMsg = '') { + logDebug(msg: unknown, dataMsg: unknown = ''): void { if (this.debug) { console.log(msg, dataMsg); } } - calculateItemRetriesChainMapKey(launchId, parentId, name, itemId = '') { + calculateItemRetriesChainMapKey( + launchId: string, + parentId: string | undefined, + name: string, + itemId = '', + ): string { return `${launchId}__${parentId}__${name}__${itemId}`; } - // eslint-disable-next-line valid-jsdoc - /** - * - * @Private - */ - cleanItemRetriesChain(tempIds) { + cleanItemRetriesChain(tempIds: string[]): void { tempIds.forEach((id) => { const key = this.itemRetriesChainKeyMapByTempId.get(id); @@ -99,21 +145,21 @@ class RPClient { }); } - getUniqId() { + getUniqId(): string { return randomUUID(); } - getRejectAnswer(tempId, error) { + getRejectAnswer(tempId: string, error: Error): ClientResponse { return { tempId, promise: Promise.reject(error), }; } - getNewItemObj(startPromiseFunc) { - let resolveFinish; - let rejectFinish; - const obj = { + getNewItemObj(startPromiseFunc: PromiseExecutor): ItemObj { + let resolveFinish!: (value?: unknown) => void; + let rejectFinish!: (reason?: unknown) => void; + const obj: ItemObj = { promiseStart: new Promise(startPromiseFunc), realId: '', children: [], @@ -122,40 +168,35 @@ class RPClient { resolveFinish = resolve; rejectFinish = reject; }), + resolveFinish, + rejectFinish, }; - obj.resolveFinish = resolveFinish; - obj.rejectFinish = rejectFinish; return obj; } - // eslint-disable-next-line valid-jsdoc - /** - * - * @Private - */ - cleanMap(ids) { + cleanMap(ids: string[]): void { ids.forEach((id) => { delete this.map[id]; }); } - checkConnect() { + checkConnect(): Promise { const url = [this.config.endpoint.replace('/v2', '/v1'), this.config.project, 'launch'] .join('/') .concat('?page.page=1&page.size=1'); return this.restClient.request('GET', url, {}); } - getServerInfoUrl() { + getServerInfoUrl(): string { return this.config.endpoint.replace('/v1', '/info').replace('/v2', '/info'); } - async fetchServerInfo() { + async fetchServerInfo(): Promise { const url = this.getServerInfoUrl(); - return this.restClient.request('GET', url, {}); + return this.restClient.request('GET', url, {}); } - async triggerStatisticsEvent() { + async triggerStatisticsEvent(): Promise { if (process.env.REPORTPORTAL_CLIENT_JS_NO_ANALYTICS) { return; } @@ -172,45 +213,9 @@ class RPClient { } /** - * Start launch and report it. - * @param {Object} launchDataRQ - request object. - * launchDataRQ should look like this - * { - "description": "string" (support markdown), - "mode": "DEFAULT" or "DEBUG", - "name": "string", - "startTime": this.helper.now(), - "attributes": [ - { - "key": "string", - "value": "string" - }, - { - "value": "string" - } - ] - * } - * @Returns an object which contains a tempID and a promise - * - * As system attributes, this method sends the following data (these data are not for public use): - * client name, version; - * agent name, version (if given); - * browser name, version (if given); - * OS type, architecture; - * RAMSize; - * nodeJS version; - * - * This method works in two ways: - * First - If launchDataRQ object doesn't contain ID field, - * it would create a new Launch instance at the Report Portal with it ID. - * Second - If launchDataRQ would contain ID field, - * client would connect to the existing Launch which ID - * has been sent , and would send all data to it. - * Notice that Launch which ID has been sent must be 'IN PROGRESS' state at the Report Portal - * or it would throw an error. - * @Returns {Object} - an object which contains a tempID and a promise - */ - startLaunch(launchDataRQ) { + * Start launch and report it. + */ + startLaunch(launchDataRQ: StartLaunchOptions): ClientResponse { const tempId = this.getUniqId(); if (launchDataRQ.id) { @@ -219,7 +224,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 +246,7 @@ class RPClient { this.map[tempId] = this.getNewItemObj((resolve, reject) => { const url = 'launch'; this.logDebug(`Start launch with tempId ${tempId}`, launchData); - this.restClient.create(url, launchData).then( + this.restClient.create(url, launchData).then( (response) => { this.map[tempId].realId = response.id; this.launchUuid = response.id; @@ -273,16 +278,8 @@ class RPClient { /** * Finish launch. - * @param {string} launchTempId - temp launch id (returned in the query "startLaunch"). - * @param {Object} finishExecutionRQ - finish launch info should include time and status. - * finishExecutionRQ should look like this - * { - * "endTime": this.helper.now(), - * "status": "passed" or one of ‘passed’, ‘failed’, ‘stopped’, ‘skipped’, ‘interrupted’, ‘cancelled’ - * } - * @Returns {Object} - an object which contains a tempID and a promise */ - finishLaunch(launchTempId, finishExecutionRQ) { + finishLaunch(launchTempId: string, finishExecutionRQ: FinishLaunchOptions = {}): ClientResponse { const launchObj = this.map[launchTempId]; if (!launchObj) { return this.getRejectAnswer( @@ -300,7 +297,7 @@ class RPClient { () => { this.logDebug(`Finish launch with tempId ${launchTempId}`, finishExecutionData); const url = ['launch', launchObj.realId, 'finish'].join('/'); - this.restClient.update(url, finishExecutionData).then( + this.restClient.update(url, finishExecutionData).then( (response) => { this.logDebug(`Success finish launch with tempId ${launchTempId}`, response); console.log(`\nReportPortal Launch Link: ${response.link}`); @@ -333,10 +330,11 @@ class RPClient { /* * This method is used to create data object for merge request to ReportPortal. - * - * @Returns {Object} - an object which contains a data for merge launches in ReportPortal. */ - getMergeLaunchesRequest(launchIds, mergeOptions = {}) { + getMergeLaunchesRequest( + launchIds: Array, + mergeOptions: MergeLaunchesOptions = {}, + ) { return { launches: launchIds, mergeType: 'BASIC', @@ -352,53 +350,43 @@ class RPClient { /** * This method is used for merge launches in ReportPortal. - * @param {Object} mergeOptions - options for merge request, can override default options. - * mergeOptions should look like this - * { - * "extendSuitesDescription": boolean, - * "description": string, - * "mergeType": 'BASIC' | 'DEEP', - * "name": string - * } - * Please, keep in mind that this method is work only in case - * the option isLaunchMergeRequired is true. - * - * @returns {Promise} - action promise + * Please, keep in mind that this method work only in case the option isLaunchMergeRequired is true. */ - mergeLaunches(mergeOptions = {}) { + mergeLaunches(mergeOptions: MergeLaunchesOptions = {}): Promise | undefined { if (this.isLaunchMergeRequired) { const launchUUIds = helpers.readLaunchesFromFile(); const params = new URLSearchParams({ 'filter.in.uuid': launchUUIds, 'page.size': launchUUIds.length, - }); + } as unknown as Record); const launchSearchUrl = this.config.mode === 'DEBUG' ? `launch/mode?${params.toString()}` : `launch?${params.toString()}`; this.logDebug(`Find launches with UUIDs to merge: ${launchUUIds}`); return this.restClient - .retrieveSyncAPI(launchSearchUrl) + .retrieveSyncAPI(launchSearchUrl) .then( (response) => { const launchIds = response.content.map((launch) => launch.id); this.logDebug(`Found launches: ${launchIds}`, response.content); return launchIds; }, - (error) => { + (error): Array => { this.logDebug(`Error during launches search with UUIDs: ${launchUUIds}`, error); console.dir(error); + return []; }, ) .then((launchIds) => { const request = this.getMergeLaunchesRequest(launchIds, mergeOptions); this.logDebug(`Merge launches with ids: ${launchIds}`, request); const mergeURL = 'launch/merge'; - return this.restClient.create(mergeURL, request); + return this.restClient.create(mergeURL, request); }) .then((response) => { this.logDebug(`Launches with UUIDs: ${launchUUIds} were successfully merged!`); - if (this.config.launchUuidPrint) { + if (this.config.launchUuidPrint && response.uuid) { this.config.launchUuidPrintOutput(response.uuid); } }) @@ -410,41 +398,22 @@ class RPClient { this.logDebug( 'Option isLaunchMergeRequired is false, merge process cannot be done as no launch UUIDs where saved.', ); + return undefined; } /* * This method is used for frameworks as Jasmine. There is problem when - * it doesn't wait for promise resolve and stop the process. So it better to call - * this method at the spec's function as @afterAll() and manually resolve this promise. - * - * @return Promise + * it doesn't wait for promise resolve and stop the process. */ - getPromiseFinishAllItems(launchTempId) { + getPromiseFinishAllItems(launchTempId: string): Promise { const launchObj = this.map[launchTempId]; return Promise.all(launchObj.children.map((itemId) => this.map[itemId].promiseFinish)); } /** - * Update launch. - * @param {string} launchTempId - temp launch id (returned in the query "startLaunch"). - * @param {Object} launchData - new launch data - * launchData should look like this - * { - "description": "string" (support markdown), - "mode": "DEFAULT" or "DEBUG", - "attributes": [ - { - "key": "string", - "value": "string" - }, - { - "value": "string" - } - ] - } - * @Returns {Object} - an object which contains a tempId and a promise - */ - updateLaunch(launchTempId, launchData) { + * Update launch. + */ + updateLaunch(launchTempId: string, launchData: UpdateLaunchOptions): ClientResponse { const launchObj = this.map[launchTempId]; if (!launchObj) { return this.getRejectAnswer( @@ -452,8 +421,8 @@ class RPClient { new Error(`Launch with tempId "${launchTempId}" not found`), ); } - let resolvePromise; - let rejectPromise; + let resolvePromise!: (value?: unknown) => void; + let rejectPromise!: (reason?: unknown) => void; const promise = new Promise((resolve, reject) => { resolvePromise = resolve; rejectPromise = reject; @@ -486,33 +455,13 @@ class RPClient { } /** - * If there is no parentItemId starts Suite, else starts test or item. - * @param {Object} testItemDataRQ - object with item parameters - * testItemDataRQ should look like this - * { - "description": "string" (support markdown), - "name": "string", - "startTime": this.helper.now(), - "attributes": [ - { - "key": "string", - "value": "string" - }, - { - "value": "string" - } - ], - "type": 'SUITE' or one of 'SUITE', 'STORY', 'TEST', - 'SCENARIO', 'STEP', 'BEFORE_CLASS', 'BEFORE_GROUPS', - 'BEFORE_METHOD', 'BEFORE_SUITE', 'BEFORE_TEST', - 'AFTER_CLASS', 'AFTER_GROUPS', 'AFTER_METHOD', - 'AFTER_SUITE', 'AFTER_TEST' - } - * @param {string} launchTempId - temp launch id (returned in the query "startLaunch"). - * @param {string} parentTempId (optional) - temp item id (returned in the query "startTestItem"). - * @Returns {Object} - an object which contains a tempId and a promise - */ - startTestItem(testItemDataRQ, launchTempId, parentTempId) { + * If there is no parentItemId starts Suite, else starts test or item. + */ + startTestItem( + testItemDataRQ: StartTestItemOptions, + launchTempId: string, + parentTempId?: string, + ): ClientResponse { let parentMapId = launchTempId; const launchObj = this.map[launchTempId]; if (!launchObj) { @@ -532,7 +481,7 @@ class RPClient { const testCaseId = testItemDataRQ.testCaseId || helpers.generateTestCaseId(testItemDataRQ.codeRef, testItemDataRQ.parameters); - const testItemData = { + const testItemData: StartTestItemRQ = { startTime: this.helpers.now(), ...testItemDataRQ, ...(testCaseId && { testCaseId }), @@ -569,12 +518,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 +553,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 +564,13 @@ class RPClient { ); } - const finishTestItemData = { + const finishTestItemData: FinishTestItemRQ = { 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 +615,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 +653,7 @@ class RPClient { }; } - sendLog(itemTempId, saveLogRQ, fileObj) { + sendLog(itemTempId: string, saveLogRQ: LogOptions, fileObj?: Attachment): ClientResponse { const saveLogData = { time: this.helpers.now(), message: '', @@ -743,17 +669,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 +679,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 +691,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 +702,7 @@ class RPClient { ); } - const requestPromise = (itemUuid, launchUuid) => { + const requestPromise: RequestPromiseFunc = (itemUuid, launchUuid) => { const isItemUuid = itemUuid !== launchUuid; return this.getRequestLogWithFile( @@ -815,10 +714,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 +735,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 +767,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: FinishTestItemRQ, + ): void { itemObj.promiseStart.then( () => { const url = ['item', itemObj.realId].join('/'); @@ -905,4 +803,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/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..3cf1296c 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": "./build", "baseUrl": ".", - "paths": { - "*": ["node_modules/*"] - }, - "typeRoots": ["node_modules/@types"], - "outDir": "./build" + "typeRoots": ["node_modules/@types", "src/types"] }, - "include": [ - "statistics/**/*", "lib/**/*", "__tests__/**/*"], + "include": ["src/**/*"], "exclude": ["node_modules", "__tests__"] } From 6e5ab0c2e92f74cefec38599340921459abddfe8 Mon Sep 17 00:00:00 2001 From: maria-hambardzumian <164881199+maria-hambardzumian@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:25:08 +0400 Subject: [PATCH 7/9] Update CHANGELOG and README (#272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update CHANGELOG and README * Update the README based on CodeRabbit’s comment * Resolve subpath imports without consumer eslint config Subpath files live under build/lib and were reachable only via package.json#exports. Filesystem-walking resolvers such as eslint-import-resolver-node, the default in eslint-plugin-import, don't read exports, so every subpath import was flagged import/no-unresolved and each consumer needed its own ignore rule. Build now emits a thin re-export (plus .d.ts) at each subpath location. These are never loaded at runtime, since exports still wins. They just give filesystem resolvers something to find, and restore the lib/** layout from 5.5.x. Generated files are gitignored and removed by npm run clean. * Type launch and test item responses * Remove formatMicrosecondsToISOString function from helpers.ts * coderrabitai crf * EPMRPP-89496 || Simplify facades generation for eslint resolver compatibility * EPMRPP-89496 || Chore: remove nested 'lib' dir * EPMRPP-89496 || Chore: use types only export for models dir * EPMRPP-89496 || Update Jest configuration and add TypeScript test files * Preserve valid config options when getClientConfig fails * Refactor getClientConfig to simplify options handling Simplified the initialization of calculatedOptions to always start with DEFAULT_CLIENT_CONFIG. * Format fix * Update Node.js engine requirement to version 16.0.0 in package.json --------- Co-authored-by: maria-hambardzumian Co-authored-by: Ilya_Hancharyk --- .eslintignore | 8 + .gitignore | 9 + CHANGELOG.md | 12 + DEV_GUIDE.md | 38 + README.md | 22 + .../{client-id.spec.js => client-id.spec.ts} | 22 +- __tests__/{config.spec.js => config.spec.ts} | 18 +- .../{helpers.spec.js => helpers.spec.ts} | 17 +- __tests__/{oauth.spec.js => oauth.spec.ts} | 179 ++-- ...roxyHelper.spec.js => proxyHelper.spec.ts} | 57 +- ...API.spec.js => publicReportingAPI.spec.ts} | 4 +- ...t.spec.js => report-portal-client.spec.ts} | 929 +++++++++++------- __tests__/{rest.spec.js => rest.spec.ts} | 202 ++-- ...{statistics.spec.js => statistics.spec.ts} | 56 +- jest.config.js | 23 +- package-lock.json | 8 + package.json | 99 +- scripts/generate-resolver-facades.js | 95 ++ src/{lib => }/commons/config.ts | 1 + src/{lib => }/commons/errors.ts | 0 src/{lib => }/constants/events.ts | 0 src/{lib => }/constants/index.ts | 1 + src/{lib => }/constants/launchModes.ts | 0 src/{lib => }/constants/logLevels.ts | 0 src/constants/mergeTypes.ts | 4 + src/{lib => }/constants/outputs.ts | 0 src/{lib => }/constants/statuses.ts | 0 src/{lib => }/constants/testItemTypes.ts | 0 src/{lib => }/helpers.ts | 0 src/lib/models/responses.ts | 45 - src/{lib => }/logger.ts | 0 src/{lib => }/models/common.ts | 0 src/{lib => }/models/config.ts | 0 src/{lib => }/models/index.ts | 0 src/{lib => }/models/reporting.ts | 0 src/{lib => }/models/requests.ts | 6 +- src/models/responses.ts | 92 ++ src/{lib => }/oauth.ts | 0 src/{lib => }/pjson.ts | 2 +- src/{lib => }/proxyHelper.ts | 0 src/{lib => }/publicReportingAPI.ts | 0 src/{lib => }/report-portal-client.ts | 148 +-- src/{lib => }/rest.ts | 0 src/statistics/constants.ts | 2 +- src/statistics/statistics.ts | 2 +- tsconfig.spec.json | 10 + version_fragment | 2 +- 47 files changed, 1367 insertions(+), 746 deletions(-) rename __tests__/{client-id.spec.js => client-id.spec.ts} (84%) rename __tests__/{config.spec.js => config.spec.ts} (86%) rename __tests__/{helpers.spec.js => helpers.spec.ts} (88%) rename __tests__/{oauth.spec.js => oauth.spec.ts} (72%) rename __tests__/{proxyHelper.spec.js => proxyHelper.spec.ts} (86%) rename __tests__/{publicReportingAPI.spec.js => publicReportingAPI.spec.ts} (94%) rename __tests__/{report-portal-client.spec.js => report-portal-client.spec.ts} (61%) rename __tests__/{rest.spec.js => rest.spec.ts} (73%) rename __tests__/{statistics.spec.js => statistics.spec.ts} (68%) create mode 100644 scripts/generate-resolver-facades.js rename src/{lib => }/commons/config.ts (99%) rename src/{lib => }/commons/errors.ts (100%) rename src/{lib => }/constants/events.ts (100%) rename src/{lib => }/constants/index.ts (87%) rename src/{lib => }/constants/launchModes.ts (100%) rename src/{lib => }/constants/logLevels.ts (100%) create mode 100644 src/constants/mergeTypes.ts rename src/{lib => }/constants/outputs.ts (100%) rename src/{lib => }/constants/statuses.ts (100%) rename src/{lib => }/constants/testItemTypes.ts (100%) rename src/{lib => }/helpers.ts (100%) delete mode 100644 src/lib/models/responses.ts rename src/{lib => }/logger.ts (100%) rename src/{lib => }/models/common.ts (100%) rename src/{lib => }/models/config.ts (100%) rename src/{lib => }/models/index.ts (100%) rename src/{lib => }/models/reporting.ts (100%) rename src/{lib => }/models/requests.ts (97%) create mode 100644 src/models/responses.ts rename src/{lib => }/oauth.ts (100%) rename src/{lib => }/pjson.ts (88%) rename src/{lib => }/proxyHelper.ts (100%) rename src/{lib => }/publicReportingAPI.ts (100%) rename src/{lib => }/report-portal-client.ts (85%) rename src/{lib => }/rest.ts (100%) create mode 100644 tsconfig.spec.json diff --git a/.eslintignore b/.eslintignore index bec94421..01f55811 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,11 @@ node_modules build __tests__/**/*.json +# Subpath facades generated by scripts/generate-resolver-facades.js +/helpers.js +/helpers.d.ts +/constants.js +/constants.d.ts +/models.d.ts +/publicReportingAPI.js +/publicReportingAPI.d.ts diff --git a/.gitignore b/.gitignore index 7a1c2746..aa492f49 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,12 @@ coverage.lcov coverage/ .npmrc build + +# Subpath facades generated by scripts/generate-resolver-facades.js +/helpers.js +/helpers.d.ts +/constants.js +/constants.d.ts +/models.d.ts +/publicReportingAPI.js +/publicReportingAPI.d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c6d94fc..e590b608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +### Changed +- The client has been migrated to TypeScript. It now ships with bundled type + definitions (no separate `@types` package required) and exposes constants, + models and helpers via subpath imports (e.g. + `@reportportal/client-javascript/constants`). +- **Breaking Change** Deep `lib/**` imports published up to 5.5.x (e.g. + `require('@reportportal/client-javascript/lib/helpers')`) still resolve for Node, + TypeScript and bundlers via the `exports` map, but are no longer backed by physical files. + Tools that resolve imports by walking the filesystem instead of reading `exports` — most + notably `eslint-import-resolver-node`, the default resolver of `eslint-plugin-import` — + will report [`import/no-unresolved`](https://github.com/import-js/eslint-plugin-import/issues/1810) for these paths. Switch to the new subpath aliases + (`constants`, `models`, `helpers`, `publicReportingAPI`) instead. ### Added - `retry_of` property is now automatically included in the `startTestItem` request payload when `retry: true` and a previous attempt exists in the diff --git a/DEV_GUIDE.md b/DEV_GUIDE.md index 7e47ed35..79f21339 100644 --- a/DEV_GUIDE.md +++ b/DEV_GUIDE.md @@ -3,6 +3,44 @@ Internal notes for contributors. This content is intentionally kept out of README.md, which is published to package registries. +## Subpath facades + +`npm run build` compiles to `build/` and then runs `scripts/generate-resolver-facades.js`, +which writes a one-line re-export at each public subpath alias — `helpers.js`, +`constants.js`, `publicReportingAPI.js` — together with matching `.d.ts` files. `models` only +gets a `.d.ts` facade: `src/models/**` is TypeScript types with no runtime value (confirmed — +the compiled `build/models/*.js` files are all empty `__esModule` stubs), so +`package.json#exports["./models"]` has no `import` / `require` condition. Node enforces that +regardless of whether a `.js` file physically exists at the root, so writing one would just be +misleading. If a consumer's TypeScript setup runs with `isolatedModules` and they import from +`models` without `import type`, they'll now get `ERR_PACKAGE_PATH_NOT_EXPORTED` at runtime +instead of a silently-empty object — that's intentional; there are no known consumers of this +brand-new subpath yet, so this is the cheapest point to make the contract strict. If a genuine +runtime value ever needs to live under `models`, move it to `constants/` (like `MERGE_TYPES`, +which used to live here) instead of adding a runtime condition back. + +Supported package imports resolve through the `exports` / `typesVersions` maps in +`package.json` straight to `build` for Node, TypeScript and bundlers; these files are never +on that path. They exist only for tools that resolve imports by walking the filesystem +instead of reading `exports`, chiefly `eslint-import-resolver-node` (the default resolver of +`eslint-plugin-import`), which otherwise reports `import/no-unresolved` for these subpath +imports and forces each consumer to configure an ignore. + +The alias list in the script is intentionally fixed and small — it does not mirror every +internal module under `build`. Deep `lib/**` imports as published up to 5.5.x (e.g. +`require('@reportportal/client-javascript/lib/rest')`) resolve via the `exports` map for +Node, TypeScript and bundlers, but are **not** backed by a physical `lib/**` tree — a +filesystem-based resolver hitting one of those undocumented deep paths still needs a local +ignore. If you're bumping a first-party agent past this version and it imports +`@reportportal/client-javascript/lib/**` directly, switch it to the matching short alias in +the same PR rather than adding it here. + +Everything the script writes is gitignored and removed by `npm run clean`. It recomputes +each facade's export style (`export *` vs `export =` vs default) from the real compiled +module on every build, so it can't silently drift the way a hand-written file could. When +you add a new top-level subpath to `exports`, add the matching entry to the `ALIASES` map in +the script. + ## Code knowledge graph This repo carries a local **code knowledge graph** ([colbymchenry/codegraph](https://github.com/colbymchenry/codegraph)) diff --git a/README.md b/README.md index 43e66e9a..b800fb8f 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,28 @@ rpClient.checkConnect().then(() => { }); ``` +## TypeScript + +Starting from version 5.6.0, the client has been migrated to TypeScript and ships with bundled type definitions. It works with both CommonJS and ES module projects: + +```typescript +import RPClient from '@reportportal/client-javascript'; + +const rpClient = new RPClient({ + apiKey: 'reportportalApiKey', + endpoint: 'https://your-instance.com:8080/api/v1', + launch: 'LAUNCH_NAME', + project: 'PROJECT_NAME', +}); +``` + +Constants, models and helpers are available via subpath imports: + +```typescript +import { RP_STATUSES } from '@reportportal/client-javascript/constants'; +import PublicReportingAPI from '@reportportal/client-javascript/publicReportingAPI'; +``` + ## Configuration When creating a client instance, you need to specify the following options. diff --git a/__tests__/client-id.spec.js b/__tests__/client-id.spec.ts similarity index 84% rename from __tests__/client-id.spec.js rename to __tests__/client-id.spec.ts index ff9f8dde..393f1ff4 100644 --- a/__tests__/client-id.spec.js +++ b/__tests__/client-id.spec.ts @@ -1,11 +1,14 @@ -const fs = require('fs'); -const util = require('util'); -const path = require('path'); -const { randomUUID } = require('crypto'); +import fs from 'fs'; +import util from 'util'; +import path from 'path'; +import { randomUUID } from 'crypto'; const testHomeDir = path.join(__dirname, '__tmp__', 'rp-home'); process.env.RP_CLIENT_JS_HOME = testHomeDir; -const { getClientId } = require('../src/statistics/client-id'); + +// A static `import` would be hoisted above the `process.env` assignment above, so the module +// (which reads RP_CLIENT_JS_HOME at load time) is loaded lazily via a dynamic import instead. +let getClientId: () => Promise; 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'); @@ -14,18 +17,21 @@ const unlink = util.promisify(fs.unlink); const readFile = util.promisify(fs.readFile); const writeFile = util.promisify(fs.writeFile); const removeTestHomeDir = () => fs.promises.rm(testHomeDir, { recursive: true, force: true }); -const unlinkFile = async (filePath) => { +const unlinkFile = async (filePath: string) => { try { await unlink(filePath); } catch (error) { - if (error.code !== 'ENOENT') { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error; } } }; describe('Client ID test suite', () => { - beforeAll(removeTestHomeDir); + beforeAll(async () => { + await removeTestHomeDir(); + ({ getClientId } = await import('../src/statistics/client-id')); + }); afterAll(removeTestHomeDir); it('getClientId should return the same client ID for two calls', async () => { diff --git a/__tests__/config.spec.js b/__tests__/config.spec.ts similarity index 86% rename from __tests__/config.spec.js rename to __tests__/config.spec.ts index 7cf6c937..33dc1e3c 100644 --- a/__tests__/config.spec.js +++ b/__tests__/config.spec.ts @@ -1,8 +1,6 @@ -const { getClientConfig, getRequiredOption, getApiKey } = require('../src/lib/commons/config'); -const { - ReportPortalRequiredOptionError, - ReportPortalValidationError, -} = require('../src/lib/commons/errors'); +import { getClientConfig, getRequiredOption, getApiKey } from '../src/commons/config'; +import { ReportPortalRequiredOptionError, ReportPortalValidationError } from '../src/commons/errors'; +import type { ReportPortalConfig } from '../src/models/config'; describe('Config commons test suite', () => { describe('getRequiredOption', () => { @@ -26,7 +24,7 @@ describe('Config commons test suite', () => { it('should throw ReportPortalRequiredOptionError in case of option not present in options', () => { let error; try { - getRequiredOption({ other: 1 }, 'project'); + getRequiredOption({ other: 1 } as unknown as { project: unknown }, 'project'); } catch (e) { error = e; } @@ -73,7 +71,7 @@ describe('Config commons test suite', () => { describe('getClientConfig', () => { it('should print ReportPortalValidationError error to the console in case of options is not an object type', () => { jest.spyOn(console, 'dir').mockImplementation(); - getClientConfig('options'); + getClientConfig('options' as unknown as ReportPortalConfig); expect(console.dir).toHaveBeenCalledWith( new ReportPortalValidationError('`options` must be an object.'), @@ -85,7 +83,7 @@ describe('Config commons test suite', () => { getClientConfig({ apiKey: '123', project: 'prj', - }); + } as unknown as ReportPortalConfig); expect(console.dir).toHaveBeenCalledWith(new ReportPortalRequiredOptionError('endpoint')); }); @@ -95,7 +93,7 @@ describe('Config commons test suite', () => { getClientConfig({ apiKey: '123', endpoint: 'https://abc.com', - }); + } as unknown as ReportPortalConfig); expect(console.dir).toHaveBeenCalledWith(new ReportPortalRequiredOptionError('project')); }); @@ -105,7 +103,7 @@ describe('Config commons test suite', () => { getClientConfig({ project: 'prj', endpoint: 'https://abc.com', - }); + } as unknown as ReportPortalConfig); expect(console.dir).toHaveBeenCalledWith(new ReportPortalRequiredOptionError('apiKey')); }); diff --git a/__tests__/helpers.spec.js b/__tests__/helpers.spec.ts similarity index 88% rename from __tests__/helpers.spec.js rename to __tests__/helpers.spec.ts index 3c905d21..ac26555a 100644 --- a/__tests__/helpers.spec.js +++ b/__tests__/helpers.spec.ts @@ -1,8 +1,9 @@ -const os = require('os'); -const fs = require('fs'); -const glob = require('glob'); -const helpers = require('../src/lib/helpers'); -const pjson = require('../package.json'); +import os from 'os'; +import fs from 'fs'; +import * as glob from 'glob'; +import * as helpers from '../src/helpers'; +import type { TestItemParameter } from '../src/models/requests'; +import pjson from '../package.json'; describe('Helpers', () => { describe('formatName', () => { @@ -22,7 +23,7 @@ describe('Helpers', () => { describe('now', () => { it('returns milliseconds from unix time', () => { - expect(new Date() - helpers.now()).toBeLessThan(100); // less than 100 miliseconds difference + expect(Number(new Date()) - helpers.now()).toBeLessThan(100); // less than 100 miliseconds difference }); }); @@ -50,7 +51,7 @@ describe('Helpers', () => { it('should return correct system attributes', () => { jest.spyOn(os, 'type').mockReturnValue('osType'); jest.spyOn(os, 'arch').mockReturnValue('osArchitecture'); - jest.spyOn(os, 'totalmem').mockReturnValue('1'); + jest.spyOn(os, 'totalmem').mockReturnValue(1); const nodeVersion = process.version; const expectedAttr = [ { @@ -106,7 +107,7 @@ describe('Helpers', () => { key: 'keyThree', value: 'valueThree', }, - ]; + ] as TestItemParameter[]; const testCaseId = helpers.generateTestCaseId('codeRef', parameters); diff --git a/__tests__/oauth.spec.js b/__tests__/oauth.spec.ts similarity index 72% rename from __tests__/oauth.spec.js rename to __tests__/oauth.spec.ts index bd27ff2a..0e1e0589 100644 --- a/__tests__/oauth.spec.js +++ b/__tests__/oauth.spec.ts @@ -1,12 +1,64 @@ -const axios = require('axios'); -const { HttpsProxyAgent } = require('https-proxy-agent'); -const OAuthInterceptor = require('../src/lib/oauth'); +import axios, { AxiosInstance } from 'axios'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import OAuthInterceptor from '../src/oauth'; jest.mock('axios', () => ({ post: jest.fn(), - isAxiosError: jest.fn((error) => !!error && typeof error === 'object' && error.isAxiosError === true), + isAxiosError: jest.fn( + (error: unknown) => !!error && typeof error === 'object' && (error as { isAxiosError?: boolean }).isAxiosError === true, + ), })); +// The whole `axios` module is replaced by the factory above, so its real (unmocked) type +// doesn't reflect what's actually exported at runtime - this alias gives typed access to the +// mock methods (`.mockResolvedValue`, `.mock.calls`, ...) on the two functions the factory does provide. +const mockedAxios = axios as unknown as { + post: jest.Mock; + isAxiosError: jest.Mock; +}; + +// `accessToken` / `refreshToken` / `tokenExpiresAt` / `tokenRenewPromise` are private on +// OAuthInterceptor by design (external callers only need `getAccessToken`/`attach`); these tests +// deliberately reach into that internal state to set up scenarios, so they need a typed escape hatch. +interface OAuthInterceptorInternal { + accessToken: string | null; + refreshToken: string | null; + tokenExpiresAt: number | null; + tokenRenewPromise: Promise | null; + getAccessToken(): Promise; + attach(axiosInstance: AxiosInstance): void; + logDebug(message: string, data?: unknown): void; +} +const asInternal = (interceptor: OAuthInterceptor): OAuthInterceptorInternal => + interceptor as unknown as OAuthInterceptorInternal; + +// Minimal stand-in for the axios instance passed to `attach()` - only `interceptors.request.use` +// is exercised, so the mock only needs to capture the two handlers it's called with. +type RequestHandler = (config: { headers: Record; url: string }) => Promise<{ + headers: Record; + url: string; +}>; +type RejectionHandler = (error: unknown) => Promise; +const createAxiosInstanceMock = () => { + let requestHandler: RequestHandler | undefined; + let rejectionHandler: RejectionHandler | undefined; + const axiosInstance = { + interceptors: { + request: { + use: jest.fn((fulfilled: RequestHandler, rejected: RejectionHandler) => { + requestHandler = fulfilled; + rejectionHandler = rejected; + }), + }, + }, + }; + return { + axiosInstance, + getRequestHandler: () => requestHandler as RequestHandler, + getRejectionHandler: () => rejectionHandler as RejectionHandler, + }; +}; + describe('OAuthInterceptor', () => { const baseConfig = { tokenEndpoint: 'https://auth.example.com/oauth/token', @@ -20,14 +72,14 @@ describe('OAuthInterceptor', () => { const DEFAULT_TOKEN_EXPIRATION_MS = 3600000; beforeEach(() => { - axios.post.mockReset(); + mockedAxios.post.mockReset(); }); it('requests an access token using password grant on first call', async () => { const baseTime = 1700000000000; const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime); const oauthInterceptor = new OAuthInterceptor(baseConfig); - axios.post.mockResolvedValue({ + mockedAxios.post.mockResolvedValue({ data: { access_token: 'token-123', refresh_token: 'refresh-123', @@ -38,8 +90,8 @@ describe('OAuthInterceptor', () => { const token = await oauthInterceptor.getAccessToken(); expect(token).toBe('token-123'); - expect(axios.post).toHaveBeenCalledTimes(1); - const [url, params, config] = axios.post.mock.calls[0]; + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + const [url, params, config] = mockedAxios.post.mock.calls[0]; expect(url).toBe(baseConfig.tokenEndpoint); expect(params).toBeInstanceOf(URLSearchParams); @@ -51,8 +103,8 @@ describe('OAuthInterceptor', () => { expect(params.get('scope')).toBe(baseConfig.scope); expect(config.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' }); expect(config.httpsAgent).toBeDefined(); // Default agent added - expect(oauthInterceptor.refreshToken).toBe('refresh-123'); - expect(oauthInterceptor.tokenExpiresAt).toBe(baseTime + 120000); + expect(asInternal(oauthInterceptor).refreshToken).toBe('refresh-123'); + expect(asInternal(oauthInterceptor).tokenExpiresAt).toBe(baseTime + 120000); nowSpy.mockRestore(); }); @@ -60,14 +112,14 @@ describe('OAuthInterceptor', () => { it('returns cached token when it is not expiring soon', async () => { const baseTime = 1700000100000; const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime); - const oauthInterceptor = new OAuthInterceptor(baseConfig); + const oauthInterceptor = asInternal(new OAuthInterceptor(baseConfig)); oauthInterceptor.accessToken = 'cached-token'; oauthInterceptor.tokenExpiresAt = baseTime + TOKEN_REFRESH_THRESHOLD_MS + 5000; const token = await oauthInterceptor.getAccessToken(); expect(token).toBe('cached-token'); - expect(axios.post).not.toHaveBeenCalled(); + expect(mockedAxios.post).not.toHaveBeenCalled(); nowSpy.mockRestore(); }); @@ -75,11 +127,11 @@ describe('OAuthInterceptor', () => { it('refreshes token using stored refresh token when it is close to expiring', async () => { const baseTime = 1700000200000; const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime); - const oauthInterceptor = new OAuthInterceptor(baseConfig); + const oauthInterceptor = asInternal(new OAuthInterceptor(baseConfig)); oauthInterceptor.accessToken = 'stale-token'; oauthInterceptor.refreshToken = 'stored-refresh'; oauthInterceptor.tokenExpiresAt = baseTime + TOKEN_REFRESH_THRESHOLD_MS - 1000; - axios.post.mockResolvedValue({ + mockedAxios.post.mockResolvedValue({ data: { access_token: 'fresh-token', refresh_token: 'fresh-refresh', @@ -89,8 +141,8 @@ describe('OAuthInterceptor', () => { const token = await oauthInterceptor.getAccessToken(); expect(token).toBe('fresh-token'); - expect(axios.post).toHaveBeenCalledTimes(1); - const [, params] = axios.post.mock.calls[0]; + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + const [, params] = mockedAxios.post.mock.calls[0]; expect(params.get('grant_type')).toBe('refresh_token'); expect(params.get('refresh_token')).toBe('stored-refresh'); expect(oauthInterceptor.refreshToken).toBe('fresh-refresh'); @@ -102,19 +154,19 @@ describe('OAuthInterceptor', () => { it('waits for ongoing token renewal and reuses the resolved token', async () => { const baseTime = 1700000300000; const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime); - const oauthInterceptor = new OAuthInterceptor(baseConfig); + const oauthInterceptor = asInternal(new OAuthInterceptor(baseConfig)); - let resolveRequest; + let resolveRequest: (value: unknown) => void; const tokenResponsePromise = new Promise((resolve) => { resolveRequest = resolve; }); - axios.post.mockReturnValue(tokenResponsePromise); + mockedAxios.post.mockReturnValue(tokenResponsePromise); const firstCall = oauthInterceptor.getAccessToken(); const secondCall = oauthInterceptor.getAccessToken(); - expect(axios.post).toHaveBeenCalledTimes(1); - resolveRequest({ + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + resolveRequest!({ data: { access_token: 'shared-token', refresh_token: 'shared-refresh', @@ -134,7 +186,7 @@ describe('OAuthInterceptor', () => { it('logs an error and throws descriptive message when token request fails', async () => { const oauthInterceptor = new OAuthInterceptor(baseConfig); const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - axios.post.mockRejectedValue({ + mockedAxios.post.mockRejectedValue({ isAxiosError: true, response: { status: 400, @@ -156,7 +208,7 @@ describe('OAuthInterceptor', () => { 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 } }); + mockedAxios.post.mockResolvedValue({ data: { expires_in: 120 } }); await expect(oauthInterceptor.getAccessToken()).rejects.toThrow( 'OAuth token request failed: No access token received from OAuth server', @@ -172,7 +224,7 @@ describe('OAuthInterceptor', () => { 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')); + mockedAxios.post.mockRejectedValue(new Error('network is unreachable')); await expect(oauthInterceptor.getAccessToken()).rejects.toThrow( 'OAuth token request failed: network is unreachable', @@ -183,21 +235,12 @@ describe('OAuthInterceptor', () => { 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; - }), - }, - }, - }; + const { axiosInstance, getRejectionHandler } = createAxiosInstanceMock(); - oauthInterceptor.attach(axiosInstance); + oauthInterceptor.attach(axiosInstance as unknown as AxiosInstance); const error = new Error('request setup failed'); - await expect(rejectionHandler(error)).rejects.toBe(error); + await expect(getRejectionHandler()(error)).rejects.toBe(error); }); it('logs debug messages only when debug mode is enabled', () => { @@ -216,25 +259,16 @@ describe('OAuthInterceptor', () => { it('injects Authorization header through attached request interceptor', async () => { const baseTime = 1700000400000; const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime); - const oauthInterceptor = new OAuthInterceptor(baseConfig); + const oauthInterceptor = asInternal(new OAuthInterceptor(baseConfig)); oauthInterceptor.accessToken = 'cached-token'; oauthInterceptor.tokenExpiresAt = baseTime + TOKEN_REFRESH_THRESHOLD_MS + 1000; - let requestHandler; - const axiosInstance = { - interceptors: { - request: { - use: jest.fn((fulfilled) => { - requestHandler = fulfilled; - }), - }, - }, - }; + const { axiosInstance, getRequestHandler } = createAxiosInstanceMock(); - oauthInterceptor.attach(axiosInstance); - const requestConfig = await requestHandler({ headers: {}, url: '/launch' }); + oauthInterceptor.attach(axiosInstance as unknown as AxiosInstance); + const requestConfig = await getRequestHandler()({ headers: {}, url: '/launch' }); expect(requestConfig.headers.Authorization).toBe('Bearer cached-token'); - expect(axios.post).not.toHaveBeenCalled(); + expect(mockedAxios.post).not.toHaveBeenCalled(); nowSpy.mockRestore(); }); @@ -244,19 +278,10 @@ describe('OAuthInterceptor', () => { const error = new Error('refresh failed'); const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); const tokenSpy = jest.spyOn(oauthInterceptor, 'getAccessToken').mockRejectedValue(error); - let requestHandler; - const axiosInstance = { - interceptors: { - request: { - use: jest.fn((fulfilled) => { - requestHandler = fulfilled; - }), - }, - }, - }; + const { axiosInstance, getRequestHandler } = createAxiosInstanceMock(); - oauthInterceptor.attach(axiosInstance); - const requestConfig = await requestHandler({ headers: {}, url: '/launch' }); + oauthInterceptor.attach(axiosInstance as unknown as AxiosInstance); + const requestConfig = await getRequestHandler()({ headers: {}, url: '/launch' }); expect(requestConfig.headers.Authorization).toBeUndefined(); expect(consoleSpy).toHaveBeenCalledWith( @@ -273,13 +298,13 @@ describe('OAuthInterceptor', () => { const baseTime = 1700000500000; const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime); const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); - const oauthInterceptor = new OAuthInterceptor(baseConfig); + const oauthInterceptor = asInternal(new OAuthInterceptor(baseConfig)); oauthInterceptor.accessToken = 'old-token'; oauthInterceptor.refreshToken = 'expired-refresh-token'; oauthInterceptor.tokenExpiresAt = baseTime - 1000; // Token already expired // First call (refresh token) fails - axios.post + mockedAxios.post .mockRejectedValueOnce({ isAxiosError: true, response: { @@ -299,15 +324,15 @@ describe('OAuthInterceptor', () => { const token = await oauthInterceptor.getAccessToken(); expect(token).toBe('new-token-from-password'); - expect(axios.post).toHaveBeenCalledTimes(2); + expect(mockedAxios.post).toHaveBeenCalledTimes(2); // First call should be refresh_token grant - const [, firstParams] = axios.post.mock.calls[0]; + const [, firstParams] = mockedAxios.post.mock.calls[0]; expect(firstParams.get('grant_type')).toBe('refresh_token'); expect(firstParams.get('refresh_token')).toBe('expired-refresh-token'); // Second call should be password grant - const [, secondParams] = axios.post.mock.calls[1]; + const [, secondParams] = mockedAxios.post.mock.calls[1]; expect(secondParams.get('grant_type')).toBe('password'); expect(secondParams.get('username')).toBe(baseConfig.username); expect(secondParams.get('password')).toBe(baseConfig.password); @@ -330,12 +355,12 @@ describe('OAuthInterceptor', () => { const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime); const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); - const oauthInterceptor = new OAuthInterceptor(baseConfig); + const oauthInterceptor = asInternal(new OAuthInterceptor(baseConfig)); oauthInterceptor.refreshToken = 'expired-refresh-token'; oauthInterceptor.tokenExpiresAt = baseTime - 1000; // Both calls fail - axios.post + mockedAxios.post .mockRejectedValueOnce({ isAxiosError: true, response: { @@ -355,7 +380,7 @@ describe('OAuthInterceptor', () => { 'OAuth password grant fallback failed: 401 - {"error":"invalid_credentials"}', ); - expect(axios.post).toHaveBeenCalledTimes(2); + expect(mockedAxios.post).toHaveBeenCalledTimes(2); expect(consoleWarnSpy).toHaveBeenCalled(); expect(consoleErrorSpy).toHaveBeenCalledWith( '[OAuth] OAuth password grant fallback failed: 401 - {"error":"invalid_credentials"}', @@ -380,7 +405,7 @@ describe('OAuthInterceptor', () => { }, }; const oauthInterceptor = new OAuthInterceptor(configWithProxy); - axios.post.mockResolvedValue({ + mockedAxios.post.mockResolvedValue({ data: { access_token: 'token-with-proxy', expires_in: 120, @@ -390,8 +415,8 @@ describe('OAuthInterceptor', () => { const token = await oauthInterceptor.getAccessToken(); expect(token).toBe('token-with-proxy'); - expect(axios.post).toHaveBeenCalledTimes(1); - const [url, , config] = axios.post.mock.calls[0]; + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + const [url, , config] = mockedAxios.post.mock.calls[0]; expect(url).toBe(baseConfig.tokenEndpoint); expect(config.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' }); @@ -415,7 +440,7 @@ describe('OAuthInterceptor', () => { }, }, }); - axios.post.mockResolvedValue({ + mockedAxios.post.mockResolvedValue({ data: { access_token: 'token-debug-proxy', expires_in: 120 }, }); @@ -446,7 +471,7 @@ describe('OAuthInterceptor', () => { }, }; const oauthInterceptor = new OAuthInterceptor(configWithNoProxy); - axios.post.mockResolvedValue({ + mockedAxios.post.mockResolvedValue({ data: { access_token: 'token-no-proxy', expires_in: 120, @@ -456,8 +481,8 @@ describe('OAuthInterceptor', () => { const token = await oauthInterceptor.getAccessToken(); expect(token).toBe('token-no-proxy'); - expect(axios.post).toHaveBeenCalledTimes(1); - const [url, , config] = axios.post.mock.calls[0]; + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + const [url, , config] = mockedAxios.post.mock.calls[0]; expect(url).toBe(baseConfig.tokenEndpoint); expect(config.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' }); diff --git a/__tests__/proxyHelper.spec.js b/__tests__/proxyHelper.spec.ts similarity index 86% rename from __tests__/proxyHelper.spec.js rename to __tests__/proxyHelper.spec.ts index c588d1fe..ca7f8535 100644 --- a/__tests__/proxyHelper.spec.js +++ b/__tests__/proxyHelper.spec.ts @@ -1,11 +1,12 @@ -const { HttpsProxyAgent } = require('https-proxy-agent'); -const { HttpProxyAgent } = require('http-proxy-agent'); -const { +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { HttpProxyAgent } from 'http-proxy-agent'; +import { shouldBypassProxy, getProxyConfig, createProxyAgents, getProxyAgentForUrl, -} = require('../src/lib/proxyHelper'); +} from '../src/proxyHelper'; +import type { RestClientConfig } from '../src/models/config'; describe('proxyHelper', () => { const originalEnv = process.env; @@ -30,7 +31,7 @@ describe('proxyHelper', () => { describe('shouldBypassProxy', () => { it('returns false when noProxy is not provided', () => { expect(shouldBypassProxy('http://example.com', '')).toBe(false); - expect(shouldBypassProxy('http://example.com', null)).toBe(false); + expect(shouldBypassProxy('http://example.com', null as unknown as string)).toBe(false); expect(shouldBypassProxy('http://example.com', undefined)).toBe(false); }); @@ -85,12 +86,12 @@ describe('proxyHelper', () => { }); it('returns null when proxy is explicitly disabled', () => { - const config = { proxy: false }; + const config: RestClientConfig = { proxy: false }; expect(getProxyConfig('http://example.com', config)).toBeNull(); }); it('returns null when URL matches noProxy from config', () => { - const config = { noProxy: 'example.com,localhost' }; + const config: RestClientConfig = { noProxy: 'example.com,localhost' }; expect(getProxyConfig('http://example.com', config)).toBeNull(); expect(getProxyConfig('http://localhost', config)).toBeNull(); }); @@ -102,12 +103,12 @@ describe('proxyHelper', () => { it('prefers noProxy from config over environment', () => { process.env.NO_PROXY = 'other.com'; - const config = { noProxy: 'example.com' }; + const config: RestClientConfig = { noProxy: 'example.com' }; expect(getProxyConfig('http://example.com', config)).toBeNull(); }); it('returns proxy URL from string config', () => { - const config = { proxy: 'http://proxy.example.com:8080' }; + const config: RestClientConfig = { proxy: 'http://proxy.example.com:8080' }; const result = getProxyConfig('http://target.com', config); expect(result).toEqual({ proxyUrl: 'http://proxy.example.com:8080', @@ -115,7 +116,7 @@ describe('proxyHelper', () => { }); it('returns proxy URL from object config', () => { - const config = { + const config: RestClientConfig = { proxy: { protocol: 'https', host: 'proxy.example.com', @@ -129,7 +130,7 @@ describe('proxyHelper', () => { }); it('returns proxy URL with authentication', () => { - const config = { + const config: RestClientConfig = { proxy: { protocol: 'http', host: 'proxy.example.com', @@ -147,7 +148,7 @@ describe('proxyHelper', () => { }); it('uses http as default protocol for object config', () => { - const config = { + const config: RestClientConfig = { proxy: { host: 'proxy.example.com', port: 8080, @@ -184,7 +185,7 @@ describe('proxyHelper', () => { it('prioritizes explicit config over environment variables', () => { process.env.HTTPS_PROXY = 'http://env-proxy.com:8080'; - const config = { proxy: 'http://config-proxy.com:9090' }; + const config: RestClientConfig = { proxy: 'http://config-proxy.com:9090' }; const result = getProxyConfig('https://target.com', config); expect(result).toEqual({ proxyUrl: 'http://config-proxy.com:9090', @@ -196,47 +197,47 @@ describe('proxyHelper', () => { it('returns default agent when no proxy is configured', () => { const agents = createProxyAgents('http://example.com', {}); expect(agents.httpAgent).toBeDefined(); - expect(agents.httpAgent.constructor.name).toBe('Agent'); + expect(agents.httpAgent!.constructor.name).toBe('Agent'); }); it('creates HttpProxyAgent for HTTP URLs', () => { - const config = { proxy: 'http://proxy.example.com:8080' }; + const config: RestClientConfig = { proxy: 'http://proxy.example.com:8080' }; const agents = createProxyAgents('http://target.com', config); expect(agents.httpAgent).toBeInstanceOf(HttpProxyAgent); expect(agents.httpsAgent).toBeUndefined(); }); it('creates HttpsProxyAgent for HTTPS URLs', () => { - const config = { proxy: 'http://proxy.example.com:8080' }; + const config: RestClientConfig = { proxy: 'http://proxy.example.com:8080' }; const agents = createProxyAgents('https://target.com', config); expect(agents.httpsAgent).toBeInstanceOf(HttpsProxyAgent); expect(agents.httpAgent).toBeUndefined(); }); it('returns default agent when URL is in noProxy list', () => { - const config = { + const config: RestClientConfig = { proxy: 'http://proxy.example.com:8080', noProxy: 'target.com', }; const agents = createProxyAgents('http://target.com', config); expect(agents.httpAgent).toBeDefined(); - expect(agents.httpAgent.constructor.name).toBe('Agent'); + expect(agents.httpAgent!.constructor.name).toBe('Agent'); }); it('returns default HTTPS agent for HTTPS URLs in noProxy list', () => { - const config = { + const config: RestClientConfig = { proxy: 'http://proxy.example.com:8080', noProxy: 'target.com', }; const agents = createProxyAgents('https://target.com', config); expect(agents.httpsAgent).toBeDefined(); - expect(agents.httpsAgent.constructor.name).toBe('Agent'); + expect(agents.httpsAgent!.constructor.name).toBe('Agent'); }); }); describe('getProxyAgentForUrl', () => { it('returns appropriate agent based on URL protocol', () => { - const config = { proxy: 'http://proxy.example.com:8080' }; + const config: RestClientConfig = { proxy: 'http://proxy.example.com:8080' }; const httpAgents = getProxyAgentForUrl('http://target.com', config); expect(httpAgents.httpAgent).toBeInstanceOf(HttpProxyAgent); @@ -246,13 +247,13 @@ describe('proxyHelper', () => { }); it('respects noProxy configuration', () => { - const config = { + const config: RestClientConfig = { proxy: 'http://proxy.example.com:8080', noProxy: 'localhost,target.com', }; const agents = getProxyAgentForUrl('http://target.com', config); expect(agents.httpAgent).toBeDefined(); - expect(agents.httpAgent.constructor.name).toBe('Agent'); + expect(agents.httpAgent!.constructor.name).toBe('Agent'); }); it('works with environment variables', () => { @@ -266,12 +267,12 @@ describe('proxyHelper', () => { process.env.NO_PROXY = 'target.com'; const agents = getProxyAgentForUrl('http://target.com', {}); expect(agents.httpAgent).toBeDefined(); - expect(agents.httpAgent.constructor.name).toBe('Agent'); + expect(agents.httpAgent!.constructor.name).toBe('Agent'); }); }); describe('credential sanitization in debug logs', () => { - let consoleLogSpy; + let consoleLogSpy: jest.SpyInstance; beforeEach(() => { consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); @@ -282,7 +283,7 @@ describe('proxyHelper', () => { }); it('sanitizes proxy credentials in debug logs when using proxy config object', () => { - const config = { + const config: RestClientConfig = { proxy: { protocol: 'https', host: 'proxy.example.com', @@ -309,7 +310,7 @@ describe('proxyHelper', () => { }); it('sanitizes proxy credentials in debug logs when using proxy URL string', () => { - const config = { + const config: RestClientConfig = { proxy: 'https://myuser:mypassword@proxy.example.com:8080', debug: true, }; @@ -325,7 +326,7 @@ describe('proxyHelper', () => { }); it('logs proxy URL normally when no credentials are present', () => { - const config = { + const config: RestClientConfig = { proxy: 'https://proxy.example.com:8080', debug: true, }; diff --git a/__tests__/publicReportingAPI.spec.js b/__tests__/publicReportingAPI.spec.ts similarity index 94% rename from __tests__/publicReportingAPI.spec.js rename to __tests__/publicReportingAPI.spec.ts index 4f52ee6a..f744dbdd 100644 --- a/__tests__/publicReportingAPI.spec.js +++ b/__tests__/publicReportingAPI.spec.ts @@ -1,5 +1,5 @@ -const PublicReportingAPI = require('../src/lib/publicReportingAPI'); -const { EVENTS } = require('../src/lib/constants/events'); +import PublicReportingAPI from '../src/publicReportingAPI'; +import { EVENTS } from '../src/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.ts similarity index 61% rename from __tests__/report-portal-client.spec.js rename to __tests__/report-portal-client.spec.ts index 67ed9f12..d79fd4bf 100644 --- a/__tests__/report-portal-client.spec.js +++ b/__tests__/report-portal-client.spec.ts @@ -1,7 +1,132 @@ -const process = require('process'); -const RPClient = require('../src/lib/report-portal-client'); -const helpers = require('../src/lib/helpers'); -const { OUTPUT_TYPES } = require('../src/lib/constants/outputs'); +import process from 'process'; +import RPClient from '../src/report-portal-client'; +import RestClient from '../src/rest'; +import * as helpers from '../src/helpers'; +import { OUTPUT_TYPES } from '../src/constants/outputs'; +import type { AgentParams, Attachment, ClientResponse } from '../src/models/common'; +import type { NormalizedClientConfig } from '../src/models/config'; +import type { + FinishLaunchOptions, + FinishTestItemOptions, + FinishTestItemRQ, + LogOptions, + MergeLaunchesOptions, + StartLaunchOptions, + StartTestItemOptions, + UpdateLaunchOptions, +} from '../src/models/requests'; +import type { + FinishLaunchResponse, + PageLaunchResource, + ServerInfoResponse, + StartLaunchResponse, + StartTestItemResponse, + UpdateLaunchResponse, +} from '../src/models/responses'; + +// Mirrors the (non-exported) internal item/launch record kept in `RPClient.map`. +interface ItemObj { + promiseStart: Promise; + realId: string; + children: string[]; + finishSend: boolean; + promiseFinish: Promise; + resolveFinish: (value: T) => void; + rejectFinish: (reason?: Error) => void; +} + +type RequestPromiseFunc = (itemUuid: string, launchUuid: string) => Promise; + +// `eventName` / `eventParams` are private on Statistics, but these tests assert on them directly. +interface StatisticsInternal { + eventName: string; + eventParams: Record; + setInstanceID(instanceID: string): void; + trackEvent(): Promise; +} + +// Almost every member of RPClient is `private` by design (the public surface is the reporting API +// only). These tests are deliberately whitebox: they seed `map`, stub `restClient`/`statistics` and +// assert on internal state, so they need a typed escape hatch into those members. +// A standalone interface (not an intersection with RPClient) is required - intersecting a type +// literal with a class that has private members collapses to `never`. +interface RPClientInternal { + config: NormalizedClientConfig; + debug?: boolean; + isLaunchMergeRequired: boolean; + apiKey: string | null; + token: string | null; + map: Record; + baseURL: string; + headers: Record; + helpers: typeof helpers; + restClient: RestClient; + statistics: StatisticsInternal; + launchUuid: string; + itemRetriesChainMap: Map>; + itemRetriesChainKeyMapByTempId: Map; + + logDebug(msg: unknown, dataMsg?: unknown): void; + calculateItemRetriesChainMapKey( + launchId: string, + parentId: string | undefined, + name: string, + itemId?: string, + ): string; + cleanItemRetriesChain(tempIds: string[]): void; + getUniqId(): string; + getRejectAnswer(tempId: string, error: Error): ClientResponse; + cleanMap(ids: string[]): void; + checkConnect(): Promise; + getServerInfoUrl(): string; + fetchServerInfo(): Promise; + triggerStatisticsEvent(): Promise; + startLaunch( + launchDataRQ: StartLaunchOptions, + ): ClientResponse; + finishLaunch( + launchTempId: string, + finishExecutionRQ?: FinishLaunchOptions, + ): ClientResponse; + getMergeLaunchesRequest( + launchIds: Array, + mergeOptions?: MergeLaunchesOptions, + ): Record; + mergeLaunches(mergeOptions?: MergeLaunchesOptions): Promise | undefined; + getPromiseFinishAllItems(launchTempId: string): Promise; + updateLaunch( + launchTempId: string, + launchData: UpdateLaunchOptions, + ): ClientResponse; + startTestItem( + testItemDataRQ: StartTestItemOptions, + launchTempId: string, + parentTempId?: string, + ): ClientResponse; + finishTestItem( + itemTempId: string, + finishTestItemRQ?: FinishTestItemOptions, + ): ClientResponse; + saveLog(itemObj: ItemObj, requestPromiseFunc: RequestPromiseFunc): ClientResponse; + sendLog(itemTempId: string, saveLogRQ: LogOptions, fileObj?: Attachment): ClientResponse; + sendLogWithoutFile(itemTempId: string, saveLogRQ: LogOptions): ClientResponse; + // `fileObj` is optional here only so the tests can reproduce the original JS calls that omit it. + sendLogWithFile(itemTempId: string, saveLogRQ: LogOptions, fileObj?: Attachment): ClientResponse; + getRequestLogWithFile(saveLogRQ: LogOptions, fileObj: Attachment): Promise; + buildMultiPartStream(jsonPart: unknown[], filePart: Attachment, boundary: string): Buffer; + finishTestItemPromiseStart( + itemObj: ItemObj, + itemTempId: string, + finishTestItemData: FinishTestItemRQ, + ): void; +} + +const asInternal = (client: RPClient): RPClientInternal => client as unknown as RPClientInternal; + +// The tests seed `map` with deliberately partial item records (only the fields the code path under +// test reads), so the literals need to be widened to the real map shape. +const asMap = (map: Record): Record => + map as unknown as Record; describe('ReportPortal javascript client', () => { afterEach(() => { @@ -10,11 +135,13 @@ describe('ReportPortal javascript client', () => { describe('constructor', () => { it('creates the client instance without error', () => { - const client = new RPClient({ - apiKey: 'test', - project: 'test', - endpoint: 'https://abc.com', - }); + const client = asInternal( + new RPClient({ + apiKey: 'test', + project: 'test', + endpoint: 'https://abc.com', + }), + ); expect(client.config.apiKey).toBe('test'); expect(client.config.project).toBe('test'); @@ -100,7 +227,8 @@ describe('ReportPortal javascript client', () => { endpoint: 'https://abc.com', }); - const rejectAnswer = client.getRejectAnswer('tempId', 'error'); + // The original test intentionally rejects with a plain string, not an Error. + const rejectAnswer = client.getRejectAnswer('tempId', 'error' as unknown as Error); expect(rejectAnswer.tempId).toEqual('tempId'); return expect(rejectAnswer.promise).rejects.toEqual('error'); @@ -109,16 +237,18 @@ describe('ReportPortal javascript client', () => { describe('cleanMap', () => { it('should delete element with id', () => { - const client = new RPClient({ - apiKey: 'test', - project: 'test', - endpoint: 'https://abc.com', - }); - client.map = { + const client = asInternal( + new RPClient({ + apiKey: 'test', + project: 'test', + endpoint: 'https://abc.com', + }), + ); + client.map = asMap({ id1: 'firstElement', id2: 'secondElement', id3: 'thirdElement', - }; + }); client.cleanMap(['id1', 'id2']); @@ -128,11 +258,13 @@ describe('ReportPortal javascript client', () => { describe('checkConnect', () => { it('should return promise', () => { - const client = new RPClient({ - apiKey: 'test', - project: 'test', - endpoint: 'https://abc.com', - }); + const client = asInternal( + new RPClient({ + apiKey: 'test', + project: 'test', + endpoint: 'https://abc.com', + }), + ); jest.spyOn(client.restClient, 'request').mockReturnValue(Promise.resolve('ok')); const request = client.checkConnect(); @@ -153,11 +285,13 @@ describe('ReportPortal javascript client', () => { }); it('should call statistics.trackEvent if REPORTPORTAL_CLIENT_JS_NO_ANALYTICS is not set', async () => { - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); jest.spyOn(client, 'fetchServerInfo').mockResolvedValue({}); jest.spyOn(client.statistics, 'trackEvent').mockImplementation(); @@ -167,12 +301,15 @@ describe('ReportPortal javascript client', () => { }); it('should not call statistics.trackEvent if REPORTPORTAL_CLIENT_JS_NO_ANALYTICS is true', async () => { - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); - process.env.REPORTPORTAL_CLIENT_JS_NO_ANALYTICS = true; + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); + // The original test assigns the boolean `true`, not the string 'true'. + process.env.REPORTPORTAL_CLIENT_JS_NO_ANALYTICS = true as unknown as string; jest.spyOn(client.statistics, 'trackEvent').mockImplementation(); await client.triggerStatisticsEvent(); @@ -181,19 +318,22 @@ describe('ReportPortal javascript client', () => { }); it('should create statistics object with agentParams is not empty', () => { - const agentParams = { + const agentParams: AgentParams = { name: 'name', version: 'version', }; - const client = new RPClient( - { - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }, - agentParams, + const client = asInternal( + new RPClient( + { + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }, + agentParams, + ), ); - process.env.REPORTPORTAL_CLIENT_JS_NO_ANALYTICS = false; + // The original test assigns the boolean `false`, not the string 'false'. + process.env.REPORTPORTAL_CLIENT_JS_NO_ANALYTICS = false as unknown as string; expect(client.statistics.eventName).toEqual('start_launch'); expect(client.statistics.eventParams).toEqual( @@ -205,11 +345,13 @@ describe('ReportPortal javascript client', () => { }); it('should create statistics object without agentParams if they are empty', () => { - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); expect(client.statistics.eventName).toEqual('start_launch'); expect(client.statistics.eventParams).not.toEqual( @@ -221,12 +363,14 @@ describe('ReportPortal javascript client', () => { }); it('should fetch server info and set instanceID before tracking event', async () => { - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); - const serverInfoResponse = { + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); + const serverInfoResponse: ServerInfoResponse = { extensions: { result: { 'server.details.instance': 'test-instance-123', @@ -245,11 +389,13 @@ describe('ReportPortal javascript client', () => { }); it('should still track event with not_set instanceID if fetchServerInfo fails', async () => { - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); jest.spyOn(client, 'fetchServerInfo').mockRejectedValue(new Error('Network error')); jest.spyOn(client.statistics, 'trackEvent').mockImplementation(); jest.spyOn(client.statistics, 'setInstanceID'); @@ -261,11 +407,13 @@ describe('ReportPortal javascript client', () => { }); it('should not set instanceID if server info does not contain it', async () => { - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); jest.spyOn(client, 'fetchServerInfo').mockResolvedValue({}); jest.spyOn(client.statistics, 'trackEvent').mockImplementation(); jest.spyOn(client.statistics, 'setInstanceID'); @@ -301,11 +449,13 @@ describe('ReportPortal javascript client', () => { describe('fetchServerInfo', () => { it('should call restClient.request with correct URL', async () => { - const client = new RPClient({ - apiKey: 'test', - project: 'test', - endpoint: 'https://rp.us/api/v1', - }); + const client = asInternal( + new RPClient({ + apiKey: 'test', + project: 'test', + endpoint: 'https://rp.us/api/v1', + }), + ); const serverInfo = { extensions: { result: {} } }; jest.spyOn(client.restClient, 'request').mockResolvedValue(serverInfo); @@ -330,11 +480,13 @@ describe('ReportPortal javascript client', () => { system: true, }, ]; - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); const myPromise = Promise.resolve({ id: 'testidlaunch' }); const time = 12345734; jest.spyOn(client.restClient, 'create').mockReturnValue(myPromise); @@ -359,11 +511,13 @@ describe('ReportPortal javascript client', () => { system: true, }, ]; - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); const myPromise = Promise.resolve({ id: 'testidlaunch' }); const time = 12345734; jest.spyOn(client.restClient, 'create').mockReturnValue(myPromise); @@ -389,20 +543,23 @@ describe('ReportPortal javascript client', () => { }); it('dont start new launch if launchDataRQ.id is not empty', () => { - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); const myPromise = Promise.resolve({ id: 'testidlaunch' }); const startTime = 12345734; const id = 12345734; jest.spyOn(client.restClient, 'create').mockReturnValue(myPromise); + // The original test passes a numeric `id`, while the API type declares it as a string. client.startLaunch({ startTime, id, - }); + } as unknown as StartLaunchOptions); expect(client.restClient.create).not.toHaveBeenCalled(); expect(client.launchUuid).toEqual(id); @@ -410,12 +567,14 @@ describe('ReportPortal javascript client', () => { it('should log Launch UUID if enabled', () => { jest.spyOn(OUTPUT_TYPES, 'STDOUT').mockImplementation(); - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - launchUuidPrint: true, - }); + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + launchUuidPrint: true, + }), + ); const myPromise = Promise.resolve({ id: 'testidlaunch' }); const time = 12345734; jest.spyOn(client.restClient, 'create').mockReturnValue(myPromise); @@ -430,13 +589,16 @@ describe('ReportPortal javascript client', () => { it('should log Launch UUID into STDERR if enabled', () => { jest.spyOn(OUTPUT_TYPES, 'STDERR').mockImplementation(); - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - launchUuidPrint: true, - launchUuidPrintOutput: 'stderr', - }); + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + launchUuidPrint: true, + // The original test uses a lowercase value; the config normalizes it to upper case. + launchUuidPrintOutput: 'stderr', + } as unknown as ConstructorParameters[0]), + ); const myPromise = Promise.resolve({ id: 'testidlaunch' }); const time = 12345734; jest.spyOn(client.restClient, 'create').mockReturnValue(myPromise); @@ -451,13 +613,16 @@ describe('ReportPortal javascript client', () => { it('should log Launch UUID into STDOUT if invalid output is set', () => { jest.spyOn(OUTPUT_TYPES, 'STDOUT').mockImplementation(); - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - launchUuidPrint: true, - launchUuidPrintOutput: 'asdfgh', - }); + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + launchUuidPrint: true, + // Intentionally invalid output type - the client must fall back to STDOUT. + launchUuidPrintOutput: 'asdfgh', + } as unknown as ConstructorParameters[0]), + ); const myPromise = Promise.resolve({ id: 'testidlaunch' }); const time = 12345734; jest.spyOn(client.restClient, 'create').mockReturnValue(myPromise); @@ -472,13 +637,15 @@ describe('ReportPortal javascript client', () => { it('should log Launch UUID into ENVIRONMENT if enabled', () => { jest.spyOn(OUTPUT_TYPES, 'ENVIRONMENT').mockImplementation(); - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - launchUuidPrint: true, - launchUuidPrintOutput: 'environment', - }); + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + launchUuidPrint: true, + launchUuidPrintOutput: 'environment', + } as unknown as ConstructorParameters[0]), + ); const myPromise = Promise.resolve({ id: 'testidlaunch' }); const time = 12345734; jest.spyOn(client.restClient, 'create').mockReturnValue(myPromise); @@ -493,13 +660,15 @@ describe('ReportPortal javascript client', () => { it('should log Launch UUID into FILE if enabled', () => { jest.spyOn(OUTPUT_TYPES, 'FILE').mockImplementation(); - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - launchUuidPrint: true, - launchUuidPrintOutput: 'file', - }); + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + launchUuidPrint: true, + launchUuidPrintOutput: 'file', + } as unknown as ConstructorParameters[0]), + ); const myPromise = Promise.resolve({ id: 'testidlaunch' }); const time = 12345734; jest.spyOn(client.restClient, 'create').mockReturnValue(myPromise); @@ -514,11 +683,13 @@ describe('ReportPortal javascript client', () => { it('should not log Launch UUID if not enabled', () => { jest.spyOn(OUTPUT_TYPES, 'STDOUT').mockImplementation(); - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); const myPromise = Promise.resolve({ id: 'testidlaunch' }); const time = 12345734; jest.spyOn(client.restClient, 'create').mockReturnValue(myPromise); @@ -534,15 +705,17 @@ describe('ReportPortal javascript client', () => { describe('finishLaunch', () => { it('should call getRejectAnswer if there is no launchTempId with suitable launchTempId', () => { - const client = new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }); - client.map = { + const client = asInternal( + new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }), + ); + client.map = asMap({ id1: { children: ['child1'], }, - }; + }); jest.spyOn(client, 'getRejectAnswer').mockImplementation(); - client.finishLaunch('id2', { some: 'data' }); + client.finishLaunch('id2', { some: 'data' } as unknown as FinishLaunchOptions); expect(client.getRejectAnswer).toHaveBeenCalledWith( 'id2', @@ -551,23 +724,27 @@ describe('ReportPortal javascript client', () => { }); it('should trigger promiseFinish', async () => { - const client = new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }); - client.map = { + const client = asInternal( + new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }), + ); + client.map = asMap({ id1: { children: ['child1'], promiseStart: Promise.resolve(), - resolveFinish: jest.fn().mockResolvedValue(), + resolveFinish: jest.fn().mockResolvedValue(undefined), }, child1: { - promiseFinish: jest.fn().mockResolvedValue(), + promiseFinish: jest.fn().mockResolvedValue(undefined), }, - }; + }); jest.spyOn(client.restClient, 'update').mockResolvedValue({ link: 'link' }); - await client.finishLaunch('id1', { some: 'data' }).promise; + await client.finishLaunch('id1', { some: 'data' } as unknown as FinishLaunchOptions).promise; - expect(client.map.child1.promiseFinish().then).toBeDefined(); + // `promiseFinish` is a jest mock here, not a promise - the original test calls it. + const child1PromiseFinish = client.map.child1.promiseFinish as unknown as jest.Mock; + expect(child1PromiseFinish().then).toBeDefined(); }); }); @@ -583,12 +760,14 @@ describe('ReportPortal javascript client', () => { name: 'Test launch name', attributes: [{ value: 'value' }], }; - const client = new RPClient({ - apiKey: 'test', - project: 'test', - endpoint: 'https://abc.com', - attributes: [{ value: 'value' }], - }); + const client = asInternal( + new RPClient({ + apiKey: 'test', + project: 'test', + endpoint: 'https://abc.com', + attributes: [{ value: 'value' }], + }), + ); jest.spyOn(client.helpers, 'now').mockReturnValue(12345734); const mergeLaunches = client.getMergeLaunchesRequest(['12345', '12346']); @@ -607,13 +786,15 @@ describe('ReportPortal javascript client', () => { name: 'launch', attributes: [{ value: 'value' }], }; - const client = new RPClient({ - apiKey: 'test', - project: 'test', - endpoint: 'https://abc.com', - launch: 'launch', - attributes: [{ value: 'value' }], - }); + const client = asInternal( + new RPClient({ + apiKey: 'test', + project: 'test', + endpoint: 'https://abc.com', + launch: 'launch', + attributes: [{ value: 'value' }], + }), + ); jest.spyOn(client.helpers, 'now').mockReturnValue(12345734); const mergeLaunches = client.getMergeLaunchesRequest(['12345', '12346']); @@ -636,12 +817,14 @@ describe('ReportPortal javascript client', () => { }; it('should call rest client with required parameters', async () => { - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - isLaunchMergeRequired: true, - }); + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + isLaunchMergeRequired: true, + }), + ); const myPromise = Promise.resolve({ id: 'testidlaunch' }); jest.spyOn(client.restClient, 'create').mockReturnValue(myPromise); @@ -655,22 +838,27 @@ describe('ReportPortal javascript client', () => { const promise = client.mergeLaunches(); - expect(promise.then).toBeDefined(); + expect(promise!.then).toBeDefined(); await promise; expect(client.restClient.create).toHaveBeenCalledWith('launch/merge', fakeMergeDataRQ); }); it('should not call rest client if something went wrong', async () => { - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - isLaunchMergeRequired: true, - }); + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + isLaunchMergeRequired: true, + }), + ); - jest.spyOn(client.helpers, 'readLaunchesFromFile').mockReturnValue('launchUUid'); - jest.spyOn(client.restClient, 'retrieveSyncAPI').mockResolvedValue(); - jest.spyOn(client.restClient, 'create').mockRejectedValue(); + // The original test returns a bare string instead of the declared string[]. + jest + .spyOn(client.helpers, 'readLaunchesFromFile') + .mockReturnValue('launchUUid' as unknown as string[]); + jest.spyOn(client.restClient, 'retrieveSyncAPI').mockResolvedValue(undefined); + jest.spyOn(client.restClient, 'create').mockRejectedValue(undefined); await client.mergeLaunches(); expect(client.restClient.create).not.toHaveBeenCalled(); @@ -692,19 +880,21 @@ describe('ReportPortal javascript client', () => { describe('getPromiseFinishAllItems', () => { it('should return promise', (done) => { - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); - client.map = { + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); + client.map = asMap({ id1: { children: ['child1'], }, child1: { promiseFinish: Promise.resolve(), }, - }; + }); const promise = client.getPromiseFinishAllItems('id1'); @@ -715,19 +905,21 @@ describe('ReportPortal javascript client', () => { describe('updateLaunch', () => { it('should call getRejectAnswer if there is no launchTempId with suitable launchTempId', () => { - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); - client.map = { + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); + client.map = asMap({ id1: { children: ['child1'], }, - }; + }); jest.spyOn(client, 'getRejectAnswer').mockImplementation(); - client.updateLaunch('id2', { some: 'data' }); + client.updateLaunch('id2', { some: 'data' } as unknown as UpdateLaunchOptions); expect(client.getRejectAnswer).toHaveBeenCalledWith( 'id2', @@ -736,20 +928,22 @@ describe('ReportPortal javascript client', () => { }); it('should return object with tempId and promise', () => { - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); - client.map = { + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); + client.map = asMap({ id1: { children: ['child1'], promiseFinish: Promise.resolve(), }, - }; - jest.spyOn(client.restClient, 'update').mockResolvedValue(); + }); + jest.spyOn(client.restClient, 'update').mockResolvedValue(undefined); - const result = client.updateLaunch('id1', { some: 'data' }); + const result = client.updateLaunch('id1', { some: 'data' } as unknown as UpdateLaunchOptions); expect(result.tempId).toEqual('id1'); return expect(result.promise).resolves.toBeUndefined(); @@ -758,19 +952,21 @@ describe('ReportPortal javascript client', () => { describe('startTestItem', () => { it('should call getRejectAnswer if there is no launchTempId with suitable launchTempId', () => { - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); - client.map = { + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); + client.map = asMap({ id1: { children: ['child1'], }, - }; + }); jest.spyOn(client, 'getRejectAnswer').mockImplementation(); - client.startTestItem({}, 'id2'); + client.startTestItem({} as unknown as StartTestItemOptions, 'id2'); expect(client.getRejectAnswer).toHaveBeenCalledWith( 'id2', @@ -779,56 +975,66 @@ describe('ReportPortal javascript client', () => { }); it('should call getRejectAnswer if launchObj.finishSend is true', () => { - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); - client.map = { + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); + client.map = asMap({ id1: { children: ['child1'], finishSend: true, }, - }; + }); jest.spyOn(client, 'getRejectAnswer').mockImplementation(); const error = new Error( 'Launch with tempId "id1" is already finished, you can not add an item to it', ); - client.startTestItem({}, 'id1'); + client.startTestItem({} as unknown as StartTestItemOptions, 'id1'); expect(client.getRejectAnswer).toHaveBeenCalledWith('id1', error); }); it('should call getRejectAnswer if there is no parentObj with suitable parentTempId', () => { - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); - client.map = { + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); + client.map = asMap({ id: { children: ['id1'], }, id1: { children: ['child1'], }, - }; + }); jest.spyOn(client, 'getRejectAnswer').mockImplementation(); const error = new Error('Item with tempId "id3" not found'); - client.startTestItem({ testCaseId: 'testCaseId' }, 'id1', 'id3'); + client.startTestItem( + { testCaseId: 'testCaseId' } as unknown as StartTestItemOptions, + 'id1', + 'id3', + ); expect(client.getRejectAnswer).toHaveBeenCalledWith('id1', error); }); it('should return object with tempId and promise', () => { - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); - client.map = { + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); + client.map = asMap({ id: { children: ['id1', '4n5pxq24kpiob12og9'], promiseStart: Promise.resolve(), @@ -840,24 +1046,30 @@ describe('ReportPortal javascript client', () => { '4n5pxq24kpiob12og9': { promiseStart: Promise.resolve(), }, - }; - jest.spyOn(client.itemRetriesChainMap, 'get').mockResolvedValue(); + }); + jest.spyOn(client.itemRetriesChainMap, 'get').mockResolvedValue(undefined); jest.spyOn(client.restClient, 'create').mockResolvedValue({}); jest.spyOn(client, 'getUniqId').mockReturnValue('4n5pxq24kpiob12og9'); - const result = client.startTestItem({ retry: false }, 'id1', 'id'); + const result = client.startTestItem( + { retry: false } as unknown as StartTestItemOptions, + 'id1', + 'id', + ); expect(result.tempId).toEqual('4n5pxq24kpiob12og9'); return expect(result.promise).resolves.toBeDefined(); }); it('should get previous try promise from itemRetriesChainMap if retry is true', async () => { - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); - client.map = { + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); + client.map = asMap({ id: { children: ['id1', '4n5pxq24kpiob12og9'], promiseStart: Promise.resolve(), @@ -869,33 +1081,35 @@ describe('ReportPortal javascript client', () => { '4n5pxq24kpiob12og9': { promiseStart: Promise.resolve(), }, - }; + }); jest.spyOn(client, 'calculateItemRetriesChainMapKey').mockReturnValue('id1__name__'); jest.spyOn(client, 'getUniqId').mockReturnValue('4n5pxq24kpiob12og9'); jest.spyOn(client.itemRetriesChainMap, 'get').mockImplementation(); jest.spyOn(client.restClient, 'create').mockResolvedValue({}); - await client.startTestItem({ retry: true }, 'id1').promise; + await client.startTestItem({ retry: true } as unknown as StartTestItemOptions, 'id1').promise; expect(client.itemRetriesChainMap.get).toHaveBeenCalledWith('id1__name__'); }); it('should include retry_of with the previous item UUID when retry is true', async () => { - const client = new RPClient({ - apiKey: 'test', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); + const client = asInternal( + new RPClient({ + apiKey: 'test', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); const prevRealId = 'prev-item-uuid-1234'; const prevPromise = Promise.resolve({ id: prevRealId }); - client.map = { + client.map = asMap({ launchId: { children: [], finishSend: false, promiseStart: Promise.resolve(), }, - }; + }); const itemKey = client.calculateItemRetriesChainMapKey( 'launchId', undefined, 'My test', undefined, @@ -906,7 +1120,7 @@ describe('ReportPortal javascript client', () => { jest.spyOn(client, 'getUniqId').mockReturnValue('newTempId'); await client.startTestItem( - { name: 'My test', type: 'STEP', retry: true }, + { name: 'My test', type: 'STEP', retry: true } as unknown as StartTestItemOptions, 'launchId', ).promise; @@ -917,23 +1131,25 @@ describe('ReportPortal javascript client', () => { }); it('should not include retry_of when retry is true but no previous entry exists', async () => { - const client = new RPClient({ - apiKey: 'test', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); - client.map = { + const client = asInternal( + new RPClient({ + apiKey: 'test', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); + client.map = asMap({ launchId: { children: [], finishSend: false, promiseStart: Promise.resolve(), }, - }; + }); jest.spyOn(client.restClient, 'create').mockResolvedValue({ id: 'new-item-uuid' }); jest.spyOn(client, 'getUniqId').mockReturnValue('newTempId'); await client.startTestItem( - { name: 'My test', type: 'STEP', retry: true }, + { name: 'My test', type: 'STEP', retry: true } as unknown as StartTestItemOptions, 'launchId', ).promise; @@ -944,19 +1160,21 @@ describe('ReportPortal javascript client', () => { }); it('should not include retry_of when retry is false', async () => { - const client = new RPClient({ - apiKey: 'test', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); + const client = asInternal( + new RPClient({ + apiKey: 'test', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); const prevPromise = Promise.resolve({ id: 'prev-item-uuid-1234' }); - client.map = { + client.map = asMap({ launchId: { children: [], finishSend: false, promiseStart: Promise.resolve(), }, - }; + }); const itemKey = client.calculateItemRetriesChainMapKey( 'launchId', undefined, 'My test', undefined, ); @@ -966,7 +1184,7 @@ describe('ReportPortal javascript client', () => { jest.spyOn(client, 'getUniqId').mockReturnValue('newTempId'); await client.startTestItem( - { name: 'My test', type: 'STEP', retry: false }, + { name: 'My test', type: 'STEP', retry: false } as unknown as StartTestItemOptions, 'launchId', ).promise; @@ -979,16 +1197,18 @@ describe('ReportPortal javascript client', () => { describe('finishTestItem', () => { it('should call getRejectAnswer if there is no itemObj with suitable itemTempId', () => { - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); - client.map = { + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); + client.map = asMap({ id1: { children: ['child1'], }, - }; + }); jest.spyOn(client, 'getRejectAnswer').mockImplementation(); client.finishTestItem('id2', {}); @@ -1000,12 +1220,14 @@ describe('ReportPortal javascript client', () => { }); it('should call finishTestItemPromiseStart with correct parameters', (done) => { - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); - client.map = { + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); + client.map = asMap({ id: { children: ['id1'], promiseFinish: Promise.resolve(), @@ -1014,7 +1236,7 @@ describe('ReportPortal javascript client', () => { children: ['child1'], promiseFinish: Promise.resolve(), }, - }; + }); client.launchUuid = 'launchUuid'; jest.spyOn(client, 'cleanMap').mockImplementation(); jest.spyOn(client, 'finishTestItemPromiseStart').mockImplementation(); @@ -1034,12 +1256,14 @@ describe('ReportPortal javascript client', () => { }); it('should call finishTestItemPromiseStart with correct parameters if smt went wrong', (done) => { - const client = new RPClient({ - apiKey: 'startLaunchTest', - endpoint: 'https://rp.us/api/v1', - project: 'tst', - }); - client.map = { + const client = asInternal( + new RPClient({ + apiKey: 'startLaunchTest', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); + client.map = asMap({ id: { children: ['id1'], promiseFinish: Promise.resolve(), @@ -1048,7 +1272,7 @@ describe('ReportPortal javascript client', () => { children: ['child1'], promiseFinish: Promise.reject(), }, - }; + }); client.launchUuid = 'launchUuid'; jest.spyOn(client, 'cleanMap').mockImplementation(); jest.spyOn(client, 'finishTestItemPromiseStart').mockImplementation(); @@ -1069,33 +1293,36 @@ describe('ReportPortal javascript client', () => { }); it('should automatically add NOT_ISSUE when status is SKIPPED and skippedIsNotIssue is true', function (done) { - const mockClient = new RPClient( - { - apiKey: 'test', - endpoint: 'https://reportportal-stub-url', - launch: 'test launch', - project: 'test project', - skippedIsNotIssue: true, - }, - { name: 'test', version: '1.0.0' }, + const mockClient = asInternal( + new RPClient( + { + apiKey: 'test', + endpoint: 'https://reportportal-stub-url', + launch: 'test launch', + project: 'test project', + skippedIsNotIssue: true, + }, + { name: 'test', version: '1.0.0' }, + ), ); const spyFinishTestItemPromiseStart = jest .spyOn(mockClient, 'finishTestItemPromiseStart') .mockImplementation(() => {}); - mockClient.map = { + mockClient.map = asMap({ testItemId: { children: [], finishSend: false, promiseFinish: Promise.resolve(), resolveFinish: () => {}, }, - }; + }); + // The original test uses the raw 'skipped' string rather than the STATUSES enum member. const finishTestItemRQ = { status: 'skipped', - }; + } as unknown as FinishTestItemOptions; mockClient.finishTestItem('testItemId', finishTestItemRQ); @@ -1117,33 +1344,36 @@ describe('ReportPortal javascript client', () => { }); it('should not add NOT_ISSUE when status is SKIPPED and skippedIsNotIssue is false', function (done) { - const mockClient = new RPClient( - { - apiKey: 'test', - endpoint: 'https://reportportal-stub-url', - launch: 'test launch', - project: 'test project', - skippedIsNotIssue: false, - }, - { name: 'test', version: '1.0.0' }, + const mockClient = asInternal( + new RPClient( + { + apiKey: 'test', + endpoint: 'https://reportportal-stub-url', + launch: 'test launch', + project: 'test project', + skippedIsNotIssue: false, + }, + { name: 'test', version: '1.0.0' }, + ), ); const spyFinishTestItemPromiseStart = jest .spyOn(mockClient, 'finishTestItemPromiseStart') .mockImplementation(() => {}); - mockClient.map = { + mockClient.map = asMap({ testItemId: { children: [], finishSend: false, promiseFinish: Promise.resolve(), resolveFinish: () => {}, }, - }; + }); + // The original test uses the raw 'skipped' string rather than the STATUSES enum member. const finishTestItemRQ = { status: 'skipped', - }; + } as unknown as FinishTestItemOptions; mockClient.finishTestItem('testItemId', finishTestItemRQ); @@ -1172,22 +1402,26 @@ describe('ReportPortal javascript client', () => { describe('saveLog', () => { it('should return object with tempId and promise', () => { - const client = new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }); - client.map = { + const client = asInternal( + new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }), + ); + client.map = asMap({ id1: { children: ['child1'], }, - }; + }); jest.spyOn(client, 'getUniqId').mockReturnValue('4n5pxq24kpiob12og9'); - jest.spyOn(client.restClient, 'create').mockResolvedValue(); + jest.spyOn(client.restClient, 'create').mockResolvedValue(undefined); const result = client.saveLog( { promiseStart: Promise.resolve(), realId: 'realId', children: [], - }, - client.restClient.create, + } as unknown as ItemObj, + // The original test passes `restClient.create` directly; its signature differs from the + // (itemUuid, launchUuid) request function saveLog expects, but it's mocked out anyway. + client.restClient.create as unknown as RequestPromiseFunc, ); expect(result.tempId).toEqual('4n5pxq24kpiob12og9'); @@ -1197,17 +1431,29 @@ describe('ReportPortal javascript client', () => { describe('sendLog', () => { it('should return sendLogWithFile if fileObj is not empty', () => { - const client = new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }); - jest.spyOn(client, 'sendLogWithFile').mockReturnValue('sendLogWithFile'); + const client = asInternal( + new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }), + ); + jest + .spyOn(client, 'sendLogWithFile') + .mockReturnValue('sendLogWithFile' as unknown as ClientResponse); - const result = client.sendLog('itemTempId', { message: 'message' }, { name: 'name' }); + const result = client.sendLog( + 'itemTempId', + { message: 'message' }, + { name: 'name' } as unknown as Attachment, + ); expect(result).toEqual('sendLogWithFile'); }); it('should return sendLogWithoutFile if fileObj is empty', () => { - const client = new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }); - jest.spyOn(client, 'sendLogWithoutFile').mockReturnValue('sendLogWithoutFile'); + const client = asInternal( + new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }), + ); + jest + .spyOn(client, 'sendLogWithoutFile') + .mockReturnValue('sendLogWithoutFile' as unknown as ClientResponse); const result = client.sendLog('itemTempId', { message: 'message' }); @@ -1217,12 +1463,14 @@ describe('ReportPortal javascript client', () => { describe('sendLogWithoutFile', () => { it('should call getRejectAnswer if there is no itemObj with suitable itemTempId', () => { - const client = new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }); - client.map = { + const client = asInternal( + new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }), + ); + client.map = asMap({ id1: { children: ['child1'], }, - }; + }); jest.spyOn(client, 'getRejectAnswer').mockImplementation(); client.sendLogWithoutFile('itemTempId', {}); @@ -1234,13 +1482,15 @@ describe('ReportPortal javascript client', () => { }); it('should return saveLog function', () => { - const client = new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }); - client.map = { + const client = asInternal( + new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }), + ); + client.map = asMap({ itemTempId: { children: ['child1'], }, - }; - jest.spyOn(client, 'saveLog').mockReturnValue('saveLog'); + }); + jest.spyOn(client, 'saveLog').mockReturnValue('saveLog' as unknown as ClientResponse); const result = client.sendLogWithoutFile('itemTempId', {}); @@ -1250,14 +1500,17 @@ describe('ReportPortal javascript client', () => { describe('sendLogWithFile', () => { it('should call getRejectAnswer if there is no itemObj with suitable itemTempId', () => { - const client = new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }); - client.map = { + const client = asInternal( + new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }), + ); + client.map = asMap({ id1: { children: ['child1'], }, - }; + }); jest.spyOn(client, 'getRejectAnswer').mockImplementation(); + // The original test calls this without the (required) fileObj argument. client.sendLogWithFile('itemTempId', {}); expect(client.getRejectAnswer).toHaveBeenCalledWith( @@ -1267,13 +1520,15 @@ describe('ReportPortal javascript client', () => { }); it('should return saveLog function', () => { - const client = new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }); - client.map = { + const client = asInternal( + new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }), + ); + client.map = asMap({ itemTempId: { children: ['child1'], }, - }; - jest.spyOn(client, 'saveLog').mockReturnValue('saveLog'); + }); + jest.spyOn(client, 'saveLog').mockReturnValue('saveLog' as unknown as ClientResponse); const result = client.sendLogWithFile('itemTempId', {}); @@ -1283,31 +1538,39 @@ describe('ReportPortal javascript client', () => { describe('getRequestLogWithFile', () => { it('should return restClient.create', () => { - const client = new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }); - client.map = { + const client = asInternal( + new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }), + ); + client.map = asMap({ id1: { children: ['child1'], }, - }; - jest.spyOn(client, 'buildMultiPartStream').mockReturnValue(); + }); + jest + .spyOn(client, 'buildMultiPartStream') + .mockReturnValue(undefined as unknown as Buffer); jest.spyOn(client.restClient, 'create').mockResolvedValue('value'); - const result = client.getRequestLogWithFile({}, { name: 'name' }); + const result = client.getRequestLogWithFile({}, { name: 'name' } as unknown as Attachment); return expect(result).resolves.toBe('value'); }); it('should return restClient.create with error', () => { - const client = new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }); - client.map = { + const client = asInternal( + new RPClient({ apiKey: 'any', endpoint: 'https://rp.api', project: 'prj' }), + ); + client.map = asMap({ id1: { children: ['child1'], }, - }; - jest.spyOn(client, 'buildMultiPartStream').mockReturnValue(); - jest.spyOn(client.restClient, 'create').mockRejectedValue(); + }); + jest + .spyOn(client, 'buildMultiPartStream') + .mockReturnValue(undefined as unknown as Buffer); + jest.spyOn(client.restClient, 'create').mockRejectedValue(undefined); - const result = client.getRequestLogWithFile({}, { name: 'name' }); + const result = client.getRequestLogWithFile({}, { name: 'name' } as unknown as Attachment); expect(result.catch).toBeDefined(); }); diff --git a/__tests__/rest.spec.js b/__tests__/rest.spec.ts similarity index 73% rename from __tests__/rest.spec.js rename to __tests__/rest.spec.ts index ec8e2222..174ed6fb 100644 --- a/__tests__/rest.spec.js +++ b/__tests__/rest.spec.ts @@ -1,9 +1,32 @@ -const nock = require('nock'); -const isEqual = require('lodash/isEqual'); -const http = require('http'); -const RestClient = require('../src/lib/rest'); -const OAuthInterceptor = require('../src/lib/oauth'); -const logger = require('../src/lib/logger'); +import nock from 'nock'; +import isEqual from 'lodash/isEqual'; +import http from 'http'; +import type { AxiosInstance, AxiosRequestConfig } from 'axios'; +import type { IAxiosRetryConfig } from 'axios-retry'; +import RestClient from '../src/rest'; +import OAuthInterceptor from '../src/oauth'; +import * as logger from '../src/logger'; +import type { RestClientConfig, RestClientOptions } from '../src/models/config'; + +// `baseURL` / `headers` / `restClientConfig` / `axiosInstance` are private on RestClient, and +// `getRestConfig` returns a plain stripped-down object rather than a true AxiosRequestConfig - +// this gives the tests typed access to that internal shape without loosening the real API. +interface RestClientInternal { + baseURL: string; + headers?: Record; + restClientConfig?: RestClientConfig; + axiosInstance: AxiosInstance; + buildPath(path?: string): string; + getRestConfig(): Record; + getRetryConfig(): IAxiosRetryConfig; + retrieve(path: string, options?: AxiosRequestConfig): Promise; + create(path: string, data: unknown, options?: AxiosRequestConfig): Promise; + update(path: string, data: unknown, options?: AxiosRequestConfig): Promise; + delete(path: string, data: unknown, options?: AxiosRequestConfig): Promise; + retrieveSyncAPI(path: string, options?: AxiosRequestConfig): Promise; +} +const asInternal = (client: RestClient): RestClientInternal => + client as unknown as RestClientInternal; describe('RestClient', () => { const originalEnv = process.env; @@ -24,7 +47,7 @@ describe('RestClient', () => { process.env = originalEnv; }); - const options = { + const options: RestClientOptions = { baseURL: 'http://report-portal-host:8080/api/v1', headers: { 'User-Agent': 'NodeJS', @@ -37,16 +60,18 @@ describe('RestClient', () => { timeout: 0, }, }; - const noOptions = {}; - const getRetryAttempts = (client) => client.getRetryConfig().retries + 1; - const restClient = new RestClient(options); - const restClientNoRetry = new RestClient({ - ...options, - restClientConfig: { - ...options.restClientConfig, - retry: 0, - }, - }); + const noOptions: AxiosRequestConfig = {}; + const getRetryAttempts = (client: RestClientInternal) => client.getRetryConfig().retries! + 1; + const restClient = asInternal(new RestClient(options)); + const restClientNoRetry = asInternal( + new RestClient({ + ...options, + restClientConfig: { + ...options.restClientConfig, + retry: 0, + }, + }), + ); const retryAttempts = getRetryAttempts(restClient); const unathorizedError = { @@ -68,29 +93,31 @@ describe('RestClient', () => { it('adds Logger to axios instance if enabled', () => { const spyLogger = jest.spyOn(logger, 'addLogger').mockReturnValue(); - const optionsWithLoggerEnabled = { + const optionsWithLoggerEnabled: RestClientOptions = { ...options, restClientConfig: { ...options.restClientConfig, debug: true, }, }; - const client = new RestClient(optionsWithLoggerEnabled); + const client = asInternal(new RestClient(optionsWithLoggerEnabled)); 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', - }, - }); + const client = asInternal( + new RestClient({ + ...options, + oauthConfig: { + tokenEndpoint: 'https://auth.example.com/oauth/token', + username: 'user', + password: 'password', + clientId: 'client-id', + }, + }), + ); expect(attachSpy).toHaveBeenCalledWith(client.axiosInstance); @@ -104,28 +131,30 @@ describe('RestClient', () => { const mathRandomSpy = jest.spyOn(Math, 'random').mockImplementationOnce(() => 0); expect(retryConfig.retries).toBe(6); - expect(retryAttempts).toBe(retryConfig.retries + 1); + expect(retryAttempts).toBe(retryConfig.retries! + 1); expect(retryConfig.shouldResetTimeout).toBe(true); - expect(retryConfig.retryDelay(1)).toBe(200); + expect(retryConfig.retryDelay!(1, undefined as never)).toBe(200); mathRandomSpy.mockImplementationOnce(() => 1); - expect(retryConfig.retryDelay(4)).toBeCloseTo(1600 * 0.6); + expect(retryConfig.retryDelay!(4, undefined as never)).toBeCloseTo(1600 * 0.6); mathRandomSpy.mockImplementationOnce(() => 0); - expect(retryConfig.retryDelay(10)).toBe(5000); + expect(retryConfig.retryDelay!(10, undefined as never)).toBe(5000); mathRandomSpy.mockRestore(); }); it('uses custom retry attempts when a numeric value is provided', (done) => { const customRetries = 2; - const client = new RestClient({ - ...options, - restClientConfig: { - ...options.restClientConfig, - retry: customRetries, - }, - }); + const client = asInternal( + new RestClient({ + ...options, + restClientConfig: { + ...options.restClientConfig, + retry: customRetries, + }, + }), + ); expect(getRetryAttempts(client)).toBe(customRetries + 1); const scope = nock(options.baseURL) @@ -133,7 +162,7 @@ describe('RestClient', () => { .times(getRetryAttempts(client)) .replyWithError(netErrConnectionResetError); - client.retrieve('users/custom-retry-number', noOptions).catch((error) => { + client.retrieve('users/custom-retry-number', noOptions).catch((error: Error) => { expect(error instanceof Error).toBeTruthy(); expect(error.message).toMatch(netErrConnectionResetError.message); expect(scope.isDone()).toBeTruthy(); @@ -144,17 +173,19 @@ describe('RestClient', () => { it('merges retry configuration object from settings', () => { const customDelay = () => 250; - const client = new RestClient({ - ...options, - restClientConfig: { - ...options.restClientConfig, - retry: { - retries: 4, - retryDelay: customDelay, - shouldResetTimeout: true, + const client = asInternal( + new RestClient({ + ...options, + restClientConfig: { + ...options.restClientConfig, + retry: { + retries: 4, + retryDelay: customDelay, + shouldResetTimeout: true, + }, }, - }, - }); + }), + ); const retryConfig = client.getRetryConfig(); @@ -168,24 +199,29 @@ describe('RestClient', () => { const timeoutError = { message: 'timeout of 1ms exceeded', }; - expect(retryConfig.retryCondition(timeoutError)).toBe(true); + expect(retryConfig.retryCondition!(timeoutError as never)).toBe(true); }); it('handles undefined restClientConfig without crashing during retries', () => { - const client = new RestClient({ - baseURL: options.baseURL, - headers: options.headers, - restClientConfig: undefined, - }); + const client = asInternal( + new RestClient({ + baseURL: options.baseURL, + headers: options.headers, + restClientConfig: undefined, + }), + ); const retryConfig = client.getRetryConfig(); expect(retryConfig.retries).toBe(6); const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); - const onRetry = retryConfig.onRetry; - + const onRetry = retryConfig.onRetry!; + expect(() => { - onRetry(1, { code: 'ECONNABORTED' }, { method: 'GET', url: 'http://test.com' }); + onRetry(1, { code: 'ECONNABORTED' } as never, { + method: 'GET', + url: 'http://test.com', + } as never); }).not.toThrow(); consoleSpy.mockRestore(); @@ -202,24 +238,28 @@ describe('RestClient', () => { describe('getRestConfig', () => { it("return {} in case agent property doesn't exist", () => { - const client = new RestClient({ - ...options, - restClientConfig: {}, - }); + const client = asInternal( + new RestClient({ + ...options, + restClientConfig: {}, + }), + ); expect(client.getRestConfig()).toEqual({}); }); it('creates object with correct properties with http(s) agent', () => { - const client = new RestClient({ - ...options, - restClientConfig: { - agent: { - rejectUnauthorized: false, + const client = asInternal( + new RestClient({ + ...options, + restClientConfig: { + agent: { + rejectUnauthorized: false, + }, + timeout: 10000, }, - timeout: 10000, - }, - }); + }), + ); const config = client.getRestConfig(); @@ -247,7 +287,7 @@ describe('RestClient', () => { it('catches NETWORK errors', (done) => { const scope = nock(options.baseURL).get('/users').replyWithError(netErrConnectionResetError); - restClientNoRetry.retrieve('users', noOptions).catch((error) => { + restClientNoRetry.retrieve('users', noOptions).catch((error: Error) => { expect(error instanceof Error).toBeTruthy(); expect(error.message).toMatch(netErrConnectionResetError.message); expect(scope.isDone()).toBeTruthy(); @@ -259,7 +299,7 @@ describe('RestClient', () => { it('catches API errors', (done) => { const scope = nock(options.baseURL).get('/users').reply(403, unathorizedError); - restClientNoRetry.retrieve('users', noOptions).catch((error) => { + restClientNoRetry.retrieve('users', noOptions).catch((error: Error) => { expect(error instanceof Error).toBeTruthy(); expect(error.message).toMatch(unauthorizedErrorMessage); expect(scope.isDone()).toBeTruthy(); @@ -293,7 +333,7 @@ describe('RestClient', () => { .post('/users', (body) => isEqual(body, newUser)) .replyWithError(netErrConnectionResetError); - restClientNoRetry.create('users', newUser, noOptions).catch((error) => { + restClientNoRetry.create('users', newUser, noOptions).catch((error: Error) => { expect(error instanceof Error).toBeTruthy(); expect(error.message).toMatch(netErrConnectionResetError.message); expect(scope.isDone()).toBeTruthy(); @@ -309,7 +349,7 @@ describe('RestClient', () => { .post('/users', (body) => isEqual(body, newUser)) .reply(403, unathorizedError); - restClientNoRetry.create('users', newUser, noOptions).catch((error) => { + restClientNoRetry.create('users', newUser, noOptions).catch((error: Error) => { expect(error instanceof Error).toBeTruthy(); expect(error.message).toMatch(unauthorizedErrorMessage); expect(scope.isDone()).toBeTruthy(); @@ -343,7 +383,7 @@ describe('RestClient', () => { .put('/users/1', (body) => isEqual(body, newUserInfo)) .replyWithError(netErrConnectionResetError); - restClientNoRetry.update('users/1', newUserInfo, noOptions).catch((error) => { + restClientNoRetry.update('users/1', newUserInfo, noOptions).catch((error: Error) => { expect(error instanceof Error).toBeTruthy(); expect(error.message).toMatch(netErrConnectionResetError.message); expect(scope.isDone()).toBeTruthy(); @@ -359,7 +399,7 @@ describe('RestClient', () => { .put('/users/1', (body) => isEqual(body, newUserInfo)) .reply(403, unathorizedError); - restClientNoRetry.update('users/1', newUserInfo, noOptions).catch((error) => { + restClientNoRetry.update('users/1', newUserInfo, noOptions).catch((error: Error) => { expect(error instanceof Error).toBeTruthy(); expect(error.message).toMatch(unauthorizedErrorMessage); expect(scope.isDone()).toBeTruthy(); @@ -391,7 +431,7 @@ describe('RestClient', () => { .delete('/users/1') .replyWithError(netErrConnectionResetError); - restClientNoRetry.delete('users/1', emptyBody, noOptions).catch((error) => { + restClientNoRetry.delete('users/1', emptyBody, noOptions).catch((error: Error) => { expect(error instanceof Error).toBeTruthy(); expect(error.message).toMatch(netErrConnectionResetError.message); expect(scope.isDone()).toBeTruthy(); @@ -405,7 +445,7 @@ describe('RestClient', () => { const scope = nock(options.baseURL).delete('/users/1').reply(403, unathorizedError); - restClientNoRetry.delete('users/1', emptyBody, noOptions).catch((error) => { + restClientNoRetry.delete('users/1', emptyBody, noOptions).catch((error: Error) => { expect(error instanceof Error).toBeTruthy(); expect(error.message).toMatch(unauthorizedErrorMessage); expect(scope.isDone()).toBeTruthy(); @@ -432,7 +472,7 @@ describe('RestClient', () => { it('catches NETWORK errors', (done) => { const scope = nock(options.baseURL).get('/users').replyWithError(netErrConnectionResetError); - restClientNoRetry.retrieveSyncAPI('users', noOptions).catch((error) => { + restClientNoRetry.retrieveSyncAPI('users', noOptions).catch((error: Error) => { expect(error instanceof Error).toBeTruthy(); expect(error.message).toMatch(netErrConnectionResetError.message); expect(scope.isDone()).toBeTruthy(); @@ -444,7 +484,7 @@ describe('RestClient', () => { it('catches API errors', (done) => { const scope = nock(options.baseURL).get('/users').reply(403, unathorizedError); - restClientNoRetry.retrieveSyncAPI('users', noOptions).catch((error) => { + restClientNoRetry.retrieveSyncAPI('users', noOptions).catch((error: Error) => { expect(error instanceof Error).toBeTruthy(); expect(error.message).toMatch(unauthorizedErrorMessage); expect(scope.isDone()).toBeTruthy(); diff --git a/__tests__/statistics.spec.js b/__tests__/statistics.spec.ts similarity index 68% rename from __tests__/statistics.spec.js rename to __tests__/statistics.spec.ts index 0f2047a0..46689fe8 100644 --- a/__tests__/statistics.spec.js +++ b/__tests__/statistics.spec.ts @@ -1,10 +1,11 @@ -const axios = require('axios'); -const Statistics = require('../src/statistics/statistics'); -const { MEASUREMENT_ID, API_KEY } = require('../src/statistics/constants'); +import axios, { AxiosResponse } from 'axios'; +import Statistics from '../src/statistics/statistics'; +import { MEASUREMENT_ID, API_KEY } from '../src/statistics/constants'; +import type { AgentParams } from '../src/models/common'; 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 agentParams = { +const agentParams: AgentParams = { name: 'AgentName', version: 'AgentVersion', }; @@ -42,41 +43,36 @@ const agentRequestValidation = expect.objectContaining({ events: expect.arrayContaining([expect.objectContaining(agentEventValidationObject)]), }); +// The client doesn't do anything with the resolved response, it just awaits it not throwing - +// so a minimal stand-in cast to AxiosResponse is enough to satisfy the mock's return type. +const fakeAxiosResponse = { send: () => {} } as unknown as AxiosResponse; + describe('Statistics', () => { afterEach(() => { jest.clearAllMocks(); }); it('should send proper event to axios', async () => { - jest.spyOn(axios, 'post').mockReturnValue({ - send: () => {}, // eslint-disable-line - }); + const postSpy = jest.spyOn(axios, 'post').mockResolvedValue(fakeAxiosResponse); const statistics = new Statistics(eventName, agentParams); await statistics.trackEvent(); - expect(axios.post).toHaveBeenCalledTimes(1); - expect(axios.post).toHaveBeenCalledWith(url, agentRequestValidation); + expect(postSpy).toHaveBeenCalledTimes(1); + expect(postSpy).toHaveBeenCalledWith(url, agentRequestValidation); }); - [ - undefined, - {}, - { - name: null, - version: null, - }, - ].forEach((params) => { + ( + [undefined, {}, { name: null, version: null }] as unknown as (AgentParams | undefined)[] + ).forEach((params) => { it(`should not fail if agent params: ${JSON.stringify(params)}`, async () => { - jest.spyOn(axios, 'post').mockReturnValue({ - send: () => {}, // eslint-disable-line - }); + const postSpy = jest.spyOn(axios, 'post').mockResolvedValue(fakeAxiosResponse); const statistics = new Statistics(eventName, params); await statistics.trackEvent(); - expect(axios.post).toHaveBeenCalledTimes(1); - expect(axios.post).toHaveBeenCalledWith(url, baseRequestValidation); + expect(postSpy).toHaveBeenCalledTimes(1); + expect(postSpy).toHaveBeenCalledWith(url, baseRequestValidation); }); it('Should properly handle errors if any', async () => { @@ -94,16 +90,14 @@ describe('Statistics', () => { describe('setInstanceID', () => { it('should set instanceID in event params', async () => { - jest.spyOn(axios, 'post').mockReturnValue({ - send: () => {}, // eslint-disable-line - }); + const postSpy = jest.spyOn(axios, 'post').mockResolvedValue(fakeAxiosResponse); const statistics = new Statistics(eventName, agentParams); statistics.setInstanceID('test-instance-id'); await statistics.trackEvent(); - expect(axios.post).toHaveBeenCalledTimes(1); - expect(axios.post).toHaveBeenCalledWith( + expect(postSpy).toHaveBeenCalledTimes(1); + expect(postSpy).toHaveBeenCalledWith( url, expect.objectContaining({ events: expect.arrayContaining([ @@ -118,15 +112,13 @@ describe('Statistics', () => { }); it('should not include instanceID if setInstanceID was not called', async () => { - jest.spyOn(axios, 'post').mockReturnValue({ - send: () => {}, // eslint-disable-line - }); + const postSpy = jest.spyOn(axios, 'post').mockResolvedValue(fakeAxiosResponse); const statistics = new Statistics(eventName, agentParams); await statistics.trackEvent(); - expect(axios.post).toHaveBeenCalledTimes(1); - const callArgs = axios.post.mock.calls[0][1]; + expect(postSpy).toHaveBeenCalledTimes(1); + const callArgs = postSpy.mock.calls[0][1] as { events: { params: Record }[] }; expect(callArgs.events[0].params).not.toHaveProperty('instanceID'); }); }); diff --git a/jest.config.js b/jest.config.js index 3273f319..d8bc89ba 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,20 +1,21 @@ module.exports = { transform: { - '^.+\\.ts$': ['ts-jest', { diagnostics: false, tsconfig: 'tsconfig.json' }], - '^.+\\.js$': 'babel-jest', + '^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.spec.json' }], }, moduleFileExtensions: ['ts', 'js', 'json'], - testRegex: '/__tests__/.*\\.(test|spec).js$', + testRegex: '/__tests__/.*\\.(test|spec).ts$', testEnvironment: 'node', 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', + 'src/**/*.ts', + '!src/statistics/**', + '!src/types/**', + '!src/logger.ts', + '!src/pjson.ts', + '!src/models/**', + '!src/constants/index.ts', + '!src/constants/launchModes.ts', + '!src/constants/logLevels.ts', + '!src/constants/testItemTypes.ts', ], coverageThreshold: { global: { diff --git a/package-lock.json b/package-lock.json index b348d265..647a6311 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ }, "devDependencies": { "@types/jest": "^29.5.12", + "@types/lodash": "^4.17.25", "@types/node": "^18.19.8", "@typescript-eslint/eslint-plugin": "5.62.0", "@typescript-eslint/parser": "^5.62.0", @@ -1408,6 +1409,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "18.19.130", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", diff --git a/package.json b/package.json index e91fe887..0c90b358 100644 --- a/package.json +++ b/package.json @@ -5,73 +5,91 @@ "author": "ReportPortal.io", "scripts": { "codegraph": "bash scripts/codegraph.sh", - "build": "npm run clean && tsc", - "clean": "rimraf ./build", + "typecheck": "tsc --noEmit", + "build": "npm run clean && tsc && npm run facades", + "facades": "node scripts/generate-resolver-facades.js", + "clean": "node scripts/generate-resolver-facades.js --clean && rimraf ./build", "lint": "eslint \"src/**/*.ts\"", "format": "npm run lint -- --fix", - "test": "jest", - "test:coverage": "jest --coverage", + "test": "npm run typecheck && jest", + "test:coverage": "npm run typecheck && jest --coverage", "prepublishOnly": "npm run build" }, - "directories": { - "lib": "./build/lib" - }, "files": [ "/build", - "/VERSION" + "/VERSION", + "/helpers.js", + "/helpers.d.ts", + "/constants.js", + "/constants.d.ts", + "/models.d.ts", + "/publicReportingAPI.js", + "/publicReportingAPI.d.ts" ], - "main": "./build/lib/report-portal-client", - "types": "./build/lib/report-portal-client.d.ts", + "main": "./build/report-portal-client", + "types": "./build/report-portal-client.d.ts", "exports": { ".": { - "types": "./build/lib/report-portal-client.d.ts", - "import": "./build/lib/report-portal-client.js", - "require": "./build/lib/report-portal-client.js" + "types": "./build/report-portal-client.d.ts", + "import": "./build/report-portal-client.js", + "require": "./build/report-portal-client.js" }, "./constants": { - "types": "./build/lib/constants/index.d.ts", - "import": "./build/lib/constants/index.js", - "require": "./build/lib/constants/index.js" + "types": "./build/constants/index.d.ts", + "import": "./build/constants/index.js", + "require": "./build/constants/index.js" }, "./models": { - "types": "./build/lib/models/index.d.ts", - "import": "./build/lib/models/index.js", - "require": "./build/lib/models/index.js" + "types": "./build/models/index.d.ts" }, "./helpers": { - "types": "./build/lib/helpers.d.ts", - "import": "./build/lib/helpers.js", - "require": "./build/lib/helpers.js" + "types": "./build/helpers.d.ts", + "import": "./build/helpers.js", + "require": "./build/helpers.js" }, "./publicReportingAPI": { - "types": "./build/lib/publicReportingAPI.d.ts", - "import": "./build/lib/publicReportingAPI.js", - "require": "./build/lib/publicReportingAPI.js" + "types": "./build/publicReportingAPI.d.ts", + "import": "./build/publicReportingAPI.js", + "require": "./build/publicReportingAPI.js" }, "./package.json": "./package.json", "./lib/constants": { - "types": "./build/lib/constants/index.d.ts", - "import": "./build/lib/constants/index.js", - "require": "./build/lib/constants/index.js" - }, - "./lib/models": { - "types": "./build/lib/models/index.d.ts", - "import": "./build/lib/models/index.js", - "require": "./build/lib/models/index.js" + "types": "./build/constants/index.d.ts", + "import": "./build/constants/index.js", + "require": "./build/constants/index.js" }, "./lib/*": { - "types": "./build/lib/*.d.ts", - "import": "./build/lib/*.js", - "require": "./build/lib/*.js" + "types": "./build/*.d.ts", + "import": "./build/*.js", + "require": "./build/*.js" }, "./lib/*.js": { - "types": "./build/lib/*.d.ts", - "import": "./build/lib/*.js", - "require": "./build/lib/*.js" + "types": "./build/*.d.ts", + "import": "./build/*.js", + "require": "./build/*.js" + } + }, + "typesVersions": { + "*": { + "constants": [ + "./build/constants/index.d.ts" + ], + "models": [ + "./build/models/index.d.ts" + ], + "helpers": [ + "./build/helpers.d.ts" + ], + "publicReportingAPI": [ + "./build/publicReportingAPI.d.ts" + ], + "lib/*": [ + "./build/*" + ] } }, "engines": { - "node": ">=14.17.0" + "node": ">=16.0.0" }, "dependencies": { "axios": "^1.15.2", @@ -85,6 +103,7 @@ "license": "Apache-2.0", "devDependencies": { "@types/jest": "^29.5.12", + "@types/lodash": "^4.17.25", "@types/node": "^18.19.8", "@typescript-eslint/eslint-plugin": "5.62.0", "@typescript-eslint/parser": "^5.62.0", diff --git a/scripts/generate-resolver-facades.js b/scripts/generate-resolver-facades.js new file mode 100644 index 00000000..5d7b2504 --- /dev/null +++ b/scripts/generate-resolver-facades.js @@ -0,0 +1,95 @@ +/** + * Generates filesystem facades for the package's public subpath aliases. + * + * `helpers`, `constants`, `models` and `publicReportingAPI` are mapped to `build` + * through `package.json#exports` / `#typesVersions`. Node, TypeScript and bundlers all read + * those maps, but tools that resolve imports by walking the filesystem do not — most notably + * `eslint-import-resolver-node`, the default resolver of `eslint-plugin-import`, which + * reports `import/no-unresolved` for these subpath imports. + * + * To keep those tools working without any consumer-side configuration, this script writes a + * thin file at each alias location that simply re-exports the real module. The files are + * never loaded at runtime (the `exports` map always wins); they only give filesystem-based + * resolvers something to find. + * + * The alias list is intentionally fixed and small — this does not attempt to mirror every + * internal module under `build` (see DEV_GUIDE.md#subpath-facades for why). Everything + * written here is gitignored and regenerated on every build; nothing is meant to be edited + * or committed by hand. + */ + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); +const BUILD_DIR = path.join(ROOT, 'build'); + +const toPosix = (p) => p.split(path.sep).join('/'); + +/** + * Public subpath alias -> module it re-exports, relative to `build`. + * + * `typesOnly: true` means `package.json#exports` has no `import` / `require` condition for + * that alias (it re-exports pure TypeScript types with no runtime value — see + * DEV_GUIDE.md#subpath-facades) — Node rejects `require()`/`import()` of it regardless of + * whether a `.js` file physically exists, so writing one would be misleading; only the + * `.d.ts` facade is generated. + */ +const ALIASES = { + helpers: { module: 'helpers' }, + constants: { module: 'constants/index' }, + models: { module: 'models/index', typesOnly: true }, + publicReportingAPI: { module: 'publicReportingAPI' }, +}; + +const facadeFiles = () => + Object.entries(ALIASES).flatMap(([name, { typesOnly }]) => (typesOnly ? [`${name}.d.ts`] : [`${name}.js`, `${name}.d.ts`])); + +const clean = () => { + const files = facadeFiles().filter((file) => fs.existsSync(path.join(ROOT, file))); + files.forEach((file) => fs.rmSync(path.join(ROOT, file))); + return files.length; +}; + +/** + * Declaration facades must mirror the export style of their target: `export =` modules + * cannot be re-exported with `export *`, and `export *` never carries a default export. + */ +const declarationFacade = (specifier, declaration) => { + const source = fs.readFileSync(declaration, 'utf8'); + + if (/^export = /m.test(source)) { + return `import target = require('${specifier}');\nexport = target;\n`; + } + + const lines = [`export * from '${specifier}';`]; + if (/^export default /m.test(source)) { + lines.push(`export { default } from '${specifier}';`); + } + return `${lines.join('\n')}\n`; +}; + +const generate = () => { + if (!fs.existsSync(BUILD_DIR)) { + throw new Error(`Nothing to generate from: ${toPosix(path.relative(ROOT, BUILD_DIR))} is missing, run "tsc" first.`); + } + + Object.entries(ALIASES).forEach(([name, { module, typesOnly }]) => { + const specifier = `./build/${module}`; + + if (!typesOnly) { + fs.writeFileSync(path.join(ROOT, `${name}.js`), `module.exports = require('${specifier}');\n`); + } + + const declaration = path.join(BUILD_DIR, `${module}.d.ts`); + fs.writeFileSync(path.join(ROOT, `${name}.d.ts`), declarationFacade(specifier, declaration)); + }); + + return Object.keys(ALIASES).length; +}; + +if (process.argv.includes('--clean')) { + console.log(`Removed ${clean()} generated resolver facade file(s).`); +} else { + console.log(`Generated ${generate()} resolver facade(s).`); +} diff --git a/src/lib/commons/config.ts b/src/commons/config.ts similarity index 99% rename from src/lib/commons/config.ts rename to src/commons/config.ts index 3f3bc2f0..25634e77 100644 --- a/src/lib/commons/config.ts +++ b/src/commons/config.ts @@ -84,6 +84,7 @@ const DEFAULT_CLIENT_CONFIG: NormalizedClientConfig = { export const getClientConfig = (options: ReportPortalConfig): NormalizedClientConfig => { let calculatedOptions = DEFAULT_CLIENT_CONFIG; + try { if (typeof options !== 'object') { throw new ReportPortalValidationError('`options` must be an object.'); diff --git a/src/lib/commons/errors.ts b/src/commons/errors.ts similarity index 100% rename from src/lib/commons/errors.ts rename to src/commons/errors.ts diff --git a/src/lib/constants/events.ts b/src/constants/events.ts similarity index 100% rename from src/lib/constants/events.ts rename to src/constants/events.ts diff --git a/src/lib/constants/index.ts b/src/constants/index.ts similarity index 87% rename from src/lib/constants/index.ts rename to src/constants/index.ts index 2a3e4739..0f25aa21 100644 --- a/src/lib/constants/index.ts +++ b/src/constants/index.ts @@ -2,5 +2,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 { MERGE_TYPES } from './mergeTypes'; export { EVENTS } from './events'; export { OUTPUT_TYPES, OutputHandler } from './outputs'; diff --git a/src/lib/constants/launchModes.ts b/src/constants/launchModes.ts similarity index 100% rename from src/lib/constants/launchModes.ts rename to src/constants/launchModes.ts diff --git a/src/lib/constants/logLevels.ts b/src/constants/logLevels.ts similarity index 100% rename from src/lib/constants/logLevels.ts rename to src/constants/logLevels.ts diff --git a/src/constants/mergeTypes.ts b/src/constants/mergeTypes.ts new file mode 100644 index 00000000..dace5cf4 --- /dev/null +++ b/src/constants/mergeTypes.ts @@ -0,0 +1,4 @@ +export enum MERGE_TYPES { + BASIC = 'BASIC', + DEEP = 'DEEP', +} diff --git a/src/lib/constants/outputs.ts b/src/constants/outputs.ts similarity index 100% rename from src/lib/constants/outputs.ts rename to src/constants/outputs.ts diff --git a/src/lib/constants/statuses.ts b/src/constants/statuses.ts similarity index 100% rename from src/lib/constants/statuses.ts rename to src/constants/statuses.ts diff --git a/src/lib/constants/testItemTypes.ts b/src/constants/testItemTypes.ts similarity index 100% rename from src/lib/constants/testItemTypes.ts rename to src/constants/testItemTypes.ts diff --git a/src/lib/helpers.ts b/src/helpers.ts similarity index 100% rename from src/lib/helpers.ts rename to src/helpers.ts diff --git a/src/lib/models/responses.ts b/src/lib/models/responses.ts deleted file mode 100644 index 8995a111..00000000 --- a/src/lib/models/responses.ts +++ /dev/null @@ -1,45 +0,0 @@ -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/src/lib/logger.ts b/src/logger.ts similarity index 100% rename from src/lib/logger.ts rename to src/logger.ts diff --git a/src/lib/models/common.ts b/src/models/common.ts similarity index 100% rename from src/lib/models/common.ts rename to src/models/common.ts diff --git a/src/lib/models/config.ts b/src/models/config.ts similarity index 100% rename from src/lib/models/config.ts rename to src/models/config.ts diff --git a/src/lib/models/index.ts b/src/models/index.ts similarity index 100% rename from src/lib/models/index.ts rename to src/models/index.ts diff --git a/src/lib/models/reporting.ts b/src/models/reporting.ts similarity index 100% rename from src/lib/models/reporting.ts rename to src/models/reporting.ts diff --git a/src/lib/models/requests.ts b/src/models/requests.ts similarity index 97% rename from src/lib/models/requests.ts rename to src/models/requests.ts index 09413e34..0daec14d 100644 --- a/src/lib/models/requests.ts +++ b/src/models/requests.ts @@ -1,6 +1,7 @@ import { Attachment, Attribute, Issue } from './common'; import { LAUNCH_MODES } from '../constants/launchModes'; import { LOG_LEVELS } from '../constants/logLevels'; +import { MERGE_TYPES } from '../constants/mergeTypes'; import { STATUSES } from '../constants/statuses'; import { TEST_ITEM_TYPES } from '../constants/testItemTypes'; @@ -85,11 +86,6 @@ export interface LogOptions { file?: Attachment; } -export enum MERGE_TYPES { - BASIC = 'BASIC', - DEEP = 'DEEP', -} - export interface MergeLaunchesOptions { extendSuitesDescription?: boolean; description?: string; diff --git a/src/models/responses.ts b/src/models/responses.ts new file mode 100644 index 00000000..4ceaad55 --- /dev/null +++ b/src/models/responses.ts @@ -0,0 +1,92 @@ +export interface StartLaunchResponse { + id: string; + number?: number; + [key: string]: unknown; +} + +export interface FinishLaunchResponse { + id?: string; + number?: number; + link?: string; + [key: string]: unknown; +} + +export interface StartTestItemResponse { + id: string; + [key: string]: unknown; +} + +export interface FinishTestItemResponse { + message?: string; + [key: string]: unknown; +} + +/** Response of updating a launch. */ +export interface UpdateLaunchResponse { + 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; +} + +export interface LaunchResource { + owner?: string; + description?: string; + locked?: boolean; + id: number; + uuid: string; + name: string; + number: number; + startTime: string; + endTime?: string; + lastModified?: string; + status: string; + statistics?: { + executions?: Record; + defects?: Record; + }; + attributes?: Array<{ key?: string; value: string }>; + mode?: 'DEFAULT' | 'DEBUG'; + analysing?: string[]; + approximateDuration?: number; + hasRetries?: boolean; + rerun?: boolean; + metadata?: Record; + retentionPolicy?: 'IMPORTANT' | 'REGULAR'; + [key: string]: unknown; +} + +/** A page of launches. */ +export interface PageLaunchResource { + content: LaunchResource[]; + page: { + number?: number; + size?: number; + totalElements?: number; + totalPages?: number; + hasNext?: boolean; + }; + [key: string]: unknown; +} diff --git a/src/lib/oauth.ts b/src/oauth.ts similarity index 100% rename from src/lib/oauth.ts rename to src/oauth.ts diff --git a/src/lib/pjson.ts b/src/pjson.ts similarity index 88% rename from src/lib/pjson.ts rename to src/pjson.ts index 00c08a18..6842d865 100644 --- a/src/lib/pjson.ts +++ b/src/pjson.ts @@ -7,7 +7,7 @@ interface PackageJson { } // 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/`). +// Works both from compiled output (`build/`) and from source when run via ts-jest (`src/`). function findPackageJson(dir: string): PackageJson { let current = dir; for (;;) { diff --git a/src/lib/proxyHelper.ts b/src/proxyHelper.ts similarity index 100% rename from src/lib/proxyHelper.ts rename to src/proxyHelper.ts diff --git a/src/lib/publicReportingAPI.ts b/src/publicReportingAPI.ts similarity index 100% rename from src/lib/publicReportingAPI.ts rename to src/publicReportingAPI.ts diff --git a/src/lib/report-portal-client.ts b/src/report-portal-client.ts similarity index 85% rename from src/lib/report-portal-client.ts rename to src/report-portal-client.ts index 98cd0a9a..650a3438 100644 --- a/src/lib/report-portal-client.ts +++ b/src/report-portal-client.ts @@ -3,8 +3,8 @@ 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 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'; @@ -21,28 +21,32 @@ import type { } from './models/requests'; import type { FinishLaunchResponse, + FinishTestItemResponse, LaunchSearchResponse, MergeLaunchesResponse, + PageLaunchResource, ServerInfoResponse, StartLaunchResponse, StartTestItemResponse, + UpdateLaunchResponse, } from './models/responses'; const MULTIPART_BOUNDARY = Math.floor(Math.random() * 10000000000).toString(); -type PromiseExecutor = ( - resolve: (value?: unknown) => void, - reject: (reason?: unknown) => void, +// Executor for a launch or item promise. +type PromiseExecutor = ( + resolve: (value: T) => void, + reject: (reason?: Error) => void, ) => void; -interface ItemObj { - promiseStart: Promise; +interface ItemObj { + promiseStart: Promise; realId: string; children: string[]; finishSend: boolean; - promiseFinish: Promise; - resolveFinish: (value?: unknown) => void; - rejectFinish: (reason?: unknown) => void; + promiseFinish: Promise; + resolveFinish: (value: T) => void; + rejectFinish: (reason?: Error) => void; } type RequestPromiseFunc = (itemUuid: string, launchUuid: string) => Promise; @@ -149,22 +153,22 @@ class RPClient { return randomUUID(); } - getRejectAnswer(tempId: string, error: Error): ClientResponse { + getRejectAnswer(tempId: string, error: Error): ClientResponse { return { tempId, - promise: Promise.reject(error), + promise: Promise.reject(error), }; } - getNewItemObj(startPromiseFunc: PromiseExecutor): ItemObj { - let resolveFinish!: (value?: unknown) => void; - let rejectFinish!: (reason?: unknown) => void; - const obj: ItemObj = { - promiseStart: new Promise(startPromiseFunc), + getNewItemObj(startPromiseFunc: PromiseExecutor): ItemObj { + let resolveFinish!: (value: T) => void; + let rejectFinish!: (reason?: Error) => void; + const obj: ItemObj = { + promiseStart: new Promise(startPromiseFunc), realId: '', children: [], finishSend: false, - promiseFinish: new Promise((resolve, reject) => { + promiseFinish: new Promise((resolve, reject) => { resolveFinish = resolve; rejectFinish = reject; }), @@ -180,11 +184,11 @@ class RPClient { }); } - checkConnect(): Promise { + 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, {}); + return this.restClient.request('GET', url, {}); } getServerInfoUrl(): string { @@ -215,12 +219,19 @@ class RPClient { /** * Start launch and report it. */ - startLaunch(launchDataRQ: StartLaunchOptions): ClientResponse { + startLaunch( + launchDataRQ: StartLaunchOptions, + ): ClientResponse { const tempId = this.getUniqId(); + // Result of starting or reusing a launch. + let launchObj: ItemObj; if (launchDataRQ.id) { this.logDebug(`Use existing launch with tempId ${tempId}`, launchDataRQ); - this.map[tempId] = this.getNewItemObj((resolve) => resolve(launchDataRQ)); + launchObj = this.getNewItemObj((resolve) => + resolve(launchDataRQ), + ); + this.map[tempId] = launchObj as ItemObj; this.map[tempId].realId = launchDataRQ.id; this.launchUuid = launchDataRQ.id; } else { @@ -243,44 +254,50 @@ class RPClient { attributes, }; - this.map[tempId] = this.getNewItemObj((resolve, reject) => { - const url = 'launch'; - this.logDebug(`Start launch with tempId ${tempId}`, launchData); - this.restClient.create(url, launchData).then( - (response) => { - this.map[tempId].realId = response.id; - this.launchUuid = response.id; - if (this.config.launchUuidPrint) { - this.config.launchUuidPrintOutput(this.launchUuid); - } + launchObj = this.getNewItemObj( + (resolve, reject) => { + const url = 'launch'; + this.logDebug(`Start launch with tempId ${tempId}`, launchData); + this.restClient.create(url, launchData).then( + (response) => { + this.map[tempId].realId = response.id; + this.launchUuid = response.id; + if (this.config.launchUuidPrint) { + this.config.launchUuidPrintOutput(this.launchUuid); + } - if (this.isLaunchMergeRequired) { - helpers.saveLaunchIdToFile(response.id); - } + if (this.isLaunchMergeRequired) { + helpers.saveLaunchIdToFile(response.id); + } - this.logDebug(`Success start launch with tempId ${tempId}`, response); - resolve(response); - }, - (error) => { - this.logDebug(`Error start launch with tempId ${tempId}`, error); - console.dir(error); - reject(error); - }, - ); - }); + this.logDebug(`Success start launch with tempId ${tempId}`, response); + resolve(response); + }, + (error) => { + this.logDebug(`Error start launch with tempId ${tempId}`, error); + console.dir(error); + reject(error); + }, + ); + }, + ); + this.map[tempId] = launchObj as ItemObj; } this.triggerStatisticsEvent().catch(console.error); return { tempId, - promise: this.map[tempId].promiseStart, + promise: launchObj.promiseStart, }; } /** * Finish launch. */ - finishLaunch(launchTempId: string, finishExecutionRQ: FinishLaunchOptions = {}): ClientResponse { - const launchObj = this.map[launchTempId]; + finishLaunch( + launchTempId: string, + finishExecutionRQ: FinishLaunchOptions = {}, + ): ClientResponse { + const launchObj = this.map[launchTempId] as ItemObj | undefined; if (!launchObj) { return this.getRejectAnswer( launchTempId, @@ -413,7 +430,10 @@ class RPClient { /** * Update launch. */ - updateLaunch(launchTempId: string, launchData: UpdateLaunchOptions): ClientResponse { + updateLaunch( + launchTempId: string, + launchData: UpdateLaunchOptions, + ): ClientResponse { const launchObj = this.map[launchTempId]; if (!launchObj) { return this.getRejectAnswer( @@ -421,9 +441,9 @@ class RPClient { new Error(`Launch with tempId "${launchTempId}" not found`), ); } - let resolvePromise!: (value?: unknown) => void; - let rejectPromise!: (reason?: unknown) => void; - const promise = new Promise((resolve, reject) => { + let resolvePromise!: (value: UpdateLaunchResponse) => void; + let rejectPromise!: (reason?: Error) => void; + const promise = new Promise((resolve, reject) => { resolvePromise = resolve; rejectPromise = reject; }); @@ -432,7 +452,7 @@ class RPClient { () => { const url = ['launch', launchObj.realId, 'update'].join('/'); this.logDebug(`Update launch with tempId ${launchTempId}`, launchData); - this.restClient.update(url, launchData).then( + this.restClient.update(url, launchData).then( (response) => { this.logDebug(`Launch with tempId ${launchTempId} were successfully updated`, response); resolvePromise(response); @@ -461,7 +481,7 @@ class RPClient { testItemDataRQ: StartTestItemOptions, launchTempId: string, parentTempId?: string, - ): ClientResponse { + ): ClientResponse { let parentMapId = launchTempId; const launchObj = this.map[launchTempId]; if (!launchObj) { @@ -509,7 +529,7 @@ class RPClient { const executionItemPromise = testItemDataRQ.retry && this.itemRetriesChainMap.get(itemKey); const tempId = this.getUniqId(); - this.map[tempId] = this.getNewItemObj((resolve, reject) => { + const itemObj = this.getNewItemObj((resolve, reject) => { (executionItemPromise || parentPromise).then( (prevResponse) => { const realLaunchId = this.map[launchTempId].realId; @@ -542,21 +562,25 @@ class RPClient { }, ); }); + this.map[tempId] = itemObj as ItemObj; this.map[parentMapId].children.push(tempId); this.itemRetriesChainKeyMapByTempId.set(tempId, itemKey); - this.itemRetriesChainMap.set(itemKey, this.map[tempId].promiseStart); + this.itemRetriesChainMap.set(itemKey, itemObj.promiseStart); return { tempId, - promise: this.map[tempId].promiseStart, + promise: itemObj.promiseStart, }; } /** * Finish Suite or Step level. */ - finishTestItem(itemTempId: string, finishTestItemRQ: FinishTestItemOptions = {}): ClientResponse { - const itemObj = this.map[itemTempId]; + finishTestItem( + itemTempId: string, + finishTestItemRQ: FinishTestItemOptions = {}, + ): ClientResponse { + const itemObj = this.map[itemTempId] as ItemObj | undefined; if (!itemObj) { return this.getRejectAnswer( itemTempId, @@ -773,8 +797,8 @@ class RPClient { return Buffer.concat(buffers); } - finishTestItemPromiseStart( - itemObj: ItemObj, + finishTestItemPromiseStart( + itemObj: ItemObj, itemTempId: string, finishTestItemData: FinishTestItemRQ, ): void { @@ -783,7 +807,7 @@ class RPClient { const url = ['item', itemObj.realId].join('/'); this.logDebug(`Finish test item with tempId ${itemTempId}`, itemObj); this.restClient - .update(url, Object.assign(finishTestItemData, { launchUuid: this.launchUuid })) + .update(url, Object.assign(finishTestItemData, { launchUuid: this.launchUuid })) .then( (response) => { this.logDebug(`Success finish item with tempId ${itemTempId}`, response); diff --git a/src/lib/rest.ts b/src/rest.ts similarity index 100% rename from src/lib/rest.ts rename to src/rest.ts diff --git a/src/statistics/constants.ts b/src/statistics/constants.ts index 2c4784f3..fa3c780a 100644 --- a/src/statistics/constants.ts +++ b/src/statistics/constants.ts @@ -1,6 +1,6 @@ import os from 'os'; import path from 'path'; -import { PJSON_NAME, PJSON_VERSION } from '../lib/pjson'; +import { PJSON_NAME, PJSON_VERSION } from '../pjson'; export const ENCODING = 'utf-8'; export { PJSON_NAME, PJSON_VERSION }; diff --git a/src/statistics/statistics.ts b/src/statistics/statistics.ts index 61eb11ab..de1f07c5 100644 --- a/src/statistics/statistics.ts +++ b/src/statistics/statistics.ts @@ -1,7 +1,7 @@ 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'; +import type { AgentParams } from '../models/common'; interface EventParams { interpreter: string | null; diff --git a/tsconfig.spec.json b/tsconfig.spec.json new file mode 100644 index 00000000..33eb27ca --- /dev/null +++ b/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "declaration": false, + "resolveJsonModule": true + }, + "include": ["src/**/*", "__tests__/**/*"] +} diff --git a/version_fragment b/version_fragment index 9eb7b90e..acb503f9 100644 --- a/version_fragment +++ b/version_fragment @@ -1 +1 @@ -patch +minor From 46cff7fefda6143967c86b65990f1839a574f431 Mon Sep 17 00:00:00 2001 From: maria-hambardzumian Date: Fri, 4 Sep 2026 17:20:07 +0400 Subject: [PATCH 8/9] Update CHANGELOG to reflect breaking change --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e590b608..017f67b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ notably `eslint-import-resolver-node`, the default resolver of `eslint-plugin-import` — will report [`import/no-unresolved`](https://github.com/import-js/eslint-plugin-import/issues/1810) for these paths. Switch to the new subpath aliases (`constants`, `models`, `helpers`, `publicReportingAPI`) instead. +- **Breaking Change** Dropped support for Node.js 14. The minimum supported Node.js + version is now 16.0.0. ### Added - `retry_of` property is now automatically included in the `startTestItem` request payload when `retry: true` and a previous attempt exists in the From 9bfb0a2fe005a7801068a9a28c8995c7f1090998 Mon Sep 17 00:00:00 2001 From: maria-hambardzumian Date: Fri, 4 Sep 2026 17:28:06 +0400 Subject: [PATCH 9/9] Update CHANGELOG to clarify breaking change regarding Node.js support --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 017f67b6..08b71fbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,7 @@ notably `eslint-import-resolver-node`, the default resolver of `eslint-plugin-import` — will report [`import/no-unresolved`](https://github.com/import-js/eslint-plugin-import/issues/1810) for these paths. Switch to the new subpath aliases (`constants`, `models`, `helpers`, `publicReportingAPI`) instead. -- **Breaking Change** Dropped support for Node.js 14. The minimum supported Node.js - version is now 16.0.0. +- **Breaking Change** Drop support of Node.js 14. The version [5.5.12](https://github.com/reportportal/client-javascript/releases/tag/v5.5.12) is the latest that supports it. The minimum supported Node.js version is now 16.0.0. ### Added - `retry_of` property is now automatically included in the `startTestItem` request payload when `retry: true` and a previous attempt exists in the