Compare commits

..

1 Commits

Author SHA1 Message Date
Bijin A B
d61c301250 fix(ux): fix sidebar invisible for environments tab, grpc and ws 2026-01-04 09:57:23 +05:30
36 changed files with 169 additions and 451 deletions

9
package-lock.json generated
View File

@@ -29,7 +29,7 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "~0.7.0",
"@opencollection/types": "~0.6.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
@@ -6100,9 +6100,9 @@
}
},
"node_modules/@opencollection/types": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.7.0.tgz",
"integrity": "sha512-CSwdaHNPa2bNNBAOy++t6W9gBTExUJZW3aPkWyhAjasusThbvjymD/0uCLR50gCXSs0ezv61jsd19m9x+2DMtQ==",
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.6.0.tgz",
"integrity": "sha512-nasB4/1hIZ61xp2dnnZWhdH83f0t800VrSl3G2q+BtHabBqN/IG+j9BMOJg0hYZjAVx+Yhl1njkzUqkiX5+Q0g==",
"dev": true,
"license": "MIT"
},
@@ -33081,7 +33081,6 @@
"@rollup/plugin-typescript": "^12.1.2",
"@types/jest": "^29.5.14",
"babel-jest": "^29.7.0",
"form-data": "^4.0.0",
"is-ip": "^5.0.1",
"moment": "^2.29.4",
"rollup": "3.29.5",

View File

@@ -23,7 +23,7 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "~0.7.0",
"@opencollection/types": "~0.6.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",

View File

@@ -259,6 +259,10 @@ const StyledWrapper = styled.div`
height: 400px;
display: flex;
flex-direction: column;
pre {
padding: 8px !important;
}
.w-full.h-full.relative.flex {
height: 100% !important;
@@ -317,7 +321,7 @@ const StyledWrapper = styled.div`
height: 100% !important;
max-height: 400px !important;
padding: 0.5rem !important;
.network-logs-pre {
color: ${(props) => props.theme.console.messageColor} !important;
font-size: ${(props) => props.theme.font.size.xs} !important;

View File

@@ -191,12 +191,9 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
flex: 1;
min-width: 0;
overflow: hidden;
.environment-name-input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
@@ -213,14 +210,12 @@ const StyledWrapper = styled.div`
display: flex;
gap: 2px;
margin-left: 4px;
flex-shrink: 0;
}
}
&.creating {
.environment-name-input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
@@ -237,7 +232,6 @@ const StyledWrapper = styled.div`
display: flex;
gap: 2px;
margin-left: 4px;
flex-shrink: 0;
}
}

View File

