Compare commits

...

8 Commits

Author SHA1 Message Date
Sid
659e02ac44 feat: error-boundary + crash cache addition (#8056)
* internal emit chain for clearance

* fix: crash ui

* fix(ErrorBoundary): ensure cache clearing is awaited before force quitting

* test(e2e): environment persistence across collections

* test: migration test

* Update environment.spec.ts

* fix: reduce padding for dark mode app errors

* chore: re-add waitForReadyPage
2026-05-20 22:25:06 +05:30
sanish chirayath
cdba12387e feat: support newer Postman export format with collection envelope (#8038)
- Updated the Postman collection importer to handle collections wrapped in a { collection: { ... } } format.
- Enhanced the parsing logic to extract collection info correctly from both legacy and newer formats.
- Added a new test case for importing a Postman v2.1 collection with the wrapped format to ensure compatibility.
2026-05-20 22:19:31 +05:30
Sid
bbe8fa474a fix: handle transient requests during app quit flow in SaveRequestsModal (#8003)
* fix: handle transient requests during app quit flow in SaveRequestsModal

* test: non serial

* chore: fix theme

* fix: ui polish

* chore: import

* chore: cr
2026-05-20 22:17:18 +05:30
shubh-bruno
739dd3ca49 fix: save dotenv cmd s (#8002) 2026-05-14 20:17:12 +05:30
shubh-bruno
09f1146ede fix/send request shortcut issue (#7993) 2026-05-14 20:16:05 +05:30
Sid
b7b4b17c11 fix: correct the request type tabs in the snapshot (#7994) 2026-05-14 20:15:50 +05:30
Sid
54567bbd69 fix: example-request tab collision (#7989)
* fix: prevent response-example tabs from hijacking request sidebar selection

* fix: add selectors for examples with index

* test: better locator

* fix: duplicate name collision

* fix: refactor sidebar example handling functions for better clarity and reusability

* chore: cr comments
2026-05-14 20:10:52 +05:30
Sid
c12fe6cc12 fix: add tab error boundary (#7987) 2026-05-14 20:09:38 +05:30
50 changed files with 1836 additions and 95 deletions

View File

@@ -64,13 +64,16 @@ class CodeEditor extends React.Component {
componentDidMount() {
const variables = getAllVariables(this.props.collection, this.props.item);
const runShortcut = () => {
if (this.props.onRun) {
this.props.onRun();
return;
}
return CodeMirror.Pass;
};
/**
* No-op. We claim Cmd-Enter / Ctrl-Enter here only to suppress CodeMirror's
* sublime keymap default (insertLineAfter), which would otherwise insert a
* newline. sendRequest dispatch is owned by Mousetrap — the editor input has
* the `mousetrap` class (added below) so the global
* useKeybinding('sendRequest', …) in RequestTabPanel handles it, and only
* in request tabs. Falling through with CodeMirror.Pass when onRun is absent
* would re-introduce the newline in collection/folder-level editors.
*/
const runShortcut = () => {};
const editor = (this.editor = CodeMirror(this._node, {
value: this.props.value || '',

View File

@@ -30,13 +30,16 @@ class MultiLineEditor extends Component {
// Initialize CodeMirror as a single line editor
/** @type {import("codemirror").Editor} */
const variables = getAllVariables(this.props.collection, this.props.item);
const runShortcut = () => {
if (this.props.onRun) {
this.props.onRun();
return;
}
return CodeMirror.Pass;
};
/**
* No-op. We claim Cmd-Enter / Ctrl-Enter here only to suppress CodeMirror's
* sublime keymap default (insertLineAfter), which would otherwise insert a
* newline. sendRequest dispatch is owned by Mousetrap — the editor input has
* the `mousetrap` class (added below) so the global
* useKeybinding('sendRequest', …) in RequestTabPanel handles it, and only
* in request tabs. Falling through with CodeMirror.Pass when onRun is absent
* would re-introduce the newline in collection/folder-level editors.
*/
const runShortcut = () => {};
this.editor = CodeMirror(this.editorRef.current, {
lineWrapping: false,

View File

@@ -93,7 +93,7 @@ const useOpenAPISync = (collection) => {
uid: itemUid,
collectionUid: collection.uid,
requestPaneTab: item ? getDefaultRequestPaneTab(item) : undefined,
type: 'request'
type: item?.type ?? 'request'
}));
}
};

View File

@@ -53,6 +53,16 @@ export default class QueryEditor extends React.Component {
}
componentDidMount() {
/**
* No-op. We claim Cmd-Enter / Ctrl-Enter here only to suppress CodeMirror's
* sublime keymap default (insertLineAfter), which would otherwise insert a
* newline. sendRequest dispatch is owned by Mousetrap — the editor input has
* the `mousetrap` class (added below) so the global
* useKeybinding('sendRequest', …) in RequestTabPanel handles it, and only
* in request tabs.
*/
const runShortcut = () => {};
const editor = (this.editor = CodeMirror(this._node, {
value: this.props.value || '',
lineNumbers: true,
@@ -125,7 +135,9 @@ export default class QueryEditor extends React.Component {
}
},
'Cmd-F': 'findPersistent',
'Ctrl-F': 'findPersistent'
'Ctrl-F': 'findPersistent',
'Cmd-Enter': runShortcut,
'Ctrl-Enter': runShortcut
}
}));
if (editor) {

View File

@@ -0,0 +1,79 @@
import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import { useDispatch, useSelector } from 'react-redux';
import find from 'lodash/find';
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { NON_CLOSABLE_TAB_TYPES } from 'providers/ReduxStore/slices/tabs';
import Button from 'ui/Button';
import { useTheme } from 'providers/Theme';
class TabPanelErrorBoundaryInner extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('[TabPanelErrorBoundary] Unexpected render error:', error, errorInfo);
}
render() {
const { theme } = this.props;
if (this.state.hasError) {
const { isClosable, onClose } = this.props;
const errorMessage = this.state.error?.message;
return (
<div className="h-full flex flex-col items-center justify-center gap-3 px-6 text-center">
<IconAlertTriangle size={36} strokeWidth={1.5} style={{ color: theme?.status?.warning?.text }} />
<h2 className="text-lg font-medium">Something went wrong</h2>
{isClosable ? (
<p className="text-sm opacity-70 max-w-md">
This tab encountered an unexpected error. Close it and try reopening the request. If the
error repeats, the request file may be corrupt.
</p>
) : (
<p className="text-sm opacity-70 max-w-md">
This panel encountered an unexpected error. Restart Bruno to recover.
</p>
)}
{errorMessage && (
<p className="text-xs font-mono opacity-50 max-w-md break-all">{errorMessage}</p>
)}
{isClosable && (
<Button size="md" data-testid="tab-panel-error-boundary-close-tab" color="primary" onClick={onClose}>
Close Tab
</Button>
)}
</div>
);
}
return this.props.children;
}
}
const TabPanelErrorBoundary = ({ tabUid, children }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const focusedTab = find(tabs, (t) => t.uid === tabUid);
const isClosable = !focusedTab || !NON_CLOSABLE_TAB_TYPES.includes(focusedTab.type);
const { theme } = useTheme();
const handleClose = () => {
dispatch(closeTabs({ tabUids: [tabUid] }));
};
return (
<TabPanelErrorBoundaryInner isClosable={isClosable} onClose={handleClose} theme={theme}>
{children}
</TabPanelErrorBoundaryInner>
);
};
export default TabPanelErrorBoundary;

View File

@@ -335,6 +335,9 @@ const RequestTabPanel = () => {
let example = null;
if (item?.examples) {
example = item.examples.find((ex) => ex.uid === focusedTab.uid);
if (!example && typeof focusedTab.exampleIndex === 'number' && focusedTab.exampleIndex >= 0) {
example = item.examples[focusedTab.exampleIndex] || null;
}
if (!example && focusedTab.exampleName) {
example = item.examples.find((ex) => ex.name === focusedTab.exampleName);
}

View File

@@ -26,11 +26,15 @@ const ExampleTab = ({ tab, collection }) => {
if (!item?.examples) return null;
const byUid = item.examples.find((ex) => ex.uid === tab.uid);
if (byUid) return byUid;
if (typeof tab.exampleIndex === 'number' && tab.exampleIndex >= 0) {
const byIndex = item.examples[tab.exampleIndex];
if (byIndex) return byIndex;
}
if (tab.exampleName) {
return item.examples.find((ex) => ex.name === tab.exampleName);
}
return null;
}, [item?.examples, tab.uid, tab.exampleName]);
}, [item?.examples, tab.uid, tab.exampleIndex, tab.exampleName]);
const hasChanges = useMemo(() => hasExampleChanges(item, example?.uid), [item, example?.uid]);

View File

@@ -259,7 +259,11 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
} else if (tab.type === 'global-environment-settings') {
if (globalEnvironmentDraft) {
const { environmentUid, variables } = globalEnvironmentDraft;
dispatch(saveGlobalEnvironment({ variables, environmentUid }));
if (environmentUid?.startsWith('dotenv:')) {
window.dispatchEvent(new Event('dotenv-save'));
} else {
dispatch(saveGlobalEnvironment({ variables, environmentUid }));
}
}
} else if (tab.type === 'folder-settings') {
if (folder) {

View File

@@ -0,0 +1,21 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
background: ${({ theme }) => theme.background.crust};
border: 1px solid ${({ theme }) => theme.border.border0};
border-radius: ${({ theme }) => theme.border.radius.sm};
.request-name {
color: ${({ theme }) => theme.text};
}
.collection-name{
color: ${({ theme }) => theme.colors.text.subtext1};
}
`;
export default StyledWrapper;

View File

@@ -7,7 +7,8 @@ import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import Modal from 'components/Modal';
import Button from 'ui/Button';
import SaveTransientRequest from './index';
import SaveTransientRequest from 'components/SaveTransientRequest';
import StyledWrapper from './StyledWrapper';
const SaveTransientRequestContainer = () => {
const dispatch = useDispatch();
@@ -86,13 +87,13 @@ const SaveTransientRequestContainer = () => {
{modals.map((modal) => {
const { item, collection } = modal;
return (
<div
<StyledWrapper
key={item.uid}
className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded border border-gray-200"
className="flex items-center justify-between"
>
<div className="flex flex-col flex-1 min-w-0 mr-3">
<span className="text-sm text-gray-700 truncate">{item.name}</span>
<span className="text-xs text-gray-500 truncate">
<span className="text-sm request-name truncate">{item.name}</span>
<span className="text-xs collection-name truncate">
{collection.name}
</span>
</div>
@@ -105,13 +106,13 @@ const SaveTransientRequestContainer = () => {
>
Save
</Button>
</div>
</StyledWrapper>
);
})}
</div>
</div>
<div className="flex justify-end mt-6 pt-4 border-t">
<div className="flex justify-end mt-6 pt-4">
<Button color="danger" onClick={handleDiscardAll}>
Discard All
</Button>

View File

@@ -358,6 +358,8 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
return null;
}
const showNewFolderFooterButton = !showNewFolderInput && !isSelectingCollection && (filteredFolders.length > 0 && !searchText.trim());
return (
<StyledWrapper>
<Modal
@@ -539,7 +541,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
size="sm"
onClick={handleCreateNewCollection}
>
Save
Create
</Button>
</div>
</li>
@@ -736,7 +738,20 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
</ul>
) : (
<div className="folder-empty-state">
{searchText.trim() ? 'No folders found' : 'No folders available'}
<div className="flex flex-col items-center">
<span>
{searchText.trim() ? 'No folders found' : 'No folders available' }
</span>
<Button
type="button"
color="primary"
variant="ghost"
icon={<IconFolder size={16} strokeWidth={1.5} />}
onClick={handleShowNewFolder}
>
New Folder
</Button>
</div>
</div>
)}
</div>
@@ -747,7 +762,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
<div className="custom-modal-footer">
<div className="footer-left">
{!showNewFolderInput && !isSelectingCollection && (
{showNewFolderFooterButton && (
<Button
type="button"
color="primary"

View File

@@ -37,13 +37,16 @@ const ExampleItem = ({ example, item, collection }) => {
const indents = range((item.depth || 0) + 1);
const handleExampleClick = () => {
const exampleIndex = item?.examples?.findIndex((ex) => ex.uid === example.uid);
dispatch(addTab({
uid: example.uid,
collectionUid: collection.uid,
type: 'response-example',
itemUid: item.uid,
pathname: item.pathname,
exampleName: example.name
exampleName: example.name,
exampleIndex: typeof exampleIndex === 'number' && exampleIndex >= 0 ? exampleIndex : undefined
}));
};

View File

@@ -7,6 +7,7 @@ import Sidebar from 'components/Sidebar';
import StatusBar from 'components/StatusBar';
import AppTitleBar from 'components/AppTitleBar';
import ApiSpecPanel from 'components/ApiSpecPanel';
import TabPanelErrorBoundary from 'components/RequestTabPanel/TabPanelErrorBoundary';
// import ErrorCapture from 'components/ErrorCapture';
import { useSelector } from 'react-redux';
import { isElectron } from 'utils/common/platform';
@@ -145,7 +146,9 @@ export default function Main() {
) : (
<>
<RequestTabs />
<RequestTabPanel key={activeTabUid} />
<TabPanelErrorBoundary key={activeTabUid} tabUid={activeTabUid}>
<RequestTabPanel key={activeTabUid} />
</TabPanelErrorBoundary>
</>
)}
</section>

View File

@@ -6,7 +6,7 @@ class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
this.state = { hasError: false, clearCaches: false };
}
componentDidMount() {
@@ -21,6 +21,10 @@ class ErrorBoundary extends React.Component {
this.setState({ hasError: true, error, errorInfo });
}
async clearCache() {
await window.ipcRenderer.invoke('main:cache-clear');
}
returnToApp() {
const { ipcRenderer } = window;
ipcRenderer.invoke('open-file');
@@ -36,7 +40,7 @@ class ErrorBoundary extends React.Component {
render() {
if (this.state.hasError) {
return (
<div className="flex text-center justify-center p-20 h-full">
<div className="flex text-center justify-center p-10 h-full">
<div className="bg-white rounded-lg p-10 w-full">
<div className="m-auto" style={{ width: '256px' }}>
<Bruno width={256} />
@@ -63,8 +67,30 @@ class ErrorBoundary extends React.Component {
Return to App
</button>
<div className="text-red-500 mt-3">
<a href="" className="hover:underline cursor-pointer" onClick={this.forceQuit}>
<div className="mt-5 pt-4 border-t border-gray-100 flex flex-col items-center gap-2">
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer select-none hover:text-gray-800 transition">
<input
type="checkbox"
checked={this.state.clearCaches}
onChange={(e) => this.setState({ clearCaches: e.target.checked })}
className="cursor-pointer"
/>
Clear caches on quit
</label>
<a
href=""
className="text-sm text-red-400 border border-red-400 hover:text-red-600 px-4 py-2 rounded transition cursor-pointer"
onClick={async (e) => {
e.preventDefault();
try {
if (this.state.clearCaches) {
await this.clearCache();
}
} finally {
this.forceQuit();
}
}}
>
Force Quit
</a>
</div>

View File

@@ -8,7 +8,7 @@ import { findCollectionByUid, flattenItems, isItemARequest, hasRequestChanges, f
import { pluralizeWord } from 'utils/common';
import { getInvalidVariableNames } from 'utils/common/variables';
import { completeQuitFlow } from 'providers/ReduxStore/slices/app';
import { saveMultipleRequests, saveMultipleCollections, saveMultipleFolders, saveEnvironment, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { saveRequest, saveMultipleRequests, saveMultipleCollections, saveMultipleFolders, saveEnvironment, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { saveGlobalEnvironment, clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments';
import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
import { IconAlertTriangle } from '@tabler/icons';
@@ -150,6 +150,8 @@ const SaveRequestsModal = ({ onClose, forceCloseTabs = false, tabUidsToClose = [
const collectionDrafts = allDrafts.filter((d) => d.type === 'collection');
const folderDrafts = allDrafts.filter((d) => d.type === 'folder');
const requestDrafts = allDrafts.filter((d) => isItemARequest(d));
const transientRequestDrafts = requestDrafts.filter((d) => d.isTransient);
const nonTransientRequestDrafts = requestDrafts.filter((d) => !d.isTransient);
const collectionEnvironmentDrafts = allDrafts.filter((d) => d.type === 'collection-environment');
const globalEnvironmentDrafts = allDrafts.filter((d) => d.type === 'global-environment');
@@ -164,8 +166,18 @@ const SaveRequestsModal = ({ onClose, forceCloseTabs = false, tabUidsToClose = [
}
// Save all request drafts
if (requestDrafts.length > 0) {
await dispatch(saveMultipleRequests(requestDrafts));
if (nonTransientRequestDrafts.length > 0) {
await dispatch(saveMultipleRequests(nonTransientRequestDrafts));
}
if (transientRequestDrafts.length > 0) {
await Promise.all(
transientRequestDrafts.map((draft) =>
dispatch(saveRequest(draft.uid, draft.collectionUid, true)).catch(() => null)
)
);
onClose();
return;
}
// Save environment drafts, skipping any with invalid variable names

View File

@@ -157,6 +157,9 @@ const serializeSnapshot = async (state) => {
const workspacePathname = activeWorkspace?.pathname || '';
const collectionSnapshotKey = getWorkspaceCollectionSnapshotKey(workspacePathname, collection.pathname);
const existingCollection = (collectionSnapshotKey && existingSnapshotLookups.collectionsByWorkspaceAndPath?.[collectionSnapshotKey])
|| existingSnapshotLookups.collectionsByPath?.[normalizedPath]
|| null;
if (collectionSnapshotKey) {
serializedCollectionKeys.add(collectionSnapshotKey);
}
@@ -174,7 +177,17 @@ const serializeSnapshot = async (state) => {
);
const selectedEnvironment = (collection.environments || []).find((env) => env.uid === collection.activeEnvironmentUid);
const environmentPath = getCollectionEnvironmentPath(collection, selectedEnvironment, '');
const environmentPathFromRedux = getCollectionEnvironmentPath(collection, selectedEnvironment, '');
const selectedEnvironmentFromRedux = selectedEnvironment?.name || '';
const existingEnvironmentPath = existingCollection?.environment?.collection || existingCollection?.environmentPath || '';
const existingSelectedEnvironment = existingCollection?.selectedEnvironment || '';
const shouldPreserveExistingEnvironment = collection.mountStatus !== 'mounted'
&& !environmentPathFromRedux
&& !selectedEnvironmentFromRedux;
const environmentPath = shouldPreserveExistingEnvironment ? existingEnvironmentPath : environmentPathFromRedux;
const selectedEnvironmentName = shouldPreserveExistingEnvironment
? existingSelectedEnvironment
: selectedEnvironmentFromRedux;
snapshot.collections.push({
pathname: collection.pathname,
@@ -184,7 +197,7 @@ const serializeSnapshot = async (state) => {
global: globalEnvironments.activeGlobalEnvironmentUid || ''
},
environmentPath,
selectedEnvironment: selectedEnvironment?.name || '',
selectedEnvironment: selectedEnvironmentName,
isOpen: !collection.collapsed,
isMounted: collection.mountStatus === 'mounted',
activeTab: serializeActiveTab(activeTabInCollection, collection),

View File

@@ -34,6 +34,8 @@ taskMiddleware.startListening({
addTab({
uid: item.uid,
collectionUid: collection.uid,
type: item.type,
pathname: item.pathname,
requestPaneTab: getDefaultRequestPaneTab(item),
preview: task?.preview ?? true,
...(item.isTransient ? { isTransient: true } : {})
@@ -80,7 +82,8 @@ taskMiddleware.startListening({
type: 'response-example',
itemUid: item.uid,
pathname: item.pathname,
exampleName: example.name
exampleName: example.name,
exampleIndex: task.exampleIndex
}));
}
}

View File

@@ -8,6 +8,8 @@ import { isActiveTab as checkIsActiveTab, deserializeTab } from 'utils/snapshot'
const MAX_RECENTLY_CLOSED_TABS = 50;
export const NON_CLOSABLE_TAB_TYPES = ['workspaceOverview', 'workspaceEnvironments'];
const initialState = {
tabs: [],
activeTabUid: null,
@@ -18,7 +20,7 @@ const tabTypeAlreadyExists = (tabs, collectionUid, type) => {
return find(tabs, (tab) => tab.collectionUid === collectionUid && tab.type === type);
};
const findTabByPathname = (tabs, { collectionUid, pathname, type, exampleName }) => {
const findTabByPathname = (tabs, { collectionUid, pathname, type, exampleName, exampleIndex }) => {
if (!pathname || !collectionUid || !type) {
return null;
}
@@ -33,6 +35,10 @@ const findTabByPathname = (tabs, { collectionUid, pathname, type, exampleName })
}
if (type === 'response-example') {
if (typeof exampleIndex === 'number' && exampleIndex >= 0 && typeof tab.exampleIndex === 'number' && tab.exampleIndex >= 0) {
return tab.exampleIndex === exampleIndex;
}
return tab.exampleName === exampleName;
}
@@ -45,7 +51,7 @@ export const tabsSlice = createSlice({
initialState,
reducers: {
addTab: (state, action) => {
const { uid, collectionUid, type, requestPaneTab, preview, exampleUid, itemUid, pathname, exampleName, isTransient } = action.payload;
const { uid, collectionUid, type, requestPaneTab, preview, exampleUid, itemUid, pathname, exampleName, exampleIndex, isTransient } = action.payload;
const nonReplaceableTabTypes = [
'variables',
@@ -65,7 +71,7 @@ export const tabsSlice = createSlice({
return;
}
const existingPathnameTab = findTabByPathname(state.tabs, { collectionUid, pathname, type, exampleName });
const existingPathnameTab = findTabByPathname(state.tabs, { collectionUid, pathname, type, exampleName, exampleIndex });
if (existingPathnameTab) {
state.activeTabUid = existingPathnameTab.uid;
return;
@@ -112,6 +118,7 @@ export const tabsSlice = createSlice({
...(exampleUid ? { exampleUid } : {}),
...(itemUid ? { itemUid } : {}),
...(exampleName ? { exampleName } : {}),
...(typeof exampleIndex === 'number' ? { exampleIndex } : {}),
...(isTransient ? { isTransient: true } : {})
};
@@ -147,6 +154,7 @@ export const tabsSlice = createSlice({
...(exampleUid ? { exampleUid } : {}),
...(itemUid ? { itemUid } : {}),
...(exampleName ? { exampleName } : {}),
...(typeof exampleIndex === 'number' ? { exampleIndex } : {}),
...(isTransient ? { isTransient: true } : {})
});
state.activeTabUid = uid;
@@ -304,12 +312,10 @@ export const tabsSlice = createSlice({
const activeTab = find(state.tabs, (t) => t.uid === state.activeTabUid);
const tabUids = action.payload.tabUids || [];
const nonClosableTypes = ['workspaceOverview', 'workspaceEnvironments'];
// Push closed tabs onto the recently closed stack (LIFO)
// Exclude transient requests — they have no persisted file and can't be reopened
const closedTabs = state.tabs.filter((t) =>
tabUids.includes(t.uid) && !nonClosableTypes.includes(t.type) && !t.isTransient
tabUids.includes(t.uid) && !NON_CLOSABLE_TAB_TYPES.includes(t.type) && !t.isTransient
);
if (closedTabs.length > 0) {
state.recentlyClosedTabs.push(...closedTabs);
@@ -320,7 +326,7 @@ export const tabsSlice = createSlice({
}
state.tabs = filter(state.tabs, (t) =>
!tabUids.includes(t.uid) || nonClosableTypes.includes(t.type)
!tabUids.includes(t.uid) || NON_CLOSABLE_TAB_TYPES.includes(t.type)
);
if (activeTab && state.tabs.length) {

View File

@@ -12,7 +12,11 @@ export const getTabUidForItem = ({ itemUid, itemPathname, collectionUid }) => cr
return null;
}
const tabByPathname = tabs.find((tab) => tab.pathname === itemPathname && (!collectionUid || tab.collectionUid === collectionUid));
const tabByPathname = tabs.find((tab) => (
tab.type !== 'response-example'
&& tab.pathname === itemPathname
&& (!collectionUid || tab.collectionUid === collectionUid)
));
return tabByPathname?.uid || null;
});
@@ -41,7 +45,7 @@ export const isTabForItemActive = ({ itemUid, itemPathname, collectionUid }) =>
return false;
}
return activeTab.pathname === itemPathname;
return activeTab.type !== 'response-example' && activeTab.pathname === itemPathname;
});
export const isTabForItemPresent = ({ itemUid, itemPathname, collectionUid }) => createSelector([
@@ -51,5 +55,5 @@ export const isTabForItemPresent = ({ itemUid, itemPathname, collectionUid }) =>
return false;
}
return tab.uid === itemUid || (itemPathname && tab.pathname === itemPathname);
return tab.uid === itemUid || (itemPathname && tab.type !== 'response-example' && tab.pathname === itemPathname);
}));

View File

@@ -0,0 +1,90 @@
import { getTabUidForItem, isTabForItemActive, isTabForItemPresent } from './tab';
describe('tab selectors', () => {
const baseState = {
tabs: {
activeTabUid: null,
tabs: []
}
};
it('does not resolve request tab uid from response-example pathname fallback', () => {
const state = {
...baseState,
tabs: {
...baseState.tabs,
tabs: [
{
uid: 'example-1',
type: 'response-example',
pathname: '/c/req.bru',
collectionUid: 'c1'
}
]
}
};
const selector = getTabUidForItem({ itemUid: 'request-1', itemPathname: '/c/req.bru', collectionUid: 'c1' });
expect(selector(state)).toBeNull();
});
it('does not mark request active when only response-example tab is active on same pathname', () => {
const state = {
...baseState,
tabs: {
activeTabUid: 'example-1',
tabs: [
{
uid: 'example-1',
type: 'response-example',
pathname: '/c/req.bru',
collectionUid: 'c1'
}
]
}
};
const selector = isTabForItemActive({ itemUid: 'request-1', itemPathname: '/c/req.bru', collectionUid: 'c1' });
expect(selector(state)).toBe(false);
});
it('does not mark request present when only response-example tab exists for same pathname', () => {
const state = {
...baseState,
tabs: {
...baseState.tabs,
tabs: [
{
uid: 'example-1',
type: 'response-example',
pathname: '/c/req.bru',
collectionUid: 'c1'
}
]
}
};
const selector = isTabForItemPresent({ itemUid: 'request-1', itemPathname: '/c/req.bru', collectionUid: 'c1' });
expect(selector(state)).toBe(false);
});
it('still resolves regular request tab by pathname fallback', () => {
const state = {
...baseState,
tabs: {
...baseState.tabs,
tabs: [
{
uid: 'request-1',
type: 'http-request',
pathname: '/c/req.bru',
collectionUid: 'c1'
}
]
}
};
const selector = getTabUidForItem({ itemUid: 'missing-uid', itemPathname: '/c/req.bru', collectionUid: 'c1' });
expect(selector(state)).toBe('request-1');
});
});

View File

@@ -23,7 +23,8 @@ const postmanToBruno = (collection) => {
};
const isPostmanCollection = (data) => {
const info = data.info;
// Newer Postman exports wrap the collection in a { collection: { ... } } envelope
const info = data.info || data?.collection?.info;
if (!info || typeof info !== 'object') {
return false;
}

View File

@@ -344,11 +344,23 @@ export const findCollectionEnvironmentFromSnapshot = (collection, snapshotData =
};
const getAccessor = (tab) => {
if (tab.type === 'response-example') return 'pathname::exampleName';
if (tab.type === 'response-example') return 'pathname::exampleIndex';
if (SINGLETON_TAB_TYPES.has(tab.type)) return 'type';
return 'pathname';
};
const getDefaultRequestPaneTabForType = (type) => {
if (type === 'grpc-request' || type === 'ws-request') {
return 'body';
}
if (type === 'graphql-request') {
return 'query';
}
return 'params';
};
export const serializeTab = (tab, collection) => {
const accessor = getAccessor(tab);
const serialized = {
@@ -367,6 +379,23 @@ export const serializeTab = (tab, collection) => {
const item = findItemInCollection(collection, tab.itemUid);
serialized.pathname = item?.pathname || tab.pathname;
serialized.exampleName = tab.exampleName;
const exampleIndex = item?.examples?.findIndex((example) => example.uid === tab.uid);
if (typeof exampleIndex === 'number' && exampleIndex >= 0) {
serialized.exampleIndex = exampleIndex;
}
serialized.exampleUid = tab.uid;
if (tab.name) {
serialized.name = tab.name;
}
} else if (accessor === 'pathname::exampleIndex') {
const item = findItemInCollection(collection, tab.itemUid);
serialized.pathname = item?.pathname || tab.pathname;
const exampleIndex = item?.examples?.findIndex((example) => example.uid === tab.uid);
if (typeof exampleIndex === 'number' && exampleIndex >= 0) {
serialized.exampleIndex = exampleIndex;
}
serialized.exampleName = tab.exampleName;
serialized.exampleUid = tab.uid;
if (tab.name) {
serialized.name = tab.name;
}
@@ -408,6 +437,22 @@ export const serializeActiveTab = (tab, collection) => {
return { accessor, value: `${pathname}::${tab.exampleName}` };
}
if (accessor === 'pathname::exampleIndex') {
const item = findItemInCollection(collection, tab.itemUid);
const pathname = item?.pathname || tab.pathname;
const exampleIndex = item?.examples?.findIndex((example) => example.uid === tab.uid);
if (typeof exampleIndex === 'number' && exampleIndex >= 0) {
return { accessor, value: `${pathname}::${exampleIndex}` };
}
if (tab.exampleName) {
return { accessor: 'pathname::exampleName', value: `${pathname}::${tab.exampleName}` };
}
return { accessor, value: `${pathname}::-1` };
}
return { accessor: 'type', value: tab.type };
};
@@ -422,7 +467,7 @@ export const isActiveTab = (tab, activeTab, collection) => {
if (accessor === 'pathname') {
const item = findItemInCollection(collection, tab.uid);
return item?.pathname === value || tab.pathname === value;
return tab.type !== 'response-example' && (item?.pathname === value || tab.pathname === value);
}
if (accessor === 'pathname::exampleName') {
@@ -431,11 +476,63 @@ export const isActiveTab = (tab, activeTab, collection) => {
return `${pathname}::${tab.exampleName}` === value;
}
if (accessor === 'pathname::exampleIndex') {
const item = findItemInCollection(collection, tab.itemUid);
const pathname = item?.pathname || tab.pathname;
const exampleIndex = item?.examples?.findIndex((example) => example.uid === tab.uid);
return `${pathname}::${exampleIndex}` === value;
}
return false;
};
const resolveResponseExampleTabState = ({ item, pathname, exampleName, exampleIndex, exampleUid }) => {
const hasExamples = Array.isArray(item?.examples);
const hasProvidedExampleIndex = typeof exampleIndex === 'number' && exampleIndex >= 0;
const hasValidExampleIndex = hasExamples && hasProvidedExampleIndex && exampleIndex < item.examples.length;
let resolvedExample = null;
if (hasExamples) {
if (hasValidExampleIndex) {
resolvedExample = item.examples[exampleIndex] || null;
} else {
if (typeof exampleUid === 'string' && exampleUid.length > 0) {
resolvedExample = item.examples.find((ex) => ex.uid === exampleUid) || null;
}
if (!resolvedExample && exampleName) {
resolvedExample = item.examples.find((ex) => ex.name === exampleName) || null;
}
}
}
const resolvedExampleIndex = hasExamples && resolvedExample?.uid
? item.examples.findIndex((ex) => ex.uid === resolvedExample.uid)
: -1;
const fallbackExampleIdentity = hasProvidedExampleIndex
? `${pathname}::${exampleIndex}`
: `${pathname}::${exampleName}`;
let normalizedExampleIndex = null;
if (resolvedExampleIndex >= 0) {
normalizedExampleIndex = resolvedExampleIndex;
} else if (hasProvidedExampleIndex) {
normalizedExampleIndex = exampleIndex;
}
return {
uid: resolvedExample?.uid || fallbackExampleIdentity,
itemUid: item?.uid || pathname,
exampleName: resolvedExample?.name || exampleName,
exampleIndex: normalizedExampleIndex
};
};
export const deserializeTab = (snapshotTab, collection) => {
const { accessor, pathname, exampleName, type } = snapshotTab;
const { accessor, pathname, exampleName, exampleIndex, exampleUid, type } = snapshotTab;
const restoredRequestPaneTab = typeof snapshotTab.request?.tab === 'string' ? snapshotTab.request.tab : null;
const tab = {
collectionUid: collection.uid,
@@ -443,7 +540,7 @@ export const deserializeTab = (snapshotTab, collection) => {
preview: !snapshotTab.permanent,
name: snapshotTab.name || null,
pathname: pathname || null,
requestPaneTab: snapshotTab.request?.tab || 'params',
requestPaneTab: restoredRequestPaneTab || getDefaultRequestPaneTabForType(type),
requestPaneWidth: snapshotTab.request?.width || null,
requestPaneHeight: snapshotTab.request?.height || null,
responsePaneTab: snapshotTab.response?.tab || 'response',
@@ -461,16 +558,22 @@ export const deserializeTab = (snapshotTab, collection) => {
if (accessor === 'pathname' && pathname) {
const item = findItemInCollectionByPathname(collection, pathname);
const resolvedType = item?.type || type;
tab.type = resolvedType;
if (!restoredRequestPaneTab) {
tab.requestPaneTab = getDefaultRequestPaneTabForType(resolvedType);
}
tab.uid = item?.uid || pathname;
if (type === 'folder-settings') {
tab.folderUid = item?.uid || pathname;
}
} else if (accessor === 'pathname::exampleName' && pathname && exampleName) {
} else if ((accessor === 'pathname::exampleName' || accessor === 'pathname::exampleIndex') && pathname) {
const item = findItemInCollectionByPathname(collection, pathname);
const example = item?.examples?.find((ex) => ex.name === exampleName);
tab.uid = example?.uid || `${pathname}::${exampleName}`;
tab.itemUid = item?.uid || pathname;
tab.exampleName = exampleName;
const resolvedTabState = resolveResponseExampleTabState({ item, pathname, exampleName, exampleIndex, exampleUid });
tab.uid = resolvedTabState.uid;
tab.itemUid = resolvedTabState.itemUid;
tab.exampleName = resolvedTabState.exampleName;
tab.exampleIndex = resolvedTabState.exampleIndex;
} else if (needsTypeBasedFallback) {
const collectionUidFromSnapshot = typeof snapshotTab.collection === 'string' && snapshotTab.collection.length > 0
? snapshotTab.collection
@@ -555,9 +658,20 @@ export const getActiveTabFromSnapshot = async (collectionPathname, collection, s
if (accessor === 'type') {
snapshotTab = tabsSnapshot.tabs.find((t) => t.type === value);
} else if (accessor === 'pathname') {
snapshotTab = tabsSnapshot.tabs.find((t) => t.pathname === value);
snapshotTab = tabsSnapshot.tabs.find((t) => t.pathname === value && t.type !== 'response-example');
} else if (accessor === 'pathname::exampleName') {
snapshotTab = tabsSnapshot.tabs.find((t) => `${t.pathname}::${t.exampleName}` === value);
} else if (accessor === 'pathname::exampleIndex') {
snapshotTab = tabsSnapshot.tabs.find((t) => `${t.pathname}::${t.exampleIndex}` === value);
if (!snapshotTab) {
const [pathname, rawIndex] = value.split('::');
const exampleIndex = Number(rawIndex);
if (pathname && Number.isInteger(exampleIndex) && exampleIndex >= 0) {
const candidateTabs = tabsSnapshot.tabs.filter((t) => t.type === 'response-example' && t.pathname === pathname);
snapshotTab = candidateTabs[exampleIndex] || null;
}
}
}
if (!snapshotTab) return null;

View File

@@ -11,7 +11,13 @@ jest.mock('nanoid', () => {
};
});
const { deserializeTab, hydrateSnapshotLookups, hydrateCollectionTabs } = require('./index');
const {
deserializeTab,
hydrateSnapshotLookups,
hydrateCollectionTabs,
isActiveTab,
getActiveTabFromSnapshot
} = require('./index');
describe('hydrateSnapshotLookups', () => {
it('builds lookup maps from array-based snapshot schema', () => {
@@ -279,6 +285,297 @@ describe('deserializeTab', () => {
const tab = deserializeTab(snapshotTab, collection);
expect(tab.uid).toBe('collection-uid-preferences');
});
it('restores response example by index when duplicate names exist', () => {
const collectionWithDuplicateExamples = {
uid: 'collection-uid',
pathname: '/collections/a',
items: [
{
uid: 'request-1',
pathname: '/collections/a/request-1.bru',
examples: [
{ uid: 'example-1', name: 'dup' },
{ uid: 'example-2', name: 'dup' }
]
}
]
};
const snapshotTab = {
type: 'response-example',
accessor: 'pathname::exampleIndex',
pathname: '/collections/a/request-1.bru',
exampleName: 'dup',
exampleIndex: 1,
permanent: true
};
const tab = deserializeTab(snapshotTab, collectionWithDuplicateExamples);
expect(tab.uid).toBe('example-2');
expect(tab.exampleName).toBe('dup');
expect(tab.exampleIndex).toBe(1);
});
it('falls back to first matching name when example index is missing or invalid', () => {
const collectionWithDuplicateExamples = {
uid: 'collection-uid',
pathname: '/collections/a',
items: [
{
uid: 'request-1',
pathname: '/collections/a/request-1.bru',
examples: [
{ uid: 'example-1', name: 'dup' },
{ uid: 'example-2', name: 'dup' }
]
}
]
};
const snapshotTab = {
type: 'response-example',
accessor: 'pathname::exampleIndex',
pathname: '/collections/a/request-1.bru',
exampleName: 'dup',
exampleIndex: 99,
permanent: true
};
const tab = deserializeTab(snapshotTab, collectionWithDuplicateExamples);
expect(tab.uid).toBe('example-1');
expect(tab.exampleName).toBe('dup');
expect(tab.exampleIndex).toBe(0);
});
it('keeps example uid and index consistent when uid fallback is used', () => {
const collectionWithDuplicateExamples = {
uid: 'collection-uid',
pathname: '/collections/a',
items: [
{
uid: 'request-1',
pathname: '/collections/a/request-1.bru',
examples: [
{ uid: 'example-1', name: 'dup' },
{ uid: 'example-2', name: 'dup' }
]
}
]
};
const snapshotTab = {
type: 'response-example',
accessor: 'pathname::exampleIndex',
pathname: '/collections/a/request-1.bru',
exampleName: 'dup',
exampleUid: 'example-1',
exampleIndex: 99,
permanent: true
};
const tab = deserializeTab(snapshotTab, collectionWithDuplicateExamples);
expect(tab.uid).toBe('example-1');
expect(tab.exampleName).toBe('dup');
expect(tab.exampleIndex).toBe(0);
});
it('defaults grpc request pane to body when snapshot request tab is missing', () => {
const snapshotTab = {
type: 'grpc-request',
accessor: 'pathname',
pathname: '/collections/a/grpc-request.bru',
permanent: true
};
const tab = deserializeTab(snapshotTab, collection);
expect(tab.requestPaneTab).toBe('body');
});
it('defaults websocket request pane to body when snapshot request tab is missing', () => {
const snapshotTab = {
type: 'ws-request',
accessor: 'pathname',
pathname: '/collections/a/ws-request.bru',
permanent: true
};
const tab = deserializeTab(snapshotTab, collection);
expect(tab.requestPaneTab).toBe('body');
});
it('resolves generic request snapshot type to item type using pathname', () => {
const collectionWithGrpcItem = {
...collection,
items: [
{
uid: 'grpc-item-1',
pathname: '/collections/a/grpc-item.bru',
type: 'grpc-request'
}
]
};
const snapshotTab = {
type: 'request',
accessor: 'pathname',
pathname: '/collections/a/grpc-item.bru',
permanent: true
};
const tab = deserializeTab(snapshotTab, collectionWithGrpcItem);
expect(tab.type).toBe('grpc-request');
expect(tab.requestPaneTab).toBe('body');
});
it('defaults to body for resolved websocket item type when generic snapshot request tab is missing', () => {
const collectionWithWsItem = {
...collection,
items: [
{
uid: 'ws-item-1',
pathname: '/collections/a/ws-item.bru',
type: 'ws-request'
}
]
};
const snapshotTab = {
type: 'request',
accessor: 'pathname',
pathname: '/collections/a/ws-item.bru',
permanent: true
};
const tab = deserializeTab(snapshotTab, collectionWithWsItem);
expect(tab.type).toBe('ws-request');
expect(tab.requestPaneTab).toBe('body');
});
it('defaults graphql request pane to query when snapshot request tab is missing', () => {
const snapshotTab = {
type: 'graphql-request',
accessor: 'pathname',
pathname: '/collections/a/graphql-request.bru',
permanent: true
};
const tab = deserializeTab(snapshotTab, collection);
expect(tab.requestPaneTab).toBe('query');
});
it('resolves generic request snapshot type to graphql-request item type using pathname', () => {
const collectionWithGraphqlItem = {
...collection,
items: [
{
uid: 'graphql-item-1',
pathname: '/collections/a/graphql-item.bru',
type: 'graphql-request'
}
]
};
const snapshotTab = {
type: 'request',
accessor: 'pathname',
pathname: '/collections/a/graphql-item.bru',
permanent: true
};
const tab = deserializeTab(snapshotTab, collectionWithGraphqlItem);
expect(tab.type).toBe('graphql-request');
expect(tab.requestPaneTab).toBe('query');
});
});
describe('active tab matching', () => {
it('does not mark response example tab as active for pathname accessor', () => {
const collection = {
uid: 'collection-uid',
pathname: '/collections/a',
items: [
{
uid: 'request-1',
pathname: '/collections/a/request-1.bru',
examples: [{ uid: 'example-1', name: 'Sample' }]
}
]
};
const tab = {
uid: 'example-1',
type: 'response-example',
itemUid: 'request-1',
pathname: '/collections/a/request-1.bru',
exampleName: 'Sample'
};
const activeTab = {
accessor: 'pathname',
value: '/collections/a/request-1.bru'
};
expect(isActiveTab(tab, activeTab, collection)).toBe(false);
});
});
describe('getActiveTabFromSnapshot', () => {
beforeEach(() => {
global.window = global.window || {};
global.window.ipcRenderer = {
invoke: jest.fn()
};
});
afterEach(() => {
delete global.window.ipcRenderer;
});
it('resolves response example using index accessor when duplicate names exist', async () => {
const collection = {
uid: 'collection-uid',
pathname: '/collections/a',
items: [
{
uid: 'request-1',
pathname: '/collections/a/request-1.bru',
examples: [
{ uid: 'example-1', name: 'dup' },
{ uid: 'example-2', name: 'dup' }
]
}
]
};
window.ipcRenderer.invoke.mockResolvedValue({
activeTab: {
accessor: 'pathname::exampleIndex',
value: '/collections/a/request-1.bru::1'
},
tabs: [
{
type: 'response-example',
accessor: 'pathname::exampleIndex',
pathname: '/collections/a/request-1.bru',
exampleName: 'dup',
exampleIndex: 0,
permanent: true
},
{
type: 'response-example',
accessor: 'pathname::exampleIndex',
pathname: '/collections/a/request-1.bru',
exampleName: 'dup',
exampleIndex: 1,
permanent: true
}
]
});
const activeTab = await getActiveTabFromSnapshot('/collections/a', collection, null, null);
expect(activeTab.uid).toBe('example-2');
expect(activeTab.exampleIndex).toBe(1);
});
});
describe('hydrateCollectionTabs', () => {
@@ -419,4 +716,4 @@ describe('hydrateCollectionTabs', () => {
expect(dispatch).toHaveBeenCalledTimes(1);
expect(restoreTabs).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -950,7 +950,10 @@ const importPostmanV2Collection = async (collection, { useWorkers = false }) =>
const parsePostmanCollection = async (collection, { useWorkers = false }) => {
try {
let schema = get(collection, 'info.schema');
// Newer Postman exports wrap the collection in a { collection: { ... } } envelope
const parsedCollection = collection.collection?.info ? collection.collection : collection;
let schema = get(parsedCollection, 'info.schema');
let v2Schemas = [
'https://schema.getpostman.com/json/collection/v2.0.0/collection.json',
@@ -960,7 +963,7 @@ const parsePostmanCollection = async (collection, { useWorkers = false }) => {
];
if (v2Schemas.includes(schema)) {
return await importPostmanV2Collection(collection, { useWorkers });
return await importPostmanV2Collection(parsedCollection, { useWorkers });
}
throw new Error('Unsupported Postman schema version. Only Postman Collection v2.0 and v2.1 are supported.');

View File

@@ -1120,6 +1120,15 @@ describe('postman-collection', () => {
expect(headers[1].value).toBe('example.com');
});
it('should unwrap and import a Postman collection with { collection: { ... } } envelope', async () => {
const wrappedCollection = {
collection: { ...postmanCollection }
};
const brunoCollection = await postmanToBruno(wrappedCollection);
expect(brunoCollection).toMatchObject(expectedOutput);
});
it('should handle string headers with no value', async () => {
const collectionWithNoValueHeader = {
info: {

View File

@@ -471,6 +471,11 @@ app.on('ready', async () => {
registerSystemMonitorIpc(mainWindow, systemMonitor);
registerGitIpc(mainWindow);
registerOpenAPISyncIpc(mainWindow);
// Internal delegator
ipcMain.handle('main:cache-clear', async () => {
ipcMain.emit('internal:snapshot:reset');
});
});
// Quit the app once all windows are closed

View File

@@ -10,6 +10,14 @@ const registerSnapshotIpc = () => {
return snapshotManager.getTabs(collectionPathname, workspacePathname);
});
ipcMain.on('internal:snapshot:reset', () => {
try {
snapshotManager.resetSnapshot();
} catch (err) {
// digest error if reset fails
}
});
ipcMain.handle('renderer:snapshot:save', async (event, data) => {
return snapshotManager.saveSnapshot(data);
});

View File

@@ -28,11 +28,13 @@ const buildWorkspaceCollectionLookupKey = (workspacePathname, collectionPathname
const tabSchema = yup.object({
type: yup.string().required(),
accessor: yup.string().oneOf(['pathname', 'pathname::exampleName', 'type']).required(),
accessor: yup.string().oneOf(['pathname', 'pathname::exampleName', 'pathname::exampleIndex', 'type']).required(),
pathname: yup.string().nullable(),
permanent: yup.boolean().required(),
name: yup.string().optional(),
exampleName: yup.string().optional(),
exampleIndex: yup.number().integer().min(0).optional(),
exampleUid: yup.string().optional(),
request: yup.object({
tab: yup.string(),
width: yup.number().nullable(),
@@ -46,7 +48,7 @@ const tabSchema = yup.object({
});
const activeTabSchema = yup.object({
accessor: yup.string().oneOf(['pathname', 'pathname::exampleName', 'type']).required(),
accessor: yup.string().oneOf(['pathname', 'pathname::exampleName', 'pathname::exampleIndex', 'type']).required(),
value: yup.string().required()
});
@@ -185,6 +187,23 @@ class SnapshotManager {
}
}
resetSnapshot() {
this.store.delete('activeWorkspacePath');
this.store.set('workspaces', (this.store.store?.workspaces ?? []).map((d) => {
d.lastActiveCollectionPathname = undefined;
return d;
}));
this.store.set('collections', (this.store.store?.collections ?? []).map((d) => {
if ('tabs' in d) {
d.tabs = [];
}
if ('activeTab' in d) {
d.activeTab = undefined;
}
return d;
}));
}
setCollection(pathname, data) {
const normalizedPath = normalizeLookupKey(pathname);
if (!normalizedPath) {
@@ -500,7 +519,7 @@ class SnapshotManager {
return null;
}
if (!['pathname', 'pathname::exampleName', 'type'].includes(activeTab.accessor) || typeof activeTab.value !== 'string') {
if (!['pathname', 'pathname::exampleName', 'pathname::exampleIndex', 'type'].includes(activeTab.accessor) || typeof activeTab.value !== 'string') {
return null;
}

View File

@@ -31,6 +31,28 @@ function isTracingEnabled(testInfo: TestInfo): boolean {
return !!(testInfo as any)._tracing.traceOptions();
}
// Wait for the Electron app to have a ready, loaded window.
// Handles cases where the first window is slow to appear (e.g. on Windows).
export async function waitForReadyPage(app: ElectronApplication, options: { timeout?: number } = {}): Promise<Page> {
const { timeout = 45000 } = options;
let page: Page | null = null;
try {
page = await app.firstWindow();
} catch {
page = null;
}
if (!page) {
page = await app.waitForEvent('window', { timeout });
}
await page.locator('[data-app-state="loaded"]').waitFor({ timeout });
await page.waitForTimeout(200);
return page;
}
async function usePageWithTracing(
context: BrowserContext,
page: Page,

View File

@@ -0,0 +1,66 @@
{
"collection": {
"info": {
"name": "Postman v2.1 Wrapped Collection",
"description": "Test collection using newer Postman export format with collection envelope",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_postman_id": "beaf4d12-a72a-47cb-902d-2942a68a59c4"
},
"item": [
{
"name": "Get Users",
"request": {
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{token}}",
"type": "text"
}
],
"url": {
"raw": "{{baseUrl}}/users",
"host": ["{{baseUrl}}"],
"path": ["users"],
"query": [
{
"key": "page",
"value": "1"
}
]
}
},
"response": []
},
{
"name": "Create User",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"John Doe\",\n \"email\": \"john@example.com\"\n}"
},
"url": {
"raw": "{{baseUrl}}/users",
"host": ["{{baseUrl}}"],
"path": ["users"]
}
},
"response": []
}
],
"variable": [
{
"key": "baseUrl",
"value": "https://api.example.com"
}
]
}
}

View File

@@ -0,0 +1,17 @@
import { test } from '../../../playwright';
import * as path from 'path';
import { closeAllCollections, importCollection } from '../../utils/page';
test.describe('Import Postman Collection v2.1 (wrapped format)', () => {
test.afterEach(async ({ page }) => {
await closeAllCollections(page);
});
test('Import Postman Collection v2.1 with collection envelope successfully', async ({ page, createTmpDir }) => {
const postmanFile = path.resolve(__dirname, 'fixtures', 'postman-v21-wrapped.json');
await importCollection(page, postmanFile, await createTmpDir('postman-v21-wrapped-test'), {
expectedCollectionName: 'Postman v2.1 Wrapped Collection'
});
});
});

View File

@@ -0,0 +1,5 @@
{
"version": "1",
"name": "invalid-tags-bru",
"type": "collection"
}

View File

@@ -0,0 +1,3 @@
meta {
name: invalid-tags-bru
}

View File

@@ -0,0 +1,12 @@
meta {
name: invalid-tags-bru-request
type: http
seq: 1
tags: smoke
}
get {
url: https://httpbin.org/get
body: none
auth: none
}

View File

@@ -0,0 +1,13 @@
meta:
name: control-yml-request
type: http
seq: 3
info:
name: control-yml-request
type: http
seq: 3
http:
method: GET
url: https://httpbin.org/get

View File

@@ -0,0 +1,15 @@
meta:
name: invalid-tags-yml-request
type: http
seq: 1
tags: smoke
info:
name: invalid-tags-yml-request
type: http
seq: 1
tags: smoke
http:
method: GET
url: https://httpbin.org/get

View File

@@ -0,0 +1,6 @@
opencollection: "1.0.0"
info:
name: invalid-tags-yml
bundled: false

View File

@@ -0,0 +1,19 @@
meta:
name: valid-tags-yml-request
type: http
seq: 2
tags:
- smoke
- sanity
info:
name: valid-tags-yml-request
type: http
seq: 2
tags:
- smoke
- sanity
http:
method: GET
url: https://httpbin.org/get

View File

@@ -0,0 +1,12 @@
{
"lastOpenedCollections": [
"{{collectionPath}}/invalid-tags-bru",
"{{collectionPath}}/invalid-tags-yml"
],
"preferences": {
"onboarding": {
"hasLaunchedBefore": true,
"hasSeenWelcomeModal": true
}
}
}

View File

@@ -0,0 +1,75 @@
import { expect, Page, test } from '../../../playwright';
import { openRequest } from '../../utils/page';
import { buildCommonLocators } from '../../utils/page/locators';
// NOTE: Rewritten instead of `selectRequestPaneTab` from actions.ts because we want to avoid assertion
// of tab active state
const openRequestSettingsTab = async (page: Page) => {
const requestPane = page.locator('[data-testid="request-pane"] > .px-4');
await expect(requestPane).toBeVisible();
const settingsTab = requestPane.locator('.tabs').getByRole('tab', { name: 'Settings' });
if (await settingsTab.isVisible().catch(() => false)) {
await settingsTab.click();
return;
}
const moreTabs = requestPane.locator('.tabs .more-tabs');
await expect(moreTabs).toBeVisible();
await moreTabs.click();
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Settings' }).click({ force: true });
};
const assertTabPanelErrorBoundary = async (page: Page, collectionName: string, requestName: string) => {
const commonLocators = buildCommonLocators(page);
await test.step(`Open ${requestName} and trigger render error`, async () => {
await openRequest(page, collectionName, requestName);
await expect(commonLocators.tabs.activeRequestTab()).toContainText(requestName);
await openRequestSettingsTab(page);
});
await test.step('Verify fallback UI', async () => {
await expect(page.getByText('Something went wrong')).toBeVisible();
await expect(
page.getByText(
'This tab encountered an unexpected error. Close it and try reopening the request. If the error repeats, the request file may be corrupt.'
)
).toBeVisible();
await expect(page.getByTestId('tab-panel-error-boundary-close-tab')).toBeVisible();
});
await test.step('Close errored tab via boundary action', async () => {
await page.getByTestId('tab-panel-error-boundary-close-tab').click();
await expect(commonLocators.tabs.requestTab(requestName)).not.toBeVisible();
});
};
const assertBoundaryVisible = async (page: Page) => {
await expect(page.getByText('Something went wrong')).toBeVisible();
await expect(page.getByTestId('tab-panel-error-boundary-close-tab')).toBeVisible();
};
test.describe.serial('Tab Panel Error Boundary', () => {
test('handles invalid tags type in YAML request metadata', async ({ pageWithUserData: page }) => {
await assertTabPanelErrorBoundary(page, 'invalid-tags-yml', 'invalid-tags-yml-request');
});
test('shows error only on failed tab and allows switching to valid tab', async ({ pageWithUserData: page }) => {
const commonLocators = buildCommonLocators(page);
await openRequest(page, 'invalid-tags-yml', 'invalid-tags-yml-request', { persist: true });
await openRequestSettingsTab(page);
await assertBoundaryVisible(page);
await openRequest(page, 'invalid-tags-yml', 'control-yml-request');
await expect(commonLocators.tabs.activeRequestTab()).toContainText('control-yml-request');
await expect(page.getByText('Something went wrong')).toHaveCount(0);
await expect(page.getByTestId('tab-panel-error-boundary-close-tab')).toHaveCount(0);
await expect(page.locator('[data-testid="request-pane"]')).toBeVisible();
await commonLocators.tabs.requestTab('invalid-tags-yml-request').click();
await expect(commonLocators.tabs.activeRequestTab()).toContainText('invalid-tags-yml-request');
await assertBoundaryVisible(page);
});
});

View File

@@ -0,0 +1,220 @@
import path from 'path';
import fs from 'fs';
import { test, expect, closeElectronApp } from '../../../playwright';
import {
createCollection,
createEnvironment,
openCollection,
selectEnvironment,
waitForReadyPage
} from '../../utils/page';
const readSnapshot = (userDataPath: string) => {
const snapshotPath = path.join(userDataPath, 'ui-state-snapshot.json');
if (!fs.existsSync(snapshotPath)) {
return null;
}
return JSON.parse(fs.readFileSync(snapshotPath, 'utf-8'));
};
const legacyPromptVariablesInitUserDataPath = path.join(
__dirname,
'init-user-data'
);
const migrationCollectionPath = path.join(
__dirname,
'fixtures/collection'
);
test.describe('Snapshot: Collection Environment Persistence', () => {
test('migrates legacy snapshot format and preserves selected collection environment', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('snap-legacy-env-migration');
const app = await launchElectronApp({
initUserDataPath: legacyPromptVariablesInitUserDataPath,
userDataPath
});
const page = await waitForReadyPage(app);
await test.step('Verify legacy selected environment is hydrated in UI', async () => {
await openCollection(page, 'migration-collection');
await expect(page.locator('.current-environment')).toContainText('local');
});
await test.step('Close app and verify snapshot migrated to new shape', async () => {
await page.waitForTimeout(2000);
await closeElectronApp(app);
const snapshot = readSnapshot(userDataPath);
expect(snapshot).not.toBeNull();
expect(snapshot).toHaveProperty('version');
expect(snapshot).toHaveProperty('activeWorkspacePath');
expect(snapshot).toHaveProperty('extras');
expect(snapshot).toHaveProperty('workspaces');
expect(snapshot).toHaveProperty('collections');
expect(Array.isArray(snapshot?.workspaces)).toBe(true);
expect(Array.isArray(snapshot?.collections)).toBe(true);
const migratedCollectionEntry = snapshot?.collections?.find(
(collection: any) => collection?.pathname === migrationCollectionPath
);
expect(migratedCollectionEntry).toBeTruthy();
console.log(JSON.stringify(migratedCollectionEntry));
expect(migratedCollectionEntry?.selectedEnvironment).toBe('local');
});
});
test('keeps selected environments for non-active collections across snapshot saves', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('snap-env-persistence');
const firstCollectionPath = await createTmpDir('snap-col-a');
const secondCollectionPath = await createTmpDir('snap-col-b');
const firstCollectionRoot = path.join(firstCollectionPath, 'Collection A');
const secondCollectionRoot = path.join(secondCollectionPath, 'Collection B');
const app = await launchElectronApp({ userDataPath });
const page = await waitForReadyPage(app);
await test.step('Create two collections with distinct selected environments', async () => {
await createCollection(page, 'Collection A', firstCollectionPath);
await openCollection(page, 'Collection A');
await createEnvironment(page, 'local-a', 'collection');
await selectEnvironment(page, 'local-a', 'collection');
await createCollection(page, 'Collection B', secondCollectionPath);
await openCollection(page, 'Collection B');
await createEnvironment(page, 'local-b', 'collection');
await selectEnvironment(page, 'local-b', 'collection');
});
await test.step('Switch back to first collection and verify environment did not drift', async () => {
await openCollection(page, 'Collection A');
await expect(page.locator('.current-environment')).toContainText('local-a');
await openCollection(page, 'Collection B');
await expect(page.locator('.current-environment')).toContainText('local-b');
});
await test.step('Close app and assert snapshot stores both environments', async () => {
await page.waitForTimeout(2000);
await closeElectronApp(app);
const snapshot = readSnapshot(userDataPath);
expect(snapshot).not.toBeNull();
const collections = Array.isArray(snapshot?.collections) ? snapshot.collections : [];
const firstEntry = collections.find((collection: any) => collection?.pathname === firstCollectionRoot);
const secondEntry = collections.find((collection: any) => collection?.pathname === secondCollectionRoot);
expect(firstEntry?.selectedEnvironment).toBe('local-a');
expect(secondEntry?.selectedEnvironment).toBe('local-b');
expect(firstEntry?.environmentPath).toContain(path.join('environments', 'local-a'));
expect(secondEntry?.environmentPath).toContain(path.join('environments', 'local-b'));
});
await test.step('Restart app and verify both selections are still restored', async () => {
const app2 = await launchElectronApp({ userDataPath });
const page2 = await waitForReadyPage(app2);
await openCollection(page2, 'Collection A');
await expect(page2.locator('.current-environment')).toContainText('local-a');
await openCollection(page2, 'Collection B');
await expect(page2.locator('.current-environment')).toContainText('local-b');
await closeElectronApp(app2);
});
});
test('keeps selected environments for three collections across delayed switches and snapshot updates', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('snap-env-persistence-three');
const firstCollectionPath = await createTmpDir('snap-col-a-three');
const secondCollectionPath = await createTmpDir('snap-col-b-three');
const thirdCollectionPath = await createTmpDir('snap-col-c-three');
const firstCollectionRoot = path.join(firstCollectionPath, 'Collection A');
const secondCollectionRoot = path.join(secondCollectionPath, 'Collection B');
const thirdCollectionRoot = path.join(thirdCollectionPath, 'Collection C');
const app = await launchElectronApp({ userDataPath });
const page = await waitForReadyPage(app);
await test.step('Create three collections with distinct selected environments', async () => {
await createCollection(page, 'Collection A', firstCollectionPath);
await openCollection(page, 'Collection A');
await createEnvironment(page, 'local-a', 'collection');
await selectEnvironment(page, 'local-a', 'collection');
await createCollection(page, 'Collection B', secondCollectionPath);
await openCollection(page, 'Collection B');
await createEnvironment(page, 'local-b', 'collection');
await selectEnvironment(page, 'local-b', 'collection');
await createCollection(page, 'Collection C', thirdCollectionPath);
await openCollection(page, 'Collection C');
await createEnvironment(page, 'local-c', 'collection');
await selectEnvironment(page, 'local-c', 'collection');
});
await test.step('Switch to each collection with delays and verify selected environment stays correct', async () => {
await openCollection(page, 'Collection A');
await expect(page.locator('.current-environment')).toContainText('local-a');
await openCollection(page, 'Collection B');
await expect(page.locator('.current-environment')).toContainText('local-b');
await openCollection(page, 'Collection C');
await expect(page.locator('.current-environment')).toContainText('local-c');
});
await test.step('Close app and assert snapshot stores all three environments', async () => {
await closeElectronApp(app);
const snapshot = readSnapshot(userDataPath);
expect(snapshot).not.toBeNull();
const collections = Array.isArray(snapshot?.collections) ? snapshot.collections : [];
const firstEntry = collections.find((collection: any) => collection?.pathname === firstCollectionRoot);
const secondEntry = collections.find((collection: any) => collection?.pathname === secondCollectionRoot);
const thirdEntry = collections.find((collection: any) => collection?.pathname === thirdCollectionRoot);
expect(firstEntry?.selectedEnvironment).toBe('local-a');
expect(secondEntry?.selectedEnvironment).toBe('local-b');
expect(thirdEntry?.selectedEnvironment).toBe('local-c');
expect(firstEntry?.environmentPath).toContain(path.join('environments', 'local-a'));
expect(secondEntry?.environmentPath).toContain(path.join('environments', 'local-b'));
expect(thirdEntry?.environmentPath).toContain(path.join('environments', 'local-c'));
});
await test.step('Restart app, switch through collections with delays, and verify all selections are restored', async () => {
const app2 = await launchElectronApp({ userDataPath });
const page2 = await waitForReadyPage(app2);
await openCollection(page2, 'Collection A');
await expect(page2.locator('.current-environment')).toContainText('local-a');
await page2.waitForTimeout(2000);
await openCollection(page2, 'Collection B');
await expect(page2.locator('.current-environment')).toContainText('local-b');
await page2.waitForTimeout(2000);
await openCollection(page2, 'Collection C');
await expect(page2.locator('.current-environment')).toContainText('local-c');
await page2.waitForTimeout(2000);
await closeElectronApp(app2);
const updatedSnapshot = readSnapshot(userDataPath);
expect(updatedSnapshot).not.toBeNull();
const updatedCollections = Array.isArray(updatedSnapshot?.collections) ? updatedSnapshot.collections : [];
const firstUpdatedEntry = updatedCollections.find((collection: any) => collection?.pathname === firstCollectionRoot);
const secondUpdatedEntry = updatedCollections.find((collection: any) => collection?.pathname === secondCollectionRoot);
const thirdUpdatedEntry = updatedCollections.find((collection: any) => collection?.pathname === thirdCollectionRoot);
expect(firstUpdatedEntry?.selectedEnvironment).toBe('local-a');
expect(secondUpdatedEntry?.selectedEnvironment).toBe('local-b');
expect(thirdUpdatedEntry?.selectedEnvironment).toBe('local-c');
});
});
});

View File

@@ -0,0 +1,10 @@
{
"version": "1",
"name": "migration-collection",
"type": "collection",
"ignore": [
"node_modules",
".git"
],
"filesCount": 1
}

View File

@@ -0,0 +1,4 @@
vars {
collectionEnvVar: hello
~collectionEnvVarDisabled: there
}

View File

@@ -0,0 +1,9 @@
meta {
name: http-request
type: http
seq: 1
}
get {
url: http://localhost:8081/ping
}

View File

@@ -0,0 +1,12 @@
{
"maximized": false,
"lastOpenedCollections": [
"{{projectRoot}}/tests/snapshots/environment/fixtures/collection"
],
"preferences": {
"onboarding": {
"hasLaunchedBefore": true,
"hasSeenWelcomeModal": true
}
}
}

View File

@@ -0,0 +1,8 @@
{
"collections": [
{
"pathname": "{{projectRoot}}/tests/snapshots/environment/fixtures/collection",
"selectedEnvironment": "local"
}
]
}

View File

@@ -1,3 +1,5 @@
import path from 'path';
import fs from 'fs';
import { test, expect, closeElectronApp } from '../../playwright';
import {
createCollection,
@@ -6,6 +8,34 @@ import {
} from '../utils/page';
import { buildCommonLocators } from '../utils/page/locators';
const readSnapshot = (userDataPath: string) => {
const snapshotPath = path.join(userDataPath, 'ui-state-snapshot.json');
if (!fs.existsSync(snapshotPath)) return null;
return JSON.parse(fs.readFileSync(snapshotPath, 'utf-8'));
};
const findSnapshotRequestTab = (snapshot: any, requestName: string) => {
if (!snapshot || !Array.isArray(snapshot.collections)) {
return null;
}
for (const collection of snapshot.collections) {
if (!Array.isArray(collection?.tabs)) continue;
const tab = collection.tabs.find((candidate) => (
candidate?.accessor === 'pathname'
&& typeof candidate?.pathname === 'string'
&& candidate.pathname.includes(requestName)
));
if (tab) {
return tab;
}
}
return null;
};
test.describe('Snapshot: Request Pane Interactivity', () => {
test('grpc request pane tab interactivity is restored after restart', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('snap-grpc-interactivity');
@@ -105,4 +135,172 @@ test.describe('Snapshot: Request Pane Interactivity', () => {
await closeElectronApp(app2);
});
});
test('grpc snapshot stores concrete type and body tab key', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('snap-grpc-snapshot-type-tab-key');
const colPath = await createTmpDir('col');
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
await test.step('Create collection and gRPC request', async () => {
await createCollection(page, 'TestCol', colPath);
const locators = buildCommonLocators(page);
await locators.sidebar.collection('TestCol').hover();
await locators.actions.collectionActions('TestCol').click();
await locators.dropdown.item('New Request').click();
await page.getByTestId('grpc-request').click();
await page.getByTestId('request-name').fill('ReqGrpcSnapshot');
await page.getByTestId('new-request-url').locator('.CodeMirror').click();
await page.keyboard.type('grpc://localhost:50051');
await locators.modal.button('Create').click();
await openRequest(page, 'TestCol', 'ReqGrpcSnapshot', { persist: true });
await selectRequestPaneTab(page, 'Message');
});
await test.step('Close app and verify snapshot stores grpc-request/body', async () => {
await page.waitForTimeout(2000);
await closeElectronApp(app);
const snapshotPath = path.join(userDataPath, 'ui-state-snapshot.json');
await expect.poll(() => fs.existsSync(snapshotPath)).toBe(true);
const snapshot = readSnapshot(userDataPath);
const tab = findSnapshotRequestTab(snapshot, 'ReqGrpcSnapshot');
expect(tab).toBeTruthy();
expect(tab.type).toBe('grpc-request');
expect(tab.request?.tab).toBe('body');
});
await test.step('Verify restore opens Message tab and avoids 404', async () => {
const app2 = await launchElectronApp({ userDataPath });
const page2 = await app2.firstWindow();
await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
const locators = buildCommonLocators(page2);
await expect(locators.tabs.requestTab('ReqGrpcSnapshot')).toBeVisible({ timeout: 15000 });
await locators.tabs.requestTab('ReqGrpcSnapshot').click({ force: true });
await expect(page2.getByTestId('responsive-tab-body')).toHaveAttribute('aria-selected', 'true');
await expect(page2.locator('text=404 | Not found')).not.toBeVisible();
await closeElectronApp(app2);
});
});
test('websocket snapshot stores concrete type and body tab key', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('snap-ws-snapshot-type-tab-key');
const colPath = await createTmpDir('col');
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
await test.step('Create collection and WebSocket request', async () => {
await createCollection(page, 'TestCol', colPath);
const locators = buildCommonLocators(page);
await locators.sidebar.collection('TestCol').hover();
await locators.actions.collectionActions('TestCol').click();
await locators.dropdown.item('New Request').click();
await page.getByTestId('ws-request').click();
await page.getByTestId('request-name').fill('ReqWsSnapshot');
await page.getByTestId('new-request-url').locator('.CodeMirror').click();
await page.keyboard.type('ws://localhost:8080');
await locators.modal.button('Create').click();
await openRequest(page, 'TestCol', 'ReqWsSnapshot', { persist: true });
await selectRequestPaneTab(page, 'Message');
});
await test.step('Close app and verify snapshot stores ws-request/body', async () => {
await page.waitForTimeout(2000);
await closeElectronApp(app);
const snapshotPath = path.join(userDataPath, 'ui-state-snapshot.json');
await expect.poll(() => fs.existsSync(snapshotPath)).toBe(true);
const snapshot = readSnapshot(userDataPath);
const tab = findSnapshotRequestTab(snapshot, 'ReqWsSnapshot');
expect(tab).toBeTruthy();
expect(tab.type).toBe('ws-request');
expect(tab.request?.tab).toBe('body');
});
await test.step('Verify restore opens Message tab and avoids 404', async () => {
const app2 = await launchElectronApp({ userDataPath });
const page2 = await app2.firstWindow();
await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
const locators = buildCommonLocators(page2);
await expect(locators.tabs.requestTab('ReqWsSnapshot')).toBeVisible({ timeout: 15000 });
await locators.tabs.requestTab('ReqWsSnapshot').click({ force: true });
await expect(page2.getByTestId('responsive-tab-body')).toHaveAttribute('aria-selected', 'true');
await expect(page2.locator('text=404 | Not found')).not.toBeVisible();
await closeElectronApp(app2);
});
});
test('graphql snapshot stores concrete type and query tab key', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('snap-graphql-snapshot-type-tab-key');
const colPath = await createTmpDir('col');
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
await test.step('Create collection and GraphQL request', async () => {
await createCollection(page, 'TestCol', colPath);
const locators = buildCommonLocators(page);
await locators.sidebar.collection('TestCol').hover();
await locators.actions.collectionActions('TestCol').click();
await locators.dropdown.item('New Request').click();
await page.getByTestId('graphql-request').click();
await page.getByTestId('request-name').fill('ReqGraphSnapshot');
await page.getByTestId('new-request-url').locator('.CodeMirror').click();
await page.keyboard.type('https://echo.usebruno.com/graphql');
await locators.modal.button('Create').click();
await openRequest(page, 'TestCol', 'ReqGraphSnapshot', { persist: true });
await selectRequestPaneTab(page, 'Headers');
});
await test.step('Close app and verify snapshot stores graphql-request/headers', async () => {
await page.waitForTimeout(2000);
await closeElectronApp(app);
const snapshotPath = path.join(userDataPath, 'ui-state-snapshot.json');
await expect.poll(() => fs.existsSync(snapshotPath)).toBe(true);
const snapshot = readSnapshot(userDataPath);
const tab = findSnapshotRequestTab(snapshot, 'ReqGraphSnapshot');
expect(tab).toBeTruthy();
expect(tab.type).toBe('graphql-request');
expect(tab.request?.tab).toBe('headers');
});
await test.step('Verify restore opens Headers tab and avoids 404', async () => {
const app2 = await launchElectronApp({ userDataPath });
const page2 = await app2.firstWindow();
await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
const locators = buildCommonLocators(page2);
await expect(locators.tabs.requestTab('ReqGraphSnapshot')).toBeVisible({ timeout: 15000 });
await locators.tabs.requestTab('ReqGraphSnapshot').click({ force: true });
await expect(page2.getByTestId('responsive-tab-headers')).toHaveAttribute('aria-selected', 'true');
await expect(page2.locator('text=404 | Not found')).not.toBeVisible();
await closeElectronApp(app2);
});
});
});

View File

@@ -1,7 +1,9 @@
import { test, expect, closeElectronApp, type Page } from '../../playwright';
import {
createCollection,
createExampleFromSidebar,
createRequest,
openExampleFromSidebar,
openRequest
} from '../utils/page';
import { buildCommonLocators } from '../utils/page/locators';
@@ -77,4 +79,117 @@ test.describe('Snapshot: Sidebar-Tab Restoration', () => {
await closeElectronApp(app2);
});
});
test('when request and example are open, last active request restores as active after restart', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('snap-sidebar-request-active-over-example');
const colPath = await createTmpDir('col');
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
await test.step('Create request, create example, then make request last active', async () => {
await createCollection(page, 'TestCol', colPath);
await createRequest(page, 'ReqAlpha', 'TestCol', { url: 'https://echo.usebruno.com', method: 'GET' });
await openRequest(page, 'TestCol', 'ReqAlpha', { persist: true });
await createExampleFromSidebar(page, 'ReqAlpha', 'Example One');
await expect(page.getByTestId('response-example-title')).toHaveText('ReqAlpha / Example One');
await openRequest(page, 'TestCol', 'ReqAlpha', { persist: true });
const locators = buildCommonLocators(page);
await expect(locators.tabs.activeRequestTab()).toContainText('ReqAlpha');
});
await test.step('Close and restart app', async () => {
await page.waitForTimeout(2000);
await closeElectronApp(app);
});
await test.step('Verify request restores as active and request click does not focus example', async () => {
const app2 = await launchElectronApp({ userDataPath });
const page2 = await app2.firstWindow();
await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
const locators = buildCommonLocators(page2);
await expect(locators.tabs.activeRequestTab()).toContainText('ReqAlpha', { timeout: 15000 });
await openRequest(page2, 'TestCol', 'ReqAlpha', { persist: true });
await expect(locators.tabs.requestTab('ReqAlpha')).toHaveCount(1);
await expect(page2.getByTestId('response-example-title')).not.toBeVisible();
await closeElectronApp(app2);
});
});
test('when last active tab is an example, it restores as active after restart', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('snap-sidebar-example-active-restore');
const colPath = await createTmpDir('col');
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
await test.step('Create request and example, then make example active', async () => {
await createCollection(page, 'TestCol', colPath);
await createRequest(page, 'ReqAlpha', 'TestCol', { url: 'https://echo.usebruno.com', method: 'GET' });
await openRequest(page, 'TestCol', 'ReqAlpha', { persist: true });
await createExampleFromSidebar(page, 'ReqAlpha', 'Example One');
await openExampleFromSidebar(page, 'ReqAlpha', 'Example One');
await expect(page.getByTestId('response-example-title')).toHaveText('ReqAlpha / Example One');
});
await test.step('Close and restart app', async () => {
await page.waitForTimeout(2000);
await closeElectronApp(app);
});
await test.step('Verify example restores as active', async () => {
const app2 = await launchElectronApp({ userDataPath });
const page2 = await app2.firstWindow();
await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
await expect(page2.getByTestId('response-example-title')).toHaveText('ReqAlpha / Example One', { timeout: 15000 });
await closeElectronApp(app2);
});
});
test('when duplicate example names exist, snapshot restores the same active example by index', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('snap-sidebar-duplicate-example-restore');
const colPath = await createTmpDir('col');
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
await test.step('Create request and two examples with duplicate names, then activate second', async () => {
await createCollection(page, 'TestCol', colPath);
await createRequest(page, 'ReqAlpha', 'TestCol', { url: 'https://echo.usebruno.com', method: 'GET' });
await openRequest(page, 'TestCol', 'ReqAlpha', { persist: true });
await createExampleFromSidebar(page, 'ReqAlpha', 'DupExample', 'first-desc');
await createExampleFromSidebar(page, 'ReqAlpha', 'DupExample', 'second-desc');
await openExampleFromSidebar(page, 'ReqAlpha', 'DupExample', 1);
await expect(page.getByTestId('response-example-description')).toHaveText('second-desc');
});
await test.step('Close and restart app', async () => {
await page.waitForTimeout(2000);
await closeElectronApp(app);
});
await test.step('Verify second duplicate example restores as active', async () => {
const app2 = await launchElectronApp({ userDataPath });
const page2 = await app2.firstWindow();
await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
await expect(page2.getByTestId('response-example-title')).toHaveText('ReqAlpha / DupExample', { timeout: 15000 });
await expect(page2.getByTestId('response-example-description')).toHaveText('second-desc');
await closeElectronApp(app2);
});
});
});

View File

@@ -0,0 +1,52 @@
import { test, expect } from '../../playwright';
import { createCollection, createTransientRequest, fillRequestUrl, closeAllCollections } from '../utils/page';
import { buildCommonLocators } from '../utils/page/locators';
test.describe('Transient Requests - Quit Flow', () => {
test('should open transient save modal when saving during app quit flow', async ({ page, electronApp, createTmpDir }) => {
const locators = buildCommonLocators(page);
const collectionPath = await createTmpDir('transient-quit-flow');
await test.step('Create collection and transient request', async () => {
await createCollection(page, 'transient-quit-flow-test', collectionPath);
await createTransientRequest(page, { requestType: 'HTTP' });
await fillRequestUrl(page, 'http://localhost:8081/ping');
});
await test.step('Trigger app quit flow from main process', async () => {
await electronApp.evaluate(({ BrowserWindow }) => {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.close();
}
}
});
const unsavedChangesModal = page.locator('.bruno-modal-card').filter({ hasText: 'Unsaved changes' });
await expect(unsavedChangesModal).toBeVisible({ timeout: 10000 });
await unsavedChangesModal.getByRole('button', { name: 'Save', exact: true }).click();
});
await test.step('Save transient request using existing Save Request flow', async () => {
const saveTransientModal = page.locator('.bruno-modal-card').filter({ hasText: 'Save Request' });
await expect(saveTransientModal).toBeVisible({ timeout: 10000 });
const requestNameInput = saveTransientModal.locator('#request-name');
await requestNameInput.clear();
await requestNameInput.fill('Saved via quit flow');
await saveTransientModal.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Request saved successfully').last()).toBeVisible({ timeout: 10000 });
});
await test.step('Verify app remains open and request is saved', async () => {
await expect(locators.sidebar.collection('transient-quit-flow-test')).toBeVisible();
await locators.sidebar.collection('transient-quit-flow-test').click();
await expect(locators.sidebar.request('Saved via quit flow')).toBeVisible({ timeout: 10000 });
});
await test.step('Cleanup collections', async () => {
await closeAllCollections(page);
});
});
});

View File

@@ -1,9 +1,18 @@
import { test, expect, Page } from '../../../playwright';
import { test, expect, Page, ElectronApplication, waitForReadyPage as waitForReadyPageImpl } from '../../../playwright';
import process from 'node:process';
import { buildCommonLocators, buildScriptErrorLocators } from './locators';
type SandboxMode = 'safe' | 'developer';
type WaitForAppReadyOptions = {
timeout?: number;
};
const waitForReadyPage = (
app: ElectronApplication,
options: WaitForAppReadyOptions = {}
) => waitForReadyPageImpl(app, options);
/**
* Close all collections
* @param page - The page object
@@ -812,37 +821,77 @@ const getResponseBody = async (page: Page): Promise<string> => {
return await page.locator('.response-pane').innerText();
};
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const trySelectPaneTabOnce = async (page: Page, paneSelector: string, tabName: string) => {
const pane = page.locator(paneSelector);
const visibleTab = pane.locator('.tabs').getByRole('tab', { name: tabName });
if (await visibleTab.isVisible().catch(() => false)) {
try {
await visibleTab.click({ timeout: 2000 });
await expect(visibleTab).toContainClass('active', { timeout: 500 });
return true;
} catch {
return false;
}
}
const overflowButton = pane.locator('.tabs .more-tabs');
if (!(await overflowButton.isVisible().catch(() => false))) {
return false;
}
try {
await overflowButton.click({ force: true, timeout: 1000 });
} catch {
return false;
}
const dropdownItem = page
.getByRole('menuitem', { name: new RegExp(escapeRegExp(tabName), 'i') })
.first();
if (await dropdownItem.isVisible({ timeout: 1500 }).catch(() => false)) {
try {
await dropdownItem.click({ force: true, timeout: 2000 });
await expect(visibleTab).toContainClass('active', { timeout: 500 });
return true;
} catch {
return false;
}
}
const fallbackDropdownItem = page.locator('.tippy-box .dropdown-item').filter({ hasText: tabName }).first();
if (await fallbackDropdownItem.isVisible({ timeout: 1500 }).catch(() => false)) {
try {
await fallbackDropdownItem.click({ force: true, timeout: 2000 });
await expect(visibleTab).toContainClass('active', { timeout: 500 });
return true;
} catch {
return false;
}
}
return false;
};
const selectPaneTab = async (page: Page, paneSelector: string, tabName: string) => {
await test.step(`Select tab "${tabName}" in ${paneSelector}`, async () => {
const pane = page.locator(paneSelector);
await expect(pane).toBeVisible();
await expect(pane.locator('.tabs')).toBeVisible();
const visibleTab = pane.locator('.tabs').getByRole('tab', { name: tabName });
// Check if tab is directly visible
if (await visibleTab.isVisible()) {
await visibleTab.click();
await expect(visibleTab).toContainClass('active');
return;
}
const overflowButton = pane.locator('.tabs .more-tabs');
// Check if there's an overflow dropdown
if (await overflowButton.isVisible()) {
await overflowButton.click();
// Wait for dropdown to appear and click the menu item
const dropdownItem = page.locator('.tippy-box .dropdown-item').filter({ hasText: tabName });
await dropdownItem.waitFor({ state: 'visible' });
await page.waitForTimeout(50);
await dropdownItem.click({ force: true });
await expect(visibleTab).toContainClass('active');
return;
}
throw new Error(`Tab "${tabName}" not found in visible tabs or overflow dropdown`);
await expect
.poll(
async () => trySelectPaneTabOnce(page, paneSelector, tabName),
{
message: `Tab "${tabName}" not found in visible tabs or overflow dropdown`,
timeout: 8000,
intervals: [100, 150, 200, 250]
}
)
.toBe(true);
});
};
@@ -1198,7 +1247,43 @@ const sendAndWaitForResponse = async (page: Page) => {
});
};
const createExampleFromSidebar = async (page: Page, requestName: string, exampleName: string, description: string = '') => {
const requestRow = page.locator('.collection-item-name').filter({ hasText: requestName }).first();
await requestRow.hover();
await requestRow.locator('..').locator('.menu-icon').click({ force: true });
await page.locator('.dropdown-item').filter({ hasText: 'Create Example' }).click();
const exampleInput = page.getByTestId('create-example-name-input');
await expect(exampleInput).toBeVisible();
await exampleInput.clear();
await exampleInput.fill(exampleName);
const descriptionInput = page.getByTestId('create-example-description-input');
await descriptionInput.clear();
await descriptionInput.fill(description);
await page.getByRole('button', { name: 'Create Example' }).click();
await expect(page.locator('text=Create Response Example')).not.toBeAttached();
};
const openExampleFromSidebar = async (page: Page, requestName: string, exampleName: string, index: number = 0) => {
const requestRow = page.locator('.collection-item-name').filter({ hasText: requestName }).first();
const requestBranch = requestRow.locator('..');
const exampleRow = requestBranch
.locator('.collection-item-name')
.filter({ has: page.locator('.example-icon') })
.getByText(exampleName, { exact: true })
.nth(index);
if (!(await exampleRow.isVisible())) {
await requestRow.getByTestId('request-item-chevron').click();
}
await expect(exampleRow).toBeVisible();
await exampleRow.click();
};
export {
waitForReadyPage,
closeAllCollections,
openCollection,
createCollection,
@@ -1243,7 +1328,9 @@ export {
addPostResponseScript,
addTestScript,
sendAndWaitForErrorCard,
sendAndWaitForResponse
sendAndWaitForResponse,
createExampleFromSidebar,
openExampleFromSidebar
};
export type { SandboxMode, EnvironmentType, EnvironmentVariable, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions, CreateTransientRequestOptions, AssertionInput };