From f23e406ef88beaa44724784914112cf64a5f64e0 Mon Sep 17 00:00:00 2001 From: Pooja Date: Mon, 1 Jun 2026 18:36:32 +0530 Subject: [PATCH] feat: show scripted requests in timeline (#8047) --- .../ResponsePane/RunnerTimeline/index.js | 100 +++-- .../Timeline/GrpcTimelineItem/index.js | 8 +- .../ResponsePane/Timeline/StyledWrapper.js | 190 +++------ .../TimelineItem/Common/Body/index.js | 64 +-- .../TimelineItem/Common/Headers/index.js | 86 ++--- .../TimelineItem/Common/Status/index.js | 33 +- .../TimelineItem/Common/Status/index.spec.js | 100 +++++ .../TimelineItem/Network/StyledWrapper.js | 22 +- .../Timeline/TimelineItem/Request/index.js | 32 +- .../Timeline/TimelineItem/Response/index.js | 71 +++- .../Timeline/TimelineItem/StyledWrapper.js | 347 +++++++++++++---- .../Timeline/TimelineItem/index.js | 246 +++++++++--- .../ResponsePane/Timeline/buildEntries.js | 78 ++++ .../ResponsePane/Timeline/entryMeta.js | 22 ++ .../components/ResponsePane/Timeline/index.js | 120 +++--- .../ReduxStore/slices/collections/index.js | 51 ++- .../collections/timeline-routing.spec.js | 364 ++++++++++++++++++ .../bruno-electron/src/ipc/network/index.js | 255 +++++++++++- .../bruno-electron/src/utils/collection.js | 35 +- packages/bruno-js/src/bru.js | 25 +- .../bruno-js/src/runtime/script-runtime.js | 21 +- .../bruno-js/src/runtime/scripted-entries.js | 16 + packages/bruno-js/src/runtime/test-runtime.js | 11 +- .../bruno-js/src/sandbox/quickjs/shims/bru.js | 6 + .../tests/bru-scripted-entries.spec.js | 132 +++++++ .../script-runtime-scripted-entries.spec.js | 209 ++++++++++ .../bruno-js/tests/scripted-entries.spec.js | 61 +++ .../bruno-requests/src/scripting/index.ts | 2 +- .../src/scripting/scripted-entry.spec.ts | 232 +++++++++++ .../src/scripting/send-request.spec.ts | 10 +- .../src/scripting/send-request.ts | 158 +++++++- tests/auth/oauth1/oauth1-runner.spec.ts | 33 +- .../timeline-nested-runrequest.spec.ts | 131 +++++++ .../timeline-runrequest-network-error.spec.ts | 59 +++ .../timeline/timeline-runrequest-skip.spec.ts | 54 +++ .../timeline-scripted-requests.spec.ts | 153 ++++++++ .../timeline/timeline-url-update.spec.ts | 2 +- tests/utils/page/actions.ts | 83 +++- 38 files changed, 3039 insertions(+), 583 deletions(-) create mode 100644 packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Status/index.spec.js create mode 100644 packages/bruno-app/src/components/ResponsePane/Timeline/buildEntries.js create mode 100644 packages/bruno-app/src/components/ResponsePane/Timeline/entryMeta.js create mode 100644 packages/bruno-app/src/providers/ReduxStore/slices/collections/timeline-routing.spec.js create mode 100644 packages/bruno-js/src/runtime/scripted-entries.js create mode 100644 packages/bruno-js/tests/bru-scripted-entries.spec.js create mode 100644 packages/bruno-js/tests/script-runtime-scripted-entries.spec.js create mode 100644 packages/bruno-js/tests/scripted-entries.spec.js create mode 100644 packages/bruno-requests/src/scripting/scripted-entry.spec.ts create mode 100644 tests/request/timeline/timeline-nested-runrequest.spec.ts create mode 100644 tests/request/timeline/timeline-runrequest-network-error.spec.ts create mode 100644 tests/request/timeline/timeline-runrequest-skip.spec.ts create mode 100644 tests/request/timeline/timeline-scripted-requests.spec.ts diff --git a/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/index.js b/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/index.js index 8c3e839aa..9a0ec8e27 100644 --- a/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/index.js +++ b/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/index.js @@ -1,68 +1,60 @@ import React, { useMemo } from 'react'; -import forOwn from 'lodash/forOwn'; import StyledWrapper from './StyledWrapper'; import TimelineItem from '../Timeline/TimelineItem'; const RunnerTimeline = ({ request = {}, response = {}, item, collection }) => { - const requestHeaders = []; + // Reads from the runner item only, never collection.timeline, so a later + // single-request invocation of the same item can't bleed into this view. + const entries = useMemo(() => { + const mainTimestamp = request?.timestamp ?? response?.timestamp ?? Date.now(); - forOwn(request.headers, (value, key) => { - requestHeaders.push({ - name: key, - value + const oauth = (item?.oauth2DebugEntries || []).flatMap((event) => { + const debugInfo = event.debugInfo || []; + return [...debugInfo].reverse().map((sub, i) => ({ + kind: 'oauth2', + timestamp: mainTimestamp - 1 - i, + request: sub?.request, + response: sub?.response + })); }); - }); - const oauth2Events = useMemo( - () => - collection?.timeline?.filter( - (event) => event.type === 'oauth2' && event.itemUid === item.uid - ) || [], - [collection?.timeline, item.uid] - ); + const scripted = (item?.scriptedRequestEntries || []).map((e) => ({ + kind: 'scripted', + timestamp: e.timestamp, + request: e.data?.request, + response: e.data?.response, + source: e.source, + scope: e.scope, + phase: e.phase + })); + + const main = { + kind: 'main', + timestamp: mainTimestamp, + request, + response + }; + + return [main, ...oauth, ...scripted].sort((a, b) => b.timestamp - a.timestamp); + }, [item?.oauth2DebugEntries, item?.scriptedRequestEntries, request, response]); return ( - {/* Show the main request/response timeline item */} - - - {oauth2Events.map((event, index) => { - const { data, timestamp } = event; - const { debugInfo } = data; - return ( -
-
-
- OAuth2.0 Calls -
-
-
- {debugInfo && debugInfo.length > 0 ? ( - debugInfo.map((data, idx) => ( -
- -
- )) - ) : ( -
No debug information available.
- )} -
-
- ); - })} + {entries.map((entry, idx) => ( + + ))}
); }; diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/GrpcTimelineItem/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/GrpcTimelineItem/index.js index aabc5d772..4b08f79ce 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/GrpcTimelineItem/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/GrpcTimelineItem/index.js @@ -40,7 +40,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, collection, // Extract relevant data from request and response const { method, url = '' } = effectiveRequest; - const { statusCode, statusText, duration } = response || {}; + const { statusCode, duration } = response || {}; // Get event-specific icon and class names const getEventIcon = () => { @@ -194,7 +194,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, collection, return (
- +
{response.statusDescription && ( @@ -227,7 +227,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, collection,
- +
{response.trailers && response.trailers.length > 0 && ( @@ -286,7 +286,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, collection, )} {eventType === 'status' && (
- +
)}
[{new Date(timestamp).toISOString()}]
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/Timeline/StyledWrapper.js index b3d075e47..bbb5e8d3e 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/StyledWrapper.js @@ -10,154 +10,58 @@ const StyledWrapper = styled.div` flex: 1; } - .timeline-item { - border-color: ${(props) => props.theme.border.border1}; + .timeline-filter-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 0; + flex-wrap: wrap; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + margin-bottom: 4px; + } + + .timeline-chip { + padding: 4px 10px; + background: transparent; + border: none; + border-radius: 4px; + color: ${(props) => props.theme.colors.text.muted}; + font-size: 12px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 7px; + transition: color 0.1s ease, background-color 0.1s ease; + + &:hover { + color: ${(props) => props.theme.text}; + background: ${(props) => props.theme.bg2 || 'rgba(255, 255, 255, 0.04)'}; + } + + &.is-active { + color: ${(props) => props.theme.text}; + background: ${(props) => props.theme.bg2 || 'rgba(255, 255, 255, 0.06)'}; + } + } + + .timeline-chip-count { + color: ${(props) => props.theme.colors.text.muted}; + opacity: 0.6; + font-size: 11px; + font-weight: 500; + font-variant-numeric: tabular-nums; + } + + .timeline-chip.is-active .timeline-chip-count { + color: ${(props) => props.theme.tabs.active.border}; + opacity: 1; } .timeline-event { cursor: pointer; } - - .timeline-event-content { - border-radius: 4px; - padding: 12px; - margin-top: 0.5rem; - } - - .timeline-event-header { - color: ${(props) => props.theme.text}; - } - - .method-label { - font-weight: 500; - } - - .status-code { - font-weight: 500; - } - - .url-text { - color: ${(props) => props.theme.colors.text.muted}; - font-size: ${(props) => props.theme.font.size.base}; - margin-top: 0.25rem; - } - - .timestamp { - color: ${(props) => props.theme.colors.text.muted}; - font-size: ${(props) => props.theme.font.size.base}; - } - - .meta-info { - color: ${(props) => props.theme.colors.text.muted}; - font-size: ${(props) => props.theme.font.size.base}; - } - - .oauth-section { - .oauth-header { - display: flex; - align-items: center; - color: ${(props) => props.theme.text}; - font-weight: 500; - - span { - margin-left: 0.5rem; - } - } - } - - .tabs-switcher { - border-bottom: 1px solid ${(props) => props.theme.border.border1}; - margin-bottom: 16px; - - button { - position: relative; - padding: 8px 16px; - color: ${(props) => props.theme.colors.text.muted}; - - &.active { - color: ${(props) => props.theme.tabs.active.color}; - &:after { - content: ''; - position: absolute; - bottom: -1px; - left: 0; - right: 0; - height: 2px; - background: ${(props) => props.theme.tabs.active.border}; - } - } - } - } - - .network-logs { - background: ${(props) => props.theme.codemirror.bg}; - color: ${(props) => props.theme.text}; - border-radius: 4px; - } - - .oauth-request-item-content { - border-radius: 4px; - margin-top: 0.5rem; - } - - .collapsible-section { - margin-bottom: 12px; - - .section-header { - cursor: pointer; - &:hover { - opacity: 0.8; - } - } - } - - .line { - white-space: pre-line; - word-wrap: break-word; - word-break: break-all; - font-family: ${(props) => props.theme.font || 'Inter, sans-serif'} !important; - - .arrow { - opacity: 0.5; - } - - &.request { - color: ${(props) => props.theme.colors.text.green}; - } - - &.response { - color: ${(props) => props.theme.colors.text.purple}; - } - } - - .request-label { - font-size: ${(props) => props.theme.font.size.base}; - padding: 2px 6px; - border-radius: 3px; - margin-left: 8px; - background: ${(props) => props.theme.requestTabs.bg}; - } - - table { - width: 100%; - border-collapse: collapse; - font-weight: 500; - table-layout: fixed; - - thead, - td { - border: 1px solid ${(props) => props.theme.table.border}; - } - - thead { - color: ${(props) => props.theme.table.thead.color}; - font-size: ${(props) => props.theme.font.size.base}; - user-select: none; - } - td { - padding: 6px 10px; - } - } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js index 21976c971..c71810830 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js @@ -1,35 +1,43 @@ -import QueryResponse from 'components/ResponsePane/QueryResponse/index'; import { useState } from 'react'; +import { IconChevronDown, IconChevronRight } from '@tabler/icons'; +import QueryResponse from 'components/ResponsePane/QueryResponse/index'; const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, type }) => { - const [isBodyCollapsed, toggleBody] = useState(true); + const [isOpen, setIsOpen] = useState(true); + const hasBody = !!(data || dataBuffer); + return ( -
-
toggleBody(!isBodyCollapsed)}> -
-          
{isBodyCollapsed ? '▼' : '▶'}
Body -
-
- {isBodyCollapsed && ( -
- {data || dataBuffer ? ( -
- -
- ) : ( -
No Body found
- )} -
+
+ + {isOpen && ( + hasBody ? ( +
+ +
+ ) : ( +
No Body found
+ ) )}
); diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Headers/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Headers/index.js index 812a61de9..27e6fc82f 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Headers/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Headers/index.js @@ -1,52 +1,52 @@ import { useState } from 'react'; +import { IconChevronDown, IconChevronRight } from '@tabler/icons'; -const HeadersBlock = ({ headers, type }) => { - const [areHeadersCollapsed, toggleHeaders] = useState(true); +const toEntries = (headers) => { + if (!headers) return []; + if (Array.isArray(headers)) { + return headers.map((h) => ({ name: h?.name, value: h?.value })); + } + return Object.entries(headers).map(([name, value]) => ({ name, value })); +}; + +const Headers = ({ headers }) => { + const [isOpen, setIsOpen] = useState(true); + const entries = toEntries(headers); + const count = entries.length; return ( -
-
toggleHeaders(!areHeadersCollapsed)}> -
-          
{areHeadersCollapsed ? '▼' : '▶'}
Headers - {headers && Object.keys(headers).length > 0 - &&
({Object.keys(headers).length})
} -
-
- {areHeadersCollapsed && ( -
- {headers && Object.keys(headers).length > 0 - ? - :
No Headers found
} -
+
+ + {isOpen && ( + count === 0 + ?
No Headers found
+ : ( + + + {entries.map((h, i) => ( + + + + + ))} + +
{h.name}{String(h.value)}
+ ) )}
); }; -const Headers = ({ headers, type }) => { - if (Array.isArray(headers)) { - return ( -
- {headers.map((header, index) => ( -
-            {type === 'request' ? '>' : '<'} {header?.name}:
-            {String(header?.value)}
-          
- ))} -
- ); - } else { - return ( -
- {Object.entries(headers).map(([key, value], index) => ( -
-            {type === 'request' ? '>' : '<'} {key}:
-            {String(value)}
-          
- ))} -
- ); - } -}; - -export default HeadersBlock; +export default Headers; diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Status/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Status/index.js index e78c656e5..f819caed8 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Status/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Status/index.js @@ -1,21 +1,38 @@ +import React from 'react'; import { useTheme } from 'providers/Theme'; +import { rgba } from 'polished'; -const Status = ({ statusCode, statusText }) => { +const Status = ({ statusCode }) => { const { theme } = useTheme(); + const isStringCode = typeof statusCode === 'string' && statusCode.length > 0; - let statusColor = theme.colors.text.muted; + let color = theme.colors.text.muted; if (statusCode >= 200 && statusCode < 300) { - statusColor = theme.requestTabPanel.responseOk; + color = theme.requestTabPanel.responseOk; } else if (statusCode >= 300 && statusCode < 400) { - statusColor = theme.colors.text.warning; + color = theme.colors.text.warning; } else if (statusCode >= 400 && statusCode < 600) { - statusColor = theme.requestTabPanel.responseError; + color = theme.requestTabPanel.responseError; } + const isStatusKnown = (typeof statusCode === 'number' && statusCode > 0) || isStringCode; + const background = isStatusKnown ? rgba(color, 0.12) : 'transparent'; + return ( - - {statusCode}{' '} - {statusText || ''} + + {statusCode} ); }; diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Status/index.spec.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Status/index.spec.js new file mode 100644 index 000000000..c68c0c2e1 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Status/index.spec.js @@ -0,0 +1,100 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ThemeProvider as SCThemeProvider } from 'styled-components'; +import { ThemeContext } from 'providers/Theme'; +import Status from './index'; + +const theme = { + colors: { + text: { muted: '#888888', warning: '#f59e0b' } + }, + requestTabPanel: { + responseOk: '#22c55e', + responseError: '#ef4444' + } +}; + +const renderStatus = (props) => + render( + {} }}> + + + + + ); + +const getPill = () => document.querySelector('.timeline-status'); + +describe('Timeline Status', () => { + describe('numeric HTTP codes', () => { + it('colors 2xx as success and shows a tinted background', () => { + renderStatus({ statusCode: 200 }); + const pill = getPill(); + expect(pill).toHaveTextContent('200'); + expect(pill).toHaveStyle({ color: theme.requestTabPanel.responseOk }); + expect(pill.style.background).not.toBe('transparent'); + }); + + it('colors 3xx as warning', () => { + renderStatus({ statusCode: 301 }); + expect(getPill()).toHaveStyle({ color: theme.colors.text.warning }); + }); + + it('colors 4xx as error', () => { + renderStatus({ statusCode: 404 }); + expect(getPill()).toHaveStyle({ color: theme.requestTabPanel.responseError }); + }); + + it('colors 5xx as error', () => { + renderStatus({ statusCode: 503 }); + expect(getPill()).toHaveStyle({ color: theme.requestTabPanel.responseError }); + }); + }); + + describe('string codes (pre-send network failures)', () => { + it('renders ECONNREFUSED in muted/gray (not red)', () => { + renderStatus({ statusCode: 'ECONNREFUSED' }); + const pill = getPill(); + expect(pill).toHaveTextContent('ECONNREFUSED'); + expect(pill).toHaveStyle({ color: theme.colors.text.muted }); + // String codes still get a tinted pill background so they're visible + expect(pill.style.background).not.toBe('transparent'); + }); + + it('renders "Error" in muted/gray', () => { + renderStatus({ statusCode: 'Error' }); + const pill = getPill(); + expect(pill).toHaveTextContent('Error'); + expect(pill).toHaveStyle({ color: theme.colors.text.muted }); + }); + + it('renders ETIMEDOUT in muted/gray', () => { + renderStatus({ statusCode: 'ETIMEDOUT' }); + expect(getPill()).toHaveStyle({ color: theme.colors.text.muted }); + }); + }); + + describe('unknown / absent codes', () => { + it('renders nothing visible when statusCode is undefined', () => { + renderStatus({ statusCode: undefined }); + const pill = getPill(); + // Pill still mounts but has transparent background and no text + expect(pill).toBeInTheDocument(); + expect(pill.textContent).toBe(''); + expect(pill.style.background).toBe('transparent'); + }); + + it('keeps background transparent when statusCode is 0 (no real status)', () => { + renderStatus({ statusCode: 0 }); + const pill = getPill(); + expect(pill.style.background).toBe('transparent'); + }); + + it('keeps background transparent for empty string', () => { + renderStatus({ statusCode: '' }); + const pill = getPill(); + expect(pill.style.background).toBe('transparent'); + }); + }); +}); diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Network/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Network/StyledWrapper.js index 3ea19abdd..e92907e89 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Network/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Network/StyledWrapper.js @@ -2,16 +2,18 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` .network-logs-container { - background: ${(props) => props.theme.codemirror.bg}; color: ${(props) => props.theme.text}; - border-radius: 4px; - overflow: auto; - height: 24rem; } .network-logs-pre { + margin: 0; + padding: 0; + background: none; + border: none; white-space: pre-wrap; - font-size: ${(props) => props.theme.font.size.base}; + word-break: break-word; + font-size: 12px; + line-height: 1.6; font-family: var(--font-family-mono); } @@ -25,7 +27,7 @@ const StyledWrapper = styled.div` &--response { color: ${(props) => props.theme.colors.text.green}; } - + &--error { color: ${(props) => props.theme.colors.text.danger}; } @@ -33,20 +35,20 @@ const StyledWrapper = styled.div` &--tls { color: ${(props) => props.theme.colors.text.purple}; } - + &--info { color: ${(props) => props.theme.colors.text.yellow}; - } + } } .network-logs-separator { - border-top: 2px solid ${(props) => props.theme.border.border1}; + border-top: 1px solid ${(props) => props.theme.border.border1}; width: 100%; margin: 0.5rem 0; } .network-logs-spacing { - margin-top: 1rem; + margin-top: 0.5rem; } `; diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Request/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Request/index.js index ef6f90a28..dff15729f 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Request/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Request/index.js @@ -3,11 +3,7 @@ import BodyBlock from '../Common/Body/index'; const safeStringifyJSONIfNotString = (obj) => { if (obj === null || obj === undefined) return ''; - - if (typeof obj === 'string') { - return obj; - } - + if (typeof obj === 'string') return obj; try { return JSON.stringify(obj); } catch (e) { @@ -16,24 +12,24 @@ const safeStringifyJSONIfNotString = (obj) => { }; const Request = ({ collection, request, item }) => { - let { url, headers, data, dataBuffer, error } = request || {}; + let { headers, data, dataBuffer, error } = request || {}; if (!dataBuffer) { dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64'); } return ( -
- {/* Method and URL */} -
-
{url}
-
- - {/* Headers */} - - - {/* Body */} - -
+ <> + + + ); }; diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Response/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Response/index.js index 84dd6920f..38a1e84f7 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Response/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Response/index.js @@ -1,14 +1,11 @@ +import { useTheme } from 'providers/Theme'; +import { formatSize } from 'utils/common'; import BodyBlock from '../Common/Body/index'; import Headers from '../Common/Headers/index'; -import Status from '../Common/Status/index'; const safeStringifyJSONIfNotString = (obj) => { if (obj === null || obj === undefined) return ''; - - if (typeof obj === 'string') { - return obj; - } - + if (typeof obj === 'string') return obj; try { return JSON.stringify(obj); } catch (e) { @@ -16,27 +13,59 @@ const safeStringifyJSONIfNotString = (obj) => { } }; +const statusColor = (theme, statusCode) => { + if (statusCode >= 200 && statusCode < 300) return theme.requestTabPanel.responseOk; + if (statusCode >= 300 && statusCode < 400) return theme.colors.text.warning; + if (statusCode >= 400 && statusCode < 600) return theme.requestTabPanel.responseError; + return theme.colors.text.muted; +}; + +const ResponseMeta = ({ code, statusText, duration, size }) => { + const { theme } = useTheme(); + const sizeLabel = typeof size === 'number' ? formatSize(size) : null; + const hasCode = code != null; + const hasAny = hasCode || statusText || (typeof duration === 'number') || sizeLabel; + if (!hasAny) return null; + return ( +
+ {(hasCode || statusText) && ( + + {code} {statusText || ''} + + )} + {typeof duration === 'number' && ( + {Math.round(duration)}ms + )} + {sizeLabel && {sizeLabel}} +
+ ); +}; + const Response = ({ collection, response, item }) => { - let { status, statusCode, statusText, dataBuffer, headers, data, error } = response || {}; + let { status, statusCode, statusText, dataBuffer, headers, data, error, duration, size } = response || {}; if (!dataBuffer) { dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64'); } return ( -
- {/* Status */} -
- - {response.duration && {response.duration}ms} - {response.size && {response.size}B} -
- - {/* Headers */} - - - {/* Body */} - -
+ <> + + + + ); }; diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/StyledWrapper.js index e570bded2..3d7e76f35 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/StyledWrapper.js @@ -2,111 +2,288 @@ import styled from 'styled-components'; import { rgba } from 'polished'; const StyledWrapper = styled.div` - .timeline-item { - border-bottom: 1px solid ${(props) => props.theme.border.border1}; - padding: 0.5rem 0; - - &--oauth2 { - border-bottom: 1px solid ${(props) => props.theme.border.border1}; - } + .tl-row-wrap { + min-width: 0; } - .timeline-item-header { + .tl-row { + display: grid; + /* Badge and time use fixed widths so they line up across rows. */ + grid-template-columns: 14px auto 50px minmax(0, 1fr) 96px 100px; + column-gap: 10px; + align-items: center; + cursor: pointer; + user-select: none; + transition: background-color 0.08s ease; + min-width: 0; + padding: 7px 4px; + border-top: 1px solid ${(props) => props.theme.border.border1}; + } + .tl-row:hover { + background: ${(props) => props.theme.bg2 || rgba(props.theme.text, 0.04)}; + } + .tl-row.is-expanded { + background: ${(props) => props.theme.bg2 || rgba(props.theme.text, 0.06)}; + } + .tl-row:focus-visible { + outline: 2px solid ${(props) => props.theme.textLink}; + outline-offset: -2px; + } + .tl-row-wrap:first-child .tl-row { + border-top: none; + } + + .tl-col-chev { + color: ${(props) => props.theme.colors.text.muted}; + opacity: 0.7; + line-height: 0; + display: flex; + align-items: center; + justify-content: center; + } + + .tl-col-status, + .tl-col-method, + .tl-col-url, + .tl-col-badge, + .tl-col-time { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + min-width: 0; + } + + .tl-col-status .timeline-status { + font-size: 11px; + } + + .tl-col-method { + padding-right: 14px; + } + + .tl-col-url { + color: ${(props) => props.theme.text}; + font-size: 13px; + } + + .tl-col-time { + color: ${(props) => props.theme.colors.text.muted}; + font-size: 11px; + text-align: right; + } + + .tl-badge { + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + letter-spacing: 0.02em; + background: ${(props) => props.theme.bg2 || rgba(props.theme.text, 0.06)}; + color: ${(props) => props.theme.colors.text.muted}; + white-space: nowrap; + } + .tl-badge--main { + background: ${(props) => rgba(props.theme.colors.text.green, 0.14)}; + color: ${(props) => props.theme.colors.text.green}; + } + .tl-badge--oauth2 { + background: ${(props) => rgba(props.theme.textLink, 0.12)}; + color: ${(props) => props.theme.textLink}; + } + .tl-badge--scripted { + background: ${(props) => rgba(props.theme.colors.text.yellow, 0.12)}; + color: ${(props) => props.theme.colors.text.yellow}; + } + .tl-badge--run-request { + background: ${(props) => rgba(props.theme.colors.text.purple, 0.14)}; + color: ${(props) => props.theme.colors.text.purple}; + } + + .tl-detail { + border-top: 1px dashed ${(props) => props.theme.border.border1}; + margin-top: 4px; + } + + .tl-header { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px 10px 28px; + } + .tl-header-url { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: ${(props) => props.theme.font?.mono || 'var(--font-family-mono)'}; + font-size: 13px; + color: ${(props) => props.theme.text}; + } + .tl-header-url-method { + font-weight: 600; + margin-right: 6px; + text-transform: uppercase; + } + .tl-header-src { + display: inline-flex; + align-items: center; + gap: 6px; + color: ${(props) => props.theme.colors.text.muted}; + text-decoration: none; + cursor: pointer; + font-family: ${(props) => props.theme.font?.mono || 'var(--font-family-mono)'}; + font-size: 11px; + max-width: 260px; + overflow: hidden; + } + .tl-header-src:hover { + color: ${(props) => props.theme.text}; + } + .tl-header-src-file { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .tl-header-src-icon { + color: ${(props) => props.theme.textLink}; + flex-shrink: 0; + } + + /* Outer padding compensates for the first tab's 14px left padding so the + tab text lines up with the URL above. */ + .tl-tabs { + display: flex; + align-items: center; + padding: 0 12px 0 14px; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + } + .tl-tab { position: relative; + padding: 9px 14px; + margin-bottom: -1px; + background: none; + border: none; + color: ${(props) => props.theme.colors.text.muted}; + font-size: 12px; + font-family: inherit; cursor: pointer; } - - .timeline-item-header-content { - display: flex; - justify-content: space-between; - align-items: center; - min-width: 0; + .tl-tab:hover { + color: ${(props) => props.theme.text}; + } + .tl-tab.is-active { + color: ${(props) => props.theme.tabs.active.color}; + } + .tl-tab.is-active::after { + content: ''; + position: absolute; + left: 14px; + right: 14px; + bottom: 0; + height: 2px; + background: ${(props) => props.theme.tabs.active.border}; } - .timeline-item-header-items { + .tl-panel { + padding: 12px 12px 14px 28px; + } + + .tl-response-meta { + display: flex; + align-items: baseline; + gap: 12px; + padding: 6px 0 4px 0; + font-size: 12px; + color: ${(props) => props.theme.colors.text.muted}; + } + .tl-response-meta-status { + font-weight: 700; + font-size: 13px; + } + .tl-response-meta-item { + color: ${(props) => props.theme.colors.text.muted}; + } + + .tl-block { + margin-top: 14px; + } + .tl-block:first-child { + margin-top: 0; + } + .tl-block-h { display: flex; align-items: center; - gap: 0.5rem; - min-width: 0; - } - - .timeline-item-url { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - margin-top: 0.25rem; + gap: 8px; + padding: 6px 0; + margin-bottom: 8px; + width: 100%; + background: none; + border: none; + text-align: left; + font-family: inherit; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + user-select: none; } - - .timeline-item-timestamp { + .tl-block-h:hover { + color: ${(props) => props.theme.text}; + } + .tl-block-chev { color: ${(props) => props.theme.colors.text.muted}; - flex-shrink: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + line-height: 0; + display: inline-flex; + align-items: center; } - - .timeline-item-timestamp-iso { - opacity: 0.7; + .tl-block-count { color: ${(props) => props.theme.colors.text.muted}; + opacity: 0.65; + font-weight: 500; + font-size: 11px; + text-transform: none; + letter-spacing: 0; } - .timeline-item-oauth-label { - opacity: 0.5; + .tl-headers-table { + width: 100%; + border-collapse: collapse; + font-family: ${(props) => props.theme.font?.mono || 'var(--font-family-mono)'}; + font-size: 12px; + table-layout: auto; + } + .tl-headers-table tr { + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + } + .tl-headers-table tr:last-child { + border-bottom: none; + } + .tl-headers-table tr:hover { + background: ${(props) => props.theme.bg2 || rgba(props.theme.text, 0.03)}; + } + .tl-headers-table td { + padding: 5px 10px 5px 0; + vertical-align: top; + word-break: break-all; + border: none; + } + .tl-headers-table td.tl-headers-key { + color: ${(props) => props.theme.colors.text.muted}; + width: 220px; + min-width: 120px; + max-width: 280px; + } + .tl-headers-table td.tl-headers-val { color: ${(props) => props.theme.text}; } - .timeline-item-content { - overflow: hidden; - } - - .timeline-item-tabs { - display: flex; - margin-bottom: 1rem; - } - - .timeline-item-tab { - margin-right: 1rem; - position: relative; - padding: 0.5rem 1rem; + .tl-empty { color: ${(props) => props.theme.colors.text.muted}; - background: none; - border: none; - cursor: pointer; - font-size: ${(props) => props.theme.font.size.base}; - - &--active { - color: ${(props) => props.theme.tabs.active.color}; - - &:after { - content: ''; - position: absolute; - bottom: -1px; - left: 0; - right: 0; - height: 2px; - background: ${(props) => props.theme.tabs.active.border}; - } - } - } - - .timeline-item-tab-content { - word-break: break-all; - } - - .timeline-item-metadata { - color: ${(props) => props.theme.colors.text.muted}; - margin-left: 0.5rem; - font-size: ${(props) => props.theme.font.size.base}; - } - - .collapsible-section { - .section-header { - cursor: pointer; - pre { - color: ${(props) => rgba(props.theme.primary.text, 0.8)}; - } - } + font-size: 12px; + padding: 6px 0; } `; diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js index 030ba6fb0..65fd87e1d 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js @@ -1,83 +1,219 @@ -import { useState } from 'react'; -import { useTheme } from 'providers/Theme'; -import Network from './Network/index'; -import Request from './Request/index'; -import Response from './Response/index'; +import { useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { IconChevronDown, IconChevronRight } from '@tabler/icons'; import Method from './Common/Method/index'; import Status from './Common/Status/index'; import { RelativeTime } from './Common/Time/index'; +import Network from './Network/index'; +import Request from './Request/index'; +import Response from './Response/index'; import StyledWrapper from './StyledWrapper'; import { usePersistedState } from 'hooks/usePersistedState/index'; +import { flattenItems } from 'utils/collections/index'; +import { getRelativePath } from 'utils/common/path'; +import { addTab, updateRequestPaneTab, updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs'; +import { updateSettingsSelectedTab, updatedFolderSettingsSelectedTab } from 'providers/ReduxStore/slices/collections'; +import { getBadge } from '../entryMeta'; -const TimelineItem = ({ timestamp, request, response, item, collection, isOauth2, hideTimestamp = false }) => { - const { theme } = useTheme(); - const [isCollapsed, _toggleCollapse] = usePersistedState({ +const findFolderByScopeFile = (collection, sourceFile) => { + if (!collection?.pathname || !sourceFile) return null; + const dir = sourceFile.replace(/\/folder\.(?:bru|yml)$/, ''); + if (!dir || dir === sourceFile) return null; + return flattenItems(collection.items || []).find( + (i) => i.type === 'folder' && getRelativePath(collection.pathname, i.pathname) === dir + ) || null; +}; + +const TimelineItem = ({ + timestamp, + request, + response, + error, + item, + collection, + isOauth2, + hideTimestamp = false, + source, + scope, + phase +}) => { + const dispatch = useDispatch(); + const [isExpanded, _toggleExpand] = usePersistedState({ key: `timeline-${timestamp}`, default: false }); const [activeTab, setActiveTab] = useState('request'); - const toggleCollapse = () => _toggleCollapse((prev) => !prev); - const { method, status, statusCode, statusText, url = '' } = request || {}; - const { status: responseStatus, statusCode: responseStatusCode, statusText: responseStatusText } = response || {}; - const showNetworkLogs = response.timeline && response.timeline.length > 0; + // CodeMirror reads its size on mount and stays blank if hidden. Lazy-mount + // each tab on first visit and keep it mounted, toggling display only. + const [visitedTabs, setVisitedTabs] = useState({ request: true }); + const toggleExpand = () => _toggleExpand((prev) => !prev); + const handleRowKeyDown = (ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + toggleExpand(); + } + }; + + useEffect(() => { + if (isExpanded) setVisitedTabs({ [activeTab]: true }); + }, [isExpanded]); + + const handleTabClick = (id) => { + setActiveTab(id); + setVisitedTabs((v) => (v[id] ? v : { ...v, [id]: true })); + }; + + const { method, url = '' } = request || {}; + // Main-request entries use `status`; scripted entries use `statusCode`. + const { status, statusCode, statusText } = response || {}; + const numericCode = typeof statusCode === 'number' + ? statusCode + : typeof status === 'number' + ? status + : null; + const code = numericCode != null + ? numericCode + : (statusText || (error ? 'Error' : undefined)); + const showNetworkLogs = response?.timeline && response.timeline.length > 0; + const badge = getBadge({ source, isOauth2 }); + + const isMainOrOauth = !source || source === 'main' || isOauth2; + const scopeType = scope?.type || (isMainOrOauth ? null : 'request'); + const requestExt = collection?.format === 'yml' ? '.yml' : '.bru'; + const scopeFile = scope?.sourceFile + || (scopeType === 'request' ? (item?.filename || (item?.name ? `${item.name}${requestExt}` : null)) : null); + const sourceFile = isMainOrOauth ? null : scopeFile; + + const folderForScope = scopeType === 'folder' + ? findFolderByScopeFile(collection, scope?.sourceFile) + : null; + const navTarget = (() => { + if (!collection?.uid) return null; + if (scopeType === 'collection') return { kind: 'collection' }; + if (scopeType === 'folder' && folderForScope?.uid) return { kind: 'folder', uid: folderForScope.uid }; + if (scopeType === 'request' && item?.uid) return { kind: 'request', uid: item.uid }; + return null; + })(); + const canNavigate = !!navTarget; + const handleNavigate = (ev) => { + ev?.preventDefault?.(); + ev?.stopPropagation?.(); + if (!navTarget) return; + // Collection settings expect tab 'tests' (plural); folder settings expect 'test' (singular). + const isTestsPhase = phase === 'tests'; + const scriptPaneTab = phase || 'pre-request'; + if (navTarget.kind === 'collection') { + dispatch(addTab({ uid: collection.uid, collectionUid: collection.uid, type: 'collection-settings' })); + if (isTestsPhase) { + dispatch(updateSettingsSelectedTab({ collectionUid: collection.uid, tab: 'tests' })); + } else { + dispatch(updateSettingsSelectedTab({ collectionUid: collection.uid, tab: 'script' })); + dispatch(updateScriptPaneTab({ uid: collection.uid, scriptPaneTab })); + } + } else if (navTarget.kind === 'folder') { + dispatch(addTab({ uid: navTarget.uid, collectionUid: collection.uid, type: 'folder-settings' })); + if (isTestsPhase) { + dispatch(updatedFolderSettingsSelectedTab({ collectionUid: collection.uid, folderUid: navTarget.uid, tab: 'test' })); + } else { + dispatch(updatedFolderSettingsSelectedTab({ collectionUid: collection.uid, folderUid: navTarget.uid, tab: 'script' })); + dispatch(updateScriptPaneTab({ uid: navTarget.uid, scriptPaneTab })); + } + } else if (navTarget.kind === 'request') { + dispatch(addTab({ uid: navTarget.uid, collectionUid: collection.uid, type: 'request' })); + if (isTestsPhase) { + dispatch(updateRequestPaneTab({ uid: navTarget.uid, requestPaneTab: 'tests' })); + } else { + dispatch(updateRequestPaneTab({ uid: navTarget.uid, requestPaneTab: 'script' })); + dispatch(updateScriptPaneTab({ uid: navTarget.uid, scriptPaneTab })); + } + } + }; + + const tabs = [ + { id: 'request', label: 'Request' }, + { id: 'response', label: 'Response' }, + ...(showNetworkLogs ? [{ id: 'network', label: 'Network' }] : []) + ]; return ( -
-
- -
+
+
+
+ {isExpanded ? : } +
+
+ +
+
-
{url}
- {isOauth2 && [oauth2.0]} +
+
{url}
+
+ {badge.badgeLabel}
{!hideTimestamp && ( - +
- +
)}
- {isCollapsed && ( -
- {/* Tabs */} -
- - - {showNetworkLogs && ( - + {sourceFile} + + )}
- {/* Tab Content */} -
- {/* Request Tab */} - {activeTab === 'request' && ( - - )} +
+ {tabs.map((tab) => ( + + ))} +
- {/* Response Tab */} - {activeTab === 'response' && ( - +
+ {visitedTabs.request && ( +
+ +
)} - - {/* Network Logs Tab */} - {activeTab === 'networkLogs' && showNetworkLogs && ( - + {visitedTabs.response && ( +
+ +
+ )} + {showNetworkLogs && visitedTabs.network && ( +
+ +
)}
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/buildEntries.js b/packages/bruno-app/src/components/ResponsePane/Timeline/buildEntries.js new file mode 100644 index 000000000..aa13d677e --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/buildEntries.js @@ -0,0 +1,78 @@ +export const getEntryKind = (entry) => { + if (entry.type === 'request') return 'main'; + if (entry.type === 'oauth2') return 'oauth'; + if (entry.type === 'scripted-request') { + // 'post-response' and 'tests' both run after the main response bucket together. + if (entry.phase === 'post-response' || entry.phase === 'tests') return 'post'; + return 'pre'; + } + return 'main'; +}; + +const findPairedMainTimestamps = (fullTimeline) => { + const map = new Map(); + fullTimeline.forEach((entry, idx) => { + if (entry.type !== 'oauth2') return; + for (let j = idx + 1; j < fullTimeline.length; j++) { + const candidate = fullTimeline[j]; + if ( + candidate.type === 'request' + && candidate.itemUid === entry.itemUid + && typeof candidate.timestamp === 'number' + ) { + map.set(idx, candidate.timestamp); + break; + } + } + }); + return map; +}; + +const isVisibleEntry = (entry, itemUid, authSource) => { + if (entry.itemUid === itemUid) return true; + if (entry.type === 'oauth2' && authSource) { + if (authSource.type === 'folder' && entry.folderUid === authSource.uid) return true; + if (authSource.type === 'collection' && !entry.folderUid) return true; + } + return false; +}; + +const expandOauthEntry = (entry, paired) => { + const debugInfo = entry.data?.debugInfo || []; + // No sub-calls to render drop the parent so the OAuth chip count + if (debugInfo.length === 0) return []; + const n = debugInfo.length; + const mainAnchor = paired != null ? paired : entry.timestamp + n; + return debugInfo.map((sub, i) => ({ + ...entry, + timestamp: mainAnchor - (n - i), + _oauth2Child: sub + })); +}; + +export const buildTimelineEntries = (timeline, itemUid, authSource) => { + const fullTimeline = timeline || []; + const visible = fullTimeline.filter((entry) => isVisibleEntry(entry, itemUid, authSource)); + const pairedMainByOauthIdx = findPairedMainTimestamps(fullTimeline); + + const flat = []; + visible.forEach((entry) => { + if (entry.type === 'oauth2') { + const paired = pairedMainByOauthIdx.get(fullTimeline.indexOf(entry)); + flat.push(...expandOauthEntry(entry, paired)); + } else { + flat.push(entry); + } + }); + + return flat.sort((a, b) => b.timestamp - a.timestamp); +}; + +export const countByKind = (entries) => { + const counts = { all: entries.length, main: 0, pre: 0, post: 0, oauth: 0 }; + entries.forEach((entry) => { + const kind = getEntryKind(entry); + if (counts[kind] != null) counts[kind]++; + }); + return counts; +}; diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/entryMeta.js b/packages/bruno-app/src/components/ResponsePane/Timeline/entryMeta.js new file mode 100644 index 000000000..addaf31d9 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/entryMeta.js @@ -0,0 +1,22 @@ +// Keys must match getEntryKind() in buildEntries.js. +export const ENTRY_KINDS = { + main: { chipLabel: 'Main', badgeLabel: 'main', badgeClass: 'tl-badge tl-badge--main' }, + oauth: { chipLabel: 'OAuth', badgeLabel: 'oauth2.0', badgeClass: 'tl-badge tl-badge--oauth2' }, + pre: { chipLabel: 'Pre-Request', badgeLabel: 'sendRequest', badgeClass: 'tl-badge tl-badge--scripted' }, + post: { chipLabel: 'Post-Response', badgeLabel: 'runRequest', badgeClass: 'tl-badge tl-badge--run-request' } +}; + +export const FILTER_CHIPS = [ + { id: 'all', label: 'All' }, + { id: 'main', label: ENTRY_KINDS.main.chipLabel }, + { id: 'pre', label: ENTRY_KINDS.pre.chipLabel }, + { id: 'post', label: ENTRY_KINDS.post.chipLabel }, + { id: 'oauth', label: ENTRY_KINDS.oauth.chipLabel } +]; + +export const getBadge = ({ source, isOauth2 }) => { + if (isOauth2) return ENTRY_KINDS.oauth; + if (!source || source === 'main') return ENTRY_KINDS.main; + if (source === 'runRequest') return ENTRY_KINDS.post; + return ENTRY_KINDS.pre; +}; diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/index.js index 93d4ec082..e6d12a41f 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/index.js @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import StyledWrapper from './StyledWrapper'; import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index'; import { get } from 'lodash'; @@ -6,6 +6,8 @@ import TimelineItem from './TimelineItem/index'; import GrpcTimelineItem from './GrpcTimelineItem/index'; import { usePersistedState } from 'hooks/usePersistedState'; import { useTrackScroll } from 'hooks/useTrackScroll'; +import { buildTimelineEntries, getEntryKind, countByKind } from './buildEntries'; +import { FILTER_CHIPS } from './entryMeta'; const getEffectiveAuthSource = (collection, item) => { const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode'); @@ -49,37 +51,55 @@ const Timeline = ({ collection, item }) => { const wrapperRef = useRef(null); const [scroll, setScroll] = usePersistedState({ key: `response-timeline-scroll-${item.uid}`, default: 0 }); useTrackScroll({ ref: wrapperRef, selector: null, onChange: setScroll, initialValue: scroll }); - // Get the effective auth source if auth mode is inherit + const [activeFilter, setActiveFilter] = useState('all'); + const authSource = getEffectiveAuthSource(collection, item); const isGrpcRequest = item.type === 'grpc-request' || item.type === 'ws-request'; - // Filter timeline entries based on new rules - const combinedTimeline = ([...(collection?.timeline || [])]).filter((obj) => { - // Always show entries for this item - if (obj.itemUid === item.uid) return true; + const entries = useMemo( + () => buildTimelineEntries(collection?.timeline, item.uid, authSource), + [collection?.timeline, item.uid, authSource] + ); + const counts = useMemo(() => countByKind(entries), [entries]); - // For OAuth2 entries, also show if auth is inherited - if (obj.type === 'oauth2' && authSource) { - if (authSource.type === 'folder' && obj.folderUid === authSource.uid) return true; - if (authSource.type === 'collection' && !obj.folderUid) return true; - } + const visibleChips = FILTER_CHIPS.filter((chip) => chip.id === 'all' || counts[chip.id] > 0); + const hasOtherKinds = counts.pre > 0 || counts.post > 0 || counts.oauth > 0; + const showFilterBar = entries.length > 0 && hasOtherKinds; - return false; - }).sort((a, b) => b.timestamp - a.timestamp); + useEffect(() => { + if (activeFilter === 'all') return; + const stillVisible = visibleChips.some((chip) => chip.id === activeFilter); + if (!stillVisible) setActiveFilter('all'); + }, [activeFilter, visibleChips]); return ( - {/* Timeline container with scrollbar */} -
- {combinedTimeline.map((event, index) => { - // Handle regular requests - if (event.type === 'request') { - const { data, timestamp, eventType } = event; + {showFilterBar && ( +
+ {visibleChips.map((chip) => ( + + ))} +
+ )} + +
+ {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 (
-
-
- OAuth2.0 Calls -
-
-
- {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,