mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-01 08:34:07 +00:00
feat: show scripted requests in timeline (#8047)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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' ? '>' : '<'} <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' ? '>' : '<'} <span className="opacity-60">{key}:</span>
|
||||
<span>{String(value)}</span>
|
||||
</pre>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default HeadersBlock;
|
||||
export default Headers;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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__;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
16
packages/bruno-js/src/runtime/scripted-entries.js
Normal file
16
packages/bruno-js/src/runtime/scripted-entries.js
Normal 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
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
132
packages/bruno-js/tests/bru-scripted-entries.spec.js
Normal file
132
packages/bruno-js/tests/bru-scripted-entries.spec.js
Normal 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' }
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
209
packages/bruno-js/tests/script-runtime-scripted-entries.spec.js
Normal file
209
packages/bruno-js/tests/script-runtime-scripted-entries.spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
61
packages/bruno-js/tests/scripted-entries.spec.js
Normal file
61
packages/bruno-js/tests/scripted-entries.spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
export { default as sendRequest, createSendRequest } from './send-request';
|
||||
export { default as sendRequest, createSendRequest, buildScriptedEntry } from './send-request';
|
||||
|
||||
232
packages/bruno-requests/src/scripting/scripted-entry.spec.ts
Normal file
232
packages/bruno-requests/src/scripting/scripted-entry.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
131
tests/request/timeline/timeline-nested-runrequest.spec.ts
Normal file
131
tests/request/timeline/timeline-nested-runrequest.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
54
tests/request/timeline/timeline-runrequest-skip.spec.ts
Normal file
54
tests/request/timeline/timeline-runrequest-skip.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
153
tests/request/timeline/timeline-scripted-requests.spec.ts
Normal file
153
tests/request/timeline/timeline-scripted-requests.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user