diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index c1a920516..89120abf5 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -591,10 +591,11 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => { } else { sendNetworkRequest(itemCopy, collectionCopy, environment, collectionCopy.runtimeVariables) .then((response) => { + const { requestSent, ...responseData } = response; // Ensure any timestamps in the response are converted to numbers const serializedResponse = { - ...response, - timeline: response.timeline?.map((entry) => ({ + ...responseData, + timeline: responseData.timeline?.map((entry) => ({ ...entry, timestamp: entry.timestamp instanceof Date ? entry.timestamp.getTime() : entry.timestamp })) @@ -604,18 +605,23 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => { responseReceived({ itemUid, collectionUid, - response: serializedResponse + response: serializedResponse, + requestSent }) ); }) .then(resolve) .catch((err) => { + const request = itemCopy.draft?.request || itemCopy.request; + const requestSent = request ? { url: request.url, method: request.method } : undefined; + if (err && err.message === 'Error invoking remote method \'send-http-request\': Error: Request cancelled') { dispatch( responseReceived({ itemUid, collectionUid, - response: null + response: null, + requestSent }) ); return; @@ -633,7 +639,8 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => { responseReceived({ itemUid, collectionUid, - response: errorResponse + response: errorResponse, + requestSent }) ); }); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index d2676e49e..f21feb1c1 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -538,10 +538,12 @@ export const collectionsSlice = createSlice({ collection.timeline = []; } + const timelineRequest = action.payload.requestSent || item.requestSent || item.request; + // Ensure timestamp is a number (milliseconds since epoch) - const timestamp = item?.requestSent?.timestamp instanceof Date - ? item.requestSent.timestamp.getTime() - : item?.requestSent?.timestamp || Date.now(); + const timestamp = timelineRequest?.timestamp instanceof Date + ? timelineRequest.timestamp.getTime() + : timelineRequest?.timestamp || Date.now(); // Append the new timeline entry with numeric timestamp collection.timeline.push({ @@ -552,7 +554,7 @@ export const collectionsSlice = createSlice({ requestUid: item.requestUid, timestamp: timestamp, data: { - request: item.requestSent || item.request, + request: timelineRequest, response: action.payload.response, timestamp: timestamp } diff --git a/packages/bruno-app/src/utils/network/index.js b/packages/bruno-app/src/utils/network/index.js index 9eca0d4cd..b1a265276 100644 --- a/packages/bruno-app/src/utils/network/index.js +++ b/packages/bruno-app/src/utils/network/index.js @@ -19,7 +19,8 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV statusText: response.statusText, duration: response.duration, timeline: response.timeline, - stream: response.stream + stream: response.stream, + requestSent: response.requestSent }); }) .catch((err) => reject(err)); diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index b91eb0197..9a1dbb251 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -774,6 +774,7 @@ const registerNetworkIpc = (mainWindow) => { // flag to see if the stream needs to be handled as an actual stream or // is it just a data stream from axios let isResponseStream = false; + let requestSent; const brunoConfig = getBrunoConfig(collectionUid, collection); const scriptingConfig = get(brunoConfig, 'scripts', {}); scriptingConfig.runtime = getJsSandboxRuntime(collection); @@ -864,7 +865,7 @@ const registerNetworkIpc = (mainWindow) => { } }); - let requestSent = { + requestSent = { url: request.url, method: request.method, headers: headersSent, @@ -1141,7 +1142,8 @@ const registerNetworkIpc = (mainWindow) => { size: Buffer.byteLength(response.dataBuffer), duration: responseTime ?? 0, url: response.request ? response.request.protocol + '//' + response.request.host + response.request.path : null, - timeline: response.timeline + timeline: response.timeline, + requestSent }; } catch (error) { deleteCancelToken(cancelTokenUid); @@ -1151,7 +1153,8 @@ const registerNetworkIpc = (mainWindow) => { return { status: error?.status, error: error?.message || ERROR_OCCURRED_WHILE_EXECUTING_REQUEST, - timeline: error?.timeline + timeline: error?.timeline, + requestSent }; } }; diff --git a/tests/request/timeline/timeline-url-update.spec.ts b/tests/request/timeline/timeline-url-update.spec.ts new file mode 100644 index 000000000..b8909d34c --- /dev/null +++ b/tests/request/timeline/timeline-url-update.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '../../../playwright'; +import { + closeAllCollections, + createCollection, + createRequest, + sendRequest +} from '../../utils/page/actions'; + +/** + * Select a tab in the response pane, handling the overflow dropdown (>>) if the tab is hidden. + */ +const selectResponsePaneTab = async (page, tabName: string) => { + await test.step(`Select response pane tab "${tabName}"`, async () => { + const responsePaneTabs = page.locator('.response-pane .tabs'); + const visibleTab = responsePaneTabs.getByRole('tab', { name: tabName }); + + if (await visibleTab.isVisible()) { + await visibleTab.click(); + return; + } + + // Tab is hidden in the overflow dropdown (>> button) + const overflowButton = responsePaneTabs.locator('.more-tabs'); + if (await overflowButton.isVisible()) { + await overflowButton.click(); + const dropdownItem = page.locator('.tippy-box .dropdown-item').filter({ hasText: tabName }); + await dropdownItem.click(); + } + }); +}; + +test.describe('Timeline URL Update', () => { + test.afterAll(async ({ page }) => { + await closeAllCollections(page); + }); + + test('should show correct URL in timeline after changing request URL between sends', async ({ page, createTmpDir }) => { + const collectionName = 'timeline-url-test'; + const firstUrl = 'http://localhost:8081/ping'; + const secondUrl = 'http://localhost:8081/headers'; + + await test.step('Create collection and request', async () => { + await createCollection(page, collectionName, await createTmpDir(collectionName)); + await createRequest(page, 'url-change-test', collectionName, { url: firstUrl }); + }); + + await test.step('Send first request', async () => { + await sendRequest(page, 200); + }); + + await test.step('Change URL and send second request', async () => { + // Click into the URL field, select all, then type the new URL + // (fillRequestUrl appends in CodeMirror, so we clear manually) + const urlEditor = page.locator('#request-url .CodeMirror'); + await urlEditor.click(); + const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; + await page.keyboard.press(`${modifier}+a`); + await page.keyboard.type(secondUrl); + await page.waitForTimeout(200); + await sendRequest(page, 200); + }); + + await test.step('Open Timeline tab and verify URLs', async () => { + await selectResponsePaneTab(page, 'Timeline'); + + // Get all timeline entries + const timelineItems = page.locator('.timeline-item'); + await expect(timelineItems).toHaveCount(2, { timeout: 5000 }); + + // Most recent entry (first in list) should show the second URL + const firstEntry = timelineItems.nth(0); + await expect(firstEntry).toContainText('/headers'); + + // Older entry (second in list) should show the first URL + const secondEntry = timelineItems.nth(1); + await expect(secondEntry).toContainText('/ping'); + }); + }); +});