Compare commits

..

71 Commits

Author SHA1 Message Date
ramki-bruno
a006fe8230 Move Playwright tests to Tests Workflow itself
Currently the test-results and annotations form jobs are getting added
to random Workflow and there is no fix for it right now
Ref: https://github.com/EnricoMi/publish-unit-test-result-action/issues/12
Also Playwright tests have the same triggers as Tests, so no need to
keep it separate.
2025-05-30 13:57:44 +05:30
ramki-bruno
577d54b432 Added Playwright test for bruno-testbench, few sanity tests and improvements
- Trace will capture snapshots now
- Added ability to add init Electron user-data, preferences and other
  app settings.
- Improved test Fixtures
  - Use tempdir for Electron user-data
  - Ability to reuse app instance for a given init user-data by placing
    them in a folder(`pageWithUserData` Fixture)
  - Ability to create tests with fresh user-data(`newPage` Fixture)
- Improved logging
- Improved the env vars to customize the Electron user-data-path
2025-05-30 13:57:44 +05:30
maintainer-bruno
afaebf6b3d Merge pull request #4796 from lohxt1/collection_auth_default_values_issue
fix: collection auth default value access fix and validations
2025-05-29 18:28:57 +05:30
lohit
6e89001825 fix: collection auth default value access fix and validations 2025-05-29 17:45:42 +05:30
lohit
e7dd78ea53 Merge pull request #4775 from lohxt1/axios_instance_redirect_error_fix
fix: return the actual axios error in bruno-cli' axios-instance for url-redirect related errors
2025-05-27 20:27:48 +05:30
lohit
9ad0f2d169 revert custom error messages 2025-05-27 19:40:51 +05:30
lohit
bf19645282 revert test update 2025-05-27 19:40:22 +05:30
lohit
bb01199877 updates 2025-05-27 19:17:05 +05:30
lohit
5627c5624f updates 2025-05-27 19:16:29 +05:30
lohit
8e23a7054f Merge pull request #4770 from lohxt1/error_requests_cli_tests_issue
fix: consider request execution errors as `CLI Tests` workflow failure
2025-05-27 18:49:02 +05:30
lohit
d820069371 return the actual axios error with the custom error message in bruno-cli axios-instance 2025-05-27 18:41:47 +05:30
lohit
2de9b87c6f consider errored request as a collection run fail 2025-05-27 15:30:54 +05:30
lohit
178773d63a Merge pull request #4173 from Pragadesh-45/feat/custom-installation-path
Feat/ Custom installation path for GUI installer on Windows
2025-05-27 11:49:29 +05:30
lohit
7994946c85 Merge pull request #4764 from lohxt1/shortcut_key_new_request_issue
fix: new request shortcut key
2025-05-27 11:49:15 +05:30
lohit
b020255269 Merge pull request #4662 from sanjaikumar-bruno/fix/cli-not-following-redirects
feat: enhance axios instance with redirect handling and cookie management in CLI
2025-05-27 11:48:43 +05:30
lohit
73b0f0919d Merge pull request #4679 from anusree-bruno/bugfix/timestamp-current-time
fix: ensure timestamp and isoTimestamp return current time instead of random values
2025-05-26 22:29:53 +05:30
Pragadesh-45
8975b9eef6 fix: update Windows build configuration for icon and publisher name 2025-05-26 21:06:01 +05:45
lohit
865e813b42 revert test bru file 2025-05-26 20:45:33 +05:30
lohit
51f36b1903 Merge pull request #4038 from Chriss4123/feature/localhost-secure-context
feat: Add RFC 6761–compliant localhost loopback checks so `secure` cookies work on localhost (fixes: #1676)
2025-05-26 17:16:33 +05:30
Clay Powers
6b122d7262 Switch GraphQL variables code editor to json linting (#4756) 2025-05-26 16:55:11 +05:30
lohit
a8e5ce9c13 fix: new request shortcut key 2025-05-26 14:58:25 +05:30
anusree-bruno
8ac916b0ff removed unwanted tests 2025-05-26 14:49:21 +05:30
anusree-bruno
8d860a051c replace real time with mocked time in faker tests 2025-05-26 14:43:23 +05:30
Anoop M D
4ac2c4ac34 Merge pull request #4706 from ved-bruno/e2e_support
Playwright: Support Element Verification
2025-05-23 21:11:27 +05:30
maintainer-bruno
7c27193983 chore: add CODEOWNERS file for repository maintenance 2025-05-23 16:57:48 +05:30
ramki-bruno
2c3d2ff6a7 Make Secure-local-cookies work in CLI as well 2025-05-23 13:49:56 +05:30
Chriss4123
a4fff01647 Support Secure cookies for localhost and loopback addresses 2025-05-23 12:35:04 +05:30
sanjai0py
2cd985faf7 Remove test file for redirects with cookies 2025-05-23 08:58:28 +05:30
sanish chirayath
9a35302d4b Feature: implemented bru.interpolate (#4122)
* feat: enhance variable highlighting in CodeMirror and update interpolation method

* feat: add interpolate function to bru shim and corresponding tests

- Implemented the `interpolate` function in the bru shim to handle variable interpolation.
- Added a new test case for the `interpolate` function to verify its functionality with mock variables.

* feat: enhance interpolate function to support object interpolation

* feat: add translation support for pm.variables.replaceIn to bru.interpolate

* revert: eslint config changes

* revert: eslint config changes

* fix: update method call to use correct interpolation function in Bru class

* refactor: added jsdoc to codemirror highlighting code

* fix: higlighting for multiline editor
2025-05-22 15:37:15 +05:30
Pooja
553f7675f2 fix: request timer reset while switching tabs (#4165)
* fix" request timer reset while switching tabs

* fix

* rm: extraReducers

* improve: logic

* fix: pass startTime as prop

* fix

* fix: directly use collection in setRequestStartTime

* rm: reseting start time null
2025-05-22 15:36:26 +05:30
sanjai0py
b299879b82 Refactor saveCookies function to remove disableCookies parameter and streamline cookie handling in response interceptors 2025-05-21 17:00:22 +05:30
Pooja
3696562414 fix: circular recursion for openapi import (#4729) 2025-05-21 15:10:35 +05:30
devendra-bruno
e02c6c274b Fix/svg render respone panel (#4655)
* Refactor getContentType in utils

* Add testcases for getContentType

* Refactor getContent

* Refactor getContent

* Refactor getContent

* Added testcase of case insensitivity

* Added content-type case in sensitive

* Refactor testcase spec

* Added testcases for empty content-type
2025-05-21 13:45:06 +05:30
sanjai0py
ab0a4b8140 Add disableCookies option to axios instance and saveCookies function 2025-05-19 15:08:12 +05:30
sanjai0py
1b268ae9db Merge branch 'main' into fix/cli-not-following-redirects 2025-05-19 14:36:54 +05:30
ved-bruno
8debb9fd11 made suggested changes for support element verification 2025-05-19 14:02:34 +05:30
naman-bruno
7c07488e16 Merge pull request #4697 from lohxt1/req_get_name_test
fix: bruno converters test for reg.getName()
2025-05-16 21:52:30 +05:30
lohit
6073a9e2c3 fix bruno converters test for reg.getName() 2025-05-16 21:27:00 +05:30
lohit
9c652f86c9 Merge pull request #4696 from naman-bruno/bugfix/noproxy-option
add: noproxy flag in options
2025-05-16 21:06:29 +05:30
naman-bruno
3c0090d86f fix: runSingleRequest function 2025-05-16 21:02:24 +05:30
naman-bruno
9132755d49 add: noproxy in options 2025-05-16 20:52:15 +05:30
lohit
2a44691cb3 Merge pull request #4590 from poojabela/feat/add-getName-api-for-script
feat: add `req.getName` & `bru.getCollectionName` api
2025-05-16 20:21:35 +05:30
lohit
0d8a696498 Merge pull request #4609 from pooja-bruno/feat/extend-support-for-more-auth-for-folder-level
feat: extend support for more auth in folder level
2025-05-16 20:20:33 +05:30
lohit
bfa2706598 Merge pull request #4366 from Pragadesh-45/fix/import-curl
Feat: Enhance curl parsing for multipart form data
2025-05-16 20:20:18 +05:30
ved-bruno
5fdb52388a support element verification 2025-05-16 19:34:39 +05:30
Pooja
799dc9a1ca feat: add function in bruno converters package (#4669)
* feat: add  function in bruno converters package

* add: example for openapi yaml to bruno conversion

* add: converting json to yaml in converters

* fix
2025-05-16 17:26:40 +05:30
naman-bruno
2bb56e8a4b Fix: properly handling redirects with status code (#4561)
* Fix: properly handling redirects with status code

* fix: updated redirect logic for method change
2025-05-16 17:14:37 +05:30
Pragadesh-45
084d2bf692 test: add unit tests for basic functionality, headers, auth, and form data handling in parseCurlCommand 2025-05-16 14:32:30 +05:45
Pragadesh-45
10640c7561 feat: enhance curl parsing for multipart form data
- Updated `parseCurlCommand` to handle `-F` and `--form` flags, allowing for multiple form fields with file uploads.
- Adjusted `curlToJson` to set `Content-Type` for multipart data and handle binary data correctly.
2025-05-16 14:32:05 +05:45
naman-bruno
9f044c48fe Added proxy flag for cli (#3963)
* system level proxy depends on proxy flag

* added proxy flag

* fix: proxy flag behaviour

* fix: noproxy flag logic
2025-05-16 14:02:11 +05:30
Anoop M D
79f4e69a05 Merge pull request #4160 from usebruno/feature/custom-user-data-path-for-dev 2025-05-15 16:18:28 +05:30
ramki-bruno
f13148af3d Added option to customize userData path on dev mode
If `ELECTRON_APP_NAME` env-variable is present and its development mode,
then the `appName` and `userData` path is modified accordingly.

e.g.
```sh
ELECTRON_APP_NAME=bruno-dev npm run dev:electron
```

Note: This doesn't change the name of the window or the names in lot of
other places, only the name used by Electron internally.
2025-05-15 16:12:51 +05:30
anusree-bruno
d2eb2d2941 fix: ensure timestamp and isoTimestamp return current time instead of random values 2025-05-15 14:11:53 +05:30
pooja-bruno
fbd3a38587 fix 2025-05-14 17:55:50 +05:30
pooja-bruno
45b660985e fix: ui 2025-05-14 17:45:03 +05:30
pooja-bruno
0888125899 add: default auth mode inherit in folder 2025-05-14 16:12:48 +05:30
pooja-bruno
c85d9bcd84 fix: folder inherit auth 2025-05-14 16:01:42 +05:30
sanjai0py
c14d3f4274 feat: add test case for redirects with cookie authentication 2025-05-14 10:46:14 +05:30
sanjai0py
5a4e33e503 Merge branch 'main' into fix/cli-not-following-redirects 2025-05-13 20:07:29 +05:30
sanjai0py
5649799167 feat: add maxRedirects configuration to runSingleRequest 2025-05-13 20:02:29 +05:30
sanjai0py
0f6da35c0b feat: enhance axios instance with redirect handling and cookie management 2025-05-13 17:27:55 +05:30
pooja-bruno
0d7c94e7e9 add: auth for other 2025-05-06 18:41:40 +05:30
pooja-bruno
9e29821012 feat: extend support for more auth in folder level 2025-05-06 17:56:34 +05:30
poojabela
e0fb379511 add: bru.collectionName api 2025-04-30 17:25:42 +05:30
poojabela
ba9362ccb2 add: getName in collection 2025-04-30 15:36:44 +05:30
poojabela
261a36c435 add: getName in hint 2025-04-30 15:29:10 +05:30
poojabela
cb92e46f8d feat: add req.getName api 2025-04-30 15:14:36 +05:30
Pragadesh-45
f6ab59ceda feat: update Windows build configuration to support custom installation path from GUI installer 2025-03-06 17:40:15 +05:30
Pragadesh-45
f1004e2e36 Merge branch 'main' of https://github.com/Pragadesh-45/bruno 2025-03-06 00:27:18 +05:30
Pragadesh-45
26eaec4c72 Merge branch 'usebruno:main' into main 2025-02-07 10:01:15 +05:30
Pragadesh-45
d0419edb92 fix: correct variable used in collection name update 2025-02-04 17:49:40 +05:30
82 changed files with 1877 additions and 274 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @helloanoop @maintainer-bruno @lohit-bruno @naman-bruno

View File

@@ -1,44 +0,0 @@
name: Playwright E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e-test:
timeout-minutes: 60
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: v22.11.x
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb
npm ci --legacy-peer-deps
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
- name: Build libraries
run: |
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build:bruno-converters
npm run build:bruno-requests
- name: Run Playwright tests
run: |
xvfb-run npm run test:e2e
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

View File

@@ -91,5 +91,43 @@ jobs:
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
check_name: CLI Test Results
files: packages/bruno-tests/collection/junit.xml
comment_mode: always
e2e-test:
name: Playwright E2E Tests
timeout-minutes: 60
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: v22.11.x
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb
npm ci --legacy-peer-deps
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
- name: Build libraries
run: |
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build:bruno-converters
npm run build:bruno-requests
- name: Run Playwright tests
run: |
xvfb-run npm run test:e2e
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

View File

@@ -98,6 +98,15 @@ npm run dev:electron
npm run dev
```
#### Customize Electron `userData` path
If `ELECTRON_USER_DATA_PATH` env-variable is present and its development mode, then `userData` path is modified accordingly.
e.g.
```sh
ELECTRON_USER_DATA_PATH=$(realpath ~/Desktop/bruno-test) npm run dev:electron
```
This will create a `bruno-test` folder on your Desktop and use it as the `userData` path.
### Troubleshooting
You might encounter a `Unsupported platform` error when you run `npm install`. To fix this, you will need to delete `node_modules` and `package-lock.json` and run `npm install`. This should install all the necessary packages needed to run the app.

View File

@@ -0,0 +1,5 @@
import { test, expect } from '../../playwright';
test('Check if the logo on top left is visible', async ({ page }) => {
await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible();
});

View File

@@ -0,0 +1,31 @@
import { test, expect } from '../../playwright';
test('Create new collection and add a simple HTTP request', async ({ page, createTmpDir }) => {
await page.getByLabel('Create Collection').click();
await page.getByLabel('Name').click();
await page.getByLabel('Name').fill('test-collection');
await page.getByLabel('Name').press('Tab');
await page.getByLabel('Location').fill(await createTmpDir('test-collection'));
await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByText('test-collection').click();
await page.getByLabel('Safe ModeBETA').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByPlaceholder('Request Name').fill('r1');
await page.getByPlaceholder('Request URL').click();
await page.getByPlaceholder('Request URL').fill('http://localhost:8081');
await page.getByRole('button', { name: 'Create' }).click();
await page.locator('pre').filter({ hasText: 'http://localhost:' }).click();
await page.locator('textarea').fill('/ping');
await page.locator('#send-request').getByRole('img').nth(2).click();
await expect(page.getByRole('main')).toContainText('200 OK');
await page.getByRole('tab', { name: 'GET r1' }).locator('circle').click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await page.getByText('GETr1').click();
await page.getByRole('button', { name: 'Clear response' }).click();
await page.locator('body').press('ControlOrMeta+Enter');
await expect(page.getByRole('main')).toContainText('200 OK');
});

View File

@@ -0,0 +1,4 @@
{
"maximized": true,
"lastOpenedCollections": ["{{projectRoot}}/packages/bruno-tests/collection"]
}

View File

@@ -0,0 +1,49 @@
import { test, expect } from '../../playwright';
test.describe.parallel('Run Testbench Requests', () => {
test('Run bruno-testbench in Developer Mode', async ({ pageWithUserData: page }) => {
test.setTimeout(2 * 60 * 1000);
await page.getByText('bruno-testbench').click();
await page.getByLabel('Developer Mode(use only if').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('.environment-selector').nth(1).click();
await page.locator('.dropdown-item').getByText('Prod').click();
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const [totalRequests, passed, failed, skipped] = result
.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/)
.slice(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - 1);
});
test.fixme('Run bruno-testbench in Safe Mode', async ({ pageWithUserData: page }) => {
test.setTimeout(2 * 60 * 1000);
await page.getByText('bruno-testbench').click();
await page.getByLabel('Safe ModeBETA').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('.environment-selector').nth(1).click();
await page.locator('.dropdown-item').getByText('Prod').click();
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const [totalRequests, passed, failed, skipped] = result
.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/)
.slice(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - 1);
});
});

View File

@@ -0,0 +1,27 @@
import { test, expect } from '../../playwright';
test('Should verify all support links with correct URL in preference > Support tab', async ({ page }) => {
// Open Preferences
await page.getByLabel('Open Preferences').click();
// Verify Support tab
await page.getByRole('tab', { name: 'Support' }).click();
const locator_twitter = page.getByRole('link', { name: 'Twitter' });
expect(await locator_twitter.getAttribute('href')).toEqual('https://twitter.com/use_bruno');
const locator_github = page.getByRole('link', { name: 'GitHub', exact: true });
expect(await locator_github.getAttribute('href')).toEqual('https://github.com/usebruno/bruno');
const locator_discord = page.getByRole('link', { name: 'Discord', exact: true });
expect(await locator_discord.getAttribute('href')).toEqual('https://discord.com/invite/KgcZUncpjq');
const locator_reportissues = page.getByRole('link', { name: 'Report Issues', exact: true });
expect(await locator_reportissues.getAttribute('href')).toEqual('https://github.com/usebruno/bruno/issues');
const locator_documentation = page.getByRole('link', { name: 'Documentation', exact: true });
expect(await locator_documentation.getAttribute('href')).toEqual('https://docs.usebruno.com');
});

View File

@@ -1,5 +0,0 @@
import { test, expect } from '../playwright';
test('test-app-start', async ({ page }) => {
await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible();
});

View File

@@ -38,4 +38,4 @@ module.exports = defineConfig([
"no-undef": "error",
},
}
]);
]);

View File

@@ -71,4 +71,4 @@
}
}
}
}
}

View File

@@ -58,6 +58,7 @@ if (!SERVER_RENDERED) {
'req.getTimeout()',
'req.setTimeout(timeout)',
'req.getExecutionMode()',
'req.getName()',
'bru',
'bru.cwd()',
'bru.getEnvName()',
@@ -80,12 +81,14 @@ if (!SERVER_RENDERED) {
'bru.getAssertionResults()',
'bru.getTestResults()',
'bru.sleep(ms)',
'bru.getCollectionName()',
'bru.getGlobalEnvVar(key)',
'bru.setGlobalEnvVar(key, value)',
'bru.runner',
'bru.runner.setNextRequest(requestName)',
'bru.runner.skipRequest()',
'bru.runner.stopExecution()'
'bru.runner.stopExecution()',
'bru.interpolate(str)'
];
CodeMirror.registerHelper('hint', 'brunoJS', (editor, options) => {
const cursor = editor.getCursor();
@@ -363,7 +366,7 @@ export default class CodeEditor extends React.Component {
let variables = getAllVariables(this.props.collection, this.props.item);
this.variables = variables;
defineCodeMirrorBrunoVariablesMode(variables, mode);
defineCodeMirrorBrunoVariablesMode(variables, mode, false, this.props.enableVariableHighlighting);
this.editor.setOption('mode', 'brunovariables');
};

View File

@@ -49,7 +49,7 @@ const CollectionSettings = ({ collection }) => {
const requestVars = get(collection, 'root.request.vars.req', []);
const responseVars = get(collection, 'root.request.vars.res', []);
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
const auth = get(collection, 'root.request.auth', {}).mode;
const authMode = get(collection, 'root.request.auth', {}).mode || 'none';
const proxyConfig = get(collection, 'brunoConfig.proxy', {});
const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []);
@@ -155,7 +155,7 @@ const CollectionSettings = ({ collection }) => {
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
Auth
{auth !== 'none' && <ContentIndicator />}
{authMode !== 'none' && <ContentIndicator />}
</div>
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
Script

View File

@@ -11,6 +11,12 @@ const Wrapper = styled.div`
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
.inherit-mode-text {
color: ${(props) => props.theme.colors.text.yellow};
}
.auth-mode-label {
color: ${(props) => props.theme.colors.text.yellow};
}
`;
export default Wrapper;

View File

@@ -9,6 +9,14 @@ import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/Passwo
import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index';
import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index';
import AuthMode from '../AuthMode';
import BasicAuth from 'components/RequestPane/Auth/BasicAuth';
import BearerAuth from 'components/RequestPane/Auth/BearerAuth';
import DigestAuth from 'components/RequestPane/Auth/DigestAuth';
import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
import { findItemInCollection, findParentItemInCollection, humanizeRequestAuthMode } from 'utils/collections/index';
const GrantTypeComponentMap = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -37,12 +45,132 @@ const Auth = ({ collection, folder }) => {
let request = get(folder, 'root.request', {});
const authMode = get(folder, 'root.request.auth.mode');
const getTreePathFromCollectionToFolder = (collection, _folder) => {
let path = [];
let item = findItemInCollection(collection, _folder?.uid);
while (item) {
path.unshift(item);
item = findParentItemInCollection(collection, item?.uid);
}
return path;
};
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
const collectionAuth = get(collection, 'root.request.auth');
let effectiveSource = {
type: 'collection',
name: 'Collection',
auth: collectionAuth
};
// Get path from collection to current folder
const folderTreePath = getTreePathFromCollectionToFolder(collection, folder);
// Check parent folders to find closest auth configuration
// Skip the last item which is the current folder
for (let i = 0; i < folderTreePath.length - 1; i++) {
const parentFolder = folderTreePath[i];
if (parentFolder.type === 'folder') {
const folderAuth = get(parentFolder, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveSource = {
type: 'folder',
name: parentFolder.name,
auth: folderAuth
};
break;
}
}
}
return effectiveSource;
};
const handleSave = () => {
dispatch(saveFolderRoot(collection.uid, folder.uid));
};
const getAuthView = () => {
switch (authMode) {
case 'basic': {
return (
<BasicAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'bearer': {
return (
<BearerAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'digest': {
return (
<DigestAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'ntlm': {
return (
<NTLMAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'wsse': {
return (
<WsseAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'apikey': {
return (
<ApiKeyAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'awsv4': {
return (
<AwsV4Auth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'oauth2': {
return (
<>
@@ -56,6 +184,17 @@ const Auth = ({ collection, folder }) => {
</>
);
}
case 'inherit': {
const source = getEffectiveAuthSource();
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div>Auth inherited from {source.name}: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
</div>
</>
);
}
case 'none': {
return null;
}
@@ -64,6 +203,7 @@ const Auth = ({ collection, folder }) => {
}
};
return (
<StyledWrapper className="w-full">
<div className="text-xs mb-4 text-muted">

View File

@@ -35,6 +35,51 @@ const AuthMode = ({ collection, folder }) => {
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('awsv4');
}}
>
AWS Sig v4
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('basic');
}}
>
Basic Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('bearer');
}}
>
Bearer Token
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('digest');
}}
>
Digest Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('ntlm');
}}
>
NTLM Auth
</div>
<div
className="dropdown-item"
onClick={() => {
@@ -44,6 +89,33 @@ const AuthMode = ({ collection, folder }) => {
>
OAuth 2.0
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('wsse');
}}
>
WSSE Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('apikey');
}}
>
API Key
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('inherit');
}}
>
Inherit
</div>
<div
className="dropdown-item"
onClick={() => {

View File

@@ -130,7 +130,7 @@ class MultiLineEditor extends Component {
addOverlay = (variables) => {
this.variables = variables;
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain');
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain', false, true);
this.editor.setOption('mode', 'brunovariables');
};

View File

@@ -5,21 +5,23 @@ import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { useTheme } from 'providers/Theme';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAPIKeyPlacement } from 'utils/collections';
const ApiKeyAuth = ({ item, collection }) => {
const ApiKeyAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const apikeyAuth = item.draft ? get(item, 'draft.request.auth.apikey', {}) : get(item, 'request.auth.apikey', {});
const apikeyAuth = get(request, 'auth.apikey', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const Icon = forwardRef((props, ref) => {
return (
@@ -90,7 +92,7 @@ const ApiKeyAuth = ({ item, collection }) => {
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
dropdownTippyRef?.current?.hide();
handleAuthChange('placement', 'header');
}}
>
@@ -99,11 +101,11 @@ const ApiKeyAuth = ({ item, collection }) => {
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
dropdownTippyRef?.current?.hide();
handleAuthChange('placement', 'queryparams');
}}
>
Query Params
Query Param
</div>
</Dropdown>
</div>

View File

@@ -8,14 +8,17 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
import StyledWrapper from './StyledWrapper';
import { update } from 'lodash';
const AwsV4Auth = ({ onTokenChange, item, collection }) => {
const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const awsv4Auth = item.draft ? get(item, 'draft.request.auth.awsv4', {}) : get(item, 'request.auth.awsv4', {});
const awsv4Auth = get(request, 'auth.awsv4', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleAccessKeyIdChange = (accessKeyId) => {
dispatch(

View File

@@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const BasicAuth = ({ item, collection }) => {
const BasicAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const basicAuth = item.draft ? get(item, 'draft.request.auth.basic', {}) : get(item, 'request.auth.basic', {});
const basicAuth = get(request, 'auth.basic', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleUsernameChange = (username) => {
dispatch(

View File

@@ -7,16 +7,18 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const BearerAuth = ({ item, collection }) => {
const BearerAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const bearerToken = item.draft
? get(item, 'draft.request.auth.bearer.token', '')
: get(item, 'request.auth.bearer.token', '');
// Use the request prop directly like OAuth2ClientCredentials does
const bearerToken = get(request, 'auth.bearer.token', '');
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleTokenChange = (token) => {
dispatch(

View File

@@ -3,18 +3,20 @@ import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const DigestAuth = ({ item, collection }) => {
const DigestAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const digestAuth = item.draft ? get(item, 'draft.request.auth.digest', {}) : get(item, 'request.auth.digest', {});
const digestAuth = get(request, 'auth.digest', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleUsernameChange = (username) => {
dispatch(

View File

@@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const NTLMAuth = ({ item, collection }) => {
const NTLMAuth = ({ item, collection, request, save, updateAuth }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const ntlmAuth = item.draft ? get(item, 'draft.request.auth.ntlm', {}) : get(item, 'request.auth.ntlm', {});
const ntlmAuth = get(request, 'auth.ntlm', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleUsernameChange = (username) => {
dispatch(
@@ -26,7 +29,6 @@ const NTLMAuth = ({ item, collection }) => {
username: username,
password: ntlmAuth.password,
domain: ntlmAuth.domain
}
})
);

View File

@@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const WsseAuth = ({ item, collection }) => {
const WsseAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const wsseAuth = item.draft ? get(item, 'draft.request.auth.wsse', {}) : get(item, 'request.auth.wsse', {});
const wsseAuth = get(request, 'auth.wsse', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleUserChange = (username) => {
dispatch(
@@ -55,6 +58,7 @@ const WsseAuth = ({ item, collection }) => {
onChange={(val) => handleUserChange(val)}
onRun={handleRun}
collection={collection}
item={item}
/>
</div>
@@ -67,6 +71,8 @@ const WsseAuth = ({ item, collection }) => {
onChange={(val) => handlePasswordChange(val)}
onRun={handleRun}
collection={collection}
item={item}
isSecret={true}
/>
</div>
</StyledWrapper>

View File

@@ -7,6 +7,8 @@ import BasicAuth from './BasicAuth';
import DigestAuth from './DigestAuth';
import WsseAuth from './WsseAuth';
import NTLMAuth from './NTLMAuth';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import ApiKeyAuth from './ApiKeyAuth';
import StyledWrapper from './StyledWrapper';
@@ -27,6 +29,16 @@ const getTreePathFromCollectionToItem = (collection, _item) => {
const Auth = ({ item, collection }) => {
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
// Create a request object to pass to the auth components
const request = item.draft
? get(item, 'draft.request', {})
: get(item, 'request', {});
// Save function for request level
const save = () => {
return saveRequest(item.uid, collection.uid);
};
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
@@ -59,28 +71,28 @@ const Auth = ({ item, collection }) => {
const getAuthView = () => {
switch (authMode) {
case 'awsv4': {
return <AwsV4Auth collection={collection} item={item} />;
return <AwsV4Auth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'basic': {
return <BasicAuth collection={collection} item={item} />;
return <BasicAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'bearer': {
return <BearerAuth collection={collection} item={item} />;
return <BearerAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'digest': {
return <DigestAuth collection={collection} item={item} />;
return <DigestAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'ntlm': {
return <NTLMAuth collection={collection} item={item} />;
return <NTLMAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'oauth2': {
return <OAuth2 collection={collection} item={item} />;
return <OAuth2 collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'wsse': {
return <WsseAuth collection={collection} item={item} />;
return <WsseAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'apikey': {
return <ApiKeyAuth collection={collection} item={item} />;
return <ApiKeyAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'inherit': {
const source = getEffectiveAuthSource();

View File

@@ -64,9 +64,10 @@ const GraphQLVariables = ({ variables, item, collection }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onEdit}
mode="javascript"
mode="application/json"
onRun={onRun}
onSave={onSave}
enableVariableHighlighting={true}
/>
</>
);

View File

@@ -49,7 +49,7 @@ const RequestBody = ({ item, collection }) => {
<StyledWrapper className="w-full">
<CodeEditor
collection={collection}
item={item}
item={item}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
@@ -58,13 +58,14 @@ const RequestBody = ({ item, collection }) => {
onRun={onRun}
onSave={onSave}
mode={codeMirrorMode[bodyMode]}
enableVariableHighlighting={true}
/>
</StyledWrapper>
);
}
if (bodyMode === 'file') {
return <FileBody item={item} collection={collection}/>
return <FileBody item={item} collection={collection} />;
}
if (bodyMode === 'formUrlEncoded') {
@@ -77,4 +78,4 @@ const RequestBody = ({ item, collection }) => {
return <StyledWrapper className="w-full">No Body</StyledWrapper>;
};
export default RequestBody;
export default RequestBody;

View File

@@ -17,7 +17,7 @@ const ResponseLoadingOverlay = ({ item, collection }) => {
<div className="overlay">
<div style={{ marginBottom: 15, fontSize: 26 }}>
<div style={{ display: 'inline-block', fontSize: 20, marginLeft: 5, marginRight: 5 }}>
<StopWatch requestTimestamp={item?.requestSent?.timestamp} />
<StopWatch startTime={item?.requestStartTime} />
</div>
</div>
<IconRefresh size={24} className="loading-icon" />

View File

@@ -4,12 +4,60 @@ import CodeView from './CodeView';
import StyledWrapper from './StyledWrapper';
import { isValidUrl } from 'utils/url';
import { get } from 'lodash';
import { findEnvironmentInCollection } from 'utils/collections';
import { findEnvironmentInCollection, findItemInCollection, findParentItemInCollection } from 'utils/collections';
import { interpolateUrl, interpolateUrlPathParams } from 'utils/url/index';
import { getLanguages } from 'utils/codegenerator/targets';
import { useSelector } from 'react-redux';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
const getTreePathFromCollectionToItem = (collection, _itemUid) => {
let path = [];
let item = findItemInCollection(collection, _itemUid);
while (item) {
path.unshift(item);
item = findParentItemInCollection(collection, item?.uid);
}
return path;
};
// Function to resolve inherited auth
const resolveInheritedAuth = (item, collection) => {
const request = item.draft?.request || item.request;
const authMode = request?.auth?.mode;
// If auth is not inherit or no auth defined, return the request as is
if (!authMode || authMode !== 'inherit') {
return {
...request
};
}
// Get the tree path from collection to item
const requestTreePath = getTreePathFromCollectionToItem(collection, item.uid);
// Default to collection auth
const collectionAuth = get(collection, 'root.request.auth', { mode: 'none' });
let effectiveAuth = collectionAuth;
let source = 'collection';
// Check folders in reverse to find the closest auth configuration
for (let i of [...requestTreePath].reverse()) {
if (i.type === 'folder') {
const folderAuth = get(i, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveAuth = folderAuth;
source = 'folder';
break;
}
}
}
return {
...request,
auth: effectiveAuth
};
};
const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
const languages = getLanguages();
@@ -46,6 +94,9 @@ const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
get(item, 'draft.request.params') !== undefined ? get(item, 'draft.request.params') : get(item, 'request.params')
);
// Resolve auth inheritance
const resolvedRequest = resolveInheritedAuth(item, collection);
const [selectedLanguage, setSelectedLanguage] = useState(languages[0]);
return (
<Modal size="lg" title="Generate Code" handleCancel={onClose} hideFooter={true}>
@@ -94,16 +145,10 @@ const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
language={selectedLanguage}
item={{
...item,
request:
item.request.url !== ''
? {
...item.request,
url: finalUrl
}
: {
...item.draft.request,
url: finalUrl
}
request: {
...resolvedRequest,
url: finalUrl
}
}}
/>
) : (

View File

@@ -146,7 +146,7 @@ class SingleLineEditor extends Component {
addOverlay = (variables) => {
this.variables = variables;
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain', this.props.highlightPathParams);
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain', this.props.highlightPathParams, true);
this.editor.setOption('mode', 'brunovariables');
};

View File

@@ -1,27 +1,24 @@
import React, { useState, useEffect } from 'react';
const StopWatch = () => {
const [milliseconds, setMilliseconds] = useState(0);
const tickInterval = 100;
const tick = () => {
setMilliseconds(_milliseconds => _milliseconds + tickInterval);
};
const StopWatch = ({ startTime }) => {
const [currentTime, setCurrentTime] = useState(Date.now());
useEffect(() => {
let timerID = setInterval(() => {
tick()
}, tickInterval);
return () => {
clearTimeout(timerID);
};
}, []);
if (milliseconds < 250) {
return 'Loading...';
}
let seconds = milliseconds / 1000;
if (!startTime) return;
const intervalId = setInterval(() => {
setCurrentTime(Date.now());
}, 100);
return () => clearInterval(intervalId);
}, [startTime]);
if (!startTime) return <span>Loading...</span>;
const elapsedTime = currentTime - startTime;
if (elapsedTime < 250) return <span>Loading...</span>;
const seconds = elapsedTime / 1000;
return <span>{seconds.toFixed(1)}s</span>;
};

View File

@@ -211,13 +211,15 @@ export const HotkeysProvider = (props) => {
};
}, [activeTabUid, tabs, collections, dispatch]);
const currentCollection = getCurrentCollection();
return (
<HotkeysContext.Provider {...props} value="hotkey">
{showEnvSettingsModal && (
<EnvironmentSettings collection={getCurrentCollection()} onClose={() => setShowEnvSettingsModal(false)} />
<EnvironmentSettings collection={currentCollection} onClose={() => setShowEnvSettingsModal(false)} />
)}
{showNewRequestModal && (
<NewRequest collection={getCurrentCollection()} onClose={() => setShowNewRequestModal(false)} />
<NewRequest collectionUid={currentCollection?.uid} onClose={() => setShowNewRequestModal(false)} />
)}
<div>{props.children}</div>
</HotkeysContext.Provider>

View File

@@ -35,6 +35,7 @@ import {
responseReceived,
updateLastAction,
setCollectionSecurityConfig,
setRequestStartTime,
collectionAddOauth2CredentialsByUrl,
collectionClearOauth2CredentialsByUrl
} from './index';
@@ -221,6 +222,12 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const collection = findCollectionByUid(state.collections.collections, collectionUid);
dispatch(setRequestStartTime({
itemUid: item.uid,
collectionUid: collectionUid,
timestamp: Date.now()
}));
return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
@@ -381,7 +388,12 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) =>
meta: {
name: folderName,
seq: items?.length + 1
}
},
request: {
auth: {
mode: 'inherit'
}
}
}
};
ipcRenderer
@@ -417,7 +429,12 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) =>
meta: {
name: folderName,
seq: items?.length + 1
}
},
request: {
auth: {
mode: 'inherit'
}
}
}
};
ipcRenderer

View File

@@ -1593,6 +1593,27 @@ export const collectionsSlice = createSlice({
case 'oauth2':
set(folder, 'root.request.auth.oauth2', action.payload.content);
break;
case 'basic':
set(folder, 'root.request.auth.basic', action.payload.content);
break;
case 'bearer':
set(folder, 'root.request.auth.bearer', action.payload.content);
break;
case 'digest':
set(folder, 'root.request.auth.digest', action.payload.content);
break;
case 'ntlm':
set(folder, 'root.request.auth.ntlm', action.payload.content);
break;
case 'apikey':
set(folder, 'root.request.auth.apikey', action.payload.content);
break;
case 'awsv4':
set(folder, 'root.request.auth.awsv4', action.payload.content);
break;
case 'wsse':
set(folder, 'root.request.auth.wsse', action.payload.content);
break;
}
}
},
@@ -2084,6 +2105,17 @@ export const collectionsSlice = createSlice({
}
}
},
setRequestStartTime: (state, action) => {
const { itemUid, collectionUid, timestamp } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (item) {
item.requestStartTime = timestamp;
}
}
},
collectionAddOauth2CredentialsByUrl: (state, action) => {
const { collectionUid, folderUid, itemUid, url, credentials, credentialsId, debugInfo } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@@ -2165,6 +2197,7 @@ export const collectionsSlice = createSlice({
);
return oauth2Credential;
},
updateFolderAuthMode: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
@@ -2173,8 +2206,9 @@ export const collectionsSlice = createSlice({
set(folder, 'root.request.auth', {});
set(folder, 'root.request.auth.mode', action.payload.mode);
}
},
}
}
},
});
export const {
@@ -2280,12 +2314,13 @@ export const {
resetCollectionRunner,
updateRequestDocs,
updateFolderDocs,
moveCollection,
setRequestStartTime,
collectionAddOauth2CredentialsByUrl,
collectionClearOauth2CredentialsByUrl,
collectionGetOauth2CredentialsByUrl,
updateFolderAuth,
updateFolderAuthMode,
moveCollection
} = collectionsSlice.actions;
export default collectionsSlice.reducer;

View File

@@ -74,11 +74,11 @@ export class MaskedEditor {
} else {
for (let line = 0; line < lineCount; line++) {
const lineLength = this.editor.getLine(line).length;
const maskedNode = document.createTextNode('*'.repeat(lineLength));
const maskedNode = document.createTextNode('*'.repeat(lineLength));
this.editor.markText(
{ line, ch: 0 },
{ line, ch: lineLength },
{ replacedWith: maskedNode, handleMouseEvents: false }
{ replacedWith: maskedNode, handleMouseEvents: false }
);
}
}
@@ -86,7 +86,18 @@ export class MaskedEditor {
};
}
export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPathParams) => {
/**
* Defines a custom CodeMirror mode for Bruno variables highlighting.
* This function creates a specialized mode that can highlight both Bruno template
* variables (in the format {{variable}}) and URL path parameters (in the format /:param).
*
* @param {Object} _variables - The variables object containing data to validate against
* @param {string} mode - The base CodeMirror mode to extend (e.g., 'javascript', 'application/json')
* @param {boolean} highlightPathParams - Whether to highlight URL path parameters
* @param {boolean} highlightVariables - Whether to highlight template variables
* @returns {void} - Registers the mode with CodeMirror for later use
*/
export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPathParams, highlightVariables) => {
CodeMirror.defineMode('brunovariables', function (config, parserConfig) {
const { pathParams = {}, ...variables } = _variables || {};
const variablesOverlay = {
@@ -139,13 +150,15 @@ export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPa
}
};
let baseMode = CodeMirror.overlayMode(CodeMirror.getMode(config, parserConfig.backdrop || mode), variablesOverlay);
let baseMode = CodeMirror.getMode(config, parserConfig.backdrop || mode);
if (highlightPathParams) {
return CodeMirror.overlayMode(baseMode, urlPathParamsOverlay);
} else {
return baseMode;
if (highlightVariables) {
baseMode = CodeMirror.overlayMode(baseMode, variablesOverlay);
}
if (highlightPathParams) {
baseMode = CodeMirror.overlayMode(baseMode, urlPathParamsOverlay);
}
return baseMode;
});
};

View File

@@ -83,29 +83,40 @@ export const normalizeFileName = (name) => {
};
export const getContentType = (headers) => {
const headersArray = typeof headers === 'object' ? Object.entries(headers) : [];
if (headersArray.length > 0) {
let contentType = headersArray
.filter((header) => header[0].toLowerCase() === 'content-type')
.map((header) => {
return header[1];
});
if (contentType && contentType.length) {
if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?json/.test(contentType[0])) {
return 'application/ld+json';
} else if (typeof contentType[0] === 'string' && /^image\/svg\+xml/i.test(contentType[0])) {
return 'image/svg+xml';
} else if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?xml/.test(contentType[0])) {
return 'application/xml';
}
return contentType[0];
}
// Return empty string for invalid headers
if (!headers || typeof headers !== 'object' || Object.keys(headers).length === 0) {
return '';
}
return '';
};
// Get content-type header value
const contentTypeHeader = Object.entries(headers)
.find(([key]) => key.toLowerCase() === 'content-type');
const contentType = contentTypeHeader && contentTypeHeader[1];
// Return empty string if no content-type or not a string
if (!contentType || typeof contentType !== 'string') {
return '';
}
// This pattern matches content types like application/json, application/ld+json, text/json, etc.
const JSON_PATTERN = /^[\w\-]+\/([\w\-]+\+)?json/;
// This pattern matches content types like image/svg.
const SVG_PATTERN = /^image\/svg/i;
// This pattern matches content types like application/xml, text/xml, application/atom+xml, etc.
const XML_PATTERN = /^[\w\-]+\/([\w\-]+\+)?xml/;
if (JSON_PATTERN.test(contentType)) {
return 'application/ld+json';
} else if (SVG_PATTERN.test(contentType)) {
return 'image/svg+xml';
} else if (XML_PATTERN.test(contentType)) {
return 'application/xml';
}
return contentType;
}
export const startsWith = (str, search) => {
if (!str || !str.length || typeof str !== 'string') {

View File

@@ -1,6 +1,6 @@
const { describe, it, expect } = require('@jest/globals');
import { normalizeFileName, startsWith, humanizeDate, relativeDate } from './index';
import { normalizeFileName, startsWith, humanizeDate, relativeDate, getContentType } from './index';
describe('common utils', () => {
describe('normalizeFileName', () => {
@@ -107,4 +107,45 @@ describe('common utils', () => {
expect(relativeDate(date)).toBe('2 months ago');
});
});
describe('getContentType', () => {
it('should handle JSON content types correctly', () => {
expect(getContentType({ 'content-type': 'application/json' })).toBe('application/ld+json');
expect(getContentType({ 'content-type': 'text/json' })).toBe('application/ld+json');
expect(getContentType({ 'content-type': 'application/ld+json' })).toBe('application/ld+json');
});
it('should handle XML content types correctly', () => {
expect(getContentType({ 'content-type': 'text/xml' })).toBe('application/xml');
expect(getContentType({ 'content-type': 'application/xml' })).toBe('application/xml');
expect(getContentType({ 'content-type': 'application/atom+xml' })).toBe('application/xml');
});
it('should handle image content types correctly', () => {
expect(getContentType({ 'content-type': 'image/svg+xml;charset=utf-8' })).toBe('image/svg+xml');
expect(getContentType({ 'content-type': 'IMAGE/SVG+xml' })).toBe('image/svg+xml');
});
it('should return original content type when no pattern matches', () => {
expect(getContentType({ 'content-type': 'image/jpeg' })).toBe('image/jpeg');
expect(getContentType({ 'content-type': 'application/pdf' })).toBe('application/pdf');
});
it('should not be case sensitive', () => {
expect(getContentType({ 'content-type': 'text/json' })).toBe('application/ld+json');
expect(getContentType({ 'Content-Type': 'text/json' })).toBe('application/ld+json');
});
it('should handle empty content type', () => {
expect(getContentType({ 'content-type': '' })).toBe('');
expect(getContentType({ 'content-type': null })).toBe('');
expect(getContentType({ 'content-type': undefined })).toBe('');
});
it('should handle empty or invalid inputs', () => {
expect(getContentType({})).toBe('');
expect(getContentType(null)).toBe('');
expect(getContentType(undefined)).toBe('');
});
});
});

View File

@@ -183,7 +183,13 @@ const curlToJson = (curlCommand) => {
if (request.query) {
requestJson.queries = getQueries(request);
} else if (request.multipartUploads || request.isDataBinary) {
} else if (request.multipartUploads) {
requestJson.data = request.multipartUploads;
if (!requestJson.headers) {
requestJson.headers = {};
}
requestJson.headers['Content-Type'] = 'multipart/form-data';
} else if (request.isDataBinary) {
Object.assign(requestJson, getFilesString(request));
} else if (typeof request.data === 'string' || typeof request.data === 'number') {
Object.assign(requestJson, getDataString(request));

View File

@@ -37,7 +37,8 @@ const parseCurlCommand = (curlCommand) => {
alias: {
H: 'header',
A: 'user-agent',
u: 'user'
u: 'user',
F: 'form'
}
});
@@ -95,17 +96,31 @@ const parseCurlCommand = (curlCommand) => {
cookieString = parsedArguments.cookie;
}
let multipartUploads;
if (parsedArguments.F) {
multipartUploads = {};
if (!Array.isArray(parsedArguments.F)) {
parsedArguments.F = [parsedArguments.F];
}
parsedArguments.F.forEach((multipartArgument) => {
// input looks like key=value. value could be json or a file path prepended with an @
const splitArguments = multipartArgument.split('=', 2);
const key = splitArguments[0];
const value = splitArguments[1];
multipartUploads[key] = value;
// Handle multipart form data specified via -F or --form flags
// Example: curl -F 'id=123' -F 'file=@/path/to/file.txt'
if (parsedArguments.F || parsedArguments.form) {
multipartUploads = [];
const formArgs = parsedArguments.F || parsedArguments.form;
const formArray = Array.isArray(formArgs) ? formArgs : [formArgs];
formArray.forEach((multipartArgument) => {
// Parse each form field using regex:
// - Group 1: Field name before =
// - Group 2: Value in quotes after = (for text fields)
// - Group 3: Value after @ (for file fields)
const match = multipartArgument.match(/^([^=]+)=(?:@?"([^"]*)"|([^@]*))?$/);
if (match) {
const key = match[1];
const value = match[2] || match[3] || '';
const isFile = multipartArgument.includes('@');
multipartUploads.push({
name: key,
value: value,
type: isFile ? 'file' : 'text',
enabled: true
});
}
});
}
if (cookieString) {

View File

@@ -0,0 +1,145 @@
const { describe, it, expect } = require('@jest/globals');
import parseCurlCommand from './parse-curl';
describe('parseCurlCommand', () => {
describe('basic functionality', () => {
it('should handle basic GET request', () => {
const result = parseCurlCommand('curl https://api.example.com/users');
expect(result).toEqual({
url: 'https://api.example.com/users',
urlWithoutQuery: 'https://api.example.com/users',
method: 'get'
});
});
it('should parse explicit POST method', () => {
const result = parseCurlCommand('curl -X POST https://api.example.com/users');
expect(result).toEqual({
url: 'https://api.example.com/users',
urlWithoutQuery: 'https://api.example.com/users',
method: 'post'
});
});
});
describe('headers handling', () => {
it('should parse multiple headers', () => {
const result = parseCurlCommand(
`curl -H 'Content-Type: application/json' -H 'Authorization: Bearer token' https://api.example.com`
);
expect(result).toEqual({
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com',
method: 'get',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer token'
}
});
});
it('should parse user-agent', () => {
const result = parseCurlCommand(`curl -A 'Custom Agent' https://api.example.com`);
expect(result).toEqual({
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com',
method: 'get',
headers: {
'User-Agent': 'Custom Agent'
}
});
});
});
describe('auth handling', () => {
it('should parse basic auth', () => {
const result = parseCurlCommand(`curl -u user:pass https://api.example.com`);
expect(result).toEqual({
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com',
method: 'get',
auth: {
mode: 'basic',
basic: {
username: 'user',
password: 'pass'
}
}
});
});
});
describe('data handling', () => {
it('should parse POST data', () => {
const result = parseCurlCommand(`curl -d 'foo=bar&baz=qux' https://api.example.com`);
expect(result).toEqual({
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com',
method: 'post',
data: 'foo=bar&baz=qux'
});
});
it('should handle data-binary', () => {
const result = parseCurlCommand(`curl --data-binary '@file.json' https://api.example.com`);
expect(result).toEqual({
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com',
method: 'post',
data: '@file.json',
isDataBinary: true
});
});
});
describe('form data handling', () => {
it('should parse complex form data with multiple fields and file upload', () => {
const curlCommand = `curl --location 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d' \
--form 'id="1"' \
--form 'documentid="ADMINN_ID"' \
--form 'appoinID="12376"' \
--form 'autoclose="false"' \
--form 'fileData=@"/path/to/file"'`;
const result = parseCurlCommand(curlCommand);
expect(result).toEqual({
url: 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d',
urlWithoutQuery: 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d',
method: 'post',
multipartUploads: [
{
name: 'id',
value: '1',
type: 'text',
enabled: true
},
{
name: 'documentid',
value: 'ADMINN_ID',
type: 'text',
enabled: true
},
{
name: 'appoinID',
value: '12376',
type: 'text',
enabled: true
},
{
name: 'autoclose',
value: 'false',
type: 'text',
enabled: true
},
{
name: 'fileData',
value: '/path/to/file',
type: 'file',
enabled: true
}
]
});
});
});
});

View File

@@ -164,6 +164,11 @@ const builder = async (yargs) => {
type: 'string',
description: 'Path to the Client certificate config file used for securing the connection in the request'
})
.option('--noproxy', {
type: 'boolean',
description: 'Disable all proxy settings (both collection-defined and system proxies)',
default: false
})
.option('delay', {
type:"number",
description: "Delay between each requests (in miliseconds)"
@@ -197,7 +202,6 @@ const builder = async (yargs) => {
'$0 run request.bru --reporter-junit results.xml --reporter-html results.html',
'Run a request and write the results to results.html in html format and results.xml in junit format in the current directory'
)
.example('$0 run request.bru --tests-only', 'Run all requests that have a test')
.example(
'$0 run request.bru --cacert myCustomCA.pem',
@@ -208,7 +212,8 @@ const builder = async (yargs) => {
'Use a custom CA certificate exclusively when validating the peers of the requests in the specified folder.'
)
.example('$0 run --client-cert-config client-cert-config.json', 'Run a request with Client certificate configurations')
.example('$0 run folder --delay delayInMs', 'Run a folder with given miliseconds delay between each requests.');
.example('$0 run folder --delay delayInMs', 'Run a folder with given miliseconds delay between each requests.')
.example('$0 run --noproxy', 'Run requests with system proxy disabled');
};
const handler = async function (argv) {
@@ -233,6 +238,7 @@ const handler = async function (argv) {
reporterSkipAllHeaders,
reporterSkipHeaders,
clientCertConfig,
noproxy,
delay
} = argv;
const collectionPath = process.cwd();
@@ -339,6 +345,9 @@ const handler = async function (argv) {
if (disableCookies) {
options['disableCookies'] = true;
}
if (noproxy) {
options['noproxy'] = true;
}
if (cacert && cacert.length) {
if (insecure) {
console.error(chalk.red(`Ignoring the cacert option since insecure connections are enabled`));
@@ -608,7 +617,7 @@ const handler = async function (argv) {
}
}
if (summary.failedAssertions + summary.failedTests + summary.failedRequests > 0) {
if ((summary.failedAssertions + summary.failedTests + summary.failedRequests > 0) || (summary?.errorRequests > 0)) {
process.exit(constants.EXIT_STATUS.ERROR_FAILED_COLLECTION);
}
} catch (err) {

View File

@@ -32,6 +32,7 @@ const prepareRequest = (item = {}, collection = {}) => {
method: request.method,
url: request.url,
headers: headers,
name: item.name,
pathParams: request?.params?.filter((param) => param.type === 'path'),
responseType: 'arraybuffer'
};

View File

@@ -58,6 +58,7 @@ const runSingleRequest = async function (
// run pre request script
const requestScriptFile = get(request, 'script.req');
const collectionName = collection?.brunoConfig?.name
if (requestScriptFile?.length) {
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
const result = await scriptRuntime.runRequestScript(
@@ -69,7 +70,8 @@ const runSingleRequest = async function (
onConsoleLog,
processEnvVars,
scriptingConfig,
runSingleRequestByPathname
runSingleRequestByPathname,
collectionName
);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
@@ -115,6 +117,7 @@ const runSingleRequest = async function (
const options = getOptions();
const insecure = get(options, 'insecure', false);
const noproxy = get(options, 'noproxy', false);
const httpsAgentRequestFields = {};
if (insecure) {
httpsAgentRequestFields['rejectUnauthorized'] = false;
@@ -179,15 +182,22 @@ const runSingleRequest = async function (
const collectionProxyConfig = get(brunoConfig, 'proxy', {});
const collectionProxyEnabled = get(collectionProxyConfig, 'enabled', false);
if (collectionProxyEnabled === true) {
if (noproxy) {
// If noproxy flag is set, don't use any proxy
proxyMode = 'off';
} else if (collectionProxyEnabled === true) {
// If collection proxy is enabled, use it
proxyConfig = collectionProxyConfig;
proxyMode = 'on';
} else {
// if the collection level proxy is not set, pick the system level proxy by default, to maintain backward compatibility
} else if (collectionProxyEnabled === 'global') {
// If collection proxy is set to 'global', use system proxy
const { http_proxy, https_proxy } = getSystemProxyEnvVariables();
if (http_proxy?.length || https_proxy?.length) {
proxyMode = 'system';
}
} else {
proxyMode = 'off';
}
if (proxyMode === 'on') {
@@ -304,6 +314,14 @@ const runSingleRequest = async function (
}
}
let requestMaxRedirects = request.maxRedirects
request.maxRedirects = 0
// Set default value for requestMaxRedirects if not explicitly set
if (requestMaxRedirects === undefined) {
requestMaxRedirects = 5; // Default to 5 redirects
}
// Handle OAuth2 authentication
if (request.oauth2) {
try {
@@ -334,7 +352,7 @@ const runSingleRequest = async function (
let response, responseTime;
try {
let axiosInstance = makeAxiosInstance();
let axiosInstance = makeAxiosInstance({ requestMaxRedirects: requestMaxRedirects, disableCookies: options.disableCookies });
if (request.ntlmConfig) {
axiosInstance=NtlmClient(request.ntlmConfig,axiosInstance.defaults)
delete request.ntlmConfig;
@@ -452,7 +470,8 @@ const runSingleRequest = async function (
null,
processEnvVars,
scriptingConfig,
runSingleRequestByPathname
runSingleRequestByPathname,
collectionName
);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
@@ -502,7 +521,8 @@ const runSingleRequest = async function (
null,
processEnvVars,
scriptingConfig,
runSingleRequestByPathname
runSingleRequestByPathname,
collectionName
);
testResults = get(result, 'results', []);

View File

@@ -1,5 +1,47 @@
const axios = require('axios');
const { CLI_VERSION } = require('../constants');
const { addCookieToJar, getCookieStringForUrl } = require('./cookies');
const redirectResponseCodes = [301, 302, 303, 307, 308];
const METHOD_CHANGING_REDIRECTS = [301, 302, 303];
const saveCookies = (url, headers) => {
if (headers['set-cookie']) {
let setCookieHeaders = Array.isArray(headers['set-cookie'])
? headers['set-cookie']
: [headers['set-cookie']];
for (let setCookieHeader of setCookieHeaders) {
if (typeof setCookieHeader === 'string' && setCookieHeader.length) {
addCookieToJar(setCookieHeader, url);
}
}
}
};
const createRedirectConfig = (error, redirectUrl) => {
const requestConfig = {
...error.config,
url: redirectUrl,
headers: { ...error.config.headers }
};
const statusCode = error.response.status;
const originalMethod = (error.config.method || 'get').toLowerCase();
// For 301, 302, 303: change method to GET unless it was HEAD
if (METHOD_CHANGING_REDIRECTS.includes(statusCode) && originalMethod !== 'head') {
requestConfig.method = 'get';
requestConfig.data = undefined;
// Clean up headers that are no longer relevant
delete requestConfig.headers['content-length'];
delete requestConfig.headers['Content-Length'];
delete requestConfig.headers['content-type'];
delete requestConfig.headers['Content-Type'];
}
return requestConfig;
};
/**
* Function that configures axios with timing interceptors
@@ -7,10 +49,13 @@ const { CLI_VERSION } = require('../constants');
* @see https://github.com/axios/axios/issues/695
* @returns {axios.AxiosInstance}
*/
function makeAxiosInstance() {
function makeAxiosInstance({ requestMaxRedirects = 5, disableCookies } = {}) {
let redirectCount = 0;
/** @type {axios.AxiosInstance} */
const instance = axios.create({
proxy: false,
maxRedirects: 0,
headers: {
"User-Agent": `bruno-runtime/${CLI_VERSION}`
}
@@ -18,6 +63,15 @@ function makeAxiosInstance() {
instance.interceptors.request.use((config) => {
config.headers['request-start-time'] = Date.now();
// Add cookies to request if available and not disabled
if (!disableCookies) {
const cookieString = getCookieStringForUrl(config.url);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
config.headers['cookie'] = cookieString;
}
}
return config;
});
@@ -26,6 +80,8 @@ function makeAxiosInstance() {
const end = Date.now();
const start = response.config.headers['request-start-time'];
response.headers['request-duration'] = end - start;
redirectCount = 0;
return response;
},
(error) => {
@@ -33,6 +89,42 @@ function makeAxiosInstance() {
const end = Date.now();
const start = error.config.headers['request-start-time'];
error.response.headers['request-duration'] = end - start;
if (redirectResponseCodes.includes(error.response.status)) {
if (redirectCount >= requestMaxRedirects) {
// todo: needs to be discussed whether the original error response message should be modified or not
return Promise.reject(error);
}
const locationHeader = error.response.headers.location;
if (!locationHeader) {
// todo: needs to be discussed whether the original error response message should be modified or not
return Promise.reject(error);
}
redirectCount++;
let redirectUrl = locationHeader;
if (!locationHeader.match(/^https?:\/\//i)) {
const URL = require('url');
redirectUrl = URL.resolve(error.config.url, locationHeader);
}
if (!disableCookies){
saveCookies(redirectUrl, error.response.headers);
}
const requestConfig = createRedirectConfig(error, redirectUrl);
if (!disableCookies) {
const cookieString = getCookieStringForUrl(redirectUrl);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
requestConfig.headers['cookie'] = cookieString;
}
}
return instance(requestConfig);
}
}
return Promise.reject(error);
}

View File

@@ -1,5 +1,6 @@
const { Cookie, CookieJar } = require('tough-cookie');
const each = require('lodash/each');
const { isPotentiallyTrustworthyOrigin } = require('@usebruno/requests').utils;
const cookieJar = new CookieJar();
@@ -11,7 +12,9 @@ const addCookieToJar = (setCookieHeader, requestUrl) => {
};
const getCookiesForUrl = (url) => {
return cookieJar.getCookiesSync(url);
return cookieJar.getCookiesSync(url, {
secure: isPotentiallyTrustworthyOrigin(url)
});
};
const getCookieStringForUrl = (url) => {

View File

@@ -1,11 +1,26 @@
import { mockDataFunctions } from "./faker-functions";
describe("mockDataFunctions Regex Validation", () => {
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-01T00:00:00.000Z'));
});
afterAll(() => {
jest.useRealTimers();
});
test("timestamp and isoTimestamp should return mocked time values", () => {
const expectedTimestamp = '1704067200';
const expectedIsoTimestamp = '2024-01-01T00:00:00.000Z';
expect(mockDataFunctions.timestamp()).toBe(expectedTimestamp);
expect(mockDataFunctions.isoTimestamp()).toBe(expectedIsoTimestamp);
});
test("all values should match their expected patterns", () => {
const patterns: Record<string, RegExp> = {
guid: /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/,
timestamp: /^\d{13,}$/,
isoTimestamp: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
randomUUID: /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/,
randomAlphaNumeric: /^[\w]$/,
randomBoolean: /^(true|false)$/,

View File

@@ -2,8 +2,8 @@ import { faker } from '@faker-js/faker';
export const mockDataFunctions = {
guid: () => faker.string.uuid(),
timestamp: () => faker.date.anytime().getTime().toString(),
isoTimestamp: () => faker.date.anytime().toISOString(),
timestamp: () => Math.floor(Date.now() / 1000).toString(),
isoTimestamp: () => new Date().toISOString(),
randomUUID: () => faker.string.uuid(),
randomAlphaNumeric: () => faker.string.alphanumeric(),
randomBoolean: () => faker.datatype.boolean(),

View File

@@ -1,5 +1,6 @@
import each from 'lodash/each';
import get from 'lodash/get';
import jsyaml from 'js-yaml';
import { validateSchema, transformItemsInCollection, hydrateSeqInCollection, uuid } from '../common';
const parseGraphQL = (text) => {
@@ -288,6 +289,9 @@ const parseInsomniaCollection = (data) => {
export const insomniaToBruno = (insomniaCollection) => {
try {
if(typeof insomniaCollection !== 'object') {
insomniaCollection = jsyaml.load(insomniaCollection);
}
let collection;
if (isInsomniaV5Export(insomniaCollection)) {
collection = parseInsomniaV5Collection(insomniaCollection);

View File

@@ -1,5 +1,6 @@
import each from 'lodash/each';
import get from 'lodash/get';
import jsyaml from 'js-yaml';
import { validateSchema, transformItemsInCollection, hydrateSeqInCollection, uuid } from '../common';
const ensureUrl = (url) => {
@@ -7,14 +8,22 @@ const ensureUrl = (url) => {
return url.replace(/([^:])\/{2,}/g, '$1/');
};
const buildEmptyJsonBody = (bodySchema) => {
const buildEmptyJsonBody = (bodySchema, visited = new Map()) => {
// Check for circular references
if (visited.has(bodySchema)) {
return {};
}
// Add this schema to visited map
visited.set(bodySchema, true);
let _jsonBody = {};
each(bodySchema.properties || {}, (prop, name) => {
if (prop.type === 'object') {
_jsonBody[name] = buildEmptyJsonBody(prop);
_jsonBody[name] = buildEmptyJsonBody(prop, visited);
} else if (prop.type === 'array') {
if (prop.items && prop.items.type === 'object') {
_jsonBody[name] = [buildEmptyJsonBody(prop.items)];
_jsonBody[name] = [buildEmptyJsonBody(prop.items, visited)];
} else {
_jsonBody[name] = [];
}
@@ -422,6 +431,10 @@ export const parseOpenApiCollection = (data) => {
export const openApiToBruno = (openApiSpecification) => {
try {
if(typeof openApiSpecification !== 'object') {
openApiSpecification = jsyaml.load(openApiSpecification);
}
const collection = parseOpenApiCollection(openApiSpecification);
const transformedCollection = transformItemsInCollection(collection);
const hydratedCollection = hydrateSeqInCollection(transformedCollection);

View File

@@ -5,6 +5,7 @@ const replacements = {
'pm\\.environment\\.set\\(': 'bru.setEnvVar(',
'pm\\.variables\\.get\\(': 'bru.getVar(',
'pm\\.variables\\.set\\(': 'bru.setVar(',
'pm\\.variables\\.replaceIn\\(': 'bru.interpolate(',
'pm\\.collectionVariables\\.get\\(': 'bru.getVar(',
'pm\\.collectionVariables\\.set\\(': 'bru.setVar(',
'pm\\.collectionVariables\\.has\\(': 'bru.hasVar(',
@@ -33,6 +34,7 @@ const replacements = {
'pm\\.request\\.method': 'req.getMethod()',
'pm\\.request\\.headers': 'req.getHeaders()',
'pm\\.request\\.body': 'req.getBody()',
'pm\\.info\\.requestName': 'req.getName()',
// deprecated translations
'postman\\.setEnvironmentVariable\\(': 'bru.setEnvVar(',
'postman\\.getEnvironmentVariable\\(': 'bru.getEnvVar(',

View File

@@ -52,7 +52,7 @@ const simpleTranslations = {
'pm.variables.get': 'bru.getVar',
'pm.variables.set': 'bru.setVar',
'pm.variables.has': 'bru.hasVar',
'pm.variables.replaceIn': 'bru.interpolate',
// Collection variables
'pm.collectionVariables.get': 'bru.getVar',
'pm.collectionVariables.set': 'bru.setVar',
@@ -67,6 +67,9 @@ const simpleTranslations = {
'pm.expect': 'expect',
'pm.expect.fail': 'expect.fail',
// Info
'pm.info.requestName': 'req.getName()',
// Request properties
'pm.request.url': 'req.getUrl()',
'pm.request.method': 'req.getMethod()',

View File

@@ -1,10 +1,9 @@
import { describe, it, expect } from '@jest/globals';
import insomniaToBruno from '../../src/insomnia/insomnia-to-bruno';
import jsyaml from 'js-yaml';
describe('insomnia-collection', () => {
it('should correctly import a valid Insomnia v5 collection file', async () => {
const brunoCollection = insomniaToBruno(jsyaml.load(insomniaCollection));
const brunoCollection = insomniaToBruno(insomniaCollection);
expect(brunoCollection).toMatchObject(expectedOutput)
});

View File

@@ -0,0 +1,248 @@
import { describe, it, expect } from '@jest/globals';
import openApiToBruno from '../../../src/openapi/openapi-to-bruno';
describe('openapi-circular-references', () => {
it('should handle simple circular references in schema correctly', async () => {
const brunoCollection = openApiToBruno(circularRefsData);
expect(brunoCollection).toMatchObject(circularRefsOutput);
});
it('should handle complex circular reference chains correctly', async () => {
const brunoCollection = openApiToBruno(complexCircularRefsData);
expect(brunoCollection).toMatchObject(circularRefsOutput);
});
});
const circularRefsData = {
"components": {
"schemas": {
"schema_1": {
"additionalProperties": false,
"description": "schema_1",
"properties": {
"conditions": {
"$ref": "#/components/schemas/schema_1"
}
},
"type": "object"
},
"schema_2": {
"additionalProperties": false,
"description": "schema_2",
"properties": {
"conditionGroup": {
"description": "nested schema_1",
"items": { "$ref": "#/components/schemas/schema_1" },
"type": "array"
},
"operation": {
"description": "operation",
"enum": ["ANY", "ALL"],
"type": "string"
}
},
"type": "object"
}
}
},
"info": {
"description": "circular reference openapi sample json spec",
"title": "circular reference openapi sample json spec",
"version": "0.1"
},
"openapi": "3.0.1",
"paths": {
"/": {
"post": {
"deprecated": false,
"description": "echo ping api",
"operationId": "echo ping",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/schema_1"
}
}
},
"description": "echo ping api",
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"example": "ping"
}
},
"description": "Returned if the request is successful."
}
}
}
}
},
"servers": [{ "url": "https://echo.usebruno.com" }]
};
// More complex circular reference test with a longer chain
const complexCircularRefsData = {
"components": {
"schemas": {
"schema_1": {
"additionalProperties": false,
"description": "schema_1",
"properties": {
"conditionGroup": {
"description": "nested schema_1",
"items": { "$ref": "#/components/schemas/schema_2" },
"type": "array"
}
},
"type": "object"
},
"schema_2": {
"additionalProperties": false,
"description": "schema_2",
"properties": {
"conditionGroup": {
"description": "nested schema_2",
"items": { "$ref": "#/components/schemas/schema_3" },
"type": "array"
}
},
"type": "object"
},
"schema_3": {
"additionalProperties": false,
"description": "schema_3",
"properties": {
"conditionGroup": {
"description": "nested schema_3",
"items": { "$ref": "#/components/schemas/schema_4" },
"type": "array"
}
},
"type": "object"
},
"schema_4": {
"additionalProperties": false,
"description": "schema_4",
"properties": {
"conditionGroup": {
"description": "nested schema_4",
"items": { "$ref": "#/components/schemas/schema_5" },
"type": "array"
}
},
"type": "object"
},
"schema_5": {
"additionalProperties": false,
"description": "schema_4",
"properties": {
"conditionGroup": {
"description": "nested schema_5",
"items": { "$ref": "#/components/schemas/schema_1" },
"type": "array"
}
},
"type": "object"
},
"schema_6": {
"additionalProperties": false,
"description": "schema_3",
"properties": {
"conditionGroup": {
"description": "nested schema_3",
"items": { "$ref": "#/components/schemas/schema_1" },
"type": "array"
},
"operation": {
"description": "operation",
"enum": ["ANY", "ALL"],
"type": "string"
}
},
"type": "object"
}
}
},
"info": {
"description": "circular reference openapi sample json spec",
"title": "circular reference openapi sample json spec",
"version": "0.1"
},
"openapi": "3.0.1",
"paths": {
"/": {
"post": {
"deprecated": false,
"description": "echo ping api",
"operationId": "echo ping",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/schema_1"
}
}
},
"description": "echo ping api",
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"example": "ping"
}
},
"description": "Returned if the request is successful."
}
}
}
}
},
"servers": [{ "url": "https://echo.usebruno.com" }]
};
const circularRefsOutput = {
"environments": [
{
"name": "Environment 1",
"variables": [
{
"enabled": true,
"name": "baseUrl",
"secret": false,
"type": "text",
"value": "https://echo.usebruno.com",
},
],
},
],
"items": [
{
"name": "echo ping",
"type": "http-request",
"request": {
"url": "{{baseUrl}}/",
"method": "POST",
"auth": {
"mode": "none",
},
"headers": [],
"params": [],
"body": {
"mode": "json",
}
},
},
],
"name": "circular reference openapi sample json spec",
"version": "1",
};

View File

@@ -1,11 +1,9 @@
import jsyaml from 'js-yaml';
import { describe, it, expect } from '@jest/globals';
import openApiToBruno from '../../src/openapi/openapi-to-bruno';
import openApiToBruno from '../../../src/openapi/openapi-to-bruno';
describe('openapi-collection', () => {
it('should correctly import a valid OpenAPI file', async () => {
const openApiSpecification = jsyaml.load(openApiCollectionString);
const brunoCollection = openApiToBruno(openApiSpecification);
const brunoCollection = openApiToBruno(openApiCollectionString);
expect(brunoCollection).toMatchObject(expectedOutput);
});

View File

@@ -7,6 +7,7 @@ describe('postmanTranslations - request commands', () => {
const requestMethod = pm.request.method;
const requestHeaders = pm.request.headers;
const requestBody = pm.request.body;
const requestName = pm.info.requestName;
pm.test('Request method is POST', function() {
pm.expect(pm.request.method).to.equal('POST');
@@ -17,6 +18,7 @@ describe('postmanTranslations - request commands', () => {
const requestMethod = req.getMethod();
const requestHeaders = req.getHeaders();
const requestBody = req.getBody();
const requestName = req.getName();
test('Request method is POST', function() {
expect(req.getMethod()).to.equal('POST');

View File

@@ -16,8 +16,8 @@ describe('postmanTranslations - comment handling', () => {
});
test('should comment non-translated pm commands', () => {
const inputScript = "pm.test('random test', () => postman.variables.replaceIn('{{$guid}}'));";
const expectedOutput = "// test('random test', () => pm.variables.replaceIn('{{$guid}}'));";
const inputScript = "pm.test('random test', () => pm.cookies.get('cookieName'));";
const expectedOutput = "// test('random test', () => pm.cookies.get('cookieName'));";
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});

View File

@@ -5,55 +5,104 @@ describe('Variables Translation', () => {
it('should translate pm.variables.get', () => {
const code = 'pm.variables.get("test");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.getVar("test");');
});
it('should translate pm.variables.set', () => {
const code = 'pm.variables.set("test", "value");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setVar("test", "value");');
});
it('should translate pm.variables.has', () => {
const code = 'pm.variables.has("userId");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.hasVar("userId");');
});
it('should translate pm.variables.replaceIn', () => {
const code = 'pm.variables.replaceIn("Hello {{name}}");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.interpolate("Hello {{name}}");');
});
it('should translate pm.variables.replaceIn with variables and expressions', () => {
const code = 'const greeting = pm.variables.replaceIn("Hello {{name}}, your user id is {{userId}}");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const greeting = bru.interpolate("Hello {{name}}, your user id is {{userId}}");');
});
it('should translate pm.variables.replaceIn within complex expressions', () => {
const code = 'const url = baseUrl + pm.variables.replaceIn("/users/{{userId}}/profile");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const url = baseUrl + bru.interpolate("/users/{{userId}}/profile");');
});
it('should translate pm.variables.replaceIn with multiple nested variable references', () => {
const code = 'const template = pm.variables.replaceIn("{{prefix}}-{{env}}-{{suffix}}");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const template = bru.interpolate("{{prefix}}-{{env}}-{{suffix}}");');
});
it('should translate aliased variables.replaceIn', () => {
const code = `
const variables = pm.variables;
const message = variables.replaceIn("Welcome, {{username}}!");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const message = bru.interpolate("Welcome, {{username}}!");
`);
});
// Collection variables tests
it('should translate pm.collectionVariables.get', () => {
const code = 'pm.collectionVariables.get("apiUrl");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.getVar("apiUrl");');
});
it('should translate pm.collectionVariables.set', () => {
const code = 'pm.collectionVariables.set("token", jsonData.token);';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setVar("token", jsonData.token);');
});
it('should translate pm.collectionVariables.has', () => {
const code = 'pm.collectionVariables.has("authToken");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.hasVar("authToken");');
});
it('should translate pm.collectionVariables.unset', () => {
const code = 'pm.collectionVariables.unset("tempVar");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.deleteVar("tempVar");');
});
it('should handle pm.globals.get', () => {
const code = 'pm.globals.get("test");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.getGlobalEnvVar("test");');
});
it('should handle pm.globals.set', () => {
const code = 'pm.globals.set("test", "value");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setGlobalEnvVar("test", "value");');
});
@@ -66,6 +115,7 @@ describe('Variables Translation', () => {
const get = vars.get("test");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const has = bru.hasVar("test");
const set = bru.setVar("test", "value");
@@ -83,6 +133,7 @@ describe('Variables Translation', () => {
const unset = collVars.unset("test");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const has = bru.hasVar("test");
const set = bru.setVar("test", "value");
@@ -98,6 +149,7 @@ describe('Variables Translation', () => {
const set = globals.set("test", "value");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const get = bru.getGlobalEnvVar("test");
const set = bru.setGlobalEnvVar("test", "value");
@@ -108,6 +160,7 @@ describe('Variables Translation', () => {
it('should handle conditional expressions with variable calls', () => {
const code = 'const userStatus = pm.variables.has("userId") ? "logged-in" : "guest";';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const userStatus = bru.hasVar("userId") ? "logged-in" : "guest";');
});
@@ -148,6 +201,7 @@ describe('Variables Translation', () => {
it('should handle more complex nested expressions with variables', () => {
const code = 'pm.collectionVariables.set("fullPath", pm.environment.get("baseUrl") + pm.variables.get("endpoint"));';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setVar("fullPath", bru.getEnvVar("baseUrl") + bru.getVar("endpoint"));');
});
});

View File

@@ -36,9 +36,22 @@ const config = {
},
win: {
artifactName: '${name}_${version}_${arch}_win.${ext}',
icon: 'resources/icons/png',
certificateFile: `${process.env.WIN_CERT_FILEPATH}`,
certificatePassword: `${process.env.WIN_CERT_PASSWORD}`
icon: 'resources/icons/win/icon.ico',
target: [
{
target: 'nsis',
arch: ['x64']
}
],
sign: null,
publisherName: 'Bruno Software Inc'
},
nsis: {
oneClick: false,
allowToChangeInstallationDirectory: true,
allowElevation: true,
createDesktopShortcut: true,
createStartMenuShortcut: true
}
};

View File

@@ -3,6 +3,10 @@
"name": "bruno",
"description": "Opensource API Client for Exploring and Testing APIs",
"homepage": "https://www.usebruno.com",
"repository": {
"type": "git",
"url": "https://github.com/usebruno/bruno.git"
},
"private": true,
"main": "src/index.js",
"author": "Anoop M D <anoop.md1421@gmail.com> (https://helloanoop.com/)",

View File

@@ -14,6 +14,13 @@ const { format } = require('url');
const { BrowserWindow, app, session, Menu, ipcMain } = require('electron');
const { setContentSecurityPolicy } = require('electron-util');
if (isDev && process.env.ELECTRON_USER_DATA_PATH) {
console.debug("`ELECTRON_USER_DATA_PATH` found, modifying `userData` path: \n"
+ `\t${app.getPath("userData")} -> ${process.env.ELECTRON_USER_DATA_PATH}`);
app.setPath('userData', process.env.ELECTRON_USER_DATA_PATH);
}
const menuTemplate = require('./app/menu-template');
const { openCollection } = require('./app/collections');
const LastOpenedCollections = require('./store/last-opened-collections');

View File

@@ -309,6 +309,27 @@ function makeAxiosInstance({
},
};
// Apply proper HTTP redirect behavior based on status code
const statusCode = error.response.status;
const originalMethod = (error.config.method || 'get').toLowerCase();
// For 301, 302, 303: change method to GET unless it was HEAD
if ([301, 302, 303].includes(statusCode) && originalMethod !== 'head') {
requestConfig.method = 'get';
requestConfig.data = undefined;
delete requestConfig.headers['content-length'];
delete requestConfig.headers['Content-Length'];
delete requestConfig.headers['content-type'];
delete requestConfig.headers['Content-Type'];
timeline.push({
timestamp: new Date(),
type: 'info',
message: `Changed method from ${originalMethod.toUpperCase()} to GET for ${statusCode} redirect and removed request body`,
});
}
if (preferencesUtil.shouldSendCookies()) {
const cookieString = getCookieStringForUrl(redirectUrl);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
@@ -316,7 +337,7 @@ function makeAxiosInstance({
}
}
try {
try {
setupProxyAgents({
requestConfig,
proxyMode,

View File

@@ -341,6 +341,7 @@ const registerNetworkIpc = (mainWindow) => {
) => {
// run pre-request script
let scriptResult;
const collectionName = collection?.name
const requestScript = get(request, 'script.req');
if (requestScript?.length) {
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
@@ -353,7 +354,8 @@ const registerNetworkIpc = (mainWindow) => {
onConsoleLog,
processEnvVars,
scriptingConfig,
runRequestByItemPathname
runRequestByItemPathname,
collectionName
);
mainWindow.webContents.send('main:script-environment-update', {
@@ -447,6 +449,7 @@ const registerNetworkIpc = (mainWindow) => {
// run post-response script
const responseScript = get(request, 'script.res');
let scriptResult;
const collectionName = collection?.name
if (responseScript?.length) {
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
scriptResult = await scriptRuntime.runResponseScript(
@@ -459,7 +462,8 @@ const registerNetworkIpc = (mainWindow) => {
onConsoleLog,
processEnvVars,
scriptingConfig,
runRequestByItemPathname
runRequestByItemPathname,
collectionName
);
mainWindow.webContents.send('main:script-environment-update', {
@@ -706,6 +710,7 @@ const registerNetworkIpc = (mainWindow) => {
}
const testFile = get(request, 'tests');
const collectionName = collection?.name
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime });
const testResults = await testRuntime.runTests(
@@ -718,7 +723,8 @@ const registerNetworkIpc = (mainWindow) => {
onConsoleLog,
processEnvVars,
scriptingConfig,
runRequestByItemPathname
runRequestByItemPathname,
collectionName
);
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
@@ -1171,6 +1177,7 @@ const registerNetworkIpc = (mainWindow) => {
}
const testFile = get(request, 'tests');
const collectionName = collection?.name
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime });
const testResults = await testRuntime.runTests(
@@ -1183,7 +1190,8 @@ const registerNetworkIpc = (mainWindow) => {
onConsoleLog,
processEnvVars,
scriptingConfig,
runRequestByItemPathname
runRequestByItemPathname,
collectionName
);
if (testResults?.nextRequestName !== undefined) {

View File

@@ -301,6 +301,7 @@ const prepareRequest = async (item, collection = {}, abortController) => {
method: request.method,
url,
headers,
name: item.name,
pathParams: request?.params?.filter((param) => param.type === 'path'),
responseType: 'arraybuffer'
};

View File

@@ -1,6 +1,7 @@
const { Cookie, CookieJar } = require('tough-cookie');
const each = require('lodash/each');
const moment = require('moment');
const { isPotentiallyTrustworthyOrigin } = require('@usebruno/requests').utils;
const cookieJar = new CookieJar();
@@ -12,7 +13,9 @@ const addCookieToJar = (setCookieHeader, requestUrl) => {
};
const getCookiesForUrl = (url) => {
return cookieJar.getCookiesSync(url);
return cookieJar.getCookiesSync(url, {
secure: isPotentiallyTrustworthyOrigin(url)
});
};
const getCookieStringForUrl = (url) => {

View File

@@ -1,10 +1,10 @@
const { cloneDeep } = require('lodash');
const { interpolate } = require('@usebruno/common');
const { interpolate: _interpolate } = require('@usebruno/common');
const variableNameRegex = /^[\w-.]*$/;
class Bru {
constructor(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables) {
constructor(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName) {
this.envVariables = envVariables || {};
this.runtimeVariables = runtimeVariables || {};
this.processEnvVars = cloneDeep(processEnvVars || {});
@@ -14,6 +14,7 @@ class Bru {
this.globalEnvironmentVariables = globalEnvironmentVariables || {};
this.oauth2CredentialVariables = oauth2CredentialVariables || {};
this.collectionPath = collectionPath;
this.collectionName = collectionName;
this.runner = {
skipRequest: () => {
this.skipRequest = true;
@@ -27,10 +28,10 @@ class Bru {
};
}
_interpolate = (str) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
}
interpolate = (strOrObj) => {
if (!strOrObj) return strOrObj;
const isObj = typeof strOrObj === 'object';
const strToInterpolate = isObj ? JSON.stringify(strOrObj) : strOrObj;
const combinedVars = {
...this.globalEnvironmentVariables,
@@ -47,7 +48,8 @@ class Bru {
}
};
return interpolate(str, combinedVars);
const interpolatedStr = _interpolate(strToInterpolate, combinedVars);
return isObj ? JSON.parse(interpolatedStr) : interpolatedStr;
};
cwd() {
@@ -67,7 +69,7 @@ class Bru {
}
getEnvVar(key) {
return this._interpolate(this.envVariables[key]);
return this.interpolate(this.envVariables[key]);
}
setEnvVar(key, value) {
@@ -83,7 +85,7 @@ class Bru {
}
getGlobalEnvVar(key) {
return this._interpolate(this.globalEnvironmentVariables[key]);
return this.interpolate(this.globalEnvironmentVariables[key]);
}
setGlobalEnvVar(key, value) {
@@ -95,7 +97,7 @@ class Bru {
}
getOauth2CredentialVar(key) {
return this._interpolate(this.oauth2CredentialVariables[key]);
return this.interpolate(this.oauth2CredentialVariables[key]);
}
hasVar(key) {
@@ -110,7 +112,7 @@ class Bru {
if (variableNameRegex.test(key) === false) {
throw new Error(
`Variable name: "${key}" contains invalid characters!` +
' Names must only contain alpha-numeric characters, "-", "_", "."'
' Names must only contain alpha-numeric characters, "-", "_", "."'
);
}
@@ -121,11 +123,11 @@ class Bru {
if (variableNameRegex.test(key) === false) {
throw new Error(
`Variable name: "${key}" contains invalid characters!` +
' Names must only contain alpha-numeric characters, "-", "_", "."'
' Names must only contain alpha-numeric characters, "-", "_", "."'
);
}
return this._interpolate(this.runtimeVariables[key]);
return this.interpolate(this.runtimeVariables[key]);
}
deleteVar(key) {
@@ -141,15 +143,15 @@ class Bru {
}
getCollectionVar(key) {
return this._interpolate(this.collectionVariables[key]);
return this.interpolate(this.collectionVariables[key]);
}
getFolderVar(key) {
return this._interpolate(this.folderVariables[key]);
return this.interpolate(this.folderVariables[key]);
}
getRequestVar(key) {
return this._interpolate(this.requestVariables[key]);
return this.interpolate(this.requestVariables[key]);
}
setNextRequest(nextRequest) {
@@ -159,6 +161,10 @@ class Bru {
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
getCollectionName() {
return this.collectionName;
}
}
module.exports = Bru;

View File

@@ -17,7 +17,7 @@ class BrunoRequest {
this.method = req.method;
this.headers = req.headers;
this.timeout = req.timeout;
this.name = req.name;
/**
* We automatically parse the JSON body if the content type is JSON
* This is to make it easier for the user to access the body directly
@@ -177,6 +177,10 @@ class BrunoRequest {
getExecutionMode() {
return this.req.__bruno__executionMode;
}
getName() {
return this.req.name;
}
}
module.exports = BrunoRequest;

View File

@@ -49,14 +49,15 @@ class ScriptRuntime {
onConsoleLog,
processEnvVars,
scriptingConfig,
runRequestByItemPathname
runRequestByItemPathname,
collectionName
) {
const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
const oauth2CredentialVariables = request?.oauth2CredentialVariables || {};
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables);
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName);
const req = new BrunoRequest(request);
const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);
const moduleWhitelist = get(scriptingConfig, 'moduleWhitelist', []);
@@ -97,7 +98,7 @@ class ScriptRuntime {
};
}
if(runRequestByItemPathname) {
if (runRequestByItemPathname) {
context.bru.runRequest = runRequestByItemPathname;
}
@@ -150,7 +151,7 @@ class ScriptRuntime {
chai,
'node-fetch': fetch,
'crypto-js': CryptoJS,
'xml2js': xml2js,
xml2js: xml2js,
cheerio,
tv4,
...whitelistedModules,
@@ -183,14 +184,15 @@ class ScriptRuntime {
onConsoleLog,
processEnvVars,
scriptingConfig,
runRequestByItemPathname
runRequestByItemPathname,
collectionName
) {
const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
const oauth2CredentialVariables = request?.oauth2CredentialVariables || {};
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables);
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName);
const req = new BrunoRequest(request);
const res = new BrunoResponse(response);
const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);
@@ -233,7 +235,7 @@ class ScriptRuntime {
};
}
if(runRequestByItemPathname) {
if (runRequestByItemPathname) {
context.bru.runRequest = runRequestByItemPathname;
}

View File

@@ -69,14 +69,15 @@ class TestRuntime {
onConsoleLog,
processEnvVars,
scriptingConfig,
runRequestByItemPathname
runRequestByItemPathname,
collectionName
) {
const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
const assertionResults = request?.assertionResults || [];
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables);
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, {}, collectionName);
const req = new BrunoRequest(request);
const res = new BrunoResponse(response);
const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);

View File

@@ -17,12 +17,24 @@ const addBruShimToContext = (vm, bru) => {
vm.setProp(bruObject, 'getEnvName', getEnvName);
getEnvName.dispose();
let getCollectionName = vm.newFunction('getCollectionName', function () {
return marshallToVm(bru.getCollectionName(), vm);
});
vm.setProp(bruObject, 'getCollectionName', getCollectionName);
getCollectionName.dispose();
let getProcessEnv = vm.newFunction('getProcessEnv', function (key) {
return marshallToVm(bru.getProcessEnv(vm.dump(key)), vm);
});
vm.setProp(bruObject, 'getProcessEnv', getProcessEnv);
getProcessEnv.dispose();
let interpolate = vm.newFunction('interpolate', function (str) {
return marshallToVm(bru.interpolate(vm.dump(str)), vm);
});
vm.setProp(bruObject, 'interpolate', interpolate);
interpolate.dispose();
let hasEnvVar = vm.newFunction('hasEnvVar', function (key) {
return marshallToVm(bru.hasEnvVar(vm.dump(key)), vm);
});
@@ -151,7 +163,8 @@ const addBruShimToContext = (vm, bru) => {
let getTestResults = vm.newFunction('getTestResults', () => {
const promise = vm.newPromise();
bru.getTestResults()
bru
.getTestResults()
.then((results) => {
promise.resolve(marshallToVm(cleanJson(results), vm));
})
@@ -172,7 +185,8 @@ const addBruShimToContext = (vm, bru) => {
let getAssertionResults = vm.newFunction('getAssertionResults', () => {
const promise = vm.newPromise();
bru.getAssertionResults()
bru
.getAssertionResults()
.then((results) => {
promise.resolve(marshallToVm(cleanJson(results), vm));
})
@@ -193,7 +207,8 @@ const addBruShimToContext = (vm, bru) => {
let runRequestHandle = vm.newFunction('runRequest', (args) => {
const promise = vm.newPromise();
bru.runRequest(vm.dump(args))
bru
.runRequest(vm.dump(args))
.then((response) => {
const { status, headers, data, dataBuffer, size, statusText } = response || {};
promise.resolve(marshallToVm(cleanJson({ status, statusText, headers, data, dataBuffer, size }), vm));

View File

@@ -8,18 +8,21 @@ const addBrunoRequestShimToContext = (vm, req) => {
const headers = marshallToVm(req.getHeaders(), vm);
const body = marshallToVm(req.getBody(), vm);
const timeout = marshallToVm(req.getTimeout(), vm);
const name = marshallToVm(req.getName(), vm);
vm.setProp(reqObject, 'url', url);
vm.setProp(reqObject, 'method', method);
vm.setProp(reqObject, 'headers', headers);
vm.setProp(reqObject, 'body', body);
vm.setProp(reqObject, 'timeout', timeout);
vm.setProp(reqObject, 'name', name);
url.dispose();
method.dispose();
headers.dispose();
body.dispose();
timeout.dispose();
name.dispose();
let getUrl = vm.newFunction('getUrl', function () {
return marshallToVm(req.getUrl(), vm);
@@ -45,6 +48,12 @@ const addBrunoRequestShimToContext = (vm, req) => {
vm.setProp(reqObject, 'getAuthMode', getAuthMode);
getAuthMode.dispose();
let getName = vm.newFunction('getName', function () {
return marshallToVm(req.getName(), vm);
});
vm.setProp(reqObject, 'getName', getName);
getName.dispose();
let setMethod = vm.newFunction('setMethod', function (method) {
req.setMethod(vm.dump(method));
});

View File

@@ -163,7 +163,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
return {
auth: {
mode: auth ? auth.mode : 'none'
mode: auth?.mode || 'none'
}
};
},

View File

@@ -330,8 +330,9 @@ ${indentString(body.sparql)}
}
if (item.type === 'file') {
let filepaths = item.value || [];
let filestr = filepaths.join('|');
const filepaths = Array.isArray(item.value) ? item.value : [];
const filestr = filepaths.join('|');
const value = `@file(${filestr})`;
return `${enabled}${item.name}: ${value}${contentType}`;
}

View File

@@ -1 +1,3 @@
export { addDigestInterceptor, getOAuth2Token } from './auth';
export * as utils from './utils';

View File

@@ -0,0 +1,105 @@
const { URL } = require('node:url');
const net = require('node:net');
const isLoopbackV4 = (address) => {
// 127.0.0.0/8: first octet = 127
const octets = address.split('.');
return (
octets.length === 4
) && parseInt(octets[0], 10) === 127;
}
const isLoopbackV6 = (address) => {
// new URL(...) follows the WHATWG URL Standard
// which compresses IPv6 addresses, therefore the IPv6
// loopback address will always be compressed to '[::1]':
// https://url.spec.whatwg.org/#concept-ipv6-serializer
return (address === '::1');
}
const isIpLoopback = (address) => {
if (net.isIPv4(address)) {
return isLoopbackV4(address);
}
if (net.isIPv6(address)) {
return isLoopbackV6(address);
}
return false;
}
const isNormalizedLocalhostTLD = (host) => {
return host.toLowerCase().endsWith('.localhost');
}
const isLocalHostname = (host) => {
return host.toLowerCase() === 'localhost' ||
isNormalizedLocalhostTLD(host);
}
/**
* Removes leading and trailing square brackets if present.
* Adapted from https://github.com/chromium/chromium/blob/main/url/gurl.cc#L440-L448
*
* @param {string} host
* @returns {string}
*/
const hostNoBrackets = (host) => {
if (host.length >= 2 && host.startsWith('[') && host.endsWith(']')) {
return host.substring(1, host.length - 1);
}
return host;
}
/**
* Determines if a URL string represents a potentially trustworthy origin.
*
* A URL is considered potentially trustworthy if it:
* - Uses HTTPS, WSS or file schemes
* - Points to a loopback address (IPv4 127.0.0.0/8 or IPv6 ::1)
* - Uses localhost or *.localhost hostnames
*
* @param {string} urlString - The URL to check
* @returns {boolean}
* @see {@link https://w3c.github.io/webappsec-secure-contexts/#potentially-trustworthy-origin W3C Spec}
*/
const isPotentiallyTrustworthyOrigin = (urlString) => {
let url;
// try ... catch doubles as an opaque origin check
try {
url = new URL(urlString);
} catch (e) {
if (e instanceof TypeError && e.code === 'ERR_INVALID_URL') {
return false;
} else throw e;
}
const scheme = url.protocol.replace(':', '').toLowerCase();
const hostname = hostNoBrackets(
url.hostname
).replace(/\.+$/, '');
if (
scheme === 'https' ||
scheme === 'wss' ||
scheme === 'file' // https://w3c.github.io/webappsec-secure-contexts/#potentially-trustworthy-origin
) {
return true;
}
// If it's already an IP literal, check if it's a loopback address
if (net.isIP(hostname)) {
return isIpLoopback(hostname);
}
// RFC 6761 states that localhost names will always resolve
// to the respective IP loopback address:
// https://datatracker.ietf.org/doc/html/rfc6761#section-6.3
return isLocalHostname(hostname);
}
module.exports = {
isPotentiallyTrustworthyOrigin
};

View File

@@ -0,0 +1 @@
export * from './cookie-utils';

View File

@@ -0,0 +1,17 @@
meta {
name: getCollectionName
type: http
seq: 13
}
get {
url: {{host}}/ping
body: none
auth: inherit
}
tests {
test("Check if collection name is bruno-testbench", function () {
expect(bru.getCollectionName()).to.eql("bruno-testbench");
});
}

View File

@@ -0,0 +1,39 @@
meta {
name: interpolate
type: http
seq: 13
}
get {
url: {{host}}/ping
body: none
auth: none
}
tests {
test("should interpolate envs", function() {
const interpolated = bru.interpolate("url: {{host}}")
expect(interpolated).to.equal("url: https://testbench-sanity.usebruno.com");
});
test("should interpolate random variables", function() {
const a = bru.interpolate("{{$randomInt}}")
const b = bru.interpolate("{{$randomInt}}")
expect(a).to.not.equal(b)
});
const randomObj = {
host: "{{host}}",
int: "{{$randomInt}}",
timestamp: "{{$timestamp}}"
}
test("should interpolate objects with vars, random vars", function() {
const objA = bru.interpolate(randomObj)
const objB = bru.interpolate(randomObj)
expect(objA).to.be.an("object")
expect(objB).to.be.an("object")
expect(objA).to.not.deep.eql(objB)
});
}

View File

@@ -0,0 +1,17 @@
meta {
name: getName
type: http
seq: 11
}
get {
url: {{host}}/ping
body: none
auth: inherit
}
tests {
test("Check if request name is getName", function () {
expect(req.getName()).to.eql("getName");
});
}

View File

@@ -1,12 +1,11 @@
import { defineConfig, devices } from '@playwright/test';
const reporter: string[][string] = [['list'], ['html']];
const reporter: any[] = [['list'], ['html']];
if (process.env.CI) {
reporter.push(["github"]);
reporter.push(['github']);
}
export default defineConfig({
testDir: './e2e-tests',
fullyParallel: false,
@@ -14,8 +13,9 @@ export default defineConfig({
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? undefined : 1,
reporter,
use: {
trace: 'on-first-retry'
trace: process.env.CI ? 'on-first-retry' : 'on'
},
projects: [
@@ -24,9 +24,16 @@ export default defineConfig({
}
],
webServer: {
command: 'npm run dev:web',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI
}
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
}
]
});

View File

@@ -7,7 +7,11 @@ exports.startApp = async () => {
const app = await electron.launch({ args: [electronAppPath] });
const context = await app.context();
app.process().stdout.on('data', (data) => console.log(data.toString()));
app.process().stderr.on('data', (error) => console.error(error.toString()));
app.process().stdout.on('data', (data) => {
process.stdout.write(data.toString().replace(/^(?=.)/gm, '[Electron] |'));
});
app.process().stderr.on('data', (error) => {
process.stderr.write(error.toString().replace(/^(?=.)/gm, '[Electron] |'));
});
return { app, context };
};

View File

@@ -1,23 +1,179 @@
import { test as baseTest, ElectronApplication, Page } from '@playwright/test';
import { test as baseTest, BrowserContext, ElectronApplication, Page } from '@playwright/test';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
const { startApp } = require('./electron.ts');
const electronAppPath = path.join(__dirname, '../packages/bruno-electron');
export const test = baseTest.extend<{ page: Page }, { electronApp: ElectronApplication }>({
electronApp: [
export const test = baseTest.extend<
{
context: BrowserContext;
page: Page;
newPage: Page;
pageWithUserData: Page;
},
{
createTmpDir: (tag?: string) => Promise<string>;
launchElectronApp: (options?: { initUserDataPath?: string }) => Promise<ElectronApplication>;
electronApp: ElectronApplication;
reuseOrLaunchElectronApp: (options?: { initUserDataPath?: string }) => Promise<ElectronApplication>;
}
>({
createTmpDir: [
async ({}, use) => {
const { app: electronApp, context } = await startApp();
await use(electronApp);
await context.close();
await electronApp.close();
const dirs: string[] = [];
await use(async (tag?: string) => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `pw-${tag || ''}-`));
dirs.push(dir);
return dir;
});
await Promise.all(
dirs.map((dir) => fs.promises.rm(dir, { recursive: true, force: true, maxRetries: 10 }).catch((e) => e))
);
},
{ scope: 'worker' }
],
page: async ({ electronApp }, use) => {
launchElectronApp: [
async ({ playwright, createTmpDir }, use, workerInfo) => {
const apps: ElectronApplication[] = [];
await use(async ({ initUserDataPath } = {}) => {
const userDataPath = await createTmpDir('electron-userdata');
if (initUserDataPath) {
const replacements = {
projectRoot: path.join(__dirname, '..')
};
for (const file of await fs.promises.readdir(initUserDataPath)) {
let content = await fs.promises.readFile(path.join(initUserDataPath, file), 'utf-8');
content = content.replace(/{{(\w+)}}/g, (_, key) => {
if (replacements[key]) {
return replacements[key];
} else {
throw new Error(`\tNo replacement for {{${key}}} in ${path.join(initUserDataPath, file)}`);
}
});
await fs.promises.writeFile(path.join(userDataPath, file), content, 'utf-8');
}
}
const app = await playwright._electron.launch({
args: [electronAppPath],
env: {
...process.env,
ELECTRON_USER_DATA_PATH: userDataPath,
}
});
const { workerIndex } = workerInfo;
app.process().stdout.on('data', (data) => {
process.stdout.write(data.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`));
});
app.process().stderr.on('data', (error) => {
process.stderr.write(error.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`));
});
apps.push(app);
return app;
});
for (const app of apps) {
await app.context().close();
await app.close();
}
},
{ scope: 'worker' }
],
electronApp: [
async ({ launchElectronApp }, use) => {
const app = await launchElectronApp();
await use(app);
},
{ scope: 'worker' }
],
context: async ({ electronApp }, use, testInfo) => {
const context = await electronApp.context();
const tracingOptions = (testInfo as any)._tracing.traceOptions();
if (tracingOptions) {
try {
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
} catch (e) {}
}
await use(context);
},
page: async ({ electronApp, context }, use, testInfo) => {
const page = await electronApp.firstWindow();
await use(page);
await page.reload();
const tracingOptions = (testInfo as any)._tracing.traceOptions();
if (tracingOptions) {
const tracePath = testInfo.outputPath(`trace-${testInfo.testId}.zip`);
await context.tracing.startChunk();
await use(page);
await context.tracing.stopChunk({ path: tracePath });
await testInfo.attach('trace', { path: tracePath });
} else {
await use(page);
}
},
newPage: async ({ launchElectronApp }, use, testInfo) => {
const app = await launchElectronApp();
const context = await app.context();
const page = await app.firstWindow();
const tracingOptions = (testInfo as any)._tracing.traceOptions();
if (tracingOptions) {
const tracePath = testInfo.outputPath(`trace-${testInfo.testId}.zip`);
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
await use(page);
await context.tracing.stop({ path: tracePath });
await testInfo.attach('trace', { path: tracePath });
} else {
await use(page);
}
},
reuseOrLaunchElectronApp: [
async ({ launchElectronApp }, use, testInfo) => {
const apps: Record<string, ElectronApplication> = {};
await use(async ({ initUserDataPath } = {}) => {
const key = initUserDataPath;
if (key && apps[key]) {
return apps[key];
}
const app = await launchElectronApp({ initUserDataPath });
apps[key] = app;
return app;
});
},
{ scope: 'worker' }
],
pageWithUserData: async ({ reuseOrLaunchElectronApp }, use, testInfo) => {
const testDir = path.dirname(testInfo.file);
const initUserDataPath = path.join(testDir, 'init-user-data');
const app = await reuseOrLaunchElectronApp(
(await fs.promises.stat(initUserDataPath).catch(() => false)) ? { initUserDataPath } : {}
);
const context = await app.context();
const page = await app.firstWindow();
const tracingOptions = (testInfo as any)._tracing.traceOptions();
if (tracingOptions) {
const tracePath = testInfo.outputPath(`trace-${testInfo.testId}.zip`);
try {
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
} catch (e) {}
await context.tracing.startChunk();
await use(page);
await context.tracing.stopChunk({ path: tracePath });
await testInfo.attach('trace', { path: tracePath });
} else {
await use(page);
}
}
});
export * from '@playwright/test'
export * from '@playwright/test';