Compare commits

..

22 Commits

Author SHA1 Message Date
Abhishek S Lal
c82b61209a refactor: remove HTML validation functions and simplify HtmlPreview component logic (#6730)
* refactor: remove HTML validation functions and simplify HtmlPreview component logic

* chore: fix playwright - removed body value check since response is rendered in webview

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
2026-01-08 00:14:39 +05:30
naman-bruno
aea4f6934e fix: workspace already opened (#6721) 2026-01-08 00:06:24 +05:30
Abhishek S Lal
77e47f361b fix: avoid error toast while pasting non-cURL value in GQL url field (#6718) 2026-01-08 00:06:14 +05:30
Bijin A B
5f6be0a82c chore: minor url bar alignment fixes and refactor (#6714) 2026-01-08 00:06:07 +05:30
Chirag Chandrashekhar
fb6e2816d5 fix: Query URL overflow pushes the action buttons outside view in gRPC and HTTP (#6706)
- Updated the QueryUrl component to handle text overflow. Now, overflow triggers scroll and does not move the action buttons out of view.
- Updated the GrpcQueryUrl component to handle text overflow. Now, overflow triggers scroll and does not move the action buttons out of view.
2026-01-08 00:05:59 +05:30
naman-bruno
65363266c6 improve: migration & default workspace handling 2026-01-08 00:05:48 +05:30
Abhishek S Lal
8182d6cef1 refactor: improve tab state management in ResponsiveTabs component (#6687) 2026-01-08 00:05:41 +05:30
naman-bruno
8300abe086 fix: auth in cli (#6675)
* fix: auth in cli

* fixes
2026-01-05 22:06:26 +05:30
Abhishek S Lal
a3809ce4b9 style: remove unnecessary padding from pre elements in StyledWrapper component (#6674) 2026-01-05 20:39:58 +05:30
gopu-bruno
adb46110dd Fix/ws environment input alignment (#6672)
* style: enhance EnvironmentList component with improved flex properties

* refactor: remove report issue link for YAML format in CreateCollection component
2026-01-05 20:28:56 +05:30
naman-bruno
7cc4c0993e fix: atomic write issue (#6664) 2026-01-05 17:29:03 +05:30
Abhishek S Lal
1030d02ac7 fix: update hover background color in dark theme (#6666) 2026-01-05 17:28:49 +05:30
Anoop M D
d616be7271 Merge pull request #6661 from naman-bruno/cli/opencollection
add: oc support for cli
2026-01-05 16:09:51 +05:30
naman-bruno
afd49d146f add: oc support for cli 2026-01-05 15:57:49 +05:30
Abhishek S Lal
97e43c4489 feat: add native select styling to global styles (#6660) 2026-01-05 15:52:34 +05:30
gopu-bruno
f9af22d586 fix: apply infoTip styling to CodeMirror tooltip (#6658)
* style: apply infoTip styling to CodeMirror tooltip

* fix: add CodeMirror lint tooltip warning and  error text colors

* fix: update font size of CodeMirror lint tooltip
2026-01-05 14:16:52 +05:30
sreelakshmi-bruno
8590bacd79 add license and readme to bruno query package (#6654) 2026-01-05 13:22:54 +05:30
Bijin A B
a7d1a349e3 fix: lighten dark pastel theme modal background color (#6653) 2026-01-04 21:46:04 +05:30
Anoop M D
d03d8f01a1 feat: update @opencollection/types to version 0.7.0 and add demo image to GenerateDocumentation component (#6651) 2026-01-04 21:28:19 +05:30
lohit
97c700beba fix: update logic for checking formdata instances (#6643)
* fix: update logic for checking formdata instance

* fix: isFormData logic update

* fix: review comment fix, add isFormData to @usebruno/common package

* fix: review comment fix
2026-01-04 21:27:07 +05:30
Sid
b6a27bc66c fix: reverse sorting order for websocket messages (#6652) 2026-01-04 16:54:27 +05:30
Bijin A B
76a2889206 fix(ux): fix sidebar invisible for environments tab, grpc and ws (#6648) 2026-01-04 12:40:22 +05:30
49 changed files with 1797 additions and 497 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.6.0",
"@opencollection/types": "~0.7.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.6.0",
"resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.6.0.tgz",
"integrity": "sha512-nasB4/1hIZ61xp2dnnZWhdH83f0t800VrSl3G2q+BtHabBqN/IG+j9BMOJg0hYZjAVx+Yhl1njkzUqkiX5+Q0g==",
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.7.0.tgz",
"integrity": "sha512-CSwdaHNPa2bNNBAOy++t6W9gBTExUJZW3aPkWyhAjasusThbvjymD/0uCLR50gCXSs0ezv61jsd19m9x+2DMtQ==",
"dev": true,
"license": "MIT"
},
@@ -33081,6 +33081,7 @@
"@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.6.0",
"@opencollection/types": "~0.7.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",

View File

@@ -259,10 +259,6 @@ const StyledWrapper = styled.div`
height: 400px;
display: flex;
flex-direction: column;
pre {
padding: 8px !important;
}
.w-full.h-full.relative.flex {
height: 100% !important;
@@ -321,7 +317,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,9 +191,12 @@ 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;
@@ -210,12 +213,14 @@ 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;
@@ -232,6 +237,7 @@ const StyledWrapper = styled.div`
display: flex;
gap: 2px;
margin-left: 4px;
flex-shrink: 0;
}
}

View File

@@ -6,7 +6,6 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
height: 100%;
margin-right: 0.5rem;
}
.method-dropdown-trigger {

View File

@@ -300,7 +300,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
<span className="text-xs font-medium" style={{ color: theme.request.grpc }}>gRPC</span>
</div>
</div>
<div className="flex items-center w-full input-container h-full relative">
<div className="flex items-center w-full input-container h-full relative overflow-auto">
<SingleLineEditor
ref={editorRef}
value={url}
@@ -313,117 +313,118 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
item={item}
/>
</div>
<div className="flex items-center h-full mx-2 gap-3" id="send-request">
<MethodDropdown
grpcMethods={grpcMethods}
selectedGrpcMethod={selectedGrpcMethod}
onMethodSelect={handleGrpcMethodSelect}
onMethodDropdownCreate={onMethodDropdownCreate}
/>
<div className="flex items-center h-full mr-2 gap-3" id="send-request">
<ProtoFileDropdown
collection={collection}
item={item}
isReflectionMode={isReflectionMode}
protoFilePath={protoFilePath}
showProtoDropdown={showProtoDropdown}
setShowProtoDropdown={setShowProtoDropdown}
onProtoDropdownCreate={onProtoDropdownCreate}
onReflectionModeToggle={handleReflectionModeToggle}
onProtoFileLoad={handleProtoFileLoad}
<ProtoFileDropdown
collection={collection}
item={item}
isReflectionMode={isReflectionMode}
protoFilePath={protoFilePath}
showProtoDropdown={showProtoDropdown}
setShowProtoDropdown={setShowProtoDropdown}
onProtoDropdownCreate={onProtoDropdownCreate}
onReflectionModeToggle={handleReflectionModeToggle}
onProtoFileLoad={handleProtoFileLoad}
/>
<div
className="infotip"
onClick={(e) => {
e.stopPropagation();
if (isReflectionMode) {
handleReflection(url, true);
} else if (protoFilePath) {
handleProtoFileLoad(protoFilePath, true);
} else {
toast.error('No proto file selected');
}
}}
>
<IconRefresh
color={theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
className={`${(isReflectionMode ? reflectionManagement.isLoadingMethods : protoFileManagement.isLoadingMethods) ? 'animate-spin' : 'cursor-pointer'}`}
data-testid="refresh-methods-icon"
/>
<div
className="infotip"
onClick={(e) => {
e.stopPropagation();
if (isReflectionMode) {
handleReflection(url, true);
} else if (protoFilePath) {
handleProtoFileLoad(protoFilePath, true);
} else {
toast.error('No proto file selected');
}
}}
>
<IconRefresh
color={theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
className={`${(isReflectionMode ? reflectionManagement.isLoadingMethods : protoFileManagement.isLoadingMethods) ? 'animate-spin' : 'cursor-pointer'}`}
data-testid="refresh-methods-icon"
/>
<span className="infotip-text text-xs">
{isReflectionMode ? 'Refresh server reflection' : 'Refresh proto file methods'}
</span>
</div>
<div
className="infotip"
onClick={(e) => {
e.stopPropagation();
handleGrpcurl(url);
}}
>
<IconCode
color={theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
/>
<span className="infotip-text text-xs">Generate grpcurl command</span>
</div>
<div
className="infotip"
onClick={(e) => {
e.stopPropagation();
if (!item.draft) return;
onSave();
}}
>
<IconDeviceFloppy
color={item.draft ? theme.draftColor : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
className={`${item.draft ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="infotip-text text-xs">
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
{isConnectionActive && isStreamingMethod && (
<div className="connection-controls relative flex items-center h-full gap-3">
<div className="infotip" onClick={handleCancelConnection} data-testid="grpc-cancel-connection-button">
<IconX color={theme.requestTabs.icon.color} strokeWidth={1.5} size={20} className="cursor-pointer" />
<span className="infotip-text text-xs">Cancel</span>
</div>
{isClientStreamingMethod && (
<div onClick={handleEndConnection} data-testid="grpc-end-connection-button">
<IconCheck
color={theme.colors.text.green}
strokeWidth={2}
size={20}
className="cursor-pointer"
/>
</div>
)}
</div>
)}
{(!isConnectionActive || !isStreamingMethod) && (
<div
className="cursor-pointer"
data-testid="grpc-send-request-button"
onClick={(e) => {
e.stopPropagation();
handleRun(e);
}}
>
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={20} />
</div>
)}
<span className="infotip-text text-xs">
{isReflectionMode ? 'Refresh server reflection' : 'Refresh proto file methods'}
</span>
</div>
<div
className="infotip"
onClick={(e) => {
e.stopPropagation();
handleGrpcurl(url);
}}
>
<IconCode
color={theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
/>
<span className="infotip-text text-xs">Generate grpcurl command</span>
</div>
<div
className="infotip"
onClick={(e) => {
e.stopPropagation();
if (!item.draft) return;
onSave();
}}
>
<IconDeviceFloppy
color={item.draft ? theme.draftColor : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
className={`${item.draft ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="infotip-text text-xs">
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
{isConnectionActive && isStreamingMethod && (
<div className="connection-controls relative flex items-center h-full gap-3">
<div className="infotip" onClick={handleCancelConnection} data-testid="grpc-cancel-connection-button">
<IconX color={theme.requestTabs.icon.color} strokeWidth={1.5} size={20} className="cursor-pointer" />
<span className="infotip-text text-xs">Cancel</span>
</div>
{isClientStreamingMethod && (
<div onClick={handleEndConnection} data-testid="grpc-end-connection-button">
<IconCheck
color={theme.colors.text.green}
strokeWidth={2}
size={20}
className="cursor-pointer"
/>
</div>
)}
</div>
)}
{(!isConnectionActive || !isStreamingMethod) && (
<div
className="cursor-pointer"
data-testid="grpc-send-request-button"
onClick={(e) => {
e.stopPropagation();
handleRun(e);
}}
>
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={20} />
</div>
)}
</div>
{isConnectionActive && isStreamingMethod && (
<div className="connection-status-strip"></div>

View File

@@ -95,7 +95,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
const curlCommandRegex = /^\s*curl\s/i;
if (!curlCommandRegex.test(pastedData)) {
toast.error('Invalid cURL command');
// Not a curl command, allow normal paste behavior
return;
}
event.preventDefault();
@@ -375,7 +375,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
</div>
<div
id="request-url"
className="h-full w-full flex flex-row input-container"
className="h-full w-full flex flex-row input-container overflow-auto"
>
<SingleLineEditor
ref={editorRef}
@@ -391,53 +391,54 @@ const QueryUrl = ({ item, collection, handleRun }) => {
item={item}
showNewlineArrow={true}
/>
<div className="flex items-center h-full mr-2 cursor-pointer" id="send-request" onClick={handleRun}>
<div
title="Generate Code"
className="infotip mr-3"
onClick={(e) => {
handleGenerateCode(e);
}}
>
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={20} className="cursor-pointer" />
<span className="infotiptext text-xs">Generate Code</span>
</div>
<div
title="Save Request"
className="infotip mr-3"
onClick={(e) => {
e.stopPropagation();
if (!hasChanges) return;
onSave();
}}
>
<IconDeviceFloppy
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="infotiptext text-xs">
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
{isLoading || item.response?.stream?.running ? (
<IconSquareRoundedX
color={theme.requestTabPanel.url.iconDanger}
strokeWidth={1.5}
size={20}
data-testid="cancel-request-icon"
onClick={handleCancelRequest}
/>
) : (
<IconArrowRight
color={theme.requestTabPanel.url.icon}
strokeWidth={1.5}
size={20}
data-testid="send-arrow-icon"
/>
)}
</div>
<div className="flex items-center h-full mx-2 gap-3 cursor-pointer" id="send-request" onClick={handleRun}>
<div
title="Generate Code"
className="infotip"
onClick={(e) => {
handleGenerateCode(e);
}}
>
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={20} className="cursor-pointer" />
<span className="infotiptext text-xs">Generate Code</span>
</div>
<div
title="Save Request"
className="infotip"
onClick={(e) => {
e.stopPropagation();
if (!hasChanges) return;
onSave();
}}
>
<IconDeviceFloppy
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="infotiptext text-xs">
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
{isLoading || item.response?.stream?.running ? (
<IconSquareRoundedX
color={theme.requestTabPanel.url.iconDanger}
strokeWidth={1.5}
size={20}
data-testid="cancel-request-icon"
onClick={handleCancelRequest}
/>
) : (
<IconArrowRight
color={theme.requestTabPanel.url.icon}
strokeWidth={1.5}
size={20}
data-testid="send-arrow-icon"
/>
)}
</div>
{generateCodeItemModalOpen && (
<GenerateCodeItem

View File

@@ -123,7 +123,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
return (
<StyledWrapper>
<div className="flex items-center h-full">
<div className="flex items-center input-container flex-1 w-full input-container pr-2 h-full relative">
<div className="flex items-center input-container flex-1 w-full h-full relative">
<div className="flex items-center justify-center px-[10px]">
<span className="text-xs font-medium method-ws">WS</span>
</div>
@@ -138,9 +138,9 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
collection={collection}
item={item}
/>
<div className="flex items-center h-full mr-2 cursor-pointer">
<div className="flex items-center h-full cursor-pointer gap-3 mx-3">
<div
className="infotip mr-3"
className="infotip"
onClick={(e) => {
e.stopPropagation();
if (!hasChanges) return;
@@ -159,7 +159,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
</div>
{connectionStatus === 'connected' && (
<div className="connection-controls relative flex items-center h-full gap-3 mr-3">
<div className="connection-controls relative flex items-center h-full">
<div className="infotip" onClick={(e) => handleDisconnect(e, true)}>
<IconPlugConnectedX
color={theme.colors.text.danger}
@@ -173,7 +173,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
)}
{connectionStatus !== 'connected' && (
<div className="connection-controls relative flex items-center h-full gap-3 mr-3">
<div className="connection-controls relative flex items-center h-full">
<div className="infotip" onClick={handleConnect}>
<IconPlugConnected
className={classnames('cursor-pointer', {

View File

@@ -1,6 +1,5 @@
import React, { useRef, useState, useEffect } from 'react';
import { isValidHtml } from 'utils/common/index';
import { escapeHtml, isValidHtmlSnippet } from 'utils/response/index';
import { escapeHtml } from 'utils/response/index';
const HtmlPreview = React.memo(({ data, baseUrl }) => {
const webviewContainerRef = useRef(null);
@@ -31,7 +30,7 @@ const HtmlPreview = React.memo(({ data, baseUrl }) => {
return () => mutationObserver.disconnect();
}, []);
if (isValidHtml(data) || isValidHtmlSnippet(data)) {
if (typeof data === 'string') {
const htmlContent = data.includes('<head>')
? data.replace('<head>', `<head><base href="${escapeHtml(baseUrl)}">`)
: `<head><base href="${escapeHtml(baseUrl)}"></head>${data}`;
@@ -60,8 +59,6 @@ const HtmlPreview = React.memo(({ data, baseUrl }) => {
displayContent = String(data);
} else if (typeof data === 'object') {
displayContent = JSON.stringify(data, null);
} else if (typeof data === 'string') {
displayContent = data;
} else {
displayContent = String(data);
}

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,6 +13,30 @@ 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.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -9,6 +9,7 @@ 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';
@@ -141,12 +142,16 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => {
<IconBook size={18} />
<span>Interactive API Documentation</span>
</h3>
<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 className="description mb-4">
Generate a standalone HTML file that can be hosted anywhere or shared with your team.
</p>
<ul className="features flex flex-col list-none gap-2.5 p-0 mb-5">
<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">
{FEATURES.map((feature) => (
<li key={feature} className="flex items-center gap-2.5">
<IconCheck size={16} className="check-icon flex-shrink-0" />
@@ -156,7 +161,7 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => {
</ul>
<p className="note m-0">
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.
The generated file loads OpenCollection's JavaScript and CSS files from a CDN, which requires an internet connection.
</p>
</div>
)}

View File

@@ -14,6 +14,18 @@ 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,7 +224,19 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
</p>
</Help>
{formik.values.format === 'yml' && (
<span className="beta-badge">Beta</span>
<>
<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>
</>
)}
</label>
<select
@@ -240,21 +252,6 @@ 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,9 +191,12 @@ 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;
@@ -210,12 +213,14 @@ 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;
@@ -232,6 +237,7 @@ const StyledWrapper = styled.div`
display: flex;
gap: 2px;
margin-left: 4px;
flex-shrink: 0;
}
.inline-action-btn {

View File

@@ -324,6 +324,27 @@ 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;
@@ -540,6 +561,25 @@ 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_1
bg: colors.GRAY_2
},
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.MANTLE,
hoverBg: palette.background.SURFACE0,
shadow: 'none',
border: palette.border.BORDER1,
separator: palette.border.BORDER1,

View File

@@ -10,10 +10,10 @@ const CALCULATION_DELAY_EXTENDED = 150;
const GAP_BETWEEN_LEFT_AND_RIGHT_CONTENT = 80;
const EXPANDABLE_HYSTERESIS = 20; // Buffer to prevent flickering at boundary
// Compare two tab arrays by their keys
const areTabArraysEqual = (a, b) => {
if (a.length !== b.length) return false;
return a.every((tab, index) => tab.key === b[index].key);
// Compare two key arrays for equality
const areKeysEqual = (prevKeys, newKeys) => {
if (prevKeys.length !== newKeys.length) return false;
return prevKeys.every((key, i) => key === newKeys[i]);
};
const ResponsiveTabs = ({
@@ -26,8 +26,8 @@ const ResponsiveTabs = ({
rightContentExpandedWidth, // Optional: width of the expandable element when expanded
expandableElementIndex = -1 // Optional: index of the expandable child element (-1 means last child)
}) => {
const [visibleTabs, setVisibleTabs] = useState([]);
const [overflowTabs, setOverflowTabs] = useState([]);
const [visibleTabKeys, setVisibleTabKeys] = useState([]);
const [overflowTabKeys, setOverflowTabKeys] = useState([]);
const [rightSideExpandable, setRightSideExpandable] = useState(false);
const tabsContainerRef = useRef(null);
@@ -79,9 +79,16 @@ const ResponsiveTabs = ({
}
}
// Only update state if arrays actually changed (prevents infinite loops)
setVisibleTabs((prev) => (areTabArraysEqual(prev, visible) ? prev : visible));
setOverflowTabs((prev) => (areTabArraysEqual(prev, overflow) ? prev : overflow));
// Extract keys and update state only if changed (prevents infinite loops)
const visibleKeys = visible.map((t) => t.key);
const overflowKeys = overflow.map((t) => t.key);
setVisibleTabKeys((prev) => {
return areKeysEqual(prev, visibleKeys) ? prev : visibleKeys;
});
setOverflowTabKeys((prev) => {
return areKeysEqual(prev, overflowKeys) ? prev : overflowKeys;
});
// Only calculate expandibility if rightContentExpandedWidth is provided
if (rightContentExpandedWidth && rightContentRef?.current) {
@@ -206,6 +213,10 @@ const ResponsiveTabs = ({
expandable: rightSideExpandable
});
// Map stored keys to fresh tab objects from props (ensures indicators stay up-to-date)
const visibleTabs = visibleTabKeys.map((key) => tabs.find((t) => t.key === key)).filter(Boolean);
const overflowTabs = overflowTabKeys.map((key) => tabs.find((t) => t.key === key)).filter(Boolean);
// Convert overflow tabs to MenuDropdown items format
const overflowMenuItems = useMemo(() => {
return overflowTabs.map((tab) => ({

View File

@@ -506,12 +506,6 @@ export function prettifyJavaScriptString(jsString) {
}
};
// Check if string contains valid HTML structure
export const isValidHtml = (str) => {
if (typeof str !== 'string' || !str.trim()) return false;
return /<\s*html[\s>]/i.test(str);
};
export function formatHexView(buffer) {
const width = 16;
let output = '';

View File

@@ -92,84 +92,6 @@ const isLikelyText = (buffer) => {
return (textChars / sampleSize) > 0.85;
};
/**
* Helper to detect if snippet is valid HTML
*/
export const isValidHtmlSnippet = (snippet) => {
if (!snippet || typeof snippet !== 'string') {
return false;
}
const trimmed = snippet.trim();
// Check for XML declaration
if (trimmed.startsWith('<?xml')) {
return false;
}
// Check for XML namespaces
if (/xmlns(:\w+)?=/.test(trimmed)) {
return false;
}
// Extract all tag names from the snippet
const tagMatches = trimmed.matchAll(/<\s*\/?([a-zA-Z][a-zA-Z0-9]*)/g);
const tags = [...tagMatches].map((match) => match[1].toLowerCase());
if (tags.length === 0) {
return false; // No tags found
}
// Define recognized HTML tags
const validHtmlTags = new Set([
'a', 'abbr', 'address', 'area', 'article', 'aside', 'audio',
'b', 'base', 'bdi', 'bdo', 'blockquote', 'body', 'br', 'button',
'canvas', 'caption', 'cite', 'code', 'col', 'colgroup',
'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dl', 'dt',
'em', 'embed',
'fieldset', 'figcaption', 'figure', 'footer', 'form',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html',
'i', 'iframe', 'img', 'input', 'ins',
'kbd',
'label', 'legend', 'li', 'link',
'main', 'map', 'mark', 'meta', 'meter',
'nav', 'noscript',
'object', 'ol', 'optgroup', 'option', 'output',
'p', 'param', 'picture', 'pre', 'progress',
'q',
'rp', 'rt', 'ruby',
's', 'samp', 'script', 'section', 'select', 'slot', 'small', 'source', 'span', 'strong', 'style', 'sub', 'summary', 'sup', 'svg',
'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track',
'u', 'ul',
'var', 'video',
'wbr'
]);
// Check if all tags are valid HTML tags
const allTagsValid = tags.every((tag) => validHtmlTags.has(tag));
if (!allTagsValid) {
return false; // Contains non-HTML tags
}
try {
// Parse with DOMParser
const parser = new DOMParser();
const doc = parser.parseFromString(trimmed, 'text/html');
// Check for parsing errors
const parseError = doc.querySelector('parsererror');
if (parseError) {
return false;
}
// HTML parser is lenient; if we reach here with valid tags, consider it valid
return true;
} catch (error) {
return false;
}
};
/**
* Decode only the first N bytes from a Base64 string
* Returns an empty buffer for invalid/missing input

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 } = require('../utils/collection');
const { findItemInCollection, createCollectionJsonFromPathname, getCallStack, FORMAT_CONFIG } = 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}.bru`);
: path.join(collectionPath, 'environments', `${env}${envExt}`);
const envFileExists = await exists(envFilePath);
if (!envFileExists) {
const errorPath = envFile || `environments/${env}.bru`;
const errorPath = envFile || `environments/${env}${envExt}`;
console.error(chalk.red(`Environment file not found: `) + chalk.dim(errorPath));
process.exit(constants.EXIT_STATUS.ERROR_ENV_NOT_FOUND);
}
const ext = path.extname(envFilePath).toLowerCase();
if (ext === '.json') {
// Parse Bruno schema JSON environment
const fileExt = path.extname(envFilePath).toLowerCase();
if (fileExt === '.json') {
let envJsonContent;
try {
envJsonContent = fs.readFileSync(envFilePath, 'utf8');
@@ -387,8 +387,12 @@ 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);
@@ -596,10 +600,11 @@ 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('.bru')) {
itemPathname = `${itemPathname}.bru`;
if (itemPathname && !itemPathname?.endsWith(ext)) {
itemPathname = `${itemPathname}${ext}`;
}
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 FormData = require('form-data');
const { isFormData } = require('@usebruno/common').utils;
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) && !(request.data instanceof FormData)) {
if (Array.isArray(request?.data) && !isFormData(request.data)) {
try {
request.data = request?.data?.map((d) => ({
...d,

View File

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

View File

@@ -3,7 +3,6 @@ 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');
@@ -25,7 +24,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 } = require('@usebruno/common').utils;
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData } = require('@usebruno/common').utils;
const onConsoleLog = (type, args) => {
console[type](...args);
@@ -429,7 +428,7 @@ const runSingleRequest = async function (
}
if (contentTypeHeader && request.headers[contentTypeHeader] === 'multipart/form-data') {
if (!(request?.data instanceof FormData)) {
if (!isFormData(request?.data)) {
request._originalMultipartData = request.data;
request.collectionPath = collectionPath;
let form = createFormData(request.data, collectionPath);

View File

@@ -7,116 +7,90 @@ const { parseRequest, parseCollection, parseFolder, stringifyCollection, stringi
const constants = require('../constants');
const chalk = require('chalk');
const createCollectionJsonFromPathname = (collectionPath) => {
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) => {
const filesInCurrentDir = fs.readdirSync(currentPath);
if (currentPath.includes('node_modules')) {
return;
}
const currentDirItems = [];
for (const file of filesInCurrentDir) {
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
if (stats.isDirectory()) {
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 (['collection.bru', 'folder.bru'].includes(file)) continue;
if (path.extname(filePath) !== '.bru') continue;
// get the request item
try {
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}`));
// 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);
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);
let collection = {
brunoConfig,
root: collectionRoot,
pathname: collectionPath,
items: collectionItems
};
return collection;
const FORMAT_CONFIG = {
yml: { ext: '.yml', collectionFile: 'opencollection.yml', folderFile: 'folder.yml' },
bru: { ext: '.bru', collectionFile: 'collection.bru', folderFile: 'folder.bru' }
};
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) {
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 brunoConfigFile = fs.readFileSync(brunoJsonPath, 'utf8');
const brunoConfig = JSON.parse(brunoConfigFile);
return brunoConfig;
};
const { brunoConfig, collectionRoot } = getCollectionConfig(collectionPath, format);
const { ext, collectionFile, folderFile } = FORMAT_CONFIG[format];
const environmentsPath = path.join(collectionPath, 'environments');
const getCollectionRoot = (dir) => {
const collectionRootPath = path.join(dir, 'collection.bru');
const exists = fs.existsSync(collectionRootPath);
if (!exists) {
return {};
}
const traverse = (currentPath) => {
if (currentPath.includes('node_modules')) return [];
const currentDirItems = [];
const content = fs.readFileSync(collectionRootPath, 'utf8');
return parseCollection(content);
};
for (const file of fs.readdirSync(currentPath)) {
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
const getFolderRoot = (dir) => {
const folderRootPath = path.join(dir, 'folder.bru');
const exists = fs.existsSync(folderRootPath);
if (!exists) {
return null;
}
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;
}
currentDirItems.push(folderItem);
} else {
if (file === collectionFile || file === folderFile || path.extname(filePath) !== ext) continue;
try {
const requestItem = parseRequest(fs.readFileSync(filePath, 'utf8'), { format });
currentDirItems.push({ name: file, ...requestItem, pathname: filePath });
} catch (err) {
console.warn(chalk.yellow(`Warning: Skipping invalid file ${filePath}\nError: ${err.message}`));
global.brunoSkippedFiles = global.brunoSkippedFiles || [];
global.brunoSkippedFiles.push({ path: filePath, error: err.message });
}
}
}
const content = fs.readFileSync(folderRootPath, 'utf8');
return parseFolder(content);
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);
};
return {
brunoConfig,
format,
root: collectionRoot,
pathname: collectionPath,
items: traverse(collectionPath)
};
};
const mergeHeaders = (collection, request, requestTreePath) => {
@@ -612,6 +586,8 @@ const sortByNameThenSequence = (items) => {
};
module.exports = {
FORMAT_CONFIG,
getCollectionFormat,
createCollectionJsonFromPathname,
mergeHeaders,
mergeVars,

View File

@@ -1,7 +1,9 @@
const path = require('node:path');
const fs = require('node:fs');
const { describe, it, expect } = require('@jest/globals');
const constants = require('../../src/constants');
const { createCollectionJsonFromPathname } = require('../../src/utils/collection');
const { createCollectionJsonFromPathname, getCollectionFormat, FORMAT_CONFIG } = require('../../src/utils/collection');
const { parseEnvironment } = require('@usebruno/filestore');
describe('create collection json from pathname', () => {
it('should throw an error when the pathname is not a valid bruno collection root', () => {
@@ -169,4 +171,96 @@ 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

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

View File

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

View File

@@ -0,0 +1,14 @@
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

@@ -0,0 +1,14 @@
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

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

View File

@@ -46,6 +46,7 @@
"@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,5 +1,6 @@
import { describe, it, expect } from '@jest/globals';
import { buildFormUrlEncodedPayload } from './form-data';
import { buildFormUrlEncodedPayload, isFormData } from './form-data';
import FormData from 'form-data';
describe('buildFormUrlEncodedPayload', () => {
it('should handle single key-value pair', () => {
@@ -110,3 +111,53 @@ 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,3 +31,15 @@ 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,7 +5,8 @@ export {
} from './url';
export {
buildFormUrlEncodedPayload
buildFormUrlEncodedPayload,
isFormData
} from './form-data';
export {

View File

@@ -147,7 +147,7 @@ class WorkspaceWatcher {
}
const watcher = chokidar.watch(workspaceFilePath, {
ignoreInitial: false,
ignoreInitial: true,
persistent: true,
ignorePermissionErrors: true,
awaitWriteFinish: {
@@ -156,8 +156,11 @@ class WorkspaceWatcher {
}
});
// Only listen for 'change' events - 'add' event is not needed because:
// 1. The workspace is already loaded when the watcher is started
// 2. ignoreInitial: true prevents firing for existing files
// 3. If workspace.yml is deleted and recreated, 'change' will catch it
watcher.on('change', () => handleWorkspaceFileChange(win, workspacePath));
watcher.on('add', () => handleWorkspaceFileChange(win, workspacePath));
self.watchers[workspacePath] = watcher;

View File

@@ -5,7 +5,6 @@ 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');
@@ -36,7 +35,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 } = require('@usebruno/common').utils;
const { buildFormUrlEncodedPayload, isFormData } = require('@usebruno/common').utils;
const ERROR_OCCURRED_WHILE_EXECUTING_REQUEST = 'Error occurred while executing the request!';
@@ -474,7 +473,7 @@ const registerNetworkIpc = (mainWindow) => {
}
if (contentTypeHeader && request.headers[contentTypeHeader] === 'multipart/form-data') {
if (!(request.data instanceof FormData)) {
if (!isFormData(request.data)) {
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 FormData = require('form-data');
const { isFormData } = require('@usebruno/common').utils;
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) && !(request.data instanceof FormData)) {
if (Array.isArray(request?.data) && !isFormData(request.data)) {
try {
request.data = request?.data?.map((d) => ({
...d,

View File

@@ -16,6 +16,7 @@ const OPENCOLLECTION_VERSION = '1.0.0';
const WORKSPACE_TYPE = 'workspace';
const DEFAULT_WORKSPACE_UID = 'default';
const MAX_WORKSPACE_CREATION_ATTEMPTS = 20;
const GLOBAL_ENV_BACKUP_FILE = 'global-environments-backup.json';
class DefaultWorkspaceManager {
constructor() {
@@ -23,6 +24,107 @@ class DefaultWorkspaceManager {
this.initializationPromise = null;
}
/**
* Finds all existing default workspace directories sorted by number (latest first)
*/
findExistingDefaultWorkspaces() {
const configDir = app.getPath('userData');
const baseWorkspacePath = path.join(configDir, 'default-workspace');
const workspaces = [];
// Check base path
if (fs.existsSync(baseWorkspacePath)) {
workspaces.push({ path: baseWorkspacePath, index: 0 });
}
// Check numbered paths
for (let i = 1; i < MAX_WORKSPACE_CREATION_ATTEMPTS; i++) {
const numberedPath = `${baseWorkspacePath}-${i}`;
if (fs.existsSync(numberedPath)) {
workspaces.push({ path: numberedPath, index: i });
}
}
// Sort by index descending (latest first)
return workspaces.sort((a, b) => b.index - a.index).map((w) => w.path);
}
/**
* Finds the latest valid default workspace from existing directories
*/
findLatestValidWorkspace() {
const workspaces = this.findExistingDefaultWorkspaces();
for (const workspacePath of workspaces) {
if (this.isValidDefaultWorkspace(workspacePath)) {
return workspacePath;
}
}
return null;
}
/**
* Recovers collections and environments from an existing workspace directory
*/
recoverDataFromWorkspace(workspacePath) {
const recovered = { collections: [], environments: [], activeEnvironmentUid: null };
try {
// Try to read workspace config for collections
const config = readWorkspaceConfig(workspacePath);
if (config.collections && Array.isArray(config.collections)) {
recovered.collections = config.collections.filter((c) => {
if (!isValidCollectionEntry(c)) return false;
const collectionPath = path.isAbsolute(c.path) ? c.path : path.resolve(workspacePath, c.path);
return isValidCollectionDirectory(collectionPath);
});
}
if (config.activeEnvironmentUid) {
recovered.activeEnvironmentUid = config.activeEnvironmentUid;
}
} catch (error) {
console.error('Failed to read workspace config during recovery:', error);
}
// Try to read environments from workspace environments directory
const envDir = path.join(workspacePath, 'environments');
if (fs.existsSync(envDir)) {
try {
const envFiles = fs.readdirSync(envDir).filter((f) => f.endsWith('.yml'));
for (const file of envFiles) {
const envPath = path.join(envDir, file);
recovered.environments.push({ path: envPath, name: path.basename(file, '.yml') });
}
} catch (error) {
console.error('Failed to read environments during recovery:', error);
}
}
return recovered;
}
/**
* Backs up global environments to filesystem
*/
backupGlobalEnvironments() {
try {
const globalEnvironments = globalEnvironmentsStore.getGlobalEnvironments();
const activeUid = globalEnvironmentsStore.getActiveGlobalEnvironmentUid();
if (globalEnvironments && globalEnvironments.length > 0) {
const configDir = app.getPath('userData');
const backupPath = path.join(configDir, GLOBAL_ENV_BACKUP_FILE);
const backup = {
environments: globalEnvironments,
activeGlobalEnvironmentUid: activeUid,
backupDate: new Date().toISOString()
};
fs.writeFileSync(backupPath, JSON.stringify(backup, null, 2), 'utf8');
}
} catch (error) {
console.error('Failed to backup global environments:', error);
}
}
getDefaultWorkspacePath() {
if (this.defaultWorkspacePath) {
return this.defaultWorkspacePath;
@@ -43,7 +145,11 @@ class DefaultWorkspaceManager {
preferences.general = {};
}
preferences.general.defaultWorkspacePath = workspacePath;
await savePreferences(preferences);
try {
await savePreferences(preferences);
} catch (error) {
console.error('Failed to save preferences:', error);
}
this.defaultWorkspacePath = workspacePath;
@@ -76,6 +182,7 @@ class DefaultWorkspaceManager {
const existingPath = this.getDefaultWorkspacePath();
// Case 1: Valid workspace exists at stored path
if (this.isValidDefaultWorkspace(existingPath)) {
this.defaultWorkspacePath = existingPath;
return {
@@ -86,8 +193,25 @@ class DefaultWorkspaceManager {
this.initializationPromise = (async () => {
try {
// Case 2: No path in preferences - check for existing default workspaces
if (!existingPath) {
const latestValid = this.findLatestValidWorkspace();
if (latestValid) {
await this.setDefaultWorkspacePath(latestValid);
return { workspacePath: latestValid, workspaceUid: this.getDefaultWorkspaceUid() };
}
}
// Case 3: Path exists but workspace is broken - try recovery
const hasExistingPath = existingPath && fs.existsSync(existingPath);
const recoverySource = hasExistingPath ? existingPath : this.findExistingDefaultWorkspaces()[0];
const recoveredData = recoverySource ? this.recoverDataFromWorkspace(recoverySource) : null;
const shouldMigrate = this.needsMigration();
const newWorkspacePath = await this.initializeDefaultWorkspace({ migrateFromPreferences: shouldMigrate });
const newWorkspacePath = await this.initializeDefaultWorkspace({
migrateFromPreferences: shouldMigrate,
recoveredData
});
return {
workspacePath: newWorkspacePath,
@@ -105,7 +229,7 @@ class DefaultWorkspaceManager {
}
async initializeDefaultWorkspace(options = {}) {
const { migrateFromPreferences = true } = options;
const { migrateFromPreferences = true, recoveredData = null } = options;
const configDir = app.getPath('userData');
const baseWorkspacePath = path.join(configDir, 'default-workspace');
@@ -136,9 +260,31 @@ class DefaultWorkspaceManager {
docs: ''
};
let migrationCleanupFn = null;
// Copy recovered environments to new workspace
if (recoveredData?.environments?.length > 0) {
const envDir = path.join(workspacePath, 'environments');
for (const env of recoveredData.environments) {
try {
const destPath = path.join(envDir, `${env.name}.yml`);
if (fs.existsSync(env.path)) {
fs.copyFileSync(env.path, destPath);
}
} catch (error) {
console.error('Failed to copy environment:', env.name, error);
}
}
if (recoveredData.activeEnvironmentUid) {
workspaceConfig.activeEnvironmentUid = recoveredData.activeEnvironmentUid;
}
}
// Apply recovered collections first (lower priority)
if (recoveredData?.collections?.length > 0) {
workspaceConfig.collections = recoveredData.collections;
}
if (migrateFromPreferences) {
migrationCleanupFn = await this.migrateFromPreferences(workspacePath, workspaceConfig);
await this.migrateFromPreferences(workspacePath, workspaceConfig);
}
const yamlContent = generateYamlContent(workspaceConfig);
@@ -146,10 +292,6 @@ class DefaultWorkspaceManager {
await this.setDefaultWorkspacePath(workspacePath);
if (migrationCleanupFn) {
migrationCleanupFn();
}
return workspacePath;
}
@@ -157,14 +299,18 @@ class DefaultWorkspaceManager {
const Store = require('electron-store');
const preferencesStore = new Store({ name: 'preferences' });
let shouldClearGlobalEnvStore = false;
let shouldDeleteWorkspaceDocs = false;
try {
const lastOpenedCollections = preferencesStore.get('lastOpenedCollections', []);
if (lastOpenedCollections && lastOpenedCollections.length > 0) {
const seenPaths = new Set();
// Build set of existing paths from recovered collections
const existingPaths = new Set(
(workspaceConfig.collections || []).map((c) => {
const collPath = path.isAbsolute(c.path) ? c.path : path.resolve(workspacePath, c.path);
return path.normalize(collPath);
})
);
const collections = lastOpenedCollections
.map((collectionPath) => {
if (!collectionPath || typeof collectionPath !== 'string') {
@@ -173,27 +319,26 @@ class DefaultWorkspaceManager {
const absolutePath = path.resolve(collectionPath);
const normalizedPath = path.normalize(absolutePath);
if (seenPaths.has(normalizedPath)) {
if (existingPaths.has(normalizedPath)) {
return null;
}
seenPaths.add(normalizedPath);
existingPaths.add(normalizedPath);
if (!isValidCollectionDirectory(absolutePath)) {
return null;
}
const collectionName = path.basename(absolutePath);
return {
path: absolutePath,
name: collectionName
};
return { path: absolutePath, name: path.basename(absolutePath) };
})
.filter((collection) => isValidCollectionEntry(collection));
workspaceConfig.collections = collections;
// Merge: preference collections come after recovered ones
workspaceConfig.collections = [...(workspaceConfig.collections || []), ...collections];
}
// Backup global environments before migrating
this.backupGlobalEnvironments();
const globalEnvironments = globalEnvironmentsStore.getGlobalEnvironments();
const activeGlobalEnvironmentUid = globalEnvironmentsStore.getActiveGlobalEnvironmentUid();
@@ -201,57 +346,53 @@ class DefaultWorkspaceManager {
const { stringifyEnvironment } = require('@usebruno/filestore');
const environmentsDir = path.join(workspacePath, 'environments');
// Get existing environment names to avoid overwriting recovered ones
let existingEnvNames = [];
if (fs.existsSync(environmentsDir)) {
try {
existingEnvNames = fs.readdirSync(environmentsDir)
.filter((f) => f.endsWith('.yml'))
.map((f) => f.replace('.yml', ''));
} catch (error) {
console.error('Failed to read environments directory:', error);
}
}
const existingEnvs = new Set(existingEnvNames);
for (const env of globalEnvironments) {
if (!env || !env.name || typeof env.name !== 'string') {
continue;
}
// Skip if environment already exists from recovery
if (existingEnvs.has(env.name)) {
continue;
}
const envFilePath = path.join(environmentsDir, `${env.name}.yml`);
const environment = {
name: env.name,
variables: env.variables || []
};
const environment = { name: env.name, variables: env.variables || [] };
const content = stringifyEnvironment(environment, { format: 'yml' });
await writeFile(envFilePath, content);
if (env.uid === activeGlobalEnvironmentUid) {
const newUid = generateUidBasedOnHash(envFilePath);
workspaceConfig.activeEnvironmentUid = newUid;
if (env.uid === activeGlobalEnvironmentUid && !workspaceConfig.activeEnvironmentUid) {
workspaceConfig.activeEnvironmentUid = generateUidBasedOnHash(envFilePath);
}
}
shouldClearGlobalEnvStore = true;
}
const defaultWorkspaceDocs = preferencesStore.get('preferences.defaultWorkspaceDocs', '');
if (defaultWorkspaceDocs) {
if (defaultWorkspaceDocs && !workspaceConfig.docs) {
workspaceConfig.docs = defaultWorkspaceDocs;
shouldDeleteWorkspaceDocs = true;
}
} catch (error) {
console.error('Failed to migrate from preferences:', error);
}
return () => {
try {
if (shouldClearGlobalEnvStore) {
const globalEnvStore = new Store({ name: 'global-environments' });
globalEnvStore.clear();
}
if (shouldDeleteWorkspaceDocs) {
preferencesStore.delete('preferences.defaultWorkspaceDocs');
}
} catch (cleanupError) {
console.error('Failed to cleanup after migration:', cleanupError);
}
};
}
needsMigration() {
const workspacePath = this.getDefaultWorkspacePath();
if (workspacePath && fs.existsSync(workspacePath)) {
// Only skip migration if workspace is valid, not just if it exists
if (workspacePath && this.isValidDefaultWorkspace(workspacePath)) {
return false;
}

View File

@@ -50,7 +50,8 @@ const defaultPreferences = {
hasLaunchedBefore: false
},
general: {
defaultCollectionLocation: ''
defaultCollectionLocation: '',
defaultWorkspacePath: ''
},
autoSave: {
enabled: false,
@@ -103,7 +104,8 @@ const preferencesSchema = Yup.object().shape({
hasLaunchedBefore: Yup.boolean()
}),
general: Yup.object({
defaultCollectionLocation: Yup.string().max(1024).nullable()
defaultCollectionLocation: Yup.string().max(1024).nullable(),
defaultWorkspacePath: Yup.string().max(1024).nullable()
}),
autoSave: Yup.object({
enabled: Yup.boolean(),

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 (request?.data instanceof FormData && Array.isArray(request._originalMultipartData)) {
} else if (isFormData(request?.data) && Array.isArray(request._originalMultipartData)) {
const boundary = request.data._boundary || 'boundary';
requestDataString = formatMultipartData(request._originalMultipartData, boundary);
} else {

View File

@@ -1,8 +1,6 @@
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');
@@ -25,24 +23,29 @@ const quoteYamlValue = (value) => {
const writeWorkspaceFileAtomic = async (workspacePath, content) => {
const workspaceFilePath = path.join(workspacePath, 'workspace.yml');
const tempFilePath = path.join(os.tmpdir(), `workspace-${Date.now()}-${crypto.randomBytes(16).toString('hex')}.yml`);
await writeFile(workspaceFilePath, content);
try {
await writeFile(tempFilePath, content);
// Previous atomic write implementation commented out due to permission issues on Linux
// when temp directory is on a different filesystem (cross-device link error)
if (fs.existsSync(workspaceFilePath)) {
fs.unlinkSync(workspaceFilePath);
}
// const tempFilePath = path.join(os.tmpdir(), `workspace-${Date.now()}-${crypto.randomBytes(16).toString('hex')}.yml`);
fs.renameSync(tempFilePath, workspaceFilePath);
} catch (error) {
if (fs.existsSync(tempFilePath)) {
try {
fs.unlinkSync(tempFilePath);
} catch (_) {}
}
throw error;
}
// 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;
// }
};
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 || null
token: auth.token || ''
};
break;

View File

@@ -81,6 +81,10 @@ 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;
}
@@ -200,7 +204,7 @@ export const toBrunoBody = (body: HttpRequestBody | null | undefined): BrunoHttp
type: entry.type,
name: entry.name || '',
value: entry.value || (entry.type === 'file' ? [] : ''),
contentType: null,
contentType: entry.contentType || null,
enabled: entry.disabled !== true
};

View File

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

View File

@@ -79,9 +79,6 @@ test.describe('Assertions - BRU Collection', () => {
// Verify response status
await expect(locators.response.statusCode()).toContainText('200');
// Verify response body contains "pong"
await expect(locators.response.body()).toContainText('pong', { timeout: 5000 });
});
await test.step('Delete assertion and save', async () => {

View File

@@ -0,0 +1,950 @@
import path from 'path';
import fs from 'fs';
import { test, expect } from '../../../playwright';
test.describe('Default Workspace Recovery and Backup', () => {
test.describe('Global Environments Backup', () => {
test('should create backup file for global environments during migration', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('global-env-backup');
// Setup: Create global-environments.json
const globalEnvData = {
environments: [
{
uid: 'env1abcdefghijk123456',
name: 'Production',
variables: [
{ uid: 'var1abcdefghijk123456', name: 'API_URL', value: 'https://api.prod.com', secret: false, type: 'text', enabled: true }
]
},
{
uid: 'env2abcdefghijk123456',
name: 'Staging',
variables: [
{ uid: 'var2abcdefghijk123456', name: 'API_URL', value: 'https://api.staging.com', secret: false, type: 'text', enabled: true }
]
}
],
activeGlobalEnvironmentUid: 'env1abcdefghijk123456'
};
fs.writeFileSync(
path.join(userDataPath, 'global-environments.json'),
JSON.stringify(globalEnvData)
);
// Also add lastOpenedCollections to trigger migration
const collectionPath = path.join(userDataPath, 'test-collection');
fs.mkdirSync(collectionPath, { recursive: true });
fs.writeFileSync(
path.join(collectionPath, 'bruno.json'),
JSON.stringify({ version: '1', name: 'Test', type: 'collection' })
);
fs.writeFileSync(
path.join(userDataPath, 'preferences.json'),
JSON.stringify({ lastOpenedCollections: [collectionPath] })
);
// Launch app - should trigger migration and create backup
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
// Verify backup file was created
const backupPath = path.join(userDataPath, 'global-environments-backup.json');
expect(fs.existsSync(backupPath)).toBe(true);
// Verify backup content
const backup = JSON.parse(fs.readFileSync(backupPath, 'utf8'));
expect(backup.environments).toHaveLength(2);
expect(backup.environments[0].name).toBe('Production');
expect(backup.environments[1].name).toBe('Staging');
expect(backup.activeGlobalEnvironmentUid).toBe('env1abcdefghijk123456');
expect(backup.backupDate).toBeDefined();
await app.context().close();
await app.close();
});
test('should preserve global environments backup across multiple app restarts', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('global-env-backup-persist');
// Setup: Create legacy global environments
const globalEnvData = {
environments: [
{ uid: 'env1abcdefghijk123456', name: 'Dev', variables: [] }
],
activeGlobalEnvironmentUid: 'env1abcdefghijk123456'
};
fs.writeFileSync(
path.join(userDataPath, 'global-environments.json'),
JSON.stringify(globalEnvData)
);
// Add collection to trigger migration
const collectionPath = path.join(userDataPath, 'test-collection');
fs.mkdirSync(collectionPath, { recursive: true });
fs.writeFileSync(
path.join(collectionPath, 'bruno.json'),
JSON.stringify({ version: '1', name: 'Test', type: 'collection' })
);
fs.writeFileSync(
path.join(userDataPath, 'preferences.json'),
JSON.stringify({ lastOpenedCollections: [collectionPath] })
);
// First launch
const app1 = await launchElectronApp({ userDataPath });
const page1 = await app1.firstWindow();
await page1.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
await app1.close();
// Verify backup exists
const backupPath = path.join(userDataPath, 'global-environments-backup.json');
expect(fs.existsSync(backupPath)).toBe(true);
const backupContentAfterFirst = fs.readFileSync(backupPath, 'utf8');
// Second launch - backup should still exist
const app2 = await launchElectronApp({ userDataPath });
const page2 = await app2.firstWindow();
await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
// Backup should not be modified on second launch
expect(fs.existsSync(backupPath)).toBe(true);
const backupContentAfterSecond = fs.readFileSync(backupPath, 'utf8');
expect(backupContentAfterSecond).toBe(backupContentAfterFirst);
await app2.context().close();
await app2.close();
});
});
test.describe('lastOpenedCollections Preservation', () => {
test('should NOT delete lastOpenedCollections from preferences after migration', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('preserve-last-opened');
// Setup: Create a valid collection
const collectionPath = path.join(userDataPath, 'my-collection');
fs.mkdirSync(collectionPath, { recursive: true });
fs.writeFileSync(
path.join(collectionPath, 'bruno.json'),
JSON.stringify({ version: '1', name: 'My Collection', type: 'collection' })
);
// Setup: Create preferences with lastOpenedCollections
fs.writeFileSync(
path.join(userDataPath, 'preferences.json'),
JSON.stringify({ lastOpenedCollections: [collectionPath] })
);
// Launch app - triggers migration
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
await app.close();
// Verify lastOpenedCollections is still in preferences
const prefsPath = path.join(userDataPath, 'preferences.json');
const prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf8'));
expect(prefs.lastOpenedCollections).toBeDefined();
expect(prefs.lastOpenedCollections).toContain(collectionPath);
});
});
test.describe('Workspace Discovery (No Path in Preferences)', () => {
test('should find and use existing valid default workspace when path not in preferences', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('discover-existing');
// Setup: Create a valid default workspace manually (without setting in preferences)
const workspacePath = path.join(userDataPath, 'default-workspace');
fs.mkdirSync(workspacePath, { recursive: true });
fs.mkdirSync(path.join(workspacePath, 'collections'), { recursive: true });
fs.mkdirSync(path.join(workspacePath, 'environments'), { recursive: true });
fs.writeFileSync(
path.join(workspacePath, 'workspace.yml'),
`opencollection: 1.0.0
info:
name: "My Workspace"
type: workspace
collections:
specs:
docs: ''
`
);
// Create empty preferences (no defaultWorkspacePath)
fs.writeFileSync(
path.join(userDataPath, 'preferences.json'),
JSON.stringify({})
);
// Launch app - should discover and use existing workspace
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
// UI always shows "My Workspace"
await expect(page.locator('.workspace-name')).toContainText('My Workspace');
// Should NOT create a new workspace
expect(fs.existsSync(path.join(userDataPath, 'default-workspace-1'))).toBe(false);
// Preferences should now have the path set (electron-store saves under 'preferences' key)
const prefs = JSON.parse(fs.readFileSync(path.join(userDataPath, 'preferences.json'), 'utf8'));
expect(prefs.preferences?.general?.defaultWorkspacePath).toBe(workspacePath);
await app.context().close();
await app.close();
});
test('should find latest numbered workspace when multiple exist and path not in preferences', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('discover-numbered');
// Setup: Create multiple numbered workspaces
const workspace0 = path.join(userDataPath, 'default-workspace');
const workspace1 = path.join(userDataPath, 'default-workspace-1');
const workspace2 = path.join(userDataPath, 'default-workspace-2');
for (const wsPath of [workspace0, workspace1, workspace2]) {
fs.mkdirSync(wsPath, { recursive: true });
fs.mkdirSync(path.join(wsPath, 'environments'), { recursive: true });
fs.writeFileSync(
path.join(wsPath, 'workspace.yml'),
`opencollection: 1.0.0
info:
name: "My Workspace"
type: workspace
collections:
specs:
docs: ''
`
);
}
// Create empty preferences
fs.writeFileSync(
path.join(userDataPath, 'preferences.json'),
JSON.stringify({})
);
// Launch app - should use workspace-2 (latest/highest number)
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
await expect(page.locator('.workspace-name')).toContainText('My Workspace');
// Verify the correct workspace was selected (workspace-2)
const prefs = JSON.parse(fs.readFileSync(path.join(userDataPath, 'preferences.json'), 'utf8'));
expect(prefs.preferences?.general?.defaultWorkspacePath).toBe(workspace2);
// No new workspace should be created
expect(fs.existsSync(path.join(userDataPath, 'default-workspace-3'))).toBe(false);
await app.context().close();
await app.close();
});
test('should skip invalid workspaces and use latest valid one', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('discover-skip-invalid');
// Setup: Create workspaces where latest is invalid
const workspace0 = path.join(userDataPath, 'default-workspace');
const workspace1 = path.join(userDataPath, 'default-workspace-1');
const workspace2 = path.join(userDataPath, 'default-workspace-2');
// workspace-0: valid
fs.mkdirSync(workspace0, { recursive: true });
fs.writeFileSync(
path.join(workspace0, 'workspace.yml'),
`opencollection: 1.0.0
info:
name: "My Workspace"
type: workspace
collections:
specs:
docs: ''
`
);
// workspace-1: valid (should be selected as highest valid)
fs.mkdirSync(workspace1, { recursive: true });
fs.writeFileSync(
path.join(workspace1, 'workspace.yml'),
`opencollection: 1.0.0
info:
name: "My Workspace"
type: workspace
collections:
specs:
docs: ''
`
);
// workspace-2: invalid (corrupt YAML)
fs.mkdirSync(workspace2, { recursive: true });
fs.writeFileSync(path.join(workspace2, 'workspace.yml'), 'invalid: yaml: [[[');
// Create empty preferences
fs.writeFileSync(
path.join(userDataPath, 'preferences.json'),
JSON.stringify({})
);
// Launch app - should skip workspace-2, use workspace-1
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
await expect(page.locator('.workspace-name')).toContainText('My Workspace');
// Verify workspace-1 was selected (not workspace-2 which is broken)
const prefs = JSON.parse(fs.readFileSync(path.join(userDataPath, 'preferences.json'), 'utf8'));
expect(prefs.preferences?.general?.defaultWorkspacePath).toBe(workspace1);
await app.context().close();
await app.close();
});
});
test.describe('Recovery from Broken Workspace', () => {
test('should recover collections from broken workspace to new workspace', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('recover-collections');
// Setup: Create a valid collection
const collectionPath = path.join(userDataPath, 'external-collection');
fs.mkdirSync(collectionPath, { recursive: true });
fs.writeFileSync(
path.join(collectionPath, 'bruno.json'),
JSON.stringify({ version: '1', name: 'External Collection', type: 'collection' })
);
// Setup: Create a "broken" workspace with valid workspace.yml but invalid internal state
const brokenWorkspace = path.join(userDataPath, 'default-workspace');
fs.mkdirSync(brokenWorkspace, { recursive: true });
fs.mkdirSync(path.join(brokenWorkspace, 'environments'), { recursive: true });
// Write a valid workspace.yml that references the collection
fs.writeFileSync(
path.join(brokenWorkspace, 'workspace.yml'),
`opencollection: 1.0.0
info:
name: "Old Workspace"
type: workspace
collections:
- name: "External Collection"
path: "${collectionPath}"
specs:
docs: ''
`
);
// Now corrupt it
fs.writeFileSync(path.join(brokenWorkspace, 'workspace.yml'), 'invalid: yaml: [[[');
// Set preferences to point to broken workspace
fs.writeFileSync(
path.join(userDataPath, 'preferences.json'),
JSON.stringify({
general: { defaultWorkspacePath: brokenWorkspace }
})
);
// Launch app - should recover collections and create new workspace
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
// New workspace should be created
const newWorkspace = path.join(userDataPath, 'default-workspace-1');
expect(fs.existsSync(newWorkspace)).toBe(true);
await app.context().close();
await app.close();
});
test('should recover environments from broken workspace to new workspace', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('recover-envs');
// Setup: Create a workspace with environments
const brokenWorkspace = path.join(userDataPath, 'default-workspace');
fs.mkdirSync(brokenWorkspace, { recursive: true });
const envDir = path.join(brokenWorkspace, 'environments');
fs.mkdirSync(envDir, { recursive: true });
// Create environment files
fs.writeFileSync(
path.join(envDir, 'production.yml'),
`name: production
variables:
- uid: var1
name: API_URL
value: https://api.prod.com
enabled: true
secret: false
type: text
`
);
fs.writeFileSync(
path.join(envDir, 'staging.yml'),
`name: staging
variables:
- uid: var2
name: API_URL
value: https://api.staging.com
enabled: true
secret: false
type: text
`
);
// Create valid workspace.yml first
fs.writeFileSync(
path.join(brokenWorkspace, 'workspace.yml'),
`opencollection: 1.0.0
info:
name: "Old Workspace"
type: workspace
collections:
specs:
docs: ''
`
);
// Now corrupt it
fs.writeFileSync(path.join(brokenWorkspace, 'workspace.yml'), 'broken: [[[');
// Set preferences
fs.writeFileSync(
path.join(userDataPath, 'preferences.json'),
JSON.stringify({
general: { defaultWorkspacePath: brokenWorkspace }
})
);
// Launch app
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
// New workspace should have recovered environments
const newWorkspace = path.join(userDataPath, 'default-workspace-1');
const newEnvDir = path.join(newWorkspace, 'environments');
expect(fs.existsSync(newEnvDir)).toBe(true);
expect(fs.existsSync(path.join(newEnvDir, 'production.yml'))).toBe(true);
expect(fs.existsSync(path.join(newEnvDir, 'staging.yml'))).toBe(true);
await app.context().close();
await app.close();
});
test('should use lastOpenedCollections as fallback when workspace config parsing fails', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('recover-fallback');
// Setup: Create a valid collection
const collectionPath = path.join(userDataPath, 'fallback-collection');
fs.mkdirSync(collectionPath, { recursive: true });
fs.writeFileSync(
path.join(collectionPath, 'bruno.json'),
JSON.stringify({ version: '1', name: 'Fallback Collection', type: 'collection' })
);
// Setup: Create broken workspace with NO valid config to recover from
const brokenWorkspace = path.join(userDataPath, 'default-workspace');
fs.mkdirSync(brokenWorkspace, { recursive: true });
fs.writeFileSync(path.join(brokenWorkspace, 'workspace.yml'), 'totally: broken: [[[');
// Set preferences with lastOpenedCollections AND point to broken workspace
fs.writeFileSync(
path.join(userDataPath, 'preferences.json'),
JSON.stringify({
general: { defaultWorkspacePath: brokenWorkspace },
lastOpenedCollections: [collectionPath]
})
);
// Launch app
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
// New workspace should have the collection from lastOpenedCollections
const newWorkspace = path.join(userDataPath, 'default-workspace-1');
expect(fs.existsSync(newWorkspace)).toBe(true);
const workspaceYml = fs.readFileSync(path.join(newWorkspace, 'workspace.yml'), 'utf8');
expect(workspaceYml).toContain('fallback-collection');
await app.context().close();
await app.close();
});
});
test.describe('Recovery from Non-Existent Workspace Path', () => {
test('should recover from previously created workspace when path in preferences does not exist', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('recover-from-old');
// Setup: Create a valid collection
const collectionPath = path.join(userDataPath, 'old-collection');
fs.mkdirSync(collectionPath, { recursive: true });
fs.writeFileSync(
path.join(collectionPath, 'bruno.json'),
JSON.stringify({ version: '1', name: 'Old Collection', type: 'collection' })
);
// Setup: Create an old default workspace (simulating previously created)
const oldWorkspace = path.join(userDataPath, 'default-workspace');
fs.mkdirSync(oldWorkspace, { recursive: true });
fs.mkdirSync(path.join(oldWorkspace, 'environments'), { recursive: true });
fs.writeFileSync(
path.join(oldWorkspace, 'workspace.yml'),
`opencollection: 1.0.0
info:
name: "My Workspace"
type: workspace
collections:
- name: "Old Collection"
path: "${collectionPath}"
specs:
docs: ''
`
);
// Set preferences to point to non-existent path
fs.writeFileSync(
path.join(userDataPath, 'preferences.json'),
JSON.stringify({
general: { defaultWorkspacePath: '/non/existent/path/workspace' }
})
);
// Launch app - should find and use the existing valid workspace
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
await expect(page.locator('.workspace-name')).toContainText('My Workspace');
// Since path doesn't exist but we have a valid workspace, it should use it
// OR create a new one recovering from the existing one
const prefs = JSON.parse(fs.readFileSync(path.join(userDataPath, 'preferences.json'), 'utf8'));
// Either uses the existing workspace or creates workspace-1
const usedExisting = prefs.preferences?.general?.defaultWorkspacePath === oldWorkspace;
const createdNew = fs.existsSync(path.join(userDataPath, 'default-workspace-1'));
expect(usedExisting || createdNew).toBe(true);
await app.context().close();
await app.close();
});
test('should recover from latest workspace when path does not exist and multiple workspaces exist', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('recover-from-latest');
// Create collection
const collectionPath = path.join(userDataPath, 'latest-collection');
fs.mkdirSync(collectionPath, { recursive: true });
fs.writeFileSync(
path.join(collectionPath, 'bruno.json'),
JSON.stringify({ version: '1', name: 'Latest Collection', type: 'collection' })
);
// Create older collection
const oldCollectionPath = path.join(userDataPath, 'old-collection');
fs.mkdirSync(oldCollectionPath, { recursive: true });
fs.writeFileSync(
path.join(oldCollectionPath, 'bruno.json'),
JSON.stringify({ version: '1', name: 'Old Collection', type: 'collection' })
);
// Create workspace-0 (older)
const workspace0 = path.join(userDataPath, 'default-workspace');
fs.mkdirSync(workspace0, { recursive: true });
fs.mkdirSync(path.join(workspace0, 'environments'), { recursive: true });
fs.writeFileSync(
path.join(workspace0, 'workspace.yml'),
`opencollection: 1.0.0
info:
name: "My Workspace"
type: workspace
collections:
- name: "Old Collection"
path: "${oldCollectionPath}"
specs:
docs: ''
`
);
// Create workspace-1 (newer - should be used)
const workspace1 = path.join(userDataPath, 'default-workspace-1');
fs.mkdirSync(workspace1, { recursive: true });
fs.mkdirSync(path.join(workspace1, 'environments'), { recursive: true });
fs.writeFileSync(
path.join(workspace1, 'workspace.yml'),
`opencollection: 1.0.0
info:
name: "My Workspace"
type: workspace
collections:
- name: "Latest Collection"
path: "${collectionPath}"
specs:
docs: ''
`
);
// Set preferences to non-existent path
fs.writeFileSync(
path.join(userDataPath, 'preferences.json'),
JSON.stringify({
general: { defaultWorkspacePath: '/deleted/workspace/path' }
})
);
// Launch app - should use workspace-1 (latest valid)
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
await expect(page.locator('.workspace-name')).toContainText('My Workspace');
// Verify workspace-1 was used (or workspace-2 was created recovering from workspace-1)
const prefs = JSON.parse(fs.readFileSync(path.join(userDataPath, 'preferences.json'), 'utf8'));
const usedWorkspace1 = prefs.preferences?.general?.defaultWorkspacePath === workspace1;
const createdWorkspace2 = fs.existsSync(path.join(userDataPath, 'default-workspace-2'));
expect(usedWorkspace1 || createdWorkspace2).toBe(true);
await app.context().close();
await app.close();
});
});
test.describe('App Restart After Breaking Workspace', () => {
test('should recover data after workspace is corrupted between app restarts', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('restart-after-break');
// Setup collection
const collectionPath = path.join(userDataPath, 'important-collection');
fs.mkdirSync(collectionPath, { recursive: true });
fs.writeFileSync(
path.join(collectionPath, 'bruno.json'),
JSON.stringify({ version: '1', name: 'Important Collection', type: 'collection' })
);
// First launch - creates workspace
const app1 = await launchElectronApp({ userDataPath });
const page1 = await app1.firstWindow();
await page1.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
// Verify workspace was created
const workspacePath = path.join(userDataPath, 'default-workspace');
expect(fs.existsSync(workspacePath)).toBe(true);
await app1.close();
// Now add collection to the workspace
const workspaceYmlPath = path.join(workspacePath, 'workspace.yml');
fs.writeFileSync(
workspaceYmlPath,
`opencollection: 1.0.0
info:
name: "My Workspace"
type: workspace
collections:
- name: "Important Collection"
path: "${collectionPath}"
specs:
docs: ''
`
);
// Create environment in workspace
const envDir = path.join(workspacePath, 'environments');
fs.mkdirSync(envDir, { recursive: true });
fs.writeFileSync(
path.join(envDir, 'myenv.yml'),
`name: myenv
variables:
- uid: v1
name: KEY
value: secret123
enabled: true
secret: false
type: text
`
);
// CORRUPT the workspace
fs.writeFileSync(workspaceYmlPath, 'corrupted: [[[');
// Second launch - should recover
const app2 = await launchElectronApp({ userDataPath });
const page2 = await app2.firstWindow();
await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
// New workspace should exist
const newWorkspace = path.join(userDataPath, 'default-workspace-1');
expect(fs.existsSync(newWorkspace)).toBe(true);
// Environment should be recovered
expect(fs.existsSync(path.join(newWorkspace, 'environments', 'myenv.yml'))).toBe(true);
await app2.context().close();
await app2.close();
});
test('should handle workspace deleted between app restarts', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('restart-after-delete');
// First launch - creates workspace
const app1 = await launchElectronApp({ userDataPath });
const page1 = await app1.firstWindow();
await page1.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
const workspacePath = path.join(userDataPath, 'default-workspace');
expect(fs.existsSync(workspacePath)).toBe(true);
await app1.close();
// DELETE the workspace directory
fs.rmSync(workspacePath, { recursive: true, force: true });
expect(fs.existsSync(workspacePath)).toBe(false);
// Second launch - should create new workspace
const app2 = await launchElectronApp({ userDataPath });
const page2 = await app2.firstWindow();
await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
// New workspace should be created at default-workspace (since it was deleted)
expect(fs.existsSync(workspacePath)).toBe(true);
expect(fs.existsSync(path.join(workspacePath, 'workspace.yml'))).toBe(true);
await app2.context().close();
await app2.close();
});
test('should preserve all data through multiple corruption and recovery cycles', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('multiple-recovery-cycles');
// Create collection
const collectionPath = path.join(userDataPath, 'persistent-collection');
fs.mkdirSync(collectionPath, { recursive: true });
fs.writeFileSync(
path.join(collectionPath, 'bruno.json'),
JSON.stringify({ version: '1', name: 'Persistent Collection', type: 'collection' })
);
// Create preferences with lastOpenedCollections (no global environments for simpler test)
fs.writeFileSync(
path.join(userDataPath, 'preferences.json'),
JSON.stringify({ lastOpenedCollections: [collectionPath] })
);
// First launch
const app1 = await launchElectronApp({ userDataPath });
const page1 = await app1.firstWindow();
await page1.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
await app1.close();
// Verify workspace-0 created
const ws0 = path.join(userDataPath, 'default-workspace');
expect(fs.existsSync(ws0)).toBe(true);
// Add an environment to workspace-0
const envDir0 = path.join(ws0, 'environments');
fs.mkdirSync(envDir0, { recursive: true });
fs.writeFileSync(
path.join(envDir0, 'PersistentEnv.yml'),
`name: PersistentEnv
variables: []
`
);
// Corrupt workspace-0
fs.writeFileSync(path.join(ws0, 'workspace.yml'), 'broken1: [[[');
// Second launch - recovery to workspace-1
const app2 = await launchElectronApp({ userDataPath });
const page2 = await app2.firstWindow();
await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
await app2.close();
// Verify workspace-1 created with recovered data
const ws1 = path.join(userDataPath, 'default-workspace-1');
expect(fs.existsSync(ws1)).toBe(true);
expect(fs.existsSync(path.join(ws1, 'environments', 'PersistentEnv.yml'))).toBe(true);
const ws1Yml = fs.readFileSync(path.join(ws1, 'workspace.yml'), 'utf8');
expect(ws1Yml).toContain('persistent-collection');
// Corrupt workspace-1
fs.writeFileSync(path.join(ws1, 'workspace.yml'), 'broken2: [[[');
// Third launch - recovery to workspace-2
const app3 = await launchElectronApp({ userDataPath });
const page3 = await app3.firstWindow();
await page3.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
// Verify workspace-2 created with all data preserved
const ws2 = path.join(userDataPath, 'default-workspace-2');
expect(fs.existsSync(ws2)).toBe(true);
expect(fs.existsSync(path.join(ws2, 'environments', 'PersistentEnv.yml'))).toBe(true);
const ws2Yml = fs.readFileSync(path.join(ws2, 'workspace.yml'), 'utf8');
expect(ws2Yml).toContain('persistent-collection');
await app3.context().close();
await app3.close();
});
});
test.describe('Edge Cases', () => {
test('should handle empty environments directory during recovery', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('empty-env-dir');
// Create workspace with empty environments dir
const workspace = path.join(userDataPath, 'default-workspace');
fs.mkdirSync(workspace, { recursive: true });
fs.mkdirSync(path.join(workspace, 'environments'), { recursive: true });
fs.writeFileSync(path.join(workspace, 'workspace.yml'), 'broken: [[[');
fs.writeFileSync(
path.join(userDataPath, 'preferences.json'),
JSON.stringify({ general: { defaultWorkspacePath: workspace } })
);
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
// Should not crash, new workspace created
const newWorkspace = path.join(userDataPath, 'default-workspace-1');
expect(fs.existsSync(newWorkspace)).toBe(true);
await app.context().close();
await app.close();
});
test('should handle missing environments directory during recovery', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('missing-env-dir');
// Create workspace WITHOUT environments dir
const workspace = path.join(userDataPath, 'default-workspace');
fs.mkdirSync(workspace, { recursive: true });
fs.writeFileSync(path.join(workspace, 'workspace.yml'), 'broken: [[[');
fs.writeFileSync(
path.join(userDataPath, 'preferences.json'),
JSON.stringify({ general: { defaultWorkspacePath: workspace } })
);
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
// Should not crash
expect(fs.existsSync(path.join(userDataPath, 'default-workspace-1'))).toBe(true);
await app.context().close();
await app.close();
});
test('should deduplicate collections between recovered and preference sources', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('dedup-collections');
// Create collection
const collectionPath = path.join(userDataPath, 'shared-collection');
fs.mkdirSync(collectionPath, { recursive: true });
fs.writeFileSync(
path.join(collectionPath, 'bruno.json'),
JSON.stringify({ version: '1', name: 'Shared Collection', type: 'collection' })
);
// Create workspace with the collection (but it will be corrupted)
const workspace = path.join(userDataPath, 'default-workspace');
fs.mkdirSync(workspace, { recursive: true });
fs.mkdirSync(path.join(workspace, 'environments'), { recursive: true });
// Workspace is created but immediately corrupted - no valid config to recover collections from
fs.writeFileSync(path.join(workspace, 'workspace.yml'), 'broken: [[[');
// Add same collection to lastOpenedCollections
fs.writeFileSync(
path.join(userDataPath, 'preferences.json'),
JSON.stringify({
general: { defaultWorkspacePath: workspace },
lastOpenedCollections: [collectionPath]
})
);
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
// New workspace should have collection only ONCE (no duplicates)
const newWorkspace = path.join(userDataPath, 'default-workspace-1');
const yml = fs.readFileSync(path.join(newWorkspace, 'workspace.yml'), 'utf8');
// Count collection entries by counting "- name:" patterns (each collection has one)
const collectionEntries = yml.match(/- name:/g);
expect(collectionEntries).toHaveLength(1);
await app.context().close();
await app.close();
});
test('should not overwrite recovered environments with global environments of same name', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('env-no-overwrite');
// Create workspace with environment
const workspace = path.join(userDataPath, 'default-workspace');
fs.mkdirSync(workspace, { recursive: true });
const envDir = path.join(workspace, 'environments');
fs.mkdirSync(envDir, { recursive: true });
// Environment in workspace (should be preserved)
fs.writeFileSync(
path.join(envDir, 'Production.yml'),
`name: Production
variables:
- uid: v1
name: URL
value: workspace-value
enabled: true
secret: false
type: text
`
);
// Corrupt workspace.yml
fs.writeFileSync(path.join(workspace, 'workspace.yml'), 'broken: [[[');
// Create global environments with same name but different value
fs.writeFileSync(
path.join(userDataPath, 'global-environments.json'),
JSON.stringify({
environments: [{
uid: 'env1abcdefghijk123456',
name: 'Production',
variables: [{ uid: 'var1abcdefghijk123456', name: 'URL', value: 'global-value', secret: false, type: 'text', enabled: true }]
}],
activeGlobalEnvironmentUid: 'env1abcdefghijk123456'
})
);
fs.writeFileSync(
path.join(userDataPath, 'preferences.json'),
JSON.stringify({ general: { defaultWorkspacePath: workspace } })
);
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
// Check new workspace has the recovered environment (not overwritten by global)
const newWorkspace = path.join(userDataPath, 'default-workspace-1');
const envContent = fs.readFileSync(path.join(newWorkspace, 'environments', 'Production.yml'), 'utf8');
expect(envContent).toContain('workspace-value');
expect(envContent).not.toContain('global-value');
await app.context().close();
await app.close();
});
});
});