feat: show scripted requests in timeline (#8047)

This commit is contained in:
Pooja
2026-06-01 18:36:32 +05:30
committed by GitHub
parent db91dbf192
commit f23e406ef8
38 changed files with 3039 additions and 583 deletions

View File

@@ -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 (
<StyledWrapper className="pb-4 w-full">
{/* Show the main request/response timeline item */}
<TimelineItem
request={request}
response={response}
item={item}
collection={collection}
hideTimestamp={true}
/>
{oauth2Events.map((event, index) => {
const { data, timestamp } = event;
const { debugInfo } = data;
return (
<div key={`oauth2-${index}`} className="timeline-event mt-4">
<div className="timeline-event-header cursor-pointer flex items-center">
<div className="flex items-center">
<span className="font-bold">OAuth2.0 Calls</span>
</div>
</div>
<div className="mt-2">
{debugInfo && debugInfo.length > 0 ? (
debugInfo.map((data, idx) => (
<div key={idx} className="ml-4">
<TimelineItem
timestamp={timestamp}
request={data?.request}
response={data?.response}
item={item}
collection={collection}
isOauth2={true}
/>
</div>
))
) : (
<div>No debug information available.</div>
)}
</div>
</div>
);
})}
{entries.map((entry, idx) => (
<TimelineItem
key={`${entry.kind}-${idx}`}
timestamp={entry.timestamp}
request={entry.request}
response={entry.response}
item={item}
collection={collection}
isOauth2={entry.kind === 'oauth2'}
source={entry.kind === 'main' ? 'main' : (entry.kind === 'scripted' ? entry.source : undefined)}
scope={entry.kind === 'scripted' ? entry.scope : undefined}
phase={entry.kind === 'scripted' ? entry.phase : undefined}
hideTimestamp={true}
/>
))}
</StyledWrapper>
);
};

View File

