HTTP stream enhancements (#6077)

* feat: add stop request button in api url bar

* docs: add farsi translation

* fix: handle escaped forward slashes by fast-json-format library upgrade

* refactor: change ui to use one from Websockets

* chore: cleanup

* fix: lint issues

* Replace IconPlayerStop with IconSquareRoundedX

* update json request and response formatting logic

* chore: format changes

* chore: remove un-needed diffs

* chore: sanitize

* bugfix(#5939): curl import fails for custom content-types

* chore: remove un-needed diffs

* chore: enhance response handling for streaming

* fix: disable requestid check for tests and assertions to be updated after streaming result

* chore: housekeeping

* fix: streamline loading and cancel request icon logic

* chore: formatting

* fix: multiple co-pilot changes

* fix: handle in folders

* feat: add WaitGroup utility for managing concurrent tasks

* refactor: remove WaitGroup utility and clean up network IPC logic

* refactor: remove unused setTimeout import and clean up post script execution

* refactor: clean up post-response script execution logic

* undiff

* re-align

* refactor: streamline post-response script execution

- Cleaned up formatting and improved readability of the post-response script execution logic.
- Consolidated parameters in function calls for consistency.

* fix: keep original dataBuffer for saving response

---------

Co-authored-by: adarshajit <adarshajit@gmail.com>
Co-authored-by: sajadoncode <sajadoncode@gmail.com>
Co-authored-by: lohit-bruno <lohit@usebruno.com>
Co-authored-by: Bijin A B <bijin@usebruno.com>
Co-authored-by: Pragadesh-45 <temporaryg7904@gmail.com>
Co-authored-by: Anoop M D <anoop@usebruno.com>
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
Co-authored-by: Dawid Góra <dawidgora@icloud.com>
This commit is contained in:
Siddharth Gelera (reaper)
2025-11-14 16:57:29 +05:30
committed by GitHub
parent fc5093eab4
commit efad149afc
27 changed files with 643 additions and 241 deletions

View File

@@ -16,6 +16,7 @@
| [日本語](docs/contributing/contributing_ja.md)
| [हिंदी](docs/contributing/contributing_hi.md)
| [Dutch](docs/contributing/contributing_nl.md)
| [فارسی](docs/contributing/contributing_fa.md)
## Let's make Bruno better, together!!
@@ -74,6 +75,7 @@ npm run build:bruno-filestore
# bundle js sandbox libraries
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
```
##### Option 2
```bash
@@ -94,18 +96,22 @@ npm run dev:electron
```
##### Option 2
```bash
# run electron and react app concurrently
npm run dev
```
#### Customize Electron `userData` path
If `ELECTRON_USER_DATA_PATH` env-variable is present and its development mode, then `userData` path is modified accordingly.
e.g.
```sh
ELECTRON_USER_DATA_PATH=$(realpath ~/Desktop/bruno-test) npm run dev:electron
```
This will create a `bruno-test` folder on your Desktop and use it as the `userData` path.
### Troubleshooting

View File

@@ -0,0 +1,92 @@
[English](../../contributing.md)
## با هم، Bruno را بهتر می‌کنیم!
خوشحالم که قصد دارید Bruno را بهبود ببخشید. در ادامه قوانین و راهنماها برای راه‌اندازی Bruno روی سیستم شما آورده شده است.
### فناوری‌های استفاده‌شده
به فارسی برونو Bruno با استفاده از Next.js و React ساخته شده است. همچنین از Electron برای بسته‌بندی نسخه دسکتاپ (که امکان مجموعه‌های محلی را فراهم می‌کند) استفاده می‌کنیم.
کتابخانه‌هایی که استفاده می‌کنیم:
- CSS - Tailwind استایل
- Codemirror - ویرایشگر کد
- Redux - مدیریت وضعیت
- Tabler Icons - آیکون‌ها
- formik - فرم‌ها
- Yup اعتبارسنجی اسکیمـا
- axios - کلاینت درخواست
- chokidar - پایش‌گر سیستم فایل
### پیش‌نیازها
شما به [نود v20.x یا اخرین نسخه پایدار](https://nodejs.org/en/) و npm 8.x نیاز دارید. در این پروژه از فضای کاری npm (npm workspaces) استفاده می‌کنیم.
### شروع به کدنویسی
برای راه‌اندازی محیط توسعه محلی به فایل [مستندات توسعه](docs/development_fa.md) مراجعه کنید:
### ارسال Pull Request
1 - لطفاً Pull Requestها (PR) را کوتاه و متمرکز نگه دارید و تنها یک هدف مشخص را دنبال کنند. </br>
2 - لطفاً از فرمت نام‌گذاری شاخه‌ها استفاده کنید:
- feature/[name]: این شاخه باید شامل یک قابلیت مشخص باشد.
- feature/dark-mode : مثال
- bugfix/[name]: این شاخه باید تنها شامل رفع یک باگ مشخص باشد.
- bugfix/bug-1 : مثال
## توسعه
به فارسی برونو یا Bruno به‌صورت یک اپلیکیشن «سنگین» توسعه داده می‌شود. برای اجرا باید ابتدا Next.js را در یک پنجره ترمینال اجرا کنید و سپس اپلیکیشن Electron را در پنجره ترمینال دیگری راه‌اندازی نمایید.
### نیازمندی توسعه
- NodeJS v18
### اجرای محلی
```bash
# از ورژن NodeJS 18 استفاده کنید
nvm use
# نصب وابستگی‌ها
npm i --legacy-peer-deps
# ساخت مستندات GraphQL
npm run build:graphql-docs
# ساخت bruno-query
npm run build:bruno-query
# اجرای اپ Next (ترمینال 1)
npm run dev:web
# اجرای اپ Electron (ترمینال 2)
npm run dev:electron
```
### عیب‌یابی
ممکن است هنگام اجرای `npm install` خطای `Unsupported platform` ببینید. برای رفع این مشکل، پوشه `node_modules` و فایل `package-lock.json` را حذف کرده و سپس دوباره `npm install` را اجرا کنید. این کار معمولاً همه پکیج‌های لازم را نصب می‌کند.
```shell
# حذف پوشه node_modules در زیردایرکتوری‌ها
find ./ -type d -name "node_modules" -print0 | while read -d $'\0' dir; do
rm -rf "$dir"
done
# حذف فایل package-lock.json در زیردایرکتوری‌ها
find . -type f -name "package-lock.json" -delete
```
### تست‌ها
```bash
# اجرای تست‌های schema مربوط به bruno
npm test --workspace=packages/bruno-schema
# اجرای تست‌ها در همه فضاهای کاری (در صورت وجود)
npm test --workspaces --if-present
```

View File

@@ -0,0 +1,8 @@
[English](../../publishing.md)
### انتشار Bruno در یک پکیج منیجر جدید
اگرچه کد ما متن‌باز است و همه می‌توانند از آن استفاده کنند، لطفاً قبل از انتشار Bruno در مدیر بسته‌های جدید با ما تماس بگیرید. به عنوان سازنده Bruno، علامت تجاری `Bruno` را برای این پروژه دارم و مایلم توزیع آن را مدیریت کنم. اگر دوست دارید Bruno را در یک مدیر بسته جدید ببینید، لطفاً یک issue در گیت‌هاب ثبت کنید.
اگرچه بیشتر قابلیت‌های ما رایگان و متن‌باز هستند (شامل REST و GraphQL Apis)،
ما تلاش می‌کنیم بین اصول متن‌باز و توسعه پایدار تعادل مناسبی برقرار کنیم - https://github.com/usebruno/bruno/discussions/269

143
docs/readme/readme_fa.md Normal file
View File

@@ -0,0 +1,143 @@
<br />
<img src="../../assets/images/logo-transparent.png" width="80"/>
### برونو یا Bruno - محیط توسعه متن باز برای تست و توسعه API ها
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)
[![Website](https://img.shields.io/badge/Website-Visit-blue)](https://www.usebruno.com)
[![Download](https://img.shields.io/badge/Download-Latest-brightgreen)](https://www.usebruno.com/downloads)
[English](../../readme.md)
| [Українська](./readme_ua.md)
| [Русский](./readme_ru.md)
| [Türkçe](./readme_tr.md)
| [Deutsch](./readme_de.md)
| [Français](./readme_fr.md)
| [Português (BR)](./readme_pt_br.md)
| [한국어](./readme_kr.md)
| [বাংলা](./readme_bn.md)
| [Español](./readme_es.md)
| **فارسی**
| [Română](./readme_ro.md)
| [Polski](./readme_pl.md)
| [简体中文](./readme_cn.md)
| [正體中文](./readme_zhtw.md)
| [العربية](./readme_ar.md)
| [日本語](./readme_ja.md)
| [ქართული](./readme_ka.md)
برونو یک کلاینت API جدید و نوآورانه است که هدفش تغییر وضعیت فعلی ابزارهایی مانند Postman و سایر ابزارهای مشابه است.
برونو مجموعه‌های شما را مستقیماً در یک پوشه روی فایل‌سیستم شما ذخیره می‌کند. ما از یک زبان نشانه‌گذاری ساده به نام Bru برای ذخیره اطلاعات درخواست‌های API استفاده می‌کنیم.
شما می‌توانید برای همکاری روی مجموعه‌های API خود، از Git یا هر سیستم کنترل نسخه دلخواهتان استفاده کنید.
برونو فقط به صورت آفلاین کار می‌کند. هیچ برنامه‌ای برای اضافه کردن همگام‌سازی ابری به برونو در آینده وجود ندارد. ما به حریم خصوصی داده‌های شما اهمیت می‌دهیم و معتقدیم که باید روی دستگاه خودتان باقی بمانند. می‌توانید چشم‌انداز بلندمدت ما را مطالعه کنید. [اینجا (به انگلیسی)](https://github.com/usebruno/bruno/discussions/269)
📢 جدیدترین ارائه ما را در کنفرانس India FOSS 3.0 تماشا کنید.
[اینجا](https://www.youtube.com/watch?v=7bSMFpbcPiY)
![bruno](/assets/images/landing-2.png) <br /><br />
### نصب
برونو به صورت یک فایل باینری برای دانلود در دسترس است. [بر روی وبسایت ما](https://www.usebruno.com/downloads) برای مک لینکوس و ویندوز.
همچنین می‌توانید برونو را از طریق مدیر بسته‌هایی مانند Homebrew، Chocolatey، Snap و Apt نصب کنید.
```sh
# بر روی مک از طریق brew
brew install bruno
# بر روی ویندوز از طریق Chocolatey
choco install bruno
# بر روی لینوکس از طریق Snap
snap install bruno
# بر روی لینوکس از طریق Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
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
```
### روی پلتفرم‌های مختلف کار می‌کند 🖥️
![bruno](/assets/images/run-anywhere.png) <br /><br />
### همکاری از طریق گیت 👩‍💻🧑‍💻
یا هر سیستم کنترل نسخه‌ای که ترجیح می‌دهید
![bruno](/assets/images/version-control.png) <br /><br />
### لینک‌های مهم 📌
- [آخرین نسخه پایدار ما](https://github.com/usebruno/bruno/discussions/269)
- [نقشه راه](https://github.com/usebruno/bruno/discussions/384)
- [مستندات](https://docs.usebruno.com)
- [وبسایت](https://www.usebruno.com)
- [اشتراک ها](https://www.usebruno.com/pricing)
- [دانلود](https://www.usebruno.com/downloads)
### ویدیوها 🎥
- [تجربه ها](https://github.com/usebruno/bruno/discussions/343)
- [مرکز دانش](https://github.com/usebruno/bruno/discussions/386)
- [اسکریپ مانیا](https://github.com/usebruno/bruno/discussions/385)
### حمایت ❤️
جوون! اگر این پروژه را دوست دارید، روی دکمه ⭐ کلیک کنید!
### تجربه‌های به اشتراک گذاشته‌شده 📣
اگر برونو به شما یا تیمتان کمک کرده است، لطفاً فراموش نکنید تجربه‌های خود را به اشتراک بگذارید. [تجربه‌های خود را در بحث گیت‌هاب ما به اشتراک بگذارید](https://github.com/usebruno/bruno/discussions/343).
### انتشار برونو در یک پکیچ منیجر جدید
لطفا چک بکنید [اینجارو](../../publishing.md) برای اطلاعات بیشتر.
### مشارکت 👩‍💻🧑‍💻
خوشحالم که می‌خواهید برونو را بهتر کنید. لطفا [راهنمای مشارکت را بررسی کنید](../contributing/contributing_fa.md).
حتی اگر نمی‌توانید از طریق کدنویسی مشارکت کنید، در گزارش باگ‌ها و درخواست قابلیت‌های جدید که به حل نیازهای شما کمک می‌کند تردید نکنید.
### نویسنده ها
<div align="center">
<a href="https://github.com/usebruno/bruno/graphs/contributors">
<img src="https://contrib.rocks/image?repo=usebruno/bruno" />
</a>
</div>
### در ارتباط باشید 🌐
[𝕏 (تویتر)](https://twitter.com/use_bruno) <br />
[وبسایت](https://www.usebruno.com) <br />
[دیسکورد](https://discord.com/invite/KgcZUncpjq) <br />
[لینکدین](https://www.linkedin.com/company/usebruno)
### برند
**نام**
به فارسی برونو - `Bruno` یک علامت تجاری ثبت‌شده متعلق به [Anoop M D](https://www.helloanoop.com/)
**لوگو**
لوگو توسط [OpenMoji](https://openmoji.org/library/emoji-1F436/) ساخته شده است. مجوز: CC [BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)
### مجوز 📄
[MIT](../../license.md)

9
package-lock.json generated
View File

@@ -14235,9 +14235,9 @@
}
},
"node_modules/fast-json-format": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/fast-json-format/-/fast-json-format-0.3.0.tgz",
"integrity": "sha512-B95psGYXJ5XItmxLR6JFcQRQafDyfy8ecHiV/jWCJF9oCIA9/o+wt89cGW61D04xf07yCpIaevvCQbgeJ9w8lQ==",
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/fast-json-format/-/fast-json-format-0.4.0.tgz",
"integrity": "sha512-HEomBtr2fYaVX3iaRdcVLU7Qd3SQhCYvXlMMM9RNaihfIaj5bIC7ADqw/bAPSg/uyX6FIBPq69ioXq0B4Cb6eA==",
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
@@ -26874,7 +26874,7 @@
"dompurify": "^3.2.4",
"escape-html": "^1.0.3",
"fast-fuzzy": "^1.12.0",
"fast-json-format": "~0.3.0",
"fast-json-format": "~0.4.0",
"file": "^0.2.2",
"file-dialog": "^0.0.8",
"file-saver": "^2.0.5",
@@ -26883,6 +26883,7 @@
"graphiql": "3.7.1",
"graphql": "^16.6.0",
"graphql-request": "^3.7.0",
"hexy": "^0.3.5",
"httpsnippet": "^3.0.9",
"i18next": "24.1.2",
"idb": "^7.0.0",

View File

@@ -28,7 +28,7 @@
"dompurify": "^3.2.4",
"escape-html": "^1.0.3",
"fast-fuzzy": "^1.12.0",
"fast-json-format": "~0.3.0",
"fast-json-format": "~0.4.0",
"file": "^0.2.2",
"file-dialog": "^0.0.8",
"file-saver": "^2.0.5",
@@ -37,6 +37,7 @@
"graphiql": "3.7.1",
"graphql": "^16.6.0",
"graphql-request": "^3.7.0",
"hexy": "^0.3.5",
"httpsnippet": "^3.0.9",
"i18next": "24.1.2",
"idb": "^7.0.0",

View File

@@ -6,9 +6,9 @@ import { updateRequestGraphqlVariables } from 'providers/ReduxStore/slices/colle
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import { format, applyEdits } from 'jsonc-parser';
import { IconWand } from '@tabler/icons';
import toast from 'react-hot-toast';
import { prettifyJsonString } from 'utils/common/index';
const GraphQLVariables = ({ variables, item, collection }) => {
const dispatch = useDispatch();
@@ -19,8 +19,7 @@ const GraphQLVariables = ({ variables, item, collection }) => {
const onPrettify = () => {
if (!variables) return;
try {
const edits = format(variables, undefined, { tabSize: 2, insertSpaces: true });
const prettyVariables = applyEdits(variables, edits);
const prettyVariables = prettifyJsonString(variables);
dispatch(
updateRequestGraphqlVariables({
variables: prettyVariables,

View File

@@ -12,9 +12,9 @@ import StyledWrapper from './StyledWrapper';
import { IconSend, IconRefresh, IconWand, IconPlus, IconTrash, IconChevronDown, IconChevronUp } from '@tabler/icons';
import ToolHint from 'components/ToolHint/index';
import { toastError } from 'utils/common/error';
import { format, applyEdits } from 'jsonc-parser';
import toast from 'react-hot-toast'
import { getAbsoluteFilePath } from 'utils/common/path';
import { prettifyJsonString } from 'utils/common/index';
const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCollapsed, onToggleCollapse, handleRun, canClientSendMultipleMessages }) => {
const dispatch = useDispatch();
@@ -130,8 +130,7 @@ const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCol
const onPrettify = () => {
try {
const edits = format(content, undefined, { tabSize: 2, insertSpaces: true });
const prettyBodyJson = applyEdits(content, edits);
const prettyBodyJson = prettifyJsonString(content);
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {

View File

@@ -2,10 +2,10 @@ import React, { useState, useEffect, useRef, useMemo } from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import { requestUrlChanged, updateRequestMethod } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { cancelRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import HttpMethodSelector from './HttpMethodSelector';
import { useTheme } from 'providers/Theme';
import { IconDeviceFloppy, IconArrowRight, IconCode, IconX } from '@tabler/icons';
import { IconDeviceFloppy, IconArrowRight, IconCode, IconSquareRoundedX } from '@tabler/icons';
import SingleLineEditor from 'components/SingleLineEditor';
import { isMacOS } from 'utils/common/platform';
import { hasRequestChanges } from 'utils/collections';
@@ -22,6 +22,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
const saveShortcut = isMac ? 'Cmd + S' : 'Ctrl + S';
const editorRef = useRef(null);
const isGrpc = item.type === 'grpc-request';
const isLoading = ['queued', 'sending'].includes(item.requestState);
const [methodSelectorWidth, setMethodSelectorWidth] = useState(90);
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
@@ -80,6 +81,10 @@ const QueryUrl = ({ item, collection, handleRun }) => {
}
};
const handleCancelRequest = () => {
dispatch(cancelRequest(item.cancelTokenUid, item, collection));
};
return (
<StyledWrapper className="flex items-center">
<div className="flex flex-1 items-center h-full method-selector-container">
@@ -87,7 +92,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
<div className="flex items-center justify-center h-full w-16">
<span className="text-xs text-indigo-500 font-bold">gRPC</span>
</div>
) : (
<HttpMethodSelector method={method} onMethodSelect={onMethodSelect} />
)}
@@ -149,11 +154,23 @@ const QueryUrl = ({ item, collection, handleRun }) => {
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
{
item.response?.hasStreamRunning ? (
<IconX color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={22} />
isLoading || item.response?.stream?.running ? (
<IconSquareRoundedX
color={theme.requestTabPanel.url.icon}
strokeWidth={1.5}
size={22}
data-testid="cancel-request-icon"
onClick={handleCancelRequest}
/>
) : (
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={22} data-testid="send-arrow-icon" />
<IconArrowRight
color={theme.requestTabPanel.url.icon}
strokeWidth={1.5}
size={22}
data-testid="send-arrow-icon"
/>
)
}
</div>

View File

@@ -74,7 +74,8 @@ const RequestTabPanel = () => {
const screenWidth = useSelector((state) => state.app.screenWidth);
let asideWidth = useSelector((state) => state.app.leftSidebarWidth);
const [leftPaneWidth, setLeftPaneWidth] = useState(
focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / 2.2); // 2.2 is intentional to make both panes appear to be of equal width
focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / 2.2
); // 2.2 is intentional to make both panes appear to be of equal width
const [topPaneHeight, setTopPaneHeight] = useState(focusedTab?.requestPaneHeight || MIN_TOP_PANE_HEIGHT);
const [dragging, setDragging] = useState(false);
const dragOffset = useRef({ x: 0, y: 0 });
@@ -140,10 +141,12 @@ const RequestTabPanel = () => {
setDragging(false);
if (!isVerticalLayout) {
const mainRect = mainSectionRef.current.getBoundingClientRect();
dispatch(updateRequestPaneTabWidth({
uid: activeTabUid,
requestPaneWidth: e.clientX - mainRect.left
}));
dispatch(
updateRequestPaneTabWidth({
uid: activeTabUid,
requestPaneWidth: e.clientX - mainRect.left
})
);
}
}
};
@@ -260,7 +263,7 @@ const RequestTabPanel = () => {
return;
}
if (item.response?.hasStreamRunning) {
if (item.response?.stream?.running) {
dispatch(cancelRequest(item.cancelTokenUid, item, collection)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000

View File

@@ -12,12 +12,25 @@ import { getInitialExampleName } from 'utils/collections/index';
import classnames from 'classnames';
import StyledWrapper from './StyledWrapper';
const getTitleText = ({ isResponseTooLarge, isStreamingResponse }) => {
if (isStreamingResponse) {
return 'Response Examples aren\'t supported in streaming responses yet.';
}
if (isResponseTooLarge) {
return 'Response size exceeds 5MB limit. Cannot save as example.';
}
return 'Save current response as example';
};
const ResponseBookmark = ({ item, collection, responseSize }) => {
const dispatch = useDispatch();
const [showSaveResponseExampleModal, setShowSaveResponseExampleModal] = useState(false);
const response = item.response || {};
const isResponseTooLarge = responseSize >= 5 * 1024 * 1024; // 5 MB
const isStreamingResponse = response.stream;
// Only show for HTTP requests
if (item.type !== 'http-request') {
@@ -96,19 +109,22 @@ const ResponseBookmark = ({ item, collection, responseSize }) => {
toast.success(`Example "${name}" created successfully`);
};
const disabledMessage = getTitleText({
isResponseTooLarge,
isStreamingResponse
});
return (
<>
<StyledWrapper className="ml-2 flex items-center">
<button
onClick={handleSaveClick}
disabled={isResponseTooLarge}
disabled={isResponseTooLarge || isStreamingResponse}
title={
isResponseTooLarge
? 'Response size exceeds 5MB limit. Cannot save as example.'
: 'Save current response as example'
disabledMessage
}
className={classnames('p-1', {
'opacity-50 cursor-not-allowed': isResponseTooLarge
'opacity-50 cursor-not-allowed': isResponseTooLarge || isStreamingResponse
})}
data-testid="response-bookmark-btn"
>

View File

@@ -6,22 +6,22 @@ const ResponseStopWatch = ({ startMillis }) => {
const tickInterval = 100;
const tick = () => {
setMilliseconds(_milliseconds => _milliseconds + tickInterval);
setMilliseconds((_milliseconds) => _milliseconds + tickInterval);
};
useEffect(() => {
let timerID = setInterval(() => {
tick()
tick();
}, tickInterval);
return () => {
clearTimeout(timerID);
clearInterval(timerID);
};
}, []);
let seconds = milliseconds / 1000;
let secondsFormatted = `${seconds.toFixed(1)}s`;
let width = secondsFormatted.length * 0.4; // Calculate width manually to stop parent layout from "flickering" by changing width too fast
return <StyledWrapper className="ml-4" style={{width: `${width}rem`}}>{secondsFormatted}</StyledWrapper>;
return <StyledWrapper className="ml-4" style={{ width: `${width}rem` }}>{secondsFormatted}</StyledWrapper>;
};
export default React.memo(ResponseStopWatch);

View File

@@ -195,7 +195,7 @@ const WSMessagesList = ({ order = -1, messages = [] }) => {
<StyledWrapper className="ws-messages-list mt-1 flex flex-col">
{ordered.map((msg, idx, src) => {
const inFocus = order === -1 ? src.length - 1 === idx : idx === 0;
return <WSMessageItem inFocus={inFocus} id={idx} message={msg} />;
return <WSMessageItem key={msg.timestamp} inFocus={inFocus} id={idx} message={msg} />;
})}
</StyledWrapper>
);

View File

@@ -21,10 +21,10 @@ import ResponseClear from 'src/components/ResponsePane/ResponseClear';
import ResponseBookmark from 'src/components/ResponsePane/ResponseBookmark';
import SkippedRequest from './SkippedRequest';
import ClearTimeline from './ClearTimeline/index';
import StopWatch from 'components/StopWatch';
import ResponseStopWatch from 'components/ResponsePane/ResponseStopWatch';
import ResponseLayoutToggle from './ResponseLayoutToggle';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import ResponseStopWatch from 'components/ResponsePane/ResponseStopWatch';
import WSMessagesList from './WsResponsePane/WSMessagesList';
const ResponsePane = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -73,6 +73,10 @@ const ResponsePane = ({ item, collection }) => {
const getTabPanel = (tab) => {
switch (tab) {
case 'response': {
const isStream = item.response?.stream ?? false;
if (isStream) {
return <WSMessagesList order={-1} messages={item.response.data} />;
}
return (
<QueryResult
item={item}
@@ -89,17 +93,15 @@ const ResponsePane = ({ item, collection }) => {
return <ResponseHeaders headers={response.headers} />;
}
case 'timeline': {
return <Timeline collection={collection} item={item} />;
return <Timeline collection={collection} item={item} />;
}
case 'tests': {
return (
<TestResults
results={item.testResults}
assertionResults={item.assertionResults}
preRequestTestResults={item.preRequestTestResults}
postResponseTestResults={item.postResponseTestResults}
/>
);
return <TestResults
results={item.testResults}
assertionResults={item.assertionResults}
preRequestTestResults={item.preRequestTestResults}
postResponseTestResults={item.postResponseTestResults}
/>;
}
default: {
@@ -188,11 +190,10 @@ const ResponsePane = ({ item, collection }) => {
<ResponseClear item={item} collection={collection} />
<ResponseSave item={item} />
<ResponseBookmark item={item} collection={collection} responseSize={responseSize} />
<StatusCode status={response.status} isStreaming={item.response?.hasStreamRunning} />
{item.response?.hasStreamRunning ? (
<ResponseStopWatch startMillis={response.duration} />
) : <ResponseTime duration={response.duration} />}
<ResponseTime duration={response.duration} />
<StatusCode status={response.status} isStreaming={item.response?.stream?.running} />
{item.response?.stream?.running
? <ResponseStopWatch startMillis={response.duration} />
: <ResponseTime duration={response.duration} />}
<ResponseSize size={responseSize} />
</>
) : null}
@@ -200,7 +201,7 @@ const ResponsePane = ({ item, collection }) => {
) : null}
</div>
<section
className="flex flex-col min-h-0 relative px-4 auto overflow-auto"
className={`flex flex-col min-h-0 relative px-4 auto overflow-auto`}
style={{
flex: '1 1 0',
height: hasScriptError && showScriptErrorCard ? 'auto' : '100%'
@@ -213,9 +214,9 @@ const ResponsePane = ({ item, collection }) => {
onClose={() => setShowScriptErrorCard(false)}
/>
)}
<div className="flex-1 overflow-y-auto">
<div className='flex-1 overflow-y-auto'>
{!item?.response ? (
focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? (
focusedTab?.responsePaneTab === "timeline" && requestTimeline?.length ? (
<Timeline
collection={collection}
item={item}

View File

@@ -2,6 +2,7 @@ import { parseQueryParams, buildQueryString as stringifyQueryParams } from '@use
import { uuid } from 'utils/common';
import { find, map, forOwn, concat, filter, each, cloneDeep, get, set, findIndex } from 'lodash';
import { createSlice } from '@reduxjs/toolkit';
import { hexy as hexdump } from 'hexy';
import {
addDepth,
areItemsTheSameExceptSeqUpdate,
@@ -83,8 +84,8 @@ const initiatedGrpcResponse = {
isError: false,
duration: 0,
responses: [],
timestamp: Date.now()
};
timestamp: Date.now(),
}
const initiatedWsResponse = {
status: 'PENDING',
@@ -380,39 +381,39 @@ export const collectionsSlice = createSlice({
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (item) {
if (item.response?.hasStreamRunning) {
item.response.hasStreamRunning = null;
if (item.response?.stream?.running) {
item.response.stream.running = null;
const startTimestamp = item.requestSent.timestamp;
item.response.duration = startTimestamp ? Date.now() - startTimestamp : item.response.duration;
item.response.data = [{ type: 'info', timestamp: Date.now(), message: 'Connection Closed' }].concat(item.response.data);
} else {
item.response = null;
item.requestUid = null;
}
item.cancelTokenUid = null;
item.requestUid = null;
item.requestStartTime = null;
}
}
},
responseReceived: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item) {
item.requestState = 'received';
item.response = action.payload.response;
item.cancelTokenUid = item.response.hasStreamRunning ? item.cancelTokenUid : null;
item.cancelTokenUid = item.response.stream?.running ? item.cancelTokenUid : null;
item.requestStartTime = null;
if (!collection.timeline) {
collection.timeline = [];
}
// Ensure timestamp is a number (milliseconds since epoch)
const timestamp = item?.requestSent?.timestamp instanceof Date
? item.requestSent.timestamp.getTime()
const timestamp = item?.requestSent?.timestamp instanceof Date
? item.requestSent.timestamp.getTime()
: item?.requestSent?.timestamp || Date.now();
// Append the new timeline entry with numeric timestamp
@@ -435,7 +436,7 @@ export const collectionsSlice = createSlice({
const { itemUid, collectionUid, eventType, eventData } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
const item = findItemInCollection(collection, itemUid);
if (!item) return;
const request = item.draft ? item.draft.request : item.request;
@@ -455,7 +456,7 @@ export const collectionsSlice = createSlice({
}
collection.timeline.push({
type: 'request',
type: "request",
eventType: eventType, // Add the specific gRPC event type
collectionUid: collection.uid,
folderUid: null,
@@ -464,34 +465,36 @@ export const collectionsSlice = createSlice({
data: {
request: eventData || item.requestSent || item.request,
timestamp: Date.now(),
eventData: eventData
eventData: eventData,
}
});
},
grpcResponseReceived: (state, action) => {
const { itemUid, collectionUid, eventType, eventData } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
const item = findItemInCollection(collection, itemUid);
if (!item) return;
// Get current response state or create initial state
const currentResponse = item.response || initiatedGrpcResponse;
const currentResponse = item.response || initiatedGrpcResponse
const timestamp = item?.requestSent?.timestamp;
let updatedResponse = { ...currentResponse, duration: Date.now() - (timestamp || Date.now()) };
// Process based on event type
switch (eventType) {
case 'response':
const { error, res } = eventData;
// Handle error if present
if (error) {
const errorCode = error.code || 2; // Default to UNKNOWN if no code
updatedResponse.error = error.details || 'gRPC error occurred';
updatedResponse.statusCode = errorCode;
updatedResponse.statusText = grpcStatusCodes[errorCode] || 'UNKNOWN';
@@ -500,72 +503,72 @@ export const collectionsSlice = createSlice({
}
// Add response to list
updatedResponse.responses = res
? [...(currentResponse?.responses || []), res]
updatedResponse.responses = res
? [...(currentResponse?.responses || []), res]
: [...(currentResponse?.responses || [])];
break;
case 'metadata':
updatedResponse.headers = eventData.metadata;
updatedResponse.metadata = eventData.metadata;
break;
case 'status':
// Extract status info
const statusCode = eventData.status?.code;
const statusDetails = eventData.status?.details;
const statusMetadata = eventData.status?.metadata;
// Set status based on actual code and details
updatedResponse.statusCode = statusCode;
updatedResponse.statusText = grpcStatusCodes[statusCode] || 'UNKNOWN';
updatedResponse.statusDescription = statusDetails;
updatedResponse.statusDetails = eventData.status;
// Store trailers (status metadata)
if (statusMetadata) {
updatedResponse.trailers = statusMetadata;
}
// Handle error status (non-zero code)
if (statusCode !== 0) {
updatedResponse.isError = true;
updatedResponse.error = statusDetails || `gRPC error with code ${statusCode} (${updatedResponse.statusText})`;
}
break;
case 'error':
// Extract error details
const errorCode = eventData.error?.code || 2; // Default to UNKNOWN if no code
const errorDetails = eventData.error?.details || eventData.error?.message;
const errorMetadata = eventData.error?.metadata;
updatedResponse.isError = true;
updatedResponse.error = errorDetails || 'Unknown gRPC error';
updatedResponse.statusCode = errorCode;
updatedResponse.statusText = grpcStatusCodes[errorCode] || 'UNKNOWN';
updatedResponse.statusDescription = errorDetails;
// Store error metadata as trailers if present
if (errorMetadata) {
updatedResponse.trailers = errorMetadata;
}
break;
case 'end':
state.activeConnections = state.activeConnections.filter((id) => id !== itemUid);
state.activeConnections = state.activeConnections.filter(id => id !== itemUid);
break;
case 'cancel':
updatedResponse.statusCode = 1; // CANCELLED
updatedResponse.statusText = 'CANCELLED';
updatedResponse.statusDescription = 'Stream cancelled by client or server';
state.activeConnections = state.activeConnections.filter((id) => id !== itemUid);
state.activeConnections = state.activeConnections.filter(id => id !== itemUid);
break;
}
item.requestState = 'received';
item.response = updatedResponse;
@@ -576,7 +579,7 @@ export const collectionsSlice = createSlice({
// Append the new timeline entry with specific gRPC event type
collection.timeline.push({
type: 'request',
type: "request",
eventType: eventType, // Add the specific gRPC event type
collectionUid: collection.uid,
folderUid: null,
@@ -586,7 +589,7 @@ export const collectionsSlice = createSlice({
request: item.requestSent || item.request,
response: updatedResponse,
eventData: eventData, // Store the original event data
timestamp: Date.now()
timestamp: Date.now(),
}
});
},
@@ -596,12 +599,11 @@ export const collectionsSlice = createSlice({
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item) {
if (item.response && item.response.hasStreamRunning) {
if (item.response && item.response.stream?.running) {
item.response.data = '';
item.response.size = 0;
return;
}
item.response = null;
}
}
@@ -928,7 +930,7 @@ export const collectionsSlice = createSlice({
if (!item.draft) {
item.draft = cloneDeep(item);
}
const existingOtherParams = item.draft.request.params?.filter((p) => p.type !== 'query') || [];
const existingOtherParams = item.draft.request.params?.filter(p => p.type !== 'query') || [];
const newQueryParams = map(params, ({ name = '', value = '', enabled = true }) => ({
uid: uuid(),
name,
@@ -942,7 +944,9 @@ export const collectionsSlice = createSlice({
// Update the request URL to reflect the new query params
const parts = splitOnFirst(item.draft.request.url, '?');
const query = stringifyQueryParams(filter(item.draft.request.params, (p) => p.enabled && p.type === 'query'));
const query = stringifyQueryParams(
filter(item.draft.request.params, (p) => p.enabled && p.type === 'query')
);
// If there are enabled query params, append them to the URL
if (query && query.length) {
@@ -1173,7 +1177,7 @@ export const collectionsSlice = createSlice({
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.headers = map(action.payload.headers, ({ name = '', value = '', enabled = true }) => ({
item.draft.request.headers = map(action.payload.headers, ({name = '', value = '', enabled = true}) => ({
uid: uuid(),
name: name,
value: value,
@@ -1215,8 +1219,8 @@ export const collectionsSlice = createSlice({
if (!folder || !isItemAFolder(folder)) {
return;
}
folder.root.request.headers = map(headers, ({ name = '', value = '', enabled = true }) => ({
folder.root.request.headers = map(headers, ({name = '', value = '', enabled = true}) => ({
uid: uuid(),
name: name,
value: value,
@@ -1497,7 +1501,7 @@ export const collectionsSlice = createSlice({
if (!item.draft) {
item.draft = cloneDeep(item);
}
switch (item.draft.request.body.mode) {
case 'json': {
item.draft.request.body.json = action.payload.content;
@@ -1634,7 +1638,7 @@ export const collectionsSlice = createSlice({
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
@@ -1885,7 +1889,7 @@ export const collectionsSlice = createSlice({
break;
case 'ntlm':
set(collection, 'draft.root.request.auth.ntlm', action.payload.content);
break;
break;
case 'oauth2':
set(collection, 'draft.root.request.auth.oauth2', action.payload.content);
break;
@@ -2614,7 +2618,7 @@ export const collectionsSlice = createSlice({
const { requestUid, itemUid, collectionUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
const item = findItemInCollection(collection, itemUid);
if (!item) return;
@@ -2647,7 +2651,7 @@ export const collectionsSlice = createSlice({
item.postResponseScriptErrorMessage = action.payload.errorMessage;
}
if (type === 'test-script-execution') {
if(type === 'test-script-execution') {
item.testScriptErrorMessage = action.payload.errorMessage;
}
@@ -2662,7 +2666,7 @@ export const collectionsSlice = createSlice({
if (type === 'request-sent') {
const { cancelTokenUid, requestSent } = action.payload;
item.requestSent = requestSent;
// sometimes the response is received before the request-sent event arrives
if (item.requestState === 'queued') {
item.requestState = 'sending';
@@ -2679,12 +2683,12 @@ export const collectionsSlice = createSlice({
const { results } = action.payload;
item.testResults = results;
}
if (type === 'test-results-pre-request') {
const { results } = action.payload;
item.preRequestTestResults = results;
}
if (type === 'test-results-post-response') {
const { results } = action.payload;
item.postResponseTestResults = results;
@@ -2798,7 +2802,7 @@ export const collectionsSlice = createSlice({
if (collection) {
collection.runnerResult = null;
collection.runnerTags = { include: [], exclude: [] };
collection.runnerTags = { include: [], exclude: [] }
collection.runnerTagsEnabled = false;
collection.runnerConfiguration = null;
}
@@ -2937,7 +2941,7 @@ export const collectionsSlice = createSlice({
updateFolderAuthMode: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
if (folder) {
if (!folder.draft) {
folder.draft = cloneDeep(folder.root);
@@ -2952,7 +2956,16 @@ export const collectionsSlice = createSlice({
if (collection) {
const item = findItemInCollection(collection, itemUid);
item.response.data = data.data + (item.response.data || '');
if (data.data) {
item.response.data ||= [];
item.response.data = [{
type: 'incoming',
message: data.data,
messageHexdump: hexdump(data.data),
timestamp: Date.now()
}].concat(item.response.data);
}
item.response.dataBuffer = Buffer.concat([Buffer.from(item.response.dataBuffer), Buffer.from(data.dataBuffer)]);
item.response.size = data.data?.length + (item.response.size || 0);
}
},
@@ -2997,7 +3010,7 @@ export const collectionsSlice = createSlice({
updateCollectionTagsList: (state, action) => {
const { collectionUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
collection.allTags = getUniqueTagsFromItems(collection.items);
}

View File

@@ -2,6 +2,7 @@ import { customAlphabet } from 'nanoid';
import xmlFormat from 'xml-formatter';
import { JSONPath } from 'jsonpath-plus';
import fastJsonFormat from 'fast-json-format';
import { format, applyEdits } from 'jsonc-parser';
import { patternHasher } from '@usebruno/common/utils';
// a customized version of nanoid without using _ and -
@@ -294,7 +295,7 @@ export const formatResponse = (data, dataBufferString, mode, filter, bufferThres
}
try {
return prettifyJsonString(rawData);
return fastJsonFormat(rawData);
} catch (error) {}
if (typeof data === 'string') {
@@ -326,9 +327,11 @@ export const formatResponse = (data, dataBufferString, mode, filter, bufferThres
export const prettifyJsonString = (jsonDataString) => {
if (typeof jsonDataString !== 'string') return jsonDataString;
try {
const { hashed, restore } = patternHasher(jsonDataString);
const formattedJsonDataStringHashed = fastJsonFormat(hashed);
const edits = format(hashed, undefined, { tabSize: 2, insertSpaces: true });
const formattedJsonDataStringHashed = applyEdits(hashed, edits);
const formattedJsonDataString = restore(formattedJsonDataStringHashed);
return formattedJsonDataString;
} catch (error) {

View File

@@ -218,16 +218,16 @@ describe('common utils', () => {
});
test('should format complex json string', () => {
const input = `{"id": 123456789123456789123456789,"name": "Test 'JSON' Data with "quotes" — Pretty Print ","active": true,"price": 199.9999999,"decimals": 1.00,"nullValue": null,"unicodeText": "こんにちは世界 ","escapedCharacters": "Line1\nLine2\tTabbed\"Quoted\" and 'single quoted' with 'code' style","nestedObject": { "level1": { "level2": { "emptyArray": [], "specialChars": "@#$%^&*()_+-=[]{}|;':,./<>?~", "booleanValues": [ true, false, true ], "numbers": [ 0, -1, 1.23e10, 3.1415926535 ] } }},"mixedArray": [ "string with 'apostrophe'", 42, false, null, { "innerObj": { "keyWithQuotes": "value containing \`backticks\` and 'single quotes'", "nestedArray": [ { "a": "O'Reilly" }{ "b": "'inline code'" }, [ "deep", "array", { "c": "contains 'quotes'" } ] ] } }],"nonStringVariable": {{nonStringVar}},"withBrunoVariable": "{{string}} '{{with}}' "{{variety}}" of '{{variables}}'","dateExample": "2025-11-07T12:34:56Z","regexExample": "^([a-z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})$","urls": { "website": "https://example.com?param='value'&flag='true'", "escapedURL": "https:\/\/escaped-url.com\/path\?q='search'\&debug='on'"},"multiLineString": "This is a long text\nthat spans multiple\nlines with \`backticks\` 'quotes' and 'code' snippets "}`;
const input = `{"id": 123456789123456789123456789,"name": "Test 'JSON' Data with \"quotes\" — Pretty Print ","active": true,"price": 199.9999999,"decimals": 1.00,"nullValue": null,"unicodeText": "こんにちは世界 ","escapedCharacters": "Line1\\nLine2\\tTabbed\"Quoted\" and 'single quoted' with 'code' style","nestedObject": { "level1": { "level2": { "emptyArray": [], "specialChars": "@#$%^&*()_+-=[]{}|;':,./<>?~", "booleanValues": [ true, false, true ], "numbers": [ 0, -1, 1.23e10, 3.1415926535 ] } }},"mixedArray": [ "string with 'apostrophe'", 42, false, null, { "innerObj": { "keyWithQuotes": "value containing \`backticks\` and 'single quotes'", "nestedArray": [ { "a": "O'Reilly" }{ "b": "'inline code'" }, [ "deep", "array", { "c": "contains 'quotes'" } ] ] } }],"nonStringVariable": {{nonStringVar}},"withBrunoVariable": "{{string}} '{{with}}' "{{variety}}" of '{{variables}}'","dateExample": "2025-11-07T12:34:56Z","regexExample": "^([a-z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})$","urls": { "website": "https://example.com?param='value'&flag='true'", "escapedURL": "https:\/\/escaped-url.com\/path\?q='search'\&debug='on'"},"multiLineString": "This is a long text\\nthat spans multiple\\nlines with \`backticks\` 'quotes' and 'code' snippets "}`;
const expectedOutput = `{
"id": 123456789123456789123456789,
"name": "Test 'JSON' Data with "quotes" — Pretty Print ",
"name": "Test 'JSON' Data with \"quotes\" — Pretty Print ",
"active": true,
"price": 199.9999999,
"decimals": 1.00,
"nullValue": null,
"unicodeText": "こんにちは世界 ",
"escapedCharacters": "Line1\nLine2\tTabbed\"Quoted\" and 'single quoted' with 'code' style",
"escapedCharacters": "Line1\\nLine2\\tTabbed\"Quoted\" and 'single quoted' with 'code' style",
"nestedObject": {
"level1": {
"level2": {
@@ -280,7 +280,7 @@ describe('common utils', () => {
"website": "https://example.com?param='value'&flag='true'",
"escapedURL": "https:\/\/escaped-url.com\/path\?q='search'\&debug='on'"
},
"multiLineString": "This is a long text\nthat spans multiple\nlines with \`backticks\` 'quotes' and 'code' snippets "
"multiLineString": "This is a long text\\nthat spans multiple\\nlines with \`backticks\` 'quotes' and 'code' snippets "
}`;
expect(prettifyJsonString(input)).toBe(expectedOutput);
});

View File

@@ -0,0 +1,29 @@
const normalizeContentType = (contentType) => {
if (!contentType || typeof contentType !== 'string') {
return '';
}
return contentType.toLowerCase();
};
export const isJsonLikeContentType = (contentType) => {
const normalized = normalizeContentType(contentType);
return normalized.includes('application/json') || normalized.includes('+json');
};
export const isXmlLikeContentType = (contentType) => {
const normalized = normalizeContentType(contentType);
return normalized.includes('application/xml') || normalized.includes('+xml') || normalized.includes('text/xml');
};
export const isPlainTextContentType = (contentType) => {
const normalized = normalizeContentType(contentType);
return normalized.includes('text/plain');
};
export const isStructuredContentType = (contentType) => {
return isJsonLikeContentType(contentType) || isXmlLikeContentType(contentType) || isPlainTextContentType(contentType);
};

View File

@@ -10,6 +10,7 @@ import parseCurlCommand from './parse-curl';
import * as querystring from 'query-string';
import * as jsesc from 'jsesc';
import { buildQueryString } from '@usebruno/common/utils';
import { isStructuredContentType } from './content-type';
function getContentType(headers = {}) {
const contentType = Object.keys(headers).find((key) => key.toLowerCase() === 'content-type');
@@ -34,7 +35,7 @@ function getDataString(request) {
const contentType = getContentType(request.headers);
if (contentType && (contentType.includes('application/json') || contentType.includes('application/xml') || contentType.includes('text/plain'))) {
if (isStructuredContentType(contentType)) {
return { data: request.data };
}

View File

@@ -120,4 +120,37 @@ describe('curlToJson', () => {
]
});
});
it('should parse custom json content-types', () => {
const curlCommand = `curl 'https://api.example.com/test'
-H 'content-type: application/x.custom+json;version=1'
--data-raw '{"test":"data"}'
`;
const result = curlToJson(curlCommand);
expect(result).toEqual({
url: 'https://api.example.com/test',
raw_url: 'https://api.example.com/test',
method: 'post',
headers: {
'content-type': 'application/x.custom+json;version=1'
},
data: '{"test":"data"}'
});
});
it('should parse vendor tree json content-types', () => {
const curlCommand = `curl --request POST \\
--url https://api.example.com/orders/42/preferences \\
--header 'accept: */*' \\
--header 'content-type: application/vnd.vendor+json' \\
--data '{\\n "data": {\\n "type": "order-preferences",\\n "attributes": {\\n "notes": "Leave at door",\\n "priority": true\\n }\\n }\\n}'`;
const result = curlToJson(curlCommand);
expect(result.data).toContain('"type": "order-preferences"');
expect(result.data).toContain('"notes": "Leave at door"');
expect(result.data).toContain('"priority": true');
expect(result.headers['content-type']).toBe('application/vnd.vendor+json');
});
});

View File

@@ -1,6 +1,7 @@
import { forOwn } from 'lodash';
import curlToJson from './curl-to-json';
import { prettifyJsonString } from 'utils/common/index';
import { isJsonLikeContentType, isPlainTextContentType, isXmlLikeContentType } from './content-type';
export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-request') => {
const parseFormData = (parsedBody) => {
@@ -59,25 +60,27 @@ export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-reque
};
if (parsedBody && contentType && typeof contentType === 'string') {
if (requestType === 'graphql-request' && (contentType.includes('application/json') || contentType.includes('application/graphql'))) {
const normalizedContentType = contentType.toLowerCase();
if (requestType === 'graphql-request' && (isJsonLikeContentType(contentType) || normalizedContentType.includes('application/graphql'))) {
body.mode = 'graphql';
body.graphql = parseGraphQL(parsedBody);
} else if (requestType === 'http-request' && request.isDataBinary) {
body.mode = 'file';
body.file = parsedBody;
}else if (contentType.includes('application/json')) {
} else if (isJsonLikeContentType(contentType)) {
body.mode = 'json';
body.json = prettifyJsonString(parsedBody);
} else if (contentType.includes('xml')) {
} else if (isXmlLikeContentType(contentType) || normalizedContentType.includes('xml')) {
body.mode = 'xml';
body.xml = parsedBody;
} else if (contentType.includes('application/x-www-form-urlencoded')) {
} else if (normalizedContentType.includes('application/x-www-form-urlencoded')) {
body.mode = 'formUrlEncoded';
body.formUrlEncoded = parseFormData(parsedBody);
} else if (contentType.includes('multipart/form-data')) {
} else if (normalizedContentType.includes('multipart/form-data')) {
body.mode = 'multipartForm';
body.multipartForm = parsedBody;
} else if (contentType.includes('text/plain')) {
} else if (isPlainTextContentType(contentType)) {
body.mode = 'text';
body.text = parsedBody;
}

View File

@@ -10,6 +10,7 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV
if (response?.error) {
resolve(response)
}
resolve({
state: 'success',
data: response.data,
@@ -21,7 +22,7 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV
statusText: response.statusText,
duration: response.duration,
timeline: response.timeline,
hasStreamRunning: response.hasStreamRunning
stream: response.stream
});
})
.catch((err) => reject(err));
@@ -32,17 +33,19 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV
export const sendGrpcRequest = async (item, collection, environment, runtimeVariables) => {
return new Promise((resolve, reject) => {
startGrpcRequest(item, collection, environment, runtimeVariables)
.then((initialState) => {
// Return an initial state object to update the UI
// The real response data will be handled by event listeners
resolve({
...initialState,
timeline: []
});
})
.catch((err) => reject(err));
.then((initialState) => {
// Return an initial state object to update the UI
// The real response data will be handled by event listeners
resolve({
...initialState,
timeline: []
});
})
.catch((err) => reject(err));
});
};
}
const sendHttpRequest = async (item, collection, environment, runtimeVariables) => {
return new Promise((resolve, reject) => {
@@ -82,19 +85,19 @@ export const startGrpcRequest = async (item, collection, environment, runtimeVar
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
const request = item.draft ? item.draft : item;
ipcRenderer.invoke('grpc:start-connection', {
request,
collection,
environment,
request,
collection,
environment,
runtimeVariables
})
.then(() => {
resolve();
})
.catch((err) => {
reject(err);
});
.then(() => {
resolve();
})
.catch(err => {
reject(err);
});
});
};
@@ -187,7 +190,7 @@ export const isGrpcConnectionActive = async (connectionId) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('grpc:is-connection-active', connectionId)
.then((response) => {
.then(response => {
if (response.success) {
resolve(response.isActive);
} else {
@@ -196,7 +199,7 @@ export const isGrpcConnectionActive = async (connectionId) => {
resolve(false);
}
})
.catch((err) => {
.catch(err => {
console.error('Failed to check connection status:', err);
// On error, assume the connection is not active
resolve(false);
@@ -214,14 +217,14 @@ export const isGrpcConnectionActive = async (connectionId) => {
export const generateGrpcSampleMessage = async (methodPath, existingMessage = null, options = {}) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('grpc:generate-sample-message', {
methodPath,
existingMessage,
options
ipcRenderer.invoke('grpc:generate-sample-message', {
methodPath,
existingMessage,
options
})
.then(resolve)
.catch(reject);
.then(resolve)
.catch(reject);
});
};

View File

@@ -69,8 +69,9 @@ const getJsSandboxRuntime = (collection) => {
return 'vm2';
};
const isStream = (headers) => {
return headers.get('content-type') === 'text/event-stream';
const hasStreamHeaders = (headers) => {
const headerSplit = (headers.get('content-type') ?? '').split(';').map((d) => d.trim());
return headerSplit.indexOf('text/event-stream') > -1;
};
const promisifyStream = async (stream, abortController, closeOnFirst) => {
@@ -95,18 +96,20 @@ const promisifyStream = async (stream, abortController, closeOnFirst) => {
});
stream.on('close', doResolve);
stream.on('error', err => reject(err));
stream.on('error', (err) => reject(err));
});
};
const configureRequest = async (collectionUid,
const configureRequest = async (
collectionUid,
collection,
request,
envVars,
runtimeVariables,
processEnvVars,
collectionPath,
globalEnvironmentVariables) => {
globalEnvironmentVariables
) => {
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
if (!protocolRegex.test(request.url)) {
request.url = `http://${request.url}`;
@@ -125,7 +128,7 @@ const configureRequest = async (collectionUid,
// Get followRedirects setting, default to true for backward compatibility
const followRedirects = request.settings?.followRedirects ?? true;
// Get maxRedirects from request settings, fallback to request.maxRedirects, then default to 5
let requestMaxRedirects = request.settings?.maxRedirects ?? request.maxRedirects ?? 5;
@@ -166,12 +169,14 @@ const configureRequest = async (collectionUid,
request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header' && credentials?.access_token) {
request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials.access_token}`.trim();
} else {
}
else {
try {
const url = new URL(request.url);
url?.searchParams?.set(tokenQueryKey, credentials?.access_token);
request.url = url?.toString();
} catch (error) {}
}
catch(error) {}
}
break;
case 'implicit':
@@ -180,7 +185,8 @@ const configureRequest = async (collectionUid,
request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header') {
request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`;
} else {
}
else {
try {
const url = new URL(request.url);
url?.searchParams?.set(tokenQueryKey, credentials?.access_token);
@@ -242,7 +248,8 @@ const configureRequest = async (collectionUid,
if (preferencesUtil.shouldSendCookies()) {
const cookieString = getCookieStringForUrl(request.url);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
const existingCookieHeaderName = Object.keys(request.headers).find((name) => name.toLowerCase() === 'cookie'
const existingCookieHeaderName = Object.keys(request.headers).find(
name => name.toLowerCase() === 'cookie'
);
const existingCookieString = existingCookieHeaderName ? request.headers[existingCookieHeaderName] : '';
@@ -306,7 +313,8 @@ const fetchGqlSchemaHandler = async (event, endpoint, environment, _request, col
// Precedence: runtimeVars > requestVariables > folderVars > envVars > collectionVariables > globalEnvironmentVars
const processEnvVars = getProcessEnvVars(collection.uid);
const resolvedVars = merge({},
const resolvedVars = merge(
{},
globalEnvironmentVars,
collectionVariables,
envVars,
@@ -319,7 +327,8 @@ const fetchGqlSchemaHandler = async (event, endpoint, environment, _request, col
...processEnvVars
}
}
});
}
);
const collectionRoot = collection?.draft?.root || collection?.root || {};
const request = prepareGqlIntrospectionRequest(endpoint, resolvedVars, _request, collectionRoot);
@@ -336,14 +345,16 @@ const fetchGqlSchemaHandler = async (event, endpoint, environment, _request, col
const collectionPath = collection.pathname;
const axiosInstance = await configureRequest(collection.uid,
const axiosInstance = await configureRequest(
collection.uid,
collection,
request,
envVars,
collection.runtimeVariables,
processEnvVars,
collectionPath,
collection.globalEnvironmentVariables);
collection.globalEnvironmentVariables
);
const response = await axiosInstance(request);
@@ -378,10 +389,10 @@ const registerNetworkIpc = (mainWindow) => {
};
const notifyScriptExecution = ({
channel, // 'main:run-request-event' | 'main:run-folder-event'
basePayload, // request-level or runner-level identifiers
scriptType, // 'pre-request' | 'post-response' | 'test'
error // optional Error
channel, // 'main:run-request-event' | 'main:run-folder-event'
basePayload, // request-level or runner-level identifiers
scriptType, // 'pre-request' | 'post-response' | 'test'
error // optional Error
}) => {
mainWindow.webContents.send(channel, {
type: `${scriptType}-script-execution`,
@@ -390,7 +401,8 @@ const registerNetworkIpc = (mainWindow) => {
});
};
const runPreRequest = async (request,
const runPreRequest = async (
request,
requestUid,
envVars,
collectionPath,
@@ -399,10 +411,11 @@ const registerNetworkIpc = (mainWindow) => {
runtimeVariables,
processEnvVars,
scriptingConfig,
runRequestByItemPathname) => {
runRequestByItemPathname
) => {
// run pre-request script
let scriptResult;
const collectionName = collection?.name;
const collectionName = collection?.name
const requestScript = get(request, 'script.req');
if (requestScript?.length) {
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
@@ -480,7 +493,8 @@ const registerNetworkIpc = (mainWindow) => {
return scriptResult;
};
const runPostResponse = async (request,
const runPostResponse = async (
request,
response,
requestUid,
envVars,
@@ -537,7 +551,7 @@ const registerNetworkIpc = (mainWindow) => {
// run post-response script
const responseScript = get(request, 'script.res');
let scriptResult;
const collectionName = collection?.name;
const collectionName = collection?.name
if (responseScript?.length) {
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
scriptResult = await scriptRuntime.runResponseScript(
@@ -612,9 +626,11 @@ const registerNetworkIpc = (mainWindow) => {
const abortController = new AbortController();
const request = await prepareRequest(item, collection, abortController);
request.__bruno__executionMode = 'standalone';
request.responseType = "stream";
request.responseType = 'stream';
// flag to see if the stream needs to be handled as an actual stream or
// is it just a data stream from axios
let isResponseStream = false;
const brunoConfig = getBrunoConfig(collectionUid, collection);
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = getJsSandboxRuntime(collection);
@@ -625,7 +641,8 @@ const registerNetworkIpc = (mainWindow) => {
let preRequestScriptResult = null;
let preRequestError = null;
try {
preRequestScriptResult = await runPreRequest(request,
preRequestScriptResult = await runPreRequest(
request,
requestUid,
envVars,
collectionPath,
@@ -634,7 +651,8 @@ const registerNetworkIpc = (mainWindow) => {
runtimeVariables,
processEnvVars,
scriptingConfig,
runRequestByItemPathname);
runRequestByItemPathname
);
} catch (error) {
preRequestError = error;
}
@@ -659,14 +677,16 @@ const registerNetworkIpc = (mainWindow) => {
if (preRequestError) {
return Promise.reject(preRequestError);
}
const axiosInstance = await configureRequest(collectionUid,
const axiosInstance = await configureRequest(
collectionUid,
collection,
request,
envVars,
runtimeVariables,
processEnvVars,
collectionPath,
collection.globalEnvironmentVariables);
collection.globalEnvironmentVariables
);
const { data: requestData, dataBuffer: requestDataBuffer } = parseDataFromRequest(request);
@@ -685,7 +705,7 @@ const registerNetworkIpc = (mainWindow) => {
data: requestData,
dataBuffer: requestDataBuffer,
timestamp: Date.now()
};
}
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'request-sent',
@@ -707,13 +727,13 @@ const registerNetworkIpc = (mainWindow) => {
});
}
let response, responseTime;
let response, responseTime, axiosDataStream;
try {
/** @type {import('axios').AxiosResponse} */
response = await axiosInstance(request);
request.isStream = isStream(response.headers);
isResponseStream = hasStreamHeaders(response.headers);
if (!request.isStream) {
if (!isResponseStream) {
response.data = await promisifyStream(response.data);
}
@@ -740,9 +760,8 @@ const registerNetworkIpc = (mainWindow) => {
// Prevents the duration on leaking to the actual result
responseTime = response.headers.get('request-duration');
response.headers.delete('request-duration');
request.isStream = isStream(response.headers);
if (!request.isStream) {
isResponseStream = hasStreamHeaders(response.headers);
if (!isResponseStream) {
response.data = await promisifyStream(response.data);
}
} else {
@@ -755,21 +774,21 @@ const registerNetworkIpc = (mainWindow) => {
statusText: error.statusText,
error: error.message || ERROR_OCCURRED_WHILE_EXECUTING_REQUEST,
timeline: error.timeline
};
}
}
}
// Continue with the rest of the request lifecycle - post response vars, script, assertions, tests
if (request.isStream) {
response.stream = response.data;
if (isResponseStream) {
axiosDataStream = response.data;
}
const { data, dataBuffer } = request.isStream
? { data: '', dataBuffer: new ArrayBuffer(0) }
const { data, dataBuffer } = isResponseStream
? { data: '', dataBuffer: Buffer.alloc(0) }
: parseDataFromResponse(response, request.__brunoDisableParsingResponseJson);
response.data = data;
response.dataBuffer = dataBuffer;
response.responseTime = responseTime;
// save cookies
@@ -783,9 +802,9 @@ const registerNetworkIpc = (mainWindow) => {
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
cookiesStore.saveCookieJar();
let postResponseScriptResult = null;
let postResponseError = null;
const runPostScripts = async () => {
let postResponseScriptResult = null;
let postResponseError = null;
try {
postResponseScriptResult = await runPostResponse(request,
response,
@@ -914,9 +933,8 @@ const registerNetworkIpc = (mainWindow) => {
cookiesStore.saveCookieJar();
}
};
if (request.isStream) {
response.stream.on('close', () => runPostScripts().then());
if (isResponseStream) {
axiosDataStream.on('close', () => runPostScripts().then());
} else {
await runPostScripts();
}
@@ -926,8 +944,10 @@ const registerNetworkIpc = (mainWindow) => {
statusText: response.statusText,
headers: response.headers,
data: response.data,
stream: request.isStream ? response.stream : null,
dataBuffer: response.dataBuffer.toString('base64'),
stream: isResponseStream ? axiosDataStream : null,
cancelTokenUid: cancelTokenUid,
size: Buffer.byteLength(response.dataBuffer),
duration: responseTime ?? 0,
url: response.request ? response.request.protocol + '//' + response.request.host + response.request.path : null,
timeline: response.timeline
@@ -953,12 +973,11 @@ const registerNetworkIpc = (mainWindow) => {
const response = await runRequest({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: false });
if (response.stream) {
const stream = response.stream;
response.stream = undefined;
response.hasStreamRunning = response.status >= 200 && response.status < 300;
response.stream = { running: response.status >= 200 && response.status < 300 };
stream.on('data', newData => {
stream.on('data', (newData) => {
const parsed = parseDataFromResponse({ data: newData, headers: {} });
mainWindow.webContents.send('main:http-stream-new-data', {collectionUid, itemUid: item.uid, data: parsed});
mainWindow.webContents.send('main:http-stream-new-data', { collectionUid, itemUid: item.uid, data: parsed });
});
stream.on('close', () => {
@@ -966,7 +985,7 @@ const registerNetworkIpc = (mainWindow) => {
return;
}
mainWindow.webContents.send('main:http-stream-end', {collectionUid, itemUid: item.uid});
mainWindow.webContents.send('main:http-stream-end', { collectionUid, itemUid: item.uid });
deleteCancelToken(response.cancelTokenUid);
});
}
@@ -990,7 +1009,7 @@ const registerNetworkIpc = (mainWindow) => {
if (cancelTokenUid && cancelTokens[cancelTokenUid]) {
const abortController = cancelTokens[cancelTokenUid];
deleteCancelToken(cancelTokenUid);
abortController.abort(); // Ensure the on stream end event is called after the token is deleted
abortController.abort();
resolve();
} else {
reject(new Error('cancel token not found'));
@@ -999,7 +1018,7 @@ const registerNetworkIpc = (mainWindow) => {
});
// handler for fetch-gql-schema
ipcMain.handle('fetch-gql-schema', fetchGqlSchemaHandler);
ipcMain.handle('fetch-gql-schema', fetchGqlSchemaHandler)
ipcMain.handle(
'renderer:run-collection-folder',
@@ -1033,7 +1052,7 @@ const registerNetworkIpc = (mainWindow) => {
}
const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname));
if(_item) {
const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true });
const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true });
resolve(res);
}
reject(`bru.runRequest: invalid request path - ${itemPathname}`);
@@ -1065,8 +1084,9 @@ const registerNetworkIpc = (mainWindow) => {
}
});
// sort requests by seq property
folderRequests = sortByNameThenSequence(folderRequests);
folderRequests = sortByNameThenSequence(folderRequests)
}
// Filter requests based on tags
@@ -1075,7 +1095,7 @@ const registerNetworkIpc = (mainWindow) => {
const excludeTags = tags.exclude ? tags.exclude : [];
folderRequests = folderRequests.filter(({ tags: requestTags = [], draft }) => {
requestTags = draft?.tags || requestTags || [];
return isRequestTagsIncluded(requestTags, includeTags, excludeTags);
return isRequestTagsIncluded(requestTags, includeTags, excludeTags)
});
}
@@ -1128,14 +1148,15 @@ const registerNetworkIpc = (mainWindow) => {
const request = await prepareRequest(item, collection, abortController);
request.__bruno__executionMode = 'runner';
const requestUid = uuid();
try {
let preRequestScriptResult;
let preRequestError = null;
try {
preRequestScriptResult = await runPreRequest(request,
preRequestScriptResult = await runPreRequest(
request,
requestUid,
envVars,
collectionPath,
@@ -1144,7 +1165,8 @@ const registerNetworkIpc = (mainWindow) => {
runtimeVariables,
processEnvVars,
scriptingConfig,
runRequestByItemPathname);
runRequestByItemPathname
);
} catch (error) {
console.error('Pre-request script error:', error);
preRequestError = error;
@@ -1214,7 +1236,7 @@ const registerNetworkIpc = (mainWindow) => {
data: requestData,
dataBuffer: requestDataBuffer,
timestamp: Date.now()
};
}
// todo:
// i have no clue why electron can't send the request object
@@ -1228,8 +1250,8 @@ const registerNetworkIpc = (mainWindow) => {
currentAbortController = new AbortController();
request.signal = currentAbortController.signal;
request.responseType = 'stream';
const axiosInstance = await configureRequest(collectionUid,
const axiosInstance = await configureRequest(
collectionUid,
collection,
request,
envVars,
@@ -1246,7 +1268,7 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid,
credentialsId: request?.oauth2Credentials?.credentialsId,
...(request?.oauth2Credentials?.folderUid ? { folderUid: request.oauth2Credentials.folderUid } : { itemUid: item.uid }),
debugInfo: request?.oauth2Credentials?.debugInfo
debugInfo: request?.oauth2Credentials?.debugInfo,
});
collection.oauth2Credentials = updateCollectionOauth2Credentials({
@@ -1274,10 +1296,7 @@ const registerNetworkIpc = (mainWindow) => {
/** @type {import('axios').AxiosResponse} */
response = await axiosInstance(request);
request.isStream = isStream(response.headers);
response.data = await promisifyStream(response.data, currentAbortController, true);
response.data = await promisifyStream(response.data, currentAbortController, false);
timeEnd = Date.now();
const { data, dataBuffer } = parseDataFromResponse(response, request.__brunoDisableParsingResponseJson);
@@ -1319,9 +1338,7 @@ const registerNetworkIpc = (mainWindow) => {
}
if (error?.response) {
request.isStream = isStream(error.response.headers);
error.response.data = await promisifyStream(error.response.data, currentAbortController, true);
const { data, dataBuffer } = parseDataFromResponse(error.response);
error.response.responseTime = error.response.headers.get('request-duration');
error.response.headers.delete('request-duration');
@@ -1338,7 +1355,7 @@ const registerNetworkIpc = (mainWindow) => {
size: Buffer.byteLength(dataBuffer),
data: error.response.data,
responseTime: error.response.responseTime,
timeline: error.response.timeline
timeline: error.response.timeline,
};
// if we get a response from the server, we consider it as a success
@@ -1359,7 +1376,8 @@ const registerNetworkIpc = (mainWindow) => {
let postResponseScriptResult;
let postResponseError = null;
try {
postResponseScriptResult = await runPostResponse(request,
postResponseScriptResult = await runPostResponse(
request,
response,
requestUid,
envVars,
@@ -1369,7 +1387,8 @@ const registerNetworkIpc = (mainWindow) => {
runtimeVariables,
processEnvVars,
scriptingConfig,
runRequestByItemPathname);
runRequestByItemPathname
);
} catch (error) {
console.error('Post-response script error:', error);
postResponseError = error;
@@ -1406,12 +1425,14 @@ const registerNetworkIpc = (mainWindow) => {
const assertions = get(item, 'request.assertions');
if (assertions) {
const assertRuntime = new AssertRuntime({ runtime: scriptingConfig?.runtime });
const results = assertRuntime.runAssertions(assertions,
const results = assertRuntime.runAssertions(
assertions,
request,
response,
envVars,
runtimeVariables,
processEnvVars);
processEnvVars
);
mainWindow.webContents.send('main:run-folder-event', {
type: 'assertion-results',
@@ -1422,14 +1443,15 @@ const registerNetworkIpc = (mainWindow) => {
}
const testFile = get(request, 'tests');
const collectionName = collection?.name;
const collectionName = collection?.name
if (typeof testFile === 'string') {
let testResults = null;
let testError = null;
try {
const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime });
testResults = await testRuntime.runTests(decomment(testFile),
testResults = await testRuntime.runTests(
decomment(testFile),
request,
response,
envVars,
@@ -1439,10 +1461,11 @@ const registerNetworkIpc = (mainWindow) => {
processEnvVars,
scriptingConfig,
runRequestByItemPathname,
collectionName);
collectionName
);
} catch (error) {
testError = error;
if (error.partialResults) {
testResults = error.partialResults;
} else {
@@ -1476,7 +1499,7 @@ const registerNetworkIpc = (mainWindow) => {
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: testResults.globalEnvironmentVariables
});
collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables;
notifyScriptExecution({
@@ -1505,7 +1528,7 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid,
folderUid,
statusText: 'collection run was terminated!',
runCompletionTime: new Date().toISOString()
runCompletionTime: new Date().toISOString(),
});
break;
}
@@ -1522,7 +1545,7 @@ const registerNetworkIpc = (mainWindow) => {
if (nextRequestIdx >= 0) {
currentRequestIndex = nextRequestIdx;
} else {
console.error('Could not find request with name \'' + nextRequestName + '\'');
console.error("Could not find request with name '" + nextRequestName + "'");
currentRequestIndex++;
}
} else {
@@ -1535,10 +1558,10 @@ const registerNetworkIpc = (mainWindow) => {
type: 'testrun-ended',
collectionUid,
folderUid,
runCompletionTime: new Date().toISOString()
runCompletionTime: new Date().toISOString(),
});
} catch (error) {
console.log('error', error);
console.log("error", error);
deleteCancelToken(cancelTokenUid);
mainWindow.webContents.send('main:run-folder-event', {
type: 'testrun-ended',
@@ -1635,13 +1658,14 @@ const executeRequestOnFailHandler = async (request, error) => {
}
};
const registerAllNetworkIpc = (mainWindow) => {
registerNetworkIpc(mainWindow);
registerGrpcEventHandlers(mainWindow);
registerWsEventHandlers(mainWindow);
};
}
module.exports = registerAllNetworkIpc;
module.exports = registerAllNetworkIpc
module.exports.configureRequest = configureRequest;
module.exports.getCertsAndProxyConfig = getCertsAndProxyConfig;
module.exports.fetchGqlSchemaHandler = fetchGqlSchemaHandler;

View File

@@ -10,6 +10,7 @@
| [正體中文](docs/publishing/publishing_zhtw.md)
| [日本語](docs/publishing/publishing_ja.md)
| [Nederlands](docs/publishing/publishing_nl.md)
| [فارسی](docs/publishing/publishing_fa.md)
### Publishing Bruno to a new package manager

View File

@@ -29,6 +29,7 @@
| [日本語](docs/readme/readme_ja.md)
| [ქართული](docs/readme/readme_ka.md)
| [Nederlands](docs/readme/readme_nl.md)
| [فارسی](docs/readme/readme_fa.md)
Bruno is a new and innovative API client, aimed at revolutionizing the status quo represented by Postman and similar tools out there.
@@ -52,6 +53,7 @@ We strive to strike a harmonious balance between [open-source principles and sus
You can explore our [paid versions](https://www.usebruno.com/pricing) to see if there are additional features that you or your team may find useful! <br/>
## Table of Contents
- [Installation](#installation)
- [Features](#features)
- [Run across multiple platforms 🖥️](#run-across-multiple-platforms-%EF%B8%8F)

View File

@@ -13,6 +13,7 @@ post {
body:json {
{
"bigint": 1736184243098437392,
"unicode": ["\u4e00","\u4e8c","\u4e09"]
"unicode": ["\u4e00","\u4e8c","\u4e09"],
"forwardslashes": "\/url\/path\/"
}
}

View File

@@ -35,6 +35,9 @@ test.describe.serial('JSON Response Formatting', () => {
await expect(responseBody).toContainText('一');
await expect(responseBody).toContainText('二');
await expect(responseBody).toContainText('三');
// The response should handle escaped forward slashes
await expect(responseBody).toContainText('/url/path/');
});
});
});