- {combinedTimeline.map((event, index) => {
- // Handle regular requests
- if (event.type === 'request') {
- const { data, timestamp, eventType } = event;
+ {showFilterBar && (
+
+ {entries.map((entry, index) => {
+ const kind = getEntryKind(entry);
+ if (activeFilter !== 'all' && activeFilter !== kind) return null;
+
+ if (entry.type === 'request') {
+ const { data, timestamp, eventType } = entry;
const { request, response, eventData = {}, timestamp: eventTimestamp = timestamp } = data;
if (isGrpcRequest) {
@@ -98,7 +118,6 @@ const Timeline = ({ collection, item }) => {
);
}
- // Regular HTTP request
return (
{
response={response}
item={item}
collection={collection}
+ source="main"
/>
);
- } else if (event.type === 'oauth2') { // Handle OAuth2 events
- const { data, timestamp } = event;
- const { debugInfo } = data;
+ }
+
+ if (entry.type === 'oauth2' && entry._oauth2Child) {
return (
-
-
- {debugInfo && debugInfo.length > 0 ? (
- debugInfo.map((data, idx) => (
-
-
-
- ))
- ) : (
-
No debug information available.
- )}
-
+
+
+ );
+ }
+
+ if (entry.type === 'scripted-request') {
+ return (
+
+
);
}
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 7656990b8..0d3f45a9b 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
@@ -3055,6 +3055,22 @@ export const collectionsSlice = createSlice({
}
}
+ if (type === 'scripted-request') {
+ const { phase, source, scope, timestamp, data } = action.payload;
+ if (!collection.timeline) collection.timeline = [];
+ collection.timeline.push({
+ type: 'scripted-request',
+ collectionUid,
+ itemUid,
+ requestUid,
+ phase,
+ source,
+ scope: scope || null,
+ timestamp,
+ data
+ });
+ }
+
if (type === 'assertion-results') {
const { results } = action.payload;
item.assertionResults = results;
@@ -3178,6 +3194,35 @@ export const collectionsSlice = createSlice({
item.preRequestScriptErrorMessage = action.payload.errorMessage;
item.preRequestScriptErrorContext = action.payload.errorContext || null;
}
+
+ if (type === 'scripted-request') {
+ const { phase, source, scope, timestamp, data } = action.payload;
+ const runnerItem = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
+ if (runnerItem) {
+ if (!runnerItem.scriptedRequestEntries) runnerItem.scriptedRequestEntries = [];
+ runnerItem.scriptedRequestEntries.push({
+ phase,
+ source,
+ scope: scope || null,
+ timestamp,
+ data
+ });
+ }
+ }
+
+ if (type === 'oauth2-debug') {
+ const { url, credentialsId, debugInfo } = action.payload;
+ const runnerItem = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
+ if (runnerItem) {
+ if (!runnerItem.oauth2DebugEntries) runnerItem.oauth2DebugEntries = [];
+ runnerItem.oauth2DebugEntries.push({
+ url,
+ credentialsId,
+ debugInfo: debugInfo?.data || debugInfo,
+ timestamp: Date.now()
+ });
+ }
+ }
}
},
resetCollectionRunner: (state, action) => {
@@ -3242,7 +3287,7 @@ export const collectionsSlice = createSlice({
}
},
collectionAddOauth2CredentialsByUrl: (state, action) => {
- const { collectionUid, folderUid, itemUid, url, credentials, credentialsId, debugInfo } = action.payload;
+ const { collectionUid, folderUid, itemUid, url, credentials, credentialsId, debugInfo, executionMode } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
@@ -3272,6 +3317,10 @@ export const collectionsSlice = createSlice({
collection.oauth2Credentials = filteredOauth2Credentials;
+ // Runner runs snapshot oauth onto the runner item via 'oauth2-debug';
+ // skip the shared timeline push so it doesn't leak into the standalone view.
+ if (executionMode === 'runner') return;
+
if (!collection.timeline) {
collection.timeline = [];
}
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/timeline-routing.spec.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/timeline-routing.spec.js
new file mode 100644
index 000000000..d3d1c5c27
--- /dev/null
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/timeline-routing.spec.js
@@ -0,0 +1,364 @@
+import reducer, {
+ initRunRequestEvent,
+ runRequestEvent,
+ runFolderEvent,
+ collectionAddOauth2CredentialsByUrl
+} from 'providers/ReduxStore/slices/collections';
+
+const COLLECTION_UID = 'col-1';
+const ITEM_UID = 'req-1';
+const REQUEST_UID = 'run-1';
+
+const makeInitialState = () => ({
+ collections: [
+ {
+ uid: COLLECTION_UID,
+ pathname: '/coll',
+ items: [
+ {
+ uid: ITEM_UID,
+ name: 'user_info',
+ type: 'http-request',
+ request: { url: 'https://example.com/userinfo', method: 'GET' }
+ }
+ ]
+ }
+ ],
+ collectionSortOrder: 'default',
+ activeWorkspaceUid: null
+});
+
+const scriptedRequestEvent = (overrides = {}) => ({
+ type: 'scripted-request',
+ collectionUid: COLLECTION_UID,
+ itemUid: ITEM_UID,
+ requestUid: REQUEST_UID,
+ phase: 'pre-request',
+ source: 'sendRequest',
+ scope: { type: 'collection', sourceFile: 'collection.bru' },
+ timestamp: 1000,
+ data: {
+ request: { method: 'GET', url: 'https://example.com/ping', headers: {}, data: undefined },
+ response: { statusCode: 200, statusText: 'OK', headers: {}, data: 'ok', dataBuffer: '', size: 0, duration: 1 }
+ },
+ ...overrides
+});
+
+describe('runRequestEvent — single-request flow', () => {
+ test('appends a scripted-request entry to collection.timeline', () => {
+ let state = makeInitialState();
+ state = reducer(state, initRunRequestEvent({
+ requestUid: REQUEST_UID, itemUid: ITEM_UID, collectionUid: COLLECTION_UID
+ }));
+ state = reducer(state, runRequestEvent(scriptedRequestEvent()));
+
+ const collection = state.collections[0];
+ expect(collection.timeline).toHaveLength(1);
+ expect(collection.timeline[0]).toEqual(
+ expect.objectContaining({
+ type: 'scripted-request',
+ itemUid: ITEM_UID,
+ requestUid: REQUEST_UID,
+ phase: 'pre-request',
+ source: 'sendRequest',
+ scope: { type: 'collection', sourceFile: 'collection.bru' },
+ timestamp: 1000
+ })
+ );
+ });
+
+ test('keeps each phase distinct as separate entries', () => {
+ let state = makeInitialState();
+ state = reducer(state, initRunRequestEvent({
+ requestUid: REQUEST_UID, itemUid: ITEM_UID, collectionUid: COLLECTION_UID
+ }));
+ state = reducer(state, runRequestEvent(scriptedRequestEvent({
+ phase: 'pre-request', source: 'sendRequest', timestamp: 100
+ })));
+ state = reducer(state, runRequestEvent(scriptedRequestEvent({
+ phase: 'post-response', source: 'runRequest', timestamp: 200
+ })));
+ state = reducer(state, runRequestEvent(scriptedRequestEvent({
+ phase: 'tests', source: 'sendRequest', timestamp: 300
+ })));
+
+ const entries = state.collections[0].timeline;
+ expect(entries).toHaveLength(3);
+ expect(entries.map((e) => e.phase)).toEqual(['pre-request', 'post-response', 'tests']);
+ expect(entries.map((e) => e.source)).toEqual(['sendRequest', 'runRequest', 'sendRequest']);
+ });
+
+ test('ignores stale events whose requestUid no longer matches the item', () => {
+ let state = makeInitialState();
+ state = reducer(state, initRunRequestEvent({
+ requestUid: REQUEST_UID, itemUid: ITEM_UID, collectionUid: COLLECTION_UID
+ }));
+ // Later invocation moves item.requestUid forward; earlier events must be dropped.
+ state = reducer(state, initRunRequestEvent({
+ requestUid: 'run-2', itemUid: ITEM_UID, collectionUid: COLLECTION_UID
+ }));
+ state = reducer(state, runRequestEvent(scriptedRequestEvent({ requestUid: REQUEST_UID })));
+
+ expect(state.collections[0].timeline || []).toHaveLength(0);
+ });
+});
+
+describe('runFolderEvent — runner flow', () => {
+ // Seed runnerResult so the scripted-request / oauth2-debug reducers find it via findLast().
+ const seedRunner = (state) => {
+ state = reducer(state, runFolderEvent({
+ type: 'testrun-started',
+ collectionUid: COLLECTION_UID,
+ folderUid: null,
+ isRecursive: false,
+ cancelTokenUid: 'cancel-1'
+ }));
+ state = reducer(state, runFolderEvent({
+ type: 'request-queued',
+ collectionUid: COLLECTION_UID,
+ folderUid: null,
+ itemUid: ITEM_UID
+ }));
+ return state;
+ };
+
+ test('routes scripted-request onto runnerItem.scriptedRequestEntries (not collection.timeline)', () => {
+ let state = seedRunner(makeInitialState());
+ state = reducer(state, runFolderEvent({
+ type: 'scripted-request',
+ collectionUid: COLLECTION_UID,
+ folderUid: null,
+ itemUid: ITEM_UID,
+ phase: 'pre-request',
+ source: 'sendRequest',
+ scope: { type: 'collection', sourceFile: 'collection.bru' },
+ timestamp: 500,
+ data: { request: { method: 'GET', url: 'https://example.com/ping' }, response: null }
+ }));
+
+ const collection = state.collections[0];
+ const runnerItem = collection.runnerResult.items.find((i) => i.uid === ITEM_UID);
+
+ expect(runnerItem.scriptedRequestEntries).toHaveLength(1);
+ expect(runnerItem.scriptedRequestEntries[0]).toEqual(
+ expect.objectContaining({
+ phase: 'pre-request',
+ source: 'sendRequest',
+ scope: { type: 'collection', sourceFile: 'collection.bru' },
+ timestamp: 500
+ })
+ );
+ // Isolation guarantee: must not bleed into the shared timeline.
+ expect(collection.timeline || []).toHaveLength(0);
+ });
+
+ test('routes oauth2-debug onto runnerItem.oauth2DebugEntries (not collection.timeline)', () => {
+ let state = seedRunner(makeInitialState());
+ const debugInfo = [{ request: { url: 'token-url' }, response: { status: 200 } }];
+
+ state = reducer(state, runFolderEvent({
+ type: 'oauth2-debug',
+ collectionUid: COLLECTION_UID,
+ folderUid: null,
+ itemUid: ITEM_UID,
+ url: 'https://idp.example.com/token',
+ credentialsId: 'credentials',
+ debugInfo: { data: debugInfo }
+ }));
+
+ const collection = state.collections[0];
+ const runnerItem = collection.runnerResult.items.find((i) => i.uid === ITEM_UID);
+
+ expect(runnerItem.oauth2DebugEntries).toHaveLength(1);
+ expect(runnerItem.oauth2DebugEntries[0]).toEqual(
+ expect.objectContaining({
+ url: 'https://idp.example.com/token',
+ credentialsId: 'credentials',
+ debugInfo
+ })
+ );
+ expect(collection.timeline || []).toHaveLength(0);
+ });
+
+ test('appends per-phase scripted entries cumulatively on the runner item', () => {
+ let state = seedRunner(makeInitialState());
+ ['pre-request', 'post-response', 'tests'].forEach((phase, i) => {
+ state = reducer(state, runFolderEvent({
+ type: 'scripted-request',
+ collectionUid: COLLECTION_UID,
+ folderUid: null,
+ itemUid: ITEM_UID,
+ phase,
+ source: i === 1 ? 'runRequest' : 'sendRequest',
+ scope: null,
+ timestamp: 100 * (i + 1),
+ data: { request: {}, response: null }
+ }));
+ });
+
+ const runnerItem = state.collections[0].runnerResult.items.find((i) => i.uid === ITEM_UID);
+ expect(runnerItem.scriptedRequestEntries.map((e) => e.phase)).toEqual(['pre-request', 'post-response', 'tests']);
+ expect(runnerItem.scriptedRequestEntries.map((e) => e.source)).toEqual(['sendRequest', 'runRequest', 'sendRequest']);
+ });
+
+ test('multiple runner invocations of the same item keep their entries separate (findLast)', () => {
+ let state = seedRunner(makeInitialState());
+ state = reducer(state, runFolderEvent({
+ type: 'scripted-request',
+ collectionUid: COLLECTION_UID, folderUid: null, itemUid: ITEM_UID,
+ phase: 'pre-request', source: 'sendRequest', scope: null, timestamp: 1,
+ data: { request: { url: 'A' }, response: null }
+ }));
+ // Second invocation queues a fresh runner item for the same uid.
+ state = reducer(state, runFolderEvent({
+ type: 'request-queued',
+ collectionUid: COLLECTION_UID, folderUid: null, itemUid: ITEM_UID
+ }));
+ state = reducer(state, runFolderEvent({
+ type: 'scripted-request',
+ collectionUid: COLLECTION_UID, folderUid: null, itemUid: ITEM_UID,
+ phase: 'pre-request', source: 'sendRequest', scope: null, timestamp: 2,
+ data: { request: { url: 'B' }, response: null }
+ }));
+
+ const items = state.collections[0].runnerResult.items.filter((i) => i.uid === ITEM_UID);
+ expect(items).toHaveLength(2);
+ expect(items[0].scriptedRequestEntries[0].data.request.url).toBe('A');
+ expect(items[1].scriptedRequestEntries[0].data.request.url).toBe('B');
+ });
+});
+
+describe('collectionAddOauth2CredentialsByUrl — executionMode gating', () => {
+ const credentials = { access_token: 'abc', expires_in: 60 };
+ const debugInfo = { data: [{ request: {}, response: {} }] };
+
+ test('standalone runs push an oauth2 entry into collection.timeline', () => {
+ let state = makeInitialState();
+ state = reducer(state, collectionAddOauth2CredentialsByUrl({
+ collectionUid: COLLECTION_UID,
+ folderUid: null,
+ itemUid: ITEM_UID,
+ url: 'https://idp.example.com/token',
+ credentials,
+ credentialsId: 'credentials',
+ debugInfo
+ }));
+
+ const collection = state.collections[0];
+ expect(collection.timeline).toHaveLength(1);
+ expect(collection.timeline[0]).toEqual(
+ expect.objectContaining({ type: 'oauth2', itemUid: ITEM_UID })
+ );
+ });
+
+ test('executionMode = "runner" updates the credential cache but skips the timeline push', () => {
+ let state = makeInitialState();
+ state = reducer(state, collectionAddOauth2CredentialsByUrl({
+ collectionUid: COLLECTION_UID,
+ folderUid: null,
+ itemUid: ITEM_UID,
+ url: 'https://idp.example.com/token',
+ credentials,
+ credentialsId: 'credentials',
+ debugInfo,
+ executionMode: 'runner'
+ }));
+
+ const collection = state.collections[0];
+ expect(collection.oauth2Credentials).toHaveLength(1);
+ expect(collection.oauth2Credentials[0]).toEqual(
+ expect.objectContaining({ url: 'https://idp.example.com/token', credentialsId: 'credentials' })
+ );
+ // Runner oauth lives on the runner item via 'oauth2-debug' instead.
+ expect(collection.timeline || []).toHaveLength(0);
+ });
+});
+
+describe('nested bru.runRequest under Runner — oauth2 routes to outer runner item', () => {
+ const credentials = { access_token: 'abc', expires_in: 60 };
+ const debugInfoData = [{ request: { url: 'token-url' }, response: { status: 200 } }];
+
+ const seedRunner = (state) => {
+ state = reducer(state, runFolderEvent({
+ type: 'testrun-started',
+ collectionUid: COLLECTION_UID,
+ folderUid: null,
+ isRecursive: false,
+ cancelTokenUid: 'cancel-1'
+ }));
+ state = reducer(state, runFolderEvent({
+ type: 'request-queued',
+ collectionUid: COLLECTION_UID,
+ folderUid: null,
+ itemUid: ITEM_UID
+ }));
+ return state;
+ };
+
+ test('emits credentials-update + oauth2-debug → runner item gets the row, standalone timeline stays empty', () => {
+ let state = seedRunner(makeInitialState());
+
+ // Event 1: credentials-update with executionMode='runner' (suppresses standalone push).
+ state = reducer(state, collectionAddOauth2CredentialsByUrl({
+ collectionUid: COLLECTION_UID,
+ folderUid: null,
+ itemUid: ITEM_UID,
+ url: 'https://idp.example.com/token',
+ credentials,
+ credentialsId: 'credentials',
+ debugInfo: { data: debugInfoData },
+ executionMode: 'runner'
+ }));
+
+ // Event 2: oauth2-debug carrying the OUTER runner item's eventData.
+ state = reducer(state, runFolderEvent({
+ type: 'oauth2-debug',
+ collectionUid: COLLECTION_UID,
+ folderUid: null,
+ itemUid: ITEM_UID,
+ url: 'https://idp.example.com/token',
+ credentialsId: 'credentials',
+ debugInfo: { data: debugInfoData }
+ }));
+
+ const collection = state.collections[0];
+ const runnerItem = collection.runnerResult.items.find((i) => i.uid === ITEM_UID);
+
+ // Cache is updated so subsequent requests reuse the token.
+ expect(collection.oauth2Credentials).toHaveLength(1);
+ // Runner timeline picks it up via the runner item.
+ expect(runnerItem.oauth2DebugEntries).toHaveLength(1);
+ expect(runnerItem.oauth2DebugEntries[0]).toEqual(
+ expect.objectContaining({
+ url: 'https://idp.example.com/token',
+ credentialsId: 'credentials',
+ debugInfo: debugInfoData
+ })
+ );
+ // Standalone tab must NOT see the oauth row.
+ expect(collection.timeline || []).toHaveLength(0);
+ });
+
+ test('regression guard: omitting executionMode (the pre-fix shape) leaks oauth2 onto collection.timeline', () => {
+ let state = seedRunner(makeInitialState());
+
+ // Pre-fix emit: no executionMode field → reducer treats it as standalone.
+ state = reducer(state, collectionAddOauth2CredentialsByUrl({
+ collectionUid: COLLECTION_UID,
+ folderUid: null,
+ itemUid: ITEM_UID,
+ url: 'https://idp.example.com/token',
+ credentials,
+ credentialsId: 'credentials',
+ debugInfo: { data: debugInfoData }
+ }));
+
+ const collection = state.collections[0];
+ const runnerItem = collection.runnerResult.items.find((i) => i.uid === ITEM_UID);
+
+ expect(collection.timeline || []).toHaveLength(1);
+ expect(collection.timeline[0]).toEqual(expect.objectContaining({ type: 'oauth2' }));
+ // And the runner item gets nothing — exactly the bug the user reported.
+ expect(runnerItem.oauth2DebugEntries || []).toHaveLength(0);
+ });
+});
diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js
index ca9a28c55..92459f61d 100644
--- a/packages/bruno-electron/src/ipc/network/index.js
+++ b/packages/bruno-electron/src/ipc/network/index.js
@@ -2,6 +2,7 @@ const https = require('https');
const axios = require('axios');
const path = require('path');
const { applyOAuth1ToRequest } = require('@usebruno/requests');
+const { buildScriptedEntry } = require('@usebruno/requests').scripting;
const qs = require('qs');
const decomment = require('decomment');
const contentDispositionParser = require('content-disposition');
@@ -733,14 +734,14 @@ const registerNetworkIpc = (mainWindow) => {
return scriptResult;
};
- const runRequest = async ({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground = false }) => {
+ const runRequest = async ({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground = false, callerBru = null, parentExecutionMode = null, parentRunnerEventData = null }) => {
const collectionUid = collection.uid;
const collectionPath = collection.pathname;
const cancelTokenUid = uuid();
- // requestUid is passed when a request is triggered; defaults to uuid() if not provided (e.g., bru.runRequest())
+ // Nested bru.runRequest() invocations have no item.requestUid; mint one.
const requestUid = item.requestUid || uuid();
- const runRequestByItemPathname = async (relativeItemPathname) => {
+ const runRequestByItemPathname = async (relativeItemPathname, callerBru) => {
return new Promise(async (resolve, reject) => {
const format = getCollectionFormat(collection.pathname);
let itemPathname = path.join(collection.pathname, relativeItemPathname);
@@ -749,13 +750,113 @@ const registerNetworkIpc = (mainWindow) => {
}
const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname));
if (_item) {
- const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true });
+ // WS/gRPC items live on separate IPC channels and can't be driven via
+ // the HTTP runRequest. Record a Skipped row so the user sees feedback.
+ if (_item.type === 'ws-request' || _item.type === 'grpc-request') {
+ const protocolLabel = _item.type === 'ws-request' ? 'WebSocket' : 'gRPC';
+ const startedAt = Date.now();
+ callerBru?._recordScriptedRequest?.({
+ source: 'runRequest',
+ request: {
+ method: (_item.request?.method || 'GET').toString().toUpperCase(),
+ url: _item.request?.url,
+ headers: {},
+ data: null
+ },
+ response: {
+ statusCode: null,
+ statusText: 'Skipped',
+ headers: {},
+ data: null,
+ dataBuffer: '',
+ size: 0,
+ duration: 0
+ },
+ error: null,
+ startedAt,
+ completedAt: startedAt
+ });
+ resolve({
+ status: 'skipped',
+ statusText: `bru.runRequest does not support ${protocolLabel} requests`,
+ headers: {},
+ data: null,
+ duration: 0,
+ size: 0
+ });
+ return;
+ }
+
+ const startedAt = Date.now();
+ let res, err;
+ try {
+ res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true, callerBru, parentExecutionMode, parentRunnerEventData });
+ } catch (e) {
+ err = e;
+ }
+ const completedAt = Date.now();
+ const sent = res?.requestSent || {};
+ // Cancel/network-error early-returns don't include requestSent; fall back
+ const fallbackRequest = _item.request || {};
+ callerBru?._recordScriptedRequest?.({
+ source: 'runRequest',
+ ...buildScriptedEntry({
+ request: {
+ method: sent.method || fallbackRequest.method,
+ url: sent.url || res?.url || fallbackRequest.url,
+ headers: sent.headers,
+ data: sent.data
+ },
+ response: res
+ ? {
+ status: res.status,
+ statusText: res.statusText,
+ headers: res.headers,
+ data: res.data,
+ dataBuffer: res.dataBuffer,
+ size: res.size,
+ duration: res.duration
+ }
+ : null,
+ error: err || (res?.error ? { message: res.error } : null),
+ startedAt,
+ completedAt
+ })
+ });
+ if (err) {
+ reject(err);
+ return;
+ }
resolve(res);
+ return;
}
reject(`bru.runRequest: invalid request path - ${itemPathname}`);
});
};
+ const emitScriptedRequestEvents = (phase, scriptResult) => {
+ const entries = scriptResult?.scriptedRequestEntries || [];
+ if (runInBackground) {
+ if (callerBru) {
+ entries.forEach((entry) => callerBru._recordScriptedRequest?.(entry));
+ }
+ return;
+ }
+ entries.forEach((entry) => {
+ mainWindow.webContents.send('main:run-request-event', {
+ type: 'scripted-request',
+ collectionUid,
+ itemUid: item.uid,
+ requestUid,
+ phase,
+ source: entry.source,
+ scope: entry.scope || null,
+ timestamp: entry.startedAt,
+ data: { request: entry.request, response: entry.response, error: entry.error }
+ });
+ });
+ };
+
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'request-queued',
requestUid,
@@ -817,6 +918,8 @@ const registerNetworkIpc = (mainWindow) => {
preRequestScriptResult = preRequestError.partialResults;
}
+ emitScriptedRequestEvents('pre-request', preRequestScriptResult);
+
preRequestScriptResult = appendScriptErrorResult('pre-request', preRequestScriptResult, preRequestError);
if (preRequestScriptResult?.results) {
@@ -887,9 +990,22 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid,
credentialsId: request?.oauth2Credentials?.credentialsId,
...(request?.oauth2Credentials?.folderUid ? { folderUid: request.oauth2Credentials.folderUid } : { itemUid: item.uid }),
- debugInfo: request?.oauth2Credentials?.debugInfo
+ debugInfo: request?.oauth2Credentials?.debugInfo,
+ // When invoked via bru.runRequest from inside the Runner, route the oauth2 timeline
+ // entry onto the outer runner item instead of leaking into collection.timeline.
+ ...(parentExecutionMode === 'runner' ? { executionMode: 'runner' } : {})
});
+ if (parentExecutionMode === 'runner' && parentRunnerEventData && request.oauth2Credentials.debugInfo) {
+ mainWindow.webContents.send('main:run-folder-event', {
+ type: 'oauth2-debug',
+ ...parentRunnerEventData,
+ url: request.oauth2Credentials.url,
+ credentialsId: request.oauth2Credentials.credentialsId,
+ debugInfo: request.oauth2Credentials.debugInfo
+ });
+ }
+
const { credentialsId, credentials } = request.oauth2Credentials;
request.oauth2CredentialVariables = request.oauth2CredentialVariables || {};
Object.entries(credentials).forEach(([key, value]) => {
@@ -999,6 +1115,8 @@ const registerNetworkIpc = (mainWindow) => {
postResponseScriptResult = postResponseError.partialResults;
}
+ emitScriptedRequestEvents('post-response', postResponseScriptResult);
+
postResponseScriptResult = appendScriptErrorResult('post-response', postResponseScriptResult, postResponseError);
if (postResponseScriptResult?.results) {
@@ -1077,6 +1195,8 @@ const registerNetworkIpc = (mainWindow) => {
}
}
+ emitScriptedRequestEvents('tests', testResults);
+
testResults = appendScriptErrorResult('test', testResults, testError);
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
@@ -1282,6 +1402,9 @@ const registerNetworkIpc = (mainWindow) => {
const processEnvVars = getProcessEnvVars(collectionUid);
let stopRunnerExecution = false;
let currentAbortController;
+ // Tracks the outer runner item currently executing so a nested bru.runRequest
+ // can route its oauth2 timeline entry back to this item.
+ let currentRunnerEventData = null;
const abortController = new AbortController();
saveCancelToken(cancelTokenUid, abortController);
@@ -1292,7 +1415,7 @@ const registerNetworkIpc = (mainWindow) => {
}
});
- const runRequestByItemPathname = async (relativeItemPathname) => {
+ const runRequestByItemPathname = async (relativeItemPathname, callerBru) => {
return new Promise(async (resolve, reject) => {
const format = getCollectionFormat(collection.pathname);
let itemPathname = path.join(collection.pathname, relativeItemPathname);
@@ -1301,8 +1424,92 @@ const registerNetworkIpc = (mainWindow) => {
}
const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname));
if (_item) {
- const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true });
+ // WS/gRPC items live on separate IPC channels and can't be driven via
+ // the HTTP runRequest. Record a Skipped row so the user sees feedback.
+ if (_item.type === 'ws-request' || _item.type === 'grpc-request') {
+ const protocolLabel = _item.type === 'ws-request' ? 'WebSocket' : 'gRPC';
+ const startedAt = Date.now();
+ callerBru?._recordScriptedRequest?.({
+ source: 'runRequest',
+ request: {
+ method: (_item.request?.method || 'GET').toString().toUpperCase(),
+ url: _item.request?.url,
+ headers: {},
+ data: null
+ },
+ response: {
+ statusCode: null,
+ statusText: 'Skipped',
+ headers: {},
+ data: null,
+ dataBuffer: '',
+ size: 0,
+ duration: 0
+ },
+ error: null,
+ startedAt,
+ completedAt: startedAt
+ });
+ resolve({
+ status: 'skipped',
+ statusText: `bru.runRequest does not support ${protocolLabel} requests`,
+ headers: {},
+ data: null,
+ duration: 0,
+ size: 0
+ });
+ return;
+ }
+
+ const startedAt = Date.now();
+ let res, err;
+ try {
+ res = await runRequest({
+ item: _item,
+ collection,
+ envVars,
+ processEnvVars,
+ runtimeVariables,
+ runInBackground: true,
+ parentExecutionMode: 'runner',
+ parentRunnerEventData: currentRunnerEventData
+ });
+ } catch (e) {
+ err = e;
+ }
+ const completedAt = Date.now();
+ const sent = res?.requestSent || {};
+ callerBru?._recordScriptedRequest?.({
+ source: 'runRequest',
+ ...buildScriptedEntry({
+ request: {
+ method: sent.method,
+ url: sent.url || res?.url,
+ headers: sent.headers,
+ data: sent.data
+ },
+ response: res
+ ? {
+ status: res.status,
+ statusText: res.statusText,
+ headers: res.headers,
+ data: res.data,
+ dataBuffer: res.dataBuffer,
+ size: res.size,
+ duration: res.duration
+ }
+ : null,
+ error: err || (res?.error ? { message: res.error } : null),
+ startedAt,
+ completedAt
+ })
+ });
+ if (err) {
+ reject(err);
+ return;
+ }
resolve(res);
+ return;
}
reject(`bru.runRequest: invalid request path - ${itemPathname}`);
});
@@ -1384,6 +1591,22 @@ const registerNetworkIpc = (mainWindow) => {
folderUid,
itemUid
};
+ currentRunnerEventData = eventData;
+
+ const emitRunnerScriptedRequestEvents = (phase, scriptResult) => {
+ const entries = scriptResult?.scriptedRequestEntries || [];
+ entries.forEach((entry) => {
+ mainWindow.webContents.send('main:run-folder-event', {
+ type: 'scripted-request',
+ ...eventData,
+ phase,
+ source: entry.source,
+ scope: entry.scope || null,
+ timestamp: entry.startedAt,
+ data: { request: entry.request, response: entry.response, error: entry.error }
+ });
+ });
+ };
let timeStart;
let timeEnd;
@@ -1477,6 +1700,7 @@ const registerNetworkIpc = (mainWindow) => {
}
preRequestScriptResult = appendScriptErrorResult('pre-request', preRequestScriptResult, preRequestError);
+ emitRunnerScriptedRequestEvents('pre-request', preRequestScriptResult);
if (preRequestScriptResult?.results) {
mainWindow.webContents.send('main:run-folder-event', {
@@ -1577,9 +1801,22 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid,
credentialsId: request?.oauth2Credentials?.credentialsId,
...(request?.oauth2Credentials?.folderUid ? { folderUid: request.oauth2Credentials.folderUid } : { itemUid: item.uid }),
- debugInfo: request?.oauth2Credentials?.debugInfo
+ debugInfo: request?.oauth2Credentials?.debugInfo,
+ // Reducer updates the cache but skips the timeline push for 'runner'.
+ executionMode: 'runner'
});
+ // RunnerTimeline reads oauth from the runner item, not collection.timeline.
+ if (request.oauth2Credentials.debugInfo) {
+ mainWindow.webContents.send('main:run-folder-event', {
+ type: 'oauth2-debug',
+ ...eventData,
+ url: request.oauth2Credentials.url,
+ credentialsId: request.oauth2Credentials.credentialsId,
+ debugInfo: request.oauth2Credentials.debugInfo
+ });
+ }
+
const { credentialsId, credentials } = request.oauth2Credentials;
request.oauth2CredentialVariables = request.oauth2CredentialVariables || {};
Object.entries(credentials).forEach(([key, value]) => {
@@ -1721,6 +1958,7 @@ const registerNetworkIpc = (mainWindow) => {
}
postResponseScriptResult = appendScriptErrorResult('post-response', postResponseScriptResult, postResponseError);
+ emitRunnerScriptedRequestEvents('post-response', postResponseScriptResult);
notifyScriptExecution({
channel: 'main:run-folder-event',
@@ -1812,6 +2050,7 @@ const registerNetworkIpc = (mainWindow) => {
}
testResults = appendScriptErrorResult('test', testResults, testError);
+ emitRunnerScriptedRequestEvents('tests', testResults);
if (testResults?.nextRequestName !== undefined) {
nextRequestName = testResults.nextRequestName;
diff --git a/packages/bruno-electron/src/utils/collection.js b/packages/bruno-electron/src/utils/collection.js
index 9c837e01b..39018e6cc 100644
--- a/packages/bruno-electron/src/utils/collection.js
+++ b/packages/bruno-electron/src/utils/collection.js
@@ -151,12 +151,9 @@ const mergeVars = (collection, request, requestTreePath = []) => {
}
};
-/**
- * Wraps a script in an IIFE closure to isolate its scope
- * @param {string} script - The script code to wrap
- * @returns {string} The wrapped script
- */
-const wrapScriptInClosure = (script) => {
+// __bruSetScope must stay on the IIFE opener line so wrapAndJoinScripts' line
+// counts (and stack-trace mapping) are unaffected.
+const wrapScriptInClosure = (script, scopeInfo = null) => {
if (!script || script.trim() === '') {
return '';
}
@@ -164,7 +161,10 @@ const wrapScriptInClosure = (script) => {
// Wrap script in async IIFE to create isolated scope
// This prevents variable re-declaration errors and allows early returns
// to only affect the current script segment
- return `await (async () => {
+ const scopeSetter = scopeInfo
+ ? ` __bruSetScope(${JSON.stringify(scopeInfo)});`
+ : '';
+ return `await (async () => {${scopeSetter}
${script}
})();`;
};
@@ -212,8 +212,17 @@ ${script}
* }
* }
*/
-const wrapAndJoinScripts = (scripts, requestIndex, segmentSources = null) => {
- const wrapped = scripts.map((s) => wrapScriptInClosure(s));
+const wrapAndJoinScripts = (scripts, requestIndex, segmentSources = null, requestSegmentSource = null) => {
+ const buildScopeInfo = (i) => {
+ if (i === requestIndex && requestSegmentSource?.displayPath) {
+ return { type: 'request', sourceFile: requestSegmentSource.displayPath };
+ }
+ const seg = segmentSources?.[i];
+ if (!seg?.type || !seg?.displayPath) return null;
+ return { type: seg.type, sourceFile: seg.displayPath };
+ };
+
+ const wrapped = scripts.map((s, i) => wrapScriptInClosure(s, buildScopeInfo(i)));
const code = wrapped.filter(Boolean).join('\n\n');
let offset = 0;
@@ -260,10 +269,15 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => {
const format = collection.format || 'bru';
const config = FORMAT_CONFIG[format];
const collectionSource = {
+ type: 'collection',
filePath: path.join(collection.pathname, config.collectionFile),
displayPath: config.collectionFile
};
+ const requestSegmentSource = request?.pathname && collection?.pathname
+ ? { displayPath: posixifyPath(path.relative(collection.pathname, request.pathname)) }
+ : null;
+
const withContent = (source, script) =>
script?.trim() ? { ...source, scriptContent: script } : source;
@@ -278,6 +292,7 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => {
if (i.type === 'folder') {
const folderRoot = i?.draft || i?.root;
const folderSource = {
+ type: 'folder',
filePath: path.join(i.pathname, config.folderFile),
displayPath: posixifyPath(path.relative(collection.pathname, path.join(i.pathname, config.folderFile)))
};
@@ -310,7 +325,7 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => {
// Wrap scripts, join them, and annotate metadata with the original request script content.
// Returns { code, metadata } where metadata.requestScriptContent is set.
const buildCombinedScript = (scripts, requestIndex, sources, originalScript) => {
- const result = wrapAndJoinScripts(scripts, requestIndex, sources);
+ const result = wrapAndJoinScripts(scripts, requestIndex, sources, requestSegmentSource);
if (result.metadata) {
result.metadata.requestScriptContent = originalScript;
}
diff --git a/packages/bruno-js/src/bru.js b/packages/bruno-js/src/bru.js
index 0e0a87f5b..d50c5698a 100644
--- a/packages/bruno-js/src/bru.js
+++ b/packages/bruno-js/src/bru.js
@@ -1,7 +1,7 @@
const { cloneDeep } = require('lodash');
const xmlFormat = require('xml-formatter');
const { interpolate: _interpolate } = require('@usebruno/common');
-const { sendRequest, createSendRequest } = require('@usebruno/requests').scripting;
+const { createSendRequest } = require('@usebruno/requests').scripting;
const { jar: createCookieJar, getCookiesForUrl } = require('@usebruno/requests').cookies;
const CookieList = require('./cookie-list');
@@ -57,8 +57,17 @@ class Bru {
this.oauth2CredentialVariables = oauth2CredentialVariables || {};
this.collectionPath = collectionPath;
this.collectionName = collectionName;
- // Use createSendRequest with config if provided, otherwise use default sendRequest
- this.sendRequest = certsAndProxyConfig ? createSendRequest(certsAndProxyConfig) : sendRequest;
+ // Set by the host-side __bruSetScope global at the top of each segment's IIFE.
+ this._currentScope = null;
+ this.scriptedRequestEntries = [];
+ this.sendRequest = (...args) => {
+ const scopeSnapshot = this._currentScope ? { ...this._currentScope } : null;
+ const send = createSendRequest(certsAndProxyConfig, {
+ onComplete: (entry) =>
+ this._recordScriptedRequest({ source: 'sendRequest', scope: scopeSnapshot, ...entry })
+ });
+ return send(...args);
+ };
this.runtime = runtime;
this.requestUrl = requestUrl;
this.cookies = new CookieList({
@@ -157,6 +166,16 @@ class Bru {
return this.collectionPath;
}
+ _recordScriptedRequest(entry) {
+ // Prefer scope passed in by the caller (snapshot at call time). Fall back to
+ // _currentScope for callers that don't supply one (e.g. bru.runRequest).
+ const { scope: providedScope, ...rest } = entry;
+ const scope = providedScope !== undefined
+ ? providedScope
+ : (this._currentScope ? { ...this._currentScope } : null);
+ this.scriptedRequestEntries.push({ ...rest, scope });
+ }
+
getEnvName() {
return this.envVariables.__name__;
}
diff --git a/packages/bruno-js/src/runtime/script-runtime.js b/packages/bruno-js/src/runtime/script-runtime.js
index 4d29e9e6e..44bc6ce7d 100644
--- a/packages/bruno-js/src/runtime/script-runtime.js
+++ b/packages/bruno-js/src/runtime/script-runtime.js
@@ -7,6 +7,7 @@ const { createBruTestResultMethods } = require('../utils/results');
const { runScriptInNodeVm } = require('../sandbox/node-vm');
const { executeQuickJsVmAsync } = require('../sandbox/quickjs');
const { SANDBOX } = require('../utils/sandbox');
+const { bindRunRequest, createScopeSetter } = require('./scripted-entries');
class ScriptRuntime {
constructor(props) {
@@ -63,7 +64,8 @@ class ScriptRuntime {
test,
expect: chai.expect,
assert: chai.assert,
- __brunoTestResults: __brunoTestResults
+ __brunoTestResults: __brunoTestResults,
+ __bruSetScope: createScopeSetter(bru)
};
if (onConsoleLog && typeof onConsoleLog === 'function') {
@@ -81,9 +83,7 @@ class ScriptRuntime {
};
}
- if (runRequestByItemPathname) {
- context.bru.runRequest = runRequestByItemPathname;
- }
+ bindRunRequest(bru, runRequestByItemPathname);
// Helper to build the result object for pre-request scripts
// Extracted to avoid duplication across runtime branches
@@ -97,7 +97,8 @@ class ScriptRuntime {
results: cleanJson(__brunoTestResults.getResults()),
nextRequestName: bru.nextRequest,
skipRequest: bru.skipRequest,
- stopExecution: bru.stopExecution
+ stopExecution: bru.stopExecution,
+ scriptedRequestEntries: cleanJson(bru.scriptedRequestEntries || [])
});
// Track script errors to attach partial results before re-throwing
@@ -199,7 +200,8 @@ class ScriptRuntime {
test,
expect: chai.expect,
assert: chai.assert,
- __brunoTestResults: __brunoTestResults
+ __brunoTestResults: __brunoTestResults,
+ __bruSetScope: createScopeSetter(bru)
};
if (onConsoleLog && typeof onConsoleLog === 'function') {
@@ -217,9 +219,7 @@ class ScriptRuntime {
};
}
- if (runRequestByItemPathname) {
- context.bru.runRequest = runRequestByItemPathname;
- }
+ bindRunRequest(bru, runRequestByItemPathname);
// Helper to build the result object for post-response scripts
// Extracted to avoid duplication across runtime branches
@@ -233,7 +233,8 @@ class ScriptRuntime {
results: cleanJson(__brunoTestResults.getResults()),
nextRequestName: bru.nextRequest,
skipRequest: bru.skipRequest,
- stopExecution: bru.stopExecution
+ stopExecution: bru.stopExecution,
+ scriptedRequestEntries: cleanJson(bru.scriptedRequestEntries || [])
});
// Track script errors to attach partial results before re-throwing
diff --git a/packages/bruno-js/src/runtime/scripted-entries.js b/packages/bruno-js/src/runtime/scripted-entries.js
new file mode 100644
index 000000000..6419cce78
--- /dev/null
+++ b/packages/bruno-js/src/runtime/scripted-entries.js
@@ -0,0 +1,16 @@
+// Forwards the caller's bru as a second arg so the host can attribute the call.
+const bindRunRequest = (bru, runRequestByItemPathname) => {
+ if (!runRequestByItemPathname) return;
+ bru.runRequest = (relativePathname) =>
+ runRequestByItemPathname(relativePathname, bru);
+};
+
+// Kept off bru to stay out of user-facing autocomplete.
+const createScopeSetter = (bru) => (scope) => {
+ bru._currentScope = scope || null;
+};
+
+module.exports = {
+ bindRunRequest,
+ createScopeSetter
+};
diff --git a/packages/bruno-js/src/runtime/test-runtime.js b/packages/bruno-js/src/runtime/test-runtime.js
index 3daa0dfde..3742aee59 100644
--- a/packages/bruno-js/src/runtime/test-runtime.js
+++ b/packages/bruno-js/src/runtime/test-runtime.js
@@ -8,6 +8,7 @@ const { runScriptInNodeVm } = require('../sandbox/node-vm');
const jsonwebtoken = require('jsonwebtoken');
const { executeQuickJsVmAsync } = require('../sandbox/quickjs');
const { SANDBOX } = require('../utils/sandbox');
+const { bindRunRequest, createScopeSetter } = require('./scripted-entries');
class TestRuntime {
constructor(props) {
@@ -77,7 +78,8 @@ class TestRuntime {
expect: chai.expect,
assert: chai.assert,
__brunoTestResults: __brunoTestResults,
- jwt: jsonwebtoken
+ jwt: jsonwebtoken,
+ __bruSetScope: createScopeSetter(bru)
};
if (onConsoleLog && typeof onConsoleLog === 'function') {
@@ -95,9 +97,7 @@ class TestRuntime {
};
}
- if (runRequestByItemPathname) {
- context.bru.runRequest = runRequestByItemPathname;
- }
+ bindRunRequest(bru, runRequestByItemPathname);
let scriptError = null;
@@ -131,7 +131,8 @@ class TestRuntime {
persistentEnvVariables: cleanJson(bru.persistentEnvVariables),
oauth2CredentialsToReset: bru.oauth2CredentialsToReset,
results: cleanJson(__brunoTestResults.getResults()),
- nextRequestName: bru.nextRequest
+ nextRequestName: bru.nextRequest,
+ scriptedRequestEntries: cleanJson(bru.scriptedRequestEntries || [])
};
if (scriptError) {
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js
index 83a3d6e11..859fd0e5f 100644
--- a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js
@@ -344,6 +344,12 @@ const addBruShimToContext = (vm, bru) => {
});
sendRequestHandle.consume((handle) => vm.setProp(bruObject, '_sendRequest', handle));
+ // On vm.global, not bru, to stay off user-facing autocomplete.
+ let setScopeHandle = vm.newFunction('__bruSetScope', (scopeArg) => {
+ bru._currentScope = vm.dump(scopeArg) || null;
+ });
+ setScopeHandle.consume((handle) => vm.setProp(vm.global, '__bruSetScope', handle));
+
const sleep = vm.newFunction('sleep', (timer) => {
const t = vm.getString(timer);
const promise = vm.newPromise();
diff --git a/packages/bruno-js/tests/bru-scripted-entries.spec.js b/packages/bruno-js/tests/bru-scripted-entries.spec.js
new file mode 100644
index 000000000..107654277
--- /dev/null
+++ b/packages/bruno-js/tests/bru-scripted-entries.spec.js
@@ -0,0 +1,132 @@
+// Mocked so we can drive onComplete directly without hitting the network. We
+// defer onComplete to a microtask so it fires after the synchronous call site
+// returns (same timing as a real network call, and what the race-condition
+// test below relies on).
+jest.mock('@usebruno/requests', () => {
+ const realCookies = jest.requireActual('@usebruno/requests').cookies;
+ return {
+ cookies: realCookies,
+ scripting: {
+ createSendRequest: jest.fn((_config, options) => {
+ return async (requestConfig) => {
+ const normalized = typeof requestConfig === 'string' ? { url: requestConfig } : requestConfig;
+ await Promise.resolve();
+ options?.onComplete?.({
+ request: {
+ method: (normalized.method || 'GET').toUpperCase(),
+ url: normalized.url,
+ headers: normalized.headers || {},
+ data: normalized.data
+ },
+ response: {
+ statusCode: 200,
+ statusText: 'OK',
+ headers: { 'content-type': 'text/plain' },
+ data: 'ok',
+ dataBuffer: Buffer.from('ok').toString('base64'),
+ size: 2,
+ duration: 4
+ },
+ error: null,
+ startedAt: 1,
+ completedAt: 5
+ });
+ return { status: 200, data: 'ok' };
+ };
+ })
+ }
+ };
+});
+
+const Bru = require('../src/bru');
+
+const makeBru = () =>
+ new Bru({
+ runtime: 'quickjs',
+ envVariables: {},
+ runtimeVariables: {},
+ processEnvVars: {},
+ collectionPath: '/coll',
+ collectionName: 'Test',
+ certsAndProxyConfig: { collectionPath: '/coll' }
+ });
+
+describe('Bru — scripted request capture', () => {
+ test('starts with an empty scriptedRequestEntries array', () => {
+ const bru = makeBru();
+ expect(bru.scriptedRequestEntries).toEqual([]);
+ });
+
+ test('records a sendRequest call with source = "sendRequest"', async () => {
+ const bru = makeBru();
+ await bru.sendRequest({ method: 'get', url: 'https://example.com/ping' });
+
+ expect(bru.scriptedRequestEntries).toHaveLength(1);
+ expect(bru.scriptedRequestEntries[0]).toEqual(
+ expect.objectContaining({
+ source: 'sendRequest',
+ request: expect.objectContaining({ method: 'GET', url: 'https://example.com/ping' }),
+ response: expect.objectContaining({ statusCode: 200, statusText: 'OK' })
+ })
+ );
+ });
+
+ test('records null scope when no _currentScope is set', async () => {
+ const bru = makeBru();
+ await bru.sendRequest('https://example.com');
+ expect(bru.scriptedRequestEntries[0].scope).toBeNull();
+ });
+
+ test('stamps the current scope onto each entry (snapshot, not reference)', async () => {
+ const bru = makeBru();
+
+ bru._currentScope = { type: 'collection', sourceFile: 'collection.bru' };
+ await bru.sendRequest('https://example.com/a');
+
+ // Flip scope. The earlier entry must keep its original snapshot.
+ bru._currentScope = { type: 'request', sourceFile: 'auth/login.bru' };
+ await bru.sendRequest('https://example.com/b');
+
+ expect(bru.scriptedRequestEntries).toHaveLength(2);
+ expect(bru.scriptedRequestEntries[0].scope).toEqual({ type: 'collection', sourceFile: 'collection.bru' });
+ expect(bru.scriptedRequestEntries[1].scope).toEqual({ type: 'request', sourceFile: 'auth/login.bru' });
+ });
+
+ test('uses scope at call time, not completion time, for non-awaited sendRequest', async () => {
+ const bru = makeBru();
+
+ // Fire-and-forget call in scope A.
+ bru._currentScope = { type: 'collection', sourceFile: 'collection.bru' };
+ const inFlight = bru.sendRequest('https://example.com/late');
+
+ // The host moves to the next segment and __bruSetScope flips the scope
+ // before the network call settles.
+ bru._currentScope = { type: 'request', sourceFile: 'auth/login.bru' };
+
+ await inFlight;
+
+ expect(bru.scriptedRequestEntries).toHaveLength(1);
+ expect(bru.scriptedRequestEntries[0].scope).toEqual({ type: 'collection', sourceFile: 'collection.bru' });
+ });
+
+ test('_recordScriptedRequest accepts entries from other sources (e.g. runRequest)', () => {
+ const bru = makeBru();
+ bru._currentScope = { type: 'folder', sourceFile: 'auth/folder.bru' };
+ bru._recordScriptedRequest({
+ source: 'runRequest',
+ request: { method: 'GET', url: 'https://example.com/user' },
+ response: { statusCode: 200, statusText: 'OK', headers: {}, data: 'x', dataBuffer: '', size: 0, duration: 1 },
+ error: null,
+ startedAt: 10,
+ completedAt: 11
+ });
+
+ expect(bru.scriptedRequestEntries).toHaveLength(1);
+ expect(bru.scriptedRequestEntries[0]).toEqual(
+ expect.objectContaining({
+ source: 'runRequest',
+ scope: { type: 'folder', sourceFile: 'auth/folder.bru' }
+ })
+ );
+ });
+});
diff --git a/packages/bruno-js/tests/script-runtime-scripted-entries.spec.js b/packages/bruno-js/tests/script-runtime-scripted-entries.spec.js
new file mode 100644
index 000000000..9cf253ea4
--- /dev/null
+++ b/packages/bruno-js/tests/script-runtime-scripted-entries.spec.js
@@ -0,0 +1,209 @@
+// Mocked so bru.sendRequest doesn't hit the network.
+jest.mock('@usebruno/requests', () => {
+ const realCookies = jest.requireActual('@usebruno/requests').cookies;
+ return {
+ cookies: realCookies,
+ scripting: {
+ createSendRequest: jest.fn((_config, options) => {
+ return async (requestConfig) => {
+ const normalized = typeof requestConfig === 'string' ? { url: requestConfig } : requestConfig;
+ options?.onComplete?.({
+ request: {
+ method: (normalized.method || 'GET').toUpperCase(),
+ url: normalized.url,
+ headers: normalized.headers || {},
+ data: normalized.data
+ },
+ response: {
+ statusCode: 200,
+ statusText: 'OK',
+ headers: {},
+ data: 'mocked',
+ dataBuffer: Buffer.from('mocked').toString('base64'),
+ size: 6,
+ duration: 3
+ },
+ error: null,
+ startedAt: 1,
+ completedAt: 4
+ });
+ return { status: 200, data: 'mocked' };
+ };
+ })
+ }
+ };
+});
+
+const ScriptRuntime = require('../src/runtime/script-runtime');
+const TestRuntime = require('../src/runtime/test-runtime');
+
+const baseRequest = { method: 'GET', url: 'http://localhost/', headers: {}, data: undefined };
+const baseResponse = { status: 200, statusText: 'OK', data: {} };
+
+describe('ScriptRuntime — scripted entries across the three script phases', () => {
+ describe('pre-request (runRequestScript)', () => {
+ test('drains bru.sendRequest calls into result.scriptedRequestEntries', async () => {
+ const script = `await bru.sendRequest('https://example.com/ping');`;
+ const runtime = new ScriptRuntime({ runtime: 'nodevm' });
+ const result = await runtime.runRequestScript(
+ script, { ...baseRequest }, {}, {}, '.', null, process.env
+ );
+
+ expect(result.scriptedRequestEntries).toHaveLength(1);
+ expect(result.scriptedRequestEntries[0]).toEqual(
+ expect.objectContaining({
+ source: 'sendRequest',
+ request: expect.objectContaining({ url: 'https://example.com/ping' })
+ })
+ );
+ });
+
+ test('returns an empty array when the script makes no scripted requests', async () => {
+ const runtime = new ScriptRuntime({ runtime: 'nodevm' });
+ const result = await runtime.runRequestScript(
+ `bru.setVar('foo', 'bar');`, { ...baseRequest }, {}, {}, '.', null, process.env
+ );
+ expect(result.scriptedRequestEntries).toEqual([]);
+ });
+
+ test('__bruSetScope from inside the script stamps scope onto every later entry', async () => {
+ const script = `
+ __bruSetScope({ type: 'collection', sourceFile: 'collection.bru' });
+ await bru.sendRequest('https://example.com/a');
+ __bruSetScope({ type: 'request', sourceFile: 'auth/login.bru' });
+ await bru.sendRequest('https://example.com/b');
+ `;
+ const runtime = new ScriptRuntime({ runtime: 'nodevm' });
+ const result = await runtime.runRequestScript(
+ script, { ...baseRequest }, {}, {}, '.', null, process.env
+ );
+
+ expect(result.scriptedRequestEntries).toHaveLength(2);
+ expect(result.scriptedRequestEntries[0].scope).toEqual({ type: 'collection', sourceFile: 'collection.bru' });
+ expect(result.scriptedRequestEntries[1].scope).toEqual({ type: 'request', sourceFile: 'auth/login.bru' });
+ });
+
+ test('bru.runRequest is wired to the host function and records a runRequest entry', async () => {
+ // Stands in for the electron-side runRequestByItemPathname bridge.
+ const host = jest.fn(async (pathname, callerBru) => {
+ callerBru._recordScriptedRequest({
+ source: 'runRequest',
+ request: { method: 'GET', url: 'inferred/from/' + pathname, headers: {}, data: undefined },
+ response: { statusCode: 200, statusText: 'OK', headers: {}, data: '', dataBuffer: '', size: 0, duration: 1 },
+ error: null,
+ startedAt: 0,
+ completedAt: 1
+ });
+ return { status: 200 };
+ });
+
+ const script = `
+ __bruSetScope({ type: 'request', sourceFile: 'driver.bru' });
+ await bru.runRequest('target.bru');
+ `;
+ const runtime = new ScriptRuntime({ runtime: 'nodevm' });
+ const result = await runtime.runRequestScript(
+ script, { ...baseRequest }, {}, {}, '.', null, process.env, {}, host
+ );
+
+ expect(host).toHaveBeenCalledTimes(1);
+ // bindRunRequest must forward the caller's bru as the second arg.
+ expect(host.mock.calls[0][0]).toBe('target.bru');
+ expect(host.mock.calls[0][1]).toBeDefined();
+ expect(result.scriptedRequestEntries).toHaveLength(1);
+ expect(result.scriptedRequestEntries[0]).toEqual(
+ expect.objectContaining({
+ source: 'runRequest',
+ scope: { type: 'request', sourceFile: 'driver.bru' }
+ })
+ );
+ });
+ });
+
+ describe('post-response (runResponseScript)', () => {
+ test('drains bru.sendRequest calls into result.scriptedRequestEntries', async () => {
+ const script = `await bru.sendRequest('https://example.com/after');`;
+ const runtime = new ScriptRuntime({ runtime: 'nodevm' });
+ const result = await runtime.runResponseScript(
+ script, { ...baseRequest }, { ...baseResponse }, {}, {}, '.', null, process.env
+ );
+ expect(result.scriptedRequestEntries).toHaveLength(1);
+ expect(result.scriptedRequestEntries[0].source).toBe('sendRequest');
+ });
+
+ test('records bru.runRequest calls with the current scope', async () => {
+ const host = jest.fn(async (_pathname, callerBru) => {
+ callerBru._recordScriptedRequest({
+ source: 'runRequest',
+ request: { method: 'GET', url: 'x', headers: {}, data: undefined },
+ response: null,
+ error: null,
+ startedAt: 0,
+ completedAt: 0
+ });
+ });
+ const script = `
+ __bruSetScope({ type: 'folder', sourceFile: 'auth/folder.bru' });
+ await bru.runRequest('next.bru');
+ `;
+ const runtime = new ScriptRuntime({ runtime: 'nodevm' });
+ const result = await runtime.runResponseScript(
+ script, { ...baseRequest }, { ...baseResponse }, {}, {}, '.', null, process.env, {}, host
+ );
+
+ expect(result.scriptedRequestEntries).toHaveLength(1);
+ expect(result.scriptedRequestEntries[0]).toEqual(
+ expect.objectContaining({
+ source: 'runRequest',
+ scope: { type: 'folder', sourceFile: 'auth/folder.bru' }
+ })
+ );
+ });
+ });
+
+ describe('tests (TestRuntime.runTests)', () => {
+ test('drains scripted requests issued from inside test scripts', async () => {
+ const testsFile = `
+ __bruSetScope({ type: 'request', sourceFile: 'spec.bru' });
+ test('calls sendRequest', async () => {
+ await bru.sendRequest('https://example.com/from-tests');
+ });
+ `;
+ const runtime = new TestRuntime({ runtime: 'nodevm' });
+ const result = await runtime.runTests(
+ testsFile, { ...baseRequest }, { ...baseResponse }, {}, {}, '.', null, process.env
+ );
+
+ expect(result.scriptedRequestEntries).toHaveLength(1);
+ expect(result.scriptedRequestEntries[0]).toEqual(
+ expect.objectContaining({
+ source: 'sendRequest',
+ scope: { type: 'request', sourceFile: 'spec.bru' },
+ request: expect.objectContaining({ url: 'https://example.com/from-tests' })
+ })
+ );
+ });
+ });
+
+ describe('partial results on script error', () => {
+ test('pre-request: entries recorded before the throw are preserved on partialResults', async () => {
+ const script = `
+ await bru.sendRequest('https://example.com/before');
+ throw new Error('explode');
+ `;
+ const runtime = new ScriptRuntime({ runtime: 'nodevm' });
+
+ let captured;
+ try {
+ await runtime.runRequestScript(script, { ...baseRequest }, {}, {}, '.', null, process.env);
+ } catch (err) {
+ captured = err;
+ }
+
+ expect(captured).toBeDefined();
+ expect(captured.partialResults).toBeDefined();
+ expect(captured.partialResults.scriptedRequestEntries).toHaveLength(1);
+ expect(captured.partialResults.scriptedRequestEntries[0].source).toBe('sendRequest');
+ });
+ });
+});
diff --git a/packages/bruno-js/tests/scripted-entries.spec.js b/packages/bruno-js/tests/scripted-entries.spec.js
new file mode 100644
index 000000000..e67c869f3
--- /dev/null
+++ b/packages/bruno-js/tests/scripted-entries.spec.js
@@ -0,0 +1,61 @@
+const { bindRunRequest, createScopeSetter } = require('../src/runtime/scripted-entries');
+
+describe('bindRunRequest', () => {
+ test('does nothing when no host function is provided', () => {
+ const bru = {};
+ bindRunRequest(bru, undefined);
+ expect(bru.runRequest).toBeUndefined();
+ });
+
+ test('exposes bru.runRequest that forwards (pathname, callerBru) to the host', async () => {
+ const host = jest.fn().mockResolvedValue('done');
+ const bru = {};
+
+ bindRunRequest(bru, host);
+ const result = await bru.runRequest('relative/path.bru');
+
+ expect(result).toBe('done');
+ expect(host).toHaveBeenCalledTimes(1);
+ expect(host).toHaveBeenCalledWith('relative/path.bru', bru);
+ });
+
+ test('each bru gets bound with its own callerBru so entries can be attributed', async () => {
+ const host = jest.fn().mockResolvedValue(null);
+ const bruA = { name: 'A' };
+ const bruB = { name: 'B' };
+
+ bindRunRequest(bruA, host);
+ bindRunRequest(bruB, host);
+
+ await bruA.runRequest('a.bru');
+ await bruB.runRequest('b.bru');
+
+ expect(host).toHaveBeenNthCalledWith(1, 'a.bru', bruA);
+ expect(host).toHaveBeenNthCalledWith(2, 'b.bru', bruB);
+ });
+});
+
+describe('createScopeSetter', () => {
+ test('mutates bru._currentScope with the scope object', () => {
+ const bru = {};
+ const setScope = createScopeSetter(bru);
+
+ setScope({ type: 'collection', sourceFile: 'collection.bru' });
+ expect(bru._currentScope).toEqual({ type: 'collection', sourceFile: 'collection.bru' });
+
+ setScope({ type: 'request', sourceFile: 'auth/login.bru' });
+ expect(bru._currentScope).toEqual({ type: 'request', sourceFile: 'auth/login.bru' });
+ });
+
+ test('clears _currentScope when called with a falsy value', () => {
+ const bru = { _currentScope: { type: 'folder', sourceFile: 'auth/folder.bru' } };
+ const setScope = createScopeSetter(bru);
+
+ setScope(null);
+ expect(bru._currentScope).toBeNull();
+
+ setScope({ type: 'request', sourceFile: 'x.bru' });
+ setScope(undefined);
+ expect(bru._currentScope).toBeNull();
+ });
+});
diff --git a/packages/bruno-requests/src/scripting/index.ts b/packages/bruno-requests/src/scripting/index.ts
index 2cb147b73..c0e7c16e8 100644
--- a/packages/bruno-requests/src/scripting/index.ts
+++ b/packages/bruno-requests/src/scripting/index.ts
@@ -1 +1 @@
-export { default as sendRequest, createSendRequest } from './send-request';
+export { default as sendRequest, createSendRequest, buildScriptedEntry } from './send-request';
diff --git a/packages/bruno-requests/src/scripting/scripted-entry.spec.ts b/packages/bruno-requests/src/scripting/scripted-entry.spec.ts
new file mode 100644
index 000000000..c0959805d
--- /dev/null
+++ b/packages/bruno-requests/src/scripting/scripted-entry.spec.ts
@@ -0,0 +1,232 @@
+// Network behavior of sendRequest lives in send-request.spec.ts.
+import { createSendRequest, buildScriptedEntry } from './send-request';
+
+jest.mock('../network', () => ({
+ makeAxiosInstance: jest.fn()
+}));
+
+jest.mock('../utils/http-https-agents', () => ({
+ getHttpHttpsAgents: jest.fn()
+}));
+
+import { makeAxiosInstance } from '../network';
+import { getHttpHttpsAgents } from '../utils/http-https-agents';
+
+const mockMakeAxiosInstance = makeAxiosInstance as jest.Mock;
+const mockGetHttpHttpsAgents = getHttpHttpsAgents as jest.Mock;
+
+describe('buildScriptedEntry', () => {
+ test('normalizes method to upper case and preserves request fields', () => {
+ const entry = buildScriptedEntry({
+ request: { method: 'get', url: 'https://example.com', headers: { 'x-a': '1' }, data: undefined },
+ response: null,
+ error: null,
+ startedAt: 1000,
+ completedAt: 1042
+ });
+
+ expect(entry.request.method).toBe('GET');
+ expect(entry.request.url).toBe('https://example.com');
+ expect(entry.request.headers).toEqual({ 'x-a': '1' });
+ expect(entry.response).toBeNull();
+ expect(entry.error).toBeNull();
+ expect(entry.startedAt).toBe(1000);
+ expect(entry.completedAt).toBe(1042);
+ });
+
+ test('defaults method to GET when not provided', () => {
+ const entry = buildScriptedEntry({
+ request: { url: 'https://example.com' },
+ response: null,
+ error: null,
+ startedAt: 0,
+ completedAt: 0
+ });
+ expect(entry.request.method).toBe('GET');
+ });
+
+ test('flattens AxiosHeaders-like objects via toJSON for both request and response', () => {
+ const headersLike = {
+ toJSON: () => ({ 'content-type': 'application/json', 'x-trace': 'abc' })
+ };
+
+ const entry = buildScriptedEntry({
+ request: { method: 'post', url: 'https://example.com', headers: headersLike, data: { hi: 1 } },
+ response: { status: 200, statusText: 'OK', headers: headersLike, data: { ok: true } },
+ error: null,
+ startedAt: 0,
+ completedAt: 10
+ });
+
+ expect(entry.request.headers).toEqual({ 'content-type': 'application/json', 'x-trace': 'abc' });
+ expect(entry.response?.headers).toEqual({ 'content-type': 'application/json', 'x-trace': 'abc' });
+ });
+
+ test('encodes string body to base64 dataBuffer and derives size/duration when not supplied', () => {
+ const entry = buildScriptedEntry({
+ request: { method: 'GET', url: 'https://example.com' },
+ response: { status: 200, statusText: 'OK', headers: {}, data: 'hello' },
+ error: null,
+ startedAt: 5,
+ completedAt: 15
+ });
+
+ expect(entry.response?.dataBuffer).toBe(Buffer.from('hello').toString('base64'));
+ expect(entry.response?.size).toBe(Buffer.from('hello').length);
+ expect(entry.response?.duration).toBe(10);
+ });
+
+ test('JSON-stringifies object body for dataBuffer when not provided', () => {
+ const body = { foo: 'bar' };
+ const entry = buildScriptedEntry({
+ request: { method: 'GET', url: 'https://example.com' },
+ response: { status: 201, statusText: 'Created', headers: {}, data: body },
+ error: null,
+ startedAt: 0,
+ completedAt: 0
+ });
+
+ expect(entry.response?.dataBuffer).toBe(Buffer.from(JSON.stringify(body)).toString('base64'));
+ });
+
+ test('honors explicit dataBuffer / size / duration on response', () => {
+ const explicitBuffer = Buffer.from('payload').toString('base64');
+ const entry = buildScriptedEntry({
+ request: { method: 'GET', url: 'https://example.com' },
+ response: {
+ status: 200,
+ statusText: 'OK',
+ headers: {},
+ data: 'ignored-for-size',
+ dataBuffer: explicitBuffer,
+ size: 999,
+ duration: 123
+ },
+ error: null,
+ startedAt: 0,
+ completedAt: 50
+ });
+
+ expect(entry.response?.dataBuffer).toBe(explicitBuffer);
+ expect(entry.response?.size).toBe(999);
+ expect(entry.response?.duration).toBe(123);
+ });
+
+ test('maps error to { message, code } and leaves response null when absent', () => {
+ const err = Object.assign(new Error('boom'), { code: 'ECONNREFUSED' });
+
+ const entry = buildScriptedEntry({
+ request: { method: 'GET', url: 'https://example.com' },
+ response: null,
+ error: err,
+ startedAt: 0,
+ completedAt: 0
+ });
+
+ expect(entry.response).toBeNull();
+ expect(entry.error).toEqual({ message: 'boom', code: 'ECONNREFUSED' });
+ });
+});
+
+describe('createSendRequest onComplete', () => {
+ let mockAxios: jest.Mock;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockAxios = jest.fn();
+ mockMakeAxiosInstance.mockReturnValue(mockAxios);
+ mockGetHttpHttpsAgents.mockResolvedValue({ httpAgent: null, httpsAgent: null });
+ });
+
+ test('fires once with the entry on a successful no-callback call', async () => {
+ mockAxios.mockResolvedValue({
+ status: 200,
+ statusText: 'OK',
+ headers: { 'content-type': 'text/plain' },
+ data: 'pong'
+ });
+ const onComplete = jest.fn();
+ const send = createSendRequest(undefined, { onComplete });
+
+ await send({ method: 'get', url: 'https://example.com/ping' });
+
+ expect(onComplete).toHaveBeenCalledTimes(1);
+ const entry = onComplete.mock.calls[0][0];
+ expect(entry.request).toEqual(
+ expect.objectContaining({ method: 'GET', url: 'https://example.com/ping' })
+ );
+ expect(entry.response).toEqual(
+ expect.objectContaining({
+ statusCode: 200,
+ statusText: 'OK',
+ headers: { 'content-type': 'text/plain' }
+ })
+ );
+ expect(entry.error).toBeNull();
+ });
+
+ test('records the response carried by a 4xx/5xx axios error', async () => {
+ const axiosError: any = new Error('Request failed with status code 404');
+ axiosError.response = {
+ status: 404,
+ statusText: 'Not Found',
+ headers: {},
+ data: 'missing'
+ };
+ mockAxios.mockRejectedValue(axiosError);
+ const onComplete = jest.fn();
+ const send = createSendRequest(undefined, { onComplete });
+
+ await expect(send({ url: 'https://example.com/missing' })).rejects.toBe(axiosError);
+
+ expect(onComplete).toHaveBeenCalledTimes(1);
+ const entry = onComplete.mock.calls[0][0];
+ expect(entry.response).toEqual(
+ expect.objectContaining({ statusCode: 404, statusText: 'Not Found' })
+ );
+ expect(entry.error?.message).toContain('404');
+ });
+
+ test('records error with null response on a pure network failure', async () => {
+ const netErr = Object.assign(new Error('ECONNREFUSED'), { code: 'ECONNREFUSED' });
+ mockAxios.mockRejectedValue(netErr);
+ const onComplete = jest.fn();
+ const send = createSendRequest(undefined, { onComplete });
+
+ await expect(send({ url: 'https://nope.invalid' })).rejects.toBe(netErr);
+
+ expect(onComplete).toHaveBeenCalledTimes(1);
+ const entry = onComplete.mock.calls[0][0];
+ expect(entry.response).toBeNull();
+ expect(entry.error).toEqual({ message: 'ECONNREFUSED', code: 'ECONNREFUSED' });
+ });
+
+ test('fires exactly once even when a callback is provided', async () => {
+ mockAxios.mockResolvedValue({ status: 200, statusText: 'OK', headers: {}, data: 'ok' });
+ const onComplete = jest.fn();
+ const callback = jest.fn();
+ const send = createSendRequest(undefined, { onComplete });
+
+ await send({ url: 'https://example.com' }, callback);
+
+ expect(callback).toHaveBeenCalledTimes(1);
+ expect(onComplete).toHaveBeenCalledTimes(1);
+ });
+
+ test('a throwing onComplete does not break the request result', async () => {
+ const mockResponse = { status: 200, statusText: 'OK', headers: {}, data: 'ok' };
+ mockAxios.mockResolvedValue(mockResponse);
+ const onComplete = jest.fn(() => { throw new Error('sink blew up'); });
+ const send = createSendRequest(undefined, { onComplete });
+
+ await expect(send({ url: 'https://example.com' })).resolves.toBe(mockResponse);
+ expect(onComplete).toHaveBeenCalledTimes(1);
+ });
+
+ test('does nothing when no onComplete is provided', async () => {
+ mockAxios.mockResolvedValue({ status: 200, statusText: 'OK', headers: {}, data: 'ok' });
+ const send = createSendRequest();
+
+ await expect(send({ url: 'https://example.com' })).resolves.toBeDefined();
+ });
+});
diff --git a/packages/bruno-requests/src/scripting/send-request.spec.ts b/packages/bruno-requests/src/scripting/send-request.spec.ts
index d785ff9b4..13fa00429 100644
--- a/packages/bruno-requests/src/scripting/send-request.spec.ts
+++ b/packages/bruno-requests/src/scripting/send-request.spec.ts
@@ -121,7 +121,10 @@ describe('createSendRequest', () => {
const mockResponse = { data: 'test' };
mockAxios.mockResolvedValue(mockResponse);
- const customSendRequest = createSendRequest({ proxyConfig: {} });
+ // `proxyConfig` isn't a real key on SendRequestConfig. This test asserts
+ // that whatever the caller passes is spread through to getHttpHttpsAgents
+ // verbatim. Cast to `any` so the deliberately-loose call still type-checks.
+ const customSendRequest = createSendRequest({ proxyConfig: {} } as any);
await customSendRequest({ url: 'https://example.com' });
expect(mockGetHttpHttpsAgents).toHaveBeenCalledWith({
@@ -145,7 +148,7 @@ describe('createSendRequest', () => {
});
mockAxios.mockResolvedValue({ data: 'test' });
- const customSendRequest = createSendRequest({ proxyConfig: {} });
+ const customSendRequest = createSendRequest({ proxyConfig: {} } as any);
await customSendRequest({
url: 'https://example.com',
httpAgent: configHttpAgent,
@@ -179,7 +182,8 @@ describe('createSendRequest', () => {
const mockResponse = { data: 'pong' };
mockAxios.mockResolvedValue(mockResponse);
- const customSendRequest = createSendRequest({ collectionPath: '/test' });
+ // SendRequestConfig also requires `options`; cast for a fixture-only partial.
+ const customSendRequest = createSendRequest({ collectionPath: '/test' } as any);
const result = await customSendRequest('https://example.com/ping');
expect(result).toBe(mockResponse);
diff --git a/packages/bruno-requests/src/scripting/send-request.ts b/packages/bruno-requests/src/scripting/send-request.ts
index 935a820d1..5d219c202 100644
--- a/packages/bruno-requests/src/scripting/send-request.ts
+++ b/packages/bruno-requests/src/scripting/send-request.ts
@@ -1,4 +1,4 @@
-import { AxiosRequestConfig } from 'axios';
+import { AxiosRequestConfig, AxiosResponse } from 'axios';
import { makeAxiosInstance } from '../network';
import { getHttpHttpsAgents } from '../utils/http-https-agents';
import type { GetHttpHttpsAgentsParams } from '../utils/http-https-agents';
@@ -12,14 +12,152 @@ type T_SendRequestCallback = (error: any, response: any) => void;
*/
type SendRequestConfig = Omit
;
+type SendRequestEntry = {
+ request: { method: string; url: string | undefined; headers: Record; data: any };
+ response: {
+ statusCode: number;
+ statusText: string;
+ headers: Record;
+ data: any;
+ dataBuffer: string;
+ size: number;
+ duration: number;
+ } | null;
+ error: any | null;
+ startedAt: number;
+ completedAt: number;
+};
+
+type ScriptedEntryRequestInput = {
+ method?: string;
+ url?: string;
+ headers?: any;
+ data?: any;
+};
+
+type ScriptedEntryResponseInput = {
+ status?: number;
+ statusText?: string;
+ headers?: any;
+ data?: any;
+ dataBuffer?: string;
+ size?: number;
+ duration?: number;
+} | null | undefined;
+
+type BuildScriptedEntryArgs = {
+ request: ScriptedEntryRequestInput;
+ response: ScriptedEntryResponseInput;
+ error: any | null;
+ startedAt: number;
+ completedAt: number;
+};
+
+// AxiosHeaders is a class instance; its methods don't survive Electron's IPC
+// structured clone, leaving the renderer with `{}`. Flatten to a plain object.
+const toPlainHeaders = (headers: any): Record => {
+ if (!headers) return {};
+ if (typeof headers.toJSON === 'function') {
+ try { return { ...headers.toJSON() }; } catch (_) { /* fall through */ }
+ }
+ const out: Record = {};
+ for (const key of Object.keys(headers)) out[key] = (headers as any)[key];
+ return out;
+};
+
+// Build dataBuffer eagerly so the Timeline's CodeMirror can size itself on mount.
+const toResponseDataBuffer = (data: any): string => {
+ try {
+ if (data === null || data === undefined) return '';
+ if (typeof data === 'string') return Buffer.from(data).toString('base64');
+ if (Buffer.isBuffer(data)) return data.toString('base64');
+ if (data instanceof ArrayBuffer) return Buffer.from(new Uint8Array(data)).toString('base64');
+ return Buffer.from(JSON.stringify(data)).toString('base64');
+ } catch (_) {
+ return '';
+ }
+};
+
+// Shared with bruno-electron's runRequest so both produce identical entries.
+const buildScriptedEntry = ({
+ request,
+ response,
+ error,
+ startedAt,
+ completedAt
+}: BuildScriptedEntryArgs): SendRequestEntry => {
+ let respPayload: SendRequestEntry['response'] = null;
+ if (response) {
+ const dataBuffer = response.dataBuffer ?? toResponseDataBuffer(response.data);
+ respPayload = {
+ statusCode: typeof response.status === 'number' ? response.status : 0,
+ statusText: response.statusText ?? '',
+ headers: toPlainHeaders(response.headers),
+ data: response.data,
+ dataBuffer,
+ size: typeof response.size === 'number'
+ ? response.size
+ : (dataBuffer ? Buffer.from(dataBuffer, 'base64').length : 0),
+ duration: typeof response.duration === 'number'
+ ? response.duration
+ : (completedAt - startedAt)
+ };
+ }
+ return {
+ request: {
+ method: (request.method || 'get').toString().toUpperCase(),
+ url: request.url,
+ headers: toPlainHeaders(request.headers),
+ data: request.data
+ },
+ response: respPayload,
+ error: error ? { message: error.message, code: error.code } : null,
+ startedAt,
+ completedAt
+ };
+};
+
+type SendRequestOptions = {
+ onComplete?: (entry: SendRequestEntry) => void;
+};
+
/**
* Creates a sendRequest function configured with proxy and certificate settings.
* This allows bru.sendRequest to use the same proxy/certs config as the main request.
*
* @param config - Configuration for proxy, certs, and TLS options (same as getHttpHttpsAgents)
+ * @param options - Optional onComplete sink invoked after each call; used by the Timeline.
* @returns A sendRequest function that applies the config to each request
*/
-const createSendRequest = (config?: SendRequestConfig) => {
+const createSendRequest = (config?: SendRequestConfig, options?: SendRequestOptions) => {
+ const onComplete = options?.onComplete;
+
+ const recordEntry = (
+ normalizedConfig: AxiosRequestConfig,
+ response: AxiosResponse | null,
+ error: any | null,
+ startedAt: number
+ ) => {
+ if (!onComplete) return;
+ const completedAt = Date.now();
+ // A 4xx/5xx surfaces as a thrown error with the response attached. Record it too.
+ const resp = response || error?.response || null;
+ try {
+ onComplete(buildScriptedEntry({
+ request: {
+ method: normalizedConfig.method,
+ url: normalizedConfig.url,
+ headers: normalizedConfig.headers,
+ data: normalizedConfig.data
+ },
+ response: resp,
+ error,
+ startedAt,
+ completedAt
+ }));
+ } catch (_) {}
+ };
+
return async (requestConfig: AxiosRequestConfig | string, callback?: T_SendRequestCallback) => {
// Handle case where requestConfig is a URL string
const normalizedConfig: AxiosRequestConfig = typeof requestConfig === 'string'
@@ -45,13 +183,22 @@ const createSendRequest = (config?: SendRequestConfig) => {
}
const axiosInstance = makeAxiosInstance();
+ const startedAt = Date.now();
if (!callback) {
- return await axiosInstance(normalizedConfig);
+ try {
+ const response = await axiosInstance(normalizedConfig);
+ recordEntry(normalizedConfig, response, null, startedAt);
+ return response;
+ } catch (error: any) {
+ recordEntry(normalizedConfig, null, error, startedAt);
+ throw error;
+ }
}
try {
const response = await axiosInstance(normalizedConfig);
+ recordEntry(normalizedConfig, response, null, startedAt);
try {
await callback(null, response);
return response;
@@ -66,6 +213,7 @@ const createSendRequest = (config?: SendRequestConfig) => {
= error && typeof error.response?.status === 'number'
? { ...error, status: error.response.status }
: error;
+ recordEntry(normalizedConfig, null, error, startedAt);
try {
await callback(errForCallback, null);
} catch (err) {
@@ -79,5 +227,5 @@ const createSendRequest = (config?: SendRequestConfig) => {
const sendRequest = createSendRequest();
export default sendRequest;
-export { createSendRequest };
-export type { SendRequestConfig };
+export { createSendRequest, buildScriptedEntry };
+export type { SendRequestConfig, SendRequestEntry, SendRequestOptions, BuildScriptedEntryArgs };
diff --git a/tests/auth/oauth1/oauth1-runner.spec.ts b/tests/auth/oauth1/oauth1-runner.spec.ts
index 183619562..c662206ac 100644
--- a/tests/auth/oauth1/oauth1-runner.spec.ts
+++ b/tests/auth/oauth1/oauth1-runner.spec.ts
@@ -71,38 +71,39 @@ const runAndValidate = async (page, collectionName: string) => {
};
/**
- * After sending a request, switch to the Timeline tab, expand the latest timeline item,
- * and return locators for the request URL and headers section.
+ * After sending a request, switch to the Timeline tab, expand the latest timeline row,
+ * and return its locator. The expanded detail panel defaults to the Request tab,
+ * which shows the sent URL, headers and body (what OAuth1 placement assertions need).
*/
const openTimelineRequest = async (page) => {
await selectResponsePaneTab(page, 'Timeline');
- // Click the first (latest) timeline item header to expand it
- const timelineItem = page.locator('.timeline-item').first();
- await timelineItem.locator('.oauth-request-item-header').click();
+ const row = page.locator('.timeline-container .tl-row-wrap').first();
+ await row.locator('.tl-row').click();
- return timelineItem;
+ return row;
};
const verifyPlacement = async (page, collectionName: string, requestName: string, placement: 'header' | 'query' | 'body') => {
await openRequest(page, collectionName, requestName);
await sendRequestAndWaitForResponse(page, 200);
- const timelineItem = await openTimelineRequest(page);
- const content = timelineItem.locator('.timeline-item-content');
+ const row = await openTimelineRequest(page);
+ const detail = row.locator('.tl-detail');
if (placement === 'header') {
- await expect(content).toContainText('Authorization');
- await expect(content).toContainText('OAuth');
+ const headers = detail.locator('.tl-headers-table');
+ await expect(headers).toContainText('Authorization');
+ await expect(headers).toContainText('OAuth');
} else if (placement === 'query') {
- const urlPre = content.locator('pre').first();
- await expect(urlPre).toContainText('oauth_consumer_key');
+ await expect(detail.locator('.tl-header-url-text')).toContainText('oauth_consumer_key');
} else {
// Body: oauth params should be in the request body, not in URL or Authorization header
- const urlPre = content.locator('pre').first();
- await expect(urlPre).not.toContainText('oauth_consumer_key');
- // Body section is expanded by default — verify oauth params are in the body
- await expect(content.locator('.collapsible-section').filter({ hasText: 'Body' })).toContainText('oauth_consumer_key');
+ await expect(detail.locator('.tl-header-url-text')).not.toContainText('oauth_consumer_key');
+ const body = detail.locator('.tl-block').filter({
+ has: page.locator('.tl-block-h', { hasText: 'Body' })
+ });
+ await expect(body).toContainText('oauth_consumer_key');
}
};
diff --git a/tests/request/timeline/timeline-nested-runrequest.spec.ts b/tests/request/timeline/timeline-nested-runrequest.spec.ts
new file mode 100644
index 000000000..30bf6012d
--- /dev/null
+++ b/tests/request/timeline/timeline-nested-runrequest.spec.ts
@@ -0,0 +1,131 @@
+import { test, expect } from '../../../playwright';
+import {
+ closeAllCollections,
+ createCollection,
+ createRequest,
+ openRequest,
+ addPreRequestScript,
+ addPostResponseScript,
+ saveRequest,
+ sendRequest,
+ selectResponsePaneTab
+} from '../../utils/page/actions';
+
+// Regression: inner script's sendRequest/runRequest must bubble to outer Timeline.
+test.describe('Timeline — nested bru.runRequest bubbles inner scripted entries to outer Timeline', () => {
+ test.afterEach(async ({ page }) => {
+ await closeAllCollections(page);
+ });
+
+ test('inner request\'s sendRequest call shows up on the outer request\'s Timeline', async ({ page, createTmpDir }) => {
+ const collectionName = 'nested-runrequest';
+ const outer = 'outer'; // the request we send
+ const inner = 'inner'; // invoked via bru.runRequest
+
+ // Distinct URLs so we can identify each row by URL.
+ const outerUrl = 'http://localhost:8081/ping';
+ const innerUrl = 'http://localhost:8081/ping';
+ const innerSendRequestUrl = 'http://localhost:8081/headers';
+
+ await test.step('Create collection with outer + inner requests', async () => {
+ await createCollection(page, collectionName, await createTmpDir(collectionName));
+ await createRequest(page, outer, collectionName, { url: outerUrl });
+ await createRequest(page, inner, collectionName, { url: innerUrl });
+ });
+
+ await test.step('Add pre-request scripts: inner does sendRequest, outer calls runRequest("inner")', async () => {
+ // Inner: sendRequest in pre-request this is what should bubble.
+ await openRequest(page, collectionName, inner);
+ await addPreRequestScript(
+ page,
+ `await bru.sendRequest({ url: "${innerSendRequestUrl}", method: "GET" });`
+ );
+ await saveRequest(page);
+
+ // Outer: drives inner via runRequest.
+ await openRequest(page, collectionName, outer);
+ await addPreRequestScript(page, `await bru.runRequest("${inner}");`);
+ await saveRequest(page);
+ });
+
+ await test.step('Send the outer request', async () => {
+ await sendRequest(page, 200);
+ });
+
+ await test.step('Outer Timeline shows three rows: main + runRequest + bubbled inner sendRequest', async () => {
+ await selectResponsePaneTab(page, 'Timeline');
+
+ const rows = page.locator('.timeline-container .tl-row-wrap');
+ // Without the fix: 2 (main + runRequest); inner sendRequest is dropped.
+ await expect(rows).toHaveCount(3);
+
+ // Badge mix guards against an accidental wrong-3-rows pass.
+ await expect(rows.locator('.tl-badge--main')).toHaveCount(1);
+ await expect(rows.locator('.tl-badge--run-request')).toHaveCount(1);
+ await expect(rows.locator('.tl-badge--scripted')).toHaveCount(1);
+ });
+
+ await test.step('Bubbled sendRequest row targets the inner-script URL (proving it came from inner)', async () => {
+ const rows = page.locator('.timeline-container .tl-row-wrap');
+ const scriptedRow = rows.filter({ has: page.locator('.tl-badge--scripted') });
+ await expect(scriptedRow).toHaveCount(1);
+ await expect(scriptedRow.locator('.tl-col-url')).toContainText('/headers');
+ });
+
+ await test.step('Filter chips count the bubbled entry under Pre-Request', async () => {
+ const chips = page.locator('.timeline-filter-bar .timeline-chip');
+ const countFor = (label: string) =>
+ chips.filter({ hasText: label }).locator('.timeline-chip-count').first();
+
+ await expect(countFor('All')).toHaveText('3');
+ await expect(countFor('Main')).toHaveText('1');
+ // runRequest + bubbled sendRequest both ran during outer's pre-request.
+ await expect(countFor('Pre-Request')).toHaveText('2');
+ });
+ });
+
+ test('inner request\'s post-response sendRequest also bubbles to the outer Timeline', async ({ page, createTmpDir }) => {
+ const collectionName = 'nested-runrequest-post';
+ const outer = 'outer-post';
+ const inner = 'inner-post';
+
+ const outerUrl = 'http://localhost:8081/ping';
+ const innerUrl = 'http://localhost:8081/ping';
+ const innerPostUrl = 'http://localhost:8081/query';
+
+ await test.step('Set up collection with outer + inner requests', async () => {
+ await createCollection(page, collectionName, await createTmpDir(collectionName));
+ await createRequest(page, outer, collectionName, { url: outerUrl });
+ await createRequest(page, inner, collectionName, { url: innerUrl });
+ });
+
+ await test.step('Inner has a post-response sendRequest; outer calls runRequest("inner") in pre-request', async () => {
+ await openRequest(page, collectionName, inner);
+ await addPostResponseScript(
+ page,
+ `await bru.sendRequest({ url: "${innerPostUrl}", method: "GET" });`
+ );
+ await saveRequest(page);
+
+ await openRequest(page, collectionName, outer);
+ await addPreRequestScript(page, `await bru.runRequest("${inner}");`);
+ await saveRequest(page);
+ });
+
+ await test.step('Send outer', async () => {
+ await sendRequest(page, 200);
+ });
+
+ await test.step('Outer Timeline shows the bubbled post-response sendRequest row', async () => {
+ await selectResponsePaneTab(page, 'Timeline');
+
+ const rows = page.locator('.timeline-container .tl-row-wrap');
+ await expect(rows).toHaveCount(3);
+
+ // URL match confirms the scripted row is the post-response one.
+ const scriptedRow = rows.filter({ has: page.locator('.tl-badge--scripted') });
+ await expect(scriptedRow).toHaveCount(1);
+ await expect(scriptedRow.locator('.tl-col-url')).toContainText('/query');
+ });
+ });
+});
diff --git a/tests/request/timeline/timeline-runrequest-network-error.spec.ts b/tests/request/timeline/timeline-runrequest-network-error.spec.ts
new file mode 100644
index 000000000..b6f601357
--- /dev/null
+++ b/tests/request/timeline/timeline-runrequest-network-error.spec.ts
@@ -0,0 +1,59 @@
+import { test, expect } from '../../../playwright';
+import {
+ closeAllCollections,
+ createCollection,
+ createRequest,
+ openRequest,
+ addPreRequestScript,
+ saveRequest,
+ sendRequest,
+ selectResponsePaneTab
+} from '../../utils/page/actions';
+
+test.describe('Timeline — runRequest network-error row shows URL and error code', () => {
+ test.afterEach(async ({ page }) => {
+ await closeAllCollections(page);
+ });
+
+ test('inner ECONNREFUSED shows inner URL + ECONNREFUSED status on outer Timeline', async ({ page, createTmpDir }) => {
+ const collectionName = 'runrequest-network-error';
+ const outer = 'outer';
+ const inner = 'inner';
+
+ const outerUrl = 'http://localhost:8081/ping';
+ // Port nothing listens on -> guaranteed ECONNREFUSED on every platform.
+ const innerUrl = 'http://localhost:9999/nope';
+
+ await test.step('Create outer + inner; inner points at an unreachable port', async () => {
+ await createCollection(page, collectionName, await createTmpDir(collectionName));
+ await createRequest(page, outer, collectionName, { url: outerUrl });
+ await createRequest(page, inner, collectionName, { url: innerUrl });
+ });
+
+ await test.step('Outer pre-request invokes inner and swallows the rejection', async () => {
+ await openRequest(page, collectionName, outer);
+ // try/catch so outer still completes 200 and the Timeline renders.
+ await addPreRequestScript(
+ page,
+ `try { await bru.runRequest("${inner}"); } catch (e) { /* expected */ }`
+ );
+ await saveRequest(page);
+ });
+
+ await test.step('Send outer', async () => {
+ await sendRequest(page, 200);
+ });
+
+ await test.step('Outer Timeline has the runRequest row with inner URL (URL fallback)', async () => {
+ await selectResponsePaneTab(page, 'Timeline');
+
+ const rows = page.locator('.timeline-container .tl-row-wrap');
+ await expect(rows).toHaveCount(2); // main + runRequest
+
+ // Without the URL fallback this column would be empty.
+ const runRequestRow = rows.filter({ has: page.locator('.tl-badge--run-request') });
+ await expect(runRequestRow).toHaveCount(1);
+ await expect(runRequestRow.locator('.tl-col-url')).toContainText('localhost:9999');
+ });
+ });
+});
diff --git a/tests/request/timeline/timeline-runrequest-skip.spec.ts b/tests/request/timeline/timeline-runrequest-skip.spec.ts
new file mode 100644
index 000000000..794c46a33
--- /dev/null
+++ b/tests/request/timeline/timeline-runrequest-skip.spec.ts
@@ -0,0 +1,54 @@
+import { test, expect } from '../../../playwright';
+import {
+ closeAllCollections,
+ createCollection,
+ createRequest,
+ openRequest,
+ addPreRequestScript,
+ saveRequest,
+ sendRequest,
+ selectResponsePaneTab
+} from '../../utils/page/actions';
+
+test.describe('Timeline — bru.runRequest skips unsupported item types', () => {
+ test.afterEach(async ({ page }) => {
+ await closeAllCollections(page);
+ });
+
+ test('shows Skipped rows for WS and gRPC targets', async ({ page, createTmpDir }) => {
+ const collectionName = 'runrequest-skip';
+ const driver = 'driver';
+
+ await test.step('Create collection with HTTP driver + WS and gRPC targets', async () => {
+ await createCollection(page, collectionName, await createTmpDir(collectionName));
+ await createRequest(page, driver, collectionName, { url: 'http://localhost:8081/ping' });
+ await createRequest(page, 'ws-target', collectionName, { url: 'ws://localhost:8081/ws', requestType: 'ws' });
+ await createRequest(page, 'grpc-target', collectionName, { url: 'grpc://localhost:50051', requestType: 'grpc' });
+ });
+
+ await test.step('Pre-request script calls bru.runRequest on both unsupported targets', async () => {
+ await openRequest(page, collectionName, driver);
+ await addPreRequestScript(
+ page,
+ `await bru.runRequest("ws-target");\nawait bru.runRequest("grpc-target");`
+ );
+ await saveRequest(page);
+ });
+
+ await test.step('Send driver request', async () => {
+ await sendRequest(page, 200);
+ });
+
+ await test.step('Timeline has main + two Skipped runRequest rows', async () => {
+ await selectResponsePaneTab(page, 'Timeline');
+
+ const rows = page.locator('.timeline-container .tl-row-wrap');
+ await expect(rows).toHaveCount(3);
+
+ const skippedRows = rows.filter({ has: page.locator('.tl-badge--run-request') });
+ await expect(skippedRows).toHaveCount(2);
+ await expect(skippedRows.nth(0).locator('.timeline-status')).toContainText('Skipped');
+ await expect(skippedRows.nth(1).locator('.timeline-status')).toContainText('Skipped');
+ });
+ });
+});
diff --git a/tests/request/timeline/timeline-scripted-requests.spec.ts b/tests/request/timeline/timeline-scripted-requests.spec.ts
new file mode 100644
index 000000000..56a737979
--- /dev/null
+++ b/tests/request/timeline/timeline-scripted-requests.spec.ts
@@ -0,0 +1,153 @@
+import { test, expect } from '../../../playwright';
+import {
+ closeAllCollections,
+ createCollection,
+ createFolder,
+ createRequest,
+ openRequest,
+ expandFolder,
+ addPreRequestScript,
+ addPostResponseScript,
+ addFolderScript,
+ addCollectionScript,
+ saveRequest,
+ sendRequest,
+ selectResponsePaneTab
+} from '../../utils/page/actions';
+import { runCollection } from '../../utils/page/runner';
+
+test.describe('Timeline — scripted requests (sendRequest / runRequest)', () => {
+ // Each test sets up its own collection and tears it down. No shared state.
+ test.afterEach(async ({ page }) => {
+ await closeAllCollections(page);
+ });
+
+ test('captures collection/folder/request pre-request scripts with correct badges, counts, ordering, and filter behavior', async ({ page, createTmpDir }) => {
+ const collectionName = 'timeline-scripted-test';
+ const folderName = 'driver-folder';
+ const driverRequest = 'driver-request';
+ const driverUrl = 'http://localhost:8081/ping';
+ // Three pre-request sendRequest calls cascade collection → folder → request.
+ const collectionSendUrl = 'http://localhost:8081/api/echo/path/collection';
+ const folderSendUrl = 'http://localhost:8081/headers';
+ const requestSendUrl = 'http://localhost:8081/query';
+
+ await test.step('Create collection, folder, and a single request inside the folder', async () => {
+ await createCollection(page, collectionName, await createTmpDir(collectionName));
+ await createFolder(page, folderName, collectionName);
+ // Newly-created folders are collapsed; expand so the new request becomes visible.
+ await expandFolder(page, folderName);
+ await createRequest(page, driverRequest, folderName, { url: driverUrl, inFolder: true });
+ });
+
+ await test.step('Add collection, folder, and request pre-request scripts (each does its own sendRequest)', async () => {
+ await addCollectionScript(page, collectionName, 'pre-request', `await bru.sendRequest({ url: "${collectionSendUrl}", method: "GET" });`);
+ await addFolderScript(page, folderName, 'pre-request', `await bru.sendRequest({ url: "${folderSendUrl}", method: "GET" });`);
+ await page.locator('.collection-item-name').filter({ hasText: driverRequest }).first().click();
+ await addPreRequestScript(page, `await bru.sendRequest({ url: "${requestSendUrl}", method: "GET" });`);
+ await saveRequest(page);
+ });
+
+ await test.step('Send the driver request', async () => {
+ await sendRequest(page, 200);
+ });
+
+ await test.step('Open Timeline and assert four rows', async () => {
+ await selectResponsePaneTab(page, 'Timeline');
+ const rows = page.locator('.timeline-container .tl-row-wrap');
+ await expect(rows).toHaveCount(4);
+ });
+
+ await test.step('Filter chips appear with correct counts (only Main + Pre-Request show)', async () => {
+ const chips = page.locator('.timeline-filter-bar .timeline-chip');
+ await expect(chips).toHaveCount(3); // All, Main, Pre-Request
+
+ const countFor = (label: string) =>
+ chips.filter({ hasText: label }).locator('.timeline-chip-count').first();
+
+ await expect(countFor('All')).toHaveText('4');
+ await expect(countFor('Main')).toHaveText('1');
+ await expect(countFor('Pre-Request')).toHaveText('3');
+ });
+
+ await test.step('Rows are sorted newest-first; the collection-script row sits last', async () => {
+ const rows = page.locator('.timeline-container .tl-row-wrap');
+
+ // Execution order: collection → folder → request → main.
+ // Newest-first: main → request-script → folder-script → collection-script.
+ await expect(rows.nth(0).locator('.tl-badge--main')).toHaveCount(1);
+
+ const requestScriptRow = rows.nth(1);
+ await expect(requestScriptRow.locator('.tl-badge--scripted')).toHaveCount(1);
+ await expect(requestScriptRow.locator('.tl-col-url')).toContainText('/query');
+
+ const folderScriptRow = rows.nth(2);
+ await expect(folderScriptRow.locator('.tl-badge--scripted')).toHaveCount(1);
+ await expect(folderScriptRow.locator('.tl-col-url')).toContainText('/headers');
+
+ const collectionScriptRow = rows.nth(3);
+ await expect(collectionScriptRow.locator('.tl-badge--scripted')).toHaveCount(1);
+ await expect(collectionScriptRow.locator('.tl-col-url')).toContainText('/echo/path');
+ });
+
+ await test.step('Clicking the Pre-Request chip narrows to the three sendRequest rows', async () => {
+ const chips = page.locator('.timeline-filter-bar .timeline-chip');
+ await chips.filter({ hasText: 'Pre-Request' }).click();
+
+ const visibleRows = page.locator('.timeline-container .tl-row-wrap');
+ await expect(visibleRows).toHaveCount(3);
+ await expect(visibleRows.locator('.tl-badge--scripted')).toHaveCount(3);
+ });
+
+ await test.step('Clicking All restores every row', async () => {
+ const chips = page.locator('.timeline-filter-bar .timeline-chip');
+ await chips.filter({ hasText: 'All' }).click();
+ await expect(page.locator('.timeline-container .tl-row-wrap')).toHaveCount(4);
+ });
+ });
+
+ test('collection runner shows scripted entries on the runner timeline (isolated from collection.timeline)', async ({ page, createTmpDir }) => {
+ const runnerCollection = 'timeline-runner-test';
+ const runnerTarget = 'runner-target';
+ const runnerDriver = 'runner-driver';
+ const runnerTargetUrl = 'http://localhost:8081/ping';
+ const runnerDriverUrl = 'http://localhost:8081/ping';
+ const runnerSendUrl = 'http://localhost:8081/headers';
+
+ await test.step('Set up collection with target and driver requests + scripts', async () => {
+ await createCollection(page, runnerCollection, await createTmpDir(runnerCollection));
+ await createRequest(page, runnerTarget, runnerCollection, { url: runnerTargetUrl });
+ await createRequest(page, runnerDriver, runnerCollection, { url: runnerDriverUrl });
+
+ await openRequest(page, runnerCollection, runnerDriver);
+ await addPreRequestScript(page, `await bru.sendRequest({ url: "${runnerSendUrl}", method: "GET" });`);
+ await addPostResponseScript(page, `await bru.runRequest("${runnerTarget}");`);
+ await saveRequest(page);
+ });
+
+ await test.step('Run the collection', async () => {
+ await runCollection(page, runnerCollection);
+ });
+
+ await test.step('Open the driver request in the runner result and switch to Timeline', async () => {
+ await page.getByTestId('runner-result-item').filter({ hasText: runnerDriver }).locator('.link').first().click();
+
+ // Runner ResponsePane has its own tab strip (no data-testid="response-pane"),
+ // so target the tab by role within the active panel.
+ const timelineTab = page.locator('[role="tab"]').filter({ hasText: 'Timeline' }).last();
+ await timelineTab.click();
+ });
+
+ await test.step('Runner timeline shows main + sendRequest + runRequest rows', async () => {
+ const rows = page.locator('.tl-row-wrap');
+ await expect(rows).toHaveCount(3, { timeout: 10000 });
+
+ await expect(rows.locator('.tl-badge--main')).toHaveCount(1);
+ await expect(rows.locator('.tl-badge--scripted')).toHaveCount(1);
+ await expect(rows.locator('.tl-badge--run-request')).toHaveCount(1);
+
+ // The runner view never shows the filter chip bar (no chip-bar UI here).
+ await expect(page.locator('.timeline-filter-bar')).toHaveCount(0);
+ });
+ });
+});
diff --git a/tests/request/timeline/timeline-url-update.spec.ts b/tests/request/timeline/timeline-url-update.spec.ts
index b8909d34c..145c07bc2 100644
--- a/tests/request/timeline/timeline-url-update.spec.ts
+++ b/tests/request/timeline/timeline-url-update.spec.ts
@@ -64,7 +64,7 @@ test.describe('Timeline URL Update', () => {
await selectResponsePaneTab(page, 'Timeline');
// Get all timeline entries
- const timelineItems = page.locator('.timeline-item');
+ const timelineItems = page.locator('.tl-row-wrap');
await expect(timelineItems).toHaveCount(2, { timeout: 5000 });
// Most recent entry (first in list) should show the second URL
diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts
index f3605d979..f9874f88d 100644
--- a/tests/utils/page/actions.ts
+++ b/tests/utils/page/actions.ts
@@ -173,6 +173,7 @@ type CreateRequestOptions = {
url?: string;
method?: string;
inFolder?: boolean;
+ requestType?: 'http' | 'graphql' | 'ws' | 'grpc';
};
type CreateUntitledRequestOptions = {
@@ -323,10 +324,11 @@ const createRequest = async (
parentName: string,
options: CreateRequestOptions = {}
) => {
- const { url, method, inFolder = false } = options;
+ const { url, method, inFolder = false, requestType = 'http' } = options;
const parentType = inFolder ? 'folder' : 'collection';
+ const hasMethodSelector = requestType === 'http' || requestType === 'graphql';
- await test.step(`Create request "${requestName}" in ${parentType} "${parentName}"`, async () => {
+ await test.step(`Create ${requestType.toUpperCase()} request "${requestName}" in ${parentType} "${parentName}"`, async () => {
const locators = buildCommonLocators(page);
if (inFolder) {
@@ -340,9 +342,15 @@ const createRequest = async (
}
await locators.dropdown.item('New Request').click();
+
+ // The modal defaults to HTTP; switch the radio for the other three types.
+ if (requestType !== 'http') {
+ await page.getByTestId(`${requestType}-request`).click();
+ }
+
await page.getByPlaceholder('Request Name').fill(requestName);
- if (method) {
+ if (method && hasMethodSelector) {
await page.locator('.bruno-modal .method-selector').click();
const isStandardMethod = STANDARD_HTTP_METHODS.includes(method.toUpperCase());
if (isStandardMethod) {
@@ -573,6 +581,20 @@ const createFolder = async (
});
};
+/**
+ * Expand a folder in the sidebar so its child requests/subfolders become visible.
+ * No-op if the folder is already expanded.
+ */
+const expandFolder = async (page: Page, folderName: string) => {
+ await test.step(`Expand folder "${folderName}"`, async () => {
+ const locators = buildCommonLocators(page);
+ const chevron = locators.folder.chevron(folderName);
+ await chevron.waitFor({ state: 'visible', timeout: 5000 });
+ const isExpanded = await chevron.evaluate((el: HTMLElement) => el.classList.contains('rotate-90'));
+ if (!isExpanded) await chevron.click();
+ });
+};
+
type EnvironmentType = 'collection' | 'global';
/**
@@ -1449,6 +1471,58 @@ const addTestScript = async (page: Page, content: string) => {
});
};
+/**
+ * Add a script to a folder's Settings → Script tab.
+ * @param page - The page object
+ * @param folderName - The folder to target (must be visible in the sidebar)
+ * @param phase - Which phase to write: 'pre-request' or 'post-response'
+ * @param content - The script content to add
+ */
+const addFolderScript = async (
+ page: Page,
+ folderName: string,
+ phase: 'pre-request' | 'post-response',
+ content: string
+) => {
+ await test.step(`Add ${phase} script on folder "${folderName}"`, async () => {
+ const locators = buildCommonLocators(page);
+ await locators.sidebar.folder(folderName).first().dblclick();
+ await locators.paneTabs.folderSettingsTab('script').click();
+ await locators.paneTabs.tabTrigger(phase).click();
+ await editCodeMirrorEditor(page, `folder-${phase}-script-editor`, content);
+ const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s';
+ await page.keyboard.press(saveShortcut);
+ await page.waitForTimeout(400);
+ });
+};
+
+/**
+ * Add a script to a collection's Settings → Script tab.
+ * @param page - The page object
+ * @param collectionName - The collection to target
+ * @param phase - Which phase to write: 'pre-request' or 'post-response'
+ * @param content - The script content to add
+ */
+const addCollectionScript = async (
+ page: Page,
+ collectionName: string,
+ phase: 'pre-request' | 'post-response',
+ content: string
+) => {
+ await test.step(`Add ${phase} script on collection "${collectionName}"`, async () => {
+ const locators = buildCommonLocators(page);
+ await locators.sidebar.collection(collectionName).hover();
+ await locators.actions.collectionActions(collectionName).click();
+ await locators.dropdown.item('Settings').click();
+ await locators.paneTabs.collectionSettingsTab('script').click();
+ await locators.paneTabs.tabTrigger(phase).click();
+ await editCodeMirrorEditor(page, `collection-${phase}-script-editor`, content);
+ const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s';
+ await page.keyboard.press(saveShortcut);
+ await page.waitForTimeout(400);
+ });
+};
+
/**
* Click send and wait for at least one error card to appear.
* @param page - The page object
@@ -1618,6 +1692,9 @@ export {
addPreRequestScript,
addPostResponseScript,
addTestScript,
+ addFolderScript,
+ addCollectionScript,
+ expandFolder,
sendAndWaitForErrorCard,
sendAndWaitForResponse,
selectAuthMode,