mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 01:18:32 +00:00
Compare commits
8 Commits
feat/fix-e
...
v3.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
659e02ac44 | ||
|
|
cdba12387e | ||
|
|
bbe8fa474a | ||
|
|
739dd3ca49 | ||
|
|
09f1146ede | ||
|
|
b7b4b17c11 | ||
|
|
54567bbd69 | ||
|
|
c12fe6cc12 |
@@ -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 || '',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -93,7 +93,7 @@ const useOpenAPISync = (collection) => {
|
||||
uid: itemUid,
|
||||
collectionUid: collection.uid,
|
||||
requestPaneTab: item ? getDefaultRequestPaneTab(item) : undefined,
|
||||
type: 'request'
|
||||
type: item?.type ?? 'request'
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
|
||||
90
packages/bruno-app/src/selectors/tab.spec.js
Normal file
90
packages/bruno-app/src/selectors/tab.spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
66
tests/import/postman/fixtures/postman-v21-wrapped.json
Normal file
66
tests/import/postman/fixtures/postman-v21-wrapped.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
17
tests/import/postman/import-postman-v21-wrapped.spec.ts
Normal file
17
tests/import/postman/import-postman-v21-wrapped.spec.ts
Normal 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'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "invalid-tags-bru",
|
||||
"type": "collection"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
meta {
|
||||
name: invalid-tags-bru
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
opencollection: "1.0.0"
|
||||
|
||||
info:
|
||||
name: invalid-tags-yml
|
||||
|
||||
bundled: false
|
||||
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"lastOpenedCollections": [
|
||||
"{{collectionPath}}/invalid-tags-bru",
|
||||
"{{collectionPath}}/invalid-tags-yml"
|
||||
],
|
||||
"preferences": {
|
||||
"onboarding": {
|
||||
"hasLaunchedBefore": true,
|
||||
"hasSeenWelcomeModal": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
220
tests/snapshots/environment/environment.spec.ts
Normal file
220
tests/snapshots/environment/environment.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
10
tests/snapshots/environment/fixtures/collection/bruno.json
Normal file
10
tests/snapshots/environment/fixtures/collection/bruno.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "migration-collection",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
],
|
||||
"filesCount": 1
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
vars {
|
||||
collectionEnvVar: hello
|
||||
~collectionEnvVarDisabled: there
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
meta {
|
||||
name: http-request
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: http://localhost:8081/ping
|
||||
}
|
||||
12
tests/snapshots/environment/init-user-data/preferences.json
Normal file
12
tests/snapshots/environment/init-user-data/preferences.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"maximized": false,
|
||||
"lastOpenedCollections": [
|
||||
"{{projectRoot}}/tests/snapshots/environment/fixtures/collection"
|
||||
],
|
||||
"preferences": {
|
||||
"onboarding": {
|
||||
"hasLaunchedBefore": true,
|
||||
"hasSeenWelcomeModal": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"collections": [
|
||||
{
|
||||
"pathname": "{{projectRoot}}/tests/snapshots/environment/fixtures/collection",
|
||||
"selectedEnvironment": "local"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
52
tests/transient-requests/transient-request-quit-flow.spec.ts
Normal file
52
tests/transient-requests/transient-request-quit-flow.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user