Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
316 changes: 316 additions & 0 deletions __tests__/report-portal-client-retry-of.spec.js
Original file line number Diff line number Diff line change
@@ -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: <previousRealId>`.
* 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);
});
});
});
12 changes: 12 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Comment on lines +203 to +207

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Expose uniqueId if retry chaining depends on it.

The new JSDoc says retries are matched by name/uniqueId, and lib/report-portal-client.js Line 554-559 already uses testItemDataRQ.uniqueId in the retry-chain key. StartTestItemOptions still doesn't declare that field, so TS consumers can't pass the discriminator the implementation relies on without escaping the type system.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@index.d.ts` around lines 203 - 207, StartTestItemOptions is missing the
optional uniqueId property required for retry chaining; add an optional
uniqueId: string field to the StartTestItemOptions type declaration in
index.d.ts and document it (JSDoc) as the discriminator used for retry chaining
so TypeScript callers can pass the same uniqueId that
lib/report-portal-client.js reads via testItemDataRQ.uniqueId (used in the
retry-chain key).

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;
}

/**
Expand Down
15 changes: 13 additions & 2 deletions lib/report-portal-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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;
}
Comment on lines +574 to +579

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve an explicit retryOf.

StartTestItemOptions.retryOf is public now, but this branch overwrites any caller-supplied value whenever a previous retry entry exists. That breaks the new API contract for agents that already know the previous UUID and need to send it even when local retry state is stale or reconstructed.

Proposed fix
-          if (previousRetryEntry) {
+          if (!testItemData.retryOf && previousRetryEntry) {
             const previousItem = this.map[previousRetryEntry.tempId];
             const previousRealId = previousItem && previousItem.realId;
             if (previousRealId) {
               testItemData.retryOf = previousRealId;
             }
           }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/report-portal-client.js` around lines 574 - 579, The current branch
unconditionally overwrites StartTestItemOptions.retryOf using
previousRetryEntry->previousItem->realId, breaking callers that explicitly set
retryOf; change the logic around previousRetryEntry (the block that assigns
testItemData.retryOf) to only set testItemData.retryOf = previousRealId when
testItemData.retryOf is not already provided (e.g., undefined/null/empty),
preserving any caller-supplied StartTestItemOptions.retryOf while still falling
back to previousItem.realId when absent.

}
this.logDebug(`Start test item with tempId ${tempId}`, testItemData);
this.restClient.create(url, testItemData).then(
(response) => {
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading