diff --git a/package-lock.json b/package-lock.json index e34e6f213..577b1d843 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32959,9 +32959,14 @@ "axios": "^1.9.0" }, "devDependencies": { + "@babel/preset-env": "^7.22.0", + "@babel/preset-typescript": "^7.22.0", "@rollup/plugin-commonjs": "^23.0.2", "@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-typescript": "^9.0.2", + "@types/jest": "^29.5.11", + "babel-jest": "^29.7.0", + "jest": "^29.2.0", "rollup": "3.29.5", "rollup-plugin-dts": "^5.0.0", "rollup-plugin-peer-deps-external": "^2.2.4", diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js index 1f91c86e5..a83535300 100644 --- a/packages/bruno-app/src/components/CodeEditor/index.js +++ b/packages/bruno-app/src/components/CodeEditor/index.js @@ -187,17 +187,17 @@ export default class CodeEditor extends React.Component { editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false); editor.on('change', this._onEdit); this.addOverlay(); + + const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item); // Setup AutoComplete Helper for all modes const autoCompleteOptions = { - showHintsFor: this.props.showHintsFor + showHintsFor: this.props.showHintsFor, + getAllVariables: getAllVariablesHandler }; - const getVariables = () => getAllVariables(this.props.collection, this.props.item); - this.brunoAutoCompleteCleanup = setupAutoComplete( editor, - getVariables, autoCompleteOptions ); } diff --git a/packages/bruno-app/src/components/MultiLineEditor/index.js b/packages/bruno-app/src/components/MultiLineEditor/index.js index e73e506e1..bd4fc60fe 100644 --- a/packages/bruno-app/src/components/MultiLineEditor/index.js +++ b/packages/bruno-app/src/components/MultiLineEditor/index.js @@ -74,18 +74,20 @@ class MultiLineEditor extends Component { 'Shift-Tab': false } }); + + + const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item); + const getAnywordAutocompleteHints = () => this.props.autocomplete || []; // Setup AutoComplete Helper const autoCompleteOptions = { showHintsFor: ['variables'], - anywordAutocompleteHints: this.props.autocomplete + getAllVariables: getAllVariablesHandler, + getAnywordAutocompleteHints }; - const getVariables = () => getAllVariables(this.props.collection, this.props.item); - this.brunoAutoCompleteCleanup = setupAutoComplete( this.editor, - getVariables, autoCompleteOptions ); diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js index 34558d928..da48bb34a 100644 --- a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js @@ -19,6 +19,7 @@ import StyledWrapper from './StyledWrapper'; import Documentation from 'components/Documentation/index'; import GraphQLSchemaActions from '../GraphQLSchemaActions/index'; import HeightBoundContainer from 'ui/HeightBoundContainer'; +import Settings from 'components/RequestPane/Settings'; const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handleGqlClickReference }) => { const dispatch = useDispatch(); @@ -101,6 +102,9 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle case 'docs': { return ; } + case 'settings': { + return ; + } default: { return
404 | Not found
; } @@ -152,6 +156,9 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
selectTab('docs')}> Docs
+
selectTab('settings')}> + Settings +
diff --git a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js index 2a2acbb21..1ca7d39c6 100644 --- a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js @@ -101,6 +101,7 @@ const HttpRequestPane = ({ item, collection }) => { const requestVars = getPropertyFromDraftOrRequest('request.vars.req'); const responseVars = getPropertyFromDraftOrRequest('request.vars.res'); const auth = getPropertyFromDraftOrRequest('request.auth'); + const tags = getPropertyFromDraftOrRequest('tags'); const activeParamsLength = params.filter((param) => param.enabled).length; const activeHeadersLength = headers.filter((header) => header.enabled).length; @@ -164,6 +165,7 @@ const HttpRequestPane = ({ item, collection }) => {
selectTab('settings')}> Settings + {tags && tags.length > 0 && }
{focusedTab.requestPaneTab === 'body' ? (
diff --git a/packages/bruno-app/src/components/RequestPane/Settings/Tags/index.js b/packages/bruno-app/src/components/RequestPane/Settings/Tags/index.js new file mode 100644 index 000000000..006a9894f --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/Settings/Tags/index.js @@ -0,0 +1,63 @@ +import React, { useCallback, useEffect } from 'react'; +import get from 'lodash/get'; +import { useDispatch } from 'react-redux'; +import { addRequestTag, deleteRequestTag, updateCollectionTagsList } from 'providers/ReduxStore/slices/collections'; +import TagList from 'components/TagList/index'; +import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; + +const Tags = ({ item, collection }) => { + const dispatch = useDispatch(); + // all tags in the collection + const collectionTags = collection.allTags || []; + + // tags for the current request + const tags = item.draft ? get(item, 'draft.tags', []) : get(item, 'tags', []); + + // Filter out tags that are already associated with the current request + const collectionTagsWithoutCurrentRequestTags = collectionTags?.filter(tag => !tags.includes(tag)) || []; + + const handleAdd = useCallback((tag) => { + const trimmedTag = tag.trim(); + if (trimmedTag && !tags.includes(trimmedTag)) { + dispatch( + addRequestTag({ + tag: trimmedTag, + itemUid: item.uid, + collectionUid: collection.uid + }) + ); + } + }, [dispatch, tags, item.uid, collection.uid]); + + const handleRemove = useCallback((tag) => { + dispatch( + deleteRequestTag({ + tag, + itemUid: item.uid, + collectionUid: collection.uid + }) + ); + }, [dispatch, item.uid, collection.uid]); + + const handleRequestSave = () => { + dispatch(saveRequest(item.uid, collection.uid)); + } + + useEffect(() => { + dispatch(updateCollectionTagsList({ collectionUid: collection.uid })); + }, [collection.uid, dispatch]); + + return ( +
+ +
+ ); +}; + +export default Tags; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RequestPane/Settings/index.js b/packages/bruno-app/src/components/RequestPane/Settings/index.js index 97caaf1af..df570085d 100644 --- a/packages/bruno-app/src/components/RequestPane/Settings/index.js +++ b/packages/bruno-app/src/components/RequestPane/Settings/index.js @@ -1,8 +1,10 @@ -import React, { useState, useCallback } from 'react'; +import React, { useCallback } from 'react'; import { useDispatch } from 'react-redux'; import get from 'lodash/get'; +import { IconTag } from '@tabler/icons'; import ToggleSelector from 'components/RequestPane/Settings/ToggleSelector'; import { updateItemSettings } from 'providers/ReduxStore/slices/collections'; +import Tags from './Tags/index'; const Settings = ({ item, collection }) => { const dispatch = useDispatch(); @@ -22,7 +24,16 @@ const Settings = ({ item, collection }) => { }, [encodeUrl, dispatch, collection.uid, item.uid]); return ( -
+
+
+

+ + Tags +

+
+ +
+
{ + const dispatch = useDispatch(); + const collections = useSelector((state) => state.collections.collections); + const collection = cloneDeep(find(collections, (c) => c.uid === collectionUid)); + + // tags for the collection run + const tags = get(collection, 'runnerTags', { include: [], exclude: [] }); + + // have tags been enabled for the collection run + const tagsEnabled = get(collection, 'runnerTagsEnabled', false); + + // all available tags in the collection that can be used for filtering + const availableTags = get(collection, 'allTags', []); + const tagsHintList = availableTags.filter(t => !tags.exclude.includes(t) && !tags.include.includes(t)); + + useEffect(() => { + dispatch(updateCollectionTagsList({ collectionUid })); + }, [collection.uid, dispatch]); + + const handleValidation = (tag) => { + const trimmedTag = tag.trim(); + if (!availableTags.includes(trimmedTag)) { + return 'tag does not exist!'; + } + if (tags.include.includes(trimmedTag)) { + return 'tag already present in the include list!'; + } + if (tags.exclude.includes(trimmedTag)) { + return 'tag is present in the exclude list!'; + } + } + + const handleAddTag = ({ tag, to }) => { + const trimmedTag = tag.trim(); + if (!trimmedTag) return; + // add tag to the `include` list + if (to === 'include') { + if (tags.include.includes(trimmedTag) || tags.exclude.includes(trimmedTag)) return; + if (!availableTags.includes(trimmedTag)) { + return; + } + const newTags = { ...tags, include: [...tags.include, trimmedTag].sort() }; + setTags(newTags); + return; + } + // add tag to the `exclude` list + if (to === 'exclude') { + if (tags.include.includes(trimmedTag) || tags.exclude.includes(trimmedTag)) return; + if (!availableTags.includes(trimmedTag)) { + return; + } + const newTags = { ...tags, exclude: [...tags.exclude, trimmedTag].sort() }; + setTags(newTags); + } + }; + + const handleRemoveTag = ({ tag, from }) => { + const trimmedTag = tag.trim(); + if (!trimmedTag) return; + // remove tag from the `include` list + if (from === 'include') { + if (!tags.include.includes(trimmedTag)) return; + const newTags = { ...tags, include: tags.include.filter((t) => t !== trimmedTag) }; + setTags(newTags); + return; + } + // remove tag from the `exclude` list + if (from === 'exclude') { + if (!tags.exclude.includes(trimmedTag)) return; + const newTags = { ...tags, exclude: tags.exclude.filter((t) => t !== trimmedTag) }; + setTags(newTags); + } + }; + + const setTags = (tags) => { + dispatch(updateRunnerTagsDetails({ collectionUid: collection.uid, tags })); + }; + + const setTagsEnabled = (tagsEnabled) => { + dispatch(updateRunnerTagsDetails({ collectionUid: collection.uid, tagsEnabled })); + }; + + return ( +
+
+ + setTagsEnabled(!tagsEnabled)} + /> +
+ {tagsEnabled && ( +
+
+ Included tags: + handleAddTag({ tag, to: 'include' })} + handleRemoveTag={tag => handleRemoveTag({ tag, from: 'include' })} + tagsHintList={tagsHintList} + handleValidation={handleValidation} + /> +
+
+ Excluded tags: + handleAddTag({ tag, to: 'exclude' })} + handleRemoveTag={tag => handleRemoveTag({ tag, from: 'exclude' })} + tagsHintList={tagsHintList} + handleValidation={handleValidation} + /> +
+
+ )} +
+ ) +} + +export default RunnerTags; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RunnerResults/index.jsx b/packages/bruno-app/src/components/RunnerResults/index.jsx index c4945c7aa..b4fd9274a 100644 --- a/packages/bruno-app/src/components/RunnerResults/index.jsx +++ b/packages/bruno-app/src/components/RunnerResults/index.jsx @@ -9,6 +9,7 @@ import { IconRefresh, IconCircleCheck, IconCircleX, IconCircleOff, IconCheck, Ic import ResponsePane from './ResponsePane'; import StyledWrapper from './StyledWrapper'; import { areItemsLoading } from 'utils/collections'; +import RunnerTags from './RunnerTags/index'; const getDisplayName = (fullPath, pathname, name = '') => { let relativePath = path.relative(fullPath, pathname); @@ -63,6 +64,15 @@ export default function RunnerResults({ collection }) { const collectionCopy = cloneDeep(collection); const runnerInfo = get(collection, 'runnerResult.info', {}); + // tags for the collection run + const tags = get(collection, 'runnerTags', { include: [], exclude: [] }); + + // have tags been enabled for the collection run + const tagsEnabled = get(collection, 'runnerTagsEnabled', false); + + // have tags been added for the collection run + const areTagsAdded = tags.include.length > 0 || tags.exclude.length > 0; + const items = cloneDeep(get(collection, 'runnerResult.items', [])) .map((item) => { const info = findItemInCollection(collectionCopy, item.uid); @@ -75,7 +85,8 @@ export default function RunnerResults({ collection }) { type: info.type, filename: info.filename, pathname: info.pathname, - displayName: getDisplayName(collection.pathname, info.pathname, info.name) + displayName: getDisplayName(collection.pathname, info.pathname, info.name), + tags: [...(info.request?.tags || [])].sort(), }; if (newItem.status !== 'error' && newItem.status !== 'skipped') { newItem.testStatus = getTestStatus(newItem.testResults); @@ -88,11 +99,19 @@ export default function RunnerResults({ collection }) { .filter(Boolean); const runCollection = () => { - dispatch(runCollectionFolder(collection.uid, null, true, Number(delay))); + dispatch(runCollectionFolder(collection.uid, null, true, Number(delay), tagsEnabled && tags)); }; const runAgain = () => { - dispatch(runCollectionFolder(collection.uid, runnerInfo.folderUid, runnerInfo.isRecursive, Number(delay))); + dispatch( + runCollectionFolder( + collection.uid, + runnerInfo.folderUid, + runnerInfo.isRecursive, + Number(delay), + tagsEnabled && tags + ) + ); }; const resetRunner = () => { @@ -141,6 +160,9 @@ export default function RunnerResults({ collection }) { />
+ {/* Tags for the collection run */} + + @@ -174,11 +196,25 @@ export default function RunnerResults({ collection }) { Total Requests: {items.length}, Passed: {passedRequests.length}, Failed: {failedRequests.length}, Skipped:{' '} {skippedRequests.length}
+ {tagsEnabled && areTagsAdded && ( +
+ Tags: +
+
+ {tags.include.join(', ')} +
+
+ {tags.exclude.join(', ')} +
+
+
+ )} {runnerInfo?.statusText ?
{runnerInfo?.statusText}
: null} + {items.map((item) => { return (
@@ -214,6 +250,11 @@ export default function RunnerResults({ collection }) { )}
+ {tagsEnabled && areTagsAdded && item?.tags?.length > 0 && ( +
+ Tags: {item.tags.filter(t => tags.include.includes(t)).join(', ')} +
+ )} {item.status == 'error' ?
{item.error}
: null}
    diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js index f56d408b0..1bdb2fcf5 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js @@ -8,6 +8,7 @@ import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/act import { flattenItems } from 'utils/collections'; import StyledWrapper from './StyledWrapper'; import { areItemsLoading } from 'utils/collections'; +import RunnerTags from 'components/RunnerResults/RunnerTags/index'; const RunCollectionItem = ({ collectionUid, item, onClose }) => { const dispatch = useDispatch(); @@ -15,6 +16,12 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => { const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid)); const isCollectionRunInProgress = collection?.runnerResult?.info?.status && (collection?.runnerResult?.info?.status !== 'ended'); + // tags for the collection run + const tags = get(collection, 'runnerTags', { include: [], exclude: [] }); + + // have tags been enabled for the collection run + const tagsEnabled = get(collection, 'runnerTagsEnabled', false); + const onSubmit = (recursive) => { dispatch( addTab({ @@ -24,7 +31,7 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => { }) ); if (!isCollectionRunInProgress) { - dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive)); + dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive, 0, tagsEnabled && tags)); } onClose(); }; @@ -71,6 +78,10 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => {
    This will run all the requests in this folder and all its subfolders.
    {isFolderLoading ?
    Requests in this folder are still loading.
    : null} {isCollectionRunInProgress ?
    A Collection Run is already in progress.
    : null} + + {/* Tags for the collection run */} + +
    + + )) + : null} +
+ + ); +}; + +export default TagList; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 4749958e3..2df8ee03b 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -316,7 +316,7 @@ export const cancelRunnerExecution = (cancelTokenUid) => (dispatch) => { cancelNetworkRequest(cancelTokenUid).catch((err) => console.log(err)); }; -export const runCollectionFolder = (collectionUid, folderUid, recursive, delay) => (dispatch, getState) => { +export const runCollectionFolder = (collectionUid, folderUid, recursive, delay, tags) => (dispatch, getState) => { const state = getState(); const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments; const collection = findCollectionByUid(state.collections.collections, collectionUid); @@ -355,7 +355,8 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay) environment, collectionCopy.runtimeVariables, recursive, - delay + delay, + tags ) .then(resolve) .catch((err) => { diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index fa3c28aee..44db21df4 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -21,6 +21,7 @@ import { getSubdirectoriesFromRoot } from 'utils/common/platform'; import toast from 'react-hot-toast'; import mime from 'mime-types'; import path from 'utils/common/path'; +import { getUniqueTagsFromItems } from 'utils/collections/index'; const initialState = { collections: [], @@ -37,6 +38,7 @@ export const collectionsSlice = createSlice({ collection.settingsSelectedTab = 'overview'; collection.folderLevelSettingsSelectedTab = {}; + collection.allTags = []; // Initialize collection-level tags // Collection mount status is used to track the mount status of the collection // values can be 'unmounted', 'mounting', 'mounted' @@ -1861,6 +1863,7 @@ export const collectionsSlice = createSlice({ currentItem.name = file.data.name; currentItem.type = file.data.type; currentItem.seq = file.data.seq; + currentItem.tags = file.data.tags; currentItem.request = file.data.request; currentItem.filename = file.meta.name; currentItem.pathname = file.meta.pathname; @@ -1876,6 +1879,7 @@ export const collectionsSlice = createSlice({ name: file.data.name, type: file.data.type, seq: file.data.seq, + tags: file.data.tags, request: file.data.request, settings: file.data.settings, filename: file.meta.name, @@ -1966,6 +1970,7 @@ export const collectionsSlice = createSlice({ item.name = file.data.name; item.type = file.data.type; item.seq = file.data.seq; + item.tags = file.data.tags; item.request = file.data.request; item.settings = file.data.settings; item.filename = file.meta.name; @@ -2225,6 +2230,20 @@ export const collectionsSlice = createSlice({ if (collection) { collection.runnerResult = null; + collection.runnerTags = { include: [], exclude: [] } + collection.runnerTagsEnabled = false; + } + }, + updateRunnerTagsDetails: (state, action) => { + const { collectionUid, tags, tagsEnabled } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + if (collection) { + if (tags) { + collection.runnerTags = tags; + } + if (typeof tagsEnabled === 'boolean') { + collection.runnerTagsEnabled = tagsEnabled; + } } }, updateRequestDocs: (state, action) => { @@ -2340,9 +2359,55 @@ export const collectionsSlice = createSlice({ set(folder, 'root.request.auth', {}); set(folder, 'root.request.auth.mode', action.payload.mode); } - } - }, + }, + addRequestTag: (state, action) => { + const { tag, collectionUid, itemUid } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + + if (collection) { + const item = findItemInCollection(collection, itemUid); + + if (item && isItemARequest(item)) { + if (!item.draft) { + item.draft = cloneDeep(item); + } + item.draft.tags = item.draft.tags || []; + if (!item.draft.tags.includes(tag.trim())) { + item.draft.tags.push(tag.trim()); + } + + collection.allTags = getUniqueTagsFromItems(collection.items); + } + } + }, + deleteRequestTag: (state, action) => { + const { tag, collectionUid, itemUid } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + + if (collection) { + const item = findItemInCollection(collection, itemUid); + + if (item && isItemARequest(item)) { + if (!item.draft) { + item.draft = cloneDeep(item); + } + item.draft.tags = item.draft.tags || []; + item.draft.tags = item.draft.tags.filter((t) => t !== tag.trim()); + + collection.allTags = getUniqueTagsFromItems(collection.items); + } + } + }, + updateCollectionTagsList: (state, action) => { + const { collectionUid } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + + if (collection) { + collection.allTags = getUniqueTagsFromItems(collection.items); + } + } + } }); export const { @@ -2450,6 +2515,7 @@ export const { runRequestEvent, runFolderEvent, resetCollectionRunner, + updateRunnerTagsDetails, updateRequestDocs, updateFolderDocs, moveCollection, @@ -2458,6 +2524,9 @@ export const { collectionGetOauth2CredentialsByUrl, updateFolderAuth, updateFolderAuthMode, + addRequestTag, + deleteRequestTag, + updateCollectionTagsList } = collectionsSlice.actions; export default collectionsSlice.reducer; diff --git a/packages/bruno-app/src/utils/codemirror/autocomplete.js b/packages/bruno-app/src/utils/codemirror/autocomplete.js index 93ad76493..7b65f4234 100644 --- a/packages/bruno-app/src/utils/codemirror/autocomplete.js +++ b/packages/bruno-app/src/utils/codemirror/autocomplete.js @@ -178,11 +178,11 @@ const addVariableHintsToSet = (variableHints, allVariables) => { /** * Add custom hints to categorized hints * @param {Set} anywordHints - Set to add custom hints to - * @param {Object} options - Options containing custom hints + * @param {string[]} customHints - Array of custom hints */ -const addCustomHintsToSet = (anywordHints, options) => { - if (options.anywordAutocompleteHints && Array.isArray(options.anywordAutocompleteHints)) { - options.anywordAutocompleteHints.forEach(hint => { +const addCustomHintsToSet = (anywordHints, customHints) => { + if (customHints && Array.isArray(customHints)) { + customHints.forEach(hint => { generateProgressiveHints(hint).forEach(h => anywordHints.add(h)); }); } @@ -191,10 +191,11 @@ const addCustomHintsToSet = (anywordHints, options) => { /** * Build categorized hints list from all sources * @param {Object} allVariables - All available variables + * @param {string[]} anywordAutocompleteHints - Custom autocomplete hints * @param {Object} options - Configuration options * @returns {Object} Categorized hints object */ -const buildCategorizedHintsList = (allVariables = {}, options = {}) => { +const buildCategorizedHintsList = (allVariables = {}, anywordAutocompleteHints = [], options = {}) => { const categorizedHints = { api: new Set(), variables: new Set(), @@ -206,7 +207,7 @@ const buildCategorizedHintsList = (allVariables = {}, options = {}) => { // Add different types of hints addApiHintsToSet(categorizedHints.api, showHintsFor); addVariableHintsToSet(categorizedHints.variables, allVariables); - addCustomHintsToSet(categorizedHints.anyword, options); + addCustomHintsToSet(categorizedHints.anyword, anywordAutocompleteHints); return { api: Array.from(categorizedHints.api).sort(), @@ -499,10 +500,11 @@ const createStandardHintList = (filteredHints, from, to) => { * Bruno AutoComplete Helper - Main function with context awareness * @param {Object} cm - CodeMirror instance * @param {Object} allVariables - All available variables + * @param {string[]} anywordAutocompleteHints - Custom autocomplete hints * @param {Object} options - Configuration options * @returns {Object|null} Hint object or null */ -export const getAutoCompleteHints = (cm, allVariables = {}, options = {}) => { +export const getAutoCompleteHints = (cm, allVariables = {}, anywordAutocompleteHints = [], options = {}) => { if (!allVariables) { return null; } @@ -513,14 +515,14 @@ export const getAutoCompleteHints = (cm, allVariables = {}, options = {}) => { } const { word, from, to, context, requiresBraces } = wordInfo; - const showHintsFor = options.showHintsFor || []; + const showHintsFor = options.showHintsFor || []; // Check if this context requires braces but we're not in a brace context if (context === 'variables' && !requiresBraces) { return null; } - const categorizedHints = buildCategorizedHintsList(allVariables, options); + const categorizedHints = buildCategorizedHintsList(allVariables, anywordAutocompleteHints, options); const filteredHints = filterHintsByContext(categorizedHints, word, context, showHintsFor); if (filteredHints.length === 0) { @@ -534,21 +536,75 @@ export const getAutoCompleteHints = (cm, allVariables = {}, options = {}) => { return createStandardHintList(filteredHints, from, to); }; +/** + * Handle click events for autocomplete + * @param {Object} cm - CodeMirror instance + * @param {Object} options - Configuration options + */ +const handleClickForAutocomplete = (cm, options) => { + const allVariables = options.getAllVariables?.() || {}; + const anywordAutocompleteHints = options.getAnywordAutocompleteHints?.() || []; + const showHintsFor = options.showHintsFor || []; + + // Build all available hints + const categorizedHints = buildCategorizedHintsList(allVariables, anywordAutocompleteHints, options); + + // Combine all hints based on showHintsFor configuration + let allHints = []; + + // Add API hints if enabled + const hasApiHints = showHintsFor.some(hint => ['req', 'res', 'bru'].includes(hint)); + if (hasApiHints) { + allHints = [...allHints, ...categorizedHints.api]; + } + + // Add variable hints if enabled + if (showHintsFor.includes('variables')) { + allHints = [...allHints, ...categorizedHints.variables]; + } + + // Add anyword hints (always included) + allHints = [...allHints, ...categorizedHints.anyword]; + + // Remove duplicates and sort + allHints = [...new Set(allHints)].sort(); + + if (allHints.length === 0) { + return; + } + + const cursor = cm.getCursor(); + + if (cursor.ch > 0) return; + + // Defer showHint to ensure editor is focused + setTimeout(() => { + cm.showHint({ + hint: () => ({ + list: allHints, + from: cursor, + to: cursor + }), + completeSingle: false + }); + }, 0); +}; + /** * Handle keyup events for autocomplete * @param {Object} cm - CodeMirror instance * @param {Event} event - The keyup event - * @param {Function} getAllVariablesFunc - Function to get all variables * @param {Object} options - Configuration options */ -const handleKeyupForAutocomplete = (cm, event, getAllVariablesFunc, options) => { +const handleKeyupForAutocomplete = (cm, event, options) => { // Skip non-character keys if (!NON_CHARACTER_KEYS.test(event?.key)) { return; } - const allVariables = getAllVariablesFunc(); - const hints = getAutoCompleteHints(cm, allVariables, options); + const allVariables = options.getAllVariables?.() || {}; + const anywordAutocompleteHints = options.getAnywordAutocompleteHints?.() || []; + const hints = getAutoCompleteHints(cm, allVariables, anywordAutocompleteHints, options); if (!hints) { if (cm.state.completionActive) { @@ -566,23 +622,37 @@ const handleKeyupForAutocomplete = (cm, event, getAllVariablesFunc, options) => /** * Setup Bruno AutoComplete Helper on a CodeMirror editor * @param {Object} editor - CodeMirror editor instance - * @param {Function} getAllVariablesFunc - Function to get all variables * @param {Object} options - Configuration options * @returns {Function} Cleanup function */ -export const setupAutoComplete = (editor, getAllVariablesFunc, options = {}) => { +export const setupAutoComplete = (editor, options = {}) => { if (!editor) { return; } const keyupHandler = (cm, event) => { - handleKeyupForAutocomplete(cm, event, getAllVariablesFunc, options); + handleKeyupForAutocomplete(cm, event, options); }; editor.on('keyup', keyupHandler); + + const clickHandler = (cm) => { + // Only show hints on click if the option is enabled and there's no active completion + if (options.showHintsOnClick) { + handleClickForAutocomplete(cm, options); + } + }; + + // Add click handler if showHintsOnClick is enabled + if (options.showHintsOnClick) { + editor.on('mousedown', clickHandler); + } return () => { editor.off('keyup', keyupHandler); + if (options.showHintsOnClick) { + editor.off('mousedown', clickHandler); + } }; }; diff --git a/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js b/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js index a14f05917..5a8d984c1 100644 --- a/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js +++ b/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js @@ -43,7 +43,7 @@ describe('Bruno Autocomplete', () => { envVar2: 'value2', }; - const result = getAutoCompleteHints(mockedCodemirror, allVariables, { + const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], { showHintsFor: ['variables'] }); @@ -60,7 +60,7 @@ describe('Bruno Autocomplete', () => { mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 9 }); mockedCodemirror.getRange.mockReturnValue('{{$randomI'); - const result = getAutoCompleteHints(mockedCodemirror, {}, { + const result = getAutoCompleteHints(mockedCodemirror, {}, [], { showHintsFor: ['variables'] }); @@ -84,7 +84,7 @@ describe('Bruno Autocomplete', () => { mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 14 }); mockedCodemirror.getRange.mockReturnValue('{{process.env.N'); - const result = getAutoCompleteHints(mockedCodemirror, allVariables, { + const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], { showHintsFor: ['variables'] }); @@ -106,7 +106,7 @@ describe('Bruno Autocomplete', () => { path: 'value' }; - const result = getAutoCompleteHints(mockedCodemirror, allVariables, { + const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], { showHintsFor: ['variables'] }); @@ -134,7 +134,7 @@ describe('Bruno Autocomplete', () => { 'config.app.name': 'bruno' }; - const result = getAutoCompleteHints(mockedCodemirror, allVariables, { + const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], { showHintsFor: ['variables'] }); @@ -174,7 +174,7 @@ describe('Bruno Autocomplete', () => { mockedCodemirror.getLine.mockReturnValue(input); mockedCodemirror.getRange.mockReturnValue(input); - const result = getAutoCompleteHints(mockedCodemirror, {}, { + const result = getAutoCompleteHints(mockedCodemirror, {}, [], { showHintsFor: ['req', 'res', 'bru'] }); @@ -188,7 +188,7 @@ describe('Bruno Autocomplete', () => { mockedCodemirror.getLine.mockReturnValue('req.get'); mockedCodemirror.getRange.mockReturnValue('req.get'); - const result = getAutoCompleteHints(mockedCodemirror, {}, { + const result = getAutoCompleteHints(mockedCodemirror, {}, [], { showHintsFor: ['req'] }); @@ -213,7 +213,7 @@ describe('Bruno Autocomplete', () => { mockedCodemirror.getLine.mockReturnValue('bru.runner.'); mockedCodemirror.getRange.mockReturnValue('bru.runner.'); - const result = getAutoCompleteHints(mockedCodemirror, {}, { + const result = getAutoCompleteHints(mockedCodemirror, {}, [], { showHintsFor: ['bru'] }); @@ -234,11 +234,9 @@ describe('Bruno Autocomplete', () => { mockedCodemirror.getLine.mockReturnValue('Content-'); mockedCodemirror.getRange.mockReturnValue('Content-'); - const options = { - anywordAutocompleteHints: ['Content-Type', 'Content-Encoding', 'Content-Length'] - }; + const customHints = ['Content-Type', 'Content-Encoding', 'Content-Length']; - const result = getAutoCompleteHints(mockedCodemirror, {}, options, { + const result = getAutoCompleteHints(mockedCodemirror, {}, customHints, { showHintsFor: ['variables'] }); @@ -253,11 +251,9 @@ describe('Bruno Autocomplete', () => { mockedCodemirror.getLine.mockReturnValue('utils.'); mockedCodemirror.getRange.mockReturnValue('utils.'); - const options = { - anywordAutocompleteHints: ['utils.string.trim', 'utils.string.capitalize', 'utils.array.map'] - }; + const customHints = ['utils.string.trim', 'utils.string.capitalize', 'utils.array.map']; - const result = getAutoCompleteHints(mockedCodemirror, {}, options, { + const result = getAutoCompleteHints(mockedCodemirror, {}, customHints, { showHintsFor: ['variables'] }); @@ -277,18 +273,14 @@ describe('Bruno Autocomplete', () => { it('should respect showHintsFor option for excluding hints', () => { const options = { showHintsFor: ['res', 'bru'] }; - const result = getAutoCompleteHints(mockedCodemirror, {}, options, { - showHintsFor: ['req'] - }); + const result = getAutoCompleteHints(mockedCodemirror, {}, [], options); expect(result).toBeNull(); }); it('should show hints when included in showHintsFor', () => { const options = { showHintsFor: ['req'] }; - const result = getAutoCompleteHints(mockedCodemirror, {}, options, { - showHintsFor: ['req'] - }); + const result = getAutoCompleteHints(mockedCodemirror, {}, [], options); expect(result).toBeTruthy(); expect(result.list).toEqual( @@ -303,7 +295,7 @@ describe('Bruno Autocomplete', () => { const allVariables = { envVar1: 'value1' }; const options = { showHintsFor: ['req', 'res', 'bru'] }; - const result = getAutoCompleteHints(mockedCodemirror, allVariables, options); + const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], options); expect(result).toBeNull(); }); @@ -318,7 +310,7 @@ describe('Bruno Autocomplete', () => { allVariables[`var${i}`] = `value${i}`; } - const result = getAutoCompleteHints(mockedCodemirror, allVariables, { + const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], { showHintsFor: ['variables'] }); @@ -337,7 +329,7 @@ describe('Bruno Autocomplete', () => { 'v.banana': 'value3' }; - const result = getAutoCompleteHints(mockedCodemirror, allVariables, { + const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], { showHintsFor: ['variables'] }); @@ -357,7 +349,7 @@ describe('Bruno Autocomplete', () => { mockedCodemirror.getLine.mockReturnValue(' '); mockedCodemirror.getRange.mockReturnValue(''); - const result = getAutoCompleteHints(mockedCodemirror, {}); + const result = getAutoCompleteHints(mockedCodemirror, {}, []); expect(result).toBeNull(); }); @@ -367,8 +359,8 @@ describe('Bruno Autocomplete', () => { mockedCodemirror.getLine.mockReturnValue('{{varName}}'); mockedCodemirror.getRange.mockReturnValue('{{varName'); - const emptyResult = getAutoCompleteHints(mockedCodemirror, {}); - const nullResult = getAutoCompleteHints(mockedCodemirror, null); + const emptyResult = getAutoCompleteHints(mockedCodemirror, {}, []); + const nullResult = getAutoCompleteHints(mockedCodemirror, null, []); expect(emptyResult).toBeNull(); expect(nullResult).toBeNull(); @@ -380,7 +372,7 @@ describe('Bruno Autocomplete', () => { mockedCodemirror.getLine.mockReturnValue(line); mockedCodemirror.getRange.mockReturnValue(line); - const result = getAutoCompleteHints(mockedCodemirror, {}, { + const result = getAutoCompleteHints(mockedCodemirror, {}, [], { showHintsFor: ['req'] }); @@ -401,7 +393,7 @@ describe('Bruno Autocomplete', () => { VARIABLE3: 'value3' }; - const result = getAutoCompleteHints(mockedCodemirror, allVariables, { + const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], { showHintsFor: ['variables'] }); @@ -428,7 +420,8 @@ describe('Bruno Autocomplete', () => { describe('Setup and cleanup', () => { it('should setup keyup event listener and return cleanup function', () => { - cleanupFn = setupAutoComplete(mockedCodemirror, mockGetAllVariables); + const options = { getAllVariables: mockGetAllVariables }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); expect(mockedCodemirror.on).toHaveBeenCalledWith('keyup', expect.any(Function)); expect(cleanupFn).toBeInstanceOf(Function); @@ -438,7 +431,7 @@ describe('Bruno Autocomplete', () => { }); it('should not setup if editor is null', () => { - const result = setupAutoComplete(null, mockGetAllVariables); + const result = setupAutoComplete(null, { getAllVariables: mockGetAllVariables }); expect(result).toBeUndefined(); expect(mockedCodemirror.on).not.toHaveBeenCalled(); @@ -447,9 +440,11 @@ describe('Bruno Autocomplete', () => { describe('Event handling', () => { it('should trigger hints on character key press', () => { - cleanupFn = setupAutoComplete(mockedCodemirror, mockGetAllVariables, { - showHintsFor: ['req'] - }); + const options = { + getAllVariables: mockGetAllVariables, + showHintsFor: ['req'] + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); const keyupHandler = mockedCodemirror.on.mock.calls[0][1]; mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 4 }); @@ -464,7 +459,8 @@ describe('Bruno Autocomplete', () => { }); it('should not trigger hints on non-character keys', () => { - cleanupFn = setupAutoComplete(mockedCodemirror, mockGetAllVariables); + const options = { getAllVariables: mockGetAllVariables }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); const keyupHandler = mockedCodemirror.on.mock.calls[0][1]; const nonCharacterKeys = ['Shift', 'Tab', 'Enter', 'Escape', 'ArrowUp', 'ArrowDown', 'Meta']; @@ -478,7 +474,8 @@ describe('Bruno Autocomplete', () => { }); it('should close existing completion when no hints available', () => { - cleanupFn = setupAutoComplete(mockedCodemirror, mockGetAllVariables); + const options = { getAllVariables: mockGetAllVariables }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); const keyupHandler = mockedCodemirror.on.mock.calls[0][1]; const mockCompletion = { close: jest.fn() }; @@ -495,8 +492,11 @@ describe('Bruno Autocomplete', () => { }); it('should pass options to getAutoCompleteHints', () => { - const options = { showHintsFor: ['req'] }; - cleanupFn = setupAutoComplete(mockedCodemirror, mockGetAllVariables, options); + const options = { + getAllVariables: mockGetAllVariables, + showHintsFor: ['req'] + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); const keyupHandler = mockedCodemirror.on.mock.calls[0][1]; mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 4 }); @@ -512,6 +512,173 @@ describe('Bruno Autocomplete', () => { }); }); }); + + describe('Click event handling (showHintsOnClick)', () => { + it('should setup mousedown event listener when showHintsOnClick is enabled', () => { + const options = { + getAllVariables: mockGetAllVariables, + showHintsOnClick: true + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + + expect(mockedCodemirror.on).toHaveBeenCalledWith('keyup', expect.any(Function)); + expect(mockedCodemirror.on).toHaveBeenCalledWith('mousedown', expect.any(Function)); + expect(mockedCodemirror.on).toHaveBeenCalledTimes(2); + }); + + it('should not setup mousedown event listener when showHintsOnClick is disabled', () => { + const options = { + getAllVariables: mockGetAllVariables, + showHintsOnClick: false + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + + expect(mockedCodemirror.on).toHaveBeenCalledWith('keyup', expect.any(Function)); + expect(mockedCodemirror.on).toHaveBeenCalledTimes(1); + }); + + it('should not setup mousedown event listener when showHintsOnClick is undefined', () => { + const options = { + getAllVariables: mockGetAllVariables + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + + expect(mockedCodemirror.on).toHaveBeenCalledWith('keyup', expect.any(Function)); + expect(mockedCodemirror.on).toHaveBeenCalledTimes(1); + }); + + it('should show hints on click when showHintsOnClick is enabled', () => { + jest.useFakeTimers(); + + const mockGetAnywordAutocompleteHints = jest.fn(() => ['Content-Type', 'Accept']); + const options = { + getAllVariables: mockGetAllVariables, + getAnywordAutocompleteHints: mockGetAnywordAutocompleteHints, + showHintsOnClick: true, + showHintsFor: ['req', 'variables'] + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + + // Find the click handler (mousedown event) + const clickHandler = mockedCodemirror.on.mock.calls.find(call => call[0] === 'mousedown')[1]; + + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 0 }); + + clickHandler(mockedCodemirror); + + // Run all timers to execute the setTimeout + jest.runAllTimers(); + + expect(mockGetAllVariables).toHaveBeenCalled(); + expect(mockGetAnywordAutocompleteHints).toHaveBeenCalled(); + expect(mockedCodemirror.showHint).toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it('should not show hints on click when showHintsOnClick is disabled', () => { + const options = { + getAllVariables: mockGetAllVariables, + showHintsOnClick: false + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + + // There should be no mousedown handler + const mousedownCalls = mockedCodemirror.on.mock.calls.filter(call => call[0] === 'mousedown'); + expect(mousedownCalls).toHaveLength(0); + }); + + it('should cleanup mousedown event listener when showHintsOnClick was enabled', () => { + const options = { + getAllVariables: mockGetAllVariables, + showHintsOnClick: true + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + + cleanupFn(); + + expect(mockedCodemirror.off).toHaveBeenCalledWith('keyup', expect.any(Function)); + expect(mockedCodemirror.off).toHaveBeenCalledWith('mousedown', expect.any(Function)); + expect(mockedCodemirror.off).toHaveBeenCalledTimes(2); + }); + + it('should only cleanup keyup event listener when showHintsOnClick was disabled', () => { + const options = { + getAllVariables: mockGetAllVariables, + showHintsOnClick: false + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + + cleanupFn(); + + expect(mockedCodemirror.off).toHaveBeenCalledWith('keyup', expect.any(Function)); + expect(mockedCodemirror.off).toHaveBeenCalledTimes(1); + }); + + it('should show all available hints on click based on showHintsFor configuration', () => { + jest.useFakeTimers(); + + const mockGetAnywordAutocompleteHints = jest.fn(() => ['Content-Type', 'Accept']); + const options = { + getAllVariables: mockGetAllVariables.mockReturnValue({ + envVar1: 'value1', + envVar2: 'value2' + }), + getAnywordAutocompleteHints: mockGetAnywordAutocompleteHints, + showHintsOnClick: true, + showHintsFor: ['req', 'variables'] + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + + // Find the click handler (mousedown event) + const clickHandler = mockedCodemirror.on.mock.calls.find(call => call[0] === 'mousedown')[1]; + + const mockCursor = { line: 0, ch: 0 }; + mockedCodemirror.getCursor.mockReturnValue(mockCursor); + + clickHandler(mockedCodemirror); + + // Run all timers to execute the setTimeout + jest.runAllTimers(); + + expect(mockedCodemirror.showHint).toHaveBeenCalledWith({ + hint: expect.any(Function), + completeSingle: false + }); + + // Verify the hint function returns the expected structure + const hintCall = mockedCodemirror.showHint.mock.calls[0][0]; + const hintResult = hintCall.hint(); + + expect(hintResult).toEqual({ + list: expect.any(Array), + from: mockCursor, + to: mockCursor + }); + expect(hintResult.list.length).toBeGreaterThan(0); + + jest.useRealTimers(); + }); + + it('should not show hints on click when no hints are available', () => { + const options = { + getAllVariables: mockGetAllVariables.mockReturnValue({}), + getAnywordAutocompleteHints: jest.fn(() => []), + showHintsOnClick: true, + showHintsFor: [] + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + + // Find the click handler (mousedown event) + const clickHandler = mockedCodemirror.on.mock.calls.find(call => call[0] === 'mousedown')[1]; + + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 0 }); + + clickHandler(mockedCodemirror); + + expect(mockedCodemirror.showHint).not.toHaveBeenCalled(); + }); + }); }); describe('CodeMirror integration', () => { diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 1cfbc028e..69918c218 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -233,7 +233,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} name: si.name, filename: si.filename, seq: si.seq, - settings: si.settings + settings: si.settings, + tags: si.tags }; if (si.request) { @@ -554,6 +555,7 @@ export const transformRequestToSaveToFilesystem = (item) => { name: _item.name, seq: _item.seq, settings: _item.settings, + tags: _item.tags, request: { method: _item.request.method, url: _item.request.url, @@ -1106,3 +1108,20 @@ export const calculateDraggedItemNewPathname = ({ draggedItem, targetItem, dropT }; // item sequence utils - END + +export const getUniqueTagsFromItems = (items = []) => { + const allTags = new Set(); + const getTags = (items) => { + items.forEach(item => { + if (isItemARequest(item)) { + const tags = item.draft ? get(item, 'draft.tags', []) : get(item, 'tags', []); + tags.forEach(tag => allTags.add(tag)); + } + if (item.items) { + getTags(item.items); + } + }); + }; + getTags(items); + return Array.from(allTags).sort(); +}; diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index ccbfa8581..811314d41 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -6,6 +6,7 @@ const { getRunnerSummary } = require('@usebruno/common/runner'); const { exists, isFile, isDirectory } = require('../utils/filesystem'); const { runSingleRequest } = require('../runner/run-single-request'); const { bruToEnvJson, getEnvVars } = require('../utils/bru'); +const { isRequestTagsIncluded } = require("@usebruno/common") const makeJUnitOutput = require('../reporters/junit'); const makeHtmlOutput = require('../reporters/html'); const { rpad } = require('../utils/common'); @@ -199,6 +200,14 @@ const builder = async (yargs) => { type:"number", description: "Delay between each requests (in miliseconds)" }) + .option('tags', { + type: 'string', + description: 'Tags to include in the run' + }) + .option('exclude-tags', { + type: 'string', + description: 'Tags to exclude from the run' + }) .example('$0 run request.bru', 'Run a request') .example('$0 run request.bru --env local', 'Run a request with the environment set to local') .example('$0 run request.bru --env-file env.bru', 'Run a request with the environment from env.bru file') @@ -241,7 +250,11 @@ const builder = async (yargs) => { ) .example('$0 run --client-cert-config client-cert-config.json', 'Run a request with Client certificate configurations') .example('$0 run folder --delay delayInMs', 'Run a folder with given miliseconds delay between each requests.') - .example('$0 run --noproxy', 'Run requests with system proxy disabled'); + .example('$0 run --noproxy', 'Run requests with system proxy disabled') + .example( + '$0 run folder --tags=hello,world --exclude-tags=skip', + 'Run only requests with tags "hello" or "world" and exclude any request with tag "skip".' + ); }; const handler = async function (argv) { @@ -268,7 +281,9 @@ const handler = async function (argv) { reporterSkipHeaders, clientCertConfig, noproxy, - delay + delay, + tags: includeTags, + excludeTags } = argv; const collectionPath = process.cwd(); @@ -353,7 +368,7 @@ const handler = async function (argv) { if (!match) { console.error( chalk.red(`Overridable environment variable not correct: use name=value - presented: `) + - chalk.dim(`${value}`) + chalk.dim(`${value}`) ); process.exit(constants.EXIT_STATUS.ERROR_INCORRECT_ENV_OVERRIDE); } @@ -389,6 +404,9 @@ const handler = async function (argv) { } options['ignoreTruststore'] = ignoreTruststore; + includeTags = includeTags ? includeTags.split(',') : []; + excludeTags = excludeTags ? excludeTags.split(',') : []; + if (['json', 'junit', 'html'].indexOf(format) === -1) { console.error(chalk.red(`Format must be one of "json", "junit or "html"`)); process.exit(constants.EXIT_STATUS.ERROR_INCORRECT_OUTPUT_FORMAT); @@ -456,6 +474,10 @@ const handler = async function (argv) { }); } + requestItems = requestItems.filter((item) => { + return isRequestTagsIncluded(item.tags, includeTags, excludeTags); + }); + const runtime = getJsSandboxRuntime(sandbox); const runSingleRequestByPathname = async (relativeItemPathname) => { diff --git a/packages/bruno-cli/src/utils/bru.js b/packages/bruno-cli/src/utils/bru.js index 205902705..b709f76f9 100644 --- a/packages/bruno-cli/src/utils/bru.js +++ b/packages/bruno-cli/src/utils/bru.js @@ -64,6 +64,7 @@ const bruToJson = (bru) => { name: _.get(json, 'meta.name'), seq: !_.isNaN(sequence) ? Number(sequence) : 1, settings: _.get(json, 'settings', {}), + tags: _.get(json, 'meta.tags', []), request: { method: _.upperCase(_.get(json, 'http.method')), url: _.get(json, 'http.url'), diff --git a/packages/bruno-common/src/index.ts b/packages/bruno-common/src/index.ts index 591a710c9..e72c1d847 100644 --- a/packages/bruno-common/src/index.ts +++ b/packages/bruno-common/src/index.ts @@ -1,4 +1,5 @@ export { mockDataFunctions } from './utils/faker-functions'; export { default as interpolate } from './interpolate'; +export { default as isRequestTagsIncluded } from './tags'; -export * as utils from './utils'; +export * as utils from './utils'; \ No newline at end of file diff --git a/packages/bruno-common/src/tags/index.spec.ts b/packages/bruno-common/src/tags/index.spec.ts new file mode 100644 index 000000000..46d307502 --- /dev/null +++ b/packages/bruno-common/src/tags/index.spec.ts @@ -0,0 +1,43 @@ +import isRequestTagsIncluded from './index'; + +describe('isRequestTagsIncluded', () => { + it('should include request when it has an included tag', () => { + const requestTags = ['tag1', 'tag2']; + const includeTags = ['tag1']; + const excludeTags: string[] = []; + const result = isRequestTagsIncluded(requestTags, includeTags, excludeTags); + expect(result).toBe(true); + }); + + it('should include request when included tags is empty', () => { + const requestTags = ['tag1', 'tag2']; + const includeTags: string[] = []; + const excludeTags: string[] = []; + const result = isRequestTagsIncluded(requestTags, includeTags, excludeTags); + expect(result).toBe(true); + }); + + it('should exclude request when it does not have an included tag', () => { + const requestTags = ['tag1']; + const includeTags = ['tag2']; + const excludeTags: string[] = []; + const result = isRequestTagsIncluded(requestTags, includeTags, excludeTags); + expect(result).toBe(false); + }); + + it('should exclude request when it has an excluded tag', () => { + const requestTags = ['tag1']; + const includeTags: string[] = []; + const excludeTags = ['tag1']; + const result = isRequestTagsIncluded(requestTags, includeTags, excludeTags); + expect(result).toBe(false); + }); + + it('should exclude request when it has both included and excluded tag', () => { + const requestTags = ['tag1', 'tag2']; + const includeTags: string[] = ['tag2']; + const excludeTags = ['tag1']; + const result = isRequestTagsIncluded(requestTags, includeTags, excludeTags); + expect(result).toBe(false); + }); +}); diff --git a/packages/bruno-common/src/tags/index.ts b/packages/bruno-common/src/tags/index.ts new file mode 100644 index 000000000..a2adfe798 --- /dev/null +++ b/packages/bruno-common/src/tags/index.ts @@ -0,0 +1,13 @@ +/** + * A request should be included if it has at least one tag that is included and no tags that are excluded + * @param requestTags Tags of the request + * @param includeTags Tags to include + * @param excludeTags Tags to exclude + */ +export const isRequestTagsIncluded = (requestTags: string[], includeTags: string[], excludeTags: string[]) => { + const shouldInclude = includeTags.length === 0 || requestTags.some((tag) => includeTags.includes(tag)); + const shouldExclude = excludeTags.length > 0 && requestTags.some((tag) => excludeTags.includes(tag)); + return shouldInclude && !shouldExclude; +}; + +export default isRequestTagsIncluded; diff --git a/packages/bruno-converters/src/insomnia/insomnia-to-bruno.js b/packages/bruno-converters/src/insomnia/insomnia-to-bruno.js index d4d829e7b..4968870b0 100644 --- a/packages/bruno-converters/src/insomnia/insomnia-to-bruno.js +++ b/packages/bruno-converters/src/insomnia/insomnia-to-bruno.js @@ -157,6 +157,12 @@ const transformInsomniaRequestItem = (request, index, allRequests) => { brunoRequestItem.request.body.graphql = parseGraphQL(request.body.text); } + const settings = { + encodeUrl: request.settings?.encodeUrl !== false && request.settingEncodeUrl !== false, // handles v4 and v5 import + } + + brunoRequestItem.settings = settings; + return brunoRequestItem; }; @@ -200,7 +206,8 @@ const parseInsomniaV5Collection = (data) => { parameters: item.parameters || [], pathParameters: item.pathParameters || [], authentication: item.authentication || {}, - body: item.body || {} + body: item.body || {}, + settings: item.settings || {} }; return transformInsomniaRequestItem(request, index, allItems); } else if (item.children && Array.isArray(item.children)) { diff --git a/packages/bruno-converters/src/postman/postman-to-bruno.js b/packages/bruno-converters/src/postman/postman-to-bruno.js index 62317ee0e..9fa85c625 100644 --- a/packages/bruno-converters/src/postman/postman-to-bruno.js +++ b/packages/bruno-converters/src/postman/postman-to-bruno.js @@ -380,6 +380,12 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, { useWorke } }; + const settings = { + encodeUrl: i.protocolProfileBehavior?.disableUrlEncoding !== true + } + + brunoRequestItem.settings = settings; + brunoParent.items.push(brunoRequestItem); if (i.event) { diff --git a/packages/bruno-converters/tests/insomnia/insomnia-collection-v5.spec.js b/packages/bruno-converters/tests/insomnia/insomnia-collection-v5.spec.js index c09065fa6..3d620d1db 100644 --- a/packages/bruno-converters/tests/insomnia/insomnia-collection-v5.spec.js +++ b/packages/bruno-converters/tests/insomnia/insomnia-collection-v5.spec.js @@ -4,7 +4,7 @@ import insomniaToBruno from '../../src/insomnia/insomnia-to-bruno'; describe('insomnia-collection', () => { it('should correctly import a valid Insomnia v5 collection file', async () => { const brunoCollection = insomniaToBruno(insomniaCollection); - + expect(brunoCollection).toMatchObject(expectedOutput) }); }); @@ -59,7 +59,7 @@ collection: method: GET settings: renderRequestBody: true - encodeUrl: true + encodeUrl: false followRedirects: global cookies: send: true @@ -113,6 +113,9 @@ const expectedOutput = { "seq": 1, "type": "http-request", "uid": "mockeduuidvalue123456", + "settings": { + "encodeUrl": true, + }, }, ], "name": "Folder1", @@ -146,6 +149,9 @@ const expectedOutput = { "seq": 1, "type": "http-request", "uid": "mockeduuidvalue123456", + "settings": { + "encodeUrl": false, + }, }, ], "name": "Folder2", diff --git a/packages/bruno-converters/tests/insomnia/insomnia-collection.spec.js b/packages/bruno-converters/tests/insomnia/insomnia-collection.spec.js index e6cb5a2c8..03df7e44f 100644 --- a/packages/bruno-converters/tests/insomnia/insomnia-collection.spec.js +++ b/packages/bruno-converters/tests/insomnia/insomnia-collection.spec.js @@ -22,6 +22,7 @@ const insomniaCollection = { "name": "Request1", "method": "GET", "url": "https://httpbin.org/get", + "settingEncodeUrl": false, "parameters": [] }, { @@ -31,6 +32,7 @@ const insomniaCollection = { "name": "Request2", "method": "GET", "url": "https://httpbin.org/get", + "settingEncodeUrl": true, "parameters": [] }, { @@ -92,6 +94,9 @@ const expectedOutput = { "seq": 1, "type": "http-request", "uid": "mockeduuidvalue123456", + "settings": { + "encodeUrl": false, + }, }, { "name": "Request1", @@ -118,6 +123,9 @@ const expectedOutput = { "seq": 2, "type": "http-request", "uid": "mockeduuidvalue123456", + "settings": { + "encodeUrl": false, + }, }, ], "name": "Folder1", @@ -151,6 +159,9 @@ const expectedOutput = { "seq": 1, "type": "http-request", "uid": "mockeduuidvalue123456", + "settings": { + "encodeUrl": true, + }, }, { "name": "Request2", @@ -177,6 +188,9 @@ const expectedOutput = { "seq": 2, "type": "http-request", "uid": "mockeduuidvalue123456", + "settings": { + "encodeUrl": true, + }, }, ], "name": "Folder2", diff --git a/packages/bruno-electron/src/bru/index.js b/packages/bruno-electron/src/bru/index.js index c830d5f7e..9dd920d8d 100644 --- a/packages/bruno-electron/src/bru/index.js +++ b/packages/bruno-electron/src/bru/index.js @@ -138,6 +138,7 @@ const bruToJson = (data, parsed = false) => { name: _.get(json, 'meta.name'), seq: !_.isNaN(sequence) ? Number(sequence) : 1, settings: _.get(json, 'settings', {}), + tags: _.get(json, 'meta.tags', []), request: { method: _.upperCase(_.get(json, 'http.method')), url: _.get(json, 'http.url'), @@ -155,7 +156,6 @@ const bruToJson = (data, parsed = false) => { transformedJson.request.auth.mode = _.get(json, 'http.auth', 'none'); transformedJson.request.body.mode = _.get(json, 'http.body', 'none'); - return transformedJson; } catch (e) { return Promise.reject(e); @@ -195,7 +195,8 @@ const jsonToBru = async (json) => { meta: { name: _.get(json, 'name'), type: type, - seq: !_.isNaN(sequence) ? Number(sequence) : 1 + seq: !_.isNaN(sequence) ? Number(sequence) : 1, + tags: _.get(json, 'tags', []), }, http: { method: _.lowerCase(_.get(json, 'request.method')), @@ -237,7 +238,8 @@ const jsonToBruViaWorker = async (json) => { meta: { name: _.get(json, 'name'), type: type, - seq: !_.isNaN(sequence) ? Number(sequence) : 1 + seq: !_.isNaN(sequence) ? Number(sequence) : 1, + tags: _.get(json, 'tags', []) }, http: { method: _.lowerCase(_.get(json, 'request.method')), diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 583ff11cd..fbad36967 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -31,6 +31,7 @@ const { preferencesUtil } = require('../../store/preferences'); const { getProcessEnvVars } = require('../../store/process-env'); const { getBrunoConfig } = require('../../store/bruno-config'); const Oauth2Store = require('../../store/oauth2'); +const { isRequestTagsIncluded } = require('@usebruno/common'); const saveCookies = (url, headers) => { if (preferencesUtil.shouldStoreCookies()) { @@ -952,7 +953,7 @@ const registerNetworkIpc = (mainWindow) => { ipcMain.handle( 'renderer:run-collection-folder', - async (event, folder, collection, environment, runtimeVariables, recursive, delay) => { + async (event, folder, collection, environment, runtimeVariables, recursive, delay, tags) => { const collectionUid = collection.uid; const collectionPath = collection.pathname; const folderUid = folder ? folder.uid : null; @@ -1012,6 +1013,15 @@ const registerNetworkIpc = (mainWindow) => { folderRequests = sortByNameThenSequence(folderRequests) } + // Filter requests based on tags + if (tags && tags.include && tags.exclude) { + const includeTags = tags.include ? tags.include : []; + const excludeTags = tags.exclude ? tags.exclude : []; + folderRequests = folderRequests.filter(({ tags }) => { + return isRequestTagsIncluded(tags, includeTags, excludeTags) + }); + } + let currentRequestIndex = 0; let nJumps = 0; // count the number of jumps to avoid infinite loops while (currentRequestIndex < folderRequests.length) { diff --git a/packages/bruno-electron/src/ipc/network/prepare-request.js b/packages/bruno-electron/src/ipc/network/prepare-request.js index b2f11b9f9..0a95657c7 100644 --- a/packages/bruno-electron/src/ipc/network/prepare-request.js +++ b/packages/bruno-electron/src/ipc/network/prepare-request.js @@ -61,6 +61,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => { break; case 'apikey': const apiKeyAuth = get(collectionAuth, 'apikey'); + if (apiKeyAuth.key.length === 0) break; if (apiKeyAuth.placement === 'header') { axiosRequest.headers[apiKeyAuth.key] = apiKeyAuth.value; } else if (apiKeyAuth.placement === 'queryparams') { @@ -277,6 +278,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => { break; case 'apikey': const apiKeyAuth = get(request, 'auth.apikey'); + if (apiKeyAuth.key.length === 0) break; if (apiKeyAuth.placement === 'header') { axiosRequest.headers[apiKeyAuth.key] = apiKeyAuth.value; } else if (apiKeyAuth.placement === 'queryparams') { @@ -426,7 +428,7 @@ const prepareRequest = async (item, collection = {}, abortController) => { } // if the mode is 'none' then set the content-type header to false. #1693 - if (request.body.mode === 'none') { + if (request.body.mode === 'none' && request.auth.mode !== 'awsv4') { if(!contentTypeDefined) { axiosRequest.headers['content-type'] = false; } diff --git a/packages/bruno-electron/tests/network/prepare-request.spec.js b/packages/bruno-electron/tests/network/prepare-request.spec.js index 6fbd745a7..f8c014869 100644 --- a/packages/bruno-electron/tests/network/prepare-request.spec.js +++ b/packages/bruno-electron/tests/network/prepare-request.spec.js @@ -62,7 +62,7 @@ describe('prepare-request: prepareRequest', () => { describe.each(['POST', 'PUT', 'PATCH'])('POST request with no body', (method) => { it('Should set content-type header to false if method is ' + method + ' and there is no data in the body', async () => { - const request = { method: method, url: 'test-domain', body: { mode: 'none' } }; + const request = { method: method, url: 'test-domain', body: { mode: 'none' }, auth: { mode: 'none' } }; const result = await prepareRequest({ request, collection: { pathname: '' } }); expect(result.headers['content-type']).toEqual(false); }); @@ -71,7 +71,8 @@ describe('prepare-request: prepareRequest', () => { method: method, url: 'test-domain', body: { mode: 'none' }, - headers: [{ name: 'content-type', value: 'application/json', enabled: true }] + headers: [{ name: 'content-type', value: 'application/json', enabled: true }], + auth: { mode: 'none' } }; const result = await prepareRequest({ request, collection: { pathname: '' } }); expect(result.headers['content-type']).toEqual('application/json'); diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js index 13d2032dc..3ce411841 100644 --- a/packages/bruno-lang/v2/src/bruToJson.js +++ b/packages/bruno-lang/v2/src/bruToJson.js @@ -4,7 +4,7 @@ const { safeParseJson, outdentString } = require('./utils'); /** * A Bru file is made up of blocks. - * There are two types of blocks + * There are three types of blocks * * 1. Dictionary Blocks - These are blocks that have key value pairs * ex: @@ -19,6 +19,13 @@ const { safeParseJson, outdentString } = require('./utils'); * "username": "John Nash", * "password": "governingdynamics * } + + * 3. List Blocks - These are blocks that have a list of items + * ex: + * tags [ + * regression + * smoke-test + * ] * */ const grammar = ohm.grammar(`Bru { @@ -45,7 +52,7 @@ const grammar = ohm.grammar(`Bru { pairlist = optionalnl* pair (~tagend stnl* pair)* (~tagend space)* pair = st* key st* ":" st* value st* key = keychar* - value = multilinetextblock | valuechar* + value = list | multilinetextblock | valuechar* // Dictionary for Assert Block assertdictionary = st* "{" assertpairlist? tagend @@ -59,6 +66,12 @@ const grammar = ohm.grammar(`Bru { textline = textchar* textchar = ~nl any + // List + listend = stnl* "]" + list = st* "[" listitems? listend + listitems = (~listend stnl)* listitem (~listend stnl* listitem)* (~listend space)* + listitem = st* textchar+ st* + meta = "meta" dictionary settings = "settings" dictionary @@ -262,6 +275,10 @@ const sem = grammar.createSemantics().addAttribute('ast', { }, pair(_1, key, _2, _3, _4, value, _5) { let res = {}; + if (Array.isArray(value.ast)) { + res[key.ast] = value.ast; + return res; + } res[key.ast] = value.ast ? value.ast.trim() : ''; return res; }, @@ -269,6 +286,9 @@ const sem = grammar.createSemantics().addAttribute('ast', { return chars.sourceString ? chars.sourceString.trim() : ''; }, value(chars) { + if (chars.ctorName === 'list') { + return chars.ast; + } try { let isMultiline = chars.sourceString?.startsWith(`'''`) && chars.sourceString?.endsWith(`'''`); if (isMultiline) { @@ -298,6 +318,15 @@ const sem = grammar.createSemantics().addAttribute('ast', { assertkey(chars) { return chars.sourceString ? chars.sourceString.trim() : ''; }, + list(_1, _2, listitems, _3) { + return listitems.ast.flat() + }, + listitems(_1, listitem, _2, rest, _3) { + return [listitem.ast, ...rest.ast] + }, + listitem(_1, textchar, _2) { + return textchar.sourceString; + }, textblock(line, _1, rest) { return [line.ast, ...rest.ast].join('\n'); }, diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/v2/src/jsonToBru.js index 1afbe19e8..3a072a254 100644 --- a/packages/bruno-lang/v2/src/jsonToBru.js +++ b/packages/bruno-lang/v2/src/jsonToBru.js @@ -36,9 +36,22 @@ const jsonToBru = (json) => { if (meta) { bru += 'meta {\n'; + + const tags = meta.tags; + delete meta.tags; + for (const key in meta) { bru += ` ${key}: ${meta[key]}\n`; } + + if (tags && tags.length) { + bru += ` tags: [\n`; + for (const tag of tags) { + bru += ` ${tag}\n`; + } + bru += ` ]\n`; + } + bru += '}\n\n'; } diff --git a/packages/bruno-lang/v2/tests/fixtures/request.bru b/packages/bruno-lang/v2/tests/fixtures/request.bru index c3f81b780..dc70da54b 100644 --- a/packages/bruno-lang/v2/tests/fixtures/request.bru +++ b/packages/bruno-lang/v2/tests/fixtures/request.bru @@ -2,6 +2,10 @@ meta { name: Send Bulk SMS type: http seq: 1 + tags: [ + foo + bar + ] } get { diff --git a/packages/bruno-lang/v2/tests/fixtures/request.json b/packages/bruno-lang/v2/tests/fixtures/request.json index 5cdebec00..539eae116 100644 --- a/packages/bruno-lang/v2/tests/fixtures/request.json +++ b/packages/bruno-lang/v2/tests/fixtures/request.json @@ -2,7 +2,8 @@ "meta": { "name": "Send Bulk SMS", "type": "http", - "seq": "1" + "seq": "1", + "tags": ["foo", "bar"] }, "http": { "method": "get", diff --git a/packages/bruno-lang/v2/tests/tags.spec.js b/packages/bruno-lang/v2/tests/tags.spec.js new file mode 100644 index 000000000..58613bca8 --- /dev/null +++ b/packages/bruno-lang/v2/tests/tags.spec.js @@ -0,0 +1,33 @@ +/** + * This test file is used to test the text parser. + */ +const parser = require('../src/bruToJson'); + +describe('tags parser', () => { + it('should parse request tags', () => { + const input = ` +meta { + name: request + type: http + seq: 1 + tags: [ + tag_1 + tag_2 + tag_3 + tag_4 + ] +} +`; + + const output = parser(input); + const expected = { + meta: { + name: 'request', + type: 'http', + tags: ['tag_1', 'tag_2', 'tag_3', 'tag_4'], + seq: '1' + } + }; + expect(output).toEqual(expected); + }); +}); diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index 0e9d31618..2f143c0e1 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -355,6 +355,7 @@ const itemSchema = Yup.object({ type: Yup.string().oneOf(['http-request', 'graphql-request', 'folder', 'js']).required('type is required'), seq: Yup.number().min(1), name: Yup.string().min(1, 'name must be at least 1 character').required('name is required'), + tags: Yup.array().of(Yup.string().matches(/^[\w-]+$/, 'tag must be alphanumeric')), request: requestSchema.when('type', { is: (type) => ['http-request', 'graphql-request'].includes(type), then: (schema) => schema.required('request is required when item-type is request') diff --git a/packages/bruno-schema/src/collections/itemSchema.spec.js b/packages/bruno-schema/src/collections/itemSchema.spec.js index 8c46bed2c..9d52132da 100644 --- a/packages/bruno-schema/src/collections/itemSchema.spec.js +++ b/packages/bruno-schema/src/collections/itemSchema.spec.js @@ -7,7 +7,8 @@ describe('Item Schema Validation', () => { const item = { uid: uuid(), name: 'A Folder', - type: 'folder' + type: 'folder', + tags: ['smoke-test'] }; const isValid = await itemSchema.validate(item); diff --git a/packages/bruno-toml/src/jsonToToml.js b/packages/bruno-toml/src/jsonToToml.js index d2922e245..368277115 100644 --- a/packages/bruno-toml/src/jsonToToml.js +++ b/packages/bruno-toml/src/jsonToToml.js @@ -47,6 +47,10 @@ const jsonToToml = (json) => { } }; + if (json.tags && json.tags.length) { + formattedJson.tags = get(json, 'tags', []); + } + if (json.headers && json.headers.length) { const hasDuplicateHeaders = keyValPairHasDuplicateKeys(json.headers); const hasReservedHeaders = keyValPairHasReservedKeys(json.headers); diff --git a/packages/bruno-toml/src/tomlToJson.js b/packages/bruno-toml/src/tomlToJson.js index f5eea0f19..f29fafff1 100644 --- a/packages/bruno-toml/src/tomlToJson.js +++ b/packages/bruno-toml/src/tomlToJson.js @@ -24,6 +24,10 @@ const tomlToJson = (toml) => { } }; + if (json.tags && json.tags.length) { + formattedJson.tags = get(json, 'tags', []); + } + if (json.headers) { formattedJson.headers = []; diff --git a/packages/bruno-toml/tests/methods/get/request.json b/packages/bruno-toml/tests/methods/get/request.json index 2fb3955f1..4f37293d2 100644 --- a/packages/bruno-toml/tests/methods/get/request.json +++ b/packages/bruno-toml/tests/methods/get/request.json @@ -4,6 +4,7 @@ "type": "http", "seq": 1 }, + "tags": ["foo", "bar"], "http": { "method": "GET", "url": "https://reqres.in/api/users" diff --git a/packages/bruno-toml/tests/methods/get/request.toml b/packages/bruno-toml/tests/methods/get/request.toml index ae34e0771..66e75af35 100644 --- a/packages/bruno-toml/tests/methods/get/request.toml +++ b/packages/bruno-toml/tests/methods/get/request.toml @@ -1,3 +1,5 @@ +tags = [ 'foo', 'bar' ] + [meta] name = 'Get users' type = 'http'