mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 01:18:32 +00:00
Compare commits
7 Commits
main
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c82b61209a | ||
|
|
aea4f6934e | ||
|
|
77e47f361b | ||
|
|
5f6be0a82c | ||
|
|
fb6e2816d5 | ||
|
|
65363266c6 | ||
|
|
8182d6cef1 |
@@ -6,7 +6,6 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.method-dropdown-trigger {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
950
tests/workspace/default-workspace/recovery-and-backup.spec.ts
Normal file
950
tests/workspace/default-workspace/recovery-and-backup.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user