mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-22 20:25:38 +00:00
Merge branch 'main' into feat/websocket-engine
This commit is contained in:
5
.github/workflows/tests.yml
vendored
5
.github/workflows/tests.yml
vendored
@@ -83,6 +83,11 @@ jobs:
|
||||
npm run build --workspace=packages/bruno-requests
|
||||
npm run build --workspace=packages/bruno-filestore
|
||||
|
||||
- name: Run Local Testbench
|
||||
run: |
|
||||
npm start --workspace=packages/bruno-tests &
|
||||
sleep 5
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd packages/bruno-tests/collection
|
||||
|
||||
BIN
assets/images/vscode-demo.png
Normal file
BIN
assets/images/vscode-demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 409 KiB |
@@ -74,10 +74,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/bruno.list
|
||||
sudo apt update && sudo apt install bruno
|
||||
```
|
||||
|
||||
|
||||
@@ -59,10 +59,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/bruno.list
|
||||
sudo apt update && sudo apt install bruno
|
||||
```
|
||||
|
||||
|
||||
@@ -63,10 +63,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/bruno.list
|
||||
sudo apt update && sudo apt install bruno
|
||||
```
|
||||
|
||||
|
||||
@@ -78,10 +78,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/bruno.list
|
||||
sudo apt update && sudo apt install bruno
|
||||
```
|
||||
|
||||
|
||||
@@ -75,10 +75,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/bruno.list
|
||||
sudo apt update && sudo apt install bruno
|
||||
```
|
||||
|
||||
|
||||
@@ -63,10 +63,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/bruno.list
|
||||
sudo apt update && sudo apt install bruno
|
||||
```
|
||||
|
||||
|
||||
@@ -75,12 +75,14 @@ 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
|
||||
sudo apt update && sudo apt install gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/bruno.list
|
||||
sudo apt update && sudo apt install bruno
|
||||
|
||||
कई प्लेटफार्मों पर चलाएं 🖥️
|
||||
<br /><br />
|
||||
@@ -148,4 +150,3 @@ Scriptmania
|
||||
|
||||
लाइसेंस 📄
|
||||
MIT
|
||||
|
||||
|
||||
@@ -59,10 +59,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/bruno.list
|
||||
sudo apt update && sudo apt install bruno
|
||||
```
|
||||
|
||||
|
||||
@@ -78,10 +78,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/bruno.list
|
||||
sudo apt update && sudo apt install bruno
|
||||
```
|
||||
|
||||
|
||||
@@ -77,12 +77,14 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/bruno.list
|
||||
sudo apt update && sudo apt install bruno
|
||||
```
|
||||
|
||||
### პლატფორმებს შორის მუშაობა 🖥️
|
||||
|
||||
@@ -59,10 +59,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/bruno.list
|
||||
sudo apt update && sudo apt install bruno
|
||||
```
|
||||
|
||||
|
||||
@@ -61,12 +61,14 @@ flatpak install com.usebruno.Bruno
|
||||
|
||||
# Op Linux via 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/bruno.list
|
||||
sudo apt update && sudo apt install bruno
|
||||
```
|
||||
|
||||
### Draai op meerdere platformen 🖥️
|
||||
|
||||
@@ -69,10 +69,13 @@ 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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/bruno.list
|
||||
sudo apt update && sudo apt install bruno
|
||||
```
|
||||
|
||||
|
||||
@@ -76,10 +76,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/bruno.list
|
||||
sudo apt update && sudo apt install bruno
|
||||
```
|
||||
|
||||
|
||||
@@ -59,10 +59,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/bruno.list
|
||||
sudo apt update && sudo apt install bruno
|
||||
```
|
||||
|
||||
|
||||
@@ -63,10 +63,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/bruno.list
|
||||
sudo apt update && sudo apt install bruno
|
||||
```
|
||||
|
||||
|
||||
@@ -63,10 +63,13 @@ 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 [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 gpg curl
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/bruno.list
|
||||
sudo apt update && sudo apt install bruno
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
|
||||
|
||||
test.describe('Import Bruno Testbench Collection', () => {
|
||||
const testDataDir = path.join(__dirname, '../test-data');
|
||||
|
||||
test.beforeAll(async ({ page }) => {
|
||||
// Navigate back to homescreen after all tests
|
||||
await page.locator('.bruno-logo').click();
|
||||
});
|
||||
|
||||
test('Import Bruno Testbench collection successfully', async ({ page }) => {
|
||||
const brunoFile = path.join(testDataDir, 'bruno-testbench.json');
|
||||
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
await page.setInputFiles('input[type="file"]', brunoFile);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Verify that the Import Collection modal is displayed (for location selection)
|
||||
const locationModal = page.getByRole('dialog');
|
||||
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
// Wait for collection to appear in the location modal
|
||||
await expect(locationModal.getByText('bruno-testbench')).toBeVisible();
|
||||
|
||||
// Cleanup: close any open modals
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
|
||||
test.describe('Import Corrupted Bruno Collection - Should Fail', () => {
|
||||
const testDataDir = path.join(__dirname, '../test-data');
|
||||
|
||||
test('Import Bruno collection with invalid JSON structure should fail', async ({ page }) => {
|
||||
const brunoFile = path.join(testDataDir, 'bruno-malformed.json');
|
||||
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
await page.setInputFiles('input[type="file"]', brunoFile);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Check for JSON parsing error
|
||||
const hasImportError = await page.getByText('Failed to parse the file – ensure it is valid JSON or YAML').first().isVisible();
|
||||
|
||||
// Either parsing error or import error should be shown
|
||||
expect(hasImportError).toBe(true);
|
||||
|
||||
// Cleanup: close any open modals
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
|
||||
test.describe('Import Bruno Collection - Missing Required Schema Fields', () => {
|
||||
const testDataDir = path.join(__dirname, '../test-data');
|
||||
|
||||
test('Import Bruno collection missing required version field should fail', async ({ page }) => {
|
||||
const brunoFile = path.join(testDataDir, 'bruno-missing-required-fields.json');
|
||||
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
await page.setInputFiles('input[type="file"]', brunoFile);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Check for schema validation error messages
|
||||
const hasImportError = await page.getByText('Import collection failed').first().isVisible();
|
||||
|
||||
expect(hasImportError).toBe(true);
|
||||
|
||||
// Cleanup: close any open modals
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
|
||||
test.describe('File Input Acceptance', () => {
|
||||
test('File input accepts expected file types', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
// Check that file input exists (even if hidden)
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await expect(fileInput).toBeAttached();
|
||||
|
||||
// Verify it accepts the expected file types
|
||||
const acceptValue = await fileInput.getAttribute('accept');
|
||||
expect(acceptValue).toContain('.json');
|
||||
expect(acceptValue).toContain('.yaml');
|
||||
expect(acceptValue).toContain('.yml');
|
||||
|
||||
// Cleanup: close any open modals
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
|
||||
test.describe('Invalid File Handling', () => {
|
||||
const testDataDir = path.join(__dirname, '../test-data');
|
||||
|
||||
test('Handle invalid file without crashing', async ({ page }) => {
|
||||
const invalidFile = path.join(testDataDir, 'invalid.txt');
|
||||
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
await page.setInputFiles('input[type="file"]', invalidFile);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
const hasError = await page.getByText("Failed to parse the file – ensure it is valid JSON or YAML").isVisible();
|
||||
expect(hasError).toBe(true);
|
||||
|
||||
// Cleanup: close any open modals
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
|
||||
test.describe('Import Insomnia Collection v4', () => {
|
||||
const testDataDir = path.join(__dirname, '../test-data');
|
||||
|
||||
test('Import Insomnia Collection v4 successfully', async ({ page }) => {
|
||||
const insomniaFile = path.join(testDataDir, 'insomnia-v4.json');
|
||||
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
await page.setInputFiles('input[type="file"]', insomniaFile);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Verify that the Import Collection modal is displayed (for location selection)
|
||||
const locationModal = page.getByRole('dialog');
|
||||
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
// Wait for collection to appear in the location modal
|
||||
await expect(locationModal.getByText('Test API Collection v4')).toBeVisible();
|
||||
|
||||
// Cleanup: close any open modals
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
|
||||
test.describe('Import Insomnia Collection v5', () => {
|
||||
const testDataDir = path.join(__dirname, '../test-data');
|
||||
|
||||
test('Import Insomnia Collection v5 successfully', async ({ page }) => {
|
||||
const insomniaFile = path.join(testDataDir, 'insomnia-v5.yaml');
|
||||
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
await page.setInputFiles('input[type="file"]', insomniaFile);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Verify that the Import Collection modal is displayed (for location selection)
|
||||
const locationModal = page.getByRole('dialog');
|
||||
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
// Wait for collection to appear in the location modal
|
||||
await expect(locationModal.getByText('Test API Collection v5')).toBeVisible();
|
||||
|
||||
// Cleanup: close any open modals
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
|
||||
test.describe('Invalid Insomnia Collection - Missing Collection Array', () => {
|
||||
const testDataDir = path.join(__dirname, '../test-data');
|
||||
|
||||
test('Handle Insomnia v5 collection missing collection array', async ({ page }) => {
|
||||
const insomniaFile = path.join(testDataDir, 'insomnia-v5-invalid-missing-collection.yaml');
|
||||
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
await page.setInputFiles('input[type="file"]', insomniaFile);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Check for error message
|
||||
const hasError = await page.getByText('Import collection failed').first().isVisible();
|
||||
expect(hasError).toBe(true);
|
||||
|
||||
// Cleanup: close any open modals
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
|
||||
test.describe('Invalid Insomnia Collection - Malformed Structure', () => {
|
||||
const testDataDir = path.join(__dirname, '../test-data');
|
||||
|
||||
test('Handle malformed Insomnia collection structure', async ({ page }) => {
|
||||
const insomniaFile = path.join(testDataDir, 'insomnia-malformed.json');
|
||||
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
await page.setInputFiles('input[type="file"]', insomniaFile);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Check for error message - this should fail during JSON parsing
|
||||
const hasError = await page.getByText('Failed to parse the file').isVisible();
|
||||
expect(hasError).toBe(true);
|
||||
|
||||
// Cleanup: close any open modals
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
|
||||
test.describe('Import OpenAPI v3 YAML Collection', () => {
|
||||
const testDataDir = path.join(__dirname, '../test-data');
|
||||
|
||||
test('Import comprehensive OpenAPI v3 YAML successfully', async ({ page }) => {
|
||||
const openApiFile = path.join(testDataDir, 'openapi-comprehensive.yaml');
|
||||
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
await page.setInputFiles('input[type="file"]', openApiFile);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Verify that the Import Collection modal is displayed (for location selection)
|
||||
const locationModal = page.getByRole('dialog');
|
||||
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
// Wait for collection to appear in the location modal
|
||||
await expect(locationModal.getByText('Comprehensive API Test Collection')).toBeVisible();
|
||||
|
||||
// Cleanup: close any open modals
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
|
||||
test.describe('Import OpenAPI v3 JSON Collection', () => {
|
||||
const testDataDir = path.join(__dirname, '../test-data');
|
||||
|
||||
test('Import simple OpenAPI v3 JSON successfully', async ({ page }) => {
|
||||
const openApiFile = path.join(testDataDir, 'openapi-simple.json');
|
||||
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
await page.setInputFiles('input[type="file"]', openApiFile);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Verify that the Import Collection modal is displayed (for location selection)
|
||||
const locationModal = page.getByRole('dialog');
|
||||
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
// Wait for collection to appear in the location modal
|
||||
await expect(locationModal.getByText('Simple Test API')).toBeVisible();
|
||||
|
||||
// Cleanup: close any open modals
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
});
|
||||
31
e2e-tests/import-tests/openapi/003-missing-info.spec.ts
Normal file
31
e2e-tests/import-tests/openapi/003-missing-info.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
|
||||
test.describe('Invalid OpenAPI - Missing Info Section', () => {
|
||||
const testDataDir = path.join(__dirname, '../test-data');
|
||||
|
||||
test('Handle OpenAPI specification missing required info section', async ({ page }) => {
|
||||
const openApiFile = path.join(testDataDir, 'openapi-missing-info.yaml');
|
||||
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
await page.setInputFiles('input[type="file"]', openApiFile);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// The OpenAPI parser might handle missing info gracefully with defaults
|
||||
const hasError = await page.getByText('Import collection failed').first().isVisible();
|
||||
|
||||
// Either should show an error or create an "Untitled Collection"
|
||||
expect(hasError).toBe(true);
|
||||
|
||||
// Cleanup: close any open modals
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
});
|
||||
32
e2e-tests/import-tests/openapi/004-malformed-yaml.spec.ts
Normal file
32
e2e-tests/import-tests/openapi/004-malformed-yaml.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
|
||||
test.describe('Invalid OpenAPI - Malformed YAML', () => {
|
||||
const testDataDir = path.join(__dirname, '../test-data');
|
||||
|
||||
test('Handle malformed OpenAPI YAML structure', async ({ page }) => {
|
||||
const openApiFile = path.join(testDataDir, 'openapi-malformed.yaml');
|
||||
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
await page.setInputFiles('input[type="file"]', openApiFile);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Check for error message - this should fail during YAML parsing
|
||||
const hasParseError = await page.getByText('Failed to parse the file').isVisible();
|
||||
const hasImportError = await page.getByText('Import collection failed').isVisible();
|
||||
|
||||
// Either parsing error or import error should be shown
|
||||
expect(hasParseError || hasImportError).toBe(true);
|
||||
|
||||
// Cleanup: close any open modals
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
|
||||
test.describe('Import Postman Collection v2.1', () => {
|
||||
const testDataDir = path.join(__dirname, '../test-data');
|
||||
|
||||
test('Import Postman Collection v2.1 successfully', async ({ page }) => {
|
||||
const postmanFile = path.join(testDataDir, 'postman-v21.json');
|
||||
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
await page.setInputFiles('input[type="file"]', postmanFile);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Verify that the Import Collection modal is displayed (for location selection)
|
||||
const locationModal = page.getByRole('dialog');
|
||||
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
// Wait for collection to appear in the location modal
|
||||
await expect(locationModal.getByText('Postman v2.1 Collection')).toBeVisible();
|
||||
|
||||
// Cleanup: close any open modals
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
|
||||
test.describe('Import Postman Collection v2.0', () => {
|
||||
const testDataDir = path.join(__dirname, '../test-data');
|
||||
|
||||
test('Import Postman Collection v2.0 successfully', async ({ page }) => {
|
||||
const postmanFile = path.join(testDataDir, 'postman-v20.json');
|
||||
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
await page.setInputFiles('input[type="file"]', postmanFile);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Verify that the Import Collection modal is displayed (for location selection)
|
||||
const locationModal = page.getByRole('dialog');
|
||||
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
// Wait for collection to appear in the location modal
|
||||
await expect(locationModal.getByText('Postman v2.0 Collection')).toBeVisible();
|
||||
|
||||
// Cleanup: close any open modals
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
|
||||
test.describe('Invalid Postman Collection - Missing Info', () => {
|
||||
const testDataDir = path.join(__dirname, '../test-data');
|
||||
|
||||
test('Handle Postman collection missing required info field', async ({ page }) => {
|
||||
const postmanFile = path.join(testDataDir, 'postman-invalid-missing-info.json');
|
||||
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
await page.setInputFiles('input[type="file"]', postmanFile);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Check for error message
|
||||
const hasError = await page.getByText('Import collection failed').isVisible();
|
||||
expect(hasError).toBe(true);
|
||||
|
||||
// Cleanup: close any open modals
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
});
|
||||
29
e2e-tests/import-tests/postman/004-invalid-schema.spec.ts
Normal file
29
e2e-tests/import-tests/postman/004-invalid-schema.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
|
||||
test.describe('Invalid Postman Collection - Invalid Schema', () => {
|
||||
const testDataDir = path.join(__dirname, '../test-data');
|
||||
|
||||
test('Handle Postman collection with invalid schema version', async ({ page }) => {
|
||||
const postmanFile = path.join(testDataDir, 'postman-invalid-schema.json');
|
||||
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
await page.setInputFiles('input[type="file"]', postmanFile);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Check for error message
|
||||
const hasError = await page.getByText('Conversion failed').isVisible();
|
||||
expect(hasError).toBe(true);
|
||||
|
||||
// Cleanup: close any open modals
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
|
||||
test.describe('Invalid Postman Collection - Malformed Structure', () => {
|
||||
const testDataDir = path.join(__dirname, '../test-data');
|
||||
|
||||
test('Handle malformed Postman collection structure', async ({ page }) => {
|
||||
const postmanFile = path.join(testDataDir, 'postman-malformed.json');
|
||||
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
await page.setInputFiles('input[type="file"]', postmanFile);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Check for error message
|
||||
const hasError = await page.getByText('Import collection failed').first().isVisible();
|
||||
expect(hasError).toBe(true);
|
||||
|
||||
// Cleanup: close any open modals
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
});
|
||||
29
e2e-tests/import-tests/postman/006-invalid-json.spec.ts
Normal file
29
e2e-tests/import-tests/postman/006-invalid-json.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
|
||||
test.describe('Invalid Postman Collection - Invalid JSON', () => {
|
||||
const testDataDir = path.join(__dirname, '../test-data');
|
||||
|
||||
test('Handle invalid JSON syntax', async ({ page }) => {
|
||||
const postmanFile = path.join(testDataDir, 'postman-invalid-schema.json');
|
||||
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
await page.setInputFiles('input[type="file"]', postmanFile);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
// Check for error message
|
||||
const hasError = await page.getByText('Conversion failed').first().isVisible();
|
||||
expect(hasError).toBe(true);
|
||||
|
||||
// Cleanup: close any open modals
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"version": "1",
|
||||
"uid": "corrupted_bruno_collection",
|
||||
"name": "Corrupted Bruno Collection",
|
||||
"items": [
|
||||
{
|
||||
"uid": "corrupted_request",
|
||||
"type": "invalid-request-type",
|
||||
"name": "Invalid Request Type",
|
||||
"seq": 1,
|
||||
"request": {
|
||||
"url": "https://example.com/api",
|
||||
"method": "INVALID_METHOD",
|
||||
"headers": "this should be an array not a string",
|
||||
"params": null,
|
||||
"body": {
|
||||
"mode": "invalid-mode",
|
||||
"invalidField": "this field doesn't exist in schema"
|
||||
},
|
||||
"auth": {
|
||||
"mode": "unknown-auth-type",
|
||||
"invalidAuth": {
|
||||
"badField": "invalid value"
|
||||
}
|
||||
},
|
||||
"script": "this should be an object not a string",
|
||||
"vars": "this should be an object not a string",
|
||||
"assertions": "this should be an array not a string",
|
||||
"tests": 12345,
|
||||
"docs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"uid": "missing_required_fields",
|
||||
"type": "http-request",
|
||||
"name": "Missing Required Fields",
|
||||
"seq": 2
|
||||
}
|
||||
],
|
||||
"environments": [
|
||||
{
|
||||
"uid": "invalid_env",
|
||||
"name": "Invalid Environment",
|
||||
"variables": "this should be an array not a string"
|
||||
}
|
||||
],
|
||||
"activeEnvironmentUid": "non_existent_environment_id",
|
||||
"root": {
|
||||
"request": {
|
||||
"headers": "invalid headers format",
|
||||
"auth": {
|
||||
"mode": "completely-unknown-auth"
|
||||
},
|
||||
"script": 42,
|
||||
"vars": false,
|
||||
"tests": null
|
||||
}
|
||||
},
|
||||
"invalidTopLevelField": "this field doesn't belong here",
|
||||
"brunoConfig": {
|
||||
"version": "999",
|
||||
"name": "Invalid Config",
|
||||
"type": "invalid-type",
|
||||
"invalidConfigField": true
|
||||
}
|
||||
}
|
||||
43
e2e-tests/import-tests/test-data/bruno-malformed.json
Normal file
43
e2e-tests/import-tests/test-data/bruno-malformed.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"version": "1",
|
||||
"uid": "malformed_bruno_collection",
|
||||
"name": "Malformed Bruno Collection",
|
||||
"items": [
|
||||
{
|
||||
"uid": "malformed_request",
|
||||
"type": "http-request",
|
||||
"name": "Malformed Request",
|
||||
"seq": 1,
|
||||
"request": {
|
||||
"url": "https://example.com/api",
|
||||
"method": "GET",
|
||||
"headers": [],
|
||||
"params": [],
|
||||
"body": {
|
||||
"mode": "none"
|
||||
},
|
||||
"auth": {
|
||||
"mode": "none"
|
||||
},
|
||||
"script": {},
|
||||
"vars": {},
|
||||
"assertions": [],
|
||||
"tests": "",
|
||||
"docs": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"environments": [],
|
||||
"activeEnvironmentUid": null,
|
||||
"root": {
|
||||
"request": {
|
||||
"headers": [],
|
||||
"auth": {
|
||||
"mode": "none"
|
||||
},
|
||||
"script": {},
|
||||
"vars": {},
|
||||
"tests": ""
|
||||
}
|
||||
}
|
||||
// Missing comma and closing bracket - this makes it malformed JSON
|
||||
2939
e2e-tests/import-tests/test-data/bruno-missing-required-fields.json
Normal file
2939
e2e-tests/import-tests/test-data/bruno-missing-required-fields.json
Normal file
File diff suppressed because it is too large
Load Diff
2940
e2e-tests/import-tests/test-data/bruno-testbench.json
Normal file
2940
e2e-tests/import-tests/test-data/bruno-testbench.json
Normal file
File diff suppressed because it is too large
Load Diff
0
e2e-tests/import-tests/test-data/empty-file.json
Normal file
0
e2e-tests/import-tests/test-data/empty-file.json
Normal file
19
e2e-tests/import-tests/test-data/insomnia-malformed.json
Normal file
19
e2e-tests/import-tests/test-data/insomnia-malformed.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"_type": "export",
|
||||
"__export_format": 4,
|
||||
"resources": [
|
||||
{
|
||||
"_id": "req_123",
|
||||
"parentId": "wrk_456",
|
||||
"url": "https://api.example.com/users",
|
||||
"name": "Get Users",
|
||||
"method": "GET",
|
||||
"_type": "request"
|
||||
},
|
||||
{
|
||||
"_id": "wrk_456",
|
||||
"name": "Test Collection",
|
||||
"_type": "workspace"
|
||||
// Missing comma and closing bracket - malformed JSON
|
||||
}
|
||||
]
|
||||
113
e2e-tests/import-tests/test-data/insomnia-v4.json
Normal file
113
e2e-tests/import-tests/test-data/insomnia-v4.json
Normal file
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"_type": "export",
|
||||
"__export_format": 4,
|
||||
"__export_date": "2025-01-01T12:00:00.000Z",
|
||||
"__export_source": "insomnia.desktop.app:v10.3.1",
|
||||
"resources": [
|
||||
{
|
||||
"_id": "req_fdedb34f7d5541d0aa7a917ce37ec067",
|
||||
"parentId": "wrk_398c634c4fbc4774bcff39cbff44b31b",
|
||||
"modified": 1689952276171,
|
||||
"created": 1689951240510,
|
||||
"url": "{{baseUrl}}/api/users",
|
||||
"name": "Get Users",
|
||||
"description": "Fetch all users from the API",
|
||||
"method": "GET",
|
||||
"body": {},
|
||||
"parameters": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "Accept",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"authentication": {},
|
||||
"metaSortKey": -1689951414329,
|
||||
"isPrivate": false,
|
||||
"settingStoreCookies": true,
|
||||
"settingSendCookies": true,
|
||||
"settingDisableRenderRequestBody": false,
|
||||
"settingEncodeUrl": true,
|
||||
"settingRebuildPath": true,
|
||||
"settingFollowRedirects": "global",
|
||||
"_type": "request"
|
||||
},
|
||||
{
|
||||
"_id": "req_c920d219404144e8bc6b6bd36f442974",
|
||||
"parentId": "fld_ab2a1533f2be48c194883bf07d693292",
|
||||
"modified": 1689952281595,
|
||||
"created": 1689951281897,
|
||||
"url": "{{baseUrl}}/api/auth/login",
|
||||
"name": "Login User",
|
||||
"description": "User authentication endpoint",
|
||||
"method": "POST",
|
||||
"body": {
|
||||
"mimeType": "application/json",
|
||||
"text": "{\n \"username\": \"admin\",\n \"password\": \"password123\"\n}"
|
||||
},
|
||||
"parameters": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"authentication": {},
|
||||
"metaSortKey": -1689951404530.5625,
|
||||
"isPrivate": false,
|
||||
"settingStoreCookies": true,
|
||||
"settingSendCookies": true,
|
||||
"settingDisableRenderRequestBody": false,
|
||||
"settingEncodeUrl": true,
|
||||
"settingRebuildPath": true,
|
||||
"settingFollowRedirects": "global",
|
||||
"_type": "request"
|
||||
},
|
||||
{
|
||||
"_id": "fld_ab2a1533f2be48c194883bf07d693292",
|
||||
"parentId": "wrk_398c634c4fbc4774bcff39cbff44b31b",
|
||||
"modified": 1743683080329,
|
||||
"created": 1743683080329,
|
||||
"name": "Authentication",
|
||||
"description": "Authentication related endpoints",
|
||||
"environment": {},
|
||||
"environmentPropertyOrder": null,
|
||||
"metaSortKey": -1743683080329,
|
||||
"_type": "request_group"
|
||||
},
|
||||
{
|
||||
"_id": "wrk_398c634c4fbc4774bcff39cbff44b31b",
|
||||
"parentId": null,
|
||||
"modified": 1743678539806,
|
||||
"created": 1743678539806,
|
||||
"name": "Test API Collection v4",
|
||||
"description": "Test collection for Insomnia v4 format",
|
||||
"scope": "collection",
|
||||
"_type": "workspace"
|
||||
},
|
||||
{
|
||||
"_id": "env_93781eb62f074459bb67692112b76da0",
|
||||
"parentId": "wrk_398c634c4fbc4774bcff39cbff44b31b",
|
||||
"modified": 1743681240772,
|
||||
"created": 1689951235312,
|
||||
"name": "Base Environment",
|
||||
"data": {
|
||||
"baseUrl": "https://api.example.com"
|
||||
},
|
||||
"dataPropertyOrder": null,
|
||||
"color": null,
|
||||
"isPrivate": false,
|
||||
"metaSortKey": 1689951235312,
|
||||
"_type": "environment"
|
||||
},
|
||||
{
|
||||
"_id": "jar_09963a0322c24b698ecd2f866ae9a6ab",
|
||||
"parentId": "wrk_398c634c4fbc4774bcff39cbff44b31b",
|
||||
"modified": 1689951235313,
|
||||
"created": 1689951235313,
|
||||
"name": "Default Jar",
|
||||
"cookies": [],
|
||||
"_type": "cookie_jar"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
type: collection.insomnia.rest/5.0
|
||||
name: Invalid v5 Collection
|
||||
meta:
|
||||
id: wrk_7faf891d273e4b7ea82bdbaa641ee17a
|
||||
created: 1743683067888
|
||||
modified: 1743683067888
|
||||
# Missing collection array - this should cause import to fail
|
||||
cookieJar:
|
||||
name: Default Jar
|
||||
meta:
|
||||
id: jar_25f97142fa796ae37f7f4937c0ebf3a07869d0a8
|
||||
created: 1743683067908
|
||||
modified: 1743683833282
|
||||
cookies: []
|
||||
environments:
|
||||
name: Base Environment
|
||||
meta:
|
||||
id: env_25f97142fa796ae37f7f4937c0ebf3a07869d0a8
|
||||
created: 1743683067895
|
||||
modified: 1743683476058
|
||||
isPrivate: false
|
||||
data:
|
||||
base_url: https://api.example.com
|
||||
129
e2e-tests/import-tests/test-data/insomnia-v5.yaml
Normal file
129
e2e-tests/import-tests/test-data/insomnia-v5.yaml
Normal file
@@ -0,0 +1,129 @@
|
||||
type: collection.insomnia.rest/5.0
|
||||
name: Test API Collection v5
|
||||
meta:
|
||||
id: wrk_7faf891d273e4b7ea82bdbaa641ee17a
|
||||
created: 1743683067888
|
||||
modified: 1743683067888
|
||||
collection:
|
||||
- name: API Tests
|
||||
meta:
|
||||
id: fld_ab2a1533f2be48c194883bf07d693292
|
||||
created: 1743683080329
|
||||
modified: 1743683080329
|
||||
sortKey: -1743683080329
|
||||
children:
|
||||
- name: Authentication
|
||||
meta:
|
||||
id: fld_e7bcaad160254179a9c86e39a58c6893
|
||||
created: 1743683088154
|
||||
modified: 1743683090190
|
||||
sortKey: -1743683090140
|
||||
children:
|
||||
- url: "{{ _.base_url }}/api/auth/login"
|
||||
name: Login User
|
||||
meta:
|
||||
id: req_d48ab8553cff4eb486b816e064cf99a4
|
||||
created: 1743683199141
|
||||
modified: 1743683342872
|
||||
isPrivate: false
|
||||
sortKey: -1743683199141
|
||||
method: POST
|
||||
body:
|
||||
mimeType: application/json
|
||||
text: |-
|
||||
{
|
||||
"username": "testuser",
|
||||
"password": "testpass123"
|
||||
}
|
||||
headers:
|
||||
- name: Content-Type
|
||||
value: application/json
|
||||
- name: User-Agent
|
||||
value: insomnia/10.3.1
|
||||
settings:
|
||||
renderRequestBody: true
|
||||
encodeUrl: true
|
||||
followRedirects: global
|
||||
cookies:
|
||||
send: true
|
||||
store: true
|
||||
rebuildPath: true
|
||||
- url: "{{ _.base_url }}/api/users"
|
||||
name: Get Users
|
||||
meta:
|
||||
id: req_0393b8ff4ee1454daddacdda33fd33ea
|
||||
created: 1743683426423
|
||||
modified: 1743683632735
|
||||
isPrivate: false
|
||||
description: Retrieve all users from the system
|
||||
sortKey: -1743683429031
|
||||
method: GET
|
||||
headers:
|
||||
- name: Authorization
|
||||
value: Bearer {{ _.auth_token }}
|
||||
- name: User-Agent
|
||||
value: insomnia/10.3.1
|
||||
settings:
|
||||
renderRequestBody: true
|
||||
encodeUrl: true
|
||||
followRedirects: global
|
||||
cookies:
|
||||
send: true
|
||||
store: true
|
||||
rebuildPath: true
|
||||
- name: Data Management
|
||||
meta:
|
||||
id: fld_4736de73a1634b16960fa9e90d78f868
|
||||
created: 1743683403969
|
||||
modified: 1743683403969
|
||||
sortKey: -1743683403969
|
||||
children:
|
||||
- url: "{{ _.base_url }}/api/posts"
|
||||
name: Create Post
|
||||
meta:
|
||||
id: req_10db7ec1332d4444aa551e70a1bfae33
|
||||
created: 1743683795359
|
||||
modified: 1743683832200
|
||||
isPrivate: false
|
||||
sortKey: -1743683429131
|
||||
method: POST
|
||||
body:
|
||||
mimeType: application/json
|
||||
text: |-
|
||||
{
|
||||
"title": "Test Post",
|
||||
"content": "This is a test post content",
|
||||
"author": "Test Author"
|
||||
}
|
||||
headers:
|
||||
- name: Content-Type
|
||||
value: application/json
|
||||
- name: Authorization
|
||||
value: Bearer {{ _.auth_token }}
|
||||
- name: User-Agent
|
||||
value: insomnia/10.3.1
|
||||
settings:
|
||||
renderRequestBody: true
|
||||
encodeUrl: true
|
||||
followRedirects: global
|
||||
cookies:
|
||||
send: true
|
||||
store: true
|
||||
rebuildPath: true
|
||||
cookieJar:
|
||||
name: Default Jar
|
||||
meta:
|
||||
id: jar_25f97142fa796ae37f7f4937c0ebf3a07869d0a8
|
||||
created: 1743683067908
|
||||
modified: 1743683833282
|
||||
cookies: []
|
||||
environments:
|
||||
name: Base Environment
|
||||
meta:
|
||||
id: env_25f97142fa796ae37f7f4937c0ebf3a07869d0a8
|
||||
created: 1743683067895
|
||||
modified: 1743683476058
|
||||
isPrivate: false
|
||||
data:
|
||||
base_url: https://api.example.com
|
||||
auth_token: your_auth_token_here
|
||||
1
e2e-tests/import-tests/test-data/invalid-json.json
Normal file
1
e2e-tests/import-tests/test-data/invalid-json.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "invalid": json syntax }
|
||||
1
e2e-tests/import-tests/test-data/invalid.txt
Normal file
1
e2e-tests/import-tests/test-data/invalid.txt
Normal file
@@ -0,0 +1 @@
|
||||
This is not a valid collection file
|
||||
374
e2e-tests/import-tests/test-data/openapi-comprehensive.yaml
Normal file
374
e2e-tests/import-tests/test-data/openapi-comprehensive.yaml
Normal file
@@ -0,0 +1,374 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Comprehensive API Test Collection
|
||||
description: A comprehensive API for testing OpenAPI v3 imports with various features
|
||||
version: 2.1.0
|
||||
contact:
|
||||
name: API Support
|
||||
email: support@example.com
|
||||
license:
|
||||
name: MIT
|
||||
url: https://opensource.org/licenses/MIT
|
||||
servers:
|
||||
- url: https://api.example.com/v1
|
||||
description: Production server
|
||||
- url: https://staging-api.example.com/v1
|
||||
description: Staging server
|
||||
- url: http://localhost:3000/v1
|
||||
description: Development server
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKey: []
|
||||
paths:
|
||||
/users:
|
||||
get:
|
||||
summary: Get all users
|
||||
description: Retrieve a paginated list of all users
|
||||
tags:
|
||||
- Users
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
description: Page number for pagination
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 1
|
||||
- name: limit
|
||||
in: query
|
||||
description: Number of items per page
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 20
|
||||
- name: filter
|
||||
in: query
|
||||
description: Filter users by name or email
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: List of users retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
users:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/User'
|
||||
pagination:
|
||||
$ref: '#/components/schemas/Pagination'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
post:
|
||||
summary: Create a new user
|
||||
description: Create a new user account
|
||||
tags:
|
||||
- Users
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateUserRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: User created successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/User'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'409':
|
||||
description: User already exists
|
||||
/users/{userId}:
|
||||
get:
|
||||
summary: Get user by ID
|
||||
description: Retrieve a specific user by their ID
|
||||
tags:
|
||||
- Users
|
||||
parameters:
|
||||
- name: userId
|
||||
in: path
|
||||
required: true
|
||||
description: The user ID
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
'200':
|
||||
description: User retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/User'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
put:
|
||||
summary: Update user
|
||||
description: Update an existing user
|
||||
tags:
|
||||
- Users
|
||||
parameters:
|
||||
- name: userId
|
||||
in: path
|
||||
required: true
|
||||
description: The user ID
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateUserRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: User updated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/User'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
delete:
|
||||
summary: Delete user
|
||||
description: Delete a user account
|
||||
tags:
|
||||
- Users
|
||||
parameters:
|
||||
- name: userId
|
||||
in: path
|
||||
required: true
|
||||
description: The user ID
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
'204':
|
||||
description: User deleted successfully
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
/auth/login:
|
||||
post:
|
||||
summary: User login
|
||||
description: Authenticate user and get access token
|
||||
tags:
|
||||
- Authentication
|
||||
security: [] # No security required for login
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- email
|
||||
- password
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
password:
|
||||
type: string
|
||||
minLength: 8
|
||||
responses:
|
||||
'200':
|
||||
description: Login successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
expiresIn:
|
||||
type: integer
|
||||
user:
|
||||
$ref: '#/components/schemas/User'
|
||||
'401':
|
||||
description: Invalid credentials
|
||||
/posts:
|
||||
get:
|
||||
summary: Get all posts
|
||||
description: Retrieve all blog posts
|
||||
tags:
|
||||
- Posts
|
||||
parameters:
|
||||
- name: author
|
||||
in: query
|
||||
description: Filter by author ID
|
||||
schema:
|
||||
type: string
|
||||
- name: category
|
||||
in: query
|
||||
description: Filter by category
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: List of posts
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Post'
|
||||
post:
|
||||
summary: Create a new post
|
||||
description: Create a new blog post
|
||||
tags:
|
||||
- Posts
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreatePostRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: Post created successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Post'
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
apiKey:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-API-Key
|
||||
schemas:
|
||||
User:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
name:
|
||||
type: string
|
||||
avatar:
|
||||
type: string
|
||||
format: uri
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
CreateUserRequest:
|
||||
type: object
|
||||
required:
|
||||
- email
|
||||
- name
|
||||
- password
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
name:
|
||||
type: string
|
||||
minLength: 2
|
||||
password:
|
||||
type: string
|
||||
minLength: 8
|
||||
avatar:
|
||||
type: string
|
||||
format: uri
|
||||
UpdateUserRequest:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
minLength: 2
|
||||
avatar:
|
||||
type: string
|
||||
format: uri
|
||||
Post:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
title:
|
||||
type: string
|
||||
content:
|
||||
type: string
|
||||
author:
|
||||
$ref: '#/components/schemas/User'
|
||||
category:
|
||||
type: string
|
||||
publishedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
CreatePostRequest:
|
||||
type: object
|
||||
required:
|
||||
- title
|
||||
- content
|
||||
- category
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
minLength: 5
|
||||
content:
|
||||
type: string
|
||||
minLength: 10
|
||||
category:
|
||||
type: string
|
||||
Pagination:
|
||||
type: object
|
||||
properties:
|
||||
page:
|
||||
type: integer
|
||||
limit:
|
||||
type: integer
|
||||
total:
|
||||
type: integer
|
||||
totalPages:
|
||||
type: integer
|
||||
Error:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
code:
|
||||
type: integer
|
||||
responses:
|
||||
BadRequest:
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
Unauthorized:
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
NotFound:
|
||||
description: Resource not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
@@ -0,0 +1,16 @@
|
||||
openapi: 2.0 # Invalid version - only v3 is supported
|
||||
info:
|
||||
title: Invalid OpenAPI Version
|
||||
description: This uses OpenAPI v2 which is not supported
|
||||
version: 1.0.0
|
||||
host: api.example.com
|
||||
basePath: /v1
|
||||
schemes:
|
||||
- https
|
||||
paths:
|
||||
/users:
|
||||
get:
|
||||
summary: Get users
|
||||
responses:
|
||||
'200':
|
||||
description: List of users
|
||||
16
e2e-tests/import-tests/test-data/openapi-malformed.yaml
Normal file
16
e2e-tests/import-tests/test-data/openapi-malformed.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Malformed OpenAPI
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/test:
|
||||
get:
|
||||
summary: Test endpoint
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
# Missing closing quotes and malformed YAML
|
||||
'400':
|
||||
description: Bad request
|
||||
malformed: yaml here
|
||||
extra: indentation
|
||||
@@ -0,0 +1,9 @@
|
||||
openapi: 3.0.0
|
||||
# Missing required info section
|
||||
paths:
|
||||
/test:
|
||||
get:
|
||||
summary: Test endpoint
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
116
e2e-tests/import-tests/test-data/openapi-simple.json
Normal file
116
e2e-tests/import-tests/test-data/openapi-simple.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"title": "Simple Test API",
|
||||
"description": "A simple API for basic OpenAPI testing",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://httpbin.org",
|
||||
"description": "HTTPBin test server"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/get": {
|
||||
"get": {
|
||||
"summary": "HTTP GET test",
|
||||
"description": "Test HTTP GET request",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"args": {
|
||||
"type": "object"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object"
|
||||
},
|
||||
"origin": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/post": {
|
||||
"post": {
|
||||
"summary": "HTTP POST test",
|
||||
"description": "Test HTTP POST request",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"json": {
|
||||
"type": "object"
|
||||
},
|
||||
"origin": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/status/{code}": {
|
||||
"get": {
|
||||
"summary": "Return status code",
|
||||
"description": "Return a specific HTTP status code",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "code",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "HTTP status code to return",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "Returns the specified status code"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"item": [
|
||||
{
|
||||
"name": "Request without info",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "https://example.com"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "Invalid Schema Collection",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v999.0.0/collection.json"
|
||||
},
|
||||
"item": []
|
||||
}
|
||||
6
e2e-tests/import-tests/test-data/postman-malformed.json
Normal file
6
e2e-tests/import-tests/test-data/postman-malformed.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "Malformed Collection"
|
||||
},
|
||||
"item": "this should be an array, not a string"
|
||||
}
|
||||
17
e2e-tests/import-tests/test-data/postman-v20.json
Normal file
17
e2e-tests/import-tests/test-data/postman-v20.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "Postman v2.0 Collection",
|
||||
"description": "Test collection using Postman Collection Format v2.0",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.0.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Get Posts",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": "https://jsonplaceholder.typicode.com/posts"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
64
e2e-tests/import-tests/test-data/postman-v21.json
Normal file
64
e2e-tests/import-tests/test-data/postman-v21.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "Postman v2.1 Collection",
|
||||
"description": "Test collection using Postman Collection Format v2.1",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
"_postman_id": "12345678-1234-1234-1234-123456789012"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Get Users",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{token}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/users",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["users"],
|
||||
"query": [
|
||||
{
|
||||
"key": "page",
|
||||
"value": "1"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Create User",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"John Doe\",\n \"email\": \"john@example.com\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/users",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["users"]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "baseUrl",
|
||||
"value": "https://api.example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
720
package-lock.json
generated
720
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@
|
||||
"@prantlf/jsonlint": "^16.0.0",
|
||||
"@reduxjs/toolkit": "^1.8.0",
|
||||
"@tabler/icons": "^1.46.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@usebruno/common": "0.1.0",
|
||||
"@usebruno/graphql-docs": "0.1.0",
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
/* Screen reader only content */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.command-k-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
z-index: 20;
|
||||
background-color: transparent;
|
||||
&:before {
|
||||
content: '';
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
opacity: ${(props) => props.theme.modal.backdrop.opacity};
|
||||
top: 0;
|
||||
background: black;
|
||||
position: fixed;
|
||||
}
|
||||
animation: fade-in 0.1s forwards cubic-bezier(0.19, 1, 0.22, 1);
|
||||
}
|
||||
.command-k-modal {
|
||||
background: ${(props) => props.theme.modal.body.bg};
|
||||
border: 1px solid ${(props) => props.theme.modal.input.border};
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
margin: 80px auto;
|
||||
animation: fade-and-slide-in-from-top 0.3s forwards cubic-bezier(0.19, 1, 0.22, 1);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
.command-k-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.modal.input.border};
|
||||
background: ${(props) => props.theme.modal.title.bg};
|
||||
}
|
||||
.search-input-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid ${(props) => props.theme.modal.input.border};
|
||||
border-radius: 6px;
|
||||
background: ${(props) => props.theme.modal.input.bg};
|
||||
transition: all 0.2s ease;
|
||||
&:focus-within {
|
||||
border-color: ${(props) => props.theme.colors.text.muted};
|
||||
box-shadow: 0 0 0 1px ${(props) => props.theme.colors.text.muted}40;
|
||||
}
|
||||
.search-icon {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.8;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.clear-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.8;
|
||||
margin-left: 8px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'};
|
||||
}
|
||||
}
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
.command-k-results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 400px;
|
||||
background: ${(props) => props.theme.modal.body.bg};
|
||||
scrollbar-width: thin;
|
||||
padding: 4px;
|
||||
scroll-behavior: smooth;
|
||||
/* Webkit scrollbar styling */
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'};
|
||||
border-radius: 4px;
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'};
|
||||
}
|
||||
}
|
||||
}
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
border-left: 2px solid transparent;
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'};
|
||||
}
|
||||
&.selected {
|
||||
background: ${(props) => `${props.theme.colors.text.yellow}15`};
|
||||
border-left: 2px solid ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
}
|
||||
.result-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.8;
|
||||
}
|
||||
.result-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.result-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.result-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.result-name {
|
||||
font-size: 13px;
|
||||
margin-bottom: 3px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: ${(props) => props.theme.text};
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.result-path {
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: 0.1px;
|
||||
}
|
||||
.method-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
min-width: 55px;
|
||||
text-align: center;
|
||||
&.get {
|
||||
color: #2ecc71;
|
||||
background: rgba(46, 204, 113, 0.1);
|
||||
}
|
||||
&.post {
|
||||
color: #3498db;
|
||||
background: rgba(52, 152, 219, 0.1);
|
||||
}
|
||||
&.put {
|
||||
color: #e67e22;
|
||||
background: rgba(230, 126, 34, 0.1);
|
||||
}
|
||||
&.delete {
|
||||
color: #e74c3c;
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
}
|
||||
&.patch {
|
||||
color: #9b59b6;
|
||||
background: rgba(155, 89, 182, 0.1);
|
||||
}
|
||||
&.head {
|
||||
color: #2980b9;
|
||||
background: rgba(41, 128, 185, 0.1);
|
||||
}
|
||||
&.options {
|
||||
color: #f1c40f;
|
||||
background: rgba(241, 196, 15, 0.1);
|
||||
}
|
||||
&.unary {
|
||||
color: #27ae60;
|
||||
background: rgba(39, 174, 96, 0.12);
|
||||
font-weight: 600;
|
||||
}
|
||||
&.client-streaming {
|
||||
color: #2980b9;
|
||||
background: rgba(41, 128, 185, 0.12);
|
||||
font-weight: 600;
|
||||
}
|
||||
&.server-streaming {
|
||||
color: #f39c12;
|
||||
background: rgba(243, 156, 18, 0.12);
|
||||
font-weight: 600;
|
||||
}
|
||||
&.bidirectional-streaming,
|
||||
&.bidi-streaming {
|
||||
color: #8e44ad;
|
||||
background: rgba(142, 68, 173, 0.12);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
.result-type {
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)'};
|
||||
opacity: 0.8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.result-item[data-type="documentation"] {
|
||||
.result-icon {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.8;
|
||||
}
|
||||
.result-path {
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: 0.1px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
&:hover:not(.selected) {
|
||||
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'};
|
||||
}
|
||||
}
|
||||
.no-results,
|
||||
.empty-state {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 13px;
|
||||
}
|
||||
.command-k-footer {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid ${(props) => props.theme.modal.input.border};
|
||||
background: ${(props) => props.theme.colors.surface};
|
||||
}
|
||||
.keyboard-hints {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.2px;
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
.hint-icon {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.8;
|
||||
}
|
||||
.hint-icon + .hint-icon {
|
||||
margin-left: -8px;
|
||||
}
|
||||
.keycap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid ${(props) => props.theme.modal.input.border};
|
||||
border-radius: 4px;
|
||||
background: ${(props) =>
|
||||
props.theme.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'};
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
line-height: 1;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
}
|
||||
.highlight {
|
||||
background: ${(props) => `${props.theme.colors.text.yellow}30`};
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
margin: 0 -1px;
|
||||
font-weight: 500;
|
||||
}
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes fade-and-slide-in-from-top {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,32 @@
|
||||
export const SEARCH_TYPES = {
|
||||
DOCUMENTATION: 'documentation',
|
||||
COLLECTION: 'collection',
|
||||
FOLDER: 'folder',
|
||||
REQUEST: 'request'
|
||||
};
|
||||
|
||||
export const MATCH_TYPES = {
|
||||
COLLECTION: 'collection',
|
||||
FOLDER: 'folder',
|
||||
REQUEST: 'request',
|
||||
URL: 'url',
|
||||
PATH: 'path',
|
||||
DOCUMENTATION: 'documentation'
|
||||
};
|
||||
|
||||
export const SEARCH_CONFIG = {
|
||||
MAX_DEPTH: 20,
|
||||
FOCUS_DELAY: 100,
|
||||
SCROLL_BEHAVIOR: 'smooth',
|
||||
SCROLL_BLOCK: 'nearest',
|
||||
DEBOUNCE_DELAY: 300
|
||||
};
|
||||
|
||||
export const DOCUMENTATION_RESULT = {
|
||||
type: SEARCH_TYPES.DOCUMENTATION,
|
||||
item: { id: 'docs', name: 'Bruno Documentation' },
|
||||
name: 'Bruno Documentation',
|
||||
path: '/',
|
||||
description: 'Browse the official Bruno documentation',
|
||||
matchType: MATCH_TYPES.DOCUMENTATION
|
||||
};
|
||||
515
packages/bruno-app/src/components/GlobalSearchModal/index.js
Normal file
515
packages/bruno-app/src/components/GlobalSearchModal/index.js
Normal file
@@ -0,0 +1,515 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
IconSearch,
|
||||
IconX,
|
||||
IconFolder,
|
||||
IconBox,
|
||||
IconFileText,
|
||||
IconBook
|
||||
} from '@tabler/icons';
|
||||
import { flattenItems, isItemARequest, isItemAFolder, findParentItemInCollection } from 'utils/collections';
|
||||
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { hideHomePage } from 'providers/ReduxStore/slices/app';
|
||||
import { toggleCollectionItem, toggleCollection } from 'providers/ReduxStore/slices/collections';
|
||||
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { getDefaultRequestPaneTab } from 'utils/collections';
|
||||
import { normalizeQuery, isValidQuery, highlightText, sortResults, getTypeLabel, getItemPath } from './utils/searchUtils';
|
||||
import { SEARCH_TYPES, MATCH_TYPES, SEARCH_CONFIG, DOCUMENTATION_RESULT } from './constants';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const GlobalSearchModal = ({ isOpen, onClose }) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [results, setResults] = useState([]);
|
||||
const inputRef = useRef(null);
|
||||
const resultsRef = useRef(null);
|
||||
const debounceTimeoutRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
|
||||
const createCollectionResults = () => {
|
||||
const collectionResults = collections.map(collection => ({
|
||||
type: SEARCH_TYPES.COLLECTION,
|
||||
item: collection,
|
||||
name: collection.name,
|
||||
path: collection.name,
|
||||
matchType: MATCH_TYPES.COLLECTION,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
|
||||
collectionResults.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return [DOCUMENTATION_RESULT, ...collectionResults];
|
||||
};
|
||||
|
||||
const searchInCollections = (searchTerms, enablePathMatch) => {
|
||||
const results = [];
|
||||
|
||||
// Check for documentation match
|
||||
const queryLower = searchTerms.join(' ');
|
||||
if (['documentation', 'docs', 'bruno docs'].some(term => term.includes(queryLower))) {
|
||||
results.push(DOCUMENTATION_RESULT);
|
||||
}
|
||||
|
||||
collections.forEach(collection => {
|
||||
// Search collection name
|
||||
if (searchTerms.every(term => collection.name.toLowerCase().includes(term))) {
|
||||
results.push({
|
||||
type: SEARCH_TYPES.COLLECTION,
|
||||
item: collection,
|
||||
name: collection.name,
|
||||
path: collection.name,
|
||||
matchType: MATCH_TYPES.COLLECTION,
|
||||
collectionUid: collection.uid
|
||||
});
|
||||
}
|
||||
|
||||
// Search collection items
|
||||
const flattenedItems = flattenItems(collection.items);
|
||||
flattenedItems.forEach(item => {
|
||||
const itemPath = getItemPath(item, collection, findParentItemInCollection);
|
||||
const itemPathLower = itemPath.toLowerCase();
|
||||
|
||||
if (isItemARequest(item)) {
|
||||
const nameMatch = searchTerms.every(term => item.name.toLowerCase().includes(term));
|
||||
const urlMatch = searchTerms.every(term => (item.request?.url || '').toLowerCase().includes(term));
|
||||
const pathMatch = enablePathMatch && searchTerms.every(term => itemPathLower.includes(term));
|
||||
|
||||
if (nameMatch || urlMatch || pathMatch) {
|
||||
// Check if this is a gRPC request and get the method type
|
||||
const isGrpcRequest = item.request?.type === 'grpc';
|
||||
|
||||
let method = item.request?.method || '';
|
||||
|
||||
if (isGrpcRequest) {
|
||||
// For gRPC requests, use the methodType
|
||||
const methodType = item.request?.methodType || 'UNARY';
|
||||
method = methodType.toLowerCase().replace(/[_]/g, '-');
|
||||
}
|
||||
|
||||
results.push({
|
||||
type: SEARCH_TYPES.REQUEST,
|
||||
item,
|
||||
name: item.name,
|
||||
path: itemPath,
|
||||
matchType: nameMatch ? MATCH_TYPES.REQUEST : urlMatch ? MATCH_TYPES.URL : MATCH_TYPES.PATH,
|
||||
method,
|
||||
collectionUid: collection.uid
|
||||
});
|
||||
}
|
||||
} else if (isItemAFolder(item)) {
|
||||
const nameMatch = searchTerms.every(term => item.name.toLowerCase().includes(term));
|
||||
const pathMatch = enablePathMatch && searchTerms.every(term => itemPathLower.includes(term));
|
||||
|
||||
if (nameMatch || pathMatch) {
|
||||
results.push({
|
||||
type: SEARCH_TYPES.FOLDER,
|
||||
item,
|
||||
name: item.name,
|
||||
path: itemPath,
|
||||
matchType: nameMatch ? MATCH_TYPES.FOLDER : MATCH_TYPES.PATH,
|
||||
collectionUid: collection.uid
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const performSearch = (searchQuery) => {
|
||||
const normalizedQuery = normalizeQuery(searchQuery);
|
||||
|
||||
if (!normalizedQuery) {
|
||||
setResults(createCollectionResults());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidQuery(normalizedQuery)) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchTerms = normalizedQuery.toLowerCase().split(/[\s\/]+/).filter(Boolean);
|
||||
if (!searchTerms.length) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const enablePathMatch = normalizedQuery.includes('/');
|
||||
const searchResults = searchInCollections(searchTerms, enablePathMatch);
|
||||
const sortedResults = sortResults(searchResults);
|
||||
|
||||
setResults(sortedResults);
|
||||
setSelectedIndex(0);
|
||||
};
|
||||
|
||||
const debouncedSearch = useCallback((searchQuery) => {
|
||||
// Clear existing timeout
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
performSearch(searchQuery);
|
||||
}, SEARCH_CONFIG.DEBOUNCE_DELAY);
|
||||
}, [collections]); // Depend on collections to recreate when they change
|
||||
|
||||
const expandItemPath = (result) => {
|
||||
const collection = collections.find(c => c.uid === result.collectionUid);
|
||||
if (!collection) return;
|
||||
|
||||
ensureCollectionIsMounted(collection);
|
||||
|
||||
if (collection.collapsed) {
|
||||
dispatch(toggleCollection(collection.uid));
|
||||
}
|
||||
|
||||
let currentItem = result.type === SEARCH_TYPES.FOLDER
|
||||
? result.item
|
||||
: findParentItemInCollection(collection, result.item.uid);
|
||||
|
||||
while (currentItem?.type === 'folder') {
|
||||
if (currentItem.collapsed) {
|
||||
dispatch(toggleCollectionItem({ collectionUid: collection.uid, itemUid: currentItem.uid }));
|
||||
}
|
||||
currentItem = findParentItemInCollection(collection, currentItem.uid);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureCollectionIsMounted = (collection) => {
|
||||
if (!collection || collection.mountStatus === 'mounted') return;
|
||||
dispatch(mountCollection({
|
||||
collectionUid: collection.uid,
|
||||
collectionPathname: collection.pathname,
|
||||
brunoConfig: collection.brunoConfig
|
||||
}));
|
||||
};
|
||||
|
||||
const handleKeyNavigation = (e) => {
|
||||
const handlers = {
|
||||
ArrowDown: () => {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => prev < results.length - 1 ? prev + 1 : 0);
|
||||
},
|
||||
ArrowUp: () => {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => prev > 0 ? prev - 1 : results.length - 1);
|
||||
},
|
||||
Enter: () => {
|
||||
e.preventDefault();
|
||||
if (results[selectedIndex]) {
|
||||
handleResultSelection(results[selectedIndex]);
|
||||
}
|
||||
},
|
||||
Escape: () => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
},
|
||||
PageDown: () => {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.min(prev + 5, results.length - 1));
|
||||
},
|
||||
PageUp: () => {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.max(prev - 5, 0));
|
||||
},
|
||||
Home: () => {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(0);
|
||||
},
|
||||
End: () => {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(results.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handler = handlers[e.key];
|
||||
if (handler) handler();
|
||||
};
|
||||
|
||||
const handleResultSelection = (result) => {
|
||||
const targetCollection = collections.find(c => c.uid === result.collectionUid);
|
||||
ensureCollectionIsMounted(targetCollection);
|
||||
|
||||
if (result.type === SEARCH_TYPES.DOCUMENTATION) {
|
||||
window.open('https://docs.usebruno.com/', '_blank');
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
expandItemPath(result);
|
||||
|
||||
if (result.type === SEARCH_TYPES.REQUEST) {
|
||||
dispatch(hideHomePage());
|
||||
|
||||
const existingTab = tabs.find(tab => tab.uid === result.item.uid);
|
||||
|
||||
if (existingTab) {
|
||||
dispatch(focusTab({ uid: result.item.uid }));
|
||||
} else {
|
||||
dispatch(addTab({
|
||||
uid: result.item.uid,
|
||||
collectionUid: result.collectionUid,
|
||||
requestPaneTab: getDefaultRequestPaneTab(result.item),
|
||||
type: 'request',
|
||||
}));
|
||||
}
|
||||
} else if (result.type === SEARCH_TYPES.FOLDER) {
|
||||
dispatch(addTab({
|
||||
uid: result.item.uid,
|
||||
collectionUid: result.collectionUid,
|
||||
type: 'folder-settings',
|
||||
}));
|
||||
} else if (result.type === SEARCH_TYPES.COLLECTION) {
|
||||
dispatch(addTab({
|
||||
uid: result.item.uid,
|
||||
collectionUid: result.collectionUid,
|
||||
type: 'collection-settings',
|
||||
}));
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleQueryChange = (e) => {
|
||||
const newQuery = e.target.value;
|
||||
setQuery(newQuery);
|
||||
|
||||
if (newQuery.trim()) {
|
||||
debouncedSearch(newQuery);
|
||||
} else {
|
||||
// For empty queries, search immediately to show collections
|
||||
performSearch(newQuery);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
// Clear any pending debounced search
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
};
|
||||
|
||||
// Initialize modal when opened
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const timeoutId = setTimeout(() => inputRef.current?.focus(), SEARCH_CONFIG.FOCUS_DELAY);
|
||||
setQuery('');
|
||||
performSearch('');
|
||||
setSelectedIndex(0);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
} else {
|
||||
// Clear any pending debounced search when modal closes
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Auto-scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (resultsRef.current && results.length > 0) {
|
||||
const selectedElement = resultsRef.current.children[selectedIndex];
|
||||
selectedElement?.scrollIntoView({
|
||||
behavior: SEARCH_CONFIG.SCROLL_BEHAVIOR,
|
||||
block: SEARCH_CONFIG.SCROLL_BLOCK
|
||||
});
|
||||
}
|
||||
}, [selectedIndex, results]);
|
||||
|
||||
// Cleanup debounce timeout on unmount or modal close
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getResultIcon = (type) => {
|
||||
const iconMap = {
|
||||
[SEARCH_TYPES.DOCUMENTATION]: IconBook,
|
||||
[SEARCH_TYPES.COLLECTION]: IconBox,
|
||||
[SEARCH_TYPES.FOLDER]: IconFolder,
|
||||
[SEARCH_TYPES.REQUEST]: IconFileText
|
||||
};
|
||||
const IconComponent = iconMap[type] || IconFileText;
|
||||
return <IconComponent size={18} stroke={1.5} />;
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div
|
||||
className="command-k-overlay"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="search-modal-title"
|
||||
aria-describedby="search-modal-description"
|
||||
>
|
||||
<div className="command-k-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h1 id="search-modal-title" className="sr-only">Global Search</h1>
|
||||
<p id="search-modal-description" className="sr-only">
|
||||
Search through collections, requests, folders, and documentation. Use arrow keys to navigate results and Enter to select.
|
||||
</p>
|
||||
<div aria-live="polite" aria-atomic="true" className="sr-only">
|
||||
{results.length > 0 && query
|
||||
? `${results.length} result${results.length === 1 ? '' : 's'} found`
|
||||
: query && results.length === 0
|
||||
? 'No results found'
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
<div className="command-k-header">
|
||||
<div className="search-input-container">
|
||||
<IconSearch size={20} className="search-icon" aria-hidden="true" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search collections, requests, or documentation..."
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
onKeyDown={handleKeyNavigation}
|
||||
className="search-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
aria-label="Search collections, requests, or documentation"
|
||||
aria-expanded={results.length > 0}
|
||||
aria-controls="search-results"
|
||||
aria-activedescendant={results.length > 0 ? `search-result-${selectedIndex}` : undefined}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
className="clear-button"
|
||||
aria-label="Clear search query"
|
||||
type="button"
|
||||
>
|
||||
<IconX size={16} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="command-k-results"
|
||||
ref={resultsRef}
|
||||
id="search-results"
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
>
|
||||
{results.length === 0 && query ? (
|
||||
<div className="no-results">
|
||||
<p>
|
||||
No results found for "{query}".
|
||||
<br />
|
||||
<span className="block mt-2">
|
||||
The item might not exist yet, or its collection isn’t mounted. Press <strong>Enter</strong> here (or open it from the sidebar) to mount the collection automatically.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>
|
||||
No collections are currently mounted or visible.
|
||||
<br />
|
||||
<span className="block mt-2">
|
||||
Mount a collection via the sidebar or this search modal, then try again.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
results.map((result, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const typeLabel = getTypeLabel(result.type);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${result.type}-${result.item.id || result.item.uid}-${index}`}
|
||||
id={`search-result-${index}`}
|
||||
className={`result-item ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => handleResultSelection(result)}
|
||||
data-selected={isSelected}
|
||||
data-type={result.type}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-label={`${result.name}, ${typeLabel || result.type}${result.method ? `, ${result.method}` : ''}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="result-icon">
|
||||
{getResultIcon(result.type)}
|
||||
</div>
|
||||
<div className="result-content">
|
||||
<div className="result-info">
|
||||
<div className="result-name">
|
||||
{highlightText(result.name, query)}
|
||||
</div>
|
||||
<div className="result-path">
|
||||
{result.type === SEARCH_TYPES.DOCUMENTATION
|
||||
? result.description
|
||||
: result.type === SEARCH_TYPES.REQUEST
|
||||
? highlightText(result.item.request?.url || '', query)
|
||||
: highlightText(result.path, query)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="result-badges">
|
||||
{result.type === SEARCH_TYPES.REQUEST && result.method && (
|
||||
<span
|
||||
className={`method-badge ${result.method.toLowerCase()}`}
|
||||
aria-label={`HTTP method ${result.method.toUpperCase().replace(/-/g, ' ')}`}
|
||||
>
|
||||
{result.method.toUpperCase().replace(/-/g, ' ')}
|
||||
</span>
|
||||
)}
|
||||
{typeLabel && (
|
||||
<div className="result-type" aria-label={`Item type ${typeLabel}`}>
|
||||
{typeLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="command-k-footer">
|
||||
<div className="keyboard-hints" role="region" aria-label="Keyboard shortcuts">
|
||||
<span aria-label="Use up and down arrows to navigate">
|
||||
<span className="keycap" aria-hidden="true">↑</span>
|
||||
<span className="keycap" aria-hidden="true">↓</span>
|
||||
<span className="hint-label">to navigate</span>
|
||||
</span>
|
||||
<span aria-label="Press Enter to select">
|
||||
<span className="keycap" aria-hidden="true">↵</span>
|
||||
<span className="hint-label">to select</span>
|
||||
</span>
|
||||
<span aria-label="Press Escape to close">
|
||||
<span className="keycap" aria-hidden="true">esc</span>
|
||||
<span className="hint-label">to close</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalSearchModal;
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { SEARCH_TYPES, MATCH_TYPES, SEARCH_CONFIG } from '../constants';
|
||||
|
||||
export const normalizeQuery = (searchQuery) => {
|
||||
return searchQuery.trim().replace(/\/+/g, '/');
|
||||
};
|
||||
|
||||
export const isValidQuery = (normalizedQuery) => {
|
||||
return normalizedQuery &&
|
||||
normalizedQuery !== '/' &&
|
||||
!(normalizedQuery.length === 1 && !normalizedQuery.match(/[a-zA-Z0-9]/));
|
||||
};
|
||||
|
||||
export const highlightText = (text, searchQuery) => {
|
||||
if (!searchQuery) return text;
|
||||
|
||||
try {
|
||||
const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
||||
return text.split(regex).map((part, i) =>
|
||||
regex.test(part) ? (
|
||||
<span key={i} className="highlight">{part}</span>
|
||||
) : part
|
||||
);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
export const sortResults = (results) => {
|
||||
return results.sort((a, b) => {
|
||||
// Documentation always first
|
||||
if (a.type === SEARCH_TYPES.DOCUMENTATION) return -1;
|
||||
if (b.type === SEARCH_TYPES.DOCUMENTATION) return 1;
|
||||
|
||||
// Sort by match type priority
|
||||
const matchTypeOrder = {
|
||||
[MATCH_TYPES.COLLECTION]: 0,
|
||||
[MATCH_TYPES.FOLDER]: 1,
|
||||
[MATCH_TYPES.REQUEST]: 2,
|
||||
[MATCH_TYPES.URL]: 3,
|
||||
[MATCH_TYPES.PATH]: 4
|
||||
};
|
||||
const aMatchType = matchTypeOrder[a.matchType] ?? 5;
|
||||
const bMatchType = matchTypeOrder[b.matchType] ?? 5;
|
||||
|
||||
if (aMatchType !== bMatchType) return aMatchType - bMatchType;
|
||||
|
||||
// Sort by type priority
|
||||
const typeOrder = {
|
||||
[SEARCH_TYPES.COLLECTION]: 0,
|
||||
[SEARCH_TYPES.FOLDER]: 1,
|
||||
[SEARCH_TYPES.REQUEST]: 2
|
||||
};
|
||||
const aType = typeOrder[a.type] ?? 3;
|
||||
const bType = typeOrder[b.type] ?? 3;
|
||||
|
||||
if (aType !== bType) return aType - bType;
|
||||
|
||||
// Finally sort alphabetically
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
};
|
||||
|
||||
export const getTypeLabel = (type) => {
|
||||
const baseLabels = {
|
||||
[SEARCH_TYPES.DOCUMENTATION]: 'Documentation',
|
||||
[SEARCH_TYPES.COLLECTION]: 'Collection',
|
||||
[SEARCH_TYPES.FOLDER]: 'Folder'
|
||||
};
|
||||
|
||||
return baseLabels[type] || '';
|
||||
};
|
||||
|
||||
export const getItemPath = (item, collection, findParentItemInCollection) => {
|
||||
const pathParts = [];
|
||||
let currentItem = item;
|
||||
let depth = 0;
|
||||
const maxDepth = SEARCH_CONFIG.MAX_DEPTH;
|
||||
|
||||
while (currentItem && depth < maxDepth) {
|
||||
pathParts.unshift(currentItem.name);
|
||||
const parent = findParentItemInCollection(collection, currentItem.uid);
|
||||
if (parent) {
|
||||
currentItem = parent;
|
||||
depth++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
pathParts.unshift(collection.name);
|
||||
return pathParts.join('/');
|
||||
};
|
||||
@@ -9,7 +9,7 @@ const ModalHeader = ({ title, handleCancel, customHeader, hideClose }) => (
|
||||
<div className="bruno-modal-header">
|
||||
{customHeader ? customHeader : <>{title ? <div className="bruno-modal-header-title">{title}</div> : null}</>}
|
||||
{handleCancel && !hideClose ? (
|
||||
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null}>
|
||||
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null} data-test-id="modal-close-button">
|
||||
×
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import HttpMethodSelector from './index';
|
||||
import themes from 'themes/index';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const renderWithTheme = (component) => {
|
||||
return render(
|
||||
<ThemeProvider theme={themes.dark}>
|
||||
{component}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('HttpMethodSelector', () => {
|
||||
const mockOnMethodSelect = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnMethodSelect.mockClear();
|
||||
});
|
||||
|
||||
describe('Initial Render', () => {
|
||||
it('should render with default GET method when no method prop is provided', () => {
|
||||
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const methodSpan = screen.getByText('GET');
|
||||
expect(methodSpan).toBeInTheDocument();
|
||||
expect(methodSpan).toHaveClass('method-span');
|
||||
expect(methodSpan).toHaveAttribute('title', 'GET');
|
||||
});
|
||||
|
||||
it('should render with a standard method when method prop is provided', () => {
|
||||
renderWithTheme(<HttpMethodSelector method="POST" onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const methodSpan = screen.getByText('POST');
|
||||
expect(methodSpan).toBeInTheDocument();
|
||||
expect(methodSpan).toHaveAttribute('title', 'POST');
|
||||
});
|
||||
|
||||
it('should render with a custom method when method prop is provided', () => {
|
||||
renderWithTheme(<HttpMethodSelector method="CUSTOM" onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const methodSpan = screen.getByText('CUSTOM');
|
||||
expect(methodSpan).toBeInTheDocument();
|
||||
expect(methodSpan).toHaveAttribute('title', 'CUSTOM');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dropdown Interaction', () => {
|
||||
it('should display all standard HTTP methods in dropdown when clicked', async () => {
|
||||
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const standardMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD', 'TRACE', 'CONNECT'];
|
||||
const dropdownItems = screen.getAllByText((content, element) => {
|
||||
return element?.classList.contains('dropdown-item');
|
||||
});
|
||||
const renderedMethods = dropdownItems.map(item => item.textContent);
|
||||
|
||||
standardMethods.forEach(method => {
|
||||
expect(renderedMethods).toContain(method);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should display "Add Custom" option in dropdown', async () => {
|
||||
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const addCustomSpan = screen.getByText('+ Add Custom');
|
||||
expect(addCustomSpan).toBeInTheDocument();
|
||||
expect(addCustomSpan).toHaveClass('text-link');
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onMethodSelect when a standard method is clicked', async () => {
|
||||
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const postMethod = screen.getByText('POST');
|
||||
fireEvent.click(postMethod);
|
||||
});
|
||||
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('POST');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Method Mode', () => {
|
||||
it('should enter custom mode when "Add Custom" is clicked', async () => {
|
||||
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const addCustom = screen.getByText('+ Add Custom');
|
||||
fireEvent.click(addCustom);
|
||||
});
|
||||
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('');
|
||||
|
||||
// Should show input field
|
||||
await waitFor(() => {
|
||||
const input = screen.getByRole('textbox');
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onMethodSelect with uppercase value when typing in custom input', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Create a wrapper component that manages the method state
|
||||
const TestWrapper = () => {
|
||||
const [method, setMethod] = React.useState('GET');
|
||||
|
||||
const handleMethodSelect = (newMethod) => {
|
||||
mockOnMethodSelect(newMethod);
|
||||
setMethod(newMethod);
|
||||
};
|
||||
|
||||
return (
|
||||
<HttpMethodSelector
|
||||
method={method}
|
||||
onMethodSelect={handleMethodSelect}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderWithTheme(<TestWrapper />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const addCustom = screen.getByText('+ Add Custom');
|
||||
fireEvent.click(addCustom);
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
await user.type(input, 'custom');
|
||||
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('');
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('C');
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('CU');
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUS');
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUST');
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUSTO');
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUSTOM');
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledTimes(7);
|
||||
});
|
||||
|
||||
it('should exit custom mode and set method on Enter key', async () => {
|
||||
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const addCustom = screen.getByText('+ Add Custom');
|
||||
fireEvent.click(addCustom);
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: 'CUSTOM' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUSTOM');
|
||||
|
||||
// Should exit custom mode
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should set default method on Enter key with empty input', async () => {
|
||||
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const addCustom = screen.getByText('+ Add Custom');
|
||||
fireEvent.click(addCustom);
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('GET');
|
||||
});
|
||||
|
||||
it('should exit custom mode on Escape key and keep the custom method', async () => {
|
||||
renderWithTheme(<HttpMethodSelector method="POST" onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const addCustom = screen.getByText('+ Add Custom');
|
||||
fireEvent.click(addCustom);
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: 'CUSTOM' } });
|
||||
fireEvent.keyDown(input, { key: 'Escape' });
|
||||
|
||||
// Should exit custom mode and onMethodSelect should be called with custom method
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUSTOM');
|
||||
});
|
||||
});
|
||||
|
||||
it('should exit custom mode on blur and keep the custom method', async () => {
|
||||
renderWithTheme(<HttpMethodSelector onMethodSelect={mockOnMethodSelect} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
const addCustom = screen.getByText('+ Add Custom');
|
||||
fireEvent.click(addCustom);
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: 'CUSTOM' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
// Should exit custom mode and onMethodSelect should be called with custom method
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
expect(mockOnMethodSelect).toHaveBeenCalledWith('CUSTOM');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -63,7 +63,8 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
div.graphql-docs-explorer-container {
|
||||
background: white;
|
||||
background: ${(props) => props.theme.requestTabPanel.graphqlDocsExplorer.bg};
|
||||
color: ${(props) => props.theme.requestTabPanel.graphqlDocsExplorer.color};
|
||||
outline: none;
|
||||
box-shadow: rgb(0 0 0 / 15%) 0px 0px 8px;
|
||||
position: absolute;
|
||||
@@ -72,6 +73,14 @@ const StyledWrapper = styled.div`
|
||||
width: 350px;
|
||||
height: 100%;
|
||||
|
||||
.doc-explorer-contents,
|
||||
.doc-explorer,
|
||||
.search-box > input,
|
||||
.search-box-clear {
|
||||
background-color: ${(props) => props.theme.requestTabPanel.graphqlDocsExplorer.bg};
|
||||
color: ${(props) => props.theme.requestTabPanel.graphqlDocsExplorer.color};
|
||||
}
|
||||
|
||||
div.doc-explorer-title {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ const TitleBar = () => {
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center">
|
||||
<button className="flex items-center gap-2 text-sm font-medium" onClick={handleTitleClick}>
|
||||
<button className="bruno-logo flex items-center gap-2 text-sm font-medium" onClick={handleTitleClick}>
|
||||
<span aria-hidden>
|
||||
<Bruno width={30} />
|
||||
</span>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconSettings, IconCookie, IconTool } from '@tabler/icons';
|
||||
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
|
||||
import { IconSettings, IconCookie, IconTool, IconSearch } from '@tabler/icons';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import { getKeyBindingsForActionAllOS } from 'providers/Hotkeys/keyMappings';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
import Preferences from 'components/Preferences';
|
||||
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
|
||||
import Cookies from 'components/Cookies';
|
||||
import Notifications from 'components/Notifications';
|
||||
import Portal from 'components/Portal';
|
||||
@@ -26,6 +28,13 @@ const StatusBar = () => {
|
||||
dispatch(openConsole());
|
||||
};
|
||||
|
||||
const openGlobalSearch = () => {
|
||||
const bindings = getKeyBindingsForActionAllOS('globalSearch') || [];
|
||||
bindings.forEach((binding) => {
|
||||
Mousetrap.trigger(binding);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{preferencesOpen && (
|
||||
@@ -93,6 +102,19 @@ const StatusBar = () => {
|
||||
|
||||
<div className="status-bar-section">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="status-bar-button"
|
||||
data-trigger="search"
|
||||
onClick={openGlobalSearch}
|
||||
tabIndex={0}
|
||||
aria-label="Global Search"
|
||||
>
|
||||
<div className="console-button-content">
|
||||
<IconSearch size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<span className="console-label">Search</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="status-bar-button"
|
||||
data-trigger="cookies"
|
||||
|
||||
@@ -24,6 +24,22 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.keycap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid ${(props) => props.theme.modal.input.border};
|
||||
border-radius: 4px;
|
||||
background: ${(props) =>
|
||||
props.theme.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'};
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
line-height: 1;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -138,6 +138,12 @@ const Welcome = () => {
|
||||
<span className="label ml-2">{t('COMMON.GITHUB')}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 select-none">
|
||||
{t('WELCOME.GLOBAL_SEARCH_TIP_PART1')} <span className="keycap">⌘</span>{' '}<span className="keycap">K</span>{' '}
|
||||
{t('WELCOME.GLOBAL_SEARCH_TIP_PART2')} <span className="keycap">Ctrl</span>{' '}<span className="keycap">K</span>{' '}
|
||||
{t('WELCOME.GLOBAL_SEARCH_TIP_PART3')}
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"IMPORT_COLLECTION": "Import Collection",
|
||||
"COLLECTION_IMPORT_SUCCESS": "Collection imported successfully",
|
||||
"COLLECTION_IMPORT_ERROR": "An error occurred while importing the collection. Check the logs for more information.",
|
||||
"COLLECTION_OPEN_ERROR": "An error occurred while opening the collection"
|
||||
"COLLECTION_OPEN_ERROR": "An error occurred while opening the collection",
|
||||
"GLOBAL_SEARCH_TIP_PART1": "Press",
|
||||
"GLOBAL_SEARCH_TIP_PART2": "(mac) or",
|
||||
"GLOBAL_SEARCH_TIP_PART3": "(windows) anytime to quickly search collections, folders, and requests"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSelector, useDispatch } from 'react-redux';
|
||||
import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
|
||||
import NetworkError from 'components/ResponsePane/NetworkError';
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
import GlobalSearchModal from 'components/GlobalSearchModal';
|
||||
import {
|
||||
sendRequest,
|
||||
saveRequest,
|
||||
@@ -27,6 +28,7 @@ export const HotkeysProvider = (props) => {
|
||||
const isEnvironmentSettingsModalOpen = useSelector((state) => state.app.isEnvironmentSettingsModalOpen);
|
||||
const [showEnvSettingsModal, setShowEnvSettingsModal] = useState(false);
|
||||
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
|
||||
const [showGlobalSearchModal, setShowGlobalSearchModal] = useState(false);
|
||||
|
||||
const getCurrentCollection = () => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
@@ -149,6 +151,19 @@ export const HotkeysProvider = (props) => {
|
||||
};
|
||||
}, [activeTabUid, tabs, collections, setShowNewRequestModal]);
|
||||
|
||||
// global search (ctrl/cmd + k)
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('globalSearch')], (e) => {
|
||||
setShowGlobalSearchModal(true);
|
||||
|
||||
return false; // stop bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('globalSearch')]);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// close tab hotkey
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('closeTab')], (e) => {
|
||||
@@ -247,6 +262,9 @@ export const HotkeysProvider = (props) => {
|
||||
{showNewRequestModal && (
|
||||
<NewRequest collectionUid={currentCollection?.uid} onClose={() => setShowNewRequestModal(false)} />
|
||||
)}
|
||||
{showGlobalSearchModal && (
|
||||
<GlobalSearchModal isOpen={showGlobalSearchModal} onClose={() => setShowGlobalSearchModal(false)} />
|
||||
)}
|
||||
<div>{props.children}</div>
|
||||
</HotkeysContext.Provider>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ const KeyMapping = {
|
||||
sendRequest: { mac: 'command+enter', windows: 'ctrl+enter', name: 'Send Request' },
|
||||
editEnvironment: { mac: 'command+e', windows: 'ctrl+e', name: 'Edit Environment' },
|
||||
newRequest: { mac: 'command+b', windows: 'ctrl+b', name: 'New Request' },
|
||||
globalSearch: { mac: 'command+k', windows: 'ctrl+k', name: 'Global Search' },
|
||||
closeTab: { mac: 'command+w', windows: 'ctrl+w', name: 'Close Tab' },
|
||||
openPreferences: { mac: 'command+,', windows: 'ctrl+,', name: 'Open Preferences' },
|
||||
closeBruno: {
|
||||
|
||||
@@ -160,6 +160,10 @@ const darkTheme = {
|
||||
color: '#ccc'
|
||||
}
|
||||
}
|
||||
},
|
||||
graphqlDocsExplorer: {
|
||||
bg: '#1e1e1e',
|
||||
color: '#d4d4d4'
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -157,6 +157,10 @@ const lightTheme = {
|
||||
color: 'rgb(75 85 99)'
|
||||
}
|
||||
}
|
||||
},
|
||||
graphqlDocsExplorer: {
|
||||
bg: '#fff',
|
||||
color: 'rgb(52, 52, 52)'
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -312,7 +312,22 @@ const isURL = (arg) => {
|
||||
if (typeof arg !== 'string') {
|
||||
return false;
|
||||
}
|
||||
return !!URL.parse(arg || '').host;
|
||||
|
||||
// First try to parse as a regular URL (with protocol)
|
||||
if (URL.parse(arg || '').host) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it looks like a domain without protocol
|
||||
// This regex matches domain patterns like:
|
||||
// - example.com
|
||||
// - sub.example.com
|
||||
// - example.com/path
|
||||
// - example.com/path?query=value
|
||||
// Must contain at least one dot to be considered a domain
|
||||
const DOMAIN_PATTERN = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\/[^\s]*)?(\?[^\s]*)?$/;
|
||||
|
||||
return DOMAIN_PATTERN.test(arg);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -320,8 +335,9 @@ const isURL = (arg) => {
|
||||
* Handles shell-quote operator objects and query parameter patterns
|
||||
*/
|
||||
const isURLFragment = (arg) => {
|
||||
// If it's a glob pattern that looks like a URL, treat it as a complete URL
|
||||
if (arg && typeof arg === 'object' && arg.op === 'glob') {
|
||||
return !!URL.parse(arg.pattern || '').host;
|
||||
return isURL(arg.pattern);
|
||||
}
|
||||
if (arg && typeof arg === 'object' && arg.op === '&') {
|
||||
return true;
|
||||
@@ -341,7 +357,13 @@ const setURL = (request, url) => {
|
||||
const urlString = getUrlString(url);
|
||||
if (!urlString) return;
|
||||
|
||||
const newUrl = request.url ? request.url + urlString : urlString;
|
||||
// Add default protocol if none is present
|
||||
let processedUrl = urlString;
|
||||
if (!request.url && !urlString.match(/^[a-zA-Z]+:\/\//)) {
|
||||
processedUrl = 'https://' + urlString;
|
||||
}
|
||||
|
||||
const newUrl = request.url ? request.url + processedUrl : processedUrl;
|
||||
|
||||
const { url: formattedUrl, queries, urlWithoutQuery } = parseUrl(newUrl);
|
||||
|
||||
|
||||
@@ -438,6 +438,73 @@ describe('parseCurlCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('handling URLs without protocols', () => {
|
||||
it('should parse URL without protocol and default to https', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl echo.usebruno.com
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'get',
|
||||
url: 'https://echo.usebruno.com',
|
||||
urlWithoutQuery: 'https://echo.usebruno.com'
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse URL without protocol with path and query parameters', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl api.example.com/users?page=1&limit=10
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'get',
|
||||
url: 'https://api.example.com/users?page=1&limit=10',
|
||||
urlWithoutQuery: 'https://api.example.com/users',
|
||||
queries: [
|
||||
{ name: 'page', value: '1' },
|
||||
{ name: 'limit', value: '10' }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse a complex curl command with multiple features and no protocol', () => {
|
||||
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 \
|
||||
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('Edge Cases', () => {
|
||||
it('should handle compressed flag', () => {
|
||||
const result = parseCurlCommand(`
|
||||
|
||||
@@ -351,6 +351,8 @@ const runSingleRequest = async function (
|
||||
|
||||
if (contentTypeHeader && request.headers[contentTypeHeader] === 'multipart/form-data') {
|
||||
if (!(request?.data instanceof FormData)) {
|
||||
request._originalMultipartData = request.data;
|
||||
request.collectionPath = collectionPath;
|
||||
let form = createFormData(request.data, collectionPath);
|
||||
request.data = form;
|
||||
extend(request.headers, form.getHeaders());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const axios = require('axios');
|
||||
const { CLI_VERSION } = require('../constants');
|
||||
const { addCookieToJar, getCookieStringForUrl } = require('./cookies');
|
||||
const { createFormData } = require('./form-data');
|
||||
|
||||
const redirectResponseCodes = [301, 302, 303, 307, 308];
|
||||
const METHOD_CHANGING_REDIRECTS = [301, 302, 303];
|
||||
@@ -38,6 +39,28 @@ const createRedirectConfig = (error, redirectUrl) => {
|
||||
delete requestConfig.headers['Content-Length'];
|
||||
delete requestConfig.headers['content-type'];
|
||||
delete requestConfig.headers['Content-Type'];
|
||||
} else {
|
||||
// For 307, 308 and other status codes: preserve method and body
|
||||
if (requestConfig.data && typeof requestConfig.data === 'object' &&
|
||||
requestConfig.data.constructor && requestConfig.data.constructor.name === 'FormData') {
|
||||
|
||||
const formData = requestConfig.data;
|
||||
if (formData._released || (formData._streams && formData._streams.length === 0)) {
|
||||
if (error.config._originalMultipartData && error.config.collectionPath) {
|
||||
const recreatedForm = createFormData(error.config._originalMultipartData, error.config.collectionPath);
|
||||
requestConfig.data = recreatedForm;
|
||||
const formHeaders = recreatedForm.getHeaders();
|
||||
Object.assign(requestConfig.headers, formHeaders);
|
||||
|
||||
// preserve the original data for potential future redirects
|
||||
requestConfig._originalMultipartData = error.config._originalMultipartData;
|
||||
requestConfig.collectionPath = error.config.collectionPath;
|
||||
}
|
||||
} else {
|
||||
requestConfig._originalMultipartData = error.config._originalMultipartData;
|
||||
requestConfig.collectionPath = error.config.collectionPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return requestConfig;
|
||||
|
||||
@@ -7,6 +7,7 @@ const { setupProxyAgents } = require('../../utils/proxy-util');
|
||||
const { addCookieToJar, getCookieStringForUrl } = require('../../utils/cookies');
|
||||
const { preferencesUtil } = require('../../store/preferences');
|
||||
const { safeStringifyJSON } = require('../../utils/common');
|
||||
const { createFormData } = require('../../utils/form-data');
|
||||
|
||||
const LOCAL_IPV6 = '::1';
|
||||
const LOCAL_IPV4 = '127.0.0.1';
|
||||
@@ -328,6 +329,41 @@ function makeAxiosInstance({
|
||||
type: 'info',
|
||||
message: `Changed method from ${originalMethod.toUpperCase()} to GET for ${statusCode} redirect and removed request body`,
|
||||
});
|
||||
} else {
|
||||
// For 307, 308 and other status codes: preserve method and body
|
||||
if (requestConfig.data && typeof requestConfig.data === 'object' &&
|
||||
requestConfig.data.constructor && requestConfig.data.constructor.name === 'FormData') {
|
||||
|
||||
const formData = requestConfig.data;
|
||||
if (formData._released || (formData._streams && formData._streams.length === 0)) {
|
||||
if (error.config._originalMultipartData && error.config.collectionPath) {
|
||||
timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'info',
|
||||
message: `Recreating consumed FormData for ${statusCode} redirect`,
|
||||
});
|
||||
|
||||
const recreatedForm = createFormData(error.config._originalMultipartData, error.config.collectionPath);
|
||||
requestConfig.data = recreatedForm;
|
||||
|
||||
const formHeaders = recreatedForm.getHeaders();
|
||||
Object.assign(requestConfig.headers, formHeaders);
|
||||
|
||||
// preserve the original data for potential future redirects
|
||||
requestConfig._originalMultipartData = error.config._originalMultipartData;
|
||||
requestConfig.collectionPath = error.config.collectionPath;
|
||||
} else {
|
||||
timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'info',
|
||||
message: `FormData consumed but no original data available for ${statusCode} redirect`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
requestConfig._originalMultipartData = error.config._originalMultipartData;
|
||||
requestConfig.collectionPath = error.config.collectionPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (preferencesUtil.shouldSendCookies()) {
|
||||
|
||||
@@ -452,6 +452,8 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
|
||||
if (request.headers['content-type'] === 'multipart/form-data') {
|
||||
if (!(request.data instanceof FormData)) {
|
||||
request._originalMultipartData = request.data;
|
||||
request.collectionPath = collectionPath;
|
||||
let form = createFormData(request.data, collectionPath);
|
||||
request.data = form;
|
||||
extend(request.headers, form.getHeaders());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
vars {
|
||||
host: http://localhost:8080
|
||||
localhost: http://localhost:8081
|
||||
httpfaker: https://www.httpfaker.org
|
||||
bearer_auth_token: your_secret_token
|
||||
basic_auth_password: della
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
vars {
|
||||
host: https://testbench-sanity.usebruno.com
|
||||
localhost: http://localhost:8081
|
||||
httpfaker: https://www.httpfaker.org
|
||||
bearer_auth_token: your_secret_token
|
||||
basic_auth_password: della
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
meta {
|
||||
name: Test Multipart Redirect Consumed FormData
|
||||
type: http
|
||||
seq: 7
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{localhost}}/api/redirect/multipart-redirect-source
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
consumed-field: consumed-value
|
||||
}
|
||||
|
||||
assert {
|
||||
res.status: 200
|
||||
}
|
||||
|
||||
tests {
|
||||
test("should handle consumed FormData recreation during 308 redirect", function() {
|
||||
const data = res.getBody();
|
||||
expect(data).to.be.an('object');
|
||||
expect(data.status).to.equal('success');
|
||||
expect(data.method).to.equal('POST');
|
||||
});
|
||||
|
||||
test("should preserve POST method when FormData is consumed and recreated", function() {
|
||||
const data = res.getBody();
|
||||
expect(data.method).to.equal('POST');
|
||||
});
|
||||
|
||||
test("should receive form data after FormData recreation", function() {
|
||||
const data = res.getBody();
|
||||
expect(data.body).to.have.property('consumed-field');
|
||||
expect(data.body['consumed-field']).to.equal('consumed-value');
|
||||
});
|
||||
|
||||
test("should maintain proper content-type after FormData recreation", function() {
|
||||
const data = res.getBody();
|
||||
expect(data.headers).to.have.property('content-type');
|
||||
expect(data.headers['content-type']).to.include('multipart/form-data');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
meta {
|
||||
name: Test Multipart Redirect Multiple Fields
|
||||
type: http
|
||||
seq: 5
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{localhost}}/api/redirect/multipart-redirect-source
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
field1: value1
|
||||
field2: value2
|
||||
field3: value3
|
||||
}
|
||||
|
||||
assert {
|
||||
res.status: 200
|
||||
}
|
||||
|
||||
tests {
|
||||
test("should successfully redirect complex multipart form data with 308", function() {
|
||||
const data = res.getBody();
|
||||
expect(data).to.be.an('object');
|
||||
expect(data.status).to.equal('success');
|
||||
expect(data.method).to.equal('POST');
|
||||
});
|
||||
|
||||
test("should preserve POST method during redirect", function() {
|
||||
const data = res.getBody();
|
||||
expect(data.method).to.equal('POST');
|
||||
});
|
||||
|
||||
test("should receive all text fields at target endpoint", function() {
|
||||
const data = res.getBody();
|
||||
expect(data.body).to.have.property('field1');
|
||||
expect(data.body).to.have.property('field2');
|
||||
expect(data.body).to.have.property('field3');
|
||||
});
|
||||
|
||||
test("should maintain content-type header during redirect", function() {
|
||||
const data = res.getBody();
|
||||
expect(data.headers).to.have.property('content-type');
|
||||
expect(data.headers['content-type']).to.include('multipart/form-data');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
meta {
|
||||
name: Test Multipart Redirect
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{localhost}}/api/redirect/multipart-redirect-source
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
test-field: test-value
|
||||
}
|
||||
|
||||
assert {
|
||||
res.status: 200
|
||||
}
|
||||
|
||||
tests {
|
||||
test("should successfully redirect multipart form data with 308", function() {
|
||||
const data = res.getBody();
|
||||
expect(data).to.be.an('object');
|
||||
expect(data.status).to.equal('success');
|
||||
expect(data.method).to.equal('POST');
|
||||
expect(data.body).to.be.an('object');
|
||||
expect(data.body['test-field']).to.equal('test-value');
|
||||
});
|
||||
|
||||
test("should preserve POST method during redirect", function() {
|
||||
const data = res.getBody();
|
||||
expect(data.method).to.equal('POST');
|
||||
});
|
||||
|
||||
test("should receive form data at target endpoint", function() {
|
||||
const data = res.getBody();
|
||||
expect(data.body).to.have.property('test-field');
|
||||
expect(data.body['test-field']).to.equal('test-value');
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const authRouter = require('./auth');
|
||||
const echoRouter = require('./echo');
|
||||
const xmlParser = require('./utils/xmlParser');
|
||||
const multipartRouter = require('./multipart');
|
||||
const redirectRouter = require('./redirect');
|
||||
|
||||
const app = new express();
|
||||
const port = process.env.PORT || 8081;
|
||||
@@ -28,6 +29,7 @@ formDataParser.init(app, express);
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/api/echo', echoRouter);
|
||||
app.use('/api/multipart', multipartRouter);
|
||||
app.use('/api/redirect', redirectRouter);
|
||||
|
||||
app.get('/ping', function (req, res) {
|
||||
return res.send('pong');
|
||||
|
||||
64
packages/bruno-tests/src/redirect/index.js
Normal file
64
packages/bruno-tests/src/redirect/index.js
Normal file
@@ -0,0 +1,64 @@
|
||||
const express = require('express');
|
||||
const formDataParser = require('../multipart/form-data-parser');
|
||||
const router = express.Router();
|
||||
|
||||
const parseMultipartFormData = (req) => {
|
||||
if (req.headers['content-type'] && req.headers['content-type'].includes('multipart/form-data')) {
|
||||
try {
|
||||
const parts = formDataParser.parse(req);
|
||||
const parsedBody = {};
|
||||
const files = [];
|
||||
|
||||
parts.forEach(part => {
|
||||
if (part.filename) {
|
||||
files.push({
|
||||
fieldname: part.name,
|
||||
originalname: part.filename,
|
||||
mimetype: part.contentType,
|
||||
size: part.value ? part.value.length : 0
|
||||
});
|
||||
} else {
|
||||
parsedBody[part.name] = part.value;
|
||||
}
|
||||
});
|
||||
|
||||
return { body: parsedBody, files };
|
||||
} catch (error) {
|
||||
console.error('Error parsing multipart form data:', error);
|
||||
return { body: {}, files: [] };
|
||||
}
|
||||
}
|
||||
return { body: req.body, files: [] };
|
||||
};
|
||||
|
||||
router.post('/multipart-redirect-source', function (req, res) {
|
||||
console.log('Multipart redirect source endpoint hit');
|
||||
console.log('Method:', req.method);
|
||||
console.log('Headers:', req.headers);
|
||||
|
||||
const { body, files } = parseMultipartFormData(req);
|
||||
console.log('Parsed Body:', body);
|
||||
console.log('Files:', files);
|
||||
|
||||
res.status(308).location('/api/redirect/multipart-redirect-target').send('Permanently moved');
|
||||
});
|
||||
|
||||
router.post('/multipart-redirect-target', function (req, res) {
|
||||
console.log('Multipart redirect target endpoint hit');
|
||||
console.log('Method:', req.method);
|
||||
console.log('Headers:', req.headers);
|
||||
|
||||
const { body, files } = parseMultipartFormData(req);
|
||||
console.log('Parsed Body:', body);
|
||||
console.log('Files:', files);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
method: req.method,
|
||||
body: body,
|
||||
files: files,
|
||||
headers: req.headers
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -95,9 +95,12 @@ flatpak install com.usebruno.Bruno
|
||||
# On Linux via Apt
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
sudo apt update && sudo apt install gpg curl
|
||||
sudo gpg --list-keys
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" | gpg --dearmor | sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
|
||||
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
|
||||
| gpg --dearmor \
|
||||
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
|
||||
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/bruno.list
|
||||
sudo apt update && sudo apt install bruno
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user