@@ -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 (
<div className="content-status">
<div className="flex items-center gap-2">
<Status statusCode={statusCode} statusText={statusText} />
<Status statusCode={statusCode} />
</div>
{response.statusDescription && (
@@ -227,7 +227,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, collection,
</div>
<div className="flex items-center gap-2">
<Status statusCode={statusCode} statusText={statusText} />
<Status statusCode={statusCode} />
</div>
{response.trailers && response.trailers.length > 0 && (
@@ -286,7 +286,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, collection,
)}
{eventType === 'status' && (
<div className="flex items-center gap-2">
<Status statusCode={statusCode} statusText={statusText} />
<Status statusCode={statusCode} />
</div>
)}
<pre className="event-timestamp">[{new Date(timestamp).toISOString()}]</pre>

View File

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

View File

@@ -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 (
<div className="collapsible-section">
<div className="section-header" onClick={() => toggleBody(!isBodyCollapsed)}>
<pre className="flex flex-row items-center">
<div className="opacity-70">{isBodyCollapsed ? '▼' : '▶'}</div> Body
</pre>
</div>
{isBodyCollapsed && (
<div className="mt-2">
{data || dataBuffer ? (
<div className="h-96 overflow-auto">
<QueryResponse
item={item}
collection={collection}
data={data}
dataBuffer={dataBuffer}
headers={headers}
error={error}
key={item?.uid}
hideResultTypeSelector={type === 'request'}
docKey={`timeline-body:${type}:${item?.uid}`}
/>
</div>
) : (
<div className="timeline-item-timestamp">No Body found</div>
)}
</div>
<div className="tl-block">
<button
type="button"
className="tl-block-h"
aria-expanded={isOpen}
data-testid="response-body-toggle"
onClick={() => setIsOpen(!isOpen)}
>
<span className="tl-block-chev">
{isOpen ? <IconChevronDown size={12} strokeWidth={2} /> : <IconChevronRight size={12} strokeWidth={2} />}
</span>
Body
</button>
{isOpen && (
hasBody ? (
<div className="h-96 overflow-auto">
<QueryResponse
item={item}
collection={collection}
data={data}
dataBuffer={dataBuffer}
headers={headers}
error={error}
key={item?.uid}
hideResultTypeSelector={type === 'request'}
docKey={`timeline-body:${type}:${item?.uid}`}
/>
</div>
) : (
<div className="tl-empty">No Body found</div>
)
)}
</div>
);

View File

@@ -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 (
<div className="collapsible-section mt-2">
<div className="section-header" onClick={() => toggleHeaders(!areHeadersCollapsed)}>
<pre className="flex flex-row items-center">
<div className="opacity-70">{areHeadersCollapsed ? '▼' : '▶'}</div> Headers
{headers && Object.keys(headers).length > 0
&& <div className="ml-1">({Object.keys(headers).length})</div>}
</pre>
</div>
{areHeadersCollapsed && (
<div className="mt-1">
{headers && Object.keys(headers).length > 0
? <Headers headers={headers} type={type} />
: <div className="timeline-item-timestamp">No Headers found</div>}
</div>
<div className="tl-block">
<button
type="button"
className="tl-block-h"
aria-expanded={isOpen}
data-testid="headers-toggle"
onClick={() => setIsOpen(!isOpen)}
>
<span className="tl-block-chev">
{isOpen ? <IconChevronDown size={12} strokeWidth={2} /> : <IconChevronRight size={12} strokeWidth={2} />}
</span>
Headers
<span className="tl-block-count">({count})</span>
</button>
{isOpen && (
count === 0
? <div className="tl-empty">No Headers found</div>
: (
<table className="tl-headers-table">
<tbody>
{entries.map((h, i) => (
<tr key={i}>
<td className="tl-headers-key">{h.name}</td>
<td className="tl-headers-val">{String(h.value)}</td>
</tr>
))}
</tbody>
</table>
)
)}
</div>
);
};
const Headers = ({ headers, type }) => {
if (Array.isArray(headers)) {
return (
<div className="mt-1">
{headers.map((header, index) => (
<pre key={index} className="mb-1 whitespace-pre-wrap">
{type === 'request' ? '>' : '<'}&nbsp;<span className="opacity-60">{header?.name}:</span>
<span className="whitespace-pre-wrap">{String(header?.value)}</span>
</pre>
))}
</div>
);
} else {
return (
<div className="mt-1">
{Object.entries(headers).map(([key, value], index) => (
<pre key={index} className="mb-1 whitespace-pre-wrap">
{type === 'request' ? '>' : '<'}&nbsp;<span className="opacity-60">{key}:</span>
<span>{String(value)}</span>
</pre>
))}
</div>
);
}
};
export default HeadersBlock;
export default Headers;

View File

@@ -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 (
<span className="timeline-status" style={{ color: statusColor, fontWeight: 'bold' }}>
{statusCode}{' '}
{statusText || ''}
<span
className="timeline-status"
style={{
color,
background,
fontWeight: 600,
fontSize: 11,
padding: '2px 8px',
borderRadius: 3,
letterSpacing: '0.02em',
whiteSpace: 'nowrap'
}}
>
{statusCode}
</span>
);
};

View File

@@ -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(
<ThemeContext.Provider value={{ theme, displayedTheme: 'dark', storedTheme: 'system', setStoredTheme: () => {} }}>
<SCThemeProvider theme={theme}>
<Status {...props} />
</SCThemeProvider>
</ThemeContext.Provider>
);
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');
});
});
});

View File

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

View File

@@ -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 (
<div>
{/* Method and URL */}
<div className="mb-1 flex gap-2">
<pre className="whitespace-pre-wrap" title={url}>{url}</pre>
</div>
{/* Headers */}
<Headers headers={headers} type="request" />
{/* Body */}
<BodyBlock collection={collection} data={data} dataBuffer={dataBuffer} error={error} headers={headers} item={item} type="request" />
</div>
<>
<Headers headers={headers} />
<BodyBlock
collection={collection}
data={data}
dataBuffer={dataBuffer}
error={error}
headers={headers}
item={item}
type="request"
/>
</>
);
};

View File

