mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 09:28:33 +00:00
Compare commits
46 Commits
oauth2_add
...
feat/node_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fa05d32cb | ||
|
|
eb0accdf21 | ||
|
|
6f57633572 | ||
|
|
e7c33f7eef | ||
|
|
1620c24557 | ||
|
|
bd9d2eabe1 | ||
|
|
990bbdb813 | ||
|
|
00636a5a31 | ||
|
|
c526eacd6b | ||
|
|
9a2836129f | ||
|
|
b8d67d9232 | ||
|
|
bcf4673a64 | ||
|
|
6c52c07494 | ||
|
|
de48c93e8d | ||
|
|
ba56e87375 | ||
|
|
cb7f61ee4b | ||
|
|
6bcb850b6e | ||
|
|
dc56c00309 | ||
|
|
1220a5f159 | ||
|
|
3046327fa7 | ||
|
|
c1c617bfeb | ||
|
|
6632407a34 | ||
|
|
447b3046b3 | ||
|
|
2666e7fee0 | ||
|
|
f9ca0e2f5a | ||
|
|
5e9cec38f0 | ||
|
|
ed1a072ba1 | ||
|
|
5f938d77b4 | ||
|
|
f5b4dbd1a1 | ||
|
|
8c72a6094b | ||
|
|
325d03b92f | ||
|
|
54c41c861e | ||
|
|
22a77b90f9 | ||
|
|
af894b5bbb | ||
|
|
48934ef74a | ||
|
|
9c16ebcda3 | ||
|
|
2ed51bb984 | ||
|
|
aec9ee6265 | ||
|
|
04d1e50f98 | ||
|
|
e74c78ea8b | ||
|
|
e71ee3eff5 | ||
|
|
8bd2216bf0 | ||
|
|
4cfc28cd73 | ||
|
|
b3a0234ec3 | ||
|
|
8c6ce2e084 | ||
|
|
b02f6b61ee |
Binary file not shown.
|
Before Width: | Height: | Size: 537 KiB After Width: | Height: | Size: 813 KiB |
@@ -69,6 +69,7 @@ npm run build:bruno-query
|
||||
npm run build:bruno-common
|
||||
npm run build:bruno-converters
|
||||
npm run build:bruno-requests
|
||||
npm run build:bruno-filestore
|
||||
|
||||
# bundle js sandbox libraries
|
||||
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
|
||||
|
||||
25
e2e-tests/footer/notifications/notifications.spec.js
Normal file
25
e2e-tests/footer/notifications/notifications.spec.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
|
||||
test.describe('Notifications Modal', () => {
|
||||
test('should open notifications modal when clicking bell icon and close with close button', async ({ page }) => {
|
||||
// Get the notification bell icon in the status bar
|
||||
const notificationBell = page.getByLabel('Check all Notifications');
|
||||
|
||||
// Click on the bell icon to open notifications
|
||||
await notificationBell.click();
|
||||
|
||||
// Get modal elements
|
||||
const notificationsModal = page.locator('.bruno-modal');
|
||||
const modalCloseButton = notificationsModal.locator('div.bruno-modal-header div.close');
|
||||
|
||||
// Verify modal is visible and has the correct title
|
||||
await expect(notificationsModal).toBeVisible();
|
||||
await expect(notificationsModal.locator('.bruno-modal-header-title')).toContainText('NOTIFICATIONS');
|
||||
|
||||
// Click the close button
|
||||
await modalCloseButton.click();
|
||||
|
||||
// Verify modal is closed
|
||||
await expect(notificationsModal).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
36
e2e-tests/footer/sidebar-toggle/sidebar-toggle.spec.js
Normal file
36
e2e-tests/footer/sidebar-toggle/sidebar-toggle.spec.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
|
||||
test.describe('Sidebar Toggle', () => {
|
||||
test('should toggle sidebar visibility when clicking the toggle button', async ({ page }) => {
|
||||
// Get the sidebar and toggle button elements
|
||||
const sidebar = page.locator('aside.sidebar');
|
||||
const toggleButton = page.getByLabel('Toggle Sidebar');
|
||||
const dragHandle = page.locator('.sidebar-drag-handle');
|
||||
|
||||
// Initial state - sidebar and drag handle should be visible
|
||||
await expect(sidebar).toBeVisible();
|
||||
await expect(dragHandle).toBeVisible();
|
||||
|
||||
// Click toggle to hide sidebar
|
||||
await toggleButton.click();
|
||||
|
||||
// Wait for transition to complete and verify sidebar and drag handle are hidden
|
||||
await expect(sidebar).not.toBeVisible();
|
||||
await expect(dragHandle).not.toBeVisible();
|
||||
|
||||
// Verify the sidebar has collapsed width
|
||||
const sidebarBox = await sidebar.boundingBox();
|
||||
expect(sidebarBox?.width).toBe(0);
|
||||
|
||||
// Click toggle again to show sidebar
|
||||
await toggleButton.click();
|
||||
|
||||
// Wait for transition and verify sidebar and drag handle are visible again
|
||||
await expect(sidebar).toBeVisible();
|
||||
await expect(dragHandle).toBeVisible();
|
||||
|
||||
// Verify the sidebar has expanded width
|
||||
const expandedSidebarBox = await sidebar.boundingBox();
|
||||
expect(expandedSidebarBox?.width).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
154
eslint.config.js
154
eslint.config.js
@@ -5,7 +5,7 @@ const globals = require("globals");
|
||||
module.exports = defineConfig([
|
||||
{
|
||||
files: ["packages/bruno-app/**/*.{js,jsx,ts}"],
|
||||
ignores: ["**/*.config.js"],
|
||||
ignores: ["**/*.config.js", "**/public/**/*"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
@@ -13,7 +13,8 @@ module.exports = defineConfig([
|
||||
global: false,
|
||||
require: false,
|
||||
Buffer: false,
|
||||
process: false
|
||||
process: false,
|
||||
ipcRenderer: false
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
@@ -39,8 +40,60 @@ module.exports = defineConfig([
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["packages/bruno-electron/**/*.{js}"],
|
||||
files: ["packages/bruno-cli/**/*.js"],
|
||||
ignores: ["**/*.config.js"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest"
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-undef": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["packages/bruno-common/**/*.ts"],
|
||||
ignores: ["**/*.config.js", "**/dist/**/*"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
parser: require("@typescript-eslint/parser"),
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
project: "./packages/bruno-common/tsconfig.json",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-undef": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["packages/bruno-converters/**/*.js"],
|
||||
ignores: ["**/*.config.js", "**/dist/**/*"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-undef": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["packages/bruno-electron/**/*.js"],
|
||||
ignores: ["**/*.config.js", "**/web/**/*"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
@@ -50,5 +103,98 @@ module.exports = defineConfig([
|
||||
rules: {
|
||||
"no-undef": "error",
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["packages/bruno-filestore/**/*.ts"],
|
||||
ignores: ["**/*.config.js", "**/dist/**/*"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
parser: require("@typescript-eslint/parser"),
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
project: "./packages/bruno-filestore/tsconfig.json",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-undef": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["packages/bruno-js/**/*.js"],
|
||||
ignores: ["**/*.config.js", "**/dist/**/*"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
window: false,
|
||||
self: false,
|
||||
HTMLElement: false,
|
||||
typeDetectGlobalObject: false
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-undef": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["packages/bruno-lang/**/*.js"],
|
||||
ignores: ["**/*.config.js", "**/dist/**/*"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-undef": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["packages/bruno-requests/**/*.ts"],
|
||||
ignores: ["**/*.config.js", "**/dist/**/*"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
parser: require("@typescript-eslint/parser"),
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
project: "./packages/bruno-requests/tsconfig.json",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-undef": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["packages/bruno-requests/**/*.js"],
|
||||
ignores: ["**/*.config.js", "**/dist/**/*"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-undef": "error",
|
||||
},
|
||||
},
|
||||
]);
|
||||
2882
package-lock.json
generated
2882
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^22.14.1",
|
||||
"@typescript-eslint/parser": "^8.39.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^9.26.0",
|
||||
"fs-extra": "^11.1.1",
|
||||
@@ -76,4 +77,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +186,8 @@ export default class CodeEditor extends React.Component {
|
||||
if (editor) {
|
||||
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
|
||||
editor.on('change', this._onEdit);
|
||||
editor.on('scroll', this.onScroll);
|
||||
editor.scrollTo(null, this.props.initialScroll);
|
||||
this.addOverlay();
|
||||
|
||||
const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
|
||||
@@ -230,12 +232,18 @@ export default class CodeEditor extends React.Component {
|
||||
if (this.props.theme !== prevProps.theme && this.editor) {
|
||||
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
|
||||
}
|
||||
|
||||
if (this.props.initialScroll !== prevProps.initialScroll) {
|
||||
this.editor.scrollTo(null, this.props.initialScroll);
|
||||
}
|
||||
|
||||
this.ignoreChangeEvent = false;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.editor) {
|
||||
this.editor.off('change', this._onEdit);
|
||||
this.editor.off('scroll', this.onScroll);
|
||||
this.editor = null;
|
||||
}
|
||||
|
||||
@@ -271,6 +279,8 @@ export default class CodeEditor extends React.Component {
|
||||
this.editor.setOption('mode', 'brunovariables');
|
||||
};
|
||||
|
||||
onScroll = (event) => this.props.onScroll?.(event);
|
||||
|
||||
_onEdit = () => {
|
||||
if (!this.ignoreChangeEvent && this.editor) {
|
||||
this.editor.setOption('lint', this.editor.getValue().trim().length > 0 ? this.lintOptions : false);
|
||||
|
||||
@@ -67,10 +67,10 @@ const RequestTab = ({ request, response }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{request?.body && (
|
||||
{request?.data && (
|
||||
<div className="section">
|
||||
<h4>Request Body</h4>
|
||||
<pre className="code-block">{formatBody(request.body)}</pre>
|
||||
<pre className="code-block">{formatBody(request.data)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -239,4 +239,4 @@ const RequestDetailsPanel = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestDetailsPanel;
|
||||
export default RequestDetailsPanel;
|
||||
|
||||
@@ -25,12 +25,7 @@ const ImportEnvironment = ({ onClose }) => {
|
||||
}
|
||||
)
|
||||
.map((environment) => {
|
||||
let variables = environment?.variables?.map(v => ({
|
||||
...v,
|
||||
uid: uuid(),
|
||||
type: 'text'
|
||||
}));
|
||||
dispatch(addGlobalEnvironment({ name: environment.name, variables }))
|
||||
dispatch(addGlobalEnvironment({ name: environment.name, variables: environment.variables }))
|
||||
.then(() => {
|
||||
toast.success('Global Environment imported successfully');
|
||||
})
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
const IconSidebarToggle = ({ collapsed = false, size = 16, strokeWidth = 1.5, className = '', ...rest }) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`icon icon-tabler icons-tabler-outline icon-tabler-layout-sidebar ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" />
|
||||
<path d="M9 4l0 16" />
|
||||
{!collapsed && (
|
||||
<rect x="4.6" y="4.6" width="4.8" height="14.8" rx="0.8" fill="currentColor" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconSidebarToggle;
|
||||
@@ -79,7 +79,7 @@ const Notifications = () => {
|
||||
|
||||
const modalCustomHeader = (
|
||||
<div className="flex flex-row gap-8">
|
||||
<div>NOTIFICATIONS</div>
|
||||
<div className="bruno-modal-header-title">NOTIFICATIONS</div>
|
||||
{unreadNotifications.length > 0 && (
|
||||
<>
|
||||
<div className="normal-case font-normal">
|
||||
|
||||
@@ -79,7 +79,7 @@ const Beta = ({ close }) => {
|
||||
<h2 className="text-lg font-semibold">Beta Features</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 text-wrap">
|
||||
Enable beta features, these features may be unstable or incomplete.
|
||||
Beta features are experimental previews that may change before full release. Try them and share feedback.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -98,6 +98,16 @@ const Beta = ({ close }) => {
|
||||
<label className="block ml-2 select-none font-medium" htmlFor={feature.id}>
|
||||
{feature.label}
|
||||
</label>
|
||||
{feature.id === 'grpc' && (
|
||||
<a
|
||||
href="https://github.com/usebruno/bruno/discussions/5447"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-2 text-xs text-blue-500 hover:text-blue-600 underline"
|
||||
>
|
||||
Share feedback
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="beta-feature-description ml-6 text-xs text-gray-500 dark:text-gray-400">
|
||||
{feature.description}
|
||||
|
||||
@@ -147,7 +147,7 @@ const QueryParams = ({ item, collection }) => {
|
||||
<Table
|
||||
headers={[
|
||||
{ name: 'Name', accessor: 'name', width: '31%' },
|
||||
{ name: 'Path', accessor: 'path', width: '56%' },
|
||||
{ name: 'Value', accessor: 'path', width: '56%' },
|
||||
{ name: '', accessor: '', width: '13%' }
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -18,6 +18,7 @@ const RequestTabs = () => {
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
|
||||
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
|
||||
const screenWidth = useSelector((state) => state.app.screenWidth);
|
||||
|
||||
const getTabClassname = (tab, index) => {
|
||||
@@ -49,7 +50,8 @@ const RequestTabs = () => {
|
||||
const activeCollection = find(collections, (c) => c.uid === activeTab.collectionUid);
|
||||
const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab.collectionUid);
|
||||
|
||||
const maxTablistWidth = screenWidth - leftSidebarWidth - 150;
|
||||
const effectiveSidebarWidth = sidebarCollapsed ? 0 : leftSidebarWidth;
|
||||
const maxTablistWidth = screenWidth - effectiveSidebarWidth - 150;
|
||||
const tabsWidth = collectionRequestTabs.length * 150 + 34; // 34: (+)icon
|
||||
const showChevrons = maxTablistWidth < tabsWidth;
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import CodeEditor from 'components/CodeEditor/index';
|
||||
import { get } from 'lodash';
|
||||
import find from 'lodash/find';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs';
|
||||
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { Document, Page } from 'react-pdf';
|
||||
import 'pdfjs-dist/build/pdf.worker';
|
||||
@@ -51,6 +53,10 @@ const QueryResultPreview = ({
|
||||
displayedTheme
|
||||
}) => {
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [numPages, setNumPages] = useState(null);
|
||||
@@ -66,9 +72,19 @@ const QueryResultPreview = ({
|
||||
if (disableRunEventListener) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(sendRequest(item, collection.uid));
|
||||
};
|
||||
|
||||
const onScroll = (event) => {
|
||||
dispatch(
|
||||
updateResponsePaneScrollPosition({
|
||||
uid: focusedTab.uid,
|
||||
scrollY: event.doc.scrollTop
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
switch (previewTab?.mode) {
|
||||
case 'preview-web': {
|
||||
const webViewSrc = data.replace('<head>', `<head><base href="${item.requestSent?.url || ''}">`);
|
||||
@@ -111,8 +127,10 @@ const QueryResultPreview = ({
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
theme={displayedTheme}
|
||||
onRun={onRun}
|
||||
onScroll={onScroll}
|
||||
value={formattedData}
|
||||
mode={mode}
|
||||
initialScroll={focusedTab.responsePaneScrollPosition || 0}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -80,10 +80,6 @@ const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListen
|
||||
const [filter, setFilter] = useState(null);
|
||||
const [showLargeResponse, setShowLargeResponse] = useState(false);
|
||||
const responseEncoding = getEncoding(headers);
|
||||
const formattedData = useMemo(
|
||||
() => formatResponse(data, dataBuffer, responseEncoding, mode, filter),
|
||||
[data, dataBuffer, responseEncoding, mode, filter]
|
||||
);
|
||||
const { displayedTheme } = useTheme();
|
||||
|
||||
const responseSize = useMemo(() => {
|
||||
@@ -105,6 +101,16 @@ const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListen
|
||||
|
||||
const isLargeResponse = responseSize > 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
const formattedData = useMemo(
|
||||
() => {
|
||||
if (isLargeResponse && !showLargeResponse) {
|
||||
return '';
|
||||
}
|
||||
return formatResponse(data, dataBuffer, responseEncoding, mode, filter);
|
||||
},
|
||||
[data, dataBuffer, responseEncoding, mode, filter, isLargeResponse, showLargeResponse]
|
||||
);
|
||||
|
||||
const debouncedResultFilterOnChange = debounce((e) => {
|
||||
setFilter(e.target.value);
|
||||
}, 250);
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function RunnerResults({ collection }) {
|
||||
displayName: getDisplayName(collection.pathname, info.pathname, info.name),
|
||||
tags: [...(info.request?.tags || [])].sort(),
|
||||
};
|
||||
if (newItem.status !== 'error' && newItem.status !== 'skipped') {
|
||||
if (newItem.status !== 'error' && newItem.status !== 'skipped' && newItem.status !== 'running') {
|
||||
newItem.testStatus = getTestStatus(newItem.testResults);
|
||||
newItem.assertionStatus = getTestStatus(newItem.assertionResults);
|
||||
newItem.preRequestTestStatus = getTestStatus(newItem.preRequestTestResults);
|
||||
|
||||
@@ -30,8 +30,9 @@ export const CollectionItemDragPreview = () => {
|
||||
clientOffset: monitor.getClientOffset(),
|
||||
}));
|
||||
if (!isDragging) return null;
|
||||
if (!item.type) return null;
|
||||
const { x, y } = clientOffset || {};
|
||||
const shouldShowFolderIcon = !item.type || item.type === 'folder';
|
||||
const shouldShowFolderIcon = item.type === 'folder';
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div style={getItemStyles({ x, y })} className='p-2'>
|
||||
|
||||
@@ -37,7 +37,9 @@ const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
|
||||
const requestUrl =
|
||||
get(item, 'draft.request.url') !== undefined ? get(item, 'draft.request.url') : get(item, 'request.url');
|
||||
|
||||
const variables = getAllVariables(collection, item);
|
||||
const variables = useMemo(() => {
|
||||
return getAllVariables({ ...collection, globalEnvironmentVariables }, item);
|
||||
}, [collection, globalEnvironmentVariables, item]);
|
||||
|
||||
const interpolatedUrl = interpolateUrl({
|
||||
url: requestUrl,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { IconArrowBackUp, IconEdit } from '@tabler/icons';
|
||||
import Help from 'components/Help';
|
||||
import { multiLineMsg } from "utils/common";
|
||||
import { formatIpcError } from "utils/common/error";
|
||||
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
|
||||
const CreateCollection = ({ onClose }) => {
|
||||
const inputRef = useRef();
|
||||
@@ -45,6 +46,7 @@ const CreateCollection = ({ onClose }) => {
|
||||
dispatch(createCollection(values.collectionName, values.collectionFolderName, values.collectionLocation))
|
||||
.then(() => {
|
||||
toast.success('Collection created!');
|
||||
dispatch(toggleSidebarCollapse());
|
||||
onClose();
|
||||
})
|
||||
.catch((e) => toast.error(multiLineMsg('An error occurred while creating the collection', formatIpcError(e))));
|
||||
|
||||
@@ -1,126 +1,204 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { IconLoader2 } from '@tabler/icons';
|
||||
import importBrunoCollection from 'utils/importers/bruno-collection';
|
||||
import { postmanToBruno, readFile } from 'utils/importers/postman-collection';
|
||||
import importInsomniaCollection from 'utils/importers/insomnia-collection';
|
||||
import importOpenapiCollection from 'utils/importers/openapi-collection';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { IconLoader2, IconFileImport } from '@tabler/icons';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import Modal from 'components/Modal';
|
||||
import fileDialog from 'file-dialog';
|
||||
import jsyaml from 'js-yaml';
|
||||
import { postmanToBruno, isPostmanCollection } from 'utils/importers/postman-collection';
|
||||
import { convertInsomniaToBruno, isInsomniaCollection } from 'utils/importers/insomnia-collection';
|
||||
import { convertOpenapiToBruno, isOpenApiSpec } from 'utils/importers/openapi-collection';
|
||||
import { processBrunoCollection } from 'utils/importers/bruno-collection';
|
||||
|
||||
const convertFileToObject = async (file) => {
|
||||
const text = await file.text();
|
||||
|
||||
try {
|
||||
if (file.type === 'application/json' || file.name.endsWith('.json')) {
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
const parsed = jsyaml.load(text);
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
throw new Error();
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
throw new Error('Failed to parse the file – ensure it is valid JSON or YAML');
|
||||
}
|
||||
};
|
||||
|
||||
const FullscreenLoader = ({ isLoading }) => {
|
||||
const [loadingMessage, setLoadingMessage] = useState('');
|
||||
|
||||
// Messages to cycle through while loading
|
||||
const loadingMessages = [
|
||||
'Processing collection...',
|
||||
'Analyzing requests...',
|
||||
'Translating scripts...',
|
||||
'Preparing collection...',
|
||||
'Almost done...'
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) return;
|
||||
|
||||
let messageIndex = 0;
|
||||
const interval = setInterval(() => {
|
||||
messageIndex = (messageIndex + 1) % loadingMessages.length;
|
||||
setLoadingMessage(loadingMessages[messageIndex]);
|
||||
}, 2000);
|
||||
|
||||
setLoadingMessage(loadingMessages[0]);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isLoading]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm transition-all duration-300">
|
||||
<div className="flex flex-col items-center p-8 rounded-lg bg-white dark:bg-zinc-800 shadow-lg max-w-md text-center">
|
||||
<IconLoader2 className="animate-spin h-12 w-12 mb-4" strokeWidth={1.5} />
|
||||
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-50 mb-2">
|
||||
{loadingMessage}
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
This may take a moment depending on the collection size
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ImportCollection = ({ onClose, handleSubmit }) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const handleImportBrunoCollection = () => {
|
||||
importBrunoCollection()
|
||||
.then(({ collection }) => {
|
||||
handleSubmit({ collection });
|
||||
})
|
||||
.catch((err) => toastError(err, 'Import collection failed'))
|
||||
const handleDrag = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
|
||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||
setDragActive(true);
|
||||
} else if (e.type === 'dragleave') {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const processFile = async (file) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await convertFileToObject(file);
|
||||
|
||||
if (!data) {
|
||||
throw new Error('Failed to parse file content');
|
||||
}
|
||||
|
||||
let collection;
|
||||
|
||||
if (isPostmanCollection(data)) {
|
||||
collection = await postmanToBruno(data);
|
||||
}
|
||||
else if (isInsomniaCollection(data)) {
|
||||
collection = convertInsomniaToBruno(data);
|
||||
}
|
||||
else if (isOpenApiSpec(data)) {
|
||||
collection = convertOpenapiToBruno(data);
|
||||
}
|
||||
else {
|
||||
collection = await processBrunoCollection(data);
|
||||
}
|
||||
|
||||
handleSubmit({ collection });
|
||||
} catch (err) {
|
||||
toastError(err, 'Import collection failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportPostmanCollection = () => {
|
||||
fileDialog({ accept: 'application/json' })
|
||||
.then((...args) => {
|
||||
setIsLoading(true);
|
||||
return readFile(...args);
|
||||
})
|
||||
.then((collection) => postmanToBruno(collection))
|
||||
.then((collection) => handleSubmit({ collection }))
|
||||
.catch((err) => toastError(err, 'Postman Import collection failed'))
|
||||
.finally(() => setIsLoading(false));
|
||||
const handleDrop = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
await processFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseFiles = () => {
|
||||
fileInputRef.current.click();
|
||||
};
|
||||
|
||||
const handleFileInputChange = async (e) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
await processFile(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <FullscreenLoader isLoading={isLoading} />;
|
||||
}
|
||||
|
||||
const handleImportInsomniaCollection = () => {
|
||||
importInsomniaCollection()
|
||||
.then(({ collection }) => {
|
||||
handleSubmit({ collection });
|
||||
})
|
||||
.catch((err) => toastError(err, 'Insomnia Import collection failed'))
|
||||
};
|
||||
const acceptedFileTypes = [
|
||||
'.json',
|
||||
'.yaml',
|
||||
'.yml',
|
||||
'application/json',
|
||||
'application/yaml',
|
||||
'application/x-yaml'
|
||||
]
|
||||
|
||||
const handleImportOpenapiCollection = () => {
|
||||
importOpenapiCollection()
|
||||
.then(({ collection }) => {
|
||||
handleSubmit({ collection });
|
||||
})
|
||||
.catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'))
|
||||
};
|
||||
|
||||
const CollectionButton = ({ children, className, onClick }) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded bg-transparent px-2.5 py-1 text-xs font-semibold text-zinc-900 dark:text-zinc-50 shadow-sm ring-1 ring-inset ring-zinc-300 dark:ring-zinc-500 hover:bg-gray-50 dark:hover:bg-zinc-700
|
||||
${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FullscreenLoader = () => {
|
||||
const [loadingMessage, setLoadingMessage] = useState('');
|
||||
|
||||
// Messages to cycle through while loading
|
||||
const loadingMessages = [
|
||||
'Processing collection...',
|
||||
'Analyzing requests...',
|
||||
'Translating scripts...',
|
||||
'Preparing collection...',
|
||||
'Almost done...'
|
||||
];
|
||||
|
||||
|
||||
// Cycle through loading messages for better UX
|
||||
useEffect(() => {
|
||||
if (!isLoading) return;
|
||||
|
||||
let messageIndex = 0;
|
||||
const interval = setInterval(() => {
|
||||
messageIndex = (messageIndex + 1) % loadingMessages.length;
|
||||
setLoadingMessage(loadingMessages[messageIndex]);
|
||||
}, 2000);
|
||||
|
||||
setLoadingMessage(loadingMessages[0]);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isLoading]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm transition-all duration-300">
|
||||
<div className="flex flex-col items-center p-8 rounded-lg bg-white dark:bg-zinc-800 shadow-lg max-w-md text-center">
|
||||
<IconLoader2 className="animate-spin h-12 w-12 mb-4" strokeWidth={1.5} />
|
||||
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-50 mb-2">
|
||||
{loadingMessage}
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
This may take a moment depending on the collection size
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <FullscreenLoader />}
|
||||
{!isLoading && (
|
||||
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose}>
|
||||
<div className="flex flex-col">
|
||||
<h3 className="text-sm">Select the type of your existing collection :</h3>
|
||||
<div className="mt-4 grid grid-rows-2 grid-flow-col gap-2">
|
||||
<CollectionButton onClick={handleImportBrunoCollection}>Bruno Collection</CollectionButton>
|
||||
<CollectionButton onClick={handleImportPostmanCollection}>Postman Collection</CollectionButton>
|
||||
<CollectionButton onClick={handleImportInsomniaCollection}>Insomnia Collection</CollectionButton>
|
||||
<CollectionButton onClick={handleImportOpenapiCollection}>OpenAPI V3 Spec</CollectionButton>
|
||||
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose}>
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Import from file</h3>
|
||||
<div
|
||||
onDragEnter={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-6 transition-colors duration-200
|
||||
${dragActive
|
||||
? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<IconFileImport
|
||||
size={28}
|
||||
className="text-gray-400 dark:text-gray-500 mb-3"
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
accept={acceptedFileTypes.join(',')}
|
||||
/>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">
|
||||
Drop file to import or{' '}
|
||||
<button
|
||||
className="text-blue-500 underline cursor-pointer"
|
||||
onClick={handleBrowseFiles}
|
||||
>
|
||||
choose a file
|
||||
</button>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Supports Bruno, Postman, Insomnia, and OpenAPI v3 formats
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ const Wrapper = styled.div`
|
||||
|
||||
aside {
|
||||
background-color: ${(props) => props.theme.sidebar.bg};
|
||||
overflow: hidden;
|
||||
|
||||
.collection-title {
|
||||
line-height: 1.5;
|
||||
@@ -41,7 +42,7 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
div.drag-sidebar {
|
||||
div.sidebar-drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -50,6 +51,7 @@ const Wrapper = styled.div`
|
||||
background-color: transparent;
|
||||
width: 6px;
|
||||
right: -3px;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover div.drag-request-border {
|
||||
width: 2px;
|
||||
|
||||
@@ -1,36 +1,37 @@
|
||||
import TitleBar from './TitleBar';
|
||||
import Collections from './Collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useApp } from 'providers/App';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateLeftSidebarWidth, updateIsDragging } from 'providers/ReduxStore/slices/app';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
const MIN_LEFT_SIDEBAR_WIDTH = 221;
|
||||
const MAX_LEFT_SIDEBAR_WIDTH = 600;
|
||||
|
||||
const Sidebar = () => {
|
||||
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
|
||||
const { version } = useApp();
|
||||
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
|
||||
const [asideWidth, setAsideWidth] = useState(leftSidebarWidth);
|
||||
|
||||
const { storedTheme } = useTheme();
|
||||
const lastWidthRef = useRef(leftSidebarWidth);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const currentWidth = sidebarCollapsed ? 0 : asideWidth;
|
||||
|
||||
// Clamp helper keeps width in allowed range
|
||||
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (dragging) {
|
||||
e.preventDefault();
|
||||
let width = e.clientX + 2;
|
||||
if (width < MIN_LEFT_SIDEBAR_WIDTH || width > MAX_LEFT_SIDEBAR_WIDTH) {
|
||||
return;
|
||||
}
|
||||
setAsideWidth(width);
|
||||
}
|
||||
if (!dragging || sidebarCollapsed) return;
|
||||
e.preventDefault();
|
||||
const nextWidth = clamp(e.clientX + 2, MIN_LEFT_SIDEBAR_WIDTH, MAX_LEFT_SIDEBAR_WIDTH);
|
||||
if (Math.abs(nextWidth - lastWidthRef.current) < 3) return;
|
||||
lastWidthRef.current = nextWidth;
|
||||
setAsideWidth(nextWidth);
|
||||
};
|
||||
|
||||
const handleMouseUp = (e) => {
|
||||
if (dragging) {
|
||||
e.preventDefault();
|
||||
@@ -49,6 +50,9 @@ const Sidebar = () => {
|
||||
};
|
||||
const handleDragbarMouseDown = (e) => {
|
||||
e.preventDefault();
|
||||
if (sidebarCollapsed) {
|
||||
return;
|
||||
}
|
||||
setDragging(true);
|
||||
dispatch(
|
||||
updateIsDragging({
|
||||
@@ -73,7 +77,7 @@ const Sidebar = () => {
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex relative h-full">
|
||||
<aside>
|
||||
<aside className="sidebar" style={{ width: currentWidth, transition: dragging ? 'none' : 'width 0.2s ease-in-out' }}>
|
||||
<div className="flex flex-row h-full w-full">
|
||||
<div className="flex flex-col w-full" style={{ width: asideWidth }}>
|
||||
<div className="flex flex-col flex-grow">
|
||||
@@ -84,9 +88,11 @@ const Sidebar = () => {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="absolute drag-sidebar h-full" onMouseDown={handleDragbarMouseDown}>
|
||||
<div className="drag-request-border" />
|
||||
</div>
|
||||
{!sidebarCollapsed && (
|
||||
<div className="absolute sidebar-drag-handle h-full" onMouseDown={handleDragbarMouseDown}>
|
||||
<div className="drag-request-border" />
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconSettings, IconCookie, IconTool } from '@tabler/icons';
|
||||
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import Preferences from 'components/Preferences';
|
||||
import Cookies from 'components/Cookies';
|
||||
import Notifications from 'components/Notifications';
|
||||
import Portal from 'components/Portal';
|
||||
import { showPreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { showPreferences, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import { openConsole } from 'providers/ReduxStore/slices/logs';
|
||||
import { useApp } from 'providers/App';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -15,6 +16,7 @@ const StatusBar = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferencesOpen = useSelector((state) => state.app.showPreferences);
|
||||
const logs = useSelector((state) => state.logs.logs);
|
||||
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
|
||||
const [cookiesOpen, setCookiesOpen] = useState(false);
|
||||
const { version } = useApp();
|
||||
|
||||
@@ -59,6 +61,16 @@ const StatusBar = () => {
|
||||
<div className="status-bar">
|
||||
<div className="status-bar-section">
|
||||
<div className="status-bar-group">
|
||||
<ToolHint text="Toggle Sidebar" toolhintId="Toggle Sidebar" place="top-start" offset={10}>
|
||||
<button
|
||||
className="status-bar-button"
|
||||
aria-label="Toggle Sidebar"
|
||||
onClick={() => dispatch(toggleSidebarCollapse())}
|
||||
>
|
||||
<IconSidebarToggle collapsed={sidebarCollapsed} size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
</button>
|
||||
</ToolHint>
|
||||
|
||||
<ToolHint text="Preferences" toolhintId="Preferences" place="top-start" offset={10}>
|
||||
<button
|
||||
className="status-bar-button"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
import { IconBrandGithub, IconPlus, IconDownload, IconFolders, IconSpeakerphone, IconBook } from '@tabler/icons';
|
||||
|
||||
import Bruno from 'components/Bruno';
|
||||
@@ -14,13 +15,19 @@ import StyledWrapper from './StyledWrapper';
|
||||
const Welcome = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const [importedCollection, setImportedCollection] = useState(null);
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
|
||||
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
|
||||
|
||||
const handleOpenCollection = () => {
|
||||
dispatch(openCollection()).catch((err) => console.log(err) && toast.error(t('WELCOME.COLLECTION_OPEN_ERROR')));
|
||||
dispatch(openCollection())
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error(t('WELCOME.COLLECTION_OPEN_ERROR'));
|
||||
});
|
||||
};
|
||||
|
||||
const handleImportCollection = ({ collection }) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
|
||||
import { closeTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import { getKeyBindingsForActionAllOS } from './keyMappings';
|
||||
|
||||
export const HotkeysContext = React.createContext();
|
||||
@@ -224,6 +225,18 @@ export const HotkeysProvider = (props) => {
|
||||
};
|
||||
}, [activeTabUid, tabs, collections, dispatch]);
|
||||
|
||||
// Collapse sidebar (ctrl/cmd + \)
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('collapseSidebar')], (e) => {
|
||||
dispatch(toggleSidebarCollapse());
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('collapseSidebar')]);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const currentCollection = getCurrentCollection();
|
||||
|
||||
return (
|
||||
|
||||
@@ -20,7 +20,8 @@ const KeyMapping = {
|
||||
windows: 'ctrl+pagedown',
|
||||
name: 'Switch to Next Tab'
|
||||
},
|
||||
closeAllTabs: { mac: 'command+shift+w', windows: 'ctrl+shift+w', name: 'Close All Tabs' }
|
||||
closeAllTabs: { mac: 'command+shift+w', windows: 'ctrl+shift+w', name: 'Close All Tabs' },
|
||||
collapseSidebar: { mac: 'command+\\', windows: 'ctrl+\\', name: 'Collapse Sidebar' }
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ const initialState = {
|
||||
isDragging: false,
|
||||
idbConnectionReady: false,
|
||||
leftSidebarWidth: 222,
|
||||
sidebarCollapsed: false,
|
||||
screenWidth: 500,
|
||||
showHomePage: false,
|
||||
showPreferences: false,
|
||||
@@ -89,6 +90,9 @@ export const appSlice = createSlice({
|
||||
...state.generateCode,
|
||||
...action.payload
|
||||
};
|
||||
},
|
||||
toggleSidebarCollapse: (state) => {
|
||||
state.sidebarCollapsed = !state.sidebarCollapsed;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -108,7 +112,8 @@ export const {
|
||||
removeTaskFromQueue,
|
||||
removeAllTasksFromQueue,
|
||||
updateSystemProxyEnvVariables,
|
||||
updateGenerateCode
|
||||
updateGenerateCode,
|
||||
toggleSidebarCollapse
|
||||
} = appSlice.actions;
|
||||
|
||||
export const savePreferences = (preferences) => (dispatch, getState) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import get from 'lodash/get';
|
||||
import set from 'lodash/set';
|
||||
import trim from 'lodash/trim';
|
||||
import path from 'utils/common/path';
|
||||
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
|
||||
import { insertTaskIntoQueue, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
findCollectionByUid,
|
||||
@@ -41,11 +41,12 @@ import {
|
||||
initRunRequestEvent,
|
||||
updateRunnerConfiguration as _updateRunnerConfiguration,
|
||||
updateActiveConnections,
|
||||
saveRequest as _saveRequest
|
||||
saveRequest as _saveRequest,
|
||||
saveEnvironment as _saveEnvironment
|
||||
} from './index';
|
||||
|
||||
import { each } from 'lodash';
|
||||
import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { closeAllCollectionTabs, updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs';
|
||||
import { resolveRequestFilename } from 'utils/common/platform';
|
||||
import { parsePathParams, splitOnFirst } from 'utils/url/index';
|
||||
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
|
||||
@@ -59,6 +60,7 @@ import {
|
||||
calculateDraggedItemNewPathname
|
||||
} from 'utils/collections/index';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
import { buildPersistedEnvVariables } from 'utils/environments';
|
||||
import { safeParseJSON, safeStringifyJSON } from 'utils/common/index';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { updateSettingsSelectedTab } from './index';
|
||||
@@ -258,6 +260,13 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
|
||||
const requestUid = uuid();
|
||||
itemCopy.requestUid = requestUid;
|
||||
|
||||
await dispatch(
|
||||
updateResponsePaneScrollPosition({
|
||||
uid: state.tabs.activeTabUid,
|
||||
scrollY: 0
|
||||
})
|
||||
);
|
||||
|
||||
await dispatch(
|
||||
initRunRequestEvent({
|
||||
requestUid,
|
||||
@@ -1167,8 +1176,16 @@ export const copyEnvironment = (name, baseEnvUid, collectionUid) => (dispatch, g
|
||||
const sanitizedName = sanitizeName(name);
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
// strip "ephemeral" metadata
|
||||
const variablesToCopy = (baseEnv.variables || [])
|
||||
.filter((v) => !v.ephemeral)
|
||||
.map(({ ephemeral, ...rest }) => {
|
||||
return rest;
|
||||
});
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:create-environment', collection.pathname, sanitizedName, baseEnv.variables)
|
||||
.invoke('renderer:create-environment', collection.pathname, sanitizedName, variablesToCopy)
|
||||
.then(
|
||||
dispatch(
|
||||
updateLastAction({
|
||||
@@ -1249,12 +1266,27 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
|
||||
return reject(new Error('Environment not found'));
|
||||
}
|
||||
|
||||
environment.variables = variables;
|
||||
/*
|
||||
Modal Save writes what the user sees:
|
||||
- Non-ephemeral vars are saved as-is (without metadata)
|
||||
- Ephemeral vars:
|
||||
- if persistedValue exists, save that (explicit persisted case)
|
||||
- otherwise save the current UI value (treat as user-authored)
|
||||
*/
|
||||
const persisted = buildPersistedEnvVariables(variables, { mode: 'save' });
|
||||
environment.variables = persisted;
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
const envForValidation = cloneDeep(environment);
|
||||
|
||||
environmentSchema
|
||||
.validate(environment)
|
||||
.then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environment))
|
||||
.then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, envForValidation))
|
||||
.then(() => {
|
||||
// Immediately sync Redux to the saved (persisted) set so old ephemerals
|
||||
// aren’t around when the watcher event arrives.
|
||||
dispatch(_saveEnvironment({ variables: persisted, environmentUid, collectionUid }));
|
||||
})
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
@@ -1311,12 +1343,15 @@ export const mergeAndPersistEnvironment =
|
||||
}
|
||||
});
|
||||
|
||||
environment.variables = merged;
|
||||
// Save only non-ephemeral vars, or ephemerals explicitly persisted this run
|
||||
const persistedNames = new Set(Object.keys(persistentEnvVariables));
|
||||
const environmentToSave = cloneDeep(environment);
|
||||
environmentToSave.variables = buildPersistedEnvVariables(merged, { mode: 'merge', persistedNames });
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
environmentSchema
|
||||
.validate(environment)
|
||||
.then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environment))
|
||||
.validate(environmentToSave)
|
||||
.then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environmentToSave))
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
@@ -1427,7 +1462,14 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
|
||||
collectionSchema
|
||||
.validate(collection)
|
||||
.then(() => dispatch(_createCollection({ ...collection, securityConfig })))
|
||||
.then(resolve)
|
||||
.then(() => {
|
||||
// Expand sidebar if it's collapsed after collection is successfully opened
|
||||
const state = getState();
|
||||
if (state.app.sidebarCollapsed) {
|
||||
dispatch(toggleSidebarCollapse());
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
});
|
||||
@@ -1627,27 +1669,33 @@ export const clearOauth2Cache = (payload) => async (dispatch, getState) => {
|
||||
};
|
||||
|
||||
// todo: could be removed
|
||||
export const loadRequestViaWorker = ({ collectionUid, pathname }) => (dispatch, getState) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer.invoke('renderer:load-request-via-worker', { collectionUid, pathname }).then(resolve).catch(reject);
|
||||
});
|
||||
};
|
||||
export const loadRequestViaWorker =
|
||||
({ collectionUid, pathname }) =>
|
||||
(dispatch, getState) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer.invoke('renderer:load-request-via-worker', { collectionUid, pathname }).then(resolve).catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
// todo: could be removed
|
||||
export const loadRequest = ({ collectionUid, pathname }) => (dispatch, getState) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer.invoke('renderer:load-request', { collectionUid, pathname }).then(resolve).catch(reject);
|
||||
});
|
||||
};
|
||||
export const loadRequest =
|
||||
({ collectionUid, pathname }) =>
|
||||
(dispatch, getState) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer.invoke('renderer:load-request', { collectionUid, pathname }).then(resolve).catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const loadLargeRequest = ({ collectionUid, pathname }) => (dispatch, getState) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer.invoke('renderer:load-large-request', { collectionUid, pathname }).then(resolve).catch(reject);
|
||||
});
|
||||
};
|
||||
export const loadLargeRequest =
|
||||
({ collectionUid, pathname }) =>
|
||||
(dispatch, getState) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer.invoke('renderer:load-large-request', { collectionUid, pathname }).then(resolve).catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const mountCollection =
|
||||
({ collectionUid, collectionPathname, brunoConfig }) =>
|
||||
@@ -1671,16 +1719,17 @@ export const showInFolder = (collectionPath) => () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const updateRunnerConfiguration = (collectionUid, selectedRequestItems, requestItemsOrder, delay) => (dispatch) => {
|
||||
dispatch(
|
||||
_updateRunnerConfiguration({
|
||||
collectionUid,
|
||||
selectedRequestItems,
|
||||
requestItemsOrder,
|
||||
delay
|
||||
})
|
||||
);
|
||||
};
|
||||
export const updateRunnerConfiguration =
|
||||
(collectionUid, selectedRequestItems, requestItemsOrder, delay) => (dispatch) => {
|
||||
dispatch(
|
||||
_updateRunnerConfiguration({
|
||||
collectionUid,
|
||||
selectedRequestItems,
|
||||
requestItemsOrder,
|
||||
delay
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const updateActiveConnectionsInStore = (activeConnectionIds) => (dispatch, getState) => {
|
||||
dispatch(updateActiveConnections(activeConnectionIds));
|
||||
|
||||
@@ -284,7 +284,20 @@ export const collectionsSlice = createSlice({
|
||||
const variable = find(activeEnvironment.variables, (v) => v.name === key);
|
||||
|
||||
if (variable) {
|
||||
variable.value = value;
|
||||
// For updates coming from scripts, treat them as ephemeral overlays.
|
||||
if (variable.value !== value) {
|
||||
/*
|
||||
Overlay (persist: false): keep new value in Redux for UI and mark ephemeral
|
||||
so it isn't written to disk. persistedValue stores the previous on-disk value;
|
||||
save/persist uses that base unless the key is explicitly persisted.
|
||||
*/
|
||||
const previousValue = variable.value;
|
||||
variable.value = value;
|
||||
variable.ephemeral = true;
|
||||
if (variable.persistedValue === undefined) {
|
||||
variable.persistedValue = previousValue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// __name__ is a private variable used to store the name of the environment
|
||||
// this is not a user defined variable and hence should not be updated
|
||||
@@ -295,7 +308,8 @@ export const collectionsSlice = createSlice({
|
||||
secret: false,
|
||||
enabled: true,
|
||||
type: 'text',
|
||||
uid: uuid()
|
||||
uid: uuid(),
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2275,7 +2289,21 @@ export const collectionsSlice = createSlice({
|
||||
const existingEnv = collection.environments.find((e) => e.uid === environment.uid);
|
||||
|
||||
if (existingEnv) {
|
||||
const prevEphemerals = (existingEnv.variables || []).filter((v) => v.ephemeral);
|
||||
existingEnv.variables = environment.variables;
|
||||
/*
|
||||
Apply temporary (ephemeral) values only to variables that actually exist in the file. This prevents deleted temporaries from “popping back” after a save. If a variable is present in the file, we temporarily override the UI value while also remembering the on-disk value in persistedValue for future saves.
|
||||
*/
|
||||
prevEphemerals.forEach((ev) => {
|
||||
const target = existingEnv.variables?.find((v) => v.name === ev.name);
|
||||
if (target) {
|
||||
if (target.value !== ev.value) {
|
||||
if (target.persistedValue === undefined) target.persistedValue = target.value;
|
||||
target.value = ev.value;
|
||||
}
|
||||
target.ephemeral = true;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
collection.environments.push(environment);
|
||||
collection.environments.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
@@ -62,10 +62,10 @@ export const tabsSlice = createSlice({
|
||||
? preview
|
||||
: !nonReplaceableTabTypes.includes(type),
|
||||
...(uid ? { folderUid: uid } : {})
|
||||
}
|
||||
};
|
||||
|
||||
state.activeTabUid = uid;
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
state.tabs.push({
|
||||
@@ -74,6 +74,7 @@ export const tabsSlice = createSlice({
|
||||
requestPaneWidth: null,
|
||||
requestPaneTab: requestPaneTab || defaultRequestPaneTab,
|
||||
responsePaneTab: 'response',
|
||||
responsePaneScrollPosition: null,
|
||||
type: type || 'request',
|
||||
...(uid ? { folderUid: uid } : {}),
|
||||
preview: preview !== undefined
|
||||
@@ -126,6 +127,13 @@ export const tabsSlice = createSlice({
|
||||
tab.responsePaneTab = action.payload.responsePaneTab;
|
||||
}
|
||||
},
|
||||
updateResponsePaneScrollPosition: (state, action) => {
|
||||
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
|
||||
|
||||
if (tab) {
|
||||
tab.responsePaneScrollPosition = action.payload.scrollY;
|
||||
}
|
||||
},
|
||||
closeTabs: (state, action) => {
|
||||
const activeTab = find(state.tabs, (t) => t.uid === state.activeTabUid);
|
||||
const tabUids = action.payload.tabUids || [];
|
||||
@@ -167,8 +175,8 @@ export const tabsSlice = createSlice({
|
||||
const tab = find(state.tabs, (t) => t.uid === uid);
|
||||
if (tab) {
|
||||
tab.preview = false;
|
||||
} else{
|
||||
console.error("Tab not found!")
|
||||
} else {
|
||||
console.error('Tab not found!');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,6 +189,7 @@ export const {
|
||||
updateRequestPaneTabWidth,
|
||||
updateRequestPaneTab,
|
||||
updateResponsePaneTab,
|
||||
updateResponsePaneScrollPosition,
|
||||
closeTabs,
|
||||
closeAllCollectionTabs,
|
||||
makeTabPermanent
|
||||
|
||||
@@ -131,6 +131,10 @@
|
||||
--px-12: 2px !important;
|
||||
}
|
||||
|
||||
.CodeMirror-hints {
|
||||
z-index: 20 !important;
|
||||
}
|
||||
|
||||
.graphiql-container {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
@@ -53,14 +53,7 @@ const createQuery = (queryParams = []) => {
|
||||
}));
|
||||
};
|
||||
|
||||
const createPostData = (body, type) => {
|
||||
if (type === 'graphql-request') {
|
||||
return {
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(body[body.mode])
|
||||
};
|
||||
}
|
||||
|
||||
const createPostData = (body) => {
|
||||
const contentType = createContentType(body.mode);
|
||||
|
||||
switch (body.mode) {
|
||||
@@ -112,6 +105,11 @@ const createPostData = (body, type) => {
|
||||
: []
|
||||
};
|
||||
}
|
||||
case 'graphql':
|
||||
return {
|
||||
mimeType: contentType,
|
||||
text: JSON.stringify(body[body.mode])
|
||||
};
|
||||
default:
|
||||
return {
|
||||
mimeType: contentType,
|
||||
@@ -120,7 +118,7 @@ const createPostData = (body, type) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const buildHarRequest = ({ request, headers, type }) => {
|
||||
export const buildHarRequest = ({ request, headers }) => {
|
||||
return {
|
||||
method: request.method,
|
||||
url: encodeURI(request.url),
|
||||
@@ -128,7 +126,7 @@ export const buildHarRequest = ({ request, headers, type }) => {
|
||||
cookies: [],
|
||||
headers: createHeaders(request, headers),
|
||||
queryString: createQuery(request.params),
|
||||
postData: createPostData(request.body, type),
|
||||
postData: createPostData(request.body),
|
||||
headersSize: 0,
|
||||
bodySize: 0,
|
||||
binary: true
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {cloneDeep, isEqual, sortBy, filter, map, isString, findIndex, find, each, get } from 'lodash';
|
||||
import { uuid } from 'utils/common';
|
||||
import { buildPersistedEnvVariables } from 'utils/environments';
|
||||
import { sortByNameThenSequence } from 'utils/common/index';
|
||||
import path from 'utils/common/path';
|
||||
import { isRequestTagsIncluded } from '@usebruno/common';
|
||||
@@ -232,6 +233,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
return;
|
||||
}
|
||||
|
||||
const isGrpcRequest = si.type === 'grpc-request'
|
||||
|
||||
const di = {
|
||||
uid: si.uid,
|
||||
type: si.type,
|
||||
@@ -246,8 +249,6 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
di.request = {
|
||||
url: si.request.url,
|
||||
method: si.request.method,
|
||||
methodType: si.request.methodType,
|
||||
protoPath: si.request.protoPath,
|
||||
headers: copyHeaders(si.request.headers),
|
||||
params: copyParams(si.request.params),
|
||||
body: {
|
||||
@@ -269,6 +270,13 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
docs: si.request.docs
|
||||
};
|
||||
|
||||
if (isGrpcRequest) {
|
||||
di.request.methodType = si.request.methodType;
|
||||
di.request.protoPath = si.request.protoPath;
|
||||
delete di.request.params;
|
||||
}
|
||||
|
||||
|
||||
// Handle auth object dynamically
|
||||
di.request.auth = {
|
||||
mode: get(si.request, 'auth.mode', 'none')
|
||||
@@ -329,6 +337,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
|
||||
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
|
||||
autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
|
||||
additionalParameters: get(si.request, 'auth.oauth2.additionalParameters', {}),
|
||||
};
|
||||
break;
|
||||
case 'authorization_code':
|
||||
@@ -349,6 +358,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
|
||||
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
|
||||
autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
|
||||
additionalParameters: get(si.request, 'auth.oauth2.additionalParameters', {}),
|
||||
};
|
||||
break;
|
||||
case 'implicit':
|
||||
@@ -364,6 +374,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'),
|
||||
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
|
||||
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
|
||||
additionalParameters: get(si.request, 'auth.oauth2.additionalParameters', {}),
|
||||
};
|
||||
break;
|
||||
case 'client_credentials':
|
||||
@@ -381,6 +392,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
|
||||
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
|
||||
autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
|
||||
additionalParameters: get(si.request, 'auth.oauth2.additionalParameters', {}),
|
||||
};
|
||||
break;
|
||||
}
|
||||
@@ -495,7 +507,11 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
collectionToSave.version = '1';
|
||||
collectionToSave.items = [];
|
||||
collectionToSave.activeEnvironmentUid = collection.activeEnvironmentUid;
|
||||
collectionToSave.environments = collection.environments || [];
|
||||
// Save environments without runtime metadata (ephemeral/persistedValue)
|
||||
collectionToSave.environments = (collection.environments || []).map((env) => ({
|
||||
...env,
|
||||
variables: buildPersistedEnvVariables(env?.variables, { mode: 'save' })
|
||||
}));
|
||||
|
||||
collectionToSave.root = {
|
||||
request: {}
|
||||
|
||||
31
packages/bruno-app/src/utils/environments.js
Normal file
31
packages/bruno-app/src/utils/environments.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const isPersistableEnvVarForMerge = (persistedNames) => (v) => {
|
||||
return !v?.ephemeral || v?.persistedValue !== undefined || (v?.name && persistedNames.has(v.name));
|
||||
};
|
||||
|
||||
const toPersistedEnvVarForMerge = (persistedNames) => (v) => {
|
||||
const { ephemeral, persistedValue, ...rest } = v || {};
|
||||
if (v?.ephemeral && persistedValue !== undefined && !(v?.name && persistedNames.has(v.name))) {
|
||||
return { ...rest, value: persistedValue };
|
||||
}
|
||||
return rest;
|
||||
};
|
||||
|
||||
const toPersistedEnvVarForSave = (v) => {
|
||||
const { ephemeral, persistedValue, ...rest } = v || {};
|
||||
return v?.ephemeral ? (persistedValue !== undefined ? { ...rest, value: persistedValue } : rest) : rest;
|
||||
};
|
||||
|
||||
/*
|
||||
High-level builder for persisted variables
|
||||
- mode 'save': write what the user sees
|
||||
- mode 'merge': write only allowed vars (non-ephemeral, ephemerals with persistedValue, or explicitly persisted this run)
|
||||
*/
|
||||
export const buildPersistedEnvVariables = (variables, { mode, persistedNames } = {}) => {
|
||||
const src = Array.isArray(variables) ? variables : [];
|
||||
if (mode === 'merge') {
|
||||
const names = persistedNames instanceof Set ? persistedNames : new Set();
|
||||
return src.filter(isPersistableEnvVarForMerge(names)).map(toPersistedEnvVarForMerge(names));
|
||||
}
|
||||
// default to save mode
|
||||
return src.map(toPersistedEnvVarForSave);
|
||||
};
|
||||
@@ -1,43 +1,16 @@
|
||||
import fileDialog from 'file-dialog';
|
||||
import { BrunoError } from 'utils/common/error';
|
||||
import { validateSchema, transformItemsInCollection, updateUidsInCollection, hydrateSeqInCollection } from './common';
|
||||
|
||||
const readFile = (files) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = (e) => resolve(e.target.result);
|
||||
fileReader.onerror = (err) => reject(err);
|
||||
fileReader.readAsText(files[0]);
|
||||
});
|
||||
};
|
||||
|
||||
const parseJsonCollection = (str) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
let parsed = JSON.parse(str);
|
||||
return resolve(parsed);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
reject(new BrunoError('Unable to parse the collection json file'));
|
||||
}
|
||||
});
|
||||
export const processBrunoCollection = async (jsonData) => {
|
||||
try {
|
||||
let collection = hydrateSeqInCollection(jsonData);
|
||||
collection = updateUidsInCollection(collection);
|
||||
collection = transformItemsInCollection(collection);
|
||||
await validateSchema(collection);
|
||||
return collection;
|
||||
} catch (err) {
|
||||
console.error('Error processing Bruno collection:', err);
|
||||
throw new BrunoError('Import collection failed');
|
||||
}
|
||||
};
|
||||
|
||||
const importCollection = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fileDialog({ accept: 'application/json' })
|
||||
.then(readFile)
|
||||
.then(parseJsonCollection)
|
||||
.then(hydrateSeqInCollection)
|
||||
.then(updateUidsInCollection)
|
||||
.then(transformItemsInCollection)
|
||||
.then(validateSchema)
|
||||
.then((collection) => resolve({ collection }))
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
reject(new BrunoError('Import collection failed'));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export default importCollection;
|
||||
|
||||
@@ -64,6 +64,7 @@ export const transformItemsInCollection = (collection) => {
|
||||
each(items, (item) => {
|
||||
if (['http', 'graphql', 'grpc'].includes(item.type)) {
|
||||
item.type = `${item.type}-request`;
|
||||
const isGrpcRequest = item.type === 'grpc-request';
|
||||
|
||||
if (item.request.query) {
|
||||
item.request.params = item.request.query.map((queryItem) => ({
|
||||
@@ -73,6 +74,10 @@ export const transformItemsInCollection = (collection) => {
|
||||
}));
|
||||
}
|
||||
|
||||
if (isGrpcRequest) {
|
||||
delete item.request.params;
|
||||
}
|
||||
|
||||
delete item.request.query;
|
||||
|
||||
// from 5 feb 2024, multipartFormData needs to have a type
|
||||
|
||||
@@ -1,43 +1,26 @@
|
||||
import jsyaml from 'js-yaml';
|
||||
import fileDialog from 'file-dialog';
|
||||
import { BrunoError } from 'utils/common/error';
|
||||
import { insomniaToBruno } from '@usebruno/converters';
|
||||
|
||||
const readFile = (files) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = (e) => {
|
||||
try {
|
||||
// try to load JSON
|
||||
const parsedData = JSON.parse(e.target.result);
|
||||
resolve(parsedData);
|
||||
} catch (jsonError) {
|
||||
// not a valid JSOn, try yaml
|
||||
try {
|
||||
const parsedData = jsyaml.load(e.target.result, { schema: jsyaml.CORE_SCHEMA });
|
||||
resolve(parsedData);
|
||||
} catch (yamlError) {
|
||||
console.error('Error parsing the file :', jsonError, yamlError);
|
||||
reject(new BrunoError('Import collection failed'));
|
||||
}
|
||||
}
|
||||
};
|
||||
fileReader.onerror = (err) => reject(err);
|
||||
fileReader.readAsText(files[0]);
|
||||
});
|
||||
|
||||
export const convertInsomniaToBruno = (data) => {
|
||||
try {
|
||||
return insomniaToBruno(data);
|
||||
} catch (err) {
|
||||
console.error('Error converting Insomnia to Bruno:', err);
|
||||
throw new BrunoError('Import collection failed: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const importCollection = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fileDialog({ accept: '.json, .yaml, .yml, application/json, application/yaml, application/x-yaml' })
|
||||
.then(readFile)
|
||||
.then((collection) => insomniaToBruno(collection))
|
||||
.then((collection) => resolve({ collection }))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
reject(new BrunoError('Import collection failed: ' + err.message));
|
||||
});
|
||||
});
|
||||
};
|
||||
export const isInsomniaCollection = (data) => {
|
||||
// Check for Insomnia v5 collection format – collection array must be present
|
||||
if (typeof data.type === 'string' && data.type.startsWith('collection.insomnia.rest/5')) {
|
||||
return Array.isArray(data.collection);
|
||||
}
|
||||
|
||||
export default importCollection;
|
||||
// Check for Insomnia v4 export format – must have __export_format and resources array
|
||||
if (data._type === 'export') {
|
||||
return Array.isArray(data.resources) && typeof data.__export_format === 'number';
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -1,43 +1,27 @@
|
||||
import jsyaml from 'js-yaml';
|
||||
import fileDialog from 'file-dialog';
|
||||
import { BrunoError } from 'utils/common/error';
|
||||
import { openApiToBruno } from '@usebruno/converters';
|
||||
|
||||
const readFile = (files) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = (e) => {
|
||||
try {
|
||||
// try to load JSON
|
||||
const parsedData = JSON.parse(e.target.result);
|
||||
resolve(parsedData);
|
||||
} catch (jsonError) {
|
||||
// not a valid JSOn, try yaml
|
||||
try {
|
||||
const parsedData = jsyaml.load(e.target.result);
|
||||
resolve(parsedData);
|
||||
} catch (yamlError) {
|
||||
console.error('Error parsing the file :', jsonError, yamlError);
|
||||
reject(new BrunoError('Import collection failed'));
|
||||
}
|
||||
}
|
||||
};
|
||||
fileReader.onerror = (err) => reject(err);
|
||||
fileReader.readAsText(files[0]);
|
||||
});
|
||||
export const convertOpenapiToBruno = (data) => {
|
||||
try {
|
||||
return openApiToBruno(data);
|
||||
} catch (err) {
|
||||
console.error('Error converting OpenAPI to Bruno:', err);
|
||||
throw new BrunoError('Import collection failed: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const importCollection = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fileDialog({ accept: '.json, .yaml, .yml, application/json, application/yaml, application/x-yaml' })
|
||||
.then(readFile)
|
||||
.then((collection) => openApiToBruno(collection))
|
||||
.then((collection) => resolve({ collection }))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
reject(new BrunoError('Import collection failed: ' + err.message));
|
||||
});
|
||||
});
|
||||
};
|
||||
export const isOpenApiSpec = (data) => {
|
||||
if (typeof data.info !== 'object' || data.info === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export default importCollection;
|
||||
if (typeof data.openapi === 'string' && data.openapi.trim().length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof data.swagger === 'string' && data.swagger.trim().length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -22,4 +22,20 @@ const postmanToBruno = (collection) => {
|
||||
});
|
||||
};
|
||||
|
||||
export { postmanToBruno, readFile };
|
||||
const isPostmanCollection = (data) => {
|
||||
const info = data.info;
|
||||
if (!info || typeof info !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const schema = info.schema;
|
||||
// Accept schemas hosted at schema.getpostman.com or schema.postman.com
|
||||
const schemaRegex = /^https:\/\/schema\.(?:getpostman|postman)\.com\//;
|
||||
if (typeof schema === 'string' && schemaRegex.test(schema)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export { postmanToBruno, readFile, isPostmanCollection };
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { transformCollectionToSaveToExportAsFile, transformRequestToSaveToFilesystem } from '../../collections/index';
|
||||
import { transformItemsInCollection } from '../../importers/common';
|
||||
|
||||
describe('gRPC Export/Import', () => {
|
||||
describe('transformCollectionToSaveToExportAsFile', () => {
|
||||
it('should preserve gRPC-specific fields when exporting collection', () => {
|
||||
const collection = {
|
||||
uid: 'test-collection',
|
||||
name: 'Test Collection',
|
||||
items: [
|
||||
{
|
||||
uid: 'grpc-request-1',
|
||||
type: 'grpc-request',
|
||||
name: 'Test gRPC Request',
|
||||
request: {
|
||||
url: 'grpc://localhost:50051',
|
||||
method: '/randomService/randomMethod',
|
||||
methodType: 'unary',
|
||||
protoPath: 'proto/service.proto',
|
||||
headers: [],
|
||||
body: {
|
||||
mode: 'grpc',
|
||||
grpc: [{ name: 'message', content: '{}' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = transformCollectionToSaveToExportAsFile(collection);
|
||||
const grpcRequest = result.items[0];
|
||||
|
||||
expect(grpcRequest.request.methodType).toBe('unary');
|
||||
expect(grpcRequest.request.method).toBe('/randomService/randomMethod');
|
||||
expect(grpcRequest.request.protoPath).toBe('proto/service.proto');
|
||||
expect(grpcRequest.request.params).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle different gRPC method types correctly', () => {
|
||||
const collection = {
|
||||
uid: 'test-collection',
|
||||
name: 'Test Collection',
|
||||
items: [
|
||||
{
|
||||
uid: 'grpc-request-1',
|
||||
type: 'grpc-request',
|
||||
name: 'Streaming Request',
|
||||
request: {
|
||||
url: 'grpc://localhost:50051',
|
||||
method: '/randomService/randomMethod',
|
||||
methodType: 'bidi-streaming',
|
||||
protoPath: 'proto/streaming.proto',
|
||||
headers: [],
|
||||
body: { mode: 'grpc', grpc: [] }
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = transformCollectionToSaveToExportAsFile(collection);
|
||||
const grpcRequest = result.items[0];
|
||||
|
||||
expect(grpcRequest.request.methodType).toBe('bidi-streaming');
|
||||
expect(grpcRequest.request.method).toBe('/randomService/randomMethod');
|
||||
expect(grpcRequest.request.protoPath).toBe('proto/streaming.proto');
|
||||
});
|
||||
|
||||
it('should handle gRPC requests without method', () => {
|
||||
const collection = {
|
||||
uid: 'test-collection',
|
||||
name: 'Test Collection',
|
||||
items: [
|
||||
{
|
||||
uid: 'grpc-request-1',
|
||||
type: 'grpc-request',
|
||||
name: 'Streaming Request',
|
||||
request: {
|
||||
url: 'grpc://localhost:50051',
|
||||
methodType: 'unary',
|
||||
headers: [],
|
||||
body: { mode: 'grpc', grpc: [] }
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = transformCollectionToSaveToExportAsFile(collection);
|
||||
const grpcRequest = result.items[0];
|
||||
|
||||
expect(grpcRequest.request.methodType).toBe('unary');
|
||||
expect(grpcRequest.request.method).toBeUndefined();
|
||||
expect(grpcRequest.request.protoPath).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformRequestToSaveToFilesystem', () => {
|
||||
it('should preserve gRPC fields and remove params for gRPC requests', () => {
|
||||
const grpcRequest = {
|
||||
uid: 'grpc-request-1',
|
||||
type: 'grpc-request',
|
||||
name: 'Test gRPC',
|
||||
request: {
|
||||
url: 'grpc://localhost:50051',
|
||||
method: '/randomService/randomMethod',
|
||||
methodType: 'server-streaming',
|
||||
protoPath: 'proto/service.proto',
|
||||
params: [{ uid: 'param-1', name: 'test', value: 'value' }],
|
||||
headers: [],
|
||||
body: { mode: 'grpc', grpc: [] }
|
||||
}
|
||||
};
|
||||
|
||||
const result = transformRequestToSaveToFilesystem(grpcRequest);
|
||||
|
||||
expect(result.request.methodType).toBe('server-streaming');
|
||||
expect(result.request.protoPath).toBe('proto/service.proto');
|
||||
expect(result.request.params).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not remove params for non-gRPC requests', () => {
|
||||
const httpRequest = {
|
||||
uid: 'http-request-1',
|
||||
type: 'http-request',
|
||||
name: 'Test HTTP',
|
||||
request: {
|
||||
url: 'http://localhost:3000',
|
||||
method: 'GET',
|
||||
params: [{ uid: 'param-1', name: 'test', value: 'value' }],
|
||||
headers: [],
|
||||
body: { mode: 'json', json: '{}' }
|
||||
}
|
||||
};
|
||||
|
||||
const result = transformRequestToSaveToFilesystem(httpRequest);
|
||||
|
||||
expect(result.request.params).toHaveLength(1);
|
||||
expect(result.request.params[0].name).toBe('test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformItemsInCollection', () => {
|
||||
it('should transform gRPC request type correctly during import', () => {
|
||||
const collection = {
|
||||
uid: 'test-collection',
|
||||
items: [
|
||||
{
|
||||
uid: 'grpc-request-1',
|
||||
type: 'grpc',
|
||||
name: 'Test gRPC',
|
||||
request: {
|
||||
url: 'grpc://localhost:50051',
|
||||
methodType: 'unary',
|
||||
protoPath: 'proto/service.proto',
|
||||
body: { mode: 'grpc', grpc: [] }
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
transformItemsInCollection(collection);
|
||||
const grpcRequest = collection.items[0];
|
||||
|
||||
expect(grpcRequest.type).toBe('grpc-request');
|
||||
expect(grpcRequest.request.methodType).toBe('unary');
|
||||
expect(grpcRequest.request.protoPath).toBe('proto/service.proto');
|
||||
});
|
||||
|
||||
it('should handle gRPC requests without protoPath', () => {
|
||||
const collection = {
|
||||
uid: 'test-collection',
|
||||
items: [
|
||||
{
|
||||
uid: 'grpc-request-1',
|
||||
type: 'grpc',
|
||||
name: 'Test gRPC',
|
||||
request: {
|
||||
url: 'grpc://localhost:50051',
|
||||
method: '/randomService/randomMethod',
|
||||
methodType: 'client-streaming',
|
||||
body: { mode: 'grpc', grpc: [] }
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
transformItemsInCollection(collection);
|
||||
const grpcRequest = collection.items[0];
|
||||
|
||||
expect(grpcRequest.type).toBe('grpc-request');
|
||||
expect(grpcRequest.request.methodType).toBe('client-streaming');
|
||||
expect(grpcRequest.request.protoPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle gRPC requests without method', () => {
|
||||
const collection = {
|
||||
uid: 'test-collection',
|
||||
items: [
|
||||
{
|
||||
uid: 'grpc-request-1',
|
||||
type: 'grpc',
|
||||
name: 'Test gRPC',
|
||||
request: {
|
||||
url: 'grpc://localhost:50051',
|
||||
methodType: 'unary',
|
||||
protoPath: 'proto/service.proto',
|
||||
body: { mode: 'grpc', grpc: [] }
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
transformItemsInCollection(collection);
|
||||
const grpcRequest = collection.items[0];
|
||||
|
||||
expect(grpcRequest.type).toBe('grpc-request');
|
||||
expect(grpcRequest.request.method).toBeUndefined();
|
||||
expect(grpcRequest.request.methodType).toBe('unary');
|
||||
expect(grpcRequest.request.protoPath).toBe('proto/service.proto');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,11 @@ async function resolveAwsV4Credentials(request) {
|
||||
const awsv4 = request.awsv4config;
|
||||
if (isStrPresent(awsv4.profileName)) {
|
||||
try {
|
||||
credentialsProvider = fromIni({
|
||||
const credentialsProvider = fromIni({
|
||||
profile: awsv4.profileName,
|
||||
ignoreCache: true
|
||||
});
|
||||
credentials = await credentialsProvider();
|
||||
const credentials = await credentialsProvider();
|
||||
awsv4.accessKeyId = credentials.accessKeyId;
|
||||
awsv4.secretAccessKey = credentials.secretAccessKey;
|
||||
awsv4.sessionToken = credentials.sessionToken;
|
||||
|
||||
@@ -111,7 +111,7 @@ function makeAxiosInstance({ requestMaxRedirects = 5, disableCookies } = {}) {
|
||||
}
|
||||
|
||||
if (!disableCookies){
|
||||
saveCookies(redirectUrl, error.response.headers);
|
||||
saveCookies(error.config.url, error.response.headers);
|
||||
}
|
||||
|
||||
const requestConfig = createRedirectConfig(error, redirectUrl);
|
||||
|
||||
@@ -476,29 +476,24 @@ const processCollectionItems = async (items = [], currentPath) => {
|
||||
// Convert JSON to BRU format based on the item type
|
||||
let type = item.type === 'http-request' ? 'http' : 'graphql';
|
||||
const bruJson = {
|
||||
meta: {
|
||||
name: item.name,
|
||||
type: type,
|
||||
seq: typeof item.seq === 'number' ? item.seq : 1
|
||||
},
|
||||
http: {
|
||||
method: (item.request?.method || 'GET').toLowerCase(),
|
||||
type: type,
|
||||
name: item.name,
|
||||
seq: typeof item.seq === 'number' ? item.seq : 1,
|
||||
tags: item.tags || [],
|
||||
settings: {},
|
||||
request: {
|
||||
method: item.request?.method || 'GET',
|
||||
url: item.request?.url || '',
|
||||
auth: item.request?.auth?.mode || 'none',
|
||||
body: item.request?.body?.mode || 'none'
|
||||
},
|
||||
params: item.request?.params || [],
|
||||
headers: item.request?.headers || [],
|
||||
auth: item.request?.auth || {},
|
||||
body: item.request?.body || {},
|
||||
script: item.request?.script || {},
|
||||
vars: {
|
||||
req: item.request?.vars?.req || [],
|
||||
res: item.request?.vars?.res || []
|
||||
},
|
||||
assertions: item.request?.assertions || [],
|
||||
tests: item.request?.tests || '',
|
||||
docs: item.request?.docs || ''
|
||||
headers: item.request?.headers || [],
|
||||
params: item.request?.params || [],
|
||||
auth: item.request?.auth || {},
|
||||
body: item.request?.body || {},
|
||||
script: item.request?.script || {},
|
||||
vars: item.request?.vars || { req: [], res: [] },
|
||||
assertions: item.request?.assertions || [],
|
||||
tests: item.request?.tests || '',
|
||||
docs: item.request?.docs || ''
|
||||
}
|
||||
};
|
||||
|
||||
// Convert to BRU format and write to file
|
||||
|
||||
@@ -1 +1 @@
|
||||
module.exports = require('@usebruno/common').cookies;
|
||||
module.exports = require('@usebruno/requests').cookies;
|
||||
|
||||
@@ -56,8 +56,5 @@
|
||||
},
|
||||
"overrides": {
|
||||
"rollup": "3.29.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"tough-cookie": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export { mockDataFunctions } from './utils/faker-functions';
|
||||
export { default as interpolate } from './interpolate';
|
||||
export { default as isRequestTagsIncluded } from './tags';
|
||||
export { default as cookies } from './cookies';
|
||||
|
||||
export * as utils from './utils';
|
||||
@@ -1,18 +1,38 @@
|
||||
import interpolate from './index';
|
||||
import moment from 'moment';
|
||||
|
||||
const BRUNO_BIRTH_DATE = new Date('2019-08-08');
|
||||
|
||||
const calculateAgeFromBirthDate = (birthDate = BRUNO_BIRTH_DATE) => {
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - birthDate.getFullYear();
|
||||
|
||||
const hasBirthdayPassedThisYear =
|
||||
today.getMonth() > birthDate.getMonth() ||
|
||||
(today.getMonth() === birthDate.getMonth() && today.getDate() >= birthDate.getDate());
|
||||
|
||||
if (!hasBirthdayPassedThisYear) {
|
||||
age--;
|
||||
}
|
||||
|
||||
return age;
|
||||
};
|
||||
|
||||
const BRUNO_AGE = calculateAgeFromBirthDate(BRUNO_BIRTH_DATE);
|
||||
|
||||
describe('interpolate', () => {
|
||||
it('should replace placeholders with values from the object', () => {
|
||||
const inputString = 'Hello, my name is {{user.name}} and I am {{user.age}} years old';
|
||||
const inputObject = {
|
||||
'user.name': 'Bruno',
|
||||
user: {
|
||||
age: 4
|
||||
age: BRUNO_AGE
|
||||
}
|
||||
};
|
||||
|
||||
const result = interpolate(inputString, inputObject);
|
||||
|
||||
expect(result).toBe('Hello, my name is Bruno and I am 4 years old');
|
||||
expect(result).toBe(`Hello, my name is Bruno and I am ${BRUNO_AGE} years old`);
|
||||
});
|
||||
|
||||
it('should handle missing values by leaving the placeholders unchanged using {{}} as delimiters', () => {
|
||||
@@ -32,7 +52,7 @@ describe('interpolate', () => {
|
||||
const inputObject = {
|
||||
user: {
|
||||
full_name: 'Bruno',
|
||||
age: 4,
|
||||
age: BRUNO_AGE,
|
||||
'fav-food': ['egg', 'meat'],
|
||||
'want.attention': true
|
||||
}
|
||||
@@ -45,7 +65,7 @@ describe('interpolate', () => {
|
||||
`;
|
||||
const expectedStr = `
|
||||
Hi, I am Bruno,
|
||||
I am 4 years old.
|
||||
I am ${BRUNO_AGE} years old.
|
||||
My favorite food is egg and meat.
|
||||
I like attention: true
|
||||
`;
|
||||
@@ -58,13 +78,13 @@ describe('interpolate', () => {
|
||||
const inputObject = {
|
||||
'user.name': 'Bruno',
|
||||
user: {
|
||||
age: 4
|
||||
age: BRUNO_AGE
|
||||
}
|
||||
};
|
||||
|
||||
const result = interpolate(inputString, inputObject);
|
||||
|
||||
expect(result).toBe('Hello, my name is {{ user.name }} and I am 4 years old');
|
||||
expect(result).toBe(`Hello, my name is {{ user.name }} and I am ${BRUNO_AGE} years old`);
|
||||
});
|
||||
|
||||
test('should give precedence to the last key in case of duplicates (not at the top level)', () => {
|
||||
@@ -74,14 +94,14 @@ describe('interpolate', () => {
|
||||
'user.name': 'Bruno',
|
||||
user: {
|
||||
name: 'Not _Bruno_',
|
||||
age: 4
|
||||
age: BRUNO_AGE
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = interpolate(inputString, inputObject);
|
||||
|
||||
expect(result).toBe('Hello, my name is Bruno and Not _Bruno_ I am 4 years old');
|
||||
expect(result).toBe(`Hello, my name is Bruno and Not _Bruno_ I am ${BRUNO_AGE} years old`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,13 +199,13 @@ describe('interpolate - recursive', () => {
|
||||
'user.message': 'Hello, my name is {{user.name}} and I am {{user.age}} years old',
|
||||
'user.name': 'Bruno',
|
||||
user: {
|
||||
age: 4
|
||||
age: BRUNO_AGE
|
||||
}
|
||||
};
|
||||
|
||||
const result = interpolate(inputString, inputObject);
|
||||
|
||||
expect(result).toBe('Hello, my name is Bruno and I am 4 years old');
|
||||
expect(result).toBe(`Hello, my name is Bruno and I am ${BRUNO_AGE} years old`);
|
||||
});
|
||||
|
||||
it('should replace placeholders with 2 level of recursion with values from the object', () => {
|
||||
@@ -195,13 +215,13 @@ describe('interpolate - recursive', () => {
|
||||
'user.name': 'Bruno {{user.lastName}}',
|
||||
'user.lastName': 'Dog',
|
||||
user: {
|
||||
age: 4
|
||||
age: BRUNO_AGE
|
||||
}
|
||||
};
|
||||
|
||||
const result = interpolate(inputString, inputObject);
|
||||
|
||||
expect(result).toBe('Hello, my name is Bruno Dog and I am 4 years old');
|
||||
expect(result).toBe(`Hello, my name is Bruno Dog and I am ${BRUNO_AGE} years old`);
|
||||
});
|
||||
|
||||
it('should replace placeholders with 3 level of recursion with values from the object', () => {
|
||||
@@ -212,13 +232,13 @@ describe('interpolate - recursive', () => {
|
||||
'user.name': 'Bruno {{user.lastName}}',
|
||||
'user.lastName': 'Dog',
|
||||
user: {
|
||||
age: 4
|
||||
age: BRUNO_AGE
|
||||
}
|
||||
};
|
||||
|
||||
const result = interpolate(inputString, inputObject);
|
||||
|
||||
expect(result).toBe('Hello, my name is Bruno Dog and I am 4 years old');
|
||||
expect(result).toBe(`Hello, my name is Bruno Dog and I am ${BRUNO_AGE} years old`);
|
||||
});
|
||||
|
||||
it('should handle missing values with 1 level of recursion by leaving the placeholders unchanged using {{}} as delimiters', () => {
|
||||
@@ -226,13 +246,13 @@ describe('interpolate - recursive', () => {
|
||||
const inputObject = {
|
||||
'user.message': 'Hello, my name is {{user.name}} and I am {{user.age}} years old',
|
||||
user: {
|
||||
age: 4
|
||||
age: BRUNO_AGE
|
||||
}
|
||||
};
|
||||
|
||||
const result = interpolate(inputString, inputObject);
|
||||
|
||||
expect(result).toBe('Hello, my name is {{user.name}} and I am 4 years old');
|
||||
expect(result).toBe(`Hello, my name is {{user.name}} and I am ${BRUNO_AGE} years old`);
|
||||
});
|
||||
|
||||
it('should handle all valid keys with 1 level of recursion', () => {
|
||||
@@ -246,7 +266,7 @@ describe('interpolate - recursive', () => {
|
||||
user: {
|
||||
message,
|
||||
full_name: 'Bruno',
|
||||
age: 4,
|
||||
age: BRUNO_AGE,
|
||||
'fav-food': ['egg', 'meat'],
|
||||
'want.attention': true
|
||||
}
|
||||
@@ -255,7 +275,7 @@ describe('interpolate - recursive', () => {
|
||||
const inputStr = '{{user.message}}';
|
||||
const expectedStr = `
|
||||
Hi, I am Bruno,
|
||||
I am 4 years old.
|
||||
I am ${BRUNO_AGE} years old.
|
||||
My favorite food is egg and meat.
|
||||
I like attention: true
|
||||
`;
|
||||
@@ -361,32 +381,32 @@ describe('interpolate - object handling', () => {
|
||||
it('should stringify simple objects', () => {
|
||||
const inputString = 'User: {{user}}';
|
||||
const inputObject = {
|
||||
'user': { name: 'Bruno', age: 4 }
|
||||
'user': { name: 'Bruno', age: BRUNO_AGE }
|
||||
};
|
||||
|
||||
const result = interpolate(inputString, inputObject);
|
||||
|
||||
expect(result).toBe('User: {"name":"Bruno","age":4}');
|
||||
expect(result).toBe(`User: {"name":"Bruno","age":${BRUNO_AGE}}`);
|
||||
});
|
||||
|
||||
it('should stringify simple objects (dot notation)', () => {
|
||||
const inputString = 'User: {{user.data}}';
|
||||
const inputObject = {
|
||||
'user.data': { name: 'Bruno', age: 4 }
|
||||
'user.data': { name: 'Bruno', age: BRUNO_AGE }
|
||||
};
|
||||
|
||||
const result = interpolate(inputString, inputObject);
|
||||
|
||||
expect(result).toBe('User: {"name":"Bruno","age":4}');
|
||||
expect(result).toBe(`User: {"name":"Bruno","age":${BRUNO_AGE}}`);
|
||||
});
|
||||
|
||||
it('should stringify nested objects', () => {
|
||||
const inputString = 'User: {{user}}';
|
||||
const inputObject = {
|
||||
'user': {
|
||||
name: 'Bruno',
|
||||
age: 4,
|
||||
preferences: {
|
||||
'user': {
|
||||
name: 'Bruno',
|
||||
age: BRUNO_AGE,
|
||||
preferences: {
|
||||
food: ['egg', 'meat'],
|
||||
toys: { favorite: 'ball' }
|
||||
}
|
||||
@@ -395,7 +415,7 @@ describe('interpolate - object handling', () => {
|
||||
|
||||
const result = interpolate(inputString, inputObject);
|
||||
|
||||
expect(result).toBe('User: {"name":"Bruno","age":4,"preferences":{"food":["egg","meat"],"toys":{"favorite":"ball"}}}');
|
||||
expect(result).toBe(`User: {"name":"Bruno","age":${BRUNO_AGE},"preferences":{"food":["egg","meat"],"toys":{"favorite":"ball"}}}`);
|
||||
});
|
||||
|
||||
it('should stringify arrays', () => {
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
* Ex: interpolate('Hello, my name is ${user.name} and I am ${user.age} years old', {
|
||||
* "user.name": "Bruno",
|
||||
* "user": {
|
||||
* "age": 4
|
||||
* "age": 6
|
||||
* }
|
||||
* });
|
||||
* Output: Hello, my name is Bruno and I am 4 years old
|
||||
* Output: Hello, my name is Bruno and I am 6 years old
|
||||
*/
|
||||
|
||||
import { mockDataFunctions } from '../utils/faker-functions';
|
||||
|
||||
@@ -2,8 +2,4 @@ export {
|
||||
encodeUrl,
|
||||
parseQueryParams,
|
||||
buildQueryString,
|
||||
} from './url';
|
||||
|
||||
export {
|
||||
isPotentiallyTrustworthyOrigin
|
||||
} from './url/validation';
|
||||
} from './url';
|
||||
@@ -31,7 +31,7 @@ const brunoEnvironment = postmanToBrunoEnvironment(postmanEnvironment);
|
||||
### Convert Insomnia collection to Bruno collection
|
||||
|
||||
```javascript
|
||||
import { insomniaToBruno } from '@usebruno/converters';
|
||||
const { insomniaToBruno } = require('@usebruno/converters');
|
||||
|
||||
const brunoCollection = insomniaToBruno(insomniaCollection);
|
||||
```
|
||||
@@ -39,7 +39,7 @@ const brunoCollection = insomniaToBruno(insomniaCollection);
|
||||
### Convert OpenAPI specification to Bruno collection
|
||||
|
||||
```javascript
|
||||
import { openApiToBruno } from '@usebruno/converters';
|
||||
const { openApiToBruno } = require('@usebruno/converters');
|
||||
|
||||
const brunoCollection = openApiToBruno(openApiSpecification);
|
||||
```
|
||||
@@ -75,4 +75,4 @@ const outputFilePath = path.resolve(__dirname, 'bruno-collection.json');
|
||||
|
||||
convertPostmanToBruno(inputFilePath, outputFilePath);
|
||||
|
||||
```
|
||||
```
|
||||
|
||||
@@ -60,7 +60,9 @@ const transformOpenapiRequestItem = (request) => {
|
||||
mode: 'inherit',
|
||||
basic: null,
|
||||
bearer: null,
|
||||
digest: null
|
||||
digest: null,
|
||||
apikey: null,
|
||||
oauth2: null
|
||||
},
|
||||
headers: [],
|
||||
params: [],
|
||||
@@ -108,13 +110,16 @@ const transformOpenapiRequestItem = (request) => {
|
||||
}
|
||||
});
|
||||
|
||||
let auth;
|
||||
// allow operation override
|
||||
// Handle explicit no-auth case where security: [] on the operation
|
||||
if (Array.isArray(_operationObject.security) && _operationObject.security.length === 0) {
|
||||
brunoRequestItem.request.auth.mode = 'inherit';
|
||||
return brunoRequestItem;
|
||||
}
|
||||
|
||||
let auth = null;
|
||||
if (_operationObject.security && _operationObject.security.length > 0) {
|
||||
let schemeName = Object.keys(_operationObject.security[0])[0];
|
||||
const schemeName = Object.keys(_operationObject.security[0])[0];
|
||||
auth = request.global.security.getScheme(schemeName);
|
||||
} else if (request.global.security.supported.length > 0) {
|
||||
auth = request.global.security.supported[0];
|
||||
}
|
||||
|
||||
if (auth) {
|
||||
@@ -129,14 +134,87 @@ const transformOpenapiRequestItem = (request) => {
|
||||
brunoRequestItem.request.auth.bearer = {
|
||||
token: '{{token}}'
|
||||
};
|
||||
} else if (auth.type === 'apiKey' && auth.in === 'header') {
|
||||
brunoRequestItem.request.headers.push({
|
||||
uid: uuid(),
|
||||
name: auth.name,
|
||||
} else if (auth.type === 'http' && auth.scheme === 'digest') {
|
||||
brunoRequestItem.request.auth.mode = 'digest';
|
||||
brunoRequestItem.request.auth.digest = {
|
||||
username: '{{username}}',
|
||||
password: '{{password}}'
|
||||
};
|
||||
} else if (auth.type === 'apiKey') {
|
||||
const apikeyConfig = {
|
||||
key: auth.name,
|
||||
value: '{{apiKey}}',
|
||||
description: 'Authentication header',
|
||||
enabled: true
|
||||
});
|
||||
placement: auth.in === 'query' ? 'queryparams' : 'header'
|
||||
};
|
||||
brunoRequestItem.request.auth.mode = 'apikey';
|
||||
brunoRequestItem.request.auth.apikey = apikeyConfig;
|
||||
|
||||
if (auth.in === 'header' || auth.in === 'cookie') {
|
||||
brunoRequestItem.request.headers.push({
|
||||
uid: uuid(),
|
||||
name: auth.name,
|
||||
value: '{{apiKey}}',
|
||||
description: auth.description || '',
|
||||
enabled: true
|
||||
});
|
||||
} else if (auth.in === 'query') {
|
||||
brunoRequestItem.request.params.push({
|
||||
uid: uuid(),
|
||||
name: auth.name,
|
||||
value: '{{apiKey}}',
|
||||
description: auth.description || '',
|
||||
enabled: true,
|
||||
type: 'query'
|
||||
});
|
||||
}
|
||||
} else if (auth.type === 'oauth2') {
|
||||
// Determine flow (grant type)
|
||||
let flows = auth.flows || {};
|
||||
let grantType = 'client_credentials';
|
||||
if (flows.authorizationCode) {
|
||||
grantType = 'authorization_code';
|
||||
} else if (flows.implicit) {
|
||||
grantType = 'implicit';
|
||||
} else if (flows.password) {
|
||||
grantType = 'password';
|
||||
} else if (flows.clientCredentials) {
|
||||
grantType = 'client_credentials';
|
||||
}
|
||||
|
||||
let flowConfig = {};
|
||||
switch (grantType) {
|
||||
case 'authorization_code':
|
||||
flowConfig = flows.authorizationCode || {};
|
||||
break;
|
||||
case 'implicit':
|
||||
flowConfig = flows.implicit || {};
|
||||
break;
|
||||
case 'password':
|
||||
flowConfig = flows.password || {};
|
||||
break;
|
||||
case 'client_credentials':
|
||||
default:
|
||||
flowConfig = flows.clientCredentials || {};
|
||||
break;
|
||||
}
|
||||
|
||||
brunoRequestItem.request.auth.mode = 'oauth2';
|
||||
brunoRequestItem.request.auth.oauth2 = {
|
||||
grantType: grantType,
|
||||
authorizationUrl: flowConfig.authorizationUrl || '{{oauth_authorize_url}}',
|
||||
accessTokenUrl: flowConfig.tokenUrl || '{{oauth_token_url}}',
|
||||
refreshTokenUrl: flowConfig.refreshUrl || '{{oauth_refresh_url}}',
|
||||
callbackUrl: '{{oauth_callback_url}}',
|
||||
clientId: '{{oauth_client_id}}',
|
||||
clientSecret: '{{oauth_client_secret}}',
|
||||
scope: Array.isArray(flowConfig.scopes) ? flowConfig.scopes.join(' ') : Object.keys(flowConfig.scopes || {}).join(' '),
|
||||
state: '{{oauth_state}}',
|
||||
credentialsPlacement: 'header',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
autoFetchToken: false,
|
||||
autoRefreshToken: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,7 +503,9 @@ export const parseOpenApiCollection = (data) => {
|
||||
mode: 'inherit',
|
||||
basic: null,
|
||||
bearer: null,
|
||||
digest: null
|
||||
digest: null,
|
||||
apikey: null,
|
||||
oauth2: null
|
||||
}
|
||||
},
|
||||
meta: {
|
||||
@@ -439,6 +519,103 @@ export const parseOpenApiCollection = (data) => {
|
||||
let ungroupedItems = ungroupedRequests.map(transformOpenapiRequestItem);
|
||||
let brunoCollectionItems = brunoFolders.concat(ungroupedItems);
|
||||
brunoCollection.items = brunoCollectionItems;
|
||||
|
||||
// Determine collection-level authentication based on global security requirements
|
||||
const buildCollectionAuth = (scheme) => {
|
||||
const authTemplate = {
|
||||
mode: 'none',
|
||||
basic: null,
|
||||
bearer: null,
|
||||
digest: null,
|
||||
apikey: null,
|
||||
oauth2: null,
|
||||
};
|
||||
|
||||
if (!scheme) return authTemplate;
|
||||
|
||||
if (scheme.type === 'http' && scheme.scheme === 'basic') {
|
||||
return {
|
||||
...authTemplate,
|
||||
mode: 'basic',
|
||||
basic: {
|
||||
username: '{{username}}',
|
||||
password: '{{password}}'
|
||||
}
|
||||
};
|
||||
} else if (scheme.type === 'http' && scheme.scheme === 'bearer') {
|
||||
return {
|
||||
...authTemplate,
|
||||
mode: 'bearer',
|
||||
bearer: {
|
||||
token: '{{token}}'
|
||||
}
|
||||
};
|
||||
} else if (scheme.type === 'http' && scheme.scheme === 'digest') {
|
||||
return {
|
||||
...authTemplate,
|
||||
mode: 'digest',
|
||||
digest: {
|
||||
username: '{{username}}',
|
||||
password: '{{password}}'
|
||||
}
|
||||
};
|
||||
} else if (scheme.type === 'apiKey') {
|
||||
return {
|
||||
...authTemplate,
|
||||
mode: 'apikey',
|
||||
apikey: {
|
||||
key: scheme.name,
|
||||
value: '{{apiKey}}',
|
||||
placement: scheme.in === 'query' ? 'queryparams' : 'header'
|
||||
}
|
||||
};
|
||||
} else if (scheme.type === 'oauth2') {
|
||||
let flows = scheme.flows || {};
|
||||
let grantType = 'client_credentials';
|
||||
if (flows.authorizationCode) {
|
||||
grantType = 'authorization_code';
|
||||
} else if (flows.implicit) {
|
||||
grantType = 'implicit';
|
||||
} else if (flows.password) {
|
||||
grantType = 'password';
|
||||
}
|
||||
const flowConfig = grantType === 'authorization_code' ? flows.authorizationCode || {} : grantType === 'implicit' ? flows.implicit || {} : grantType === 'password' ? flows.password || {} : flows.clientCredentials || {};
|
||||
|
||||
return {
|
||||
...authTemplate,
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType,
|
||||
authorizationUrl: flowConfig.authorizationUrl || '{{oauth_authorize_url}}',
|
||||
accessTokenUrl: flowConfig.tokenUrl || '{{oauth_token_url}}',
|
||||
refreshTokenUrl: flowConfig.refreshUrl || '{{oauth_refresh_url}}',
|
||||
callbackUrl: '{{oauth_callback_url}}',
|
||||
clientId: '{{oauth_client_id}}',
|
||||
clientSecret: '{{oauth_client_secret}}',
|
||||
scope: Array.isArray(flowConfig.scopes) ? flowConfig.scopes.join(' ') : Object.keys(flowConfig.scopes || {}).join(' '),
|
||||
state: '{{oauth_state}}',
|
||||
credentialsPlacement: 'header',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
autoFetchToken: false,
|
||||
autoRefreshToken: true
|
||||
}
|
||||
};
|
||||
}
|
||||
return authTemplate;
|
||||
};
|
||||
|
||||
let collectionAuth = buildCollectionAuth(securityConfig.supported[0]);
|
||||
|
||||
brunoCollection.root = {
|
||||
request: {
|
||||
auth: collectionAuth,
|
||||
},
|
||||
meta: {
|
||||
name: brunoCollection.name
|
||||
}
|
||||
};
|
||||
|
||||
return brunoCollection;
|
||||
} catch (err) {
|
||||
if (!(err instanceof Error)) {
|
||||
|
||||
@@ -15,6 +15,7 @@ const importPostmanEnvironmentVariables = (brunoEnvironment, values = []) => {
|
||||
name: (i.key ?? '').replace(invalidVariableCharacterRegex, '_'),
|
||||
value: i.value ?? '',
|
||||
enabled: i.enabled,
|
||||
type: 'text',
|
||||
secret: isSecret(i.type)
|
||||
};
|
||||
|
||||
|
||||
@@ -350,6 +350,9 @@ function translateCode(code) {
|
||||
// Process all transformations in a single pass
|
||||
processTransformations(ast, transformedNodes);
|
||||
|
||||
// Handle legacy Postman global APIs
|
||||
handleLegacyGlobalAPIs(ast, transformedNodes, code);
|
||||
|
||||
// Handle special Postman syntax patterns
|
||||
handleTestsBracketNotation(ast);
|
||||
|
||||
@@ -787,5 +790,102 @@ function handleTestsBracketNotation(ast) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle legacy Postman global API transformations
|
||||
* This function processes legacy Postman globals like responseBody, responseHeaders, responseTime
|
||||
* while preserving user-defined variables with the same names
|
||||
*
|
||||
* @param {Object} ast - jscodeshift AST
|
||||
* @param {Set} transformedNodes - Set of already transformed nodes
|
||||
* @param {string} code - The original Postman script code
|
||||
*/
|
||||
function handleLegacyGlobalAPIs(ast, transformedNodes, code) {
|
||||
// regex check before the ast traversal
|
||||
const legacyGlobalRegex = /responseBody|responseHeaders|responseTime/;
|
||||
|
||||
if (!legacyGlobalRegex.test(code)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for variable declarations with legacy global names - track which ones have conflicts
|
||||
const conflictingNames = new Set();
|
||||
|
||||
// Check variable declarations
|
||||
ast.find(j.VariableDeclarator).forEach(path => {
|
||||
if (path.value.id.type === 'Identifier') {
|
||||
const varName = path.value.id.name;
|
||||
if (legacyGlobalRegex.test(varName)) {
|
||||
conflictingNames.add(varName);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle JSON.parse(responseBody) → res.getBody()
|
||||
// Only transform if responseBody doesn't have a user variable conflict
|
||||
if (!conflictingNames.has('responseBody')) {
|
||||
ast.find(j.CallExpression).forEach(path => {
|
||||
if (transformedNodes.has(path.node)) return;
|
||||
|
||||
const callExpr = path.value;
|
||||
if (callExpr.callee.type === 'MemberExpression' && callExpr.callee.object.name === 'JSON' && callExpr.callee.property.name === 'parse') {
|
||||
const args = callExpr.arguments;
|
||||
|
||||
// Check if the argument is 'responseBody'
|
||||
if (args.length > 0 && args[0].type === 'Identifier' && args[0].name === 'responseBody') {
|
||||
// Replace JSON.parse(responseBody) with res.getBody()
|
||||
j(path).replaceWith(j.identifier('res.getBody()'));
|
||||
transformedNodes.add(path.node);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle standalone legacy Postman global variables
|
||||
const legacyGlobals = [
|
||||
{ name: 'responseBody', replacement: 'res.getBody()' },
|
||||
{ name: 'responseHeaders', replacement: 'res.getHeaders()' },
|
||||
{ name: 'responseTime', replacement: 'res.getResponseTime()' }
|
||||
];
|
||||
|
||||
legacyGlobals.forEach(({ name, replacement }) => {
|
||||
// Skip transformation if this name has a user variable conflict
|
||||
if (conflictingNames.has(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ast.find(j.Identifier, { name }).forEach(path => {
|
||||
if (transformedNodes.has(path.node)) return;
|
||||
|
||||
// Only transform identifiers that are being used as values, not as variable names
|
||||
const parent = path.parent.value;
|
||||
|
||||
// Skip if this is part of a variable declaration (const responseBody = ...)
|
||||
if (parent.type === 'VariableDeclarator' && parent.id === path.node) {
|
||||
return; // Keep unchanged
|
||||
}
|
||||
|
||||
// Skip if this is part of an assignment (responseBody = ...)
|
||||
if (parent.type === 'AssignmentExpression' && parent.left === path.node) {
|
||||
return; // Keep unchanged
|
||||
}
|
||||
|
||||
// Skip if this is part of a function parameter
|
||||
if (parent.type === 'FunctionDeclaration' || parent.type === 'FunctionExpression') {
|
||||
return; // Keep unchanged
|
||||
}
|
||||
|
||||
// Skip if this is part of an object property
|
||||
if (parent.type === 'Property' && (parent.key === path.node || parent.value === path.node)) {
|
||||
return; // Keep unchanged
|
||||
}
|
||||
|
||||
// Transform all other references (including function call arguments)
|
||||
// This will transform console.log(responseBody) → console.log(res.getBody())
|
||||
j(path).replaceWith(j.identifier(replacement));
|
||||
transformedNodes.add(path.node);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export { getMemberExpressionString };
|
||||
export default translateCode;
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import openApiToBruno from '../../../src/openapi/openapi-to-bruno';
|
||||
|
||||
describe('openapi-to-bruno auth enhancements', () => {
|
||||
it('maps HTTP Digest scheme to digest auth on the request', () => {
|
||||
const spec = `
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Digest API
|
||||
version: '1.0'
|
||||
components:
|
||||
securitySchemes:
|
||||
DigestAuth:
|
||||
type: http
|
||||
scheme: digest
|
||||
paths:
|
||||
/secure:
|
||||
get:
|
||||
security:
|
||||
- DigestAuth: []
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
servers:
|
||||
- url: https://example.com
|
||||
`;
|
||||
const collection = openApiToBruno(spec);
|
||||
const req = collection.items[0];
|
||||
expect(req.request.auth.mode).toBe('digest');
|
||||
expect(req.request.auth.digest).toEqual({ username: '{{username}}', password: '{{password}}' });
|
||||
});
|
||||
|
||||
it('maps apiKey in query and injects query param', () => {
|
||||
const spec = `
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Query API-Key
|
||||
version: '1.0'
|
||||
components:
|
||||
securitySchemes:
|
||||
ApiKeyQuery:
|
||||
type: apiKey
|
||||
in: query
|
||||
name: api_key
|
||||
paths:
|
||||
/search:
|
||||
get:
|
||||
security:
|
||||
- ApiKeyQuery: []
|
||||
parameters:
|
||||
- in: query
|
||||
name: q
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
servers:
|
||||
- url: https://example.com
|
||||
`;
|
||||
const collection = openApiToBruno(spec);
|
||||
const req = collection.items[0];
|
||||
expect(req.request.auth.mode).toBe('apikey');
|
||||
expect(req.request.auth.apikey.placement).toBe('queryparams');
|
||||
const hasQueryParam = req.request.params.some(p => p.name === 'api_key' && p.type === 'query');
|
||||
expect(hasQueryParam).toBe(true);
|
||||
});
|
||||
|
||||
it('maps apiKey in cookie and treats it as a header', () => {
|
||||
const spec = `
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Cookie API-Key
|
||||
version: '1.0'
|
||||
components:
|
||||
securitySchemes:
|
||||
ApiKeyCookie:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: DEMO_API_KEY
|
||||
paths:
|
||||
/favorites:
|
||||
get:
|
||||
security:
|
||||
- ApiKeyCookie: []
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
servers:
|
||||
- url: https://example.com
|
||||
`;
|
||||
const { items: [req] } = openApiToBruno(spec);
|
||||
expect(req.request.auth.mode).toBe('apikey');
|
||||
expect(req.request.auth.apikey.placement).toBe('header');
|
||||
const apiKeyHeader = req.request.headers.find(h => h.name === 'DEMO_API_KEY');
|
||||
expect(apiKeyHeader).toBeDefined();
|
||||
expect(apiKeyHeader.value).toBe('{{apiKey}}');
|
||||
});
|
||||
|
||||
it('maps OAuth2 authorizationCode flow to oauth2 grantType authorization_code', () => {
|
||||
const spec = `
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: OAuth2 AuthCode
|
||||
version: '1.0'
|
||||
components:
|
||||
securitySchemes:
|
||||
OAuthAuthCode:
|
||||
type: oauth2
|
||||
flows:
|
||||
authorizationCode:
|
||||
authorizationUrl: https://auth.example.com/authorize
|
||||
tokenUrl: https://auth.example.com/token
|
||||
paths:
|
||||
/orders:
|
||||
get:
|
||||
security:
|
||||
- OAuthAuthCode: []
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
servers:
|
||||
- url: https://example.com
|
||||
`;
|
||||
const { items: [req] } = openApiToBruno(spec);
|
||||
expect(req.request.auth.mode).toBe('oauth2');
|
||||
expect(req.request.auth.oauth2.grantType).toBe('authorization_code');
|
||||
});
|
||||
|
||||
it('sets auth mode to inherit when operation security is explicitly empty', () => {
|
||||
const spec = `
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Public Endpoint
|
||||
version: '1.0'
|
||||
paths:
|
||||
/public:
|
||||
get:
|
||||
security: []
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
servers:
|
||||
- url: https://example.com
|
||||
`;
|
||||
const { items: [req] } = openApiToBruno(spec);
|
||||
expect(req.request.auth.mode).toBe('inherit');
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ describe('postmanToBrunoEnvironment Function', () => {
|
||||
value: 'value1',
|
||||
enabled: true,
|
||||
secret: false,
|
||||
type: 'text',
|
||||
uid: "mockeduuidvalue123456",
|
||||
},
|
||||
{
|
||||
@@ -39,6 +40,7 @@ describe('postmanToBrunoEnvironment Function', () => {
|
||||
value: 'value2',
|
||||
enabled: false,
|
||||
secret: true,
|
||||
type: 'text',
|
||||
uid: "mockeduuidvalue123456",
|
||||
},
|
||||
],
|
||||
@@ -85,6 +87,7 @@ describe('postmanToBrunoEnvironment Function', () => {
|
||||
value: '',
|
||||
enabled: true,
|
||||
secret: false,
|
||||
type: 'text',
|
||||
uid: "mockeduuidvalue123456",
|
||||
},
|
||||
{
|
||||
@@ -92,6 +95,7 @@ describe('postmanToBrunoEnvironment Function', () => {
|
||||
value: '',
|
||||
enabled: true,
|
||||
secret: false,
|
||||
type: 'text',
|
||||
uid: "mockeduuidvalue123456",
|
||||
},
|
||||
{
|
||||
@@ -99,7 +103,8 @@ describe('postmanToBrunoEnvironment Function', () => {
|
||||
value: '',
|
||||
enabled: true,
|
||||
secret: false,
|
||||
uid: "mockeduuidvalue123456",
|
||||
type: 'text',
|
||||
uid: "mockeduuidvalue123456"
|
||||
}
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import translateCode from '../../../../src/utils/jscode-shift-translator.js';
|
||||
|
||||
describe('Legacy Postman API Translation', () => {
|
||||
describe('handleLegacyGlobalAPIs - No Conflicts', () => {
|
||||
test('should translate responseBody when no user variables exist', () => {
|
||||
const input = `
|
||||
const data = JSON.parse(responseBody);
|
||||
`;
|
||||
|
||||
const result = translateCode(input);
|
||||
const expected = `
|
||||
const data = res.getBody();
|
||||
`;
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
test('should translate responseHeaders when no user variables exist', () => {
|
||||
const input = `
|
||||
console.log(responseHeaders);
|
||||
const headers = responseHeaders;
|
||||
`;
|
||||
|
||||
const result = translateCode(input);
|
||||
|
||||
expect(result).toContain('res.getHeaders()');
|
||||
expect(result).not.toContain('responseHeaders');
|
||||
});
|
||||
|
||||
test('should translate responseTime when no user variables exist', () => {
|
||||
const input = `
|
||||
console.log(responseTime);
|
||||
const time = responseTime;
|
||||
`;
|
||||
|
||||
const result = translateCode(input);
|
||||
|
||||
expect(result).toContain('res.getResponseTime()');
|
||||
expect(result).not.toContain('responseTime');
|
||||
});
|
||||
|
||||
test('should translate JSON.parse(responseBody) when no user variables exist', () => {
|
||||
const input = `
|
||||
const data = JSON.parse(responseBody);
|
||||
console.log(data);
|
||||
`;
|
||||
|
||||
const result = translateCode(input);
|
||||
|
||||
expect(result).toContain('res.getBody()');
|
||||
expect(result).not.toContain('JSON.parse(responseBody)');
|
||||
expect(result).not.toContain('responseBody');
|
||||
});
|
||||
|
||||
test('should translate JSON.parse(responseBody) usage without assignment when no user variables exist', () => {
|
||||
const input = `
|
||||
console.log(JSON.parse(responseBody));
|
||||
`;
|
||||
|
||||
const result = translateCode(input);
|
||||
const expected = `
|
||||
console.log(res.getBody());
|
||||
`;
|
||||
|
||||
expect(result).toContain(expected);
|
||||
});
|
||||
|
||||
test('should translate all legacy APIs when no conflicts exist', () => {
|
||||
const input = `
|
||||
const data = JSON.parse(responseBody);
|
||||
const headers = responseHeaders;
|
||||
const time = responseTime;
|
||||
|
||||
console.log(data, headers, time);
|
||||
`;
|
||||
|
||||
const result = translateCode(input);
|
||||
|
||||
expect(result).toContain('res.getBody()');
|
||||
expect(result).toContain('res.getHeaders()');
|
||||
expect(result).toContain('res.getResponseTime()');
|
||||
expect(result).not.toContain('responseBody');
|
||||
expect(result).not.toContain('responseHeaders');
|
||||
expect(result).not.toContain('responseTime');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLegacyGlobalAPIs - With Conflicts', () => {
|
||||
test('should NOT translate responseBody when user variable exists', () => {
|
||||
const input = `
|
||||
const responseBody = pm.response.json();
|
||||
console.log(responseBody);
|
||||
`;
|
||||
|
||||
const result = translateCode(input);
|
||||
const expected = `
|
||||
const responseBody = res.getBody();
|
||||
console.log(responseBody);
|
||||
`;
|
||||
|
||||
// pm.response.json() should be transformed to res.getBody() (Postman API transformation)
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
test('should NOT translate responseHeaders when user variable exists', () => {
|
||||
const input = `
|
||||
const responseHeaders = pm.response.headers;
|
||||
console.log(responseHeaders);
|
||||
`;
|
||||
|
||||
const result = translateCode(input);
|
||||
const expected = `
|
||||
const responseHeaders = res.getHeaders();
|
||||
console.log(responseHeaders);
|
||||
`;
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
test('should NOT translate responseTime when user variable exists', () => {
|
||||
const input = `
|
||||
const responseTime = pm.response.responseTime;
|
||||
console.log(responseTime);
|
||||
`;
|
||||
|
||||
const result = translateCode(input);
|
||||
const expected = `
|
||||
const responseTime = res.getResponseTime();
|
||||
console.log(responseTime);
|
||||
`;
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
test('should NOT translate JSON.parse(responseBody) when user variable exists', () => {
|
||||
const input = `
|
||||
const responseBody = pm.response.json();
|
||||
const data = JSON.parse(responseBody);
|
||||
console.log(data);
|
||||
`;
|
||||
|
||||
const result = translateCode(input);
|
||||
const expected = `
|
||||
const responseBody = res.getBody();
|
||||
const data = JSON.parse(responseBody);
|
||||
console.log(data);
|
||||
`;
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLegacyGlobalAPIs - Partial Conflicts', () => {
|
||||
test('should translate non-conflicting APIs when some conflicts exist', () => {
|
||||
const input = `
|
||||
const responseBody = pm.response.json();
|
||||
console.log(responseBody);
|
||||
console.log(responseHeaders);
|
||||
console.log(responseTime);
|
||||
`;
|
||||
|
||||
const result = translateCode(input);
|
||||
const expected = `
|
||||
const responseBody = res.getBody();
|
||||
console.log(responseBody);
|
||||
console.log(res.getHeaders());
|
||||
console.log(res.getResponseTime());
|
||||
`;
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
|
||||
});
|
||||
|
||||
test('should translate JSON.parse(responseBody) only when no conflict exists', () => {
|
||||
const input = `
|
||||
const responseHeaders = pm.response.headers;
|
||||
const data = JSON.parse(responseBody);
|
||||
console.log(responseHeaders);
|
||||
`;
|
||||
|
||||
const result = translateCode(input);
|
||||
const expected = `
|
||||
const responseHeaders = res.getHeaders();
|
||||
const data = res.getBody();
|
||||
console.log(responseHeaders);
|
||||
`;
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLegacyGlobalAPIs - Edge Cases', () => {
|
||||
test.skip('should handle function parameters with legacy names', () => {
|
||||
const input = `
|
||||
function test(responseBody) {
|
||||
console.log(responseBody);
|
||||
console.log(responseHeaders);
|
||||
}
|
||||
`;
|
||||
|
||||
const result = translateCode(input);
|
||||
const expected = `
|
||||
function test(responseBody) {
|
||||
console.log(responseBody);
|
||||
console.log(res.getHeaders());
|
||||
}
|
||||
`;
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
test('should handle object properties with legacy names', () => {
|
||||
const input = `
|
||||
const config = {
|
||||
responseBody: 'custom',
|
||||
responseHeaders: 'custom'
|
||||
};
|
||||
console.log(responseTime);
|
||||
`;
|
||||
|
||||
const result = translateCode(input);
|
||||
|
||||
const expected = `
|
||||
const config = {
|
||||
responseBody: 'custom',
|
||||
responseHeaders: 'custom'
|
||||
};
|
||||
console.log(res.getResponseTime());
|
||||
`;
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
test('should handle assignments with legacy names', () => {
|
||||
const input = `
|
||||
responseBody = 'new value';
|
||||
responseHeaders = 'new headers';
|
||||
console.log(responseTime);
|
||||
`;
|
||||
|
||||
const result = translateCode(input);
|
||||
|
||||
const expected = `
|
||||
responseBody = 'new value';
|
||||
responseHeaders = 'new headers';
|
||||
console.log(res.getResponseTime());
|
||||
`;
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
test('should handle mixed usage patterns', () => {
|
||||
const input = `
|
||||
const responseBody = pm.response.json();
|
||||
const data = JSON.parse(responseBody);
|
||||
console.log(responseHeaders);
|
||||
console.log(responseTime);
|
||||
|
||||
function test(data) {
|
||||
console.log(responseBody);
|
||||
console.log(responseHeaders);
|
||||
}
|
||||
`;
|
||||
|
||||
const result = translateCode(input);
|
||||
|
||||
const expected = `
|
||||
const responseBody = res.getBody();
|
||||
const data = JSON.parse(responseBody);
|
||||
console.log(res.getHeaders());
|
||||
console.log(res.getResponseTime());
|
||||
|
||||
function test(data) {
|
||||
console.log(responseBody);
|
||||
console.log(res.getHeaders());
|
||||
}
|
||||
`;
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLegacyGlobalAPIs - No Legacy APIs', () => {
|
||||
test('should not modify code when no legacy APIs are present', () => {
|
||||
const input = `
|
||||
const data = { name: 'test' };
|
||||
console.log(data.name);
|
||||
`;
|
||||
|
||||
const result = translateCode(input);
|
||||
const expected = `
|
||||
const data = { name: 'test' };
|
||||
console.log(data.name);
|
||||
`;
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ require('dotenv').config({ path: process.env.DOTENV_PATH });
|
||||
const config = {
|
||||
appId: 'com.usebruno.app',
|
||||
productName: 'Bruno',
|
||||
electronVersion: '33.2.1',
|
||||
electronVersion: '37.2.6',
|
||||
directories: {
|
||||
buildResources: 'resources',
|
||||
output: 'out'
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "~37.2.6",
|
||||
"electron-builder": "25.1.8",
|
||||
"electron-builder": "^24.13.3",
|
||||
"electron-devtools-installer": "^4.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,13 +174,7 @@ app.on('ready', async () => {
|
||||
mainWindow.webContents.setZoomLevel(mainWindow.webContents.getZoomLevel() + 1);
|
||||
});
|
||||
|
||||
globalShortcut.register('CommandOrControl+M', () => {
|
||||
mainWindow.minimize();
|
||||
});
|
||||
|
||||
globalShortcut.register('CommandOrControl+H', () => {
|
||||
mainWindow.minimize();
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('did-finish-load', async () => {
|
||||
let ogSend = mainWindow.webContents.send;
|
||||
|
||||
@@ -9,11 +9,11 @@ async function resolveAwsV4Credentials(request) {
|
||||
const awsv4 = request.awsv4config;
|
||||
if (isStrPresent(awsv4.profileName)) {
|
||||
try {
|
||||
credentialsProvider = fromIni({
|
||||
const credentialsProvider = fromIni({
|
||||
profile: awsv4.profileName,
|
||||
ignoreCache: true
|
||||
});
|
||||
credentials = await credentialsProvider();
|
||||
const credentials = await credentialsProvider();
|
||||
awsv4.accessKeyId = credentials.accessKeyId;
|
||||
awsv4.secretAccessKey = credentials.secretAccessKey;
|
||||
awsv4.sessionToken = credentials.sessionToken;
|
||||
|
||||
@@ -297,7 +297,7 @@ function makeAxiosInstance({
|
||||
}
|
||||
|
||||
if (preferencesUtil.shouldStoreCookies()) {
|
||||
saveCookies(redirectUrl, error.response.headers);
|
||||
saveCookies(error.config.url, error.response.headers);
|
||||
}
|
||||
|
||||
// Create a new request config for the redirect
|
||||
|
||||
@@ -129,6 +129,7 @@ const prepareRequest = async (item, collection, environment, runtimeVariables, c
|
||||
const collectionRoot = collection?.draft ? get(collection, 'draft', {}) : get(collection, 'root', {});
|
||||
const headers = {};
|
||||
const url = request.url;
|
||||
let contentTypeDefined = false;
|
||||
|
||||
each(get(collectionRoot, 'request.headers', []), (h) => {
|
||||
if (h.enabled && h.name?.toLowerCase() === 'content-type') {
|
||||
@@ -186,7 +187,7 @@ const prepareRequest = async (item, collection, environment, runtimeVariables, c
|
||||
if (grpcRequest.oauth2) {
|
||||
let requestCopy = cloneDeep(grpcRequest);
|
||||
const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey } = {} } = requestCopy || {};
|
||||
let credentials, credentialsId;
|
||||
let credentials, credentialsId, oauth2Url, debugInfo;
|
||||
switch (grantType) {
|
||||
case 'authorization_code':
|
||||
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
|
||||
|
||||
@@ -102,7 +102,7 @@ const configureRequest = async (
|
||||
if (request.oauth2) {
|
||||
let requestCopy = cloneDeep(request);
|
||||
const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey } = {} } = requestCopy || {};
|
||||
let credentials, credentialsId;
|
||||
let credentials, credentialsId, oauth2Url, debugInfo;
|
||||
switch (grantType) {
|
||||
case 'authorization_code':
|
||||
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const Store = require('electron-store');
|
||||
const { cookies: cookiesModule } = require('@usebruno/common');
|
||||
const { cookies: cookiesModule } = require('@usebruno/requests');
|
||||
const { cookieJar } = cookiesModule;
|
||||
const { Cookie } = require('tough-cookie');
|
||||
const { createCookieString } = cookiesModule;
|
||||
|
||||
@@ -41,6 +41,18 @@ class GlobalEnvironmentsStore {
|
||||
getGlobalEnvironments() {
|
||||
let globalEnvironments = this.store.get('environments', []);
|
||||
globalEnvironments = this.decryptGlobalEnvironmentVariables({ globalEnvironments });
|
||||
|
||||
// Previously, a bug caused environment variables to be saved without a type.
|
||||
// Since that issue is now fixed, this code ensures that anyone who imported
|
||||
// data before the fix will have the missing types added retroactively.
|
||||
globalEnvironments?.forEach(env => {
|
||||
env?.variables?.forEach(v => {
|
||||
if (!v.type) {
|
||||
v.type = 'text';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return globalEnvironments;
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
module.exports = require('@usebruno/common').cookies;
|
||||
module.exports = require('@usebruno/requests').cookies;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
const { globalEnvironmentsStore } = require('../../src/store/global-environments');
|
||||
|
||||
// Previously, a bug caused environment variables to be saved without a type.
|
||||
// Since that issue is now fixed, this code ensures that anyone who imported
|
||||
// data before the fix will have the missing types added retroactively.
|
||||
describe('global environment variable type backward compatibility', () => {
|
||||
beforeEach(() => {
|
||||
globalEnvironmentsStore.store.clear();
|
||||
});
|
||||
|
||||
it('should add type field for existing global environments without type', () => {
|
||||
// Mock global environments without type field
|
||||
const mockGlobalEnvironments = [
|
||||
{
|
||||
uid: "env-1",
|
||||
name: "Test Environment",
|
||||
variables: [
|
||||
{
|
||||
uid: "var-1",
|
||||
name: "regular_var",
|
||||
value: "regular_value",
|
||||
enabled: true,
|
||||
secret: false
|
||||
// Missing: type field
|
||||
},
|
||||
{
|
||||
uid: "var-2",
|
||||
name: "secret_var",
|
||||
value: "secret_value",
|
||||
enabled: true,
|
||||
secret: true
|
||||
// Missing: type field
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
globalEnvironmentsStore.store.set('environments', mockGlobalEnvironments);
|
||||
|
||||
const processedEnvironments = globalEnvironmentsStore.getGlobalEnvironments();
|
||||
|
||||
expect(processedEnvironments).toHaveLength(1);
|
||||
expect(processedEnvironments[0].variables).toHaveLength(2);
|
||||
|
||||
const regularVar = processedEnvironments[0].variables.find(v => v.name === 'regular_var');
|
||||
const secretVar = processedEnvironments[0].variables.find(v => v.name === 'secret_var');
|
||||
|
||||
expect(regularVar.name).toBe('regular_var');
|
||||
expect(regularVar.type).toBe('text');
|
||||
|
||||
expect(secretVar.name).toBe('secret_var');
|
||||
expect(secretVar.type).toBe('text');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
const { cloneDeep } = require('lodash');
|
||||
const { interpolate: _interpolate } = require('@usebruno/common');
|
||||
const { sendRequest } = require('@usebruno/requests').scripting;
|
||||
const { jar: createCookieJar } = require('@usebruno/common').cookies;
|
||||
const { jar: createCookieJar } = require('@usebruno/requests').cookies;
|
||||
|
||||
const variableNameRegex = /^[\w-.]*$/;
|
||||
|
||||
@@ -125,6 +125,12 @@ class Bru {
|
||||
throw new Error('Creating a env variable without specifying a name is not allowed.');
|
||||
}
|
||||
|
||||
if (variableNameRegex.test(key) === false) {
|
||||
throw new Error(
|
||||
`Variable name: "${key}" contains invalid characters! Names must only contain alpha-numeric characters, "-", "_", "."`
|
||||
);
|
||||
}
|
||||
|
||||
// When persist is true, only string values are allowed
|
||||
if (options?.persist && typeof value !== 'string') {
|
||||
throw new Error(`Persistent environment variables must be strings. Received ${typeof value} for key "${key}".`);
|
||||
@@ -133,7 +139,7 @@ class Bru {
|
||||
this.envVariables[key] = value;
|
||||
|
||||
if (options?.persist) {
|
||||
this.persistentEnvVariables[key] = value
|
||||
this.persistentEnvVariables[key] = value;
|
||||
} else {
|
||||
if (this.persistentEnvVariables[key]) {
|
||||
delete this.persistentEnvVariables[key];
|
||||
|
||||
74
packages/bruno-js/tests/setEnvVar.spec.js
Normal file
74
packages/bruno-js/tests/setEnvVar.spec.js
Normal file
@@ -0,0 +1,74 @@
|
||||
const Bru = require('../src/bru');
|
||||
|
||||
describe('Bru.setEnvVar', () => {
|
||||
const makeBru = () =>
|
||||
new Bru(
|
||||
/* envVariables */ {},
|
||||
/* runtimeVariables */ {},
|
||||
/* processEnvVars */ {},
|
||||
/* collectionPath */ '/',
|
||||
/* historyLogger */ undefined,
|
||||
/* setVisualizations */ undefined,
|
||||
/* secretVariables */ {},
|
||||
/* collectionVariables */ {},
|
||||
/* folderVariables */ {},
|
||||
/* requestVariables */ {},
|
||||
/* globalEnvironmentVariables */ {},
|
||||
/* oauth2CredentialVariables */ {},
|
||||
/* iterationDetails */ {},
|
||||
/* collectionName */ 'Test'
|
||||
);
|
||||
|
||||
test('updates envVariables and does not mark persistent when persist=false', () => {
|
||||
const bru = makeBru();
|
||||
bru.setEnvVar('non_persist', 'value', { persist: false });
|
||||
expect(bru.envVariables.non_persist).toBe('value');
|
||||
expect(bru.persistentEnvVariables.non_persist).toBeUndefined();
|
||||
});
|
||||
|
||||
test('updates envVariables and tracks persistent when persist=true (string only)', () => {
|
||||
const bru = makeBru();
|
||||
bru.setEnvVar('persist_me', 'value', { persist: true });
|
||||
expect(bru.envVariables.persist_me).toBe('value');
|
||||
expect(bru.persistentEnvVariables.persist_me).toBe('value');
|
||||
});
|
||||
|
||||
test('updates envVariables when options are omitted (defaults to non-persistent)', () => {
|
||||
const bru = makeBru();
|
||||
bru.setEnvVar('no_options', 'value');
|
||||
expect(bru.envVariables.no_options).toBe('value');
|
||||
expect(bru.persistentEnvVariables.no_options).toBeUndefined();
|
||||
});
|
||||
|
||||
test('throws when persist=true but value is not a string', () => {
|
||||
const bru = makeBru();
|
||||
expect(() => bru.setEnvVar('persist_me', 123, { persist: true })).toThrow(
|
||||
/Persistent environment variables must be strings/
|
||||
);
|
||||
});
|
||||
|
||||
test('changing existing key to non-persistent removes prior persisted entry', () => {
|
||||
const bru = makeBru();
|
||||
bru.setEnvVar('same_key', 'old', { persist: true });
|
||||
expect(bru.persistentEnvVariables.same_key).toBe('old');
|
||||
|
||||
bru.setEnvVar('same_key', 'new');
|
||||
expect(bru.envVariables.same_key).toBe('new');
|
||||
expect(bru.persistentEnvVariables.same_key).toBeUndefined();
|
||||
});
|
||||
|
||||
test('changing existing key to persistent updates persisted value', () => {
|
||||
const bru = makeBru();
|
||||
bru.setEnvVar('same_key', 'old');
|
||||
expect(bru.persistentEnvVariables.same_key).toBeUndefined();
|
||||
|
||||
bru.setEnvVar('same_key', 'new', { persist: true });
|
||||
expect(bru.envVariables.same_key).toBe('new');
|
||||
expect(bru.persistentEnvVariables.same_key).toBe('new');
|
||||
});
|
||||
|
||||
test('validates key name - invalid characters are rejected', () => {
|
||||
const bru = makeBru();
|
||||
expect(() => bru.setEnvVar('invalid key', 'v')).toThrow(/contains invalid characters/);
|
||||
});
|
||||
});
|
||||
@@ -56,7 +56,13 @@ const grammar = ohm.grammar(`Bru {
|
||||
// Dictionary Blocks
|
||||
dictionary = st* "{" pairlist? tagend
|
||||
pairlist = optionalnl* pair (~tagend stnl* pair)* (~tagend space)*
|
||||
pair = st* key st* ":" st* value st*
|
||||
pair = st* (quoted_key | key) st* ":" st* value st*
|
||||
disable_char = "~"
|
||||
quote_char = "\\""
|
||||
esc_char = "\\\\"
|
||||
esc_quote_char = esc_char quote_char
|
||||
quoted_key_char = ~(quote_char | esc_quote_char | nl) any
|
||||
quoted_key = disable_char? quote_char (esc_quote_char | quoted_key_char)* quote_char
|
||||
key = keychar*
|
||||
value = list | multilinetextblock | valuechar*
|
||||
|
||||
@@ -301,6 +307,14 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
res[key.ast] = value.ast ? value.ast.trim() : '';
|
||||
return res;
|
||||
},
|
||||
esc_quote_char(_1, quote) {
|
||||
// unescape
|
||||
return quote.sourceString;
|
||||
},
|
||||
quoted_key(disabled, _1, chars, _2) {
|
||||
// unquote
|
||||
return (disabled? disabled.sourceString : "") + chars.ast.join("");
|
||||
},
|
||||
key(chars) {
|
||||
return chars.sourceString ? chars.sourceString.trim() : '';
|
||||
},
|
||||
@@ -364,6 +378,9 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
tagend(_1, _2) {
|
||||
return '';
|
||||
},
|
||||
_terminal(){
|
||||
return this.sourceString;
|
||||
},
|
||||
multilinetextblockdelimiter(_) {
|
||||
return '';
|
||||
},
|
||||
|
||||
@@ -4,6 +4,10 @@ const { indentString } = require('./utils');
|
||||
|
||||
const enabled = (items = [], key = "enabled") => items.filter((item) => item[key]);
|
||||
const disabled = (items = [], key = "enabled") => items.filter((item) => !item[key]);
|
||||
const quoteKey = (key) => {
|
||||
const quotableChars = [':', '"', '{', '}', ' '];
|
||||
return quotableChars.some(char => key.includes(char)) ? ('"' + key.replaceAll('"', '\\"') + '"') : key;
|
||||
}
|
||||
|
||||
// remove the last line if two new lines are found
|
||||
const stripLastLine = (text) => {
|
||||
@@ -121,7 +125,7 @@ const jsonToBru = (json) => {
|
||||
if (enabled(queryParams).length) {
|
||||
bru += `\n${indentString(
|
||||
enabled(queryParams)
|
||||
.map((item) => `${item.name}: ${item.value}`)
|
||||
.map((item) => `${quoteKey(item.name)}: ${item.value}`)
|
||||
.join('\n')
|
||||
)}`;
|
||||
}
|
||||
@@ -129,7 +133,7 @@ const jsonToBru = (json) => {
|
||||
if (disabled(queryParams).length) {
|
||||
bru += `\n${indentString(
|
||||
disabled(queryParams)
|
||||
.map((item) => `~${item.name}: ${item.value}`)
|
||||
.map((item) => `~${quoteKey(item.name)}: ${item.value}`)
|
||||
.join('\n')
|
||||
)}`;
|
||||
}
|
||||
@@ -151,7 +155,7 @@ const jsonToBru = (json) => {
|
||||
if (enabled(headers).length) {
|
||||
bru += `\n${indentString(
|
||||
enabled(headers)
|
||||
.map((item) => `${item.name}: ${item.value}`)
|
||||
.map((item) => `${quoteKey(item.name)}: ${item.value}`)
|
||||
.join('\n')
|
||||
)}`;
|
||||
}
|
||||
@@ -159,7 +163,7 @@ const jsonToBru = (json) => {
|
||||
if (disabled(headers).length) {
|
||||
bru += `\n${indentString(
|
||||
disabled(headers)
|
||||
.map((item) => `~${item.name}: ${item.value}`)
|
||||
.map((item) => `~${quoteKey(item.name)}: ${item.value}`)
|
||||
.join('\n')
|
||||
)}`;
|
||||
}
|
||||
@@ -246,7 +250,7 @@ ${indentString(`domain: ${auth?.ntlm?.domain || ''}`)}
|
||||
}
|
||||
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (auth && auth.oauth2) {
|
||||
switch (auth?.oauth2?.grantType) {
|
||||
@@ -496,14 +500,14 @@ ${indentString(body.sparql)}
|
||||
|
||||
if (enabled(body.formUrlEncoded).length) {
|
||||
const enabledValues = enabled(body.formUrlEncoded)
|
||||
.map((item) => `${item.name}: ${getValueString(item.value)}`)
|
||||
.map((item) => `${quoteKey(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n');
|
||||
bru += `${indentString(enabledValues)}\n`;
|
||||
}
|
||||
|
||||
if (disabled(body.formUrlEncoded).length) {
|
||||
const disabledValues = disabled(body.formUrlEncoded)
|
||||
.map((item) => `~${item.name}: ${getValueString(item.value)}`)
|
||||
.map((item) => `~${quoteKey(item.name)}: ${getValueString(item.value)}`)
|
||||
.join('\n');
|
||||
bru += `${indentString(disabledValues)}\n`;
|
||||
}
|
||||
@@ -524,7 +528,7 @@ ${indentString(body.sparql)}
|
||||
item.contentType && item.contentType !== '' ? ' @contentType(' + item.contentType + ')' : '';
|
||||
|
||||
if (item.type === 'text') {
|
||||
return `${enabled}${item.name}: ${getValueString(item.value)}${contentType}`;
|
||||
return `${enabled}${quoteKey(item.name)}: ${getValueString(item.value)}${contentType}`;
|
||||
}
|
||||
|
||||
if (item.type === 'file') {
|
||||
@@ -532,7 +536,7 @@ ${indentString(body.sparql)}
|
||||
const filestr = filepaths.join('|');
|
||||
|
||||
const value = `@file(${filestr})`;
|
||||
return `${enabled}${item.name}: ${value}${contentType}`;
|
||||
return `${enabled}${quoteKey(item.name)}: ${value}${contentType}`;
|
||||
}
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
@@ -81,6 +81,25 @@ headers {
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should parse single header with empty key', () => {
|
||||
const input = `
|
||||
headers {
|
||||
: world
|
||||
}`;
|
||||
|
||||
const output = parser(input);
|
||||
const expected = {
|
||||
headers: [
|
||||
{
|
||||
name: '',
|
||||
value: 'world',
|
||||
enabled: true
|
||||
}
|
||||
]
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should parse multi headers', () => {
|
||||
const input = `
|
||||
headers {
|
||||
|
||||
@@ -17,6 +17,11 @@ get {
|
||||
params:query {
|
||||
apiKey: secret
|
||||
numbers: 998877665
|
||||
"key with spaces": is allowed
|
||||
"colon:parameter": is allowed
|
||||
"nested escaped \"quote\"": is allowed
|
||||
"{braces}": is allowed
|
||||
~"disabled:colon:parameter": is allowed
|
||||
~message: hello
|
||||
}
|
||||
|
||||
@@ -27,6 +32,11 @@ params:path {
|
||||
headers {
|
||||
content-type: application/json
|
||||
Authorization: Bearer 123
|
||||
"key with spaces": is allowed
|
||||
"colon:header": is allowed
|
||||
"{braces}": is allowed
|
||||
"nested escaped \"quote\"": is allowed
|
||||
~"disabled:colon:header": is allowed
|
||||
~transaction-id: {{transactionId}}
|
||||
}
|
||||
|
||||
@@ -104,13 +114,23 @@ body:sparql {
|
||||
body:form-urlencoded {
|
||||
apikey: secret
|
||||
numbers: +91998877665
|
||||
"key with spaces": is allowed
|
||||
"colon:parameter": is allowed
|
||||
"nested escaped \"quote\"": is allowed
|
||||
"{braces}": is allowed
|
||||
~message: hello
|
||||
~"disabled colon:parameter": is allowed
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
apikey: secret
|
||||
numbers: +91998877665
|
||||
"key with spaces": is allowed
|
||||
"colon:part": is allowed
|
||||
"nested escaped \"quote\"": is allowed
|
||||
"{braces}": is allowed
|
||||
~message: hello
|
||||
~"disabled colon:part": is allowed
|
||||
}
|
||||
|
||||
body:file {
|
||||
|
||||
115
packages/bruno-lang/v2/tests/fixtures/request.json
vendored
115
packages/bruno-lang/v2/tests/fixtures/request.json
vendored
@@ -24,6 +24,36 @@
|
||||
"type": "query",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "key with spaces",
|
||||
"value": "is allowed",
|
||||
"type": "query",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name" : "colon:parameter",
|
||||
"value" : "is allowed",
|
||||
"type": "query",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name" : "nested escaped \"quote\"",
|
||||
"value" : "is allowed",
|
||||
"type": "query",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "{braces}",
|
||||
"value": "is allowed",
|
||||
"type": "query",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name" : "disabled:colon:parameter",
|
||||
"value" : "is allowed",
|
||||
"type": "query",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"value": "hello",
|
||||
@@ -48,6 +78,31 @@
|
||||
"value": "Bearer 123",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "key with spaces",
|
||||
"value": "is allowed",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "colon:header",
|
||||
"value": "is allowed",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "{braces}",
|
||||
"value": "is allowed",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "nested escaped \"quote\"",
|
||||
"value": "is allowed",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "disabled:colon:header",
|
||||
"value": "is allowed",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"name": "transaction-id",
|
||||
"value": "{{transactionId}}",
|
||||
@@ -118,10 +173,35 @@
|
||||
"value": "+91998877665",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "key with spaces",
|
||||
"value": "is allowed",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "colon:parameter",
|
||||
"value": "is allowed",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "nested escaped \"quote\"",
|
||||
"value": "is allowed",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "{braces}",
|
||||
"value": "is allowed",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"value": "hello",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"name": "disabled colon:parameter",
|
||||
"value": "is allowed",
|
||||
"enabled": false
|
||||
}
|
||||
],
|
||||
"multipartForm": [
|
||||
@@ -139,12 +219,47 @@
|
||||
"enabled": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"contentType": "",
|
||||
"name": "key with spaces",
|
||||
"value": "is allowed",
|
||||
"enabled": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"contentType": "",
|
||||
"name": "colon:part",
|
||||
"value": "is allowed",
|
||||
"enabled": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"contentType": "",
|
||||
"name": "nested escaped \"quote\"",
|
||||
"value": "is allowed",
|
||||
"enabled": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"contentType": "",
|
||||
"name": "{braces}",
|
||||
"value": "is allowed",
|
||||
"enabled": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"contentType": "",
|
||||
"name": "message",
|
||||
"value": "hello",
|
||||
"enabled": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"contentType": "",
|
||||
"name": "disabled colon:part",
|
||||
"value": "is allowed",
|
||||
"enabled": false,
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"file" : [
|
||||
|
||||
@@ -3,7 +3,7 @@ module.exports = {
|
||||
'^.+\\.(ts|js)$': 'babel-jest',
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'/node_modules/(?!(lodash-es)/)',
|
||||
'/node_modules/(?!(lodash-es|is-ip|ip-regex|super-regex|function-timeout|time-span|convert-hrtime|clone-regexp|is-regexp)/)'
|
||||
],
|
||||
testEnvironment: 'node',
|
||||
testMatch: [
|
||||
|
||||
@@ -24,8 +24,10 @@
|
||||
"@grpc/grpc-js": "^1.13.3",
|
||||
"@grpc/proto-loader": "^0.7.15",
|
||||
"@types/qs": "^6.9.18",
|
||||
"axios": "^1.9.0",
|
||||
"grpc-reflection-js": "^0.3.0",
|
||||
"axios": "^1.9.0"
|
||||
"is-ip": "^5.0.1",
|
||||
"tough-cookie": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "^7.22.0",
|
||||
@@ -37,8 +39,8 @@
|
||||
"@rollup/plugin-typescript": "^9.0.2",
|
||||
"@types/jest": "^29.5.11",
|
||||
"babel-jest": "^29.7.0",
|
||||
"jest": "^29.2.0",
|
||||
"builtin-modules": "^5.0.0",
|
||||
"jest": "^29.2.0",
|
||||
"rollup": "3.29.5",
|
||||
"rollup-plugin-dts": "^5.0.0",
|
||||
"rollup-plugin-peer-deps-external": "^2.2.4",
|
||||
@@ -48,4 +50,4 @@
|
||||
"overrides": {
|
||||
"rollup": "3.29.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,9 +89,16 @@ export function addDigestInterceptor(axiosInstance, request) {
|
||||
authDetails.algorithm = 'MD5';
|
||||
}
|
||||
|
||||
const uri = new URL(request.url, request.baseURL || 'http://localhost').pathname; // Handle relative URLs
|
||||
// Build full URL from the original request (may include query params and baseURL)
|
||||
const resolvedUrl = new URL(
|
||||
originalRequest.url || request.url,
|
||||
originalRequest.baseURL || request.baseURL || 'http://localhost'
|
||||
);
|
||||
const uri = `${resolvedUrl.pathname}${resolvedUrl.search}`;
|
||||
// Used 'GET' as default method to avoid missing method error
|
||||
const method = (originalRequest.method || request.method || 'GET').toUpperCase();
|
||||
const HA1 = md5(`${username}:${authDetails.realm}:${password}`);
|
||||
const HA2 = md5(`${request.method}:${uri}`);
|
||||
const HA2 = md5(`${method}:${uri}`);
|
||||
const response = md5(
|
||||
`${HA1}:${authDetails.nonce}:${nonceCount}:${cnonce}:auth:${HA2}`
|
||||
);
|
||||
|
||||
58
packages/bruno-requests/src/auth/digestauth-helper.spec.js
Normal file
58
packages/bruno-requests/src/auth/digestauth-helper.spec.js
Normal file
@@ -0,0 +1,58 @@
|
||||
const axios = require('axios');
|
||||
const { addDigestInterceptor } = require('./digestauth-helper');
|
||||
|
||||
describe('Digest Auth with query params', () => {
|
||||
test('uri should include path and query string', async () => {
|
||||
const axiosInstance = axios.create();
|
||||
|
||||
let callCount = 0;
|
||||
let capturedAuthorization;
|
||||
|
||||
// Custom adapter to simulate a 401 challenge then a 200 success
|
||||
axiosInstance.defaults.adapter = async (config) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
const error = new Error('Unauthorized');
|
||||
error.config = config;
|
||||
error.response = {
|
||||
status: 401,
|
||||
headers: {
|
||||
'www-authenticate': 'Digest realm="test", nonce="abc", qop="auth"'
|
||||
}
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Second call should have Authorization header set by interceptor
|
||||
capturedAuthorization = config.headers && (config.headers.Authorization || config.headers.authorization);
|
||||
return {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config,
|
||||
data: { ok: true }
|
||||
};
|
||||
};
|
||||
|
||||
const request = {
|
||||
method: 'GET',
|
||||
url: 'http://example.com/resource?foo=bar&baz=qux',
|
||||
headers: {},
|
||||
digestConfig: { username: 'user', password: 'pass' }
|
||||
};
|
||||
|
||||
addDigestInterceptor(axiosInstance, request);
|
||||
|
||||
const res = await axiosInstance(request);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(capturedAuthorization).toBeTruthy();
|
||||
// Extract uri="..." from the header
|
||||
const uriMatch = /uri="([^"]+)"/.exec(capturedAuthorization);
|
||||
expect(uriMatch).toBeTruthy();
|
||||
const uri = uriMatch[1];
|
||||
|
||||
// Expected to include both pathname and query
|
||||
expect(uri).toBe('/resource?foo=bar&baz=qux');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,17 @@
|
||||
const cookiesModule = require('../../src/cookies/index.ts').default;
|
||||
import cookiesModule from './index';
|
||||
import { Cookie } from 'tough-cookie';
|
||||
|
||||
// Provide explicit type for the cookie-jar wrapper returned by cookiesModule.jar()
|
||||
type CookieJarWrapper = ReturnType<typeof cookiesModule.jar>;
|
||||
|
||||
const jarFactory = (): CookieJarWrapper => cookiesModule.jar();
|
||||
|
||||
describe('Bruno Cookie Jar Wrapper - API Examples', () => {
|
||||
let jar;
|
||||
let jar: CookieJarWrapper;
|
||||
const testUrl = 'https://api.example.com';
|
||||
|
||||
beforeEach(() => {
|
||||
jar = cookiesModule.jar();
|
||||
jar = jarFactory();
|
||||
// Clear all cookies before each test
|
||||
jar.clear();
|
||||
});
|
||||
@@ -19,7 +25,7 @@ describe('Bruno Cookie Jar Wrapper - API Examples', () => {
|
||||
await jar.setCookie(testUrl, cookieName, cookieValue);
|
||||
|
||||
// Get the cookie back
|
||||
const cookie = await jar.getCookie(testUrl, cookieName);
|
||||
const cookie = (await jar.getCookie(testUrl, cookieName))!;
|
||||
expect(cookie.key).toBe(cookieName);
|
||||
expect(cookie.value).toBe(cookieValue);
|
||||
expect(cookie.domain).toBe('api.example.com');
|
||||
@@ -36,7 +42,7 @@ describe('Bruno Cookie Jar Wrapper - API Examples', () => {
|
||||
|
||||
await jar.setCookie(testUrl, cookieObj);
|
||||
|
||||
const cookie = await jar.getCookie(testUrl + '/api', 'sessionId');
|
||||
const cookie = (await jar.getCookie(testUrl + '/api', 'sessionId'))!;
|
||||
expect(cookie.key).toBe('sessionId');
|
||||
expect(cookie.value).toBe('abc123');
|
||||
expect(cookie.path).toBe('/api');
|
||||
@@ -61,10 +67,10 @@ describe('Bruno Cookie Jar Wrapper - API Examples', () => {
|
||||
await jar.setCookies(testUrl, cookies);
|
||||
|
||||
// Verify all cookies were set
|
||||
const retrievedCookies = await jar.getCookies(testUrl);
|
||||
const retrievedCookies = (await jar.getCookies(testUrl)) as Cookie[];
|
||||
expect(retrievedCookies).toHaveLength(3);
|
||||
|
||||
const cookieNames = retrievedCookies.map(c => c.key);
|
||||
const cookieNames = retrievedCookies.map((c: Cookie) => c.key);
|
||||
expect(cookieNames).toContain('cookie1');
|
||||
expect(cookieNames).toContain('cookie2');
|
||||
expect(cookieNames).toContain('cookie3');
|
||||
@@ -76,13 +82,13 @@ describe('Bruno Cookie Jar Wrapper - API Examples', () => {
|
||||
await jar.setCookie(testUrl, 'session', 'sess456');
|
||||
await jar.setCookie(testUrl, 'prefs', 'theme=dark');
|
||||
|
||||
const cookies = await jar.getCookies(testUrl);
|
||||
const cookies = (await jar.getCookies(testUrl)) as Cookie[];
|
||||
expect(cookies).toHaveLength(3);
|
||||
|
||||
const cookieMap = cookies.reduce((map, cookie) => {
|
||||
const cookieMap = (cookies as Cookie[]).reduce<Record<string, string>>((map, cookie: Cookie) => {
|
||||
map[cookie.key] = cookie.value;
|
||||
return map;
|
||||
}, {});
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
expect(cookieMap.auth).toBe('token123');
|
||||
expect(cookieMap.session).toBe('sess456');
|
||||
@@ -100,10 +106,10 @@ describe('Bruno Cookie Jar Wrapper - API Examples', () => {
|
||||
await jar.deleteCookie(testUrl, 'remove');
|
||||
|
||||
// Verify only one cookie remains
|
||||
const cookies = await jar.getCookies(testUrl);
|
||||
const cookies = (await jar.getCookies(testUrl)) as Cookie[];
|
||||
expect(cookies).toHaveLength(1);
|
||||
expect(cookies[0].key).toBe('keep');
|
||||
expect(cookies[0].value).toBe('keepValue');
|
||||
expect(cookies[0]!.key).toBe('keep');
|
||||
expect(cookies[0]!.value).toBe('keepValue');
|
||||
});
|
||||
|
||||
test('deleteCookies removes all cookies for URL', async () => {
|
||||
@@ -115,7 +121,7 @@ describe('Bruno Cookie Jar Wrapper - API Examples', () => {
|
||||
await jar.deleteCookies(testUrl);
|
||||
|
||||
// Verify no cookies remain
|
||||
const cookies = await jar.getCookies(testUrl);
|
||||
const cookies = (await jar.getCookies(testUrl)) as Cookie[];
|
||||
expect(cookies).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -128,8 +134,8 @@ describe('Bruno Cookie Jar Wrapper - API Examples', () => {
|
||||
await jar.clear();
|
||||
|
||||
// Verify no cookies remain for any URL
|
||||
const cookies1 = await jar.getCookies('https://site1.com');
|
||||
const cookies2 = await jar.getCookies('https://site2.com');
|
||||
const cookies1 = (await jar.getCookies('https://site1.com')) as Cookie[];
|
||||
const cookies2 = (await jar.getCookies('https://site2.com')) as Cookie[];
|
||||
|
||||
expect(cookies1).toHaveLength(0);
|
||||
expect(cookies2).toHaveLength(0);
|
||||
@@ -146,7 +152,7 @@ describe('Bruno Cookie Jar Wrapper - API Examples', () => {
|
||||
});
|
||||
|
||||
test('setCookies handles invalid input', async () => {
|
||||
await expect(jar.setCookies(testUrl, 'not-an-array')).rejects.toThrow('expects an array');
|
||||
await expect(jar.setCookies(testUrl, 'not-an-array' as any)).rejects.toThrow('expects an array');
|
||||
});
|
||||
|
||||
test('setCookie handles missing cookie name in object', async () => {
|
||||
@@ -163,7 +169,7 @@ describe('Bruno Cookie Jar Wrapper - API Examples', () => {
|
||||
await jar.setCookie(apiUrl, 'authToken', authToken);
|
||||
|
||||
// Later in the session - retrieve auth token
|
||||
const cookie = await jar.getCookie(apiUrl, 'authToken');
|
||||
const cookie = (await jar.getCookie(apiUrl, 'authToken'))!;
|
||||
expect(cookie.value).toBe(authToken);
|
||||
|
||||
// Simulate logout - remove auth cookie
|
||||
@@ -187,13 +193,13 @@ describe('Bruno Cookie Jar Wrapper - API Examples', () => {
|
||||
await jar.setCookies(sessionUrl, sessionCookies);
|
||||
|
||||
// Retrieve all session cookies
|
||||
const cookies = await jar.getCookies(sessionUrl);
|
||||
const cookies = (await jar.getCookies(sessionUrl)) as Cookie[];
|
||||
expect(cookies).toHaveLength(3);
|
||||
|
||||
// Find specific cookies
|
||||
const sessionCookie = cookies.find(c => c.key === 'sessionId');
|
||||
const csrfCookie = cookies.find(c => c.key === 'csrfToken');
|
||||
const prefsCookie = cookies.find(c => c.key === 'userPrefs');
|
||||
const sessionCookie = cookies.find((c: Cookie) => c.key === 'sessionId')!;
|
||||
const csrfCookie = cookies.find((c: Cookie) => c.key === 'csrfToken')!;
|
||||
const prefsCookie = cookies.find((c: Cookie) => c.key === 'userPrefs')!;
|
||||
|
||||
expect(sessionCookie.value).toBe('sess_123');
|
||||
expect(sessionCookie.httpOnly).toBe(true);
|
||||
@@ -212,15 +218,15 @@ describe('Bruno Cookie Jar Wrapper - API Examples', () => {
|
||||
await jar.setCookie(baseUrl, { key: 'api', value: 'api_val', path: '/api' });
|
||||
await jar.setCookie(baseUrl, { key: 'admin', value: 'admin_val', path: '/admin' });
|
||||
|
||||
const rootCookies = await jar.getCookies(baseUrl + '/');
|
||||
const globalCookie = rootCookies.find(c => c.key === 'global');
|
||||
const rootCookies = (await jar.getCookies(baseUrl + '/')) as Cookie[];
|
||||
const globalCookie = rootCookies.find((c: Cookie) => c.key === 'global')!;
|
||||
expect(globalCookie).toBeTruthy();
|
||||
expect(globalCookie.value).toBe('global_val');
|
||||
|
||||
const apiCookies = await jar.getCookies(baseUrl + '/api/users');
|
||||
const apiCookies = (await jar.getCookies(baseUrl + '/api/users')) as Cookie[];
|
||||
expect(apiCookies.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const apiCookieNames = apiCookies.map(c => c.key);
|
||||
const apiCookieNames = apiCookies.map((c: Cookie) => c.key);
|
||||
expect(apiCookieNames).toContain('global');
|
||||
expect(apiCookieNames).toContain('api');
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Cookie, CookieJar } from 'tough-cookie';
|
||||
import each from 'lodash/each';
|
||||
import moment from 'moment';
|
||||
import { isPotentiallyTrustworthyOrigin } from '../utils';
|
||||
import { isPotentiallyTrustworthyOrigin } from '../utils/url-validation';
|
||||
|
||||
const cookieJar = new CookieJar();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { addDigestInterceptor, getOAuth2Token } from './auth';
|
||||
export { GrpcClient, generateGrpcSampleMessage } from './grpc';
|
||||
export { default as cookies } from './cookies';
|
||||
|
||||
export * as network from './network';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isPotentiallyTrustworthyOrigin } from './validation';
|
||||
import { isPotentiallyTrustworthyOrigin } from './url-validation';
|
||||
|
||||
describe('isPotentiallyTrustworthyOrigin', () => {
|
||||
describe('secure schemes', () => {
|
||||
@@ -0,0 +1,32 @@
|
||||
meta {
|
||||
name: Redirect Cookie Save
|
||||
type: http
|
||||
seq: 9
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://httpbun.com/mix/s=302/c=foo:bar/r=https%3A%2F%2Fhttpbun.org%2Fget
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
tests {
|
||||
const jar = bru.cookies.jar()
|
||||
|
||||
const cookieData = await jar.getCookie(
|
||||
"https://httpbun.com",
|
||||
"foo"
|
||||
);
|
||||
|
||||
test("should store redirect cookie under initial request domain", function () {
|
||||
expect(cookieData).to.not.be.undefined;
|
||||
expect(cookieData.key).to.equal("foo");
|
||||
expect(cookieData.value).to.equal("bar");
|
||||
});
|
||||
|
||||
jar.clear();
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
}
|
||||
@@ -38,9 +38,28 @@ assert {
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const brunoBirthDate = new Date('2019-08-08');
|
||||
|
||||
const calculateAgeFromBirthDate = (birthDate = brunoBirthDate) => {
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - birthDate.getFullYear();
|
||||
|
||||
const hasBirthdayPassedThisYear =
|
||||
today.getMonth() > birthDate.getMonth() ||
|
||||
(today.getMonth() === birthDate.getMonth() && today.getDate() >= birthDate.getDate());
|
||||
|
||||
if (!hasBirthdayPassedThisYear) {
|
||||
age--;
|
||||
}
|
||||
|
||||
return age;
|
||||
};
|
||||
|
||||
const brunoAge = calculateAgeFromBirthDate(brunoBirthDate);
|
||||
|
||||
bru.setVar("rUser", {
|
||||
full_name: 'Bruno',
|
||||
age: 5,
|
||||
age: brunoAge,
|
||||
'fav-food': ['egg', 'meat'],
|
||||
'want.attention': true
|
||||
});
|
||||
@@ -48,8 +67,27 @@ script:pre-request {
|
||||
|
||||
tests {
|
||||
test("should return json", function() {
|
||||
const brunoBirthDate = new Date('2019-08-08');
|
||||
|
||||
const calculateAgeFromBirthDate = (birthDate = brunoBirthDate) => {
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - birthDate.getFullYear();
|
||||
|
||||
const hasBirthdayPassedThisYear =
|
||||
today.getMonth() > birthDate.getMonth() ||
|
||||
(today.getMonth() === birthDate.getMonth() && today.getDate() >= birthDate.getDate());
|
||||
|
||||
if (!hasBirthdayPassedThisYear) {
|
||||
age--;
|
||||
}
|
||||
|
||||
return age;
|
||||
};
|
||||
|
||||
const brunoAge = calculateAgeFromBirthDate(brunoBirthDate);
|
||||
|
||||
const expectedResponse = `Hi, I am Bruno,
|
||||
I am 5 years old.
|
||||
I am ${brunoAge} years old.
|
||||
My favorite food is egg and meat.
|
||||
I like attention: true`;
|
||||
expect(res.getBody()).to.equal(expectedResponse);
|
||||
|
||||
@@ -8,19 +8,20 @@
|
||||
"name": "@usebruno/test-collection",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@faker-js/faker": "^8.4.0"
|
||||
"@faker-js/faker": "^8.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@faker-js/faker": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.0.tgz",
|
||||
"integrity": "sha512-htW87352wzUCdX1jyUQocUcmAaFqcR/w082EC8iP/gtkF0K+aKcBp0hR5Arb7dzR8tQ1TrhE9DNa5EbJELm84w==",
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz",
|
||||
"integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fakerjs"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0",
|
||||
"npm": ">=6.14.13"
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
"name": "@usebruno/test-collection",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@faker-js/faker": "^8.4.0"
|
||||
"@faker-js/faker": "^8.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,9 +94,9 @@ flatpak install com.usebruno.Bruno
|
||||
|
||||
# On Linux via Apt
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
sudo apt update && sudo apt install gpg
|
||||
sudo apt update && sudo apt install gpg curl
|
||||
sudo gpg --list-keys
|
||||
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" | gpg --dearmor | sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
|
||||
sudo apt update && sudo apt install bruno
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user