mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-05 02:18:32 +00:00
Compare commits
31 Commits
feat/node_
...
v2.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28c5042d33 | ||
|
|
1e1bb8efb6 | ||
|
|
66bcbf00ea | ||
|
|
b02639d30a | ||
|
|
6d8dbeac11 | ||
|
|
5b716cbe60 | ||
|
|
a6b0b6c117 | ||
|
|
3c656270b3 | ||
|
|
1bc7a1f655 | ||
|
|
5a10322608 | ||
|
|
2864ddaa72 | ||
|
|
c2f3d8e7da | ||
|
|
1fd61f0601 | ||
|
|
033c5cc0f7 | ||
|
|
db35e7059c | ||
|
|
cd80332de9 | ||
|
|
1902329226 | ||
|
|
b25569d29a | ||
|
|
de4674dcc4 | ||
|
|
457a2f83e7 | ||
|
|
ae3d5a5515 | ||
|
|
3b74e0da86 | ||
|
|
985b5ed20c | ||
|
|
188a2e63e3 | ||
|
|
01839c8e5f | ||
|
|
648581ded5 | ||
|
|
bf38cc0f51 | ||
|
|
abddc98767 | ||
|
|
5dd90e1386 | ||
|
|
da2f2519ec | ||
|
|
7efaa427ca |
26
.github/actions/common/setup-node-deps/action.yml
vendored
Normal file
26
.github/actions/common/setup-node-deps/action.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: 'Setup Node Dependencies'
|
||||
description: 'Install Node.js and npm dependencies'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: v22.17.0
|
||||
cache: 'npm'
|
||||
cache-dependency-path: './package-lock.json'
|
||||
|
||||
- name: Install node dependencies
|
||||
shell: bash
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Build libraries
|
||||
shell: bash
|
||||
run: |
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
npm run build:bruno-common
|
||||
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
|
||||
npm run build:bruno-converters
|
||||
npm run build:bruno-requests
|
||||
npm run build:bruno-filestore
|
||||
36
.github/actions/ssl/linux/run-basic-ssl-cli-tests/action.yml
vendored
Normal file
36
.github/actions/ssl/linux/run-basic-ssl-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: 'Run Basic SSL CLI Tests - Linux'
|
||||
description: 'Run basic SSL CLI tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# navigate to basic SSL test collection directory
|
||||
cd tests/ssl/basic-ssl/collections/badssl
|
||||
|
||||
echo "basic ssl success"
|
||||
# should pass
|
||||
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit1.xml --insecure --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit1.xml | grep -q "^1$" || exit 1
|
||||
|
||||
echo "with default/system ca certs"
|
||||
# should pass
|
||||
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit2.xml --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit2.xml | grep -q "^1$" || exit 1
|
||||
|
||||
# navigate to self-signed SSL test collection directory
|
||||
cd ../self-signed-badssl
|
||||
|
||||
echo "self-signed ssl with validation disabled"
|
||||
# should pass
|
||||
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit3.xml --insecure --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit3.xml | grep -q "^1$" || exit 1
|
||||
|
||||
echo "self-signed ssl with default/system ca certs"
|
||||
echo "request will error"
|
||||
# should fail
|
||||
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit4.xml --format junit 2>/dev/null || true
|
||||
xmllint --xpath 'count(//testsuite[@errors="1"])' junit4.xml | grep -q "^1$" || exit 1
|
||||
33
.github/actions/ssl/linux/run-custom-ca-certs-cli-tests/action.yml
vendored
Normal file
33
.github/actions/ssl/linux/run-custom-ca-certs-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: 'Run Custom CA Certs CLI Tests - Linux'
|
||||
description: 'Run custom CA certs CLI tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# navigate to CA certificates test collection directory
|
||||
cd tests/ssl/custom-ca-certs/collection
|
||||
|
||||
echo "custom valid ca cert"
|
||||
# should pass
|
||||
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit1.xml --cacert ../server/certs/ca-cert.pem --ignore-truststore --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit1.xml | grep -q "^1$" || exit 1
|
||||
|
||||
echo "custom valid ca cert with defaults"
|
||||
# should pass
|
||||
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit2.xml --cacert ../server/certs/ca-cert.pem --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit2.xml | grep -q "^1$" || exit 1
|
||||
|
||||
echo "custom invalid ca cert"
|
||||
echo "request will error"
|
||||
# should fail
|
||||
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit3.xml --cacert ../server/certs/ca-key.pem --ignore-truststore --format junit 2>/dev/null || true
|
||||
xmllint --xpath 'count(//testsuite[@errors="1"])' junit3.xml | grep -q "^1$" || exit 1
|
||||
|
||||
echo "custom invalid ca cert with defaults"
|
||||
# should pass
|
||||
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit4.xml --cacert ../server/certs/ca-key.pem --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit4.xml | grep -q "^1$" || exit 1
|
||||
19
.github/actions/ssl/linux/run-ssl-e2e-tests/action.yml
vendored
Normal file
19
.github/actions/ssl/linux/run-ssl-e2e-tests/action.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: 'Run SSL E2E Tests - Linux'
|
||||
description: 'Run SSL E2E tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run E2E tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
xvfb-run npm run test:e2e:ssl
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-linux
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
26
.github/actions/ssl/linux/setup-ca-certs/action.yml
vendored
Normal file
26
.github/actions/ssl/linux/setup-ca-certs/action.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: 'Setup CA Certificates - Linux'
|
||||
description: 'Setup CA certificates and start test server for custom CA certs tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup CA certificates
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd tests/ssl/custom-ca-certs/server
|
||||
|
||||
echo "running certificate setup"
|
||||
node scripts/generate-certs.js
|
||||
|
||||
- name: Start test server
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd tests/ssl/custom-ca-certs/server
|
||||
|
||||
echo "starting server in background"
|
||||
node index.js &
|
||||
|
||||
echo "server started with PID: $!"
|
||||
15
.github/actions/ssl/linux/setup-feature-specific-deps/action.yml
vendored
Normal file
15
.github/actions/ssl/linux/setup-feature-specific-deps/action.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
name: 'Setup Custom CA Certs Feature Dependencies - Linux'
|
||||
description: 'Setup feature-specific dependencies for custom CA certs tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Install additional OS dependencies for custom CA certs
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get --no-install-recommends install -y \
|
||||
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
|
||||
xvfb libxml2-utils
|
||||
|
||||
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
|
||||
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
|
||||
36
.github/actions/ssl/macos/run-basic-ssl-cli-tests/action.yml
vendored
Normal file
36
.github/actions/ssl/macos/run-basic-ssl-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: 'Run Basic SSL CLI Tests - macOS'
|
||||
description: 'Run basic SSL CLI tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# navigate to basic SSL test collection directory
|
||||
cd tests/ssl/basic-ssl/collections/badssl
|
||||
|
||||
echo "basic ssl success"
|
||||
# should pass
|
||||
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit1.xml --insecure --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit1.xml | grep -q "^1$" || exit 1
|
||||
|
||||
echo "with default/system ca certs"
|
||||
# should pass
|
||||
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit2.xml --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit2.xml | grep -q "^1$" || exit 1
|
||||
|
||||
# navigate to self-signed SSL test collection directory
|
||||
cd ../self-signed-badssl
|
||||
|
||||
echo "self-signed ssl with validation disabled"
|
||||
# should pass
|
||||
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit3.xml --insecure --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit3.xml | grep -q "^1$" || exit 1
|
||||
|
||||
echo "self-signed ssl with default/system ca certs"
|
||||
echo "request will error"
|
||||
# should fail
|
||||
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit4.xml --format junit 2>/dev/null || true
|
||||
xmllint --xpath 'count(//testsuite[@errors="1"])' junit4.xml | grep -q "^1$" || exit 1
|
||||
33
.github/actions/ssl/macos/run-custom-ca-certs-cli-tests/action.yml
vendored
Normal file
33
.github/actions/ssl/macos/run-custom-ca-certs-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: 'Run Custom CA Certs CLI Tests - macOS'
|
||||
description: 'Run custom CA certs CLI tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# navigate to CA certificates test collection directory
|
||||
cd tests/ssl/custom-ca-certs/collection
|
||||
|
||||
echo "custom valid ca cert"
|
||||
# should pass
|
||||
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit1.xml --cacert ../server/certs/ca-cert.pem --ignore-truststore --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit1.xml | grep -q "^1$" || exit 1
|
||||
|
||||
echo "custom valid ca cert with defaults"
|
||||
# should pass
|
||||
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit2.xml --cacert ../server/certs/ca-cert.pem --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit2.xml | grep -q "^1$" || exit 1
|
||||
|
||||
echo "custom invalid ca cert"
|
||||
echo "request will error"
|
||||
# should fail
|
||||
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit3.xml --cacert ../server/certs/ca-key.pem --ignore-truststore --format junit 2>/dev/null || true
|
||||
xmllint --xpath 'count(//testsuite[@errors="1"])' junit3.xml | grep -q "^1$" || exit 1
|
||||
|
||||
echo "custom invalid ca cert with defaults"
|
||||
# should pass
|
||||
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit4.xml --cacert ../server/certs/ca-key.pem --format junit
|
||||
xmllint --xpath 'count(//testsuite[@errors="0"])' junit4.xml | grep -q "^1$" || exit 1
|
||||
17
.github/actions/ssl/macos/run-ssl-e2e-tests/action.yml
vendored
Normal file
17
.github/actions/ssl/macos/run-ssl-e2e-tests/action.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: 'Run SSL E2E Tests - macOS'
|
||||
description: 'Run SSL E2E tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run E2E tests
|
||||
shell: bash
|
||||
run: |
|
||||
npm run test:e2e:ssl
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-macos
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
26
.github/actions/ssl/macos/setup-ca-certs/action.yml
vendored
Normal file
26
.github/actions/ssl/macos/setup-ca-certs/action.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: 'Setup CA Certificates - macOS'
|
||||
description: 'Setup CA certificates and start test server for custom CA certs tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup CA certificates
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd tests/ssl/custom-ca-certs/server
|
||||
|
||||
echo "running certificate setup"
|
||||
node scripts/generate-certs.js
|
||||
|
||||
- name: Start test server
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd tests/ssl/custom-ca-certs/server
|
||||
|
||||
echo "starting server in background"
|
||||
node index.js &
|
||||
|
||||
echo "server started with PID: $!"
|
||||
9
.github/actions/ssl/macos/setup-feature-specific-deps/action.yml
vendored
Normal file
9
.github/actions/ssl/macos/setup-feature-specific-deps/action.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
name: 'Setup Custom CA Certs Feature Dependencies - macOS'
|
||||
description: 'Setup feature-specific dependencies for custom CA certs tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Install additional OS dependencies for custom CA certs
|
||||
shell: bash
|
||||
run: |
|
||||
brew install libxml2
|
||||
50
.github/actions/ssl/windows/run-basic-ssl-cli-tests/action.yml
vendored
Normal file
50
.github/actions/ssl/windows/run-basic-ssl-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: 'Run Basic SSL CLI Tests - Windows'
|
||||
description: 'Run basic SSL CLI tests on Windows'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run CLI tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# navigate to basic SSL test collection directory
|
||||
Set-Location tests\ssl\basic-ssl\collections\badssl
|
||||
|
||||
Write-Host "basic ssl success"
|
||||
# should pass
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit1.xml --insecure --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
[xml]$xml1 = Get-Content junit1.xml
|
||||
$testsuites1 = if ($xml1.testsuites) { $xml1.testsuites.testsuite } else { $xml1.testsuite }
|
||||
$errorCount1 = ($testsuites1 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
|
||||
if ($errorCount1 -ne 1) { exit 1 }
|
||||
|
||||
Write-Host "with default/system ca certs"
|
||||
# should pass
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit2.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
[xml]$xml2 = Get-Content junit2.xml
|
||||
$testsuites2 = if ($xml2.testsuites) { $xml2.testsuites.testsuite } else { $xml2.testsuite }
|
||||
$errorCount2 = ($testsuites2 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
|
||||
if ($errorCount2 -ne 1) { exit 1 }
|
||||
|
||||
# navigate to self-signed SSL test collection directory
|
||||
Set-Location ..\self-signed-badssl
|
||||
|
||||
Write-Host "self-signed ssl with validation disabled"
|
||||
# should pass
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit3.xml --insecure --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
[xml]$xml3 = Get-Content junit3.xml
|
||||
$testsuites3 = if ($xml3.testsuites) { $xml3.testsuites.testsuite } else { $xml3.testsuite }
|
||||
$errorCount3 = ($testsuites3 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
|
||||
if ($errorCount3 -ne 1) { exit 1 }
|
||||
|
||||
Write-Host "self-signed ssl with default/system ca certs"
|
||||
Write-Host "request will error"
|
||||
# should fail
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit4.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
# Ignore the exit code - we expect this to fail
|
||||
[xml]$xml4 = Get-Content junit4.xml
|
||||
$testsuites4 = if ($xml4.testsuites) { $xml4.testsuites.testsuite } else { $xml4.testsuite }
|
||||
$errorCount4 = ($testsuites4 | Where-Object { $_.errors -eq "1" } | Measure-Object).Count
|
||||
if ($errorCount4 -ne 1) { exit 1 }
|
||||
47
.github/actions/ssl/windows/run-custom-ca-certs-cli-tests/action.yml
vendored
Normal file
47
.github/actions/ssl/windows/run-custom-ca-certs-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: 'Run Custom CA Certs CLI Tests - Windows'
|
||||
description: 'Run custom CA certs CLI tests on Windows'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run CLI tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# navigate to CA certificates test collection directory
|
||||
Set-Location tests\ssl\custom-ca-certs\collection
|
||||
|
||||
Write-Host "custom valid ca cert"
|
||||
# should pass
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit1.xml --cacert ..\server\certs\ca-cert.pem --ignore-truststore --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
[xml]$xml1 = Get-Content junit1.xml
|
||||
$testsuites1 = if ($xml1.testsuites) { $xml1.testsuites.testsuite } else { $xml1.testsuite }
|
||||
$errorCount1 = ($testsuites1 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
|
||||
if ($errorCount1 -ne 1) { exit 1 }
|
||||
|
||||
Write-Host "custom valid ca cert with defaults"
|
||||
# should pass
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit2.xml --cacert ..\server\certs\ca-cert.pem --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
[xml]$xml2 = Get-Content junit2.xml
|
||||
$testsuites2 = if ($xml2.testsuites) { $xml2.testsuites.testsuite } else { $xml2.testsuite }
|
||||
$errorCount2 = ($testsuites2 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
|
||||
if ($errorCount2 -ne 1) { exit 1 }
|
||||
|
||||
Write-Host "custom invalid ca cert"
|
||||
Write-Host "request will error"
|
||||
# should fail
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit3.xml --cacert ..\server\certs\ca-key.pem --ignore-truststore --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
# Ignore the exit code - we expect this to fail
|
||||
[xml]$xml3 = Get-Content junit3.xml
|
||||
$testsuites3 = if ($xml3.testsuites) { $xml3.testsuites.testsuite } else { $xml3.testsuite }
|
||||
$errorCount3 = ($testsuites3 | Where-Object { $_.errors -eq "1" } | Measure-Object).Count
|
||||
if ($errorCount3 -ne 1) { exit 1 }
|
||||
|
||||
Write-Host "custom invalid ca cert with defaults"
|
||||
# should pass
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit4.xml --cacert ..\server\certs\ca-key.pem --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
[xml]$xml4 = Get-Content junit4.xml
|
||||
$testsuites4 = if ($xml4.testsuites) { $xml4.testsuites.testsuite } else { $xml4.testsuite }
|
||||
$errorCount4 = ($testsuites4 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
|
||||
if ($errorCount4 -ne 1) { exit 1 }
|
||||
17
.github/actions/ssl/windows/run-ssl-e2e-tests/action.yml
vendored
Normal file
17
.github/actions/ssl/windows/run-ssl-e2e-tests/action.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: 'Run SSL E2E Tests - Windows'
|
||||
description: 'Run SSL E2E tests on Windows'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run E2E tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
npm run test:e2e:ssl
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-windows
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
25
.github/actions/ssl/windows/setup-ca-certs/action.yml
vendored
Normal file
25
.github/actions/ssl/windows/setup-ca-certs/action.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: 'Setup CA Certificates - Windows'
|
||||
description: 'Setup CA certificates and start test server for custom CA certs tests on Windows'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup CA certificates
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Set-Location tests\ssl\custom-ca-certs\server
|
||||
|
||||
Write-Host "running certificate setup"
|
||||
node scripts/generate-certs.js
|
||||
|
||||
- name: Start test server
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
Set-Location tests\ssl\custom-ca-certs\server
|
||||
|
||||
Write-Host "starting server in background"
|
||||
Start-Process -FilePath "node" -ArgumentList "index.js" -PassThru -WindowStyle Hidden
|
||||
91
.github/workflows/ssl-tests.yml
vendored
Normal file
91
.github/workflows/ssl-tests.yml
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
name: SSL Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
tests-for-linux:
|
||||
name: SSL Tests - Linux
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Setup Feature Dependencies
|
||||
uses: ./.github/actions/ssl/linux/setup-feature-specific-deps
|
||||
|
||||
- name: Setup CA Certificates
|
||||
uses: ./.github/actions/ssl/linux/setup-ca-certs
|
||||
|
||||
- name: Run Basic SSL CLI Tests
|
||||
uses: ./.github/actions/ssl/linux/run-basic-ssl-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs CLI Tests
|
||||
uses: ./.github/actions/ssl/linux/run-custom-ca-certs-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs E2E Tests
|
||||
uses: ./.github/actions/ssl/linux/run-ssl-e2e-tests
|
||||
|
||||
tests-for-macos:
|
||||
name: SSL Tests - macOS
|
||||
timeout-minutes: 60
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Setup Feature Dependencies
|
||||
uses: ./.github/actions/ssl/macos/setup-feature-specific-deps
|
||||
|
||||
- name: Setup CA Certificates
|
||||
uses: ./.github/actions/ssl/macos/setup-ca-certs
|
||||
|
||||
- name: Run Basic SSL CLI Tests
|
||||
uses: ./.github/actions/ssl/macos/run-basic-ssl-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs CLI Tests
|
||||
uses: ./.github/actions/ssl/macos/run-custom-ca-certs-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs E2E Tests
|
||||
uses: ./.github/actions/ssl/macos/run-ssl-e2e-tests
|
||||
|
||||
tests-for-windows:
|
||||
name: SSL Tests - Windows
|
||||
timeout-minutes: 60
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Setup CA Certificates
|
||||
uses: ./.github/actions/ssl/windows/setup-ca-certs
|
||||
|
||||
- name: Run Basic SSL CLI Tests
|
||||
uses: ./.github/actions/ssl/windows/run-basic-ssl-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs CLI Tests
|
||||
uses: ./.github/actions/ssl/windows/run-custom-ca-certs-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs E2E Tests
|
||||
uses: ./.github/actions/ssl/windows/run-ssl-e2e-tests
|
||||
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 |
470
docs/playwright-testing-guide.md
Normal file
470
docs/playwright-testing-guide.md
Normal file
@@ -0,0 +1,470 @@
|
||||
# Playwright Testing Guide for Bruno
|
||||
|
||||
This guide explains how to create and run Playwright test cases for the Bruno application using the UI.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Creating Tests Using Codegen](#creating-tests-using-codegen)
|
||||
- [Manual Test Creation](#manual-test-creation)
|
||||
- [Test Structure and Organization](#test-structure-and-organization)
|
||||
- [Available Test Fixtures](#available-test-fixtures)
|
||||
- [Running Tests](#running-tests)
|
||||
- [Best Practices](#best-practices)
|
||||
- [Examples](#examples)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## Overview
|
||||
|
||||
Bruno uses Playwright for end-to-end testing of its Electron application. The testing setup includes custom fixtures for Electron app testing and utilities for managing test data.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js installed
|
||||
- All dependencies installed (`npm install`)
|
||||
- Electron app can be built and run
|
||||
|
||||
## Creating Tests Using Codegen
|
||||
|
||||
The easiest way to create tests is using Playwright's codegen feature, which records your UI interactions and generates test code.
|
||||
|
||||
### Using the Built-in Codegen Script
|
||||
|
||||
```bash
|
||||
# Generate a test with a specific name
|
||||
npm run test:codegen my-new-test
|
||||
|
||||
# Generate a test without specifying a name (will prompt for input)
|
||||
npm run test:codegen
|
||||
```
|
||||
|
||||
### What Happens During Codegen
|
||||
|
||||
1. The Electron app launches automatically
|
||||
2. Playwright Inspector opens in a separate window
|
||||
3. You interact with the Bruno UI
|
||||
4. Actions are recorded and converted to test code
|
||||
5. The generated test file is saved in `e2e-tests/`
|
||||
|
||||
### Codegen Workflow
|
||||
|
||||
1. **Start Recording**: Run the codegen command
|
||||
2. **Interact with UI**: Perform the actions you want to test
|
||||
3. **Add Assertions**: Use the inspector to add assertions
|
||||
4. **Save Test**: The test file is automatically generated
|
||||
5. **Review and Refine**: Edit the generated test as needed
|
||||
|
||||
## Manual Test Creation
|
||||
|
||||
You can also create tests manually by following the established patterns.
|
||||
|
||||
### Basic Test Structure
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../playwright';
|
||||
|
||||
test('Test description', async ({ page }) => {
|
||||
// Test steps here
|
||||
await page.getByLabel('Some Label').click();
|
||||
|
||||
// Assertions
|
||||
await expect(page.getByText('Expected Text')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Test with Temporary Data
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../playwright';
|
||||
|
||||
test('Test with temporary data', async ({ page, createTmpDir }) => {
|
||||
// Create temporary directory for test data
|
||||
const testDir = await createTmpDir('test-collection');
|
||||
|
||||
// Test steps
|
||||
await page.getByLabel('Create Collection').click();
|
||||
await page.getByLabel('Name').fill('test-collection');
|
||||
await page.getByLabel('Location').fill(testDir);
|
||||
|
||||
// Assertions
|
||||
await expect(page.getByText('test-collection')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Test Structure and Organization
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
e2e-tests/
|
||||
├── 001-sanity-tests/ # Basic functionality tests
|
||||
│ ├── 001-home-screen.spec.ts
|
||||
│ └── 002-create-new-collection-and-new-request.spec.ts
|
||||
├── 002-feature-tests/ # Specific feature tests
|
||||
├── 003-integration-tests/ # Complex workflow tests
|
||||
└── bruno-testbench/ # Test utilities and helpers
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Files**: Use descriptive names with `.spec.ts` extension
|
||||
- **Tests**: Use clear, descriptive test names
|
||||
- **Folders**: Use numbered prefixes for ordering
|
||||
|
||||
### Test File Template
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../playwright';
|
||||
|
||||
test.describe('Feature Name', () => {
|
||||
test('should perform specific action', async ({ page }) => {
|
||||
// Arrange
|
||||
// Act
|
||||
// Assert
|
||||
});
|
||||
|
||||
test('should handle error case', async ({ page }) => {
|
||||
// Test error scenarios
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Available Test Fixtures
|
||||
|
||||
The Bruno Playwright setup provides several custom fixtures:
|
||||
|
||||
### Core Fixtures
|
||||
|
||||
- `page`: Main page for testing
|
||||
- `context`: Browser context
|
||||
- `electronApp`: Electron application instance
|
||||
|
||||
### Utility Fixtures
|
||||
|
||||
- `createTmpDir`: Creates temporary directories for test data
|
||||
- `newPage`: Creates a new page instance
|
||||
- `pageWithUserData`: Page with custom user data
|
||||
- `launchElectronApp`: Launches a new Electron app instance
|
||||
- `reuseOrLaunchElectronApp`: Reuses existing app or launches new one
|
||||
|
||||
### Using Fixtures
|
||||
|
||||
```typescript
|
||||
test('Test with multiple fixtures', async ({ page, createTmpDir, electronApp }) => {
|
||||
const testDir = await createTmpDir('test-data');
|
||||
|
||||
// Your test logic here
|
||||
});
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm run test:e2e
|
||||
|
||||
# Run specific test file
|
||||
npx playwright test e2e-tests/001-sanity-tests/001-home-screen.spec.ts
|
||||
|
||||
# Run tests in a specific folder
|
||||
npx playwright test e2e-tests/001-sanity-tests/
|
||||
```
|
||||
|
||||
### Advanced Options
|
||||
|
||||
```bash
|
||||
# Run with UI mode (for debugging)
|
||||
npx playwright test --ui
|
||||
|
||||
# Run in headed mode (see browser)
|
||||
npx playwright test --headed
|
||||
|
||||
# Run with specific browser
|
||||
npx playwright test --project="Bruno Electron App"
|
||||
|
||||
# Run with debugging
|
||||
npx playwright test --debug
|
||||
|
||||
# Run with trace recording
|
||||
npx playwright test --trace on
|
||||
```
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
```bash
|
||||
# Install browsers for CI
|
||||
npx playwright install
|
||||
|
||||
# Run tests in CI mode
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Semantic Selectors
|
||||
|
||||
**Preferred:**
|
||||
|
||||
```typescript
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
await page.getByLabel('Collection Name').fill('test');
|
||||
await page.getByText('Success message').toBeVisible();
|
||||
```
|
||||
|
||||
**Avoid:**
|
||||
|
||||
```typescript
|
||||
await page.locator('.btn-primary').click();
|
||||
await page.locator('#collection-name').fill('test');
|
||||
```
|
||||
|
||||
### 2. Create Isolated Tests
|
||||
|
||||
Each test should be independent and not rely on other tests:
|
||||
|
||||
```typescript
|
||||
test('should create collection', async ({ page, createTmpDir }) => {
|
||||
const testDir = await createTmpDir('collection-test');
|
||||
|
||||
// Test creates its own data
|
||||
await page.getByLabel('Create Collection').click();
|
||||
await page.getByLabel('Name').fill('test-collection');
|
||||
await page.getByLabel('Location').fill(testDir);
|
||||
|
||||
// Clean up happens automatically via createTmpDir
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Add Meaningful Assertions
|
||||
|
||||
Always verify the expected outcomes:
|
||||
|
||||
```typescript
|
||||
test('should save request successfully', async ({ page }) => {
|
||||
// Arrange
|
||||
await page.getByLabel('Create Collection').click();
|
||||
|
||||
// Act
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Assert
|
||||
await expect(page.getByText('Request saved successfully')).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'GET request' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Handle Async Operations
|
||||
|
||||
```typescript
|
||||
test('should wait for network requests', async ({ page }) => {
|
||||
// Wait for specific network request
|
||||
await page.waitForResponse((response) => response.url().includes('/api/endpoint'));
|
||||
|
||||
// Or wait for element to be stable
|
||||
await page.waitForSelector('[data-testid="loading"]', { state: 'hidden' });
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Use Test Data Management
|
||||
|
||||
```typescript
|
||||
test('should work with test data', async ({ page, createTmpDir }) => {
|
||||
const testDir = await createTmpDir('test-data');
|
||||
|
||||
// Create test files
|
||||
await fs.writeFile(path.join(testDir, 'test.bru'), testContent);
|
||||
|
||||
// Use in test
|
||||
await page.getByLabel('Open Collection').click();
|
||||
await page.getByText(testDir).click();
|
||||
});
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Basic Collection Creation
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../playwright';
|
||||
|
||||
test('should create a new collection', async ({ page, createTmpDir }) => {
|
||||
const testDir = await createTmpDir('new-collection');
|
||||
|
||||
await page.getByLabel('Create Collection').click();
|
||||
await page.getByLabel('Name').fill('My Test Collection');
|
||||
await page.getByLabel('Location').fill(testDir);
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
await expect(page.getByText('My Test Collection')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Example 2: Request Creation and Execution
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../playwright';
|
||||
|
||||
test('should create and execute HTTP request', async ({ page, createTmpDir }) => {
|
||||
const testDir = await createTmpDir('request-test');
|
||||
|
||||
// Create collection
|
||||
await page.getByLabel('Create Collection').click();
|
||||
await page.getByLabel('Name').fill('Request Test');
|
||||
await page.getByLabel('Location').fill(testDir);
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Create request
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
await page.getByPlaceholder('Request Name').fill('Test Request');
|
||||
await page.locator('#new-request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('http://localhost:8081/ping');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Execute request
|
||||
await page.locator('#send-request').getByRole('img').nth(2).click();
|
||||
|
||||
// Verify response
|
||||
await expect(page.getByRole('main')).toContainText('200 OK');
|
||||
});
|
||||
```
|
||||
|
||||
### Example 3: Environment Management
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../playwright';
|
||||
|
||||
test('should create and use environment variables', async ({ page, createTmpDir }) => {
|
||||
const testDir = await createTmpDir('env-test');
|
||||
|
||||
// Setup collection
|
||||
await page.getByLabel('Create Collection').click();
|
||||
await page.getByLabel('Name').fill('Environment Test');
|
||||
await page.getByLabel('Location').fill(testDir);
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Create environment
|
||||
await page.getByRole('button', { name: 'Environments' }).click();
|
||||
await page.getByRole('button', { name: 'Add Environment' }).click();
|
||||
await page.getByLabel('Environment Name').fill('Development');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Add variable
|
||||
await page.getByRole('button', { name: 'Add Variable' }).click();
|
||||
await page.getByLabel('Variable Name').fill('API_URL');
|
||||
await page.getByLabel('Variable Value').fill('http://localhost:3000');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.getByText('API_URL')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Electron App Not Starting**
|
||||
|
||||
```bash
|
||||
# Ensure dependencies are installed
|
||||
npm install
|
||||
|
||||
# Try running the app manually first
|
||||
npm run dev:electron
|
||||
```
|
||||
|
||||
2. **Tests Timing Out**
|
||||
|
||||
```typescript
|
||||
// Increase timeout for specific test
|
||||
test('slow test', async ({ page }) => {
|
||||
test.setTimeout(60000); // 60 seconds
|
||||
// Test steps
|
||||
});
|
||||
```
|
||||
|
||||
3. **Element Not Found**
|
||||
|
||||
```typescript
|
||||
// Wait for element to be present
|
||||
await page.waitForSelector('[data-testid="element"]');
|
||||
|
||||
// Or use more specific selectors
|
||||
await page.getByRole('button', { name: 'Exact Button Text' }).click();
|
||||
```
|
||||
|
||||
4. **Flaky Tests**
|
||||
|
||||
```typescript
|
||||
// Use stable selectors
|
||||
await page.getByTestId('stable-id').click();
|
||||
|
||||
// Wait for state changes
|
||||
await page.waitForLoadState('networkidle');
|
||||
```
|
||||
|
||||
### Debug Mode
|
||||
|
||||
```bash
|
||||
# Run with debug mode
|
||||
npx playwright test --debug
|
||||
|
||||
# Run specific test in debug mode
|
||||
npx playwright test --debug e2e-tests/001-sanity-tests/001-home-screen.spec.ts
|
||||
```
|
||||
|
||||
### Trace Analysis
|
||||
|
||||
```bash
|
||||
# Run with trace recording
|
||||
npx playwright test --trace on
|
||||
|
||||
# View trace in browser
|
||||
npx playwright show-trace test-results/trace-*.zip
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The Playwright configuration is in `playwright.config.ts`:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
testDir: './e2e-tests',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? undefined : 1,
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'Bruno Electron App'
|
||||
}
|
||||
],
|
||||
|
||||
webServer: [
|
||||
{
|
||||
command: 'npm run dev:web',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI
|
||||
},
|
||||
{
|
||||
command: 'npm start --workspace=packages/bruno-tests',
|
||||
url: 'http://localhost:8081/ping',
|
||||
reuseExistingServer: !process.env.CI
|
||||
}
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Playwright Documentation](https://playwright.dev/)
|
||||
- [Playwright Test API](https://playwright.dev/docs/api/class-test)
|
||||
- [Electron Testing with Playwright](https://playwright.dev/docs/api/class-electronapplication)
|
||||
- [Bruno Project Structure](../readme.md)
|
||||
|
||||
---
|
||||
|
||||
For questions or issues with testing, please refer to the project's contributing guidelines or create an issue in the repository.
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { test, expect } from '../../playwright';
|
||||
|
||||
test.describe.serial('Persistent Environment Test', () => {
|
||||
test.setTimeout(2 * 10 * 1000);
|
||||
|
||||
test('add env using script', async ({ pageWithUserData: page, restartApp }) => {
|
||||
await page.locator('#sidebar-collection-name').click();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.getByText('ping', { exact: true }).click();
|
||||
await page.getByText('No Environment').click();
|
||||
await page.getByRole('tooltip').locator('div').filter({ hasText: 'Env' }).nth(3).click();
|
||||
await page.locator('#send-request').getByRole('img').nth(2).click();
|
||||
await page.waitForTimeout(1000);
|
||||
await page.locator('div').filter({ hasText: /^Env$/ }).nth(3).click();
|
||||
await page.getByText('Configure', { exact: true }).click();
|
||||
await expect(page.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
|
||||
await page.getByText('×').click();
|
||||
|
||||
const newApp = await restartApp();
|
||||
const newPage = await newApp.firstWindow();
|
||||
await newPage.locator('#sidebar-collection-name').click();
|
||||
await newPage.getByRole('button', { name: 'Save' }).click();
|
||||
await newPage.getByText('ping', { exact: true }).click();
|
||||
await newPage.getByText('No Environment').click();
|
||||
await newPage.getByRole('tooltip').locator('div').filter({ hasText: 'Env' }).nth(3).click();
|
||||
await newPage.locator('div').filter({ hasText: /^Env$/ }).nth(3).click();
|
||||
await newPage.getByText('Configure', { exact: true }).click();
|
||||
await expect(newPage.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
|
||||
await newPage.getByText('×').click();
|
||||
await newPage.waitForTimeout(1000);
|
||||
await newPage.close();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { test, expect } from '../../playwright';
|
||||
|
||||
test.describe.serial('Persistent Environment Test', () => {
|
||||
test.setTimeout(2 * 10 * 1000);
|
||||
|
||||
test('add env using script', async ({ pageWithUserData: page, restartApp }) => {
|
||||
await page.locator('#sidebar-collection-name').click();
|
||||
await page.getByText('ping2', { exact: true }).click();
|
||||
await page.getByText('Env', { exact: true }).click();
|
||||
await page.getByText('Stage', { exact: true }).click();
|
||||
await page.locator('#send-request').getByRole('img').nth(2).click();
|
||||
await page.waitForTimeout(1000);
|
||||
await page
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Stage$/ })
|
||||
.nth(3)
|
||||
.click();
|
||||
await page.getByText('Configure', { exact: true }).click();
|
||||
await expect(page.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
|
||||
await page.getByText('×').click();
|
||||
|
||||
const newApp = await restartApp();
|
||||
const newPage = await newApp.firstWindow();
|
||||
await newPage.locator('#sidebar-collection-name').click();
|
||||
await newPage.getByRole('button', { name: 'Save' }).click();
|
||||
await newPage.getByText('ping2', { exact: true }).click();
|
||||
await newPage.getByText('No Environment').click();
|
||||
await newPage.getByText('Stage').click();
|
||||
await newPage
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Stage$/ })
|
||||
.nth(3)
|
||||
.click();
|
||||
await newPage.getByText('Configure', { exact: true }).click();
|
||||
await expect(newPage.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).not.toBeVisible();
|
||||
await newPage.getByText('×').click();
|
||||
await newPage.waitForTimeout(1000);
|
||||
await newPage.close();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
vars {
|
||||
host: https://testbench-sanity.usebruno.com
|
||||
persistent-env-test: persistent-env-test-value
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"maximized": true,
|
||||
"lastOpenedCollections": [
|
||||
"{{projectRoot}}/e2e-tests/persistent-env-tests/collection"
|
||||
]
|
||||
}
|
||||
744
package-lock.json
generated
744
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -65,7 +65,8 @@
|
||||
"build:electron:snap": "./scripts/build-electron.sh snap",
|
||||
"watch:common": "npm run watch --workspace=packages/bruno-common",
|
||||
"test:codegen": "node playwright/codegen.ts",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e": "playwright test --project=default",
|
||||
"test:e2e:ssl": "playwright test --project=ssl",
|
||||
"test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app",
|
||||
"lint": "node --max_old_space_size=4096 $(npx which eslint)"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
@@ -92,7 +93,7 @@
|
||||
"@rsbuild/plugin-react": "^1.0.7",
|
||||
"@rsbuild/plugin-sass": "^1.1.0",
|
||||
"@rsbuild/plugin-styled-components": "1.1.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"autoprefixer": "10.4.20",
|
||||
|
||||
@@ -18,8 +18,8 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
|
||||
const Icon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="current-environment flex items-center justify-center pl-3 pr-2 py-1 select-none">
|
||||
<p className="text-nowrap truncate max-w-32">{activeEnvironment ? activeEnvironment.name : 'No Environment'}</p>
|
||||
<div ref={ref} className="current-environment collection-environment flex items-center justify-center pl-3 pr-2 py-1 select-none">
|
||||
<p className="text-nowrap truncate max-w-32" title={activeEnvironment ? activeEnvironment.name : 'No Environment'}>{activeEnvironment ? activeEnvironment.name : 'No Environment'}</p>
|
||||
<IconCaretDown className="caret" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
@@ -82,7 +82,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
handleSettingsIconClick();
|
||||
dropdownTippyRef.current.hide();
|
||||
}}>
|
||||
<div className="pr-2 text-gray-600">
|
||||
<div className="pr-2 text-gray-600" id="Configure">
|
||||
<IconSettings size={18} strokeWidth={1.5} />
|
||||
</div>
|
||||
<span>Configure</span>
|
||||
|
||||
@@ -173,7 +173,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
|
||||
return (
|
||||
<StyledWrapper className="w-full mt-6 mb-6">
|
||||
<div className="h-[50vh] overflow-y-auto w-full">
|
||||
<table>
|
||||
<table className="environment-variables">
|
||||
<thead>
|
||||
<tr>
|
||||
<td className="text-center">Enabled</td>
|
||||
@@ -253,6 +253,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
|
||||
ref={addButtonRef}
|
||||
className="btn-add-param text-link pr-2 py-3 mt-2 select-none"
|
||||
onClick={addVariable}
|
||||
id="add-variable"
|
||||
>
|
||||
+ Add Variable
|
||||
</button>
|
||||
|
||||
@@ -19,7 +19,7 @@ const EnvironmentSelector = () => {
|
||||
|
||||
const Icon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className={`current-environment flex flex-row gap-1 rounded-xl text-xs cursor-pointer items-center justify-center select-none ${activeGlobalEnvironmentUid? 'environment-active': ''}`}>
|
||||
<div ref={ref} className={`current-environment global-environment flex flex-row gap-1 rounded-xl text-xs cursor-pointer items-center justify-center select-none ${activeGlobalEnvironmentUid? 'environment-active': ''}`}>
|
||||
<ToolHint text="Global Environments" toolhintId="GlobalEnvironmentsToolhintId" className='flex flex-row'>
|
||||
<IconWorld className="globe" size={16} strokeWidth={1.5} />
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -14,6 +14,11 @@ const BETA_FEATURES = [
|
||||
id: 'grpc',
|
||||
label: 'gRPC Support',
|
||||
description: 'Enable gRPC request support for making gRPC calls to services'
|
||||
},
|
||||
{
|
||||
id: 'nodevm',
|
||||
label: 'Node VM Runtime',
|
||||
description: 'Enable Node VM runtime for JavaScript execution in Developer Mode'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -68,7 +73,7 @@ const Beta = ({ close }) => {
|
||||
.catch((err) => console.log(err) && toast.error('Failed to update beta preferences'));
|
||||
};
|
||||
|
||||
const hasAnyBetaFeatures = Object.values(formik.values).length > 0;
|
||||
const hasAnyBetaFeatures = BETA_FEATURES.length > 0;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
|
||||
@@ -176,11 +176,11 @@ const General = ({ close }) => {
|
||||
name="keepDefaultCaCertificates.enabled"
|
||||
checked={formik.values.keepDefaultCaCertificates.enabled}
|
||||
onChange={formik.handleChange}
|
||||
className={`mousetrap mr-0 ${formik.values.customCaCertificate.enabled ? '' : 'opacity-25'}`}
|
||||
disabled={formik.values.customCaCertificate.enabled ? false : true}
|
||||
className={`mousetrap mr-0 ${formik.values.customCaCertificate.enabled && formik.values.customCaCertificate.filePath ? '' : 'opacity-25'}`}
|
||||
disabled={formik.values.customCaCertificate.enabled && formik.values.customCaCertificate.filePath ? false : true}
|
||||
/>
|
||||
<label
|
||||
className={`block ml-2 select-none ${formik.values.customCaCertificate.enabled ? '' : 'opacity-25'}`}
|
||||
className={`block ml-2 select-none ${formik.values.customCaCertificate.enabled && formik.values.customCaCertificate.filePath ? '' : 'opacity-25'}`}
|
||||
htmlFor="keepDefaultCaCertificatesEnabled"
|
||||
>
|
||||
Keep Default CA Certificates
|
||||
|
||||
@@ -30,6 +30,10 @@ const GrpcAuthMode = ({ item, collection }) => {
|
||||
name: 'OAuth2',
|
||||
mode: 'oauth2'
|
||||
},
|
||||
{
|
||||
name: 'WSSE Auth',
|
||||
mode: 'wsse'
|
||||
},
|
||||
{
|
||||
name: 'Inherit',
|
||||
mode: 'inherit'
|
||||
|
||||
@@ -6,6 +6,7 @@ import BearerAuth from '../../Auth/BearerAuth';
|
||||
import BasicAuth from '../../Auth/BasicAuth';
|
||||
import ApiKeyAuth from '../../Auth/ApiKeyAuth';
|
||||
import OAuth2 from '../../Auth/OAuth2/index';
|
||||
import WsseAuth from '../../Auth/WsseAuth';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections';
|
||||
import { getTreePathFromCollectionToItem } from 'utils/collections/index';
|
||||
@@ -13,7 +14,10 @@ import { updateRequestAuthMode, updateAuth } from 'providers/ReduxStore/slices/c
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
// List of auth modes supported by gRPC
|
||||
const supportedGrpcAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'none', 'inherit'];
|
||||
// Note: Only header-based auth modes work with gRPC
|
||||
// Complex auth modes like AWS Sig v4, Digest, and NTLM require axios interceptors
|
||||
// and cannot be supported in gRPC requests as of now
|
||||
const supportedGrpcAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'wsse', 'none', 'inherit'];
|
||||
|
||||
const GrpcAuth = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -83,6 +87,9 @@ const GrpcAuth = ({ item, collection }) => {
|
||||
case 'oauth2': {
|
||||
return <OAuth2 collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
|
||||
}
|
||||
case 'wsse': {
|
||||
return <WsseAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
|
||||
}
|
||||
case 'inherit': {
|
||||
const source = getEffectiveAuthSource();
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ const Wrapper = styled.div`
|
||||
|
||||
.method-selector {
|
||||
border-radius: 3px;
|
||||
min-width: 90px;
|
||||
|
||||
.tippy-box {
|
||||
max-width: 150px !important;
|
||||
@@ -21,6 +20,28 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
background-color: ${(props) => props.theme.requestTabPanel.url.bg};
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
text-align: left;
|
||||
|
||||
&:focus {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.method-span {
|
||||
width: 70px;
|
||||
min-width: 70px;
|
||||
max-width: 90px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.caret {
|
||||
color: rgb(140, 140, 140);
|
||||
fill: rgb(140 140 140);
|
||||
|
||||
@@ -1,52 +1,142 @@
|
||||
import React, { useRef, forwardRef } from 'react';
|
||||
import React, { useState, useRef, forwardRef } from 'react';
|
||||
import { IconCaretDown } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const HttpMethodSelector = ({ method, onMethodSelect }) => {
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
const STANDARD_METHODS = Object.freeze(['GET','POST','PUT','DELETE','PATCH','OPTIONS','HEAD','TRACE','CONNECT']);
|
||||
|
||||
const Icon = forwardRef((props, ref) => {
|
||||
const KEY = Object.freeze({ ENTER: 'Enter', ESCAPE: 'Escape' });
|
||||
|
||||
const DEFAULT_METHOD = 'GET';
|
||||
|
||||
function Verb({ verb, onSelect }) {
|
||||
return (
|
||||
<div className="dropdown-item" onClick={() => onSelect(verb)}>
|
||||
{verb}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = forwardRef(function IconComponent(
|
||||
{ isCustomMode, inputValue, handleInputChange, handleBlur, handleKeyDown, inputRef },
|
||||
ref
|
||||
) {
|
||||
if (isCustomMode) {
|
||||
return (
|
||||
<div ref={ref} className="flex w-full items-center pl-3 py-1 select-none uppercase">
|
||||
<div className="flex-grow font-medium" id="create-new-request-method">
|
||||
{method}
|
||||
</div>
|
||||
<div>
|
||||
<IconCaretDown className="caret ml-2 mr-2" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="font-medium px-2 w-full focus:bg-transparent"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
title={inputValue}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const handleMethodSelect = (verb) => onMethodSelect(verb);
|
||||
|
||||
const Verb = ({ verb }) => {
|
||||
return (
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleMethodSelect(verb);
|
||||
}}
|
||||
return (
|
||||
<div ref={ref} className="flex pr-4 select-none">
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer flex items-center text-left w-full"
|
||||
>
|
||||
{verb}
|
||||
</div>
|
||||
);
|
||||
<span
|
||||
className="font-medium px-2 truncate method-span"
|
||||
id="create-new-request-method"
|
||||
title={inputValue}
|
||||
>
|
||||
{inputValue}
|
||||
</span>
|
||||
<IconCaretDown className="caret" size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const HttpMethodSelector = ({ method = DEFAULT_METHOD, onMethodSelect }) => {
|
||||
const [isCustomMode, setIsCustomMode] = useState(false);
|
||||
const dropdownTippyRef = useRef();
|
||||
const inputRef = useRef();
|
||||
|
||||
const blurInput = () => inputRef.current?.blur();
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const val = e.target.value.toUpperCase();
|
||||
onMethodSelect(val);
|
||||
};
|
||||
|
||||
const handleDropdownSelect = (verb) => {
|
||||
onMethodSelect(verb);
|
||||
setIsCustomMode(false);
|
||||
dropdownTippyRef.current?.hide();
|
||||
blurInput();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsCustomMode(false);
|
||||
};
|
||||
|
||||
const handleAddCustomMethod = () => {
|
||||
setIsCustomMode(true);
|
||||
onMethodSelect('');
|
||||
dropdownTippyRef.current?.hide();
|
||||
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
switch (e.key) {
|
||||
case KEY.ESCAPE:
|
||||
setIsCustomMode(false);
|
||||
blurInput();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
case KEY.ENTER:
|
||||
onMethodSelect(e.target.value ? e.target.value.toUpperCase() : DEFAULT_METHOD);
|
||||
setIsCustomMode(false);
|
||||
blurInput();
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="flex items-center cursor-pointer method-selector">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-start">
|
||||
<Verb verb="GET" />
|
||||
<Verb verb="POST" />
|
||||
<Verb verb="PUT" />
|
||||
<Verb verb="DELETE" />
|
||||
<Verb verb="PATCH" />
|
||||
<Verb verb="OPTIONS" />
|
||||
<Verb verb="HEAD" />
|
||||
<div className="flex method-selector">
|
||||
<Dropdown
|
||||
onCreate={onDropdownCreate}
|
||||
icon={
|
||||
<Icon
|
||||
isCustomMode={isCustomMode}
|
||||
inputValue={method}
|
||||
handleInputChange={handleInputChange}
|
||||
handleBlur={handleBlur}
|
||||
handleKeyDown={handleKeyDown}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
}
|
||||
placement="bottom-start"
|
||||
>
|
||||
<div>
|
||||
{STANDARD_METHODS.map((verb) => (
|
||||
<Verb key={verb} verb={verb} onSelect={handleDropdownSelect} />
|
||||
))}
|
||||
<div className="dropdown-item font-normal mt-1" onClick={handleAddCustomMethod}>
|
||||
<span className="text-link">+ Add Custom</span>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -80,7 +80,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex items-center">
|
||||
<div className="flex items-center h-full method-selector-container">
|
||||
<div className="flex flex-1 items-center h-full method-selector-container">
|
||||
{isGrpc ? (
|
||||
<div className="flex items-center justify-center h-full w-16">
|
||||
<span className="text-xs text-indigo-500 font-bold">gRPC</span>
|
||||
@@ -112,6 +112,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
/>
|
||||
<div className="flex items-center h-full mr-2 cursor-pointer" id="send-request" onClick={handleRun}>
|
||||
<div
|
||||
title="Generate Code"
|
||||
className="infotip mr-3"
|
||||
onClick={(e) => {
|
||||
handleGenerateCode(e);
|
||||
@@ -128,6 +129,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
title="Save Request"
|
||||
className="infotip mr-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const StatusCode = ({ status }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className={getTabClassname(status)}>
|
||||
<StyledWrapper className={`response-status-code ${getTabClassname(status)}`}>
|
||||
{status} {statusCodePhraseMap[status]}
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -232,7 +232,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
onClick={handleCollectionCollapse}
|
||||
onDoubleClick={handleCollectionDoubleClick}
|
||||
/>
|
||||
<div className="ml-1 w-full" id="sidebar-collection-name">
|
||||
<div className="ml-1 w-full" id="sidebar-collection-name" title={collection.name}>
|
||||
{collection.name}
|
||||
</div>
|
||||
{isLoading ? <IconLoader2 className="animate-spin mx-1" size={18} strokeWidth={1.5} /> : null}
|
||||
|
||||
@@ -463,7 +463,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
|
||||
</label>
|
||||
<div className="flex items-center mt-2 ">
|
||||
{formik.values.requestType !== 'grpc-request' ? (
|
||||
<div className="flex items-center h-full method-selector-container">
|
||||
<div className="flex items-center h-full method-selector-container w-1/5">
|
||||
<HttpMethodSelector
|
||||
method={formik.values.requestMethod}
|
||||
onMethodSelect={(val) => formik.setFieldValue('requestMethod', val)}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -2440,6 +2440,9 @@ export const collectionsSlice = createSlice({
|
||||
if (type === 'testrun-ended') {
|
||||
const info = collection.runnerResult.info;
|
||||
info.status = 'ended';
|
||||
if (action.payload.runCompletionTime) {
|
||||
info.runCompletionTime = action.payload.runCompletionTime;
|
||||
}
|
||||
if (action.payload.statusText) {
|
||||
info.statusText = action.payload.statusText;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ const darkTheme = {
|
||||
// customize these colors if needed
|
||||
patch: '#d69956',
|
||||
options: '#d69956',
|
||||
head: '#d69956',
|
||||
head: '#d69956'
|
||||
},
|
||||
grpc: '#6366f1'
|
||||
},
|
||||
@@ -134,6 +134,10 @@ const darkTheme = {
|
||||
color: '#ccc'
|
||||
}
|
||||
}
|
||||
},
|
||||
graphqlDocsExplorer: {
|
||||
bg: '#1e1e1e',
|
||||
color: '#d4d4d4'
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -131,6 +131,10 @@ const lightTheme = {
|
||||
color: 'rgb(75 85 99)'
|
||||
}
|
||||
}
|
||||
},
|
||||
graphqlDocsExplorer: {
|
||||
bg: '#fff',
|
||||
color: 'rgb(52, 52, 52)'
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useSelector } from 'react-redux';
|
||||
* Contains all available beta feature keys
|
||||
*/
|
||||
export const BETA_FEATURES = Object.freeze({
|
||||
GRPC: 'grpc'
|
||||
GRPC: 'grpc',
|
||||
NODE_VM: 'nodevm'
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -623,6 +623,7 @@ const handler = async function (argv) {
|
||||
}
|
||||
|
||||
const summary = printRunSummary(results);
|
||||
const runCompletionTime = new Date().toISOString();
|
||||
const totalTime = results.reduce((acc, res) => acc + res.response.responseTime, 0);
|
||||
console.log(chalk.dim(chalk.grey(`Ran all requests - ${totalTime} ms`)));
|
||||
|
||||
@@ -636,7 +637,7 @@ const handler = async function (argv) {
|
||||
const reporters = {
|
||||
'json': (path) => fs.writeFileSync(path, JSON.stringify(outputJson, null, 2)),
|
||||
'junit': (path) => makeJUnitOutput(results, path),
|
||||
'html': (path) => makeHtmlOutput(outputJson, path),
|
||||
'html': (path) => makeHtmlOutput(outputJson, path, runCompletionTime),
|
||||
}
|
||||
|
||||
for (const formatter of Object.keys(formats))
|
||||
|
||||
@@ -1,637 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<!-- Would use latest version, you'd better specify a version -->
|
||||
<script src="https://unpkg.com/naive-ui"></script>
|
||||
|
||||
<title>Bruno</title>
|
||||
<style>
|
||||
.error > .status {
|
||||
color: red;
|
||||
}
|
||||
.success > .status {
|
||||
color: green;
|
||||
}
|
||||
|
||||
.n-collapse-item.success > .n-collapse-item__header {
|
||||
background-color: rgba(237, 247, 242, 1);
|
||||
}
|
||||
.n-collapse-item.error > .n-collapse-item__header {
|
||||
background-color: rgba(251, 238, 241, 1);
|
||||
}
|
||||
|
||||
.min-width-150 {
|
||||
min-width: 150px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<n-config-provider :theme="theme">
|
||||
<n-layout embedded position="absolute" content-style="padding: 24px;">
|
||||
<n-card>
|
||||
<n-flex>
|
||||
<n-page-header title="Bruno run dashboard">
|
||||
<template #avatar>
|
||||
<n-avatar size="large" style="background-color: transparent">
|
||||
<svg id="emoji" width="34" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="color">
|
||||
<path
|
||||
fill="#F4AA41"
|
||||
stroke="none"
|
||||
d="M23.5,14.5855l-4.5,1.75l-7.25,8.5l-4.5,10.75l2,5.25c1.2554,3.7911,3.5231,7.1832,7.25,10l2.5-3.3333 c0,0,3.8218,7.7098,10.7384,8.9598c0,0,10.2616,1.936,15.5949-0.8765c3.4203-1.8037,4.4167-4.4167,4.4167-4.4167l3.4167-3.4167 l1.5833,2.3333l2.0833-0.0833l5.4167-7.25L64,37.3355l-0.1667-4.5l-2.3333-5.5l-4.8333-7.4167c0,0-2.6667-4.9167-8.1667-3.9167 c0,0-6.5-4.8333-11.8333-4.0833S32.0833,10.6688,23.5,14.5855z"
|
||||
></path>
|
||||
<polygon
|
||||
fill="#EA5A47"
|
||||
stroke="none"
|
||||
points="36,47.2521 32.9167,49.6688 30.4167,49.6688 30.3333,53.5021 31.0833,57.0021 32.1667,58.9188 35,60.4188 39.5833,59.8355 41.1667,58.0855 42.1667,53.8355 41.9167,49.8355 39.9167,50.0855"
|
||||
></polygon>
|
||||
<polygon
|
||||
fill="#3F3F3F"
|
||||
stroke="none"
|
||||
points="32.5,36.9188 30.9167,40.6688 33.0833,41.9188 34.3333,42.4188 38.6667,42.5855 41.5833,40.3355 39.8333,37.0855"
|
||||
></polygon>
|
||||
</g>
|
||||
<g id="hair"></g>
|
||||
<g id="skin"></g>
|
||||
<g id="skin-shadow"></g>
|
||||
<g id="line">
|
||||
<path
|
||||
fill="#000000"
|
||||
stroke="none"
|
||||
d="M29.5059,30.1088c0,0-1.8051,1.2424-2.7484,0.6679c-0.9434-0.5745-1.2424-1.8051-0.6679-2.7484 s1.805-1.2424,2.7484-0.6679S29.5059,30.1088,29.5059,30.1088z"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="2"
|
||||
d="M33.1089,37.006h6.1457c0.4011,0,0.7634,0.2397,0.9203,0.6089l1.1579,2.7245l-2.1792,1.1456 c-0.6156,0.3236-1.3654-0.0645-1.4567-0.754"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="2"
|
||||
d="M34.7606,40.763c-0.1132,0.6268-0.7757,0.9895-1.3647,0.7471l-2.3132-0.952l1.0899-2.9035 c0.1465-0.3901,0.5195-0.6486,0.9362-0.6486"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="2"
|
||||
d="M30.4364,50.0268c0,0-0.7187,8.7934,3.0072,9.9375c2.6459,0.8125,5.1497,0.5324,6.0625-0.25 c0.875-0.75,2.6323-4.4741,1.8267-9.6875"
|
||||
></path>
|
||||
<path
|
||||
fill="#000000"
|
||||
stroke="none"
|
||||
d="M44.2636,30.1088c0,0,1.805,1.2424,2.7484,0.6679c0.9434-0.5745,1.2424-1.8051,0.6679-2.7484 c-0.5745-0.9434-1.805-1.2424-2.7484-0.6679C43.9881,27.9349,44.2636,30.1088,44.2636,30.1088z"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="2"
|
||||
d="M25.6245,42.8393c-0.475,3.6024,2.2343,5.7505,4.2847,6.8414c1.1968,0.6367,2.6508,0.5182,3.7176-0.3181l2.581-2.0233l2.581,2.0233 c1.0669,0.8363,2.5209,0.9548,3.7176,0.3181c2.0504-1.0909,4.7597-3.239,4.2847-6.8414"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="2"
|
||||
d="M19.9509,28.3572c-2.3166,5.1597-0.5084,13.0249,0.119,15.3759c0.122,0.4571,0.0755,0.9355-0.1271,1.3631l-1.9874,4.1937 c-0.623,1.3146-2.3934,1.5533-3.331,0.4409c-3.1921-3.7871-8.5584-11.3899-6.5486-16.686 c7.0625-18.6104,15.8677-18.1429,15.8677-18.1429c2.8453-1.9336,13.1042-6.9375,24.8125,0.875c0,0,8.6323-1.7175,14.9375,16.9375 c1.8036,5.3362-3.4297,12.8668-6.5506,16.6442c-0.9312,1.127-2.7162,0.8939-3.3423-0.4272l-1.9741-4.1656 c-0.2026-0.4275-0.2491-0.906-0.1271-1.3631c0.6275-2.3509,2.4356-10.2161,0.119-15.3759"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="2"
|
||||
d="M52.6309,46.4628c0,0-3.0781,6.7216-7.8049,8.2712"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="2"
|
||||
d="M19.437,46.969c0,0,3.0781,6.0823,7.8049,7.632"
|
||||
></path>
|
||||
<line
|
||||
x1="36.2078"
|
||||
x2="36.2078"
|
||||
y1="47.3393"
|
||||
y2="44.3093"
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="2"
|
||||
></line>
|
||||
</g>
|
||||
</svg>
|
||||
</n-avatar>
|
||||
</template>
|
||||
<template #extra>
|
||||
<n-flex justify="end">
|
||||
<n-switch v-model:value="darkMode" :rail-style="darkModeRailStyle">
|
||||
<template #checked> Dark </template>
|
||||
<template #unchecked> Light </template>
|
||||
</n-switch>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-page-header>
|
||||
<n-tabs type="segment" animated>
|
||||
<n-tab-pane name="summary" tab="Summary">
|
||||
<x-summary :res="res"></x-summary>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="Requests" tab="Requests">
|
||||
<x-requests :res="res"></x-requests>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
</n-layout>
|
||||
</n-config-provider>
|
||||
</div>
|
||||
<script type="text/x-template" id="summary-component">
|
||||
<n-flex vertical>
|
||||
<n-flex justify="center">
|
||||
<n-alert type="success">
|
||||
<n-statistic
|
||||
label="Total Controls"
|
||||
:value="summaryTotalControls"
|
||||
>
|
||||
</n-statistic>
|
||||
</n-alert>
|
||||
<n-alert :type="summaryFailedControls ? 'error' : 'success'">
|
||||
<n-statistic
|
||||
label="Total Failed Controls"
|
||||
:value="summaryFailedControls"
|
||||
>
|
||||
</n-statistic>
|
||||
</n-alert>
|
||||
<n-alert :type="summaryErrors ? 'error' : 'success'">
|
||||
<n-statistic label="Total errors" :value="summaryErrors">
|
||||
</n-statistic>
|
||||
</n-alert>
|
||||
</n-flex>
|
||||
<n-card title="TIMINGS AND DATA">
|
||||
<n-flex justify="center">
|
||||
<n-statistic
|
||||
label="Total run duration"
|
||||
:value="Math.round(totalRunDuration*1000)/1000"
|
||||
>
|
||||
<template #suffix>s</template>
|
||||
</n-statistic>
|
||||
<n-statistic
|
||||
label="Total requests"
|
||||
:value="summaryTotalRequests"
|
||||
>
|
||||
</n-statistic>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
<n-data-table :columns="summaryColumns" :data="summaryData" />
|
||||
</n-flex>
|
||||
</script>
|
||||
<script type="text/x-template" id="requests-component">
|
||||
<n-flex vertical>
|
||||
<n-switch
|
||||
v-model:value="onlyFailed"
|
||||
:rail-style="railStyle"
|
||||
>
|
||||
<template #checked> Only Failed </template>
|
||||
<template #unchecked> Only Failed </template>
|
||||
</n-switch>
|
||||
|
||||
<n-collapse>
|
||||
<x-results-group v-for="(results, group) in groupedResults" :results="results" :group="group" :key="group + '-' + results.length"></x-results-group>
|
||||
</n-collapse>
|
||||
</n-flex>
|
||||
</script>
|
||||
<script type="text/x-template" id="results-group-component">
|
||||
<n-collapse-item
|
||||
:name="group"
|
||||
arrow-placement="right"
|
||||
>
|
||||
<template #header>
|
||||
<n-alert
|
||||
:type="hasError || hasFailure ? 'error' : 'success'"
|
||||
:bordered="false"
|
||||
>
|
||||
<template #header>
|
||||
{{group}} - {{totalPassed}} / {{total}} Passed {{ hasError? " - Error" : "" }}
|
||||
</template>
|
||||
</n-alert>
|
||||
</template>
|
||||
<n-collapse>
|
||||
<x-result v-for="(result, index) in results" :result="result" :group="group" :key="index"></x-result>
|
||||
</n-collapse>
|
||||
</n-collapse-item>
|
||||
</script>
|
||||
<script type="text/x-template" id="result-component">
|
||||
<n-collapse-item
|
||||
:name="name"
|
||||
arrow-placement="right"
|
||||
>
|
||||
<template #header>
|
||||
<n-alert
|
||||
:type="hasError || hasFailure ? 'error' : 'success'"
|
||||
:bordered="false"
|
||||
>
|
||||
<template #header>
|
||||
{{suitename}} - {{totalPassed}} / {{total}} Passed {{hasError ? " - Error" : "" }}
|
||||
</template>
|
||||
</n-alert>
|
||||
</template>
|
||||
<n-flex vertical>
|
||||
<n-grid x-gap="12" :cols="2">
|
||||
<n-gi>
|
||||
<n-card title="REQUEST INFORMATION">
|
||||
<n-list>
|
||||
<n-list-item>
|
||||
<n-thing
|
||||
title="File"
|
||||
:description="result.test.filename"
|
||||
/>
|
||||
</n-list-item>
|
||||
<n-list-item>
|
||||
<n-thing
|
||||
title="Request Method"
|
||||
:description="result.request.method"
|
||||
/>
|
||||
</n-list-item>
|
||||
<n-list-item>
|
||||
<n-thing
|
||||
title="Request URL"
|
||||
:description="result.request.url"
|
||||
/>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card title="RESPONSE INFORMATION">
|
||||
<n-list>
|
||||
<n-list-item>
|
||||
<n-thing
|
||||
title="Response Code"
|
||||
:description="'' + result.response.status"
|
||||
/>
|
||||
</n-list-item>
|
||||
<n-list-item>
|
||||
<n-thing
|
||||
title="Response time"
|
||||
:description="result.response.responseTime + ' ms'"
|
||||
/>
|
||||
</n-list-item>
|
||||
<n-list-item>
|
||||
<n-thing
|
||||
title="Test duration"
|
||||
:description="testDuration"
|
||||
/>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-alert v-if="hasError" title="Error" type="error">
|
||||
{{result.error}}
|
||||
</n-alert>
|
||||
<n-card title="REQUEST HEADERS">
|
||||
<n-data-table
|
||||
:columns="headerColumns"
|
||||
:data="headerDataRequest"
|
||||
/>
|
||||
</n-card>
|
||||
<n-card
|
||||
v-if="result.request.data"
|
||||
title="REQUEST BODY"
|
||||
>
|
||||
<pre>{{result.request.data}}</pre>
|
||||
</n-card>
|
||||
<n-card title="RESPONSE HEADERS">
|
||||
<n-data-table
|
||||
:columns="headerColumns"
|
||||
:data="headerDataResponse"
|
||||
/>
|
||||
</n-card>
|
||||
<n-card
|
||||
v-if="result.response.data"
|
||||
title="RESPONSE BODY"
|
||||
>
|
||||
<pre>{{result.response.data}}</pre>
|
||||
</n-card>
|
||||
<n-card title="ASSERTIONS INFORMATION">
|
||||
<n-data-table
|
||||
:columns="assertionsColumns"
|
||||
:data="result.assertionResults"
|
||||
:row-class-name="assertionsRowClassName"
|
||||
/>
|
||||
</n-card>
|
||||
<n-card title="TESTS INFORMATION">
|
||||
<n-data-table
|
||||
:columns="testsColumns"
|
||||
:data="result.testResults"
|
||||
:row-class-name="testsRowClassName"
|
||||
/>
|
||||
</n-card>
|
||||
</n-flex>
|
||||
</n-collapse-item>
|
||||
</script>
|
||||
<script>
|
||||
const { createApp, ref, computed } = Vue;
|
||||
|
||||
const App = {
|
||||
setup() {
|
||||
const res = __RESULTS_JSON__;
|
||||
|
||||
const darkMode = ref(false);
|
||||
const theme = computed(() => {
|
||||
return darkMode.value ? naive.darkTheme : null;
|
||||
});
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
darkMode.value = true;
|
||||
}
|
||||
// To watch for os theme changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (event) => {
|
||||
darkMode.value = event.matches;
|
||||
});
|
||||
return {
|
||||
res,
|
||||
theme,
|
||||
darkMode,
|
||||
darkModeRailStyle: () => ({ background: 'var(--n-rail-color)' })
|
||||
};
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(App);
|
||||
|
||||
app.component('x-summary', {
|
||||
template: `#summary-component`,
|
||||
props: ['res'],
|
||||
setup(props) {
|
||||
const summaryColumns = [
|
||||
{
|
||||
title: 'SUMMARY ITEM',
|
||||
key: 'title'
|
||||
},
|
||||
{
|
||||
title: 'TOTAL',
|
||||
key: 'total'
|
||||
},
|
||||
{
|
||||
title: 'PASSED',
|
||||
key: 'passed'
|
||||
},
|
||||
{
|
||||
title: 'FAILED',
|
||||
key: 'failed'
|
||||
}
|
||||
];
|
||||
const summaryData = computed(() => [
|
||||
{
|
||||
title: 'Requests',
|
||||
total: props.res.summary.totalRequests,
|
||||
passed: props.res.summary.passedRequests,
|
||||
failed: props.res.summary.failedRequests
|
||||
},
|
||||
{
|
||||
title: 'Assertions',
|
||||
total: props.res.summary.totalAssertions,
|
||||
passed: props.res.summary.passedAssertions,
|
||||
failed: props.res.summary.failedAssertions
|
||||
},
|
||||
{
|
||||
title: 'Tests',
|
||||
total: props.res.summary.totalTests,
|
||||
passed: props.res.summary.passedTests,
|
||||
failed: props.res.summary.failedTests
|
||||
}
|
||||
]);
|
||||
const summaryTotalRequests = computed(() => {
|
||||
return props.res.summary.totalRequests;
|
||||
});
|
||||
const summaryTotalControls = computed(() => {
|
||||
return props.res.summary.totalTests + props.res.summary.totalAssertions;
|
||||
});
|
||||
const summaryFailedControls = computed(
|
||||
() => props.res.summary.failedRequests + props.res.summary.failedTests + props.res.summary.failedAssertions
|
||||
);
|
||||
const summaryErrors = computed(() => props.res.results.filter((r) => r.error).length);
|
||||
const totalRunDuration = computed(() => props.res?.results?.reduce((total, test) => test.runtime + total, 0));
|
||||
return {
|
||||
summaryColumns,
|
||||
summaryData,
|
||||
summaryTotalControls,
|
||||
summaryTotalRequests,
|
||||
summaryFailedControls,
|
||||
summaryErrors,
|
||||
totalRunDuration
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.component('x-requests', {
|
||||
template: `#requests-component`,
|
||||
props: ['res'],
|
||||
setup(props) {
|
||||
const onlyFailed = ref(false);
|
||||
const filteredResults = computed(() => {
|
||||
if (onlyFailed.value) {
|
||||
return props.res.results.filter(
|
||||
(r) =>
|
||||
!!r.error ||
|
||||
!!r.testResults.find((t) => t.status !== 'pass') ||
|
||||
!!r.assertionResults.find((t) => t.status !== 'pass')
|
||||
);
|
||||
}
|
||||
return props.res.results;
|
||||
});
|
||||
const groupedResults = computed(() => {
|
||||
return filteredResults.value.reduce((groups, curr) => {
|
||||
const path = curr.suitename.split('/');
|
||||
const test = path.pop();
|
||||
const name = path.length ? path.join('/') : '(root)';
|
||||
if (!groups[name]) {
|
||||
groups[name] = [];
|
||||
}
|
||||
groups[name].push(curr);
|
||||
return groups;
|
||||
}, {});
|
||||
});
|
||||
return {
|
||||
onlyFailed,
|
||||
groupedResults,
|
||||
railStyle: ({ checked }) => {
|
||||
const style = {};
|
||||
if (checked) {
|
||||
style.background = '#d03050';
|
||||
}
|
||||
return style;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.component('x-results-group', {
|
||||
template: `#results-group-component`,
|
||||
props: ['group', 'results'],
|
||||
setup(props) {
|
||||
const totalPassed = computed(() => {
|
||||
return props.results.reduce((total, curr) => {
|
||||
return (
|
||||
total +
|
||||
curr.testResults.filter((t) => t.status === 'pass').length +
|
||||
curr.assertionResults.filter((t) => t.status === 'pass').length
|
||||
);
|
||||
}, 0);
|
||||
});
|
||||
const total = computed(() => {
|
||||
return props.results.reduce((total, curr) => {
|
||||
return total + curr.testResults.length + curr.assertionResults.length;
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const hasError = computed(() => props.results.some((r) => !!r.error));
|
||||
const hasFailure = computed(() => totalPassed.value !== total.value);
|
||||
return {
|
||||
totalPassed,
|
||||
total,
|
||||
hasFailure,
|
||||
hasError,
|
||||
group: props.group,
|
||||
results: props.results
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.component('x-result', {
|
||||
template: `#result-component`,
|
||||
props: ['group', 'result'],
|
||||
setup(props) {
|
||||
const headerColumns = [
|
||||
{
|
||||
title: 'Header Name',
|
||||
key: 'name',
|
||||
className: 'min-width-150'
|
||||
},
|
||||
{
|
||||
title: 'Header Value',
|
||||
key: 'value'
|
||||
}
|
||||
];
|
||||
const assertionsColumns = [
|
||||
{
|
||||
title: 'Expression',
|
||||
key: 'lhsExpr'
|
||||
},
|
||||
{
|
||||
title: 'Operator',
|
||||
key: 'operator'
|
||||
},
|
||||
{
|
||||
title: 'Operand',
|
||||
key: 'rhsOperand'
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
key: 'status',
|
||||
className: 'status'
|
||||
},
|
||||
{
|
||||
title: 'Error',
|
||||
key: 'error'
|
||||
}
|
||||
];
|
||||
const assertionsRowClassName = (row) => {
|
||||
return row.status === 'fail' ? 'error' : 'success';
|
||||
};
|
||||
const testsRowClassName = (row) => {
|
||||
return row.status === 'fail' ? 'error' : 'success';
|
||||
};
|
||||
const testsColumns = [
|
||||
{
|
||||
title: 'Description',
|
||||
key: 'description'
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
key: 'status',
|
||||
className: 'status'
|
||||
},
|
||||
{
|
||||
title: 'Error',
|
||||
key: 'error'
|
||||
}
|
||||
];
|
||||
|
||||
function mapHeaderToTableData(headers) {
|
||||
if (!headers) {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(headers).map((name) => ({
|
||||
name,
|
||||
value: headers[name]
|
||||
}));
|
||||
}
|
||||
const headerDataRequest = computed(() => {
|
||||
return mapHeaderToTableData(props.result.request.headers);
|
||||
});
|
||||
const headerDataResponse = computed(() => {
|
||||
return mapHeaderToTableData(props.result.response.headers);
|
||||
});
|
||||
const totalPassed = computed(() => {
|
||||
return (
|
||||
props.result.testResults.filter((t) => t.status === 'pass').length +
|
||||
props.result.assertionResults.filter((t) => t.status === 'pass').length
|
||||
);
|
||||
});
|
||||
const total = computed(() => {
|
||||
return props.result.testResults.length + props.result.assertionResults.length;
|
||||
});
|
||||
|
||||
const hasError = computed(() => !!props.result.error);
|
||||
const hasFailure = computed(() => total.value !== totalPassed.value);
|
||||
const suitename = computed(() => props.result.suitename.replace(props.group + '/', ''));
|
||||
const testDuration = computed(() => Math.round(props.result.runtime * 1000) + ' ms');
|
||||
const name = computed(() => props.result.suitename + props.result.runtime);
|
||||
return {
|
||||
headerColumns,
|
||||
headerDataRequest,
|
||||
headerDataResponse,
|
||||
assertionsColumns,
|
||||
assertionsRowClassName,
|
||||
testsRowClassName,
|
||||
totalPassed,
|
||||
total,
|
||||
hasFailure,
|
||||
hasError,
|
||||
testsColumns,
|
||||
result: props.result,
|
||||
suitename,
|
||||
testDuration,
|
||||
name
|
||||
};
|
||||
}
|
||||
});
|
||||
app.use(naive);
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,13 +1,31 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { generateHtmlReport } = require('@usebruno/common/runner');
|
||||
const { CLI_VERSION } = require('../constants');
|
||||
|
||||
const makeHtmlOutput = async (results, outputPath) => {
|
||||
const resultsJson = JSON.stringify(results, null, 2);
|
||||
|
||||
const reportPath = path.join(__dirname, 'html-template.html');
|
||||
const template = fs.readFileSync(reportPath, 'utf8');
|
||||
|
||||
fs.writeFileSync(outputPath, template.replace('__RESULTS_JSON__', resultsJson));
|
||||
const makeHtmlOutput = async (results, outputPath, runCompletionTime) => {
|
||||
let runnerResults = results;
|
||||
if (!results) {
|
||||
runnerResults = [];
|
||||
} else if (results.results) {
|
||||
// Convert CLI format to expected format: array of { iterationIndex, results, summary }
|
||||
runnerResults = [{
|
||||
iterationIndex: 0,
|
||||
results: results.results,
|
||||
summary: results.summary
|
||||
}];
|
||||
} else if (Array.isArray(results)) {
|
||||
runnerResults = results;
|
||||
}
|
||||
|
||||
const environment = runnerResults.length > 0 ? runnerResults[0].environment : null;
|
||||
|
||||
const htmlString = generateHtmlReport({
|
||||
runnerResults: runnerResults,
|
||||
version: `usebruno v${CLI_VERSION}`,
|
||||
environment: environment,
|
||||
runCompletionTime: runCompletionTime
|
||||
});
|
||||
fs.writeFileSync(outputPath, htmlString);
|
||||
};
|
||||
|
||||
module.exports = makeHtmlOutput;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
const os = require('os');
|
||||
const qs = require('qs');
|
||||
const chalk = require('chalk');
|
||||
const decomment = require('decomment');
|
||||
const fs = require('fs');
|
||||
const tls = require('tls');
|
||||
const { forOwn, isUndefined, isNull, each, extend, get, compact } = require('lodash');
|
||||
const FormData = require('form-data');
|
||||
const prepareRequest = require('./prepare-request');
|
||||
@@ -26,6 +24,7 @@ const { getOAuth2Token } = require('./oauth2');
|
||||
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
|
||||
const { NtlmClient } = require('axios-ntlm');
|
||||
const { addDigestInterceptor } = require('@usebruno/requests');
|
||||
const { getCACertificates } = require('@usebruno/requests');
|
||||
const { encodeUrl } = require('@usebruno/common').utils;
|
||||
|
||||
const onConsoleLog = (type, args) => {
|
||||
@@ -151,22 +150,14 @@ const runSingleRequest = async function (
|
||||
const insecure = get(options, 'insecure', false);
|
||||
const noproxy = get(options, 'noproxy', false);
|
||||
const httpsAgentRequestFields = {};
|
||||
|
||||
if (insecure) {
|
||||
httpsAgentRequestFields['rejectUnauthorized'] = false;
|
||||
} else {
|
||||
const caCertArray = [options['cacert'], process.env.SSL_CERT_FILE, process.env.NODE_EXTRA_CA_CERTS];
|
||||
const caCert = caCertArray.find((el) => el);
|
||||
if (caCert && caCert.length > 1) {
|
||||
try {
|
||||
let caCertBuffer = fs.readFileSync(caCert);
|
||||
if (!options['ignoreTruststore']) {
|
||||
caCertBuffer += '\n' + tls.rootCertificates.join('\n'); // Augment default truststore with custom CA certificates
|
||||
}
|
||||
httpsAgentRequestFields['ca'] = caCertBuffer;
|
||||
} catch (err) {
|
||||
console.log('Error reading CA cert file:' + caCert, err);
|
||||
}
|
||||
}
|
||||
const caCertFilePath = options['cacert'];
|
||||
let caCertificatesData = getCACertificates({ caCertFilePath, shouldKeepDefaultCerts: !options['ignoreTruststore'] });
|
||||
let caCertificates = caCertificatesData.caCertificates;
|
||||
httpsAgentRequestFields['ca'] = caCertificates || [];
|
||||
}
|
||||
|
||||
const interpolationOptions = {
|
||||
@@ -347,6 +338,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;
|
||||
|
||||
@@ -77,6 +77,8 @@ const bruToJson = (bru) => {
|
||||
request: {
|
||||
url: _.get(json, requestType === 'grpc-request' ? 'grpc.url' : 'http.url'),
|
||||
headers: requestType === 'grpc-request' ? _.get(json, 'metadata', []) : _.get(json, 'headers', []),
|
||||
// Preserving special characters in custom methods. Using _.upperCase strips special characters.
|
||||
method: String(_.get(json, 'http.method') ?? '').toUpperCase(),
|
||||
auth: _.get(json, 'auth', {}),
|
||||
params: _.get(json, 'params', []),
|
||||
vars: _.get(json, 'vars', []),
|
||||
|
||||
52
packages/bruno-cli/tests/runner/report-metadata.spec.js
Normal file
52
packages/bruno-cli/tests/runner/report-metadata.spec.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const { describe, it, expect } = require('@jest/globals');
|
||||
const { generateHtmlReport } = require('@usebruno/common/runner');
|
||||
|
||||
describe('HTML Report Generation', () => {
|
||||
it('should include all metadata in the HTML report', async () => {
|
||||
// Sample test results
|
||||
const mockResults = [
|
||||
{
|
||||
iterationIndex: 0,
|
||||
environment: 'production',
|
||||
results: [],
|
||||
summary: {
|
||||
totalRequests: 1,
|
||||
passedRequests: 1,
|
||||
failedRequests: 0,
|
||||
errorRequests: 0,
|
||||
skippedRequests: 0,
|
||||
totalAssertions: 0,
|
||||
passedAssertions: 0,
|
||||
failedAssertions: 0,
|
||||
totalTests: 0,
|
||||
passedTests: 0,
|
||||
failedTests: 0
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Generate HTML using mock data
|
||||
const htmlString = generateHtmlReport({
|
||||
runnerResults: mockResults,
|
||||
version: 'usebruno v1.16.0',
|
||||
environment: 'production',
|
||||
runCompletionTime: '2024-01-15T14:30:45.123Z'
|
||||
});
|
||||
|
||||
// Verify the HTML contains expected metadata structure
|
||||
expect(htmlString).toContain('Bruno run dashboard');
|
||||
expect(htmlString).toContain('Date & Time');
|
||||
expect(htmlString).toContain('Version');
|
||||
expect(htmlString).toContain('Environment');
|
||||
expect(htmlString).toContain('Total run duration');
|
||||
expect(htmlString).toContain('Total data received');
|
||||
expect(htmlString).toContain('Average response time');
|
||||
|
||||
expect(htmlString).toContain('{{ runCompletionTime }}');
|
||||
expect(htmlString).toContain('{{ brunoVersion }}');
|
||||
expect(htmlString).toContain('{{ environment }}');
|
||||
expect(htmlString).toContain('{{ totalDuration }}');
|
||||
expect(htmlString).toContain('{{ totalDataReceived }}');
|
||||
expect(htmlString).toContain('{{ averageResponseTime }}');
|
||||
});
|
||||
});
|
||||
@@ -2,4 +2,4 @@ export { mockDataFunctions } from './utils/faker-functions';
|
||||
export { default as interpolate } from './interpolate';
|
||||
export { default as isRequestTagsIncluded } from './tags';
|
||||
|
||||
export * as utils from './utils';
|
||||
export * as utils from './utils';
|
||||
|
||||
@@ -3,9 +3,15 @@ import { isHtmlContentType, getContentType, redactImageData, encodeBase64 } from
|
||||
import htmlTemplateString from "./template";
|
||||
|
||||
const generateHtmlReport = ({
|
||||
runnerResults
|
||||
runnerResults,
|
||||
version = '', // Default to empty string if not provided
|
||||
environment = null, // Default environment if not provided
|
||||
runCompletionTime = '' // Default run completion time if not provided
|
||||
}: {
|
||||
runnerResults: T_RunnerResults[]
|
||||
runnerResults: T_RunnerResults[];
|
||||
version?: string;
|
||||
environment?: string | null;
|
||||
runCompletionTime?: string;
|
||||
}): string => {
|
||||
const resultsWithSummaryAndCleanData = runnerResults.map(({ iterationIndex, results, summary }) => {
|
||||
return {
|
||||
@@ -31,7 +37,12 @@ const generateHtmlReport = ({
|
||||
summary
|
||||
}
|
||||
});
|
||||
const htmlString = htmlTemplateString(encodeBase64(JSON.stringify(resultsWithSummaryAndCleanData)));
|
||||
const htmlString = htmlTemplateString(encodeBase64(JSON.stringify({
|
||||
results: resultsWithSummaryAndCleanData,
|
||||
version,
|
||||
environment,
|
||||
runCompletionTime
|
||||
})));
|
||||
return htmlString;
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,37 @@ export const htmlTemplateString = (resutsJsonString: string) =>`<!DOCTYPE html>
|
||||
.min-width-150 {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* Metadata card styling - minimal custom styles */
|
||||
.metadata-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.metadata-item {
|
||||
text-align: center;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metadata-label {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.metadata-value {
|
||||
font-size: 0.8rem;
|
||||
font-weight: normal;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -162,6 +193,35 @@ export const htmlTemplateString = (resutsJsonString: string) =>`<!DOCTYPE html>
|
||||
<n-tabs type="segment" animated v-model:value="currentTab">
|
||||
<n-tab-pane name="summary" tab="Summary">
|
||||
<n-flex justify="center" vertical>
|
||||
<!-- Run Information Card using Naive UI components -->
|
||||
<n-card title="Run Information" size="small">
|
||||
<div class="metadata-grid">
|
||||
<n-card class="metadata-item" size="small">
|
||||
<div class="metadata-label">Date & Time</div>
|
||||
<div class="metadata-value">{{ runCompletionTime }}</div>
|
||||
</n-card>
|
||||
<n-card class="metadata-item" size="small">
|
||||
<div class="metadata-label">Version</div>
|
||||
<div class="metadata-value">{{ brunoVersion }}</div>
|
||||
</n-card>
|
||||
<n-card class="metadata-item" size="small">
|
||||
<div class="metadata-label">Environment</div>
|
||||
<div class="metadata-value">{{ environment }}</div>
|
||||
</n-card>
|
||||
<n-card class="metadata-item" size="small">
|
||||
<div class="metadata-label">Total run duration</div>
|
||||
<div class="metadata-value">{{ totalDuration }}</div>
|
||||
</n-card>
|
||||
<n-card class="metadata-item" size="small">
|
||||
<div class="metadata-label">Total data received</div>
|
||||
<div class="metadata-value">{{ totalDataReceived }}</div>
|
||||
</n-card>
|
||||
<n-card class="metadata-item" size="small">
|
||||
<div class="metadata-label">Average response time</div>
|
||||
<div class="metadata-value">{{ averageResponseTime }}</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</n-card>
|
||||
<x-summary v-for="(result, index) in res" :res="result" :key="index"></x-summary>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
@@ -213,12 +273,6 @@ export const htmlTemplateString = (resutsJsonString: string) =>`<!DOCTYPE html>
|
||||
<n-statistic label="Skipped requests" :value="summarySkippedRequests">
|
||||
</n-statistic>
|
||||
</n-alert>
|
||||
<n-statistic
|
||||
label="Total run duration"
|
||||
:value="Math.round(totalRunDuration*1000)/1000"
|
||||
>
|
||||
<template #suffix>s</template>
|
||||
</n-statistic>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
@@ -400,10 +454,25 @@ export const htmlTemplateString = (resutsJsonString: string) =>`<!DOCTYPE html>
|
||||
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
const rawResults = JSON.parse(decodeBase64('${resutsJsonString}'));
|
||||
|
||||
const res = computed(() => {
|
||||
const rawResults = JSON.parse(decodeBase64('${resutsJsonString}'));
|
||||
return mergeTests(rawResults);
|
||||
return mergeTests(rawResults.results);
|
||||
});
|
||||
|
||||
const brunoVersion = computed(() => {
|
||||
return rawResults.version || '-';
|
||||
});
|
||||
|
||||
const environment = computed(() => {
|
||||
return rawResults.environment || '-';
|
||||
});
|
||||
|
||||
const runCompletionTime = computed(() => {
|
||||
if (rawResults.runCompletionTime) {
|
||||
return new Date(rawResults.runCompletionTime).toLocaleString();
|
||||
}
|
||||
return '-';
|
||||
});
|
||||
|
||||
const currentTab = ref('summary');
|
||||
@@ -422,6 +491,47 @@ export const htmlTemplateString = (resutsJsonString: string) =>`<!DOCTYPE html>
|
||||
const theme = computed(() => {
|
||||
return darkMode.value ? naive.darkTheme : null;
|
||||
});
|
||||
|
||||
const totalDuration = computed(() => {
|
||||
const total = res.value.reduce((totalTime, iteration) => {
|
||||
return totalTime + iteration.results.reduce((sum, result) => sum + (result.runDuration || 0), 0);
|
||||
}, 0);
|
||||
return total > 0 ? Math.round(total * 1000) / 1000 + 's' : '-';
|
||||
});
|
||||
|
||||
const totalDataReceived = computed(() => {
|
||||
const bytes = res.value.reduce((total, iteration) => {
|
||||
return total + iteration.results.reduce((sum, result) => {
|
||||
const responseData = result.response?.data;
|
||||
if (typeof responseData === 'string') {
|
||||
return sum + new Blob([responseData]).size;
|
||||
}
|
||||
return sum + (JSON.stringify(responseData || {}).length || 0);
|
||||
}, 0);
|
||||
}, 0);
|
||||
|
||||
if (bytes === 0) return '-';
|
||||
if (bytes < 1024) return bytes + 'B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + 'KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(2) + 'MB';
|
||||
});
|
||||
|
||||
const averageResponseTime = computed(() => {
|
||||
let totalTime = 0;
|
||||
let count = 0;
|
||||
|
||||
res.value.forEach(iteration => {
|
||||
iteration.results.forEach(result => {
|
||||
if (result.response?.responseTime) {
|
||||
totalTime += result.response.responseTime;
|
||||
count++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return count > 0 ? Math.round(totalTime / count) + 'ms' : '-';
|
||||
});
|
||||
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
darkMode.value = true;
|
||||
}
|
||||
@@ -434,7 +544,13 @@ export const htmlTemplateString = (resutsJsonString: string) =>`<!DOCTYPE html>
|
||||
theme,
|
||||
darkMode,
|
||||
darkModeRailStyle: () => ({ background: 'var(--n-rail-color)' }),
|
||||
currentTab
|
||||
currentTab,
|
||||
brunoVersion,
|
||||
environment,
|
||||
totalDuration,
|
||||
totalDataReceived,
|
||||
averageResponseTime,
|
||||
runCompletionTime
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -271,7 +271,6 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
|
||||
brunoParent.items = brunoParent.items || [];
|
||||
const folderMap = {};
|
||||
const requestMap = {};
|
||||
const requestMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE']
|
||||
|
||||
item.forEach((i, index) => {
|
||||
if (isItemAFolder(i)) {
|
||||
@@ -336,8 +335,9 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
|
||||
folderMap[folderName] = brunoFolderItem;
|
||||
|
||||
} else if (i.request) {
|
||||
if (!requestMethods.includes(i?.request?.method.toUpperCase())) {
|
||||
console.warn('Unexpected request.method', i?.request?.method);
|
||||
const method = i?.request?.method?.toUpperCase();
|
||||
if (!method || typeof method !== 'string' || !method.trim()) {
|
||||
console.warn('Missing or invalid request.method', method);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -359,7 +359,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
|
||||
seq: index + 1,
|
||||
request: {
|
||||
url: url,
|
||||
method: i?.request?.method?.toUpperCase(),
|
||||
method: method,
|
||||
auth: {
|
||||
mode: 'inherit',
|
||||
basic: null,
|
||||
|
||||
@@ -169,12 +169,6 @@ app.on('ready', async () => {
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
// Quick fix for Electron issue #29996: https://github.com/electron/electron/issues/29996
|
||||
globalShortcut.register('Ctrl+=', () => {
|
||||
mainWindow.webContents.setZoomLevel(mainWindow.webContents.getZoomLevel() + 1);
|
||||
});
|
||||
|
||||
|
||||
|
||||
mainWindow.webContents.on('did-finish-load', async () => {
|
||||
let ogSend = mainWindow.webContents.send;
|
||||
@@ -218,3 +212,17 @@ app.on('window-all-closed', app.quit);
|
||||
app.on('open-file', (event, path) => {
|
||||
openCollection(mainWindow, collectionWatcher, path);
|
||||
});
|
||||
|
||||
|
||||
// Register the global shortcuts
|
||||
app.on('browser-window-focus', () => {
|
||||
// Quick fix for Electron issue #29996: https://github.com/electron/electron/issues/29996
|
||||
globalShortcut.register('Ctrl+=', () => {
|
||||
mainWindow.webContents.setZoomLevel(mainWindow.webContents.getZoomLevel() + 1);
|
||||
});
|
||||
})
|
||||
|
||||
// Disable global shortcuts when not focused
|
||||
app.on('browser-window-blur', () => {
|
||||
globalShortcut.unregisterAll()
|
||||
})
|
||||
@@ -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()) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const fs = require('fs');
|
||||
const tls = require('tls');
|
||||
const fs = require('node:fs');
|
||||
const path = require('path');
|
||||
const { get } = require('lodash');
|
||||
const { getCACertificates } = require('@usebruno/requests');
|
||||
const { preferencesUtil } = require('../../store/preferences');
|
||||
const { getBrunoConfig } = require('../../store/bruno-config');
|
||||
const { interpolateString } = require('./interpolate-string');
|
||||
@@ -26,16 +26,18 @@ const getCertsAndProxyConfig = async ({
|
||||
httpsAgentRequestFields['rejectUnauthorized'] = false;
|
||||
}
|
||||
|
||||
if (preferencesUtil.shouldUseCustomCaCertificate()) {
|
||||
const caCertFilePath = preferencesUtil.getCustomCaCertificateFilePath();
|
||||
if (caCertFilePath) {
|
||||
let caCertBuffer = fs.readFileSync(caCertFilePath);
|
||||
if (preferencesUtil.shouldKeepDefaultCaCertificates()) {
|
||||
caCertBuffer += '\n' + tls.rootCertificates.join('\n'); // Augment default truststore with custom CA certificates
|
||||
}
|
||||
httpsAgentRequestFields['ca'] = caCertBuffer;
|
||||
}
|
||||
}
|
||||
let caCertFilePath = preferencesUtil.shouldUseCustomCaCertificate() && preferencesUtil.getCustomCaCertificateFilePath();
|
||||
let caCertificatesData = getCACertificates({
|
||||
caCertFilePath,
|
||||
shouldKeepDefaultCerts: preferencesUtil.shouldKeepDefaultCaCertificates()
|
||||
});
|
||||
|
||||
let caCertificates = caCertificatesData.caCertificates;
|
||||
let caCertificatesCount = caCertificatesData.caCertificatesCount;
|
||||
|
||||
// configure HTTPS agent with aggregated CA certificates
|
||||
httpsAgentRequestFields['caCertificatesCount'] = caCertificatesCount;
|
||||
httpsAgentRequestFields['ca'] = caCertificates || [];
|
||||
|
||||
const brunoConfig = getBrunoConfig(collectionUid);
|
||||
const interpolationOptions = {
|
||||
|
||||
@@ -11,118 +11,7 @@ const { getProcessEnvVars } = require('../../store/process-env');
|
||||
const { getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingAuthorizationCode } = require('../../utils/oauth2');
|
||||
const { interpolateString } = require('./interpolate-string');
|
||||
const path = require('node:path');
|
||||
|
||||
const setGrpcAuthHeaders = (grpcRequest, request, collectionRoot) => {
|
||||
const collectionAuth = get(collectionRoot, 'request.auth');
|
||||
if (collectionAuth && request.auth?.mode === 'inherit') {
|
||||
if (collectionAuth.mode === 'basic') {
|
||||
grpcRequest.basicAuth = {
|
||||
username: get(collectionAuth, 'basic.username'),
|
||||
password: get(collectionAuth, 'basic.password')
|
||||
};
|
||||
}
|
||||
|
||||
if (collectionAuth.mode === 'bearer') {
|
||||
grpcRequest.headers['Authorization'] = `Bearer ${get(collectionAuth, 'bearer.token')}`;
|
||||
}
|
||||
|
||||
if (collectionAuth.mode === 'apikey') {
|
||||
grpcRequest.headers[collectionAuth.apikey?.key] = collectionAuth.apikey?.value;
|
||||
|
||||
}
|
||||
|
||||
if (collectionAuth.mode === 'oauth2') {
|
||||
const grantType = get(collectionAuth, 'oauth2.grantType');
|
||||
|
||||
if (grantType === 'client_credentials') {
|
||||
grpcRequest.oauth2 = {
|
||||
grantType,
|
||||
accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
|
||||
clientId: get(collectionAuth, 'oauth2.clientId'),
|
||||
clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
|
||||
scope: get(collectionAuth, 'oauth2.scope'),
|
||||
credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
|
||||
tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
|
||||
tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
|
||||
tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey')
|
||||
};
|
||||
} else if (grantType === 'password') {
|
||||
grpcRequest.oauth2 = {
|
||||
grantType,
|
||||
accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
|
||||
username: get(collectionAuth, 'oauth2.username'),
|
||||
password: get(collectionAuth, 'oauth2.password'),
|
||||
clientId: get(collectionAuth, 'oauth2.clientId'),
|
||||
clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
|
||||
scope: get(collectionAuth, 'oauth2.scope'),
|
||||
credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
|
||||
tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
|
||||
tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
|
||||
tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (request.auth && request.auth.mode !== 'inherit') {
|
||||
if (request.auth.mode === 'basic') {
|
||||
grpcRequest.basicAuth = {
|
||||
username: get(request, 'auth.basic.username'),
|
||||
password: get(request, 'auth.basic.password')
|
||||
};
|
||||
}
|
||||
|
||||
if (request.auth.mode === 'bearer') {
|
||||
grpcRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`;
|
||||
}
|
||||
|
||||
if (request.auth.mode === 'oauth2') {
|
||||
const grantType = get(request, 'auth.oauth2.grantType');
|
||||
|
||||
|
||||
if (grantType === 'client_credentials') {
|
||||
grpcRequest.oauth2 = {
|
||||
grantType,
|
||||
clientId: get(request, 'auth.oauth2.clientId'),
|
||||
clientSecret: get(request, 'auth.oauth2.clientSecret'),
|
||||
scope: get(request, 'auth.oauth2.scope'),
|
||||
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
|
||||
tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
|
||||
credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'),
|
||||
tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
|
||||
tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey')
|
||||
};
|
||||
} else if (grantType === 'password') {
|
||||
grpcRequest.oauth2 = {
|
||||
grantType,
|
||||
username: get(request, 'auth.oauth2.username'),
|
||||
password: get(request, 'auth.oauth2.password'),
|
||||
clientId: get(request, 'auth.oauth2.clientId'),
|
||||
clientSecret: get(request, 'auth.oauth2.clientSecret'),
|
||||
scope: get(request, 'auth.oauth2.scope'),
|
||||
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
|
||||
tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
|
||||
credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'),
|
||||
tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
|
||||
tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey')
|
||||
};
|
||||
} else if (grantType === 'authorization_code') {
|
||||
grpcRequest.oauth2 = {
|
||||
grantType,
|
||||
...get(request, 'auth.oauth2')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (request.auth.mode === 'apikey') {
|
||||
grpcRequest.headers[request.auth.apikey?.key] = request.auth.apikey?.value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return grpcRequest;
|
||||
}
|
||||
const { setAuthHeaders } = require('./prepare-request');
|
||||
|
||||
const prepareRequest = async (item, collection, environment, runtimeVariables, certsAndProxyConfig = {}) => {
|
||||
const request = item.draft ? item.draft.request : item.request;
|
||||
@@ -182,7 +71,7 @@ const prepareRequest = async (item, collection, environment, runtimeVariables, c
|
||||
oauth2CredentialVariables: request.oauth2CredentialVariables,
|
||||
}
|
||||
|
||||
grpcRequest = setGrpcAuthHeaders(grpcRequest, request, collectionRoot);
|
||||
grpcRequest = setAuthHeaders(grpcRequest, request, collectionRoot);
|
||||
|
||||
if (grpcRequest.oauth2) {
|
||||
let requestCopy = cloneDeep(grpcRequest);
|
||||
|
||||
@@ -52,7 +52,16 @@ const saveCookies = (url, headers) => {
|
||||
|
||||
const getJsSandboxRuntime = (collection) => {
|
||||
const securityConfig = get(collection, 'securityConfig', {});
|
||||
return securityConfig.jsSandboxMode === 'safe' ? 'quickjs' : 'vm2';
|
||||
|
||||
if (securityConfig.jsSandboxMode === 'safe') {
|
||||
return 'quickjs';
|
||||
}
|
||||
|
||||
if (preferencesUtil.isBetaFeatureEnabled('nodevm')) {
|
||||
return 'nodevm';
|
||||
}
|
||||
|
||||
return 'vm2';
|
||||
};
|
||||
|
||||
const configureRequest = async (
|
||||
@@ -402,6 +411,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());
|
||||
@@ -1360,7 +1371,8 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
type: 'testrun-ended',
|
||||
collectionUid,
|
||||
folderUid,
|
||||
statusText: 'collection run was terminated!'
|
||||
statusText: 'collection run was terminated!',
|
||||
runCompletionTime: new Date().toISOString(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -1389,7 +1401,8 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
mainWindow.webContents.send('main:run-folder-event', {
|
||||
type: 'testrun-ended',
|
||||
collectionUid,
|
||||
folderUid
|
||||
folderUid,
|
||||
runCompletionTime: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("error", error);
|
||||
@@ -1398,6 +1411,7 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
type: 'testrun-ended',
|
||||
collectionUid,
|
||||
folderUid,
|
||||
runCompletionTime: new Date().toISOString(),
|
||||
error: error && !error.isCancel ? error : null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,8 +43,8 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
||||
};
|
||||
break;
|
||||
case 'wsse':
|
||||
const username = get(request, 'auth.wsse.username', '');
|
||||
const password = get(request, 'auth.wsse.password', '');
|
||||
const username = get(collectionAuth, 'wsse.username', '');
|
||||
const password = get(collectionAuth, 'wsse.password', '');
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
const nonce = crypto.randomBytes(16).toString('hex');
|
||||
@@ -193,7 +193,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
||||
axiosRequest.oauth2 = {
|
||||
grantType: grantType,
|
||||
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
|
||||
refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
|
||||
refreshTokenUrl: get(request, 'auth.oauth2.refreshTokenUrl'),
|
||||
username: get(request, 'auth.oauth2.username'),
|
||||
password: get(request, 'auth.oauth2.password'),
|
||||
clientId: get(request, 'auth.oauth2.clientId'),
|
||||
@@ -215,7 +215,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
||||
callbackUrl: get(request, 'auth.oauth2.callbackUrl'),
|
||||
authorizationUrl: get(request, 'auth.oauth2.authorizationUrl'),
|
||||
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
|
||||
refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
|
||||
refreshTokenUrl: get(request, 'auth.oauth2.refreshTokenUrl'),
|
||||
clientId: get(request, 'auth.oauth2.clientId'),
|
||||
clientSecret: get(request, 'auth.oauth2.clientSecret'),
|
||||
scope: get(request, 'auth.oauth2.scope'),
|
||||
@@ -251,7 +251,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
|
||||
axiosRequest.oauth2 = {
|
||||
grantType: grantType,
|
||||
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
|
||||
refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
|
||||
refreshTokenUrl: get(request, 'auth.oauth2.refreshTokenUrl'),
|
||||
clientId: get(request, 'auth.oauth2.clientId'),
|
||||
clientSecret: get(request, 'auth.oauth2.clientSecret'),
|
||||
scope: get(request, 'auth.oauth2.scope'),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const path = require('node:path');
|
||||
const _ = require('lodash');
|
||||
const Store = require('electron-store');
|
||||
const { isDirectory } = require('../utils/filesystem');
|
||||
@@ -12,7 +13,9 @@ class LastOpenedCollections {
|
||||
}
|
||||
|
||||
getAll() {
|
||||
return this.store.get('lastOpenedCollections') || [];
|
||||
let collections = this.store.get('lastOpenedCollections') || [];
|
||||
collections = collections.map(collection => path.resolve(collection));
|
||||
return collections;
|
||||
}
|
||||
|
||||
add(collectionPath) {
|
||||
|
||||
@@ -42,7 +42,8 @@ const defaultPreferences = {
|
||||
responsePaneOrientation: 'horizontal'
|
||||
},
|
||||
beta: {
|
||||
grpc: false
|
||||
grpc: false,
|
||||
nodevm: false
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,7 +81,8 @@ const preferencesSchema = Yup.object().shape({
|
||||
responsePaneOrientation: Yup.string().oneOf(['horizontal', 'vertical'])
|
||||
}),
|
||||
beta: Yup.object({
|
||||
grpc: Yup.boolean()
|
||||
grpc: Yup.boolean(),
|
||||
nodevm: Yup.boolean()
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const parseUrl = require('url').parse;
|
||||
const https = require('https');
|
||||
const https = require('node:https');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { interpolateString } = require('../ipc/network/interpolate-string');
|
||||
const { SocksProxyAgent } = require('socks-proxy-agent');
|
||||
@@ -87,6 +87,10 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent {
|
||||
function createTimelineAgentClass(BaseAgentClass) {
|
||||
return class extends BaseAgentClass {
|
||||
constructor(options, timeline) {
|
||||
|
||||
let caCertificatesCount = options.caCertificatesCount || {};
|
||||
delete options.caCertificatesCount;
|
||||
|
||||
// For proxy agents, the first argument is the proxy URI and the second is options
|
||||
if (options?.proxy) {
|
||||
const { proxy: proxyUri, ...agentOptions } = options;
|
||||
@@ -118,7 +122,7 @@ function createTimelineAgentClass(BaseAgentClass) {
|
||||
const tlsOptions = {
|
||||
...options,
|
||||
rejectUnauthorized: options.rejectUnauthorized ?? true,
|
||||
};
|
||||
};
|
||||
super(tlsOptions);
|
||||
this.timeline = Array.isArray(timeline) ? timeline : [];
|
||||
this.alpnProtocols = options.ALPNProtocols || ['h2', 'http/1.1'];
|
||||
@@ -131,6 +135,8 @@ function createTimelineAgentClass(BaseAgentClass) {
|
||||
message: `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`,
|
||||
});
|
||||
}
|
||||
|
||||
this.caCertificatesCount = caCertificatesCount;
|
||||
}
|
||||
|
||||
|
||||
@@ -146,20 +152,16 @@ function createTimelineAgentClass(BaseAgentClass) {
|
||||
});
|
||||
}
|
||||
|
||||
// Log CAfile and CApath (if possible)
|
||||
if (this.caProvided) {
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'tls',
|
||||
message: `CA certificates provided`,
|
||||
});
|
||||
} else {
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'tls',
|
||||
message: `Using system default CA certificates`,
|
||||
});
|
||||
}
|
||||
const rootCerts = this.caCertificatesCount.root || 0;
|
||||
const systemCerts = this.caCertificatesCount.system || 0;
|
||||
const extraCerts = this.caCertificatesCount.extra || 0;
|
||||
const customCerts = this.caCertificatesCount.custom || 0;
|
||||
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'tls',
|
||||
message: `CA Certificates: ${rootCerts} root, ${systemCerts} system, ${extraCerts} extra, ${customCerts} custom`,
|
||||
});
|
||||
|
||||
// Log "Trying host:port..."
|
||||
this.timeline.push({
|
||||
|
||||
959
packages/bruno-electron/tests/prepare-request.test.js
Normal file
959
packages/bruno-electron/tests/prepare-request.test.js
Normal file
@@ -0,0 +1,959 @@
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
// Mock crypto.randomBytes to return predictable values for testing
|
||||
jest.mock('node:crypto', () => ({
|
||||
...jest.requireActual('node:crypto'),
|
||||
randomBytes: jest.fn(() => Buffer.from('1234567890abcdef', 'hex'))
|
||||
}));
|
||||
|
||||
// Mock the lodash get function with a more sophisticated mock
|
||||
const mockGet = jest.fn();
|
||||
jest.mock('lodash', () => ({
|
||||
get: mockGet,
|
||||
each: jest.fn(),
|
||||
filter: jest.fn(),
|
||||
find: jest.fn()
|
||||
}));
|
||||
|
||||
// Import the function to test
|
||||
const { setAuthHeaders } = require('../src/ipc/network/prepare-request');
|
||||
|
||||
describe('setAuthHeaders', () => {
|
||||
let mockAxiosRequest;
|
||||
let mockRequest;
|
||||
let mockCollectionRoot;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset crypto mock to return predictable values
|
||||
crypto.randomBytes.mockReturnValue(Buffer.from('1234567890abcdef', 'hex'));
|
||||
|
||||
// Setup default mock objects
|
||||
mockAxiosRequest = {
|
||||
headers: {}
|
||||
};
|
||||
|
||||
mockRequest = {
|
||||
auth: {
|
||||
mode: 'none'
|
||||
}
|
||||
};
|
||||
|
||||
mockCollectionRoot = {
|
||||
request: {
|
||||
auth: null
|
||||
}
|
||||
};
|
||||
|
||||
// Setup a more sophisticated mock for lodash get function
|
||||
mockGet.mockImplementation((obj, path, defaultValue) => {
|
||||
if (!obj) return defaultValue;
|
||||
|
||||
const keys = path.split('.');
|
||||
let current = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (current && typeof current === 'object' && key in current) {
|
||||
current = current[key];
|
||||
} else {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Collection-level authentication inheritance', () => {
|
||||
test('should inherit AWS v4 authentication from collection', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'awsv4',
|
||||
awsv4: {
|
||||
accessKeyId: 'test-access-key',
|
||||
secretAccessKey: 'test-secret-key',
|
||||
sessionToken: 'test-session-token',
|
||||
service: 's3',
|
||||
region: 'us-east-1',
|
||||
profileName: 'default'
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.awsv4config).toEqual({
|
||||
accessKeyId: 'test-access-key',
|
||||
secretAccessKey: 'test-secret-key',
|
||||
sessionToken: 'test-session-token',
|
||||
service: 's3',
|
||||
region: 'us-east-1',
|
||||
profileName: 'default'
|
||||
});
|
||||
});
|
||||
|
||||
test('should inherit basic authentication from collection', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'basic',
|
||||
basic: {
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.basicAuth).toEqual({
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
});
|
||||
});
|
||||
|
||||
test('should inherit bearer authentication from collection', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'bearer',
|
||||
bearer: {
|
||||
token: 'test-token'
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.headers['Authorization']).toBe('Bearer test-token');
|
||||
});
|
||||
|
||||
test('should inherit digest authentication from collection', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'digest',
|
||||
digest: {
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.digestConfig).toEqual({
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
});
|
||||
});
|
||||
|
||||
test('should inherit NTLM authentication from collection', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'ntlm',
|
||||
ntlm: {
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
domain: 'testdomain'
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.ntlmConfig).toEqual({
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
domain: 'testdomain'
|
||||
});
|
||||
});
|
||||
|
||||
test('should inherit WSSE authentication from collection', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'wsse',
|
||||
wsse: {
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.headers['X-WSSE']).toMatch(/UsernameToken Username="testuser", PasswordDigest="[^"]+", Nonce="1234567890abcdef", Created="[^"]+"/);
|
||||
});
|
||||
|
||||
test('should inherit API key authentication from collection (header placement)', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'apikey',
|
||||
apikey: {
|
||||
key: 'X-API-Key',
|
||||
value: 'test-api-key',
|
||||
placement: 'header'
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.headers['X-API-Key']).toBe('test-api-key');
|
||||
});
|
||||
|
||||
test('should inherit API key authentication from collection (query params placement)', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'apikey',
|
||||
apikey: {
|
||||
key: 'api_key',
|
||||
value: 'test-api-key',
|
||||
placement: 'queryparams'
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.apiKeyAuthValueForQueryParams).toEqual({
|
||||
key: 'api_key',
|
||||
value: 'test-api-key',
|
||||
placement: 'queryparams'
|
||||
});
|
||||
});
|
||||
|
||||
test('should skip API key authentication when key is empty', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'apikey',
|
||||
apikey: {
|
||||
key: '',
|
||||
value: 'test-api-key',
|
||||
placement: 'header'
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.headers['']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth2 authentication inheritance', () => {
|
||||
test('should inherit OAuth2 password grant from collection', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'password',
|
||||
accessTokenUrl: 'https://example.com/token',
|
||||
refreshTokenUrl: 'https://example.com/refresh',
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
clientId: 'test-client',
|
||||
clientSecret: 'test-secret',
|
||||
scope: 'read write',
|
||||
credentialsPlacement: 'body',
|
||||
credentialsId: 'test-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.oauth2).toEqual({
|
||||
grantType: 'password',
|
||||
accessTokenUrl: 'https://example.com/token',
|
||||
refreshTokenUrl: 'https://example.com/refresh',
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
clientId: 'test-client',
|
||||
clientSecret: 'test-secret',
|
||||
scope: 'read write',
|
||||
credentialsPlacement: 'body',
|
||||
credentialsId: 'test-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
});
|
||||
});
|
||||
|
||||
test('should inherit OAuth2 authorization_code grant from collection', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'authorization_code',
|
||||
callbackUrl: 'https://example.com/callback',
|
||||
authorizationUrl: 'https://example.com/auth',
|
||||
accessTokenUrl: 'https://example.com/token',
|
||||
refreshTokenUrl: 'https://example.com/refresh',
|
||||
clientId: 'test-client',
|
||||
clientSecret: 'test-secret',
|
||||
scope: 'read write',
|
||||
state: 'random-state',
|
||||
pkce: true,
|
||||
credentialsPlacement: 'body',
|
||||
credentialsId: 'test-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.oauth2).toEqual({
|
||||
grantType: 'authorization_code',
|
||||
callbackUrl: 'https://example.com/callback',
|
||||
authorizationUrl: 'https://example.com/auth',
|
||||
accessTokenUrl: 'https://example.com/token',
|
||||
refreshTokenUrl: 'https://example.com/refresh',
|
||||
clientId: 'test-client',
|
||||
scope: 'read write',
|
||||
state: 'random-state',
|
||||
pkce: true,
|
||||
credentialsPlacement: 'body',
|
||||
clientSecret: 'test-secret',
|
||||
credentialsId: 'test-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
});
|
||||
});
|
||||
|
||||
test('should inherit OAuth2 implicit grant from collection', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'implicit',
|
||||
callbackUrl: 'https://example.com/callback',
|
||||
authorizationUrl: 'https://example.com/auth',
|
||||
clientId: 'test-client',
|
||||
scope: 'read write',
|
||||
state: 'random-state',
|
||||
credentialsId: 'test-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.oauth2).toEqual({
|
||||
grantType: 'implicit',
|
||||
callbackUrl: 'https://example.com/callback',
|
||||
authorizationUrl: 'https://example.com/auth',
|
||||
clientId: 'test-client',
|
||||
scope: 'read write',
|
||||
state: 'random-state',
|
||||
credentialsId: 'test-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
});
|
||||
});
|
||||
|
||||
test('should inherit OAuth2 client_credentials grant from collection', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'client_credentials',
|
||||
accessTokenUrl: 'https://example.com/token',
|
||||
refreshTokenUrl: 'https://example.com/refresh',
|
||||
clientId: 'test-client',
|
||||
clientSecret: 'test-secret',
|
||||
scope: 'read write',
|
||||
credentialsPlacement: 'body',
|
||||
credentialsId: 'test-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.auth.mode = 'inherit';
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.oauth2).toEqual({
|
||||
grantType: 'client_credentials',
|
||||
accessTokenUrl: 'https://example.com/token',
|
||||
refreshTokenUrl: 'https://example.com/refresh',
|
||||
clientId: 'test-client',
|
||||
clientSecret: 'test-secret',
|
||||
scope: 'read write',
|
||||
credentialsPlacement: 'body',
|
||||
credentialsId: 'test-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request-level authentication (overrides collection)', () => {
|
||||
test('should set AWS v4 authentication at request level', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'awsv4',
|
||||
awsv4: {
|
||||
accessKeyId: 'test-access-key',
|
||||
secretAccessKey: 'test-secret-key',
|
||||
sessionToken: 'test-session-token',
|
||||
service: 's3',
|
||||
region: 'us-east-1',
|
||||
profileName: 'default'
|
||||
}
|
||||
}
|
||||
mockRequest.auth = {
|
||||
mode: 'awsv4',
|
||||
awsv4: {
|
||||
accessKeyId: 'request-access-key',
|
||||
secretAccessKey: 'request-secret-key',
|
||||
sessionToken: 'request-session-token',
|
||||
service: 's3',
|
||||
region: 'us-west-2',
|
||||
profileName: 'production'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.awsv4config).toEqual({
|
||||
accessKeyId: 'request-access-key',
|
||||
secretAccessKey: 'request-secret-key',
|
||||
sessionToken: 'request-session-token',
|
||||
service: 's3',
|
||||
region: 'us-west-2',
|
||||
profileName: 'production'
|
||||
});
|
||||
});
|
||||
|
||||
test('should set basic authentication at request level', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'basic',
|
||||
basic: {
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'basic',
|
||||
basic: {
|
||||
username: 'requestuser',
|
||||
password: 'requestpass'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.basicAuth).toEqual({
|
||||
username: 'requestuser',
|
||||
password: 'requestpass'
|
||||
});
|
||||
});
|
||||
|
||||
test('should set bearer authentication at request level', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'bearer',
|
||||
bearer: {
|
||||
token: 'test-token'
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'bearer',
|
||||
bearer: {
|
||||
token: 'request-token'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.headers['Authorization']).toBe('Bearer request-token');
|
||||
});
|
||||
|
||||
test('should set digest authentication at request level', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'digest',
|
||||
digest: {
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'digest',
|
||||
digest: {
|
||||
username: 'requestuser',
|
||||
password: 'requestpass'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.digestConfig).toEqual({
|
||||
username: 'requestuser',
|
||||
password: 'requestpass'
|
||||
});
|
||||
});
|
||||
|
||||
test('should set NTLM authentication at request level', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'ntlm',
|
||||
ntlm: {
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
domain: 'testdomain'
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'ntlm',
|
||||
ntlm: {
|
||||
username: 'requestuser',
|
||||
password: 'requestpass',
|
||||
domain: 'requestdomain'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.ntlmConfig).toEqual({
|
||||
username: 'requestuser',
|
||||
password: 'requestpass',
|
||||
domain: 'requestdomain'
|
||||
});
|
||||
});
|
||||
|
||||
test('should set WSSE authentication at request level', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'wsse',
|
||||
wsse: {
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'wsse',
|
||||
wsse: {
|
||||
username: 'requestuser',
|
||||
password: 'requestpass'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.headers['X-WSSE']).toMatch(/UsernameToken Username="requestuser", PasswordDigest="[^"]+", Nonce="1234567890abcdef", Created="[^"]+"/);
|
||||
});
|
||||
|
||||
test('should set API key authentication at request level (header placement)', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'apikey',
|
||||
apikey: {
|
||||
key: 'X-Request-API-Key',
|
||||
value: 'test-api-key',
|
||||
placement: 'header'
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'apikey',
|
||||
apikey: {
|
||||
key: 'X-Request-API-Key',
|
||||
value: 'request-api-key',
|
||||
placement: 'header'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.headers['X-Request-API-Key']).toBe('request-api-key');
|
||||
});
|
||||
|
||||
test('should set API key authentication at request level (query params placement)', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'apikey',
|
||||
apikey: {
|
||||
key: 'X-Request-API-Key',
|
||||
value: 'test-api-key',
|
||||
placement: 'header'
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'apikey',
|
||||
apikey: {
|
||||
key: 'request_api_key',
|
||||
value: 'request-api-key',
|
||||
placement: 'queryparams'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.apiKeyAuthValueForQueryParams).toEqual({
|
||||
key: 'request_api_key',
|
||||
value: 'request-api-key',
|
||||
placement: 'queryparams'
|
||||
});
|
||||
});
|
||||
|
||||
test('should set OAuth2 password grant at request level', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'password',
|
||||
accessTokenUrl: 'https://collection.com/token',
|
||||
refreshTokenUrl: 'https://collection.com/refresh',
|
||||
username: 'collectionuser',
|
||||
password: 'collectionpass',
|
||||
clientId: 'collection-client',
|
||||
clientSecret: 'collection-secret',
|
||||
scope: 'read',
|
||||
credentialsPlacement: 'header',
|
||||
credentialsId: 'collection-credentials',
|
||||
tokenPlacement: 'query',
|
||||
tokenHeaderPrefix: 'Token',
|
||||
tokenQueryKey: 'token',
|
||||
autoFetchToken: false,
|
||||
autoRefreshToken: false,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'password',
|
||||
accessTokenUrl: 'https://request.com/token',
|
||||
refreshTokenUrl: 'https://request.com/refresh',
|
||||
username: 'requestuser',
|
||||
password: 'requestpass',
|
||||
clientId: 'request-client',
|
||||
clientSecret: 'request-secret',
|
||||
scope: 'read',
|
||||
credentialsPlacement: 'header',
|
||||
credentialsId: 'request-credentials',
|
||||
tokenPlacement: 'query',
|
||||
tokenHeaderPrefix: 'Token',
|
||||
tokenQueryKey: 'token',
|
||||
autoFetchToken: false,
|
||||
autoRefreshToken: false,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.oauth2).toEqual({
|
||||
grantType: 'password',
|
||||
accessTokenUrl: 'https://request.com/token',
|
||||
refreshTokenUrl: 'https://request.com/refresh',
|
||||
username: 'requestuser',
|
||||
password: 'requestpass',
|
||||
clientId: 'request-client',
|
||||
clientSecret: 'request-secret',
|
||||
scope: 'read',
|
||||
credentialsPlacement: 'header',
|
||||
credentialsId: 'request-credentials',
|
||||
tokenPlacement: 'query',
|
||||
tokenHeaderPrefix: 'Token',
|
||||
tokenQueryKey: 'token',
|
||||
autoFetchToken: false,
|
||||
autoRefreshToken: false,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
});
|
||||
});
|
||||
|
||||
test('should set OAuth2 authorization_code grant at request level', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'password',
|
||||
callbackUrl: 'https://collection.com/callback',
|
||||
authorizationUrl: 'https://collection.com/auth',
|
||||
accessTokenUrl: 'https://collection.com/token',
|
||||
refreshTokenUrl: 'https://collection.com/refresh',
|
||||
username: 'collectionuser',
|
||||
password: 'collectionpass',
|
||||
clientId: 'collection-client',
|
||||
clientSecret: 'collection-secret',
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'authorization_code',
|
||||
callbackUrl: 'https://request.com/callback',
|
||||
authorizationUrl: 'https://request.com/auth',
|
||||
accessTokenUrl: 'https://request.com/token',
|
||||
refreshTokenUrl: 'https://request.com/refresh',
|
||||
clientId: 'request-client',
|
||||
clientSecret: 'request-secret',
|
||||
scope: 'read',
|
||||
state: 'request-state',
|
||||
pkce: false,
|
||||
credentialsPlacement: 'body',
|
||||
credentialsId: 'request-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.oauth2).toEqual({
|
||||
grantType: 'authorization_code',
|
||||
callbackUrl: 'https://request.com/callback',
|
||||
authorizationUrl: 'https://request.com/auth',
|
||||
accessTokenUrl: 'https://request.com/token',
|
||||
refreshTokenUrl: 'https://request.com/refresh',
|
||||
clientId: 'request-client',
|
||||
clientSecret: 'request-secret',
|
||||
scope: 'read',
|
||||
state: 'request-state',
|
||||
pkce: false,
|
||||
credentialsPlacement: 'body',
|
||||
credentialsId: 'request-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
});
|
||||
});
|
||||
|
||||
test('should set OAuth2 implicit grant at request level', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'implicit',
|
||||
callbackUrl: 'https://collection.com/callback',
|
||||
authorizationUrl: 'https://collection.com/auth',
|
||||
clientId: 'collection-client',
|
||||
scope: 'read',
|
||||
state: 'collection-state',
|
||||
credentialsId: 'collection-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'implicit',
|
||||
callbackUrl: 'https://request.com/callback',
|
||||
authorizationUrl: 'https://request.com/auth',
|
||||
clientId: 'request-client',
|
||||
scope: 'read',
|
||||
state: 'request-state',
|
||||
credentialsId: 'request-credentials',
|
||||
tokenPlacement: 'query',
|
||||
tokenHeaderPrefix: 'Token',
|
||||
tokenQueryKey: 'token',
|
||||
autoFetchToken: false,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.oauth2).toEqual({
|
||||
grantType: 'implicit',
|
||||
callbackUrl: 'https://request.com/callback',
|
||||
authorizationUrl: 'https://request.com/auth',
|
||||
clientId: 'request-client',
|
||||
credentialsId: 'request-credentials',
|
||||
scope: 'read',
|
||||
state: 'request-state',
|
||||
tokenPlacement: 'query',
|
||||
tokenHeaderPrefix: 'Token',
|
||||
tokenQueryKey: 'token',
|
||||
autoFetchToken: false,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
});
|
||||
});
|
||||
|
||||
test('should set OAuth2 client_credentials grant at request level', () => {
|
||||
mockCollectionRoot.request.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'client_credentials',
|
||||
accessTokenUrl: 'https://collection.com/token',
|
||||
refreshTokenUrl: 'https://collection.com/refresh',
|
||||
clientId: 'collection-client',
|
||||
clientSecret: 'collection-secret',
|
||||
scope: 'read',
|
||||
credentialsPlacement: 'body',
|
||||
credentialsId: 'collection-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
mockRequest.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'client_credentials',
|
||||
accessTokenUrl: 'https://request.com/token',
|
||||
refreshTokenUrl: 'https://request.com/refresh',
|
||||
clientId: 'request-client',
|
||||
clientSecret: 'request-secret',
|
||||
scope: 'read',
|
||||
credentialsPlacement: 'body',
|
||||
credentialsId: 'request-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.oauth2).toEqual({
|
||||
grantType: 'client_credentials',
|
||||
accessTokenUrl: 'https://request.com/token',
|
||||
refreshTokenUrl: 'https://request.com/refresh',
|
||||
clientId: 'request-client',
|
||||
clientSecret: 'request-secret',
|
||||
scope: 'read',
|
||||
credentialsPlacement: 'body',
|
||||
credentialsId: 'request-credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token',
|
||||
autoFetchToken: true,
|
||||
autoRefreshToken: true,
|
||||
additionalParameters: { authorization: [], token: [], refresh: [] }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases and error handling', () => {
|
||||
test('should handle missing collection auth gracefully', () => {
|
||||
mockCollectionRoot.request.auth = null;
|
||||
mockRequest.auth = {
|
||||
mode: 'basic',
|
||||
basic: {
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result.basicAuth).toEqual({
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle missing request auth gracefully', () => {
|
||||
mockRequest.auth = null;
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result).toBe(mockAxiosRequest);
|
||||
expect(result.headers).toEqual({});
|
||||
});
|
||||
|
||||
test('should handle missing auth mode gracefully', () => {
|
||||
mockRequest.auth = {};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result).toBe(mockAxiosRequest);
|
||||
expect(result.headers).toEqual({});
|
||||
});
|
||||
|
||||
test('should handle unknown auth mode gracefully', () => {
|
||||
mockRequest.auth = {
|
||||
mode: 'unknown'
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result).toBe(mockAxiosRequest);
|
||||
expect(result.headers).toEqual({});
|
||||
});
|
||||
|
||||
test('should handle missing OAuth2 grant type gracefully', () => {
|
||||
mockRequest.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result).toBe(mockAxiosRequest);
|
||||
expect(result.oauth2).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should handle unknown OAuth2 grant type gracefully', () => {
|
||||
mockRequest.auth = {
|
||||
mode: 'oauth2',
|
||||
oauth2: {
|
||||
grantType: 'unknown_grant'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result).toBe(mockAxiosRequest);
|
||||
expect(result.oauth2).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should return the modified axiosRequest object', () => {
|
||||
mockRequest.auth = {
|
||||
mode: 'bearer',
|
||||
bearer: {
|
||||
token: 'test-token'
|
||||
}
|
||||
};
|
||||
|
||||
const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
|
||||
|
||||
expect(result).toBe(mockAxiosRequest);
|
||||
expect(result.headers['Authorization']).toBe('Bearer test-token');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,8 +36,9 @@ export const bruRequestToJson = (data: string | any, parsed: boolean = false): a
|
||||
settings: _.get(json, 'settings', {}),
|
||||
tags: _.get(json, 'meta.tags', []),
|
||||
request: {
|
||||
// Preserving special characters in custom methods. Using _.upperCase strips special characters.
|
||||
method:
|
||||
requestType === 'grpc-request' ? _.get(json, 'grpc.method', '') : _.upperCase(_.get(json, 'http.method')),
|
||||
requestType === 'grpc-request' ? _.get(json, 'grpc.method', '') : String(_.get(json, 'http.method') ?? '').toUpperCase(),
|
||||
url: _.get(json, requestType === 'grpc-request' ? 'grpc.url' : 'http.url'),
|
||||
headers: requestType === 'grpc-request' ? _.get(json, 'metadata', []) : _.get(json, 'headers', []),
|
||||
auth: _.get(json, 'auth', {}),
|
||||
@@ -121,7 +122,8 @@ export const jsonRequestToBru = (json: any): string => {
|
||||
// For HTTP and GraphQL requests, maintain the current structure
|
||||
if (type === 'http' || type === 'graphql') {
|
||||
bruJson.http = {
|
||||
method: _.lowerCase(_.get(json, 'request.method')),
|
||||
// Preserve special characters in custom request methods. Avoid _.lowerCase which strips symbols.
|
||||
method: String(_.get(json, 'request.method') ?? '').toLowerCase(),
|
||||
url: _.get(json, 'request.url'),
|
||||
auth: _.get(json, 'request.auth.mode', 'none'),
|
||||
body: _.get(json, 'request.body.mode', 'none')
|
||||
|
||||
@@ -2,10 +2,12 @@ const ScriptRuntime = require('./runtime/script-runtime');
|
||||
const TestRuntime = require('./runtime/test-runtime');
|
||||
const VarsRuntime = require('./runtime/vars-runtime');
|
||||
const AssertRuntime = require('./runtime/assert-runtime');
|
||||
const { runScriptInNodeVm } = require('./sandbox/node-vm');
|
||||
|
||||
module.exports = {
|
||||
ScriptRuntime,
|
||||
TestRuntime,
|
||||
VarsRuntime,
|
||||
AssertRuntime
|
||||
AssertRuntime,
|
||||
runScriptInNodeVm
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ const BrunoRequest = require('../bruno-request');
|
||||
const BrunoResponse = require('../bruno-response');
|
||||
const { cleanJson } = require('../utils');
|
||||
const { createBruTestResultMethods } = require('../utils/results');
|
||||
const { runScriptInNodeVm } = require('../sandbox/node-vm');
|
||||
|
||||
// Inbuilt Library Support
|
||||
const ajv = require('ajv');
|
||||
@@ -111,6 +112,27 @@ class ScriptRuntime {
|
||||
context.bru.runRequest = runRequestByItemPathname;
|
||||
}
|
||||
|
||||
if (this.runtime === 'nodevm') {
|
||||
await runScriptInNodeVm({
|
||||
script,
|
||||
context,
|
||||
collectionPath,
|
||||
scriptingConfig
|
||||
});
|
||||
|
||||
return {
|
||||
request,
|
||||
envVariables: cleanJson(envVariables),
|
||||
runtimeVariables: cleanJson(runtimeVariables),
|
||||
persistentEnvVariables: bru.persistentEnvVariables,
|
||||
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
|
||||
results: cleanJson(__brunoTestResults.getResults()),
|
||||
nextRequestName: bru.nextRequest,
|
||||
skipRequest: bru.skipRequest,
|
||||
stopExecution: bru.stopExecution
|
||||
};
|
||||
}
|
||||
|
||||
if (this.runtime === 'quickjs') {
|
||||
await executeQuickJsVmAsync({
|
||||
script: script,
|
||||
@@ -260,6 +282,27 @@ class ScriptRuntime {
|
||||
context.bru.runRequest = runRequestByItemPathname;
|
||||
}
|
||||
|
||||
if (this.runtime === 'nodevm') {
|
||||
await runScriptInNodeVm({
|
||||
script,
|
||||
context,
|
||||
collectionPath,
|
||||
scriptingConfig
|
||||
});
|
||||
|
||||
return {
|
||||
response,
|
||||
envVariables: cleanJson(envVariables),
|
||||
persistentEnvVariables: cleanJson(bru.persistentEnvVariables),
|
||||
runtimeVariables: cleanJson(runtimeVariables),
|
||||
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
|
||||
results: cleanJson(__brunoTestResults.getResults()),
|
||||
nextRequestName: bru.nextRequest,
|
||||
skipRequest: bru.skipRequest,
|
||||
stopExecution: bru.stopExecution
|
||||
};
|
||||
}
|
||||
|
||||
if (this.runtime === 'quickjs') {
|
||||
await executeQuickJsVmAsync({
|
||||
script: script,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user