@@ -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 (
<div className="tl-response-meta">
{(hasCode || statusText) && (
<span className="tl-response-meta-status" style={{ color: statusColor(theme, code) }}>
{code} {statusText || ''}
</span>
)}
{typeof duration === 'number' && (
<span className="tl-response-meta-item">{Math.round(duration)}ms</span>
)}
{sizeLabel && <span className="tl-response-meta-item">{sizeLabel}</span>}
</div>
);
};
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 (
<div>
{/* Status */}
<div className="mb-1">
<Status statusCode={status || statusCode} statusText={statusText} />
{response.duration && <span className="timeline-item-metadata">{response.duration}ms</span>}
{response.size && <span className="timeline-item-metadata">{response.size}B</span>}
</div>
{/* Headers */}
<Headers headers={headers} type="response" />
{/* Body */}
<BodyBlock collection={collection} data={data} dataBuffer={dataBuffer} error={error} headers={headers} item={item} type="response" />
</div>
<>
<ResponseMeta
code={statusCode ?? status}
statusText={statusText}
duration={duration}
size={size}
/>
<Headers headers={headers} />
<BodyBlock
collection={collection}
data={data}
dataBuffer={dataBuffer}
error={error}
headers={headers}
item={item}
type="response"
/>
</>
);
};

View File

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

View File

