diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..b033398a3 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @helloanoop @maintainer-bruno @lohit-bruno @naman-bruno diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml deleted file mode 100644 index f630b56cb..000000000 --- a/.github/workflows/playwright.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Playwright E2E Tests -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - e2e-test: - timeout-minutes: 60 - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: v22.11.x - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get --no-install-recommends install -y \ - libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \ - xvfb - npm ci --legacy-peer-deps - sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox - sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox - - - name: Build libraries - run: | - npm run build:graphql-docs - npm run build:bruno-query - npm run build:bruno-common - npm run sandbox:bundle-libraries --workspace=packages/bruno-js - npm run build:bruno-converters - npm run build:bruno-requests - - - name: Run Playwright tests - run: | - xvfb-run npm run test:e2e - - uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} - with: - name: playwright-report - path: playwright-report/ - retention-days: 30 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 144cdee5b..aaf5d1880 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -91,5 +91,47 @@ jobs: uses: EnricoMi/publish-unit-test-result-action@v2 if: always() with: + check_name: CLI Test Results files: packages/bruno-tests/collection/junit.xml comment_mode: always + e2e-test: + name: Playwright E2E Tests + timeout-minutes: 60 + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: v22.11.x + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get --no-install-recommends install -y \ + libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \ + xvfb + npm ci --legacy-peer-deps + sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox + sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox + + - name: Install dependencies for test collection environment + run: | + npm ci --prefix packages/bruno-tests/collection + + - name: Build libraries + run: | + npm run build:graphql-docs + npm run build:bruno-query + npm run build:bruno-common + npm run sandbox:bundle-libraries --workspace=packages/bruno-js + npm run build:bruno-converters + npm run build:bruno-requests + + - name: Run Playwright tests + run: | + xvfb-run npm run test:e2e + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/contributing.md b/contributing.md index 7656eb5fa..b72d71293 100644 --- a/contributing.md +++ b/contributing.md @@ -99,14 +99,13 @@ npm run dev ``` #### Customize Electron `userData` path -If `ELECTRON_APP_NAME` env-variable is present and its development mode, then the `appName` and `userData` path is modified accordingly. +If `ELECTRON_USER_DATA_PATH` env-variable is present and its development mode, then `userData` path is modified accordingly. e.g. ```sh -ELECTRON_APP_NAME=bruno-dev npm run dev:electron +ELECTRON_USER_DATA_PATH=$(realpath ~/Desktop/bruno-test) npm run dev:electron ``` - -> This doesn't change the name of the window or the names in lot of other places, only the name used by Electron internally. +This will create a `bruno-test` folder on your Desktop and use it as the `userData` path. ### Troubleshooting diff --git a/docs/contributing/contributing_es.md b/docs/contributing/contributing_es.md index f080a765c..fc1d9866b 100644 --- a/docs/contributing/contributing_es.md +++ b/docs/contributing/contributing_es.md @@ -1,4 +1,4 @@ -[English](../../contributing.md) +[Inglés](../../contributing.md) ## ¡Juntos, hagamos a Bruno mejor! @@ -6,58 +6,111 @@ Estamos encantados de que quieras ayudar a mejorar Bruno. A continuación encont ### Tecnologías utilizadas -Bruno está construido con NextJs y React. También usamos electron para distribuir una versión de escritorio (que soporta colecciones locales). +Bruno está construido con React y Electron Librerías que utilizamos: -- CSS - Tailwind -- Editores de código - Codemirror +- CSS - Tailwind CSS +- Editores de código - CodeMirror - Manejo del estado - Redux - Íconos - Tabler Icons - Formularios - formik - Validación de esquemas - Yup - Cliente de peticiones - axios - Monitor del sistema de archivos - chokidar +- i18n (internacionalización) - i18next ### Dependencias -Necesitarás [Node v20.x o la última versión LTS](https://nodejs.org/es) y npm 8.x. Ten en cuenta que utilizamos espacios de trabajo de npm en el proyecto. +> [!IMPORTANT] +> Necesitarás [Node v22.x o la última versión LTS](https://nodejs.org/es/). Ten en cuenta que Bruno usa los espacios de trabajo de npm ## Desarrollo -Bruno está siendo desarrollado como una aplicación de escritorio. Para ejecutarlo, primero debes ejecutar la aplicación de nextjs en una terminal y luego ejecutar la aplicación de electron en otra terminal. +Bruno es una aplicación de escritorio. A continuación se detallan las instrucciones paso a paso para ejecutar Bruno. -### Dependencias +> Nota: Utilizamos React para el frontend y rsbuild para el servidor de desarrollo. -- NodeJS v18 +### Instalar dependencias + +```bash +# Use la versión 22.x o LTS (Soporte a Largo Plazo) de Node.js +nvm use 22.11.0 + +# instalar las dependencias +npm i --legacy-peer-deps +``` + +> ¿Por qué `--legacy-peer-deps`?: Fuerza la instalación ignorando conflictos en dependencias “peer”, evitando errores de árbol de dependencias. ### Desarrollo local +#### Construir paquetes + +##### Opción 1 + ```bash -# Utiliza la versión 18 de nodejs -nvm use - -# Instala las dependencias -npm i --legacy-peer-deps - -# Construye la documentación de graphql +# construir paquetes npm run build:graphql-docs - -# Construye bruno-query npm run build:bruno-query +npm run build:bruno-common +npm run build:bruno-converters +npm run build:bruno-requests -# Ejecuta la aplicación de nextjs (terminal 1) +# empaquetar bibliotecas JavaScript del entorno de pruebas aislado +npm run sandbox:bundle-libraries --workspace=packages/bruno-js +``` + +##### Opción 2 + +```bash +# instalar dependencias y configurar el entorno +npm run setup +``` + +#### Ejecutar la aplicación + +```bash +# ejecutar aplicación react (terminal 1) npm run dev:web -# Ejecuta la aplicación de electron (terminal 2) +# ejecutar aplicación electron (terminal 2) npm run dev:electron ``` +##### Opción 1 + +```bash +# ejecutar aplicación react (terminal 1) +npm run dev:web + +# ejecutar aplicación electron (terminal 2) +npm run dev:electron +``` + +##### Opción 2 + +```bash +# ejecutar aplicación electron y react de forma concurrente +npm run dev +``` + +#### Personalizar la ruta `userData` de Electron + +Si la variable de entorno `ELECTRON_USER_DATA_PATH` está presente y se encuentra en modo de desarrollo, entonces la ruta `userData` se modifica en consecuencia. +ejemplo: + +```sh +ELECTRON_USER_DATA_PATH=$(realpath ~/Desktop/bruno-test) npm run dev:electron +``` + +Esto creará una carpeta llamada `bruno-test` en tu escritorio y la usará como la ruta userData. + ### Solución de problemas -Es posible que encuentres un error de `Unsupported platform` cuando ejecutes `npm install`. Para solucionarlo, debes eliminar la carpeta `node_modules` y el archivo `package-lock.json`, luego, ejecuta `npm install`. Lo anterior debería instalar todos los paquetes necesarios para ejecutar la aplicación. +Es posible que te encuentres con un error `Unsupported platform` cuando ejecutes `npm install`. Para solucionarlo, tendrás que eliminar las carpetas `node_modules` y el archivo `package-lock.json`, y luego volver a ejecutar `npm install`. Esto debería instalar todos los paquetes necesarios para que la aplicación funcione. -```shell +```sh # Elimina la carpeta node_modules en los subdirectorios find ./ -type d -name "node_modules" -print0 | while read -d $'\0' dir; do rm -rf "$dir" @@ -69,10 +122,42 @@ find . -type f -name "package-lock.json" -delete ### Pruebas -```bash -# ejecutar pruebas de esquema bruno -npm test --workspace=packages/bruno-schema +#### Pruebas individuales +```bash +# ejecutar pruebas de bruno-app +npm run test --workspace=packages/bruno-app + +# ejecutar pruebas de bruno-electron +npm run test --workspace=packages/bruno-electron + +# ejecutar pruebas de bruno-cli +npm run test --workspace=packages/bruno-cli + +# ejecutar pruebas de bruno-common +npm run test --workspace=packages/bruno-common + +# ejecutar pruebas de bruno-converters +npm run test --workspace=packages/bruno-converters + +# ejecutar pruebas de bruno-schema +npm run test --workspace=packages/bruno-schema + +# ejecutar pruebas de bruno-query +npm run test --workspace=packages/bruno-query + +# ejecutar pruebas de bruno-js +npm run test --workspace=packages/bruno-js + +# ejecutar pruebas de bruno-lang +npm run test --workspace=packages/bruno-lang + +# ejecutar pruebas de bruno-toml +npm run test --workspace=packages/bruno-toml +``` +#### Pruebas en conjunto + +```bash # ejecutar pruebas en todos los espacios de trabajo npm test --workspaces --if-present ``` diff --git a/docs/readme/readme_ar.md b/docs/readme/readme_ar.md index d72eb834b..9e7829fcc 100644 --- a/docs/readme/readme_ar.md +++ b/docs/readme/readme_ar.md @@ -74,12 +74,11 @@ flatpak install com.usebruno.Bruno # على نظام Linux عبر Apt sudo mkdir -p /etc/apt/keyrings +sudo apt update && sudo apt install gpg +sudo gpg --list-keys sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266 - -echo "deb [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 +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 ``` ### التشغيل عبر منصات متعددة 🖥️ diff --git a/docs/readme/readme_bn.md b/docs/readme/readme_bn.md index 88c2c699b..ffcc1a916 100644 --- a/docs/readme/readme_bn.md +++ b/docs/readme/readme_bn.md @@ -59,12 +59,11 @@ snap install bruno # Apt এর মাধ্যমে লিনাক্সে sudo mkdir -p /etc/apt/keyrings +sudo apt update && sudo apt install gpg +sudo gpg --list-keys sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266 - -echo "deb [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 +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 ``` ### একাধিক প্ল্যাটফর্মে চালান 🖥️ diff --git a/docs/readme/readme_cn.md b/docs/readme/readme_cn.md index 7b26bd217..560e34cd1 100644 --- a/docs/readme/readme_cn.md +++ b/docs/readme/readme_cn.md @@ -63,12 +63,11 @@ snap install bruno # 在 Linux 上用 Apt 安装 sudo mkdir -p /etc/apt/keyrings +sudo apt update && sudo apt install gpg +sudo gpg --list-keys sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266 - -echo "deb [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 +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 ``` ### 在 Mac 上通过 Homebrew 安装 🖥️ diff --git a/docs/readme/readme_de.md b/docs/readme/readme_de.md index 9c5d27ecc..d7c1a89be 100644 --- a/docs/readme/readme_de.md +++ b/docs/readme/readme_de.md @@ -78,12 +78,11 @@ flatpak install com.usebruno.Bruno # Auf Linux via Apt sudo mkdir -p /etc/apt/keyrings +sudo apt update && sudo apt install gpg +sudo gpg --list-keys sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266 - -echo "deb [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 +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 ``` ### Einsatz auf verschiedensten Plattformen 🖥️ diff --git a/docs/readme/readme_es.md b/docs/readme/readme_es.md index 4cdadc48a..58e293bad 100644 --- a/docs/readme/readme_es.md +++ b/docs/readme/readme_es.md @@ -75,12 +75,11 @@ flatpak install com.usebruno.Bruno # En Linux con Apt sudo mkdir -p /etc/apt/keyrings +sudo apt update && sudo apt install gpg +sudo gpg --list-keys sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266 - -echo "deb [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 +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 ``` ### Ejecútalo en múltiples plataformas 🖥️ diff --git a/docs/readme/readme_fr.md b/docs/readme/readme_fr.md index c02e6ecbd..c3553bebd 100644 --- a/docs/readme/readme_fr.md +++ b/docs/readme/readme_fr.md @@ -63,12 +63,11 @@ snap install bruno # Linux via Apt sudo mkdir -p /etc/apt/keyrings +sudo apt update && sudo apt install gpg +sudo gpg --list-keys sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266 - -echo "deb [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 +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 ``` ### Fonctionne sur de multiples plateformes 🖥️ diff --git a/docs/readme/readme_hi.md b/docs/readme/readme_hi.md new file mode 100644 index 000000000..395f38dec --- /dev/null +++ b/docs/readme/readme_hi.md @@ -0,0 +1,151 @@ +
+ + +### ब्रूनो - API इंटरफेस (API) का अन्वेषण और परीक्षण करने के लिए एक ओपन-सोर्स विकास वातावरण। + +[![GitHub संस्करण](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno) +[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml) +[![कमिट गतिविधि](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) +[![वेबसाइट](https://img.shields.io/badge/Website-Visit-blue)](https://www.usebruno.com) +[![डाउनलोड](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) +| [Italiano](./readme_it.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 और अन्य समान उपकरणों द्वारा प्रस्तुत स्थिति को बदलना है। + +ब्रूनो आपकी कलेक्शनों को सीधे आपकी फाइल सिस्टम के एक फ़ोल्डर में संग्रहीत करता है। हम API अनुरोधों के बारे में जानकारी सहेजने के लिए एक सामान्य टेक्स्ट मार्कअप भाषा, Bru, का उपयोग करते हैं। + +आप अपनी 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)

+ +### गोल्डन संस्करण ✨ + +हमारी अधिकांश सुविधाएँ मुफ्त और ओपन-सोर्स हैं। +हम [पारदर्शिता और स्थिरता के सिद्धांतों](https://github.com/usebruno/bruno/discussions/269) के बीच एक सामंजस्यपूर्ण संतुलन प्राप्त करने का प्रयास करते हैं। + +[गोल्डन संस्करण](https://www.usebruno.com/pricing) के लिए खरीदारी जल्द ही $9 की कीमत पर उपलब्ध होगी!
+[यहाँ सदस्यता लें](https://usebruno.ck.page/4c65576bd4) ताकि आपको लॉन्च पर सूचनाएं मिलें। + +### स्थापना + +ब्रूनो Mac, Windows और Linux के लिए हमारे [वेबसाइट](https://www.usebruno.com/downloads) पर एक बाइनरी डाउनलोड के रूप में उपलब्ध है। + +आप ब्रूनो को Homebrew, Chocolatey, Scoop, Snap, Flatpak और Apt जैसे पैकेज प्रबंधकों के माध्यम से भी स्थापित कर सकते हैं। + +```sh +# Mac पर Homebrew के माध्यम से +brew install bruno + +# Windows पर Chocolatey के माध्यम से +choco install bruno + +# Windows पर Scoop के माध्यम से +scoop bucket add extras +scoop install bruno + +# Linux पर Snap के माध्यम से +snap install bruno + +# Linux पर Flatpak के माध्यम से +flatpak install com.usebruno.Bruno + +# Linux पर Apt के माध्यम से +sudo mkdir -p /etc/apt/keyrings +sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266 + +echo "deb [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 + +कई प्लेटफार्मों पर चलाएं 🖥️ +

+ +Git के माध्यम से सहयोग करें 👩‍💻🧑‍💻 +या अपनी पसंद के किसी भी संस्करण नियंत्रण प्रणाली का उपयोग करें + +

+ +महत्वपूर्ण लिंक 📌 +हमारी दीर्घकालिक दृष्टि + +रोडमैप + +प्रलेखन + +Stack Overflow + +वेबसाइट + +मूल्य निर्धारण + +डाउनलोड + +GitHub प्रायोजक + +प्रस्तुतियाँ 🎥 +प्रशंसापत्र + +ज्ञान केंद्र + +Scriptmania + +समर्थन ❤️ +यदि आप ब्रूनो को पसंद करते हैं और हमारे ओपन-सोर्स कार्य का समर्थन करना चाहते हैं, तो कृपया GitHub प्रायोजक के माध्यम से हमें प्रायोजित करने पर विचार करें। + +प्रशंसापत्र साझा करें 📣 +यदि ब्रूनो ने आपके और आपकी टीमों के लिए काम में मदद की है, तो कृपया हमारे GitHub चर्चा में अपने प्रशंसापत्र साझा करना न भूलें + +नए पैकेज प्रबंधकों में प्रकाशित करना +अधिक जानकारी के लिए कृपया यहाँ देखें। + +हमसे संपर्क करें 🌐 +𝕏 (ट्विटर)
+वेबसाइट
+डिस्कॉर्ड
+लिंक्डइन + +ट्रेडमार्क +नाम + +ब्रूनो एक ट्रेडमार्क है जो अनूप एम डी के स्वामित्व में है। + +लोगो + +लोगो OpenMoji से लिया गया है। लाइसेंस: CC BY-SA 4.0 + +योगदान 👩‍💻🧑‍💻 +हमें खुशी है कि आप ब्रूनो को बेहतर बनाने में रुचि रखते हैं। कृपया योगदान गाइड देखें। + +यदि आप सीधे कोड के माध्यम से योगदान नहीं कर सकते, तो भी कृपया बग्स की रिपोर्ट करने और उन सुविधाओं का अनुरोध करने में संकोच न करें जिन्हें आपकी स्थिति को हल करने के लिए लागू किया जाना चाहिए। + +लेखक +
+ +लाइसेंस 📄 +MIT + diff --git a/docs/readme/readme_it.md b/docs/readme/readme_it.md index 0f47e6399..82a84564f 100644 --- a/docs/readme/readme_it.md +++ b/docs/readme/readme_it.md @@ -59,12 +59,11 @@ snap install bruno # Su Linux tramite Apt sudo mkdir -p /etc/apt/keyrings +sudo apt update && sudo apt install gpg +sudo gpg --list-keys sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266 - -echo "deb [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 +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 ``` ### Funziona su diverse piattaforme 🖥️ diff --git a/docs/readme/readme_ja.md b/docs/readme/readme_ja.md index 970cd84d8..ea494e9fd 100644 --- a/docs/readme/readme_ja.md +++ b/docs/readme/readme_ja.md @@ -78,12 +78,11 @@ flatpak install com.usebruno.Bruno # LinuxでAptを使ってインストール sudo mkdir -p /etc/apt/keyrings +sudo apt update && sudo apt install gpg +sudo gpg --list-keys sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266 - -echo "deb [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 +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 ``` ### マルチプラットフォームでの実行に対応 🖥️ diff --git a/docs/readme/readme_kr.md b/docs/readme/readme_kr.md index 52bd3c97e..c901d6720 100644 --- a/docs/readme/readme_kr.md +++ b/docs/readme/readme_kr.md @@ -59,12 +59,11 @@ snap install bruno # On Linux via Apt sudo mkdir -p /etc/apt/keyrings +sudo apt update && sudo apt install gpg +sudo gpg --list-keys sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266 - -echo "deb [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 +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 ``` ### 여러 플랫폼에서 실행하세요. 🖥️ diff --git a/docs/readme/readme_pl.md b/docs/readme/readme_pl.md index d32adb9be..bef47b724 100644 --- a/docs/readme/readme_pl.md +++ b/docs/readme/readme_pl.md @@ -69,12 +69,11 @@ flatpak install com.usebruno.Bruno # On Linux via Apt sudo mkdir -p /etc/apt/keyrings +sudo apt update && sudo apt install gpg +sudo gpg --list-keys sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266 - 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 +sudo apt update && sudo apt install bruno ``` ### Uruchom na wielu platformach 🖥️ diff --git a/docs/readme/readme_pt_br.md b/docs/readme/readme_pt_br.md index 2ea06b4ba..ba83906b3 100644 --- a/docs/readme/readme_pt_br.md +++ b/docs/readme/readme_pt_br.md @@ -76,12 +76,11 @@ flatpak install com.usebruno.Bruno # No Linux via Apt sudo mkdir -p /etc/apt/keyrings +sudo apt update && sudo apt install gpg +sudo gpg --list-keys sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266 - -echo "deb [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 +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 ``` ### Execute em várias plataformas 🖥️ diff --git a/docs/readme/readme_ro.md b/docs/readme/readme_ro.md index b7ef5089f..fe82a5ce9 100644 --- a/docs/readme/readme_ro.md +++ b/docs/readme/readme_ro.md @@ -59,12 +59,11 @@ snap install bruno # Pe Linux cu Apt sudo mkdir -p /etc/apt/keyrings +sudo apt update && sudo apt install gpg +sudo gpg --list-keys sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266 - -echo "deb [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 +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 ``` ### Utilizați pe mai multe platforme 🖥️ diff --git a/docs/readme/readme_tr.md b/docs/readme/readme_tr.md index b477687e8..cfd4df1bc 100644 --- a/docs/readme/readme_tr.md +++ b/docs/readme/readme_tr.md @@ -63,12 +63,11 @@ snap install bruno # Apt aracılığıyla Linux'ta sudo mkdir -p /etc/apt/keyrings +sudo apt update && sudo apt install gpg +sudo gpg --list-keys sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266 - -echo "deb [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 +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 ``` ### Birden fazla platformda çalıştırın 🖥️ diff --git a/docs/readme/readme_zhtw.md b/docs/readme/readme_zhtw.md index 320904585..92e08edd5 100644 --- a/docs/readme/readme_zhtw.md +++ b/docs/readme/readme_zhtw.md @@ -63,12 +63,11 @@ snap install bruno # 在 Linux 上使用 Apt 安裝 sudo mkdir -p /etc/apt/keyrings +sudo apt update && sudo apt install gpg +sudo gpg --list-keys sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266 - -echo "deb [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 +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 ``` ### 跨多個平台運行 🖥️ diff --git a/e2e-tests/001-sanity-tests/001-home-screen.spec.ts b/e2e-tests/001-sanity-tests/001-home-screen.spec.ts new file mode 100644 index 000000000..d993fb7bc --- /dev/null +++ b/e2e-tests/001-sanity-tests/001-home-screen.spec.ts @@ -0,0 +1,5 @@ +import { test, expect } from '../../playwright'; + +test('Check if the logo on top left is visible', async ({ page }) => { + await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible(); +}); \ No newline at end of file diff --git a/e2e-tests/001-sanity-tests/002-create-new-collection-and-new-request.spec.ts b/e2e-tests/001-sanity-tests/002-create-new-collection-and-new-request.spec.ts new file mode 100644 index 000000000..705a2bc3b --- /dev/null +++ b/e2e-tests/001-sanity-tests/002-create-new-collection-and-new-request.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '../../playwright'; + +test('Create new collection and add a simple HTTP request', async ({ page, createTmpDir }) => { + await page.getByLabel('Create Collection').click(); + await page.getByLabel('Name').click(); + await page.getByLabel('Name').fill('test-collection'); + await page.getByLabel('Name').press('Tab'); + await page.getByLabel('Location').fill(await createTmpDir('test-collection')); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await page.getByText('test-collection').click(); + await page.getByLabel('Safe Mode').check(); + await page.getByRole('button', { name: 'Save' }).click(); + await page.locator('#create-new-tab').getByRole('img').click(); + await page.getByPlaceholder('Request Name').fill('r1'); + await page.locator('#new-request-url .CodeMirror').click(); + await page.locator('textarea').fill('http://localhost:8081'); + await page.getByRole('button', { name: 'Create' }).click(); + await page.locator('#request-url .CodeMirror').click(); + await page.locator('textarea').fill('/ping'); + await page.locator('#send-request').getByRole('img').nth(2).click(); + + await expect(page.getByRole('main')).toContainText('200 OK'); + + await page.getByRole('tab', { name: 'GET r1' }).locator('circle').click(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await page.getByText('GETr1').click(); + await page.getByRole('button', { name: 'Clear response' }).click(); + await page.locator('body').press('ControlOrMeta+Enter'); + + await expect(page.getByRole('main')).toContainText('200 OK'); +}); \ No newline at end of file diff --git a/e2e-tests/bruno-testbench/init-user-data/preferences.json b/e2e-tests/bruno-testbench/init-user-data/preferences.json new file mode 100644 index 000000000..4ab7e9620 --- /dev/null +++ b/e2e-tests/bruno-testbench/init-user-data/preferences.json @@ -0,0 +1,4 @@ +{ + "maximized": true, + "lastOpenedCollections": ["{{projectRoot}}/packages/bruno-tests/collection"] +} \ No newline at end of file diff --git a/e2e-tests/bruno-testbench/run-testbench-requests.spec.ts b/e2e-tests/bruno-testbench/run-testbench-requests.spec.ts new file mode 100644 index 000000000..874c9f276 --- /dev/null +++ b/e2e-tests/bruno-testbench/run-testbench-requests.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from '../../playwright'; + +test.describe.parallel('Run Testbench Requests', () => { + test('Run bruno-testbench in Developer Mode', async ({ pageWithUserData: page }) => { + test.setTimeout(2 * 60 * 1000); + + await page.getByText('bruno-testbench').click(); + await page.getByLabel('Developer Mode(use only if').check(); + await page.getByRole('button', { name: 'Save' }).click(); + await page.locator('.environment-selector').nth(1).click(); + await page.locator('.dropdown-item').getByText('Prod').click(); + await page.locator('.collection-actions').hover(); + await page.locator('.collection-actions .icon').click(); + await page.getByText('Run', { exact: true }).click(); + await page.getByRole('button', { name: 'Run Collection' }).click(); + await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 }); + + const result = await page.getByText('Total Requests: ').innerText(); + const [totalRequests, passed, failed, skipped] = result + .match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/) + .slice(1); + + await expect(parseInt(failed)).toBe(0); + await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed)); + }); + + test.fixme('Run bruno-testbench in Safe Mode', async ({ pageWithUserData: page }) => { + test.setTimeout(2 * 60 * 1000); + + await page.getByText('bruno-testbench').click(); + await page.getByLabel('Safe Mode').check(); + await page.getByRole('button', { name: 'Save' }).click(); + await page.locator('.environment-selector').nth(1).click(); + await page.locator('.dropdown-item').getByText('Prod').click(); + await page.locator('.collection-actions').hover(); + await page.locator('.collection-actions .icon').click(); + await page.getByText('Run', { exact: true }).click(); + await page.getByRole('button', { name: 'Run Collection' }).click(); + await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 }); + + const result = await page.getByText('Total Requests: ').innerText(); + const [totalRequests, passed, failed, skipped] = result + .match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/) + .slice(1); + + await expect(parseInt(failed)).toBe(0); + await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed)); + }); +}); \ No newline at end of file diff --git a/e2e-tests/preferences/verify-support-links-in-preferences.spec.js b/e2e-tests/preferences/verify-support-links-in-preferences.spec.js new file mode 100644 index 000000000..5f12a866c --- /dev/null +++ b/e2e-tests/preferences/verify-support-links-in-preferences.spec.js @@ -0,0 +1,27 @@ +import { test, expect } from '../../playwright'; + +test('Should verify all support links with correct URL in preference > Support tab', async ({ page }) => { + + // Open Preferences + await page.getByLabel('Open Preferences').click(); + + // Verify Support tab + await page.getByRole('tab', { name: 'Support' }).click(); + + const locator_twitter = page.getByRole('link', { name: 'Twitter' }); + expect(await locator_twitter.getAttribute('href')).toEqual('https://twitter.com/use_bruno'); + + const locator_github = page.getByRole('link', { name: 'GitHub', exact: true }); + expect(await locator_github.getAttribute('href')).toEqual('https://github.com/usebruno/bruno'); + + const locator_discord = page.getByRole('link', { name: 'Discord', exact: true }); + expect(await locator_discord.getAttribute('href')).toEqual('https://discord.com/invite/KgcZUncpjq'); + + const locator_reportissues = page.getByRole('link', { name: 'Report Issues', exact: true }); + expect(await locator_reportissues.getAttribute('href')).toEqual('https://github.com/usebruno/bruno/issues'); + + const locator_documentation = page.getByRole('link', { name: 'Documentation', exact: true }); + expect(await locator_documentation.getAttribute('href')).toEqual('https://docs.usebruno.com'); + + +}); \ No newline at end of file diff --git a/e2e-tests/test-app-start.spec.ts b/e2e-tests/test-app-start.spec.ts deleted file mode 100644 index 891c7ce3b..000000000 --- a/e2e-tests/test-app-start.spec.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { test, expect } from '../playwright'; - -test('test-app-start', async ({ page }) => { - await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible(); -}); \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 40f6c3351..30930c550 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -25,6 +25,19 @@ module.exports = defineConfig([ "no-undef": "error", }, }, + { + // It prevents lint errors when using CommonJS exports (module.exports) in Jest mocks. + files: ["packages/bruno-app/src/test-utils/mocks/codemirror.js"], + languageOptions: { + globals: { + ...globals.node, + ...globals.jest, + }, + }, + rules: { + "no-undef": "error", + }, + }, { files: ["packages/bruno-electron/**/*.{js}"], ignores: ["**/*.config.js"], @@ -38,4 +51,4 @@ module.exports = defineConfig([ "no-undef": "error", }, } -]); +]); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9e78ac936..577b1d843 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,13 @@ "ts-jest": "^29.2.6" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.3.tgz", + "integrity": "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -1451,14 +1458,14 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" @@ -1527,13 +1534,13 @@ "license": "MIT" }, "node_modules/@babel/generator": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz", - "integrity": "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz", + "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.3", - "@babel/types": "^7.26.3", + "@babel/parser": "^7.27.1", + "@babel/types": "^7.27.1", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -1543,12 +1550,12 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", - "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz", + "integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==", "license": "MIT", "dependencies": { - "@babel/types": "^7.25.9" + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1595,6 +1602,7 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.26.3.tgz", "integrity": "sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", @@ -1612,6 +1620,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz", "integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", @@ -1628,6 +1637,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1645,6 +1655,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/@babel/helper-member-expression-to-functions": { @@ -1661,13 +1672,13 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1703,9 +1714,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", - "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1715,6 +1726,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", @@ -1759,27 +1771,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1789,6 +1801,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", + "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.25.9", @@ -1813,12 +1826,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", - "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz", + "integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==", "license": "MIT", "dependencies": { - "@babel/types": "^7.26.3" + "@babel/types": "^7.27.1" }, "bin": { "parser": "bin/babel-parser.js" @@ -1831,6 +1844,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -1847,6 +1861,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -1862,6 +1877,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz", "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -1877,6 +1893,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -1894,6 +1911,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -1928,6 +1946,7 @@ "version": "7.21.0-placeholder-for-preset-env.2", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2026,6 +2045,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2041,6 +2061,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2079,12 +2100,12 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", - "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -2222,6 +2243,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", @@ -2238,6 +2260,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2253,6 +2276,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.9.tgz", "integrity": "sha512-RXV6QAzTBbhDMO9fWwOmwwTuYaiPbggWQ9INdZqAYeSHyG7FzQ+nOZaUUjNwKv9pV3aE4WFqFm1Hnbci5tBCAw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -2270,6 +2294,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.25.9", @@ -2287,6 +2312,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.25.9.tgz", "integrity": "sha512-toHc9fzab0ZfenFpsyYinOX0J/5dgJVA2fm64xPewu7CoYHWEivIWKxkK2rMi4r3yQqLnVmheMXRdG+k239CgA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2302,6 +2328,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz", "integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2333,6 +2360,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.25.9", @@ -2349,6 +2377,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", @@ -2369,6 +2398,7 @@ "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -2378,6 +2408,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -2394,6 +2425,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2409,6 +2441,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -2425,6 +2458,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2440,6 +2474,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -2456,6 +2491,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2471,6 +2507,7 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.26.3.tgz", "integrity": "sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2486,6 +2523,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2517,6 +2555,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz", "integrity": "sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -2533,6 +2572,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.25.9", @@ -2550,6 +2590,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2565,6 +2606,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2580,6 +2622,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2595,6 +2638,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2610,6 +2654,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.25.9", @@ -2642,6 +2687,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.25.9", @@ -2660,6 +2706,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.25.9", @@ -2676,6 +2723,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -2692,6 +2740,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2722,6 +2771,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2737,6 +2787,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.25.9", @@ -2754,6 +2805,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -2770,6 +2822,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2801,6 +2854,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2832,6 +2886,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", @@ -2849,6 +2904,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2860,10 +2916,80 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.27.1.tgz", + "integrity": "sha512-p9+Vl3yuHPmkirRrg021XiP+EETmPMQTLr6Ayjj85RLNEbb3Eya/4VI0vAdzQG9SEAl2Lnt7fy5lZyMzjYoZQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz", + "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", + "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-regenerator": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz", "integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -2880,6 +3006,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz", "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -2896,6 +3023,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2911,6 +3039,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2926,6 +3055,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -2942,6 +3072,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2957,6 +3088,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.25.9.tgz", "integrity": "sha512-o97AE4syN71M/lxrCtQByzphAdlYluKPDBzDVzMmfCobUjjhAryZV0AIpRPrxN0eAkxXO6ZLEScmt+PNhj2OTw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2972,6 +3104,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.9.tgz", "integrity": "sha512-v61XqUMiueJROUv66BVIOi0Fv/CUuZuZMl5NkRoCVxLAnMexZ0A3kMe7vvZ0nulxMuMp0Mk6S5hNh48yki08ZA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3006,6 +3139,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3021,6 +3155,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -3037,6 +3172,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -3053,6 +3189,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -3069,6 +3206,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.0.tgz", "integrity": "sha512-H84Fxq0CQJNdPFT2DrfnylZ3cf5K43rGfWK4LJGPpjKHiZlk0/RzwEus3PDDZZg+/Er7lCA03MVacueUuXdzfw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.26.0", @@ -3169,6 +3307,7 @@ "version": "0.1.6-no-external-plugins", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", @@ -3179,6 +3318,27 @@ "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/@babel/preset-react": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.27.1.tgz", + "integrity": "sha512-oJHWh2gLhU9dW9HHr42q0cI0/iHHXTLGe39qvpAZZzagHy0MzYLCnCVV0symeRvzmjHyVU7mw2K06E6u/JwbhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-transform-react-display-name": "^7.27.1", + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@babel/plugin-transform-react-pure-annotations": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/preset-typescript": { "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.26.0.tgz", @@ -3261,30 +3421,30 @@ } }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.26.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz", - "integrity": "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz", + "integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.3", - "@babel/parser": "^7.26.3", - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.3", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.1", + "@babel/parser": "^7.27.1", + "@babel/template": "^7.27.1", + "@babel/types": "^7.27.1", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -3325,13 +3485,13 @@ "license": "MIT" }, "node_modules/@babel/types": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", - "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", + "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -8049,6 +8209,34 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/jest-dom": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", + "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tippyjs/react": { "version": "4.2.6", "resolved": "https://registry.npmjs.org/@tippyjs/react/-/react-4.2.6.tgz", @@ -8082,6 +8270,13 @@ "node": ">=10.13.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -8269,6 +8464,44 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/jsdom": { + "version": "20.0.1", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", + "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, + "node_modules/@types/jsdom/node_modules/entities": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", + "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@types/jsdom/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -8421,6 +8654,13 @@ "@types/estree": "*" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -8834,6 +9074,14 @@ "dev": true, "license": "MIT" }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -8885,6 +9133,17 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -9263,12 +9522,45 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -9619,6 +9911,7 @@ "version": "0.4.12", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz", "integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==", + "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.22.6", @@ -9633,6 +9926,7 @@ "version": "0.10.6", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.2", @@ -9646,6 +9940,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz", "integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.3" @@ -9730,6 +10025,12 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base16": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base16/-/base16-1.0.0.tgz", + "integrity": "sha512-pNdYkNPiJUnEhnfXV56+sQy8+AaPcG3POZAUnwr4EeqCUZFz4u2PePbo3e5Gj4ziYPCWGUZT9RHisvJKnwFuBQ==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -11709,6 +12010,7 @@ "version": "3.39.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.39.0.tgz", "integrity": "sha512-VgEUx3VwlExr5no0tXlBt+silBvhTryPwCXRI2Id1PN8WTKu7MreethvddqOubrYxkFdv/RnYrqlv1sFNAUelw==", + "dev": true, "license": "MIT", "dependencies": { "browserslist": "^4.24.2" @@ -12129,6 +12431,13 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -12234,6 +12543,33 @@ "node": ">=8.0.0" } }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -12252,6 +12588,68 @@ "node": ">=0.10" } }, + "node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -12304,6 +12702,13 @@ "ms": "2.0.0" } }, + "node_modules/decimal.js": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", + "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-uri-component": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", @@ -12382,6 +12787,46 @@ "node": ">=6" } }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/deep-equal/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -12732,6 +13177,13 @@ "redux": "^4.2.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-converter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", @@ -12792,6 +13244,30 @@ "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", "license": "BSD-2-Clause" }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "license": "MIT", + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/domexception/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/domhandler": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.3.0.tgz", @@ -13387,6 +13863,34 @@ "node": ">= 0.4" } }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-get-iterator/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", @@ -13442,6 +13946,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/eslint": { "version": "9.26.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.26.0.tgz", @@ -13809,6 +14345,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -14438,6 +14975,36 @@ "bser": "2.1.1" } }, + "node_modules/fbemitter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fbemitter/-/fbemitter-3.0.0.tgz", + "integrity": "sha512-KWKaceCwKQU0+HPoop6gn4eOHk50bBv/VxjJtGMfwmJt3D29JpN4H4eisCtIPA+a8GVBam+ldMMpMjJUvpDyHw==", + "license": "BSD-3-Clause", + "dependencies": { + "fbjs": "^3.0.0" + } + }, + "node_modules/fbjs": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", + "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.1.5", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^1.0.35" + } + }, + "node_modules/fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", + "license": "MIT" + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -14734,6 +15301,19 @@ "node": ">=0.4.0" } }, + "node_modules/flux": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/flux/-/flux-4.0.4.tgz", + "integrity": "sha512-NCj3XlayA2UsapRpM7va6wU1+9rE5FIL7qoMcmxWHRzbp0yujihMBm9BBHZ1MDIk5h5o2Bl6eGiCe8rYELAmYw==", + "license": "BSD-3-Clause", + "dependencies": { + "fbemitter": "^3.0.0", + "fbjs": "^3.0.1" + }, + "peerDependencies": { + "react": "^15.0.2 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/follow-redirects": { "version": "1.15.9", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", @@ -14976,6 +15556,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gauge": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", @@ -15093,6 +15683,20 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", @@ -15431,6 +16035,19 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -15595,6 +16212,32 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/html-encoding-sniffer/node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/html-entities": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz", @@ -16220,6 +16863,21 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/interpret": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", @@ -16269,12 +16927,46 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -16287,6 +16979,23 @@ "node": ">=8" } }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -16317,6 +17026,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -16328,6 +17038,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -16434,6 +17161,19 @@ "dev": true, "license": "MIT" }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", @@ -16467,6 +17207,23 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-obj": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", @@ -16488,6 +17245,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-primitive": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-3.0.1.tgz", @@ -16514,6 +17278,25 @@ "@types/estree": "*" } }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", @@ -16523,6 +17306,35 @@ "node": ">=0.10.0" } }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -16536,6 +17348,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-typed-array": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", @@ -16583,6 +17430,36 @@ "node": ">=0.10.0" } }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -17153,6 +18030,34 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/jest-environment-jsdom": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", + "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/jsdom": "^20.0.0", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0", + "jsdom": "^20.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jest-environment-node": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", @@ -17864,6 +18769,205 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/jsdom": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/jsdom/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", + "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/jsdom/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/jsep": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", @@ -18339,10 +19443,23 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.curry": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.curry/-/lodash.curry-4.1.1.tgz", + "integrity": "sha512-/u14pXGviLaweY5JI0IUzgzF2J6Ne8INyzAZjImcryjgkZ+ebruBxy2/JaOOkTqScddcYtakjhSaeemV8lR0tA==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.flow": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/lodash.flow/-/lodash.flow-3.5.0.tgz", + "integrity": "sha512-ff3BX/tSioo+XojX4MOsOMhJw0nZoUEF011LX8g8d3gvjVbxd89cCio4BCXronjxcTUIJUoqKEUA+n4CqvvRPw==", "license": "MIT" }, "node_modules/lodash.includes": { @@ -18492,6 +19609,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", @@ -19047,6 +20174,16 @@ "node": ">=4" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mini-css-extract-plugin": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.2.tgz", @@ -19751,6 +20888,13 @@ "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", "license": "MIT" }, + "node_modules/nwsapi": { + "version": "2.2.20", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", + "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", + "dev": true, + "license": "MIT" + }, "node_modules/oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -20329,6 +21473,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, "license": "MIT" }, "node_modules/path-scurry": { @@ -21684,6 +22829,15 @@ "node": ">=0.4.0" } }, + "node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.3" + } + }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -21828,6 +22982,12 @@ "node": ">=6" } }, + "node_modules/pure-color": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pure-color/-/pure-color-1.3.0.tgz", + "integrity": "sha512-QFADYnsVoBMw1srW7OVKEYjG+MbIa49s54w1MA1EDY6r2r/sTcKKYqRX1f4GYvnXP7eN/Pe9HFcX+hwzmrXRHA==", + "license": "MIT" + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -22040,6 +23200,18 @@ "node": ">=0.10.0" } }, + "node_modules/react-base16-styling": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/react-base16-styling/-/react-base16-styling-0.6.0.tgz", + "integrity": "sha512-yvh/7CArceR/jNATXOKDlvTnPKPmGZz7zsenQ3jUwLzHkNUR0CvY3yGYJbWJ/nnxsL8Sgmt5cO3/SILVuPO6TQ==", + "license": "MIT", + "dependencies": { + "base16": "^1.0.0", + "lodash.curry": "^4.0.1", + "lodash.flow": "^3.3.0", + "pure-color": "^1.2.0" + } + }, "node_modules/react-copy-to-clipboard": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.0.tgz", @@ -22163,6 +23335,28 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-json-view": { + "version": "1.21.3", + "resolved": "https://registry.npmjs.org/react-json-view/-/react-json-view-1.21.3.tgz", + "integrity": "sha512-13p8IREj9/x/Ye4WI/JpjhoIwuzEgUAtgJZNBJckfzJt1qyh24BdTm6UQNGnyTq9dapQdrqvquZTo3dz1X6Cjw==", + "license": "MIT", + "dependencies": { + "flux": "^4.0.1", + "react-base16-styling": "^0.6.0", + "react-lifecycles-compat": "^3.0.4", + "react-textarea-autosize": "^8.3.2" + }, + "peerDependencies": { + "react": "^17.0.0 || ^16.3.0 || ^15.5.4", + "react-dom": "^17.0.0 || ^16.3.0 || ^15.5.4" + } + }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", + "license": "MIT" + }, "node_modules/react-pdf": { "version": "9.1.1", "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-9.1.1.tgz", @@ -22333,6 +23527,23 @@ } } }, + "node_modules/react-textarea-autosize": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", + "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-tooltip": { "version": "5.28.0", "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.28.0.tgz", @@ -22488,6 +23699,20 @@ "node": ">= 0.10" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reduce-configs": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reduce-configs/-/reduce-configs-1.1.0.tgz", @@ -22517,12 +23742,14 @@ "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, "license": "MIT" }, "node_modules/regenerate-unicode-properties": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", + "dev": true, "license": "MIT", "dependencies": { "regenerate": "^1.4.2" @@ -22541,15 +23768,38 @@ "version": "0.15.2", "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.4" } }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/regexpu-core": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", + "dev": true, "license": "MIT", "dependencies": { "regenerate": "^1.4.2", @@ -22567,12 +23817,14 @@ "version": "0.8.0", "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, "license": "MIT" }, "node_modules/regjsparser": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "jsesc": "~3.0.2" @@ -22585,6 +23837,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -22758,6 +24011,7 @@ "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.0", @@ -23259,6 +24513,24 @@ "dev": true, "license": "ISC" }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -23730,6 +25002,19 @@ "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", "license": "ISC" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", @@ -23928,6 +25213,22 @@ "node": ">= 0.4" } }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/set-value": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/set-value/-/set-value-4.1.0.tgz", @@ -23950,7 +25251,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "dev": true, "license": "MIT" }, "node_modules/setprototypeof": { @@ -24446,6 +25746,20 @@ "node": ">= 0.8" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/stream": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stream/-/stream-0.0.2.tgz", @@ -24682,6 +25996,19 @@ "node": ">=6" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.1.tgz", @@ -24965,6 +26292,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -25005,6 +26333,13 @@ "node": ">= 10" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/sync-child-process": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", @@ -25789,6 +27124,32 @@ "node": ">=4.2.0" } }, + "node_modules/ua-parser-js": { + "version": "1.0.40", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz", + "integrity": "sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/uc.micro": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", @@ -25815,6 +27176,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -25824,6 +27186,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, "license": "MIT", "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", @@ -25837,6 +27200,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -25846,6 +27210,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -26007,6 +27372,51 @@ } } }, + "node_modules/use-composed-ref": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", + "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", + "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", + "license": "MIT", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sidecar": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", @@ -26153,6 +27563,19 @@ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -26379,6 +27802,45 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/which-typed-array": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.18.tgz", @@ -26483,6 +27945,28 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-formatter": { "version": "3.6.3", "resolved": "https://registry.npmjs.org/xml-formatter/-/xml-formatter-3.6.3.tgz", @@ -26495,6 +27979,16 @@ "node": ">= 16" } }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, "node_modules/xml-parser-xo": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/xml-parser-xo/-/xml-parser-xo-4.1.3.tgz", @@ -26535,6 +28029,13 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -26668,8 +28169,8 @@ "packages/bruno-app": { "name": "@usebruno/app", "version": "2.0.0", + "license": "MIT", "dependencies": { - "@babel/preset-env": "^7.26.0", "@fontsource/inter": "^5.0.15", "@prantlf/jsonlint": "^16.0.0", "@reduxjs/toolkit": "^1.8.0", @@ -26725,34 +28226,44 @@ "react-hot-toast": "^2.4.0", "react-i18next": "^15.0.1", "react-inspector": "^6.0.2", + "react-json-view": "^1.21.3", "react-pdf": "9.1.1", "react-player": "^2.16.0", "react-redux": "^7.2.9", "react-tooltip": "^5.5.2", "sass": "^1.46.0", "semver": "^7.7.1", + "shell-quote": "^1.8.3", "strip-json-comments": "^5.0.1", "styled-components": "^5.3.3", "system": "^2.0.1", "url": "^0.11.3", "xml-formatter": "^3.5.0", - "yargs-parser": "^21.1.1", "yup": "^0.32.11" }, "devDependencies": { + "@babel/core": "^7.27.1", + "@babel/preset-env": "^7.27.2", + "@babel/preset-react": "^7.27.1", "@rsbuild/core": "^1.1.2", "@rsbuild/plugin-babel": "^1.0.3", "@rsbuild/plugin-node-polyfill": "^1.2.0", "@rsbuild/plugin-react": "^1.0.7", "@rsbuild/plugin-sass": "^1.1.0", "@rsbuild/plugin-styled-components": "1.1.0", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", "autoprefixer": "10.4.20", + "babel-jest": "^29.7.0", "babel-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110", + "babel-plugin-styled-components": "^2.1.4", "cross-env": "^7.0.3", "css-loader": "7.1.2", "file-loader": "^6.2.0", "html-loader": "^3.0.1", "html-webpack-plugin": "^5.5.0", + "jest-environment-jsdom": "^29.7.0", "mini-css-extract-plugin": "^2.4.5", "postcss": "8.4.47", "style-loader": "^3.3.1", @@ -26761,6 +28272,1458 @@ "webpack-cli": "^4.9.1" } }, + "packages/bruno-app/node_modules/@babel/compat-data": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.2.tgz", + "integrity": "sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "packages/bruno-app/node_modules/@babel/core": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz", + "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.1", + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helpers": "^7.27.1", + "@babel/parser": "^7.27.1", + "@babel/template": "^7.27.1", + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "packages/bruno-app/node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "packages/bruno-app/node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "packages/bruno-app/node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "packages/bruno-app/node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", + "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.27.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "packages/bruno-app/node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "packages/bruno-app/node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz", + "integrity": "sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "regexpu-core": "^6.2.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "packages/bruno-app/node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "packages/bruno-app/node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", + "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "packages/bruno-app/node_modules/@babel/helper-module-transforms": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz", + "integrity": "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "packages/bruno-app/node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "packages/bruno-app/node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "packages/bruno-app/node_modules/@babel/helper-replace-supers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "packages/bruno-app/node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "packages/bruno-app/node_modules/@babel/helper-wrap-function": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.27.1.tgz", + "integrity": "sha512-NFJK2sHUvrjo8wAU/nQTWU890/zB2jj0qBcCbZbbf+005cAsv6tMjXz31fBign6M5ov1o0Bllu+9nbqkfsjjJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.1", + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "packages/bruno-app/node_modules/@babel/helpers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz", + "integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz", + "integrity": "sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.27.1.tgz", + "integrity": "sha512-6BpaYGDavZqkI6yT+KSPdpZFfpnd68UKXbcjI9pJ13pvHhPrCKWOOLp+ysvMeA+DxnhuPpgIaRpxRxo5A9t5jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", + "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.27.1.tgz", + "integrity": "sha512-eST9RrwlpaoJBDHShc+DS2SG4ATTi2MYNb4OxYkf3n+7eb49LWpnS+HSpVfW4x927qQwgk8A2hGNVaajAEw0EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz", + "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.1.tgz", + "integrity": "sha512-QEcFlMl9nGTgh1rn2nIeU5bkfb9BAjaQcWbiP4LvKxUot52ABcTkpcyJ7f2Q2U2RuQ84BNLgts3jRme2dTx6Fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-class-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", + "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.27.1.tgz", + "integrity": "sha512-s734HmYU78MVzZ++joYM+NkJusItbdRcbm+AGRgJCt3iA+yux0QpD9cBVdz3tKyrjVYWRl7j0mHSmv4lhV0aoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-classes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.27.1.tgz", + "integrity": "sha512-7iLhfFAubmpeJe/Wo2TVuDrykh/zlWXLzPNdL0Jqn/Xu8R3QQ8h9ff8FQoISZOsw74/HFqFI7NX63HN7QFIHKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/traverse": "^7.27.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", + "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/template": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-destructuring": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.27.1.tgz", + "integrity": "sha512-ttDCqhfvpE9emVkXbPD8vyxxh4TWYACVybGkDj+oReOGwnp066ITEivDlLwe0b1R0+evJ13IXQuLNB5w1fhC5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", + "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz", + "integrity": "sha512-uspvXnhHvGKf2r4VVtBpeFnuDWsJLQ6MF6lGJLC89jBR1uoVeqM416AZtTuhTezOfgHicpJQmoD5YUakO/YmXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-json-strings": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", + "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.27.1.tgz", + "integrity": "sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", + "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz", + "integrity": "sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", + "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", + "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.27.2.tgz", + "integrity": "sha512-AIUHD7xJ1mCrj3uPozvtngY3s0xpv7Nu7DoUSnzNY6Xam1Cy4rUznR//pvMHOhQ4AvbCexhbqXCtpxGHOGOO6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.27.1", + "@babel/plugin-transform-parameters": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", + "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", + "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.1.tgz", + "integrity": "sha512-018KRk76HWKeZ5l4oTj2zPpSh+NbGdt0st5S6x0pga6HgrjBOJb24mMDHorFopOOd6YHkLgOZ+zaCjZGPO4aKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-private-methods": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", + "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", + "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-regenerator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.1.tgz", + "integrity": "sha512-B19lbbL7PMrKr52BNPjCqg1IyNUIjTcxKj8uX9zHO+PmWN93s19NDr/f69mIkEp2x9nmDJ08a7lgHaTTzvW7mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", + "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-spread": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", + "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", + "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", + "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "packages/bruno-app/node_modules/@babel/preset-env": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.27.2.tgz", + "integrity": "sha512-Ma4zSuYSlGNRlCLO+EAzLnCmJK2vdstgv+n7aUP+/IKZrOfWHOJVdSJtuub8RzHTj3ahD37k5OKJWvzf16TQyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.27.1", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.27.1", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.27.1", + "@babel/plugin-syntax-import-attributes": "^7.27.1", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.27.1", + "@babel/plugin-transform-async-to-generator": "^7.27.1", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.27.1", + "@babel/plugin-transform-class-properties": "^7.27.1", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-classes": "^7.27.1", + "@babel/plugin-transform-computed-properties": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.27.1", + "@babel/plugin-transform-dotall-regex": "^7.27.1", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-exponentiation-operator": "^7.27.1", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.27.1", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.27.1", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-modules-systemjs": "^7.27.1", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", + "@babel/plugin-transform-numeric-separator": "^7.27.1", + "@babel/plugin-transform-object-rest-spread": "^7.27.2", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1", + "@babel/plugin-transform-parameters": "^7.27.1", + "@babel/plugin-transform-private-methods": "^7.27.1", + "@babel/plugin-transform-private-property-in-object": "^7.27.1", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.27.1", + "@babel/plugin-transform-regexp-modifiers": "^7.27.1", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.27.1", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.11.0", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "core-js-compat": "^3.40.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "packages/bruno-app/node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "packages/bruno-app/node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "packages/bruno-app/node_modules/@testing-library/react": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "packages/bruno-app/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "packages/bruno-app/node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", + "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.3", + "core-js-compat": "^3.40.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "packages/bruno-app/node_modules/browserslist": { + "version": "4.24.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", + "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001716", + "electron-to-chromium": "^1.5.149", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "packages/bruno-app/node_modules/caniuse-lite": { + "version": "1.0.30001718", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", + "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "packages/bruno-app/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "packages/bruno-app/node_modules/cookie": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", @@ -26770,6 +29733,55 @@ "node": ">= 0.6" } }, + "packages/bruno-app/node_modules/core-js-compat": { + "version": "3.42.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.42.0.tgz", + "integrity": "sha512-bQasjMfyDGyaeWKBIu33lHh9qlSR0MFE/Nmc6nMjf/iU9b3rSMdAYz1Baxrv4lPdGUsTqZudHA4jIGSJy0SWZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "packages/bruno-app/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "packages/bruno-app/node_modules/electron-to-chromium": { + "version": "1.5.157", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.157.tgz", + "integrity": "sha512-/0ybgsQd1muo8QlnuTpKwtl0oX5YMlUGbm8xyqgDU00motRkKFFbUJySAQBWcY79rVqNLWIWa87BGVGClwAB2w==", + "dev": true, + "license": "ISC" + }, + "packages/bruno-app/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "packages/bruno-app/node_modules/jsonpath-plus": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.3.0.tgz", @@ -26788,6 +29800,13 @@ "node": ">=18.0.0" } }, + "packages/bruno-app/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "packages/bruno-app/node_modules/nanoid": { "version": "3.3.8", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", @@ -26806,6 +29825,41 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "packages/bruno-app/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "packages/bruno-app/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "packages/bruno-app/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "packages/bruno-app/node_modules/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", @@ -26817,6 +29871,49 @@ "node": ">=10" } }, + "packages/bruno-app/node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/bruno-app/node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "packages/bruno-cli": { "name": "@usebruno/cli", "version": "1.16.0", @@ -28589,7 +31686,6 @@ "version": "2.0.0", "dependencies": { "@aws-sdk/credential-providers": "3.750.0", - "@faker-js/faker": "^9.5.1", "@usebruno/common": "0.1.0", "@usebruno/converters": "^0.1.0", "@usebruno/js": "0.12.0", @@ -29099,23 +32195,6 @@ } } }, - "packages/bruno-electron/node_modules/@faker-js/faker": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.5.1.tgz", - "integrity": "sha512-0fzMEDxkExR2cn731kpDaCCnBGBUOIXEi2S1N5l8Hltp6aPf4soTMJ+g4k8r2sI5oB+rpwIW8Uy/6jkwGpnWPg==", - "deprecated": "Please update to a newer version", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/fakerjs" - } - ], - "license": "MIT", - "engines": { - "node": ">=18.0.0", - "npm": ">=9.0.0" - } - }, "packages/bruno-electron/node_modules/@smithy/abort-controller": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.1.tgz", @@ -29876,12 +32955,18 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@types/qs": "^6.9.18" + "@types/qs": "^6.9.18", + "axios": "^1.9.0" }, "devDependencies": { + "@babel/preset-env": "^7.22.0", + "@babel/preset-typescript": "^7.22.0", "@rollup/plugin-commonjs": "^23.0.2", "@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-typescript": "^9.0.2", + "@types/jest": "^29.5.11", + "babel-jest": "^29.7.0", + "jest": "^29.2.0", "rollup": "3.29.5", "rollup-plugin-dts": "^5.0.0", "rollup-plugin-peer-deps-external": "^2.2.4", @@ -29889,6 +32974,17 @@ "typescript": "^4.8.4" } }, + "packages/bruno-requests/node_modules/axios": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "packages/bruno-schema": { "name": "@usebruno/schema", "version": "0.7.0", diff --git a/package.json b/package.json index b1329d2ee..55873fff4 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "setup": "node ./scripts/setup.js", "watch:converters": "npm run watch --workspace=packages/bruno-converters", "dev": "concurrently --kill-others \"npm run dev:web\" \"npm run dev:electron\"", + "dev:watch": "node ./scripts/dev-hot-reload.js", "dev:web": "npm run dev --workspace=packages/bruno-app", "build:web": "npm run build --workspace=packages/bruno-app", "prettier:web": "npm run prettier --workspace=packages/bruno-app", @@ -71,4 +72,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/bruno-app/.babelrc b/packages/bruno-app/.babelrc index c5ddc56c8..3d8b68884 100644 --- a/packages/bruno-app/.babelrc +++ b/packages/bruno-app/.babelrc @@ -1,4 +1,4 @@ { - "presets": ["@babel/preset-env"], + "presets": ["@babel/preset-env", "@babel/preset-react"], "plugins": [["styled-components", { "ssr": true }]] } \ No newline at end of file diff --git a/packages/bruno-app/babel.config.js b/packages/bruno-app/babel.config.js new file mode 100644 index 000000000..e04b84e5e --- /dev/null +++ b/packages/bruno-app/babel.config.js @@ -0,0 +1,9 @@ +module.exports = { + presets: [ + '@babel/preset-env', + ['@babel/preset-react', { + runtime: 'automatic' + }] + ], + plugins: ['babel-plugin-styled-components'] +}; \ No newline at end of file diff --git a/packages/bruno-app/jest.config.js b/packages/bruno-app/jest.config.js index 5d94a67b7..fdab3f936 100644 --- a/packages/bruno-app/jest.config.js +++ b/packages/bruno-app/jest.config.js @@ -1,5 +1,11 @@ module.exports = { rootDir: '.', + transform: { + '^.+\\.[jt]sx?$': 'babel-jest', + }, + transformIgnorePatterns: [ + "/node_modules/(?!strip-json-comments|nanoid|xml-formatter)/", + ], moduleNameMapper: { '^assets/(.*)$': '/src/assets/$1', '^components/(.*)$': '/src/components/$1', @@ -8,9 +14,17 @@ module.exports = { '^api/(.*)$': '/src/api/$1', '^pageComponents/(.*)$': '/src/pageComponents/$1', '^providers/(.*)$': '/src/providers/$1', - '^utils/(.*)$': '/src/utils/$1' + '^utils/(.*)$': '/src/utils/$1', + '^test-utils/(.*)$': '/src/test-utils/$1' }, clearMocks: true, moduleDirectories: ['node_modules', 'src'], - testEnvironment: 'node' -}; + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['@testing-library/jest-dom'], + setupFiles: [ + '/jest.setup.js', + ], + testMatch: [ + '/src/**/*.spec.[jt]s?(x)' + ] +}; \ No newline at end of file diff --git a/packages/bruno-app/jest.setup.js b/packages/bruno-app/jest.setup.js new file mode 100644 index 000000000..1dbb39d85 --- /dev/null +++ b/packages/bruno-app/jest.setup.js @@ -0,0 +1,11 @@ +jest.mock('nanoid', () => { + return { + nanoid: () => {} + }; +}); + +jest.mock('strip-json-comments', () => { + return { + stripJsonComments: (str) => str + }; +}); diff --git a/packages/bruno-app/jsconfig.json b/packages/bruno-app/jsconfig.json index 867626852..a71bc3138 100644 --- a/packages/bruno-app/jsconfig.json +++ b/packages/bruno-app/jsconfig.json @@ -6,6 +6,7 @@ "baseUrl": "./", "paths": { "assets/*": ["src/assets/*"], + "ui/*": ["src/ui/*"], "components/*": ["src/components/*"], "hooks/*": ["src/hooks/*"], "themes/*": ["src/themes/*"], diff --git a/packages/bruno-app/package.json b/packages/bruno-app/package.json index 2ed9f445e..5e0f6a8ea 100644 --- a/packages/bruno-app/package.json +++ b/packages/bruno-app/package.json @@ -1,6 +1,7 @@ { "name": "@usebruno/app", "version": "2.0.0", + "license": "MIT", "private": true, "scripts": { "dev": "rsbuild dev", @@ -11,7 +12,6 @@ "prettier": "prettier --write \"./src/**/*.{js,jsx,json,ts,tsx}\"" }, "dependencies": { - "@babel/preset-env": "^7.26.0", "@fontsource/inter": "^5.0.15", "@prantlf/jsonlint": "^16.0.0", "@reduxjs/toolkit": "^1.8.0", @@ -67,34 +67,44 @@ "react-hot-toast": "^2.4.0", "react-i18next": "^15.0.1", "react-inspector": "^6.0.2", + "react-json-view": "^1.21.3", "react-pdf": "9.1.1", "react-player": "^2.16.0", "react-redux": "^7.2.9", "react-tooltip": "^5.5.2", "sass": "^1.46.0", "semver": "^7.7.1", + "shell-quote": "^1.8.3", "strip-json-comments": "^5.0.1", "styled-components": "^5.3.3", "system": "^2.0.1", "url": "^0.11.3", "xml-formatter": "^3.5.0", - "yargs-parser": "^21.1.1", "yup": "^0.32.11" }, "devDependencies": { + "@babel/core": "^7.27.1", + "@babel/preset-env": "^7.27.2", + "@babel/preset-react": "^7.27.1", "@rsbuild/core": "^1.1.2", "@rsbuild/plugin-babel": "^1.0.3", "@rsbuild/plugin-node-polyfill": "^1.2.0", "@rsbuild/plugin-react": "^1.0.7", "@rsbuild/plugin-sass": "^1.1.0", "@rsbuild/plugin-styled-components": "1.1.0", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", "autoprefixer": "10.4.20", + "babel-jest": "^29.7.0", "babel-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110", + "babel-plugin-styled-components": "^2.1.4", "cross-env": "^7.0.3", "css-loader": "7.1.2", "file-loader": "^6.2.0", "html-loader": "^3.0.1", "html-webpack-plugin": "^5.5.0", + "jest-environment-jsdom": "^29.7.0", "mini-css-extract-plugin": "^2.4.5", "postcss": "8.4.47", "style-loader": "^3.3.1", diff --git a/packages/bruno-app/rsbuild.config.mjs b/packages/bruno-app/rsbuild.config.mjs index 0a2e9081f..f21f80666 100644 --- a/packages/bruno-app/rsbuild.config.mjs +++ b/packages/bruno-app/rsbuild.config.mjs @@ -20,6 +20,11 @@ export default defineConfig({ ], source: { tsconfigPath: './jsconfig.json', // Specifies the path to the JavaScript/TypeScript configuration file, + exclude: [ + '**/test-utils/**', + '**/*.test.*', + '**/*.spec.*' + ] }, html: { title: 'Bruno' diff --git a/packages/bruno-app/src/components/BulkEditor/index.js b/packages/bruno-app/src/components/BulkEditor/index.js new file mode 100644 index 000000000..1739c963f --- /dev/null +++ b/packages/bruno-app/src/components/BulkEditor/index.js @@ -0,0 +1,40 @@ +import React, { useMemo } from 'react'; +import CodeEditor from 'components/CodeEditor'; +import { useTheme } from 'providers/Theme'; +import { useSelector } from 'react-redux'; +import { parseBulkKeyValue, serializeBulkKeyValue } from 'utils/common/bulkKeyValueUtils'; + +const BulkEditor = ({ params, onChange, onToggle, onSave, onRun }) => { + const preferences = useSelector((state) => state.app.preferences); + const { displayedTheme } = useTheme(); + + const parsedParams = useMemo(() => serializeBulkKeyValue(params), [params]); + + const handleEdit = (value) => { + const parsed = parseBulkKeyValue(value); + onChange(parsed); + }; + + return ( + <> +
+ +
+
+ +
+ + ); +}; + +export default BulkEditor; diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js index f574cf82f..a83535300 100644 --- a/packages/bruno-app/src/components/CodeEditor/index.js +++ b/packages/bruno-app/src/components/CodeEditor/index.js @@ -8,117 +8,19 @@ import React from 'react'; import { isEqual, escapeRegExp } from 'lodash'; import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror'; +import { setupAutoComplete } from 'utils/codemirror/autocomplete'; import StyledWrapper from './StyledWrapper'; import * as jsonlint from '@prantlf/jsonlint'; import { JSHINT } from 'jshint'; import stripJsonComments from 'strip-json-comments'; import { getAllVariables } from 'utils/collections'; -let CodeMirror; -const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true; +const CodeMirror = require('codemirror'); +window.jsonlint = jsonlint; +window.JSHINT = JSHINT; + const TAB_SIZE = 2; -if (!SERVER_RENDERED) { - CodeMirror = require('codemirror'); - window.jsonlint = jsonlint; - window.JSHINT = JSHINT; - //This should be done dynamically if possible - const hintWords = [ - 'res', - 'res.status', - 'res.statusText', - 'res.headers', - 'res.body', - 'res.responseTime', - 'res.getStatus()', - 'res.getStatusText()', - 'res.getHeader(name)', - 'res.getHeaders()', - 'res.getBody()', - 'res.setBody(data)', - 'res.getResponseTime()', - 'req', - 'req.url', - 'req.method', - 'req.headers', - 'req.body', - 'req.timeout', - 'req.getUrl()', - 'req.setUrl(url)', - 'req.getMethod()', - 'req.getAuthMode()', - 'req.setMethod(method)', - 'req.getHeader(name)', - 'req.getHeaders()', - 'req.setHeader(name, value)', - 'req.setHeaders(data)', - 'req.getBody()', - 'req.setBody(data)', - 'req.setMaxRedirects(maxRedirects)', - 'req.getTimeout()', - 'req.setTimeout(timeout)', - 'req.getExecutionMode()', - 'bru', - 'bru.cwd()', - 'bru.getEnvName()', - 'bru.getProcessEnv(key)', - 'bru.hasEnvVar(key)', - 'bru.getEnvVar(key)', - 'bru.getFolderVar(key)', - 'bru.getCollectionVar(key)', - 'bru.setEnvVar(key,value)', - 'bru.deleteEnvVar(key)', - 'bru.hasVar(key)', - 'bru.getVar(key)', - 'bru.setVar(key,value)', - 'bru.deleteVar(key)', - 'bru.deleteAllVars()', - 'bru.setNextRequest(requestName)', - 'req.disableParsingResponseJson()', - 'bru.getRequestVar(key)', - 'bru.runRequest(requestPathName)', - 'bru.getAssertionResults()', - 'bru.getTestResults()', - 'bru.sleep(ms)', - 'bru.getGlobalEnvVar(key)', - 'bru.setGlobalEnvVar(key, value)', - 'bru.runner', - 'bru.runner.setNextRequest(requestName)', - 'bru.runner.skipRequest()', - 'bru.runner.stopExecution()' - ]; - CodeMirror.registerHelper('hint', 'brunoJS', (editor, options) => { - const cursor = editor.getCursor(); - const currentLine = editor.getLine(cursor.line); - let startBru = cursor.ch; - let endBru = startBru; - while (endBru < currentLine.length && /[\w.]/.test(currentLine.charAt(endBru))) ++endBru; - while (startBru && /[\w.]/.test(currentLine.charAt(startBru - 1))) --startBru; - let curWordBru = startBru != endBru && currentLine.slice(startBru, endBru); - - let start = cursor.ch; - let end = start; - while (end < currentLine.length && /[\w]/.test(currentLine.charAt(end))) ++end; - while (start && /[\w]/.test(currentLine.charAt(start - 1))) --start; - const jsHinter = CodeMirror.hint.javascript; - let result = jsHinter(editor) || { list: [] }; - result.to = CodeMirror.Pos(cursor.line, end); - result.from = CodeMirror.Pos(cursor.line, start); - if (curWordBru) { - hintWords.forEach((h) => { - if (h.includes('.') == curWordBru.includes('.') && h.startsWith(curWordBru)) { - result.list.push(curWordBru.includes('.') ? h.split('.')?.at(-1) : h); - } - }); - result.list?.sort(); - } - return result; - }); - CodeMirror.commands.autocomplete = (cm, hint, options) => { - cm.showHint({ hint, ...options }); - }; -} - export default class CodeEditor extends React.Component { constructor(props) { super(props); @@ -138,12 +40,17 @@ export default class CodeEditor extends React.Component { } componentDidMount() { + const variables = getAllVariables(this.props.collection, this.props.item); + const editor = (this.editor = CodeMirror(this._node, { value: this.props.value || '', lineNumbers: true, lineWrapping: true, tabSize: TAB_SIZE, mode: this.props.mode || 'application/ld+json', + brunoVarInfo: { + variables + }, keyMap: 'sublime', autoCloseBrackets: true, matchBrackets: true, @@ -275,30 +182,24 @@ export default class CodeEditor extends React.Component { } return found; }); + if (editor) { editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false); editor.on('change', this._onEdit); this.addOverlay(); - } - if (this.props.mode == 'javascript') { - editor.on('keyup', function (cm, event) { - const cursor = editor.getCursor(); - const currentLine = editor.getLine(cursor.line); - let start = cursor.ch; - let end = start; - while (end < currentLine.length && /[^{}();\s\[\]\,]/.test(currentLine.charAt(end))) ++end; - while (start && /[^{}();\s\[\]\,]/.test(currentLine.charAt(start - 1))) --start; - let curWord = start != end && currentLine.slice(start, end); - // Qualify if autocomplete will be shown - if ( - /^(?!Shift|Tab|Enter|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Meta|Alt|Home|End\s)\w*/.test(event.key) && - curWord.length > 0 && - !/\/\/|\/\*|.*{{|`[^$]*{|`[^{]*$/.test(currentLine.slice(0, end)) && - /(? getAllVariables(this.props.collection, this.props.item); + + // Setup AutoComplete Helper for all modes + const autoCompleteOptions = { + showHintsFor: this.props.showHintsFor, + getAllVariables: getAllVariablesHandler + }; + + this.brunoAutoCompleteCleanup = setupAutoComplete( + editor, + autoCompleteOptions + ); } } @@ -339,6 +240,9 @@ export default class CodeEditor extends React.Component { } this._unbindSearchHandler(); + if (this.brunoAutoCompleteCleanup) { + this.brunoAutoCompleteCleanup(); + } } render() { @@ -363,7 +267,7 @@ export default class CodeEditor extends React.Component { let variables = getAllVariables(this.props.collection, this.props.item); this.variables = variables; - defineCodeMirrorBrunoVariablesMode(variables, mode); + defineCodeMirrorBrunoVariablesMode(variables, mode, false, this.props.enableVariableHighlighting); this.editor.setOption('mode', 'brunovariables'); }; diff --git a/packages/bruno-app/src/components/CodeEditor/index.spec.js b/packages/bruno-app/src/components/CodeEditor/index.spec.js new file mode 100644 index 000000000..973a8d3a9 --- /dev/null +++ b/packages/bruno-app/src/components/CodeEditor/index.spec.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { render, act } from '@testing-library/react'; +import CodeEditor from './index'; +import { ThemeProvider } from 'styled-components'; + +jest.mock('codemirror', () => { + const codemirror = require('test-utils/mocks/codemirror'); + return codemirror; +}); + +const MOCK_THEME = { + codemirror: { + bg: "#1e1e1e", + border: "#333", + }, + textLink: "#007acc", +}; + +const setupEditorState = (editor, { value, cursorPosition }) => { + editor._currentValue = value; + editor.getCursor.mockReturnValue({ line: 0, ch: cursorPosition }); + editor.getRange.mockImplementation((from, to) => { + if (from.line === 0 && from.ch === 0 && to.line === 0 && to.ch === cursorPosition) { + return value; + } + return editor._currentValue.slice(from.ch, to.ch); + }); + + editor.state = { + completionActive: null, + } +}; + +const setupEditorWithRef = () => { + const ref = React.createRef(); + const { rerender } = render( + + + + ); + return { ref, rerender }; +}; + +describe('CodeEditor', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + }); + + it("add CodeEditor related tests here", () => {}); +}); \ No newline at end of file diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/AwsV4Auth/index.js b/packages/bruno-app/src/components/CollectionSettings/Auth/AwsV4Auth/index.js index 38fae3447..0f14a4dfa 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Auth/AwsV4Auth/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/AwsV4Auth/index.js @@ -21,12 +21,12 @@ const AwsV4Auth = ({ collection }) => { mode: 'awsv4', collectionUid: collection.uid, content: { - accessKeyId: accessKeyId, - secretAccessKey: awsv4Auth.secretAccessKey, - sessionToken: awsv4Auth.sessionToken, - service: awsv4Auth.service, - region: awsv4Auth.region, - profileName: awsv4Auth.profileName + accessKeyId: accessKeyId || '', + secretAccessKey: awsv4Auth.secretAccessKey || '', + sessionToken: awsv4Auth.sessionToken || '', + service: awsv4Auth.service || '', + region: awsv4Auth.region || '', + profileName: awsv4Auth.profileName || '' } }) ); @@ -38,12 +38,12 @@ const AwsV4Auth = ({ collection }) => { mode: 'awsv4', collectionUid: collection.uid, content: { - accessKeyId: awsv4Auth.accessKeyId, - secretAccessKey: secretAccessKey, - sessionToken: awsv4Auth.sessionToken, - service: awsv4Auth.service, - region: awsv4Auth.region, - profileName: awsv4Auth.profileName + accessKeyId: awsv4Auth.accessKeyId || '', + secretAccessKey: secretAccessKey || '', + sessionToken: awsv4Auth.sessionToken || '', + service: awsv4Auth.service || '', + region: awsv4Auth.region || '', + profileName: awsv4Auth.profileName || '' } }) ); @@ -55,12 +55,12 @@ const AwsV4Auth = ({ collection }) => { mode: 'awsv4', collectionUid: collection.uid, content: { - accessKeyId: awsv4Auth.accessKeyId, - secretAccessKey: awsv4Auth.secretAccessKey, - sessionToken: sessionToken, - service: awsv4Auth.service, - region: awsv4Auth.region, - profileName: awsv4Auth.profileName + accessKeyId: awsv4Auth.accessKeyId || '', + secretAccessKey: awsv4Auth.secretAccessKey || '', + sessionToken: sessionToken || '', + service: awsv4Auth.service || '', + region: awsv4Auth.region || '', + profileName: awsv4Auth.profileName || '' } }) ); @@ -72,12 +72,12 @@ const AwsV4Auth = ({ collection }) => { mode: 'awsv4', collectionUid: collection.uid, content: { - accessKeyId: awsv4Auth.accessKeyId, - secretAccessKey: awsv4Auth.secretAccessKey, - sessionToken: awsv4Auth.sessionToken, - service: service, - region: awsv4Auth.region, - profileName: awsv4Auth.profileName + accessKeyId: awsv4Auth.accessKeyId || '', + secretAccessKey: awsv4Auth.secretAccessKey || '', + sessionToken: awsv4Auth.sessionToken || '', + service: service || '', + region: awsv4Auth.region || '', + profileName: awsv4Auth.profileName || '' } }) ); @@ -89,12 +89,12 @@ const AwsV4Auth = ({ collection }) => { mode: 'awsv4', collectionUid: collection.uid, content: { - accessKeyId: awsv4Auth.accessKeyId, - secretAccessKey: awsv4Auth.secretAccessKey, - sessionToken: awsv4Auth.sessionToken, - service: awsv4Auth.service, - region: region, - profileName: awsv4Auth.profileName + accessKeyId: awsv4Auth.accessKeyId || '', + secretAccessKey: awsv4Auth.secretAccessKey || '', + sessionToken: awsv4Auth.sessionToken || '', + service: awsv4Auth.service || '', + region: region || '', + profileName: awsv4Auth.profileName || '' } }) ); @@ -106,12 +106,12 @@ const AwsV4Auth = ({ collection }) => { mode: 'awsv4', collectionUid: collection.uid, content: { - accessKeyId: awsv4Auth.accessKeyId, - secretAccessKey: awsv4Auth.secretAccessKey, - sessionToken: awsv4Auth.sessionToken, - service: awsv4Auth.service, - region: awsv4Auth.region, - profileName: profileName + accessKeyId: awsv4Auth.accessKeyId || '', + secretAccessKey: awsv4Auth.secretAccessKey || '', + sessionToken: awsv4Auth.sessionToken || '', + service: awsv4Auth.service || '', + region: awsv4Auth.region || '', + profileName: profileName || '' } }) ); diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/BasicAuth/index.js b/packages/bruno-app/src/components/CollectionSettings/Auth/BasicAuth/index.js index 3c29895ed..9ea532646 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Auth/BasicAuth/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/BasicAuth/index.js @@ -21,8 +21,8 @@ const BasicAuth = ({ collection }) => { mode: 'basic', collectionUid: collection.uid, content: { - username: username, - password: basicAuth.password + username: username || '', + password: basicAuth.password || '' } }) ); @@ -34,8 +34,8 @@ const BasicAuth = ({ collection }) => { mode: 'basic', collectionUid: collection.uid, content: { - username: basicAuth.username, - password: password + username: basicAuth.username || '', + password: password || '' } }) ); diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/DigestAuth/index.js b/packages/bruno-app/src/components/CollectionSettings/Auth/DigestAuth/index.js index 5ac6b1e26..582b17b82 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Auth/DigestAuth/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/DigestAuth/index.js @@ -21,8 +21,8 @@ const DigestAuth = ({ collection }) => { mode: 'digest', collectionUid: collection.uid, content: { - username: username, - password: digestAuth.password + username: username || '', + password: digestAuth.password || '' } }) ); @@ -34,8 +34,8 @@ const DigestAuth = ({ collection }) => { mode: 'digest', collectionUid: collection.uid, content: { - username: digestAuth.username, - password: password + username: digestAuth.username || '', + password: password || '' } }) ); diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/NTLMAuth/index.js b/packages/bruno-app/src/components/CollectionSettings/Auth/NTLMAuth/index.js index 341c805dc..173c99a12 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Auth/NTLMAuth/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/NTLMAuth/index.js @@ -28,9 +28,9 @@ const NTLMAuth = ({ collection }) => { mode: 'ntlm', collectionUid: collection.uid, content: { - username: username, - password: ntlmAuth.password, - domain: ntlmAuth.domain + username: username || '', + password: ntlmAuth.password || '', + domain: ntlmAuth.domain || '' } }) @@ -43,9 +43,9 @@ const NTLMAuth = ({ collection }) => { mode: 'ntlm', collectionUid: collection.uid, content: { - username: ntlmAuth.username, - password: password, - domain: ntlmAuth.domain + username: ntlmAuth.username || '', + password: password || '', + domain: ntlmAuth.domain || '' } }) ); @@ -57,9 +57,9 @@ const NTLMAuth = ({ collection }) => { mode: 'ntlm', collectionUid: collection.uid, content: { - username: ntlmAuth.username, - password: ntlmAuth.password, - domain: domain + username: ntlmAuth.username || '', + password: ntlmAuth.password || '', + domain: domain || '' } }) ); diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/index.js b/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/index.js index 474e44717..e8d20f25c 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/index.js @@ -7,6 +7,7 @@ import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections/in import { useDispatch } from 'react-redux'; import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index'; import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index'; +import OAuth2Implicit from 'components/RequestPane/Auth/OAuth2/Implicit/index'; import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index'; const GrantTypeComponentMap = ({collection }) => { @@ -29,6 +30,9 @@ const GrantTypeComponentMap = ({collection }) => { case 'client_credentials': return ; break; + case 'implicit': + return ; + break; default: return
TBD
; break; diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/WsseAuth/index.js b/packages/bruno-app/src/components/CollectionSettings/Auth/WsseAuth/index.js index 45efc7b1e..2e1a2c65c 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Auth/WsseAuth/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/WsseAuth/index.js @@ -21,8 +21,8 @@ const WsseAuth = ({ collection }) => { mode: 'wsse', collectionUid: collection.uid, content: { - username, - password: wsseAuth.password + username: username || '', + password: wsseAuth.password || '' } }) ); @@ -34,8 +34,8 @@ const WsseAuth = ({ collection }) => { mode: 'wsse', collectionUid: collection.uid, content: { - username: wsseAuth.username, - password + username: wsseAuth.username || '', + password: password || '' } }) ); diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js b/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js index ebe29a21a..eb42be7cb 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js @@ -53,7 +53,7 @@ const Info = ({ collection }) => {
Requests
-
+
{ isCollectionLoading? `${totalItems - itemsLoadingCount} out of ${totalItems} requests in the collection loaded` : `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection` } diff --git a/packages/bruno-app/src/components/CollectionSettings/Script/index.js b/packages/bruno-app/src/components/CollectionSettings/Script/index.js index 6fe979cbf..625df1ff7 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Script/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Script/index.js @@ -53,6 +53,7 @@ const Script = ({ collection }) => { onSave={handleSave} font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} + showHintsFor={['req', 'bru']} />
@@ -66,6 +67,7 @@ const Script = ({ collection }) => { onSave={handleSave} font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} + showHintsFor={['req', 'res', 'bru']} />
diff --git a/packages/bruno-app/src/components/CollectionSettings/Tests/index.js b/packages/bruno-app/src/components/CollectionSettings/Tests/index.js index d87a1dea4..975758ee1 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Tests/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Tests/index.js @@ -37,6 +37,7 @@ const Tests = ({ collection }) => { onSave={handleSave} font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} + showHintsFor={['req', 'res', 'bru']} />
diff --git a/packages/bruno-app/src/components/CollectionSettings/index.js b/packages/bruno-app/src/components/CollectionSettings/index.js index 7d5d60574..ef220351a 100644 --- a/packages/bruno-app/src/components/CollectionSettings/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/index.js @@ -15,17 +15,9 @@ import Test from './Tests'; import Presets from './Presets'; import StyledWrapper from './StyledWrapper'; import Vars from './Vars/index'; -import DotIcon from 'components/Icons/Dot'; +import StatusDot from 'components/StatusDot'; import Overview from './Overview/index'; -const ContentIndicator = () => { - return ( - - - - ); -}; - const CollectionSettings = ({ collection }) => { const dispatch = useDispatch(); const tab = collection.settingsSelectedTab; @@ -49,7 +41,7 @@ const CollectionSettings = ({ collection }) => { const requestVars = get(collection, 'root.request.vars.req', []); const responseVars = get(collection, 'root.request.vars.res', []); const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length; - const auth = get(collection, 'root.request.auth', {}).mode; + const authMode = get(collection, 'root.request.auth', {}).mode || 'none'; const proxyConfig = get(collection, 'brunoConfig.proxy', {}); const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []); @@ -140,7 +132,7 @@ const CollectionSettings = ({ collection }) => { }; return ( - +
setTab('overview')}> Overview @@ -155,29 +147,29 @@ const CollectionSettings = ({ collection }) => {
setTab('auth')}> Auth - {auth !== 'none' && } + {authMode !== 'none' && }
setTab('script')}> Script - {hasScripts && } + {hasScripts && }
setTab('tests')}> Tests - {hasTests && } + {hasTests && }
setTab('presets')}> Presets
setTab('proxy')}> Proxy - {Object.keys(proxyConfig).length > 0 && } + {Object.keys(proxyConfig).length > 0 && }
setTab('clientCert')}> Client Certificates - {clientCertConfig.length > 0 && } + {clientCertConfig.length > 0 && }
-
{getTabPanel(tab)}
+
{getTabPanel(tab)}
); }; diff --git a/packages/bruno-app/src/components/Devtools/Console/DebugTab/StyledWrapper.js b/packages/bruno-app/src/components/Devtools/Console/DebugTab/StyledWrapper.js new file mode 100644 index 000000000..a9c71b8c2 --- /dev/null +++ b/packages/bruno-app/src/components/Devtools/Console/DebugTab/StyledWrapper.js @@ -0,0 +1,163 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + display: flex; + flex-direction: column; + height: 100%; + background: ${(props) => props.theme.console.contentBg}; + overflow: hidden; + + .debug-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: ${(props) => props.theme.console.headerBg}; + border-bottom: 1px solid ${(props) => props.theme.console.border}; + flex-shrink: 0; + } + + .debug-title { + display: flex; + align-items: center; + gap: 8px; + color: ${(props) => props.theme.console.titleColor}; + font-size: 13px; + font-weight: 500; + + .error-count { + color: ${(props) => props.theme.console.countColor}; + font-size: 12px; + font-weight: 400; + } + } + + .debug-controls { + display: flex; + align-items: center; + gap: 8px; + } + + .control-button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: transparent; + border: 1px solid ${(props) => props.theme.console.border}; + border-radius: 4px; + color: ${(props) => props.theme.console.buttonColor}; + cursor: pointer; + transition: all 0.2s ease; + } + + .debug-content { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + min-height: 0; + } + + .debug-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: ${(props) => props.theme.console.emptyColor}; + text-align: center; + gap: 8px; + padding: 40px 20px; + + p { + margin: 0; + font-size: 14px; + font-weight: 500; + } + + span { + font-size: 12px; + opacity: 0.7; + } + } + + .errors-container { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + min-height: 0; + } + + .errors-header { + display: grid; + grid-template-columns: 1fr 200px 120px; + gap: 12px; + padding: 8px 16px; + background: ${(props) => props.theme.console.headerBg}; + border-bottom: 1px solid ${(props) => props.theme.console.border}; + font-size: 11px; + font-weight: 600; + color: ${(props) => props.theme.console.titleColor}; + text-transform: uppercase; + letter-spacing: 0.5px; + flex-shrink: 0; + } + + .errors-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; + } + + .error-row { + display: grid; + grid-template-columns: 1fr 200px 120px; + gap: 12px; + padding: 8px 16px; + border-bottom: 1px solid ${(props) => props.theme.console.border}; + cursor: pointer; + transition: background-color 0.1s ease; + font-size: 12px; + align-items: center; + + &:hover { + background: ${(props) => props.theme.console.logHoverBg}; + } + + &.selected { + background: ${(props) => props.theme.console.buttonHoverBg}; + border-left: 3px solid ${(props) => props.theme.console.checkboxColor}; + } + } + + .error-message { + color: ${(props) => props.theme.console.messageColor}; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + } + + .error-location { + color: ${(props) => props.theme.console.messageColor}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + font-size: 11px; + } + + .error-time { + color: ${(props) => props.theme.console.timestampColor}; + font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + font-size: 11px; + text-align: right; + } +`; + +export default StyledWrapper; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Devtools/Console/DebugTab/index.js b/packages/bruno-app/src/components/Devtools/Console/DebugTab/index.js new file mode 100644 index 000000000..1faa14ec7 --- /dev/null +++ b/packages/bruno-app/src/components/Devtools/Console/DebugTab/index.js @@ -0,0 +1,106 @@ +import React from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { IconBug } from '@tabler/icons'; +import { + setSelectedError, + clearDebugErrors +} from 'providers/ReduxStore/slices/logs'; +import StyledWrapper from './StyledWrapper'; + +const ErrorRow = ({ error, isSelected, onClick }) => { + const formatTime = (timestamp) => { + const date = new Date(timestamp); + return date.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3 + }); + }; + + const getShortMessage = (message, maxLength = 80) => { + if (!message) return 'Unknown error'; + return message.length > maxLength ? message.substring(0, maxLength) + '...' : message; + }; + + const getLocation = (error) => { + if (error.filename) { + const filename = error.filename.split('/').pop(); // Get just the filename + if (error.lineno && error.colno) { + return `${filename}:${error.lineno}:${error.colno}`; + } else if (error.lineno) { + return `${filename}:${error.lineno}`; + } + return filename; + } + return '-'; + }; + + return ( +
+
+ {getShortMessage(error.message)} +
+ +
+ {getLocation(error)} +
+ +
+ {formatTime(error.timestamp)} +
+
+ ); +}; + +const DebugTab = () => { + const dispatch = useDispatch(); + const { debugErrors, selectedError } = useSelector(state => state.logs); + + const handleErrorClick = (error) => { + dispatch(setSelectedError(error)); + }; + + const handleClearErrors = () => { + dispatch(clearDebugErrors()); + }; + + return ( + +
+ {debugErrors.length === 0 ? ( +
+ +

No errors

+ console.error() calls will appear here +
+ ) : ( +
+
+
Message
+
Location
+
Time
+
+ +
+ {debugErrors.map((error, index) => ( + handleErrorClick(error)} + /> + ))} +
+
+ )} +
+
+ ); +}; + +export default DebugTab; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Devtools/Console/ErrorDetailsPanel/StyledWrapper.js b/packages/bruno-app/src/components/Devtools/Console/ErrorDetailsPanel/StyledWrapper.js new file mode 100644 index 000000000..94900df8f --- /dev/null +++ b/packages/bruno-app/src/components/Devtools/Console/ErrorDetailsPanel/StyledWrapper.js @@ -0,0 +1,228 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + display: flex; + flex-direction: column; + height: 100%; + background: ${(props) => props.theme.console.contentBg}; + border-left: 1px solid ${(props) => props.theme.console.border}; + min-width: 400px; + max-width: 600px; + width: 40%; + overflow: hidden; + + .panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: ${(props) => props.theme.console.headerBg}; + border-bottom: 1px solid ${(props) => props.theme.console.border}; + flex-shrink: 0; + } + + .panel-title { + display: flex; + align-items: center; + gap: 8px; + color: ${(props) => props.theme.console.titleColor}; + font-size: 13px; + font-weight: 500; + + .error-time { + color: ${(props) => props.theme.console.countColor}; + font-size: 11px; + font-weight: 400; + } + } + + .close-button { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: transparent; + border: none; + border-radius: 4px; + color: ${(props) => props.theme.console.buttonColor}; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: ${(props) => props.theme.console.buttonHoverBg}; + color: ${(props) => props.theme.console.buttonHoverColor}; + } + } + + .panel-tabs { + display: flex; + background: ${(props) => props.theme.console.headerBg}; + border-bottom: 1px solid ${(props) => props.theme.console.border}; + flex-shrink: 0; + } + + .tab-button { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: ${(props) => props.theme.console.buttonColor}; + cursor: pointer; + transition: all 0.2s ease; + font-size: 12px; + font-weight: 500; + + &:hover { + background: ${(props) => props.theme.console.buttonHoverBg}; + color: ${(props) => props.theme.console.buttonHoverColor}; + } + + &.active { + color: ${(props) => props.theme.console.checkboxColor}; + border-bottom-color: ${(props) => props.theme.console.checkboxColor}; + background: ${(props) => props.theme.console.contentBg}; + } + } + + .panel-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + background: ${(props) => props.theme.console.contentBg}; + min-height: 0; + } + + .tab-content { + padding: 16px; + height: 100%; + overflow-y: auto; + } + + .section { + margin-bottom: 24px; + + &:last-child { + margin-bottom: 0; + } + + h4 { + margin: 0 0 12px 0; + font-size: 13px; + font-weight: 600; + color: ${(props) => props.theme.console.titleColor}; + text-transform: uppercase; + letter-spacing: 0.5px; + } + } + + .info-grid { + display: flex; + flex-direction: column; + gap: 12px; + } + + .info-item { + display: flex; + flex-direction: column; + gap: 4px; + + label { + font-size: 11px; + font-weight: 600; + color: ${(props) => props.theme.console.titleColor}; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + span { + font-size: 12px; + color: ${(props) => props.theme.console.messageColor}; + font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + word-break: break-all; + line-height: 1.4; + } + } + + .error-message-full { + color: ${(props) => props.theme.console.messageColor} !important; + background: ${(props) => props.theme.console.headerBg}; + padding: 8px 12px; + border-radius: 4px; + border: 1px solid ${(props) => props.theme.console.border}; + } + + .file-path { + color: ${(props) => props.theme.console.checkboxColor} !important; + font-weight: 500 !important; + } + + .report-section { + display: flex; + flex-direction: column; + gap: 12px; + + p { + margin: 0; + font-size: 12px; + color: ${(props) => props.theme.console.messageColor}; + line-height: 1.4; + } + } + + .report-button { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: ${(props) => props.theme.console.buttonHoverBg}; + border: 1px solid ${(props) => props.theme.console.border}; + border-radius: 6px; + color: ${(props) => props.theme.console.buttonColor}; + cursor: pointer; + transition: all 0.2s ease; + font-size: 12px; + font-weight: 500; + text-decoration: none; + align-self: flex-start; + + &:hover { + background: ${(props) => props.theme.console.checkboxColor}; + color: white; + border-color: ${(props) => props.theme.console.checkboxColor}; + } + + span { + font-family: inherit; + } + } + + .stack-trace-container, + .arguments-container { + background: ${(props) => props.theme.console.headerBg}; + border: 1px solid ${(props) => props.theme.console.border}; + border-radius: 6px; + overflow: hidden; + } + + .stack-trace, + .arguments { + margin: 0; + padding: 16px; + font-size: 11px; + line-height: 1.5; + color: ${(props) => props.theme.console.messageColor}; + background: transparent; + font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + white-space: pre-wrap; + word-break: break-word; + overflow-x: auto; + max-height: 400px; + overflow-y: auto; + } +`; + +export default StyledWrapper; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Devtools/Console/ErrorDetailsPanel/index.js b/packages/bruno-app/src/components/Devtools/Console/ErrorDetailsPanel/index.js new file mode 100644 index 000000000..91499b4a3 --- /dev/null +++ b/packages/bruno-app/src/components/Devtools/Console/ErrorDetailsPanel/index.js @@ -0,0 +1,268 @@ +import React, { useState } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { + IconX, + IconBug, + IconFileText, + IconCode, + IconStack, + IconBrandGithub +} from '@tabler/icons'; +import { clearSelectedError } from 'providers/ReduxStore/slices/logs'; +import { useApp } from 'providers/App'; +import platformLib from 'platform'; +import StyledWrapper from './StyledWrapper'; + +const ErrorInfoTab = ({ error }) => { + const { version } = useApp(); + + const formatTimestamp = (timestamp) => { + const date = new Date(timestamp); + return date.toLocaleString(); + }; + + const generateGitHubIssueUrl = () => { + const title = `Bug: ${error.message.substring(0, 50)}${error.message.length > 50 ? '...' : ''}`; + + const body = `## Bug Report + +### Error Details +- **Message**: ${error.message} +- **File**: ${error.filename || 'Unknown'} +- **Line**: ${error.lineno || 'Unknown'}:${error.colno || 'Unknown'} +- **Timestamp**: ${formatTimestamp(error.timestamp)} + +### Environment +- **Bruno Version**: ${version} +- **OS**: ${platformLib.os.family} ${platformLib.os.version || ''} +- **Browser**: ${platformLib.name} ${platformLib.version || ''} + +### Stack Trace +\`\`\` +${error.stack || 'No stack trace available'} +\`\`\` + +### Arguments +\`\`\` +${error.args ? error.args.map((arg, index) => { + if (arg && typeof arg === 'object' && arg.__type === 'Error') { + return `[${index}]: Error: ${arg.message}`; + } + return `[${index}]: ${typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)}`; +}).join('\n') : 'No arguments'} +\`\`\` + +### Steps to Reproduce +1. +2. +3. + +### Expected Behavior + + +### Additional Context + +`; + + const encodedTitle = encodeURIComponent(title); + const encodedBody = encodeURIComponent(body); + + return `https://github.com/usebruno/bruno/issues/new?template=BLANK_ISSUE&title=${encodedTitle}&body=${encodedBody}`; + }; + + const handleReportIssue = () => { + const url = generateGitHubIssueUrl(); + window.open(url, '_blank'); + }; + + return ( +
+
+

Error Information

+
+
+ + {error.message || 'No message available'} +
+ + {error.filename && ( +
+ + {error.filename} +
+ )} + + {error.lineno && ( +
+ + {error.lineno}{error.colno ? `:${error.colno}` : ''} +
+ )} + +
+ + {formatTimestamp(error.timestamp)} +
+
+
+ +
+

Report Issue

+
+

Found a bug? Help us improve Bruno by reporting this error on GitHub.

+ +
+
+
+ ); +}; + +const StackTraceTab = ({ error }) => { + const formatStackTrace = (stack) => { + if (!stack) return 'Stack trace not available'; + + return stack + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + .join('\n'); + }; + + return ( +
+
+

Stack Trace

+
+
+            {formatStackTrace(error.stack)}
+          
+
+
+
+ ); +}; + +const ArgumentsTab = ({ error }) => { + const formatArguments = (args) => { + if (!args || args.length === 0) return 'No arguments available'; + + try { + return args.map((arg, index) => { + // Handle special Error object format + if (arg && typeof arg === 'object' && arg.__type === 'Error') { + return `[${index}]: Error: ${arg.message}\n Name: ${arg.name}\n Stack: ${arg.stack || 'No stack trace'}`; + } + + if (typeof arg === 'object' && arg !== null) { + return `[${index}]: ${JSON.stringify(arg, null, 2)}`; + } + + return `[${index}]: ${String(arg)}`; + }).join('\n\n'); + } catch (e) { + return 'Arguments could not be formatted'; + } + }; + + return ( +
+
+

Arguments

+
+
+            {formatArguments(error.args)}
+          
+
+
+
+ ); +}; + +const ErrorDetailsPanel = () => { + const dispatch = useDispatch(); + const { selectedError } = useSelector(state => state.logs); + const [activeTab, setActiveTab] = useState('info'); + + if (!selectedError) return null; + + const handleClose = () => { + dispatch(clearSelectedError()); + }; + + const formatTime = (timestamp) => { + const date = new Date(timestamp); + return date.toLocaleString(); + }; + + const getTabContent = () => { + switch (activeTab) { + case 'info': + return ; + case 'stack': + return ; + case 'args': + return ; + default: + return ; + } + }; + + return ( + +
+
+ + Error Details + ({formatTime(selectedError.timestamp)}) +
+ + +
+ +
+ + + + + +
+ +
+ {getTabContent()} +
+
+ ); +}; + +export default ErrorDetailsPanel; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/StyledWrapper.js b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/StyledWrapper.js new file mode 100644 index 000000000..2fc45bafe --- /dev/null +++ b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/StyledWrapper.js @@ -0,0 +1,293 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + display: flex; + flex-direction: column; + height: 100%; + background: ${(props) => props.theme.console.contentBg}; + overflow: hidden; + + .network-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: ${(props) => props.theme.console.headerBg}; + border-bottom: 1px solid ${(props) => props.theme.console.border}; + flex-shrink: 0; + } + + .network-title { + display: flex; + align-items: center; + gap: 8px; + color: ${(props) => props.theme.console.titleColor}; + font-size: 13px; + font-weight: 500; + + .request-count { + color: ${(props) => props.theme.console.countColor}; + font-size: 12px; + font-weight: 400; + } + } + + .network-controls { + display: flex; + align-items: center; + gap: 8px; + } + + .network-content { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + min-height: 0; /* Important for proper flex behavior */ + } + + .network-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: ${(props) => props.theme.console.emptyColor}; + text-align: center; + gap: 8px; + padding: 40px 20px; + + p { + margin: 0; + font-size: 14px; + font-weight: 500; + } + + span { + font-size: 12px; + opacity: 0.7; + } + } + + .requests-container { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + min-height: 0; /* Important for proper flex behavior */ + } + + .requests-header { + display: grid; + grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px; + gap: 12px; + padding: 8px 16px; + background: ${(props) => props.theme.console.headerBg}; + border-bottom: 1px solid ${(props) => props.theme.console.border}; + font-size: 11px; + font-weight: 600; + color: ${(props) => props.theme.console.titleColor}; + text-transform: uppercase; + letter-spacing: 0.5px; + flex-shrink: 0; + } + + .requests-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; /* Important for proper scrolling */ + } + + .request-row { + display: grid; + grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px; + gap: 12px; + padding: 6px 16px; + border-bottom: 1px solid ${(props) => props.theme.console.border}; + cursor: pointer; + transition: background-color 0.1s ease; + font-size: 12px; + align-items: center; + + &:hover { + background: ${(props) => props.theme.console.logHoverBg}; + } + + &.selected { + background: ${(props) => props.theme.console.buttonHoverBg}; + border-left: 3px solid ${(props) => props.theme.console.checkboxColor}; + } + } + + .method-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + color: white; + text-transform: uppercase; + letter-spacing: 0.5px; + min-width: 45px; + } + + .status-badge { + font-weight: 600; + font-size: 12px; + } + + .request-domain { + color: ${(props) => props.theme.console.messageColor}; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .request-path { + color: ${(props) => props.theme.console.messageColor}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + } + + .request-time { + color: ${(props) => props.theme.console.timestampColor}; + font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + font-size: 11px; + } + + .request-duration { + color: ${(props) => props.theme.console.messageColor}; + font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + font-size: 11px; + text-align: right; + } + + .request-size { + color: ${(props) => props.theme.console.messageColor}; + font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + font-size: 11px; + text-align: right; + } + + .filter-dropdown { + position: relative; + } + + .filter-dropdown-trigger { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + background: transparent; + border: 1px solid ${(props) => props.theme.console.border}; + border-radius: 4px; + color: ${(props) => props.theme.console.buttonColor}; + cursor: pointer; + transition: all 0.2s ease; + font-size: 12px; + + &:hover { + background: ${(props) => props.theme.console.buttonHoverBg}; + color: ${(props) => props.theme.console.buttonHoverColor}; + } + + .filter-summary { + font-weight: 500; + min-width: 24px; + text-align: center; + } + } + + .filter-dropdown-menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + min-width: 200px; + max-width: 250px; + background: ${(props) => props.theme.console.dropdownBg}; + border: 1px solid ${(props) => props.theme.console.border}; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + z-index: 1000; + overflow: hidden; + } + + .filter-dropdown-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: ${(props) => props.theme.console.dropdownHeaderBg}; + border-bottom: 1px solid ${(props) => props.theme.console.border}; + font-size: 12px; + font-weight: 500; + color: ${(props) => props.theme.console.titleColor}; + } + + .filter-toggle-all { + background: transparent; + border: none; + color: ${(props) => props.theme.console.buttonColor}; + cursor: pointer; + font-size: 11px; + font-weight: 500; + padding: 2px 4px; + border-radius: 2px; + transition: all 0.2s ease; + + &:hover { + background: ${(props) => props.theme.console.buttonHoverBg}; + } + } + + .filter-dropdown-options { + padding: 4px 0; + } + + .filter-option { + display: flex; + align-items: center; + padding: 6px 12px; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background: ${(props) => props.theme.console.optionHoverBg}; + } + + input[type="checkbox"] { + margin: 0 8px 0 0; + width: 14px; + height: 14px; + accent-color: ${(props) => props.theme.console.checkboxColor}; + } + } + + .filter-option-content { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + } + + .filter-option-label { + color: ${(props) => props.theme.console.optionLabelColor}; + font-size: 12px; + font-weight: 400; + } + + .filter-option-count { + color: ${(props) => props.theme.console.optionCountColor}; + font-size: 11px; + font-weight: 400; + margin-left: auto; + } +`; + +export default StyledWrapper; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.js b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.js new file mode 100644 index 000000000..6e4480398 --- /dev/null +++ b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.js @@ -0,0 +1,302 @@ +import React, { useState, useRef, useEffect, useMemo } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { + IconFilter, + IconChevronDown, + IconNetwork, +} from '@tabler/icons'; +import { + updateNetworkFilter, + toggleAllNetworkFilters, + setSelectedRequest +} from 'providers/ReduxStore/slices/logs'; +import StyledWrapper from './StyledWrapper'; + +const MethodBadge = ({ method }) => { + const getMethodColor = (method) => { + switch (method?.toUpperCase()) { + case 'GET': return '#10b981'; + case 'POST': return '#8b5cf6'; + case 'PUT': return '#f59e0b'; + case 'DELETE': return '#ef4444'; + case 'PATCH': return '#06b6d4'; + case 'HEAD': return '#6b7280'; + case 'OPTIONS': return '#84cc16'; + default: return '#6b7280'; + } + }; + + return ( + + {method?.toUpperCase() || 'GET'} + + ); +}; + +const StatusBadge = ({ status, statusCode }) => { + const getStatusColor = (code) => { + if (code >= 200 && code < 300) return '#10b981'; + if (code >= 300 && code < 400) return '#f59e0b'; + if (code >= 400 && code < 500) return '#ef4444'; + if (code >= 500) return '#dc2626'; + return '#6b7280'; + }; + + const displayStatus = statusCode || status; + + return ( + + {displayStatus} + + ); +}; + +const NetworkFilterDropdown = ({ filters, requestCounts, onFilterToggle, onToggleAll }) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const allFiltersEnabled = Object.values(filters).every(f => f); + const activeFilters = Object.entries(filters).filter(([_, enabled]) => enabled); + + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( +
+ + + {isOpen && ( +
+
+ Filter by Method + +
+ +
+ {Object.keys(filters).map(method => ( + + ))} +
+
+ )} +
+ ); +}; + +const RequestRow = ({ request, isSelected, onClick }) => { + const { data } = request; + const { request: req, response: res, timestamp } = data; + + const formatTime = (timestamp) => { + const date = new Date(timestamp); + return date.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3 + }); + }; + + const formatDuration = (duration) => { + if (!duration) return '-'; + if (duration < 1000) return `${Math.round(duration)}ms`; + return `${(duration / 1000).toFixed(2)}s`; + }; + + const formatSize = (size) => { + if (!size) return '-'; + if (size < 1024) return `${size}B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)}KB`; + return `${(size / (1024 * 1024)).toFixed(1)}MB`; + }; + + const getUrl = () => { + return req?.url || 'Unknown URL'; + }; + + const getDomain = () => { + try { + const url = new URL(getUrl()); + return url.hostname; + } catch { + return getUrl(); + } + }; + + const getPath = () => { + try { + const url = new URL(getUrl()); + return url.pathname + url.search; + } catch { + return getUrl(); + } + }; + + return ( +
+
+ +
+ +
+ +
+ +
+ {getDomain()} +
+ +
+ {getPath()} +
+ +
+ {formatTime(timestamp)} +
+ +
+ {formatDuration(res?.duration)} +
+ +
+ {formatSize(res?.size)} +
+
+ ); +}; + +const NetworkTab = () => { + const dispatch = useDispatch(); + const { networkFilters, selectedRequest } = useSelector(state => state.logs); + const collections = useSelector(state => state.collections.collections); + + const allRequests = useMemo(() => { + const requests = []; + + collections.forEach(collection => { + if (collection.timeline) { + collection.timeline + .filter(entry => entry.type === 'request') + .forEach(entry => { + requests.push({ + ...entry, + collectionName: collection.name, + collectionUid: collection.uid + }); + }); + } + }); + + return requests.sort((a, b) => a.timestamp - b.timestamp); + }, [collections]); + + const filteredRequests = useMemo(() => { + return allRequests.filter(request => { + const method = request.data?.request?.method?.toUpperCase() || 'GET'; + return networkFilters[method]; + }); + }, [allRequests, networkFilters]); + + const requestCounts = useMemo(() => { + return allRequests.reduce((counts, request) => { + const method = request.data?.request?.method?.toUpperCase() || 'GET'; + counts[method] = (counts[method] || 0) + 1; + return counts; + }, {}); + }, [allRequests]); + + const handleFilterToggle = (method, enabled) => { + dispatch(updateNetworkFilter({ method, enabled })); + }; + + const handleToggleAllFilters = (enabled) => { + dispatch(toggleAllNetworkFilters(enabled)); + }; + + const handleRequestClick = (request) => { + dispatch(setSelectedRequest(request)); + }; + + return ( + +
+ {filteredRequests.length === 0 ? ( +
+ +

No network requests

+ Requests will appear here as you make API calls +
+ ) : ( +
+
+
Method
+
Status
+
Domain
+
Path
+
Time
+
Duration
+
Size
+
+ +
+ {filteredRequests.map((request, index) => ( + handleRequestClick(request)} + /> + ))} +
+
+ )} +
+
+ ); +}; + +export default NetworkTab; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/StyledWrapper.js b/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/StyledWrapper.js new file mode 100644 index 000000000..3cc9ba03d --- /dev/null +++ b/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/StyledWrapper.js @@ -0,0 +1,347 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + display: flex; + flex-direction: column; + height: 100%; + background: ${(props) => props.theme.console.contentBg}; + border-left: 1px solid ${(props) => props.theme.console.border}; + min-width: 400px; + max-width: 600px; + width: 40%; + overflow: hidden; + + .panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: ${(props) => props.theme.console.headerBg}; + border-bottom: 1px solid ${(props) => props.theme.console.border}; + flex-shrink: 0; + } + + .panel-title { + display: flex; + align-items: center; + gap: 8px; + color: ${(props) => props.theme.console.titleColor}; + font-size: 13px; + font-weight: 500; + + .request-time { + color: ${(props) => props.theme.console.countColor}; + font-size: 11px; + font-weight: 400; + } + } + + .close-button { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: transparent; + border: none; + border-radius: 4px; + color: ${(props) => props.theme.console.buttonColor}; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: ${(props) => props.theme.console.buttonHoverBg}; + color: ${(props) => props.theme.console.buttonHoverColor}; + } + } + + .panel-tabs { + display: flex; + background: ${(props) => props.theme.console.headerBg}; + border-bottom: 1px solid ${(props) => props.theme.console.border}; + flex-shrink: 0; + } + + .tab-button { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: ${(props) => props.theme.console.buttonColor}; + cursor: pointer; + transition: all 0.2s ease; + font-size: 12px; + font-weight: 500; + + &:hover { + background: ${(props) => props.theme.console.buttonHoverBg}; + color: ${(props) => props.theme.console.buttonHoverColor}; + } + + &.active { + color: ${(props) => props.theme.console.checkboxColor}; + border-bottom-color: ${(props) => props.theme.console.checkboxColor}; + background: ${(props) => props.theme.console.contentBg}; + } + } + + .panel-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 16px; + min-height: 0; + height: 0; + } + + .tab-content { + display: flex; + flex-direction: column; + gap: 20px; + min-height: min-content; + } + + .section { + display: flex; + flex-direction: column; + gap: 8px; + + h4 { + margin: 0; + font-size: 13px; + font-weight: 600; + color: ${(props) => props.theme.console.titleColor}; + padding-bottom: 4px; + border-bottom: 1px solid ${(props) => props.theme.console.border}; + } + } + + .info-grid { + display: flex; + flex-direction: column; + gap: 8px; + } + + .info-item { + display: flex; + flex-direction: column; + gap: 2px; + + .label { + font-size: 11px; + font-weight: 600; + color: ${(props) => props.theme.console.countColor}; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .value { + font-size: 12px; + color: ${(props) => props.theme.console.messageColor}; + font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + word-break: break-all; + padding: 4px 8px; + background: ${(props) => props.theme.console.headerBg}; + border-radius: 4px; + border: 1px solid ${(props) => props.theme.console.border}; + } + } + + .headers-table, + .timeline-table { + overflow: auto; + border-radius: 4px; + border: 1px solid ${(props) => props.theme.console.border}; + max-height: 300px; + + table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + background: ${(props) => props.theme.console.headerBg}; + + thead { + background: ${(props) => props.theme.console.dropdownHeaderBg}; + position: sticky; + top: 0; + z-index: 10; + + td { + padding: 8px 12px; + font-weight: 600; + color: ${(props) => props.theme.console.titleColor}; + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.5px; + border-bottom: 1px solid ${(props) => props.theme.console.border}; + } + } + + tbody { + tr { + border-bottom: 1px solid ${(props) => props.theme.console.border}; + + &:last-child { + border-bottom: none; + } + + &:nth-child(odd) { + background: ${(props) => props.theme.console.contentBg}; + } + + &:hover { + background: ${(props) => props.theme.console.logHoverBg}; + } + } + + td { + padding: 8px 12px; + vertical-align: top; + word-break: break-word; + } + } + } + } + + .header-name, + .timeline-phase { + color: ${(props) => props.theme.console.countColor}; + font-weight: 600; + font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + min-width: 120px; + } + + .header-value, + .timeline-message { + color: ${(props) => props.theme.console.messageColor}; + font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + word-break: break-all; + } + + .timeline-duration { + color: ${(props) => props.theme.console.timestampColor}; + font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + text-align: right; + min-width: 80px; + } + + .code-block { + background: ${(props) => props.theme.console.headerBg}; + border: 1px solid ${(props) => props.theme.console.border}; + border-radius: 4px; + padding: 12px; + font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + font-size: 11px; + line-height: 1.4; + color: ${(props) => props.theme.console.messageColor}; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + max-height: 400px; + margin: 0; + } + + .empty-state { + padding: 12px; + text-align: center; + color: ${(props) => props.theme.console.emptyColor}; + font-style: italic; + font-size: 12px; + background: ${(props) => props.theme.console.headerBg}; + border: 1px solid ${(props) => props.theme.console.border}; + border-radius: 4px; + } + + .response-body-container { + border: 1px solid ${(props) => props.theme.console.border}; + border-radius: 4px; + overflow: hidden; + background: ${(props) => props.theme.console.headerBg}; + height: 400px; + display: flex; + flex-direction: column; + + .w-full.h-full.relative.flex { + height: 100% !important; + width: 100% !important; + background: ${(props) => props.theme.console.headerBg} !important; + display: flex !important; + flex-direction: column !important; + } + + div[role="tablist"] { + background: ${(props) => props.theme.console.dropdownHeaderBg}; + padding: 8px 12px; + border-bottom: 1px solid ${(props) => props.theme.console.border}; + display: flex !important; + gap: 8px !important; + flex-wrap: wrap !important; + align-items: center !important; + min-height: 40px !important; + flex-shrink: 0 !important; + + > div { + color: ${(props) => props.theme.console.buttonColor}; + font-size: 12px !important; + padding: 6px 12px !important; + border-radius: 4px; + transition: all 0.2s ease; + cursor: pointer; + border: 1px solid ${(props) => props.theme.console.border}; + background: ${(props) => props.theme.console.contentBg}; + white-space: nowrap !important; + min-width: auto !important; + height: auto !important; + line-height: 1.2 !important; + font-weight: 500 !important; + + &:hover { + background: ${(props) => props.theme.console.buttonHoverBg}; + color: ${(props) => props.theme.console.buttonHoverColor}; + border-color: ${(props) => props.theme.console.buttonHoverBg}; + } + + &.active { + background: ${(props) => props.theme.console.checkboxColor}; + color: white; + border-color: ${(props) => props.theme.console.checkboxColor}; + } + } + } + .response-filter { + position: absolute !important; + bottom: 8px !important; + right: 8px !important; + left: 8px !important; + z-index: 10 !important; + } + } + + .network-logs-container { + border: 1px solid ${(props) => props.theme.console.border}; + border-radius: 4px; + overflow: hidden; + background: ${(props) => props.theme.console.headerBg}; + min-height: 200px; + max-height: 400px; + + .network-logs { + background: ${(props) => props.theme.console.contentBg} !important; + color: ${(props) => props.theme.console.messageColor} !important; + height: 100% !important; + max-height: 400px !important; + + pre { + color: ${(props) => props.theme.console.messageColor} !important; + font-size: 11px !important; + line-height: 1.4 !important; + padding: 12px !important; + } + } + } +`; + +export default StyledWrapper; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/index.js b/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/index.js new file mode 100644 index 000000000..23439c959 --- /dev/null +++ b/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/index.js @@ -0,0 +1,242 @@ +import React, { useState } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { + IconX, + IconFileText, + IconArrowRight, + IconNetwork +} from '@tabler/icons'; +import { clearSelectedRequest } from 'providers/ReduxStore/slices/logs'; +import QueryResult from 'components/ResponsePane/QueryResult'; +import Network from 'components/ResponsePane/Timeline/TimelineItem/Network'; +import StyledWrapper from './StyledWrapper'; +import { uuid } from 'utils/common/index'; + +const RequestTab = ({ request, response }) => { + const formatHeaders = (headers) => { + if (!headers) return []; + if (Array.isArray(headers)) return headers; + return Object.entries(headers).map(([key, value]) => ({ name: key, value })); + }; + + const formatBody = (body) => { + if (!body) return 'No body'; + if (typeof body === 'string') return body; + return JSON.stringify(body, null, 2); + }; + + return ( +
+
+

General

+
+
+ Request URL: + {request?.url || 'N/A'} +
+
+ Request Method: + {request?.method || 'GET'} +
+
+
+ +
+

Request Headers

+ {formatHeaders(request?.headers).length > 0 ? ( +
+ + + + + + + + + {formatHeaders(request.headers).map((header, index) => ( + + + + + ))} + +
NameValue
{header.name}{header.value}
+
+ ) : ( +
No headers
+ )} +
+ + {request?.body && ( +
+

Request Body

+
{formatBody(request.body)}
+
+ )} +
+ ); +}; + +const ResponseTab = ({ response, request, collection }) => { + const formatHeaders = (headers) => { + if (!headers) return []; + if (Array.isArray(headers)) return headers; + return Object.entries(headers).map(([key, value]) => ({ name: key, value })); + }; + + return ( +
+
+

Response Headers

+ {formatHeaders(response?.headers).length > 0 ? ( +
+ + + + + + + + + {formatHeaders(response.headers).map((header, index) => ( + + + + + ))} + +
NameValue
{header.name}{header.value}
+
+ ) : ( +
No headers
+ )} +
+ +
+

Response Body

+
+ {response?.data || response?.dataBuffer ? ( + + ) : ( +
No response data
+ )} +
+
+
+ ); +}; + +const NetworkTab = ({ response }) => { + const timeline = response?.timeline || []; + + return ( +
+
+

Network Logs

+
+ {timeline.length > 0 ? ( + + ) : ( +
No network logs available
+ )} +
+
+
+ ); +}; + +const RequestDetailsPanel = () => { + const dispatch = useDispatch(); + const { selectedRequest } = useSelector(state => state.logs); + const collections = useSelector(state => state.collections.collections); + const [activeTab, setActiveTab] = useState('request'); + + if (!selectedRequest) return null; + + const { data } = selectedRequest; + const { request, response } = data; + + const collection = collections.find(c => c.uid === selectedRequest.collectionUid); + + const handleClose = () => { + dispatch(clearSelectedRequest()); + }; + + const formatTime = (timestamp) => { + const date = new Date(timestamp); + return date.toLocaleString(); + }; + + const getTabContent = () => { + switch (activeTab) { + case 'request': + return ; + case 'response': + return ; + case 'network': + return ; + default: + return ; + } + }; + + return ( + +
+
+ + Request Details + ({formatTime(selectedRequest.timestamp)}) +
+ + +
+ +
+ + + + + +
+ +
+ {getTabContent()} +
+
+ ); +}; + +export default RequestDetailsPanel; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Devtools/Console/StyledWrapper.js b/packages/bruno-app/src/components/Devtools/Console/StyledWrapper.js new file mode 100644 index 000000000..117d176e6 --- /dev/null +++ b/packages/bruno-app/src/components/Devtools/Console/StyledWrapper.js @@ -0,0 +1,520 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + width: 100%; + height: 100%; + background: ${(props) => props.theme.console.bg}; + border-top: 1px solid ${(props) => props.theme.console.border}; + display: flex; + flex-direction: column; + overflow: hidden; + + .console-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + background: ${(props) => props.theme.console.headerBg}; + border-bottom: 1px solid ${(props) => props.theme.console.border}; + flex-shrink: 0; + position: relative; + } + + .console-tabs { + display: flex; + align-items: center; + gap: 2px; + } + + .console-tab { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: ${(props) => props.theme.console.buttonColor}; + cursor: pointer; + transition: all 0.2s ease; + font-size: 12px; + font-weight: 500; + border-radius: 4px 4px 0 0; + + &:hover { + background: ${(props) => props.theme.console.buttonHoverBg}; + color: ${(props) => props.theme.console.buttonHoverColor}; + } + + &.active { + color: ${(props) => props.theme.console.checkboxColor}; + border-bottom-color: ${(props) => props.theme.console.checkboxColor}; + background: ${(props) => props.theme.console.contentBg}; + } + } + + .console-controls { + display: flex; + align-items: center; + gap: 4px; + } + + .console-content { + flex: 1; + overflow: hidden; + background: ${(props) => props.theme.console.contentBg}; + min-height: 0; + display: flex; + flex-direction: column; + } + + .tab-content { + display: flex; + flex-direction: column; + height: 100%; + } + + .tab-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: ${(props) => props.theme.console.headerBg}; + border-bottom: 1px solid ${(props) => props.theme.console.border}; + flex-shrink: 0; + } + + .tab-title { + display: flex; + align-items: center; + gap: 8px; + color: ${(props) => props.theme.console.titleColor}; + font-size: 13px; + font-weight: 500; + + .log-count { + color: ${(props) => props.theme.console.countColor}; + font-size: 12px; + font-weight: 400; + } + } + + .tab-controls { + display: flex; + align-items: center; + gap: 8px; + } + + .tab-content-area { + flex: 1; + overflow-y: auto; + background: ${(props) => props.theme.console.contentBg}; + min-height: 0; + } + + .network-with-details { + display: flex; + height: 100%; + overflow: hidden; + } + + .network-main { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + min-width: 0; + } + + .debug-with-details { + display: flex; + height: 100%; + overflow: hidden; + } + + .debug-main { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + min-width: 0; + } + + .filter-controls { + display: flex; + align-items: center; + gap: 4px; + margin-right: 8px; + padding-right: 8px; + border-right: 1px solid ${(props) => props.theme.console.border}; + } + + .action-controls { + display: flex; + align-items: center; + gap: 4px; + } + + .control-button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: transparent; + border: none; + border-radius: 4px; + color: ${(props) => props.theme.console.buttonColor}; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: ${(props) => props.theme.console.buttonHoverBg}; + color: ${(props) => props.theme.console.buttonHoverColor}; + } + + &.close-button:hover { + background: #e81123; + color: white; + } + } + + .filter-dropdown { + position: relative; + } + + .filter-dropdown-trigger { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + background: transparent; + border: 1px solid ${(props) => props.theme.console.border}; + border-radius: 4px; + color: ${(props) => props.theme.console.buttonColor}; + cursor: pointer; + transition: all 0.2s ease; + font-size: 12px; + + &:hover { + background: ${(props) => props.theme.console.buttonHoverBg}; + color: ${(props) => props.theme.console.buttonHoverColor}; + border-color: ${(props) => props.theme.console.border}; + } + + .filter-summary { + font-weight: 500; + min-width: 24px; + text-align: center; + } + } + + .filter-dropdown-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 200px; + max-width: 250px; + background: ${(props) => props.theme.console.dropdownBg}; + border: 1px solid ${(props) => props.theme.console.border}; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + z-index: 1000; + overflow: hidden; + + &.right { + left: auto; + right: 0; + } + } + + .filter-dropdown-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: ${(props) => props.theme.console.dropdownHeaderBg}; + border-bottom: 1px solid ${(props) => props.theme.console.border}; + font-size: 12px; + font-weight: 500; + color: ${(props) => props.theme.console.titleColor}; + } + + .filter-toggle-all { + background: transparent; + border: none; + color: ${(props) => props.theme.console.buttonColor}; + cursor: pointer; + font-size: 11px; + font-weight: 500; + padding: 2px 4px; + border-radius: 2px; + transition: all 0.2s ease; + + &:hover { + background: ${(props) => props.theme.console.buttonHoverBg}; + } + } + + .filter-dropdown-options { + padding: 4px 0; + } + + .filter-option { + display: flex; + align-items: center; + padding: 6px 12px; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background: ${(props) => props.theme.console.optionHoverBg}; + } + + input[type="checkbox"] { + margin: 0 8px 0 0; + width: 14px; + height: 14px; + accent-color: ${(props) => props.theme.console.checkboxColor}; + } + } + + .filter-option-content { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + } + + .filter-option-label { + color: ${(props) => props.theme.console.optionLabelColor}; + font-size: 12px; + font-weight: 400; + } + + .filter-option-count { + color: ${(props) => props.theme.console.optionCountColor}; + font-size: 11px; + font-weight: 400; + margin-left: auto; + } + + .console-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: ${(props) => props.theme.console.emptyColor}; + text-align: center; + gap: 8px; + padding: 40px 20px; + + p { + margin: 0; + font-size: 14px; + font-weight: 500; + } + + span { + font-size: 12px; + opacity: 0.7; + } + } + + .logs-container { + padding: 8px 0; + } + + .method-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + color: white; + text-transform: uppercase; + letter-spacing: 0.5px; + min-width: 45px; + } + + .log-entry { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 4px 16px; + font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + font-size: 12px; + line-height: 1.4; + border-left: 2px solid transparent; + transition: background-color 0.1s ease; + + &:hover { + background: ${(props) => props.theme.console.logHoverBg}; + } + + &.error { + border-left-color: #f14c4c; + + .log-level { + background: #f14c4c; + color: white; + } + + .log-icon { + color: #f14c4c; + } + } + + &.warn { + border-left-color: #ffcc02; + + .log-level { + background: #ffcc02; + color: #000; + } + + .log-icon { + color: #ffcc02; + } + } + + &.info { + border-left-color: #0078d4; + + .log-level { + background: #0078d4; + color: white; + } + + .log-icon { + color: #0078d4; + } + } + + &.debug { + border-left-color: #9b59b6; + + .log-level { + background: #9b59b6; + color: white; + } + + .log-icon { + color: #9b59b6; + } + } + + &.log { + border-left-color: #6a6a6a; + + .log-level { + background: #6a6a6a; + color: white; + } + + .log-icon { + color: #6a6a6a; + } + } + } + + .log-meta { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + min-width: 120px; + } + + .log-timestamp { + color: ${(props) => props.theme.console.timestampColor}; + font-size: 11px; + font-weight: 400; + } + + .log-level { + font-size: 9px; + font-weight: 600; + padding: 2px 4px; + border-radius: 2px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .log-icon { + flex-shrink: 0; + } + + .log-message { + color: ${(props) => props.theme.console.messageColor}; + white-space: pre-wrap; + word-break: break-word; + flex: 1; + + .log-object { + margin: 4px 0; + padding: 8px; + background: ${(props) => props.theme.console.headerBg}; + border-radius: 4px; + border: 1px solid ${(props) => props.theme.console.border}; + + .react-json-view { + background: transparent !important; + + .object-key-val { + font-size: 12px !important; + } + + .object-key { + color: ${(props) => props.theme.console.messageColor} !important; + font-weight: 500 !important; + } + + .object-value { + color: ${(props) => props.theme.console.messageColor} !important; + } + + .string-value { + color: ${(props) => props.theme.colors?.text?.green || (props.theme.console.messageColor)} !important; + } + + .number-value { + color: ${(props) => props.theme.colors?.text?.purple || (props.theme.console.messageColor)} !important; + } + + .boolean-value { + color: ${(props) => props.theme.colors?.text?.yellow || (props.theme.console.messageColor)} !important; + } + + .null-value { + color: ${(props) => props.theme.colors?.text?.danger || (props.theme.console.messageColor)} !important; + } + + .object-size { + color: ${(props) => props.theme.console.timestampColor} !important; + } + + .brace, .bracket { + color: ${(props) => props.theme.console.messageColor} !important; + } + + .collapsed-icon, .expanded-icon { + color: ${(props) => props.theme.console.checkboxColor} !important; + } + + .icon-container { + color: ${(props) => props.theme.console.checkboxColor} !important; + } + + .click-to-expand, .click-to-collapse { + color: ${(props) => props.theme.console.checkboxColor} !important; + } + } + } + } +`; + +export default StyledWrapper; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Devtools/Console/index.js b/packages/bruno-app/src/components/Devtools/Console/index.js new file mode 100644 index 000000000..e87e38d37 --- /dev/null +++ b/packages/bruno-app/src/components/Devtools/Console/index.js @@ -0,0 +1,531 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import ReactJson from 'react-json-view'; +import { useTheme } from 'providers/Theme'; +import { + IconX, + IconTrash, + IconFilter, + IconAlertTriangle, + IconAlertCircle, + IconBug, + IconCode, + IconChevronDown, + IconTerminal2, + IconNetwork +} from '@tabler/icons'; +import { + closeConsole, + clearLogs, + updateFilter, + toggleAllFilters, + setActiveTab, + clearDebugErrors, + updateNetworkFilter, + toggleAllNetworkFilters +} from 'providers/ReduxStore/slices/logs'; +import NetworkTab from './NetworkTab'; +import RequestDetailsPanel from './RequestDetailsPanel'; +// import DebugTab from './DebugTab'; +import ErrorDetailsPanel from './ErrorDetailsPanel'; +import StyledWrapper from './StyledWrapper'; + +const LogIcon = ({ type }) => { + const iconProps = { size: 16, strokeWidth: 1.5 }; + + switch (type) { + case 'error': + return ; + case 'warn': + return ; + case 'info': + return ; + // case 'debug': + // return ; + default: + return ; + } +}; + +const LogTimestamp = ({ timestamp }) => { + const date = new Date(timestamp); + const time = date.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3 + }); + + return {time}; +}; + +const LogMessage = ({ message, args }) => { + const { displayedTheme } = useTheme(); + + const formatMessage = (msg, originalArgs) => { + if (originalArgs && originalArgs.length > 0) { + return originalArgs.map((arg, index) => { + if (typeof arg === 'object' && arg !== null) { + return ( +
+ +
+ ); + } + return String(arg); + }); + } + return msg; + }; + + const formattedMessage = formatMessage(message, args); + + return ( + + {Array.isArray(formattedMessage) ? formattedMessage.map((item, index) => ( + {item} + )) : formattedMessage} + + ); +}; + +const FilterDropdown = ({ filters, logCounts, onFilterToggle, onToggleAll }) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const allFiltersEnabled = Object.values(filters).every(f => f); + const activeFilters = Object.entries(filters).filter(([_, enabled]) => enabled); + + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( +
+ + + {isOpen && ( +
+
+ Filter by Type + +
+ +
+ {Object.entries(filters).map(([filterType, enabled]) => ( + + ))} +
+
+ )} +
+ ); +}; + +const NetworkFilterDropdown = ({ filters, requestCounts, onFilterToggle, onToggleAll }) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const allFiltersEnabled = Object.values(filters).every(f => f); + const activeFilters = Object.entries(filters).filter(([_, enabled]) => enabled); + + const getMethodColor = (method) => { + switch (method?.toUpperCase()) { + case 'GET': return '#10b981'; + case 'POST': return '#8b5cf6'; + case 'PUT': return '#f59e0b'; + case 'DELETE': return '#ef4444'; + case 'PATCH': return '#06b6d4'; + case 'HEAD': return '#6b7280'; + case 'OPTIONS': return '#84cc16'; + default: return '#6b7280'; + } + }; + + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( +
+ + + {isOpen && ( +
+
+ Filter by Method + +
+ +
+ {Object.entries(filters).map(([method, enabled]) => ( + + ))} +
+
+ )} +
+ ); +}; + +const ConsoleTab = ({ logs, filters, logCounts, onFilterToggle, onToggleAll, onClearLogs }) => { + const logsEndRef = useRef(null); + const prevLogsCountRef = useRef(0); + + useEffect(() => { + // Only scroll when new logs are added, not when switching tabs + if (logsEndRef.current && logs.length > prevLogsCountRef.current) { + logsEndRef.current.scrollIntoView({ behavior: 'auto' }); + } + prevLogsCountRef.current = logs.length; + }, [logs]); + + const filteredLogs = logs.filter(log => filters[log.type]); + + return ( +
+
+ {filteredLogs.length === 0 ? ( +
+ +

No logs to display

+ Logs will appear here as your application runs +
+ ) : ( +
+ {filteredLogs.map((log) => ( +
+
+ + +
+ +
+ ))} +
+
+ )} +
+
+ ); +}; + +const Console = () => { + const dispatch = useDispatch(); + const { logs, filters, activeTab, selectedRequest, selectedError, networkFilters, debugErrors } = useSelector(state => state.logs); + const collections = useSelector(state => state.collections.collections); + const consoleRef = useRef(null); + + const logCounts = logs.reduce((counts, log) => { + counts[log.type] = (counts[log.type] || 0) + 1; + return counts; + }, {}); + + const allRequests = React.useMemo(() => { + const requests = []; + + collections.forEach(collection => { + if (collection.timeline) { + collection.timeline + .filter(entry => entry.type === 'request') + .forEach(entry => { + requests.push({ + ...entry, + collectionName: collection.name, + collectionUid: collection.uid + }); + }); + } + }); + + return requests.sort((a, b) => a.timestamp - b.timestamp); + }, [collections]); + + const filteredLogs = logs.filter(log => filters[log.type]); + const filteredRequests = allRequests.filter(request => { + const method = request.data?.request?.method?.toUpperCase() || 'GET'; + return networkFilters[method]; + }); + + const requestCounts = allRequests.reduce((counts, request) => { + const method = request.data?.request?.method?.toUpperCase() || 'GET'; + counts[method] = (counts[method] || 0) + 1; + return counts; + }, {}); + + const handleFilterToggle = (filterType, enabled) => { + dispatch(updateFilter({ filterType, enabled })); + }; + + const handleNetworkFilterToggle = (method, enabled) => { + dispatch(updateNetworkFilter({ method, enabled })); + }; + + const handleClearLogs = () => { + dispatch(clearLogs()); + }; + + const handleClearDebugErrors = () => { + dispatch(clearDebugErrors()); + }; + + const handlecloseConsole = () => { + dispatch(closeConsole()); + }; + + const handleToggleAllFilters = (enabled) => { + dispatch(toggleAllFilters(enabled)); + }; + + const handleToggleAllNetworkFilters = (enabled) => { + dispatch(toggleAllNetworkFilters(enabled)); + }; + + const handleTabChange = (tab) => { + dispatch(setActiveTab(tab)); + }; + + const renderTabContent = () => { + switch (activeTab) { + case 'console': + return ( + + ); + case 'network': + return ; + // case 'debug': + // return ; + default: + return ( + + ); + } + }; + + const renderTabControls = () => { + switch (activeTab) { + case 'console': + return ( +
+
+ +
+
+ +
+
+ ); + case 'network': + return ( +
+
+ +
+
+ ); + // case 'debug': + // return ( + //
+ //
+ // {debugErrors.length > 0 && ( + // + // )} + //
+ //
+ // ); + default: + return null; + } + }; + + + + return ( + +
+ +
+
+ + + + + {/* */} +
+ +
+ {renderTabControls()} + +
+
+ +
+ {activeTab === 'network' && selectedRequest ? ( +
+
+ {renderTabContent()} +
+ +
+ ) : activeTab === 'debug' && selectedError ? ( +
+
+ {renderTabContent()} +
+ +
+ ) : ( + renderTabContent() + )} +
+ + ); +}; + +export default Console; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Devtools/index.js b/packages/bruno-app/src/components/Devtools/index.js new file mode 100644 index 000000000..b943e4502 --- /dev/null +++ b/packages/bruno-app/src/components/Devtools/index.js @@ -0,0 +1,88 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import Console from './Console'; + +const MIN_DEVTOOLS_HEIGHT = 150; +const MAX_DEVTOOLS_HEIGHT = window.innerHeight * 0.7; +const DEFAULT_DEVTOOLS_HEIGHT = 300; + +const Devtools = ({ mainSectionRef }) => { + const isDevtoolsOpen = useSelector((state) => state.logs.isConsoleOpen); + const [devtoolsHeight, setDevtoolsHeight] = useState(DEFAULT_DEVTOOLS_HEIGHT); + const [isResizingDevtools, setIsResizingDevtools] = useState(false); + + const handleDevtoolsResizeStart = useCallback((e) => { + e.preventDefault(); + setIsResizingDevtools(true); + }, []); + + const handleDevtoolsResize = useCallback((e) => { + if (!isResizingDevtools || !mainSectionRef.current) return; + + const windowHeight = window.innerHeight; + const statusBarHeight = 22; + const mouseY = e.clientY; + + // Calculate new devtools height - expanding upward from bottom + const newHeight = windowHeight - mouseY - statusBarHeight; + const clampedHeight = Math.min(MAX_DEVTOOLS_HEIGHT, Math.max(MIN_DEVTOOLS_HEIGHT, newHeight)); + setDevtoolsHeight(clampedHeight); + + // Update main section height + if (mainSectionRef.current) { + mainSectionRef.current.style.height = `calc(100vh - 22px - ${clampedHeight}px)`; + } + }, [isResizingDevtools, mainSectionRef]); + + const handleDevtoolsResizeEnd = useCallback(() => { + setIsResizingDevtools(false); + }, []); + + useEffect(() => { + if (isResizingDevtools) { + document.addEventListener('mousemove', handleDevtoolsResize); + document.addEventListener('mouseup', handleDevtoolsResizeEnd); + document.body.style.userSelect = 'none'; + + return () => { + document.removeEventListener('mousemove', handleDevtoolsResize); + document.removeEventListener('mouseup', handleDevtoolsResizeEnd); + document.body.style.userSelect = ''; + }; + } + }, [isResizingDevtools, handleDevtoolsResize, handleDevtoolsResizeEnd]); + + // Set initial height + useEffect(() => { + if (mainSectionRef.current && isDevtoolsOpen) { + mainSectionRef.current.style.height = `calc(100vh - 22px - ${devtoolsHeight}px)`; + } + }, [isDevtoolsOpen, devtoolsHeight, mainSectionRef]); + + if (!isDevtoolsOpen) { + return null; + } + + return ( + <> +
e.target.style.backgroundColor = '#0078d4'} + onMouseLeave={(e) => e.target.style.backgroundColor = isResizingDevtools ? '#0078d4' : 'transparent'} + /> +
+ +
+ + ); +}; + +export default Devtools; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/ManageSecrets/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/ManageSecrets/index.js index ca025003c..de50ad92b 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/ManageSecrets/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/ManageSecrets/index.js @@ -9,7 +9,7 @@ const ManageSecrets = ({ onClose }) => {

In any collection, there are secrets that need to be managed.

These secrets can be anything such as API keys, passwords, or tokens.

-

Bruno offers two approaches to manage secrets in collections.

+

Bruno offers three approaches to manage secrets in collections.

Read more about it in our{' '} { + this.setState({ hasError: false }); + }, 100); + } + + render() { + return this.props.children; + } +} + +const serializeArgs = (args) => { + return args.map(arg => { + try { + if (arg === null) return 'null'; + if (arg === undefined) return 'undefined'; + if (typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean') { + return arg; + } + if (arg instanceof Error) { + return { + __type: 'Error', + name: arg.name, + message: arg.message, + stack: arg.stack + }; + } + if (typeof arg === 'object') { + try { + return JSON.parse(JSON.stringify(arg)); + } catch { + return String(arg); + } + } + return String(arg); + } catch (e) { + return '[Unserializable]'; + } + }); +}; + +// Helper function to extract file and line info from stack trace +const extractFileInfo = (stack) => { + if (!stack) return { filename: null, lineno: null, colno: null }; + + try { + const lines = stack.split('\n'); + for (let line of lines) { + if (line.includes('ErrorCapture') || line.trim() === 'Error') continue; + + const match = line.match(/(?:at\s+.*?\s+)?\(?([^)]+):(\d+):(\d+)\)?/); + if (match) { + return { + filename: match[1], + lineno: parseInt(match[2]), + colno: parseInt(match[3]) + }; + } + } + } catch (e) { + // Ignore parsing errors + } + + return { filename: null, lineno: null, colno: null }; +}; + +const useGlobalErrorCapture = () => { + const dispatch = useDispatch(); + + useEffect(() => { + const originalConsoleError = console.error; + + console.error = (...args) => { + const currentStack = new Error().stack; + + originalConsoleError.apply(console, args); + + if (currentStack && currentStack.includes('useIpcEvents.js')) { + return; + } + + const errorMessage = args.join(' '); + if (errorMessage.includes('removeConsoleLogListener')) { + return; + } + + const { filename, lineno, colno } = extractFileInfo(currentStack); + + const serializedArgs = serializeArgs(args); + + dispatch(addDebugError({ + message: errorMessage, + stack: currentStack, + filename: filename, + lineno: lineno, + colno: colno, + args: serializedArgs, + timestamp: new Date().toISOString() + })); + }; + + return () => { + console.error = originalConsoleError; + }; + }, [dispatch]); +}; + +const ErrorCapture = ({ children }) => { + const dispatch = useDispatch(); + + useGlobalErrorCapture(); + + const handleReactError = (errorData) => { + dispatch(addDebugError(errorData)); + }; + + return ( + + {children} + + ); +}; + +export default ErrorCapture; \ No newline at end of file diff --git a/packages/bruno-app/src/components/FolderSettings/Auth/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/Auth/StyledWrapper.js index ecb0976df..ba243d42b 100644 --- a/packages/bruno-app/src/components/FolderSettings/Auth/StyledWrapper.js +++ b/packages/bruno-app/src/components/FolderSettings/Auth/StyledWrapper.js @@ -11,6 +11,12 @@ const Wrapper = styled.div` border: solid 1px ${(props) => props.theme.input.border}; background-color: ${(props) => props.theme.input.bg}; } + .inherit-mode-text { + color: ${(props) => props.theme.colors.text.yellow}; + } + .auth-mode-label { + color: ${(props) => props.theme.colors.text.yellow}; + } `; export default Wrapper; \ No newline at end of file diff --git a/packages/bruno-app/src/components/FolderSettings/Auth/index.js b/packages/bruno-app/src/components/FolderSettings/Auth/index.js index 360d5c64f..2c852af4b 100644 --- a/packages/bruno-app/src/components/FolderSettings/Auth/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Auth/index.js @@ -7,8 +7,17 @@ import { updateFolderAuth } from 'providers/ReduxStore/slices/collections'; import { useDispatch } from 'react-redux'; import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index'; import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index'; +import OAuth2Implicit from 'components/RequestPane/Auth/OAuth2/Implicit/index'; import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index'; import AuthMode from '../AuthMode'; +import BasicAuth from 'components/RequestPane/Auth/BasicAuth'; +import BearerAuth from 'components/RequestPane/Auth/BearerAuth'; +import DigestAuth from 'components/RequestPane/Auth/DigestAuth'; +import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth'; +import WsseAuth from 'components/RequestPane/Auth/WsseAuth'; +import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth'; +import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth'; +import { findItemInCollection, findParentItemInCollection, humanizeRequestAuthMode } from 'utils/collections/index'; const GrantTypeComponentMap = ({ collection, folder }) => { const dispatch = useDispatch(); @@ -27,6 +36,8 @@ const GrantTypeComponentMap = ({ collection, folder }) => { return ; case 'client_credentials': return ; + case 'implicit': + return ; default: return

; } @@ -37,12 +48,132 @@ const Auth = ({ collection, folder }) => { let request = get(folder, 'root.request', {}); const authMode = get(folder, 'root.request.auth.mode'); + const getTreePathFromCollectionToFolder = (collection, _folder) => { + let path = []; + let item = findItemInCollection(collection, _folder?.uid); + while (item) { + path.unshift(item); + item = findParentItemInCollection(collection, item?.uid); + } + return path; + }; + + const getEffectiveAuthSource = () => { + if (authMode !== 'inherit') return null; + + const collectionAuth = get(collection, 'root.request.auth'); + let effectiveSource = { + type: 'collection', + name: 'Collection', + auth: collectionAuth + }; + + // Get path from collection to current folder + const folderTreePath = getTreePathFromCollectionToFolder(collection, folder); + + // Check parent folders to find closest auth configuration + // Skip the last item which is the current folder + for (let i = 0; i < folderTreePath.length - 1; i++) { + const parentFolder = folderTreePath[i]; + if (parentFolder.type === 'folder') { + const folderAuth = get(parentFolder, 'root.request.auth'); + if (folderAuth && folderAuth.mode && folderAuth.mode !== 'inherit') { + effectiveSource = { + type: 'folder', + name: parentFolder.name, + auth: folderAuth + }; + break; + } + } + } + + return effectiveSource; + }; + const handleSave = () => { dispatch(saveFolderRoot(collection.uid, folder.uid)); }; const getAuthView = () => { switch (authMode) { + case 'basic': { + return ( + handleSave()} + /> + ); + } + case 'bearer': { + return ( + handleSave()} + /> + ); + } + case 'digest': { + return ( + handleSave()} + /> + ); + } + case 'ntlm': { + return ( + handleSave()} + /> + ); + } + case 'wsse': { + return ( + handleSave()} + /> + ); + } + case 'apikey': { + return ( + handleSave()} + /> + ); + } + case 'awsv4': { + return ( + handleSave()} + /> + ); + } case 'oauth2': { return ( <> @@ -56,6 +187,17 @@ const Auth = ({ collection, folder }) => { ); } + case 'inherit': { + const source = getEffectiveAuthSource(); + return ( + <> +
+
Auth inherited from {source.name}:
+
{humanizeRequestAuthMode(source.auth?.mode)}
+
+ + ); + } case 'none': { return null; } @@ -64,6 +206,7 @@ const Auth = ({ collection, folder }) => { } }; + return (
diff --git a/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js b/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js index e6e48f110..36377973a 100644 --- a/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js +++ b/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js @@ -35,6 +35,51 @@ const AuthMode = ({ collection, folder }) => {
} placement="bottom-end"> +
{ + dropdownTippyRef.current.hide(); + onModeChange('awsv4'); + }} + > + AWS Sig v4 +
+
{ + dropdownTippyRef.current.hide(); + onModeChange('basic'); + }} + > + Basic Auth +
+
{ + dropdownTippyRef.current.hide(); + onModeChange('bearer'); + }} + > + Bearer Token +
+
{ + dropdownTippyRef.current.hide(); + onModeChange('digest'); + }} + > + Digest Auth +
+
{ + dropdownTippyRef.current.hide(); + onModeChange('ntlm'); + }} + > + NTLM Auth +
{ @@ -44,6 +89,33 @@ const AuthMode = ({ collection, folder }) => { > OAuth 2.0
+
{ + dropdownTippyRef.current.hide(); + onModeChange('wsse'); + }} + > + WSSE Auth +
+
{ + dropdownTippyRef.current.hide(); + onModeChange('apikey'); + }} + > + API Key +
+
{ + dropdownTippyRef.current.hide(); + onModeChange('inherit'); + }} + > + Inherit +
{ diff --git a/packages/bruno-app/src/components/FolderSettings/Headers/index.js b/packages/bruno-app/src/components/FolderSettings/Headers/index.js index 0f6e05f1f..4ee0002a2 100644 --- a/packages/bruno-app/src/components/FolderSettings/Headers/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Headers/index.js @@ -9,6 +9,7 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions' import SingleLineEditor from 'components/SingleLineEditor'; import StyledWrapper from './StyledWrapper'; import { headers as StandardHTTPHeaders } from 'know-your-http-well'; +import { MimeTypes } from 'utils/codemirror/autocompleteConstants'; const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header); const Headers = ({ collection, folder }) => { @@ -117,6 +118,7 @@ const Headers = ({ collection, folder }) => { } collection={collection} item={folder} + autocomplete={MimeTypes} /> diff --git a/packages/bruno-app/src/components/FolderSettings/Script/index.js b/packages/bruno-app/src/components/FolderSettings/Script/index.js index 628fa5cb5..5c3ca5b0d 100644 --- a/packages/bruno-app/src/components/FolderSettings/Script/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Script/index.js @@ -55,6 +55,7 @@ const Script = ({ collection, folder }) => { onSave={handleSave} font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} + showHintsFor={['req', 'bru']} />
@@ -68,6 +69,7 @@ const Script = ({ collection, folder }) => { onSave={handleSave} font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} + showHintsFor={['req', 'res', 'bru']} />
diff --git a/packages/bruno-app/src/components/FolderSettings/Tests/index.js b/packages/bruno-app/src/components/FolderSettings/Tests/index.js index 8854b06cd..ae20a3b8e 100644 --- a/packages/bruno-app/src/components/FolderSettings/Tests/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Tests/index.js @@ -38,6 +38,7 @@ const Tests = ({ collection, folder }) => { onSave={handleSave} font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} + showHintsFor={['req', 'res', 'bru']} />
diff --git a/packages/bruno-app/src/components/FolderSettings/index.js b/packages/bruno-app/src/components/FolderSettings/index.js index 621ae6815..abd13614a 100644 --- a/packages/bruno-app/src/components/FolderSettings/index.js +++ b/packages/bruno-app/src/components/FolderSettings/index.js @@ -9,17 +9,9 @@ import StyledWrapper from './StyledWrapper'; import Vars from './Vars'; import Documentation from './Documentation'; import Auth from './Auth'; -import DotIcon from 'components/Icons/Dot'; +import StatusDot from 'components/StatusDot'; import get from 'lodash/get'; -const ContentIndicator = () => { - return ( - - - - ); -}; - const FolderSettings = ({ collection, folder }) => { const dispatch = useDispatch(); let tab = 'headers'; @@ -82,7 +74,7 @@ const FolderSettings = ({ collection, folder }) => { }; return ( - +
setTab('headers')}> @@ -91,11 +83,11 @@ const FolderSettings = ({ collection, folder }) => {
setTab('script')}> Script - {hasScripts && } + {hasScripts && }
setTab('test')}> Test - {hasTests && } + {hasTests && }
setTab('vars')}> Vars @@ -103,13 +95,13 @@ const FolderSettings = ({ collection, folder }) => {
setTab('auth')}> Auth - {hasAuth && } + {hasAuth && }
setTab('docs')}> Docs
-
{getTabPanel(tab)}
+
{getTabPanel(tab)}
); diff --git a/packages/bruno-app/src/components/MultiLineEditor/index.js b/packages/bruno-app/src/components/MultiLineEditor/index.js index a44caf4ba..bd4fc60fe 100644 --- a/packages/bruno-app/src/components/MultiLineEditor/index.js +++ b/packages/bruno-app/src/components/MultiLineEditor/index.js @@ -2,14 +2,10 @@ import React, { Component } from 'react'; import isEqual from 'lodash/isEqual'; import { getAllVariables } from 'utils/collections'; import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror'; +import { setupAutoComplete } from 'utils/codemirror/autocomplete'; import StyledWrapper from './StyledWrapper'; -let CodeMirror; -const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true; - -if (!SERVER_RENDERED) { - CodeMirror = require('codemirror'); -} +const CodeMirror = require('codemirror'); class MultiLineEditor extends Component { constructor(props) { @@ -78,14 +74,23 @@ class MultiLineEditor extends Component { 'Shift-Tab': false } }); - if (this.props.autocomplete) { - this.editor.on('keyup', (cm, event) => { - if (!cm.state.completionActive /*Enables keyboard navigation in autocomplete list*/ && event.keyCode != 13) { - /*Enter - do not open autocomplete list just after item has been selected in it*/ - CodeMirror.commands.autocomplete(cm, CodeMirror.hint.anyword, { autocomplete: this.props.autocomplete }); - } - }); - } + + + const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item); + const getAnywordAutocompleteHints = () => this.props.autocomplete || []; + + // Setup AutoComplete Helper + const autoCompleteOptions = { + showHintsFor: ['variables'], + getAllVariables: getAllVariablesHandler, + getAnywordAutocompleteHints + }; + + this.brunoAutoCompleteCleanup = setupAutoComplete( + this.editor, + autoCompleteOptions + ); + this.editor.setValue(String(this.props.value) || ''); this.editor.on('change', this._onEdit); this.addOverlay(variables); @@ -125,12 +130,15 @@ class MultiLineEditor extends Component { } componentWillUnmount() { + if (this.brunoAutoCompleteCleanup) { + this.brunoAutoCompleteCleanup(); + } this.editor.getWrapperElement().remove(); } addOverlay = (variables) => { this.variables = variables; - defineCodeMirrorBrunoVariablesMode(variables, 'text/plain'); + defineCodeMirrorBrunoVariablesMode(variables, 'text/plain', false, true); this.editor.setOption('mode', 'brunovariables'); }; diff --git a/packages/bruno-app/src/components/Notifications/index.js b/packages/bruno-app/src/components/Notifications/index.js index d11a6254f..0667a4146 100644 --- a/packages/bruno-app/src/components/Notifications/index.js +++ b/packages/bruno-app/src/components/Notifications/index.js @@ -2,6 +2,7 @@ import { IconBell } from '@tabler/icons'; import { useState } from 'react'; import StyledWrapper from './StyleWrapper'; import Modal from 'components/Modal/index'; +import Portal from 'components/Portal'; import { useEffect } from 'react'; import { useApp } from 'providers/App'; import { @@ -109,7 +110,7 @@ const Notifications = () => { > 0 ? 'bell' : ''}`} @@ -121,6 +122,7 @@ const Notifications = () => {
{showNotificationsModal && ( + {
) : ( -
No Notifications
+
You are all caught up!
)}
+ )} ); diff --git a/packages/bruno-app/src/components/Preferences/Display/Font/index.js b/packages/bruno-app/src/components/Preferences/Display/Font/index.js index 622ea0817..e6bbf9c3f 100644 --- a/packages/bruno-app/src/components/Preferences/Display/Font/index.js +++ b/packages/bruno-app/src/components/Preferences/Display/Font/index.js @@ -3,6 +3,7 @@ import get from 'lodash/get'; import { useSelector, useDispatch } from 'react-redux'; import { savePreferences } from 'providers/ReduxStore/slices/app'; import StyledWrapper from './StyledWrapper'; +import toast from 'react-hot-toast'; const Font = ({ close }) => { const dispatch = useDispatch(); @@ -31,7 +32,10 @@ const Font = ({ close }) => { } }) ).then(() => { + toast.success('Preferences saved successfully') close(); + }).catch(() => { + toast.error('Failed to save preferences') }); }; diff --git a/packages/bruno-app/src/components/Preferences/General/index.js b/packages/bruno-app/src/components/Preferences/General/index.js index 929eae0ff..554dd0d72 100644 --- a/packages/bruno-app/src/components/Preferences/General/index.js +++ b/packages/bruno-app/src/components/Preferences/General/index.js @@ -80,9 +80,9 @@ const General = ({ close }) => { storeCookies: newPreferences.storeCookies, sendCookies: newPreferences.sendCookies } - }) - ) + })) .then(() => { + toast.success('Preferences saved successfully') close(); }) .catch((err) => console.log(err) && toast.error('Failed to update preferences')); diff --git a/packages/bruno-app/src/components/Preferences/ProxySettings/index.js b/packages/bruno-app/src/components/Preferences/ProxySettings/index.js index e7ac735c7..16695f60f 100644 --- a/packages/bruno-app/src/components/Preferences/ProxySettings/index.js +++ b/packages/bruno-app/src/components/Preferences/ProxySettings/index.js @@ -84,7 +84,10 @@ const ProxySettings = ({ close }) => { proxy: validatedProxy }) ).then(() => { + toast.success('Preferences saved successfully') close(); + }).catch(() => { + toast.error('Failed to save preferences') }); }) .catch((error) => { diff --git a/packages/bruno-app/src/components/RequestPane/Auth/ApiKeyAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/ApiKeyAuth/index.js index 22a16563e..513c29500 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/ApiKeyAuth/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/ApiKeyAuth/index.js @@ -5,21 +5,23 @@ import { IconCaretDown } from '@tabler/icons'; import Dropdown from 'components/Dropdown'; import { useTheme } from 'providers/Theme'; import SingleLineEditor from 'components/SingleLineEditor'; -import { updateAuth } from 'providers/ReduxStore/slices/collections'; -import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { sendRequest } from 'providers/ReduxStore/slices/collections/actions'; import StyledWrapper from './StyledWrapper'; import { humanizeRequestAPIKeyPlacement } from 'utils/collections'; -const ApiKeyAuth = ({ item, collection }) => { +const ApiKeyAuth = ({ item, collection, updateAuth, request, save }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); const dropdownTippyRef = useRef(); const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); - const apikeyAuth = item.draft ? get(item, 'draft.request.auth.apikey', {}) : get(item, 'request.auth.apikey', {}); + const apikeyAuth = get(request, 'auth.apikey', {}); const handleRun = () => dispatch(sendRequest(item, collection.uid)); - const handleSave = () => dispatch(saveRequest(item.uid, collection.uid)); + + const handleSave = () => { + save(); + }; const Icon = forwardRef((props, ref) => { return ( @@ -90,7 +92,7 @@ const ApiKeyAuth = ({ item, collection }) => {
{ - dropdownTippyRef.current.hide(); + dropdownTippyRef?.current?.hide(); handleAuthChange('placement', 'header'); }} > @@ -99,11 +101,11 @@ const ApiKeyAuth = ({ item, collection }) => {
{ - dropdownTippyRef.current.hide(); + dropdownTippyRef?.current?.hide(); handleAuthChange('placement', 'queryparams'); }} > - Query Params + Query Param
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js index a44cecc1b..35ae35d7a 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js @@ -8,14 +8,17 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection import StyledWrapper from './StyledWrapper'; import { update } from 'lodash'; -const AwsV4Auth = ({ onTokenChange, item, collection }) => { +const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); - const awsv4Auth = item.draft ? get(item, 'draft.request.auth.awsv4', {}) : get(item, 'request.auth.awsv4', {}); + const awsv4Auth = get(request, 'auth.awsv4', {}); const handleRun = () => dispatch(sendRequest(item, collection.uid)); - const handleSave = () => dispatch(saveRequest(item.uid, collection.uid)); + + const handleSave = () => { + save(); + }; const handleAccessKeyIdChange = (accessKeyId) => { dispatch( @@ -25,11 +28,11 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => { itemUid: item.uid, content: { accessKeyId: accessKeyId, - secretAccessKey: awsv4Auth.secretAccessKey, - sessionToken: awsv4Auth.sessionToken, - service: awsv4Auth.service, - region: awsv4Auth.region, - profileName: awsv4Auth.profileName + secretAccessKey: awsv4Auth.secretAccessKey || '', + sessionToken: awsv4Auth.sessionToken || '', + service: awsv4Auth.service || '', + region: awsv4Auth.region || '', + profileName: awsv4Auth.profileName || '' } }) ); @@ -42,12 +45,12 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - accessKeyId: awsv4Auth.accessKeyId, - secretAccessKey: secretAccessKey, - sessionToken: awsv4Auth.sessionToken, - service: awsv4Auth.service, - region: awsv4Auth.region, - profileName: awsv4Auth.profileName + accessKeyId: awsv4Auth.accessKeyId || '', + secretAccessKey: secretAccessKey || '', + sessionToken: awsv4Auth.sessionToken || '', + service: awsv4Auth.service || '', + region: awsv4Auth.region || '', + profileName: awsv4Auth.profileName || '' } }) ); @@ -60,12 +63,12 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - accessKeyId: awsv4Auth.accessKeyId, - secretAccessKey: awsv4Auth.secretAccessKey, - sessionToken: sessionToken, - service: awsv4Auth.service, - region: awsv4Auth.region, - profileName: awsv4Auth.profileName + accessKeyId: awsv4Auth.accessKeyId || '', + secretAccessKey: awsv4Auth.secretAccessKey || '', + sessionToken: sessionToken || '', + service: awsv4Auth.service || '', + region: awsv4Auth.region || '', + profileName: awsv4Auth.profileName || '' } }) ); @@ -78,12 +81,12 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - accessKeyId: awsv4Auth.accessKeyId, - secretAccessKey: awsv4Auth.secretAccessKey, - sessionToken: awsv4Auth.sessionToken, - service: service, - region: awsv4Auth.region, - profileName: awsv4Auth.profileName + accessKeyId: awsv4Auth.accessKeyId || '', + secretAccessKey: awsv4Auth.secretAccessKey || '', + sessionToken: awsv4Auth.sessionToken || '', + service: service || '', + region: awsv4Auth.region || '', + profileName: awsv4Auth.profileName || '' } }) ); @@ -96,12 +99,12 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - accessKeyId: awsv4Auth.accessKeyId, - secretAccessKey: awsv4Auth.secretAccessKey, - sessionToken: awsv4Auth.sessionToken, - service: awsv4Auth.service, - region: region, - profileName: awsv4Auth.profileName + accessKeyId: awsv4Auth.accessKeyId || '', + secretAccessKey: awsv4Auth.secretAccessKey || '', + sessionToken: awsv4Auth.sessionToken || '', + service: awsv4Auth.service || '', + region: region || '', + profileName: awsv4Auth.profileName || '' } }) ); @@ -114,12 +117,12 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - accessKeyId: awsv4Auth.accessKeyId, - secretAccessKey: awsv4Auth.secretAccessKey, - sessionToken: awsv4Auth.sessionToken, - service: awsv4Auth.service, - region: awsv4Auth.region, - profileName: profileName + accessKeyId: awsv4Auth.accessKeyId || '', + secretAccessKey: awsv4Auth.secretAccessKey || '', + sessionToken: awsv4Auth.sessionToken || '', + service: awsv4Auth.service || '', + region: awsv4Auth.region || '', + profileName: profileName || '' } }) ); diff --git a/packages/bruno-app/src/components/RequestPane/Auth/BasicAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/BasicAuth/index.js index 8582a53cd..752d7ce33 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/BasicAuth/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/BasicAuth/index.js @@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import StyledWrapper from './StyledWrapper'; -const BasicAuth = ({ item, collection }) => { +const BasicAuth = ({ item, collection, updateAuth, request, save }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); - const basicAuth = item.draft ? get(item, 'draft.request.auth.basic', {}) : get(item, 'request.auth.basic', {}); + const basicAuth = get(request, 'auth.basic', {}); const handleRun = () => dispatch(sendRequest(item, collection.uid)); - const handleSave = () => dispatch(saveRequest(item.uid, collection.uid)); + + const handleSave = () => { + save(); + }; const handleUsernameChange = (username) => { dispatch( @@ -23,8 +26,8 @@ const BasicAuth = ({ item, collection }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - username: username, - password: basicAuth.password + username: username || '', + password: basicAuth.password || '' } }) ); @@ -37,8 +40,8 @@ const BasicAuth = ({ item, collection }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - username: basicAuth.username, - password: password + username: basicAuth.username || '', + password: password || '' } }) ); diff --git a/packages/bruno-app/src/components/RequestPane/Auth/BearerAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/BearerAuth/index.js index bef4d062a..c8ba9d1c6 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/BearerAuth/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/BearerAuth/index.js @@ -7,16 +7,18 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import StyledWrapper from './StyledWrapper'; -const BearerAuth = ({ item, collection }) => { +const BearerAuth = ({ item, collection, updateAuth, request, save }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); - const bearerToken = item.draft - ? get(item, 'draft.request.auth.bearer.token', '') - : get(item, 'request.auth.bearer.token', ''); + // Use the request prop directly like OAuth2ClientCredentials does + const bearerToken = get(request, 'auth.bearer.token', ''); const handleRun = () => dispatch(sendRequest(item, collection.uid)); - const handleSave = () => dispatch(saveRequest(item.uid, collection.uid)); + + const handleSave = () => { + save(); + }; const handleTokenChange = (token) => { dispatch( diff --git a/packages/bruno-app/src/components/RequestPane/Auth/DigestAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/DigestAuth/index.js index e91ed8d1f..a4ff3012e 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/DigestAuth/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/DigestAuth/index.js @@ -3,18 +3,20 @@ import get from 'lodash/get'; import { useTheme } from 'providers/Theme'; import { useDispatch } from 'react-redux'; import SingleLineEditor from 'components/SingleLineEditor'; -import { updateAuth } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import StyledWrapper from './StyledWrapper'; -const DigestAuth = ({ item, collection }) => { +const DigestAuth = ({ item, collection, updateAuth, request, save }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); - const digestAuth = item.draft ? get(item, 'draft.request.auth.digest', {}) : get(item, 'request.auth.digest', {}); + const digestAuth = get(request, 'auth.digest', {}); const handleRun = () => dispatch(sendRequest(item, collection.uid)); - const handleSave = () => dispatch(saveRequest(item.uid, collection.uid)); + + const handleSave = () => { + save(); + }; const handleUsernameChange = (username) => { dispatch( @@ -23,8 +25,8 @@ const DigestAuth = ({ item, collection }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - username: username, - password: digestAuth.password + username: username || '', + password: digestAuth.password || '' } }) ); @@ -37,8 +39,8 @@ const DigestAuth = ({ item, collection }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - username: digestAuth.username, - password: password + username: digestAuth.username || '', + password: password || '' } }) ); diff --git a/packages/bruno-app/src/components/RequestPane/Auth/NTLMAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/NTLMAuth/index.js index 65e756041..44f87656e 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/NTLMAuth/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/NTLMAuth/index.js @@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import StyledWrapper from './StyledWrapper'; -const NTLMAuth = ({ item, collection }) => { +const NTLMAuth = ({ item, collection, request, save, updateAuth }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); - const ntlmAuth = item.draft ? get(item, 'draft.request.auth.ntlm', {}) : get(item, 'request.auth.ntlm', {}); + const ntlmAuth = get(request, 'auth.ntlm', {}); const handleRun = () => dispatch(sendRequest(item, collection.uid)); - const handleSave = () => dispatch(saveRequest(item.uid, collection.uid)); + + const handleSave = () => { + save(); + }; const handleUsernameChange = (username) => { dispatch( @@ -23,10 +26,9 @@ const NTLMAuth = ({ item, collection }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - username: username, - password: ntlmAuth.password, - domain: ntlmAuth.domain - + username: username || '', + password: ntlmAuth.password || '', + domain: ntlmAuth.domain || '' } }) ); @@ -39,9 +41,9 @@ const NTLMAuth = ({ item, collection }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - username: ntlmAuth.username, - password: password, - domain: ntlmAuth.domain + username: ntlmAuth.username || '', + password: password || '', + domain: ntlmAuth.domain || '' } }) ); @@ -54,9 +56,9 @@ const NTLMAuth = ({ item, collection }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - username: ntlmAuth.username, - password: ntlmAuth.password, - domain: domain + username: ntlmAuth.username || '', + password: ntlmAuth.password || '', + domain: domain || '' } }) ); diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/GrantTypeSelector/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/GrantTypeSelector/index.js index e468845e4..f89aa9579 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/GrantTypeSelector/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/GrantTypeSelector/index.js @@ -101,6 +101,15 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => { > Authorization Code
+
{ + dropdownTippyRef.current.hide(); + onGrantTypeChange('implicit'); + }} + > + Implicit +
{ diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/StyledWrapper.js new file mode 100644 index 000000000..273806001 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/StyledWrapper.js @@ -0,0 +1,61 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + label { + font-size: 0.8125rem; + } + .oauth2-input-wrapper { + max-width: 400px; + padding: 0.15rem 0.4rem; + border-radius: 3px; + border: solid 1px ${(props) => props.theme.input.border}; + background-color: ${(props) => props.theme.input.bg}; + } + + .token-placement-selector { + padding: 0.5rem 0px; + border-radius: 3px; + border: solid 1px ${(props) => props.theme.input.border}; + background-color: ${(props) => props.theme.input.bg}; + min-width: 100px; + + .dropdown { + width: fit-content; + min-width: 100px; + + div[data-tippy-root] { + width: fit-content; + min-width: 100px; + } + .tippy-box { + width: fit-content; + max-width: none !important; + min-width: 100px; + + .tippy-content { + width: fit-content; + max-width: none !important; + min-width: 100px; + } + } + } + + .token-placement-label { + width: fit-content; + justify-content: space-between; + padding: 0 0.5rem; + min-width: 100px; + } + + .dropdown-item { + padding: 0.2rem 0.6rem !important; + } + } + + .checkbox-label { + color: ${(props) => props.theme.colors.text.primary}; + user-select: none; + } +`; + +export default Wrapper; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/index.js new file mode 100644 index 000000000..3952180b0 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/index.js @@ -0,0 +1,281 @@ +import React, { useRef, forwardRef, useState, useMemo } from 'react'; +import get from 'lodash/get'; +import { useTheme } from 'providers/Theme'; +import { useDispatch } from 'react-redux'; +import { IconCaretDown, IconLoader2, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons'; +import Dropdown from 'components/Dropdown'; +import SingleLineEditor from 'components/SingleLineEditor'; +import { clearOauth2Cache, fetchOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions'; +import Wrapper from './StyledWrapper'; +import { inputsConfig } from './inputsConfig'; +import toast from 'react-hot-toast'; +import Oauth2TokenViewer from '../Oauth2TokenViewer/index'; +import { cloneDeep } from 'lodash'; +import { getAllVariables } from 'utils/collections/index'; +import { interpolate } from '@usebruno/common'; + +const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, collection, folder }) => { + const dispatch = useDispatch(); + const { storedTheme } = useTheme(); + const dropdownTippyRef = useRef(); + const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); + const [fetchingToken, toggleFetchingToken] = useState(false); + + const oAuth = get(request, 'auth.oauth2', {}); + const { + callbackUrl, + authorizationUrl, + clientId, + scope, + state, + credentialsId, + tokenPlacement, + tokenHeaderPrefix, + tokenQueryKey, + autoFetchToken + } = oAuth; + + const interpolatedAuthUrl = useMemo(() => { + const variables = getAllVariables(collection, item); + return interpolate(authorizationUrl, variables); + }, [collection, item, authorizationUrl]); + + const TokenPlacementIcon = forwardRef((props, ref) => { + return ( +
+ {tokenPlacement == 'url' ? 'URL' : 'Headers'} + +
+ ); + }); + + const handleFetchOauth2Credentials = async () => { + let requestCopy = cloneDeep(request); + requestCopy.oauth2 = requestCopy?.auth.oauth2; + requestCopy.headers = {}; + toggleFetchingToken(true); + try { + const result = await dispatch(fetchOauth2Credentials({ + itemUid: item.uid, + request: requestCopy, + collection, + folderUid: folder?.uid || null, + forceGetToken: true + })); + + toggleFetchingToken(false); + + // Check if the result contains error or if access_token is missing + if (result?.error || !result?.access_token) { + const errorMessage = result?.error || 'No access token received from authorization server'; + toast.error(errorMessage); + return; + } + + toast.success('Token fetched successfully!'); + } + catch (error) { + console.error(error); + toggleFetchingToken(false); + toast.error(error?.message || 'An error occurred while fetching token!'); + } + } + + const handleSave = () => { save(); }; + + const handleChange = (key, value) => { + dispatch( + updateAuth({ + mode: 'oauth2', + collectionUid: collection.uid, + itemUid: item.uid, + content: { + grantType: 'implicit', + callbackUrl, + authorizationUrl, + clientId, + state, + scope, + credentialsId, + tokenPlacement, + tokenHeaderPrefix, + tokenQueryKey, + autoFetchToken, + [key]: value, + } + }) + ); + }; + + const handleAutoFetchTokenToggle = (e) => { + handleChange('autoFetchToken', e.target.checked); + }; + + const handleClearCache = (e) => { + dispatch(clearOauth2Cache({ collectionUid: collection?.uid, url: interpolatedAuthUrl, credentialsId })) + .then(() => { + toast.success('Cleared cache successfully'); + }) + .catch((err) => { + toast.error(err.message); + }); + }; + + return ( + + +
+
+ +
+ + Configuration + +
+ {inputsConfig.map((input) => { + const { key, label, isSecret } = input; + return ( +
+ +
+ handleChange(key, val)} + onRun={handleRun} + collection={collection} + item={item} + isSecret={isSecret} + /> +
+
+ ); + })} + +
+
+ +
+ + Token + +
+ +
+ +
+ handleChange('credentialsId', val)} + onRun={handleRun} + collection={collection} + item={item} + /> +
+
+ +
+ +
+ } placement="bottom-end"> +
{ + dropdownTippyRef.current.hide(); + handleChange('tokenPlacement', 'header'); + }} + > + Headers +
+
{ + dropdownTippyRef.current.hide(); + handleChange('tokenPlacement', 'url'); + }} + > + URL +
+
+
+
+ + {tokenPlacement == 'header' ? ( +
+ +
+ handleChange('tokenHeaderPrefix', val)} + onRun={handleRun} + collection={collection} + item={item} + /> +
+
+ ) : ( +
+ +
+ handleChange('tokenQueryKey', val)} + onRun={handleRun} + collection={collection} + item={item} + /> +
+
+ )} + +
+
+ +
+ + Advanced Options + +
+ +
+ + +
+
+ + + Automatically fetch a new token when the current one expires. + +
+
+
+ +
+ + +
+
+ ); +}; + +export default OAuth2Implicit; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/inputsConfig.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/inputsConfig.js new file mode 100644 index 000000000..86040b838 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/inputsConfig.js @@ -0,0 +1,24 @@ +const inputsConfig = [ + { + key: 'callbackUrl', + label: 'Callback URL' + }, + { + key: 'authorizationUrl', + label: 'Authorization URL' + }, + { + key: 'clientId', + label: 'Client ID' + }, + { + key: 'scope', + label: 'Scope' + }, + { + key: 'state', + label: 'State' + } +]; + +export { inputsConfig }; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js index 7b45f03ea..de729fdd5 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js @@ -28,20 +28,30 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c requestCopy.headers = {}; toggleFetchingToken(true); try { - const credentials = await dispatch(fetchOauth2Credentials({ itemUid: item.uid, request: requestCopy, collection })); + const result = await dispatch(fetchOauth2Credentials({ + itemUid: item.uid, + request: requestCopy, + collection, + forceGetToken: true + })); + toggleFetchingToken(false); - if (credentials?.access_token) { - toast.success('token fetched successfully!'); - } - else { - toast.error('An error occurred while fetching token!'); + + // Check if the result contains error or if access_token is missing + if (!result || !result.access_token) { + const errorMessage = result?.error || 'No access token received from authorization server'; + console.error(errorMessage); + toast.error(errorMessage); + return; } + + toast.success('Token fetched successfully!'); } catch (error) { console.error('could not fetch the token!'); console.error(error); toggleFetchingToken(false); - toast.error('An error occurred while fetching token!'); + toast.error(error?.message || 'An error occurred while fetching token!'); } } @@ -51,26 +61,36 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c requestCopy.headers = {}; toggleRefreshingToken(true); try { - const credentials = await dispatch(refreshOauth2Credentials({ itemUid: item.uid, request: requestCopy, collection })); + const result = await dispatch(refreshOauth2Credentials({ + itemUid: item.uid, + request: requestCopy, + collection, + forceGetToken: true + })); + toggleRefreshingToken(false); - if (credentials?.access_token) { - toast.success('token refreshed successfully!'); - } - else { - toast.error('An error occurred while refreshing token!'); + + // Check if the result contains error or if access_token is missing + if (!result || !result.access_token) { + const errorMessage = result?.error || 'No access token received from authorization server'; + console.error(errorMessage); + toast.error(errorMessage); + return; } + + toast.success('Token refreshed successfully!'); } catch(error) { console.error(error); toggleRefreshingToken(false); - toast.error('An error occurred while refreshing token!'); + toast.error(error?.message || 'An error occurred while refreshing token!'); } }; const handleClearCache = (e) => { dispatch(clearOauth2Cache({ collectionUid: collection?.uid, url: interpolatedAccessTokenUrl, credentialsId })) .then(() => { - toast.success('cleared cache successfully'); + toast.success('Cleared cache successfully'); }) .catch((err) => { toast.error(err.message); diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/index.js index bbee9d4f5..98b435f1d 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/index.js @@ -4,6 +4,7 @@ import StyledWrapper from './StyledWrapper'; import GrantTypeSelector from './GrantTypeSelector/index'; import OAuth2PasswordCredentials from './PasswordCredentials/index'; import OAuth2AuthorizationCode from './AuthorizationCode/index'; +import OAuth2Implicit from './Implicit/index'; import OAuth2ClientCredentials from './ClientCredentials/index'; import { updateAuth } from 'providers/ReduxStore/slices/collections'; import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions'; @@ -31,6 +32,9 @@ const GrantTypeComponentMap = ({ item, collection }) => { case 'authorization_code': return ; break; + case 'implicit': + return ; + break; case 'client_credentials': return ; break; diff --git a/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/index.js index 76a20e6f6..05e9daaf1 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/index.js @@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import StyledWrapper from './StyledWrapper'; -const WsseAuth = ({ item, collection }) => { +const WsseAuth = ({ item, collection, updateAuth, request, save }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); - const wsseAuth = item.draft ? get(item, 'draft.request.auth.wsse', {}) : get(item, 'request.auth.wsse', {}); + const wsseAuth = get(request, 'auth.wsse', {}); const handleRun = () => dispatch(sendRequest(item, collection.uid)); - const handleSave = () => dispatch(saveRequest(item.uid, collection.uid)); + + const handleSave = () => { + save(); + }; const handleUserChange = (username) => { dispatch( @@ -23,8 +26,8 @@ const WsseAuth = ({ item, collection }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - username, - password: wsseAuth.password + username: username || '', + password: wsseAuth.password || '' } }) ); @@ -37,8 +40,8 @@ const WsseAuth = ({ item, collection }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - username: wsseAuth.username, - password + username: wsseAuth.username || '', + password: password || '' } }) ); @@ -55,6 +58,7 @@ const WsseAuth = ({ item, collection }) => { onChange={(val) => handleUserChange(val)} onRun={handleRun} collection={collection} + item={item} />
@@ -67,6 +71,8 @@ const WsseAuth = ({ item, collection }) => { onChange={(val) => handlePasswordChange(val)} onRun={handleRun} collection={collection} + item={item} + isSecret={true} />
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/index.js index 4cb8897d3..c16f7bb68 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/index.js @@ -7,6 +7,8 @@ import BasicAuth from './BasicAuth'; import DigestAuth from './DigestAuth'; import WsseAuth from './WsseAuth'; import NTLMAuth from './NTLMAuth'; +import { updateAuth } from 'providers/ReduxStore/slices/collections'; +import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import ApiKeyAuth from './ApiKeyAuth'; import StyledWrapper from './StyledWrapper'; @@ -27,6 +29,16 @@ const getTreePathFromCollectionToItem = (collection, _item) => { const Auth = ({ item, collection }) => { const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode'); const requestTreePath = getTreePathFromCollectionToItem(collection, item); + + // Create a request object to pass to the auth components + const request = item.draft + ? get(item, 'draft.request', {}) + : get(item, 'request', {}); + + // Save function for request level + const save = () => { + return saveRequest(item.uid, collection.uid); + }; const getEffectiveAuthSource = () => { if (authMode !== 'inherit') return null; @@ -42,7 +54,7 @@ const Auth = ({ item, collection }) => { for (let i of [...requestTreePath].reverse()) { if (i.type === 'folder') { const folderAuth = get(i, 'root.request.auth'); - if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') { + if (folderAuth && folderAuth.mode && folderAuth.mode !== 'inherit') { effectiveSource = { type: 'folder', name: i.name, @@ -59,28 +71,28 @@ const Auth = ({ item, collection }) => { const getAuthView = () => { switch (authMode) { case 'awsv4': { - return ; + return ; } case 'basic': { - return ; + return ; } case 'bearer': { - return ; + return ; } case 'digest': { - return ; + return ; } case 'ntlm': { - return ; + return ; } case 'oauth2': { - return ; + return ; } case 'wsse': { - return ; + return ; } case 'apikey': { - return ; + return ; } case 'inherit': { const source = getEffectiveAuthSource(); diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js index 07dcf1419..da48bb34a 100644 --- a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js @@ -18,8 +18,10 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection import StyledWrapper from './StyledWrapper'; import Documentation from 'components/Documentation/index'; import GraphQLSchemaActions from '../GraphQLSchemaActions/index'; +import HeightBoundContainer from 'ui/HeightBoundContainer'; +import Settings from 'components/RequestPane/Settings'; -const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, toggleDocs, handleGqlClickReference }) => { +const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handleGqlClickReference }) => { const dispatch = useDispatch(); const tabs = useSelector((state) => state.tabs.tabs); const activeTabUid = useSelector((state) => state.tabs.activeTabUid); @@ -66,7 +68,6 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog collection={collection} theme={displayedTheme} schema={schema} - width={leftPaneWidth} onSave={onSave} value={query} onRun={onRun} @@ -101,6 +102,9 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog case 'docs': { return ; } + case 'settings': { + return ; + } default: { return
404 | Not found
; } @@ -152,9 +156,14 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
selectTab('docs')}> Docs
+
selectTab('settings')}> + Settings +
-
{getTabPanel(focusedTab.requestPaneTab)}
+
+ {getTabPanel(focusedTab.requestPaneTab)} +
); }; diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/index.js index 8fe747389..3b1cc6109 100644 --- a/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/index.js +++ b/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/index.js @@ -7,8 +7,10 @@ import Dropdown from '../../Dropdown'; const GraphQLSchemaActions = ({ item, collection, onSchemaLoad, toggleDocs }) => { const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', ''); + const pathname = item.draft ? get(item, 'draft.pathname', '') : get(item, 'pathname', ''); + const uid = item.draft ? get(item, 'draft.uid', '') : get(item, 'uid', ''); const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid); - const request = item.draft ? item.draft.request : item.request; + const request = item.draft ? { ...item.draft.request, pathname, uid } : { ...item.request, pathname, uid }; let { schema, diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js index eaac6f204..0a7fd98c9 100644 --- a/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js +++ b/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js @@ -64,9 +64,11 @@ const GraphQLVariables = ({ variables, item, collection }) => { font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} onEdit={onEdit} - mode="javascript" + mode="application/json" onRun={onRun} onSave={onSave} + enableVariableHighlighting={true} + showHintsFor={['variables']} /> ); diff --git a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js index 4c7e6029b..1ca7d39c6 100644 --- a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js @@ -7,7 +7,6 @@ import RequestHeaders from 'components/RequestPane/RequestHeaders'; import RequestBody from 'components/RequestPane/RequestBody'; import RequestBodyMode from 'components/RequestPane/RequestBody/RequestBodyMode'; import Auth from 'components/RequestPane/Auth'; -import DotIcon from 'components/Icons/Dot'; import Vars from 'components/RequestPane/Vars'; import Assertions from 'components/RequestPane/Assertions'; import Script from 'components/RequestPane/Script'; @@ -15,25 +14,12 @@ import Tests from 'components/RequestPane/Tests'; import StyledWrapper from './StyledWrapper'; import { find, get } from 'lodash'; import Documentation from 'components/Documentation/index'; +import HeightBoundContainer from 'ui/HeightBoundContainer'; import { useEffect } from 'react'; +import StatusDot from 'components/StatusDot'; +import Settings from 'components/RequestPane/Settings'; -const ContentIndicator = () => { - return ( - - - - ); -}; - -const ErrorIndicator = () => { - return ( - - - - ); -}; - -const HttpRequestPane = ({ item, collection, leftPaneWidth }) => { +const HttpRequestPane = ({ item, collection }) => { const dispatch = useDispatch(); const tabs = useSelector((state) => state.tabs.tabs); const activeTabUid = useSelector((state) => state.tabs.activeTabUid); @@ -76,6 +62,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => { case 'docs': { return ; } + case 'settings': { + return ; + } default: { return
404 | Not found
; } @@ -112,6 +101,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => { const requestVars = getPropertyFromDraftOrRequest('request.vars.req'); const responseVars = getPropertyFromDraftOrRequest('request.vars.res'); const auth = getPropertyFromDraftOrRequest('request.auth'); + const tags = getPropertyFromDraftOrRequest('tags'); const activeParamsLength = params.filter((param) => param.enabled).length; const activeHeadersLength = headers.filter((header) => header.enabled).length; @@ -135,7 +125,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
selectTab('body')}> Body - {body.mode !== 'none' && } + {body.mode !== 'none' && }
selectTab('headers')}> Headers @@ -143,7 +133,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
selectTab('auth')}> Auth - {auth.mode !== 'none' && } + {auth.mode !== 'none' && }
selectTab('vars')}> Vars @@ -152,9 +142,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
selectTab('script')}> Script {(script.req || script.res) && ( - item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage ? - : - + item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage ? + : + )}
selectTab('assert')}> @@ -163,11 +153,19 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
selectTab('tests')}> Tests - {tests && tests.length > 0 && } + {tests && tests.length > 0 && ( + item.testScriptErrorMessage ? + : + + )}
selectTab('docs')}> Docs - {docs && docs.length > 0 && } + {docs && docs.length > 0 && } +
+
selectTab('settings')}> + Settings + {tags && tags.length > 0 && }
{focusedTab.requestPaneTab === 'body' ? (
@@ -180,7 +178,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => { 'mt-5': !isMultipleContentTab })} > - {getTabPanel(focusedTab.requestPaneTab)} + + {getTabPanel(focusedTab.requestPaneTab)} + ); diff --git a/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js b/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js index 6571c14ae..decc7bd1d 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js @@ -18,12 +18,7 @@ import { IconWand } from '@tabler/icons'; import onHasCompletion from './onHasCompletion'; -let CodeMirror; -const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true; - -if (!SERVER_RENDERED) { - CodeMirror = require('codemirror'); -} +const CodeMirror = require('codemirror'); const md = new MD(); const AUTO_COMPLETE_AFTER_KEY = /^[a-zA-Z0-9_@(]$/; diff --git a/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js index b460c1b4f..9a23f2f9c 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js @@ -31,7 +31,7 @@ const Wrapper = styled.div` } } - .btn-add-param { + .btn-action { font-size: 0.8125rem; &:hover span { text-decoration: underline; diff --git a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js index 3f7f7ef01..0b1b9df9c 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js @@ -1,16 +1,17 @@ -import React from 'react'; +import React, { useState } from 'react'; import get from 'lodash/get'; import cloneDeep from 'lodash/cloneDeep'; import InfoTip from 'components/InfoTip'; import { IconTrash } from '@tabler/icons'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useTheme } from 'providers/Theme'; import { addQueryParam, updateQueryParam, deleteQueryParam, moveQueryParam, - updatePathParam + updatePathParam, + setQueryParams } from 'providers/ReduxStore/slices/collections'; import SingleLineEditor from 'components/SingleLineEditor'; import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions'; @@ -18,6 +19,7 @@ import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collection import StyledWrapper from './StyledWrapper'; import Table from 'components/Table/index'; import ReorderTable from 'components/ReorderTable'; +import BulkEditor from '../../BulkEditor'; const QueryParams = ({ item, collection }) => { const dispatch = useDispatch(); @@ -25,6 +27,8 @@ const QueryParams = ({ item, collection }) => { const params = item.draft ? get(item, 'draft.request.params') : get(item, 'request.params'); const queryParams = params.filter((param) => param.type === 'query'); const pathParams = params.filter((param) => param.type === 'path'); + + const [isBulkEditMode, setIsBulkEditMode] = useState(false); const handleAddQueryParam = () => { dispatch( @@ -113,8 +117,31 @@ const QueryParams = ({ item, collection }) => { ); }; + const toggleBulkEditMode = () => { + setIsBulkEditMode(!isBulkEditMode); + }; + + const handleBulkParamsChange = (newParams) => { + const paramsWithType = newParams.map((item) => ({ ...item, type: 'query' })); + dispatch(setQueryParams({ collectionUid: collection.uid, itemUid: item.uid, params: paramsWithType })); + }; + + if (isBulkEditMode) { + return ( + + + + ); + } + return ( - +
Query
{
- +
+ + +
Path diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js index 321ed4fd5..2035ba00c 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js @@ -83,6 +83,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
{ const Icon = forwardRef((props, ref) => { return (
- {humanizeRequestBodyMode(bodyMode)} + {humanizeRequestBodyMode(bodyMode)}
); }); @@ -149,7 +149,7 @@ const RequestBodyMode = ({ item, collection }) => {
{(bodyMode === 'json' || bodyMode === 'xml') && ( - )} diff --git a/packages/bruno-app/src/components/RequestPane/RequestBody/index.js b/packages/bruno-app/src/components/RequestPane/RequestBody/index.js index 8f7fa8465..d562684e5 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestBody/index.js +++ b/packages/bruno-app/src/components/RequestPane/RequestBody/index.js @@ -49,7 +49,7 @@ const RequestBody = ({ item, collection }) => { { onRun={onRun} onSave={onSave} mode={codeMirrorMode[bodyMode]} + enableVariableHighlighting={true} + showHintsFor={['variables']} /> ); } if (bodyMode === 'file') { - return + return ; } if (bodyMode === 'formUrlEncoded') { @@ -77,4 +79,4 @@ const RequestBody = ({ item, collection }) => { return No Body; }; -export default RequestBody; \ No newline at end of file +export default RequestBody; diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js index 5b787e8bb..86cb4e365 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js @@ -22,8 +22,11 @@ const Wrapper = styled.div` } } - .btn-add-header { + .btn-action { font-size: 0.8125rem; + &:hover span { + text-decoration: underline; + } } input[type='text'] { diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js index d88318017..ddcc62af2 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js +++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js @@ -1,10 +1,10 @@ -import React from 'react'; +import React, { useState } from 'react'; import get from 'lodash/get'; import cloneDeep from 'lodash/cloneDeep'; import { IconTrash } from '@tabler/icons'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useTheme } from 'providers/Theme'; -import { addRequestHeader, updateRequestHeader, deleteRequestHeader, moveRequestHeader } from 'providers/ReduxStore/slices/collections'; +import { addRequestHeader, updateRequestHeader, deleteRequestHeader, moveRequestHeader, setRequestHeaders } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import SingleLineEditor from 'components/SingleLineEditor'; import StyledWrapper from './StyledWrapper'; @@ -12,12 +12,16 @@ import { headers as StandardHTTPHeaders } from 'know-your-http-well'; import { MimeTypes } from 'utils/codemirror/autocompleteConstants'; import Table from 'components/Table/index'; import ReorderTable from 'components/ReorderTable/index'; +import BulkEditor from '../../BulkEditor'; + const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header); const RequestHeaders = ({ item, collection }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); const headers = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers'); + + const [isBulkEditMode, setIsBulkEditMode] = useState(false); const addHeader = () => { dispatch( @@ -75,6 +79,28 @@ const RequestHeaders = ({ item, collection }) => { ); }; + const toggleBulkEditMode = () => { + setIsBulkEditMode(!isBulkEditMode); + }; + + const handleBulkHeadersChange = (newHeaders) => { + dispatch(setRequestHeaders({ collectionUid: collection.uid, itemUid: item.uid, headers: newHeaders })); + }; + + if (isBulkEditMode) { + return ( + + + + ); + } + return ( { : null}
- +
+ + +
); }; diff --git a/packages/bruno-app/src/components/RequestPane/Script/index.js b/packages/bruno-app/src/components/RequestPane/Script/index.js index ec4f4df95..adcf3ebe6 100644 --- a/packages/bruno-app/src/components/RequestPane/Script/index.js +++ b/packages/bruno-app/src/components/RequestPane/Script/index.js @@ -52,6 +52,7 @@ const Script = ({ item, collection }) => { mode="javascript" onRun={onRun} onSave={onSave} + showHintsFor={['req', 'bru']} />
@@ -66,6 +67,7 @@ const Script = ({ item, collection }) => { mode="javascript" onRun={onRun} onSave={onSave} + showHintsFor={['req', 'res', 'bru']} />
diff --git a/packages/bruno-app/src/components/RequestPane/Settings/Tags/index.js b/packages/bruno-app/src/components/RequestPane/Settings/Tags/index.js new file mode 100644 index 000000000..006a9894f --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/Settings/Tags/index.js @@ -0,0 +1,63 @@ +import React, { useCallback, useEffect } from 'react'; +import get from 'lodash/get'; +import { useDispatch } from 'react-redux'; +import { addRequestTag, deleteRequestTag, updateCollectionTagsList } from 'providers/ReduxStore/slices/collections'; +import TagList from 'components/TagList/index'; +import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; + +const Tags = ({ item, collection }) => { + const dispatch = useDispatch(); + // all tags in the collection + const collectionTags = collection.allTags || []; + + // tags for the current request + const tags = item.draft ? get(item, 'draft.tags', []) : get(item, 'tags', []); + + // Filter out tags that are already associated with the current request + const collectionTagsWithoutCurrentRequestTags = collectionTags?.filter(tag => !tags.includes(tag)) || []; + + const handleAdd = useCallback((tag) => { + const trimmedTag = tag.trim(); + if (trimmedTag && !tags.includes(trimmedTag)) { + dispatch( + addRequestTag({ + tag: trimmedTag, + itemUid: item.uid, + collectionUid: collection.uid + }) + ); + } + }, [dispatch, tags, item.uid, collection.uid]); + + const handleRemove = useCallback((tag) => { + dispatch( + deleteRequestTag({ + tag, + itemUid: item.uid, + collectionUid: collection.uid + }) + ); + }, [dispatch, item.uid, collection.uid]); + + const handleRequestSave = () => { + dispatch(saveRequest(item.uid, collection.uid)); + } + + useEffect(() => { + dispatch(updateCollectionTagsList({ collectionUid: collection.uid })); + }, [collection.uid, dispatch]); + + return ( +
+ +
+ ); +}; + +export default Tags; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RequestPane/Settings/ToggleSelector/index.js b/packages/bruno-app/src/components/RequestPane/Settings/ToggleSelector/index.js new file mode 100644 index 000000000..f0294aee9 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/Settings/ToggleSelector/index.js @@ -0,0 +1,74 @@ +import React from 'react'; + +const ToggleSelector = ({ + checked, + onChange, + label, + description, + disabled = false, + size = 'small' // 'small', 'medium', 'large' +}) => { + const sizeClasses = { + small: { + container: 'h-4 w-8', + thumb: 'h-3 w-3', + translate: checked ? 'translate-x-4' : 'translate-x-1' + }, + medium: { + container: 'h-5 w-9', + thumb: 'h-3 w-3', + translate: checked ? 'translate-x-5' : 'translate-x-1' + }, + large: { + container: 'h-6 w-11', + thumb: 'h-4 w-4', + translate: checked ? 'translate-x-6' : 'translate-x-1' + } + }; + + const currentSize = sizeClasses[size]; + + return ( +
+ +
+ + {description && ( +

+ {description} +

+ )} +
+
+ ); +}; + +export default ToggleSelector; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RequestPane/Settings/index.js b/packages/bruno-app/src/components/RequestPane/Settings/index.js new file mode 100644 index 000000000..df570085d --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/Settings/index.js @@ -0,0 +1,50 @@ +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import get from 'lodash/get'; +import { IconTag } from '@tabler/icons'; +import ToggleSelector from 'components/RequestPane/Settings/ToggleSelector'; +import { updateItemSettings } from 'providers/ReduxStore/slices/collections'; +import Tags from './Tags/index'; + +const Settings = ({ item, collection }) => { + const dispatch = useDispatch(); + + // get the length of active params, headers, asserts and vars as well as the contents of the body, tests and script + const getPropertyFromDraftOrRequest = (propertyKey) => + item.draft ? get(item, `draft.${propertyKey}`, {}) : get(item, propertyKey, {}); + + const { encodeUrl } = getPropertyFromDraftOrRequest('settings'); + + const onToggleUrlEncoding = useCallback(() => { + dispatch(updateItemSettings({ + collectionUid: collection.uid, + itemUid: item.uid, + settings: { encodeUrl: !encodeUrl } + })); + }, [encodeUrl, dispatch, collection.uid, item.uid]); + + return ( +
+
+

+ + Tags +

+
+ +
+
+
+ +
+
+ ); +}; + +export default Settings; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RequestPane/Tests/index.js b/packages/bruno-app/src/components/RequestPane/Tests/index.js index d0d19c283..b9c9633be 100644 --- a/packages/bruno-app/src/components/RequestPane/Tests/index.js +++ b/packages/bruno-app/src/components/RequestPane/Tests/index.js @@ -37,6 +37,7 @@ const Tests = ({ item, collection }) => { mode="javascript" onRun={onRun} onSave={onSave} + showHintsFor={['req', 'res', 'bru']} /> ); }; diff --git a/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js index ec0a03217..3bee80c40 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js @@ -3,9 +3,13 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` &.dragging { cursor: col-resize; + + &.vertical-layout { + cursor: row-resize; + } } - div.drag-request { + div.dragbar-wrapper { display: flex; align-items: center; justify-content: center; @@ -15,18 +19,47 @@ const StyledWrapper = styled.div` cursor: col-resize; background: transparent; - div.drag-request-border { + div.dragbar-handle { display: flex; height: 100%; width: 1px; border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border}; } - &:hover div.drag-request-border { + &:hover div.dragbar-handle { border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder}; } } + &.vertical-layout { + .request-pane { + padding-bottom: 0.5rem; + } + + .response-pane { + padding-top: 0.5rem; + } + + div.dragbar-wrapper { + width: 100%; + height: 10px; + cursor: row-resize; + padding: 0 1rem; + + div.dragbar-handle { + width: 100%; + height: 1px; + border-left: none; + border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border}; + } + + &:hover div.dragbar-handle { + border-left: none; + border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder}; + } + } + } + div.graphql-docs-explorer-container { background: white; outline: none; diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index 5f53a5e02..6863b30b2 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -29,7 +29,8 @@ import FolderNotFound from './FolderNotFound'; const MIN_LEFT_PANE_WIDTH = 300; const MIN_RIGHT_PANE_WIDTH = 350; -const DEFAULT_PADDING = 5; +const MIN_TOP_PANE_HEIGHT = 150; +const MIN_BOTTOM_PANE_HEIGHT = 150; const RequestTabPanel = () => { if (typeof window == 'undefined') { @@ -41,6 +42,8 @@ const RequestTabPanel = () => { const focusedTab = find(tabs, (t) => t.uid === activeTabUid); const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments); const _collections = useSelector((state) => state.collections.collections); + const preferences = useSelector((state) => state.app.preferences); + const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical'; // merge `globalEnvironmentVariables` into the active collection and rebuild `collections` immer proxy object let collections = produce(_collections, (draft) => { @@ -64,13 +67,15 @@ const RequestTabPanel = () => { let asideWidth = useSelector((state) => state.app.leftSidebarWidth); const [leftPaneWidth, setLeftPaneWidth] = useState( focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / 2.2 - ); // 2.2 so that request pane is relatively smaller - const [rightPaneWidth, setRightPaneWidth] = useState(screenWidth - asideWidth - leftPaneWidth - DEFAULT_PADDING); + ); // 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 }); // Not a recommended pattern here to have the child component // make a callback to set state, but treating this as an exception const docExplorerRef = useRef(null); + const mainSectionRef = useRef(null); const [schema, setSchema] = useState(null); const [showGqlDocs, setShowGqlDocs] = useState(false); const onSchemaLoad = (schema) => setSchema(schema); @@ -85,43 +90,72 @@ const RequestTabPanel = () => { }; useEffect(() => { - const leftPaneWidth = (screenWidth - asideWidth) / 2.2; - setLeftPaneWidth(leftPaneWidth); - }, [screenWidth]); - - useEffect(() => { - setRightPaneWidth(screenWidth - asideWidth - leftPaneWidth - DEFAULT_PADDING); - }, [screenWidth, asideWidth, leftPaneWidth]); + // Initialize vertical heights when switching to vertical layout + if (mainSectionRef.current) { + const mainRect = mainSectionRef.current.getBoundingClientRect(); + if (isVerticalLayout) { + const initialHeight = mainRect.height / 2; + setTopPaneHeight(initialHeight); + // In vertical mode, set leftPaneWidth to full container width + setLeftPaneWidth(mainRect.width); + } else { + // In horizontal mode, set to roughly half width + setLeftPaneWidth((screenWidth - asideWidth) / 2.2); + } + } + }, [isVerticalLayout, screenWidth, asideWidth]); const handleMouseMove = (e) => { - if (dragging) { + if (dragging && mainSectionRef.current) { e.preventDefault(); - let leftPaneXPosition = e.clientX + 2; - if ( - leftPaneXPosition < asideWidth + DEFAULT_PADDING + MIN_LEFT_PANE_WIDTH || - leftPaneXPosition > screenWidth - MIN_RIGHT_PANE_WIDTH - ) { - return; + const mainRect = mainSectionRef.current.getBoundingClientRect(); + + if (isVerticalLayout) { + const newHeight = e.clientY - mainRect.top - dragOffset.current.y; + if (newHeight < MIN_TOP_PANE_HEIGHT || newHeight > mainRect.height - MIN_BOTTOM_PANE_HEIGHT) { + return; + } + + setTopPaneHeight(newHeight); + } else { + const newWidth = e.clientX - mainRect.left - dragOffset.current.x; + if (newWidth < MIN_LEFT_PANE_WIDTH || newWidth > mainRect.width - MIN_RIGHT_PANE_WIDTH) { + return; + } + setLeftPaneWidth(newWidth); } - setLeftPaneWidth(leftPaneXPosition - asideWidth); - setRightPaneWidth(screenWidth - e.clientX - DEFAULT_PADDING); } }; + const handleMouseUp = (e) => { - if (dragging) { + if (dragging && mainSectionRef.current) { e.preventDefault(); setDragging(false); - dispatch( - updateRequestPaneTabWidth({ - uid: activeTabUid, - requestPaneWidth: e.clientX - asideWidth - DEFAULT_PADDING - }) - ); + if (!isVerticalLayout) { + const mainRect = mainSectionRef.current.getBoundingClientRect(); + dispatch( + updateRequestPaneTabWidth({ + uid: activeTabUid, + requestPaneWidth: e.clientX - mainRect.left + }) + ); + } } }; + const handleDragbarMouseDown = (e) => { e.preventDefault(); setDragging(true); + + if (isVerticalLayout) { + const dragBar = e.currentTarget; + const dragBarRect = dragBar.getBoundingClientRect(); + dragOffset.current.y = e.clientY - dragBarRect.top; + } else { + const dragBar = e.currentTarget; + const dragBarRect = dragBar.getBoundingClientRect(); + dragOffset.current.x = e.clientX - dragBarRect.left; + } }; useEffect(() => { @@ -132,7 +166,7 @@ const RequestTabPanel = () => { document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mousemove', handleMouseMove); }; - }, [dragging, asideWidth]); + }, [dragging]); if (!activeTabUid) { return ; @@ -197,15 +231,19 @@ const RequestTabPanel = () => { }; return ( - +
-
+
@@ -213,7 +251,6 @@ const RequestTabPanel = () => { { ) : null} {item.type === 'http-request' ? ( - + ) : null}
-
-
+
+
-
- +
+
diff --git a/packages/bruno-app/src/components/ResponsePane/LargeResponseWarning/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/LargeResponseWarning/StyledWrapper.js new file mode 100644 index 000000000..0a5339df8 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/LargeResponseWarning/StyledWrapper.js @@ -0,0 +1,65 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + + .warning-container { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 1.5rem; + margin-top: 10%; + text-align: center; + max-width: 480px; + } + + .warning-icon { + margin-bottom: 1rem; + color: ${(props) => props.theme.colors.text.yellow}; + } + + .warning-title { + font-weight: 600; + color: ${(props) => props.theme.text}; + margin-bottom: 1rem; + } + + .warning-description { + color: ${(props) => props.theme.colors.text.muted}; + + .size-highlight { + padding: 0.125rem 0.375rem; + border-radius: 4px; + font-size: 0.8rem; + } + + .current-size { + color: ${(props) => props.theme.colors.text.danger}; + background: ${(props) => props.theme.colors.text.danger}15; + } + + .supported-size { + color: ${(props) => props.theme.colors.text.yellow}; + background: ${(props) => props.theme.colors.text.yellow}15; + } + } + + .warning-actions { + display: flex; + gap: 0.75rem; + } + + button { + align-items: center; + display: flex; + gap: 0.5rem; + background: ${(props) => props.theme.button.secondary.bg}; + border-radius: 4px; + padding: 0.5rem 1rem; + cursor: pointer; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/LargeResponseWarning/index.js b/packages/bruno-app/src/components/ResponsePane/LargeResponseWarning/index.js new file mode 100644 index 000000000..1686b4a38 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/LargeResponseWarning/index.js @@ -0,0 +1,92 @@ +import React from 'react'; +import { IconDownload, IconCopy, IconEye, IconAlertTriangle } from '@tabler/icons'; +import toast from 'react-hot-toast'; +import get from 'lodash/get'; +import StyledWrapper from './StyledWrapper'; +import { formatSize } from 'utils/common/index'; + +const LargeResponseWarning = ({ item, responseSize, onRevealResponse }) => { + const { ipcRenderer } = window; + const response = item.response || {}; + + const saveResponseToFile = () => { + return new Promise((resolve, reject) => { + ipcRenderer + .invoke('renderer:save-response-to-file', response, item.requestSent.url) + .then(() => { + toast.success('Response saved to file'); + resolve(); + }) + .catch((err) => { + toast.error(get(err, 'error.message') || 'Something went wrong!'); + reject(err); + }); + }); + }; + + const copyResponse = () => { + try { + const textToCopy = typeof response.data === 'string' + ? response.data + : JSON.stringify(response.data, null, 2); + + navigator.clipboard.writeText(textToCopy).then(() => { + toast.success('Response copied to clipboard'); + }).catch(() => { + toast.error('Failed to copy response'); + }); + } catch (error) { + toast.error('Failed to copy response'); + } + }; + + return ( + +
+
+ +
+
+
+ Large Response Warning +
+
+ Handling responses over {formatSize(10 * 1024 * 1024)} could degrade performance. +
+ Size of current response: {formatSize(responseSize)} +
+
+
+
+ + + +
+
+ ); +}; + +export default LargeResponseWarning; \ No newline at end of file diff --git a/packages/bruno-app/src/components/ResponsePane/Overlay/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/Overlay/StyledWrapper.js index 045a9dcc3..e60eb7024 100644 --- a/packages/bruno-app/src/components/ResponsePane/Overlay/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/Overlay/StyledWrapper.js @@ -22,6 +22,15 @@ const StyledWrapper = styled.div` animation: rotateCounterClockwise 1s linear infinite; } } + + // spinner and request time content looks better centered vertically in vertical layout + // while in horizontal layout, it looks better when the content is aligned to the top + &.vertical-layout { + div.overlay { + justify-content: center; + padding: 1rem; + } + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/Overlay/index.js b/packages/bruno-app/src/components/ResponsePane/Overlay/index.js index 91fb02d78..429c4889a 100644 --- a/packages/bruno-app/src/components/ResponsePane/Overlay/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Overlay/index.js @@ -1,23 +1,25 @@ import React from 'react'; import { IconRefresh } from '@tabler/icons'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { cancelRequest } from 'providers/ReduxStore/slices/collections/actions'; import StopWatch from '../../StopWatch'; import StyledWrapper from './StyledWrapper'; const ResponseLoadingOverlay = ({ item, collection }) => { const dispatch = useDispatch(); + const preferences = useSelector((state) => state.app.preferences); + const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical'; const handleCancelRequest = () => { dispatch(cancelRequest(item.cancelTokenUid, item, collection)); }; return ( - +
- +
diff --git a/packages/bruno-app/src/components/ResponsePane/Placeholder/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/Placeholder/StyledWrapper.js index f6d7a09c5..369637b01 100644 --- a/packages/bruno-app/src/components/ResponsePane/Placeholder/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/Placeholder/StyledWrapper.js @@ -1,12 +1,19 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` + display: flex; + flex-direction: column; padding-top: 20%; width: 100%; .send-icon { color: ${(props) => props.theme.requestTabPanel.responseSendIcon}; } + + &.vertical-layout { + padding: 1rem; + justify-content: center; + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/Placeholder/index.js b/packages/bruno-app/src/components/ResponsePane/Placeholder/index.js index bca9e138a..4a315ca26 100644 --- a/packages/bruno-app/src/components/ResponsePane/Placeholder/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Placeholder/index.js @@ -1,5 +1,6 @@ import React from 'react'; import { IconSend } from '@tabler/icons'; +import { useSelector } from 'react-redux'; import StyledWrapper from './StyledWrapper'; import { isMacOS } from 'utils/common/platform'; @@ -8,9 +9,11 @@ const Placeholder = () => { const sendRequestShortcut = isMac ? 'Cmd + Enter' : 'Ctrl + Enter'; const newRequestShortcut = isMac ? 'Cmd + B' : 'Ctrl + B'; const editEnvironmentShortcut = isMac ? 'Cmd + E' : 'Ctrl + E'; + const preferences = useSelector((state) => state.app.preferences); + const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical'; return ( - +
diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js index 1407e69ae..a52d1499b 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js @@ -11,6 +11,7 @@ import StyledWrapper from './StyledWrapper'; import { useState, useMemo, useEffect } from 'react'; import { useTheme } from 'providers/Theme/index'; import { getEncoding, uuid } from 'utils/common/index'; +import LargeResponseWarning from '../LargeResponseWarning'; const formatResponse = (data, dataBuffer, encoding, mode, filter) => { if (data === undefined || !dataBuffer || !mode) { @@ -73,10 +74,11 @@ const formatErrorMessage = (error) => { return error; }; -const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEventListener, headers, error }) => { +const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListener, headers, error }) => { const contentType = getContentType(headers); const mode = getCodeMirrorModeBasedOnContentType(contentType, data); const [filter, setFilter] = useState(null); + const [showLargeResponse, setShowLargeResponse] = useState(false); const responseEncoding = getEncoding(headers); const formattedData = useMemo( () => formatResponse(data, dataBuffer, responseEncoding, mode, filter), @@ -84,6 +86,25 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven ); const { displayedTheme } = useTheme(); + const responseSize = useMemo(() => { + const response = item.response || {}; + if (typeof response.size === 'number') { + return response.size; + } + + if (!dataBuffer) return 0; + + try { + // dataBuffer is base64 encoded, so we need to calculate the actual size + const buffer = Buffer.from(dataBuffer, 'base64'); + return buffer.length; + } catch (error) { + return 0; + } + }, [dataBuffer, item.response]); + + const isLargeResponse = responseSize > 10 * 1024 * 1024; // 10 MB + const debouncedResultFilterOnChange = debounce((e) => { setFilter(e.target.value); }, 250); @@ -143,7 +164,6 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven return (
@@ -151,7 +171,9 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
{error ? (
- {hasScriptError ? null :
{formatErrorMessage(error)}
} + {hasScriptError ? null : ( +
{formatErrorMessage(error)}
+ )} {error && typeof error === 'string' && error.toLowerCase().includes('self signed certificate') ? (
@@ -160,6 +182,12 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
) : null}
+ ) : isLargeResponse && !showLargeResponse ? ( + setShowLargeResponse(true)} + /> ) : (
diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/StyledWrapper.js new file mode 100644 index 000000000..8cb5d4b43 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/StyledWrapper.js @@ -0,0 +1,15 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + button { + display: flex; + align-items: center; + padding: 0.25rem; + background: transparent; + border: none; + cursor: pointer; + color: ${(props) => props.theme.colors.text.muted}; + } +`; + +export default Wrapper; \ No newline at end of file diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.js new file mode 100644 index 000000000..49299422b --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.js @@ -0,0 +1,84 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { savePreferences } from 'providers/ReduxStore/slices/app'; +import StyledWrapper from './StyledWrapper'; + +const IconDockToBottom = () => { + return ( + + + + + + + ); +}; + +const IconDockToRight = () => { + return ( + + + + + + + ); +}; + +const ResponseLayoutToggle = () => { + const dispatch = useDispatch(); + const preferences = useSelector((state) => state.app.preferences); + const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal'; + + const toggleOrientation = () => { + const newOrientation = orientation === 'horizontal' ? 'vertical' : 'horizontal'; + const updatedPreferences = { + ...preferences, + layout: { + ...preferences.layout, + responsePaneOrientation: newOrientation + } + }; + dispatch(savePreferences(updatedPreferences)); + }; + + return ( + + + + ); +}; + +export default ResponseLayoutToggle; \ No newline at end of file diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.spec.js b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.spec.js new file mode 100644 index 000000000..0dd1c7b1a --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.spec.js @@ -0,0 +1,173 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen, fireEvent} from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { ThemeProvider } from 'providers/Theme'; +import { configureStore, createSlice } from '@reduxjs/toolkit'; +import ResponseLayoutToggle from './index'; + +const mockSavePreferences = jest.fn((payload) => ({ type: 'app/savePreferences', payload })); + +// Mock the savePreferences action +jest.mock('providers/ReduxStore/slices/app', () => ({ + savePreferences: (payload) => mockSavePreferences(payload) +})); + +// Mock localStorage +const mockLocalStorage = { + getItem: jest.fn(() => 'dark'), + setItem: jest.fn(), + removeItem: jest.fn() +}; + +// Mock matchMedia +beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + addEventListener: jest.fn(), + removeEventListener: jest.fn() + })), + }); + Object.defineProperty(window, 'localStorage', { + value: mockLocalStorage + }); +}); + +beforeEach(() => { + mockSavePreferences.mockClear(); +}); + +const initialState = { + app: { + preferences: { + layout: { + responsePaneOrientation: 'horizontal' + } + } + } +}; + +const createTestStore = (initialState) => { + const appSlice = createSlice({ + name: 'app', + initialState: initialState.app, + reducers: { + savePreferences: (state, action) => { + state.preferences = action.payload; + } + } + }); + + return configureStore({ + reducer: { app: appSlice.reducer } + }); +}; + +const renderWithProviders = (component, customState = initialState) => { + const store = createTestStore(customState); + return { + store, + ...render( + + + {component} + + + ) + }; +}; + +describe('ResponseLayoutToggle', () => { + describe('Initial Render', () => { + it('should render with horizontal orientation by default', () => { + renderWithProviders(); + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('title', 'Switch to vertical layout'); + }); + + it('should render with vertical orientation when specified', () => { + const customState = { + app: { + preferences: { + layout: { + responsePaneOrientation: 'vertical' + } + } + } + }; + renderWithProviders(, customState); + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('title', 'Switch to horizontal layout'); + }); + }); + + describe('Interaction', () => { + it('should switch to vertical layout when clicked in horizontal mode', () => { + const { store } = renderWithProviders(); + const button = screen.getByRole('button'); + + // Initial state check + expect(button).toHaveAttribute('title', 'Switch to vertical layout'); + + fireEvent.click(button); + + // Check if action was called + expect(mockSavePreferences).toHaveBeenCalledWith({ + layout: { + responsePaneOrientation: 'vertical' + } + }); + + // Manually update store to simulate state change + store.dispatch(mockSavePreferences({ + layout: { + responsePaneOrientation: 'vertical' + } + })); + + // Check if button title was updated + expect(button).toHaveAttribute('title', 'Switch to horizontal layout'); + }); + + it('should switch to horizontal layout when clicked in vertical mode', () => { + const customState = { + app: { + preferences: { + layout: { + responsePaneOrientation: 'vertical' + } + } + } + }; + const { store } = renderWithProviders(, customState); + const button = screen.getByRole('button'); + + // Initial state check + expect(button).toHaveAttribute('title', 'Switch to horizontal layout'); + + fireEvent.click(button); + + // Check if action was called + expect(mockSavePreferences).toHaveBeenCalledWith({ + layout: { + responsePaneOrientation: 'horizontal' + } + }); + + // Manually update store to simulate state change + store.dispatch(mockSavePreferences({ + layout: { + responsePaneOrientation: 'horizontal' + } + })); + + // Check if button title was updated + expect(button).toHaveAttribute('title', 'Switch to vertical layout'); + }); + }); +}); diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseSize/ResponseSize.spec.js b/packages/bruno-app/src/components/ResponsePane/ResponseSize/ResponseSize.spec.js new file mode 100644 index 000000000..ef46d4361 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/ResponseSize/ResponseSize.spec.js @@ -0,0 +1,110 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from 'styled-components'; +import ResponseSize from './index'; + +// Create minimal theme with only the properties needed for the component +const theme = { + requestTabPanel: { + responseStatus: '#666' + } +}; + +// Wrap component with theme provider for styled-components +const renderWithTheme = (component) => { + return render( + + {component} + + ); +}; + +describe('ResponseSize', () => { + describe('Invalid or excluded size values', () => { + it('should not render when size is undefined', () => { + const { container } = renderWithTheme(); + expect(container).toBeEmptyDOMElement(); + }); + + it('should not render when size is null', () => { + const { container } = renderWithTheme(); + expect(container).toBeEmptyDOMElement(); + }); + + it('should not render when size is NaN', () => { + const { container } = renderWithTheme(); + expect(container).toBeEmptyDOMElement(); + }); + + it('should not render when size is Infinity', () => { + const { container } = renderWithTheme(); + expect(container).toBeEmptyDOMElement(); + }); + + it('should not render when size is -Infinity', () => { + const { container } = renderWithTheme(); + expect(container).toBeEmptyDOMElement(); + }); + + it('should not render when size is a string', () => { + const { container } = renderWithTheme(); + expect(container).toBeEmptyDOMElement(); + }); + + it('should not render when size is an object', () => { + const { container } = renderWithTheme(); + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe('Valid size values', () => { + it('should handle zero bytes', () => { + renderWithTheme(); + const element = screen.getByText(/0B/); + expect(element).toBeInTheDocument(); + expect(element.textContent).toMatch(/^0B$/); + expect(element).toHaveAttribute('title', '0B'); + }); + + it('should render bytes when size is less than 1024', () => { + renderWithTheme(); + const element = screen.getByText(/500B/); + expect(element).toBeInTheDocument(); + expect(element.textContent).toMatch(/^500B$/); + expect(element).toHaveAttribute('title', '500B'); + }); + + it('should handle exactly 1024 bytes as size', () => { + renderWithTheme(); + const element = screen.getByText(/1024B/); + expect(element).toBeInTheDocument(); + expect(element.textContent).toMatch(/^1024B$/); + expect(element).toHaveAttribute('title', '1,024B'); + }); + + it('should render kilobytes when size is greater than 1024', () => { + renderWithTheme(); + const element = screen.getByText(/1\.46KB/); + expect(element).toBeInTheDocument(); + expect(element.textContent).toMatch(/^\d+\.\d+KB$/); + expect(element).toHaveAttribute('title', '1,500B'); + }); + + it('should handle large size numbers', () => { + renderWithTheme(); + const element = screen.getByText(/10\.0KB/); + expect(element).toBeInTheDocument(); + expect(element.textContent).toMatch(/^\d+\.\d+KB$/); + expect(element).toHaveAttribute('title', '10,240B'); + }); + + it('should handle decimal size numbers', () => { + renderWithTheme(); + const element = screen.getByText(/1\.10KB/); + expect(element).toBeInTheDocument(); + expect(element.textContent).toMatch(/^\d+\.\d+KB$/); + expect(element).toHaveAttribute('title', '1,126.5B'); + }); + }); +}); \ No newline at end of file diff --git a/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/StyledWrapper.js index 020d5bd91..4b7cb28a7 100644 --- a/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/StyledWrapper.js @@ -1,11 +1,109 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` + .timeline-event { + padding: 8px 0 0 0; + cursor: pointer; + } + + .timeline-event-content { + border-radius: 4px; + padding: 12px; + margin-top: 0.5rem; + } + + .timeline-event-header { + color: ${(props) => props.theme.text}; + } + + .method-label { + font-weight: 600; + } + + .status-code { + font-weight: 600; + } + + .url-text { + color: ${(props) => props.theme.colors.text.muted}; + font-size: 0.875rem; + margin-top: 0.25rem; + } + + .timestamp { + color: ${(props) => props.theme.colors.text.muted}; + font-size: 0.875rem; + } + + .meta-info { + color: ${(props) => props.theme.colors.text.muted}; + font-size: 0.875rem; + } + + .oauth-section { + .oauth-header { + display: flex; + align-items: center; + color: ${(props) => props.theme.text}; + font-weight: 600; + + span { + margin-left: 0.5rem; + } + } + } + + .tabs-switcher { + border-bottom: 1px solid ${(props) => props.theme.modal.input.border}; + margin-bottom: 16px; + + button { + position: relative; + padding: 8px 16px; + color: ${(props) => props.theme.colors.text.muted}; + + &.active { + color: ${(props) => props.theme.tabs.active.color}; + &:after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background: ${(props) => props.theme.tabs.active.border}; + } + } + } + } + + .network-logs { + background: ${(props) => props.theme.codemirror.bg}; + color: ${(props) => props.theme.text}; + border-radius: 4px; + } + + .oauth-request-item-content { + border-radius: 4px; + margin-top: 0.5rem; + } + + .collapsible-section { + margin-bottom: 12px; + + .section-header { + cursor: pointer; + &:hover { + opacity: 0.8; + } + } + } + .line { white-space: pre-line; word-wrap: break-word; word-break: break-all; - font-family: Inter, sans-serif !important; + font-family: ${(props) => props.theme.font || 'Inter, sans-serif'} !important; .arrow { opacity: 0.5; @@ -19,6 +117,35 @@ const StyledWrapper = styled.div` color: ${(props) => props.theme.colors.text.purple}; } } + + .request-label { + font-size: 0.75rem; + padding: 2px 6px; + border-radius: 3px; + margin-left: 8px; + background: ${(props) => props.theme.requestTabs.bg}; + } + + table { + width: 100%; + border-collapse: collapse; + font-weight: 600; + table-layout: fixed; + + thead, + td { + border: 1px solid ${(props) => props.theme.table.border}; + } + + thead { + color: ${(props) => props.theme.table.thead.color}; + font-size: 0.8125rem; + user-select: none; + } + td { + padding: 6px 10px; + } + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/index.js b/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/index.js index 592e0641b..4fac7ed6d 100644 --- a/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/index.js +++ b/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/index.js @@ -1,14 +1,10 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import forOwn from 'lodash/forOwn'; -import { safeStringifyJSON } from 'utils/common'; import StyledWrapper from './StyledWrapper'; +import TimelineItem from '../Timeline/TimelineItem'; -const RunnerTimeline = ({ request, response }) => { +const RunnerTimeline = ({ request = {}, response = {}, item, collection }) => { const requestHeaders = []; - const responseHeaders = typeof response.headers === 'object' ? Object.entries(response.headers) : []; - - request = request || {}; - response = response || {}; forOwn(request.headers, (value, key) => { requestHeaders.push({ @@ -17,43 +13,56 @@ const RunnerTimeline = ({ request, response }) => { }); }); - let requestData = typeof request?.data === "string" ? request?.data : safeStringifyJSON(request?.data, true); + const oauth2Events = useMemo( + () => + collection?.timeline?.filter( + (event) => event.type === 'oauth2' && event.itemUid === item.uid + ) || [], + [collection?.timeline, item.uid] + ); return ( -
-
-          {'>'} {request.method} {request.url}
-        
- {requestHeaders.map((h) => { - return ( -
-              {'>'} {h.name}: {h.value}
-            
- ); - })} - - {requestData ? ( -
-            {'>'} data{' '}
-            
{requestData}
-
- ) : null} -
- -
-
-          {'<'} {response.status} - {response.statusText}
-        
- - {responseHeaders.map((h) => { - return ( -
-              {'<'} {h[0]}: {h[1]}
-            
- ); - })} -
+ {/* Show the main request/response timeline item */} + + + {oauth2Events.map((event, index) => { + const { data, timestamp } = event; + const { debugInfo } = data; + return ( +
+
+
+ OAuth2.0 Calls +
+
+
+ {debugInfo && debugInfo.length > 0 ? ( + debugInfo.map((data, idx) => ( +
+ +
+ )) + ) : ( +
No debug information available.
+ )} +
+
+ ); + })}
); }; diff --git a/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js b/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js index 4af07c587..1db032d2e 100644 --- a/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js @@ -6,22 +6,48 @@ import StyledWrapper from './StyledWrapper'; const ScriptError = ({ item, onClose }) => { const preRequestError = item?.preRequestScriptErrorMessage; const postResponseError = item?.postResponseScriptErrorMessage; + const testScriptError = item?.testScriptErrorMessage; - if (!preRequestError && !postResponseError) return null; + if (!preRequestError && !postResponseError && !testScriptError) return null; - const errorMessage = preRequestError || postResponseError; - const errorTitle = preRequestError ? 'Pre-Request Script Error' : 'Post-Response Script Error'; + const errors = []; + + if (preRequestError) { + errors.push({ + title: 'Pre-Request Script Error', + message: preRequestError + }); + } + + if (postResponseError) { + errors.push({ + title: 'Post-Response Script Error', + message: postResponseError + }); + } + + if (testScriptError) { + errors.push({ + title: 'Test Script Error', + message: testScriptError + }); + } return (
-
- {errorTitle} -
-
- {errorMessage} -
+ {errors.map((error, index) => ( +
+ {index > 0 &&
} +
+ {error.title} +
+
+ {error.message} +
+
+ ))}
props.theme.text}; + + .test-summary { + transition: background-color 0.2s; + border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder}; + color: ${(props) => props.theme.text}; + + &:hover { + background-color: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + } + } + .test-success { color: ${(props) => props.theme.colors.text.green}; } @@ -9,12 +21,24 @@ const StyledWrapper = styled.div` color: ${(props) => props.theme.colors.text.danger}; } + .test-success-count { + color: ${(props) => props.theme.colors.text.green}; + } + + .test-failure-count { + color: ${(props) => props.theme.colors.text.danger}; + } + .error-message { color: ${(props) => props.theme.colors.text.muted}; } - .skipped-request { - color: ${(props) => props.theme.colors.text.muted}; + .test-results-list { + transition: all 0.3s ease; + } + + .dropdown-icon { + color: ${(props) => props.theme.sidebar.dropdownIcon.color}; } `; diff --git a/packages/bruno-app/src/components/ResponsePane/TestResults/index.js b/packages/bruno-app/src/components/ResponsePane/TestResults/index.js index 074fac9e1..624de837d 100644 --- a/packages/bruno-app/src/components/ResponsePane/TestResults/index.js +++ b/packages/bruno-app/src/components/ResponsePane/TestResults/index.js @@ -1,63 +1,151 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import StyledWrapper from './StyledWrapper'; +import { + IconChevronDown, + IconChevronRight, + IconCircleCheck, + IconCircleX +} from '@tabler/icons'; -const TestResults = ({ results, assertionResults }) => { +const ResultIcon = ({ status }) => ( + + {status === 'pass' ? ( + + ) : ( + + )} + +); + +const ErrorMessage = ({ error }) => error && ( + <> +
+ + {error} + + +); + +const ResultItem = ({ result, type }) => ( +
+ + + {type === 'assertion' + ? `${result.lhsExpr}: ${result.rhsExpr}` + : result.description + } + + +
+); + +const TestSection = ({ + title, + results, + isExpanded, + onToggle, + type = 'test' +}) => { + const passedResults = results.filter((result) => result.status === 'pass'); + const failedResults = results.filter((result) => result.status === 'fail'); + + if (results.length === 0) return null; + + return ( +
+
+ + {isExpanded ? + : + + } + + + {title} ({results.length}), Passed: {passedResults.length}, Failed: {failedResults.length} + +
+ {isExpanded && ( +
    + {results.map((result) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +}; + +const TestResults = ({ results, assertionResults, preRequestTestResults, postResponseTestResults }) => { results = results || []; assertionResults = assertionResults || []; - if (!results.length && !assertionResults.length) { - return
No tests found
; + preRequestTestResults = preRequestTestResults || []; + postResponseTestResults = postResponseTestResults || []; + + const [expandedSections, setExpandedSections] = useState({ + preRequest: true, + tests: true, + postResponse: true, + assertions: true + }); + + useEffect(() => { + setExpandedSections({ + preRequest: preRequestTestResults.length > 0, + tests: results.length > 0, + postResponse: postResponseTestResults.length > 0, + assertions: assertionResults.length > 0 + }); + }, [results.length, assertionResults.length, preRequestTestResults.length, postResponseTestResults.length]); + + const toggleSection = (section) => { + setExpandedSections({ + ...expandedSections, + [section]: !expandedSections[section] + }); + }; + + if (!results.length && !assertionResults.length && !preRequestTestResults.length && !postResponseTestResults.length) { + return
No tests found
; } - const passedTests = results.filter((result) => result.status === 'pass'); - const failedTests = results.filter((result) => result.status === 'fail'); - - const passedAssertions = assertionResults.filter((result) => result.status === 'pass'); - const failedAssertions = assertionResults.filter((result) => result.status === 'fail'); - return ( -
- Tests ({results.length}/{results.length}), Passed: {passedTests.length}, Failed: {failedTests.length} -
-
    - {results.map((result) => ( -
  • - {result.status === 'pass' ? ( - ✔  {result.description} - ) : ( - <> - ✘  {result.description} -
    - {result.error} - - )} -
  • - ))} -
+ toggleSection('preRequest')} + type="test" + /> -
- Assertions ({assertionResults.length}/{assertionResults.length}), Passed: {passedAssertions.length}, Failed:{' '} - {failedAssertions.length} -
-
    - {assertionResults.map((result) => ( -
  • - {result.status === 'pass' ? ( - - ✔  {result.lhsExpr}: {result.rhsExpr} - - ) : ( - <> - - ✘  {result.lhsExpr}: {result.rhsExpr} - -
    - {result.error} - - )} -
  • - ))} -
+ toggleSection('postResponse')} + type="test" + /> + + toggleSection('tests')} + type="test" + /> + + toggleSection('assertions')} + type="assertion" + />
); }; diff --git a/packages/bruno-app/src/components/ResponsePane/TestResultsLabel/index.js b/packages/bruno-app/src/components/ResponsePane/TestResultsLabel/index.js index f894d1f76..51d6f94cc 100644 --- a/packages/bruno-app/src/components/ResponsePane/TestResultsLabel/index.js +++ b/packages/bruno-app/src/components/ResponsePane/TestResultsLabel/index.js @@ -1,9 +1,13 @@ import React from 'react'; +import { IconCircleCheck, IconCircleX } from '@tabler/icons'; -const TestResultsLabel = ({ results, assertionResults }) => { +const TestResultsLabel = ({ results, assertionResults, preRequestTestResults, postResponseTestResults }) => { results = results || []; assertionResults = assertionResults || []; - if (!results.length && !assertionResults.length) { + preRequestTestResults = preRequestTestResults || []; + postResponseTestResults = postResponseTestResults || []; + + if (!results.length && !assertionResults.length && !preRequestTestResults.length && !postResponseTestResults.length) { return 'Tests'; } @@ -13,8 +17,14 @@ const TestResultsLabel = ({ results, assertionResults }) => { const numberOfAssertions = assertionResults.length; const numberOfFailedAssertions = assertionResults.filter((result) => result.status === 'fail').length; - const totalNumberOfTests = numberOfTests + numberOfAssertions; - const totalNumberOfFailedTests = numberOfFailedTests + numberOfFailedAssertions; + const numberOfPreRequestTests = preRequestTestResults.length; + const numberOfFailedPreRequestTests = preRequestTestResults.filter((result) => result.status === 'fail').length; + + const numberOfPostResponseTests = postResponseTestResults.length; + const numberOfFailedPostResponseTests = postResponseTestResults.filter((result) => result.status === 'fail').length; + + const totalNumberOfTests = numberOfTests + numberOfAssertions + numberOfPreRequestTests + numberOfPostResponseTests; + const totalNumberOfFailedTests = numberOfFailedTests + numberOfFailedAssertions + numberOfFailedPreRequestTests + numberOfFailedPostResponseTests; return (
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js index 6f9d2f832..683c82bba 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js @@ -1,7 +1,7 @@ import QueryResult from "components/ResponsePane/QueryResult/index"; import { useState } from "react"; -const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, width }) => { +const BodyBlock = ({ collection, data, dataBuffer, headers, error, item }) => { const [isBodyCollapsed, toggleBody] = useState(true); return (
@@ -17,7 +17,6 @@ const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, width } { }; -const Request = ({ collection, request, item, width }) => { +const Request = ({ collection, request, item }) => { let { url, headers, data, dataBuffer, error } = request || {}; if (!dataBuffer) { dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64'); @@ -33,7 +33,7 @@ const Request = ({ collection, request, item, width }) => { {/* Body */} - +
) } diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Response/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Response/index.js index c70825eb9..9a72c8856 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Response/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Response/index.js @@ -16,7 +16,7 @@ const safeStringifyJSONIfNotString = (obj) => { } }; -const Response = ({ collection, response, item, width }) => { +const Response = ({ collection, response, item }) => { let { status, statusCode, statusText, dataBuffer, headers, data, error } = response || {}; if (!dataBuffer) { dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64'); @@ -35,7 +35,7 @@ const Response = ({ collection, response, item, width }) => { {/* Body */} - +
) } diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js index 92e5db8ec..ff33e41ec 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js @@ -6,7 +6,7 @@ import Method from "./Common/Method/index"; import Status from "./Common/Status/index"; import { RelativeTime } from "./Common/Time/index"; -const TimelineItem = ({ timestamp, request, response, item, collection, width, isOauth2 }) => { +const TimelineItem = ({ timestamp, request, response, item, collection, isOauth2, hideTimestamp = false }) => { const [isCollapsed, _toggleCollapse] = useState(false); const [activeTab, setActiveTab] = useState('request'); const toggleCollapse = () => _toggleCollapse(prev => !prev); @@ -23,11 +23,15 @@ const TimelineItem = ({ timestamp, request, response, item, collection, width, i {isOauth2 ?
[oauth2.0]
: null} -
[{new Date(timestamp).toISOString()}]
+ {!hideTimestamp && ( + <> +
[{new Date(timestamp).toISOString()}]
+ + + + + )}
- - -
{url}
@@ -57,15 +61,15 @@ const TimelineItem = ({ timestamp, request, response, item, collection, width, i
{/* Tab Content */} -
+
{/* Request Tab */} {activeTab === 'request' && ( - + )} {/* Response Tab */} {activeTab === 'response' && ( - + )} {/* Network Logs Tab */} diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/index.js index 40fccf969..98fe1479a 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/index.js @@ -41,7 +41,7 @@ const getEffectiveAuthSource = (collection, item) => { return effectiveSource; }; -const Timeline = ({ collection, item, width }) => { +const Timeline = ({ collection, item }) => { // Get the effective auth source if auth mode is inherit const authSource = getEffectiveAuthSource(collection, item); @@ -62,7 +62,6 @@ const Timeline = ({ collection, item, width }) => { return ( {combinedTimeline.map((event, index) => { if (event.type === 'request') { @@ -76,7 +75,6 @@ const Timeline = ({ collection, item, width }) => { response={response} item={item} collection={collection} - width={width} />
); @@ -101,7 +99,6 @@ const Timeline = ({ collection, item, width }) => { response={data?.response} item={item} collection={collection} - width={width - 50} isOauth2={true} />
diff --git a/packages/bruno-app/src/components/ResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/index.js index 1fb120ae9..22955ac2d 100644 --- a/packages/bruno-app/src/components/ResponsePane/index.js +++ b/packages/bruno-app/src/components/ResponsePane/index.js @@ -20,8 +20,10 @@ import ResponseSave from 'src/components/ResponsePane/ResponseSave'; import ResponseClear from 'src/components/ResponsePane/ResponseClear'; import SkippedRequest from './SkippedRequest'; import ClearTimeline from './ClearTimeline/index'; +import ResponseLayoutToggle from './ResponseLayoutToggle'; +import HeightBoundContainer from 'ui/HeightBoundContainer'; -const ResponsePane = ({ rightPaneWidth, item, collection }) => { +const ResponsePane = ({ item, collection }) => { const dispatch = useDispatch(); const tabs = useSelector((state) => state.tabs.tabs); const activeTabUid = useSelector((state) => state.tabs.activeTabUid); @@ -33,10 +35,10 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => { }); useEffect(() => { - if (item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage) { + if (item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage) { setShowScriptErrorCard(true); } - }, [item?.preRequestScriptErrorMessage, item?.postResponseScriptErrorMessage]); + }, [item?.preRequestScriptErrorMessage, item?.postResponseScriptErrorMessage, item?.testScriptErrorMessage]); const selectTab = (tab) => { dispatch( @@ -57,7 +59,6 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => { { return ; } case 'timeline': { - return ; + return ; } case 'tests': { - return ; + return ; } default: { @@ -100,9 +106,9 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => { if (!item.response && !requestTimeline?.length) { return ( - + - + ); } @@ -122,12 +128,12 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => { }; const responseHeadersCount = typeof response.headers === 'object' ? Object.entries(response.headers).length : 0; - - const hasScriptError = item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage; + + const hasScriptError = item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage; return ( -
+
selectTab('response')}> Response
@@ -139,16 +145,22 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => { Timeline
selectTab('tests')}> - +
{!isLoading ? (
{hasScriptError && !showScriptErrorCard && ( - setShowScriptErrorCard(true)} + setShowScriptErrorCard(true)} /> )} + {focusedTab?.responsePaneTab === "timeline" ? ( ) : (item?.response && !item?.response?.error) ? ( @@ -164,26 +176,31 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => { ) : null}
{isLoading ? : null} {hasScriptError && showScriptErrorCard && ( - setShowScriptErrorCard(false)} + setShowScriptErrorCard(false)} /> )} - {!item?.response ? ( - focusedTab?.responsePaneTab === "timeline" && requestTimeline?.length ? ( - - ) : null - ) : ( - <>{getTabPanel(focusedTab.responsePaneTab)} - )} +
+ {!item?.response ? ( + focusedTab?.responsePaneTab === "timeline" && requestTimeline?.length ? ( + + ) : null + ) : ( + <>{getTabPanel(focusedTab.responsePaneTab)} + )} +
); diff --git a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js index 5591dbfea..bfc716d28 100644 --- a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js +++ b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import get from 'lodash/get'; import classnames from 'classnames'; import { safeStringifyJSON } from 'utils/common'; @@ -12,17 +12,28 @@ import TestResultsLabel from 'components/ResponsePane/TestResultsLabel'; import StyledWrapper from './StyledWrapper'; import SkippedRequest from 'components/ResponsePane/SkippedRequest'; import RunnerTimeline from 'components/ResponsePane/RunnerTimeline'; +import ScriptError from 'components/ResponsePane/ScriptError'; +import ScriptErrorIcon from 'components/ResponsePane/ScriptErrorIcon'; const ResponsePane = ({ rightPaneWidth, item, collection }) => { const [selectedTab, setSelectedTab] = useState('response'); + const [showScriptErrorCard, setShowScriptErrorCard] = useState(false); - const { requestSent, responseReceived, testResults, assertionResults, error } = item; + const { requestSent, responseReceived, testResults, assertionResults, preRequestTestResults, postResponseTestResults, error } = item; + + useEffect(() => { + if (item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage) { + setShowScriptErrorCard(true); + } + }, [item?.preRequestScriptErrorMessage, item?.postResponseScriptErrorMessage, item?.testScriptErrorMessage]); const headers = get(item, 'responseReceived.headers', []); const status = get(item, 'responseReceived.status', 0); const size = get(item, 'responseReceived.size', 0); const duration = get(item, 'responseReceived.duration', 0); + const hasScriptError = item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage; + const selectTab = (tab) => setSelectedTab(tab); const getTabPanel = (tab) => { @@ -46,10 +57,22 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => { return ; } case 'timeline': { - return ; + return ( + + ); } case 'tests': { - return ; + return ; } default: { @@ -73,8 +96,8 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => { } return ( - -
+ +
selectTab('response')}> Response
@@ -86,15 +109,36 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => { Timeline
selectTab('tests')}> - +
+ {hasScriptError && !showScriptErrorCard && ( + setShowScriptErrorCard(true)} + /> + )}
-
{getTabPanel(selectedTab)}
+
+ {hasScriptError && showScriptErrorCard && ( + setShowScriptErrorCard(false)} + /> + )} +
+ {getTabPanel(selectedTab)} +
+
); }; diff --git a/packages/bruno-app/src/components/RunnerResults/RunnerTags/index.jsx b/packages/bruno-app/src/components/RunnerResults/RunnerTags/index.jsx new file mode 100644 index 000000000..984452d78 --- /dev/null +++ b/packages/bruno-app/src/components/RunnerResults/RunnerTags/index.jsx @@ -0,0 +1,128 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { get, cloneDeep, find } from 'lodash'; +import { updateCollectionTagsList, updateRunnerTagsDetails } from 'providers/ReduxStore/slices/collections'; +import TagList from 'components/TagList'; + +const RunnerTags = ({ collectionUid, className = '' }) => { + const dispatch = useDispatch(); + const collections = useSelector((state) => state.collections.collections); + const collection = cloneDeep(find(collections, (c) => c.uid === collectionUid)); + + // tags for the collection run + const tags = get(collection, 'runnerTags', { include: [], exclude: [] }); + + // have tags been enabled for the collection run + const tagsEnabled = get(collection, 'runnerTagsEnabled', false); + + // all available tags in the collection that can be used for filtering + const availableTags = get(collection, 'allTags', []); + const tagsHintList = availableTags.filter(t => !tags.exclude.includes(t) && !tags.include.includes(t)); + + useEffect(() => { + dispatch(updateCollectionTagsList({ collectionUid })); + }, [collection.uid, dispatch]); + + const handleValidation = (tag) => { + const trimmedTag = tag.trim(); + if (!availableTags.includes(trimmedTag)) { + return 'tag does not exist!'; + } + if (tags.include.includes(trimmedTag)) { + return 'tag already present in the include list!'; + } + if (tags.exclude.includes(trimmedTag)) { + return 'tag is present in the exclude list!'; + } + } + + const handleAddTag = ({ tag, to }) => { + const trimmedTag = tag.trim(); + if (!trimmedTag) return; + // add tag to the `include` list + if (to === 'include') { + if (tags.include.includes(trimmedTag) || tags.exclude.includes(trimmedTag)) return; + if (!availableTags.includes(trimmedTag)) { + return; + } + const newTags = { ...tags, include: [...tags.include, trimmedTag].sort() }; + setTags(newTags); + return; + } + // add tag to the `exclude` list + if (to === 'exclude') { + if (tags.include.includes(trimmedTag) || tags.exclude.includes(trimmedTag)) return; + if (!availableTags.includes(trimmedTag)) { + return; + } + const newTags = { ...tags, exclude: [...tags.exclude, trimmedTag].sort() }; + setTags(newTags); + } + }; + + const handleRemoveTag = ({ tag, from }) => { + const trimmedTag = tag.trim(); + if (!trimmedTag) return; + // remove tag from the `include` list + if (from === 'include') { + if (!tags.include.includes(trimmedTag)) return; + const newTags = { ...tags, include: tags.include.filter((t) => t !== trimmedTag) }; + setTags(newTags); + return; + } + // remove tag from the `exclude` list + if (from === 'exclude') { + if (!tags.exclude.includes(trimmedTag)) return; + const newTags = { ...tags, exclude: tags.exclude.filter((t) => t !== trimmedTag) }; + setTags(newTags); + } + }; + + const setTags = (tags) => { + dispatch(updateRunnerTagsDetails({ collectionUid: collection.uid, tags })); + }; + + const setTagsEnabled = (tagsEnabled) => { + dispatch(updateRunnerTagsDetails({ collectionUid: collection.uid, tagsEnabled })); + }; + + return ( +
+
+ + setTagsEnabled(!tagsEnabled)} + /> +
+ {tagsEnabled && ( +
+
+ Included tags: + handleAddTag({ tag, to: 'include' })} + handleRemoveTag={tag => handleRemoveTag({ tag, from: 'include' })} + tagsHintList={tagsHintList} + handleValidation={handleValidation} + /> +
+
+ Excluded tags: + handleAddTag({ tag, to: 'exclude' })} + handleRemoveTag={tag => handleRemoveTag({ tag, from: 'exclude' })} + tagsHintList={tagsHintList} + handleValidation={handleValidation} + /> +
+
+ )} +
+ ) +} + +export default RunnerTags; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RunnerResults/index.jsx b/packages/bruno-app/src/components/RunnerResults/index.jsx index cfe3c0f1a..f2dbe9d7f 100644 --- a/packages/bruno-app/src/components/RunnerResults/index.jsx +++ b/packages/bruno-app/src/components/RunnerResults/index.jsx @@ -9,6 +9,8 @@ import { IconRefresh, IconCircleCheck, IconCircleX, IconCircleOff, IconCheck, Ic import ResponsePane from './ResponsePane'; import StyledWrapper from './StyledWrapper'; import { areItemsLoading } from 'utils/collections'; +import RunnerTags from './RunnerTags/index'; +import { getRequestItemsForCollectionRun } from 'utils/collections/index'; const getDisplayName = (fullPath, pathname, name = '') => { let relativePath = path.relative(fullPath, pathname); @@ -16,6 +18,28 @@ const getDisplayName = (fullPath, pathname, name = '') => { return path.join(dir, name); }; +const getTestStatus = (results) => { + if (!results || !results.length) return 'pass'; + const failed = results.filter((result) => result.status === 'fail'); + return failed.length ? 'fail' : 'pass'; +}; + +const allTestsPassed = (item) => { + return item.status !== 'error' && + item.testStatus === 'pass' && + item.assertionStatus === 'pass' && + item.preRequestTestStatus === 'pass' && + item.postResponseTestStatus === 'pass'; +}; + +const anyTestFailed = (item) => { + return item.status === 'error' || + item.testStatus === 'fail' || + item.assertionStatus === 'fail' || + item.preRequestTestStatus === 'fail' || + item.postResponseTestStatus === 'fail'; +}; + export default function RunnerResults({ collection }) { const dispatch = useDispatch(); const [selectedItem, setSelectedItem] = useState(null); @@ -41,6 +65,19 @@ export default function RunnerResults({ collection }) { const collectionCopy = cloneDeep(collection); const runnerInfo = get(collection, 'runnerResult.info', {}); + // tags for the collection run + const tags = get(collection, 'runnerTags', { include: [], exclude: [] }); + + // have tags been enabled for the collection run + const tagsEnabled = get(collection, 'runnerTagsEnabled', false); + + // have tags been added for the collection run + const areTagsAdded = tags.include.length > 0 || tags.exclude.length > 0; + + const requestItemsForCollectionRun = getRequestItemsForCollectionRun({ recursive: true, tags, items: collection.items }); + const totalRequestItemsCountForCollectionRun = requestItemsForCollectionRun.length; + const shouldDisableCollectionRun = totalRequestItemsCountForCollectionRun <= 0; + const items = cloneDeep(get(collection, 'runnerResult.items', [])) .map((item) => { const info = findItemInCollection(collectionCopy, item.uid); @@ -53,33 +90,33 @@ export default function RunnerResults({ collection }) { type: info.type, filename: info.filename, pathname: info.pathname, - displayName: getDisplayName(collection.pathname, info.pathname, info.name) + displayName: getDisplayName(collection.pathname, info.pathname, info.name), + tags: [...(info.request?.tags || [])].sort(), }; if (newItem.status !== 'error' && newItem.status !== 'skipped') { - if (newItem.testResults) { - const failed = newItem.testResults.filter((result) => result.status === 'fail'); - newItem.testStatus = failed.length ? 'fail' : 'pass'; - } else { - newItem.testStatus = 'pass'; - } - - if (newItem.assertionResults) { - const failed = newItem.assertionResults.filter((result) => result.status === 'fail'); - newItem.assertionStatus = failed.length ? 'fail' : 'pass'; - } else { - newItem.assertionStatus = 'pass'; - } + newItem.testStatus = getTestStatus(newItem.testResults); + newItem.assertionStatus = getTestStatus(newItem.assertionResults); + newItem.preRequestTestStatus = getTestStatus(newItem.preRequestTestResults); + newItem.postResponseTestStatus = getTestStatus(newItem.postResponseTestResults); } return newItem; }) .filter(Boolean); const runCollection = () => { - dispatch(runCollectionFolder(collection.uid, null, true, Number(delay))); + dispatch(runCollectionFolder(collection.uid, null, true, Number(delay), tagsEnabled && tags)); }; const runAgain = () => { - dispatch(runCollectionFolder(collection.uid, runnerInfo.folderUid, runnerInfo.isRecursive, Number(delay))); + dispatch( + runCollectionFolder( + collection.uid, + runnerInfo.folderUid, + runnerInfo.isRecursive, + Number(delay), + tagsEnabled && tags + ) + ); }; const resetRunner = () => { @@ -95,12 +132,8 @@ export default function RunnerResults({ collection }) { }; const totalRequestsInCollection = getTotalRequestCountInCollection(collectionCopy); - const passedRequests = items.filter((item) => { - return item.status !== 'error' && item.testStatus === 'pass' && item.assertionStatus === 'pass'; - }); - const failedRequests = items.filter((item) => { - return (item.status !== 'error' && item.testStatus === 'fail') || item.assertionStatus === 'fail'; - }); + const passedRequests = items.filter(allTestsPassed); + const failedRequests = items.filter(anyTestFailed); const skippedRequests = items.filter((item) => { return item.status === 'skipped'; @@ -132,7 +165,10 @@ export default function RunnerResults({ collection }) { />
- @@ -144,7 +180,7 @@ export default function RunnerResults({ collection }) { } return ( - +
Runner @@ -156,38 +192,52 @@ export default function RunnerResults({ collection }) { )}
-
+
Total Requests: {items.length}, Passed: {passedRequests.length}, Failed: {failedRequests.length}, Skipped:{' '} {skippedRequests.length}
+ {tagsEnabled && areTagsAdded && ( +
+ Tags: +
+
+ {tags.include.join(', ')} +
+
+ {tags.exclude.join(', ')} +
+
+
+ )} {runnerInfo?.statusText ?
{runnerInfo?.statusText}
: null} + {items.map((item) => { return (
- {item.testStatus === 'pass' && item.assertionStatus === 'pass' ? + {allTestsPassed(item) ? : null} {item.status === 'skipped' ? :null} - {item.status === 'error' || item.testStatus === 'fail' || item.assertionStatus === 'fail' ? + {anyTestFailed(item) ? :null} {item.displayName} @@ -205,9 +255,54 @@ export default function RunnerResults({ collection }) { )}
+ {tagsEnabled && areTagsAdded && item?.tags?.length > 0 && ( +
+ Tags: {item.tags.filter(t => tags.include.includes(t)).join(', ')} +
+ )} {item.status == 'error' ?
{item.error}
: null}
    + {item.preRequestTestResults + ? item.preRequestTestResults.map((result) => ( +
  • + {result.status === 'pass' ? ( + + + {result.description} + + ) : ( + <> + + + {result.description} + + {result.error} + + )} +
  • + )) + : null} + {item.postResponseTestResults + ? item.postResponseTestResults.map((result) => ( +
  • + {result.status === 'pass' ? ( + + + {result.description} + + ) : ( + <> + + + {result.description} + + {result.error} + + )} +
  • + )) + : null} {item.testResults ? item.testResults.map((result) => (
  • @@ -256,7 +351,7 @@ export default function RunnerResults({ collection }) { -
{selectedItem ? ( -
-
-
+
+
+
{selectedItem.displayName} - {selectedItem.testStatus === 'pass' && selectedItem.assertionStatus === 'pass' ? + {allTestsPassed(selectedItem) ? : null} - {selectedItem.status === 'error' || selectedItem.testStatus === 'fail' || selectedItem.assertionStatus === 'fail' ? + {anyTestFailed(selectedItem) ? : null} {selectedItem.status === 'skipped' ? diff --git a/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/StyledWrapper.js b/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/StyledWrapper.js index ecaab4ff1..81cd5cd08 100644 --- a/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/StyledWrapper.js +++ b/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/StyledWrapper.js @@ -3,16 +3,6 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` max-width: 800px; - span.beta-tag { - display: flex; - align-items: center; - padding: 0.1rem 0.25rem; - font-size: 0.75rem; - border-radius: 0.25rem; - color: ${(props) => props.theme.colors.text.green}; - border: solid 1px ${(props) => props.theme.colors.text.green} !important; - } - span.developer-mode-warning { font-weight: 400; color: ${(props) => props.theme.colors.text.yellow}; diff --git a/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/index.js b/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/index.js index 52a988ea7..4cbbc80ea 100644 --- a/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/index.js +++ b/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/index.js @@ -61,7 +61,6 @@ const JsSandboxModeModal = ({ collection }) => { Safe Mode - BETA

JavaScript code is executed in a secure sandbox and cannot access your filesystem or execute system commands. @@ -79,15 +78,12 @@ const JsSandboxModeModal = ({ collection }) => { /> Developer Mode - (use only if you trust the collections authors) + (use only if you trust the authors of the collection)

JavaScript code has access to the filesystem, can execute system commands and access sensitive information.

- - * SAFE mode has been introduced v1.26 onwards and is in beta. Please report any issues on github. -
diff --git a/packages/bruno-app/src/components/SecuritySettings/StyledWrapper.js b/packages/bruno-app/src/components/SecuritySettings/StyledWrapper.js index ecaab4ff1..81cd5cd08 100644 --- a/packages/bruno-app/src/components/SecuritySettings/StyledWrapper.js +++ b/packages/bruno-app/src/components/SecuritySettings/StyledWrapper.js @@ -3,16 +3,6 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` max-width: 800px; - span.beta-tag { - display: flex; - align-items: center; - padding: 0.1rem 0.25rem; - font-size: 0.75rem; - border-radius: 0.25rem; - color: ${(props) => props.theme.colors.text.green}; - border: solid 1px ${(props) => props.theme.colors.text.green} !important; - } - span.developer-mode-warning { font-weight: 400; color: ${(props) => props.theme.colors.text.yellow}; diff --git a/packages/bruno-app/src/components/SecuritySettings/index.js b/packages/bruno-app/src/components/SecuritySettings/index.js index 7761760f6..f7738cfa8 100644 --- a/packages/bruno-app/src/components/SecuritySettings/index.js +++ b/packages/bruno-app/src/components/SecuritySettings/index.js @@ -47,7 +47,6 @@ const SecuritySettings = ({ collection }) => { Safe Mode - BETA

JavaScript code is executed in a secure sandbox and cannot access your filesystem or execute system commands. @@ -65,7 +64,7 @@ const SecuritySettings = ({ collection }) => { /> Developer Mode - (use only if you trust the collections authors) + (use only if you trust the authors of the collection)

@@ -75,9 +74,6 @@ const SecuritySettings = ({ collection }) => { - - * SAFE mode has been introduced v1.26 onwards and is in beta. Please report any issues on github. -

); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js index ff06f4f31..181a258ae 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js @@ -1,19 +1,59 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - position: relative; height: 100%; + position: relative; + + .editor-content { + height: 100%; + + .CodeMirror { + height: 100%; + font-size: 12px; + line-height: 1.5; + padding: 0; + + .CodeMirror-gutters { + background: ${props => props.theme.codemirror.gutter.bg}; + border-right: 1px solid ${props => props.theme.codemirror.border}; + } + + .CodeMirror-linenumber { + color: ${props => props.theme.colors.text.muted}; + font-size: 11px; + padding: 0 3px 0 5px; + } + + .CodeMirror-lines { + padding: 0; + } + + .CodeMirror-line { + padding: 0 4px; + } + } + } .copy-to-clipboard { position: absolute; - cursor: pointer; top: 10px; right: 10px; z-index: 10; - opacity: 0.5; + background: transparent; + border: none; + color: ${props => props.theme.colors.text.muted}; + cursor: pointer; + padding: 6px; + opacity: 0.7; + transition: all 0.2s ease; &:hover { opacity: 1; + color: ${props => props.theme.text}; + } + + &:active { + transform: translateY(1px); } } `; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js index ea3ed43a7..34b779370 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js @@ -1,64 +1,52 @@ import CodeEditor from 'components/CodeEditor/index'; import get from 'lodash/get'; -import { HTTPSnippet } from 'httpsnippet'; import { useTheme } from 'providers/Theme/index'; import StyledWrapper from './StyledWrapper'; -import { buildHarRequest } from 'utils/codegenerator/har'; import { useSelector } from 'react-redux'; import { CopyToClipboard } from 'react-copy-to-clipboard'; import toast from 'react-hot-toast'; import { IconCopy } from '@tabler/icons'; -import { findCollectionByItemUid, getGlobalEnvironmentVariables } from '../../../../../../../utils/collections/index'; -import { getAuthHeaders } from '../../../../../../../utils/codegenerator/auth'; +import { findCollectionByItemUid, getGlobalEnvironmentVariables } from 'utils/collections/index'; import { cloneDeep } from 'lodash'; +import { useMemo } from 'react'; +import { generateSnippet } from '../utils/snippet-generator'; const CodeView = ({ language, item }) => { const { displayedTheme } = useTheme(); const preferences = useSelector((state) => state.app.preferences); const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments); - const { target, client, language: lang } = language; - const requestHeaders = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers'); - let _collection = findCollectionByItemUid( + const generateCodePrefs = useSelector((state) => state.app.generateCode); + + let collectionOriginal = findCollectionByItemUid( useSelector((state) => state.collections.collections), item.uid ); - let collection = cloneDeep(_collection); + const collection = useMemo(() => { + const c = cloneDeep(collectionOriginal); + const globalEnvironmentVariables = getGlobalEnvironmentVariables({ + globalEnvironments, + activeGlobalEnvironmentUid + }); + c.globalEnvironmentVariables = globalEnvironmentVariables; + return c; + }, [collectionOriginal, globalEnvironments, activeGlobalEnvironmentUid]); - // add selected global env variables to the collection object - const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid }); - collection.globalEnvironmentVariables = globalEnvironmentVariables; - - const collectionRootAuth = collection?.root?.request?.auth; - const requestAuth = item.draft ? get(item, 'draft.request.auth') : get(item, 'request.auth'); - - const headers = [ - ...getAuthHeaders(collectionRootAuth, requestAuth), - ...(collection?.root?.request?.headers || []), - ...(requestHeaders || []) - ]; - - let snippet = ''; - try { - snippet = new HTTPSnippet(buildHarRequest({ request: item.request, headers, type: item.type })).convert( - target, - client - ); - } catch (e) { - console.error(e); - snippet = 'Error generating code snippet'; - } + const snippet = useMemo(() => { + return generateSnippet({ language, item, collection, shouldInterpolate: generateCodePrefs.shouldInterpolate }); + }, [language, item, collection, generateCodePrefs.shouldInterpolate]); return ( - <> - - toast.success('Copied to clipboard!')} - > + + toast.success('Copied to clipboard!')} + > + + +
{ font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} theme={displayedTheme} - mode={lang} + mode={language.language} + enableVariableHighlighting={true} + showHintsFor={['variables']} /> - - +
+
); }; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/StyledWrapper.js new file mode 100644 index 000000000..c73d2ae39 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/StyledWrapper.js @@ -0,0 +1,117 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: ${props => props.theme.requestTabPanel.card.bg}; + border-bottom: 1px solid ${props => props.theme.requestTabPanel.card.border}; + gap: 12px; + flex-shrink: 0; + } + + .left-controls { + display: flex; + align-items: center; + gap: 12px; + } + + .select-wrapper { + position: relative; + display: flex; + align-items: center; + } + + .select-arrow { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: ${props => props.theme.colors.text.muted}; + } + + .native-select { + background: ${props => props.theme.requestTabPanel.url.bg}; + border: 1px solid ${props => props.theme.input.border}; + border-radius: 3px; + color: ${props => props.theme.text}; + font-size: 12px; + padding: 6px 28px 6px 10px; + min-width: 140px; + height: 32px; + cursor: pointer; + transition: all 0.2s ease; + appearance: none; + + &:hover { + border-color: ${props => props.theme.input.focusBorder}; + } + + &:focus { + outline: none; + border-color: ${props => props.theme.input.focusBorder}; + box-shadow: 0 0 0 2px ${props => props.theme.input.focusBoxShadow}; + } + + option { + background: ${props => props.theme.bg}; + color: ${props => props.theme.text}; + padding: 8px 12px; + } + } + + .library-options { + display: flex; + gap: 6px; + } + + .lib-btn { + height: 32px; + padding: 0 12px; + background: ${props => props.theme.requestTabPanel.url.bg}; + border: 1px solid ${props => props.theme.input.border}; + border-radius: 3px; + color: ${props => props.theme.text}; + font-size: 12px; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + + &:hover { + background: ${props => props.theme.dropdown.hoverBg}; + border-color: ${props => props.theme.input.focusBorder}; + } + + &.active { + background: ${props => props.theme.button.secondary.bg}; + border-color: ${props => props.theme.button.secondary.border}; + color: ${props => props.theme.button.secondary.color}; + } + } + + .right-controls { + .interpolate-checkbox { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 13px; + color: ${props => props.theme.text}; + + input[type="checkbox"] { + cursor: pointer; + margin: 0; + } + + &:hover { + opacity: 0.8; + } + } + } +`; + +export default StyledWrapper; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/index.js new file mode 100644 index 000000000..2e63ce384 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/index.js @@ -0,0 +1,106 @@ +import { IconChevronDown } from '@tabler/icons'; +import { useSelector, useDispatch } from 'react-redux'; +import { useMemo } from 'react'; +import { getLanguages } from 'utils/codegenerator/targets'; +import { updateGenerateCode } from 'providers/ReduxStore/slices/app'; +import StyledWrapper from './StyledWrapper'; + +const CodeViewToolbar = () => { + const dispatch = useDispatch(); + const languages = getLanguages(); + const generateCodePrefs = useSelector((state) => state.app.generateCode); + + // Group languages by their main language type + const languageGroups = useMemo(() => { + return languages.reduce((acc, lang) => { + const mainLang = lang.name.split('-')[0]; + if (!acc[mainLang]) { + acc[mainLang] = []; + } + acc[mainLang].push({ + ...lang, + libraryName: lang.name.split('-')[1] || 'default' + }); + return acc; + }, {}); + }, [languages]); + + const mainLanguages = useMemo(() => Object.keys(languageGroups), [languageGroups]); + + const availableLibraries = useMemo(() => { + return languageGroups[generateCodePrefs.mainLanguage] || []; + }, [generateCodePrefs.mainLanguage, languageGroups]); + + // Event handlers + const handleMainLanguageChange = (e) => { + const newMainLang = e.target.value; + const defaultLibrary = languageGroups[newMainLang][0].libraryName; + + dispatch(updateGenerateCode({ + mainLanguage: newMainLang, + library: defaultLibrary + })); + }; + + const handleLibraryChange = (libraryName) => { + dispatch(updateGenerateCode({ + library: libraryName + })); + }; + + const handleInterpolateChange = (e) => { + dispatch(updateGenerateCode({ + shouldInterpolate: e.target.checked + })); + }; + + return ( + +
+
+
+ + +
+ + {availableLibraries.length > 1 && ( +
+ {availableLibraries.map((lib) => ( + + ))} +
+ )} +
+ +
+ +
+
+
+ ); +}; + +export default CodeViewToolbar; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js index 3d8ea1229..324e9ec3c 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js @@ -1,60 +1,44 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - margin-inline: -1rem; - margin-block: -1.5rem; + margin: -1.5rem -1rem; + height: 50vh; + display: flex; + flex-direction: column; background-color: ${(props) => props.theme.collection.environment.settings.bg}; - .generate-code-sidebar { - background-color: ${(props) => props.theme.collection.environment.settings.sidebar.bg}; - border-right: solid 1px ${(props) => props.theme.collection.environment.settings.sidebar.borderRight}; - max-height: 80vh; + .code-generator { + display: flex; + flex-direction: column; height: 100%; - overflow-y: auto; } - .generate-code-item { - min-width: 150px; - display: block; + .editor-container { + flex: 1; + overflow: hidden; position: relative; - cursor: pointer; - padding: 8px 10px; - border-left: solid 2px transparent; - text-decoration: none; + background: ${props => props.theme.bg}; + } - &:hover { - text-decoration: none; - background-color: ${(props) => props.theme.collection.environment.settings.item.hoverBg}; + .error-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: ${props => props.theme.colors.text.muted}; + text-align: center; + padding: 20px; + + h1 { + font-size: 14px; + margin-bottom: 8px; + color: ${props => props.theme.text}; } - } - .active { - background-color: ${(props) => props.theme.collection.environment.settings.item.active.bg} !important; - border-left: solid 2px ${(props) => props.theme.collection.environment.settings.item.border}; - &:hover { - background-color: ${(props) => props.theme.collection.environment.settings.item.active.hoverBg} !important; - } - } - - .flexible-container { - width: 100%; - } - - @media (max-width: 600px) { - .flexible-container { - width: 500px; - } - } - - @media (min-width: 601px) and (max-width: 1200px) { - .flexible-container { - width: 800px; - } - } - - @media (min-width: 1201px) { - .flexible-container { - width: 900px; + p { + font-size: 12px; + opacity: 0.8; } } `; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js index 42f0bc8ca..aabaafcba 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js @@ -1,24 +1,30 @@ import Modal from 'components/Modal/index'; -import { useState } from 'react'; +import { useMemo } from 'react'; import CodeView from './CodeView'; +import CodeViewToolbar from './CodeViewToolbar'; import StyledWrapper from './StyledWrapper'; import { isValidUrl } from 'utils/url'; import { get } from 'lodash'; -import { findEnvironmentInCollection } from 'utils/collections'; +import { + findEnvironmentInCollection +} from 'utils/collections'; import { interpolateUrl, interpolateUrlPathParams } from 'utils/url/index'; import { getLanguages } from 'utils/codegenerator/targets'; import { useSelector } from 'react-redux'; import { getGlobalEnvironmentVariables } from 'utils/collections/index'; +import { resolveInheritedAuth } from './utils/auth-utils'; const GenerateCodeItem = ({ collectionUid, item, onClose }) => { const languages = getLanguages(); - const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid)); - const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments); - const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid }); - + const generateCodePrefs = useSelector((state) => state.app.generateCode); + const globalEnvironmentVariables = getGlobalEnvironmentVariables({ + globalEnvironments, + activeGlobalEnvironmentUid + }); const environment = findEnvironmentInCollection(collection, collection?.activeEnvironmentUid); + let envVars = {}; if (environment) { const vars = get(environment, 'variables', []); @@ -31,7 +37,6 @@ const GenerateCodeItem = ({ collectionUid, item, onClose }) => { const requestUrl = get(item, 'draft.request.url') !== undefined ? get(item, 'draft.request.url') : get(item, 'request.url'); - // interpolate the url const interpolatedUrl = interpolateUrl({ url: requestUrl, globalEnvironmentVariables, @@ -46,72 +51,40 @@ const GenerateCodeItem = ({ collectionUid, item, onClose }) => { get(item, 'draft.request.params') !== undefined ? get(item, 'draft.request.params') : get(item, 'request.params') ); - const [selectedLanguage, setSelectedLanguage] = useState(languages[0]); + // Get the full language object based on current preferences + const selectedLanguage = useMemo(() => { + const fullName = generateCodePrefs.library === 'default' + ? generateCodePrefs.mainLanguage + : `${generateCodePrefs.mainLanguage}-${generateCodePrefs.library}`; + + return languages.find(lang => lang.name === fullName) || languages[0]; + }, [generateCodePrefs.mainLanguage, generateCodePrefs.library, languages]); + + // Resolve auth inheritance + const resolvedRequest = resolveInheritedAuth(item, collection); + return ( -
-
-
- {languages && - languages.length && - languages.map((language) => ( -
setSelectedLanguage(language)} - onKeyDown={(e) => { - if (e.key === 'Tab' || (e.shiftKey && e.key === 'Tab')) { - e.preventDefault(); - const currentIndex = languages.findIndex((lang) => lang.name === selectedLanguage.name); - const nextIndex = e.shiftKey - ? (currentIndex - 1 + languages.length) % languages.length - : (currentIndex + 1) % languages.length; - setSelectedLanguage(languages[nextIndex]); +
+ - // Explicitly focus on the new active element - const nextElement = document.querySelector(`[data-language="${languages[nextIndex].name}"]`); - nextElement?.focus(); - } - - }} - data-language={language.name} - aria-pressed={language.name === selectedLanguage.name} - > - {language.name} -
- ))} -
-
-
+
{isValidUrl(finalUrl) ? ( ) : ( -
-
-

Invalid URL: {finalUrl}

-

Please check the URL and try again

-
+
+

Invalid URL: {finalUrl}

+

Please check the URL and try again

)}
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.js new file mode 100644 index 000000000..f9885f0df --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.js @@ -0,0 +1,42 @@ +import { get } from 'lodash'; +import { + getTreePathFromCollectionToItem +} from 'utils/collections/index'; + +// Resolve inherited auth by traversing up the folder hierarchy +export const resolveInheritedAuth = (item, collection) => { + const mergedRequest = { + ...(item.request || {}), + ...(item.draft?.request || {}) + }; + + const authMode = mergedRequest.auth.mode; + + // If auth is not inherit or no auth defined, return the merged request as is + if (!authMode || authMode !== 'inherit') { + return mergedRequest; + } + + // Get the tree path from collection to item + const requestTreePath = getTreePathFromCollectionToItem(collection, item); + + // Default to collection auth + const collectionAuth = get(collection, 'root.request.auth', { mode: 'none' }); + let effectiveAuth = collectionAuth; + + // Check folders in reverse to find the closest auth configuration + for (let i of [...requestTreePath].reverse()) { + if (i.type === 'folder') { + const folderAuth = get(i, 'root.request.auth'); + if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') { + effectiveAuth = folderAuth; + break; + } + } + } + + return { + ...mergedRequest, + auth: effectiveAuth + }; +}; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.spec.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.spec.js new file mode 100644 index 000000000..ad5afc3e6 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.spec.js @@ -0,0 +1,79 @@ +import { resolveInheritedAuth } from './auth-utils'; + +jest.mock('utils/collections/index', () => ({ + getTreePathFromCollectionToItem: (collection, item) => { + const itemUid = item.uid; + + if (itemUid === 'r1') { + return [collection.items[0], collection.items[0].items[0]]; + } + return []; + } +})); + +// Helper to build mock collection structure +const buildCollection = () => { + return { + uid: 'c1', + root: { + request: { + auth: { mode: 'bearer', bearer: { token: 'COLLECTION' } } + } + }, + items: [ + { + uid: 'f1', + type: 'folder', + name: 'Folder', + root: { + request: { + auth: { mode: 'basic', basic: { username: 'user', password: 'pass' } } + } + }, + items: [ + { + uid: 'r1', + type: 'request', + name: 'Request', + request: { + auth: { mode: 'inherit' }, + url: 'http://example.com', + method: 'GET' + } + } + ] + } + ] + }; +}; + +describe('auth-utils.resolveInheritedAuth', () => { + it('should resolve to nearest folder auth when request mode is inherit', () => { + const collection = buildCollection(); + const item = collection.items[0].items[0]; // r1 + + const resolved = resolveInheritedAuth(item, collection); + expect(resolved.auth.mode).toBe('basic'); + expect(resolved.auth.basic.username).toBe('user'); + }); + + it('should resolve to collection auth if no folder auth', () => { + const collection = buildCollection(); + collection.items[0].root.request.auth = { mode: 'inherit' }; + const item = collection.items[0].items[0]; + + const resolved = resolveInheritedAuth(item, collection); + expect(resolved.auth.mode).toBe('bearer'); + expect(resolved.auth.bearer.token).toBe('COLLECTION'); + }); + + it('should return original request when mode is not inherit', () => { + const collection = buildCollection(); + const item = collection.items[0].items[0]; + item.request.auth = { mode: 'basic', basic: { username: 'override', password: 'pwd' } }; + + const resolved = resolveInheritedAuth(item, collection); + expect(resolved.auth.mode).toBe('basic'); + expect(resolved.auth.basic.username).toBe('override'); + }); +}); \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.js new file mode 100644 index 000000000..22a52f84f --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.js @@ -0,0 +1,92 @@ +import { interpolate } from '@usebruno/common'; +import { cloneDeep } from 'lodash'; + +export const interpolateHeaders = (headers = [], variables = {}) => { + return headers.map((header) => ({ + ...header, + name: interpolate(header.name, variables), + value: interpolate(header.value, variables) + })); +}; + +export const interpolateBody = (body, variables = {}) => { + if (!body) return null; + + const interpolatedBody = cloneDeep(body); + + switch (body.mode) { + case 'json': + let parsed = body.json; + // If it's already a string, use it directly; if it's an object, stringify it first + if (typeof parsed === 'object') { + parsed = JSON.stringify(parsed); + } + parsed = interpolate(parsed, variables, { escapeJSONStrings: true }); + try { + const jsonObj = JSON.parse(parsed); + interpolatedBody.json = JSON.stringify(jsonObj, null, 2); + } catch { + interpolatedBody.json = parsed; + } + break; + + case 'text': + interpolatedBody.text = interpolate(body.text, variables); + break; + + case 'xml': + interpolatedBody.xml = interpolate(body.xml, variables); + break; + + case 'sparql': + interpolatedBody.sparql = interpolate(body.sparql, variables); + break; + + case 'formUrlEncoded': + interpolatedBody.formUrlEncoded = Array.isArray(body.formUrlEncoded) + ? body.formUrlEncoded.map((param) => ({ + ...param, + value: param.enabled ? interpolate(param.value, variables) : param.value + })) + : []; + break; + + case 'multipartForm': + interpolatedBody.multipartForm = Array.isArray(body.multipartForm) + ? body.multipartForm.map((param) => ({ + ...param, + value: + param.type === 'text' && param.enabled + ? interpolate(param.value, variables) + : param.value + })) + : []; + break; + + default: + break; + } + + return interpolatedBody; +}; + +export const createVariablesObject = ({ + globalEnvironmentVariables = {}, + collectionVars = {}, + allVariables = {}, + collection = {}, + runtimeVariables = {}, + processEnvVars = {} +}) => { + return { + ...globalEnvironmentVariables, + ...allVariables, + ...collectionVars, + ...runtimeVariables, + process: { + env: { + ...processEnvVars + } + } + }; +}; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.spec.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.spec.js new file mode 100644 index 000000000..8c5920b76 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.spec.js @@ -0,0 +1,48 @@ +import { interpolateHeaders, interpolateBody } from './interpolation'; + +describe('interpolation utils', () => { + describe('interpolateHeaders', () => { + it('should interpolate variables in header name and value while preserving other props', () => { + const headers = [ + { uid: '1', name: 'X-{{var}}', value: 'value-{{var}}', enabled: true } + ]; + const variables = { var: 'test' }; + + const result = interpolateHeaders(headers, variables); + expect(result).toEqual([ + { + uid: '1', + name: 'X-test', + value: 'value-test', + enabled: true + } + ]); + }); + }); + + describe('interpolateBody', () => { + it('should interpolate JSON body strings and keep formatting', () => { + const body = { + mode: 'json', + json: '{"name": "{{username}}"}' + }; + const variables = { username: 'bruno' }; + + const result = interpolateBody(body, variables); + expect(result.json).toBe('{\n "name": "bruno"\n}'); + }); + + it('should interpolate text body', () => { + const body = { + mode: 'text', + text: 'Hello {{name}}' + }; + const result = interpolateBody(body, { name: 'World' }); + expect(result.text).toBe('Hello World'); + }); + + it('should return null when body is null', () => { + expect(interpolateBody(null, { a: 1 })).toBeNull(); + }); + }); +}); \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js new file mode 100644 index 000000000..60f181ed1 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js @@ -0,0 +1,102 @@ +import { buildHarRequest } from 'utils/codegenerator/har'; +import { getAuthHeaders } from 'utils/codegenerator/auth'; +import { getAllVariables, getTreePathFromCollectionToItem } from 'utils/collections/index'; +import { interpolateHeaders, interpolateBody, createVariablesObject } from './interpolation'; + +// Merge headers from collection, folders, and request +const mergeHeaders = (collection, request, requestTreePath) => { + let headers = new Map(); + + // Add collection headers first + const collectionHeaders = collection?.root?.request?.headers || []; + collectionHeaders.forEach((header) => { + if (header.enabled) { + headers.set(header.name, header); + } + }); + + // Add folder headers next, traversing from root to leaf + if (requestTreePath && requestTreePath.length > 0) { + for (let i of requestTreePath) { + if (i.type === 'folder') { + const folderHeaders = i?.root?.request?.headers || []; + folderHeaders.forEach((header) => { + if (header.enabled) { + headers.set(header.name, header); + } + }); + } + } + } + + // Add request headers last (they take precedence) + const requestHeaders = request.headers || []; + requestHeaders.forEach((header) => { + if (header.enabled) { + headers.set(header.name, header); + } + }); + + // Convert Map back to array + return Array.from(headers.values()); +}; + +const generateSnippet = ({ language, item, collection, shouldInterpolate = false }) => { + try { + // Get HTTPSnippet dynamically so mocks can be applied in tests + const { HTTPSnippet } = require('httpsnippet'); + + const allVariables = getAllVariables(collection, item); + + // Create variables object for interpolation + const variables = createVariablesObject({ + globalEnvironmentVariables: collection.globalEnvironmentVariables || {}, + collectionVars: collection.collectionVars || {}, + allVariables, + collection, + runtimeVariables: collection.runtimeVariables || {}, + processEnvVars: collection.processEnvVariables || {} + }); + + const request = item.request; + + // Get the request tree path and merge headers + const requestTreePath = getTreePathFromCollectionToItem(collection, item); + let headers = mergeHeaders(collection, request, requestTreePath); + + // Add auth headers if needed + if (request.auth && request.auth.mode !== 'none') { + const collectionAuth = collection?.root?.request?.auth || null; + const authHeaders = getAuthHeaders(collectionAuth, request.auth); + headers = [...headers, ...authHeaders]; + } + + // Interpolate headers and body if needed + if (shouldInterpolate) { + headers = interpolateHeaders(headers, variables); + if (request.body) { + request.body = interpolateBody(request.body, variables); + } + } + + // Build HAR request + const harRequest = buildHarRequest({ + request, + headers + }); + + // Generate snippet using HTTPSnippet + const snippet = new HTTPSnippet(harRequest); + const result = snippet.convert(language.target, language.client); + + return result; + } catch (error) { + console.error('Error generating code snippet:', error); + return 'Error generating code snippet'; + } +}; + +export { + generateSnippet, + mergeHeaders +}; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js new file mode 100644 index 000000000..941ea7a76 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js @@ -0,0 +1,584 @@ +jest.mock('httpsnippet', () => { + return { + HTTPSnippet: jest.fn().mockImplementation((harRequest) => ({ + convert: jest.fn(() => { + const method = harRequest?.method || 'GET'; + const url = harRequest?.url || 'http://example.com'; + const hasBody = harRequest?.postData?.text; + + if (method === 'POST' && hasBody) { + return `curl -X POST ${url} -H "Content-Type: application/json" -d '${hasBody}'`; + } + return `curl -X ${method} ${url}`; + }) + })) + }; +}); + +jest.mock('utils/codegenerator/har', () => ({ + buildHarRequest: jest.fn((data) => { + const request = data.request || {}; + const method = request.method || 'GET'; + const url = request.url || 'http://example.com'; + const body = request.body || {}; + + const harRequest = { + method: method, + url: url, + headers: data.headers || [], + httpVersion: 'HTTP/1.1' + }; + + // Add body data for POST requests + if (method === 'POST' && body.mode === 'json' && body.json) { + harRequest.postData = { + mimeType: 'application/json', + text: body.json + }; + } + + return harRequest; + }) +})); + +jest.mock('utils/codegenerator/auth', () => ({ + getAuthHeaders: jest.fn(() => []) +})); + +jest.mock('utils/collections/index', () => ({ + getAllVariables: jest.fn(() => ({ + baseUrl: 'https://api.example.com', + apiKey: 'secret-key-123', + userId: '12345' + })), + getTreePathFromCollectionToItem: jest.fn(() => []) +})); + +import { generateSnippet, mergeHeaders } from './snippet-generator'; + +describe('Snippet Generator - Simple Tests', () => { + + // Simple test request - easy to understand + const testRequest = { + uid: 'test-request-123', + name: 'test api call', + type: 'http-request', + request: { + method: 'POST', + url: 'https://api.example.com/{{endpoint}}', + headers: [ + { uid: 'h1', name: 'Authorization', value: 'Bearer {{apiToken}}', enabled: true }, + { uid: 'h2', name: 'Content-Type', value: 'application/json', enabled: true }, + { uid: 'h3', name: 'X-Custom', value: '{{customValue}}', enabled: true } + ], + body: { + mode: 'json', + json: '{"message": "{{greeting}}", "count": {{number}}}' + }, + auth: { mode: 'none' }, + assertions: [], + tests: '', + docs: '', + params: [], + vars: { req: [] } + } + }; + + const testCollection = { + root: { + request: { + auth: { mode: 'none' }, + headers: [] + } + }, + globalEnvironmentVariables: { + endpoint: 'data', + apiToken: 'token123', + customValue: 'test-value', + greeting: 'Hello World', + number: 42 + }, + runtimeVariables: {}, + processEnvVariables: {} + }; + + const curlLanguage = { target: 'shell', client: 'curl' }; + + beforeEach(() => { + jest.clearAllMocks(); + require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation((harRequest) => ({ + convert: jest.fn(() => { + const method = harRequest?.method || 'GET'; + const url = harRequest?.url || 'http://example.com'; + const hasBody = harRequest?.postData?.text; + + if (method === 'POST' && hasBody) { + return `curl -X POST ${url} -H "Content-Type: application/json" -d '${hasBody}'`; + } + return `curl -X ${method} ${url}`; + }) + })); + }); + + it('should generate curl for POST request with JSON body', () => { + const result = generateSnippet({ + language: curlLanguage, + item: testRequest, + collection: testCollection, + shouldInterpolate: false + }); + + expect(result).toBe('curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d \'{"message": "{{greeting}}", "count": {{number}}}\''); + }); + + it('should interpolate variables when enabled', () => { + const result = generateSnippet({ + language: curlLanguage, + item: testRequest, + collection: testCollection, + shouldInterpolate: true + }); + + const expectedBody = `{ + "message": "Hello World", + "count": 42 +}`; + expect(result).toBe(`curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedBody}'`); + }); + + it('should handle GET requests', () => { + const getRequest = { + ...testRequest, + request: { + ...testRequest.request, + method: 'GET', + body: { mode: 'none' } + } + }; + + const result = generateSnippet({ + language: curlLanguage, + item: getRequest, + collection: testCollection, + shouldInterpolate: false + }); + + expect(result).toBe('curl -X GET https://api.example.com/{{endpoint}}'); + }); + + it('should handle requests with different headers', () => { + const requestWithDifferentHeaders = { + ...testRequest, + request: { + ...testRequest.request, + headers: [ + { uid: 'h1', name: 'X-API-Key', value: '{{apiKey}}', enabled: true }, + { uid: 'h2', name: 'Accept', value: 'application/json', enabled: true }, + { uid: 'h3', name: 'User-Agent', value: 'TestApp/{{version}}', enabled: true } + ] + } + }; + + const collectionWithDifferentVars = { + ...testCollection, + globalEnvironmentVariables: { + ...testCollection.globalEnvironmentVariables, + apiKey: 'secret-key-456', + version: '1.0.0' + } + }; + + const result = generateSnippet({ + language: curlLanguage, + item: requestWithDifferentHeaders, + collection: collectionWithDifferentVars, + shouldInterpolate: true + }); + + // Body should have interpolated variables with proper formatting + const expectedBody = `{ + "message": "Hello World", + "count": 42 +}`; + expect(result).toBe(`curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedBody}'`); + }); + + it('should handle complex nested JSON body', () => { + const complexBody = { + user: { + name: '{{userName}}', + settings: { + theme: '{{userTheme}}', + active: true + } + }, + data: { + items: ['{{item1}}', '{{item2}}'], + total: '{{totalCount}}' + } + }; + + const requestWithComplexBody = { + ...testRequest, + request: { + ...testRequest.request, + body: { + mode: 'json', + json: JSON.stringify(complexBody, null, 2) + } + } + }; + + const collectionWithComplexVars = { + ...testCollection, + globalEnvironmentVariables: { + ...testCollection.globalEnvironmentVariables, + userName: 'Alice', + userTheme: 'dark', + item1: 'first', + item2: 'second', + totalCount: 100 + } + }; + + const result = generateSnippet({ + language: curlLanguage, + item: requestWithComplexBody, + collection: collectionWithComplexVars, + shouldInterpolate: true + }); + + const expectedComplexBody = JSON.stringify({ + user: { + name: 'Alice', + settings: { + theme: 'dark', + active: true + } + }, + data: { + items: ['first', 'second'], + total: '100' + } + }, null, 2); + + expect(result).toBe(`curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedComplexBody}'`); + }); + + it('should handle errors gracefully', () => { + // Set up the error mock after beforeEach has run + const originalHTTPSnippet = require('httpsnippet').HTTPSnippet; + require('httpsnippet').HTTPSnippet = jest.fn(() => { + throw new Error('Mock error!'); + }); + + const originalConsoleError = console.error; + console.error = jest.fn(); + + const result = generateSnippet({ + language: curlLanguage, + item: testRequest, + collection: testCollection, + shouldInterpolate: false + }); + + expect(result).toBe('Error generating code snippet'); + + require('httpsnippet').HTTPSnippet = originalHTTPSnippet; + console.error = originalConsoleError; + }); + + it('should work with JavaScript language', () => { + const javascriptLanguage = { target: 'javascript', client: 'fetch' }; + + const expectedJavaScriptCode = `fetch("https://api.example.com/data", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ "message": "Hello World", "count": 42 }) +})`; + + const originalHTTPSnippet = require('httpsnippet').HTTPSnippet; + require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation(() => ({ + convert: jest.fn(() => expectedJavaScriptCode) + })); + + const result = generateSnippet({ + language: javascriptLanguage, + item: testRequest, + collection: testCollection, + shouldInterpolate: false + }); + + expect(result).toBe(expectedJavaScriptCode); + + // Restore the original mock + require('httpsnippet').HTTPSnippet = originalHTTPSnippet; + }); + + it('should interpolate simple headers and body variables', () => { + const simpleTestRequest = { + uid: 'test-123', + name: 'simple test', + type: 'http-request', + request: { + method: 'POST', + url: 'https://api.test.com/{{endpoint}}', + headers: [ + { uid: 'h1', name: 'Authorization', value: 'Bearer {{token}}', enabled: true }, + { uid: 'h2', name: 'X-User-ID', value: '{{userId}}', enabled: true }, + { uid: 'h3', name: 'Content-Type', value: 'application/json', enabled: true } + ], + body: { + mode: 'json', + json: '{"name": "{{userName}}", "email": "{{userEmail}}", "age": {{userAge}}}' + } + } + }; + + // Simple collection with clear variable values + const simpleTestCollection = { + root: { + request: { + auth: { mode: 'none' }, + headers: [] + } + }, + globalEnvironmentVariables: { + endpoint: 'users', + token: 'abc123token', + userId: 'user456', + userName: 'John Smith', + userEmail: 'john@test.com', + userAge: 30 + }, + runtimeVariables: {}, + processEnvVariables: {} + }; + + const result = generateSnippet({ + language: curlLanguage, + item: simpleTestRequest, + collection: simpleTestCollection, + shouldInterpolate: true + }); + + const expectedInterpolatedBody = `{ + "name": "John Smith", + "email": "john@test.com", + "age": 30 +}`; + + expect(result).toBe(`curl -X POST https://api.test.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedInterpolatedBody}'`); + }); + + it('should NOT interpolate when shouldInterpolate is false', () => { + const simpleTestRequest = { + uid: 'test-123', + name: 'simple test', + type: 'http-request', + request: { + method: 'POST', + url: 'https://api.test.com/{{endpoint}}', + headers: [ + { uid: 'h1', name: 'Authorization', value: 'Bearer {{token}}', enabled: true }, + { uid: 'h2', name: 'X-User-ID', value: '{{userId}}', enabled: true }, + { uid: 'h3', name: 'Content-Type', value: 'application/json', enabled: true } + ], + body: { + mode: 'json', + json: '{"name": "{{userName}}", "email": "{{userEmail}}", "age": {{userAge}}}' + } + } + }; + + const simpleTestCollection = { + root: { + request: { + auth: { mode: 'none' }, + headers: [] + } + }, + globalEnvironmentVariables: { + endpoint: 'users', + token: 'abc123token', + userId: 'user456', + userName: 'John Smith', + userEmail: 'john@test.com', + userAge: 30 + }, + runtimeVariables: {}, + processEnvVariables: {} + }; + + const result = generateSnippet({ + language: curlLanguage, + item: simpleTestRequest, + collection: simpleTestCollection, + shouldInterpolate: false + }); + + expect(result).toBe('curl -X POST https://api.test.com/{{endpoint}} -H "Content-Type: application/json" -d \'{"name": "{{userName}}", "email": "{{userEmail}}", "age": {{userAge}}}\''); + }); +}); + +describe('mergeHeaders', () => { + it('should include headers from collection, folder and request (with correct precedence)', () => { + const collection = { + root: { + request: { + headers: [ + { name: 'X-Collection', value: 'c', enabled: true } + ] + } + } + }; + + const folder = { + type: 'folder', + root: { + request: { + headers: [ + { name: 'X-Folder', value: 'f', enabled: true } + ] + } + } + }; + + const request = { + headers: [ + { name: 'X-Request', value: 'r', enabled: true } + ] + }; + + const headers = mergeHeaders(collection, request, [folder]); + const names = headers.map((h) => h.name); + expect(names).toEqual(expect.arrayContaining(['X-Collection', 'X-Folder', 'X-Request'])); + }); +}); + +// Snippet should include inherited headers +describe('generateSnippet – header inclusion in output', () => { + it('should include collection and folder headers in generated snippet', () => { + const language = { target: 'shell', client: 'curl' }; + + const collection = { + root: { + request: { + headers: [ + { name: 'X-Collection', value: 'c', enabled: true } + ], + auth: { mode: 'none' } + } + } + }; + + const folder = { + uid: 'f1', + type: 'folder', + root: { + request: { + headers: [ + { name: 'X-Folder', value: 'f', enabled: true } + ] + } + } + }; + + const item = { + uid: 'r1', + request: { + method: 'GET', + url: 'https://example.com', + headers: [], + auth: { mode: 'none' } + } + }; + + // Override tree path to include folder + const utilsCollections = require('utils/collections/index'); + utilsCollections.getTreePathFromCollectionToItem.mockImplementation(() => [folder]); + + // Custom HTTPSnippet mock that outputs headers list + const originalHTTPSnippet = require('httpsnippet').HTTPSnippet; + require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation((harRequest) => ({ + convert: jest.fn(() => `HEADERS:${harRequest.headers.map((h) => h.name).join(',')}`) + })); + + const result = generateSnippet({ language, item, collection, shouldInterpolate: false }); + + // Restore original mock + require('httpsnippet').HTTPSnippet = originalHTTPSnippet; + + expect(result).toContain('X-Collection'); + expect(result).toContain('X-Folder'); + }); +}); + +describe('generateSnippet with edge-case bodies', () => { + const language = { target: 'shell', client: 'curl' }; + const baseCollection = { root: { request: { auth: { mode: 'none' }, headers: [] } } }; + + it('should generate snippet for empty formUrlEncoded body when interpolation is disabled', () => { + const item = { + uid: 'req1', + request: { + method: 'POST', + url: 'https://example.com', + headers: [], + body: { mode: 'formUrlEncoded', formUrlEncoded: [] }, + auth: { mode: 'none' } + } + }; + + const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false }); + expect(result).toMatch(/^curl -X POST/); + }); + + it('should generate snippet for empty multipartForm body when interpolation is disabled', () => { + const item = { + uid: 'req2', + request: { + method: 'POST', + url: 'https://example.com', + headers: [], + body: { mode: 'multipartForm' }, + auth: { mode: 'none' } + } + }; + + const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false }); + expect(result).toMatch(/^curl -X POST/); + }); + + it('should generate snippet for undefined formUrlEncoded array with interpolation enabled', () => { + const item = { + uid: 'req3', + request: { + method: 'POST', + url: 'https://example.com', + headers: [], + body: { mode: 'formUrlEncoded' }, + auth: { mode: 'none' } + } + }; + + const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: true }); + expect(result).toMatch(/^curl -X POST/); + }); + + it('should generate snippet for empty multipartForm array with interpolation enabled', () => { + const item = { + uid: 'req4', + request: { + method: 'POST', + url: 'https://example.com', + headers: [], + body: { mode: 'multipartForm', multipartForm: [] }, + auth: { mode: 'none' } + } + }; + + const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: true }); + expect(result).toMatch(/^curl -X POST/); + }); +}); \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js index f56d408b0..a3cbbd0fa 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js @@ -8,6 +8,8 @@ import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/act import { flattenItems } from 'utils/collections'; import StyledWrapper from './StyledWrapper'; import { areItemsLoading } from 'utils/collections'; +import RunnerTags from 'components/RunnerResults/RunnerTags/index'; +import { getRequestItemsForCollectionRun } from 'utils/collections/index'; const RunCollectionItem = ({ collectionUid, item, onClose }) => { const dispatch = useDispatch(); @@ -15,6 +17,12 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => { const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid)); const isCollectionRunInProgress = collection?.runnerResult?.info?.status && (collection?.runnerResult?.info?.status !== 'ended'); + // tags for the collection run + const tags = get(collection, 'runnerTags', { include: [], exclude: [] }); + + // have tags been enabled for the collection run + const tagsEnabled = get(collection, 'runnerTagsEnabled', false); + const onSubmit = (recursive) => { dispatch( addTab({ @@ -24,7 +32,7 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => { }) ); if (!isCollectionRunInProgress) { - dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive)); + dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive, 0, tagsEnabled && tags)); } onClose(); }; @@ -41,66 +49,65 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => { onClose(); } - const getRequestsCount = (items) => { - const requestTypes = ['http-request', 'graphql-request'] - return items.filter(req => requestTypes.includes(req.type)).length; - } - - const runLength = item ? getRequestsCount(item.items) : get(collection, 'items.length', 0); - const flattenedItems = flattenItems(item ? item.items : collection.items); - const recursiveRunLength = getRequestsCount(flattenedItems); - const isFolderLoading = areItemsLoading(item); + const requestItemsForRecursiveFolderRun = getRequestItemsForCollectionRun({ recursive: true, tags, items: item ? item.items : collection.items }); + const totalRequestItemsCountForRecursiveFolderRun = requestItemsForRecursiveFolderRun.length; + const shouldDisableRecursiveFolderRun = totalRequestItemsCountForRecursiveFolderRun <= 0; + + const requestItemsForFolderRun = getRequestItemsForCollectionRun({ recursive: false, tags, items: item ? item.items : collection.items }); + const totalRequestItemsCountForFolderRun = requestItemsForFolderRun.length; + const shouldDisableFolderRun = totalRequestItemsCountForFolderRun <= 0; + return ( - {!runLength && !recursiveRunLength ? ( -
No request found in this folder.
- ) : ( -
-
- Run - ({runLength} requests) -
-
This will only run the requests in this folder.
-
- Recursive Run - ({recursiveRunLength} requests) -
-
This will run all the requests in this folder and all its subfolders.
- {isFolderLoading ?
Requests in this folder are still loading.
: null} - {isCollectionRunInProgress ?
A Collection Run is already in progress.
: null} -
- - - - { - isCollectionRunInProgress ? +
+
+ Run + ({totalRequestItemsCountForFolderRun} requests) +
+
This will only run the requests in this folder.
+
+ Recursive Run + ({totalRequestItemsCountForRecursiveFolderRun} requests) +
+
This will run all the requests in this folder and all its subfolders.
+ {isFolderLoading ?
Requests in this folder are still loading.
: null} + {isCollectionRunInProgress ?
A Collection Run is already in progress.
: null} + + {/* Tags for the collection run */} + + +
+ + + + { + isCollectionRunInProgress ? + + + + : + <> - - : - <> - - - - - - - - } -
+ + + + + }
- )} +
); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js index 0f59f4a69..e7a3a21ea 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js @@ -8,7 +8,7 @@ import { IconChevronRight, IconDots } from '@tabler/icons'; import { useSelector, useDispatch } from 'react-redux'; import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; import { handleCollectionItemDrop, sendRequest, showInFolder } from 'providers/ReduxStore/slices/collections/actions'; -import { collectionFolderClicked } from 'providers/ReduxStore/slices/collections'; +import { toggleCollectionItem } from 'providers/ReduxStore/slices/collections'; import Dropdown from 'components/Dropdown'; import NewRequest from 'components/Sidebar/NewRequest'; import NewFolder from 'components/Sidebar/NewFolder'; @@ -30,6 +30,7 @@ import { scrollToTheActiveTab } from 'utils/tabs'; import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab'; import { isEqual } from 'lodash'; import { calculateDraggedItemNewPathname } from 'utils/collections/index'; +import { sortByNameThenSequence } from 'utils/common/index'; const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) => { const _isTabForItemActiveSelector = isTabForItemActiveSelector({ itemUid: item.uid }); @@ -194,12 +195,14 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) type: 'folder-settings', }) ); - dispatch( - collectionFolderClicked({ - itemUid: item.uid, - collectionUid: collectionUid - }) - ); + if(item.collapsed) { + dispatch( + toggleCollectionItem({ + itemUid: item.uid, + collectionUid: collectionUid + }) + ); + } } }; @@ -207,13 +210,19 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) e.stopPropagation(); e.preventDefault(); dispatch( - collectionFolderClicked({ + toggleCollectionItem({ itemUid: item.uid, collectionUid: collectionUid }) ); }; + // prevent the parent's double-click handler from firing + const handleFolderDoubleClick = (e) => { + e.stopPropagation(); + e.preventDefault(); + }; + const handleRightClick = (event) => { const _menuDropdown = dropdownTippyRef.current; if (_menuDropdown) { @@ -248,7 +257,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) dispatch(makeTabPermanent({ uid: item.uid })); }; - // Sort items by their "seq" property. + // Sort items by their "seq" property. const sortItemsBySequence = (items = []) => { return items.sort((a, b) => a.seq - b.seq); }; @@ -260,9 +269,9 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) }); }; - const folderItems = sortItemsBySequence(filter(item.items, (i) => isItemAFolder(i))); + const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i))); const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i))); - + const handleGenerateCode = (e) => { e.stopPropagation(); dropdownTippyRef.current.hide(); @@ -355,6 +364,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) className={iconClassName} style={{ color: 'rgb(160 160 160)' }} onClick={handleFolderCollapse} + onDoubleClick={handleFolderDoubleClick} /> ) : null}
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js index b47881fad..8c1111c29 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js @@ -41,6 +41,7 @@ const Wrapper = styled.div` } &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; .collection-actions { .dropdown { div[aria-expanded='false'] { @@ -84,6 +85,14 @@ const Wrapper = styled.div` background: transparent; transition: ${(props) => props.theme.dragAndDrop.transition}; } + + &.collection-focused-in-tab { + background: ${(props) => props.theme.sidebar.collection.item.bg}; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.bg} !important; + } + } } .collection-name.drop-target { diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js index 995be522c..4b787fc3d 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -6,9 +6,9 @@ import filter from 'lodash/filter'; import { useDrop, useDrag } from 'react-dnd'; import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons'; import Dropdown from 'components/Dropdown'; -import { collapseCollection } from 'providers/ReduxStore/slices/collections'; +import { toggleCollection } from 'providers/ReduxStore/slices/collections'; import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop } from 'providers/ReduxStore/slices/collections/actions'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; import NewRequest from 'components/Sidebar/NewRequest'; import NewFolder from 'components/Sidebar/NewFolder'; @@ -16,6 +16,7 @@ import CollectionItem from './CollectionItem'; import RemoveCollection from './RemoveCollection'; import { doesCollectionHaveItemsMatchingSearchText } from 'utils/collections/search'; import { isItemAFolder, isItemARequest } from 'utils/collections'; +import { isTabForItemActive } from 'src/selectors/tab'; import RenameCollection from './RenameCollection'; import StyledWrapper from './StyledWrapper'; @@ -24,6 +25,7 @@ import { areItemsLoading } from 'utils/collections'; import { scrollToTheActiveTab } from 'utils/tabs'; import ShareCollection from 'components/ShareCollection/index'; import { CollectionItemDragPreview } from './CollectionItem/CollectionItemDragPreview/index'; +import { sortByNameThenSequence } from 'utils/common/index'; const Collection = ({ collection, searchText }) => { const [showNewFolderModal, setShowNewFolderModal] = useState(false); @@ -36,6 +38,8 @@ const Collection = ({ collection, searchText }) => { const isLoading = areItemsLoading(collection); const collectionRef = useRef(null); + const isCollectionFocused = useSelector(isTabForItemActive({ itemUid: collection.uid })); + const menuDropdownTippyRef = useRef(); const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref); const MenuIcon = forwardRef((props, ref) => { @@ -81,7 +85,9 @@ const Collection = ({ collection, searchText }) => { ensureCollectionIsMounted(); - dispatch(collapseCollection(collection.uid)); + if(collection.collapsed) { + dispatch(toggleCollection(collection.uid)); + } if(!isChevronClick) { dispatch( @@ -102,9 +108,15 @@ const Collection = ({ collection, searchText }) => { e.stopPropagation(); e.preventDefault(); ensureCollectionIsMounted(); - dispatch(collapseCollection(collection.uid)); + dispatch(toggleCollection(collection.uid)); } + // prevent the parent's double-click handler from firing + const handleCollectionDoubleClick = (e) => { + e.stopPropagation(); + e.preventDefault(); + }; + const handleRightClick = (event) => { const _menuDropdown = menuDropdownTippyRef.current; if (_menuDropdown) { @@ -170,7 +182,8 @@ const Collection = ({ collection, searchText }) => { } const collectionRowClassName = classnames('flex py-1 collection-name items-center', { - 'item-hovered': isOver + 'item-hovered': isOver, + 'collection-focused-in-tab': isCollectionFocused }); // we need to sort request items by seq property @@ -179,7 +192,7 @@ const Collection = ({ collection, searchText }) => { }; const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i))); - const folderItems = sortItemsBySequence(filter(collection.items, (i) => isItemAFolder(i))); + const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i))); return ( @@ -216,6 +229,7 @@ const Collection = ({ collection, searchText }) => { className={`chevron-icon ${iconClassName}`} style={{ width: 16, minWidth: 16, color: 'rgb(160 160 160)' }} onClick={handleCollectionCollapse} + onDoubleClick={handleCollectionDoubleClick} /> -
+
{collections && collections.length ? collections.map((c) => { return ( diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js b/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js index 7c4e9f83f..15410cbcd 100644 --- a/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js +++ b/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js @@ -4,6 +4,8 @@ import { useFormik } from 'formik'; import * as Yup from 'yup'; import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions'; import Modal from 'components/Modal'; +import Help from 'components/Help'; + const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) => { const inputRef = useRef(); @@ -54,8 +56,16 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) =>
{collectionName}
<> -
-
- + { + formik.handleChange({ + target: { + name: "requestUrl", + value: value + } + }); + }} + collection={collection} + variablesAutocomplete={true} />
diff --git a/packages/bruno-app/src/components/Sidebar/index.js b/packages/bruno-app/src/components/Sidebar/index.js index 9f476e3c8..1ba71b1ab 100644 --- a/packages/bruno-app/src/components/Sidebar/index.js +++ b/packages/bruno-app/src/components/Sidebar/index.js @@ -1,29 +1,20 @@ import TitleBar from './TitleBar'; import Collections from './Collections'; import StyledWrapper from './StyledWrapper'; -import Preferences from 'components/Preferences'; -import Cookies from 'components/Cookies'; -import ToolHint from 'components/ToolHint'; -import GoldenEdition from './GoldenEdition'; import { useApp } from 'providers/App'; import { useState, useEffect } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import { IconSettings, IconCookie, IconHeart } from '@tabler/icons'; -import { updateLeftSidebarWidth, updateIsDragging, showPreferences } from 'providers/ReduxStore/slices/app'; +import { updateLeftSidebarWidth, updateIsDragging } from 'providers/ReduxStore/slices/app'; import { useTheme } from 'providers/Theme'; -import Notifications from 'components/Notifications'; const MIN_LEFT_SIDEBAR_WIDTH = 221; const MAX_LEFT_SIDEBAR_WIDTH = 600; const Sidebar = () => { const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth); - const preferencesOpen = useSelector((state) => state.app.showPreferences); - const [goldenEditionOpen, setGoldenEditionOpen] = useState(false); const { version } = useApp(); const [asideWidth, setAsideWidth] = useState(leftSidebarWidth); - const [cookiesOpen, setCookiesOpen] = useState(false); const { storedTheme } = useTheme(); @@ -81,112 +72,14 @@ const Sidebar = () => { }, [leftSidebarWidth]); return ( - + diff --git a/packages/bruno-app/src/components/SingleLineEditor/StyledWrapper.js b/packages/bruno-app/src/components/SingleLineEditor/StyledWrapper.js index 592a75b28..3398cb5ff 100644 --- a/packages/bruno-app/src/components/SingleLineEditor/StyledWrapper.js +++ b/packages/bruno-app/src/components/SingleLineEditor/StyledWrapper.js @@ -26,6 +26,11 @@ const StyledWrapper = styled.div` .CodeMirror-lines { padding: 0; + + .CodeMirror-placeholder { + color: ${(props) => props.theme.codemirror.placeholder.color} !important; + opacity: ${(props) => props.theme.codemirror.placeholder.opacity} !important + } } .CodeMirror-cursor { diff --git a/packages/bruno-app/src/components/SingleLineEditor/index.js b/packages/bruno-app/src/components/SingleLineEditor/index.js index 16413bdf3..c29558df5 100644 --- a/packages/bruno-app/src/components/SingleLineEditor/index.js +++ b/packages/bruno-app/src/components/SingleLineEditor/index.js @@ -2,15 +2,11 @@ import React, { Component } from 'react'; import isEqual from 'lodash/isEqual'; import { getAllVariables } from 'utils/collections'; import { defineCodeMirrorBrunoVariablesMode, MaskedEditor } from 'utils/common/codemirror'; +import { setupAutoComplete } from 'utils/codemirror/autocomplete'; import StyledWrapper from './StyledWrapper'; import { IconEye, IconEyeOff } from '@tabler/icons'; -let CodeMirror; -const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true; - -if (!SERVER_RENDERED) { - CodeMirror = require('codemirror'); -} +const CodeMirror = require('codemirror'); class SingleLineEditor extends Component { constructor(props) { @@ -26,6 +22,7 @@ class SingleLineEditor extends Component { maskInput: props.isSecret || false // Always mask the input by default (if it's a secret) }; } + componentDidMount() { // Initialize CodeMirror as a single line editor /** @type {import("codemirror").Editor} */ @@ -44,6 +41,7 @@ class SingleLineEditor extends Component { const noopHandler = () => {}; this.editor = CodeMirror(this.editorRef.current, { + placeholder: this.props.placeholder ?? '', lineWrapping: false, lineNumbers: false, theme: this.props.theme === 'dark' ? 'monokai' : 'default', @@ -75,16 +73,26 @@ class SingleLineEditor extends Component { 'Shift-Tab': false } }); - if (this.props.autocomplete) { - this.editor.on('keyup', (cm, event) => { - if (!cm.state.completionActive /*Enables keyboard navigation in autocomplete list*/ && event.key !== 'Enter') { - /*Enter - do not open autocomplete list just after item has been selected in it*/ - CodeMirror.commands.autocomplete(cm, CodeMirror.hint.anyword, { autocomplete: this.props.autocomplete }); - } - }); - } - this.editor.setValue(String(this.props.value) || ''); + + const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item); + const getAnywordAutocompleteHints = () => this.props.autocomplete || []; + + // Setup AutoComplete Helper + const autoCompleteOptions = { + getAllVariables: getAllVariablesHandler, + getAnywordAutocompleteHints, + showHintsFor: this.props.showHintsFor || ['variables'], + showHintsOnClick: this.props.showHintsOnClick + }; + + this.brunoAutoCompleteCleanup = setupAutoComplete( + this.editor, + autoCompleteOptions + ); + + this.editor.setValue(String(this.props.value ?? '')); this.editor.on('change', this._onEdit); + this.editor.on('paste', this._onPaste); this.addOverlay(variables); this._enableMaskedEditor(this.props.isSecret); this.setState({ maskInput: this.props.isSecret }); @@ -94,7 +102,6 @@ class SingleLineEditor extends Component { _enableMaskedEditor = (enabled) => { if (typeof enabled !== 'boolean') return; - console.log('Enabling masked editor: ' + enabled); if (enabled == true) { if (!this.maskedEditor) this.maskedEditor = new MaskedEditor(this.editor, '*'); this.maskedEditor.enable(); @@ -107,12 +114,14 @@ class SingleLineEditor extends Component { _onEdit = () => { if (!this.ignoreChangeEvent && this.editor) { this.cachedValue = this.editor.getValue(); - if (this.props.onChange) { + if (this.props.onChange && (this.props.value !== this.cachedValue)) { this.props.onChange(this.cachedValue); } } }; + _onPaste = (_, event) => this.props.onPaste?.(event); + componentDidUpdate(prevProps) { // Ensure the changes caused by this update are not interpreted as // user-input changes which could otherwise result in an infinite @@ -129,7 +138,7 @@ class SingleLineEditor extends Component { } if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) { this.cachedValue = String(this.props.value); - this.editor.setValue(String(this.props.value) || ''); + this.editor.setValue(String(this.props.value ?? '')); } if (!isEqual(this.props.isSecret, prevProps.isSecret)) { // If the secret flag has changed, update the editor to reflect the change @@ -141,12 +150,20 @@ class SingleLineEditor extends Component { } componentWillUnmount() { - this.editor.getWrapperElement().remove(); + if (this.editor) { + this.editor.off('change', this._onEdit); + this.editor.off('paste', this._onPaste); + this.editor.getWrapperElement().remove(); + this.editor = null; + } + if (this.brunoAutoCompleteCleanup) { + this.brunoAutoCompleteCleanup(); + } } addOverlay = (variables) => { this.variables = variables; - defineCodeMirrorBrunoVariablesMode(variables, 'text/plain', this.props.highlightPathParams); + defineCodeMirrorBrunoVariablesMode(variables, 'text/plain', this.props.highlightPathParams, true); this.editor.setOption('mode', 'brunovariables'); }; @@ -174,7 +191,7 @@ class SingleLineEditor extends Component { render() { return ( -
+
{this.secretEye(this.props.isSecret)}
diff --git a/packages/bruno-app/src/components/StatusBar/StyledWrapper.js b/packages/bruno-app/src/components/StatusBar/StyledWrapper.js new file mode 100644 index 000000000..930753319 --- /dev/null +++ b/packages/bruno-app/src/components/StatusBar/StyledWrapper.js @@ -0,0 +1,83 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .status-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + height: 22px; + background: ${(props) => props.theme.sidebar.bg}; + border-top: 1px solid ${(props) => props.theme.statusBar.border}; + color: ${(props) => props.theme.sidebar.color}; + font-size: 12px; + user-select: none; + position: relative; + } + + .status-bar-section { + display: flex; + align-items: center; + position: relative; + } + + .status-bar-group { + display: flex; + align-items: center; + gap: 2px; + } + + .status-bar-button { + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; + color: ${(props) => props.theme.sidebar.color}; + cursor: pointer; + opacity: 0.7; + position: relative; + outline: none; + } + + .console-button-content { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + position: relative; + } + + .console-label { + font-size: 11px; + font-weight: 500; + white-space: nowrap; + } + + .error-count-inline { + font-size: 10px; + font-weight: 600; + color: ${(props) => props.theme.colors.text.danger}; + background: ${(props) => props.theme.colors.bg.danger}20; + padding: 1px 4px; + border-radius: 4px; + } + + .status-bar-divider { + width: 1px; + height: 16px; + background: ${(props) => props.theme.sidebar.dragbar}; + margin: 0 8px; + opacity: 0.3; + } + + .status-bar-version { + display: flex; + align-items: center; + padding: 2px 6px; + font-size: 10px; + color: ${(props) => props.theme.sidebar.muted}; + font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + } +`; + +export default StyledWrapper; \ No newline at end of file diff --git a/packages/bruno-app/src/components/StatusBar/index.js b/packages/bruno-app/src/components/StatusBar/index.js new file mode 100644 index 000000000..fc4718bbb --- /dev/null +++ b/packages/bruno-app/src/components/StatusBar/index.js @@ -0,0 +1,124 @@ +import React, { useState } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { IconSettings, IconCookie, IconTool } from '@tabler/icons'; +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 { openConsole } from 'providers/ReduxStore/slices/logs'; +import { useApp } from 'providers/App'; +import StyledWrapper from './StyledWrapper'; + +const StatusBar = () => { + const dispatch = useDispatch(); + const preferencesOpen = useSelector((state) => state.app.showPreferences); + const logs = useSelector((state) => state.logs.logs); + const [cookiesOpen, setCookiesOpen] = useState(false); + const { version } = useApp(); + + const errorCount = logs.filter(log => log.type === 'error').length; + + const handleConsoleClick = () => { + dispatch(openConsole()); + }; + + return ( + + {preferencesOpen && ( + + { + dispatch(showPreferences(false)); + document.querySelector('[data-trigger="preferences"]').focus(); + }} + aria-modal="true" + role="dialog" + aria-labelledby="preferences-title" + aria-describedby="preferences-description" + /> + + )} + + {cookiesOpen && ( + + { + setCookiesOpen(false); + document.querySelector('[data-trigger="cookies"]').focus(); + }} + aria-modal="true" + role="dialog" + aria-labelledby="cookies-title" + aria-describedby="cookies-description" + /> + + )} + +
+
+
+ + + + + + + + + +
+ +
+
+
+
+ +
+
+ + +
+ +
+ v{version} +
+
+
+
+
+ ); +}; + +export default StatusBar; \ No newline at end of file diff --git a/packages/bruno-app/src/components/StatusDot/index.js b/packages/bruno-app/src/components/StatusDot/index.js new file mode 100644 index 000000000..d5c090efe --- /dev/null +++ b/packages/bruno-app/src/components/StatusDot/index.js @@ -0,0 +1,15 @@ +import React from 'react'; +import DotIcon from 'components/Icons/Dot'; + +const StatusDot = ({ type = 'default' }) => ( + + + +); + + +export default StatusDot; \ No newline at end of file diff --git a/packages/bruno-app/src/components/StopWatch/index.js b/packages/bruno-app/src/components/StopWatch/index.js index debba9cd8..5954106d3 100644 --- a/packages/bruno-app/src/components/StopWatch/index.js +++ b/packages/bruno-app/src/components/StopWatch/index.js @@ -1,27 +1,24 @@ import React, { useState, useEffect } from 'react'; -const StopWatch = () => { - const [milliseconds, setMilliseconds] = useState(0); - - const tickInterval = 100; - const tick = () => { - setMilliseconds(_milliseconds => _milliseconds + tickInterval); - }; - +const StopWatch = ({ startTime }) => { + const [currentTime, setCurrentTime] = useState(Date.now()); + useEffect(() => { - let timerID = setInterval(() => { - tick() - }, tickInterval); - return () => { - clearTimeout(timerID); - }; - }, []); - - if (milliseconds < 250) { - return 'Loading...'; - } - - let seconds = milliseconds / 1000; + if (!startTime) return; + + const intervalId = setInterval(() => { + setCurrentTime(Date.now()); + }, 100); + + return () => clearInterval(intervalId); + }, [startTime]); + + if (!startTime) return Loading...; + + const elapsedTime = currentTime - startTime; + if (elapsedTime < 250) return Loading...; + + const seconds = elapsedTime / 1000; return {seconds.toFixed(1)}s; }; diff --git a/packages/bruno-app/src/components/Table/StyledWrapper.js b/packages/bruno-app/src/components/Table/StyledWrapper.js index eeead4ed2..74dc2e37a 100644 --- a/packages/bruno-app/src/components/Table/StyledWrapper.js +++ b/packages/bruno-app/src/components/Table/StyledWrapper.js @@ -6,12 +6,10 @@ const StyledWrapper = styled.div` display: grid; overflow-y: hidden; overflow-x: auto; + padding: 0 1px; // for icon hover position: inherit; - left: -4px; - padding-left: 4px; - padding-right: 4px; grid-template-columns: ${({ columns }) => columns?.[0]?.width diff --git a/packages/bruno-app/src/components/Table/index.js b/packages/bruno-app/src/components/Table/index.js index 7c9b48d7d..4944276c6 100644 --- a/packages/bruno-app/src/components/Table/index.js +++ b/packages/bruno-app/src/components/Table/index.js @@ -86,7 +86,7 @@ const Table = ({ minColumnWidth = 1, headers = [], children }) => { return (
- +
{columns.map(({ ref, name }, i) => ( diff --git a/packages/bruno-app/src/components/TagList/StyledWrapper.js b/packages/bruno-app/src/components/TagList/StyledWrapper.js new file mode 100644 index 000000000..c81897a27 --- /dev/null +++ b/packages/bruno-app/src/components/TagList/StyledWrapper.js @@ -0,0 +1,132 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .tags-container { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-height: 40px; + padding: 8px 0; + } + + .tag-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 7px; + background-color: ${(props) => props.theme.sidebar.bg}; + border: 1px solid ${(props) => props.theme.requestTabs.bottomBorder}; + border-radius: 3px; + font-size: 12px; + font-weight: 500; + color: ${(props) => props.theme.text}; + max-width: 200px; + transition: all 0.2s ease; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + + &:hover { + background-color: ${(props) => props.theme.requestTabs.active.bg}; + border-color: ${(props) => props.theme.requestTabs.active.border || props.theme.requestTabs.bottomBorder}; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transform: translateY(-1px); + } + } + + .tag-icon { + color: ${(props) => props.theme.textSecondary || props.theme.text}; + opacity: 0.7; + flex-shrink: 0; + } + + .tag-text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + + .tag-remove { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + cursor: pointer; + padding: 2px; + border-radius: 3px; + color: ${(props) => props.theme.textSecondary || props.theme.text}; + transition: all 0.2s ease; + flex-shrink: 0; + opacity: 0.7; + + &:hover { + background-color: ${(props) => props.theme.danger}; + color: white; + opacity: 1; + transform: scale(1.1); + } + + &:focus-visible { + outline: 2px solid ${(props) => props.theme.danger}; + outline-offset: 1px; + } + } + + .empty-state { + display: flex; + align-items: center; + gap: 12px; + padding: 24px 16px; + background-color: ${(props) => props.theme.sidebar.bg}; + border: 2px dashed ${(props) => props.theme.requestTabs.bottomBorder}; + border-radius: 3px; + color: ${(props) => props.theme.textSecondary || props.theme.text}; + text-align: left; + } + + .empty-icon { + opacity: 0.5; + flex-shrink: 0; + } + + .empty-text { + flex: 1; + min-width: 0; + } + + .empty-title { + font-weight: 600; + margin: 0 0 4px 0; + font-size: 14px; + color: ${(props) => props.theme.text}; + } + + .empty-subtitle { + margin: 0; + font-size: 12px; + opacity: 0.8; + line-height: 1.5; + color: ${(props) => props.theme.textSecondary || props.theme.text}; + } + + /* Responsive design */ + @media (max-width: 480px) { + .tags-container { + gap: 6px; + } + + .tag-item { + padding: 4px 8px; + font-size: 11px; + } + + .empty-state { + padding: 16px 12px; + flex-direction: column; + text-align: center; + } + } +`; + +export default StyledWrapper; \ No newline at end of file diff --git a/packages/bruno-app/src/components/TagList/index.js b/packages/bruno-app/src/components/TagList/index.js new file mode 100644 index 000000000..e683a0a41 --- /dev/null +++ b/packages/bruno-app/src/components/TagList/index.js @@ -0,0 +1,77 @@ +import { useState } from 'react'; +import { IconX, IconTag } from '@tabler/icons'; +import StyledWrapper from './StyledWrapper'; +import SingleLineEditor from 'components/SingleLineEditor/index'; +import { useTheme } from 'providers/Theme/index'; + +const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSave, handleValidation }) => { + const { displayedTheme } = useTheme(); + const tagNameRegex = /^[\w-]+$/; + const [text, setText] = useState(''); + const [error, setError] = useState(''); + + const handleInputChange = (value) => { + setError(''); + setText(value); + }; + + const handleKeyDown = (e) => { + if (!tagNameRegex.test(text)) { + setError('Tags must only contain alpha-numeric characters, "-", "_"'); + return; + } + if (tags.includes(text)) { + setError(`Tag "${text}" already exists`); + return; + } + if (handleValidation) { + const error = handleValidation(text); + if (error) { + setError(error); + setText(''); + return; + } + } + handleAddTag(text); + setText(''); + }; + + return ( + + + {error && {error}} +
    + {tags && tags.length + ? tags.map((_tag) => ( +
  • + +
  • + )) + : null} +
+
+ ); +}; + +export default TagList; diff --git a/packages/bruno-app/src/components/VariablesEditor/index.js b/packages/bruno-app/src/components/VariablesEditor/index.js index a06b6a1ff..b601f3de0 100644 --- a/packages/bruno-app/src/components/VariablesEditor/index.js +++ b/packages/bruno-app/src/components/VariablesEditor/index.js @@ -89,14 +89,13 @@ const VariablesEditor = ({ collection }) => { const reactInspectorTheme = storedTheme === 'light' ? 'chromeLight' : 'chromeDark'; return ( - +
Note: As of today, runtime variables can only be set via the API - getVar(){' '} and setVar().
- In the next release, we will add a UI to set and modify runtime variables.
); diff --git a/packages/bruno-app/src/pages/Bruno/StyledWrapper.js b/packages/bruno-app/src/pages/Bruno/StyledWrapper.js index 741978cdf..c570cbf0b 100644 --- a/packages/bruno-app/src/pages/Bruno/StyledWrapper.js +++ b/packages/bruno-app/src/pages/Bruno/StyledWrapper.js @@ -4,8 +4,7 @@ const Wrapper = styled.div` display: flex; width: 100%; height: 100%; - min-height: 100vh; - max-height: 100vh; + flex: 1; &.is-dragging { cursor: col-resize !important; diff --git a/packages/bruno-app/src/pages/Bruno/index.js b/packages/bruno-app/src/pages/Bruno/index.js index ed5539dbb..b42462547 100644 --- a/packages/bruno-app/src/pages/Bruno/index.js +++ b/packages/bruno-app/src/pages/Bruno/index.js @@ -1,78 +1,89 @@ -import React from 'react'; +import React, { useState, useCallback, useRef, useEffect } from 'react'; import classnames from 'classnames'; import Welcome from 'components/Welcome'; import RequestTabs from 'components/RequestTabs'; import RequestTabPanel from 'components/RequestTabPanel'; import Sidebar from 'components/Sidebar'; +import StatusBar from 'components/StatusBar'; +// import ErrorCapture from 'components/ErrorCapture'; import { useSelector } from 'react-redux'; import StyledWrapper from './StyledWrapper'; import 'codemirror/theme/material.css'; import 'codemirror/theme/monokai.css'; import 'codemirror/addon/scroll/simplescrollbars.css'; +import Devtools from 'components/Devtools'; -const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true; -if (!SERVER_RENDERED) { - require('codemirror/mode/javascript/javascript'); - require('codemirror/mode/xml/xml'); - require('codemirror/mode/sparql/sparql'); - require('codemirror/addon/comment/comment'); - require('codemirror/addon/dialog/dialog'); - require('codemirror/addon/edit/closebrackets'); - require('codemirror/addon/edit/matchbrackets'); - require('codemirror/addon/fold/brace-fold'); - require('codemirror/addon/fold/foldgutter'); - require('codemirror/addon/fold/xml-fold'); - require('codemirror/addon/hint/javascript-hint'); - require('codemirror/addon/hint/show-hint'); - require('codemirror/addon/lint/lint'); - require('codemirror/addon/lint/json-lint'); - require('codemirror/addon/mode/overlay'); - require('codemirror/addon/scroll/simplescrollbars'); - require('codemirror/addon/search/jump-to-line'); - require('codemirror/addon/search/search'); - require('codemirror/addon/search/searchcursor'); - require('codemirror/addon/display/placeholder'); - require('codemirror/keymap/sublime'); +require('codemirror/mode/javascript/javascript'); +require('codemirror/mode/xml/xml'); +require('codemirror/mode/sparql/sparql'); +require('codemirror/addon/comment/comment'); +require('codemirror/addon/dialog/dialog'); +require('codemirror/addon/edit/closebrackets'); +require('codemirror/addon/edit/matchbrackets'); +require('codemirror/addon/fold/brace-fold'); +require('codemirror/addon/fold/foldgutter'); +require('codemirror/addon/fold/xml-fold'); +require('codemirror/addon/hint/javascript-hint'); +require('codemirror/addon/hint/show-hint'); +require('codemirror/addon/lint/lint'); +require('codemirror/addon/lint/json-lint'); +require('codemirror/addon/mode/overlay'); +require('codemirror/addon/scroll/simplescrollbars'); +require('codemirror/addon/search/jump-to-line'); +require('codemirror/addon/search/search'); +require('codemirror/addon/search/searchcursor'); +require('codemirror/addon/display/placeholder'); +require('codemirror/keymap/sublime'); - require('codemirror-graphql/hint'); - require('codemirror-graphql/info'); - require('codemirror-graphql/jump'); - require('codemirror-graphql/lint'); - require('codemirror-graphql/mode'); +require('codemirror-graphql/hint'); +require('codemirror-graphql/info'); +require('codemirror-graphql/jump'); +require('codemirror-graphql/lint'); +require('codemirror-graphql/mode'); - require('utils/codemirror/brunoVarInfo'); - require('utils/codemirror/javascript-lint'); - require('utils/codemirror/autocomplete'); -} +require('utils/codemirror/brunoVarInfo'); +require('utils/codemirror/javascript-lint'); +require('utils/codemirror/autocomplete'); export default function Main() { const activeTabUid = useSelector((state) => state.tabs.activeTabUid); const isDragging = useSelector((state) => state.app.isDragging); const showHomePage = useSelector((state) => state.app.showHomePage); - - // Todo: write a better logging flow that can be used to log by turning on debug flag - // Enable for debugging. - // console.log(useSelector((state) => state.collections.collections)); + const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen); + const mainSectionRef = useRef(null); const className = classnames({ 'is-dragging': isDragging }); return ( -
- - -
- {showHomePage ? ( - - ) : ( - <> - - - - )} -
-
-
+ // +
+
+ + +
+ {showHomePage ? ( + + ) : ( + <> + + + + )} +
+
+
+ + + +
+ //
); } diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js index 1828a5890..583e31725 100644 --- a/packages/bruno-app/src/providers/App/useIpcEvents.js +++ b/packages/bruno-app/src/providers/App/useIpcEvents.js @@ -25,6 +25,7 @@ import { useDispatch } from 'react-redux'; import { isElectron } from 'utils/common/platform'; import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'providers/ReduxStore/slices/global-environments'; import { collectionAddOauth2CredentialsByUrl } from 'providers/ReduxStore/slices/collections/index'; +import { addLog } from 'providers/ReduxStore/slices/logs'; const useIpcEvents = () => { const dispatch = useDispatch(); @@ -131,8 +132,13 @@ const useIpcEvents = () => { dispatch(processEnvUpdateEvent(val)); }); - const removeConsoleLogListener = ipcRenderer.on('main:console-log', (val) => { - console[val.type](...val.args); + const removeConsoleLogListener = ipcRenderer.on('main:console-log', (val) => { + console[val.type](...val.args); + dispatch(addLog({ + type: val.type, + args: val.args, + timestamp: new Date().toISOString() + })); }); const removeConfigUpdatesListener = ipcRenderer.on('main:bruno-config-update', (val) => diff --git a/packages/bruno-app/src/providers/Hotkeys/index.js b/packages/bruno-app/src/providers/Hotkeys/index.js index f9316eb94..f756fb86f 100644 --- a/packages/bruno-app/src/providers/Hotkeys/index.js +++ b/packages/bruno-app/src/providers/Hotkeys/index.js @@ -211,13 +211,15 @@ export const HotkeysProvider = (props) => { }; }, [activeTabUid, tabs, collections, dispatch]); + const currentCollection = getCurrentCollection(); + return ( {showEnvSettingsModal && ( - setShowEnvSettingsModal(false)} /> + setShowEnvSettingsModal(false)} /> )} {showNewRequestModal && ( - setShowNewRequestModal(false)} /> + setShowNewRequestModal(false)} /> )}
{props.children}
diff --git a/packages/bruno-app/src/providers/ReduxStore/index.js b/packages/bruno-app/src/providers/ReduxStore/index.js index e02886582..8ed528073 100644 --- a/packages/bruno-app/src/providers/ReduxStore/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/index.js @@ -6,6 +6,7 @@ import collectionsReducer from './slices/collections'; import tabsReducer from './slices/tabs'; import notificationsReducer from './slices/notifications'; import globalEnvironmentsReducer from './slices/global-environments'; +import logsReducer from './slices/logs'; import { draftDetectMiddleware } from './middlewares/draft/middleware'; const isDevEnv = () => { @@ -23,7 +24,8 @@ export const store = configureStore({ collections: collectionsReducer, tabs: tabsReducer, notifications: notificationsReducer, - globalEnvironments: globalEnvironmentsReducer + globalEnvironments: globalEnvironmentsReducer, + logs: logsReducer }, middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(middleware) }); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js index f19c51101..6cb7f541b 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js @@ -1,6 +1,5 @@ import { createSlice } from '@reduxjs/toolkit'; import filter from 'lodash/filter'; -import toast from 'react-hot-toast'; const initialState = { isDragging: false, @@ -12,7 +11,7 @@ const initialState = { isEnvironmentSettingsModalOpen: false, preferences: { request: { - sslVerification: true, + sslVerification: false, customCaCertificate: { enabled: false, filePath: null @@ -26,6 +25,11 @@ const initialState = { codeFont: 'default' } }, + generateCode: { + mainLanguage: 'Shell', + library: 'curl', + shouldInterpolate: true + }, cookies: [], taskQueue: [], systemProxyEnvVariables: {} @@ -76,6 +80,12 @@ export const appSlice = createSlice({ }, updateSystemProxyEnvVariables: (state, action) => { state.systemProxyEnvVariables = action.payload; + }, + updateGenerateCode: (state, action) => { + state.generateCode = { + ...state.generateCode, + ...action.payload + }; } } }); @@ -94,7 +104,8 @@ export const { insertTaskIntoQueue, removeTaskFromQueue, removeAllTasksFromQueue, - updateSystemProxyEnvVariables + updateSystemProxyEnvVariables, + updateGenerateCode } = appSlice.actions; export const savePreferences = (preferences) => (dispatch, getState) => { @@ -103,14 +114,9 @@ export const savePreferences = (preferences) => (dispatch, getState) => { ipcRenderer .invoke('renderer:save-preferences', preferences) - .then(() => toast.success('Preferences saved successfully')) .then(() => dispatch(updatePreferences(preferences))) .then(resolve) - .catch((err) => { - toast.error('An error occurred while saving preferences'); - console.error(err); - reject(err); - }); + .catch(reject); }); }; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 6aa890d8b..2df8ee03b 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -1,4 +1,5 @@ import { collectionSchema, environmentSchema, itemSchema } from '@usebruno/schema'; +import { parseQueryParams } from '@usebruno/common/utils'; import cloneDeep from 'lodash/cloneDeep'; import filter from 'lodash/filter'; import find from 'lodash/find'; @@ -36,13 +37,14 @@ import { updateLastAction, setCollectionSecurityConfig, collectionAddOauth2CredentialsByUrl, - collectionClearOauth2CredentialsByUrl + collectionClearOauth2CredentialsByUrl, + initRunRequestEvent } from './index'; import { each } from 'lodash'; import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs'; import { resolveRequestFilename } from 'utils/common/platform'; -import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index'; +import { parsePathParams, splitOnFirst } from 'utils/url/index'; import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index'; import { getGlobalEnvironmentVariables, findCollectionByPathname, findEnvironmentInCollectionByName, getReorderedItemsInTargetDirectory, resetSequencesInFolder, getReorderedItemsInSourceDirectory, calculateDraggedItemNewPathname } from 'utils/collections/index'; import { sanitizeName } from 'utils/common/regex'; @@ -220,15 +222,26 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => { const state = getState(); const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments; const collection = findCollectionByUid(state.collections.collections, collectionUid); + const itemUid = item?.uid; - return new Promise((resolve, reject) => { + return new Promise(async (resolve, reject) => { if (!collection) { return reject(new Error('Collection not found')); } - - const itemCopy = cloneDeep(item || {}); + let collectionCopy = cloneDeep(collection); + const itemCopy = cloneDeep(item); + + const requestUid = uuid(); + itemCopy.requestUid = requestUid; + + await dispatch(initRunRequestEvent({ + requestUid, + itemUid, + collectionUid + })); + // add selected global env variables to the collection object const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid }); collectionCopy.globalEnvironmentVariables = globalEnvironmentVariables; @@ -247,8 +260,8 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => { return dispatch( responseReceived({ - itemUid: item.uid, - collectionUid: collectionUid, + itemUid, + collectionUid, response: serializedResponse }) ); @@ -259,8 +272,8 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => { console.log('>> request cancelled'); dispatch( responseReceived({ - itemUid: item.uid, - collectionUid: collectionUid, + itemUid, + collectionUid, response: null }) ); @@ -277,8 +290,8 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => { dispatch( responseReceived({ - itemUid: item.uid, - collectionUid: collectionUid, + itemUid, + collectionUid, response: errorResponse }) ); @@ -303,7 +316,7 @@ export const cancelRunnerExecution = (cancelTokenUid) => (dispatch) => { cancelNetworkRequest(cancelTokenUid).catch((err) => console.log(err)); }; -export const runCollectionFolder = (collectionUid, folderUid, recursive, delay) => (dispatch, getState) => { +export const runCollectionFolder = (collectionUid, folderUid, recursive, delay, tags) => (dispatch, getState) => { const state = getState(); const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments; const collection = findCollectionByUid(state.collections.collections, collectionUid); @@ -342,7 +355,8 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay) environment, collectionCopy.runtimeVariables, recursive, - delay + delay, + tags ) .then(resolve) .catch((err) => { @@ -371,28 +385,26 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) => if (!folderWithSameNameExists) { const fullName = path.join(collection.pathname, directoryName); const { ipcRenderer } = window; + + const folderBruJsonData = { + meta: { + name: folderName, + seq: items?.length + 1 + }, + request: { + auth: { + mode: 'inherit' + } + } + }; + ipcRenderer - .invoke('renderer:new-folder', fullName) - .then(async () => { - const folderData = { - name: folderName, - pathname: fullName, - root: { - meta: { - name: folderName, - seq: items?.length + 1 - } - } - }; - ipcRenderer - .invoke('renderer:save-folder-root', folderData) - .then(resolve) - .catch((err) => { - toast.error('Failed to save folder settings!'); - reject(err); - }); - }) - .catch((error) => reject(error)); + .invoke('renderer:new-folder', { pathname: fullName, folderBruJsonData }) + .then(resolve) + .catch((error) => { + toast.error('Failed to create a new folder!'); + reject(error) + }); } else { return reject(new Error('Duplicate folder names under same parent folder are not allowed')); } @@ -407,28 +419,25 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) => const fullName = path.join(currentItem.pathname, directoryName); const { ipcRenderer } = window; + const folderBruJsonData = { + meta: { + name: folderName, + seq: items?.length + 1 + }, + request: { + auth: { + mode: 'inherit' + } + } + }; + ipcRenderer - .invoke('renderer:new-folder', fullName) - .then(async () => { - const folderData = { - name: folderName, - pathname: fullName, - root: { - meta: { - name: folderName, - seq: items?.length + 1 - } - } - }; - ipcRenderer - .invoke('renderer:save-folder-root', folderData) - .then(resolve) - .catch((err) => { - toast.error('Failed to save folder settings!'); - reject(err); - }); - }) - .catch((error) => reject(error)); + .invoke('renderer:new-folder', { pathname: fullName, folderBruJsonData }) + .then(resolve) + .catch((error) => { + toast.error('Failed to create a new folder!'); + reject(error) + }); } else { return reject(new Error('Duplicate folder names under same parent folder are not allowed')); } @@ -741,7 +750,7 @@ export const updateItemsSequences = ({ itemsToResequence }) => (dispatch, getSta } export const newHttpRequest = (params) => (dispatch, getState) => { - const { requestName, filename, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body, auth } = params; + const { requestName, filename, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body, auth, settings } = params; return new Promise((resolve, reject) => { const state = getState(); @@ -788,6 +797,9 @@ export const newHttpRequest = (params) => (dispatch, getState) => { auth: auth ?? { mode: 'inherit' } + }, + settings: settings ?? { + encodeUrl: true } }; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 3dfa6d052..44db21df4 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -1,3 +1,4 @@ +import { parseQueryParams, buildQueryString as stringifyQueryParams } from '@usebruno/common/utils'; import { uuid } from 'utils/common'; import { find, map, forOwn, concat, filter, each, cloneDeep, get, set, findIndex } from 'lodash'; import { createSlice } from '@reduxjs/toolkit'; @@ -15,11 +16,12 @@ import { isItemAFolder, isItemARequest } from 'utils/collections'; -import { parsePathParams, parseQueryParams, splitOnFirst, stringifyQueryParams } from 'utils/url'; +import { parsePathParams, splitOnFirst } from 'utils/url'; import { getSubdirectoriesFromRoot } from 'utils/common/platform'; import toast from 'react-hot-toast'; import mime from 'mime-types'; import path from 'utils/common/path'; +import { getUniqueTagsFromItems } from 'utils/collections/index'; const initialState = { collections: [], @@ -36,6 +38,7 @@ export const collectionsSlice = createSlice({ collection.settingsSelectedTab = 'overview'; collection.folderLevelSettingsSelectedTab = {}; + collection.allTags = []; // Initialize collection-level tags // Collection mount status is used to track the mount status of the collection // values can be 'unmounted', 'mounting', 'mounted' @@ -276,6 +279,8 @@ export const collectionsSlice = createSlice({ if (item) { item.response = null; item.cancelTokenUid = null; + item.requestUid = null; + item.requestStartTime = null; } } }, @@ -288,6 +293,7 @@ export const collectionsSlice = createSlice({ item.requestState = 'received'; item.response = action.payload.response; item.cancelTokenUid = null; + item.requestStartTime = null; if (!collection.timeline) { collection.timeline = []; @@ -419,14 +425,14 @@ export const collectionsSlice = createSlice({ collection.items.push(item); } }, - collapseCollection: (state, action) => { + toggleCollection: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload); if (collection) { collection.collapsed = !collection.collapsed; } }, - collectionFolderClicked: (state, action) => { + toggleCollectionItem: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); if (collection) { @@ -505,6 +511,20 @@ export const collectionsSlice = createSlice({ } } }, + updateItemSettings: (state, action) => { + const collection = findCollectionByUid(state.collections, action.payload.collectionUid); + + if (collection) { + const item = findItemInCollection(collection, action.payload.itemUid); + + if (item && isItemARequest(item)) { + if (!item.draft) { + item.draft = cloneDeep(item); + } + item.draft.settings = { ...item.draft.settings, ...action.payload.settings }; + } + } + }, updateAuth: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); @@ -576,7 +596,48 @@ export const collectionsSlice = createSlice({ } } }, + setQueryParams: (state, action) => { + const { collectionUid, itemUid, params } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + if (!collection) { + return; + } + + const item = findItemInCollection(collection, itemUid); + if (!item || !isItemARequest(item)) { + return; + } + + if (!item.draft) { + item.draft = cloneDeep(item); + } + const existingOtherParams = item.draft.request.params?.filter(p => p.type !== 'query') || []; + const newQueryParams = map(params, ({ name = '', value = '', enabled = true }) => ({ + uid: uuid(), + name, + value, + description: '', + type: 'query', + enabled + })); + + item.draft.request.params = [...newQueryParams, ...existingOtherParams]; + + // 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') + ); + + // If there are enabled query params, append them to the URL + if (query && query.length) { + item.draft.request.url = parts[0] + '?' + query; + } else { + // If no enabled query params, remove the query part from URL + item.draft.request.url = parts[0]; + } + }, moveQueryParam: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); @@ -782,6 +843,30 @@ export const collectionsSlice = createSlice({ } } }, + setRequestHeaders: (state, action) => { + const { collectionUid, itemUid, headers } = action.payload; + + const collection = findCollectionByUid(state.collections, collectionUid); + if (!collection) { + return; + } + + const item = findItemInCollection(collection, itemUid); + if (!item || !isItemARequest(item)) { + return; + } + + if (!item.draft) { + item.draft = cloneDeep(item); + } + item.draft.request.headers = map(action.payload.headers, ({name = '', value = '', enabled = true}) => ({ + uid: uuid(), + name: name, + value: value, + description: '', + enabled: enabled + })); + }, addFormUrlEncodedParam: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); @@ -1593,6 +1678,27 @@ export const collectionsSlice = createSlice({ case 'oauth2': set(folder, 'root.request.auth.oauth2', action.payload.content); break; + case 'basic': + set(folder, 'root.request.auth.basic', action.payload.content); + break; + case 'bearer': + set(folder, 'root.request.auth.bearer', action.payload.content); + break; + case 'digest': + set(folder, 'root.request.auth.digest', action.payload.content); + break; + case 'ntlm': + set(folder, 'root.request.auth.ntlm', action.payload.content); + break; + case 'apikey': + set(folder, 'root.request.auth.apikey', action.payload.content); + break; + case 'awsv4': + set(folder, 'root.request.auth.awsv4', action.payload.content); + break; + case 'wsse': + set(folder, 'root.request.auth.wsse', action.payload.content); + break; } } }, @@ -1757,9 +1863,11 @@ export const collectionsSlice = createSlice({ currentItem.name = file.data.name; currentItem.type = file.data.type; currentItem.seq = file.data.seq; + currentItem.tags = file.data.tags; currentItem.request = file.data.request; currentItem.filename = file.meta.name; currentItem.pathname = file.meta.pathname; + currentItem.settings = file.data.settings; currentItem.draft = null; currentItem.partial = file.partial; currentItem.loading = file.loading; @@ -1771,7 +1879,9 @@ export const collectionsSlice = createSlice({ name: file.data.name, type: file.data.type, seq: file.data.seq, + tags: file.data.tags, request: file.data.request, + settings: file.data.settings, filename: file.meta.name, pathname: file.meta.pathname, draft: null, @@ -1801,7 +1911,7 @@ export const collectionsSlice = createSlice({ uid: dir?.meta?.uid || uuid(), pathname: currentPath, name: dir?.meta?.name || directoryName, - seq: dir?.meta?.seq || 1, + seq: dir?.meta?.seq, filename: directoryName, collapsed: true, type: 'folder', @@ -1860,7 +1970,9 @@ export const collectionsSlice = createSlice({ item.name = file.data.name; item.type = file.data.type; item.seq = file.data.seq; + item.tags = file.data.tags; item.request = file.data.request; + item.settings = file.data.settings; item.filename = file.meta.name; item.pathname = file.meta.pathname; item.draft = null; @@ -1933,26 +2045,51 @@ export const collectionsSlice = createSlice({ collection.runnerResult = null; } }, + initRunRequestEvent: (state, action) => { + const { requestUid, itemUid, collectionUid } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + if (!collection) return; + + const item = findItemInCollection(collection, itemUid); + if (!item) return; + + item.requestState = null; + item.requestUid = requestUid; + item.requestStartTime = Date.now(); + item.testResults = []; + item.preRequestTestResults = []; + item.postResponseTestResults = []; + item.assertionResults = []; + item.preRequestScriptErrorMessage = null; + item.postResponseScriptErrorMessage = null; + item.testScriptErrorMessage = null; + }, runRequestEvent: (state, action) => { - const { itemUid, collectionUid, type, requestUid, hasError } = action.payload; + const { itemUid, collectionUid, type, requestUid } = action.payload; const collection = findCollectionByUid(state.collections, collectionUid); if (collection) { const item = findItemInCollection(collection, itemUid); if (item) { + // ignore outdated updates in case multiple requests are fired rapidly to avoid state inconsistency + if (item.requestUid !== requestUid) return; + if (type === 'pre-request-script-execution') { - item.requestUid = requestUid; item.preRequestScriptErrorMessage = action.payload.errorMessage; } if(type === 'post-response-script-execution') { - item.requestUid = requestUid; item.postResponseScriptErrorMessage = action.payload.errorMessage; } + if(type === 'test-script-execution') { + item.testScriptErrorMessage = action.payload.errorMessage; + } + if (type === 'request-queued') { const { cancelTokenUid } = action.payload; - item.requestUid = requestUid; + // ignore if request is already in progress or completed + if (['sending', 'received'].includes(item.requestState)) return; item.requestState = 'queued'; item.cancelTokenUid = cancelTokenUid; } @@ -1960,10 +2097,9 @@ 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.requestUid === requestUid && item.requestState === 'queued') { - item.requestUid = requestUid; + if (item.requestState === 'queued') { item.requestState = 'sending'; item.cancelTokenUid = cancelTokenUid; } @@ -1978,6 +2114,16 @@ 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; + } } } }, @@ -2034,6 +2180,16 @@ export const collectionsSlice = createSlice({ item.testResults = action.payload.testResults; } + if (type === 'test-results-pre-request') { + const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid); + item.preRequestTestResults = action.payload.preRequestTestResults; + } + + if (type === 'test-results-post-response') { + const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid); + item.postResponseTestResults = action.payload.postResponseTestResults; + } + if (type === 'assertion-results') { const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid); item.assertionResults = action.payload.assertionResults; @@ -2051,6 +2207,21 @@ export const collectionsSlice = createSlice({ item.status = 'skipped'; item.responseReceived = action.payload.responseReceived; } + + if (type === 'post-response-script-execution') { + const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid); + item.postResponseScriptErrorMessage = action.payload.errorMessage; + } + + if (type === 'test-script-execution') { + const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid); + item.testScriptErrorMessage = action.payload.errorMessage; + } + + if (type === 'pre-request-script-execution') { + const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid); + item.preRequestScriptErrorMessage = action.payload.errorMessage; + } } }, resetCollectionRunner: (state, action) => { @@ -2059,6 +2230,20 @@ export const collectionsSlice = createSlice({ if (collection) { collection.runnerResult = null; + collection.runnerTags = { include: [], exclude: [] } + collection.runnerTagsEnabled = false; + } + }, + updateRunnerTagsDetails: (state, action) => { + const { collectionUid, tags, tagsEnabled } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + if (collection) { + if (tags) { + collection.runnerTags = tags; + } + if (typeof tagsEnabled === 'boolean') { + collection.runnerTagsEnabled = tagsEnabled; + } } }, updateRequestDocs: (state, action) => { @@ -2165,6 +2350,7 @@ export const collectionsSlice = createSlice({ ); return oauth2Credential; }, + updateFolderAuthMode: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null; @@ -2174,6 +2360,53 @@ export const collectionsSlice = createSlice({ set(folder, 'root.request.auth.mode', action.payload.mode); } }, + + addRequestTag: (state, action) => { + const { tag, collectionUid, itemUid } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + + if (collection) { + const item = findItemInCollection(collection, itemUid); + + if (item && isItemARequest(item)) { + if (!item.draft) { + item.draft = cloneDeep(item); + } + item.draft.tags = item.draft.tags || []; + if (!item.draft.tags.includes(tag.trim())) { + item.draft.tags.push(tag.trim()); + } + + collection.allTags = getUniqueTagsFromItems(collection.items); + } + } + }, + deleteRequestTag: (state, action) => { + const { tag, collectionUid, itemUid } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + + if (collection) { + const item = findItemInCollection(collection, itemUid); + + if (item && isItemARequest(item)) { + if (!item.draft) { + item.draft = cloneDeep(item); + } + item.draft.tags = item.draft.tags || []; + item.draft.tags = item.draft.tags.filter((t) => t !== tag.trim()); + + collection.allTags = getUniqueTagsFromItems(collection.items); + } + } + }, + updateCollectionTagsList: (state, action) => { + const { collectionUid } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + + if (collection) { + collection.allTags = getUniqueTagsFromItems(collection.items); + } + } } }); @@ -2205,11 +2438,13 @@ export const { saveRequest, deleteRequestDraft, newEphemeralHttpRequest, - collapseCollection, - collectionFolderClicked, + toggleCollection, + toggleCollectionItem, requestUrlChanged, + updateItemSettings, updateAuth, addQueryParam, + setQueryParams, moveQueryParam, updateQueryParam, deleteQueryParam, @@ -2218,6 +2453,7 @@ export const { updateRequestHeader, deleteRequestHeader, moveRequestHeader, + setRequestHeaders, addFormUrlEncodedParam, updateFormUrlEncodedParam, deleteFormUrlEncodedParam, @@ -2275,17 +2511,22 @@ export const { collectionAddEnvFileEvent, collectionRenamedEvent, resetRunResults, + initRunRequestEvent, runRequestEvent, runFolderEvent, resetCollectionRunner, + updateRunnerTagsDetails, updateRequestDocs, updateFolderDocs, + moveCollection, collectionAddOauth2CredentialsByUrl, collectionClearOauth2CredentialsByUrl, collectionGetOauth2CredentialsByUrl, updateFolderAuth, updateFolderAuthMode, - moveCollection + addRequestTag, + deleteRequestTag, + updateCollectionTagsList } = collectionsSlice.actions; export default collectionsSlice.reducer; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/logs.js b/packages/bruno-app/src/providers/ReduxStore/slices/logs.js new file mode 100644 index 000000000..a96f1ec90 --- /dev/null +++ b/packages/bruno-app/src/providers/ReduxStore/slices/logs.js @@ -0,0 +1,143 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + logs: [], + debugErrors: [], + isConsoleOpen: false, + activeTab: 'console', + filters: { + info: true, + warn: true, + error: true, + debug: true, + log: true + }, + networkFilters: { + GET: true, + POST: true, + PUT: true, + DELETE: true, + PATCH: true, + HEAD: true, + OPTIONS: true + }, + selectedRequest: null, + selectedError: null, + maxLogs: 1000, + maxDebugErrors: 500 +}; + +export const logsSlice = createSlice({ + name: 'logs', + initialState, + reducers: { + addLog: (state, action) => { + const { type, args, timestamp } = action.payload; + const newLog = { + id: Date.now() + Math.random(), + type: type || 'log', + message: args ? args.join(' ') : '', + args: args || [], + timestamp: timestamp || new Date().toISOString() + }; + + state.logs.push(newLog); + + if (state.logs.length > state.maxLogs) { + state.logs = state.logs.slice(-state.maxLogs); + } + }, + addDebugError: (state, action) => { + const { message, stack, filename, lineno, colno, args, timestamp } = action.payload; + const newError = { + id: Date.now() + Math.random(), + message: message || 'Unknown error', + stack: stack, + filename: filename, + lineno: lineno, + colno: colno, + args: args || [], + timestamp: timestamp || new Date().toISOString() + }; + + state.debugErrors.push(newError); + + if (state.debugErrors.length > state.maxDebugErrors) { + state.debugErrors = state.debugErrors.slice(-state.maxDebugErrors); + } + }, + clearLogs: (state) => { + state.logs = []; + }, + clearDebugErrors: (state) => { + state.debugErrors = []; + }, + openConsole: (state) => { + state.isConsoleOpen = true; + }, + closeConsole: (state) => { + state.isConsoleOpen = false; + }, + setActiveTab: (state, action) => { + state.activeTab = action.payload; + if (action.payload !== 'network') { + state.selectedRequest = null; + } + if (action.payload !== 'debug') { + state.selectedError = null; + } + }, + updateFilter: (state, action) => { + const { filterType, enabled } = action.payload; + state.filters[filterType] = enabled; + }, + toggleAllFilters: (state, action) => { + const enabled = action.payload; + Object.keys(state.filters).forEach(key => { + state.filters[key] = enabled; + }); + }, + updateNetworkFilter: (state, action) => { + const { method, enabled } = action.payload; + state.networkFilters[method] = enabled; + }, + toggleAllNetworkFilters: (state, action) => { + const enabled = action.payload; + Object.keys(state.networkFilters).forEach(key => { + state.networkFilters[key] = enabled; + }); + }, + setSelectedRequest: (state, action) => { + state.selectedRequest = action.payload; + }, + clearSelectedRequest: (state) => { + state.selectedRequest = null; + }, + setSelectedError: (state, action) => { + state.selectedError = action.payload; + }, + clearSelectedError: (state) => { + state.selectedError = null; + } + } +}); + +export const { + addLog, + addDebugError, + clearLogs, + clearDebugErrors, + openConsole, + closeConsole, + setActiveTab, + updateFilter, + toggleAllFilters, + updateNetworkFilter, + toggleAllNetworkFilters, + setSelectedRequest, + clearSelectedRequest, + setSelectedError, + clearSelectedError +} = logsSlice.actions; + +export default logsSlice.reducer; \ No newline at end of file diff --git a/packages/bruno-app/src/providers/Theme/index.js b/packages/bruno-app/src/providers/Theme/index.js index 44025197a..9b741872b 100644 --- a/packages/bruno-app/src/providers/Theme/index.js +++ b/packages/bruno-app/src/providers/Theme/index.js @@ -1,3 +1,4 @@ +import React from 'react'; import themes from 'themes/index'; import useLocalStorage from 'hooks/useLocalStorage/index'; diff --git a/packages/bruno-app/src/test-utils/mocks/codemirror.js b/packages/bruno-app/src/test-utils/mocks/codemirror.js new file mode 100644 index 000000000..03ee94c3b --- /dev/null +++ b/packages/bruno-app/src/test-utils/mocks/codemirror.js @@ -0,0 +1,45 @@ +const CodeMirror = jest.fn((node, options) => { + const editor = { + options, + _currentValue: '', + _onKeyUpMockDataHints: null, + getCursor: jest.fn(() => ({ line: 0, ch: editor._currentValue?.length || 0 })), + getRange: jest.fn((from, to) => editor._currentValue?.slice(0, to.ch) || ''), + getValue: jest.fn(() => editor._currentValue), + setValue: jest.fn(function (val) { + editor._currentValue = val; + }), + getLine: jest.fn(() => editor._currentValue || ''), + setOption: jest.fn(), + refresh: jest.fn(), + off: jest.fn(), + showHint: jest.fn(), + on: jest.fn(function (event, handler) { + if (event === 'keyup') { + if (handler && handler.name === '_onKeyUpMockDataHints') { + this._onKeyUpMockDataHints = handler; + } + } + }) + }; + return editor; +}); + +CodeMirror.commands = { + autocomplete: jest.fn() +}; + +CodeMirror.hint = {}; + +CodeMirror.registerHelper = jest.fn((type, name, value) => { + if (!CodeMirror[type]) { + CodeMirror[type] = {}; + } + + CodeMirror[type][name] = value; +}); + +CodeMirror.fromTextArea = jest.fn(); +CodeMirror.defineMode = jest.fn(); + +module.exports = CodeMirror; diff --git a/packages/bruno-app/src/themes/dark.js b/packages/bruno-app/src/themes/dark.js index 04ee6134e..f56217e7a 100644 --- a/packages/bruno-app/src/themes/dark.js +++ b/packages/bruno-app/src/themes/dark.js @@ -248,6 +248,10 @@ const darkTheme = { codemirror: { bg: '#1e1e1e', border: '#373737', + placeholder: { + color: '#a2a2a2', + opacity: 0.50 + }, gutter: { bg: '#262626' }, @@ -291,6 +295,36 @@ const darkTheme = { bg: '#1f1f1f', border: '#333333', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.5)' + }, + + statusBar: { + border: '#323233', + }, + console: { + bg: '#1e1e1e', + headerBg: '#2d2d30', + contentBg: '#1e1e1e', + border: '#3c3c3c', + titleColor: '#cccccc', + countColor: '#858585', + buttonColor: '#cccccc', + buttonHoverBg: 'rgba(255, 255, 255, 0.1)', + buttonHoverColor: '#ffffff', + messageColor: '#cccccc', + timestampColor: '#858585', + emptyColor: '#858585', + logHoverBg: 'rgba(255, 255, 255, 0.05)', + resizeHandleHover: '#0078d4', + resizeHandleActive: '#0078d4', + dropdownBg: '#2d2d30', + dropdownHeaderBg: '#3c3c3c', + optionHoverBg: 'rgba(255, 255, 255, 0.05)', + optionLabelColor: '#cccccc', + optionCountColor: '#858585', + checkboxColor: '#0078d4', + scrollbarTrack: '#2d2d30', + scrollbarThumb: '#5a5a5a', + scrollbarThumbHover: '#6a6a6a' } }; diff --git a/packages/bruno-app/src/themes/light.js b/packages/bruno-app/src/themes/light.js index e95b0e45e..a012fdd31 100644 --- a/packages/bruno-app/src/themes/light.js +++ b/packages/bruno-app/src/themes/light.js @@ -7,7 +7,7 @@ const lightTheme = { colors: { text: { green: '#047857', - danger: 'rgb(185, 28, 28)', + danger: '#B91C1C', muted: '#838383', purple: '#8e44ad', yellow: '#d97706' @@ -249,6 +249,10 @@ const lightTheme = { codemirror: { bg: 'white', border: '#efefef', + placeholder: { + color: '#a2a2a2', + opacity: 0.75 + }, gutter: { bg: '#f3f3f3' }, @@ -292,6 +296,36 @@ const lightTheme = { bg: 'white', border: '#e0e0e0', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)' + }, + + statusBar: { + border: '#E9E9E9', + }, + console: { + bg: '#f8f9fa', + headerBg: '#f8f9fa', + contentBg: '#ffffff', + border: '#dee2e6', + titleColor: '#212529', + countColor: '#6c757d', + buttonColor: '#495057', + buttonHoverBg: '#e9ecef', + buttonHoverColor: '#212529', + messageColor: '#212529', + timestampColor: '#6c757d', + emptyColor: '#6c757d', + logHoverBg: 'rgba(0, 0, 0, 0.03)', + resizeHandleHover: '#0d6efd', + resizeHandleActive: '#0d6efd', + dropdownBg: '#ffffff', + dropdownHeaderBg: '#f8f9fa', + optionHoverBg: '#f8f9fa', + optionLabelColor: '#212529', + optionCountColor: '#6c757d', + checkboxColor: '#0d6efd', + scrollbarTrack: '#f8f9fa', + scrollbarThumb: '#ced4da', + scrollbarThumbHover: '#adb5bd' } }; diff --git a/packages/bruno-app/src/ui/HeightBoundContainer/StyledWrapper.js b/packages/bruno-app/src/ui/HeightBoundContainer/StyledWrapper.js new file mode 100644 index 000000000..6d261b9bf --- /dev/null +++ b/packages/bruno-app/src/ui/HeightBoundContainer/StyledWrapper.js @@ -0,0 +1,25 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + /* Primary container - establishes flex context */ + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + + /* Flex shrink container - allows content to be constrained */ + .height-constraint { + display: flex; + flex: 1 1 0; + min-height: 0; + } + + /* flex container - enforces boundaries */ + .flex-boundary { + width: 100%; + display: flex; + overflow-y: auto; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/ui/HeightBoundContainer/index.js b/packages/bruno-app/src/ui/HeightBoundContainer/index.js new file mode 100644 index 000000000..149c02da1 --- /dev/null +++ b/packages/bruno-app/src/ui/HeightBoundContainer/index.js @@ -0,0 +1,16 @@ +import React from 'react'; +import StyledWrapper from './StyledWrapper'; + +const HeightBoundContainer = ({children}) => { + return ( + +
+
+ {children} +
+
+
+ ); +}; + +export default HeightBoundContainer; diff --git a/packages/bruno-app/src/utils/codegenerator/auth.js b/packages/bruno-app/src/utils/codegenerator/auth.js index 981d6cec2..d5ecb4cc5 100644 --- a/packages/bruno-app/src/utils/codegenerator/auth.js +++ b/packages/bruno-app/src/utils/codegenerator/auth.js @@ -1,6 +1,15 @@ import get from 'lodash/get'; export const getAuthHeaders = (collectionRootAuth, requestAuth) => { + + // Discovered edge case where code generation fails when you create a collection which has not been saved yet: + // Collection auth therefore null, and request inherits from collection, therefore it is also null + // TypeError: Cannot read properties of undefined (reading 'mode') + // at getAuthHeaders + if (!collectionRootAuth && !requestAuth) { + return []; + } + const auth = collectionRootAuth && ['inherit'].includes(requestAuth?.mode) ? collectionRootAuth : requestAuth; switch (auth.mode) { diff --git a/packages/bruno-app/src/utils/codegenerator/har.js b/packages/bruno-app/src/utils/codegenerator/har.js index 110f82db5..5ee08cac6 100644 --- a/packages/bruno-app/src/utils/codegenerator/har.js +++ b/packages/bruno-app/src/utils/codegenerator/har.js @@ -68,15 +68,15 @@ const createPostData = (body, type) => { return { mimeType: contentType, text: new URLSearchParams( - body[body.mode] - .filter((param) => param.enabled) + (Array.isArray(body[body.mode]) ? body[body.mode] : []) + .filter((param) => param?.enabled) .reduce((acc, param) => { acc[param.name] = param.value; return acc; }, {}) ).toString(), - params: body[body.mode] - .filter((param) => param.enabled) + params: (Array.isArray(body[body.mode]) ? body[body.mode] : []) + .filter((param) => param?.enabled) .map((param) => ({ name: param.name, value: param.value @@ -85,23 +85,33 @@ const createPostData = (body, type) => { case 'multipartForm': return { mimeType: contentType, - params: body[body.mode] - .filter((param) => param.enabled) + params: (Array.isArray(body[body.mode]) ? body[body.mode] : []) + .filter((param) => param?.enabled) .map((param) => ({ name: param.name, value: param.value, ...(param.type === 'file' && { fileName: param.value }) })) }; - case 'file': + case 'file': { + const files = Array.isArray(body[body.mode]) ? body[body.mode] : []; + const selectedFile = files.find((param) => param.selected) || files[0]; + const filePath = selectedFile?.filePath || ''; return { - mimeType: body[body.mode].filter((param) => param.enabled)[0].contentType, - params: body[body.mode] - .filter((param) => param.selected) - .map((param) => ({ - value: param.filePath, - })) + mimeType: selectedFile?.contentType || 'application/octet-stream', + text: filePath, + params: filePath + ? [ + { + name: selectedFile?.name || 'file', + value: filePath, + fileName: filePath, + contentType: selectedFile?.contentType || 'application/octet-stream' + } + ] + : [] }; + } default: return { mimeType: contentType, diff --git a/packages/bruno-app/src/utils/codemirror/autocomplete.js b/packages/bruno-app/src/utils/codemirror/autocomplete.js index 9c068f20e..7b65f4234 100644 --- a/packages/bruno-app/src/utils/codemirror/autocomplete.js +++ b/packages/bruno-app/src/utils/codemirror/autocomplete.js @@ -1,40 +1,664 @@ -let CodeMirror; -const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true; +import { mockDataFunctions } from '@usebruno/common'; -if (!SERVER_RENDERED) { - CodeMirror = require('codemirror'); - CodeMirror.registerHelper('hint', 'anyword', (editor, options) => { - const word = /[\w$-]+/; - const wordlist = (options && options.autocomplete) || []; - let cur = editor.getCursor(), - curLine = editor.getLine(cur.line); - let end = cur.ch, - start = end; - while (start && word.test(curLine.charAt(start - 1))) --start; - let curWord = start != end && curLine.slice(start, end); +const CodeMirror = require('codemirror'); - // Check if curWord is a valid string before proceeding - if (typeof curWord !== 'string' || curWord.length < 3) { - return null; // Abort the hint +// Static API hints - Bruno JavaScript API (subgrouped by category) +const STATIC_API_HINTS = { + req: [ + 'req', + 'req.url', + 'req.method', + 'req.headers', + 'req.body', + 'req.timeout', + 'req.getUrl()', + 'req.setUrl(url)', + 'req.getMethod()', + 'req.getAuthMode()', + 'req.setMethod(method)', + 'req.getHeader(name)', + 'req.getHeaders()', + 'req.setHeader(name, value)', + 'req.setHeaders(data)', + 'req.getBody()', + 'req.setBody(data)', + 'req.setMaxRedirects(maxRedirects)', + 'req.getTimeout()', + 'req.setTimeout(timeout)', + 'req.getExecutionMode()', + 'req.getName()', + 'req.disableParsingResponseJson()', + 'req.onFail(function(err) {})', + ], + res: [ + 'res', + 'res.status', + 'res.statusText', + 'res.headers', + 'res.body', + 'res.responseTime', + 'res.getStatus()', + 'res.getStatusText()', + 'res.getHeader(name)', + 'res.getHeaders()', + 'res.getBody()', + 'res.setBody(data)', + 'res.getResponseTime()', + 'res.getSize()', + 'res.getSize().header', + 'res.getSize().body', + 'res.getSize().total', + ], + bru: [ + 'bru', + 'bru.cwd()', + 'bru.getEnvName()', + 'bru.getProcessEnv(key)', + 'bru.hasEnvVar(key)', + 'bru.getEnvVar(key)', + 'bru.getFolderVar(key)', + 'bru.getCollectionVar(key)', + 'bru.setEnvVar(key,value)', + 'bru.deleteEnvVar(key)', + 'bru.hasVar(key)', + 'bru.getVar(key)', + 'bru.setVar(key,value)', + 'bru.deleteVar(key)', + 'bru.deleteAllVars()', + 'bru.setNextRequest(requestName)', + 'bru.getRequestVar(key)', + 'bru.runRequest(requestPathName)', + 'bru.getAssertionResults()', + 'bru.getTestResults()', + 'bru.sleep(ms)', + 'bru.getCollectionName()', + 'bru.getGlobalEnvVar(key)', + 'bru.setGlobalEnvVar(key, value)', + 'bru.runner', + 'bru.runner.setNextRequest(requestName)', + 'bru.runner.skipRequest()', + 'bru.runner.stopExecution()', + 'bru.interpolate(str)' + ] +}; + +// Mock data functions - prefixed with $ +const MOCK_DATA_HINTS = Object.keys(mockDataFunctions).map(key => `$${key}`); + +// Constants for word pattern matching +const WORD_PATTERN = /[\w.$-/]/; +const VARIABLE_PATTERN = /\{\{([\w$.-]*)$/; +const NON_CHARACTER_KEYS = /^(?!Shift|Tab|Enter|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Meta|Alt|Home|End\s)\w*/; + +/** + * Generate progressive hints for a given full hint + * @param {string} fullHint - The complete hint string + * @returns {string[]} Array of progressive hints + */ +const generateProgressiveHints = (fullHint) => { + const parts = fullHint.split('.'); + const progressiveHints = []; + + for (let i = 1; i <= parts.length; i++) { + progressiveHints.push(parts.slice(0, i).join('.')); + } + + return progressiveHints; +}; + +/** + * Check if a variable key should be skipped + * @param {string} key - The variable key to check + * @returns {boolean} True if the key should be skipped + */ +const shouldSkipVariableKey = (key) => { + return key === 'pathParams' || key === 'maskedEnvVariables' || key === 'process'; +}; + +/** + * Transform variables object into flat hint list + * @param {Object} allVariables - All available variables + * @returns {string[]} Array of variable hints + */ +const transformVariablesToHints = (allVariables = {}) => { + const hints = []; + + // Process all variables without type-specific handling + Object.keys(allVariables).forEach(key => { + if (!shouldSkipVariableKey(key)) { + hints.push(key); } - - const list = (options && options.list) || []; - const re = new RegExp(word.source, 'g'); - for (let dir = -1; dir <= 1; dir += 2) { - let line = cur.line, - endLine = Math.min(Math.max(line + dir * 500, editor.firstLine()), editor.lastLine()) + dir; - for (; line != endLine; line += dir) { - let text = editor.getLine(line), - m; - while ((m = re.exec(text))) { - if (line == cur.line && curWord.length < 3) continue; - list.push(...wordlist.filter((el) => el.toLowerCase().startsWith(curWord.toLowerCase()))); - } - } - } - return { list: [...new Set(list)], from: CodeMirror.Pos(cur.line, start), to: CodeMirror.Pos(cur.line, end) }; }); + + // Handle process environment variables + if (allVariables.process && allVariables.process.env) { + Object.keys(allVariables.process.env).forEach(key => { + hints.push(`process.env.${key}`); + }); + } + + return hints; +}; + +/** + * Add API hints to categorized hints based on showHintsFor configuration + * @param {Set} apiHints - Set to add API hints to + * @param {string[]} showHintsFor - Array of hint types to show + */ +const addApiHintsToSet = (apiHints, showHintsFor) => { + const apiTypes = ['req', 'res', 'bru']; + + apiTypes.forEach(apiType => { + if (showHintsFor.includes(apiType)) { + STATIC_API_HINTS[apiType].forEach(hint => { + generateProgressiveHints(hint).forEach(h => apiHints.add(h)); + }); + } + }); +}; + +/** + * Add variable hints to categorized hints + * @param {Set} variableHints - Set to add variable hints to + * @param {Object} allVariables - All available variables + */ +const addVariableHintsToSet = (variableHints, allVariables) => { + // Add mock data hints + MOCK_DATA_HINTS.forEach(hint => { + generateProgressiveHints(hint).forEach(h => variableHints.add(h)); + }); + + // Add variable hints with progressive hints + const variableHintsList = transformVariablesToHints(allVariables); + variableHintsList.forEach(hint => { + generateProgressiveHints(hint).forEach(h => variableHints.add(h)); + }); +}; + +/** + * Add custom hints to categorized hints + * @param {Set} anywordHints - Set to add custom hints to + * @param {string[]} customHints - Array of custom hints + */ +const addCustomHintsToSet = (anywordHints, customHints) => { + if (customHints && Array.isArray(customHints)) { + customHints.forEach(hint => { + generateProgressiveHints(hint).forEach(h => anywordHints.add(h)); + }); + } +}; + +/** + * Build categorized hints list from all sources + * @param {Object} allVariables - All available variables + * @param {string[]} anywordAutocompleteHints - Custom autocomplete hints + * @param {Object} options - Configuration options + * @returns {Object} Categorized hints object + */ +const buildCategorizedHintsList = (allVariables = {}, anywordAutocompleteHints = [], options = {}) => { + const categorizedHints = { + api: new Set(), + variables: new Set(), + anyword: new Set() + }; + + const showHintsFor = options.showHintsFor || []; + + // Add different types of hints + addApiHintsToSet(categorizedHints.api, showHintsFor); + addVariableHintsToSet(categorizedHints.variables, allVariables); + addCustomHintsToSet(categorizedHints.anyword, anywordAutocompleteHints); + + return { + api: Array.from(categorizedHints.api).sort(), + variables: Array.from(categorizedHints.variables).sort(), + anyword: Array.from(categorizedHints.anyword).sort() + }; +}; + +/** + * Calculate replacement positions for variable context + * @param {Object} cursor - Current cursor position + * @param {Object} startPos - Start position of variable + * @param {string} wordMatch - The matched word + * @returns {Object} From and to positions for replacement + */ +const calculateVariableReplacementPositions = (cursor, startPos, wordMatch) => { + let replaceFrom, replaceTo; + + if (wordMatch.endsWith('.')) { + replaceFrom = cursor; + replaceTo = cursor; + } else { + const lastDotIndex = wordMatch.lastIndexOf('.'); + if (lastDotIndex !== -1) { + replaceFrom = { line: cursor.line, ch: startPos.ch + lastDotIndex + 1 }; + replaceTo = cursor; + } else { + replaceFrom = startPos; + replaceTo = cursor; + } + } + + return { replaceFrom, replaceTo }; +}; + +/** + * Calculate replacement positions for regular word context + * @param {Object} cursor - Current cursor position + * @param {number} start - Start position of word + * @param {number} end - End position of word + * @param {string} word - The matched word + * @returns {Object} From and to positions for replacement + */ +const calculateWordReplacementPositions = (cursor, start, end, word) => { + let replaceFrom, replaceTo; + + if (word.endsWith('.')) { + replaceFrom = { line: cursor.line, ch: end }; + replaceTo = cursor; + } else { + const lastDotIndex = word.lastIndexOf('.'); + if (lastDotIndex !== -1) { + replaceFrom = { line: cursor.line, ch: start + lastDotIndex + 1 }; + replaceTo = { line: cursor.line, ch: end }; + } else { + replaceFrom = { line: cursor.line, ch: start }; + replaceTo = { line: cursor.line, ch: end }; + } + } + + return { replaceFrom, replaceTo }; +}; + +/** + * Determine context based on word prefix + * @param {string} word - The word to analyze + * @returns {string} The determined context + */ +const determineWordContext = (word) => { + if (word.startsWith('req') || word.startsWith('res') || word.startsWith('bru')) { + return 'api'; + } + return 'anyword'; +}; + +/** + * Extract word from current line with boundaries + * @param {string} currentLine - The current line content + * @param {number} cursorPosition - Current cursor position + * @returns {Object|null} Word information or null if no word found + */ +const extractWordFromLine = (currentLine, cursorPosition) => { + let start = cursorPosition; + let end = start; + + while (end < currentLine.length && WORD_PATTERN.test(currentLine.charAt(end))) { + ++end; + } + while (start && WORD_PATTERN.test(currentLine.charAt(start - 1))) { + --start; + } + + if (start === end) { + return null; + } + + return { + word: currentLine.slice(start, end), + start, + end + }; +}; + +/** + * Get current word being typed at cursor position with context information + * @param {Object} cm - CodeMirror instance + * @returns {Object|null} Word information with context or null + */ +const getCurrentWordWithContext = (cm) => { + const cursor = cm.getCursor(); + const currentLine = cm.getLine(cursor.line); + const currentString = cm.getRange({ line: cursor.line, ch: 0 }, cursor); + + // Check for variable pattern {{word + const variableMatch = currentString.match(VARIABLE_PATTERN); + if (variableMatch) { + const wordMatch = variableMatch[1]; + const startPos = { line: cursor.line, ch: currentString.lastIndexOf('{{') + 2 }; + const { replaceFrom, replaceTo } = calculateVariableReplacementPositions(cursor, startPos, wordMatch); + + return { + word: wordMatch, + from: replaceFrom, + to: replaceTo, + context: 'variables', + requiresBraces: true + }; + } + + // Check for regular word + const wordInfo = extractWordFromLine(currentLine, cursor.ch); + if (!wordInfo) { + return null; + } + + const { word, start, end } = wordInfo; + const { replaceFrom, replaceTo } = calculateWordReplacementPositions(cursor, start, end, word); + const context = determineWordContext(word); + + return { + word, + from: replaceFrom, + to: replaceTo, + context, + requiresBraces: false + }; +}; + +/** + * Extract next segment suggestions from filtered hints + * @param {string[]} filteredHints - Pre-filtered hints + * @param {string} currentInput - Current user input + * @returns {string[]} Array of suggestion segments + */ +const extractNextSegmentSuggestions = (filteredHints, currentInput) => { + const suggestions = new Set(); + + filteredHints.forEach(hint => { + if (!hint.toLowerCase().startsWith(currentInput.toLowerCase())) { + return; + } + + // Handle exact match case + if (hint.toLowerCase() === currentInput.toLowerCase()) { + suggestions.add(hint.substring(hint.lastIndexOf('.') + 1)); + return; + } + + const inputLength = currentInput.length; + + if (currentInput.endsWith('.')) { + // Show next segment after the dot + const afterDot = hint.substring(inputLength); + const nextDot = afterDot.indexOf('.'); + const segment = nextDot === -1 ? afterDot : afterDot.substring(0, nextDot); + suggestions.add(segment); + } else { + // Show complete current segment + const lastDotInInput = currentInput.lastIndexOf('.'); + const currentSegmentStart = lastDotInInput + 1; + const nextDotAfterInput = hint.indexOf('.', currentSegmentStart); + const segment = nextDotAfterInput === -1 + ? hint.substring(currentSegmentStart) + : hint.substring(currentSegmentStart, nextDotAfterInput); + suggestions.add(segment); + } + }); + + return Array.from(suggestions).sort(); +}; + +/** + * Extract the relevant part of hints based on user input + * @param {string[]} filteredHints - Pre-filtered hints + * @param {string} currentInput - Current user input + * @returns {string[]} Array of hint parts + */ +const getHintParts = (filteredHints, currentInput) => { + if (!filteredHints || filteredHints.length === 0) { + return []; + } + + return extractNextSegmentSuggestions(filteredHints, currentInput); +}; + +/** + * Get allowed hints based on context and configuration + * @param {Object} categorizedHints - All categorized hints + * @param {string} context - Current context + * @param {string[]} showHintsFor - Allowed hint types + * @returns {string[]} Array of allowed hints + */ +const getAllowedHintsByContext = (categorizedHints, context, showHintsFor) => { + let allowedHints = []; + + if (context === 'variables' && showHintsFor.includes('variables')) { + allowedHints = [...categorizedHints.variables]; + } else if (context === 'api') { + const hasApiHints = showHintsFor.some(hint => ['req', 'res', 'bru'].includes(hint)); + if (hasApiHints) { + allowedHints = [...categorizedHints.api]; + } + } else if (context === 'anyword') { + allowedHints = [...categorizedHints.anyword]; + } + + return allowedHints; +}; + +/** + * Filter hints based on current word and allowed hint types + * @param {Object} categorizedHints - All categorized hints + * @param {string} currentWord - Current word being typed + * @param {string} context - Current context + * @param {string[]} showHintsFor - Allowed hint types + * @returns {string[]} Filtered hints + */ +const filterHintsByContext = (categorizedHints, currentWord, context, showHintsFor = []) => { + if (!currentWord) { + return []; + } + + const allowedHints = getAllowedHintsByContext(categorizedHints, context, showHintsFor); + + const filtered = allowedHints.filter(hint => { + return hint.toLowerCase().startsWith(currentWord.toLowerCase()); + }); + + const hintParts = getHintParts(filtered, currentWord); + + return hintParts.slice(0, 50); +}; + +/** + * Create hint list for variables context + * @param {string[]} filteredHints - Filtered hints + * @param {Object} from - Start position + * @param {Object} to - End position + * @returns {Object} Hint object with list and positions + */ +const createVariableHintList = (filteredHints, from, to) => { + const hintList = filteredHints.map(hint => ({ + text: hint, + displayText: hint + })); + + return { + list: hintList, + from, + to + }; +}; + +/** + * Create hint list for non-variable contexts + * @param {string[]} filteredHints - Filtered hints + * @param {Object} from - Start position + * @param {Object} to - End position + * @returns {Object} Hint object with list and positions + */ +const createStandardHintList = (filteredHints, from, to) => { + return { + list: filteredHints, + from, + to + }; +}; + +/** + * Bruno AutoComplete Helper - Main function with context awareness + * @param {Object} cm - CodeMirror instance + * @param {Object} allVariables - All available variables + * @param {string[]} anywordAutocompleteHints - Custom autocomplete hints + * @param {Object} options - Configuration options + * @returns {Object|null} Hint object or null + */ +export const getAutoCompleteHints = (cm, allVariables = {}, anywordAutocompleteHints = [], options = {}) => { + if (!allVariables) { + return null; + } + + const wordInfo = getCurrentWordWithContext(cm); + if (!wordInfo) { + return null; + } + + const { word, from, to, context, requiresBraces } = wordInfo; + const showHintsFor = options.showHintsFor || []; + + // Check if this context requires braces but we're not in a brace context + if (context === 'variables' && !requiresBraces) { + return null; + } + + const categorizedHints = buildCategorizedHintsList(allVariables, anywordAutocompleteHints, options); + const filteredHints = filterHintsByContext(categorizedHints, word, context, showHintsFor); + + if (filteredHints.length === 0) { + return null; + } + + if (context === 'variables') { + return createVariableHintList(filteredHints, from, to); + } + + return createStandardHintList(filteredHints, from, to); +}; + +/** + * Handle click events for autocomplete + * @param {Object} cm - CodeMirror instance + * @param {Object} options - Configuration options + */ +const handleClickForAutocomplete = (cm, options) => { + const allVariables = options.getAllVariables?.() || {}; + const anywordAutocompleteHints = options.getAnywordAutocompleteHints?.() || []; + const showHintsFor = options.showHintsFor || []; + + // Build all available hints + const categorizedHints = buildCategorizedHintsList(allVariables, anywordAutocompleteHints, options); + + // Combine all hints based on showHintsFor configuration + let allHints = []; + + // Add API hints if enabled + const hasApiHints = showHintsFor.some(hint => ['req', 'res', 'bru'].includes(hint)); + if (hasApiHints) { + allHints = [...allHints, ...categorizedHints.api]; + } + + // Add variable hints if enabled + if (showHintsFor.includes('variables')) { + allHints = [...allHints, ...categorizedHints.variables]; + } + + // Add anyword hints (always included) + allHints = [...allHints, ...categorizedHints.anyword]; + + // Remove duplicates and sort + allHints = [...new Set(allHints)].sort(); + + if (allHints.length === 0) { + return; + } + + const cursor = cm.getCursor(); + + if (cursor.ch > 0) return; + + // Defer showHint to ensure editor is focused + setTimeout(() => { + cm.showHint({ + hint: () => ({ + list: allHints, + from: cursor, + to: cursor + }), + completeSingle: false + }); + }, 0); +}; + +/** + * Handle keyup events for autocomplete + * @param {Object} cm - CodeMirror instance + * @param {Event} event - The keyup event + * @param {Object} options - Configuration options + */ +const handleKeyupForAutocomplete = (cm, event, options) => { + // Skip non-character keys + if (!NON_CHARACTER_KEYS.test(event?.key)) { + return; + } + + const allVariables = options.getAllVariables?.() || {}; + const anywordAutocompleteHints = options.getAnywordAutocompleteHints?.() || []; + const hints = getAutoCompleteHints(cm, allVariables, anywordAutocompleteHints, options); + + if (!hints) { + if (cm.state.completionActive) { + cm.state.completionActive.close(); + } + return; + } + + cm.showHint({ + hint: () => hints, + completeSingle: false + }); +}; + +/** + * Setup Bruno AutoComplete Helper on a CodeMirror editor + * @param {Object} editor - CodeMirror editor instance + * @param {Object} options - Configuration options + * @returns {Function} Cleanup function + */ +export const setupAutoComplete = (editor, options = {}) => { + if (!editor) { + return; + } + + const keyupHandler = (cm, event) => { + handleKeyupForAutocomplete(cm, event, options); + }; + + editor.on('keyup', keyupHandler); + + const clickHandler = (cm) => { + // Only show hints on click if the option is enabled and there's no active completion + if (options.showHintsOnClick) { + handleClickForAutocomplete(cm, options); + } + }; + + // Add click handler if showHintsOnClick is enabled + if (options.showHintsOnClick) { + editor.on('mousedown', clickHandler); + } + + return () => { + editor.off('keyup', keyupHandler); + if (options.showHintsOnClick) { + editor.off('mousedown', clickHandler); + } + }; +}; + +// Initialize autocomplete command if not already present +if (!CodeMirror.commands.autocomplete) { CodeMirror.commands.autocomplete = (cm, hint, options) => { cm.showHint({ hint, ...options }); }; -} +} \ No newline at end of file diff --git a/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js b/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js new file mode 100644 index 000000000..5a8d984c1 --- /dev/null +++ b/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js @@ -0,0 +1,707 @@ +const { describe, it, expect, jest, beforeEach, afterEach } = require('@jest/globals'); + +const _mockedCodemirror = { + commands: {}, + getCursor: jest.fn(), + getLine: jest.fn(), + getRange: jest.fn(), + showHint: jest.fn(), + on: jest.fn(), + off: jest.fn(), + state: {} +}; + +jest.mock('codemirror', () => { + return _mockedCodemirror; +}); + +// Import the functions to test +import { + getAutoCompleteHints, + setupAutoComplete +} from './autocomplete'; + +describe('Bruno Autocomplete', () => { + let mockedCodemirror; + + beforeEach(() => { + mockedCodemirror = _mockedCodemirror; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getAutoCompleteHints', () => { + describe('Variable autocomplete', () => { + it('should provide variable hints when typing inside double curly braces', () => { + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 9 }); + mockedCodemirror.getLine.mockReturnValue('{{envVar}}'); + mockedCodemirror.getRange.mockReturnValue('{{envVar'); + const allVariables = { + envVar1: 'value1', + envVar2: 'value2', + }; + + const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], { + showHintsFor: ['variables'] + }); + + expect(result).toBeTruthy(); + expect(result.list).toEqual( + expect.arrayContaining([ + expect.objectContaining({ text: 'envVar1', displayText: 'envVar1' }), + expect.objectContaining({ text: 'envVar2', displayText: 'envVar2' }) + ]) + ); + }); + + it('should include mock data functions with $ prefix', () => { + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 9 }); + mockedCodemirror.getRange.mockReturnValue('{{$randomI'); + + const result = getAutoCompleteHints(mockedCodemirror, {}, [], { + showHintsFor: ['variables'] + }); + + expect(result.list).toEqual( + expect.arrayContaining([ + expect.objectContaining({ displayText: '$randomInt' }) + ]) + ); + }); + + it('should handle process environment variables', () => { + const allVariables = { + process: { + env: { + NODE_ENV: 'development', + API_URL: 'https://api.example.com' + } + } + }; + + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 14 }); + mockedCodemirror.getRange.mockReturnValue('{{process.env.N'); + + const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], { + showHintsFor: ['variables'] + }); + + expect(result).toBeTruthy(); + expect(result.list).toEqual( + expect.arrayContaining([ + expect.objectContaining({ displayText: 'NODE_ENV' }) + ]) + ); + }); + + it('should skip special internal keys', () => { + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 10 }); + mockedCodemirror.getRange.mockReturnValue('{{path'); + + const allVariables = { + pathParams: { id: '123' }, + maskedEnvVariables: { secret: '***' }, + path: 'value' + }; + + const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], { + showHintsFor: ['variables'] + }); + + expect(result).toBeTruthy(); + expect(result.list).toEqual( + expect.arrayContaining([ + expect.objectContaining({ displayText: 'path' }) + ]) + ); + expect(result.list).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ displayText: 'pathParams' }) + ]) + ); + }); + + it('should handle nested object variables', () => { + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 12 }); + mockedCodemirror.getRange.mockReturnValue('{{config.api.'); + + const allVariables = { + 'config.api.url': 'https://echo.usebruno.com', + 'config.api.client_id': 'client_id', + 'config.api.client_secret': 'client_secret', + 'config.app.name': 'bruno' + }; + + const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], { + showHintsFor: ['variables'] + }); + + expect(result).toBeTruthy(); + expect(result.list).toEqual( + expect.arrayContaining([ + expect.objectContaining({ displayText: 'url' }), + expect.objectContaining({ displayText: 'client_id' }), + expect.objectContaining({ displayText: 'client_secret' }) + ]) + ); + }); + }); + + describe('API object context (req, res, bru)', () => { + const testCases = [ + { + name: 'req object', + input: 'req.', + expected: ['url', 'method', 'headers', 'body', 'timeout'] + }, + { + name: 'res object', + input: 'res.', + expected: ['status', 'statusText', 'headers', 'body', 'responseTime'] + }, + { + name: 'bru object', + input: 'bru.', + expected: ['cwd()', 'getEnvName()', 'getProcessEnv(key)', 'hasEnvVar(key)', 'getEnvVar(key)'] + } + ]; + + testCases.forEach(({ name, input, expected }) => { + it(`should provide ${name} hints`, () => { + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: input.length }); + mockedCodemirror.getLine.mockReturnValue(input); + mockedCodemirror.getRange.mockReturnValue(input); + + const result = getAutoCompleteHints(mockedCodemirror, {}, [], { + showHintsFor: ['req', 'res', 'bru'] + }); + + expect(result).toBeTruthy(); + expect(result.list).toEqual(expect.arrayContaining(expected)); + }); + }); + + it('should provide method hints for nested req objects', () => { + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 7 }); + mockedCodemirror.getLine.mockReturnValue('req.get'); + mockedCodemirror.getRange.mockReturnValue('req.get'); + + const result = getAutoCompleteHints(mockedCodemirror, {}, [], { + showHintsFor: ['req'] + }); + + expect(result).toBeTruthy(); + expect(result.list).toEqual( + expect.arrayContaining([ + 'getUrl()', + 'getMethod()', + 'getAuthMode()', + 'getHeader(name)', + 'getHeaders()', + 'getBody()', + 'getTimeout()', + 'getExecutionMode()', + 'getName()' + ]) + ); + }); + + it('should handle bru.runner sub-object', () => { + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 11 }); + mockedCodemirror.getLine.mockReturnValue('bru.runner.'); + mockedCodemirror.getRange.mockReturnValue('bru.runner.'); + + const result = getAutoCompleteHints(mockedCodemirror, {}, [], { + showHintsFor: ['bru'] + }); + + expect(result).toBeTruthy(); + expect(result.list).toEqual( + expect.arrayContaining([ + 'setNextRequest(requestName)', + 'skipRequest()', + 'stopExecution()' + ]) + ); + }); + }); + + describe('Custom hints and anyword context', () => { + it('should provide custom anyword hints', () => { + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 7 }); + mockedCodemirror.getLine.mockReturnValue('Content-'); + mockedCodemirror.getRange.mockReturnValue('Content-'); + + const customHints = ['Content-Type', 'Content-Encoding', 'Content-Length']; + + const result = getAutoCompleteHints(mockedCodemirror, {}, customHints, { + showHintsFor: ['variables'] + }); + + expect(result).toBeTruthy(); + expect(result.list).toEqual( + expect.arrayContaining(['Content-Type', 'Content-Encoding', 'Content-Length']) + ); + }); + + it('should handle progressive hints for custom hints', () => { + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 6 }); + mockedCodemirror.getLine.mockReturnValue('utils.'); + mockedCodemirror.getRange.mockReturnValue('utils.'); + + const customHints = ['utils.string.trim', 'utils.string.capitalize', 'utils.array.map']; + + const result = getAutoCompleteHints(mockedCodemirror, {}, customHints, { + showHintsFor: ['variables'] + }); + + expect(result).toBeTruthy(); + expect(result.list).toEqual( + expect.arrayContaining(['string', 'array']) + ); + }); + }); + + describe('Filtering and options', () => { + beforeEach(() => { + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 4 }); + mockedCodemirror.getLine.mockReturnValue('req.'); + mockedCodemirror.getRange.mockReturnValue('req.'); + }); + + it('should respect showHintsFor option for excluding hints', () => { + const options = { showHintsFor: ['res', 'bru'] }; + const result = getAutoCompleteHints(mockedCodemirror, {}, [], options); + + expect(result).toBeNull(); + }); + + it('should show hints when included in showHintsFor', () => { + const options = { showHintsFor: ['req'] }; + const result = getAutoCompleteHints(mockedCodemirror, {}, [], options); + + expect(result).toBeTruthy(); + expect(result.list).toEqual( + expect.arrayContaining(['url', 'method']) + ); + }); + + it('should filter variables based on showHintsFor', () => { + mockedCodemirror.getLine.mockReturnValue('{{varNa}}'); + mockedCodemirror.getRange.mockReturnValue('{{varNa'); + + const allVariables = { envVar1: 'value1' }; + const options = { showHintsFor: ['req', 'res', 'bru'] }; + + const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], options); + + expect(result).toBeNull(); + }); + + it('should limit results to 50 hints', () => { + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 10 }); + mockedCodemirror.getLine.mockReturnValue('{{varName}}'); + mockedCodemirror.getRange.mockReturnValue('{{v'); + + const allVariables = {}; + for (let i = 0; i < 100; i++) { + allVariables[`var${i}`] = `value${i}`; + } + + const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], { + showHintsFor: ['variables'] + }); + + expect(result).toBeTruthy(); + expect(result.list.length).toBeLessThanOrEqual(50); + }); + + it('should sort hints alphabetically', () => { + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 10 }); + mockedCodemirror.getLine.mockReturnValue('{{v.'); + mockedCodemirror.getRange.mockReturnValue('{{v.'); + + const allVariables = { + 'v.zebra': 'value1', + 'v.apple': 'value2', + 'v.banana': 'value3' + }; + + const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], { + showHintsFor: ['variables'] + }); + + expect(result).toBeTruthy(); + const displayTexts = result.list.map(item => + typeof item === 'object' ? item.displayText : item + ); + + const userVars = displayTexts.filter(text => !text.startsWith('$')); + expect(userVars).toEqual(['apple', 'banana', 'zebra']); + }); + }); + + describe('Edge cases', () => { + it('should return null when no word is found at cursor', () => { + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 0 }); + mockedCodemirror.getLine.mockReturnValue(' '); + mockedCodemirror.getRange.mockReturnValue(''); + + const result = getAutoCompleteHints(mockedCodemirror, {}, []); + + expect(result).toBeNull(); + }); + + it('should handle empty or null variables', () => { + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 10 }); + mockedCodemirror.getLine.mockReturnValue('{{varName}}'); + mockedCodemirror.getRange.mockReturnValue('{{varName'); + + const emptyResult = getAutoCompleteHints(mockedCodemirror, {}, []); + const nullResult = getAutoCompleteHints(mockedCodemirror, null, []); + + expect(emptyResult).toBeNull(); + expect(nullResult).toBeNull(); + }); + + it('should handle cursor at end of line', () => { + const line = 'req.getHea'; + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: line.length }); + mockedCodemirror.getLine.mockReturnValue(line); + mockedCodemirror.getRange.mockReturnValue(line); + + const result = getAutoCompleteHints(mockedCodemirror, {}, [], { + showHintsFor: ['req'] + }); + + expect(result).toBeTruthy(); + expect(result.list).toEqual( + expect.arrayContaining(['getHeader(name)', 'getHeaders()']) + ); + }); + + it('should handle case-insensitive matching', () => { + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 10 }); + mockedCodemirror.getLine.mockReturnValue('{{varName}}'); + mockedCodemirror.getRange.mockReturnValue('{{var'); + + const allVariables = { + variable1: 'value1', + Variable2: 'value2', + VARIABLE3: 'value3' + }; + + const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], { + showHintsFor: ['variables'] + }); + + expect(result).toBeTruthy(); + expect(result.list.length).toBe(3); + }); + }); + }); + + describe('setupAutoComplete', () => { + let mockGetAllVariables; + let cleanupFn; + + beforeEach(() => { + mockGetAllVariables = jest.fn(() => ({ })); + mockedCodemirror.state = {}; + }); + + afterEach(() => { + if (cleanupFn) { + cleanupFn(); + } + }); + + describe('Setup and cleanup', () => { + it('should setup keyup event listener and return cleanup function', () => { + const options = { getAllVariables: mockGetAllVariables }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + + expect(mockedCodemirror.on).toHaveBeenCalledWith('keyup', expect.any(Function)); + expect(cleanupFn).toBeInstanceOf(Function); + + cleanupFn(); + expect(mockedCodemirror.off).toHaveBeenCalledWith('keyup', expect.any(Function)); + }); + + it('should not setup if editor is null', () => { + const result = setupAutoComplete(null, { getAllVariables: mockGetAllVariables }); + + expect(result).toBeUndefined(); + expect(mockedCodemirror.on).not.toHaveBeenCalled(); + }); + }); + + describe('Event handling', () => { + it('should trigger hints on character key press', () => { + const options = { + getAllVariables: mockGetAllVariables, + showHintsFor: ['req'] + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + const keyupHandler = mockedCodemirror.on.mock.calls[0][1]; + + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 4 }); + mockedCodemirror.getLine.mockReturnValue('req.'); + mockedCodemirror.getRange.mockReturnValue('req.'); + + const mockEvent = { key: 'a' }; + keyupHandler(mockedCodemirror, mockEvent); + + expect(mockGetAllVariables).toHaveBeenCalled(); + expect(mockedCodemirror.showHint).toHaveBeenCalled(); + }); + + it('should not trigger hints on non-character keys', () => { + const options = { getAllVariables: mockGetAllVariables }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + const keyupHandler = mockedCodemirror.on.mock.calls[0][1]; + + const nonCharacterKeys = ['Shift', 'Tab', 'Enter', 'Escape', 'ArrowUp', 'ArrowDown', 'Meta']; + + nonCharacterKeys.forEach(key => { + const mockEvent = { key }; + keyupHandler(mockedCodemirror, mockEvent); + }); + + expect(mockedCodemirror.showHint).not.toHaveBeenCalled(); + }); + + it('should close existing completion when no hints available', () => { + const options = { getAllVariables: mockGetAllVariables }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + const keyupHandler = mockedCodemirror.on.mock.calls[0][1]; + + const mockCompletion = { close: jest.fn() }; + mockedCodemirror.state.completionActive = mockCompletion; + + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 0 }); + mockedCodemirror.getLine.mockReturnValue(' '); + mockedCodemirror.getRange.mockReturnValue(''); + + const mockEvent = { key: 'a' }; + keyupHandler(mockedCodemirror, mockEvent); + + expect(mockCompletion.close).toHaveBeenCalled(); + }); + + it('should pass options to getAutoCompleteHints', () => { + const options = { + getAllVariables: mockGetAllVariables, + showHintsFor: ['req'] + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + const keyupHandler = mockedCodemirror.on.mock.calls[0][1]; + + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 4 }); + mockedCodemirror.getLine.mockReturnValue('req.'); + mockedCodemirror.getRange.mockReturnValue('req.'); + + const mockEvent = { key: 'a' }; + keyupHandler(mockedCodemirror, mockEvent); + + expect(mockedCodemirror.showHint).toHaveBeenCalledWith({ + hint: expect.any(Function), + completeSingle: false + }); + }); + }); + + describe('Click event handling (showHintsOnClick)', () => { + it('should setup mousedown event listener when showHintsOnClick is enabled', () => { + const options = { + getAllVariables: mockGetAllVariables, + showHintsOnClick: true + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + + expect(mockedCodemirror.on).toHaveBeenCalledWith('keyup', expect.any(Function)); + expect(mockedCodemirror.on).toHaveBeenCalledWith('mousedown', expect.any(Function)); + expect(mockedCodemirror.on).toHaveBeenCalledTimes(2); + }); + + it('should not setup mousedown event listener when showHintsOnClick is disabled', () => { + const options = { + getAllVariables: mockGetAllVariables, + showHintsOnClick: false + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + + expect(mockedCodemirror.on).toHaveBeenCalledWith('keyup', expect.any(Function)); + expect(mockedCodemirror.on).toHaveBeenCalledTimes(1); + }); + + it('should not setup mousedown event listener when showHintsOnClick is undefined', () => { + const options = { + getAllVariables: mockGetAllVariables + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + + expect(mockedCodemirror.on).toHaveBeenCalledWith('keyup', expect.any(Function)); + expect(mockedCodemirror.on).toHaveBeenCalledTimes(1); + }); + + it('should show hints on click when showHintsOnClick is enabled', () => { + jest.useFakeTimers(); + + const mockGetAnywordAutocompleteHints = jest.fn(() => ['Content-Type', 'Accept']); + const options = { + getAllVariables: mockGetAllVariables, + getAnywordAutocompleteHints: mockGetAnywordAutocompleteHints, + showHintsOnClick: true, + showHintsFor: ['req', 'variables'] + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + + // Find the click handler (mousedown event) + const clickHandler = mockedCodemirror.on.mock.calls.find(call => call[0] === 'mousedown')[1]; + + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 0 }); + + clickHandler(mockedCodemirror); + + // Run all timers to execute the setTimeout + jest.runAllTimers(); + + expect(mockGetAllVariables).toHaveBeenCalled(); + expect(mockGetAnywordAutocompleteHints).toHaveBeenCalled(); + expect(mockedCodemirror.showHint).toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it('should not show hints on click when showHintsOnClick is disabled', () => { + const options = { + getAllVariables: mockGetAllVariables, + showHintsOnClick: false + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + + // There should be no mousedown handler + const mousedownCalls = mockedCodemirror.on.mock.calls.filter(call => call[0] === 'mousedown'); + expect(mousedownCalls).toHaveLength(0); + }); + + it('should cleanup mousedown event listener when showHintsOnClick was enabled', () => { + const options = { + getAllVariables: mockGetAllVariables, + showHintsOnClick: true + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + + cleanupFn(); + + expect(mockedCodemirror.off).toHaveBeenCalledWith('keyup', expect.any(Function)); + expect(mockedCodemirror.off).toHaveBeenCalledWith('mousedown', expect.any(Function)); + expect(mockedCodemirror.off).toHaveBeenCalledTimes(2); + }); + + it('should only cleanup keyup event listener when showHintsOnClick was disabled', () => { + const options = { + getAllVariables: mockGetAllVariables, + showHintsOnClick: false + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + + cleanupFn(); + + expect(mockedCodemirror.off).toHaveBeenCalledWith('keyup', expect.any(Function)); + expect(mockedCodemirror.off).toHaveBeenCalledTimes(1); + }); + + it('should show all available hints on click based on showHintsFor configuration', () => { + jest.useFakeTimers(); + + const mockGetAnywordAutocompleteHints = jest.fn(() => ['Content-Type', 'Accept']); + const options = { + getAllVariables: mockGetAllVariables.mockReturnValue({ + envVar1: 'value1', + envVar2: 'value2' + }), + getAnywordAutocompleteHints: mockGetAnywordAutocompleteHints, + showHintsOnClick: true, + showHintsFor: ['req', 'variables'] + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + + // Find the click handler (mousedown event) + const clickHandler = mockedCodemirror.on.mock.calls.find(call => call[0] === 'mousedown')[1]; + + const mockCursor = { line: 0, ch: 0 }; + mockedCodemirror.getCursor.mockReturnValue(mockCursor); + + clickHandler(mockedCodemirror); + + // Run all timers to execute the setTimeout + jest.runAllTimers(); + + expect(mockedCodemirror.showHint).toHaveBeenCalledWith({ + hint: expect.any(Function), + completeSingle: false + }); + + // Verify the hint function returns the expected structure + const hintCall = mockedCodemirror.showHint.mock.calls[0][0]; + const hintResult = hintCall.hint(); + + expect(hintResult).toEqual({ + list: expect.any(Array), + from: mockCursor, + to: mockCursor + }); + expect(hintResult.list.length).toBeGreaterThan(0); + + jest.useRealTimers(); + }); + + it('should not show hints on click when no hints are available', () => { + const options = { + getAllVariables: mockGetAllVariables.mockReturnValue({}), + getAnywordAutocompleteHints: jest.fn(() => []), + showHintsOnClick: true, + showHintsFor: [] + }; + cleanupFn = setupAutoComplete(mockedCodemirror, options); + + // Find the click handler (mousedown event) + const clickHandler = mockedCodemirror.on.mock.calls.find(call => call[0] === 'mousedown')[1]; + + mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 0 }); + + clickHandler(mockedCodemirror); + + expect(mockedCodemirror.showHint).not.toHaveBeenCalled(); + }); + }); + }); + + describe('CodeMirror integration', () => { + it('should define autocomplete command if not exists', () => { + delete mockedCodemirror.commands.autocomplete; + + jest.isolateModules(() => { + require('./autocomplete'); + }); + + expect(mockedCodemirror.commands.autocomplete).toBeDefined(); + expect(typeof mockedCodemirror.commands.autocomplete).toBe('function'); + }); + + it('should not override existing autocomplete command', () => { + const existingCommand = jest.fn(); + mockedCodemirror.commands.autocomplete = existingCommand; + + jest.isolateModules(() => { + require('./autocomplete'); + }); + + expect(mockedCodemirror.commands.autocomplete).toBe(existingCommand); + }); + }); +}); \ No newline at end of file diff --git a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js index cef99a22d..52614703f 100644 --- a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js +++ b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js @@ -16,23 +16,8 @@ if (!SERVER_RENDERED) { CodeMirror = require('codemirror'); const renderVarInfo = (token, options, cm, pos) => { - const str = token.string || ''; - if (!str || !str.length || typeof str !== 'string') { - return; - } - - // str is of format {{variableName}} or :variableName, extract variableName - let variableName; - let variableValue; - - if (str.startsWith('{{')) { - variableName = str.replace('{{', '').replace('}}', '').trim(); - variableValue = interpolate(get(options.variables, variableName), options.variables); - } else if (str.startsWith('/:')) { - variableName = str.replace('/:', '').trim(); - variableValue = - options.variables && options.variables.pathParams ? options.variables.pathParams[variableName] : undefined; - } + // Extract variable name and value based on token + const { variableName, variableValue } = extractVariableInfo(token.string, options.variables); if (variableValue === undefined) { return; @@ -41,11 +26,13 @@ if (!SERVER_RENDERED) { const into = document.createElement('div'); const descriptionDiv = document.createElement('div'); descriptionDiv.className = 'info-description'; + if (options?.variables?.maskedEnvVariables?.includes(variableName)) { descriptionDiv.appendChild(document.createTextNode('*****')); } else { descriptionDiv.appendChild(document.createTextNode(variableValue)); } + into.appendChild(descriptionDiv); return into; @@ -202,3 +189,29 @@ if (!SERVER_RENDERED) { CodeMirror.on(cm.getWrapperElement(), 'mouseout', onMouseOut); } } + +export const extractVariableInfo = (str, variables) => { + let variableName; + let variableValue; + + if (!str || !str.length || typeof str !== 'string') { + return { variableName, variableValue }; + } + + // Regex to match double brace variable syntax: {{variableName}} + const DOUBLE_BRACE_PATTERN = /\{\{([^}]+)\}\}/; + + if (DOUBLE_BRACE_PATTERN.test(str)) { + variableName = str.replace('{{', '').replace('}}', '').trim(); + variableValue = interpolate(get(variables, variableName), variables); + } else if (str.startsWith('/:')) { + variableName = str.replace('/:', '').trim(); + variableValue = variables?.pathParams?.[variableName]; + } else { + // direct variable reference (e.g., for numeric values in JSON mode or plain variable names) + variableName = str; + variableValue = interpolate(get(variables, variableName), variables); + } + + return { variableName, variableValue }; +}; \ No newline at end of file diff --git a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js new file mode 100644 index 000000000..5002097c2 --- /dev/null +++ b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js @@ -0,0 +1,227 @@ +import { interpolate } from '@usebruno/common'; +import { extractVariableInfo } from './brunoVarInfo'; + +// Mock the dependencies +jest.mock('@usebruno/common', () => ({ + interpolate: jest.fn() +})); + +describe('extractVariableInfo', () => { + let mockVariables; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup mock variables + mockVariables = { + apiKey: 'test-api-key-123', + baseUrl: 'https://api.example.com', + userId: 12345, + pathParams: { + id: 'user-123', + slug: 'test-post' + } + }; + + // Setup interpolate mock + interpolate.mockImplementation((value, variables) => { + if (typeof value === 'string' && value.includes('{{')) { + return value.replace(/\{\{(\w+)\}\}/g, (match, key) => variables[key] || match); + } + return value; + }); + }); + + describe('input validation', () => { + it('should return undefined for null input', () => { + const result = extractVariableInfo(null, mockVariables); + expect(result.variableName).toBeUndefined(); + expect(result.variableValue).toBeUndefined(); + }); + + it('should return undefined for undefined input', () => { + const result = extractVariableInfo(undefined, mockVariables); + expect(result.variableName).toBeUndefined(); + expect(result.variableValue).toBeUndefined(); + }); + + it('should return undefined for empty string', () => { + const result = extractVariableInfo('', mockVariables); + expect(result.variableName).toBeUndefined(); + expect(result.variableValue).toBeUndefined(); + }); + + it('should return undefined for non-string input', () => { + const result = extractVariableInfo(123, mockVariables); + expect(result.variableName).toBeUndefined(); + expect(result.variableValue).toBeUndefined(); + }); + + it('should return undefined for object input', () => { + const result = extractVariableInfo({}, mockVariables); + expect(result.variableName).toBeUndefined(); + expect(result.variableValue).toBeUndefined(); + }); + }); + + describe('double brace format ({{variableName}})', () => { + it('should parse double brace variables correctly', () => { + const result = extractVariableInfo('{{apiKey}}', mockVariables); + + expect(result).toEqual({ + variableName: 'apiKey', + variableValue: 'test-api-key-123' + }); + + expect(interpolate).toHaveBeenCalledWith('test-api-key-123', mockVariables); + }); + + it('should handle whitespace in double brace variables', () => { + const result = extractVariableInfo('{{ apiKey }}', mockVariables); + + expect(result).toEqual({ + variableName: 'apiKey', + variableValue: 'test-api-key-123' + }); + }); + + it('should return undefined variableValue for non-existent double brace variable', () => { + const result = extractVariableInfo('{{nonExistent}}', mockVariables); + + expect(result).toEqual({ + variableName: 'nonExistent', + variableValue: undefined + }); + }); + }); + + describe('path parameter format (/:variableName)', () => { + it('should parse path parameter variables correctly', () => { + const result = extractVariableInfo('/:id', mockVariables); + + expect(result).toEqual({ + variableName: 'id', + variableValue: 'user-123' + }); + }); + + it('should return undefined for non-existent path parameter', () => { + const result = extractVariableInfo('/:nonExistent', mockVariables); + + expect(result).toEqual({ + variableName: 'nonExistent', + variableValue: undefined + }); + }); + + it('should handle missing pathParams object', () => { + const variablesWithoutPathParams = { ...mockVariables }; + delete variablesWithoutPathParams.pathParams; + + const result = extractVariableInfo('/:id', variablesWithoutPathParams); + + expect(result).toEqual({ + variableName: 'id', + variableValue: undefined + }); + }); + + it('should handle null pathParams', () => { + const variablesWithNullPathParams = { ...mockVariables, pathParams: null }; + + const result = extractVariableInfo('/:id', variablesWithNullPathParams); + + expect(result).toEqual({ + variableName: 'id', + variableValue: undefined + }); + }); + }); + + describe('direct variable format', () => { + it('should parse direct variable names correctly', () => { + const result = extractVariableInfo('baseUrl', mockVariables); + + expect(result).toEqual({ + variableName: 'baseUrl', + variableValue: 'https://api.example.com' + }); + + expect(interpolate).toHaveBeenCalledWith('https://api.example.com', mockVariables); + }); + + it('should handle numeric variable values', () => { + const result = extractVariableInfo('userId', mockVariables); + + expect(result).toEqual({ + variableName: 'userId', + variableValue: 12345 + }); + }); + + it('should return undefined for non-existent direct variable', () => { + const result = extractVariableInfo('nonExistent', mockVariables); + + expect(result).toEqual({ + variableName: 'nonExistent', + variableValue: undefined + }); + }); + + it('should handle variables with special characters', () => { + mockVariables['special-var_name'] = 'special-var_value'; + + const result = extractVariableInfo('special-var_name', mockVariables); + + expect(result).toEqual({ + variableName: 'special-var_name', + variableValue: 'special-var_value' + }); + }); + }); + + describe('edge cases', () => { + it('should handle empty variables object', () => { + const result = extractVariableInfo('{{apiKey}}', {}); + + expect(result).toEqual({ + variableName: 'apiKey', + variableValue: undefined + }); + }); + + it('should handle null variables object', () => { + const result = extractVariableInfo('{{apiKey}}', null); + + expect(result).toEqual({ + variableName: 'apiKey', + variableValue: undefined + }); + }); + + it('should handle undefined variables object', () => { + const result = extractVariableInfo('{{apiKey}}', undefined); + + expect(result).toEqual({ + variableName: 'apiKey', + variableValue: undefined + }); + }); + }); + + describe('return value structure', () => { + it('should always return an object with variableName and variableValue properties', () => { + const result = extractVariableInfo('{{apiKey}}', mockVariables); + + expect(result).toHaveProperty('variableName'); + expect(result).toHaveProperty('variableValue'); + expect(typeof result.variableName).toBe('string'); + }); + + it('should return variableValue as the interpolated value', () => { + const result = extractVariableInfo('{{apiKey}}', mockVariables); + + expect(result.variableValue).toBe('test-api-key-123'); + }); + }); +}); diff --git a/packages/bruno-app/src/utils/codemirror/mock-data-hints.js b/packages/bruno-app/src/utils/codemirror/mock-data-hints.js new file mode 100644 index 000000000..fb03e9f11 --- /dev/null +++ b/packages/bruno-app/src/utils/codemirror/mock-data-hints.js @@ -0,0 +1,25 @@ +import { mockDataFunctions } from '@usebruno/common'; + +const MOCK_FUNCTION_SUGGESTIONS = Object.keys(mockDataFunctions).map(key => `$${key}`); + +export const getMockDataHints = (cm) => { + const cursor = cm.getCursor(); + const currentString = cm.getRange({ line: cursor.line, ch: 0 }, cursor); + + const match = currentString.match(/\{\{\$(\w*)$/); + if (!match) return null; + + const wordMatch = match[1]; + if (!wordMatch) return null; + + const suggestions = MOCK_FUNCTION_SUGGESTIONS.filter(name => name.startsWith(`$${wordMatch}`)); + if (!suggestions.length) return null; + + const startPos = { line: cursor.line, ch: currentString.lastIndexOf('{{$') + 2 }; // +2 accounts for `{{` + + return { + list: suggestions, + from: startPos, + to: cm.getCursor(), + }; +}; \ No newline at end of file diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 57d3ab50b..35366c00b 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -1,6 +1,8 @@ import {cloneDeep, isEqual, sortBy, filter, map, isString, findIndex, find, each, get } from 'lodash'; import { uuid } from 'utils/common'; +import { sortByNameThenSequence } from 'utils/common/index'; import path from 'utils/common/path'; +import { isRequestTagsIncluded } from '@usebruno/common'; const replaceTabsWithSpaces = (str, numSpaces = 2) => { if (!str || !str.length || !isString(str)) { @@ -231,7 +233,9 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} type: si.type, name: si.name, filename: si.filename, - seq: si.seq + seq: si.seq, + settings: si.settings, + tags: si.tags }; if (si.request) { @@ -314,7 +318,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} credentialsPlacement: get(si.request, 'auth.oauth2.credentialsPlacement', 'body'), credentialsId: get(si.request, 'auth.oauth2.credentialsId', 'credentials'), tokenPlacement: get(si.request, 'auth.oauth2.tokenPlacement', 'header'), - tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'), + 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), @@ -334,12 +338,27 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} pkce: get(si.request, 'auth.oauth2.pkce', false), credentialsId: get(si.request, 'auth.oauth2.credentialsId', 'credentials'), tokenPlacement: get(si.request, 'auth.oauth2.tokenPlacement', 'header'), - tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'), + 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), }; break; + case 'implicit': + di.request.auth.oauth2 = { + grantType: grantType, + callbackUrl: get(si.request, 'auth.oauth2.callbackUrl', ''), + authorizationUrl: get(si.request, 'auth.oauth2.authorizationUrl', ''), + clientId: get(si.request, 'auth.oauth2.clientId', ''), + scope: get(si.request, 'auth.oauth2.scope', ''), + state: get(si.request, 'auth.oauth2.state', ''), + credentialsId: get(si.request, 'auth.oauth2.credentialsId', 'credentials'), + 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), + }; + break; case 'client_credentials': di.request.auth.oauth2 = { grantType: grantType, @@ -351,7 +370,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} credentialsPlacement: get(si.request, 'auth.oauth2.credentialsPlacement', 'body'), credentialsId: get(si.request, 'auth.oauth2.credentialsId', 'credentials'), tokenPlacement: get(si.request, 'auth.oauth2.tokenPlacement', 'header'), - tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'), + 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), @@ -387,7 +406,12 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} }; let { request, meta, docs } = si?.root || {}; - let { headers, script = {}, vars = {}, tests } = request || {}; + let { auth, headers, script = {}, vars = {}, tests } = request || {}; + + // folder level auth + if (auth?.mode) { + di.root.request.auth = auth; + } // folder level headers if (headers?.length) { @@ -531,6 +555,8 @@ export const transformRequestToSaveToFilesystem = (item) => { type: _item.type, name: _item.name, seq: _item.seq, + settings: _item.settings, + tags: _item.tags, request: { method: _item.request.method, url: _item.request.url, @@ -704,23 +730,22 @@ export const humanizeRequestAPIKeyPlacement = (placement) => { }; export const humanizeGrantType = (mode) => { - let label = 'No Auth'; - switch (mode) { - case 'password': { - label = 'Password Credentials'; - break; - } - case 'authorization_code': { - label = 'Authorization Code'; - break; - } - case 'client_credentials': { - label = 'Client Credentials'; - break; - } + if (!mode || typeof mode !== 'string') { + return ''; } - return label; + switch (mode) { + case 'password': + return 'Password Credentials'; + case 'authorization_code': + return 'Authorization Code'; + case 'client_credentials': + return 'Client Credentials'; + case 'implicit': + return 'Implicit'; + default: + return mode; + } }; export const refreshUidsInItem = (item) => { @@ -934,7 +959,7 @@ export const maskInputValue = (value) => { .join(''); }; -const getTreePathFromCollectionToItem = (collection, _item) => { +export const getTreePathFromCollectionToItem = (collection, _item) => { let path = []; let item = findItemInCollection(collection, _item?.uid); while (item) { @@ -1016,7 +1041,7 @@ export const getFormattedCollectionOauth2Credentials = ({ oauth2Credentials = [] export const resetSequencesInFolder = (folderItems) => { const items = folderItems; - const sortedItems = items.sort((a, b) => a.seq - b.seq); + const sortedItems = sortByNameThenSequence(items); return sortedItems.map((item, index) => { item.seq = index + 1; return item; @@ -1084,3 +1109,48 @@ export const calculateDraggedItemNewPathname = ({ draggedItem, targetItem, dropT // item sequence utils - END +export const getUniqueTagsFromItems = (items = []) => { + const allTags = new Set(); + const getTags = (items) => { + items.forEach(item => { + if (isItemARequest(item)) { + const tags = item.draft ? get(item, 'draft.tags', []) : get(item, 'tags', []); + tags.forEach(tag => allTags.add(tag)); + } + if (item.items) { + getTags(item.items); + } + }); + }; + getTags(items); + return Array.from(allTags).sort(); +}; + + +export const getRequestItemsForCollectionRun = ({ recursive, items = [], tags }) => { + let requestItems = []; + + if (recursive) { + requestItems = flattenItems(items); + } else { + each(items, (item) => { + if (item.request) { + requestItems.push(item); + } + }); + } + + const requestTypes = ['http-request', 'graphql-request']; + requestItems = requestItems.filter(request => requestTypes.includes(request.type)); + + if (tags && tags.include && tags.exclude) { + const includeTags = tags.include ? tags.include : []; + const excludeTags = tags.exclude ? tags.exclude : []; + requestItems = requestItems.filter(({ tags: requestTags = [], draft }) => { + requestTags = draft?.tags || requestTags || []; + return isRequestTagsIncluded(requestTags, includeTags, excludeTags); + }); + } + + return requestItems; +}; \ No newline at end of file diff --git a/packages/bruno-app/src/utils/common/bulkKeyValueUtils.js b/packages/bruno-app/src/utils/common/bulkKeyValueUtils.js new file mode 100644 index 000000000..b165c2f3f --- /dev/null +++ b/packages/bruno-app/src/utils/common/bulkKeyValueUtils.js @@ -0,0 +1,20 @@ +export function parseBulkKeyValue(value) { + return value + .split(/\r?\n/) + .map((pair) => { + const isEnabled = !pair.trim().startsWith('//'); + const cleanPair = pair.replace(/^\/\/\s*/, ''); + const sep = cleanPair.indexOf(':'); + if (sep < 0) return null; + return { + name: cleanPair.slice(0, sep).trim(), + value: cleanPair.slice(sep + 1).trim(), + enabled: isEnabled + }; + }) + .filter(Boolean); +} + +export function serializeBulkKeyValue(items) { + return items.map((item) => `${item.enabled ? '' : '//'}${item.name}:${item.value}`).join('\n'); +} diff --git a/packages/bruno-app/src/utils/common/codemirror.js b/packages/bruno-app/src/utils/common/codemirror.js index 661b84433..7306c63fb 100644 --- a/packages/bruno-app/src/utils/common/codemirror.js +++ b/packages/bruno-app/src/utils/common/codemirror.js @@ -1,11 +1,7 @@ import get from 'lodash/get'; +import { mockDataFunctions } from '@usebruno/common'; -let CodeMirror; -const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true; - -if (!SERVER_RENDERED) { - CodeMirror = require('codemirror'); -} +const CodeMirror = require('codemirror'); const pathFoundInVariables = (path, obj) => { const value = get(obj, path); @@ -74,11 +70,11 @@ export class MaskedEditor { } else { for (let line = 0; line < lineCount; line++) { const lineLength = this.editor.getLine(line).length; - const maskedNode = document.createTextNode('*'.repeat(lineLength)); + const maskedNode = document.createTextNode('*'.repeat(lineLength)); this.editor.markText( { line, ch: 0 }, { line, ch: lineLength }, - { replacedWith: maskedNode, handleMouseEvents: false } + { replacedWith: maskedNode, handleMouseEvents: false } ); } } @@ -86,7 +82,18 @@ export class MaskedEditor { }; } -export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPathParams) => { +/** + * Defines a custom CodeMirror mode for Bruno variables highlighting. + * This function creates a specialized mode that can highlight both Bruno template + * variables (in the format {{variable}}) and URL path parameters (in the format /:param). + * + * @param {Object} _variables - The variables object containing data to validate against + * @param {string} mode - The base CodeMirror mode to extend (e.g., 'javascript', 'application/json') + * @param {boolean} highlightPathParams - Whether to highlight URL path parameters + * @param {boolean} highlightVariables - Whether to highlight template variables + * @returns {void} - Registers the mode with CodeMirror for later use + */ +export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPathParams, highlightVariables) => { CodeMirror.defineMode('brunovariables', function (config, parserConfig) { const { pathParams = {}, ...variables } = _variables || {}; const variablesOverlay = { @@ -97,7 +104,9 @@ export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPa while ((ch = stream.next()) != null) { if (ch === '}' && stream.peek() === '}') { stream.eat('}'); - const found = pathFoundInVariables(word, variables); + // Check if it's a mock variable (starts with $) and exists in mockDataFunctions + const isMockVariable = word.startsWith('$') && mockDataFunctions.hasOwnProperty(word.substring(1)); + const found = isMockVariable || pathFoundInVariables(word, variables); const status = found ? 'valid' : 'invalid'; const randomClass = `random-${(Math.random() + 1).toString(36).substring(9)}`; return `variable-${status} ${randomClass}`; @@ -139,13 +148,15 @@ export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPa } }; - let baseMode = CodeMirror.overlayMode(CodeMirror.getMode(config, parserConfig.backdrop || mode), variablesOverlay); + let baseMode = CodeMirror.getMode(config, parserConfig.backdrop || mode); - if (highlightPathParams) { - return CodeMirror.overlayMode(baseMode, urlPathParamsOverlay); - } else { - return baseMode; + if (highlightVariables) { + baseMode = CodeMirror.overlayMode(baseMode, variablesOverlay); } + if (highlightPathParams) { + baseMode = CodeMirror.overlayMode(baseMode, urlPathParamsOverlay); + } + return baseMode; }); }; diff --git a/packages/bruno-app/src/utils/common/folders-requests-sorting.spec.js b/packages/bruno-app/src/utils/common/folders-requests-sorting.spec.js new file mode 100644 index 000000000..fbcf71379 --- /dev/null +++ b/packages/bruno-app/src/utils/common/folders-requests-sorting.spec.js @@ -0,0 +1,374 @@ +const { describe, it, expect } = require('@jest/globals'); +const { sortByNameThenSequence } = require('./index'); + +describe('sortByNameThenSequence', () => { + describe('Basic functionality', () => { + it('should return an empty array when given an empty array', () => { + const items = []; + const result = sortByNameThenSequence(items); + expect(result).toEqual([]); + }); + + it('should not mutate the original array', () => { + const items = [ + { name: 'folder_2', seq: 2 }, + { name: 'folder_1', seq: 1 } + ]; + const originalItems = JSON.parse(JSON.stringify(items)); + sortByNameThenSequence(items); + expect(items).toEqual(originalItems); + }); + + it('should return a new array instance', () => { + const items = [{ name: 'folder_1' }]; + const result = sortByNameThenSequence(items); + expect(result).not.toBe(items); + }); + }); + + describe('Alphabetical sorting (no sequence numbers)', () => { + it('should sort items alphabetically by name when no sequence numbers are present', () => { + const items = [ + { name: 'folder_3' }, + { name: 'folder_1' }, + { name: 'folder_2' } + ]; + const result = sortByNameThenSequence(items); + expect(result).toEqual([ + { name: 'folder_1' }, + { name: 'folder_2' }, + { name: 'folder_3' } + ]); + }); + + it('should handle case-sensitive sorting correctly', () => { + const items = [ + { name: 'Folder_2' }, + { name: 'folder_1' }, + { name: 'FOLDER_3' } + ]; + const result = sortByNameThenSequence(items); + expect(result).toEqual([ + { name: 'folder_1' }, + { name: 'Folder_2' }, + { name: 'FOLDER_3' } + ]); + }); + + it('should handle special characters in names', () => { + const items = [ + { name: 'folder-2' }, + { name: 'folder_1' }, + { name: 'folder 3' } + ]; + const result = sortByNameThenSequence(items); + expect(result).toEqual([ + { name: 'folder 3' }, + { name: 'folder_1' }, + { name: 'folder-2' } + ]); + }); + }); + + describe('Sequence-based sorting (valid sequence numbers)', () => { + it('should sort items by sequence when all items have valid sequence numbers', () => { + const items = [ + { name: 'folder_3', seq: 3 }, + { name: 'folder_1', seq: 1 }, + { name: 'folder_2', seq: 2 } + ]; + const result = sortByNameThenSequence(items); + expect(result).toEqual([ + { name: 'folder_1', seq: 1 }, + { name: 'folder_2', seq: 2 }, + { name: 'folder_3', seq: 3 } + ]); + }); + + it('should handle duplicate sequence numbers by inserting them in alphabetical order', () => { + const items = [ + { name: 'folder_3', seq: 1 }, + { name: 'folder_1', seq: 1 }, + { name: 'folder_2', seq: 2 } + ]; + const result = sortByNameThenSequence(items); + expect(result).toEqual([ + { name: 'folder_1', seq: 1 }, + { name: 'folder_3', seq: 1 }, + { name: 'folder_2', seq: 2 }, + ]); + }); + + it('should handle large sequence numbers correctly', () => { + const items = [ + { name: 'folder_1', seq: 100 }, + { name: 'folder_2', seq: 1 }, + { name: 'folder_3', seq: 50 } + ]; + const result = sortByNameThenSequence(items); + expect(result).toEqual([ + { name: 'folder_2', seq: 1 }, + { name: 'folder_3', seq: 50 }, + { name: 'folder_1', seq: 100 } + ]); + }); + }); + + describe('Invalid sequence numbers', () => { + it('should treat undefined sequence as invalid and sort alphabetically', () => { + const items = [ + { name: 'folder_3', seq: undefined }, + { name: 'folder_1', seq: undefined }, + { name: 'folder_2', seq: undefined } + ]; + const result = sortByNameThenSequence(items); + expect(result).toEqual([ + { name: 'folder_1', seq: undefined }, + { name: 'folder_2', seq: undefined }, + { name: 'folder_3', seq: undefined } + ]); + }); + + it('should treat null sequence as invalid and sort alphabetically', () => { + const items = [ + { name: 'folder_3', seq: null }, + { name: 'folder_1', seq: null }, + { name: 'folder_2', seq: null } + ]; + const result = sortByNameThenSequence(items); + expect(result).toEqual([ + { name: 'folder_1', seq: null }, + { name: 'folder_2', seq: null }, + { name: 'folder_3', seq: null } + ]); + }); + + it('should treat boolean values as invalid sequence numbers', () => { + const items = [ + { name: 'folder_3', seq: true }, + { name: 'folder_1', seq: false }, + { name: 'folder_2', seq: true } + ]; + const result = sortByNameThenSequence(items); + expect(result).toEqual([ + { name: 'folder_1', seq: false }, + { name: 'folder_2', seq: true }, + { name: 'folder_3', seq: true } + ]); + }); + + it('should treat string values as invalid sequence numbers', () => { + const items = [ + { name: 'folder_3', seq: '3' }, + { name: 'folder_1', seq: '1' }, + { name: 'folder_2', seq: 'invalid' } + ]; + const result = sortByNameThenSequence(items); + expect(result).toEqual([ + { name: 'folder_1', seq: '1' }, + { name: 'folder_2', seq: 'invalid' }, + { name: 'folder_3', seq: '3' } + ]); + }); + + it('should treat non-integer numbers as invalid sequence numbers', () => { + const items = [ + { name: 'folder_3', seq: 3.5 }, + { name: 'folder_1', seq: 1.2 }, + { name: 'folder_2', seq: 2.0 } + ]; + const result = sortByNameThenSequence(items); + expect(result).toEqual([ + { name: 'folder_1', seq: 1.2 }, + { name: 'folder_2', seq: 2.0 }, + { name: 'folder_3', seq: 3.5 } + ]); + }); + + it('should treat zero and negative numbers as invalid sequence numbers', () => { + const items = [ + { name: 'folder_3', seq: 0 }, + { name: 'folder_1', seq: -1 }, + { name: 'folder_2', seq: -5 } + ]; + const result = sortByNameThenSequence(items); + expect(result).toEqual([ + { name: 'folder_1', seq: -1 }, + { name: 'folder_2', seq: -5 }, + { name: 'folder_3', seq: 0 } + ]); + }); + + it('should treat NaN and Infinity as invalid sequence numbers', () => { + const items = [ + { name: 'folder_3', seq: NaN }, + { name: 'folder_1', seq: Infinity }, + { name: 'folder_2', seq: -Infinity } + ]; + const result = sortByNameThenSequence(items); + expect(result).toEqual([ + { name: 'folder_1', seq: Infinity }, + { name: 'folder_2', seq: -Infinity }, + { name: 'folder_3', seq: NaN } + ]); + }); + + it('should handle invalid sequence numbers correctly', () => { + const items = [ + { name: 'folder_4', seq: undefined }, + { name: 'folder_1', seq: false }, + { name: 'folder_5', seq: 'invalid' }, + { name: 'folder_2', seq: true }, + { name: 'folder_3', seq: null } + ]; + const sorted = sortByNameThenSequence(items); + expect(sorted).toEqual([ + { name: 'folder_1', seq: false }, + { name: 'folder_2', seq: true }, + { name: 'folder_3', seq: null }, + { name: 'folder_4', seq: undefined }, + { name: 'folder_5', seq: 'invalid' } + ]); + }); + }); + + describe('Mixed valid and invalid sequence numbers', () => { + it('should handle mixed valid and invalid sequence numbers correctly', () => { + const items = [ + { name: 'folder_4', seq: undefined }, + { name: 'folder_1', seq: false }, + { name: 'folder_5', seq: 3 }, + { name: 'folder_2', seq: 2 }, + { name: 'folder_3', seq: null }, + { name: 'folder_6', seq: 9 }, + { name: 'folder_8', seq: 'invalid' }, + { name: 'folder_7', seq: 4 } + ]; + const sorted = sortByNameThenSequence(items); + expect(sorted).toEqual([ + { name: 'folder_1', seq: false }, + { name: 'folder_2', seq: 2 }, + { name: 'folder_5', seq: 3 }, + { name: 'folder_7', seq: 4 }, + { name: 'folder_3', seq: null }, + { name: 'folder_4', seq: undefined }, + { name: 'folder_8', seq: 'invalid' }, + { name: 'folder_6', seq: 9 } + ]); + }); + + it('should insert sequenced items at their positions among non-sequenced items', () => { + const items = [ + { name: 'folder_6' }, + { name: 'folder_1', seq: 1 }, + { name: 'folder_5' }, + { name: 'folder_2', seq: 2 }, + { name: 'folder_4' }, + { name: 'folder_3', seq: 4 } + ]; + const result = sortByNameThenSequence(items); + expect(result).toEqual([ + { name: 'folder_1', seq: 1 }, + { name: 'folder_2', seq: 2 }, + { name: 'folder_4' }, + { name: 'folder_3', seq: 4 }, + { name: 'folder_5' }, + { name: 'folder_6' } + ]); + }); + + it('should handle sequence numbers beyond the array length', () => { + const items = [ + { name: 'folder_1', seq: 10 }, + { name: 'folder_2' }, + { name: 'folder_3', seq: 20 } + ]; + const result = sortByNameThenSequence(items); + expect(result).toEqual([ + { name: 'folder_2' }, + { name: 'folder_1', seq: 10 }, + { name: 'folder_3', seq: 20 } + ]); + }); + }); + + describe('Edge cases and boundary conditions', () => { + it('should handle items with missing name property without throwing errors', () => { + const items = [ + { seq: 1 }, + { name: 'folder_1' }, + { name: 'folder_2', seq: 2 } + ]; + // Note: This might cause issues in production, but we test the current behavior + expect(() => sortByNameThenSequence(items)).not.toThrow(); + }); + + it('should handle items with no seq property (equivalent to undefined)', () => { + const items = [ + { name: 'folder_3' }, + { name: 'folder_1', seq: 1 }, + { name: 'folder_2' } + ]; + const result = sortByNameThenSequence(items); + expect(result).toEqual([ + { name: 'folder_1', seq: 1 }, + { name: 'folder_2' }, + { name: 'folder_3' } + ]); + }); + + it('should handle single item arrays', () => { + const items = [{ name: 'folder_1', seq: 1 }]; + const result = sortByNameThenSequence(items); + expect(result).toEqual([{ name: 'folder_1', seq: 1 }]); + }); + + it('should handle items with identical names but different sequences', () => { + const items = [ + { name: 'folder', seq: 2 }, + { name: 'folder', seq: 1 }, + { name: 'folder' } + ]; + const result = sortByNameThenSequence(items); + expect(result).toEqual([ + { name: 'folder', seq: 1 }, + { name: 'folder', seq: 2 }, + { name: 'folder' } + ]); + }); + }); + + describe('Complex scenarios', () => { + it('should handle a comprehensive mix of all scenarios', () => { + const items = [ + { name: 'folder_10', seq: 'invalid' }, + { name: 'folder_1', seq: false }, + { name: 'folder_11', seq: 3 }, + { name: 'folder_2', seq: 2 }, + { name: 'folder_3', seq: null }, + { name: 'folder_12', seq: 9 }, + { name: 'folder_4', seq: undefined }, + { name: 'folder_5' }, + { name: 'folder_6', seq: 0 }, + { name: 'folder_7', seq: 4 }, + { name: 'folder_8', seq: 1 }, + { name: 'folder_9', seq: -1 } + ]; + const result = sortByNameThenSequence(items); + expect(result).toEqual([ + { name: 'folder_8', seq: 1 }, + { name: 'folder_2', seq: 2 }, + { name: 'folder_11', seq: 3 }, + { name: 'folder_7', seq: 4 }, + { name: 'folder_1', seq: false }, + { name: 'folder_10', seq: 'invalid' }, + { name: 'folder_3', seq: null }, + { name: 'folder_4', seq: undefined }, + { name: 'folder_12', seq: 9 }, + { name: 'folder_5' }, + { name: 'folder_6', seq: 0 }, + { name: 'folder_9', seq: -1 } + ]); + }); + }); +}); \ No newline at end of file diff --git a/packages/bruno-app/src/utils/common/index.js b/packages/bruno-app/src/utils/common/index.js index 7c1165ed9..1c647ae28 100644 --- a/packages/bruno-app/src/utils/common/index.js +++ b/packages/bruno-app/src/utils/common/index.js @@ -1,5 +1,6 @@ import { customAlphabet } from 'nanoid'; import xmlFormat from 'xml-formatter'; +import { format, applyEdits } from 'jsonc-parser'; // a customized version of nanoid without using _ and - export const uuid = () => { @@ -51,9 +52,12 @@ export const safeStringifyJSON = (obj, indent = false) => { } }; -export const convertToCodeMirrorJson = (obj) => { +export const prettifyJSON = (obj, spaces = 2) => { try { - return JSON.stringify(obj, null, 2).slice(1, -1); + const formatted = obj.replace(/\\"/g, '"').replace(/\\'/g, "'"); + const edits = format(formatted, undefined, { tabSize: spaces, insertSpaces: true }); + + return applyEdits(formatted, edits); } catch (e) { return obj; } @@ -83,29 +87,40 @@ export const normalizeFileName = (name) => { }; export const getContentType = (headers) => { - const headersArray = typeof headers === 'object' ? Object.entries(headers) : []; - if (headersArray.length > 0) { - let contentType = headersArray - .filter((header) => header[0].toLowerCase() === 'content-type') - .map((header) => { - return header[1]; - }); - if (contentType && contentType.length) { - if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?json/.test(contentType[0])) { - return 'application/ld+json'; - } else if (typeof contentType[0] === 'string' && /^image\/svg\+xml/i.test(contentType[0])) { - return 'image/svg+xml'; - } else if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?xml/.test(contentType[0])) { - return 'application/xml'; - } - - return contentType[0]; - } + // Return empty string for invalid headers + if (!headers || typeof headers !== 'object' || Object.keys(headers).length === 0) { + return ''; } - return ''; -}; + // Get content-type header value + const contentTypeHeader = Object.entries(headers) + .find(([key]) => key.toLowerCase() === 'content-type'); + + const contentType = contentTypeHeader && contentTypeHeader[1]; + + // Return empty string if no content-type or not a string + if (!contentType || typeof contentType !== 'string') { + return ''; + } + // This pattern matches content types like application/json, application/ld+json, text/json, etc. + const JSON_PATTERN = /^[\w\-]+\/([\w\-]+\+)?json/; + // This pattern matches content types like image/svg. + const SVG_PATTERN = /^image\/svg/i; + // This pattern matches content types like application/xml, text/xml, application/atom+xml, etc. + const XML_PATTERN = /^[\w\-]+\/([\w\-]+\+)?xml/; + + if (JSON_PATTERN.test(contentType)) { + return 'application/ld+json'; + } else if (SVG_PATTERN.test(contentType)) { + return 'image/svg+xml'; + } else if (XML_PATTERN.test(contentType)) { + return 'application/xml'; + } + + return contentType; +} + export const startsWith = (str, search) => { if (!str || !str.length || typeof str !== 'string') { @@ -185,4 +200,64 @@ export const getEncoding = (headers) => { export const multiLineMsg = (...messages) => { return messages.filter(m => m !== undefined && m !== null && m !== '').join('\n'); -} \ No newline at end of file +} + +export const formatSize = (bytes) => { + // Handle invalid inputs + if (isNaN(bytes) || typeof bytes !== 'number') { + return '0B'; + } + + if (bytes < 1024) { + return bytes + 'B'; + } + if (bytes < 1024 * 1024) { + return (bytes / 1024).toFixed(1) + 'KB'; + } + if (bytes < 1024 * 1024 * 1024) { + return (bytes / (1024 * 1024)).toFixed(1) + 'MB'; + } + + return (bytes / (1024 * 1024 * 1024)).toFixed(1) + 'GB'; +} + +export const sortByNameThenSequence = items => { + const isSeqValid = seq => Number.isFinite(seq) && Number.isInteger(seq) && seq > 0; + + // Sort folders alphabetically by name + const alphabeticallySorted = [...items].sort((a, b) => a.name && b.name && a.name.localeCompare(b.name)); + + // Extract folders without 'seq' + const withoutSeq = alphabeticallySorted.filter(f => !isSeqValid(f['seq'])); + + // Extract folders with 'seq' and sort them by 'seq' + const withSeq = alphabeticallySorted.filter(f => isSeqValid(f['seq'])).sort((a, b) => a.seq - b.seq); + + const sortedItems = withoutSeq; + + // Insert folders with 'seq' at their specified positions + withSeq.forEach((item) => { + const position = item.seq - 1; + const existingItem = withoutSeq[position]; + + // Check if there's already an item with the same sequence number + const hasItemWithSameSeq = Array.isArray(existingItem) + ? existingItem?.[0]?.seq === item.seq + : existingItem?.seq === item.seq; + + if (hasItemWithSameSeq) { + // If there's a conflict, group items with same sequence together + const newGroup = Array.isArray(existingItem) + ? [...existingItem, item] + : [existingItem, item]; + + withoutSeq.splice(position, 1, newGroup); + } else { + // Insert item at the specified position + withoutSeq.splice(position, 0, item); + } + }); + + // return flattened sortedItems + return sortedItems.flat(); +}; \ No newline at end of file diff --git a/packages/bruno-app/src/utils/common/index.spec.js b/packages/bruno-app/src/utils/common/index.spec.js index 39f3dff0a..89eeebf2e 100644 --- a/packages/bruno-app/src/utils/common/index.spec.js +++ b/packages/bruno-app/src/utils/common/index.spec.js @@ -1,6 +1,6 @@ const { describe, it, expect } = require('@jest/globals'); -import { normalizeFileName, startsWith, humanizeDate, relativeDate } from './index'; +import { normalizeFileName, startsWith, humanizeDate, relativeDate, getContentType, formatSize } from './index'; describe('common utils', () => { describe('normalizeFileName', () => { @@ -107,4 +107,81 @@ describe('common utils', () => { expect(relativeDate(date)).toBe('2 months ago'); }); }); + + describe('getContentType', () => { + it('should handle JSON content types correctly', () => { + expect(getContentType({ 'content-type': 'application/json' })).toBe('application/ld+json'); + expect(getContentType({ 'content-type': 'text/json' })).toBe('application/ld+json'); + expect(getContentType({ 'content-type': 'application/ld+json' })).toBe('application/ld+json'); + }); + + it('should handle XML content types correctly', () => { + expect(getContentType({ 'content-type': 'text/xml' })).toBe('application/xml'); + expect(getContentType({ 'content-type': 'application/xml' })).toBe('application/xml'); + expect(getContentType({ 'content-type': 'application/atom+xml' })).toBe('application/xml'); + }); + + it('should handle image content types correctly', () => { + expect(getContentType({ 'content-type': 'image/svg+xml;charset=utf-8' })).toBe('image/svg+xml'); + expect(getContentType({ 'content-type': 'IMAGE/SVG+xml' })).toBe('image/svg+xml'); + }); + + it('should return original content type when no pattern matches', () => { + expect(getContentType({ 'content-type': 'image/jpeg' })).toBe('image/jpeg'); + expect(getContentType({ 'content-type': 'application/pdf' })).toBe('application/pdf'); + }); + + it('should not be case sensitive', () => { + expect(getContentType({ 'content-type': 'text/json' })).toBe('application/ld+json'); + expect(getContentType({ 'Content-Type': 'text/json' })).toBe('application/ld+json'); + }); + + it('should handle empty content type', () => { + expect(getContentType({ 'content-type': '' })).toBe(''); + expect(getContentType({ 'content-type': null })).toBe(''); + expect(getContentType({ 'content-type': undefined })).toBe(''); + }); + + it('should handle empty or invalid inputs', () => { + expect(getContentType({})).toBe(''); + expect(getContentType(null)).toBe(''); + expect(getContentType(undefined)).toBe(''); + }); + }); + + describe('formatSize', () => { + it('should format bytes', () => { + expect(formatSize(0)).toBe('0B'); + expect(formatSize(1023)).toBe('1023B'); + }); + + it('should format kilobytes', () => { + expect(formatSize(1024)).toBe('1.0KB'); + expect(formatSize(1048575)).toBe('1024.0KB'); + }); + + it('should format megabytes', () => { + expect(formatSize(1048576)).toBe('1.0MB'); + expect(formatSize(1073741823)).toBe('1024.0MB'); + }); + + it('should format gigabytes', () => { + expect(formatSize(1073741824)).toBe('1.0GB'); + expect(formatSize(1099511627776)).toBe('1024.0GB'); + }); + + it('should format decimal values', () => { + expect(formatSize(1126.5)).toBe('1.1KB'); + expect(formatSize(1153433.6)).toBe('1.1MB'); + expect(formatSize(1153433600)).toBe('1.1GB'); + expect(formatSize(1024.1)).toBe('1.0KB'); + expect(formatSize(1048576.1)).toBe('1.0MB'); + }); + + it('should format invalid inputs', () => { + expect(formatSize(null)).toBe('0B'); + expect(formatSize(undefined)).toBe('0B'); + expect(formatSize(NaN)).toBe('0B'); + }); + }); }); diff --git a/packages/bruno-app/src/utils/curl/curl-to-json.js b/packages/bruno-app/src/utils/curl/curl-to-json.js index ea0ec2a05..21daf8283 100644 --- a/packages/bruno-app/src/utils/curl/curl-to-json.js +++ b/packages/bruno-app/src/utils/curl/curl-to-json.js @@ -9,6 +9,7 @@ import parseCurlCommand from './parse-curl'; import * as querystring from 'query-string'; import * as jsesc from 'jsesc'; +import { buildQueryString } from '@usebruno/common/utils'; function getContentType(headers = {}) { const contentType = Object.keys(headers).find((key) => key.toLowerCase() === 'content-type'); @@ -20,22 +21,6 @@ function repr(value, isKey) { return isKey ? "'" + jsesc(value, { quotes: 'single' }) + "'" : value; } -function getQueries(request) { - const queries = {}; - for (const paramName in request.query) { - const rawValue = request.query[paramName]; - let paramValue; - if (Array.isArray(rawValue)) { - paramValue = rawValue.map(repr); - } else { - paramValue = repr(rawValue); - } - queries[repr(paramName)] = paramValue; - } - - return queries; -} - /** * Converts request data to a string based on its content type. * @@ -49,15 +34,7 @@ function getDataString(request) { const contentType = getContentType(request.headers); - if (contentType && contentType.includes('application/json')) { - try { - const parsedData = JSON.parse(request.data); - return { data: JSON.stringify(parsedData) }; - } catch (error) { - console.error('Failed to parse JSON data:', error); - return { data: request.data.toString() }; - } - } else if (contentType && (contentType.includes('application/xml') || contentType.includes('text/plain'))) { + if (contentType && (contentType.includes('application/json') || contentType.includes('application/xml') || contentType.includes('text/plain'))) { return { data: request.data }; } @@ -147,6 +124,10 @@ function getFilesString(request) { const curlToJson = (curlCommand) => { const request = parseCurlCommand(curlCommand); + if (!request?.url) { + return null; + } + const requestJson = {}; // curl automatically prepends 'http' if the scheme is missing, but python fails and returns an error @@ -181,9 +162,17 @@ const curlToJson = (curlCommand) => { requestJson.headers = headers; } - if (request.query) { - requestJson.queries = getQueries(request); - } else if (request.multipartUploads || request.isDataBinary) { + if (request.queries) { + requestJson.url = requestJson.url + '?' + buildQueryString(request.queries, { encode: false }); + } + + if (request.multipartUploads) { + requestJson.data = request.multipartUploads; + if (!requestJson.headers) { + requestJson.headers = {}; + } + requestJson.headers['Content-Type'] = 'multipart/form-data'; + } else if (request.isDataBinary) { Object.assign(requestJson, getFilesString(request)); } else if (typeof request.data === 'string' || typeof request.data === 'number') { Object.assign(requestJson, getDataString(request)); @@ -205,7 +194,7 @@ const curlToJson = (curlCommand) => { } } - return Object.keys(requestJson).length ? requestJson : {}; + return Object.keys(requestJson).length ? requestJson : null; }; export default curlToJson; diff --git a/packages/bruno-app/src/utils/curl/curl-to-json.spec.js b/packages/bruno-app/src/utils/curl/curl-to-json.spec.js index 991150c57..058064391 100644 --- a/packages/bruno-app/src/utils/curl/curl-to-json.spec.js +++ b/packages/bruno-app/src/utils/curl/curl-to-json.spec.js @@ -62,7 +62,7 @@ describe('curlToJson', () => { it('should accept escaped curl string', () => { const curlCommand = `curl https://www.usebruno.com - -H $'cookie: val_1=\'\'; val_2=\\^373:0\\^373:0; val_3=\u0068\u0065\u006C\u006C\u006F' + -H $'cookie: val_1=\\'\\'; val_2=\\^373:0\\^373:0; val_3=\u0068\u0065\u006C\u006C\u006F' `; const result = curlToJson(curlCommand); diff --git a/packages/bruno-app/src/utils/curl/index.js b/packages/bruno-app/src/utils/curl/index.js index ad4f1edf6..0b4d894cd 100644 --- a/packages/bruno-app/src/utils/curl/index.js +++ b/packages/bruno-app/src/utils/curl/index.js @@ -1,5 +1,5 @@ import { forOwn } from 'lodash'; -import { convertToCodeMirrorJson } from 'utils/common'; +import { prettifyJSON } from 'utils/common'; import curlToJson from './curl-to-json'; export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-request') => { @@ -34,6 +34,10 @@ export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-reque } const request = curlToJson(curlCommand); + if (!request || !request.url) { + return null; + } + const parsedHeaders = request?.headers; const headers = parsedHeaders && @@ -63,7 +67,7 @@ export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-reque body.file = parsedBody; }else if (contentType.includes('application/json')) { body.mode = 'json'; - body.json = convertToCodeMirrorJson(parsedBody); + body.json = prettifyJSON(parsedBody); } else if (contentType.includes('xml')) { body.mode = 'xml'; body.xml = parsedBody; @@ -77,7 +81,11 @@ export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-reque body.mode = 'text'; body.text = parsedBody; } + } else if (parsedBody) { + body.mode = 'formUrlEncoded'; + body.formUrlEncoded = parseFormData(parsedBody); } + return { url: request.url, method: request.method, diff --git a/packages/bruno-app/src/utils/curl/parse-curl.js b/packages/bruno-app/src/utils/curl/parse-curl.js index 79db23672..d44aa547a 100644 --- a/packages/bruno-app/src/utils/curl/parse-curl.js +++ b/packages/bruno-app/src/utils/curl/parse-curl.js @@ -1,265 +1,494 @@ +import cookie from 'cookie'; +import URL from 'url'; +import { parse } from 'shell-quote'; +import { isEmpty } from 'lodash'; +import { parseQueryParams } from '@usebruno/common/utils'; + /** - * Copyright (c) 2014-2016 Nick Carneiro - * https://github.com/curlconverter/curlconverter - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. + * Flag definitions - maps flag names to their states and actions + * State-returning flags expect a value, immediate action flags don't */ +const FLAG_CATEGORIES = { + // State-returning flags (expect a value after the flag) + 'user-agent': ['-A', '--user-agent'], + 'header': ['-H', '--header'], + 'data': ['-d', '--data', '--data-ascii', '--data-urlencode'], + 'json': ['--json'], + 'user': ['-u', '--user'], + 'method': ['-X', '--request'], + 'cookie': ['-b', '--cookie'], + 'form': ['-F', '--form'], + // Special data flags with properties + 'data-raw': ['--data-raw'], + 'data-binary': ['--data-binary'], -import * as cookie from 'cookie'; -import * as URL from 'url'; -import * as querystring from 'query-string'; -import yargs from 'yargs-parser'; + // Immediate action flags (no value expected) + 'head': ['-I', '--head'], + 'compressed': ['--compressed'], + 'insecure': ['-k', '--insecure'], + /** + * Query flags: mark data for conversion to query parameters. + * While this is an immediate action flag, the actual conversion to a query string occurs later during post-build request processing. + * Due to the unpredictable order of flags, query string construction is deferred to the end. + */ + 'query': ['-G', '--get'] +}; -const parseCurlCommand = (curlCommand) => { - // catch escape sequences (e.g. -H $'cookie: it=\'\'') - curlCommand = curlCommand.replace(/\$('.*')/g, (match, group) => group); +/** + * Parse a curl command into a request object + * + * @TODO + * - Handle T (file upload) + */ +const parseCurlCommand = (curl) => { + const cleanedCommand = cleanCurlCommand(curl); + const parsedArgs = parse(cleanedCommand); + const request = buildRequest(parsedArgs); - // Remove newlines (and from continuations) - curlCommand = curlCommand.replace(/\\\r|\\\n/g, ''); + return cleanRequest(postBuildProcessRequest(request)); +}; - // Remove extra whitespace - curlCommand = curlCommand.replace(/\s+/g, ' '); +/** + * Build request object by processing parsed arguments + * Uses a state machine pattern to handle flag-value pairs + */ +const buildRequest = (parsedArgs) => { + const request = { headers: {} }; + let currentState = null; - // yargs parses -XPOST as separate arguments. just prescreen for it. - curlCommand = curlCommand.replace(/ -XPOST/, ' -X POST'); - curlCommand = curlCommand.replace(/ -XGET/, ' -X GET'); - curlCommand = curlCommand.replace(/ -XPUT/, ' -X PUT'); - curlCommand = curlCommand.replace(/ -XPATCH/, ' -X PATCH'); - curlCommand = curlCommand.replace(/ -XDELETE/, ' -X DELETE'); - curlCommand = curlCommand.replace(/ -XOPTIONS/, ' -X OPTIONS'); - // Safari adds `-Xnull` if is unable to determine the request type, it can be ignored - curlCommand = curlCommand.replace(/ -Xnull/, ' '); - curlCommand = curlCommand.trim(); - - const parsedArguments = yargs(curlCommand, { - boolean: ['I', 'head', 'compressed', 'L', 'k', 'silent', 's', 'G', 'get'], - alias: { - H: 'header', - A: 'user-agent', - u: 'user' - } - }); - - let cookieString; - let cookies; - let url = parsedArguments._[1] || ''; - - // remove surrounding quotes if present - if (url && url.length) { - url = url.replace(/^['"]|['"]$/g, ''); - } - - // if url argument wasn't where we expected it, try to find it in the other arguments - if (!url) { - for (const argName in parsedArguments) { - if (typeof parsedArguments[argName] === 'string') { - if (parsedArguments[argName].indexOf('http') === 0 || parsedArguments[argName].indexOf('www.') === 0) { - url = parsedArguments[argName]; - } - } + for (const arg of parsedArgs) { + const newState = processArgument(arg, currentState, request); + // Reset state after handling a value, or update to new state + if (currentState && !newState) { + currentState = null; + } else if (newState) { + currentState = newState; } } - let headers; - - if (parsedArguments.header) { - if (!headers) { - headers = {}; - } - if (!Array.isArray(parsedArguments.header)) { - parsedArguments.header = [parsedArguments.header]; - } - parsedArguments.header.forEach((header) => { - if (header.indexOf('Cookie') !== -1) { - cookieString = header; - } - const components = header.split(/:(.*)/); - if (components[1]) { - headers[components[0]] = components[1].trim(); - } - }); - } - - if (parsedArguments['user-agent']) { - if (!headers) { - headers = {}; - } - headers['User-Agent'] = parsedArguments['user-agent']; - } - - if (parsedArguments.b) { - cookieString = parsedArguments.b; - } - if (parsedArguments.cookie) { - cookieString = parsedArguments.cookie; - } - let multipartUploads; - if (parsedArguments.F) { - multipartUploads = {}; - if (!Array.isArray(parsedArguments.F)) { - parsedArguments.F = [parsedArguments.F]; - } - parsedArguments.F.forEach((multipartArgument) => { - // input looks like key=value. value could be json or a file path prepended with an @ - const splitArguments = multipartArgument.split('=', 2); - const key = splitArguments[0]; - const value = splitArguments[1]; - multipartUploads[key] = value; - }); - } - if (cookieString) { - const cookieParseOptions = { - decode: function (s) { - return s; - } - }; - // separate out cookie headers into separate data structure - // note: cookie is case insensitive - cookies = cookie.parse(cookieString.replace(/^Cookie: /gi, ''), cookieParseOptions); - } - let method; - let parsedMethodArgument = parsedArguments.X || parsedArguments.request || parsedArguments.T; - if (parsedMethodArgument === 'POST') { - method = 'post'; - } else if (parsedMethodArgument === 'PUT') { - method = 'put'; - } else if (parsedMethodArgument === 'PATCH') { - method = 'patch'; - } else if (parsedMethodArgument === 'DELETE') { - method = 'delete'; - } else if (parsedMethodArgument === 'OPTIONS') { - method = 'options'; - } else if ( - (parsedArguments.d || - parsedArguments.data || - parsedArguments['data-ascii'] || - parsedArguments['data-binary'] || - parsedArguments['data-raw'] || - parsedArguments.F || - parsedArguments.form) && - !(parsedArguments.G || parsedArguments.get) - ) { - method = 'post'; - } else if (parsedArguments.I || parsedArguments.head) { - method = 'head'; - } else { - method = 'get'; - } - - const compressed = !!parsedArguments.compressed; - const urlObject = URL.parse(url || ''); - - // if GET request with data, convert data to query string - // NB: the -G flag does not change the http verb. It just moves the data into the url. - if (parsedArguments.G || parsedArguments.get) { - urlObject.query = urlObject.query ? urlObject.query : ''; - let option = null; - if ('d' in parsedArguments) option = 'd'; - if ('data' in parsedArguments) option = 'data'; - if ('data-urlencode' in parsedArguments) option = 'data-urlencode'; - if (option) { - let urlQueryString = ''; - - if (url.indexOf('?') < 0) { - url += '?'; - } else { - urlQueryString += '&'; - } - - if (typeof parsedArguments[option] === 'object') { - urlQueryString += parsedArguments[option].join('&'); - } else { - urlQueryString += parsedArguments[option]; - } - urlObject.query += urlQueryString; - url += urlQueryString; - delete parsedArguments[option]; - } - } - if (urlObject.query && urlObject.query.endsWith('&')) { - urlObject.query = urlObject.query.slice(0, -1); - } - const query = querystring.parse(urlObject.query, { sort: false }); - for (const param in query) { - if (query[param] === null) { - query[param] = ''; - } - } - - urlObject.search = null; // Clean out the search/query portion. - - let urlWithoutQuery = URL.format(urlObject); - let urlHost = urlObject?.host; - if (!url?.includes(`${urlHost}/`)) { - if (urlWithoutQuery && urlHost) { - const [beforeHost, afterHost] = urlWithoutQuery.split(urlHost); - urlWithoutQuery = beforeHost + urlHost + afterHost?.slice(1); - } - } - - const request = { - url, - urlWithoutQuery - }; - - if (compressed) { - request.compressed = true; - } - - if (Object.keys(query).length > 0) { - request.query = query; - } - if (headers) { - request.headers = headers; - } - request.method = method; - - if (cookies) { - request.cookies = cookies; - request.cookieString = cookieString.replace('Cookie: ', ''); - } - if (multipartUploads) { - request.multipartUploads = multipartUploads; - } - if (parsedArguments.data) { - request.data = parsedArguments.data; - } else if (parsedArguments['data-binary']) { - request.data = parsedArguments['data-binary']; - request.isDataBinary = true; - } else if (parsedArguments.d) { - request.data = parsedArguments.d; - } else if (parsedArguments['data-ascii']) { - request.data = parsedArguments['data-ascii']; - } else if (parsedArguments['data-raw']) { - request.data = parsedArguments['data-raw']; - request.isDataRaw = true; - } else if (parsedArguments['data-urlencode']) { - request.data = parsedArguments['data-urlencode']; - } - - if (parsedArguments.user && typeof parsedArguments.user === 'string') { - const basicAuth = parsedArguments.user.split(':') - const username = basicAuth[0] || '' - const password = basicAuth[1] || '' - request.auth = { - mode: 'basic', - basic: { - username, - password - } - } - } - - if (Array.isArray(request.data)) { - request.dataArray = request.data; - request.data = request.data.join('&'); - } - - if (parsedArguments.k || parsedArguments.insecure) { - request.insecure = true; - } return request; }; +/** + * Process a single argument and return new state if needed + * State machine: flags set states, values are processed based on current state + */ +const processArgument = (arg, currentState, request) => { + // Handle flag arguments first (they set states) + const flagState = handleFlag(arg, request); + if (flagState) { + return flagState; + } + + // Handle values based on current state (e.g., -H "value" where currentState is 'header') + if (arg && currentState) { + handleValue(arg, currentState, request); + return null; + } + + // Handle URL detection (only when no current state to avoid conflicts) + if (!currentState && isURLOrFragment(arg)) { + setURL(request, arg); + return null; + } + + return null; +}; + +/** + * Handle flag arguments and return new state + * Determines if flag expects a value or performs immediate action + */ +const handleFlag = (arg, request) => { + // Find which category this flag belongs to + for (const [category, flags] of Object.entries(FLAG_CATEGORIES)) { + if (flags.includes(arg)) { + return handleFlagCategory(category, arg, request); + } + } + + return null; +}; + +/** + * Handle flag based on its category + * Returns state name for flags that expect values, null for immediate actions + */ +const handleFlagCategory = (category, arg, request) => { + switch (category) { + // State-returning flags (return category name to expect value) + case 'user-agent': + case 'header': + case 'data': + case 'json': + case 'user': + case 'method': + case 'cookie': + case 'form': + return category; + + // Special data flags (set properties and return 'data' state) + case 'data-raw': + request.isDataRaw = true; + return 'data'; + + case 'data-binary': + request.isDataBinary = true; + return 'data'; + + // Immediate action flags (perform action and return null) + case 'head': + request.method = 'HEAD'; + return null; + + case 'compressed': + request.headers['Accept-Encoding'] = request.headers['Accept-Encoding'] || 'deflate, gzip'; + return null; + + case 'insecure': + request.insecure = true; + return null; + + case 'query': + // set temporary property isQuery to true to indicate that the data should be converted to query string + // this is processed later at post build request processing + request.isQuery = true; + return null; + + default: + return null; + } +}; + +/** + * Handle values based on the current parsing state + * Maps state names to their value processing functions + */ +const handleValue = (value, state, request) => { + const valueHandlers = { + 'header': () => setHeader(request, value), + 'user-agent': () => setUserAgent(request, value), + 'data': () => setData(request, value), + 'json': () => setJsonData(request, value), + 'form': () => setFormData(request, value), + 'user': () => setAuth(request, value), + 'method': () => setMethod(request, value), + 'cookie': () => setCookie(request, value) + }; + + const handler = valueHandlers[state]; + if (handler) { + handler(); + } +}; + +/** + * Set header from value + */ +const setHeader = (request, value) => { + const [headerName, headerValue] = value.split(/: (.+)/); + request.headers[headerName] = headerValue; +}; + +/** + * Set user agent + */ +const setUserAgent = (request, value) => { + request.headers['User-Agent'] = value; +}; + +/** + * Set authentication + */ +const setAuth = (request, value) => { + if (typeof value !== 'string') { + return; + } + + const [username, password] = value.split(':'); + request.auth = { + mode: 'basic', + basic: { + username: username || '', + password: password || '' + } + }; +}; + +/** + * Set request method + */ +const setMethod = (request, value) => { + request.method = value.toUpperCase(); +}; + +/** + * Set request cookies + */ +const setCookie = (request, value) => { + if (typeof value !== 'string') { + return; + } + + const parsedCookies = cookie.parse(value); + request.cookies = { ...request.cookies, ...parsedCookies }; + request.cookieString = request.cookieString ? request.cookieString + '; ' + value : value; + + request.headers['Cookie'] = request.cookieString; +}; + +/** + * Set data (handles multiple -d flags by concatenating with &) + */ +const setData = (request, value) => { + request.data = request.data ? request.data + '&' + value : value; +}; + +/** + * Set JSON data + * JSON flag automatically sets Content-Type and converts GET/HEAD to POST + */ +const setJsonData = (request, value) => { + if (request.method === 'GET' || request.method === 'HEAD') { + request.method = 'POST'; + } + request.headers['Content-Type'] = 'application/json'; + // JSON data replaces existing data (don't append with &) + request.data = value; +}; + +/** + * Set form data + * Form data always sets method to POST and creates multipart uploads + */ +const setFormData = (request, value) => { + const formArray = Array.isArray(value) ? value : [value]; + const multipartUploads = []; + + formArray.forEach((field) => { + const upload = parseFormField(field); + if (upload) { + multipartUploads.push(upload); + } + }); + + request.multipartUploads = request.multipartUploads || []; + request.multipartUploads.push(...multipartUploads); + request.method = 'POST'; +}; + +/** + * Parse a single form field + * Handles text fields, quoted values, and file uploads (@path) + */ +const parseFormField = (field) => { + const match = field.match(/^([^=]+)=(?:@?"([^"]*)"|@([^@]*)|([^@]*))?$/); + + if (!match) return null; + + const fieldName = match[1]; + const fieldValue = match[2] || match[3] || match[4] || ''; + const isFile = field.includes('@'); + + return { + name: fieldName, + value: fieldValue, + type: isFile ? 'file' : 'text', + enabled: true + }; +}; + +/** + * Check if argument is a URL or URL fragment + */ +const isURLOrFragment = (arg) => { + return isURL(arg) || isURLFragment(arg); +}; + +/** + * Check if argument looks like a URL + */ +const isURL = (arg) => { + if (typeof arg !== 'string') { + return false; + } + return !!URL.parse(arg || '').host; +}; + +/** + * Check if argument looks like a URL fragment + * Handles shell-quote operator objects and query parameter patterns + */ +const isURLFragment = (arg) => { + if (arg && typeof arg === 'object' && arg.op === 'glob') { + return !!URL.parse(arg.pattern || '').host; + } + if (arg && typeof arg === 'object' && arg.op === '&') { + return true; + } + if (typeof arg === 'string') { + // check if arg is a query string containing key=value pair + return /^[^=]+=[^&]*$/.test(arg); + } + return false; +}; + +/** + * Set URL and related properties + * Handles URL concatenation for shell-quote fragments + */ +const setURL = (request, url) => { + const urlString = getUrlString(url); + if (!urlString) return; + + const newUrl = request.url ? request.url + urlString : urlString; + + const { url: formattedUrl, queries, urlWithoutQuery } = parseUrl(newUrl); + + request.url = formattedUrl; + request.urlWithoutQuery = urlWithoutQuery; + request.queries = queries; +}; + +/** + * Convert URL fragment to string + * Handles shell-quote operator objects + */ +const getUrlString = (url) => { + if (typeof url === 'string') return url; + if (url?.op === 'glob') return url.pattern; + if (url?.op === '&') return '&'; + return null; +}; + +/** + * Parse URL + * Returns formatted URL, URL without query, and queries + */ +const parseUrl = (url) => { + const parsedUrl = URL.parse(url); + + const queries = parseQueryParams(parsedUrl.query, { decode: false }); + + let formattedUrl = URL.format(parsedUrl); + if (!url.endsWith('/') && formattedUrl.endsWith('/')) { + // Remove trailing slashes if origin url does not have a trailing slash + formattedUrl = formattedUrl.slice(0, -1); + } + + const urlWithoutQuery = formattedUrl.split('?')[0]; + + return { + url: formattedUrl, + urlWithoutQuery, + queries + }; +}; + +/** + * Convert data to query string + * Used when -G or --get flag is present to move data from body to URL + */ +const convertDataToQueryString = (request) => { + let url = request.url; + + if (url.indexOf('?') < 0) { + url += '?'; + } else if (!url.endsWith('&')) { + url += '&'; + } + + // append data to url as query string + url += request.data; + + const { url: formattedUrl, queries } = parseUrl(url); + + request.url = formattedUrl; + request.queries = queries; + + return request; +}; + +/** + * Post-build processing of request + * Handles method conversion and query parameter processing + */ +const postBuildProcessRequest = (request) => { + if (request.isQuery && request.data) { + request = convertDataToQueryString(request); + // remove data and isQuery from request as they are no longer needed + delete request.data; + delete request.isQuery; + + } else if (request.data) { + // if data is present, set method to POST unless the method is explicitly set + if (!request.method || request.method === 'HEAD') { + request.method = 'POST'; + } + } + + // if method is not set, set it to GET + if (!request.method) { + request.method = 'GET'; + } + + // bruno requires method to be lowercase + request.method = request.method.toLowerCase(); + + return request; +}; + +/** + * Clean up the final request object + */ +const cleanRequest = (request) => { + if (isEmpty(request.headers)) { + delete request.headers; + } + + if (isEmpty(request.queries)) { + delete request.queries; + } + + return request; +}; + +/** + * Clean up curl command + * Handles escape sequences, line continuations, and method concatenation + */ +const cleanCurlCommand = (curlCommand) => { + // Handle escape sequences + curlCommand = curlCommand.replace(/\$('.*')/g, (match, group) => group); + // Convert escaped single quotes to shell quote pattern + curlCommand = curlCommand.replace(/\\'(?!')/g, "'\\''"); + // Fix concatenated HTTP methods + curlCommand = fixConcatenatedMethods(curlCommand); + + return curlCommand.trim(); +}; + +/** + * Fix concatenated HTTP methods + * Eg: Converts -XPOST to -X POST for proper parsing + */ +const fixConcatenatedMethods = (command) => { + const methodFixes = [ + { from: / -XPOST/, to: ' -X POST' }, + { from: / -XGET/, to: ' -X GET' }, + { from: / -XPUT/, to: ' -X PUT' }, + { from: / -XPATCH/, to: ' -X PATCH' }, + { from: / -XDELETE/, to: ' -X DELETE' }, + { from: / -XOPTIONS/, to: ' -X OPTIONS' }, + { from: / -XHEAD/, to: ' -X HEAD' }, + { from: / -Xnull/, to: ' ' } + ]; + + methodFixes.forEach(({ from, to }) => { + command = command.replace(from, to); + }); + + return command; +}; + export default parseCurlCommand; diff --git a/packages/bruno-app/src/utils/curl/parse-curl.spec.js b/packages/bruno-app/src/utils/curl/parse-curl.spec.js new file mode 100644 index 000000000..3ab767f62 --- /dev/null +++ b/packages/bruno-app/src/utils/curl/parse-curl.spec.js @@ -0,0 +1,755 @@ +const { describe, it, expect } = require('@jest/globals'); +import parseCurlCommand from './parse-curl'; + +describe('parseCurlCommand', () => { + describe('Basic HTTP Methods', () => { + it('should parse simple GET request', () => { + const result = parseCurlCommand(` + curl https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'get', + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should parse explicit POST method', () => { + const result = parseCurlCommand(` + curl -X POST https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'post', + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should parse PUT method', () => { + const result = parseCurlCommand(` + curl -X PUT https://api.example.com/users/1 + `); + + expect(result).toEqual({ + method: 'put', + url: 'https://api.example.com/users/1', + urlWithoutQuery: 'https://api.example.com/users/1' + }); + }); + + it('should parse DELETE method', () => { + const result = parseCurlCommand(` + curl -X DELETE https://api.example.com/users/1 + `); + + expect(result).toEqual({ + method: 'delete', + url: 'https://api.example.com/users/1', + urlWithoutQuery: 'https://api.example.com/users/1' + }); + }); + + it('should parse HEAD method', () => { + const result = parseCurlCommand(` + curl -I https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'head', + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + }); + + describe('Headers', () => { + it('should parse single header', () => { + const result = parseCurlCommand(` + curl --header "Content-Type: application/json" https://api.example.com + `); + + expect(result).toEqual({ + method: 'get', + headers: { + 'Content-Type': 'application/json' + }, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + + it('should parse multiple headers', () => { + const result = parseCurlCommand(` + curl -H "Content-Type: application/json" \ + -H "Authorization: Bearer token" \ + https://api.example.com + `); + + expect(result).toEqual({ + method: 'get', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer token' + }, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + + it('should parse user-agent header', () => { + const result = parseCurlCommand(` + curl -A "Custom User Agent" https://api.example.com + `); + + expect(result).toEqual({ + method: 'get', + headers: { + 'User-Agent': 'Custom User Agent' + }, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + }); + + describe('Data and Request Body', () => { + it('should parse JSON data and change method to POST', () => { + const result = parseCurlCommand(` + curl -d '{"name": "John", "age": 30}' https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'post', + data: '{"name": "John", "age": 30}', + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should parse post data', () => { + const result = parseCurlCommand(` + curl --data "name=John&age=30" https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'post', + data: 'name=John&age=30', + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should handle multiple data flags', () => { + const result = parseCurlCommand(` + curl -d "name=John" \ + -d "age=30" \ + https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'post', + data: 'name=John&age=30', + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should keep multiline data', () => { + const result = parseCurlCommand(` + curl -d '{"key": "some long message with line breaks + + + multiline"}' \ + https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'post', + data: `{"key": "some long message with line breaks + + + multiline"}`, + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should keep multi space data', () => { + const result = parseCurlCommand(` + curl -d '{"key": "some long spaced message"}' \ + https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'post', + data: '{"key": "some long spaced message"}', + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should parse binary data flag', () => { + const result = parseCurlCommand(` + curl --data-binary "@/path/to/file" https://api.example.com/upload + `); + + expect(result).toEqual({ + method: 'post', + data: '@/path/to/file', + isDataBinary: true, + url: 'https://api.example.com/upload', + urlWithoutQuery: 'https://api.example.com/upload' + }); + }); + + it('should parse raw data flag', () => { + const result = parseCurlCommand(` + curl --data-raw '{"raw": "data"}' https://api.example.com + `); + + expect(result).toEqual({ + method: 'post', + data: '{"raw": "data"}', + isDataRaw: true, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + }); + + describe('Authentication', () => { + it('should parse basic authentication', () => { + const result = parseCurlCommand(` + curl -u "username:password" https://api.example.com + `); + + expect(result).toEqual({ + method: 'get', + auth: { + mode: 'basic', + basic: { + username: 'username', + password: 'password' + } + }, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + + it('should handle username without password', () => { + const result = parseCurlCommand(` + curl --user "username" https://api.example.com + `); + + expect(result).toEqual({ + method: 'get', + auth: { + mode: 'basic', + basic: { + username: 'username', + password: '' + } + }, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + }); + + describe('Form Data', () => { + it('should parse form data with text fields', () => { + const result = parseCurlCommand(` + curl -F "name=John" \ + -F "age=30" \ + https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'post', + multipartUploads: [ + { name: 'name', value: 'John', type: 'text', enabled: true }, + { name: 'age', value: '30', type: 'text', enabled: true } + ], + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should parse form data with file uploads', () => { + const result = parseCurlCommand(` + curl --form "file=@/path/to/file.txt" https://api.example.com/upload + `); + + expect(result).toEqual({ + method: 'post', + multipartUploads: [ + { name: 'file', value: '/path/to/file.txt', type: 'file', enabled: true } + ], + url: 'https://api.example.com/upload', + urlWithoutQuery: 'https://api.example.com/upload' + }); + }); + }); + + describe('Cookie', () => { + it('should handle cookie flag', () => { + const result = parseCurlCommand(` + curl -b "session=abc123" https://api.example.com + `); + + expect(result).toEqual({ + method: 'get', + headers: { + 'Cookie': 'session=abc123' + }, + cookieString: "session=abc123", + cookies: { + session: 'abc123' + }, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + + it('should handle cookie flag with multiple cookies', () => { + const result = parseCurlCommand(` + curl -b "session=abc123; user=john" https://api.example.com + `); + + expect(result).toEqual({ + method: 'get', + headers: { + 'Cookie': 'session=abc123; user=john' + }, + cookieString: "session=abc123; user=john", + cookies: { + session: 'abc123', + user: 'john' + }, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + + it('should handle multiple cookie flags', () => { + const result = parseCurlCommand(` + curl -b "session=abc123" -b "user=john" https://api.example.com + `); + + expect(result).toEqual({ + method: 'get', + headers: { + 'Cookie': 'session=abc123; user=john' + }, + cookieString: "session=abc123; user=john", + cookies: { + session: 'abc123', + user: 'john' + }, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + + it('should handle complex cookie string', () => { + const result = parseCurlCommand(` + curl -b "session=abc123; user=john; path=/; domain=example.com; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure; HttpOnly" \ + https://api.example.com + `); + + expect(result).toEqual({ + method: 'get', + headers: { + 'Cookie': 'session=abc123; user=john; path=/; domain=example.com; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure; HttpOnly' + }, + cookieString: "session=abc123; user=john; path=/; domain=example.com; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure; HttpOnly", + cookies: { + session: 'abc123', + user: 'john', + path: '/', + domain: 'example.com', + expires: 'Thu, 01 Jan 1970 00:00:00 GMT', + }, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + }); + + describe('Shell Quote Handling', () => { + it(`should handle shell quote patterns ('\'' => \')`, () => { + const result = parseCurlCommand(` + curl -d '{"name": "John\'\\'\'s data"}' https://api.example.com + `); + + expect(result).toEqual({ + method: 'post', + data: '{"name": "John\'s data"}', + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + + it('should handle complex escaped quotes', () => { + const result = parseCurlCommand(` + curl -d '{"message": "Don\\'t stop believing"}' https://api.example.com + `); + + expect(result).toEqual({ + method: 'post', + data: '{"message": "Don\'t stop believing"}', + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + }); + + describe('URL Handling', () => { + it('should parse URLs with query parameters', () => { + const result = parseCurlCommand(` + curl https://api.example.com/users?page=1&limit=10&sort=asc + `); + + expect(result).toEqual({ + method: 'get', + queries: [ + { name: 'page', value: '1' }, + { name: 'limit', value: '10' }, + { name: 'sort', value: 'asc' } + ], + url: 'https://api.example.com/users?page=1&limit=10&sort=asc', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should handle URLs with paths', () => { + const result = parseCurlCommand(` + curl https://api.example.com/v1/users/123 + `); + + expect(result).toEqual({ + method: 'get', + url: 'https://api.example.com/v1/users/123', + urlWithoutQuery: 'https://api.example.com/v1/users/123' + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle compressed flag', () => { + const result = parseCurlCommand(` + curl --compressed https://api.example.com + `); + + expect(result).toEqual({ + method: 'get', + headers: { + 'Accept-Encoding': 'deflate, gzip' + }, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + + it('should handle concatenated HTTP methods', () => { + const result = parseCurlCommand(` + curl -XPOST https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'post', + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should handle newlines and continuations', () => { + const result = parseCurlCommand(` + curl -H "Content-Type: application/json" \ + -d '{"name": "John"}' \ + https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'post', + headers: { + 'Content-Type': 'application/json' + }, + data: '{"name": "John"}', + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + }); + + describe('Complex Examples', () => { + it('should parse a complex curl command with multiple features', () => { + const result = parseCurlCommand(` + curl -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer token123" \ + -H "X-Custom-Header: custom header" \ + -d '{"name": "John\\'s data", "email": "john@example.com", "message": "Don\\'t stop believing!", "path": "/home/user/file.txt", "json": {"nested": "value", "array": [1, 2, 3]}}' \ + -u "api_user:api_pass" \ + --compressed \ + https://api.example.com/v1/users?param1=value1¶m2=custom+param + `); + + expect(result).toEqual({ + method: 'post', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer token123', + 'X-Custom-Header': 'custom header', + 'Accept-Encoding': 'deflate, gzip' + }, + data: '{"name": "John\'s data", "email": "john@example.com", "message": "Don\'t stop believing!", "path": "/home/user/file.txt", "json": {"nested": "value", "array": [1, 2, 3]}}', + auth: { + mode: 'basic', + basic: { + username: 'api_user', + password: 'api_pass' + } + }, + queries: [ + { name: 'param1', value: 'value1' }, + { name: 'param2', value: 'custom+param' } + ], + url: 'https://api.example.com/v1/users?param1=value1¶m2=custom+param', + urlWithoutQuery: 'https://api.example.com/v1/users' + }); + }); + }); + + describe('curl command with complex escape characters', () => { + it('should parse a curl command with complex escape characters', () => { + const result = parseCurlCommand(` + curl -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer token123" \ + -d '{"name": "John\\'s data", "email": "john@example.com"}' \ + -u "api_user:api_pass" \ + --compressed \ + https://api.example.com/v1/users + `); + + expect(result).toEqual({ + method: 'post', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer token123', + 'Accept-Encoding': 'deflate, gzip' + }, + data: '{"name": "John\'s data", "email": "john@example.com"}', + auth: { + mode: 'basic', + basic: { + username: 'api_user', + password: 'api_pass' + } + }, + url: 'https://api.example.com/v1/users', + urlWithoutQuery: 'https://api.example.com/v1/users' + }); + }); + }); + + describe('JSON Flag', () => { + it('should handle basic JSON request', () => { + const result = parseCurlCommand(` + curl --json '{"name": "John Doe", "email": "john@example.com"}' \ + https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'post', + headers: { + 'Content-Type': 'application/json' + }, + data: '{"name": "John Doe", "email": "john@example.com"}', + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should handle JSON with authentication headers', () => { + const result = parseCurlCommand(` + curl --json '{"title": "New Post", "content": "Post content"}' \ + -H "Authorization: Bearer token123" \ + https://api.example.com/posts + `); + + expect(result).toEqual({ + method: 'post', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer token123' + }, + data: '{"title": "New Post", "content": "Post content"}', + url: 'https://api.example.com/posts', + urlWithoutQuery: 'https://api.example.com/posts' + }); + }); + + it('should handle complex JSON data', () => { + const result = parseCurlCommand(` + curl --json '{"user": {"name": "Jane", "email": "jane@example.com"}, "metadata": {"source": "web"}}' \ + https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'post', + headers: { + 'Content-Type': 'application/json' + }, + data: '{"user": {"name": "Jane", "email": "jane@example.com"}, "metadata": {"source": "web"}}', + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should handle JSON with escaped quotes', () => { + const result = parseCurlCommand(` + curl --json '{"message": "Don\\'t stop believing!", "user": "John\\'s account"}' \ + https://api.example.com/messages + `); + + expect(result).toEqual({ + method: 'post', + headers: { + 'Content-Type': 'application/json' + }, + data: '{"message": "Don\'t stop believing!", "user": "John\'s account"}', + url: 'https://api.example.com/messages', + urlWithoutQuery: 'https://api.example.com/messages' + }); + }); + + it('should handle JSON with arrays and nested objects', () => { + const result = parseCurlCommand(` + curl --json '{"items": [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}], "total": 2}' \ + https://api.example.com/orders + `); + + expect(result).toEqual({ + method: 'post', + headers: { + 'Content-Type': 'application/json' + }, + data: '{"items": [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}], "total": 2}', + url: 'https://api.example.com/orders', + urlWithoutQuery: 'https://api.example.com/orders' + }); + }); + + it('should handle JSON with custom method', () => { + const result = parseCurlCommand(` + curl -X PUT \ + --json '{"status": "completed", "updated_at": "2024-01-15T10:30:00Z"}' \ + https://api.example.com/tasks/123 + `); + + expect(result).toEqual({ + method: 'put', + headers: { + 'Content-Type': 'application/json' + }, + data: '{"status": "completed", "updated_at": "2024-01-15T10:30:00Z"}', + url: 'https://api.example.com/tasks/123', + urlWithoutQuery: 'https://api.example.com/tasks/123' + }); + }); + }); + + describe('Insecure Flag', () => { + it('should handle -k flag', () => { + const result = parseCurlCommand(` + curl -k https://api.example.com + `); + + expect(result).toEqual({ + method: 'get', + insecure: true, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + + it('should handle --insecure flag', () => { + const result = parseCurlCommand(` + curl --insecure https://api.example.com + `); + + expect(result).toEqual({ + method: 'get', + insecure: true, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + }); + + describe('Query Flag', () => { + it('should handle -G flag to convert POST data to GET query parameters', () => { + const result = parseCurlCommand(` + curl -G -d "name=John" -d "age=30" https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'get', + url: 'https://api.example.com/users?name=John&age=30', + urlWithoutQuery: 'https://api.example.com/users', + queries: [ + { name: 'name', value: 'John' }, + { name: 'age', value: '30' } + ] + }); + }); + + it('should handle -G flag with --data-urlencode', () => { + const result = parseCurlCommand(` + curl -G --data-urlencode "name=John Doe" \ + --data-urlencode "email=john@example.com" \ + --data-urlencode "hello" \ + https://api.example.com/users?test=urlquery&hello + `); + + expect(result).toEqual({ + method: 'get', + url: 'https://api.example.com/users?test=urlquery&name=John%20Doe&email=john@example.com&hello', + urlWithoutQuery: 'https://api.example.com/users', + queries: [ + { name: 'test', value: 'urlquery' }, + { name: 'name', value: 'John%20Doe' }, + { name: 'email', value: 'john@example.com' }, + { name: 'hello', value: '' } + ] + }); + }); + + it('should handle -G flag with complex data', () => { + const result = parseCurlCommand(` + curl -G -d "search=test+query" \ + -d "filter=active" \ + -d "sort=name" \ + -d "page=1" \ + https://api.example.com/search + `); + + expect(result).toEqual({ + method: 'get', + url: 'https://api.example.com/search?search=test+query&filter=active&sort=name&page=1', + urlWithoutQuery: 'https://api.example.com/search', + queries: [ + { name: 'search', value: 'test+query' }, + { name: 'filter', value: 'active' }, + { name: 'sort', value: 'name' }, + { name: 'page', value: '1' } + ] + }); + }); + }); +}); diff --git a/packages/bruno-app/src/utils/url/index.js b/packages/bruno-app/src/utils/url/index.js index 3a82398a1..7f9bdcc99 100644 --- a/packages/bruno-app/src/utils/url/index.js +++ b/packages/bruno-app/src/utils/url/index.js @@ -1,6 +1,3 @@ -import isEmpty from 'lodash/isEmpty'; -import trim from 'lodash/trim'; -import each from 'lodash/each'; import find from 'lodash/find'; import { interpolate } from '@usebruno/common'; @@ -15,20 +12,6 @@ const hasLength = (str) => { return str.length > 0; }; -export const parseQueryParams = (query) => { - try { - if (!query || !query.length) { - return []; - } - - return Array.from(new URLSearchParams(query.split('#')[0]).entries()) - .map(([name, value]) => ({ name, value })); - } catch (error) { - console.error('Error parsing query params:', error); - return []; - } -}; - export const parsePathParams = (url) => { let uri = url.slice(); @@ -64,26 +47,6 @@ export const parsePathParams = (url) => { return paths; }; -export const stringifyQueryParams = (params) => { - if (!params || isEmpty(params)) { - return ''; - } - - let queryString = []; - each(params, (p) => { - const hasEmptyName = isEmpty(trim(p.name)); - const hasEmptyVal = isEmpty(trim(p.value)); - - // query param name must be present - if (!hasEmptyName) { - // if query param value is missing, push only , else push - queryString.push(hasEmptyVal ? p.name : `${p.name}=${p.value}`); - } - }); - - return queryString.join('&'); -}; - export const splitOnFirst = (str, char) => { if (!str || !str.length) { return [str]; diff --git a/packages/bruno-app/src/utils/url/index.spec.js b/packages/bruno-app/src/utils/url/index.spec.js index 0645befee..8ecd0531d 100644 --- a/packages/bruno-app/src/utils/url/index.spec.js +++ b/packages/bruno-app/src/utils/url/index.spec.js @@ -1,72 +1,4 @@ -import { parseQueryParams, splitOnFirst, parsePathParams, interpolateUrl, interpolateUrlPathParams } from './index'; - -describe('Url Utils - parseQueryParams', () => { - it('should parse query - case 1', () => { - const params = parseQueryParams(''); - expect(params).toEqual([]); - }); - - it('should parse query - case 2', () => { - const params = parseQueryParams('a'); - expect(params).toEqual([{ name: 'a', value: '' }]); - }); - - it('should parse query - case 3', () => { - const params = parseQueryParams('a='); - expect(params).toEqual([{ name: 'a', value: '' }]); - }); - - it('should parse query - case 4', () => { - const params = parseQueryParams('a=1'); - expect(params).toEqual([{ name: 'a', value: '1' }]); - }); - - it('should parse query - case 5', () => { - const params = parseQueryParams('a=1&'); - expect(params).toEqual([{ name: 'a', value: '1' }]); - }); - - it('should parse query - case 6', () => { - const params = parseQueryParams('a=1&b'); - expect(params).toEqual([ - { name: 'a', value: '1' }, - { name: 'b', value: '' } - ]); - }); - - it('should parse query - case 7', () => { - const params = parseQueryParams('a=1&b='); - expect(params).toEqual([ - { name: 'a', value: '1' }, - { name: 'b', value: '' } - ]); - }); - - it('should parse query - case 8', () => { - const params = parseQueryParams('a=1&b=2'); - expect(params).toEqual([ - { name: 'a', value: '1' }, - { name: 'b', value: '2' } - ]); - }); - - it('should parse query with "=" character - case 9', () => { - const params = parseQueryParams('a=1&b={color=red,size=large}&c=3'); - expect(params).toEqual([ - { name: 'a', value: '1' }, - { name: 'b', value: '{color=red,size=large}' }, - { name: 'c', value: '3' } - ]); - }); - - it('should parse query with fragment - case 10', () => { - const params = parseQueryParams('a=1&b=2#I-AM-FRAGMENT'); - expect(params).toEqual([ - { name: 'a', value: '1' }, - { name: 'b', value: '2' } - ]); - }); -}); +import { splitOnFirst, parsePathParams, interpolateUrl, interpolateUrlPathParams } from './index'; describe('Url Utils - parsePathParams', () => { it('should parse path - case 1', () => { diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index 0ef587a9c..811314d41 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -6,15 +6,30 @@ const { getRunnerSummary } = require('@usebruno/common/runner'); const { exists, isFile, isDirectory } = require('../utils/filesystem'); const { runSingleRequest } = require('../runner/run-single-request'); const { bruToEnvJson, getEnvVars } = require('../utils/bru'); +const { isRequestTagsIncluded } = require("@usebruno/common") const makeJUnitOutput = require('../reporters/junit'); const makeHtmlOutput = require('../reporters/html'); const { rpad } = require('../utils/common'); const { bruToJson, getOptions, collectionBruToJson } = require('../utils/bru'); const { dotenvToJson } = require('@usebruno/lang'); const constants = require('../constants'); -const { findItemInCollection, getAllRequestsInFolder, createCollectionJsonFromPathname } = require('../utils/collection'); -const command = 'run [filename]'; -const desc = 'Run a request'; +const { findItemInCollection, getAllRequestsInFolder, createCollectionJsonFromPathname, getCallStack } = require('../utils/collection'); +const command = 'run [paths...]'; +const desc = 'Run one or more requests/folders'; + +const formatTestSummary = (label, maxLength, passed, failed, total, errorCount = 0, skippedCount = 0) => { + const parts = [ + `${rpad(label, maxLength)} ${chalk.green(`${passed} passed`)}` + ]; + + if (failed > 0) parts.push(chalk.red(`${failed} failed`)); + if (errorCount > 0) parts.push(chalk.red(`${errorCount} error`)); + if (skippedCount > 0) parts.push(chalk.magenta(`${skippedCount} skipped`)); + + parts.push(`${total} total`); + + return parts.join(', '); +}; const printRunSummary = (results) => { const { @@ -28,38 +43,40 @@ const printRunSummary = (results) => { failedAssertions, totalTests, passedTests, - failedTests + failedTests, + totalPreRequestTests, + passedPreRequestTests, + failedPreRequestTests, + totalPostResponseTests, + passedPostResponseTests, + failedPostResponseTests } = getRunnerSummary(results); const maxLength = 12; - let requestSummary = `${rpad('Requests:', maxLength)} ${chalk.green(`${passedRequests} passed`)}`; - if (failedRequests > 0) { - requestSummary += `, ${chalk.red(`${failedRequests} failed`)}`; - } - if (errorRequests > 0) { - requestSummary += `, ${chalk.red(`${errorRequests} error`)}`; - } - if (skippedRequests > 0) { - requestSummary += `, ${chalk.magenta(`${skippedRequests} skipped`)}`; - } - requestSummary += `, ${totalRequests} total`; + const requestSummary = formatTestSummary('Requests:', maxLength, passedRequests, failedRequests, totalRequests, errorRequests, skippedRequests); + const testSummary = formatTestSummary('Tests:', maxLength, passedTests, failedTests, totalTests); + const assertSummary = formatTestSummary('Assertions:', maxLength, passedAssertions, failedAssertions, totalAssertions); - let assertSummary = `${rpad('Tests:', maxLength)} ${chalk.green(`${passedTests} passed`)}`; - if (failedTests > 0) { - assertSummary += `, ${chalk.red(`${failedTests} failed`)}`; + let preRequestTestSummary = ''; + if (totalPreRequestTests > 0) { + preRequestTestSummary = formatTestSummary('Pre-Request Tests:', maxLength, passedPreRequestTests, failedPreRequestTests, totalPreRequestTests); } - assertSummary += `, ${totalTests} total`; - let testSummary = `${rpad('Assertions:', maxLength)} ${chalk.green(`${passedAssertions} passed`)}`; - if (failedAssertions > 0) { - testSummary += `, ${chalk.red(`${failedAssertions} failed`)}`; + let postResponseTestSummary = ''; + if (totalPostResponseTests > 0) { + postResponseTestSummary = formatTestSummary('Post-Response Tests:', maxLength, passedPostResponseTests, failedPostResponseTests, totalPostResponseTests); } - testSummary += `, ${totalAssertions} total`; console.log('\n' + chalk.bold(requestSummary)); - console.log(chalk.bold(assertSummary)); + if (preRequestTestSummary) { + console.log(chalk.bold(preRequestTestSummary)); + } + if (postResponseTestSummary) { + console.log(chalk.bold(postResponseTestSummary)); + } console.log(chalk.bold(testSummary)); + console.log(chalk.bold(assertSummary)); return { totalRequests, @@ -72,7 +89,13 @@ const printRunSummary = (results) => { failedAssertions, totalTests, passedTests, - failedTests + failedTests, + totalPreRequestTests, + passedPreRequestTests, + failedPreRequestTests, + totalPostResponseTests, + passedPostResponseTests, + failedPostResponseTests } }; @@ -106,6 +129,10 @@ const builder = async (yargs) => { describe: 'Environment variables', type: 'string' }) + .option('env-file', { + describe: 'Path to environment file (.bru) - can be absolute or relative path', + type: 'string' + }) .option('env-var', { describe: 'Overwrite a single environment variable, multiple usages possible', type: 'string' @@ -164,14 +191,29 @@ const builder = async (yargs) => { type: 'string', description: 'Path to the Client certificate config file used for securing the connection in the request' }) + .option('--noproxy', { + type: 'boolean', + description: 'Disable all proxy settings (both collection-defined and system proxies)', + default: false + }) .option('delay', { type:"number", description: "Delay between each requests (in miliseconds)" }) + .option('tags', { + type: 'string', + description: 'Tags to include in the run' + }) + .option('exclude-tags', { + type: 'string', + description: 'Tags to exclude from the run' + }) .example('$0 run request.bru', 'Run a request') .example('$0 run request.bru --env local', 'Run a request with the environment set to local') + .example('$0 run request.bru --env-file env.bru', 'Run a request with the environment from env.bru file') .example('$0 run folder', 'Run all requests in a folder') .example('$0 run folder -r', 'Run all requests in a folder recursively') + .example('$0 run request.bru folder', 'Run a request and all requests in a folder') .example('$0 run --reporter-skip-all-headers', 'Run all requests in a folder recursively with omitted headers from the reporter output') .example( '$0 run --reporter-skip-headers "Authorization"', @@ -197,7 +239,6 @@ const builder = async (yargs) => { '$0 run request.bru --reporter-junit results.xml --reporter-html results.html', 'Run a request and write the results to results.html in html format and results.xml in junit format in the current directory' ) - .example('$0 run request.bru --tests-only', 'Run all requests that have a test') .example( '$0 run request.bru --cacert myCustomCA.pem', @@ -208,17 +249,23 @@ const builder = async (yargs) => { 'Use a custom CA certificate exclusively when validating the peers of the requests in the specified folder.' ) .example('$0 run --client-cert-config client-cert-config.json', 'Run a request with Client certificate configurations') - .example('$0 run folder --delay delayInMs', 'Run a folder with given miliseconds delay between each requests.'); + .example('$0 run folder --delay delayInMs', 'Run a folder with given miliseconds delay between each requests.') + .example('$0 run --noproxy', 'Run requests with system proxy disabled') + .example( + '$0 run folder --tags=hello,world --exclude-tags=skip', + 'Run only requests with tags "hello" or "world" and exclude any request with tag "skip".' + ); }; const handler = async function (argv) { try { let { - filename, + paths, cacert, ignoreTruststore, disableCookies, env, + envFile, envVar, insecure, r: recursive, @@ -233,7 +280,10 @@ const handler = async function (argv) { reporterSkipAllHeaders, reporterSkipHeaders, clientCertConfig, - delay + noproxy, + delay, + tags: includeTags, + excludeTags } = argv; const collectionPath = process.cwd(); @@ -274,33 +324,31 @@ const handler = async function (argv) { } } - if (filename && filename.length) { - const pathExists = await exists(filename); - if (!pathExists) { - console.error(chalk.red(`File or directory ${filename} does not exist`)); - process.exit(constants.EXIT_STATUS.ERROR_FILE_NOT_FOUND); - } - } else { - filename = './'; - recursive = true; - } - const runtimeVariables = {}; let envVars = {}; - if (env) { - const envFile = path.join(collectionPath, 'environments', `${env}.bru`); - const envPathExists = await exists(envFile); + if (env && envFile) { + console.error(chalk.red(`Cannot use both --env and --env-file options together`)); + process.exit(constants.EXIT_STATUS.ERROR_MALFORMED_ENV_OVERRIDE); + } + + if (envFile || env) { + const envFilePath = envFile + ? path.resolve(collectionPath, envFile) + : path.join(collectionPath, 'environments', `${env}.bru`); + + const envFileExists = await exists(envFilePath); + if (!envFileExists) { + const errorPath = envFile || `environments/${env}.bru`; + console.error(chalk.red(`Environment file not found: `) + chalk.dim(errorPath)); - if (!envPathExists) { - console.error(chalk.red(`Environment file not found: `) + chalk.dim(`environments/${env}.bru`)); process.exit(constants.EXIT_STATUS.ERROR_ENV_NOT_FOUND); } - const envBruContent = fs.readFileSync(envFile, 'utf8'); + const envBruContent = fs.readFileSync(envFilePath, 'utf8').replace(/\r\n/g, '\n'); const envJson = bruToEnvJson(envBruContent); envVars = getEnvVars(envJson); - envVars.__name__ = env; + envVars.__name__ = envFile ? path.basename(envFilePath, '.bru') : env; } if (envVar) { @@ -320,7 +368,7 @@ const handler = async function (argv) { if (!match) { console.error( chalk.red(`Overridable environment variable not correct: use name=value - presented: `) + - chalk.dim(`${value}`) + chalk.dim(`${value}`) ); process.exit(constants.EXIT_STATUS.ERROR_INCORRECT_ENV_OVERRIDE); } @@ -339,6 +387,9 @@ const handler = async function (argv) { if (disableCookies) { options['disableCookies'] = true; } + if (noproxy) { + options['noproxy'] = true; + } if (cacert && cacert.length) { if (insecure) { console.error(chalk.red(`Ignoring the cacert option since insecure connections are enabled`)); @@ -353,6 +404,9 @@ const handler = async function (argv) { } options['ignoreTruststore'] = ignoreTruststore; + includeTags = includeTags ? includeTags.split(',') : []; + excludeTags = excludeTags ? excludeTags.split(',') : []; + if (['json', 'junit', 'html'].indexOf(format) === -1) { console.error(chalk.red(`Format must be one of "json", "junit or "html"`)); process.exit(constants.EXIT_STATUS.ERROR_INCORRECT_OUTPUT_FORMAT); @@ -392,45 +446,38 @@ const handler = async function (argv) { }); } - const _isFile = isFile(filename); + let requestItems = []; let results = []; - let requestItems = []; - - if (_isFile) { - console.log(chalk.yellow('Running Request \n')); - const bruContent = fs.readFileSync(filename, 'utf8'); - const requestItem = bruToJson(bruContent); - requestItem.pathname = path.resolve(collectionPath, filename); - requestItems.push(requestItem); + if (!paths || !paths.length) { + paths = ['./']; + recursive = true; } - const _isDirectory = isDirectory(filename); - if (_isDirectory) { - if (!recursive) { - console.log(chalk.yellow('Running Folder \n')); - } else { - console.log(chalk.yellow('Running Folder Recursively \n')); - } - const resolvedFilepath = path.resolve(filename); - if (resolvedFilepath === collectionPath) { - requestItems = getAllRequestsInFolder(collection?.items, recursive); - } else { - const folderItem = findItemInCollection(collection, resolvedFilepath); - if (folderItem) { - requestItems = getAllRequestsInFolder(folderItem.items, recursive); - } - } + const resolvedPaths = paths.map(p => path.resolve(process.cwd(), p)); - if (testsOnly) { - requestItems = requestItems.filter((iter) => { - const requestHasTests = iter.request?.tests; - const requestHasActiveAsserts = iter.request?.assertions.some((x) => x.enabled) || false; - return requestHasTests || requestHasActiveAsserts; - }); + for (const resolvedPath of resolvedPaths) { + const pathExists = await exists(resolvedPath); + if (!pathExists) { + console.error(chalk.red(`Path not found: ${resolvedPath}`)); + process.exit(constants.EXIT_STATUS.ERROR_FILE_NOT_FOUND); } } + requestItems = getCallStack(resolvedPaths, collection, { recursive }); + + if (testsOnly) { + requestItems = requestItems.filter((iter) => { + const requestHasTests = iter.request?.tests; + const requestHasActiveAsserts = iter.request?.assertions.some((x) => x.enabled) || false; + return requestHasTests || requestHasActiveAsserts; + }); + } + + requestItems = requestItems.filter((item) => { + return isRequestTagsIncluded(item.tags, includeTags, excludeTags); + }); + const runtime = getJsSandboxRuntime(sandbox); const runSingleRequestByPathname = async (relativeItemPathname) => { @@ -489,7 +536,7 @@ const handler = async function (argv) { if(Number.isNaN(delay) && !isLastRun){ console.log(chalk.red(`Ignoring delay because it's not a valid number.`)); } - + results.push({ ...result, runtime: process.hrtime(start)[0] + process.hrtime(start)[1] / 1e9, @@ -504,9 +551,11 @@ const handler = async function (argv) { } const deleteHeaderIfExists = (headers, header) => { - if (headers && headers[header]) { - delete headers[header]; - } + Object.keys(headers).forEach((key) => { + if (key.toLowerCase() === header.toLowerCase()) { + delete headers[key]; + } + }); }; if (reporterSkipHeaders?.length) { @@ -530,7 +579,9 @@ const handler = async function (argv) { const requestFailure = result?.error && !result?.skipped; const testFailure = result?.testResults?.find((iter) => iter.status === 'fail'); const assertionFailure = result?.assertionResults?.find((iter) => iter.status === 'fail'); - if (requestFailure || testFailure || assertionFailure) { + const preRequestTestFailure = result?.preRequestTestResults?.find((iter) => iter.status === 'fail'); + const postResponseTestFailure = result?.postResponseTestResults?.find((iter) => iter.status === 'fail'); + if (requestFailure || testFailure || assertionFailure || preRequestTestFailure || postResponseTestFailure) { break; } } @@ -541,7 +592,7 @@ const handler = async function (argv) { if (result?.shouldStopRunnerExecution) { break; } - + if (nextRequestName !== undefined) { nJumps++; if (nJumps > 10000) { @@ -608,7 +659,7 @@ const handler = async function (argv) { } } - if (summary.failedAssertions + summary.failedTests + summary.failedRequests > 0) { + if ((summary.failedAssertions + summary.failedTests + summary.failedPreRequestTests + summary.failedPostResponseTests + summary.failedRequests > 0) || (summary?.errorRequests > 0)) { process.exit(constants.EXIT_STATUS.ERROR_FAILED_COLLECTION); } } catch (err) { diff --git a/packages/bruno-cli/src/runner/prepare-request.js b/packages/bruno-cli/src/runner/prepare-request.js index bd63704a2..1b5993852 100644 --- a/packages/bruno-cli/src/runner/prepare-request.js +++ b/packages/bruno-cli/src/runner/prepare-request.js @@ -2,7 +2,7 @@ const { get, each, filter } = require('lodash'); const decomment = require('decomment'); const crypto = require('node:crypto'); const { mergeHeaders, mergeScripts, mergeVars, mergeAuth, getTreePathFromCollectionToItem } = require('../utils/collection'); -const { createFormData } = require('../utils/form-data'); +const { buildFormUrlEncodedPayload } = require('../utils/form-data'); const prepareRequest = (item = {}, collection = {}) => { const request = item?.request; @@ -32,7 +32,9 @@ const prepareRequest = (item = {}, collection = {}) => { method: request.method, url: request.url, headers: headers, - pathParams: request?.params?.filter((param) => param.type === 'path'), + name: item.name, + pathParams: request.params?.filter((param) => param.type === 'path'), + settings: item.settings, responseType: 'arraybuffer' }; @@ -46,7 +48,7 @@ const prepareRequest = (item = {}, collection = {}) => { } if (collectionAuth.mode === 'bearer') { - axiosRequest.headers['Authorization'] = `Bearer ${get(collectionAuth, 'bearer.token')}`; + axiosRequest.headers['Authorization'] = `Bearer ${get(collectionAuth, 'bearer.token', '')}`; } if (collectionAuth.mode === 'apikey') { @@ -173,7 +175,7 @@ const prepareRequest = (item = {}, collection = {}) => { } if (request.auth.mode === 'bearer') { - axiosRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`; + axiosRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token', '')}`; } if (request.auth.mode === 'wsse') { @@ -287,13 +289,13 @@ const prepareRequest = (item = {}, collection = {}) => { } if (request.body.mode === 'formUrlEncoded') { - axiosRequest.headers['content-type'] = 'application/x-www-form-urlencoded'; - const params = {}; + if (!contentTypeDefined) { + axiosRequest.headers['content-type'] = 'application/x-www-form-urlencoded'; + } const enabledParams = filter(request.body.formUrlEncoded, (p) => p.enabled); - each(enabledParams, (p) => (params[p.name] = p.value)); - axiosRequest.data = params; + axiosRequest.data = buildFormUrlEncodedPayload(enabledParams); } - + if (request.body.mode === 'multipartForm') { axiosRequest.headers['content-type'] = 'multipart/form-data'; const enabledParams = filter(request.body.multipartForm, (p) => p.enabled); diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index cb7eb98b5..e8ecb4d61 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -26,6 +26,7 @@ const { getOAuth2Token } = require('./oauth2'); const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/; const { NtlmClient } = require('axios-ntlm'); const { addDigestInterceptor } = require('@usebruno/requests'); +const { encodeUrl } = require('@usebruno/common').utils; const onConsoleLog = (type, args) => { console[type](...args); @@ -45,10 +46,33 @@ const runSingleRequest = async function ( ) { const { pathname: itemPathname } = item; const relativeItemPathname = path.relative(collectionPath, itemPathname); + + const logResults = (results, title) => { + if (results?.length) { + if (title) { + console.log(chalk.dim(title)); + } + each(results, (r) => { + const message = r.description || `${r.lhsExpr}: ${r.rhsExpr}`; + if (r.status === 'pass') { + console.log(chalk.green(` ✓ `) + chalk.dim(message)); + } else { + console.log(chalk.red(` ✕ `) + chalk.red(message)); + if (r.error) { + console.log(chalk.red(` ${r.error}`)); + } + } + }); + } + }; + try { let request; let nextRequestName; let shouldStopRunnerExecution = false; + let preRequestTestResults = []; + let postResponseTestResults = []; + request = prepareRequest(item, collection); request.__bruno__executionMode = 'cli'; @@ -58,6 +82,7 @@ const runSingleRequest = async function ( // run pre request script const requestScriptFile = get(request, 'script.req'); + const collectionName = collection?.brunoConfig?.name if (requestScriptFile?.length) { const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime }); const result = await scriptRuntime.runRequestScript( @@ -69,7 +94,8 @@ const runSingleRequest = async function ( onConsoleLog, processEnvVars, scriptingConfig, - runSingleRequestByPathname + runSingleRequestByPathname, + collectionName ); if (result?.nextRequestName !== undefined) { nextRequestName = result.nextRequestName; @@ -101,20 +127,29 @@ const runSingleRequest = async function ( skipped: true, assertionResults: [], testResults: [], + preRequestTestResults: result?.results || [], + postResponseTestResults: [], shouldStopRunnerExecution }; } + + preRequestTestResults = result?.results || []; } // interpolate variables inside request interpolateVars(request, envVariables, runtimeVariables, processEnvVars); + if (request.settings?.encodeUrl) { + request.url = encodeUrl(request.url); + } + if (!protocolRegex.test(request.url)) { request.url = `http://${request.url}`; } const options = getOptions(); const insecure = get(options, 'insecure', false); + const noproxy = get(options, 'noproxy', false); const httpsAgentRequestFields = {}; if (insecure) { httpsAgentRequestFields['rejectUnauthorized'] = false; @@ -179,15 +214,22 @@ const runSingleRequest = async function ( const collectionProxyConfig = get(brunoConfig, 'proxy', {}); const collectionProxyEnabled = get(collectionProxyConfig, 'enabled', false); - if (collectionProxyEnabled === true) { + + if (noproxy) { + // If noproxy flag is set, don't use any proxy + proxyMode = 'off'; + } else if (collectionProxyEnabled === true) { + // If collection proxy is enabled, use it proxyConfig = collectionProxyConfig; proxyMode = 'on'; - } else { - // if the collection level proxy is not set, pick the system level proxy by default, to maintain backward compatibility + } else if (collectionProxyEnabled === 'global') { + // If collection proxy is set to 'global', use system proxy const { http_proxy, https_proxy } = getSystemProxyEnvVariables(); if (http_proxy?.length || https_proxy?.length) { proxyMode = 'system'; } + } else { + proxyMode = 'off'; } if (proxyMode === 'on') { @@ -201,8 +243,8 @@ const runSingleRequest = async function ( let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`; let proxyUri; if (proxyAuthEnabled) { - const proxyAuthUsername = interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions); - const proxyAuthPassword = interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions); + const proxyAuthUsername = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions)); + const proxyAuthPassword = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions)); proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`; } else { @@ -292,11 +334,14 @@ const runSingleRequest = async function ( } // stringify the request url encoded params - if (request.headers['content-type'] === 'application/x-www-form-urlencoded') { - request.data = qs.stringify(request.data); + const contentTypeHeader = Object.keys(request.headers).find( + name => name.toLowerCase() === 'content-type' + ); + if (contentTypeHeader && request.headers[contentTypeHeader] === 'application/x-www-form-urlencoded') { + request.data = qs.stringify(request.data, { arrayFormat: 'repeat' }); } - if (request?.headers?.['content-type'] === 'multipart/form-data') { + if (contentTypeHeader && request.headers[contentTypeHeader] === 'multipart/form-data') { if (!(request?.data instanceof FormData)) { let form = createFormData(request.data, collectionPath); request.data = form; @@ -304,15 +349,23 @@ const runSingleRequest = async function ( } } + let requestMaxRedirects = request.maxRedirects + request.maxRedirects = 0 + + // Set default value for requestMaxRedirects if not explicitly set + if (requestMaxRedirects === undefined) { + requestMaxRedirects = 5; // Default to 5 redirects + } + // Handle OAuth2 authentication if (request.oauth2) { try { const token = await getOAuth2Token(request.oauth2); if (token) { - const { tokenPlacement = 'header', tokenHeaderPrefix = 'Bearer', tokenQueryKey = 'access_token' } = request.oauth2; + const { tokenPlacement = 'header', tokenHeaderPrefix = '', tokenQueryKey = 'access_token' } = request.oauth2; - if (tokenPlacement === 'header') { - request.headers['Authorization'] = `${tokenHeaderPrefix} ${token}`; + if (tokenPlacement === 'header' && token) { + request.headers['Authorization'] = `${tokenHeaderPrefix} ${token}`.trim(); } else if (tokenPlacement === 'url') { try { const url = new URL(request.url); @@ -334,7 +387,7 @@ const runSingleRequest = async function ( let response, responseTime; try { - let axiosInstance = makeAxiosInstance(); + let axiosInstance = makeAxiosInstance({ requestMaxRedirects: requestMaxRedirects, disableCookies: options.disableCookies }); if (request.ntlmConfig) { axiosInstance=NtlmClient(request.ntlmConfig,axiosInstance.defaults) delete request.ntlmConfig; @@ -367,8 +420,9 @@ const runSingleRequest = async function ( /** @type {import('axios').AxiosResponse} */ response = await axiosInstance(request); - const { data } = parseDataFromResponse(response, request.__brunoDisableParsingResponseJson); + const { data, dataBuffer } = parseDataFromResponse(response, request.__brunoDisableParsingResponseJson); response.data = data; + response.dataBuffer = dataBuffer; // Prevents the duration on leaking to the actual result responseTime = response.headers.get('request-duration'); @@ -380,8 +434,9 @@ const runSingleRequest = async function ( } } catch (err) { if (err?.response) { - const { data } = parseDataFromResponse(err?.response); + const { data, dataBuffer } = parseDataFromResponse(err?.response); err.response.data = data; + err.response.dataBuffer = dataBuffer; response = err.response; // Prevents the duration on leaking to the actual result @@ -410,6 +465,8 @@ const runSingleRequest = async function ( status: 'error', assertionResults: [], testResults: [], + preRequestTestResults, + postResponseTestResults, nextRequestName: nextRequestName, shouldStopRunnerExecution }; @@ -423,6 +480,9 @@ const runSingleRequest = async function ( chalk.dim(` (${response.status} ${response.statusText}) - ${responseTime} ms`) ); + // Log pre-request test results + logResults(preRequestTestResults, 'Pre-Request Tests'); + // run post-response vars const postResponseVars = get(item, 'request.vars.res'); if (postResponseVars?.length) { @@ -442,28 +502,35 @@ const runSingleRequest = async function ( const responseScriptFile = get(request, 'script.res'); if (responseScriptFile?.length) { const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime }); - const result = await scriptRuntime.runResponseScript( - decomment(responseScriptFile), - request, - response, - envVariables, - runtimeVariables, - collectionPath, - null, - processEnvVars, - scriptingConfig, - runSingleRequestByPathname - ); - if (result?.nextRequestName !== undefined) { - nextRequestName = result.nextRequestName; - } + try { + const result = await scriptRuntime.runResponseScript( + decomment(responseScriptFile), + request, + response, + envVariables, + runtimeVariables, + collectionPath, + null, + processEnvVars, + scriptingConfig, + runSingleRequestByPathname, + collectionName + ); + if (result?.nextRequestName !== undefined) { + nextRequestName = result.nextRequestName; + } - if (result?.stopExecution) { - shouldStopRunnerExecution = true; + if (result?.stopExecution) { + shouldStopRunnerExecution = true; + } + + postResponseTestResults = result?.results || []; + logResults(postResponseTestResults, 'Post-Response Tests'); + } catch (error) { + console.error('Post-response script execution error:', error); } } - // run assertions let assertionResults = []; const assertions = get(item, 'request.assertions'); if (assertions) { @@ -476,15 +543,6 @@ const runSingleRequest = async function ( runtimeVariables, processEnvVars ); - - each(assertionResults, (r) => { - if (r.status === 'pass') { - console.log(chalk.green(` ✓ `) + chalk.dim(`assert: ${r.lhsExpr}: ${r.rhsExpr}`)); - } else { - console.log(chalk.red(` ✕ `) + chalk.red(`assert: ${r.lhsExpr}: ${r.rhsExpr}`)); - console.log(chalk.red(` ${r.error}`)); - } - }); } // run tests @@ -492,39 +550,39 @@ const runSingleRequest = async function ( const testFile = get(request, 'tests'); if (typeof testFile === 'string') { const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime }); - const result = await testRuntime.runTests( - decomment(testFile), - request, - response, - envVariables, - runtimeVariables, - collectionPath, - null, - processEnvVars, - scriptingConfig, - runSingleRequestByPathname - ); - testResults = get(result, 'results', []); + try { + const result = await testRuntime.runTests( + decomment(testFile), + request, + response, + envVariables, + runtimeVariables, + collectionPath, + null, + processEnvVars, + scriptingConfig, + runSingleRequestByPathname, + collectionName + ); + testResults = get(result, 'results', []); - if (result?.nextRequestName !== undefined) { - nextRequestName = result.nextRequestName; - } - - if (result?.stopExecution) { - shouldStopRunnerExecution = true; - } - } - - if (testResults?.length) { - each(testResults, (testResult) => { - if (testResult.status === 'pass') { - console.log(chalk.green(` ✓ `) + chalk.dim(testResult.description)); - } else { - console.log(chalk.red(` ✕ `) + chalk.red(testResult.description)); + if (result?.nextRequestName !== undefined) { + nextRequestName = result.nextRequestName; } - }); + + if (result?.stopExecution) { + shouldStopRunnerExecution = true; + } + + logResults(testResults, 'Tests'); + } catch (error) { + console.error('Test script execution error:', error); + } } + + logResults(assertionResults, 'Assertions'); + return { test: { filename: relativeItemPathname @@ -546,6 +604,8 @@ const runSingleRequest = async function ( status: 'pass', assertionResults, testResults, + preRequestTestResults, + postResponseTestResults, nextRequestName: nextRequestName, shouldStopRunnerExecution }; @@ -571,7 +631,9 @@ const runSingleRequest = async function ( status: 'error', error: err.message, assertionResults: [], - testResults: [] + testResults: [], + preRequestTestResults: [], + postResponseTestResults: [] }; } }; diff --git a/packages/bruno-cli/src/utils/axios-instance.js b/packages/bruno-cli/src/utils/axios-instance.js index 834cda2a8..e919412e7 100644 --- a/packages/bruno-cli/src/utils/axios-instance.js +++ b/packages/bruno-cli/src/utils/axios-instance.js @@ -1,5 +1,47 @@ const axios = require('axios'); const { CLI_VERSION } = require('../constants'); +const { addCookieToJar, getCookieStringForUrl } = require('./cookies'); + +const redirectResponseCodes = [301, 302, 303, 307, 308]; +const METHOD_CHANGING_REDIRECTS = [301, 302, 303]; + +const saveCookies = (url, headers) => { + if (headers['set-cookie']) { + let setCookieHeaders = Array.isArray(headers['set-cookie']) + ? headers['set-cookie'] + : [headers['set-cookie']]; + for (let setCookieHeader of setCookieHeaders) { + if (typeof setCookieHeader === 'string' && setCookieHeader.length) { + addCookieToJar(setCookieHeader, url); + } + } + } +}; + +const createRedirectConfig = (error, redirectUrl) => { + const requestConfig = { + ...error.config, + url: redirectUrl, + headers: { ...error.config.headers } + }; + + const statusCode = error.response.status; + const originalMethod = (error.config.method || 'get').toLowerCase(); + + // For 301, 302, 303: change method to GET unless it was HEAD + if (METHOD_CHANGING_REDIRECTS.includes(statusCode) && originalMethod !== 'head') { + requestConfig.method = 'get'; + requestConfig.data = undefined; + + // Clean up headers that are no longer relevant + delete requestConfig.headers['content-length']; + delete requestConfig.headers['Content-Length']; + delete requestConfig.headers['content-type']; + delete requestConfig.headers['Content-Type']; + } + + return requestConfig; +}; /** * Function that configures axios with timing interceptors @@ -7,10 +49,13 @@ const { CLI_VERSION } = require('../constants'); * @see https://github.com/axios/axios/issues/695 * @returns {axios.AxiosInstance} */ -function makeAxiosInstance() { +function makeAxiosInstance({ requestMaxRedirects = 5, disableCookies } = {}) { + let redirectCount = 0; + /** @type {axios.AxiosInstance} */ const instance = axios.create({ proxy: false, + maxRedirects: 0, headers: { "User-Agent": `bruno-runtime/${CLI_VERSION}` } @@ -18,6 +63,15 @@ function makeAxiosInstance() { instance.interceptors.request.use((config) => { config.headers['request-start-time'] = Date.now(); + + // Add cookies to request if available and not disabled + if (!disableCookies) { + const cookieString = getCookieStringForUrl(config.url); + if (cookieString && typeof cookieString === 'string' && cookieString.length) { + config.headers['cookie'] = cookieString; + } + } + return config; }); @@ -26,6 +80,8 @@ function makeAxiosInstance() { const end = Date.now(); const start = response.config.headers['request-start-time']; response.headers['request-duration'] = end - start; + redirectCount = 0; + return response; }, (error) => { @@ -33,6 +89,42 @@ function makeAxiosInstance() { const end = Date.now(); const start = error.config.headers['request-start-time']; error.response.headers['request-duration'] = end - start; + + if (redirectResponseCodes.includes(error.response.status)) { + if (redirectCount >= requestMaxRedirects) { + // todo: needs to be discussed whether the original error response message should be modified or not + return Promise.reject(error); + } + + const locationHeader = error.response.headers.location; + if (!locationHeader) { + // todo: needs to be discussed whether the original error response message should be modified or not + return Promise.reject(error); + } + + redirectCount++; + let redirectUrl = locationHeader; + + if (!locationHeader.match(/^https?:\/\//i)) { + const URL = require('url'); + redirectUrl = URL.resolve(error.config.url, locationHeader); + } + + if (!disableCookies){ + saveCookies(redirectUrl, error.response.headers); + } + + const requestConfig = createRedirectConfig(error, redirectUrl); + + if (!disableCookies) { + const cookieString = getCookieStringForUrl(redirectUrl); + if (cookieString && typeof cookieString === 'string' && cookieString.length) { + requestConfig.headers['cookie'] = cookieString; + } + } + + return instance(requestConfig); + } } return Promise.reject(error); } diff --git a/packages/bruno-cli/src/utils/bru.js b/packages/bruno-cli/src/utils/bru.js index 07844a455..b709f76f9 100644 --- a/packages/bruno-cli/src/utils/bru.js +++ b/packages/bruno-cli/src/utils/bru.js @@ -22,8 +22,11 @@ const collectionBruToJson = (bru) => { if (json?.meta) { transformedJson.meta = { name: json.meta.name, - seq: !isNaN(sequence) ? Number(sequence) : 1 }; + + if (sequence) { + transformedJson.meta.seq = Number(sequence); + } } return transformedJson; @@ -59,7 +62,9 @@ const bruToJson = (bru) => { const transformedJson = { type: requestType, name: _.get(json, 'meta.name'), - seq: !isNaN(sequence) ? Number(sequence) : 1, + seq: !_.isNaN(sequence) ? Number(sequence) : 1, + settings: _.get(json, 'settings', {}), + tags: _.get(json, 'meta.tags', []), request: { method: _.upperCase(_.get(json, 'http.method')), url: _.get(json, 'http.url'), diff --git a/packages/bruno-cli/src/utils/collection.js b/packages/bruno-cli/src/utils/collection.js index 649fb2a33..09d78506c 100644 --- a/packages/bruno-cli/src/utils/collection.js +++ b/packages/bruno-cli/src/utils/collection.js @@ -55,7 +55,7 @@ const createCollectionJsonFromPathname = (collectionPath) => { } } let currentDirFolderItems = currentDirItems?.filter((iter) => iter.type === 'folder'); - let sortedFolderItems = currentDirFolderItems?.sort((a, b) => a.seq - b.seq); + let sortedFolderItems = sortByNameThenSequence(currentDirFolderItems); let currentDirRequestItems = currentDirItems?.filter((iter) => iter.type !== 'folder'); let sortedRequestItems = currentDirRequestItems?.sort((a, b) => a.seq - b.seq); @@ -349,6 +349,39 @@ const getAllRequestsAtFolderRoot = (folderItems = []) => { return getAllRequestsInFolder(folderItems, false); } +const getCallStack = (resolvedPaths = [], collection, {recursive}) => { + let requestItems = []; + + + if (!resolvedPaths || !resolvedPaths.length) { + return requestItems; + } + + for (const resolvedPath of resolvedPaths) { + if (!resolvedPath || !resolvedPath.length) { + continue; + } + + if (resolvedPath === collection.pathname) { + requestItems = requestItems.concat(getAllRequestsInFolder(collection.items, recursive)); + continue; + } + + const item = findItemInCollection(collection, resolvedPath); + if (!item) { + continue; + } + + if (item.type === 'folder') { + requestItems = requestItems.concat(getAllRequestsInFolder(item.items, recursive)); + } else { + requestItems.push(item); + } + } + + return requestItems; +}; + /** * Safe write file implementation to handle errors * @param {string} filePath - Path to write file @@ -479,6 +512,48 @@ const processCollectionItems = async (items = [], currentPath) => { } }; +const sortByNameThenSequence = items => { + const isSeqValid = seq => Number.isFinite(seq) && Number.isInteger(seq) && seq > 0; + + // Sort folders alphabetically by name + const alphabeticallySorted = [...items].sort((a, b) => a.name && b.name && a.name.localeCompare(b.name)); + + // Extract folders without 'seq' + const withoutSeq = alphabeticallySorted.filter(f => !isSeqValid(f['seq'])); + + // Extract folders with 'seq' and sort them by 'seq' + const withSeq = alphabeticallySorted.filter(f => isSeqValid(f['seq'])).sort((a, b) => a.seq - b.seq); + + const sortedItems = withoutSeq; + + // Insert folders with 'seq' at their specified positions + withSeq.forEach((item) => { + const position = item.seq - 1; + const existingItem = withoutSeq[position]; + + // Check if there's already an item with the same sequence number + const hasItemWithSameSeq = Array.isArray(existingItem) + ? existingItem?.[0]?.seq === item.seq + : existingItem?.seq === item.seq; + + if (hasItemWithSameSeq) { + // If there's a conflict, group items with same sequence together + const newGroup = Array.isArray(existingItem) + ? [...existingItem, item] + : [existingItem, item]; + + withoutSeq.splice(position, 1, newGroup); + } else { + // Insert item at the specified position + withoutSeq.splice(position, 0, item); + } + }); + + // return flattened sortedItems + return sortedItems.flat(); +}; + + module.exports = { createCollectionJsonFromPathname, mergeHeaders, @@ -489,5 +564,6 @@ module.exports = { createCollectionFromBrunoObject, mergeAuth, getAllRequestsInFolder, - getAllRequestsAtFolderRoot + getAllRequestsAtFolderRoot, + getCallStack } \ No newline at end of file diff --git a/packages/bruno-cli/src/utils/cookies.js b/packages/bruno-cli/src/utils/cookies.js index acb58b505..01a82316b 100644 --- a/packages/bruno-cli/src/utils/cookies.js +++ b/packages/bruno-cli/src/utils/cookies.js @@ -1,5 +1,6 @@ const { Cookie, CookieJar } = require('tough-cookie'); const each = require('lodash/each'); +const { isPotentiallyTrustworthyOrigin } = require('@usebruno/requests').utils; const cookieJar = new CookieJar(); @@ -11,7 +12,9 @@ const addCookieToJar = (setCookieHeader, requestUrl) => { }; const getCookiesForUrl = (url) => { - return cookieJar.getCookiesSync(url); + return cookieJar.getCookiesSync(url, { + secure: isPotentiallyTrustworthyOrigin(url) + }); }; const getCookieStringForUrl = (url) => { diff --git a/packages/bruno-cli/src/utils/form-data.js b/packages/bruno-cli/src/utils/form-data.js index eab5d5824..7bb00ba81 100644 --- a/packages/bruno-cli/src/utils/form-data.js +++ b/packages/bruno-cli/src/utils/form-data.js @@ -3,6 +3,25 @@ const FormData = require('form-data'); const fs = require('fs'); const path = require('path'); +/** + * @param {Array.} params The request body Array + * @returns {object} Returns an obj with repeating key as an array of values + * {item: 2, item: 3, item1: 4} becomes {item: [2,3], item1: 4} + */ +const buildFormUrlEncodedPayload = (params) => { + return params.reduce((acc, p) => { + if (!acc[p.name]) { + acc[p.name] = p.value; + } else if (Array.isArray(acc[p.name])) { + acc[p.name].push(p.value); + } else { + acc[p.name] = [acc[p.name], p.value]; + } + return acc; + }, {}); +}; + + const createFormData = (data, collectionPath) => { // make axios work in node using form data // reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427 @@ -38,5 +57,6 @@ const createFormData = (data, collectionPath) => { }; module.exports = { + buildFormUrlEncodedPayload, createFormData } \ No newline at end of file diff --git a/packages/bruno-cli/tests/utils/collection/get-call-stack.spec.js b/packages/bruno-cli/tests/utils/collection/get-call-stack.spec.js new file mode 100644 index 000000000..8260b1485 --- /dev/null +++ b/packages/bruno-cli/tests/utils/collection/get-call-stack.spec.js @@ -0,0 +1,460 @@ +const { describe, it, expect, beforeEach } = require('@jest/globals'); +const { getCallStack } = require('../../../src/utils/collection'); + +const collection = { + brunoConfig: { + version: '1', + name: 'multirun-cli', + type: 'collection', + ignore: ['node_modules', '.git'] + }, + root: { + request: { + headers: [], + auth: {}, + script: {}, + vars: {}, + tests: '' + } + }, + pathname: '/Users/tempo/Downloads/t-temp/multirun-cli-20', + items: [ + { + name: 'root-folder', + pathname: '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder', + type: 'folder', + items: [ + { + name: 'root-child-folder', + pathname: '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder', + type: 'folder', + items: [ + { + name: 'root-child-child-folder', + pathname: + '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-folder', + type: 'folder', + items: [ + { + name: 'root-child-child-child-req-0', + pathname: + '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-folder/root-child-child-child-req-0.bru', + type: 'http-request', + seq: 1, + request: { + method: 'GET', + url: 'https://g.cn', + auth: { + mode: 'inherit' + }, + params: [], + headers: [], + body: { + mode: 'none' + }, + vars: [], + assertions: [], + script: { + req: 'console.log("root-child-child-child-file-0")' + }, + tests: '' + } + }, + { + name: 'root-child-child-child-req-1', + pathname: + '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-folder/root-child-child-child-req-1.bru', + type: 'http-request', + seq: 2, + request: { + method: 'GET', + url: 'https://g.cn', + auth: { + mode: 'inherit' + }, + params: [], + headers: [], + body: { + mode: 'none' + }, + vars: [], + assertions: [], + script: { + req: 'console.log("root-child-child-child-file-1")' + }, + tests: '' + } + } + ], + root: { + request: { + headers: [], + auth: {}, + script: {}, + vars: {}, + tests: '' + }, + meta: { + name: 'root-child-child-folder', + seq: 3 + } + }, + seq: 3 + }, + { + name: 'root-child-child-req-0', + pathname: + '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-req-0.bru', + type: 'http-request', + seq: 4, + request: { + method: 'GET', + url: 'https://g.cn', + auth: { + mode: 'inherit' + }, + params: [], + headers: [], + body: { + mode: 'none' + }, + vars: [], + assertions: [], + script: { + req: 'console.log("root-child-child-file-0")' + }, + tests: '' + } + }, + { + name: 'root-child-child-req-1', + pathname: + '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-req-1.bru', + type: 'http-request', + seq: 5, + request: { + method: 'GET', + url: 'https://g.cn', + auth: { + mode: 'inherit' + }, + params: [], + headers: [], + body: { + mode: 'none' + }, + vars: [], + assertions: [], + script: { + req: 'console.log("root-child-child-file-1")' + }, + tests: '' + } + } + ], + root: { + request: { + headers: [], + auth: {}, + script: {}, + vars: {}, + tests: '' + }, + meta: { + name: 'root-child-folder', + seq: 6 + } + }, + seq: 6 + }, + { + name: 'root-child-req-0', + pathname: '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-req-0.bru', + type: 'http-request', + seq: 7, + request: { + method: 'GET', + url: 'https://g.cn', + auth: { + mode: 'inherit' + }, + params: [], + headers: [], + body: { + mode: 'none' + }, + vars: [], + assertions: [], + script: { + req: 'console.log("root-child-file-0")' + }, + tests: '' + } + }, + { + name: 'root-child-req-1', + pathname: '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-req-1.bru', + type: 'http-request', + seq: 8, + request: { + method: 'GET', + url: 'https://g.cn', + auth: { + mode: 'inherit' + }, + params: [], + headers: [], + body: { + mode: 'none' + }, + vars: [], + assertions: [], + script: { + req: 'console.log("root-child-file-1")' + }, + tests: '' + } + } + ], + root: { + request: { + headers: [], + auth: {}, + script: {}, + vars: {}, + tests: '' + }, + meta: { + name: 'root-folder', + seq: 9 + } + }, + seq: 9 + }, + { + name: 'root-req-0', + pathname: '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-req-0.bru', + type: 'http-request', + seq: 10, + request: { + method: 'GET', + url: 'https://g.cn', + auth: { + mode: 'inherit' + }, + params: [], + headers: [], + body: { + mode: 'none' + }, + vars: [], + assertions: [], + script: { + req: 'console.log("root-file-0")' + }, + tests: '' + } + }, + { + name: 'root-req-1', + pathname: '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-req-1.bru', + type: 'http-request', + seq: 11, + request: { + method: 'GET', + url: 'https://g.cn', + auth: { + mode: 'inherit' + }, + params: [], + headers: [], + body: { + mode: 'none' + }, + vars: [], + assertions: [], + script: { + req: 'console.log("root-file-1")' + }, + tests: '' + } + }, + { + name: 'root-req-2', + pathname: '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-req-2.bru', + type: 'http-request', + seq: 12, + request: { + method: 'GET', + url: 'https://g.cn', + auth: { + mode: 'inherit' + }, + params: [], + headers: [], + body: { + mode: 'none' + }, + vars: [], + assertions: [], + script: { + req: 'console.log("root-file-2")' + }, + tests: '' + } + } + ] +}; + +const sequenceChangedCollection = { + brunoConfig: { + version: '1', + name: 'sequenceChangedCollection', + type: 'collection', + ignore: ['node_modules', '.git'] + }, + root: {}, + pathname: '/Users/tempo/Downloads/t-temp/sequenceChangedCollection', + items: [ + { + name: 'three', + pathname: '/Users/tempo/Downloads/t-temp/sequenceChangedCollection/three.bru', + type: 'http-request', + seq: 1, + request: { + method: 'GET', + url: 'https://usebruno.com', + auth: { + mode: 'inherit' + }, + params: [], + headers: [], + body: { + mode: 'none' + }, + vars: [], + assertions: [], + script: {}, + tests: '' + } + }, + { + name: 'one', + pathname: '/Users/tempo/Downloads/t-temp/sequenceChangedCollection/one.bru', + type: 'http-request', + seq: 2, + request: { + method: 'GET', + url: 'https://usebruno.com', + auth: { + mode: 'inherit' + }, + params: [], + headers: [], + body: { + mode: 'none' + }, + vars: [], + assertions: [], + script: {}, + tests: '' + } + }, + { + name: 'two', + pathname: '/Users/tempo/Downloads/t-temp/sequenceChangedCollection/two.bru', + type: 'http-request', + seq: 2, + request: { + method: 'GET', + url: 'https://usebruno.com', + auth: { + mode: 'inherit' + }, + params: [], + headers: [], + body: { + mode: 'none' + }, + vars: [], + assertions: [], + script: {}, + tests: '' + } + } + ] +}; + +describe('getCallStack', () => { + it('should return all requests in the collection', () => { + const callStack = getCallStack(['/Users/tempo/Downloads/t-temp/multirun-cli-20'], collection, { recursive: true }); + const expectedCallStack = [ + '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-folder/root-child-child-child-req-0.bru', + '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-folder/root-child-child-child-req-1.bru', + '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-req-0.bru', + '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-req-1.bru', + '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-req-0.bru', + '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-req-1.bru', + '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-req-0.bru', + '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-req-1.bru', + '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-req-2.bru' + ]; + expect(callStack.map((item) => item.pathname)).toEqual(expectedCallStack); + }); + + it('should return all requests in the collection when sequence is changed', () => { + const callStack = getCallStack( + ['/Users/tempo/Downloads/t-temp/sequenceChangedCollection'], + sequenceChangedCollection, + { + recursive: true + } + ); + const expectedCallStack = [ + '/Users/tempo/Downloads/t-temp/sequenceChangedCollection/three.bru', + '/Users/tempo/Downloads/t-temp/sequenceChangedCollection/one.bru', + '/Users/tempo/Downloads/t-temp/sequenceChangedCollection/two.bru' + ]; + expect(callStack.map((item) => item.pathname)).toEqual(expectedCallStack); + }); +}); + +describe('getCallStack with collection sequence changed', () => { + it('should return an empty array', () => { + const callStack = getCallStack( + ['/Users/tempo/Downloads/t-temp/sequenceChangedCollection'], + sequenceChangedCollection, + { + recursive: true + } + ); + const expectedCallStack = [ + '/Users/tempo/Downloads/t-temp/sequenceChangedCollection/three.bru', + '/Users/tempo/Downloads/t-temp/sequenceChangedCollection/one.bru', + '/Users/tempo/Downloads/t-temp/sequenceChangedCollection/two.bru' + ]; + expect(callStack.map((item) => item.pathname)).toEqual(expectedCallStack); + }); +}); + +describe('getCallStack with muliple folders and requests run', () => { + it('should return an empty array', () => { + const callStack = getCallStack( + [ + '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-req-0.bru', + '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-req-0.bru', + '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-req-2.bru' + ], + collection, + { + recursive: true + } + ); + const expectedCallStack = [ + '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-req-0.bru', + '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-req-0.bru', + '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-req-2.bru' + ]; + expect(callStack.map((item) => item.pathname)).toEqual(expectedCallStack); + }); +}); diff --git a/packages/bruno-common/package.json b/packages/bruno-common/package.json index a29f586ec..66ded2519 100644 --- a/packages/bruno-common/package.json +++ b/packages/bruno-common/package.json @@ -15,6 +15,11 @@ "require": "./dist/runner/cjs/index.js", "import": "./dist/runner/esm/index.js", "types": "./dist/runner/index.d.ts" + }, + "./utils": { + "require": "./dist/utils/cjs/index.js", + "import": "./dist/utils/esm/index.js", + "types": "./dist/utils/index.d.ts" } }, "files": [ diff --git a/packages/bruno-common/rollup.config.js b/packages/bruno-common/rollup.config.js index 8eec5127f..398379451 100644 --- a/packages/bruno-common/rollup.config.js +++ b/packages/bruno-common/rollup.config.js @@ -55,5 +55,11 @@ module.exports = [ input: 'src/runner/index.ts', cjsOutput: 'dist/runner/cjs/index.js', esmOutput: 'dist/runner/esm/index.js' + }), + ...createBuildConfig({ + inputDir: 'src/utils/**/*', + input: 'src/utils/index.ts', + cjsOutput: 'dist/utils/cjs/index.js', + esmOutput: 'dist/utils/esm/index.js' }) ]; diff --git a/packages/bruno-common/src/index.ts b/packages/bruno-common/src/index.ts index 7d3b6e72d..e72c1d847 100644 --- a/packages/bruno-common/src/index.ts +++ b/packages/bruno-common/src/index.ts @@ -1 +1,5 @@ +export { mockDataFunctions } from './utils/faker-functions'; export { default as interpolate } from './interpolate'; +export { default as isRequestTagsIncluded } from './tags'; + +export * as utils from './utils'; \ No newline at end of file diff --git a/packages/bruno-common/src/interpolate/index.spec.ts b/packages/bruno-common/src/interpolate/index.spec.ts index 925886dcd..025898a42 100644 --- a/packages/bruno-common/src/interpolate/index.spec.ts +++ b/packages/bruno-common/src/interpolate/index.spec.ts @@ -458,7 +458,7 @@ describe('interpolate - mock variable interpolation', () => { const result = interpolate(inputString, {}); // Validate the result using regex patterns - const randomIntPattern = /^\d+$/; + const randomIntPattern = /^(?:[0-9]{1,2}|[1-9][0-9]{2}|1000)$/; const randomIPPattern = /^([\da-f]{1,4}:){7}[\da-f]{1,4}$|^(\d{1,3}\.){3}\d{1,3}$/; const randomIPV4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/; const randomIPV6Pattern = /^([\da-f]{1,4}:){7}[\da-f]{1,4}$/; diff --git a/packages/bruno-common/src/runner/reports/html/template.ts b/packages/bruno-common/src/runner/reports/html/template.ts index d19e2077f..cd0839e67 100644 --- a/packages/bruno-common/src/runner/reports/html/template.ts +++ b/packages/bruno-common/src/runner/reports/html/template.ts @@ -369,6 +369,30 @@ export const htmlTemplateString = (resutsJsonString: string) =>`