@@ -185,7 +185,7 @@ const WSMessagesList = ({ order = -1, messages = [] }) => {
// sort based on order, seq was newly added and might be missing in some cases and when missing,
// the timestamp will be used instead
const ordered = messages.toSorted((x, y) => ((x.seq ?? x.timestamp) - (y.seq ?? y.timestamp)) * (-order));
const ordered = messages.toSorted((x, y) => ((x.seq ?? x.timestamp) - (y.seq ?? y.timestamp)) * order);
return (
<StyledWrapper className="ws-messages-list flex flex-col">

View File

@@ -13,30 +13,6 @@ const StyledWrapper = styled.div`
line-height: 1.6;
}
.preview-container {
border-radius: ${(props) => props.theme.border.radius.md};
overflow: hidden;
border: 1px solid ${(props) => props.theme.border.border1};
.preview-label {
top: 0.5rem;
right: 0.5rem;
padding: 0.125rem 0.5rem;
font-size: ${(props) => props.theme.font.size.xs};
font-weight: 500;
color: #3b82f6;
background-color: rgba(59, 130, 246, 0.1);
border: 1px dashed rgba(59, 130, 246, 0.4);
border-radius: ${(props) => props.theme.border.radius.sm};
}
.preview-image {
width: 100%;
height: auto;
display: block;
}
}
.features {
li {
font-size: ${(props) => props.theme.font.size.sm};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -9,7 +9,6 @@ import { IconBook, IconCheck, IconAlertTriangle, IconLoader2 } from '@tabler/ico
import Modal from 'components/Modal';
import StyledWrapper from './StyledWrapper';
import demoImage from './demo.png';
import { useApp } from 'providers/App';
import { transformCollectionToSaveToExportAsFile, findCollectionByUid, areItemsLoading } from 'utils/collections/index';
import { brunoToOpenCollection } from '@usebruno/converters';
@@ -142,16 +141,12 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => {
<IconBook size={18} />
<span>Interactive API Documentation</span>
</h3>
<p className="description mb-4">
Generate a standalone HTML file that can be hosted anywhere or shared with your team.
<p className="description mb-5">
Generate a standalone HTML file containing interactive documentation for your API collection.
This file can be hosted anywhere or shared with your team.
</p>
<div className="preview-container relative mb-4">
<span className="preview-label absolute">Sample Output</span>
<img src={demoImage} alt="Documentation preview" className="preview-image" />
</div>
<ul className="features flex flex-col list-none gap-2 p-0 mb-4">
<ul className="features flex flex-col list-none gap-2.5 p-0 mb-5">
{FEATURES.map((feature) => (
<li key={feature} className="flex items-center gap-2.5">
<IconCheck size={16} className="check-icon flex-shrink-0" />
@@ -161,7 +156,7 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => {
</ul>
<p className="note m-0">
The generated file loads OpenCollection's JavaScript and CSS files from a CDN, which requires an internet connection.
The generated file does not embed all assets. It loads OpenCollections JavaScript and CSS files from a CDN when viewing docs, which requires an internet connection.
</p>
</div>
)}

View File

@@ -14,18 +14,6 @@ const StyledWrapper = styled.div`
border-radius: ${(props) => props.theme.border.radius.sm};
}
.discussion-link {
margin-left: 0.5rem;
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.textLink};
cursor: pointer;
font-weight: 400;
&:hover {
text-decoration: underline;
}
}
.report-issue-link {
display: inline-flex;
align-items: center;

View File

@@ -224,19 +224,7 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
</p>
</Help>
{formik.values.format === 'yml' && (
<>
<span className="beta-badge">Beta</span>
<a
href="#"
className="discussion-link"
onClick={(e) => {
e.preventDefault();
window.open('https://github.com/usebruno/bruno/discussions/6634', '_blank', 'noopener,noreferrer');
}}
>
Join the discussion
</a>
</>
<span className="beta-badge">Beta</span>
)}
</label>
<select
@@ -252,6 +240,21 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
{formik.touched.format && formik.errors.format ? (
<div className="text-red-500">{formik.errors.format}</div>
) : null}
{formik.values.format === 'yml' && (
<div className="mt-2">
<a
href="#"
className="report-issue-link"
onClick={(e) => {
e.preventDefault();
window.open('https://github.com/usebruno/bruno/discussions/6466', '_blank', 'noopener,noreferrer');
}}
>
<IconExternalLink size={14} strokeWidth={1.5} />
<span>Report an issue</span>
</a>
</div>
)}
</div>
</div>
<div className="flex justify-end items-center mt-8 bruno-modal-footer">

View File

@@ -191,12 +191,9 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
flex: 1;
min-width: 0;
overflow: hidden;
.environment-name-input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
@@ -213,14 +210,12 @@ const StyledWrapper = styled.div`
display: flex;
gap: 2px;
margin-left: 4px;
flex-shrink: 0;
}
}
&.creating {
.environment-name-input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
@@ -237,7 +232,6 @@ const StyledWrapper = styled.div`
display: flex;
gap: 2px;
margin-left: 4px;
flex-shrink: 0;
}
.inline-action-btn {

View File

@@ -324,27 +324,6 @@ const GlobalStyle = createGlobalStyle`
margin: 1em 0;
}
.CodeMirror-lint-tooltip {
padding: 4px 8px;
background-color: ${(props) => props.theme.infoTip.bg};
border: 1px solid ${(props) => props.theme.infoTip.border};
box-shadow: ${(props) => props.theme.infoTip.boxShadow};
border-radius: ${(props) => props.theme.border.radius.sm};
}
.CodeMirror-lint-message {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.text};
}
.CodeMirror-lint-message-warning {
color: ${(props) => props.theme.status.warning.text};
}
.CodeMirror-lint-message-error {
color: ${(props) => props.theme.status.danger.text};
}
/* Header */
.CodeMirror-brunoVarInfo .var-info-header {
display: flex;
@@ -561,25 +540,6 @@ const GlobalStyle = createGlobalStyle`
cursor: pointer;
color: ${(props) => props.theme.textLink} !important;
}
// Native select styling
select {
background-color: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.base};
font-weight: 400;
}
select option {
background-color: ${(props) => props.theme.dropdown.bg};
color: ${(props) => props.theme.dropdown.color};
}
select option:hover,
select option:focus {
background-color: ${(props) => props.theme.dropdown.hoverBg} !important;
color: ${(props) => props.theme.dropdown.color} !important;
}
`;
export default GlobalStyle;

View File

@@ -295,7 +295,7 @@ const darkPastelTheme = {
},
body: {
color: colors.TEXT,
bg: colors.GRAY_2
bg: colors.GRAY_1
},
input: {
bg: 'transparent',

View File

@@ -241,7 +241,7 @@ const darkTheme = {
color: palette.text.BASE,
iconColor: palette.text.SUBTEXT2,
bg: palette.background.MANTLE,
hoverBg: palette.background.SURFACE0,
hoverBg: palette.background.MANTLE,
shadow: 'none',
border: palette.border.BORDER1,
separator: palette.border.BORDER1,

View File

@@ -15,7 +15,7 @@ const { rpad } = require('../utils/common');
const { getOptions } = require('../utils/bru');
const { parseDotEnv, parseEnvironment } = require('@usebruno/filestore');
const constants = require('../constants');
const { findItemInCollection, createCollectionJsonFromPathname, getCallStack, FORMAT_CONFIG } = require('../utils/collection');
const { findItemInCollection, createCollectionJsonFromPathname, getCallStack } = require('../utils/collection');
const { hasExecutableTestInScript } = require('../utils/request');
const command = 'run [paths...]';
const desc = 'Run one or more requests/folders';
@@ -359,21 +359,21 @@ const handler = async function (argv) {
}
if (envFile || env) {
const envExt = FORMAT_CONFIG[collection.format].ext;
const envFilePath = envFile
? path.resolve(collectionPath, envFile)
: path.join(collectionPath, 'environments', `${env}${envExt}`);
: path.join(collectionPath, 'environments', `${env}.bru`);
const envFileExists = await exists(envFilePath);
if (!envFileExists) {
const errorPath = envFile || `environments/${env}${envExt}`;
const errorPath = envFile || `environments/${env}.bru`;
console.error(chalk.red(`Environment file not found: `) + chalk.dim(errorPath));
process.exit(constants.EXIT_STATUS.ERROR_ENV_NOT_FOUND);
}
const fileExt = path.extname(envFilePath).toLowerCase();
if (fileExt === '.json') {
const ext = path.extname(envFilePath).toLowerCase();
if (ext === '.json') {
// Parse Bruno schema JSON environment
let envJsonContent;
try {
envJsonContent = fs.readFileSync(envFilePath, 'utf8');
@@ -387,12 +387,8 @@ const handler = async function (argv) {
console.error(chalk.red(`Failed to parse Environment JSON: ${err.message}`));
process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE);
}
} else if (fileExt === '.yml' || fileExt === '.yaml') {
const envContent = fs.readFileSync(envFilePath, 'utf8');
const envJson = parseEnvironment(envContent, { format: 'yml' });
envVars = getEnvVars(envJson);
envVars.__name__ = envFile ? path.basename(envFilePath, fileExt) : env;
} else {
// Default to .bru parsing
const envBruContent = fs.readFileSync(envFilePath, 'utf8').replace(/\r\n/g, '\n');
const envJson = parseEnvironment(envBruContent);
envVars = getEnvVars(envJson);
@@ -600,11 +596,10 @@ const handler = async function (argv) {
const runtime = getJsSandboxRuntime(sandbox);
const runSingleRequestByPathname = async (relativeItemPathname) => {
const ext = FORMAT_CONFIG[collection.format].ext;
return new Promise(async (resolve, reject) => {
let itemPathname = path.join(collectionPath, relativeItemPathname);
if (itemPathname && !itemPathname?.endsWith(ext)) {
itemPathname = `${itemPathname}${ext}`;
if (itemPathname && !itemPathname?.endsWith('.bru')) {
itemPathname = `${itemPathname}.bru`;
}
const requestItem = cloneDeep(findItemInCollection(collection, itemPathname));
if (requestItem) {

View File

@@ -1,6 +1,6 @@
const { interpolate } = require('@usebruno/common');
const { each, forOwn, cloneDeep, find } = require('lodash');
const { isFormData } = require('@usebruno/common').utils;
const FormData = require('form-data');
const getContentType = (headers = {}) => {
let contentType = '';
@@ -87,7 +87,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
}));
}
} else if (contentType === 'multipart/form-data') {
if (Array.isArray(request?.data) && !isFormData(request.data)) {
if (Array.isArray(request?.data) && !(request.data instanceof FormData)) {
try {
request.data = request?.data?.map((d) => ({
...d,

View File

@@ -288,16 +288,13 @@ const prepareRequest = async (item = {}, collection = {}) => {
request.body = request.body || {};
if (request.body.mode === 'json') {
const jsonBody = request.body.json;
if (jsonBody && jsonBody.length > 0) {
if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'application/json';
}
try {
axiosRequest.data = decomment(jsonBody);
} catch (error) {
axiosRequest.data = jsonBody;
}
if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'application/json';
}
try {
axiosRequest.data = decomment(request?.body?.json);
} catch (error) {
axiosRequest.data = request?.body?.json;
}
}

View File

@@ -3,6 +3,7 @@ const chalk = require('chalk');
const decomment = require('decomment');
const fs = require('fs');
const { forOwn, isUndefined, isNull, each, extend, get, compact } = require('lodash');
const FormData = require('form-data');
const prepareRequest = require('./prepare-request');
const interpolateVars = require('./interpolate-vars');
const { interpolateString } = require('./interpolate-string');
@@ -24,7 +25,7 @@ const { NtlmClient } = require('axios-ntlm');
const { addDigestInterceptor } = require('@usebruno/requests');
const { getCACertificates } = require('@usebruno/requests');
const { getOAuth2Token } = require('../utils/oauth2');
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData } = require('@usebruno/common').utils;
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables } = require('@usebruno/common').utils;
const onConsoleLog = (type, args) => {
console[type](...args);
@@ -428,7 +429,7 @@ const runSingleRequest = async function (
}
if (contentTypeHeader && request.headers[contentTypeHeader] === 'multipart/form-data') {
if (!isFormData(request?.data)) {
if (!(request?.data instanceof FormData)) {
request._originalMultipartData = request.data;
request.collectionPath = collectionPath;
let form = createFormData(request.data, collectionPath);

View File

@@ -7,90 +7,116 @@ const { parseRequest, parseCollection, parseFolder, stringifyCollection, stringi
const constants = require('../constants');
const chalk = require('chalk');
const FORMAT_CONFIG = {
yml: { ext: '.yml', collectionFile: 'opencollection.yml', folderFile: 'folder.yml' },
bru: { ext: '.bru', collectionFile: 'collection.bru', folderFile: 'folder.bru' }
};
const getCollectionFormat = (collectionPath) => {
if (fs.existsSync(path.join(collectionPath, 'opencollection.yml'))) return 'yml';
if (fs.existsSync(path.join(collectionPath, 'bruno.json'))) return 'bru';
return null;
};
const getCollectionConfig = (collectionPath, format) => {
if (format === 'yml') {
const content = fs.readFileSync(path.join(collectionPath, 'opencollection.yml'), 'utf8');
const parsed = parseCollection(content, { format: 'yml' });
return { brunoConfig: parsed.brunoConfig, collectionRoot: parsed.collectionRoot || {} };
}
const brunoConfig = JSON.parse(fs.readFileSync(path.join(collectionPath, 'bruno.json'), 'utf8'));
const collectionBruPath = path.join(collectionPath, 'collection.bru');
const collectionRoot = fs.existsSync(collectionBruPath)
? parseCollection(fs.readFileSync(collectionBruPath, 'utf8'), { format: 'bru' })
: {};
return { brunoConfig, collectionRoot };
};
const getFolderRoot = (dir, format) => {
const folderPath = path.join(dir, FORMAT_CONFIG[format].folderFile);
if (!fs.existsSync(folderPath)) return null;
return parseFolder(fs.readFileSync(folderPath, 'utf8'), { format });
};
const createCollectionJsonFromPathname = (collectionPath) => {
const format = getCollectionFormat(collectionPath);
if (!format) {
console.error(chalk.red(`You can run only at the root of a collection`));
process.exit(constants.EXIT_STATUS.ERROR_NOT_IN_COLLECTION);
}
const environmentsPath = path.join(collectionPath, `environments`);
const { brunoConfig, collectionRoot } = getCollectionConfig(collectionPath, format);
const { ext, collectionFile, folderFile } = FORMAT_CONFIG[format];
const environmentsPath = path.join(collectionPath, 'environments');
// get the collection bruno json config [<collection-path>/bruno.json]
const brunoConfig = getCollectionBrunoJsonConfig(collectionPath);
// get the collection root [<collection-path>/collection.bru]
const collectionRoot = getCollectionRoot(collectionPath);
// get the collection items recursively
const traverse = (currentPath) => {
if (currentPath.includes('node_modules')) return [];
const filesInCurrentDir = fs.readdirSync(currentPath);
if (currentPath.includes('node_modules')) {
return;
}
const currentDirItems = [];
for (const file of fs.readdirSync(currentPath)) {
for (const file of filesInCurrentDir) {
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
if (stats.isDirectory()) {
if (filePath === environmentsPath || file === '.git' || file === 'node_modules') continue;
const folderItem = { name: file, pathname: filePath, type: 'folder', items: traverse(filePath) };
const folderRoot = getFolderRoot(filePath, format);
if (folderRoot) {
folderItem.root = folderRoot;
folderItem.seq = folderRoot.meta?.seq;
if (filePath === environmentsPath) continue;
if (filePath.startsWith('.git') || filePath.startsWith('node_modules')) continue;
// get the folder root
let folderItem = { name: file, pathname: filePath, type: 'folder', items: traverse(filePath) };
const folderBruJson = getFolderRoot(filePath);
if (folderBruJson) {
folderItem.root = folderBruJson;
folderItem.seq = folderBruJson.meta.seq;
}
currentDirItems.push(folderItem);
} else {
if (file === collectionFile || file === folderFile || path.extname(filePath) !== ext) continue;
if (['collection.bru', 'folder.bru'].includes(file)) continue;
if (path.extname(filePath) !== '.bru') continue;
// get the request item
try {
const requestItem = parseRequest(fs.readFileSync(filePath, 'utf8'), { format });
currentDirItems.push({ name: file, ...requestItem, pathname: filePath });
const bruContent = fs.readFileSync(filePath, 'utf8');
const requestItem = parseRequest(bruContent);
currentDirItems.push({
name: file,
pathname: filePath,
...requestItem
});
} catch (err) {
// Log warning for invalid .bru file but continue processing
console.warn(chalk.yellow(`Warning: Skipping invalid file ${filePath}\nError: ${err.message}`));
global.brunoSkippedFiles = global.brunoSkippedFiles || [];
// Track skipped files for later reporting
if (!global.brunoSkippedFiles) {
global.brunoSkippedFiles = [];
}
global.brunoSkippedFiles.push({ path: filePath, error: err.message });
}
}
}
let currentDirFolderItems = currentDirItems?.filter((iter) => iter.type === 'folder');
let sortedFolderItems = sortByNameThenSequence(currentDirFolderItems);
const folders = sortByNameThenSequence(currentDirItems.filter((i) => i.type === 'folder'));
const requests = currentDirItems.filter((i) => i.type !== 'folder').sort((a, b) => a.seq - b.seq);
return folders.concat(requests);
let currentDirRequestItems = currentDirItems?.filter((iter) => iter.type !== 'folder');
let sortedRequestItems = currentDirRequestItems?.sort((a, b) => a.seq - b.seq);
return sortedFolderItems?.concat(sortedRequestItems);
};
let collectionItems = traverse(collectionPath);
return {
let collection = {
brunoConfig,
format,
root: collectionRoot,
pathname: collectionPath,
items: traverse(collectionPath)
items: collectionItems
};
return collection;
};
const getCollectionBrunoJsonConfig = (dir) => {
// right now, bru must be run from the root of the collection
// will add support in the future to run it from anywhere inside the collection
const brunoJsonPath = path.join(dir, 'bruno.json');
const brunoJsonExists = fs.existsSync(brunoJsonPath);
if (!brunoJsonExists) {
console.error(chalk.red(`You can run only at the root of a collection`));
process.exit(constants.EXIT_STATUS.ERROR_NOT_IN_COLLECTION);
}
const brunoConfigFile = fs.readFileSync(brunoJsonPath, 'utf8');
const brunoConfig = JSON.parse(brunoConfigFile);
return brunoConfig;
};
const getCollectionRoot = (dir) => {
const collectionRootPath = path.join(dir, 'collection.bru');
const exists = fs.existsSync(collectionRootPath);
if (!exists) {
return {};
}
const content = fs.readFileSync(collectionRootPath, 'utf8');
return parseCollection(content);
};
const getFolderRoot = (dir) => {
const folderRootPath = path.join(dir, 'folder.bru');
const exists = fs.existsSync(folderRootPath);
if (!exists) {
return null;
}
const content = fs.readFileSync(folderRootPath, 'utf8');
return parseFolder(content);
};
const mergeHeaders = (collection, request, requestTreePath) => {
@@ -586,8 +612,6 @@ const sortByNameThenSequence = (items) => {
};
module.exports = {
FORMAT_CONFIG,
getCollectionFormat,
createCollectionJsonFromPathname,
mergeHeaders,
mergeVars,

View File

@@ -1,9 +1,7 @@
const path = require('node:path');
const fs = require('node:fs');
const { describe, it, expect } = require('@jest/globals');
const constants = require('../../src/constants');
const { createCollectionJsonFromPathname, getCollectionFormat, FORMAT_CONFIG } = require('../../src/utils/collection');
const { parseEnvironment } = require('@usebruno/filestore');
const { createCollectionJsonFromPathname } = require('../../src/utils/collection');
describe('create collection json from pathname', () => {
it('should throw an error when the pathname is not a valid bruno collection root', () => {
@@ -171,96 +169,4 @@ describe('create collection json from pathname', () => {
// tests
expect(c).toHaveProperty('items[4].request.tests', 'test(\"request level script\", function() {\n expect(\"test\").to.equal(\"test\");\n});');
});
it('creates a collection json from OpenCollection yml files', () => {
const collectionPathname = path.join(__dirname, './fixtures/opencollection/collection');
const c = createCollectionJsonFromPathname(collectionPathname);
expect(c).toBeDefined();
expect(c).toHaveProperty('format', 'yml');
expect(c).toHaveProperty('brunoConfig.opencollection', '1.0.0');
expect(c).toHaveProperty('brunoConfig.name', 'Test OpenCollection');
expect(c).toHaveProperty('brunoConfig.type', 'collection');
expect(c).toHaveProperty('brunoConfig.ignore', ['node_modules', '.git']);
expect(c).toHaveProperty('pathname', collectionPathname);
// collection root headers
expect(c).toHaveProperty('root.request.headers[0].name', 'X-Collection-Header');
expect(c).toHaveProperty('root.request.headers[0].value', 'collection-header-value');
expect(c).toHaveProperty('root.request.headers[0].enabled', true);
// folder
expect(c.items.some((i) => i.type === 'folder' && i.name === 'users')).toBe(true);
const usersFolder = c.items.find((i) => i.name === 'users');
expect(usersFolder).toHaveProperty('root.meta.name', 'Users');
expect(usersFolder).toHaveProperty('root.meta.seq', 1);
expect(usersFolder.pathname).toContain('users');
// request in folder - name comes from info.name, pathname is correct
const createUserReq = usersFolder.items.find((i) => i.name === 'Create User');
expect(createUserReq).toBeDefined();
expect(createUserReq).toHaveProperty('type', 'http-request');
expect(createUserReq).toHaveProperty('request.method', 'POST');
expect(createUserReq).toHaveProperty('request.url', 'https://api.example.com/users');
expect(createUserReq.pathname).toContain('create-user.yml');
// root level request - name comes from info.name, pathname is correct
const getUsersReq = c.items.find((i) => i.name === 'Get Users');
expect(getUsersReq).toBeDefined();
expect(getUsersReq).toHaveProperty('type', 'http-request');
expect(getUsersReq).toHaveProperty('request.method', 'GET');
expect(getUsersReq).toHaveProperty('request.url', 'https://api.example.com/users');
expect(getUsersReq.pathname).toContain('get-users.yml');
});
});
describe('getCollectionFormat', () => {
it('returns yml for OpenCollection', () => {
const collectionPath = path.join(__dirname, './fixtures/opencollection/collection');
expect(getCollectionFormat(collectionPath)).toBe('yml');
});
it('returns bru for Bruno collection', () => {
const collectionPath = path.join(__dirname, './fixtures/collection-json-from-pathname/collection');
expect(getCollectionFormat(collectionPath)).toBe('bru');
});
it('returns null for invalid path', () => {
const collectionPath = path.join(__dirname, './fixtures/collection-invalid');
expect(getCollectionFormat(collectionPath)).toBe(null);
});
});
describe('FORMAT_CONFIG', () => {
it('has correct config for yml format', () => {
expect(FORMAT_CONFIG.yml).toEqual({
ext: '.yml',
collectionFile: 'opencollection.yml',
folderFile: 'folder.yml'
});
});
it('has correct config for bru format', () => {
expect(FORMAT_CONFIG.bru).toEqual({
ext: '.bru',
collectionFile: 'collection.bru',
folderFile: 'folder.bru'
});
});
});
describe('OpenCollection environment parsing', () => {
it('parses YML environment files correctly', () => {
const envPath = path.join(__dirname, './fixtures/opencollection/collection/environments/dev.yml');
const envContent = fs.readFileSync(envPath, 'utf8');
const env = parseEnvironment(envContent, { format: 'yml' });
expect(env).toBeDefined();
expect(env).toHaveProperty('name', 'Development');
expect(env.variables).toHaveLength(2);
expect(env.variables[0]).toHaveProperty('name', 'baseUrl');
expect(env.variables[0]).toHaveProperty('value', 'https://api.dev.example.com');
expect(env.variables[1]).toHaveProperty('name', 'apiKey');
expect(env.variables[1]).toHaveProperty('value', 'dev-api-key-123');
});
});

View File

@@ -1,6 +0,0 @@
name: Development
variables:
- name: baseUrl
value: https://api.dev.example.com
- name: apiKey
value: dev-api-key-123

View File

@@ -1,8 +0,0 @@
info:
name: Get Users
type: http
seq: 1
http:
method: GET
url: https://api.example.com/users

View File

@@ -1,14 +0,0 @@
opencollection: "1.0.0"
info:
name: Test OpenCollection
extensions:
ignore:
- node_modules
- .git
request:
headers:
- name: X-Collection-Header
value: collection-header-value
enabled: true

View File

@@ -1,14 +0,0 @@
info:
name: Create User
type: http
seq: 1
http:
method: POST
url: https://api.example.com/users
body:
mode: json
json: |
{
"name": "John Doe"
}

View File

@@ -1,3 +0,0 @@
info:
name: Users
seq: 1

View File

@@ -46,7 +46,6 @@
"@rollup/plugin-typescript": "^12.1.2",
"@types/jest": "^29.5.14",
"babel-jest": "^29.7.0",
"form-data": "^4.0.0",
"is-ip": "^5.0.1",
"moment": "^2.29.4",
"rollup": "3.29.5",

View File

@@ -1,6 +1,5 @@
import { describe, it, expect } from '@jest/globals';
import { buildFormUrlEncodedPayload, isFormData } from './form-data';
import FormData from 'form-data';
import { buildFormUrlEncodedPayload } from './form-data';
describe('buildFormUrlEncodedPayload', () => {
it('should handle single key-value pair', () => {
@@ -111,53 +110,3 @@ describe('buildFormUrlEncodedPayload', () => {
expect(result).toEqual(expected);
});
});
describe('isFormData', () => {
it('should return true for objects with FormData constructor name', () => {
const mockFormData = {
constructor: { name: 'FormData' }
};
expect(isFormData(mockFormData)).toBe(true);
});
it('should return false for null', () => {
expect(isFormData(null)).toBe(false);
});
it('should return false for undefined', () => {
expect(isFormData(undefined)).toBe(false);
});
it('should return false for plain objects', () => {
expect(isFormData({})).toBe(false);
expect(isFormData({ key: 'value' })).toBe(false);
});
it('should return false for arrays', () => {
expect(isFormData([])).toBe(false);
expect(isFormData([1, 2, 3])).toBe(false);
});
it('should return false for primitives', () => {
expect(isFormData('string')).toBe(false);
expect(isFormData(123)).toBe(false);
expect(isFormData(true)).toBe(false);
});
it('should return false for objects with different constructor names', () => {
class CustomClass {}
const customObj = new CustomClass();
expect(isFormData(customObj)).toBe(false);
});
it('should return false for objects without constructor', () => {
const obj = Object.create(null);
expect(isFormData(obj)).toBe(false);
});
it('should return true for actual FormData instance from form-data library', () => {
const formData = new FormData();
formData.append('key', 'value');
expect(isFormData(formData)).toBe(true);
});
});

View File

@@ -31,15 +31,3 @@ export const buildFormUrlEncodedPayload = (params: Array<{ name: string; value:
return resultParams.toString();
};
/**
* Determines if the given object is a FormData instance.
* Supports native FormData (Node 18+, browser) and the 'form-data' npm package.
* @param obj - Object to check.
* @returns True if obj is a FormData instance, false otherwise.
*/
export const isFormData = (obj: unknown): boolean => {
// Check constructor name (works for both native FormData and form-data npm package)
// todo: checking constructor.name can produce false positives for objects that have a constructor.name property set to 'FormData', but this is rare.
return obj?.constructor?.name === 'FormData';
};

View File

@@ -5,8 +5,7 @@ export {
} from './url';
export {
buildFormUrlEncodedPayload,
isFormData
buildFormUrlEncodedPayload
} from './form-data';
export {

View File

@@ -5,6 +5,7 @@ const qs = require('qs');
const decomment = require('decomment');
const contentDispositionParser = require('content-disposition');
const mime = require('mime-types');
const FormData = require('form-data');
const { ipcMain } = require('electron');
const { each, get, extend, cloneDeep, merge } = require('lodash');
const { NtlmClient } = require('axios-ntlm');
@@ -35,7 +36,7 @@ const { cookiesStore } = require('../../store/cookies');
const registerGrpcEventHandlers = require('./grpc-event-handlers');
const { registerWsEventHandlers } = require('./ws-event-handlers');
const { getCertsAndProxyConfig } = require('./cert-utils');
const { buildFormUrlEncodedPayload, isFormData } = require('@usebruno/common').utils;
const { buildFormUrlEncodedPayload } = require('@usebruno/common').utils;
const ERROR_OCCURRED_WHILE_EXECUTING_REQUEST = 'Error occurred while executing the request!';
@@ -473,7 +474,7 @@ const registerNetworkIpc = (mainWindow) => {
}
if (contentTypeHeader && request.headers[contentTypeHeader] === 'multipart/form-data') {
if (!isFormData(request.data)) {
if (!(request.data instanceof FormData)) {
request._originalMultipartData = request.data;
request.collectionPath = collectionPath;
let form = createFormData(request.data, collectionPath);

View File

@@ -1,6 +1,6 @@
const { interpolate } = require('@usebruno/common');
const { each, forOwn, cloneDeep } = require('lodash');
const { isFormData } = require('@usebruno/common').utils;
const FormData = require('form-data');
const getContentType = (headers = {}) => {
let contentType = '';
@@ -132,7 +132,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
}));
}
} else if (contentType === 'multipart/form-data') {
if (Array.isArray(request?.data) && !isFormData(request.data)) {
if (Array.isArray(request?.data) && !(request.data instanceof FormData)) {
try {
request.data = request?.data?.map((d) => ({
...d,

View File

@@ -1,8 +1,8 @@
const { customAlphabet } = require('nanoid');
const iconv = require('iconv-lite');
const { cloneDeep } = require('lodash');
const FormData = require('form-data');
const { formatMultipartData } = require('./form-data');
const { isFormData } = require('@usebruno/common').utils;
// a customized version of nanoid without using _ and -
const uuid = () => {
@@ -135,7 +135,7 @@ const parseDataFromRequest = (request) => {
// File uploads are redacted, multipart FormData is formatted from original data for readability, and other types are stringified as-is.
if (request.mode === 'file') {
requestDataString = '<request body redacted>';
} else if (isFormData(request?.data) && Array.isArray(request._originalMultipartData)) {
} else if (request?.data instanceof FormData && Array.isArray(request._originalMultipartData)) {
const boundary = request.data._boundary || 'boundary';
requestDataString = formatMultipartData(request._originalMultipartData, boundary);
} else {

View File

@@ -1,6 +1,8 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const yaml = require('js-yaml');
const crypto = require('node:crypto');
const { writeFile, validateName, isValidCollectionDirectory } = require('./filesystem');
const { generateUidBasedOnHash } = require('./common');
const { withLock, getWorkspaceLockKey } = require('./workspace-lock');
@@ -23,29 +25,24 @@ const quoteYamlValue = (value) => {
const writeWorkspaceFileAtomic = async (workspacePath, content) => {
const workspaceFilePath = path.join(workspacePath, 'workspace.yml');
await writeFile(workspaceFilePath, content);
const tempFilePath = path.join(os.tmpdir(), `workspace-${Date.now()}-${crypto.randomBytes(16).toString('hex')}.yml`);
// Previous atomic write implementation commented out due to permission issues on Linux
// when temp directory is on a different filesystem (cross-device link error)
try {
await writeFile(tempFilePath, content);
// const tempFilePath = path.join(os.tmpdir(), `workspace-${Date.now()}-${crypto.randomBytes(16).toString('hex')}.yml`);
if (fs.existsSync(workspaceFilePath)) {
fs.unlinkSync(workspaceFilePath);
}
// try {
// await writeFile(tempFilePath, content);
// if (fs.existsSync(workspaceFilePath)) {
// fs.unlinkSync(workspaceFilePath);
// }
// fs.renameSync(tempFilePath, workspaceFilePath);
// } catch (error) {
// if (fs.existsSync(tempFilePath)) {
// try {
// fs.unlinkSync(tempFilePath);
// } catch (_) {}
// }
// throw error;
// }
fs.renameSync(tempFilePath, workspaceFilePath);
} catch (error) {
if (fs.existsSync(tempFilePath)) {
try {
fs.unlinkSync(tempFilePath);
} catch (_) {}
}
throw error;
}
};
const isValidCollectionEntry = (collection) => {

View File

@@ -193,7 +193,7 @@ export const toBrunoAuth = (auth: Auth | null | undefined): BrunoAuth | null =>
case 'bearer':
brunoAuth.mode = 'bearer';
brunoAuth.bearer = {
token: auth.token || ''
token: auth.token || null
};
break;

View File

@@ -81,10 +81,6 @@ export const toOpenCollectionBody = (body: BrunoHttpRequestBody | null | undefin
value: entry.value || (entry.type === 'file' ? [] : '')
};
if (entry?.contentType?.trim().length) {
multipartEntry.contentType = entry.contentType;
}
if (entry?.description?.trim().length) {
multipartEntry.description = entry.description;
}
@@ -204,7 +200,7 @@ export const toBrunoBody = (body: HttpRequestBody | null | undefined): BrunoHttp
type: entry.type,
name: entry.name || '',
value: entry.value || (entry.type === 'file' ? [] : ''),
contentType: entry.contentType || null,
contentType: null,
enabled: entry.disabled !== true
};

View File

@@ -8,8 +8,6 @@
"files": [
"dist",
"src",
"license.md",
"readme.md",
"package.json"
],
"scripts": {