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/.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/.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/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/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7b343a3d..102eedff 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -51,6 +51,8 @@ jobs: registry-url: 'https://registry.npmjs.org' - name: Install dependencies run: npm install + - name: Build + run: npm run build - name: Publish to NPM run: | npm config list diff --git a/.gitignore b/.gitignore index 7a1c2746..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 b57a0da8..08b71fbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,23 @@ +### 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. +- **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 + 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 - -## [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`). diff --git a/DEV_GUIDE.md b/DEV_GUIDE.md new file mode 100644 index 00000000..79f21339 --- /dev/null +++ b/DEV_GUIDE.md @@ -0,0 +1,58 @@ +# Dev Guide + +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)) +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/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/VERSION b/VERSION index f1918b54..dc8bfb4f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.5.12 +5.5.13-SNAPSHOT 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 de0ad4c1..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('../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 d566182a..33dc1e3c 100644 --- a/__tests__/config.spec.js +++ b/__tests__/config.spec.ts @@ -1,8 +1,6 @@ -const { getClientConfig, getRequiredOption, getApiKey } = require('../lib/commons/config'); -const { - ReportPortalRequiredOptionError, - ReportPortalValidationError, -} = require('../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 86% rename from __tests__/helpers.spec.js rename to __tests__/helpers.spec.ts index fe650891..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('../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 }); }); @@ -46,11 +47,11 @@ describe('Helpers', () => { }); }); - describe('getSystemAttribute', () => { + describe('getSystemAttributes', () => { 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 = [ { @@ -75,7 +76,7 @@ describe('Helpers', () => { }, ]; - const attr = helpers.getSystemAttribute(); + const attr = helpers.getSystemAttributes(); expect(attr).toEqual(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 60% rename from __tests__/oauth.spec.js rename to __tests__/oauth.spec.ts index 156b7ffc..0e1e0589 100644 --- a/__tests__/oauth.spec.js +++ b/__tests__/oauth.spec.ts @@ -1,11 +1,64 @@ -const axios = require('axios'); -const { HttpsProxyAgent } = require('https-proxy-agent'); -const OAuthInterceptor = require('../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: 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', @@ -19,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', @@ -37,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); @@ -50,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(); }); @@ -59,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(); }); @@ -74,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', @@ -88,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'); @@ -101,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', @@ -133,7 +186,8 @@ 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, data: { error: 'invalid_grant' }, @@ -150,6 +204,45 @@ 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. + mockedAxios.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). + mockedAxios.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); + const { axiosInstance, getRejectionHandler } = createAxiosInstanceMock(); + + oauthInterceptor.attach(axiosInstance as unknown as AxiosInstance); + const error = new Error('request setup failed'); + + await expect(getRejectionHandler()(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({ @@ -166,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(); }); @@ -194,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( @@ -223,14 +298,15 @@ 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: { status: 400, data: { error: 'invalid_grant', error_description: 'refresh token expired' }, @@ -248,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); @@ -279,19 +355,21 @@ 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: { status: 400, data: { error: 'invalid_grant' }, }, }) .mockRejectedValueOnce({ + isAxiosError: true, response: { status: 401, data: { error: 'invalid_credentials' }, @@ -302,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"}', @@ -327,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, @@ -337,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' }); @@ -347,6 +425,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, + }, + }, + }); + mockedAxios.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); @@ -362,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, @@ -372,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 cd0b8583..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('../lib/proxyHelper'); +} from '../src/proxyHelper'; +import type { RestClientConfig } from '../src/models/config'; describe('proxyHelper', () => { const originalEnv = process.env; @@ -16,9 +17,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(() => { @@ -28,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); }); @@ -83,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(); }); @@ -100,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', @@ -113,7 +116,7 @@ describe('proxyHelper', () => { }); it('returns proxy URL from object config', () => { - const config = { + const config: RestClientConfig = { proxy: { protocol: 'https', host: 'proxy.example.com', @@ -127,7 +130,7 @@ describe('proxyHelper', () => { }); it('returns proxy URL with authentication', () => { - const config = { + const config: RestClientConfig = { proxy: { protocol: 'http', host: 'proxy.example.com', @@ -145,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, @@ -182,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', @@ -194,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); @@ -244,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', () => { @@ -264,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(); @@ -280,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', @@ -307,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, }; @@ -323,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 95% rename from __tests__/publicReportingAPI.spec.js rename to __tests__/publicReportingAPI.spec.ts index d7755b5c..f744dbdd 100644 --- a/__tests__/publicReportingAPI.spec.js +++ b/__tests__/publicReportingAPI.spec.ts @@ -1,5 +1,5 @@ -const PublicReportingAPI = require('../lib/publicReportingAPI'); -const { EVENTS } = require('../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 56% rename from __tests__/report-portal-client.spec.js rename to __tests__/report-portal-client.spec.ts index 47542799..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('../lib/report-portal-client'); -const helpers = require('../lib/helpers'); -const { OUTPUT_TYPES } = require('../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,15 +480,17 @@ 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); - jest.spyOn(helpers, 'getSystemAttribute').mockReturnValue(fakeSystemAttr); + jest.spyOn(helpers, 'getSystemAttributes').mockReturnValue(fakeSystemAttr); client.startLaunch({ startTime: time, @@ -359,15 +511,17 @@ 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); - jest.spyOn(helpers, 'getSystemAttribute').mockReturnValue(fakeSystemAttr); + jest.spyOn(helpers, 'getSystemAttributes').mockReturnValue(fakeSystemAttr); client.startLaunch({ startTime: time, @@ -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,30 +1081,134 @@ 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 = 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 = asMap({ + 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 } as unknown as StartTestItemOptions, + '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 = 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 } as unknown as StartTestItemOptions, + '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 = asInternal( + new RPClient({ + apiKey: 'test', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }), + ); + const prevPromise = Promise.resolve({ id: 'prev-item-uuid-1234' }); + client.map = asMap({ + 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 } as unknown as StartTestItemOptions, + 'launchId', + ).promise; + + expect(client.restClient.create).toHaveBeenCalledWith( + expect.any(String), + expect.not.objectContaining({ retry_of: expect.anything() }), + ); + }); }); 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', {}); @@ -904,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(), @@ -918,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(); @@ -938,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(), @@ -952,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(); @@ -973,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); @@ -1021,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); @@ -1076,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'); @@ -1101,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' }); @@ -1121,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', {}); @@ -1138,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', {}); @@ -1154,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( @@ -1171,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', {}); @@ -1187,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 69% rename from __tests__/rest.spec.js rename to __tests__/rest.spec.ts index 7f6bb063..174ed6fb 100644 --- a/__tests__/rest.spec.js +++ b/__tests__/rest.spec.ts @@ -1,11 +1,53 @@ -const nock = require('nock'); -const isEqual = require('lodash/isEqual'); -const http = require('http'); -const RestClient = require('../lib/rest'); -const logger = require('../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 options = { + 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: RestClientOptions = { baseURL: 'http://report-portal-host:8080/api/v1', headers: { 'User-Agent': 'NodeJS', @@ -18,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 = { @@ -49,17 +93,36 @@ 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 = asInternal( + 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', () => { @@ -68,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) @@ -97,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(); @@ -108,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(); @@ -132,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(); @@ -166,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(); @@ -211,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(); @@ -223,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(); @@ -257,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(); @@ -273,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(); @@ -307,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(); @@ -323,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(); @@ -355,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(); @@ -369,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(); @@ -396,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(); @@ -408,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 23d66ee5..46689fe8 100644 --- a/__tests__/statistics.spec.js +++ b/__tests__/statistics.spec.ts @@ -1,10 +1,11 @@ -const axios = require('axios'); -const Statistics = require('../statistics/statistics'); -const { MEASUREMENT_ID, API_KEY } = require('../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/index.d.ts b/index.d.ts deleted file mode 100644 index fedd35d0..00000000 --- a/index.d.ts +++ /dev/null @@ -1,443 +0,0 @@ -declare module '@reportportal/client-javascript' { - /** - * OAuth 2.0 configuration for password grant flow. - */ - export interface OAuthConfig { - /** - * OAuth 2.0 token endpoint URL for password grant flow. - */ - tokenEndpoint: string; - /** - * Username for OAuth 2.0 password grant. - */ - username: string; - /** - * Password for OAuth 2.0 password grant. - */ - password: string; - /** - * OAuth 2.0 client ID. - */ - clientId: string; - /** - * OAuth 2.0 client secret (optional, depending on your OAuth server configuration). - */ - clientSecret?: string; - /** - * OAuth 2.0 scope (optional, space-separated list of scopes). - */ - scope?: string; - } - - /** - * Proxy configuration object. - */ - export interface ProxyConfig { - /** - * Protocol for the proxy (http or https). - */ - protocol?: string; - /** - * Proxy host. - */ - host: string; - /** - * Proxy port. - */ - port: number; - /** - * Optional authentication for the proxy. - */ - auth?: { - username: string; - password: string; - }; - /** - * Optional debug logs for the proxy. - */ - debug?: boolean; - } - - /** - * REST client configuration options. - */ - export interface RestClientConfig { - /** - * Request timeout in milliseconds. - */ - timeout?: number; - /** - * Proxy configuration. Can be: - * - false: Disable proxy - * - string: Proxy URL (e.g., 'http://proxy.example.com:8080') - * - ProxyConfig object: Detailed proxy configuration - */ - proxy?: false | string | ProxyConfig; - /** - * Comma-separated list of domains to bypass proxy. - * Example: 'localhost,127.0.0.1,.example.com' - * This takes precedence over NO_PROXY environment variable. - */ - noProxy?: string; - /** - * Custom HTTP agent options. - */ - agent?: any; - /** - * Retry configuration. - */ - retry?: number | any; - /** - * Enable debug logging. - */ - debug?: boolean; - /** - * Any other axios configuration options. - */ - [key: string]: any; - } - - /** - * Configuration options for initializing the Report Portal client. - * - * @example API Key Authentication - * ```typescript - * const rp = new ReportPortalClient({ - * endpoint: 'https://your.reportportal.server/api/v1', - * project: 'your_project_name', - * apiKey: 'your_api_key', - * }); - * ``` - * - * @example OAuth 2.0 Authentication - * ```typescript - * const rp = new ReportPortalClient({ - * endpoint: 'https://your.reportportal.server/api/v1', - * project: 'your_project_name', - * oauth: { - * tokenEndpoint: 'https://your-oauth-server.com/oauth/token', - * username: 'your-username', - * password: 'your-password', - * clientId: 'your-client-id', - * clientSecret: 'your-client-secret', // optional - * scope: 'reportportal', // optional - * } - * }); - * ``` - * - * @example With Proxy Configuration - * ```typescript - * const rp = new ReportPortalClient({ - * endpoint: 'https://your.reportportal.server/api/v1', - * project: 'your_project_name', - * apiKey: 'your_api_key', - * restClientConfig: { - * proxy: { - * protocol: 'https', - * host: '127.0.0.1', - * port: 8080, - * }, - * noProxy: 'localhost,.local.domain', - * } - * }); - * ``` - */ - export interface ReportPortalConfig { - apiKey?: string; - endpoint: string; - launch: string; - project: string; - headers?: Record; - debug?: boolean; - isLaunchMergeRequired?: boolean; - launchUuidPrint?: boolean; - launchUuidPrintOutput?: string; - restClientConfig?: RestClientConfig; - token?: string; - skippedIsNotIssue?: boolean; - /** - * OAuth 2.0 configuration object. When provided, OAuth authentication will be used instead of API key. - */ - oauth?: OAuthConfig; - } - - /** - * Options to start a new launch. - * - * @example - * ```typescript - * const launch = rp.startLaunch({ - * name: 'My Test Launch', - * startTime: rp.helpers.now(), - * }); - * ``` - */ - export interface LaunchOptions { - name?: string; - startTime?: string | number; - description?: string; - attributes?: Array<{ key?: string; value?: string } | string>; - mode?: string; - id?: string; - } - - /** - * Options to start a new test item (e.g., test case or suite). - * - * @example - * ```typescript - * const testItem = rp.startTestItem({ - * name: 'My Test Case', - * type: 'TEST', - * startTime: rp.helpers.now(), - * }); - * ``` - */ - export interface StartTestItemOptions { - name: string; - type: string; - description?: string; - startTime?: string | number; - attributes?: Array<{ key?: string; value?: string } | string>; - hasStats?: boolean; - } - - /** - * Options to send logs to Report Portal. - * - * @example - * ```typescript - * await rp.sendLog(testItem.tempId, { - * level: 'INFO', - * message: 'Step executed successfully', - * time: rp.helpers.now(), - * }); - * ``` - */ - export interface LogOptions { - level?: string; - message?: string; - time?: string | number; - file?: { - name: string; - content: string; - type: string; - }; - } - - /** - * Options to finish a test item. - * - * @example - * ```typescript - * await rp.finishTestItem(testItem.tempId, { - * status: 'PASSED', - * endTime: rp.helpers.now(), - * }); - * ``` - */ - export interface FinishTestItemOptions { - status?: string; - endTime?: string | number; - issue?: { - issueType: string; - comment?: string; - externalSystemIssues?: Array; - }; - } - - /** - * Options to finish a launch. - * - * @example - * ```typescript - * await rp.finishLaunch(launch.tempId, { - * endTime: rp.helpers.now(), - * }); - * ``` - */ - export interface FinishLaunchOptions { - endTime?: string | number; - status?: string; - } - - /** - * Main Report Portal client for interacting with the API. - */ - export default class ReportPortalClient { - /** - * Initializes a new Report Portal client. - */ - constructor(config: ReportPortalConfig, agentInfo?: { name?: string; version?: string; framework_version?: string }); - - /** - * Starts a new launch. - * @example - * ```typescript - * const launchObj = rpClient.startLaunch({ - * name: 'Client test', - * startTime: rpClient.helpers.now(), - * description: 'description of the launch', - * attributes: [ - * { - * 'key': 'yourKey', - * 'value': 'yourValue' - * }, - * { - * 'value': 'yourValue' - * } - * ], - * //this param used only when you need client to send data into the existing launch - * id: 'id' - * }); - * await launchObj.promise; - * ``` - */ - startLaunch(options: LaunchOptions): { tempId: string; promise: Promise }; - - /** - * Finishes an active launch. - * @example - * ```typescript - * const launchFinishObj = rpClient.finishLaunch(launchObj.tempId, { - * endTime: rpClient.helpers.now() - * }); - * await launchFinishObj.promise; - * ``` - */ - finishLaunch( - launchId: string, - options?: FinishLaunchOptions, - ): { tempId: string; promise: Promise }; - - /** - * Update the launch data - * @example - * ```typescript - * const updateLunch = rpClient.updateLaunch( - * launchObj.tempId, - * { - * description: 'new launch description', - * attributes: [ - * { - * key: 'yourKey', - * value: 'yourValue' - * }, - * { - * value: 'yourValue' - * } - * ], - * mode: 'DEBUG' - * } - * ); - * await updateLaunch.promise; - * ``` - */ - updateLaunch( - launchId: string, - options: LaunchOptions, - ): { tempId: string; promise: Promise }; - - /** - * Starts a new test item under a launch or parent item. - * @example - * ```typescript - * const suiteObj = rpClient.startTestItem({ - * description: makeid(), - * name: makeid(), - * startTime: rpClient.helpers.now(), - * type: 'SUITE' - * }, launchObj.tempId); - * const stepObj = rpClient.startTestItem({ - * description: makeid(), - * name: makeid(), - * startTime: rpClient.helpers.now(), - * attributes: [ - * { - * key: 'yourKey', - * value: 'yourValue' - * }, - * { - * value: 'yourValue' - * } - * ], - * type: 'STEP' - * }, launchObj.tempId, suiteObj.tempId); - * ``` - */ - startTestItem( - options: StartTestItemOptions, - launchId: string, - parentId?: string, - ): { - tempId: string; - promise: Promise; - }; - - /** - * Finishes a test item. - * @example - * ```typescript - * const finishObj = rpClient.finishTestItem(itemObj.tempId, { - * status: 'failed' - * }); - * await finishObj.promise; - * ``` - */ - finishTestItem( - itemId: string, - options: FinishTestItemOptions, - ): { tempId: string; promise: Promise }; - - /** - * Sends a log entry to a test item. - * @example - * ```typescript - * const logObj = rpClient.sendLog(stepObj.tempId, { - * level: 'INFO', - * message: 'User clicks login button', - * time: rpClient.helpers.now() - * }); - * await logObj.promise; - * ``` - */ - sendLog( - itemId: string, - options: LogOptions, - file?: { name: string; content: string | Buffer; type: string }, - ): { tempId: string; promise: Promise }; - - /** - * Waits for all test items to be finished. - * @example - * ```typescript - * await agent.getPromiseFinishAllItems(agent.tempLaunchId); - * ``` - */ - getPromiseFinishAllItems(launchId: string): Promise; - - /** - * Check if connection is established - * @example - * ```typescript - * await agent.checkConnect(); - * ``` - */ - checkConnect(): Promise; - - helpers: { - /** - * Generate ISO timestamp - * @example - * ```typescript - * await rpClient.sendLog(stepObj.tempId, { - * level: 'INFO', - * message: 'User clicks login button', - * time: rpClient.helpers.now() - * }); - * ``` - */ - now(): string; - }; - } -} diff --git a/jest.config.js b/jest.config.js index 10700586..d8bc89ba 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,8 +1,22 @@ module.exports = { - moduleFileExtensions: ['js'], - testRegex: '/__tests__/.*\\.(test|spec).js$', + transform: { + '^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'json'], + testRegex: '/__tests__/.*\\.(test|spec).ts$', testEnvironment: 'node', - collectCoverageFrom: ['lib/**/*.js', '!lib/logger.js'], + collectCoverageFrom: [ + '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: { 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 c11aa894..647a6311 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", @@ -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", @@ -2195,9 +2203,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 +3623,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 +3993,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 +5572,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 ae86790b..0c90b358 100644 --- a/package.json +++ b/package.json @@ -4,26 +4,92 @@ "description": "ReportPortal client for Node.js", "author": "ReportPortal.io", "scripts": { - "build": "npm run clean && tsc", - "clean": "rimraf ./build", - "lint": "eslint ./statistics/**/* ./lib/**/*", + "codegraph": "bash scripts/codegraph.sh", + "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" - }, - "directories": { - "lib": "./lib" + "test": "npm run typecheck && jest", + "test:coverage": "npm run typecheck && jest --coverage", + "prepublishOnly": "npm run build" }, "files": [ - "/lib", - "/statistics", + "/build", "/VERSION", - "index.d.ts" + "/helpers.js", + "/helpers.d.ts", + "/constants.js", + "/constants.d.ts", + "/models.d.ts", + "/publicReportingAPI.js", + "/publicReportingAPI.d.ts" ], - "main": "./lib/report-portal-client", - "types": "./index.d.ts", + "main": "./build/report-portal-client", + "types": "./build/report-portal-client.d.ts", + "exports": { + ".": { + "types": "./build/report-portal-client.d.ts", + "import": "./build/report-portal-client.js", + "require": "./build/report-portal-client.js" + }, + "./constants": { + "types": "./build/constants/index.d.ts", + "import": "./build/constants/index.js", + "require": "./build/constants/index.js" + }, + "./models": { + "types": "./build/models/index.d.ts" + }, + "./helpers": { + "types": "./build/helpers.d.ts", + "import": "./build/helpers.js", + "require": "./build/helpers.js" + }, + "./publicReportingAPI": { + "types": "./build/publicReportingAPI.d.ts", + "import": "./build/publicReportingAPI.js", + "require": "./build/publicReportingAPI.js" + }, + "./package.json": "./package.json", + "./lib/constants": { + "types": "./build/constants/index.d.ts", + "import": "./build/constants/index.js", + "require": "./build/constants/index.js" + }, + "./lib/*": { + "types": "./build/*.d.ts", + "import": "./build/*.js", + "require": "./build/*.js" + }, + "./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", @@ -37,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/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 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/lib/commons/config.js b/src/commons/config.ts similarity index 69% rename from lib/commons/config.js rename to src/commons/config.ts index 0700b926..25634e77 100644 --- a/lib/commons/config.js +++ b/src/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,19 @@ 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 +94,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 +144,3 @@ const getClientConfig = (options) => { return calculatedOptions; }; - -module.exports = { - getClientConfig, - getRequiredOption, - getApiKey, - getOAuthConfig, -}; diff --git a/lib/commons/errors.js b/src/commons/errors.ts similarity index 56% rename from lib/commons/errors.js rename to src/commons/errors.ts index a47a6a33..79125e57 100644 --- a/lib/commons/errors.js +++ b/src/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/constants/events.ts b/src/constants/events.ts new file mode 100644 index 00000000..ca7ea9f6 --- /dev/null +++ b/src/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/constants/index.ts b/src/constants/index.ts new file mode 100644 index 00000000..0f25aa21 --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,7 @@ +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/constants/launchModes.ts b/src/constants/launchModes.ts new file mode 100644 index 00000000..fcc1b08c --- /dev/null +++ b/src/constants/launchModes.ts @@ -0,0 +1,4 @@ +export enum LAUNCH_MODES { + DEFAULT = 'DEFAULT', + DEBUG = 'DEBUG', +} diff --git a/src/constants/logLevels.ts b/src/constants/logLevels.ts new file mode 100644 index 00000000..f78a49a6 --- /dev/null +++ b/src/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/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/lib/constants/outputs.js b/src/constants/outputs.ts similarity index 64% rename from lib/constants/outputs.js rename to src/constants/outputs.ts index b5818e37..56eab5ed 100644 --- a/lib/constants/outputs.js +++ b/src/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/constants/statuses.ts b/src/constants/statuses.ts new file mode 100644 index 00000000..d146bfa4 --- /dev/null +++ b/src/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/constants/testItemTypes.ts b/src/constants/testItemTypes.ts new file mode 100644 index 00000000..79e03e29 --- /dev/null +++ b/src/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/helpers.ts similarity index 51% rename from lib/helpers.js rename to src/helpers.ts index 4a7dcac9..96036906 100644 --- a/lib/helpers.js +++ b/src/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/logger.ts similarity index 59% rename from lib/logger.js rename to src/logger.ts index 9eb54e69..9cc6a45f 100644 --- a/lib/logger.js +++ b/src/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/models/common.ts b/src/models/common.ts new file mode 100644 index 00000000..e6a20cc8 --- /dev/null +++ b/src/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/models/config.ts b/src/models/config.ts new file mode 100644 index 00000000..c2c1af83 --- /dev/null +++ b/src/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/models/index.ts b/src/models/index.ts new file mode 100644 index 00000000..5e31e20c --- /dev/null +++ b/src/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/models/reporting.ts b/src/models/reporting.ts new file mode 100644 index 00000000..5c6b0b10 --- /dev/null +++ b/src/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/models/requests.ts b/src/models/requests.ts new file mode 100644 index 00000000..0daec14d --- /dev/null +++ b/src/models/requests.ts @@ -0,0 +1,94 @@ +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'; + +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 interface MergeLaunchesOptions { + extendSuitesDescription?: boolean; + description?: string; + mergeType?: MERGE_TYPES; + name?: 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/lib/oauth.js b/src/oauth.ts similarity index 70% rename from lib/oauth.js rename to src/oauth.ts index 048a05da..11c38a2e 100644 --- a/lib/oauth.js +++ b/src/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/pjson.ts b/src/pjson.ts new file mode 100644 index 00000000..6842d865 --- /dev/null +++ b/src/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 (`build/`) and from source when run via ts-jest (`src/`). +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/proxyHelper.ts b/src/proxyHelper.ts new file mode 100644 index 00000000..233f13c0 --- /dev/null +++ b/src/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/publicReportingAPI.ts b/src/publicReportingAPI.ts new file mode 100644 index 00000000..69b1c282 --- /dev/null +++ b/src/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/report-portal-client.ts similarity index 59% rename from lib/report-portal-client.js rename to src/report-portal-client.ts index c8357725..650a3438 100644 --- a/lib/report-portal-client.js +++ b/src/report-portal-client.ts @@ -1,35 +1,90 @@ -/* 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, + FinishTestItemResponse, + LaunchSearchResponse, + MergeLaunchesResponse, + PageLaunchResource, + ServerInfoResponse, + StartLaunchResponse, + StartTestItemResponse, + UpdateLaunchResponse, +} from './models/responses'; const MULTIPART_BOUNDARY = Math.floor(Math.random() * 10000000000).toString(); +// Executor for a launch or item promise. +type PromiseExecutor = ( + resolve: (value: T) => void, + reject: (reason?: Error) => void, +) => void; + +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; + 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 +95,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 +122,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,63 +149,58 @@ class RPClient { }); } - getUniqId() { + getUniqId(): string { return randomUUID(); } - getRejectAnswer(tempId, error) { + getRejectAnswer(tempId: string, error: Error): ClientResponse { return { tempId, - promise: Promise.reject(error), + promise: Promise.reject(error), }; } - getNewItemObj(startPromiseFunc) { - let resolveFinish; - let rejectFinish; - const obj = { - 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; }), + 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, {}); + 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,54 +217,25 @@ 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(); + // 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 { - const systemAttr = helpers.getSystemAttribute(); + const systemAttr = helpers.getSystemAttributes(); if (this.config.skippedIsNotIssue === true) { const skippedIsNotIssueAttribute = { key: 'skippedIssue', @@ -238,52 +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. - * @param {string} launchTempId - temp launch id (returned in the query "startLaunch"). - * @param {Object} finishExecutionRQ - finish launch info should include time and status. - * finishExecutionRQ should look like this - * { - * "endTime": this.helper.now(), - * "status": "passed" or one of ‘passed’, ‘failed’, ‘stopped’, ‘skipped’, ‘interrupted’, ‘cancelled’ - * } - * @Returns {Object} - an object which contains a tempID and a promise */ - finishLaunch(launchTempId, finishExecutionRQ) { - const launchObj = this.map[launchTempId]; + finishLaunch( + launchTempId: string, + finishExecutionRQ: FinishLaunchOptions = {}, + ): ClientResponse { + const launchObj = this.map[launchTempId] as ItemObj | undefined; if (!launchObj) { return this.getRejectAnswer( launchTempId, @@ -300,7 +314,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 +347,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 +367,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 +415,25 @@ 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,9 +441,9 @@ class RPClient { new Error(`Launch with tempId "${launchTempId}" not found`), ); } - let resolvePromise; - let rejectPromise; - 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; }); @@ -463,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); @@ -486,33 +475,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 +501,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 }), @@ -560,18 +529,22 @@ 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; let url = 'item/'; if (parentTempId) { const realParentId = this.map[parentTempId].realId; url += `${realParentId}`; } + 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; @@ -589,42 +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. - * @param {string} itemTempId - temp item id (returned in the query "startTestItem"). - * @param {Object} finishTestItemRQ - object with item parameters. - * finishTestItemRQ should look like this - { - "endTime": this.helper.now(), - "issue": { - "comment": "string", - "externalSystemIssues": [ - { - "submitDate": 0, - "submitter": "string", - "systemId": "string", - "ticketId": "string", - "url": "string" - } - ], - "issueType": "string" - }, - "status": "passed" or one of 'passed', 'failed', 'stopped', 'skipped', 'interrupted', 'cancelled' - } - * @Returns {Object} - an object which contains a tempId and a promise - */ - finishTestItem(itemTempId, finishTestItemRQ) { - const itemObj = this.map[itemTempId]; + * Finish Suite or Step level. + */ + finishTestItem( + itemTempId: string, + finishTestItemRQ: FinishTestItemOptions = {}, + ): ClientResponse { + const itemObj = this.map[itemTempId] as ItemObj | undefined; if (!itemObj) { return this.getRejectAnswer( itemTempId, @@ -632,16 +588,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' }; } @@ -686,7 +639,7 @@ class RPClient { }; } - saveLog(itemObj, requestPromiseFunc) { + saveLog(itemObj: ItemObj, requestPromiseFunc: RequestPromiseFunc): ClientResponse { const tempId = this.getUniqId(); this.map[tempId] = this.getNewItemObj((resolve, reject) => { itemObj.promiseStart.then( @@ -724,7 +677,7 @@ class RPClient { }; } - sendLog(itemTempId, saveLogRQ, fileObj) { + sendLog(itemTempId: string, saveLogRQ: LogOptions, fileObj?: Attachment): ClientResponse { const saveLogData = { time: this.helpers.now(), message: '', @@ -740,17 +693,8 @@ class RPClient { /** * Send log of test results. - * @param {string} itemTempId - temp item id (returned in the query "startTestItem"). - * @param {Object} saveLogRQ - object with data of test result. - * saveLogRQ should look like this - * { - * level: 'error' or one of 'trace', 'debug', 'info', 'warn', 'error', '', - * message: 'string' (support markdown), - * time: this.helpers.now() - * } - * @Returns {Object} - an object which contains a tempId and a promise */ - sendLogWithoutFile(itemTempId, saveLogRQ) { + sendLogWithoutFile(itemTempId: string, saveLogRQ: LogOptions): ClientResponse { const itemObj = this.map[itemTempId]; if (!itemObj) { return this.getRejectAnswer( @@ -759,7 +703,7 @@ class RPClient { ); } - const requestPromise = (itemUuid, launchUuid) => { + const requestPromise: RequestPromiseFunc = (itemUuid, launchUuid) => { const url = 'log'; const isItemUuid = itemUuid !== launchUuid; return this.restClient.create( @@ -771,27 +715,9 @@ class RPClient { } /** - * Send log of test results with file. - * @param {string} itemTempId - temp item id (returned in the query "startTestItem"). - * @param {Object} saveLogRQ - object with data of test result. - * saveLogRQ should look like this - * { - * level: 'error' or one of 'trace', 'debug', 'info', 'warn', 'error', '', - * message: 'string' (support markdown), - * time: this.helpers.now() - * } - * @param {Object} fileObj - object with file data. - * fileObj should look like this - * { - name: 'string', - type: "image/png" or your file mimeType - (supported types: 'image/*', application/ ['xml', 'javascript', 'json', 'css', 'php'], - another format will be opened in a new browser tab ), - content: file - * } - * @Returns {Object} - an object which contains a tempId and a promise - */ - sendLogWithFile(itemTempId, saveLogRQ, fileObj) { + * Send log of test results with file. + */ + sendLogWithFile(itemTempId: string, saveLogRQ: LogOptions, fileObj: Attachment): ClientResponse { const itemObj = this.map[itemTempId]; if (!itemObj) { return this.getRejectAnswer( @@ -800,7 +726,7 @@ class RPClient { ); } - const requestPromise = (itemUuid, launchUuid) => { + const requestPromise: RequestPromiseFunc = (itemUuid, launchUuid) => { const isItemUuid = itemUuid !== launchUuid; return this.getRequestLogWithFile( @@ -812,10 +738,10 @@ class RPClient { return this.saveLog(itemObj, requestPromise); } - getRequestLogWithFile(saveLogRQ, fileObj) { + getRequestLogWithFile(saveLogRQ: LogOptions, fileObj: Attachment): Promise { const url = 'log'; // eslint-disable-next-line no-param-reassign - saveLogRQ.file = { name: fileObj.name }; + saveLogRQ.file = { name: fileObj.name } as Attachment; this.logDebug(`Save log with file: ${fileObj.name}`, saveLogRQ); return this.restClient .create(url, this.buildMultiPartStream([saveLogRQ], fileObj, MULTIPART_BOUNDARY), { @@ -833,12 +759,7 @@ class RPClient { }); } - // eslint-disable-next-line valid-jsdoc - /** - * - * @Private - */ - buildMultiPartStream(jsonPart, filePart, boundary) { + buildMultiPartStream(jsonPart: unknown[], filePart: Attachment, boundary: string): Buffer { const eol = '\r\n'; const bx = `--${boundary}`; const buffers = [ @@ -870,19 +791,23 @@ 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('/'); 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); @@ -902,4 +827,4 @@ class RPClient { } } -module.exports = RPClient; +export = RPClient; diff --git a/lib/rest.js b/src/rest.ts similarity index 54% rename from lib/rest.js rename to src/rest.ts index 5e3b002d..fa2d9693 100644 --- a/lib/rest.js +++ b/src/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..fa3c780a --- /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 '../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..de1f07c5 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 '../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__"] } 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