From 83431f827b5e52fdc180d833e4ee6b256e6db8d8 Mon Sep 17 00:00:00 2001 From: reportportal-agents-ai Date: Thu, 4 Jun 2026 01:23:51 +0000 Subject: [PATCH] [EPMRPP-113709] Introduce the retry_of property for JS agents (ai) --- CHANGELOG.md | 4 + .../report-portal-client-retry-of.spec.js | 316 ++++++++++++++++++ index.d.ts | 12 + lib/report-portal-client.js | 15 +- package-lock.json | 2 +- 5 files changed, 346 insertions(+), 3 deletions(-) create mode 100644 __tests__/report-portal-client-retry-of.spec.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7800f22..b45048a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ +## [Unreleased] +### Added +- `retryOf` property on test item start payload. When a test item is reported with `retry: true`, the client now includes the UUID of the previous retry attempt in the request, allowing the ReportPortal server to link retries without recomputing the chain. Improves backend ingestion performance. + ## [5.5.11] - 2026-05-22 ### Added - Google Analytics improvements. diff --git a/__tests__/report-portal-client-retry-of.spec.js b/__tests__/report-portal-client-retry-of.spec.js new file mode 100644 index 0000000..9ac5138 --- /dev/null +++ b/__tests__/report-portal-client-retry-of.spec.js @@ -0,0 +1,316 @@ +const RPClient = require('../lib/report-portal-client'); + +/** + * Tests for EPMRPP-113709 — Introduce the `retryOf` property for JS agents. + * + * These tests cover the new behaviour added to `RPClient.startTestItem`: + * 1. `itemRetriesChainMap` now stores `{ promiseStart, tempId }` (not the + * bare promise). + * 2. When `retry: true` and a previous attempt with a known `realId` + * exists, the outgoing `restClient.create` payload contains + * `retryOf: `. + * 3. When `retry` is `false` / omitted, `retryOf` is NOT set. + * 4. When the previous attempt's `realId` is empty, `retryOf` is NOT set. + * + * All test data is deterministic (fixed strings, fixed UUIDs, resolved + * promises). No `Math.random`, no `Date.now`, no timers. + */ +describe('RPClient.startTestItem — retryOf (EPMRPP-113709)', () => { + const makeClient = () => + new RPClient({ + apiKey: 'test-key', + endpoint: 'https://rp.us/api/v1', + project: 'tst', + }); + + /** Wire a client with the given map and stub restClient.create. */ + const seedClient = (client, { mapOverrides = {}, createResponse = { id: 'new-uuid' } } = {}) => { + client.map = { + launchTemp: { + children: ['parentTemp'], + promiseStart: Promise.resolve(), + realId: 'launch-real-id', + }, + parentTemp: { + children: [], + promiseStart: Promise.resolve(), + realId: 'parent-real-id', + }, + ...mapOverrides, + }; + client.launchUuid = 'launch-real-id'; + jest.spyOn(client.restClient, 'create').mockResolvedValue(createResponse); + return client; + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('itemRetriesChainMap value shape', () => { + it('stores an object with promiseStart and tempId for the new attempt', () => { + const client = makeClient(); + seedClient(client); + jest.spyOn(client, 'getUniqId').mockReturnValue('new-temp-id'); + jest + .spyOn(client, 'calculateItemRetriesChainMapKey') + .mockReturnValue('launchTemp__parentTemp__case-1__'); + + client.startTestItem( + { name: 'case-1', type: 'STEP', startTime: 1700000000000 }, + 'launchTemp', + 'parentTemp', + ); + + const entry = client.itemRetriesChainMap.get('launchTemp__parentTemp__case-1__'); + expect(entry).toBeDefined(); + expect(entry.tempId).toBe('new-temp-id'); + // promiseStart should be a thenable (Promise) — not the bare value. + expect(entry.promiseStart).toBeDefined(); + expect(typeof entry.promiseStart.then).toBe('function'); + // And it should reference the same promise we stored on the item map. + expect(entry.promiseStart).toBe(client.map['new-temp-id'].promiseStart); + }); + + it('keeps the itemRetriesChainKeyMapByTempId mapping intact', () => { + const client = makeClient(); + seedClient(client); + jest.spyOn(client, 'getUniqId').mockReturnValue('new-temp-id'); + jest + .spyOn(client, 'calculateItemRetriesChainMapKey') + .mockReturnValue('launchTemp__parentTemp__case-1__'); + + client.startTestItem( + { name: 'case-1', type: 'STEP', startTime: 1700000000000 }, + 'launchTemp', + 'parentTemp', + ); + + expect(client.itemRetriesChainKeyMapByTempId.get('new-temp-id')).toBe( + 'launchTemp__parentTemp__case-1__', + ); + }); + }); + + describe('retry: true', () => { + it('passes the previous item realId as retryOf in restClient.create payload', async () => { + const client = makeClient(); + seedClient(client, { + mapOverrides: { + prevTemp: { + children: [], + promiseStart: Promise.resolve(), + realId: 'prev-real-uuid', + }, + }, + }); + jest.spyOn(client, 'getUniqId').mockReturnValue('retry-temp-id'); + jest + .spyOn(client, 'calculateItemRetriesChainMapKey') + .mockReturnValue('launchTemp__parentTemp__case-1__'); + // Pre-seed the chain map as if the previous attempt has run. + client.itemRetriesChainMap.set('launchTemp__parentTemp__case-1__', { + promiseStart: Promise.resolve(), + tempId: 'prevTemp', + }); + + await client.startTestItem( + { name: 'case-1', type: 'STEP', retry: true, startTime: 1700000000000 }, + 'launchTemp', + 'parentTemp', + ).promise; + + expect(client.restClient.create).toHaveBeenCalledTimes(1); + const [url, payload] = client.restClient.create.mock.calls[0]; + expect(url).toBe('item/parent-real-id'); + expect(payload).toMatchObject({ + name: 'case-1', + type: 'STEP', + retry: true, + retryOf: 'prev-real-uuid', + launchUuid: 'launch-real-id', + }); + }); + + it('omits retryOf when the previous attempts realId is empty', async () => { + const client = makeClient(); + seedClient(client, { + mapOverrides: { + prevTemp: { + children: [], + promiseStart: Promise.resolve(), + realId: '', + }, + }, + }); + jest.spyOn(client, 'getUniqId').mockReturnValue('retry-temp-id'); + jest + .spyOn(client, 'calculateItemRetriesChainMapKey') + .mockReturnValue('launchTemp__parentTemp__case-1__'); + client.itemRetriesChainMap.set('launchTemp__parentTemp__case-1__', { + promiseStart: Promise.resolve(), + tempId: 'prevTemp', + }); + + await client.startTestItem( + { name: 'case-1', type: 'STEP', retry: true, startTime: 1700000000000 }, + 'launchTemp', + 'parentTemp', + ).promise; + + expect(client.restClient.create).toHaveBeenCalledTimes(1); + const payload = client.restClient.create.mock.calls[0][1]; + expect(payload.retry).toBe(true); + expect(payload).not.toHaveProperty('retryOf'); + }); + + it('omits retryOf when the previous attempts map entry is missing', async () => { + const client = makeClient(); + seedClient(client); + jest.spyOn(client, 'getUniqId').mockReturnValue('retry-temp-id'); + jest + .spyOn(client, 'calculateItemRetriesChainMapKey') + .mockReturnValue('launchTemp__parentTemp__case-1__'); + // Chain map points at a tempId that no longer exists in client.map. + client.itemRetriesChainMap.set('launchTemp__parentTemp__case-1__', { + promiseStart: Promise.resolve(), + tempId: 'no-such-prev', + }); + + await client.startTestItem( + { name: 'case-1', type: 'STEP', retry: true, startTime: 1700000000000 }, + 'launchTemp', + 'parentTemp', + ).promise; + + const payload = client.restClient.create.mock.calls[0][1]; + expect(payload).not.toHaveProperty('retryOf'); + }); + + it('awaits the previous attempts promiseStart before firing the retry create', async () => { + const client = makeClient(); + let resolvePrev; + const prevPromise = new Promise((resolve) => { + resolvePrev = resolve; + }); + seedClient(client, { + mapOverrides: { + prevTemp: { + children: [], + promiseStart: prevPromise, + realId: 'prev-real-uuid', + }, + }, + }); + jest.spyOn(client, 'getUniqId').mockReturnValue('retry-temp-id'); + jest + .spyOn(client, 'calculateItemRetriesChainMapKey') + .mockReturnValue('launchTemp__parentTemp__case-1__'); + client.itemRetriesChainMap.set('launchTemp__parentTemp__case-1__', { + promiseStart: prevPromise, + tempId: 'prevTemp', + }); + + const { promise } = client.startTestItem( + { name: 'case-1', type: 'STEP', retry: true, startTime: 1700000000000 }, + 'launchTemp', + 'parentTemp', + ); + + // Give the microtask queue a chance to run; create must NOT fire yet + // because the previous attempts promiseStart is still pending. + await Promise.resolve(); + await Promise.resolve(); + expect(client.restClient.create).not.toHaveBeenCalled(); + + resolvePrev(); + await promise; + + expect(client.restClient.create).toHaveBeenCalledTimes(1); + const payload = client.restClient.create.mock.calls[0][1]; + expect(payload.retryOf).toBe('prev-real-uuid'); + }); + }); + + describe('retry: false / omitted', () => { + it('does not add retryOf when retry is false', async () => { + const client = makeClient(); + seedClient(client); + jest.spyOn(client, 'getUniqId').mockReturnValue('new-temp-id'); + jest + .spyOn(client, 'calculateItemRetriesChainMapKey') + .mockReturnValue('launchTemp__parentTemp__case-1__'); + + await client.startTestItem( + { name: 'case-1', type: 'STEP', retry: false, startTime: 1700000000000 }, + 'launchTemp', + 'parentTemp', + ).promise; + + const payload = client.restClient.create.mock.calls[0][1]; + expect(payload).not.toHaveProperty('retryOf'); + }); + + it('does not add retryOf when retry is omitted', async () => { + const client = makeClient(); + seedClient(client); + jest.spyOn(client, 'getUniqId').mockReturnValue('new-temp-id'); + jest + .spyOn(client, 'calculateItemRetriesChainMapKey') + .mockReturnValue('launchTemp__parentTemp__case-1__'); + + await client.startTestItem( + { name: 'case-1', type: 'STEP', startTime: 1700000000000 }, + 'launchTemp', + 'parentTemp', + ).promise; + + const payload = client.restClient.create.mock.calls[0][1]; + expect(payload).not.toHaveProperty('retryOf'); + }); + + it('does not call itemRetriesChainMap.get when retry is false', () => { + const client = makeClient(); + seedClient(client); + jest.spyOn(client, 'getUniqId').mockReturnValue('new-temp-id'); + jest + .spyOn(client, 'calculateItemRetriesChainMapKey') + .mockReturnValue('launchTemp__parentTemp__case-1__'); + const getSpy = jest.spyOn(client.itemRetriesChainMap, 'get'); + + client.startTestItem( + { name: 'case-1', type: 'STEP', retry: false, startTime: 1700000000000 }, + 'launchTemp', + 'parentTemp', + ); + + // Short-circuit: `retry && map.get(...)` should never invoke get(). + expect(getSpy).not.toHaveBeenCalled(); + }); + }); + + describe('cleanItemRetriesChain interplay', () => { + it('removes the new-shape entry from the chain map on cleanup', () => { + const client = makeClient(); + seedClient(client); + jest.spyOn(client, 'getUniqId').mockReturnValue('new-temp-id'); + jest + .spyOn(client, 'calculateItemRetriesChainMapKey') + .mockReturnValue('launchTemp__parentTemp__case-1__'); + + client.startTestItem( + { name: 'case-1', type: 'STEP', startTime: 1700000000000 }, + 'launchTemp', + 'parentTemp', + ); + + expect(client.itemRetriesChainMap.has('launchTemp__parentTemp__case-1__')).toBe(true); + expect(client.itemRetriesChainKeyMapByTempId.has('new-temp-id')).toBe(true); + + client.cleanItemRetriesChain(['new-temp-id']); + + expect(client.itemRetriesChainMap.has('launchTemp__parentTemp__case-1__')).toBe(false); + expect(client.itemRetriesChainKeyMapByTempId.has('new-temp-id')).toBe(false); + }); + }); +}); diff --git a/index.d.ts b/index.d.ts index fedd35d..72e0337 100644 --- a/index.d.ts +++ b/index.d.ts @@ -200,6 +200,18 @@ declare module '@reportportal/client-javascript' { startTime?: string | number; attributes?: Array<{ key?: string; value?: string } | string>; hasStats?: boolean; + /** + * Marks the test item as a retry of a previous attempt with the same + * `name`/`uniqueId` under the same parent. The client uses this flag to + * link the new attempt to the previous one via the `retryOf` field. + */ + retry?: boolean; + /** + * UUID of the previous retry attempt that this item is a retry of. + * Populated automatically by the client when `retry: true` and the + * previous attempt's UUID is known; can also be supplied explicitly. + */ + retryOf?: string; } /** diff --git a/lib/report-portal-client.js b/lib/report-portal-client.js index 7adbb25..4d7c67e 100644 --- a/lib/report-portal-client.js +++ b/lib/report-portal-client.js @@ -557,7 +557,8 @@ class RPClient { testItemDataRQ.name, testItemDataRQ.uniqueId, ); - const executionItemPromise = testItemDataRQ.retry && this.itemRetriesChainMap.get(itemKey); + const previousRetryEntry = testItemDataRQ.retry && this.itemRetriesChainMap.get(itemKey); + const executionItemPromise = previousRetryEntry && previousRetryEntry.promiseStart; const tempId = this.getUniqId(); this.map[tempId] = this.getNewItemObj((resolve, reject) => { @@ -570,6 +571,13 @@ class RPClient { url += `${realParentId}`; } testItemData.launchUuid = realLaunchId; + if (previousRetryEntry) { + const previousItem = this.map[previousRetryEntry.tempId]; + const previousRealId = previousItem && previousItem.realId; + if (previousRealId) { + testItemData.retryOf = previousRealId; + } + } this.logDebug(`Start test item with tempId ${tempId}`, testItemData); this.restClient.create(url, testItemData).then( (response) => { @@ -591,7 +599,10 @@ class RPClient { }); this.map[parentMapId].children.push(tempId); this.itemRetriesChainKeyMapByTempId.set(tempId, itemKey); - this.itemRetriesChainMap.set(itemKey, this.map[tempId].promiseStart); + this.itemRetriesChainMap.set(itemKey, { + promiseStart: this.map[tempId].promiseStart, + tempId, + }); return { tempId, diff --git a/package-lock.json b/package-lock.json index 0312957..46b051f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "@reportportal/client-javascript", - "version": "5.5.10", + "version": "5.5.11", "license": "Apache-2.0", "dependencies": { "axios": "^1.15.2",