mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-22 20:25:38 +00:00
Merge branch 'main' into feat/websocket-engine
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 537 KiB After Width: | Height: | Size: 813 KiB |
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);
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -84,7 +84,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>
|
||||
|
||||
@@ -103,6 +103,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}
|
||||
|
||||
@@ -30,6 +30,10 @@ const GrpcAuthMode = ({ item, collection }) => {
|
||||
name: 'OAuth2',
|
||||
mode: 'oauth2'
|
||||
},
|
||||
{
|
||||
name: 'WSSE Auth',
|
||||
mode: 'wsse'
|
||||
},
|
||||
{
|
||||
name: 'Inherit',
|
||||
mode: 'inherit'
|
||||
|
||||
@@ -6,6 +6,7 @@ import BearerAuth from '../../Auth/BearerAuth';
|
||||
import BasicAuth from '../../Auth/BasicAuth';
|
||||
import ApiKeyAuth from '../../Auth/ApiKeyAuth';
|
||||
import OAuth2 from '../../Auth/OAuth2/index';
|
||||
import WsseAuth from '../../Auth/WsseAuth';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections';
|
||||
import { getTreePathFromCollectionToItem } from 'utils/collections/index';
|
||||
@@ -13,7 +14,10 @@ import { updateRequestAuthMode, updateAuth } from 'providers/ReduxStore/slices/c
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
// List of auth modes supported by gRPC
|
||||
const supportedGrpcAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'none', 'inherit'];
|
||||
// Note: Only header-based auth modes work with gRPC
|
||||
// Complex auth modes like AWS Sig v4, Digest, and NTLM require axios interceptors
|
||||
// and cannot be supported in gRPC requests as of now
|
||||
const supportedGrpcAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'wsse', 'none', 'inherit'];
|
||||
|
||||
const GrpcAuth = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -83,6 +87,9 @@ const GrpcAuth = ({ item, collection }) => {
|
||||
case 'oauth2': {
|
||||
return <OAuth2 collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
|
||||
}
|
||||
case 'wsse': {
|
||||
return <WsseAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
|
||||
}
|
||||
case 'inherit': {
|
||||
const source = getEffectiveAuthSource();
|
||||
|
||||
|
||||
@@ -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%' }
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -9,7 +9,6 @@ const Wrapper = styled.div`
|
||||
|
||||
.method-selector {
|
||||
border-radius: 3px;
|
||||
min-width: 90px;
|
||||
|
||||
.tippy-box {
|
||||
max-width: 150px !important;
|
||||
@@ -21,6 +20,28 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
background-color: ${(props) => props.theme.requestTabPanel.url.bg};
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
text-align: left;
|
||||
|
||||
&:focus {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.method-span {
|
||||
width: 70px;
|
||||
min-width: 70px;
|
||||
max-width: 90px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.caret {
|
||||
color: rgb(140, 140, 140);
|
||||
fill: rgb(140 140 140);
|
||||
|
||||
@@ -1,52 +1,142 @@
|
||||
import React, { useRef, forwardRef } from 'react';
|
||||
import React, { useState, useRef, forwardRef } from 'react';
|
||||
import { IconCaretDown } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const HttpMethodSelector = ({ method, onMethodSelect }) => {
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
const STANDARD_METHODS = Object.freeze(['GET','POST','PUT','DELETE','PATCH','OPTIONS','HEAD','TRACE','CONNECT']);
|
||||
|
||||
const Icon = forwardRef((props, ref) => {
|
||||
const KEY = Object.freeze({ ENTER: 'Enter', ESCAPE: 'Escape' });
|
||||
|
||||
const DEFAULT_METHOD = 'GET';
|
||||
|
||||
function Verb({ verb, onSelect }) {
|
||||
return (
|
||||
<div className="dropdown-item" onClick={() => onSelect(verb)}>
|
||||
{verb}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = forwardRef(function IconComponent(
|
||||
{ isCustomMode, inputValue, handleInputChange, handleBlur, handleKeyDown, inputRef },
|
||||
ref
|
||||
) {
|
||||
if (isCustomMode) {
|
||||
return (
|
||||
<div ref={ref} className="flex w-full items-center pl-3 py-1 select-none uppercase">
|
||||
<div className="flex-grow font-medium" id="create-new-request-method">
|
||||
{method}
|
||||
</div>
|
||||
<div>
|
||||
<IconCaretDown className="caret ml-2 mr-2" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="font-medium px-2 w-full focus:bg-transparent"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
title={inputValue}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const handleMethodSelect = (verb) => onMethodSelect(verb);
|
||||
|
||||
const Verb = ({ verb }) => {
|
||||
return (
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleMethodSelect(verb);
|
||||
}}
|
||||
return (
|
||||
<div ref={ref} className="flex pr-4 select-none">
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer flex items-center text-left w-full"
|
||||
>
|
||||
{verb}
|
||||
</div>
|
||||
);
|
||||
<span
|
||||
className="font-medium px-2 truncate method-span"
|
||||
id="create-new-request-method"
|
||||
title={inputValue}
|
||||
>
|
||||
{inputValue}
|
||||
</span>
|
||||
<IconCaretDown className="caret" size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const HttpMethodSelector = ({ method = DEFAULT_METHOD, onMethodSelect }) => {
|
||||
const [isCustomMode, setIsCustomMode] = useState(false);
|
||||
const dropdownTippyRef = useRef();
|
||||
const inputRef = useRef();
|
||||
|
||||
const blurInput = () => inputRef.current?.blur();
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const val = e.target.value.toUpperCase();
|
||||
onMethodSelect(val);
|
||||
};
|
||||
|
||||
const handleDropdownSelect = (verb) => {
|
||||
onMethodSelect(verb);
|
||||
setIsCustomMode(false);
|
||||
dropdownTippyRef.current?.hide();
|
||||
blurInput();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsCustomMode(false);
|
||||
};
|
||||
|
||||
const handleAddCustomMethod = () => {
|
||||
setIsCustomMode(true);
|
||||
onMethodSelect('');
|
||||
dropdownTippyRef.current?.hide();
|
||||
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
switch (e.key) {
|
||||
case KEY.ESCAPE:
|
||||
setIsCustomMode(false);
|
||||
blurInput();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
case KEY.ENTER:
|
||||
onMethodSelect(e.target.value ? e.target.value.toUpperCase() : DEFAULT_METHOD);
|
||||
setIsCustomMode(false);
|
||||
blurInput();
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="flex items-center cursor-pointer method-selector">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-start">
|
||||
<Verb verb="GET" />
|
||||
<Verb verb="POST" />
|
||||
<Verb verb="PUT" />
|
||||
<Verb verb="DELETE" />
|
||||
<Verb verb="PATCH" />
|
||||
<Verb verb="OPTIONS" />
|
||||
<Verb verb="HEAD" />
|
||||
<div className="flex method-selector">
|
||||
<Dropdown
|
||||
onCreate={onDropdownCreate}
|
||||
icon={
|
||||
<Icon
|
||||
isCustomMode={isCustomMode}
|
||||
inputValue={method}
|
||||
handleInputChange={handleInputChange}
|
||||
handleBlur={handleBlur}
|
||||
handleKeyDown={handleKeyDown}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
}
|
||||
placement="bottom-start"
|
||||
>
|
||||
<div>
|
||||
{STANDARD_METHODS.map((verb) => (
|
||||
<Verb key={verb} verb={verb} onSelect={handleDropdownSelect} />
|
||||
))}
|
||||
<div className="dropdown-item font-normal mt-1" onClick={handleAddCustomMethod}>
|
||||
<span className="text-link">+ Add Custom</span>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -80,7 +80,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex items-center">
|
||||
<div className="flex items-center h-full method-selector-container">
|
||||
<div className="flex flex-1 items-center h-full method-selector-container">
|
||||
{isGrpc ? (
|
||||
<div className="flex items-center justify-center h-full w-16">
|
||||
<span className="text-xs text-indigo-500 font-bold">gRPC</span>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))));
|
||||
|
||||
@@ -501,7 +501,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
|
||||
</label>
|
||||
<div className="flex items-center mt-2 ">
|
||||
{!['grpc-request', 'ws-request'].includes(formik.values.requestType) ? (
|
||||
<div className="flex items-center h-full method-selector-container">
|
||||
<div className="flex items-center h-full method-selector-container w-1/5">
|
||||
<HttpMethodSelector
|
||||
method={formik.values.requestMethod}
|
||||
onMethodSelect={(val) => formik.setFieldValue('requestMethod', val)}
|
||||
|
||||
@@ -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,
|
||||
@@ -90,6 +91,9 @@ export const appSlice = createSlice({
|
||||
...state.generateCode,
|
||||
...action.payload
|
||||
};
|
||||
},
|
||||
toggleSidebarCollapse: (state) => {
|
||||
state.sidebarCollapsed = !state.sidebarCollapsed;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -109,7 +113,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,7 +41,8 @@ import {
|
||||
initRunRequestEvent,
|
||||
updateRunnerConfiguration as _updateRunnerConfiguration,
|
||||
updateActiveConnections,
|
||||
saveRequest as _saveRequest
|
||||
saveRequest as _saveRequest,
|
||||
saveEnvironment as _saveEnvironment
|
||||
} from './index';
|
||||
|
||||
import { each } from 'lodash';
|
||||
@@ -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';
|
||||
@@ -1241,8 +1243,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({
|
||||
@@ -1323,12 +1333,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);
|
||||
});
|
||||
@@ -1385,12 +1410,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);
|
||||
});
|
||||
@@ -1501,7 +1529,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -320,7 +320,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
|
||||
@@ -331,7 +344,8 @@ export const collectionsSlice = createSlice({
|
||||
secret: false,
|
||||
enabled: true,
|
||||
type: 'text',
|
||||
uid: uuid()
|
||||
uid: uuid(),
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2320,7 +2334,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));
|
||||
@@ -2457,6 +2485,9 @@ export const collectionsSlice = createSlice({
|
||||
if (type === 'testrun-ended') {
|
||||
const info = collection.runnerResult.info;
|
||||
info.status = 'ended';
|
||||
if (action.payload.runCompletionTime) {
|
||||
info.runCompletionTime = action.payload.runCompletionTime;
|
||||
}
|
||||
if (action.payload.statusText) {
|
||||
info.statusText = action.payload.statusText;
|
||||
}
|
||||
|
||||
@@ -131,6 +131,10 @@
|
||||
--px-12: 2px !important;
|
||||
}
|
||||
|
||||
.CodeMirror-hints {
|
||||
z-index: 20 !important;
|
||||
}
|
||||
|
||||
.graphiql-container {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cloneDeep, each, filter, find, findIndex, get, isEqual, isString, map, sortBy } 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';
|
||||
@@ -334,7 +335,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', ''),
|
||||
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
|
||||
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
|
||||
autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true)
|
||||
autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
|
||||
additionalParameters: get(si.request, 'auth.oauth2.additionalParameters', {})
|
||||
};
|
||||
break;
|
||||
case 'authorization_code':
|
||||
@@ -354,7 +356,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', ''),
|
||||
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
|
||||
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
|
||||
autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true)
|
||||
autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
|
||||
additionalParameters: get(si.request, 'auth.oauth2.additionalParameters', {})
|
||||
};
|
||||
break;
|
||||
case 'implicit':
|
||||
@@ -369,7 +372,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
tokenPlacement: get(si.request, 'auth.oauth2.tokenPlacement', 'header'),
|
||||
tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'),
|
||||
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
|
||||
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true)
|
||||
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
|
||||
additionalParameters: get(si.request, 'auth.oauth2.additionalParameters', {})
|
||||
};
|
||||
break;
|
||||
case 'client_credentials':
|
||||
@@ -386,7 +390,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', ''),
|
||||
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
|
||||
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
|
||||
autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true)
|
||||
autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
|
||||
additionalParameters: get(si.request, 'auth.oauth2.additionalParameters', {})
|
||||
};
|
||||
break;
|
||||
}
|
||||
@@ -501,7 +506,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);
|
||||
};
|
||||
@@ -623,6 +623,7 @@ const handler = async function (argv) {
|
||||
}
|
||||
|
||||
const summary = printRunSummary(results);
|
||||
const runCompletionTime = new Date().toISOString();
|
||||
const totalTime = results.reduce((acc, res) => acc + res.response.responseTime, 0);
|
||||
console.log(chalk.dim(chalk.grey(`Ran all requests - ${totalTime} ms`)));
|
||||
|
||||
@@ -636,7 +637,7 @@ const handler = async function (argv) {
|
||||
const reporters = {
|
||||
'json': (path) => fs.writeFileSync(path, JSON.stringify(outputJson, null, 2)),
|
||||
'junit': (path) => makeJUnitOutput(results, path),
|
||||
'html': (path) => makeHtmlOutput(outputJson, path),
|
||||
'html': (path) => makeHtmlOutput(outputJson, path, runCompletionTime),
|
||||
}
|
||||
|
||||
for (const formatter of Object.keys(formats))
|
||||
|
||||
@@ -1,637 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<!-- Would use latest version, you'd better specify a version -->
|
||||
<script src="https://unpkg.com/naive-ui"></script>
|
||||
|
||||
<title>Bruno</title>
|
||||
<style>
|
||||
.error > .status {
|
||||
color: red;
|
||||
}
|
||||
.success > .status {
|
||||
color: green;
|
||||
}
|
||||
|
||||
.n-collapse-item.success > .n-collapse-item__header {
|
||||
background-color: rgba(237, 247, 242, 1);
|
||||
}
|
||||
.n-collapse-item.error > .n-collapse-item__header {
|
||||
background-color: rgba(251, 238, 241, 1);
|
||||
}
|
||||
|
||||
.min-width-150 {
|
||||
min-width: 150px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<n-config-provider :theme="theme">
|
||||
<n-layout embedded position="absolute" content-style="padding: 24px;">
|
||||
<n-card>
|
||||
<n-flex>
|
||||
<n-page-header title="Bruno run dashboard">
|
||||
<template #avatar>
|
||||
<n-avatar size="large" style="background-color: transparent">
|
||||
<svg id="emoji" width="34" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="color">
|
||||
<path
|
||||
fill="#F4AA41"
|
||||
stroke="none"
|
||||
d="M23.5,14.5855l-4.5,1.75l-7.25,8.5l-4.5,10.75l2,5.25c1.2554,3.7911,3.5231,7.1832,7.25,10l2.5-3.3333 c0,0,3.8218,7.7098,10.7384,8.9598c0,0,10.2616,1.936,15.5949-0.8765c3.4203-1.8037,4.4167-4.4167,4.4167-4.4167l3.4167-3.4167 l1.5833,2.3333l2.0833-0.0833l5.4167-7.25L64,37.3355l-0.1667-4.5l-2.3333-5.5l-4.8333-7.4167c0,0-2.6667-4.9167-8.1667-3.9167 c0,0-6.5-4.8333-11.8333-4.0833S32.0833,10.6688,23.5,14.5855z"
|
||||
></path>
|
||||
<polygon
|
||||
fill="#EA5A47"
|
||||
stroke="none"
|
||||
points="36,47.2521 32.9167,49.6688 30.4167,49.6688 30.3333,53.5021 31.0833,57.0021 32.1667,58.9188 35,60.4188 39.5833,59.8355 41.1667,58.0855 42.1667,53.8355 41.9167,49.8355 39.9167,50.0855"
|
||||
></polygon>
|
||||
<polygon
|
||||
fill="#3F3F3F"
|
||||
stroke="none"
|
||||
points="32.5,36.9188 30.9167,40.6688 33.0833,41.9188 34.3333,42.4188 38.6667,42.5855 41.5833,40.3355 39.8333,37.0855"
|
||||
></polygon>
|
||||
</g>
|
||||
<g id="hair"></g>
|
||||
<g id="skin"></g>
|
||||
<g id="skin-shadow"></g>
|
||||
<g id="line">
|
||||
<path
|
||||
fill="#000000"
|
||||
stroke="none"
|
||||
d="M29.5059,30.1088c0,0-1.8051,1.2424-2.7484,0.6679c-0.9434-0.5745-1.2424-1.8051-0.6679-2.7484 s1.805-1.2424,2.7484-0.6679S29.5059,30.1088,29.5059,30.1088z"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="2"
|
||||
d="M33.1089,37.006h6.1457c0.4011,0,0.7634,0.2397,0.9203,0.6089l1.1579,2.7245l-2.1792,1.1456 c-0.6156,0.3236-1.3654-0.0645-1.4567-0.754"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="2"
|
||||
d="M34.7606,40.763c-0.1132,0.6268-0.7757,0.9895-1.3647,0.7471l-2.3132-0.952l1.0899-2.9035 c0.1465-0.3901,0.5195-0.6486,0.9362-0.6486"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="2"
|
||||
d="M30.4364,50.0268c0,0-0.7187,8.7934,3.0072,9.9375c2.6459,0.8125,5.1497,0.5324,6.0625-0.25 c0.875-0.75,2.6323-4.4741,1.8267-9.6875"
|
||||
></path>
|
||||
<path
|
||||
fill="#000000"
|
||||
stroke="none"
|
||||
d="M44.2636,30.1088c0,0,1.805,1.2424,2.7484,0.6679c0.9434-0.5745,1.2424-1.8051,0.6679-2.7484 c-0.5745-0.9434-1.805-1.2424-2.7484-0.6679C43.9881,27.9349,44.2636,30.1088,44.2636,30.1088z"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="2"
|
||||
d="M25.6245,42.8393c-0.475,3.6024,2.2343,5.7505,4.2847,6.8414c1.1968,0.6367,2.6508,0.5182,3.7176-0.3181l2.581-2.0233l2.581,2.0233 c1.0669,0.8363,2.5209,0.9548,3.7176,0.3181c2.0504-1.0909,4.7597-3.239,4.2847-6.8414"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="2"
|
||||
d="M19.9509,28.3572c-2.3166,5.1597-0.5084,13.0249,0.119,15.3759c0.122,0.4571,0.0755,0.9355-0.1271,1.3631l-1.9874,4.1937 c-0.623,1.3146-2.3934,1.5533-3.331,0.4409c-3.1921-3.7871-8.5584-11.3899-6.5486-16.686 c7.0625-18.6104,15.8677-18.1429,15.8677-18.1429c2.8453-1.9336,13.1042-6.9375,24.8125,0.875c0,0,8.6323-1.7175,14.9375,16.9375 c1.8036,5.3362-3.4297,12.8668-6.5506,16.6442c-0.9312,1.127-2.7162,0.8939-3.3423-0.4272l-1.9741-4.1656 c-0.2026-0.4275-0.2491-0.906-0.1271-1.3631c0.6275-2.3509,2.4356-10.2161,0.119-15.3759"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="2"
|
||||
d="M52.6309,46.4628c0,0-3.0781,6.7216-7.8049,8.2712"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="2"
|
||||
d="M19.437,46.969c0,0,3.0781,6.0823,7.8049,7.632"
|
||||
></path>
|
||||
<line
|
||||
x1="36.2078"
|
||||
x2="36.2078"
|
||||
y1="47.3393"
|
||||
y2="44.3093"
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="2"
|
||||
></line>
|
||||
</g>
|
||||
</svg>
|
||||
</n-avatar>
|
||||
</template>
|
||||
<template #extra>
|
||||
<n-flex justify="end">
|
||||
<n-switch v-model:value="darkMode" :rail-style="darkModeRailStyle">
|
||||
<template #checked> Dark </template>
|
||||
<template #unchecked> Light </template>
|
||||
</n-switch>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-page-header>
|
||||
<n-tabs type="segment" animated>
|
||||
<n-tab-pane name="summary" tab="Summary">
|
||||
<x-summary :res="res"></x-summary>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="Requests" tab="Requests">
|
||||
<x-requests :res="res"></x-requests>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
</n-layout>
|
||||
</n-config-provider>
|
||||
</div>
|
||||
<script type="text/x-template" id="summary-component">
|
||||
<n-flex vertical>
|
||||
<n-flex justify="center">
|
||||
<n-alert type="success">
|
||||
<n-statistic
|
||||
label="Total Controls"
|
||||
:value="summaryTotalControls"
|
||||
>
|
||||
</n-statistic>
|
||||
</n-alert>
|
||||
<n-alert :type="summaryFailedControls ? 'error' : 'success'">
|
||||
<n-statistic
|
||||
label="Total Failed Controls"
|
||||
:value="summaryFailedControls"
|
||||
>
|
||||
</n-statistic>
|
||||
</n-alert>
|
||||
<n-alert :type="summaryErrors ? 'error' : 'success'">
|
||||
<n-statistic label="Total errors" :value="summaryErrors">
|
||||
</n-statistic>
|
||||
</n-alert>
|
||||
</n-flex>
|
||||
<n-card title="TIMINGS AND DATA">
|
||||
<n-flex justify="center">
|
||||
<n-statistic
|
||||
label="Total run duration"
|
||||
:value="Math.round(totalRunDuration*1000)/1000"
|
||||
>
|
||||
<template #suffix>s</template>
|
||||
</n-statistic>
|
||||
<n-statistic
|
||||
label="Total requests"
|
||||
:value="summaryTotalRequests"
|
||||
>
|
||||
</n-statistic>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
<n-data-table :columns="summaryColumns" :data="summaryData" />
|
||||
</n-flex>
|
||||
</script>
|
||||
<script type="text/x-template" id="requests-component">
|
||||
<n-flex vertical>
|
||||
<n-switch
|
||||
v-model:value="onlyFailed"
|
||||
:rail-style="railStyle"
|
||||
>
|
||||
<template #checked> Only Failed </template>
|
||||
<template #unchecked> Only Failed </template>
|
||||
</n-switch>
|
||||
|
||||
<n-collapse>
|
||||
<x-results-group v-for="(results, group) in groupedResults" :results="results" :group="group" :key="group + '-' + results.length"></x-results-group>
|
||||
</n-collapse>
|
||||
</n-flex>
|
||||
</script>
|
||||
<script type="text/x-template" id="results-group-component">
|
||||
<n-collapse-item
|
||||
:name="group"
|
||||
arrow-placement="right"
|
||||
>
|
||||
<template #header>
|
||||
<n-alert
|
||||
:type="hasError || hasFailure ? 'error' : 'success'"
|
||||
:bordered="false"
|
||||
>
|
||||
<template #header>
|
||||
{{group}} - {{totalPassed}} / {{total}} Passed {{ hasError? " - Error" : "" }}
|
||||
</template>
|
||||
</n-alert>
|
||||
</template>
|
||||
<n-collapse>
|
||||
<x-result v-for="(result, index) in results" :result="result" :group="group" :key="index"></x-result>
|
||||
</n-collapse>
|
||||
</n-collapse-item>
|
||||
</script>
|
||||
<script type="text/x-template" id="result-component">
|
||||
<n-collapse-item
|
||||
:name="name"
|
||||
arrow-placement="right"
|
||||
>
|
||||
<template #header>
|
||||
<n-alert
|
||||
:type="hasError || hasFailure ? 'error' : 'success'"
|
||||
:bordered="false"
|
||||
>
|
||||
<template #header>
|
||||
{{suitename}} - {{totalPassed}} / {{total}} Passed {{hasError ? " - Error" : "" }}
|
||||
</template>
|
||||
</n-alert>
|
||||
</template>
|
||||
<n-flex vertical>
|
||||
<n-grid x-gap="12" :cols="2">
|
||||
<n-gi>
|
||||
<n-card title="REQUEST INFORMATION">
|
||||
<n-list>
|
||||
<n-list-item>
|
||||
<n-thing
|
||||
title="File"
|
||||
:description="result.test.filename"
|
||||
/>
|
||||
</n-list-item>
|
||||
<n-list-item>
|
||||
<n-thing
|
||||
title="Request Method"
|
||||
:description="result.request.method"
|
||||
/>
|
||||
</n-list-item>
|
||||
<n-list-item>
|
||||
<n-thing
|
||||
title="Request URL"
|
||||
:description="result.request.url"
|
||||
/>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card title="RESPONSE INFORMATION">
|
||||
<n-list>
|
||||
<n-list-item>
|
||||
<n-thing
|
||||
title="Response Code"
|
||||
:description="'' + result.response.status"
|
||||
/>
|
||||
</n-list-item>
|
||||
<n-list-item>
|
||||
<n-thing
|
||||
title="Response time"
|
||||
:description="result.response.responseTime + ' ms'"
|
||||
/>
|
||||
</n-list-item>
|
||||
<n-list-item>
|
||||
<n-thing
|
||||
title="Test duration"
|
||||
:description="testDuration"
|
||||
/>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-alert v-if="hasError" title="Error" type="error">
|
||||
{{result.error}}
|
||||
</n-alert>
|
||||
<n-card title="REQUEST HEADERS">
|
||||
<n-data-table
|
||||
:columns="headerColumns"
|
||||
:data="headerDataRequest"
|
||||
/>
|
||||
</n-card>
|
||||
<n-card
|
||||
v-if="result.request.data"
|
||||
title="REQUEST BODY"
|
||||
>
|
||||
<pre>{{result.request.data}}</pre>
|
||||
</n-card>
|
||||
<n-card title="RESPONSE HEADERS">
|
||||
<n-data-table
|
||||
:columns="headerColumns"
|
||||
:data="headerDataResponse"
|
||||
/>
|
||||
</n-card>
|
||||
<n-card
|
||||
v-if="result.response.data"
|
||||
title="RESPONSE BODY"
|
||||
>
|
||||
<pre>{{result.response.data}}</pre>
|
||||
</n-card>
|
||||
<n-card title="ASSERTIONS INFORMATION">
|
||||
<n-data-table
|
||||
:columns="assertionsColumns"
|
||||
:data="result.assertionResults"
|
||||
:row-class-name="assertionsRowClassName"
|
||||
/>
|
||||
</n-card>
|
||||
<n-card title="TESTS INFORMATION">
|
||||
<n-data-table
|
||||
:columns="testsColumns"
|
||||
:data="result.testResults"
|
||||
:row-class-name="testsRowClassName"
|
||||
/>
|
||||
</n-card>
|
||||
</n-flex>
|
||||
</n-collapse-item>
|
||||
</script>
|
||||
<script>
|
||||
const { createApp, ref, computed } = Vue;
|
||||
|
||||
const App = {
|
||||
setup() {
|
||||
const res = __RESULTS_JSON__;
|
||||
|
||||
const darkMode = ref(false);
|
||||
const theme = computed(() => {
|
||||
return darkMode.value ? naive.darkTheme : null;
|
||||
});
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
darkMode.value = true;
|
||||
}
|
||||
// To watch for os theme changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (event) => {
|
||||
darkMode.value = event.matches;
|
||||
});
|
||||
return {
|
||||
res,
|
||||
theme,
|
||||
darkMode,
|
||||
darkModeRailStyle: () => ({ background: 'var(--n-rail-color)' })
|
||||
};
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(App);
|
||||
|
||||
app.component('x-summary', {
|
||||
template: `#summary-component`,
|
||||
props: ['res'],
|
||||
setup(props) {
|
||||
const summaryColumns = [
|
||||
{
|
||||
title: 'SUMMARY ITEM',
|
||||
key: 'title'
|
||||
},
|
||||
{
|
||||
title: 'TOTAL',
|
||||
key: 'total'
|
||||
},
|
||||
{
|
||||
title: 'PASSED',
|
||||
key: 'passed'
|
||||
},
|
||||
{
|
||||
title: 'FAILED',
|
||||
key: 'failed'
|
||||
}
|
||||
];
|
||||
const summaryData = computed(() => [
|
||||
{
|
||||
title: 'Requests',
|
||||
total: props.res.summary.totalRequests,
|
||||
passed: props.res.summary.passedRequests,
|
||||
failed: props.res.summary.failedRequests
|
||||
},
|
||||
{
|
||||
title: 'Assertions',
|
||||
total: props.res.summary.totalAssertions,
|
||||
passed: props.res.summary.passedAssertions,
|
||||
failed: props.res.summary.failedAssertions
|
||||
},
|
||||
{
|
||||
title: 'Tests',
|
||||
total: props.res.summary.totalTests,
|
||||
passed: props.res.summary.passedTests,
|
||||
failed: props.res.summary.failedTests
|
||||
}
|
||||
]);
|
||||
const summaryTotalRequests = computed(() => {
|
||||
return props.res.summary.totalRequests;
|
||||
});
|
||||
const summaryTotalControls = computed(() => {
|
||||
return props.res.summary.totalTests + props.res.summary.totalAssertions;
|
||||
});
|
||||
const summaryFailedControls = computed(
|
||||
() => props.res.summary.failedRequests + props.res.summary.failedTests + props.res.summary.failedAssertions
|
||||
);
|
||||
const summaryErrors = computed(() => props.res.results.filter((r) => r.error).length);
|
||||
const totalRunDuration = computed(() => props.res?.results?.reduce((total, test) => test.runtime + total, 0));
|
||||
return {
|
||||
summaryColumns,
|
||||
summaryData,
|
||||
summaryTotalControls,
|
||||
summaryTotalRequests,
|
||||
summaryFailedControls,
|
||||
summaryErrors,
|
||||
totalRunDuration
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.component('x-requests', {
|
||||
template: `#requests-component`,
|
||||
props: ['res'],
|
||||
setup(props) {
|
||||
const onlyFailed = ref(false);
|
||||
const filteredResults = computed(() => {
|
||||
if (onlyFailed.value) {
|
||||
return props.res.results.filter(
|
||||
(r) =>
|
||||
!!r.error ||
|
||||
!!r.testResults.find((t) => t.status !== 'pass') ||
|
||||
!!r.assertionResults.find((t) => t.status !== 'pass')
|
||||
);
|
||||
}
|
||||
return props.res.results;
|
||||
});
|
||||
const groupedResults = computed(() => {
|
||||
return filteredResults.value.reduce((groups, curr) => {
|
||||
const path = curr.suitename.split('/');
|
||||
const test = path.pop();
|
||||
const name = path.length ? path.join('/') : '(root)';
|
||||
if (!groups[name]) {
|
||||
groups[name] = [];
|
||||
}
|
||||
groups[name].push(curr);
|
||||
return groups;
|
||||
}, {});
|
||||
});
|
||||
return {
|
||||
onlyFailed,
|
||||
groupedResults,
|
||||
railStyle: ({ checked }) => {
|
||||
const style = {};
|
||||
if (checked) {
|
||||
style.background = '#d03050';
|
||||
}
|
||||
return style;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.component('x-results-group', {
|
||||
template: `#results-group-component`,
|
||||
props: ['group', 'results'],
|
||||
setup(props) {
|
||||
const totalPassed = computed(() => {
|
||||
return props.results.reduce((total, curr) => {
|
||||
return (
|
||||
total +
|
||||
curr.testResults.filter((t) => t.status === 'pass').length +
|
||||
curr.assertionResults.filter((t) => t.status === 'pass').length
|
||||
);
|
||||
}, 0);
|
||||
});
|
||||
const total = computed(() => {
|
||||
return props.results.reduce((total, curr) => {
|
||||
return total + curr.testResults.length + curr.assertionResults.length;
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const hasError = computed(() => props.results.some((r) => !!r.error));
|
||||
const hasFailure = computed(() => totalPassed.value !== total.value);
|
||||
return {
|
||||
totalPassed,
|
||||
total,
|
||||
hasFailure,
|
||||
hasError,
|
||||
group: props.group,
|
||||
results: props.results
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.component('x-result', {
|
||||
template: `#result-component`,
|
||||
props: ['group', 'result'],
|
||||
setup(props) {
|
||||
const headerColumns = [
|
||||
{
|
||||
title: 'Header Name',
|
||||
key: 'name',
|
||||
className: 'min-width-150'
|
||||
},
|
||||
{
|
||||
title: 'Header Value',
|
||||
key: 'value'
|
||||
}
|
||||
];
|
||||
const assertionsColumns = [
|
||||
{
|
||||
title: 'Expression',
|
||||
key: 'lhsExpr'
|
||||
},
|
||||
{
|
||||
title: 'Operator',
|
||||
key: 'operator'
|
||||
},
|
||||
{
|
||||
title: 'Operand',
|
||||
key: 'rhsOperand'
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
key: 'status',
|
||||
className: 'status'
|
||||
},
|
||||
{
|
||||
title: 'Error',
|
||||
key: 'error'
|
||||
}
|
||||
];
|
||||
const assertionsRowClassName = (row) => {
|
||||
return row.status === 'fail' ? 'error' : 'success';
|
||||
};
|
||||
const testsRowClassName = (row) => {
|
||||
return row.status === 'fail' ? 'error' : 'success';
|
||||
};
|
||||
const testsColumns = [
|
||||
{
|
||||
title: 'Description',
|
||||
key: 'description'
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
key: 'status',
|
||||
className: 'status'
|
||||
},
|
||||
{
|
||||
title: 'Error',
|
||||
key: 'error'
|
||||
}
|
||||
];
|
||||
|
||||
function mapHeaderToTableData(headers) {
|
||||
if (!headers) {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(headers).map((name) => ({
|
||||
name,
|
||||
value: headers[name]
|
||||
}));
|
||||
}
|
||||
const headerDataRequest = computed(() => {
|
||||
return mapHeaderToTableData(props.result.request.headers);
|
||||
});
|
||||
const headerDataResponse = computed(() => {
|
||||
return mapHeaderToTableData(props.result.response.headers);
|
||||
});
|
||||
const totalPassed = computed(() => {
|
||||
return (
|
||||
props.result.testResults.filter((t) => t.status === 'pass').length +
|
||||
props.result.assertionResults.filter((t) => t.status === 'pass').length
|
||||
);
|
||||
});
|
||||
const total = computed(() => {
|
||||
return props.result.testResults.length + props.result.assertionResults.length;
|
||||
});
|
||||
|
||||
const hasError = computed(() => !!props.result.error);
|
||||
const hasFailure = computed(() => total.value !== totalPassed.value);
|
||||
const suitename = computed(() => props.result.suitename.replace(props.group + '/', ''));
|
||||
const testDuration = computed(() => Math.round(props.result.runtime * 1000) + ' ms');
|
||||
const name = computed(() => props.result.suitename + props.result.runtime);
|
||||
return {
|
||||
headerColumns,
|
||||
headerDataRequest,
|
||||
headerDataResponse,
|
||||
assertionsColumns,
|
||||
assertionsRowClassName,
|
||||
testsRowClassName,
|
||||
totalPassed,
|
||||
total,
|
||||
hasFailure,
|
||||
hasError,
|
||||
testsColumns,
|
||||
result: props.result,
|
||||
suitename,
|
||||
testDuration,
|
||||
name
|
||||
};
|
||||
}
|
||||
});
|
||||
app.use(naive);
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,13 +1,31 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { generateHtmlReport } = require('@usebruno/common/runner');
|
||||
const { CLI_VERSION } = require('../constants');
|
||||
|
||||
const makeHtmlOutput = async (results, outputPath) => {
|
||||
const resultsJson = JSON.stringify(results, null, 2);
|
||||
|
||||
const reportPath = path.join(__dirname, 'html-template.html');
|
||||
const template = fs.readFileSync(reportPath, 'utf8');
|
||||
|
||||
fs.writeFileSync(outputPath, template.replace('__RESULTS_JSON__', resultsJson));
|
||||
const makeHtmlOutput = async (results, outputPath, runCompletionTime) => {
|
||||
let runnerResults = results;
|
||||
if (!results) {
|
||||
runnerResults = [];
|
||||
} else if (results.results) {
|
||||
// Convert CLI format to expected format: array of { iterationIndex, results, summary }
|
||||
runnerResults = [{
|
||||
iterationIndex: 0,
|
||||
results: results.results,
|
||||
summary: results.summary
|
||||
}];
|
||||
} else if (Array.isArray(results)) {
|
||||
runnerResults = results;
|
||||
}
|
||||
|
||||
const environment = runnerResults.length > 0 ? runnerResults[0].environment : null;
|
||||
|
||||
const htmlString = generateHtmlReport({
|
||||
runnerResults: runnerResults,
|
||||
version: `usebruno v${CLI_VERSION}`,
|
||||
environment: environment,
|
||||
runCompletionTime: runCompletionTime
|
||||
});
|
||||
fs.writeFileSync(outputPath, htmlString);
|
||||
};
|
||||
|
||||
module.exports = makeHtmlOutput;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -77,6 +77,8 @@ const bruToJson = (bru) => {
|
||||
request: {
|
||||
url: _.get(json, requestType === 'grpc-request' ? 'grpc.url' : 'http.url'),
|
||||
headers: requestType === 'grpc-request' ? _.get(json, 'metadata', []) : _.get(json, 'headers', []),
|
||||
// Preserving special characters in custom methods. Using _.upperCase strips special characters.
|
||||
method: String(_.get(json, 'http.method') ?? '').toUpperCase(),
|
||||
auth: _.get(json, 'auth', {}),
|
||||
params: _.get(json, 'params', []),
|
||||
vars: _.get(json, 'vars', []),
|
||||
|
||||
52
packages/bruno-cli/tests/runner/report-metadata.spec.js
Normal file
52
packages/bruno-cli/tests/runner/report-metadata.spec.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const { describe, it, expect } = require('@jest/globals');
|
||||
const { generateHtmlReport } = require('@usebruno/common/runner');
|
||||
|
||||
describe('HTML Report Generation', () => {
|
||||
it('should include all metadata in the HTML report', async () => {
|
||||
// Sample test results
|
||||
const mockResults = [
|
||||
{
|
||||
iterationIndex: 0,
|
||||
environment: 'production',
|
||||
results: [],
|
||||
summary: {
|
||||
totalRequests: 1,
|
||||
passedRequests: 1,
|
||||
failedRequests: 0,
|
||||
errorRequests: 0,
|
||||
skippedRequests: 0,
|
||||
totalAssertions: 0,
|
||||
passedAssertions: 0,
|
||||
failedAssertions: 0,
|
||||
totalTests: 0,
|
||||
passedTests: 0,
|
||||
failedTests: 0
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Generate HTML using mock data
|
||||
const htmlString = generateHtmlReport({
|
||||
runnerResults: mockResults,
|
||||
version: 'usebruno v1.16.0',
|
||||
environment: 'production',
|
||||
runCompletionTime: '2024-01-15T14:30:45.123Z'
|
||||
});
|
||||
|
||||
// Verify the HTML contains expected metadata structure
|
||||
expect(htmlString).toContain('Bruno run dashboard');
|
||||
expect(htmlString).toContain('Date & Time');
|
||||
expect(htmlString).toContain('Version');
|
||||
expect(htmlString).toContain('Environment');
|
||||
expect(htmlString).toContain('Total run duration');
|
||||
expect(htmlString).toContain('Total data received');
|
||||
expect(htmlString).toContain('Average response time');
|
||||
|
||||
expect(htmlString).toContain('{{ runCompletionTime }}');
|
||||
expect(htmlString).toContain('{{ brunoVersion }}');
|
||||
expect(htmlString).toContain('{{ environment }}');
|
||||
expect(htmlString).toContain('{{ totalDuration }}');
|
||||
expect(htmlString).toContain('{{ totalDataReceived }}');
|
||||
expect(htmlString).toContain('{{ averageResponseTime }}');
|
||||
});
|
||||
});
|
||||
@@ -2,4 +2,4 @@ export { mockDataFunctions } from './utils/faker-functions';
|
||||
export { default as interpolate } from './interpolate';
|
||||
export { default as isRequestTagsIncluded } from './tags';
|
||||
|
||||
export * as utils from './utils';
|
||||
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';
|
||||
|
||||
@@ -3,9 +3,15 @@ import { isHtmlContentType, getContentType, redactImageData, encodeBase64 } from
|
||||
import htmlTemplateString from "./template";
|
||||
|
||||
const generateHtmlReport = ({
|
||||
runnerResults
|
||||
runnerResults,
|
||||
version = '', // Default to empty string if not provided
|
||||
environment = null, // Default environment if not provided
|
||||
runCompletionTime = '' // Default run completion time if not provided
|
||||
}: {
|
||||
runnerResults: T_RunnerResults[]
|
||||
runnerResults: T_RunnerResults[];
|
||||
version?: string;
|
||||
environment?: string | null;
|
||||
runCompletionTime?: string;
|
||||
}): string => {
|
||||
const resultsWithSummaryAndCleanData = runnerResults.map(({ iterationIndex, results, summary }) => {
|
||||
return {
|
||||
@@ -31,7 +37,12 @@ const generateHtmlReport = ({
|
||||
summary
|
||||
}
|
||||
});
|
||||
const htmlString = htmlTemplateString(encodeBase64(JSON.stringify(resultsWithSummaryAndCleanData)));
|
||||
const htmlString = htmlTemplateString(encodeBase64(JSON.stringify({
|
||||
results: resultsWithSummaryAndCleanData,
|
||||
version,
|
||||
environment,
|
||||
runCompletionTime
|
||||
})));
|
||||
return htmlString;
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,37 @@ export const htmlTemplateString = (resutsJsonString: string) =>`<!DOCTYPE html>
|
||||
.min-width-150 {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* Metadata card styling - minimal custom styles */
|
||||
.metadata-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.metadata-item {
|
||||
text-align: center;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metadata-label {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.metadata-value {
|
||||
font-size: 0.8rem;
|
||||
font-weight: normal;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -162,6 +193,35 @@ export const htmlTemplateString = (resutsJsonString: string) =>`<!DOCTYPE html>
|
||||
<n-tabs type="segment" animated v-model:value="currentTab">
|
||||
<n-tab-pane name="summary" tab="Summary">
|
||||
<n-flex justify="center" vertical>
|
||||
<!-- Run Information Card using Naive UI components -->
|
||||
<n-card title="Run Information" size="small">
|
||||
<div class="metadata-grid">
|
||||
<n-card class="metadata-item" size="small">
|
||||
<div class="metadata-label">Date & Time</div>
|
||||
<div class="metadata-value">{{ runCompletionTime }}</div>
|
||||
</n-card>
|
||||
<n-card class="metadata-item" size="small">
|
||||
<div class="metadata-label">Version</div>
|
||||
<div class="metadata-value">{{ brunoVersion }}</div>
|
||||
</n-card>
|
||||
<n-card class="metadata-item" size="small">
|
||||
<div class="metadata-label">Environment</div>
|
||||
<div class="metadata-value">{{ environment }}</div>
|
||||
</n-card>
|
||||
<n-card class="metadata-item" size="small">
|
||||
<div class="metadata-label">Total run duration</div>
|
||||
<div class="metadata-value">{{ totalDuration }}</div>
|
||||
</n-card>
|
||||
<n-card class="metadata-item" size="small">
|
||||
<div class="metadata-label">Total data received</div>
|
||||
<div class="metadata-value">{{ totalDataReceived }}</div>
|
||||
</n-card>
|
||||
<n-card class="metadata-item" size="small">
|
||||
<div class="metadata-label">Average response time</div>
|
||||
<div class="metadata-value">{{ averageResponseTime }}</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</n-card>
|
||||
<x-summary v-for="(result, index) in res" :res="result" :key="index"></x-summary>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
@@ -213,12 +273,6 @@ export const htmlTemplateString = (resutsJsonString: string) =>`<!DOCTYPE html>
|
||||
<n-statistic label="Skipped requests" :value="summarySkippedRequests">
|
||||
</n-statistic>
|
||||
</n-alert>
|
||||
<n-statistic
|
||||
label="Total run duration"
|
||||
:value="Math.round(totalRunDuration*1000)/1000"
|
||||
>
|
||||
<template #suffix>s</template>
|
||||
</n-statistic>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
@@ -400,10 +454,25 @@ export const htmlTemplateString = (resutsJsonString: string) =>`<!DOCTYPE html>
|
||||
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
const rawResults = JSON.parse(decodeBase64('${resutsJsonString}'));
|
||||
|
||||
const res = computed(() => {
|
||||
const rawResults = JSON.parse(decodeBase64('${resutsJsonString}'));
|
||||
return mergeTests(rawResults);
|
||||
return mergeTests(rawResults.results);
|
||||
});
|
||||
|
||||
const brunoVersion = computed(() => {
|
||||
return rawResults.version || '-';
|
||||
});
|
||||
|
||||
const environment = computed(() => {
|
||||
return rawResults.environment || '-';
|
||||
});
|
||||
|
||||
const runCompletionTime = computed(() => {
|
||||
if (rawResults.runCompletionTime) {
|
||||
return new Date(rawResults.runCompletionTime).toLocaleString();
|
||||
}
|
||||
return '-';
|
||||
});
|
||||
|
||||
const currentTab = ref('summary');
|
||||
@@ -422,6 +491,47 @@ export const htmlTemplateString = (resutsJsonString: string) =>`<!DOCTYPE html>
|
||||
const theme = computed(() => {
|
||||
return darkMode.value ? naive.darkTheme : null;
|
||||
});
|
||||
|
||||
const totalDuration = computed(() => {
|
||||
const total = res.value.reduce((totalTime, iteration) => {
|
||||
return totalTime + iteration.results.reduce((sum, result) => sum + (result.runDuration || 0), 0);
|
||||
}, 0);
|
||||
return total > 0 ? Math.round(total * 1000) / 1000 + 's' : '-';
|
||||
});
|
||||
|
||||
const totalDataReceived = computed(() => {
|
||||
const bytes = res.value.reduce((total, iteration) => {
|
||||
return total + iteration.results.reduce((sum, result) => {
|
||||
const responseData = result.response?.data;
|
||||
if (typeof responseData === 'string') {
|
||||
return sum + new Blob([responseData]).size;
|
||||
}
|
||||
return sum + (JSON.stringify(responseData || {}).length || 0);
|
||||
}, 0);
|
||||
}, 0);
|
||||
|
||||
if (bytes === 0) return '-';
|
||||
if (bytes < 1024) return bytes + 'B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + 'KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(2) + 'MB';
|
||||
});
|
||||
|
||||
const averageResponseTime = computed(() => {
|
||||
let totalTime = 0;
|
||||
let count = 0;
|
||||
|
||||
res.value.forEach(iteration => {
|
||||
iteration.results.forEach(result => {
|
||||
if (result.response?.responseTime) {
|
||||
totalTime += result.response.responseTime;
|
||||
count++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return count > 0 ? Math.round(totalTime / count) + 'ms' : '-';
|
||||
});
|
||||
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
darkMode.value = true;
|
||||
}
|
||||
@@ -434,7 +544,13 @@ export const htmlTemplateString = (resutsJsonString: string) =>`<!DOCTYPE html>
|
||||
theme,
|
||||
darkMode,
|
||||
darkModeRailStyle: () => ({ background: 'var(--n-rail-color)' }),
|
||||
currentTab
|
||||
currentTab,
|
||||
brunoVersion,
|
||||
environment,
|
||||
totalDuration,
|
||||
totalDataReceived,
|
||||
averageResponseTime,
|
||||
runCompletionTime
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -271,7 +271,6 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
|
||||
brunoParent.items = brunoParent.items || [];
|
||||
const folderMap = {};
|
||||
const requestMap = {};
|
||||
const requestMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE']
|
||||
|
||||
item.forEach((i, index) => {
|
||||
if (isItemAFolder(i)) {
|
||||
@@ -336,8 +335,9 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
|
||||
folderMap[folderName] = brunoFolderItem;
|
||||
|
||||
} else if (i.request) {
|
||||
if (!requestMethods.includes(i?.request?.method.toUpperCase())) {
|
||||
console.warn('Unexpected request.method', i?.request?.method);
|
||||
const method = i?.request?.method?.toUpperCase();
|
||||
if (!method || typeof method !== 'string' || !method.trim()) {
|
||||
console.warn('Missing or invalid request.method', method);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -359,7 +359,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
|
||||
seq: index + 1,
|
||||
request: {
|
||||
url: url,
|
||||
method: i?.request?.method?.toUpperCase(),
|
||||
method: method,
|
||||
auth: {
|
||||
mode: 'inherit',
|
||||
basic: null,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,118 +11,7 @@ const { getProcessEnvVars } = require('../../store/process-env');
|
||||
const { getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingAuthorizationCode } = require('../../utils/oauth2');
|
||||
const { interpolateString } = require('./interpolate-string');
|
||||
const path = require('node:path');
|
||||
|
||||
const setGrpcAuthHeaders = (grpcRequest, request, collectionRoot) => {
|
||||
const collectionAuth = get(collectionRoot, 'request.auth');
|
||||
if (collectionAuth && request.auth?.mode === 'inherit') {
|
||||
if (collectionAuth.mode === 'basic') {
|
||||
grpcRequest.basicAuth = {
|
||||
username: get(collectionAuth, 'basic.username'),
|
||||
password: get(collectionAuth, 'basic.password')
|
||||
};
|
||||
}
|
||||
|
||||
if (collectionAuth.mode === 'bearer') {
|
||||
grpcRequest.headers['Authorization'] = `Bearer ${get(collectionAuth, 'bearer.token')}`;
|
||||
}
|
||||
|
||||
if (collectionAuth.mode === 'apikey') {
|
||||
grpcRequest.headers[collectionAuth.apikey?.key] = collectionAuth.apikey?.value;
|
||||
|
||||
}
|
||||
|
||||
if (collectionAuth.mode === 'oauth2') {
|
||||
const grantType = get(collectionAuth, 'oauth2.grantType');
|
||||
|
||||
if (grantType === 'client_credentials') {
|
||||
grpcRequest.oauth2 = {
|
||||
grantType,
|
||||
accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
|
||||
clientId: get(collectionAuth, 'oauth2.clientId'),
|
||||
clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
|
||||
scope: get(collectionAuth, 'oauth2.scope'),
|
||||
credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
|
||||
tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
|
||||
tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
|
||||
tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey')
|
||||
};
|
||||
} else if (grantType === 'password') {
|
||||
grpcRequest.oauth2 = {
|
||||
grantType,
|
||||
accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
|
||||
username: get(collectionAuth, 'oauth2.username'),
|
||||
password: get(collectionAuth, 'oauth2.password'),
|
||||
clientId: get(collectionAuth, 'oauth2.clientId'),
|
||||
clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
|
||||
scope: get(collectionAuth, 'oauth2.scope'),
|
||||
credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
|
||||
tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
|
||||
tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
|
||||
tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (request.auth && request.auth.mode !== 'inherit') {
|
||||
if (request.auth.mode === 'basic') {
|
||||
grpcRequest.basicAuth = {
|
||||
username: get(request, 'auth.basic.username'),
|
||||
password: get(request, 'auth.basic.password')
|
||||
};
|
||||
}
|
||||
|
||||
if (request.auth.mode === 'bearer') {
|
||||
grpcRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`;
|
||||
}
|
||||
|
||||
if (request.auth.mode === 'oauth2') {
|
||||
const grantType = get(request, 'auth.oauth2.grantType');
|
||||
|
||||
|
||||
if (grantType === 'client_credentials') {
|
||||
grpcRequest.oauth2 = {
|
||||
grantType,
|
||||
clientId: get(request, 'auth.oauth2.clientId'),
|
||||
clientSecret: get(request, 'auth.oauth2.clientSecret'),
|
||||
scope: get(request, 'auth.oauth2.scope'),
|
||||
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
|
||||
tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
|
||||
credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'),
|
||||
tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
|
||||
tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey')
|
||||
};
|
||||
} else if (grantType === 'password') {
|
||||
grpcRequest.oauth2 = {
|
||||
grantType,
|
||||
username: get(request, 'auth.oauth2.username'),
|
||||
password: get(request, 'auth.oauth2.password'),
|
||||
clientId: get(request, 'auth.oauth2.clientId'),
|
||||
clientSecret: get(request, 'auth.oauth2.clientSecret'),
|
||||
scope: get(request, 'auth.oauth2.scope'),
|
||||
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
|
||||
tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
|
||||
credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'),
|
||||
tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
|
||||
tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey')
|
||||
};
|
||||
} else if (grantType === 'authorization_code') {
|
||||
grpcRequest.oauth2 = {
|
||||
grantType,
|
||||
...get(request, 'auth.oauth2')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (request.auth.mode === 'apikey') {
|
||||
grpcRequest.headers[request.auth.apikey?.key] = request.auth.apikey?.value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return grpcRequest;
|
||||
}
|
||||
const { setAuthHeaders } = require('./prepare-request');
|
||||
|
||||
const prepareRequest = async (item, collection, environment, runtimeVariables, certsAndProxyConfig = {}) => {
|
||||
const request = item.draft ? item.draft.request : item.request;
|
||||
@@ -182,7 +71,7 @@ const prepareRequest = async (item, collection, environment, runtimeVariables, c
|
||||
oauth2CredentialVariables: request.oauth2CredentialVariables,
|
||||
}
|
||||
|
||||
grpcRequest = setGrpcAuthHeaders(grpcRequest, request, collectionRoot);
|
||||
grpcRequest = setAuthHeaders(grpcRequest, request, collectionRoot);
|
||||
|
||||
if (grpcRequest.oauth2) {
|
||||
let requestCopy = cloneDeep(grpcRequest);
|
||||
|
||||
@@ -1452,7 +1452,8 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
type: 'testrun-ended',
|
||||
collectionUid,
|
||||
folderUid,
|
||||
statusText: 'collection run was terminated!'
|
||||
statusText: 'collection run was terminated!',
|
||||
runCompletionTime: new Date().toISOString(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -1481,7 +1482,8 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
mainWindow.webContents.send('main:run-folder-event', {
|
||||
type: 'testrun-ended',
|
||||
collectionUid,
|
||||
folderUid
|
||||
folderUid,
|
||||
runCompletionTime: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('error', error);
|
||||
@@ -1490,6 +1492,7 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
type: 'testrun-ended',
|
||||
collectionUid,
|
||||
folderUid,
|
||||
runCompletionTime: new Date().toISOString(),
|
||||
error: error && !error.isCancel ? error : null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,8 +43,8 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
||||
};
|
||||
break;
|
||||
case 'wsse':
|
||||
const username = get(request, 'auth.wsse.username', '');
|
||||
const password = get(request, 'auth.wsse.password', '');
|
||||
const username = get(collectionAuth, 'wsse.username', '');
|
||||
const password = get(collectionAuth, 'wsse.password', '');
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
const nonce = crypto.randomBytes(16).toString('hex');
|
||||
@@ -193,7 +193,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
||||
axiosRequest.oauth2 = {
|
||||
grantType: grantType,
|
||||
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
|
||||
refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
|
||||
refreshTokenUrl: get(request, 'auth.oauth2.refreshTokenUrl'),
|
||||
username: get(request, 'auth.oauth2.username'),
|
||||
password: get(request, 'auth.oauth2.password'),
|
||||
clientId: get(request, 'auth.oauth2.clientId'),
|
||||
@@ -215,7 +215,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
||||
callbackUrl: get(request, 'auth.oauth2.callbackUrl'),
|
||||
authorizationUrl: get(request, 'auth.oauth2.authorizationUrl'),
|
||||
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
|
||||
refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
|
||||
refreshTokenUrl: get(request, 'auth.oauth2.refreshTokenUrl'),
|
||||
clientId: get(request, 'auth.oauth2.clientId'),
|
||||
clientSecret: get(request, 'auth.oauth2.clientSecret'),
|
||||
scope: get(request, 'auth.oauth2.scope'),
|
||||
@@ -251,7 +251,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
||||
axiosRequest.oauth2 = {
|
||||
grantType: grantType,
|
||||
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
|
||||
refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
|
||||
refreshTokenUrl: get(request, 'auth.oauth2.refreshTokenUrl'),
|
||||
clientId: get(request, 'auth.oauth2.clientId'),
|
||||
clientSecret: get(request, 'auth.oauth2.clientSecret'),
|
||||
scope: get(request, 'auth.oauth2.scope'),
|
||||
|
||||
959
packages/bruno-electron/tests/prepare-request.test.js
Normal file
959
packages/bruno-electron/tests/prepare-request.test.js
Normal file
@@ -0,0 +1,959 @@
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
// Mock crypto.randomBytes to return predictable values for testing
|
||||
jest.mock('node:crypto', () => ({
|
||||
...jest.requireActual('node:crypto'),
|
||||
randomBytes: jest.fn(() => Buffer.from('1234567890abcdef', 'hex'))
|
||||
}));
|
||||
|
||||
// Mock the lodash get function with a more sophisticated mock
|
||||
const mockGet = jest.fn();
|
||||
jest.mock('lodash', () => ({
|
||||
get: mockGet,
|
||||
each: jest.fn(),
|
||||
filter: jest.fn(),
|
||||
find: jest.fn()
|
||||
}));
|
||||
|
||||
// Import the function to test
|
||||
const { setAuthHeaders } = require('../src/ipc/network/prepare-request');
|
||||
|
||||
describe('setAuthHeaders', () => {
|
||||
let mockAxiosRequest;
|
||||
let mockRequest;
|
||||
let mockCollectionRoot;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset crypto mock to return predictable values
|
||||
crypto.randomBytes.mockReturnValue(Buffer.from('1234567890abcdef', 'hex'));
|
||||
|
||||
// Setup default mock objects
|
||||
mockAxiosRequest = {
|
||||
headers: {}
|
||||
};
|
||||
|
||||
mockRequest = {
|
||||
auth: {
|
||||
mode: 'none'
|
||||
}
|
||||
};
|
||||
|
||||
mockCollectionRoot = {
|
||||
request: {
|
||||
auth: null
|
||||
}
|
||||
};
|
||||
|
||||
// Setup a more sophisticated mock for lodash get function
|
||||
mockGet.mockImplementation((obj, path, defaultValue) => {
|
||||
if (!obj) return defaultValue;
|
||||
|
||||
const keys = path.split('.');
|
||||
let current = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (current && typeof current === 'object' && key in current) {
|
||||
current = current[key];
|
||||
} else {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Collection-level authentication inheritance', () => {
|
||||
test('should inherit AWS v4 authentication from collection', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'awsv4',
|
||||
awsv4: {
|
||||
accessKeyId: 'test-access-key',
|
||||
secretAccessKey: 'test-secret-key',
|
||||
sessionToken: 'test-session-token',
|
||||
service: 's3',
|
||||
region: 'us-east-1',
|
||||
profileName: 'default'
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.awsv4config).toEqual({
|
||||
accessKeyId: 'test-access-key',
|
||||
secretAccessKey: 'test-secret-key',
|
||||
sessionToken: 'test-session-token',
|
||||
service: 's3',
|
||||
region: 'us-east-1',
|
||||
profileName: 'default'
|
||||
});
|
||||
});
|
||||
|
||||
test('should inherit basic authentication from collection', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'basic',
|
||||
basic: {
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.basicAuth).toEqual({
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
});
|
||||
});
|
||||
|
||||
test('should inherit bearer authentication from collection', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'bearer',
|
||||
bearer: {
|
||||
token: 'test-token'
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.headers['Authorization']).toBe('Bearer test-token');
|
||||
});
|
||||
|
||||
test('should inherit digest authentication from collection', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'digest',
|
||||
digest: {
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.digestConfig).toEqual({
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
});
|
||||
});
|
||||
|
||||
test('should inherit NTLM authentication from collection', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'ntlm',
|
||||
ntlm: {
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
domain: 'testdomain'
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.ntlmConfig).toEqual({
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
domain: 'testdomain'
|
||||
});
|
||||
});
|
||||
|
||||
test('should inherit WSSE authentication from collection', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'wsse',
|
||||
wsse: {
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.headers['X-WSSE']).toMatch(/UsernameToken Username="testuser", PasswordDigest="[^"]+", Nonce="1234567890abcdef", Created="[^"]+"/);
|
||||
});
|
||||
|
||||
test('should inherit API key authentication from collection (header placement)', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'apikey',
|
||||
apikey: {
|
||||
key: 'X-API-Key',
|
||||
value: 'test-api-key',
|
||||
placement: 'header'
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.headers['X-API-Key']).toBe('test-api-key');
|
||||
});
|
||||
|
||||
test('should inherit API key authentication from collection (query params placement)', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'apikey',
|
||||
apikey: {
|
||||
key: 'api_key',
|
||||
value: 'test-api-key',
|
||||
placement: 'queryparams'
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.apiKeyAuthValueForQueryParams).toEqual({
|
||||
key: 'api_key',
|
||||
value: 'test-api-key',
|
||||
placement: 'queryparams'
|
||||
});
|
||||
});
|
||||
|
||||
test('should skip API key authentication when key is empty', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'apikey',
|
||||
apikey: {
|
||||
key: '',
|
||||
value: 'test-api-key',
|
||||
placement: 'header'
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.headers['']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth2 authentication inheritance', () => {
|
||||
test('should inherit OAuth2 password grant from collection', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'password',
|
||||
accessTokenUrl: 'https://example.com/token',
|
||||
refreshTokenUrl: 'https://example.com/refresh',
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
clientId: 'test-client',
|
||||
clientSecret: 'test-secret',
|
||||
scope: 'read write',
|
||||
credentialsPlacement: 'body',
|
||||
credentialsId: 'test-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.oauth2).toEqual({
|
||||
grantType: 'password',
|
||||
accessTokenUrl: 'https://example.com/token',
|
||||
refreshTokenUrl: 'https://example.com/refresh',
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
clientId: 'test-client',
|
||||
clientSecret: 'test-secret',
|
||||
scope: 'read write',
|
||||
credentialsPlacement: 'body',
|
||||
credentialsId: 'test-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
});
|
||||
});
|
||||
|
||||
test('should inherit OAuth2 authorization_code grant from collection', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'authorization_code',
|
||||
callbackUrl: 'https://example.com/callback',
|
||||
authorizationUrl: 'https://example.com/auth',
|
||||
accessTokenUrl: 'https://example.com/token',
|
||||
refreshTokenUrl: 'https://example.com/refresh',
|
||||
clientId: 'test-client',
|
||||
clientSecret: 'test-secret',
|
||||
scope: 'read write',
|
||||
state: 'random-state',
|
||||
pkce: true,
|
||||
credentialsPlacement: 'body',
|
||||
credentialsId: 'test-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.oauth2).toEqual({
|
||||
grantType: 'authorization_code',
|
||||
callbackUrl: 'https://example.com/callback',
|
||||
authorizationUrl: 'https://example.com/auth',
|
||||
accessTokenUrl: 'https://example.com/token',
|
||||
refreshTokenUrl: 'https://example.com/refresh',
|
||||
clientId: 'test-client',
|
||||
scope: 'read write',
|
||||
state: 'random-state',
|
||||
pkce: true,
|
||||
credentialsPlacement: 'body',
|
||||
clientSecret: 'test-secret',
|
||||
credentialsId: 'test-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
});
|
||||
});
|
||||
|
||||
test('should inherit OAuth2 implicit grant from collection', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'implicit',
|
||||
callbackUrl: 'https://example.com/callback',
|
||||
authorizationUrl: 'https://example.com/auth',
|
||||
clientId: 'test-client',
|
||||
scope: 'read write',
|
||||
state: 'random-state',
|
||||
credentialsId: 'test-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.oauth2).toEqual({
|
||||
grantType: 'implicit',
|
||||
callbackUrl: 'https://example.com/callback',
|
||||
authorizationUrl: 'https://example.com/auth',
|
||||
clientId: 'test-client',
|
||||
scope: 'read write',
|
||||
state: 'random-state',
|
||||
credentialsId: 'test-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
});
|
||||
});
|
||||
|
||||
test('should inherit OAuth2 client_credentials grant from collection', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'client_credentials',
|
||||
accessTokenUrl: 'https://example.com/token',
|
||||
refreshTokenUrl: 'https://example.com/refresh',
|
||||
clientId: 'test-client',
|
||||
clientSecret: 'test-secret',
|
||||
scope: 'read write',
|
||||
credentialsPlacement: 'body',
|
||||
credentialsId: 'test-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.oauth2).toEqual({
|
||||
grantType: 'client_credentials',
|
||||
accessTokenUrl: 'https://example.com/token',
|
||||
refreshTokenUrl: 'https://example.com/refresh',
|
||||
clientId: 'test-client',
|
||||
clientSecret: 'test-secret',
|
||||
scope: 'read write',
|
||||
credentialsPlacement: 'body',
|
||||
credentialsId: 'test-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request-level authentication (overrides collection)', () => {
|
||||
test('should set AWS v4 authentication at request level', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'awsv4',
|
||||
awsv4: {
|
||||
accessKeyId: 'test-access-key',
|
||||
secretAccessKey: 'test-secret-key',
|
||||
sessionToken: 'test-session-token',
|
||||
service: 's3',
|
||||
region: 'us-east-1',
|
||||
profileName: 'default'
|
||||
}
|
||||
}
|
||||
mockRequest.auth = {
|
||||
mode: 'awsv4',
|
||||
awsv4: {
|
||||
accessKeyId: 'request-access-key',
|
||||
secretAccessKey: 'request-secret-key',
|
||||
sessionToken: 'request-session-token',
|
||||
service: 's3',
|
||||
region: 'us-west-2',
|
||||
profileName: 'production'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.awsv4config).toEqual({
|
||||
accessKeyId: 'request-access-key',
|
||||
secretAccessKey: 'request-secret-key',
|
||||
sessionToken: 'request-session-token',
|
||||
service: 's3',
|
||||
region: 'us-west-2',
|
||||
profileName: 'production'
|
||||
});
|
||||
});
|
||||
|
||||
test('should set basic authentication at request level', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'basic',
|
||||
basic: {
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'basic',
|
||||
basic: {
|
||||
username: 'requestuser',
|
||||
password: 'requestpass'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.basicAuth).toEqual({
|
||||
username: 'requestuser',
|
||||
password: 'requestpass'
|
||||
});
|
||||
});
|
||||
|
||||
test('should set bearer authentication at request level', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'bearer',
|
||||
bearer: {
|
||||
token: 'test-token'
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'bearer',
|
||||
bearer: {
|
||||
token: 'request-token'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.headers['Authorization']).toBe('Bearer request-token');
|
||||
});
|
||||
|
||||
test('should set digest authentication at request level', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'digest',
|
||||
digest: {
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'digest',
|
||||
digest: {
|
||||
username: 'requestuser',
|
||||
password: 'requestpass'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.digestConfig).toEqual({
|
||||
username: 'requestuser',
|
||||
password: 'requestpass'
|
||||
});
|
||||
});
|
||||
|
||||
test('should set NTLM authentication at request level', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'ntlm',
|
||||
ntlm: {
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
domain: 'testdomain'
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'ntlm',
|
||||
ntlm: {
|
||||
username: 'requestuser',
|
||||
password: 'requestpass',
|
||||
domain: 'requestdomain'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.ntlmConfig).toEqual({
|
||||
username: 'requestuser',
|
||||
password: 'requestpass',
|
||||
domain: 'requestdomain'
|
||||
});
|
||||
});
|
||||
|
||||
test('should set WSSE authentication at request level', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'wsse',
|
||||
wsse: {
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'wsse',
|
||||
wsse: {
|
||||
username: 'requestuser',
|
||||
password: 'requestpass'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.headers['X-WSSE']).toMatch(/UsernameToken Username="requestuser", PasswordDigest="[^"]+", Nonce="1234567890abcdef", Created="[^"]+"/);
|
||||
});
|
||||
|
||||
test('should set API key authentication at request level (header placement)', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'apikey',
|
||||
apikey: {
|
||||
key: 'X-Request-API-Key',
|
||||
value: 'test-api-key',
|
||||
placement: 'header'
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'apikey',
|
||||
apikey: {
|
||||
key: 'X-Request-API-Key',
|
||||
value: 'request-api-key',
|
||||
placement: 'header'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.headers['X-Request-API-Key']).toBe('request-api-key');
|
||||
});
|
||||
|
||||
test('should set API key authentication at request level (query params placement)', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'apikey',
|
||||
apikey: {
|
||||
key: 'X-Request-API-Key',
|
||||
value: 'test-api-key',
|
||||
placement: 'header'
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'apikey',
|
||||
apikey: {
|
||||
key: 'request_api_key',
|
||||
value: 'request-api-key',
|
||||
placement: 'queryparams'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.apiKeyAuthValueForQueryParams).toEqual({
|
||||
key: 'request_api_key',
|
||||
value: 'request-api-key',
|
||||
placement: 'queryparams'
|
||||
});
|
||||
});
|
||||
|
||||
test('should set OAuth2 password grant at request level', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'password',
|
||||
accessTokenUrl: 'https://collection.com/token',
|
||||
refreshTokenUrl: 'https://collection.com/refresh',
|
||||
username: 'collectionuser',
|
||||
password: 'collectionpass',
|
||||
clientId: 'collection-client',
|
||||
clientSecret: 'collection-secret',
|
||||
scope: 'read',
|
||||
credentialsPlacement: 'header',
|
||||
credentialsId: 'collection-credentials',
|
||||
tokenPlacement: 'query',
|
||||
tokenHeaderPrefix: 'Token',
|
||||
tokenQueryKey: 'token',
|
||||
autoFetchToken: false,
|
||||
autoRefreshToken: false,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'password',
|
||||
accessTokenUrl: 'https://request.com/token',
|
||||
refreshTokenUrl: 'https://request.com/refresh',
|
||||
username: 'requestuser',
|
||||
password: 'requestpass',
|
||||
clientId: 'request-client',
|
||||
clientSecret: 'request-secret',
|
||||
scope: 'read',
|
||||
credentialsPlacement: 'header',
|
||||
credentialsId: 'request-credentials',
|
||||
tokenPlacement: 'query',
|
||||
tokenHeaderPrefix: 'Token',
|
||||
tokenQueryKey: 'token',
|
||||
autoFetchToken: false,
|
||||
autoRefreshToken: false,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.oauth2).toEqual({
|
||||
grantType: 'password',
|
||||
accessTokenUrl: 'https://request.com/token',
|
||||
refreshTokenUrl: 'https://request.com/refresh',
|
||||
username: 'requestuser',
|
||||
password: 'requestpass',
|
||||
clientId: 'request-client',
|
||||
clientSecret: 'request-secret',
|
||||
scope: 'read',
|
||||
credentialsPlacement: 'header',
|
||||
credentialsId: 'request-credentials',
|
||||
tokenPlacement: 'query',
|
||||
tokenHeaderPrefix: 'Token',
|
||||
tokenQueryKey: 'token',
|
||||
autoFetchToken: false,
|
||||
autoRefreshToken: false,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
});
|
||||
});
|
||||
|
||||
test('should set OAuth2 authorization_code grant at request level', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'password',
|
||||
callbackUrl: 'https://collection.com/callback',
|
||||
authorizationUrl: 'https://collection.com/auth',
|
||||
accessTokenUrl: 'https://collection.com/token',
|
||||
refreshTokenUrl: 'https://collection.com/refresh',
|
||||
username: 'collectionuser',
|
||||
password: 'collectionpass',
|
||||
clientId: 'collection-client',
|
||||
clientSecret: 'collection-secret',
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'authorization_code',
|
||||
callbackUrl: 'https://request.com/callback',
|
||||
authorizationUrl: 'https://request.com/auth',
|
||||
accessTokenUrl: 'https://request.com/token',
|
||||
refreshTokenUrl: 'https://request.com/refresh',
|
||||
clientId: 'request-client',
|
||||
clientSecret: 'request-secret',
|
||||
scope: 'read',
|
||||
state: 'request-state',
|
||||
pkce: false,
|
||||
credentialsPlacement: 'body',
|
||||
credentialsId: 'request-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.oauth2).toEqual({
|
||||
grantType: 'authorization_code',
|
||||
callbackUrl: 'https://request.com/callback',
|
||||
authorizationUrl: 'https://request.com/auth',
|
||||
accessTokenUrl: 'https://request.com/token',
|
||||
refreshTokenUrl: 'https://request.com/refresh',
|
||||
clientId: 'request-client',
|
||||
clientSecret: 'request-secret',
|
||||
scope: 'read',
|
||||
state: 'request-state',
|
||||
pkce: false,
|
||||
credentialsPlacement: 'body',
|
||||
credentialsId: 'request-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
});
|
||||
});
|
||||
|
||||
test('should set OAuth2 implicit grant at request level', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'implicit',
|
||||
callbackUrl: 'https://collection.com/callback',
|
||||
authorizationUrl: 'https://collection.com/auth',
|
||||
clientId: 'collection-client',
|
||||
scope: 'read',
|
||||
state: 'collection-state',
|
||||
credentialsId: 'collection-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'implicit',
|
||||
callbackUrl: 'https://request.com/callback',
|
||||
authorizationUrl: 'https://request.com/auth',
|
||||
clientId: 'request-client',
|
||||
scope: 'read',
|
||||
state: 'request-state',
|
||||
credentialsId: 'request-credentials',
|
||||
tokenPlacement: 'query',
|
||||
tokenHeaderPrefix: 'Token',
|
||||
tokenQueryKey: 'token',
|
||||
autoFetchToken: false,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.oauth2).toEqual({
|
||||
grantType: 'implicit',
|
||||
callbackUrl: 'https://request.com/callback',
|
||||
authorizationUrl: 'https://request.com/auth',
|
||||
clientId: 'request-client',
|
||||
credentialsId: 'request-credentials',
|
||||
scope: 'read',
|
||||
state: 'request-state',
|
||||
tokenPlacement: 'query',
|
||||
tokenHeaderPrefix: 'Token',
|
||||
tokenQueryKey: 'token',
|
||||
autoFetchToken: false,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
});
|
||||
});
|
||||
|
||||
test('should set OAuth2 client_credentials grant at request level', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'client_credentials',
|
||||
accessTokenUrl: 'https://collection.com/token',
|
||||
refreshTokenUrl: 'https://collection.com/refresh',
|
||||
clientId: 'collection-client',
|
||||
clientSecret: 'collection-secret',
|
||||
scope: 'read',
|
||||
credentialsPlacement: 'body',
|
||||
credentialsId: 'collection-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'client_credentials',
|
||||
accessTokenUrl: 'https://request.com/token',
|
||||
refreshTokenUrl: 'https://request.com/refresh',
|
||||
clientId: 'request-client',
|
||||
clientSecret: 'request-secret',
|
||||
scope: 'read',
|
||||
credentialsPlacement: 'body',
|
||||
credentialsId: 'request-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.oauth2).toEqual({
|
||||
grantType: 'client_credentials',
|
||||
accessTokenUrl: 'https://request.com/token',
|
||||
refreshTokenUrl: 'https://request.com/refresh',
|
||||
clientId: 'request-client',
|
||||
clientSecret: 'request-secret',
|
||||
scope: 'read',
|
||||
credentialsPlacement: 'body',
|
||||
credentialsId: 'request-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases and error handling', () => {
|
||||
test('should handle missing collection auth gracefully', () => {
|
||||
mockCollectionRoot.request.auth = null;
|
||||
mockRequest.auth = {
|
||||
mode: 'basic',
|
||||
basic: {
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.basicAuth).toEqual({
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle missing request auth gracefully', () => {
|
||||
mockRequest.auth = null;
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result).toBe(mockAxiosRequest);
|
||||
expect(result.headers).toEqual({});
|
||||
});
|
||||
|
||||
test('should handle missing auth mode gracefully', () => {
|
||||
mockRequest.auth = {};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result).toBe(mockAxiosRequest);
|
||||
expect(result.headers).toEqual({});
|
||||
});
|
||||
|
||||
test('should handle unknown auth mode gracefully', () => {
|
||||
mockRequest.auth = {
|
||||
mode: 'unknown'
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result).toBe(mockAxiosRequest);
|
||||
expect(result.headers).toEqual({});
|
||||
});
|
||||
|
||||
test('should handle missing OAuth2 grant type gracefully', () => {
|
||||
mockRequest.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result).toBe(mockAxiosRequest);
|
||||
expect(result.oauth2).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should handle unknown OAuth2 grant type gracefully', () => {
|
||||
mockRequest.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'unknown_grant'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result).toBe(mockAxiosRequest);
|
||||
expect(result.oauth2).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should return the modified axiosRequest object', () => {
|
||||
mockRequest.auth = {
|
||||
mode: 'bearer',
|
||||
bearer: {
|
||||
token: 'test-token'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result).toBe(mockAxiosRequest);
|
||||
expect(result.headers['Authorization']).toBe('Bearer test-token');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,8 +44,11 @@ export const bruRequestToJson = (data: string | any, parsed: boolean = false): a
|
||||
settings: _.get(json, 'settings', {}),
|
||||
tags: _.get(json, 'meta.tags', []),
|
||||
request: {
|
||||
// Preserving special characters in custom methods. Using _.upperCase strips special characters.
|
||||
method:
|
||||
requestType === 'grpc-request' ? _.get(json, 'grpc.method', '') : _.upperCase(_.get(json, 'http.method')),
|
||||
requestType === 'grpc-request'
|
||||
? _.get(json, 'grpc.method', '')
|
||||
: String(_.get(json, 'http.method') ?? '').toUpperCase(),
|
||||
url: _.get(json, urlPath[requestType], urlPath.default),
|
||||
headers: requestType === 'grpc-request' ? _.get(json, 'metadata', []) : _.get(json, 'headers', []),
|
||||
auth: _.get(json, 'auth', {}),
|
||||
@@ -143,7 +146,8 @@ export const jsonRequestToBru = (json: any): string => {
|
||||
// For HTTP and GraphQL requests, maintain the current structure
|
||||
if (type === 'http' || type === 'graphql') {
|
||||
bruJson.http = {
|
||||
method: _.lowerCase(_.get(json, 'request.method')),
|
||||
// Preserve special characters in custom request methods. Avoid _.lowerCase which strips symbols.
|
||||
method: String(_.get(json, 'request.method') ?? '').toLowerCase(),
|
||||
url: _.get(json, 'request.url'),
|
||||
auth: _.get(json, 'request.auth.mode', 'none'),
|
||||
body: _.get(json, 'request.body.mode', 'none')
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -80,10 +86,9 @@ const grammar = ohm.grammar(`Bru {
|
||||
meta = "meta" dictionary
|
||||
settings = "settings" dictionary
|
||||
|
||||
http = get | post | put | delete | patch | options | head | connect | trace
|
||||
http = get | post | put | delete | patch | options | head | connect | trace | httpcustom
|
||||
grpc = "grpc" dictionary
|
||||
ws = "ws" dictionary
|
||||
|
||||
get = "get" dictionary
|
||||
post = "post" dictionary
|
||||
put = "put" dictionary
|
||||
@@ -93,6 +98,7 @@ const grammar = ohm.grammar(`Bru {
|
||||
head = "head" dictionary
|
||||
connect = "connect" dictionary
|
||||
trace = "trace" dictionary
|
||||
httpcustom = "http" dictionary
|
||||
|
||||
|
||||
headers = "headers" dictionary
|
||||
@@ -302,6 +308,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() : '';
|
||||
},
|
||||
@@ -365,6 +379,9 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
tagend(_1, _2) {
|
||||
return '';
|
||||
},
|
||||
_terminal() {
|
||||
return this.sourceString;
|
||||
},
|
||||
multilinetextblockdelimiter(_) {
|
||||
return '';
|
||||
},
|
||||
@@ -473,6 +490,26 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
}
|
||||
};
|
||||
},
|
||||
trace(_1, dictionary) {
|
||||
return {
|
||||
http: {
|
||||
method: 'trace',
|
||||
...mapPairListToKeyValPair(dictionary.ast)
|
||||
}
|
||||
};
|
||||
},
|
||||
httpcustom(_1, dictionary) {
|
||||
const dict = mapPairListToKeyValPair(dictionary.ast);
|
||||
const method = dict.method;
|
||||
const rest = { ...dict };
|
||||
delete rest.method;
|
||||
return {
|
||||
http: {
|
||||
method,
|
||||
...rest
|
||||
}
|
||||
};
|
||||
},
|
||||
query(_1, dictionary) {
|
||||
return {
|
||||
params: mapRequestParams(dictionary.ast, 'query')
|
||||
|
||||
@@ -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) => {
|
||||
@@ -71,24 +75,24 @@ const jsonToBru = (json) => {
|
||||
bru += '}\n\n';
|
||||
}
|
||||
|
||||
if (http && http.method) {
|
||||
bru += `${http.method} {
|
||||
url: ${http.url}`;
|
||||
if (http?.method) {
|
||||
const { method, url, body, auth } = http;
|
||||
const standardMethods = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace', 'connect']);
|
||||
|
||||
if (http.body && http.body.length) {
|
||||
bru += `
|
||||
body: ${http.body}`;
|
||||
const isStandard = standardMethods.has(method);
|
||||
|
||||
bru += isStandard ? `${method} {` : `http {\n method: ${method}`;
|
||||
bru += `\n url: ${url}`;
|
||||
|
||||
if (body?.length) {
|
||||
bru += `\n body: ${body}`;
|
||||
}
|
||||
|
||||
if (http.auth && http.auth.length) {
|
||||
bru += `
|
||||
auth: ${http.auth}`;
|
||||
if (auth?.length) {
|
||||
bru += `\n auth: ${auth}`;
|
||||
}
|
||||
|
||||
bru += `
|
||||
}
|
||||
|
||||
`;
|
||||
bru += `\n}\n\n`;
|
||||
}
|
||||
|
||||
if (grpc && grpc.url) {
|
||||
@@ -165,7 +169,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')
|
||||
)}`;
|
||||
}
|
||||
@@ -173,7 +177,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')
|
||||
)}`;
|
||||
}
|
||||
@@ -195,7 +199,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')
|
||||
)}`;
|
||||
}
|
||||
@@ -203,7 +207,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')
|
||||
)}`;
|
||||
}
|
||||
@@ -559,14 +563,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`;
|
||||
}
|
||||
@@ -587,7 +591,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') {
|
||||
@@ -595,7 +599,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')
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const bruToJson = require('../../src/bruToJson');
|
||||
const jsonToBru = require('../../src/jsonToBru');
|
||||
|
||||
describe('Custom Method Conversion Tests', () => {
|
||||
const fixturesDir = path.join(__dirname, 'fixtures');
|
||||
|
||||
describe('parse (BRU to JSON)', () => {
|
||||
it('should parse FETCH custom method from BRU to JSON', () => {
|
||||
const input = fs.readFileSync(path.join(fixturesDir, 'custom-method.bru'), 'utf8');
|
||||
const expected = require(path.join(fixturesDir, 'custom-method.json'));
|
||||
const output = bruToJson(input);
|
||||
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should parse X-CUSTOM method from BRU to JSON', () => {
|
||||
const input = fs.readFileSync(path.join(fixturesDir, 'custom-method-x-custom.bru'), 'utf8');
|
||||
const expected = require(path.join(fixturesDir, 'custom-method-x-custom.json'));
|
||||
const output = bruToJson(input);
|
||||
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should parse custom method with special characters from BRU to JSON', () => {
|
||||
const input = fs.readFileSync(path.join(fixturesDir, 'custom-method-with-special-chars.bru'), 'utf8');
|
||||
const expected = require(path.join(fixturesDir, 'custom-method-with-special-chars.json'));
|
||||
const output = bruToJson(input);
|
||||
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringify (JSON to BRU)', () => {
|
||||
it('should stringify FETCH custom method from JSON to BRU', () => {
|
||||
const input = require(path.join(fixturesDir, 'custom-method.json'));
|
||||
const expected = fs.readFileSync(path.join(fixturesDir, 'custom-method.bru'), 'utf8');
|
||||
const output = jsonToBru(input);
|
||||
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should stringify X-CUSTOM method from JSON to BRU', () => {
|
||||
const input = require(path.join(fixturesDir, 'custom-method-x-custom.json'));
|
||||
const expected = fs.readFileSync(path.join(fixturesDir, 'custom-method-x-custom.bru'), 'utf8');
|
||||
const output = jsonToBru(input);
|
||||
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should stringify custom method with special characters from JSON to BRU', () => {
|
||||
const input = require(path.join(fixturesDir, 'custom-method-with-special-chars.json'));
|
||||
const expected = fs.readFileSync(path.join(fixturesDir, 'custom-method-with-special-chars.bru'), 'utf8');
|
||||
const output = jsonToBru(input);
|
||||
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
meta {
|
||||
name: Custom Method with Special Characters
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
http {
|
||||
method: CUSTOM@METHOD
|
||||
url: https://api.example.com/special-method
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Custom Method with Special Characters",
|
||||
"type": "http",
|
||||
"seq": "3"
|
||||
},
|
||||
"http": {
|
||||
"method": "CUSTOM@METHOD",
|
||||
"url": "https://api.example.com/special-method"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
meta {
|
||||
name: Custom Method X-CUSTOM
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
http {
|
||||
method: X-CUSTOM
|
||||
url: https://api.example.com/x-custom
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Custom Method X-CUSTOM",
|
||||
"type": "http",
|
||||
"seq": "2"
|
||||
},
|
||||
"http": {
|
||||
"method": "X-CUSTOM",
|
||||
"url": "https://api.example.com/x-custom"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
meta {
|
||||
name: Custom Method FETCH
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
http {
|
||||
method: FETCH
|
||||
url: https://api.example.com/custom
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Custom Method FETCH",
|
||||
"type": "http",
|
||||
"seq": "1"
|
||||
},
|
||||
"http": {
|
||||
"method": "FETCH",
|
||||
"url": "https://api.example.com/custom"
|
||||
}
|
||||
}
|
||||
@@ -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" : [
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -48,7 +48,7 @@ const varsSchema = Yup.object({
|
||||
|
||||
const requestUrlSchema = Yup.string().min(0).defined();
|
||||
const requestMethodSchema = Yup.string()
|
||||
.oneOf(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE'])
|
||||
.min(1, 'method is required')
|
||||
.required('method is required');
|
||||
|
||||
const graphqlBodySchema = Yup.object({
|
||||
|
||||
@@ -18,10 +18,10 @@ describe('Request Schema Validation', () => {
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
it('request schema must throw an error of method is invalid', async () => {
|
||||
it('request schema must validate successfully - custom method', async () => {
|
||||
const request = {
|
||||
url: 'https://restcountries.com/v2/alpha/in',
|
||||
method: 'GET-junk',
|
||||
method: 'FOO',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: {
|
||||
@@ -29,12 +29,51 @@ describe('Request Schema Validation', () => {
|
||||
}
|
||||
};
|
||||
|
||||
return Promise.all([
|
||||
expect(requestSchema.validate(request)).rejects.toEqual(
|
||||
validationErrorWithMessages(
|
||||
'method must be one of the following values: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, TRACE'
|
||||
)
|
||||
)
|
||||
]);
|
||||
const isValid = await requestSchema.validate(request);
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
it('request schema must validate successfully - custom method with dash', async () => {
|
||||
const request = {
|
||||
url: 'https://restcountries.com/v2/alpha/in',
|
||||
method: 'X-CUSTOM',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: {
|
||||
mode: 'none'
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = await requestSchema.validate(request);
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
it('request schema must throw an error if method is empty', async () => {
|
||||
const request = {
|
||||
url: 'https://restcountries.com/v2/alpha/in',
|
||||
method: '',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: {
|
||||
mode: 'none'
|
||||
}
|
||||
};
|
||||
|
||||
await expect(requestSchema.validate(request)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('request schema must validate successfully - method with space is allowed now', async () => {
|
||||
const request = {
|
||||
url: 'https://restcountries.com/v2/alpha/in',
|
||||
method: 'GET JUNK',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: {
|
||||
mode: 'none'
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = await requestSchema.validate(request);
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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