mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 01:18:32 +00:00
Compare commits
6 Commits
feat/ci-de
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34460d5bcf | ||
|
|
422a43ce56 | ||
|
|
2720ac20b4 | ||
|
|
2e1c8b3382 | ||
|
|
95fccbeb8d | ||
|
|
e964bdc7fe |
@@ -84,8 +84,8 @@ export default class CodeEditor extends React.Component {
|
||||
this.searchBarRef.current?.focus();
|
||||
});
|
||||
},
|
||||
'Cmd-H': 'replace',
|
||||
'Ctrl-H': 'replace',
|
||||
'Cmd-H': this.props.readOnly ? false : 'replace',
|
||||
'Ctrl-H': this.props.readOnly ? false : 'replace',
|
||||
'Tab': function (cm) {
|
||||
cm.getSelection().includes('\n') || editor.getLine(cm.getCursor().line) == cm.getSelection()
|
||||
? cm.execCommand('indentMore')
|
||||
|
||||
@@ -39,7 +39,6 @@ const StyledWrapper = styled.div`
|
||||
.section-divider {
|
||||
height: 1px;
|
||||
background: ${(props) => props.theme.input.border};
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.tables-container {
|
||||
@@ -75,7 +74,7 @@ const StyledWrapper = styled.div`
|
||||
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color} !important;
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
background: ${(props) => props.theme.table.striped};
|
||||
user-select: none;
|
||||
|
||||
td {
|
||||
@@ -100,9 +99,8 @@ const StyledWrapper = styled.div`
|
||||
tr {
|
||||
transition: background 0.1s ease;
|
||||
height: 30px;
|
||||
|
||||
td {
|
||||
padding: 0 10px !important;
|
||||
padding: 0px 10px !important;
|
||||
border: none !important;
|
||||
vertical-align: middle;
|
||||
background: transparent;
|
||||
@@ -111,7 +109,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
tr:hover:not(.row-editing) td {
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -120,7 +118,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
tr.section-heading-row td {
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
padding: 6px 10px !important;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -131,8 +129,28 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
tr.section-last-row td {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
tr.section-spacer-row {
|
||||
height: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
tr.section-spacer-row td {
|
||||
padding: 0 !important;
|
||||
height: 8px;
|
||||
line-height: 8px;
|
||||
font-size: 0;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
|
||||
}
|
||||
|
||||
tr.section-spacer-row:hover td {
|
||||
background: transparent !important;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.keybinding-row {
|
||||
@@ -180,7 +198,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.shortcut-input--editing {
|
||||
outline: 1px solid #E4AE49;
|
||||
outline: 1px solid ${(props) => props.theme.status.warning.border};
|
||||
border-radius: 4px;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
@@ -189,7 +207,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.shortcut-input--error.shortcut-input--editing {
|
||||
outline: 1px solid #CE4F3B;
|
||||
outline: 1px solid ${(props) => props.theme.status.danger.border};
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -220,39 +238,41 @@ const StyledWrapper = styled.div`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 22px;
|
||||
height: 20px;
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
background: ${(props) => props.theme.background.base};
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
font-size: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
tbody tr.row-success td {
|
||||
background: #2E8A540F;
|
||||
tbody tr.row-success td,
|
||||
tbody tr.row-success:hover td {
|
||||
background: ${(props) => props.theme.status.success.background} !important;
|
||||
}
|
||||
|
||||
tbody tr.row-error td {
|
||||
background: #D32F2F0F;
|
||||
tbody tr.row-error td,
|
||||
tbody tr.row-error:hover td {
|
||||
background: ${(props) => props.theme.status.danger.background} !important;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
color: #2E8A54;
|
||||
color: ${(props) => props.theme.status.success.text};
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: #CE4F3B;
|
||||
color: ${(props) => props.theme.status.danger.text};
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-error-icon {
|
||||
color: #CE4F3B;
|
||||
color: ${(props) => props.theme.status.danger.text};
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
@@ -294,6 +314,11 @@ const StyledWrapper = styled.div`
|
||||
border-radius: 6px;
|
||||
padding: 0px 6px;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import React, { useMemo, useRef, useState, useEffect, Fragment } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
@@ -10,6 +10,7 @@ import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { KEY_BINDING_SECTIONS } from 'providers/Hotkeys/keyMappings.js';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import ToggleSwitch from 'components/ToggleSwitch/index';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const SEP = '+bind+';
|
||||
const getOS = () => (isMacOS() ? 'mac' : 'windows');
|
||||
@@ -82,10 +83,10 @@ const renderDisplayValue = (displayValue, os) => {
|
||||
return (
|
||||
<span className="shortcut-pills">
|
||||
{parsed.map((keysArr, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Fragment key={index}>
|
||||
{index > 0 && <span className="shortcut-separator"> - </span>}
|
||||
{renderKeycaps(keysArr, os)}
|
||||
</React.Fragment>
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
@@ -218,23 +219,21 @@ const RESERVED_BY_OS = {
|
||||
comboSignature(['f12']) // Dashboard (older macOS)
|
||||
]),
|
||||
windows: new Set([
|
||||
// System-level shortcuts (intercepted by Windows before reaching the app)
|
||||
comboSignature(['alt', 'tab']),
|
||||
comboSignature(['alt', 'shift', 'tab']),
|
||||
comboSignature(['alt', 'f4']),
|
||||
comboSignature(['f1']), // Windows Help
|
||||
comboSignature(['alt', 'esc']),
|
||||
comboSignature(['alt', 'space']),
|
||||
comboSignature(['ctrl', 'alt', 'delete']),
|
||||
comboSignature(['command', 'l']),
|
||||
comboSignature(['command', 'd']),
|
||||
comboSignature(['command', 'e']),
|
||||
comboSignature(['command', 'r']),
|
||||
comboSignature(['command', 'i']),
|
||||
comboSignature(['command', 's']),
|
||||
comboSignature(['command', 'a']),
|
||||
comboSignature(['command', 'x']),
|
||||
comboSignature(['command', 'm']),
|
||||
comboSignature(['command', 'tab']),
|
||||
comboSignature(['ctrl', 'shift', 'esc']),
|
||||
// Function keys
|
||||
comboSignature(['f1']), // Windows Help
|
||||
comboSignature(['f11']), // Fullscreen toggle
|
||||
comboSignature(['f12']), // DevTools
|
||||
// Undo/Redo - standard text editing shortcuts that browsers handle natively
|
||||
comboSignature(['ctrl', 'z']),
|
||||
comboSignature(['ctrl', 'y']),
|
||||
comboSignature(['ctrl', 'shift', 'z']),
|
||||
// Toggle Developer Tools
|
||||
comboSignature(['ctrl', 'shift', 'i'])
|
||||
@@ -493,7 +492,7 @@ const Keybindings = () => {
|
||||
if (buildUsedSignatures(action).has(sig)) {
|
||||
return {
|
||||
code: ERROR.DUPLICATE,
|
||||
message: 'That shortcut is already in use.'
|
||||
message: 'This shortcut is already in use.'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -562,9 +561,24 @@ const Keybindings = () => {
|
||||
return next;
|
||||
});
|
||||
|
||||
persistToPreferences(action, def);
|
||||
// Remove the entry from user preferences entirely so falls back to default.
|
||||
// This also keeps `hasCustomizedKeybindings` accurate.
|
||||
const nextKeyBindings = { ...(preferences?.keyBindings || {}) };
|
||||
delete nextKeyBindings[action];
|
||||
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
keyBindings: nextKeyBindings
|
||||
};
|
||||
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
const hasCustomizedKeybindings = useMemo(() => {
|
||||
const userKeyBindings = preferences?.keyBindings || {};
|
||||
return Object.keys(userKeyBindings).length > 0;
|
||||
}, [preferences?.keyBindings]);
|
||||
|
||||
const resetAllKeybindings = () => {
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
@@ -572,6 +586,7 @@ const Keybindings = () => {
|
||||
};
|
||||
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
toast.success('All shortcuts have been reset to default');
|
||||
};
|
||||
|
||||
const startEditing = (action) => {
|
||||
@@ -799,6 +814,7 @@ const Keybindings = () => {
|
||||
onClick={resetAllKeybindings}
|
||||
className="reset-btn"
|
||||
data-testid="reset-all-keybindings-btn"
|
||||
disabled={!hasCustomizedKeybindings}
|
||||
>
|
||||
Reset Default
|
||||
</button>
|
||||
@@ -817,7 +833,7 @@ const Keybindings = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupedKeyMappings.map((section, sectionIndex) => (
|
||||
<React.Fragment key={section.heading}>
|
||||
<Fragment key={section.heading}>
|
||||
<tr className="section-heading-row">
|
||||
<td colSpan={2}>{section.heading}</td>
|
||||
</tr>
|
||||
@@ -946,7 +962,12 @@ const Keybindings = () => {
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
{sectionIndex < groupedKeyMappings.length - 1 && (
|
||||
<tr className="section-spacer-row" aria-hidden="true">
|
||||
<td colSpan={2}> </td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -94,6 +94,7 @@ const ArgValueInput = ({ value, onChange, field }) => {
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="Enter value"
|
||||
className="mousetrap"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -139,7 +140,7 @@ const InputObjectFields = ({ namedType, parentKey, fieldPath, indent, argValues,
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isEnabled}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -230,12 +231,6 @@ const FieldNode = ({
|
||||
role="treeitem"
|
||||
aria-expanded={isExpanded}
|
||||
onClick={handleExpand}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleExpand(e);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="field-indent" style={{ width: indent }} />
|
||||
@@ -248,7 +243,7 @@ const FieldNode = ({
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isChecked}
|
||||
onChange={handleCheck}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -268,12 +263,6 @@ const FieldNode = ({
|
||||
role="treeitem"
|
||||
aria-expanded={canExpand ? isExpanded : undefined}
|
||||
onClick={handleExpand}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleExpand(e);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="field-indent" style={{ width: indent }} />
|
||||
@@ -288,7 +277,7 @@ const FieldNode = ({
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isChecked}
|
||||
onChange={handleCheck}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -315,7 +304,7 @@ const FieldNode = ({
|
||||
<span className="input-object-chevron-spacer" />
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isArgEnabled}
|
||||
onChange={() => onToggleArg && onToggleArg(field.path, arg.name)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -369,7 +358,7 @@ const FieldNode = ({
|
||||
<span className="input-object-chevron-spacer" />
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isArgEnabled}
|
||||
onChange={() => onToggleArg && onToggleArg(field.path, arg.name)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -419,12 +408,6 @@ const InputObjectArgRow = ({ arg, argKey, fieldPath, isArgEnabled, sectionIndent
|
||||
className="arg-row"
|
||||
style={{ paddingLeft: sectionIndent + 8 }}
|
||||
onClick={toggleExpand}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleExpand(e);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-expanded={isExpanded}
|
||||
@@ -438,7 +421,7 @@ const InputObjectArgRow = ({ arg, argKey, fieldPath, isArgEnabled, sectionIndent
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isArgEnabled}
|
||||
onChange={handleCheck}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -486,12 +469,6 @@ const ListArgRow = ({ arg, fieldPath, isArgEnabled, argValue, sectionIndent, onT
|
||||
className="arg-row"
|
||||
style={{ paddingLeft: sectionIndent + 8 }}
|
||||
onClick={toggleExpand}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleExpand(e);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-expanded={isExpanded}
|
||||
@@ -505,7 +482,7 @@ const ListArgRow = ({ arg, fieldPath, isArgEnabled, argValue, sectionIndent, onT
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isArgEnabled}
|
||||
onChange={handleCheck}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -211,7 +211,12 @@ const StyledWrapper = styled.div`
|
||||
padding: 3px 8px;
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
cursor: default;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
}
|
||||
|
||||
.input-object-chevron {
|
||||
width: 14px;
|
||||
|
||||
@@ -175,6 +175,7 @@ const QueryBuilder = ({ schema, onQueryChange, editorValue, onVariablesChange, v
|
||||
type="text"
|
||||
placeholder="Search operations..."
|
||||
value={searchText}
|
||||
className="mousetrap"
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -137,6 +137,12 @@ export default class QueryEditor extends React.Component {
|
||||
this.addOverlay();
|
||||
|
||||
setupLinkAware(editor);
|
||||
|
||||
// Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused
|
||||
const cmInput = editor.getInputField();
|
||||
if (cmInput) {
|
||||
cmInput.classList.add('mousetrap');
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
|
||||
@@ -225,7 +225,11 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
if (tab.type === 'environment-settings') {
|
||||
if (collection?.environmentsDraft) {
|
||||
const { environmentUid, variables } = collection.environmentsDraft;
|
||||
dispatch(saveEnvironment(variables, environmentUid, collection.uid));
|
||||
if (environmentUid?.startsWith('dotenv:')) {
|
||||
window.dispatchEvent(new Event('dotenv-save'));
|
||||
} else {
|
||||
dispatch(saveEnvironment(variables, environmentUid, collection.uid));
|
||||
}
|
||||
}
|
||||
} else if (tab.type === 'global-environment-settings') {
|
||||
if (globalEnvironmentDraft) {
|
||||
|
||||
@@ -19,6 +19,8 @@ const QueryResponse = ({
|
||||
const previewFormatOptions = useResponsePreviewFormatOptions(dataBuffer, headers);
|
||||
const [selectedFormat, setSelectedFormat] = useState('raw');
|
||||
const [selectedTab, setSelectedTab] = useState('editor');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [filterExpanded, setFilterExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFormat !== null && initialTab !== null) {
|
||||
@@ -56,6 +58,10 @@ const QueryResponse = ({
|
||||
error={error}
|
||||
selectedFormat={selectedFormat}
|
||||
selectedTab={selectedTab}
|
||||
filter={filter}
|
||||
filterExpanded={filterExpanded}
|
||||
onFilterChange={setFilter}
|
||||
onFilterExpandChange={setFilterExpanded}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -316,18 +316,6 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
|
||||
<form
|
||||
className="bruno-form"
|
||||
onSubmit={formik.handleSubmit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.defaultPrevented) {
|
||||
const isTextInput
|
||||
= ['input', 'textarea'].includes(e.target.tagName.toLowerCase())
|
||||
|| e.target.isContentEditable;
|
||||
|
||||
if (!isTextInput) {
|
||||
e.preventDefault();
|
||||
formik.handleSubmit();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="requestName" className="block font-medium">
|
||||
@@ -523,6 +511,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
|
||||
className="flex px-2 items-center flex-grow input-container h-full min-w-0"
|
||||
>
|
||||
<SingleLineEditor
|
||||
onRun={() => formik.handleSubmit()}
|
||||
onPaste={handlePaste}
|
||||
placeholder="Request URL"
|
||||
value={formik.values.requestUrl || ''}
|
||||
|
||||
@@ -35,7 +35,8 @@ taskMiddleware.startListening({
|
||||
uid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
requestPaneTab: getDefaultRequestPaneTab(item),
|
||||
preview: task?.preview ?? true
|
||||
preview: task?.preview ?? true,
|
||||
...(item.isTransient ? { isTransient: true } : {})
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1079,11 +1079,12 @@ export const collectionsSlice = createSlice({
|
||||
item.draft = cloneDeep(item);
|
||||
}
|
||||
const existingOtherParams = item.draft.request.params?.filter((p) => p.type !== 'query') || [];
|
||||
const newQueryParams = map(params, ({ uid, name = '', value = '', description = '', type = 'query', enabled = true }) => ({
|
||||
const newQueryParams = map(params, ({ uid, name = '', value = '', description = '', annotations = null, type = 'query', enabled = true }) => ({
|
||||
uid: uid || uuid(),
|
||||
name,
|
||||
value,
|
||||
description,
|
||||
annotations,
|
||||
type,
|
||||
enabled
|
||||
}));
|
||||
@@ -1325,11 +1326,12 @@ export const collectionsSlice = createSlice({
|
||||
if (!item.draft) {
|
||||
item.draft = cloneDeep(item);
|
||||
}
|
||||
item.draft.request.headers = map(action.payload.headers, ({ uid, name = '', value = '', description = '', enabled = true }) => ({
|
||||
item.draft.request.headers = map(action.payload.headers, ({ uid, name = '', value = '', description = '', annotations = null, enabled = true }) => ({
|
||||
uid: uid || uuid(),
|
||||
name,
|
||||
value,
|
||||
description,
|
||||
annotations,
|
||||
enabled
|
||||
}));
|
||||
},
|
||||
@@ -1353,11 +1355,12 @@ export const collectionsSlice = createSlice({
|
||||
collection.draft.root.request = {};
|
||||
}
|
||||
|
||||
collection.draft.root.request.headers = map(headers, ({ uid, name = '', value = '', description = '', enabled = true }) => ({
|
||||
collection.draft.root.request.headers = map(headers, ({ uid, name = '', value = '', description = '', annotations = null, enabled = true }) => ({
|
||||
uid: uid || uuid(),
|
||||
name,
|
||||
value,
|
||||
description,
|
||||
annotations,
|
||||
enabled
|
||||
}));
|
||||
},
|
||||
@@ -1380,11 +1383,12 @@ export const collectionsSlice = createSlice({
|
||||
if (!folder.draft.request) {
|
||||
folder.draft.request = {};
|
||||
}
|
||||
folder.draft.request.headers = map(headers, ({ uid, name = '', value = '', description = '', enabled = true }) => ({
|
||||
folder.draft.request.headers = map(headers, ({ uid, name = '', value = '', description = '', annotations = null, enabled = true }) => ({
|
||||
uid: uid || uuid(),
|
||||
name,
|
||||
value,
|
||||
description,
|
||||
annotations,
|
||||
enabled
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@ export const tabsSlice = createSlice({
|
||||
initialState,
|
||||
reducers: {
|
||||
addTab: (state, action) => {
|
||||
const { uid, collectionUid, type, requestPaneTab, preview, exampleUid, itemUid } = action.payload;
|
||||
const { uid, collectionUid, type, requestPaneTab, preview, exampleUid, itemUid, isTransient } = action.payload;
|
||||
|
||||
const nonReplaceableTabTypes = [
|
||||
'variables',
|
||||
@@ -75,7 +75,8 @@ export const tabsSlice = createSlice({
|
||||
: !nonReplaceableTabTypes.includes(type),
|
||||
...(uid ? { folderUid: uid } : {}),
|
||||
...(exampleUid ? { exampleUid } : {}),
|
||||
...(itemUid ? { itemUid } : {})
|
||||
...(itemUid ? { itemUid } : {}),
|
||||
...(isTransient ? { isTransient: true } : {})
|
||||
};
|
||||
|
||||
state.activeTabUid = uid;
|
||||
@@ -103,7 +104,8 @@ export const tabsSlice = createSlice({
|
||||
? preview
|
||||
: !nonReplaceableTabTypes.includes(type),
|
||||
...(exampleUid ? { exampleUid } : {}),
|
||||
...(itemUid ? { itemUid } : {})
|
||||
...(itemUid ? { itemUid } : {}),
|
||||
...(isTransient ? { isTransient: true } : {})
|
||||
});
|
||||
state.activeTabUid = uid;
|
||||
},
|
||||
@@ -270,8 +272,9 @@ export const tabsSlice = createSlice({
|
||||
const nonClosableTypes = ['workspaceOverview', 'workspaceEnvironments'];
|
||||
|
||||
// Push closed tabs onto the recently closed stack (LIFO)
|
||||
// Exclude transient requests — they have no persisted file and can't be reopened
|
||||
const closedTabs = state.tabs.filter((t) =>
|
||||
tabUids.includes(t.uid) && !nonClosableTypes.includes(t.type)
|
||||
tabUids.includes(t.uid) && !nonClosableTypes.includes(t.type) && !t.isTransient
|
||||
);
|
||||
if (closedTabs.length > 0) {
|
||||
state.recentlyClosedTabs.push(...closedTabs);
|
||||
|
||||
@@ -181,6 +181,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
name: header.name,
|
||||
value: header.value,
|
||||
description: header.description,
|
||||
annotations: header.annotations,
|
||||
enabled: header.enabled
|
||||
};
|
||||
});
|
||||
@@ -193,6 +194,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
name: param.name,
|
||||
value: param.value,
|
||||
description: param.description,
|
||||
annotations: param.annotations,
|
||||
type: param.type,
|
||||
enabled: param.enabled
|
||||
};
|
||||
@@ -745,6 +747,7 @@ export const transformRequestToSaveToFilesystem = (item) => {
|
||||
name: param.name,
|
||||
value: param.value,
|
||||
description: param.description,
|
||||
annotations: param.annotations,
|
||||
type: param.type,
|
||||
enabled: param.enabled
|
||||
});
|
||||
@@ -757,6 +760,7 @@ export const transformRequestToSaveToFilesystem = (item) => {
|
||||
name: header.name,
|
||||
value: header.value,
|
||||
description: header.description,
|
||||
annotations: header.annotations,
|
||||
enabled: header.enabled
|
||||
});
|
||||
});
|
||||
@@ -813,6 +817,7 @@ export const transformCollectionRootToSave = (collection) => {
|
||||
name: header.name,
|
||||
value: header.value,
|
||||
description: header.description,
|
||||
annotations: header.annotations,
|
||||
enabled: header.enabled
|
||||
});
|
||||
});
|
||||
@@ -843,6 +848,7 @@ export const transformFolderRootToSave = (folder) => {
|
||||
name: header.name,
|
||||
value: header.value,
|
||||
description: header.description,
|
||||
annotations: header.annotations,
|
||||
enabled: header.enabled
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { describe, it, expect } = require('@jest/globals');
|
||||
import { mergeHeaders } from './index';
|
||||
import { mergeHeaders, transformRequestToSaveToFilesystem } from './index';
|
||||
|
||||
describe('mergeHeaders', () => {
|
||||
it('should include headers from collection, folder and request (with correct precedence)', () => {
|
||||
@@ -35,3 +35,54 @@ describe('mergeHeaders', () => {
|
||||
expect(names).toEqual(expect.arrayContaining(['X-Collection', 'X-Folder', 'X-Request']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformRequestToSaveToFilesystem', () => {
|
||||
it('preserves header and param annotations', () => {
|
||||
const item = {
|
||||
uid: 'requestuid123456789012',
|
||||
type: 'http-request',
|
||||
name: 'Annotated Request',
|
||||
seq: 1,
|
||||
settings: {},
|
||||
tags: [],
|
||||
examples: [],
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: 'https://example.com',
|
||||
params: [
|
||||
{
|
||||
uid: 'paramuid1234567890123',
|
||||
name: 'q',
|
||||
value: '1',
|
||||
description: '',
|
||||
annotations: [{ name: 'param-note', value: 'keep me' }],
|
||||
type: 'query',
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
headers: [
|
||||
{
|
||||
uid: 'headeruid123456789012',
|
||||
name: 'X-Test',
|
||||
value: '1',
|
||||
description: '',
|
||||
annotations: [{ name: 'header-note', value: 'keep me' }],
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
auth: { mode: 'none' },
|
||||
body: { mode: 'none' },
|
||||
script: { req: '', res: '' },
|
||||
vars: { req: [], res: [] },
|
||||
assertions: [],
|
||||
tests: '',
|
||||
docs: ''
|
||||
}
|
||||
};
|
||||
|
||||
const transformed = transformRequestToSaveToFilesystem(item);
|
||||
|
||||
expect(transformed.request.params[0].annotations).toEqual([{ name: 'param-note', value: 'keep me' }]);
|
||||
expect(transformed.request.headers[0].annotations).toEqual([{ name: 'header-note', value: 'keep me' }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -322,6 +322,18 @@ const transformSwaggerRequestItem = (request, usedNames = new Set(), options = {
|
||||
requestBodySchema,
|
||||
requestBodyContentType
|
||||
}));
|
||||
} else if (response.description) {
|
||||
// description only (e.g., 204 No Content) — create example without body
|
||||
examples.push(createBrunoExample({
|
||||
brunoRequestItem,
|
||||
exampleValue: '',
|
||||
exampleName: `${statusCode} Response`,
|
||||
exampleDescription: response.description,
|
||||
statusCode,
|
||||
contentType: null,
|
||||
requestBodySchema,
|
||||
requestBodyContentType
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ describe('swagger2-to-bruno response examples', () => {
|
||||
expect(req.examples[0].response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should not generate examples when responses have no schema or examples', () => {
|
||||
it('should generate examples from description only responses', () => {
|
||||
const spec = {
|
||||
swagger: '2.0',
|
||||
info: { title: 'No Schema API', version: '1.0' },
|
||||
@@ -229,8 +229,20 @@ describe('swagger2-to-bruno response examples', () => {
|
||||
const collection = swagger2ToBruno(spec);
|
||||
const req = collection.items.find((i) => i.name === 'Delete data');
|
||||
|
||||
// No schema or examples → no examples array
|
||||
expect(req.examples).toBeUndefined();
|
||||
expect(req.examples).toBeDefined();
|
||||
expect(req.examples.length).toBe(2);
|
||||
|
||||
const noContentExample = req.examples.find((e) => e.response.status === 204);
|
||||
expect(noContentExample).toBeDefined();
|
||||
expect(noContentExample.name).toBe('204 Response');
|
||||
expect(noContentExample.description).toBe('No Content');
|
||||
expect(noContentExample.response.body.content).toBe('');
|
||||
expect(noContentExample.response.headers).toEqual([]);
|
||||
|
||||
const notFoundExample = req.examples.find((e) => e.response.status === 404);
|
||||
expect(notFoundExample).toBeDefined();
|
||||
expect(notFoundExample.name).toBe('404 Response');
|
||||
expect(notFoundExample.description).toBe('Not Found');
|
||||
});
|
||||
|
||||
it('should set correct statusText in response examples', () => {
|
||||
|
||||
@@ -645,6 +645,7 @@ const transformRequestToSaveToFilesystem = (item) => {
|
||||
name: param.name,
|
||||
value: param.value,
|
||||
description: param.description,
|
||||
annotations: param.annotations,
|
||||
type: param.type,
|
||||
enabled: param.enabled
|
||||
});
|
||||
@@ -657,6 +658,7 @@ const transformRequestToSaveToFilesystem = (item) => {
|
||||
name: header.name,
|
||||
value: header.value,
|
||||
description: header.description,
|
||||
annotations: header.annotations,
|
||||
enabled: header.enabled
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('transformRequestToSaveToFilesystem', () => {
|
||||
name: 'param1',
|
||||
value: 'value1',
|
||||
description: 'Test parameter',
|
||||
annotations: [{ name: 'note', value: 'param annotation' }],
|
||||
type: 'text',
|
||||
enabled: true
|
||||
}
|
||||
@@ -30,6 +31,7 @@ describe('transformRequestToSaveToFilesystem', () => {
|
||||
name: 'Content-Type',
|
||||
value: 'application/json',
|
||||
description: 'Request content type',
|
||||
annotations: [{ name: 'note', value: 'header annotation' }],
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
@@ -101,6 +103,7 @@ describe('transformRequestToSaveToFilesystem', () => {
|
||||
name: 'param1',
|
||||
value: 'value1',
|
||||
description: 'Test parameter',
|
||||
annotations: [{ name: 'note', value: 'param annotation' }],
|
||||
type: 'text',
|
||||
enabled: true
|
||||
});
|
||||
@@ -112,6 +115,7 @@ describe('transformRequestToSaveToFilesystem', () => {
|
||||
name: 'Content-Type',
|
||||
value: 'application/json',
|
||||
description: 'Request content type',
|
||||
annotations: [{ name: 'note', value: 'header annotation' }],
|
||||
enabled: true
|
||||
});
|
||||
});
|
||||
|
||||
@@ -585,10 +585,11 @@ ${indentString(body.sparql)}
|
||||
const selected = item.selected ? '' : '~';
|
||||
const contentType
|
||||
= item.contentType && item.contentType !== '' ? ' @contentType(' + item.contentType + ')' : '';
|
||||
const annotPrefix = serializeAnnotations(item.annotations);
|
||||
const filePath = item.filePath || '';
|
||||
const value = `@file(${filePath})`;
|
||||
const itemName = 'file';
|
||||
return `${selected}${itemName}: ${value}${contentType}`;
|
||||
return `${annotPrefix}${selected}${itemName}: ${value}${contentType}`;
|
||||
})
|
||||
.join('\n')
|
||||
)}`;
|
||||
|
||||
@@ -320,6 +320,35 @@ headers {
|
||||
expect(parsed.headers[0].annotations).toEqual([{ name: 'description', value: '{{baseUrl}}/path' }]);
|
||||
});
|
||||
|
||||
it('serializeAnnotations — annotation on params:path', () => {
|
||||
const json = {
|
||||
params: [{ name: 'userId', value: '123', enabled: true, type: 'path', annotations: [{ name: 'description', value: 'user id' }] }]
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
expect(bru).toContain('params:path {');
|
||||
expect(bru).toContain('@description(\'user id\')\n userId: 123');
|
||||
});
|
||||
|
||||
it('serializeAnnotations — annotation on metadata', () => {
|
||||
const json = {
|
||||
metadata: [{ name: 'trace-id', value: 'abc123', enabled: true, annotations: [{ name: 'description', value: 'trace id' }] }]
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
expect(bru).toContain('metadata {');
|
||||
expect(bru).toContain('@description(\'trace id\')\n trace-id: abc123');
|
||||
});
|
||||
|
||||
it('serializeAnnotations — annotation on body:form-urlencoded', () => {
|
||||
const json = {
|
||||
body: {
|
||||
formUrlEncoded: [{ name: 'username', value: 'alice', enabled: true, annotations: [{ name: 'description', value: 'username field' }] }]
|
||||
}
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
expect(bru).toContain('body:form-urlencoded {');
|
||||
expect(bru).toContain('@description(\'username field\')\n username: alice');
|
||||
});
|
||||
|
||||
it('annotation on params:query block', () => {
|
||||
const input = `
|
||||
params:query {
|
||||
@@ -333,6 +362,45 @@ params:query {
|
||||
]);
|
||||
});
|
||||
|
||||
it('annotation on params:path block', () => {
|
||||
const input = `
|
||||
params:path {
|
||||
@description('user id')
|
||||
userId: 123
|
||||
}
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.params).toEqual([
|
||||
{ name: 'userId', value: '123', enabled: true, type: 'path', annotations: [{ name: 'description', value: 'user id' }] }
|
||||
]);
|
||||
});
|
||||
|
||||
it('annotation on metadata block', () => {
|
||||
const input = `
|
||||
metadata {
|
||||
@description('trace id')
|
||||
trace-id: abc123
|
||||
}
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.metadata).toEqual([
|
||||
{ name: 'trace-id', value: 'abc123', enabled: true, annotations: [{ name: 'description', value: 'trace id' }] }
|
||||
]);
|
||||
});
|
||||
|
||||
it('annotation on body:form-urlencoded block', () => {
|
||||
const input = `
|
||||
body:form-urlencoded {
|
||||
@description('username field')
|
||||
username: alice
|
||||
}
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.body.formUrlEncoded).toEqual([
|
||||
{ name: 'username', value: 'alice', enabled: true, annotations: [{ name: 'description', value: 'username field' }] }
|
||||
]);
|
||||
});
|
||||
|
||||
it('annotation on vars:pre-request block', () => {
|
||||
const input = `
|
||||
vars:pre-request {
|
||||
@@ -352,6 +420,225 @@ vars:pre-request {
|
||||
]);
|
||||
});
|
||||
|
||||
it('annotation on vars:post-response block', () => {
|
||||
const input = `
|
||||
vars:post-response {
|
||||
@description('auth token')
|
||||
token: abc123
|
||||
}
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.vars.res).toEqual([
|
||||
{
|
||||
name: 'token',
|
||||
value: 'abc123',
|
||||
enabled: true,
|
||||
local: false,
|
||||
annotations: [{ name: 'description', value: 'auth token' }]
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('annotation on local vars:pre-request pair', () => {
|
||||
const input = `
|
||||
vars:pre-request {
|
||||
@description('local base url')
|
||||
@BASE_URL: http://localhost
|
||||
}
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.vars.req).toEqual([
|
||||
{
|
||||
name: 'BASE_URL',
|
||||
value: 'http://localhost',
|
||||
enabled: true,
|
||||
local: true,
|
||||
annotations: [{ name: 'description', value: 'local base url' }]
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('annotation on local vars:post-response pair', () => {
|
||||
const input = `
|
||||
vars:post-response {
|
||||
@description('local token')
|
||||
@token: abc123
|
||||
}
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.vars.res).toEqual([
|
||||
{
|
||||
name: 'token',
|
||||
value: 'abc123',
|
||||
enabled: true,
|
||||
local: true,
|
||||
annotations: [{ name: 'description', value: 'local token' }]
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('annotation on body:multipart-form text field', () => {
|
||||
const input = `
|
||||
body:multipart-form {
|
||||
@description('plain field')
|
||||
field: value @contentType(text/plain)
|
||||
}
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.body.multipartForm).toEqual([
|
||||
{
|
||||
name: 'field',
|
||||
value: 'value',
|
||||
enabled: true,
|
||||
type: 'text',
|
||||
contentType: 'text/plain',
|
||||
annotations: [{ name: 'description', value: 'plain field' }]
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('annotation on body:multipart-form file field', () => {
|
||||
const input = `
|
||||
body:multipart-form {
|
||||
@description('upload image')
|
||||
upload: @file(/tmp/a.png|/tmp/b.png) @contentType(image/png)
|
||||
}
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.body.multipartForm).toEqual([
|
||||
{
|
||||
name: 'upload',
|
||||
value: ['/tmp/a.png', '/tmp/b.png'],
|
||||
enabled: true,
|
||||
type: 'file',
|
||||
contentType: 'image/png',
|
||||
annotations: [{ name: 'description', value: 'upload image' }]
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('annotation on body:file', () => {
|
||||
const input = `
|
||||
body:file {
|
||||
@description('upload doc')
|
||||
file: @file(/tmp/readme.pdf) @contentType(application/pdf)
|
||||
}
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.body.file).toEqual([
|
||||
{
|
||||
filePath: '/tmp/readme.pdf',
|
||||
selected: true,
|
||||
contentType: 'application/pdf',
|
||||
annotations: [{ name: 'description', value: 'upload doc' }]
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializeAnnotations — multipart text field with contentType', () => {
|
||||
const json = {
|
||||
body: {
|
||||
multipartForm: [
|
||||
{
|
||||
name: 'field',
|
||||
value: 'value',
|
||||
enabled: true,
|
||||
type: 'text',
|
||||
contentType: 'text/plain',
|
||||
annotations: [{ name: 'description', value: 'plain field' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
expect(bru).toContain('@description(\'plain field\')\n field: value @contentType(text/plain)');
|
||||
});
|
||||
|
||||
it('serializeAnnotations — multipart file field with contentType', () => {
|
||||
const json = {
|
||||
body: {
|
||||
multipartForm: [
|
||||
{
|
||||
name: 'upload',
|
||||
value: ['/tmp/a.png', '/tmp/b.png'],
|
||||
enabled: true,
|
||||
type: 'file',
|
||||
contentType: 'image/png',
|
||||
annotations: [{ name: 'description', value: 'upload image' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
expect(bru).toContain('@description(\'upload image\')\n upload: @file(/tmp/a.png|/tmp/b.png) @contentType(image/png)');
|
||||
});
|
||||
|
||||
it('serializeAnnotations — annotation on vars:post-response', () => {
|
||||
const json = {
|
||||
vars: {
|
||||
res: [{ name: 'token', value: 'abc123', enabled: true, local: false, annotations: [{ name: 'description', value: 'auth token' }] }]
|
||||
}
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
expect(bru).toContain('vars:post-response {');
|
||||
expect(bru).toContain('@description(\'auth token\')\n token: abc123');
|
||||
});
|
||||
|
||||
it('serializeAnnotations — annotation on local vars:pre-request', () => {
|
||||
const json = {
|
||||
vars: {
|
||||
req: [{ name: 'BASE_URL', value: 'http://localhost', enabled: true, local: true, annotations: [{ name: 'description', value: 'local base url' }] }]
|
||||
}
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
expect(bru).toContain('vars:pre-request {');
|
||||
expect(bru).toContain('@description(\'local base url\')\n @BASE_URL: http://localhost');
|
||||
});
|
||||
|
||||
it('serializeAnnotations — annotation on disabled local vars:post-response', () => {
|
||||
const json = {
|
||||
vars: {
|
||||
res: [{ name: 'token', value: 'abc123', enabled: false, local: true, annotations: [{ name: 'description', value: 'local token' }] }]
|
||||
}
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
expect(bru).toContain('vars:post-response {');
|
||||
expect(bru).toContain('@description(\'local token\')\n ~@token: abc123');
|
||||
});
|
||||
|
||||
it('serializeAnnotations — body:file with annotations', () => {
|
||||
const json = {
|
||||
body: {
|
||||
file: [{ filePath: '/tmp/readme.pdf', selected: true, contentType: 'application/pdf', annotations: [{ name: 'description', value: 'upload doc' }] }]
|
||||
}
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
expect(bru).toContain('body:file {');
|
||||
expect(bru).toContain('@description(\'upload doc\')\n file: @file(/tmp/readme.pdf) @contentType(application/pdf)');
|
||||
const parsed = parser(bru);
|
||||
expect(parsed.body.file).toEqual(json.body.file);
|
||||
});
|
||||
|
||||
it('roundtrip — multipart annotation survives json→bru→json', () => {
|
||||
const json = {
|
||||
body: {
|
||||
multipartForm: [
|
||||
{
|
||||
name: 'upload',
|
||||
value: ['/tmp/a.png'],
|
||||
enabled: true,
|
||||
type: 'file',
|
||||
contentType: 'image/png',
|
||||
annotations: [{ name: 'description', value: 'upload image' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
const parsed = parser(bru);
|
||||
expect(parsed.body.multipartForm).toEqual(json.body.multipartForm);
|
||||
});
|
||||
|
||||
it('roundtrip: bru → json → bru → json equal', () => {
|
||||
const input = `get {
|
||||
url: https://example.com
|
||||
@@ -792,6 +1079,39 @@ describe('collection pair annotations', () => {
|
||||
expect(bru).toContain('@description(\'base url\')\n BASE_URL: http://localhost');
|
||||
});
|
||||
|
||||
it('serializeAnnotations in jsonToCollectionBru — vars:post-response with annotation', () => {
|
||||
const json = {
|
||||
vars: {
|
||||
res: [{ name: 'token', value: 'abc123', enabled: true, local: false, annotations: [{ name: 'description', value: 'auth token' }] }]
|
||||
}
|
||||
};
|
||||
const bru = jsonToCollectionBru(json);
|
||||
expect(bru).toContain('vars:post-response {');
|
||||
expect(bru).toContain('@description(\'auth token\')\n token: abc123');
|
||||
});
|
||||
|
||||
it('serializeAnnotations in jsonToCollectionBru — local vars:pre-request with annotation', () => {
|
||||
const json = {
|
||||
vars: {
|
||||
req: [{ name: 'BASE_URL', value: 'http://localhost', enabled: true, local: true, annotations: [{ name: 'description', value: 'local base url' }] }]
|
||||
}
|
||||
};
|
||||
const bru = jsonToCollectionBru(json);
|
||||
expect(bru).toContain('vars:pre-request {');
|
||||
expect(bru).toContain('@description(\'local base url\')\n @BASE_URL: http://localhost');
|
||||
});
|
||||
|
||||
it('serializeAnnotations in jsonToCollectionBru — disabled local vars:post-response with annotation', () => {
|
||||
const json = {
|
||||
vars: {
|
||||
res: [{ name: 'token', value: 'abc123', enabled: false, local: true, annotations: [{ name: 'description', value: 'local token' }] }]
|
||||
}
|
||||
};
|
||||
const bru = jsonToCollectionBru(json);
|
||||
expect(bru).toContain('vars:post-response {');
|
||||
expect(bru).toContain('@description(\'local token\')\n ~@token: abc123');
|
||||
});
|
||||
|
||||
it('parseAndSerialise - bru sourced roundtrip check - collection headers', () => {
|
||||
const input = `headers {
|
||||
@description('content type')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UID } from '../common';
|
||||
import type { UID, Annotation } from '../common';
|
||||
|
||||
export interface EnvironmentVariable {
|
||||
uid: UID;
|
||||
@@ -7,6 +7,7 @@ export interface EnvironmentVariable {
|
||||
type: 'text';
|
||||
enabled?: boolean;
|
||||
secret?: boolean;
|
||||
annotations?: Annotation[] | null;
|
||||
}
|
||||
|
||||
export interface Environment {
|
||||
|
||||
7
packages/bruno-schema-types/src/common/annotation.ts
Normal file
7
packages/bruno-schema-types/src/common/annotation.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Annotation applied to pairs (headers, vars, params, etc.)
|
||||
*/
|
||||
export interface Annotation {
|
||||
name: string;
|
||||
value?: string | null;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Annotation } from './annotation';
|
||||
import type { UID } from './uid';
|
||||
|
||||
export interface FileEntry {
|
||||
@@ -5,6 +6,7 @@ export interface FileEntry {
|
||||
filePath?: string | null;
|
||||
contentType?: string | null;
|
||||
selected: boolean;
|
||||
annotations?: Annotation[];
|
||||
}
|
||||
|
||||
export type FileList = FileEntry[];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type { UID } from './uid';
|
||||
export type { KeyValue } from './key-value';
|
||||
export type { Variable, Variables } from './variables';
|
||||
export type { Annotation } from './annotation';
|
||||
export type { MultipartFormEntry, MultipartForm } from './multipart-form';
|
||||
export type { FileEntry, FileList } from './file';
|
||||
export type { GraphqlBody } from './graphql';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Annotation } from './annotation';
|
||||
import type { UID } from './uid';
|
||||
|
||||
/**
|
||||
@@ -9,4 +10,5 @@ export interface KeyValue {
|
||||
value?: string | null;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
annotations?: Annotation[] | null;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Annotation } from './annotation';
|
||||
import type { UID } from './uid';
|
||||
|
||||
export interface MultipartFormEntry {
|
||||
@@ -8,6 +9,7 @@ export interface MultipartFormEntry {
|
||||
description?: string | null;
|
||||
contentType?: string | null;
|
||||
enabled?: boolean;
|
||||
annotations?: Annotation[];
|
||||
}
|
||||
|
||||
export type MultipartForm = MultipartFormEntry[];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Annotation } from './annotation';
|
||||
import type { UID } from './uid';
|
||||
|
||||
/**
|
||||
@@ -10,6 +11,7 @@ export interface Variable {
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
local?: boolean;
|
||||
annotations?: Annotation[] | null;
|
||||
}
|
||||
|
||||
export type Variables = Variable[] | null;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const { itemSchema, environmentSchema, collectionSchema } = require('./index');
|
||||
|
||||
describe('annotation acceptance', () => {
|
||||
test('itemSchema accepts annotations on headers and params', async () => {
|
||||
const item = {
|
||||
uid: 'aaaaaaaaaaaaaaaaaaaaa',
|
||||
type: 'http-request',
|
||||
name: 'Req',
|
||||
request: {
|
||||
url: 'https://example.com',
|
||||
method: 'GET',
|
||||
headers: [
|
||||
{ uid: 'bbbbbbbbbbbbbbbbbbbbb', name: 'X-Test', value: '1', annotations: [{ name: 'note', value: 'header note' }] }
|
||||
],
|
||||
params: [
|
||||
{ uid: 'ccccccccccccccccccccc', name: 'q', value: '1', type: 'query', annotations: [{ name: 'hint' }] }
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await expect(itemSchema.validate(item)).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test('environmentSchema accepts annotations on variables', async () => {
|
||||
const env = {
|
||||
uid: 'ddddddddddddddddddddd',
|
||||
name: 'Env',
|
||||
variables: [
|
||||
{ uid: 'eeeeeeeeeeeeeeeeeeeee', name: 'API_KEY', value: 'abc', annotations: [{ name: 'secret', value: null }], type: 'text', enabled: true, secret: false }
|
||||
]
|
||||
};
|
||||
|
||||
await expect(environmentSchema.validate(env)).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test('collectionSchema accepts annotations in item vars and items', async () => {
|
||||
const coll = {
|
||||
version: '1',
|
||||
uid: 'fffffffffffffffffffff',
|
||||
name: 'Coll',
|
||||
items: [
|
||||
{
|
||||
uid: 'ggggggggggggggggggggg',
|
||||
type: 'http-request',
|
||||
name: 'Req2',
|
||||
request: { url: '/path', method: 'POST', headers: [], params: [], vars: { req: [{ uid: 'hhhhhhhhhhhhhhhhhhhhh', name: 'base', value: 'https://example.com', annotations: [{ name: 'base-note' }] }] } }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await expect(collectionSchema.validate(coll)).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,22 @@
|
||||
const Yup = require('yup');
|
||||
const { uidSchema } = require('../common');
|
||||
|
||||
const annotationSchema = Yup.object({
|
||||
name: Yup.string().min(1).required('annotation name is required'),
|
||||
value: Yup.string().nullable()
|
||||
}).noUnknown(true)
|
||||
.strict();
|
||||
|
||||
const environmentVariablesSchema = Yup.object({
|
||||
uid: uidSchema,
|
||||
name: Yup.string().nullable(),
|
||||
// Allow mixed types (string, number, boolean, object) to support setting non-string values via scripts.
|
||||
value: Yup.mixed().nullable(),
|
||||
annotations: Yup.array()
|
||||
.of(
|
||||
annotationSchema
|
||||
)
|
||||
.nullable(),
|
||||
type: Yup.string().oneOf(['text']).required('type is required'),
|
||||
enabled: Yup.boolean().defined(),
|
||||
secret: Yup.boolean()
|
||||
@@ -29,6 +40,11 @@ const keyValueSchema = Yup.object({
|
||||
name: Yup.string().nullable(),
|
||||
value: Yup.string().nullable(),
|
||||
description: Yup.string().nullable(),
|
||||
annotations: Yup.array()
|
||||
.of(
|
||||
annotationSchema
|
||||
)
|
||||
.nullable(),
|
||||
enabled: Yup.boolean()
|
||||
})
|
||||
.noUnknown(true)
|
||||
@@ -79,6 +95,12 @@ const varsSchema = Yup.object({
|
||||
name: Yup.string().nullable(),
|
||||
value: Yup.string().nullable(),
|
||||
description: Yup.string().nullable(),
|
||||
// Optional annotations on variables
|
||||
annotations: Yup.array()
|
||||
.of(
|
||||
annotationSchema
|
||||
)
|
||||
.nullable(),
|
||||
enabled: Yup.boolean(),
|
||||
|
||||
// todo
|
||||
@@ -109,6 +131,17 @@ const multipartFormSchema = Yup.object({
|
||||
then: Yup.array().of(Yup.string().nullable()).nullable(),
|
||||
otherwise: Yup.string().nullable()
|
||||
}),
|
||||
// Optional annotations on multipart entries
|
||||
annotations: Yup.array()
|
||||
.of(
|
||||
Yup.object({
|
||||
name: Yup.string().min(1).required('annotation name is required'),
|
||||
value: Yup.string().nullable()
|
||||
})
|
||||
.noUnknown(true)
|
||||
.strict()
|
||||
)
|
||||
.nullable(),
|
||||
description: Yup.string().nullable(),
|
||||
contentType: Yup.string().nullable(),
|
||||
enabled: Yup.boolean()
|
||||
@@ -126,6 +159,16 @@ const fileSchema = Yup.object({
|
||||
.noUnknown(true)
|
||||
.strict();
|
||||
|
||||
// Add annotations to file entries (when parsed from body:file blocks they can have @contentType only currently,
|
||||
// but adding annotations ensures roundtrip validation doesn't fail if annotations are present in future)
|
||||
const fileSchemaWithAnnotations = fileSchema.shape({
|
||||
annotations: Yup.array()
|
||||
.of(
|
||||
annotationSchema
|
||||
)
|
||||
.nullable()
|
||||
});
|
||||
|
||||
const requestBodySchema = Yup.object({
|
||||
mode: Yup.string()
|
||||
.oneOf(['none', 'json', 'text', 'xml', 'formUrlEncoded', 'multipartForm', 'graphql', 'sparql', 'file'])
|
||||
@@ -137,7 +180,7 @@ const requestBodySchema = Yup.object({
|
||||
formUrlEncoded: Yup.array().of(keyValueSchema).nullable(),
|
||||
multipartForm: Yup.array().of(multipartFormSchema).nullable(),
|
||||
graphql: graphqlBodySchema.nullable(),
|
||||
file: Yup.array().of(fileSchema).nullable()
|
||||
file: Yup.array().of(fileSchemaWithAnnotations).nullable()
|
||||
})
|
||||
.noUnknown(true)
|
||||
.strict();
|
||||
@@ -378,6 +421,12 @@ const requestParamsSchema = Yup.object({
|
||||
name: Yup.string().nullable(),
|
||||
value: Yup.string().nullable(),
|
||||
description: Yup.string().nullable(),
|
||||
// Optional annotations on params
|
||||
annotations: Yup.array()
|
||||
.of(
|
||||
annotationSchema
|
||||
)
|
||||
.nullable(),
|
||||
type: Yup.string().oneOf(['query', 'path']).required('type is required'),
|
||||
enabled: Yup.boolean()
|
||||
})
|
||||
@@ -649,5 +698,6 @@ module.exports = {
|
||||
itemSchema,
|
||||
environmentSchema,
|
||||
environmentsSchema,
|
||||
collectionSchema
|
||||
collectionSchema,
|
||||
annotationSchema
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user