@@ -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 (
<StyledWrapper>
<div className={`timeline-item ${isOauth2 ? 'timeline-item--oauth2' : ''}`}>
<div className="oauth-request-item-header relative cursor-pointer flex items-center justify-between gap-3 min-w-0" onClick={toggleCollapse}>
<Status statusCode={responseStatus || responseStatusCode} statusText={responseStatusText} />
<div className="flex items-center gap-1">
<div className={`tl-row-wrap ${isOauth2 ? 'tl-row-wrap--oauth2' : ''}`}>
<div
className={`tl-row ${isExpanded ? 'is-expanded' : ''}`}
role="button"
tabIndex={0}
aria-expanded={isExpanded}
onClick={toggleExpand}
onKeyDown={handleRowKeyDown}
>
<div className="tl-col-chev">
{isExpanded ? <IconChevronDown size={14} strokeWidth={2} /> : <IconChevronRight size={14} strokeWidth={2} />}
</div>
<div className="tl-col-status">
<Status statusCode={code} />
</div>
<div className="tl-col-method">
<Method method={method} />
<div className="truncate flex-1 min-w-0">{url}</div>
{isOauth2 && <span className="text-xs flex-shrink-0" style={{ color: theme.colors.text.muted }}>[oauth2.0]</span>}
</div>
<div className="tl-col-url" title={url}>{url}</div>
<div className="tl-col-badge">
<span className={badge.badgeClass}>{badge.badgeLabel}</span>
</div>
{!hideTimestamp && (
<span className="flex-shrink-0 ml-auto">
<div className="tl-col-time">
<RelativeTime timestamp={timestamp} />
</span>
</div>
)}
</div>
{isCollapsed && (
<div className="timeline-item-content">
{/* Tabs */}
<div className="timeline-item-tabs">
<button
className={`timeline-item-tab ${activeTab === 'request' ? 'timeline-item-tab--active' : ''}`}
onClick={() => setActiveTab('request')}
>
Request
</button>
<button
className={`timeline-item-tab ${activeTab === 'response' ? 'timeline-item-tab--active' : ''}`}
onClick={() => setActiveTab('response')}
>
Response
</button>
{showNetworkLogs && (
<button
className={`timeline-item-tab ${activeTab === 'networkLogs' ? 'timeline-item-tab--active' : ''}`}
onClick={() => setActiveTab('networkLogs')}
{isExpanded && (
<div className="tl-detail">
<div className="tl-header">
<div className="tl-header-url" title={`${method || ''} ${url}`}>
<span className="tl-header-url-method">{method}</span>
<span className="tl-header-url-text">{url}</span>
</div>
{sourceFile && (
<a
className={`tl-header-src${canNavigate ? '' : ' is-disabled'}`}
href="#"
title={canNavigate ? `Open ${sourceFile}` : sourceFile}
onClick={canNavigate ? handleNavigate : (ev) => ev.preventDefault()}
>
Network Logs
</button>
<span className="tl-header-src-file">{sourceFile}</span>
<span className="tl-header-src-icon"></span>
</a>
)}
</div>
{/* Tab Content */}
<div className="timeline-item-tab-content">
{/* Request Tab */}
{activeTab === 'request' && (
<Request request={request} item={item} collection={collection} />
)}
<div className="tl-tabs">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
className={`tl-tab ${activeTab === tab.id ? 'is-active' : ''}`}
onClick={() => handleTabClick(tab.id)}
>
{tab.label}
</button>
))}
</div>
{/* Response Tab */}
{activeTab === 'response' && (
<Response response={response} item={item} collection={collection} />
<div className="tl-panel">
{visitedTabs.request && (
<div style={{ display: activeTab === 'request' ? 'block' : 'none' }}>
<Request request={request} item={item} collection={collection} />
</div>
)}
{/* Network Logs Tab */}
{activeTab === 'networkLogs' && showNetworkLogs && (
<Network logs={response?.timeline} />
{visitedTabs.response && (
<div style={{ display: activeTab === 'response' ? 'block' : 'none' }}>
<Response response={response} item={item} collection={collection} />
</div>
)}
{showNetworkLogs && visitedTabs.network && (
<div style={{ display: activeTab === 'network' ? 'block' : 'none' }}>
<Network logs={response?.timeline} />
</div>
)}
</div>
</div>

View File

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

View File

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

View File

@@ -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 (
<StyledWrapper
className="pb-4 w-full flex flex-grow flex-col"
ref={wrapperRef}
>
{/* Timeline container with scrollbar */}
<div
className="timeline-container"
>
{combinedTimeline.map((event, index) => {
// Handle regular requests
if (event.type === 'request') {
const { data, timestamp, eventType } = event;
{showFilterBar && (
<div className="timeline-filter-bar">
{visibleChips.map((chip) => (
<button
key={chip.id}
type="button"
className={`timeline-chip ${activeFilter === chip.id ? 'is-active' : ''}`}
onClick={() => setActiveFilter(chip.id)}
>
{chip.label}
<span className="timeline-chip-count">{counts[chip.id] ?? 0}</span>
</button>
))}
</div>
)}
<div className="timeline-container">
{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 (
<div key={index} className="timeline-event">
<TimelineItem
@@ -107,37 +126,42 @@ const Timeline = ({ collection, item }) => {
response={response}
item={item}
collection={collection}
source="main"
/>
</div>
);
} else if (event.type === 'oauth2') { // Handle OAuth2 events
const { data, timestamp } = event;
const { debugInfo } = data;
}
if (entry.type === 'oauth2' && entry._oauth2Child) {
return (
<div key={index} className="timeline-event">
<div className="timeline-event-header cursor-pointer flex items-center">
<div className="flex items-center">
<span className="font-bold">OAuth2.0 Calls</span>
</div>
</div>
<div className="mt-2">
{debugInfo && debugInfo.length > 0 ? (
debugInfo.map((data, idx) => (
<div className="ml-4" key={idx}>
<TimelineItem
timestamp={timestamp}
request={data?.request}
response={data?.response}
item={item}
collection={collection}
isOauth2={true}
/>
</div>
))
) : (
<div>No debug information available.</div>
)}
</div>
<TimelineItem
timestamp={entry.timestamp}
request={entry._oauth2Child.request}
response={entry._oauth2Child.response}
item={item}
collection={collection}
source="oauth2.0"
isOauth2={true}
/>
</div>
);
}
if (entry.type === 'scripted-request') {
return (
<div key={index} className="timeline-event">
<TimelineItem
timestamp={entry.timestamp}
request={entry.data?.request}
response={entry.data?.response}
error={entry.data?.error}
item={item}
collection={collection}
source={entry.source || 'sendRequest'}
scope={entry.scope}
phase={entry.phase}
/>
</div>
);
}

View File

@@ -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 = [];
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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' }
})
);
});
});

View File

@@ -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');
});
});
});

View File

@@ -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();
});
});

View File

@@ -1 +1 @@
export { default as sendRequest, createSendRequest } from './send-request';
export { default as sendRequest, createSendRequest, buildScriptedEntry } from './send-request';

View File

@@ -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();
});
});

View File

@@ -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);

View File

@@ -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<GetHttpHttpsAgentsParams, 'requestUrl'>;
type SendRequestEntry = {
request: { method: string; url: string | undefined; headers: Record<string, any>; data: any };
response: {
statusCode: number;
statusText: string;
headers: Record<string, any>;
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<string, any> => {
if (!headers) return {};
if (typeof headers.toJSON === 'function') {
try { return { ...headers.toJSON() }; } catch (_) { /* fall through */ }
}
const out: Record<string, any> = {};
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 };

View File

@@ -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');
}
};

View File

@@ -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');
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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');
});
});
});

View File

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

View File

@@ -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

View File

@@ -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,