mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
fix: example-request tab collision (#7989)
* fix: prevent response-example tabs from hijacking request sidebar selection * fix: add selectors for examples with index * test: better locator * fix: duplicate name collision * fix: refactor sidebar example handling functions for better clarity and reusability * chore: cr comments
This commit is contained in:
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -80,7 +80,8 @@ taskMiddleware.startListening({
|
||||
type: 'response-example',
|
||||
itemUid: item.uid,
|
||||
pathname: item.pathname,
|
||||
exampleName: example.name
|
||||
exampleName: example.name,
|
||||
exampleIndex: task.exampleIndex
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,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;
|
||||
}
|
||||
@@ -35,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;
|
||||
}
|
||||
|
||||
@@ -47,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',
|
||||
@@ -67,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;
|
||||
@@ -114,6 +118,7 @@ export const tabsSlice = createSlice({
|
||||
...(exampleUid ? { exampleUid } : {}),
|
||||
...(itemUid ? { itemUid } : {}),
|
||||
...(exampleName ? { exampleName } : {}),
|
||||
...(typeof exampleIndex === 'number' ? { exampleIndex } : {}),
|
||||
...(isTransient ? { isTransient: true } : {})
|
||||
};
|
||||
|
||||
@@ -149,6 +154,7 @@ export const tabsSlice = createSlice({
|
||||
...(exampleUid ? { exampleUid } : {}),
|
||||
...(itemUid ? { itemUid } : {}),
|
||||
...(exampleName ? { exampleName } : {}),
|
||||
...(typeof exampleIndex === 'number' ? { exampleIndex } : {}),
|
||||
...(isTransient ? { isTransient: true } : {})
|
||||
});
|
||||
state.activeTabUid = uid;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -344,7 +344,7 @@ 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';
|
||||
};
|
||||
@@ -367,6 +367,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 +425,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 +455,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 +464,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,
|
||||
@@ -465,12 +550,13 @@ export const deserializeTab = (snapshotTab, collection) => {
|
||||
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 +641,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', () => {
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
@@ -500,7 +502,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1198,6 +1198,41 @@ 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 {
|
||||
closeAllCollections,
|
||||
openCollection,
|
||||
@@ -1243,7 +1278,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