Compare commits

..

132 Commits

Author SHA1 Message Date
Anoop M D
5fc32d035f wip: working on async scripting support 2023-03-28 14:49:57 +05:30
Anoop M D
78251c530c feat: added custom assertion for chaijs for match() method 2023-03-23 21:36:35 +05:30
Anoop M D
dea95664b9 fix: fixed issue in bru cli where assertions was not being run 2023-03-23 21:35:41 +05:30
Anoop M D
fbc6e7bff5 Merge pull request #135 from dcoomber/bugfix/132-isjson-assertion
Resolve issue with to.be.json assertions Re #132
2023-03-23 14:11:55 +05:30
David Coomber
4884106aaa Removed chai-http Re #132 2023-03-22 22:12:17 +02:00
David Coomber
5c15438949 Updated plugin to be addProperty Re #132 2023-03-22 20:56:35 +02:00
Anoop M D
b53a9eaee9 Merge pull request #134 from dcoomber/bugfix/128-close-tab-hotkey
Proposed addition of CMD+W hotkey Re #128
2023-03-21 22:26:28 +05:30
David Coomber
5899ca446d Applied code review feedback Re #128 2023-03-21 17:45:26 +02:00
David Coomber
d21e7f6fb5 Added Chai.js plugin to cater for isJson assertion Re #132 2023-03-21 17:30:45 +02:00
Anoop M D
ee8a3eae8c Merge pull request #130 from dcoomber/bugfix/request-dialog-terminology
Proposed adjustment to terminology on requests
2023-03-21 01:34:19 +05:30
Anoop M D
fac5109242 Merge pull request #136 from dcoomber/bugfix/dev-docs
Corrected reference to bruno-query node script
2023-03-21 01:33:24 +05:30
David Coomber
47dfbd2a64 Corrected reference to bruno-query node script 2023-03-19 21:14:52 +02:00
David Coomber
074d72d885 Add chai-http to enable to.be.json assertions Re #132 2023-03-19 21:08:19 +02:00
David Coomber
8c29d131e2 Proposed addition of CMD+W hotkey Re #128 2023-03-19 18:38:08 +02:00
David Coomber
437044bdcd Applied code review feedback 2023-03-19 17:17:38 +02:00
Anoop M D
2120a562da chore: improved dev documentation 2023-03-19 15:41:18 +05:30
Anoop M D
04c3c2dbf1 Merge pull request #133 from bharathbdev/bugfix/assertion-result-issue
Bugfix/assertion result issue
2023-03-19 14:57:19 +05:30
David Coomber
1d03e1d5ea Adjusted terminology on requests (REST, GraphQL, Form URL encoded) 2023-03-18 10:53:49 +02:00
Bharath B
2b174e1c60 added the indentation 2023-03-18 13:43:16 +05:30
Bharath B
7a2b32069e bugfix/assertion-result-issue fixed the issue related to assertions still displayed in Tests tab after deletion#121 2023-03-18 12:06:20 +05:30
Anoop M D
a9e6c3a35c feat: support for importing insomnia collections (#74) 2023-03-05 00:19:03 +05:30
Anoop M D
e6a754b933 Merge pull request #108 from ajaishankar/feature/object-predicate
filter shortcut for scalar properties
2023-02-27 21:32:59 +05:30
Ajai Shankar
ee4509f037 feat(query): simple object predicate for scalar properties 2023-02-26 12:56:11 -06:00
Anoop M D
c04f0e7a71 chore: added docs link 2023-02-26 17:26:06 +05:30
Anoop M D
2f52ce4c71 feat: windows codesigning 2023-02-26 17:22:30 +05:30
Anoop M D
b1edaba1c6 fix: fixed issue in react hook order during search (#106) 2023-02-26 14:51:55 +05:30
Anoop M D
3f6fcdd582 Merge branch 'main' of github.com:usebruno/bruno 2023-02-23 12:37:47 +05:30
Anoop M D
c745786b1c chore: release v0.10.1 2023-02-23 12:37:34 +05:30
Anoop M D
9e30c7b440 feat: vars and asserts in gql request UI 2023-02-23 11:42:25 +05:30
Anoop M D
b87cc7ccae Merge pull request #104 from dcoomber/feature/update-development-doc
Added snippet to development.md
2023-02-22 23:48:07 +05:30
David Coomber
1595d736f2 Added snippet to assist in deleting node_modules / package-lock.json in dir structure 2023-02-22 20:08:48 +02:00
Anoop M D
b38c25ca70 feat: mac signinging and notarization 2023-02-22 19:15:37 +05:30
Anoop M D
f22858219b fix: fixed issue while deleting empty query params (#93) 2023-02-22 02:42:59 +05:30
Anoop M D
8044286b80 feat: integrated assert runtime for ui 2023-02-22 02:25:02 +05:30
Anoop M D
34a2e23dc6 feat: assertion operator in UI 2023-02-22 01:20:07 +05:30
Anoop M D
224b8c3cc4 feat: vars runtime in UI 2023-02-21 15:26:12 +05:30
Anoop M D
d58e92205b feat: assertions implementation in UI 2023-02-21 14:04:05 +05:30
Anoop M D
925af1f26f feat: vars implementation in UI 2023-02-21 13:05:51 +05:30
Anoop M D
d07744d5c2 chore: deleted unused chrome extension package 2023-02-21 00:22:20 +05:30
Anoop M D
5efb18ad63 chore: npm publish 2023-02-21 00:00:10 +05:30
Anoop M D
9cfb54ee9f Merge pull request #91 from ajaishankar/feature/get-supercharged
res.get : deep object navigation and filtering
2023-02-20 19:35:10 +05:30
Anoop M D
4c9d22d1e0 Merge pull request #99 from dcoomber/bugfix/createcollection-tab-order
Correct the tab order on the CreateCollection modal
2023-02-20 14:53:42 +05:30
Ajai Shankar
c5d43cc9e6 chore: add bruno-query test/build to github workflows 2023-02-19 23:51:47 -06:00
Ajai Shankar
8300830a95 Merge branch 'main' into feature/get-supercharged 2023-02-19 23:38:27 -06:00
Ajai Shankar
2dfc972930 feat: res default to bruno query 2023-02-19 23:35:49 -06:00
Ajai Shankar
4fdfdaf2cb feat(query): bruno-query package 2023-02-19 22:48:34 -06:00
David Coomber
a1385ba1e2 Location 'inputRef' was overriding the same on Name 2023-02-18 13:02:59 +02:00
Anoop M D
15804ac293 chore: updated bruno schema version 2023-02-17 14:20:36 +05:30
Anoop M D
0244b2e1d6 Merge pull request #98 from dcoomber/bugfix/contributing
Removed redundant instructions in docs
2023-02-17 14:06:05 +05:30
Anoop M D
7e70d05dc8 chore: bumped version to v0.9.4 2023-02-17 13:58:02 +05:30
Anoop M D
e60b06e4a4 chore: npm publish 2023-02-17 13:57:06 +05:30
Anoop M D
17ded5de4c fix: fixed issues with creating patch requests 2023-02-17 13:55:23 +05:30
Anoop M D
e1b97643bd fix: fixed issue with separators in electron menu #92 2023-02-17 13:47:13 +05:30
Anoop M D
8103554545 fix: disable app reload #94 2023-02-17 13:39:05 +05:30
Anoop M D
a425b42615 feat: ux improvements in environment settings 2023-02-17 13:36:22 +05:30
Anoop M D
b14f867811 chore: publish npm packages 2023-02-17 12:59:30 +05:30
Anoop M D
013abeaa80 fix: fixed parser issue related to env variables #97 2023-02-17 12:56:48 +05:30
David Coomber
9d3762702f Removed redundant local dev environment instructions 2023-02-16 21:22:35 +02:00
Anoop M D
cac9f9aef4 chore: updated dev docs 2023-02-16 01:59:07 +05:30
Anoop M D
2b63368f2c chore: updated dev docs 2023-02-16 01:58:11 +05:30
Anoop M D
acd980ffc6 chore: updated dev docs 2023-02-16 01:56:38 +05:30
Anoop M D
1a175e4449 chore: added bruno-js unit tests to github workflows 2023-02-13 13:11:56 +05:30
Ajai Shankar
209f30998e test: minor 2023-02-12 19:47:14 -06:00
Ajai Shankar
e777eed00d feat(get): supercharged res getter 2023-02-12 17:27:54 -06:00
Anoop M D
15fc24679c chore: release v0.9.3 2023-02-12 22:05:38 +05:30
Anoop M D
48d26c05d9 fix: fix windows filepath issues #89 2023-02-12 21:59:20 +05:30
Anoop M D
9d395ded33 Merge branch 'main' of github.com:usebruno/bruno 2023-02-12 21:46:58 +05:30
Anoop M D
943e74c327 fix: fix windows filepath issues #89 2023-02-12 21:46:42 +05:30
Anoop M D
b852d1cc52 Merge pull request #90 from ajaishankar/feature/expression-eval
Compiled and cached expressions
2023-02-11 22:57:17 +05:30
Ajai Shankar
3d22f77226 feat(eval): handle globals 2023-02-11 08:57:27 -06:00
Ajai Shankar
429ca4093c test: expression cache 2023-02-10 23:34:46 -06:00
Ajai Shankar
a4f757ee87 minor: clear expression cache before and after test 2023-02-10 22:24:28 -06:00
Ajai Shankar
df4f322024 feat(eval): compiled and cached expressions 2023-02-10 21:55:05 -06:00
Anoop M D
ddd39e630d chore: release v0.9.2 2023-02-09 17:56:25 +05:30
Anoop M D
ef8e8bf637 feat: improved error messaging while attempting to create duplicate requests and folders 2023-02-09 17:55:42 +05:30
Anoop M D
7405fa9709 chore: publish npm packages 2023-02-09 17:33:21 +05:30
Anoop M D
242fcac2d3 feat: bru lang now allows empty urls 2023-02-09 17:31:37 +05:30
Anoop M D
efd15838aa fix: fixed bug where gql imports were not working 2023-02-09 15:11:05 +05:30
Anoop M D
c55f9d42da feat: better error handling in bru cli 2023-02-08 18:27:33 +05:30
Anoop M D
2f32f7024e chore: npm publish 2023-02-08 18:19:26 +05:30
Anoop M D
aff6499478 feat: assert tab allows any valid js code as keys 2023-02-08 18:17:30 +05:30
Anoop M D
45ed47ff90 chore: added website link in readme 2023-02-08 16:47:52 +05:30
Anoop M D
27c6c1349a chore: release v0.9.1 2023-02-08 16:27:33 +05:30
Anoop M D
c78ffa3a80 chore: npm publish 2023-02-08 16:26:11 +05:30
Anoop M D
6b2d335ade fix: fixed string length comparision bug in assert runtime 2023-02-08 05:10:14 +05:30
Anoop M D
837e39d870 fix: fixed bugs in bru cli related to gql requests 2023-02-08 04:13:22 +05:30
Anoop M D
67643c4c48 chore: publishing to npm 2023-02-08 03:45:27 +05:30
Anoop M D
2b384656b6 feat: bruno can run a collection by specifying "bru run" 2023-02-08 03:27:27 +05:30
Anoop M D
411c06f4cb feat: bruno cli can not run a folder recursively 2023-02-08 02:53:55 +05:30
Anoop M D
03fa46d8b3 feat: bruno cli can sort the requests being run 2023-02-08 01:43:16 +05:30
Anoop M D
d0f2eb27bc feat: bru cli can now run all requests inside a directory 2023-02-08 01:25:15 +05:30
Anoop M D
1b9ec05a58 feat: assert runtime 2023-02-08 01:13:21 +05:30
Anoop M D
3f74178c81 feat: bru cli - specify env + completed vars runtime 2023-02-07 21:01:35 +05:30
Anoop M D
78ca6c5e96 feat: error messages for reserved file and folder names in bruno 2023-02-07 19:36:34 +05:30
Anoop M D
5f59a16090 feat: run again, run collection and close functionality in collection runner 2023-02-07 19:00:17 +05:30
Anoop M D
3805cef0c4 feat: auto focus newly created environment 2023-02-07 18:11:34 +05:30
Anoop M D
3c1a6ca71e chore: release v0.9.0 2023-02-07 08:15:13 +05:30
Anoop M D
d2227b2b05 feat: renamed vars:req,res as vars:pre-request,post-response 2023-02-07 05:13:14 +05:30
Anoop M D
dc03b6a761 feat: renamed script:req,res as script:pre-request,post-response 2023-02-07 04:39:23 +05:30
Anoop M D
e22f164cbc feat: simple vars runtime is working! 2023-02-07 04:33:25 +05:30
Anoop M D
580d681e0a fix: fixing issues in bru cli 2023-02-07 02:58:44 +05:30
Anoop M D
89b721d726 fix: fixed issues around body mode conversion 2023-02-07 02:50:15 +05:30
Anoop M D
1110a4edda fix: fixed gql related issues 2023-02-07 02:12:23 +05:30
Anoop M D
f69332d9c3 feat: automagically migrate users of bru v1 to bru v2 2023-02-07 01:19:32 +05:30
Anoop M D
6947860204 feat: made bru lang parser more robust to optional newlines and whitespaces 2023-02-07 01:18:18 +05:30
Anoop M D
963b0c257f feat: integrate new env model of bru lang 2023-02-06 23:22:48 +05:30
Anoop M D
33f8900705 chore: cleanup unused files 2023-02-06 23:02:47 +05:30
Anoop M D
22a14aa67a feat: making request and response scripts work 2023-02-06 23:00:50 +05:30
Anoop M D
60c96f7d27 feat: script and vars are segmented at req and res levels separately 2023-02-06 21:18:36 +05:30
Anoop M D
c8de57aa51 chore: restructure bru js package 2023-02-06 15:24:34 +05:30
Anoop M D
827c480689 feat: bru cli prints test results 2023-02-06 14:52:22 +05:30
Anoop M D
1c869013c6 feat: cli runner can now run a single request 2023-02-06 03:40:13 +05:30
Anoop M D
404a516fef chore: bruno cli accept request filename 2023-02-06 02:57:59 +05:30
Anoop M D
e26075060e chore: bru cli - added package deps 2023-02-06 02:34:27 +05:30
Anoop M D
c524f40ab2 feat: bru cli init 2023-02-06 02:27:22 +05:30
Anoop M D
3e563ea126 feat: bru lang - support body default as json 2023-02-06 01:27:08 +05:30
Anoop M D
a0cb53445f feat: bru lang - supporting ~ @ identifiers 2023-02-05 23:13:18 +05:30
Anoop M D
84bd603e11 feat: bru lang - parse env files 2023-02-05 19:06:48 +05:30
Anoop M D
c3236d4eb1 feat: making changes in app to use the new bru lang format 2023-02-05 01:25:36 +05:30
Anoop M D
4a4208f272 feat: bru lang - jsonToBru functionality 2023-02-05 00:27:18 +05:30
Anoop M D
d24f1a1054 refactor: organized v1 and v2 versions inside bru-lang 2023-02-04 20:11:33 +05:30
Anoop M D
86200a8f11 Merge pull request #85 from usebruno/feature/bru-lang-parser
Bru Lang Parser
2023-02-04 20:02:34 +05:30
Anoop M D
cf0ede1a83 chore: using fixtures to cleanup test file 2023-02-04 16:11:29 +05:30
Anoop M D
342a39bcb4 chore: renamed test files 2023-02-04 16:06:32 +05:30
Anoop M D
e7d332c7d7 feat: bru lang - support for vars, asserts and docs 2023-02-04 16:02:27 +05:30
Anoop M D
689d886e74 feat: bru lang - support for body type parsing 2023-02-04 06:06:02 +05:30
Anoop M D
7a8e5198ff feat: bru lang - support disabled headers parsing 2023-02-03 23:27:06 +05:30
Anoop M D
118ceacf46 feat: bru lang - allow parsing empty header values 2023-02-03 23:02:16 +05:30
Anoop M D
a21615a5fb feat: bru lang - keys can support any char except whitespace, values can have any char except newline 2023-02-03 21:44:07 +05:30
Anoop M D
2ee2e270b0 feat: brun lang - ast updates, tests for headers and script tags 2023-02-03 21:08:40 +05:30
Anoop M D
9d6ba4691c feat: bru lang tests, scripts and headers using ohm 2023-02-03 08:01:44 +05:30
Anoop M D
104bd272f9 feat: bru lang - simple parser 2023-02-03 04:39:45 +05:30
160 changed files with 6089 additions and 1006 deletions

View File

@@ -15,9 +15,15 @@ jobs:
node-version: 16
- name: Install dependencies
run: npm i --legacy-peer-deps
- name: Test Package bruno-query
run: npm run test --workspace=packages/bruno-query
- name: Build Package bruno-query
run: npm run build --workspace=packages/bruno-query
- name: Test Package bruno-lang
run: npm run test --workspace=packages/bruno-lang
- name: Test Package bruno-schema
run: npm run test --workspace=packages/bruno-schema
- name: Test Package bruno-app
run: npm run test --workspace=packages/bruno-app
- name: Test Package bruno-js
run: npm run test --workspace=packages/bruno-js

View File

@@ -23,25 +23,7 @@ You would need [Node v14.x or the latest LTS version](https://nodejs.org/en/) an
### Lets start coding
```bash
# clone and cd into bruno
# use Node 14.x, Npm 8.x
# Install deps (note that we use npm workspaces)
npm i
# run next app
npm run dev:web
# run electron app
# neededonly if you want to test changes related to electron app
# please note that both web and electron use the same code
# if it works in web, then it should also work in electron
npm run dev:electron
# open in browser
open http://localhost:3000
```
Please reference [development.md](docs/development.md) for instructions on running the local development environment.
### Raising Pull Request

View File

@@ -1,40 +1,53 @@
## development
## Development
Bruno is deing developed as a desktop app. You need to load the app by running the nextjs app in one terminal and then run the electron app in another terminal.
### Dependencies
* NodeJS v18
###
### Local Development
```bash
# use nodejs 18 version
nvm use
# install deps
npm i
npm i --legacy-peer-deps
# run next app
npm run dev --workspace=packages/bruno-app
# build graphql docs
# note: you can for now ignore the error thrown while building the graphql docs
npm run build:graphql-docs
# run electron app
npm run dev --workspace=packages/bruno-electron
# build bruno query
npm run build:bruno-query
# build next app
npm run build --workspace=packages/bruno-app
# run next app (terminal 1)
npm run dev:web
# run electron app (terminal 2)
npm run dev:electron
```
### fix
### 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.
### testing
```shell
# Delete node_modules in sub-directories
find ./ -type d -name "node_modules" -print0 | while read -d $'\0' dir; do
rm -rf "$dir"
done
# Delete package-lock in sub-directories
find . -type f -name "package-lock.json" -delete
```
### Testing
```bash
# bruno-schema
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-schema
npm test --workspace=packages/bruno-lang
```

View File

@@ -4,8 +4,10 @@
"workspaces": [
"packages/bruno-app",
"packages/bruno-electron",
"packages/bruno-cli",
"packages/bruno-tauri",
"packages/bruno-schema",
"packages/bruno-query",
"packages/bruno-js",
"packages/bruno-lang",
"packages/bruno-testbench",
@@ -13,17 +15,19 @@
],
"devDependencies": {
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@playwright/test": "^1.27.1",
"jest": "^29.2.0",
"randomstring": "^1.2.2"
"randomstring": "^1.2.2",
"ts-jest": "^29.0.5"
},
"scripts": {
"dev:web": "npm run dev --workspace=packages/bruno-app",
"build:web": "npm run build --workspace=packages/bruno-app",
"prettier:web": "npm run prettier --workspace=packages/bruno-app",
"dev:electron": "npm run dev --workspace=packages/bruno-electron",
"build:bruno-query": "npm run build --workspace=packages/bruno-query",
"build:graphql-docs": "npm run build --workspace=packages/bruno-graphql-docs",
"build:chrome-extension": "./scripts/build-chrome-extension.sh",
"build:electron": "./scripts/build-electron.sh",
"test:e2e": "npx playwright test",
"test:report": "npx playwright show-report"
@@ -31,4 +35,4 @@
"overrides": {
"rollup": "3.2.5"
}
}
}

View File

@@ -18,7 +18,7 @@
"@tabler/icons": "^1.46.0",
"@tippyjs/react": "^4.2.6",
"@usebruno/graphql-docs": "0.1.0",
"@usebruno/schema": "0.2.0",
"@usebruno/schema": "0.3.1",
"axios": "^0.26.0",
"classnames": "^2.3.1",
"codemirror": "^5.65.2",
@@ -50,7 +50,6 @@
"react-redux": "^7.2.6",
"react-tooltip": "^5.5.2",
"sass": "^1.46.0",
"split-on-first": "^3.0.0",
"styled-components": "^5.3.3",
"tailwindcss": "^2.2.19",
"yup": "^0.32.11"

View File

@@ -1,23 +0,0 @@
import { get, post, put } from './base';
// not used. kept as a placeholder for reference while implementing license key stuff
const AuthApi = {
whoami: () => get('auth/v1/user/whoami'),
signup: (params) => post('auth/v1/user/signup', params),
login: (params) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window.require('electron');
ipcRenderer
.invoke('bruno-account-request', {
data: params,
method: 'POST',
url: `${process.env.NEXT_PUBLIC_BRUNO_SERVER_API}/auth/v1/user/login`
})
.then(resolve)
.catch(reject);
});
}
};
export default AuthApi;

View File

@@ -1,30 +0,0 @@
import axios from 'axios';
const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_GRAFNODE_SERVER_API
});
apiClient.interceptors.request.use(
(config) => {
const headers = {
'Content-Type': 'application/json'
};
return {
...config,
headers: headers
};
},
(error) => Promise.reject(error)
);
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
return Promise.reject(error.response ? error.response.data : error);
}
);
const { get, post, put, delete: destroy } = apiClient;
export { get, post, put, destroy };

View File

@@ -1,6 +1,6 @@
import React from 'react';
import Modal from 'components/Modal/index';
import { IconSpeakerphone, IconBrandTwitter, IconBrandGithub, IconBrandDiscord } from '@tabler/icons';
import { IconSpeakerphone, IconBrandTwitter, IconBrandGithub, IconBrandDiscord, IconBook } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const BrunoSupport = ({ onClose }) => {
@@ -8,6 +8,12 @@ const BrunoSupport = ({ onClose }) => {
<StyledWrapper>
<Modal size="sm" title={'Support'} handleCancel={onClose} hideFooter={true}>
<div className="collection-options">
<div className="mt-2">
<a href="https://docs.usebruno.com" target="_blank" className="flex items-end">
<IconBook size={18} strokeWidth={2} />
<span className="label ml-2">Documentation</span>
</a>
</div>
<div className="mt-2">
<a href="https://github.com/usebruno/bruno/issues" target="_blank" className="flex items-end">
<IconSpeakerphone size={18} strokeWidth={2} />

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState, forwardRef, useRef } from 'react';
import { findEnvironmentInCollection } from 'utils/collections';
import usePrevious from 'hooks/usePrevious';
import EnvironmentDetails from './EnvironmentDetails';
import CreateEnvironment from '../CreateEnvironment/index';
import StyledWrapper from './StyledWrapper';
@@ -9,14 +10,36 @@ const EnvironmentList = ({ collection }) => {
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
const [openCreateModal, setOpenCreateModal] = useState(false);
const envUids = environments ? environments.map((env) => env.uid) : [];
const prevEnvUids = usePrevious(envUids);
useEffect(() => {
if(selectedEnvironment) {
return;
}
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
if(environment) {
setSelectedEnvironment(environment);
} else {
setSelectedEnvironment(environments && environments.length ? environments[0] : null);
}
}, [collection, environments]);
}, [collection, environments, selectedEnvironment]);
useEffect(() => {
// check env add
if (prevEnvUids && prevEnvUids.length && envUids.length > prevEnvUids.length) {
const newEnv = environments.find((env) => !prevEnvUids.includes(env.uid));
if(newEnv){
setSelectedEnvironment(newEnv);
}
}
// check env delete
if (prevEnvUids && prevEnvUids.length && envUids.length < prevEnvUids.length) {
setSelectedEnvironment(environments && environments.length ? environments[0] : null);
}
}, [envUids, environments, prevEnvUids]);
if (!selectedEnvironment) {
return null;
@@ -31,7 +54,11 @@ const EnvironmentList = ({ collection }) => {
{environments &&
environments.length &&
environments.map((env) => (
<div key={env.uid} className={selectedEnvironment.uid === env.uid ? 'environment-item active' : 'environment-item'} onClick={() => setSelectedEnvironment(env)}>
<div
key={env.uid}
className={selectedEnvironment.uid === env.uid ? 'environment-item active' : 'environment-item'}
onClick={() => setSelectedEnvironment(env)}
>
<span>{env.name}</span>
</div>
))}

View File

@@ -0,0 +1,68 @@
import React from 'react';
/**
* Assertion operators
*
* eq : equal to
* neq : not equal to
* gt : greater than
* gte : greater than or equal to
* lt : less than
* lte : less than or equal to
* in : in
* notIn : not in
* contains : contains
* notContains : not contains
* length : length
* matches : matches
* notMatches : not matches
* startsWith : starts with
* endsWith : ends with
* between : between
* isEmpty : is empty
* isNull : is null
* isUndefined : is undefined
* isDefined : is defined
* isTruthy : is truthy
* isFalsy : is falsy
* isJson : is json
* isNumber : is number
* isString : is string
* isBoolean : is boolean
*/
const AssertionOperator = ({ operator, onChange }) => {
const operators = [
'eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn',
'contains', 'notContains', 'length', 'matches', 'notMatches',
'startsWith', 'endsWith', 'between', 'isEmpty', 'isNull', 'isUndefined',
'isDefined', 'isTruthy', 'isFalsy', 'isJson', 'isNumber', 'isString', 'isBoolean'
];
const handleChange = (e) => {
onChange(e.target.value);
};
const getLabel = (operator) => {
switch(operator) {
case 'eq':
return 'equals';
case 'neq':
return 'notEquals';
default:
return operator;
}
};
return (
<select value={operator} onChange={handleChange} className="mousetrap">
{operators.map((operator) => (
<option key={operator} value={operator}>
{getLabel(operator)}
</option>
))}
</select>
);
};
export default AssertionOperator;

View File

@@ -0,0 +1,162 @@
import React from 'react';
import { IconTrash } from '@tabler/icons';
import SingleLineEditor from 'components/SingleLineEditor';
import AssertionOperator from '../AssertionOperator';
import { useTheme } from 'providers/Theme';
/**
* Assertion operators
*
* eq : equal to
* neq : not equal to
* gt : greater than
* gte : greater than or equal to
* lt : less than
* lte : less than or equal to
* in : in
* notIn : not in
* contains : contains
* notContains : not contains
* length : length
* matches : matches
* notMatches : not matches
* startsWith : starts with
* endsWith : ends with
* between : between
* isEmpty : is empty
* isNull : is null
* isUndefined : is undefined
* isDefined : is defined
* isTruthy : is truthy
* isFalsy : is falsy
* isJson : is json
* isNumber : is number
* isString : is string
* isBoolean : is boolean
*/
const parseAssertionOperator = (str = '') => {
if(!str || typeof str !== 'string' || !str.length) {
return {
operator: 'eq',
value: str
};
}
const operators = [
'eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn',
'contains', 'notContains', 'length', 'matches', 'notMatches',
'startsWith', 'endsWith', 'between', 'isEmpty', 'isNull', 'isUndefined',
'isDefined', 'isTruthy', 'isFalsy', 'isJson', 'isNumber', 'isString', 'isBoolean'
];
const unaryOperators = [
'isEmpty', 'isNull', 'isUndefined', 'isDefined', 'isTruthy', 'isFalsy', 'isJson', 'isNumber', 'isString', 'isBoolean'
];
const [operator, ...rest] = str.trim().split(' ');
const value = rest.join(' ');
if(unaryOperators.includes(operator)) {
return {
operator,
value: ''
};
}
if(operators.includes(operator)) {
return {
operator,
value
};
}
return {
operator: 'eq',
value: str
};
};
const isUnaryOperator = (operator) => {
const unaryOperators = [
'isEmpty', 'isNull', 'isUndefined', 'isDefined', 'isTruthy', 'isFalsy', 'isJson', 'isNumber', 'isString', 'isBoolean'
];
return unaryOperators.includes(operator);
};
const AssertionRow = ({
item, collection, assertion, handleAssertionChange, handleRemoveAssertion,
onSave, handleRun
}) => {
const { storedTheme } = useTheme();
const {
operator,
value
} = parseAssertionOperator(assertion.value);
return (
<tr key={assertion.uid}>
<td>
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={assertion.name}
className="mousetrap"
onChange={(e) => handleAssertionChange(e, assertion, 'name')}
/>
</td>
<td>
<AssertionOperator
operator={operator}
onChange={(op) => handleAssertionChange({
target: {
value: `${op} ${value}`
}
}, assertion, 'value')}
/>
</td>
<td>
{!isUnaryOperator(operator) ? (
<SingleLineEditor
value={value}
theme={storedTheme}
readOnly={true}
onSave={onSave}
onChange={(newValue) => handleAssertionChange({
target: {
value: newValue
}
}, assertion, 'value')}
onRun={handleRun}
collection={collection}
/>
) : (
<input
type="text"
className='cursor-default'
disabled
/>
)}
</td>
<td>
<div className="flex items-center">
<input
type="checkbox"
checked={assertion.enabled}
className="mr-3 mousetrap"
onChange={(e) => handleAssertionChange(e, assertion, 'enabled')}
/>
<button onClick={() => handleRemoveAssertion(assertion)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
);
};
export default AssertionRow;

View File

@@ -0,0 +1,56 @@
import styled from 'styled-components';
const Wrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
font-weight: 600;
table-layout: fixed;
thead,
td {
border: 1px solid ${(props) => props.theme.table.border};
}
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: 0.8125rem;
user-select: none;
}
td {
padding: 6px 10px;
&:nth-child(1) {
width: 30%;
}
&:nth-child(4) {
width: 70px;
}
}
}
.btn-add-assertion {
font-size: 0.8125rem;
}
input[type='text'] {
width: 100%;
border: solid 1px transparent;
outline: none !important;
background-color: inherit;
&:focus {
outline: none !important;
border: solid 1px transparent;
}
}
input[type='checkbox'] {
cursor: pointer;
position: relative;
top: 1px;
}
`;
export default Wrapper;

View File

@@ -0,0 +1,96 @@
import React from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { useDispatch } from 'react-redux';
import { addAssertion, updateAssertion, deleteAssertion } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import AssertionRow from './AssertionRow';
import StyledWrapper from './StyledWrapper';
const Assertions = ({ item, collection }) => {
const dispatch = useDispatch();
const assertions = item.draft ? get(item, 'draft.request.assertions') : get(item, 'request.assertions');
const handleAddAssertion = () => {
dispatch(
addAssertion({
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleAssertionChange = (e, _assertion, type) => {
const assertion = cloneDeep(_assertion);
switch (type) {
case 'name': {
assertion.name = e.target.value;
break;
}
case 'value': {
assertion.value = e.target.value;
break;
}
case 'enabled': {
assertion.enabled = e.target.checked;
break;
}
}
dispatch(
updateAssertion({
assertion: assertion,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const handleRemoveAssertion = (assertion) => {
dispatch(
deleteAssertion({
assertUid: assertion.uid,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
return (
<StyledWrapper className="w-full">
<table>
<thead>
<tr>
<td>Expr</td>
<td>Operator</td>
<td>Value</td>
<td></td>
</tr>
</thead>
<tbody>
{assertions && assertions.length
? assertions.map((assertion) => {
return (
<AssertionRow
key={assertion.uid}
assertion={assertion}
item={item}
collection={collection}
handleAssertionChange={handleAssertionChange}
handleRemoveAssertion={handleRemoveAssertion}
onSave={onSave}
handleRun={handleRun}
/>
);
})
: null}
</tbody>
</table>
<button className="btn-add-assertion text-link pr-2 py-3 mt-2 select-none" onClick={handleAddAssertion}>
+ Add Assertion
</button>
</StyledWrapper>
);
};
export default Assertions;

View File

@@ -8,6 +8,8 @@ import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
import QueryEditor from 'components/RequestPane/QueryEditor';
import GraphQLVariables from 'components/RequestPane/GraphQLVariables';
import RequestHeaders from 'components/RequestPane/RequestHeaders';
import Vars from 'components/RequestPane/Vars';
import Assertions from 'components/RequestPane/Assertions';
import Script from 'components/RequestPane/Script';
import Tests from 'components/RequestPane/Tests';
import { useTheme } from 'providers/Theme';
@@ -91,6 +93,12 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
case 'headers': {
return <RequestHeaders item={item} collection={collection} />;
}
case 'vars': {
return <Vars item={item} collection={collection} />;
}
case 'assert': {
return <Assertions item={item} collection={collection} />;
}
case 'script': {
return <Script item={item} collection={collection} />;
}
@@ -130,9 +138,15 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Headers
</div>
<div className={getTabClassname('vars')} role="tab" onClick={() => selectTab('vars')}>
Vars
</div>
<div className={getTabClassname('script')} role="tab" onClick={() => selectTab('script')}>
Script
</div>
<div className={getTabClassname('assert')} role="tab" onClick={() => selectTab('assert')}>
Assert
</div>
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
Tests
</div>
@@ -143,7 +157,7 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
) : null}
{!isSchemaLoading && !schema ? <IconDownload size={18} strokeWidth={1.5}/> : null }
{!isSchemaLoading && schema ? <IconRefresh size={18} strokeWidth={1.5}/> : null }
<span className='ml-1'>{schema ? 'Schema' : 'Load Schema'}</span>
<span className='ml-1'>Schema</span>
</div>
<div
className='flex items-center cursor-pointer hover:underline ml-2'

View File

@@ -32,7 +32,7 @@ const useGraphqlSchema = (endpoint, environment) => {
setSchema(buildClientSchema(s.data));
setIsLoading(false);
localStorage.setItem(localStorageKey, JSON.stringify(s.data));
toast.success('Graphql Schema loaded successfully');
toast.success('GraphQL Schema loaded successfully');
} else {
return Promise.reject(new Error('An error occurred while introspecting schema'));
}
@@ -40,7 +40,7 @@ const useGraphqlSchema = (endpoint, environment) => {
.catch((err) => {
setIsLoading(false);
setError(err);
toast.error('Error occured while loading Graphql Schema');
toast.error('Error occured while loading GraphQL Schema');
});
};

View File

@@ -7,6 +7,8 @@ import QueryParams from 'components/RequestPane/QueryParams';
import RequestHeaders from 'components/RequestPane/RequestHeaders';
import RequestBody from 'components/RequestPane/RequestBody';
import RequestBodyMode from 'components/RequestPane/RequestBody/RequestBodyMode';
import Vars from 'components/RequestPane/Vars';
import Assertions from 'components/RequestPane/Assertions';
import Script from 'components/RequestPane/Script';
import Tests from 'components/RequestPane/Tests';
import StyledWrapper from './StyledWrapper';
@@ -36,6 +38,12 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
case 'headers': {
return <RequestHeaders item={item} collection={collection} />;
}
case 'vars': {
return <Vars item={item} collection={collection} />;
}
case 'assert': {
return <Assertions item={item} collection={collection} />;
}
case 'script': {
return <Script item={item} collection={collection} />;
}
@@ -67,7 +75,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex items-center tabs" role="tablist">
<div className={getTabClassname('params')} role="tab" onClick={() => selectTab('params')}>
Params
Query
</div>
<div className={getTabClassname('body')} role="tab" onClick={() => selectTab('body')}>
Body
@@ -75,9 +83,15 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Headers
</div>
<div className={getTabClassname('vars')} role="tab" onClick={() => selectTab('vars')}>
Vars
</div>
<div className={getTabClassname('script')} role="tab" onClick={() => selectTab('script')}>
Script
</div>
<div className={getTabClassname('assert')} role="tab" onClick={() => selectTab('assert')}>
Assert
</div>
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
Tests
</div>
@@ -89,7 +103,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
</div>
) : null}
</div>
<section className="flex w-full mt-5">{getTabPanel(focusedTab.requestPaneTab)}</section>
<section className={`flex w-full ${['script', 'vars'].includes(focusedTab.requestPaneTab) ? '' : 'mt-5'}`}>{getTabPanel(focusedTab.requestPaneTab)}</section>
</StyledWrapper>
);
};

View File

@@ -68,7 +68,7 @@ const QueryParams = ({ item, collection }) => {
<table>
<thead>
<tr>
<td>Key</td>
<td>Name</td>
<td>Value</td>
<td></td>
</tr>

View File

@@ -52,7 +52,7 @@ const RequestBodyMode = ({ item, collection }) => {
onModeChange('formUrlEncoded');
}}
>
Form Url Encoded
Form URL Encoded
</div>
<div className="label-item font-medium">Raw</div>
<div

View File

@@ -65,7 +65,7 @@ const RequestHeaders = ({ item, collection }) => {
<table>
<thead>
<tr>
<td>Key</td>
<td>Name</td>
<td>Value</td>
<td></td>
</tr>

View File

@@ -2,8 +2,11 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
div.CodeMirror {
/* todo: find a better way */
height: calc(100vh - 220px);
height: inherit;
}
div.title {
color: var(--color-tab-inactive);
}
`;

View File

@@ -2,20 +2,21 @@ import React from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import { updateRequestScript } from 'providers/ReduxStore/slices/collections';
import { updateRequestScript, updateResponseScript } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
const Script = ({ item, collection }) => {
const dispatch = useDispatch();
const script = item.draft ? get(item, 'draft.request.script') : get(item, 'request.script');
const requestScript = item.draft ? get(item, 'draft.request.script.req') : get(item, 'request.script.req');
const responseScript = item.draft ? get(item, 'draft.request.script.res') : get(item, 'request.script.res');
const {
storedTheme
} = useTheme();
const onEdit = (value) => {
const onRequestScriptEdit = (value) => {
dispatch(
updateRequestScript({
script: value,
@@ -25,19 +26,43 @@ const Script = ({ item, collection }) => {
);
};
const onResponseScriptEdit = (value) => {
dispatch(
updateResponseScript({
script: value,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onRun = () => dispatch(sendRequest(item, collection.uid));
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
return (
<StyledWrapper className="w-full">
<CodeEditor
collection={collection} value={script || ''}
theme={storedTheme}
onEdit={onEdit}
mode='javascript'
onRun={onRun}
onSave={onSave}
/>
<StyledWrapper className="w-full flex flex-col">
<div className='flex-1 mt-2'>
<div className='mb-1 title text-xs'>Pre Request</div>
<CodeEditor
collection={collection} value={requestScript || ''}
theme={storedTheme}
onEdit={onRequestScriptEdit}
mode='javascript'
onRun={onRun}
onSave={onSave}
/>
</div>
<div className='flex-1 mt-6'>
<div className='mt-1 mb-1 title text-xs'>Post Response</div>
<CodeEditor
collection={collection} value={responseScript || ''}
theme={storedTheme}
onEdit={onResponseScriptEdit}
mode='javascript'
onRun={onRun}
onSave={onSave}
/>
</div>
</StyledWrapper>
);
};

View File

@@ -0,0 +1,9 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.title {
color: var(--color-tab-inactive);
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,56 @@
import styled from 'styled-components';
const Wrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
font-weight: 600;
table-layout: fixed;
thead,
td {
border: 1px solid ${(props) => props.theme.table.border};
}
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: 0.8125rem;
user-select: none;
}
td {
padding: 6px 10px;
&:nth-child(1) {
width: 30%;
}
&:nth-child(3) {
width: 70px;
}
}
}
.btn-add-var {
font-size: 0.8125rem;
}
input[type='text'] {
width: 100%;
border: solid 1px transparent;
outline: none !important;
background-color: inherit;
&:focus {
outline: none !important;
border: solid 1px transparent;
}
}
input[type='checkbox'] {
cursor: pointer;
position: relative;
top: 1px;
}
`;
export default Wrapper;

View File

@@ -0,0 +1,145 @@
import React from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { addVar, updateVar, deleteVar } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import Tooltip from 'components/Tooltip';
import StyledWrapper from './StyledWrapper';
const VarsTable = ({ item, collection, vars, varType }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const handleAddVar = () => {
dispatch(
addVar({
type: varType,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleVarChange = (e, v, type) => {
const _var = cloneDeep(v);
switch (type) {
case 'name': {
_var.name = e.target.value;
break;
}
case 'value': {
_var.value = e.target.value;
break;
}
case 'enabled': {
_var.enabled = e.target.checked;
break;
}
}
dispatch(
updateVar({
type: varType,
var: _var,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const handleRemoveVar = (_var) => {
dispatch(
deleteVar({
type: varType,
varUid: _var.uid,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
return (
<StyledWrapper className="w-full">
<table>
<thead>
<tr>
<td>Name</td>
{ varType === 'request' ? (
<td>
<div className='flex items-center'>
<span>Value</span>
<Tooltip text="You can write any valid JS Template Literal here" tooltipId="request-var"/>
</div>
</td>
) : (
<td>
<div className='flex items-center'>
<span>Expr</span>
<Tooltip text="You can write any valid JS expression here" tooltipId="response-var"/>
</div>
</td>
)}
<td></td>
</tr>
</thead>
<tbody>
{vars && vars.length
? vars.map((_var, index) => {
return (
<tr key={_var.uid}>
<td>
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={_var.name}
className="mousetrap"
onChange={(e) => handleVarChange(e, _var, 'name')}
/>
</td>
<td>
<SingleLineEditor
value={_var.value}
theme={storedTheme}
onSave={onSave}
onChange={(newValue) => handleVarChange({
target: {
value: newValue
}
}, _var, 'value')}
onRun={handleRun}
collection={collection}
/>
</td>
<td>
<div className="flex items-center">
<input
type="checkbox"
checked={_var.enabled}
className="mr-3 mousetrap"
onChange={(e) => handleVarChange(e, _var, 'enabled')}
/>
<button onClick={() => handleRemoveVar(_var)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
);
})
: null}
</tbody>
</table>
<button className="btn-add-var text-link pr-2 py-3 mt-2 select-none" onClick={handleAddVar}>
+ Add
</button>
</StyledWrapper>
);
};
export default VarsTable;

View File

@@ -0,0 +1,56 @@
import React from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import { updateRequestScript, updateResponseScript } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import VarsTable from './VarsTable';
import StyledWrapper from './StyledWrapper';
const Vars = ({ item, collection }) => {
const dispatch = useDispatch();
const requestVars = item.draft ? get(item, 'draft.request.vars.req') : get(item, 'request.vars.req');
const responseVars = item.draft ? get(item, 'draft.request.vars.res') : get(item, 'request.vars.res');
const {
storedTheme
} = useTheme();
const onRequestScriptEdit = (value) => {
dispatch(
updateRequestScript({
script: value,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onResponseScriptEdit = (value) => {
dispatch(
updateResponseScript({
script: value,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onRun = () => dispatch(sendRequest(item, collection.uid));
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
return (
<StyledWrapper className="w-full flex flex-col">
<div className='flex-1 mt-2'>
<div className='mb-1 title text-xs'>Pre Request</div>
<VarsTable item={item} collection={collection} vars={requestVars} varType='request'/>
</div>
<div className='flex-1'>
<div className='mt-1 mb-1 title text-xs'>Post Response</div>
<VarsTable item={item} collection={collection} vars={responseVars} varType='response'/>
</div>
</StyledWrapper>
);
};
export default Vars;

View File

@@ -7,10 +7,10 @@ const StyledWrapper = styled.div`
.test-failure {
color: ${(props) => props.theme.colors.text.danger};
}
.error-message {
color: ${(props) => props.theme.colors.text.muted};
}
.error-message {
color: ${(props) => props.theme.colors.text.muted};
}
`;

View File

@@ -1,8 +1,10 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
const TestResults = ({ results }) => {
if (!results || !results.length) {
const TestResults = ({ results, assertionResults }) => {
results = results || [];
assertionResults = assertionResults || [];
if (!results.length && !assertionResults.length) {
return (
<div className="px-3">
No tests found
@@ -13,6 +15,9 @@ const TestResults = ({ results }) => {
const passedTests = results.filter((result) => result.status === 'pass');
const failedTests = results.filter((result) => result.status === 'fail');
const passedAssertions = assertionResults.filter((result) => result.status === 'pass');
const failedAssertions = assertionResults.filter((result) => result.status === 'fail');
return (
<StyledWrapper className='flex flex-col px-3'>
<div className="py-2 font-medium test-summary">
@@ -39,6 +44,31 @@ const TestResults = ({ results }) => {
</li>
))}
</ul>
<div className="py-2 font-medium test-summary">
Assertions ({assertionResults.length}/{assertionResults.length}), Passed: {passedAssertions.length}, Failed: {failedAssertions.length}
</div>
<ul className="">
{assertionResults.map((result) => (
<li key={result.uid} className="py-1">
{result.status === 'pass' ? (
<span className="test-success">
&#x2714;&nbsp; {result.lhsExpr}: {result.rhsExpr}
</span>
) : (
<>
<span className="test-failure">
&#x2718;&nbsp; {result.lhsExpr}: {result.rhsExpr}
</span>
<br />
<span className="error-message pl-8">
{result.error}
</span>
</>
)}
</li>
))}
</ul>
</StyledWrapper>
);
};

View File

@@ -1,23 +1,31 @@
import React from 'react';
const TestResultsLabel = ({ results }) => {
if(!results || !results.length) {
const TestResultsLabel = ({ results, assertionResults }) => {
results = results || [];
assertionResults = assertionResults || [];
if(!results.length && !assertionResults.length) {
return 'Tests';
}
const numberOfTests = results.length;
const numberOfFailedTests = results.filter(result => result.status === 'fail').length;
const numberOfAssertions = assertionResults.length;
const numberOfFailedAssertions = assertionResults.filter(result => result.status === 'fail').length;
const totalNumberOfTests = numberOfTests + numberOfAssertions;
const totalNumberOfFailedTests = numberOfFailedTests + numberOfFailedAssertions;
return (
<div className='flex items-center'>
<div>Tests</div>
{numberOfFailedTests ? (
{totalNumberOfFailedTests ? (
<sup className='sups some-tests-failed ml-1 font-medium'>
{numberOfFailedTests}
{totalNumberOfFailedTests}
</sup>
) : (
<sup className='sups all-tests-passed ml-1 font-medium'>
{numberOfTests}
{totalNumberOfTests}
</sup>
)}
</div>

View File

@@ -50,7 +50,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
return <Timeline request={item.requestSent} response={item.response}/>;
}
case 'tests': {
return <TestResults results={item.testResults} />;
return <TestResults results={item.testResults} assertionResults={item.assertionResults} />;
}
default: {
@@ -103,7 +103,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
Timeline
</div>
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
<TestResultsLabel results={item.testResults} />
<TestResultsLabel results={item.testResults} assertionResults={item.assertionResults} />
</div>
{!isLoading ? (
<div className="flex flex-grow justify-end items-center">

View File

@@ -1,18 +1,27 @@
import React, { useState, useEffect } from 'react';
import path from 'path';
import { useDispatch } from 'react-redux';
import { get, each, cloneDeep } from 'lodash';
import { findItemInCollection } from 'utils/collections';
import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/actions';
import { closeCollectionRunner } from 'providers/ReduxStore/slices/collections';
import { findItemInCollection, getTotalRequestCountInCollection } from 'utils/collections';
import { IconRefresh, IconCircleCheck, IconCircleX, IconCheck, IconX, IconRun } from '@tabler/icons';
import slash from 'utils/common/slash';
import ResponsePane from './ResponsePane';
import StyledWrapper from './StyledWrapper';
const getRelativePath = (fullPath, pathname) => {
// convert to unix style path
fullPath = slash(fullPath);
pathname = slash(pathname);
let relativePath = path.relative(fullPath, pathname);
const { dir, name } = path.parse(relativePath);
return path.join(dir, name);
}
export default function RunnerResults({collection}) {
const dispatch = useDispatch();
const [selectedItem, setSelectedItem] = useState(null);
useEffect(() => {
@@ -23,6 +32,7 @@ export default function RunnerResults({collection}) {
const collectionCopy = cloneDeep(collection);
const items = cloneDeep(get(collection, 'runnerResult.items', []));
const runnerInfo = get(collection, 'runnerResult.info', {});
each(items, (item) => {
const info = findItemInCollection(collectionCopy, item.uid);
@@ -40,11 +50,61 @@ export default function RunnerResults({collection}) {
} else {
item.testStatus = 'pass';
}
if(item.assertionResults) {
const failed = item.assertionResults.filter((result) => result.status === 'fail');
item.assertionStatus = failed.length ? 'fail' : 'pass';
} else {
item.assertionStatus = 'pass';
}
}
});
const passedRequests = items.filter((item) => item.status !== "error" && item.testStatus === 'pass');
const failedRequests = items.filter((item) => item.status !== "error" && item.testStatus === 'fail');
const runCollection = () => {
dispatch(runCollectionFolder(collection.uid, null, true));
};
const runAgain = () => {
dispatch(runCollectionFolder(collection.uid, runnerInfo.folderUid, runnerInfo.isRecursive));
};
const closeRunner = () => {
dispatch(closeCollectionRunner({
collectionUid: collection.uid,
}));
};
const totalRequestsInCollection = getTotalRequestCountInCollection(collectionCopy);
const passedRequests = items.filter((item) => {
return item.status !== "error" && item.testStatus === 'pass' && item.assertionStatus === 'pass';
});
const failedRequests = items.filter((item) => {
return item.status !== "error" && item.testStatus === 'fail' || item.assertionStatus === 'fail';
});
if(!items || !items.length) {
return (
<StyledWrapper className='px-4'>
<div className='font-medium mt-6 title flex items-center'>
Runner
<IconRun size={20} strokeWidth={1.5} className='ml-2'/>
</div>
<div className='mt-6'>
You have <span className='font-medium'>{totalRequestsInCollection}</span> requests in this collection.
</div>
<button type="submit" className="submit btn btn-sm btn-secondary mt-6" onClick={runCollection}>
Run Collection
</button>
<button className="submit btn btn-sm btn-close mt-6 ml-3" onClick={closeRunner}>
Close
</button>
</StyledWrapper>
);
}
return (
<StyledWrapper className='px-4'>
@@ -91,7 +151,7 @@ export default function RunnerResults({collection}) {
<ul className="pl-8">
{item.testResults ? item.testResults.map((result) => (
<li key={result.uid} className="py-1">
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
<IconCheck size={18} strokeWidth={2} className="mr-2"/>
@@ -110,11 +170,45 @@ export default function RunnerResults({collection}) {
)}
</li>
)): null}
{item.assertionResults ? item.assertionResults.map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
<IconCheck size={18} strokeWidth={2} className="mr-2"/>
{result.lhsExpr}: {result.rhsExpr}
</span>
) : (
<>
<span className="test-failure flex items-center">
<IconX size={18} strokeWidth={2} className="mr-2"/>
{result.lhsExpr}: {result.rhsExpr}
</span>
<span className="error-message pl-8 text-xs">
{result.error}
</span>
</>
)}
</li>
)): null}
</ul>
</div>
</div>
);
})}
{runnerInfo.status === 'ended' ? (
<div className="mt-2 mb-4">
<button type="submit" className="submit btn btn-sm btn-secondary mt-6" onClick={runAgain}>
Run Again
</button>
<button type="submit" className="submit btn btn-sm btn-secondary mt-6 ml-3" onClick={runCollection}>
Run Collection
</button>
<button className="btn btn-sm btn-close mt-6 ml-3" onClick={closeRunner}>
Close
</button>
</div>
) : null}
</div>
<div className='flex flex-1' style={{width: '50%'}}>
{selectedItem ? (

View File

@@ -1,4 +1,5 @@
import React, { useRef, useEffect } from 'react';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import Modal from 'components/Modal';
@@ -19,8 +20,13 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
name: Yup.string().min(1, 'must be atleast 1 characters').max(50, 'must be 50 characters or less').required('name is required')
}),
onSubmit: (values) => {
dispatch(cloneItem(values.name, item.uid, collection.uid));
onClose();
dispatch(cloneItem(values.name, item.uid, collection.uid))
.then(() => {
onClose();
})
.catch((err) => {
toast.error(err ? err.message : 'An error occured while cloning the request')
});
}
});

View File

@@ -55,12 +55,6 @@ const Collection = ({ collection, searchText }) => {
dispatch(collectionClicked(collection.uid));
};
if (searchText && searchText.length) {
if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) {
return null;
}
}
const handleExportClick = () => {
const collectionCopy = cloneDeep(collection);
exportCollection(transformCollectionToSaveToIdb(collectionCopy));
@@ -80,6 +74,12 @@ const Collection = ({ collection, searchText }) => {
})
});
if (searchText && searchText.length) {
if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) {
return null;
}
}
// we need to sort request items by seq property
const sortRequestItems = (items = []) => {
return items.sort((a, b) => a.seq - b.seq);

View File

@@ -84,7 +84,6 @@ const CreateCollection = ({ onClose }) => {
id="collection-folder-name"
type="text"
name="collectionFolderName"
ref={inputRef}
className="block textbox mt-2 w-full"
onChange={formik.handleChange}
autoComplete="off"

View File

@@ -1,6 +1,7 @@
import React from 'react';
import importBrunoCollection from 'utils/importers/bruno-collection';
import importPostmanCollection from 'utils/importers/postman-collection';
import importInsomniaCollection from 'utils/importers/insomnia-collection';
import { toastError } from 'utils/common/error';
import Modal from 'components/Modal';
@@ -21,6 +22,14 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
.catch((err) => toastError(err, 'Postman Import collection failed'));
};
const handleImportInsomniaCollection = () => {
importInsomniaCollection()
.then((collection) => {
handleSubmit(collection);
})
.catch((err) => toastError(err, 'Insomnia Import collection failed'));
};
return (
<Modal size="sm" title="Import Collection" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
<div>
@@ -36,6 +45,12 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
>
Postman Collection
</div>
<div
className='text-link hover:underline cursor-pointer mt-2'
onClick={handleImportInsomniaCollection}
>
Insomnia Collection
</div>
</div>
</Modal>
);

View File

@@ -15,12 +15,24 @@ const NewFolder = ({ collection, item, onClose }) => {
folderName: ''
},
validationSchema: Yup.object({
folderName: Yup.string().min(1, 'must be atleast 1 characters').max(50, 'must be 50 characters or less').required('name is required')
folderName: Yup.string()
.min(1, 'must be atleast 1 characters')
.required('name is required')
.test({
name: 'folderName',
message: 'The folder name "environments" at the root of the collection is reserved in bruno',
test:(value) => {
if(item && item.uid) {
return true;
}
return value && !(value.trim().toLowerCase().includes('environments'))
}
})
}),
onSubmit: (values) => {
dispatch(newFolder(values.folderName, collection.uid, item ? item.uid : null))
.then(() => onClose())
.catch(() => toast.error('An error occured while adding the request'));
.catch((err) => toast.error(err ? err.message : 'An error occured while adding the request'));
}
});

View File

@@ -24,7 +24,14 @@ const NewRequest = ({ collection, item, isEphermal, onClose }) => {
requestMethod: 'GET'
},
validationSchema: Yup.object({
requestName: Yup.string().min(1, 'must be atleast 1 characters').max(50, 'must be 50 characters or less').required('name is required')
requestName: Yup.string()
.min(1, 'must be atleast 1 characters')
.required('name is required')
.test({
name: 'requestName',
message: 'The request name "index" is reserved in bruno',
test: value => value && !(value.trim().toLowerCase().includes('index')),
})
}),
onSubmit: (values) => {
if (isEphermal) {
@@ -49,7 +56,7 @@ const NewRequest = ({ collection, item, isEphermal, onClose }) => {
);
onClose();
})
.catch(() => toast.error('An error occured while adding the request'));
.catch((err) => toast.error(err ? err.message : 'An error occured while adding the request'));
} else {
dispatch(
newHttpRequest({
@@ -62,7 +69,7 @@ const NewRequest = ({ collection, item, isEphermal, onClose }) => {
})
)
.then(() => onClose())
.catch(() => toast.error('An error occured while adding the request'));
.catch((err) => toast.error(err ? err.message : 'An error occured while adding the request'));
}
}
});
@@ -95,7 +102,7 @@ const NewRequest = ({ collection, item, isEphermal, onClose }) => {
checked={formik.values.requestType === 'http-request'}
/>
<label htmlFor="http-request" className="ml-1 cursor-pointer select-none">
Http
HTTP
</label>
<input
@@ -111,7 +118,7 @@ const NewRequest = ({ collection, item, isEphermal, onClose }) => {
checked={formik.values.requestType === 'graphql-request'}
/>
<label htmlFor="graphql-request" className="ml-1 cursor-pointer select-none">
Graphql
GraphQL
</label>
</div>
</div>
@@ -138,7 +145,7 @@ const NewRequest = ({ collection, item, isEphermal, onClose }) => {
<div className="mt-4">
<label htmlFor="request-url" className="block font-semibold">
Url
URL
</label>
<div className="flex items-center mt-2 ">

View File

@@ -117,7 +117,7 @@ const Sidebar = () => {
</GitHubButton>
)}
</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v0.8.1</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v0.10.2</div>
</div>
</div>
</div>

View File

@@ -45,6 +45,8 @@ const StyledWrapper = styled.div`
.CodeMirror-line {
color: ${(props) => props.theme.text};
padding-left: 0;
padding-right: 0;
}
}

View File

@@ -102,7 +102,7 @@ class SingleLineEditor extends Component {
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
this.cachedValue = this.props.value;
this.editor.setValue(this.props.value);
this.editor.setValue(this.props.value || '');
}
this.ignoreChangeEvent = false;
}

View File

@@ -4,7 +4,7 @@ import { Tooltip as ReactTooltip } from 'react-tooltip';
const Tooltip = ({ text, tooltipId }) => {
return (
<>
<svg tabindex="-1" id={tooltipId} xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" className="inline-block ml-2 cursor-pointer" viewBox="0 0 16 16" style={{marginTop: 1}}>
<svg tabIndex="-1" id={tooltipId} xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" className="inline-block ml-2 cursor-pointer" viewBox="0 0 16 16" style={{marginTop: 1}}>
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286zm1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94z"/>
</svg>

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions';
import { IconBrandGithub, IconPlus, IconUpload, IconFolders, IconSpeakerphone } from '@tabler/icons';
import { IconBrandGithub, IconPlus, IconUpload, IconFolders, IconSpeakerphone, IconBook } from '@tabler/icons';
import Bruno from 'components/Bruno';
import CreateCollection from 'components/Sidebar/CreateCollection';
@@ -74,15 +74,18 @@ const Welcome = () => {
<div className="uppercase font-semibold heading mt-10 pt-6">Links</div>
<div className="mt-4 flex flex-col collection-options select-none">
<div className="flex items-center mt-2">
<a href="https://docs.usebruno.com" target="_blank" className="inline-flex items-center">
<IconBook size={18} strokeWidth={2} />
<span className="label ml-2">Documentation</span>
</a>
</div>
<div className="mt-2">
<a href="https://github.com/usebruno/bruno/issues" target="_blank" className="inline-flex items-center">
<IconSpeakerphone size={18} strokeWidth={2} />
<span className="label ml-2">Report Issues</span>
</a>
</div>
{/* <div className="flex items-center mt-2">
<IconBook size={18} strokeWidth={2}/><span className="label ml-2">Docs</span>
</div> */}
<div className="mt-2">
<a href="https://github.com/usebruno/bruno" target="_blank" className="flex items-center">
<IconBrandGithub size={18} strokeWidth={2} />

View File

@@ -43,7 +43,11 @@ const GlobalStyle = createGlobalStyle`
.btn-close {
color: ${(props) => props.theme.button.close.color};
background: ${(props) => props.theme.button.close.bg};
border: solid 1px ${(props) => props.theme.button.close.border};;
border: solid 1px ${(props) => props.theme.button.close.border};
&.btn-border {
border: solid 1px #696969;
}
&:hover,
&:focus {

View File

@@ -0,0 +1,13 @@
import { useRef, useEffect } from 'react';
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value; //assign the value of ref to the argument
},[value]); //this code will run when the value of 'value' changes
return ref.current; //in the end, return the current ref value.
}
export default usePrevious;

View File

@@ -10,6 +10,7 @@ import {
requestSentEvent,
requestQueuedEvent,
testResultsEvent,
assertionResultsEvent,
scriptEnvironmentUpdateEvent,
collectionRenamedEvent,
runFolderEvent
@@ -33,6 +34,10 @@ const useCollectionTreeSync = () => {
};
const _collectionTreeUpdated = (type, val) => {
if(window.__IS_DEV__) {
console.log(type);
console.log(val);
}
if (type === 'addDir') {
dispatch(
collectionAddDirectoryEvent({
@@ -107,6 +112,10 @@ const useCollectionTreeSync = () => {
dispatch(testResultsEvent(val));
};
const _assertionResults = (val) => {
dispatch(assertionResultsEvent(val));
};
const _collectionRenamed = (val) => {
dispatch(collectionRenamedEvent(val));
};
@@ -125,8 +134,9 @@ const useCollectionTreeSync = () => {
const removeListener6 = ipcRenderer.on('main:script-environment-update', _scriptEnvironmentUpdate);
const removeListener7 = ipcRenderer.on('main:http-request-queued', _httpRequestQueued);
const removeListener8 = ipcRenderer.on('main:test-results', _testResults);
const removeListener9 = ipcRenderer.on('main:collection-renamed', _collectionRenamed);
const removeListener10 = ipcRenderer.on('main:run-folder-event', _runFolderEvent);
const removeListener9 = ipcRenderer.on('main:assertion-results', _assertionResults);
const removeListener10 = ipcRenderer.on('main:collection-renamed', _collectionRenamed);
const removeListener11 = ipcRenderer.on('main:run-folder-event', _runFolderEvent);
return () => {
removeListener1();
@@ -139,6 +149,7 @@ const useCollectionTreeSync = () => {
removeListener8();
removeListener9();
removeListener10();
removeListener11();
};
}, [isElectron]);
};

View File

@@ -1,62 +0,0 @@
import React, { useEffect, useReducer } from 'react';
import { useRouter } from 'next/router';
import AuthApi from 'api/auth';
import reducer from './reducer';
const AuthContext = React.createContext();
const initialState = {
isLoading: true,
lastStateTransition: null,
currentUser: null
};
export const AuthProvider = (props) => {
const router = useRouter();
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
AuthApi.whoami()
.then((response) => {
let data = response.data;
dispatch({
type: 'WHOAMI_SUCCESS',
user: {
id: data.id,
name: data.name,
username: data.username
}
});
})
.catch((error) => {
dispatch({
type: 'WHOAMI_ERROR',
error: error
});
});
}, []);
useEffect(() => {
if (state.lastStateTransition === 'LOGIN_SUCCESS') {
router.push('/');
}
if (state.lastStateTransition === 'WHOAMI_ERROR') {
// Todo: decide action
// router.push('/login');
}
}, [state]);
return <AuthContext.Provider value={[state, dispatch]} {...props} />;
};
export const useAuth = () => {
const context = React.useContext(AuthContext);
if (context === undefined) {
throw new Error(`useAuth must be used within a AuthProvider`);
}
return context;
};
export default AuthProvider;

View File

@@ -1,43 +0,0 @@
import produce from 'immer';
const reducer = (state, action) => {
switch (action.type) {
case 'WHOAMI_SUCCESS': {
return produce(state, (draft) => {
draft.isLoading = false;
draft.currentUser = action.user;
draft.lastStateTransition = 'WHOAMI_SUCCESS';
});
}
case 'WHOAMI_ERROR': {
return produce(state, (draft) => {
draft.isLoading = false;
draft.currentUser = null;
draft.lastStateTransition = 'WHOAMI_ERROR';
});
}
case 'LOGIN_SUCCESS': {
return produce(state, (draft) => {
draft.isLoading = false;
draft.currentUser = action.user;
draft.lastStateTransition = 'LOGIN_SUCCESS';
});
}
case 'LOGOUT_SUCCESS': {
return produce(state, (draft) => {
draft.isLoading = false;
draft.currentUser = null;
draft.lastStateTransition = 'LOGOUT_SUCCESS';
});
}
default: {
return state;
}
}
};
export default reducer;

View File

@@ -10,6 +10,7 @@ import NewRequest from 'components/Sidebar/NewRequest';
import BrunoSupport from 'components/BrunoSupport';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
export const HotkeysContext = React.createContext();
@@ -144,6 +145,23 @@ export const HotkeysProvider = (props) => {
};
}, [setShowNewRequestModal]);
// close tab hotkey
useEffect(() => {
Mousetrap.bind(['command+w', 'ctrl+w'], (e) => {
dispatch(
closeTabs({
tabUids: [activeTabUid]
})
);
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind(['command+w', 'ctrl+w']);
};
}, [activeTabUid]);
return (
<HotkeysContext.Provider {...props} value="hotkey">
{showBrunoSupportModal && <BrunoSupport onClose={() => setShowBrunoSupportModal(false)} />}

View File

@@ -1,6 +1,7 @@
import path from 'path';
import toast from 'react-hot-toast';
import trim from 'lodash/trim';
import find from 'lodash/find';
import get from 'lodash/get';
import filter from 'lodash/filter';
import { uuid } from 'utils/common';
@@ -17,13 +18,15 @@ import {
findEnvironmentInCollection,
isItemARequest,
isItemAFolder,
refreshUidsInItem,
refreshUidsInItem
} from 'utils/collections';
import { collectionSchema, itemSchema, environmentSchema, environmentsSchema } from '@usebruno/schema';
import { waitForNextTick } from 'utils/common';
import { getDirectoryName } from 'utils/common/platform';
import { sendNetworkRequest, cancelNetworkRequest } from 'utils/network';
import {
updateLastAction,
resetRunResults,
requestCancelled,
responseReceived,
@@ -191,7 +194,7 @@ export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getS
.then(() => resolve())
.catch((error) => reject(error));
} else {
return reject(new Error('folder with same name already exists'));
return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
}
} else {
const currentItem = findItemInCollection(collection, itemUid);
@@ -206,7 +209,7 @@ export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getS
.then(() => resolve())
.catch((error) => reject(error));
} else {
return reject(new Error('folder with same name already exists'));
return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
}
} else {
return reject(new Error('unable to find parent folder'));
@@ -230,14 +233,14 @@ export const renameItem = (newName, itemUid, collectionUid) => (dispatch, getSta
return reject(new Error('Unable to locate item'));
}
const dirname = path.dirname(item.pathname);
const dirname = getDirectoryName(item.pathname);
let newPathname = '';
if (item.type === 'folder') {
newPathname = `${dirname}${PATH_SEPARATOR}${trim(newName)}`;
newPathname = path.join(dirname, trim(newName));
} else {
const filename = resolveRequestFilename(newName);
newPathname = `${dirname}${PATH_SEPARATOR}${filename}`;
newPathname = path.join(dirname, filename);
}
const { ipcRenderer } = window;
@@ -284,13 +287,13 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat
.then(resolve)
.catch(reject);
} else {
return reject(new Error(`${requestName} already exists in collection`));
return reject(new Error('Duplicate request names are not allowed under the same folder'));
}
} else {
const reqWithSameNameExists = find(parentItem.items, (i) => i.type !== 'folder' && trim(i.filename) === trim(filename));
if (!reqWithSameNameExists) {
const dirname = path.dirname(item.pathname);
const fullName = `${dirname}${PATH_SEPARATOR}${filename}`;
const dirname = getDirectoryName(item.pathname);
const fullName = path.join(dirname, filename);
const { ipcRenderer } = window;
const requestItems = filter(parentItem.items, (i) => i.type !== 'folder');
itemToSave.seq = requestItems ? (requestItems.length + 1) : 1;
@@ -301,7 +304,7 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat
.then(resolve)
.catch(reject);
} else {
return reject(new Error(`${requestName} already exists in the folder`));
return reject(new Error('Duplicate request names are not allowed under the same folder'));
}
}
});
@@ -543,7 +546,7 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
ipcRenderer.invoke('renderer:new-request', fullName, item).then(resolve).catch(reject);
} else {
return reject(new Error(`${requestName} already exists in collection`));
return reject(new Error('Duplicate request names are not allowed under the same folder'));
}
} else {
const currentItem = findItemInCollection(collection, itemUid);
@@ -557,7 +560,7 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
ipcRenderer.invoke('renderer:new-request', fullName, item).then(resolve).catch(reject);
} else {
return reject(new Error(`${requestName} already exists in the folder`));
return reject(new Error('Duplicate request names are not allowed under the same folder'));
}
}
}
@@ -574,6 +577,13 @@ export const addEnvironment = (name, collectionUid) => (dispatch, getState) => {
ipcRenderer
.invoke('renderer:create-environment', collection.pathname, name)
.then(dispatch(updateLastAction({
collectionUid,
lastAction: {
type: 'ADD_ENVIRONMENT',
payload: name
}
})))
.then(resolve)
.catch(reject);
});

View File

@@ -8,7 +8,7 @@ import filter from 'lodash/filter';
import each from 'lodash/each';
import cloneDeep from 'lodash/cloneDeep';
import { createSlice } from '@reduxjs/toolkit';
import splitOnFirst from 'split-on-first';
import { splitOnFirst } from 'utils/url';
import {
findCollectionByUid,
findCollectionByPathname,
@@ -23,7 +23,7 @@ import {
areItemsTheSameExceptSeqUpdate
} from 'utils/collections';
import { parseQueryParams, stringifyQueryParams } from 'utils/url';
import { getSubdirectoriesFromRoot } from 'utils/common/platform';
import { getSubdirectoriesFromRoot, getDirectoryName } from 'utils/common/platform';
const PATH_SEPARATOR = path.sep;
@@ -38,6 +38,14 @@ export const collectionsSlice = createSlice({
createCollection: (state, action) => {
const collectionUids = map(state.collections, (c) => c.uid);
const collection = action.payload;
// last action is used to track the last action performed on the collection
// this is optional
// this is used in scenarios where we want to know the last action performed on the collection
// and take some extra action based on that
// for example, when a env is created, we want to auto select it the env modal
collection.lastAction = null;
collapseCollection(collection);
addDepth(collection.items);
if (!collectionUids.includes(collection.uid)) {
@@ -54,13 +62,12 @@ export const collectionsSlice = createSlice({
removeCollection: (state, action) => {
state.collections = filter(state.collections, (c) => c.uid !== action.payload.collectionUid);
},
addEnvironment: (state, action) => {
const { environment, collectionUid } = action.payload;
updateLastAction: (state, action) => {
const { collectionUid, lastAction } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
collection.environments = collection.environments || [];
collection.environments.push(environment);
collection.lastAction = lastAction;
}
},
collectionUnlinkEnvFileEvent: (state, action) => {
@@ -176,7 +183,7 @@ export const collectionsSlice = createSlice({
}
},
scriptEnvironmentUpdateEvent: (state, action) => {
const { collectionUid, environment, collectionVariables } = action.payload;
const { collectionUid, envVariables, collectionVariables } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
@@ -184,7 +191,7 @@ export const collectionsSlice = createSlice({
const activeEnvironment = findEnvironmentInCollection(collection, activeEnvironmentUid);
if (activeEnvironment) {
forOwn(environment, (value, key) => {
forOwn(envVariables, (value, key) => {
const variable = find(activeEnvironment.variables, (v) => v.name === key);
if (variable) {
@@ -194,7 +201,6 @@ export const collectionsSlice = createSlice({
}
collection.collectionVariables = collectionVariables;
}
},
requestCancelled: (state, action) => {
@@ -652,7 +658,23 @@ export const collectionsSlice = createSlice({
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.script = action.payload.script;
item.draft.request.script = item.draft.request.script || {};
item.draft.request.script.req = action.payload.script;
}
}
},
updateResponseScript: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.script = item.draft.request.script || {};
item.draft.request.script.res = action.payload.script;
}
}
},
@@ -684,12 +706,159 @@ export const collectionsSlice = createSlice({
}
}
},
addAssertion: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.assertions = item.draft.request.assertions || [];
item.draft.request.assertions.push({
uid: uuid(),
name: '',
value: '',
enabled: true
});
}
}
},
updateAssertion: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
const assertion = item.draft.request.assertions.find((a) => a.uid === action.payload.assertion.uid);
if (assertion) {
assertion.name = action.payload.assertion.name;
assertion.value = action.payload.assertion.value;
assertion.enabled = action.payload.assertion.enabled;
}
}
}
},
deleteAssertion: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.assertions = item.draft.request.assertions.filter((a) => a.uid !== action.payload.assertUid);
}
}
},
addVar: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const type = action.payload.type;
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
if(type === 'request') {
item.draft.request.vars = item.draft.request.vars || {};
item.draft.request.vars.req = item.draft.request.vars.req || [];
item.draft.request.vars.req.push({
uid: uuid(),
name: '',
value: '',
local: false,
enabled: true
});
} else if(type === 'response') {
item.draft.request.vars = item.draft.request.vars || {};
item.draft.request.vars.res = item.draft.request.vars.res || [];
item.draft.request.vars.res.push({
uid: uuid(),
name: '',
value: '',
local: false,
enabled: true
});
}
}
}
},
updateVar: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const type = action.payload.type;
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
if(type === 'request') {
item.draft.request.vars = item.draft.request.vars || {};
item.draft.request.vars.req = item.draft.request.vars.req || [];
const reqVar = find(item.draft.request.vars.req, (v) => v.uid === action.payload.var.uid);
if (reqVar) {
reqVar.name = action.payload.var.name;
reqVar.value = action.payload.var.value;
reqVar.description = action.payload.var.description;
reqVar.enabled = action.payload.var.enabled;
}
} else if(type === 'response') {
item.draft.request.vars = item.draft.request.vars || {};
item.draft.request.vars.res = item.draft.request.vars.res || [];
const resVar = find(item.draft.request.vars.res, (v) => v.uid === action.payload.var.uid);
if (resVar) {
resVar.name = action.payload.var.name;
resVar.value = action.payload.var.value;
resVar.description = action.payload.var.description;
resVar.enabled = action.payload.var.enabled;
}
}
}
}
},
deleteVar: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const type = action.payload.type;
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
if(type === 'request') {
item.draft.request.vars = item.draft.request.vars || {};
item.draft.request.vars.req = item.draft.request.vars.req || [];
item.draft.request.vars.req = item.draft.request.vars.req.filter((v) => v.uid !== action.payload.varUid);
} else if(type === 'response') {
item.draft.request.vars = item.draft.request.vars || {};
item.draft.request.vars.res = item.draft.request.vars.res || [];
item.draft.request.vars.res = item.draft.request.vars.res.filter((v) => v.uid !== action.payload.varUid);
}
}
}
},
collectionAddFileEvent: (state, action) => {
const file = action.payload.file;
const collection = findCollectionByUid(state.collections, file.meta.collectionUid);
if (collection) {
const dirname = path.dirname(file.meta.pathname);
const dirname = getDirectoryName(file.meta.pathname);
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dirname);
let currentPath = collection.pathname;
let currentSubItems = collection.items;
@@ -830,6 +999,14 @@ export const collectionsSlice = createSlice({
existingEnv.variables = environment.variables;
} else {
collection.environments.push(environment);
const lastAction = collection.lastAction;
if(lastAction && lastAction.type === 'ADD_ENVIRONMENT') {
collection.lastAction = null;
if(lastAction.payload === environment.name) {
collection.activeEnvironmentUid = environment.uid;
}
}
}
}
},
@@ -845,6 +1022,19 @@ export const collectionsSlice = createSlice({
}
}
},
assertionResultsEvent: (state, action) => {
const { itemUid, collectionUid, results } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
console.log(results);
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (item) {
item.assertionResults = results;
}
}
},
collectionRenamedEvent: (state, action) => {
const { collectionPathname, newName } = action.payload;
const collection = findCollectionByPathname(state.collections, collectionPathname);
@@ -857,10 +1047,8 @@ export const collectionsSlice = createSlice({
const { collectionUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
console.log('here');
if (collection) {
console.log('here2');
collection.showRunner = !collection.showRunner;
collection.showRunner = !collection.showRunner;
}
},
showRunnerView: (state, action) => {
@@ -888,14 +1076,30 @@ export const collectionsSlice = createSlice({
}
},
runFolderEvent: (state, action) => {
const { collectionUid, folderUid, itemUid, type, error } = action.payload;
const { collectionUid, folderUid, itemUid, type, isRecursive, error } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
const folder = findItemInCollection(collection, folderUid);
const request = findItemInCollection(collection, itemUid);
collection.runnerResult = collection.runnerResult || {items: []};
collection.runnerResult = collection.runnerResult || {info: {}, items: []};
// todo
// get startedAt and endedAt from the runner and display it in the UI
if(type === 'testrun-started') {
const info = collection.runnerResult.info;
info.collectionUid = collectionUid;
info.folderUid = folderUid;
info.isRecursive = isRecursive;
info.status = 'started';
}
if(type === 'testrun-ended') {
const info = collection.runnerResult.info;
info.status = 'ended';
}
if(type === 'request-queued') {
collection.runnerResult.items.push({
@@ -921,6 +1125,11 @@ export const collectionsSlice = createSlice({
item.testResults = action.payload.testResults;
}
if(type === 'assertion-results') {
const item = collection.runnerResult.items.find((i) => i.uid === request.uid);
item.assertionResults = action.payload.assertionResults;
}
if(type === 'error') {
const item = collection.runnerResult.items.find((i) => i.uid === request.uid);
item.error = action.payload.error;
@@ -928,6 +1137,15 @@ export const collectionsSlice = createSlice({
item.status = "error";
}
}
},
closeCollectionRunner: (state, action) => {
const { collectionUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
collection.runnerResult = null;
collection.showRunner = false;
}
}
}
});
@@ -936,7 +1154,7 @@ export const {
createCollection,
renameCollection,
removeCollection,
addEnvironment,
updateLastAction,
collectionUnlinkEnvFileEvent,
saveEnvironment,
selectEnvironment,
@@ -971,8 +1189,15 @@ export const {
updateRequestGraphqlQuery,
updateRequestGraphqlVariables,
updateRequestScript,
updateResponseScript,
updateRequestTests,
updateRequestMethod,
addAssertion,
updateAssertion,
deleteAssertion,
addVar,
updateVar,
deleteVar,
collectionAddFileEvent,
collectionAddDirectoryEvent,
collectionChangeFileEvent,
@@ -980,12 +1205,14 @@ export const {
collectionUnlinkDirectoryEvent,
collectionAddEnvFileEvent,
testResultsEvent,
assertionResultsEvent,
collectionRenamedEvent,
toggleRunnerView,
showRunnerView,
hideRunnerView,
resetRunResults,
runFolderEvent
runFolderEvent,
closeCollectionRunner
} = collectionsSlice.actions;
export default collectionsSlice.reducer;

View File

@@ -193,9 +193,9 @@ const darkTheme = {
codemirror: {
bg: '#1e1e1e',
border: 'transparent',
border: '#373737',
gutter: {
bg: '#1e1e1e'
bg: '#262626'
},
variable: {
valid: 'rgb(11 178 126)',

View File

@@ -9,6 +9,9 @@ const deleteUidsInItems = (items) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
each(get(item, 'request.headers'), (header) => delete header.uid);
each(get(item, 'request.params'), (param) => delete param.uid);
each(get(item, 'request.vars.req'), (v) => delete v.uid);
each(get(item, 'request.vars.res'), (v) => delete v.uid);
each(get(item, 'request.vars.assertions'), (a) => delete a.uid);
each(get(item, 'request.body.multipartForm'), (param) => delete param.uid);
each(get(item, 'request.body.formUrlEncoded'), (param) => delete param.uid);
}
@@ -19,6 +22,31 @@ const deleteUidsInItems = (items) => {
});
};
/**
* Some of the models in the app are not consistent with the Collection Json format
* This function is used to transform the models to the Collection Json format
*/
const transformItem = (items = []) => {
each(items, (item) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
item.request.query = item.request.params;
delete item.request.params;
if(item.type === 'graphql-request') {
item.type = 'graphql';
}
if(item.type === 'http-request') {
item.type = 'http';
}
}
if (item.items && item.items.length) {
transformItem(item.items);
}
});
};
const deleteUidsInEnvs = (envs) => {
each(envs, (env) => {
delete env.uid;
@@ -31,6 +59,8 @@ const exportCollection = (collection) => {
delete collection.uid;
deleteUidsInItems(collection.items);
deleteUidsInEnvs(collection.environments);
transformItem(collection.items);
const fileName = `${collection.name}.json`;
const fileBlob = new Blob([JSON.stringify(collection, null, 2)], { type: 'application/json' });

View File

@@ -282,6 +282,8 @@ export const transformCollectionToSaveToIdb = (collection, options = {}) => {
multipartForm: copyMultipartFormParams(si.draft.request.body.multipartForm)
},
script: si.draft.request.script,
vars: si.draft.request.vars,
assertions: si.draft.request.assertions,
tests: si.draft.request.tests
};
}
@@ -302,6 +304,8 @@ export const transformCollectionToSaveToIdb = (collection, options = {}) => {
multipartForm: copyMultipartFormParams(si.request.body.multipartForm)
},
script: si.request.script,
vars: si.request.vars,
assertions: si.request.assertions,
tests: si.request.tests
};
}
@@ -349,6 +353,8 @@ export const transformRequestToSaveToFilesystem = (item) => {
headers: [],
body: _item.request.body,
script: _item.request.script,
vars: _item.request.vars,
assertions: _item.request.assertions,
tests: _item.request.tests
}
};
@@ -427,7 +433,7 @@ export const humanizeRequestBodyMode = (mode) => {
break;
}
case 'formUrlEncoded': {
label = 'Form Url Encoded';
label = 'Form URL Encoded';
break;
}
case 'multipartForm': {
@@ -518,6 +524,19 @@ export const getEnvironmentVariables = (collection) => {
return variables;
}
export const getTotalRequestCountInCollection = (collection) => {
let count = 0;
each(collection.items, (item) => {
if (isItemARequest(item)) {
count++;
} else if (isItemAFolder(item)) {
count += getTotalRequestCountInCollection(item);
}
});
return count;
};
export const getAllVariables = (collection) => {
const environmentVariables = getEnvironmentVariables(collection);

View File

@@ -1,5 +1,6 @@
import trim from 'lodash/trim';
import path from 'path';
import slash from './slash';
export const isElectron = () => {
if (!window) {
@@ -18,9 +19,17 @@ export const resolveRequestFilename = (name) => {
};
export const getSubdirectoriesFromRoot = (rootPath, pathname) => {
if (!path.isAbsolute(pathname)) {
throw new Error('Invalid path!');
}
// convert to unix style path
pathname = slash(pathname);
rootPath = slash(rootPath);
const relativePath = path.relative(rootPath, pathname);
return relativePath ? relativePath.split(path.sep) : [];
};
export const getDirectoryName = (pathname) => {
// convert to unix style path
pathname = slash(pathname);
return path.dirname(pathname);
}

View File

@@ -0,0 +1,20 @@
/**
* MIT License
*
* Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const slash = (path) => {
const isExtendedLengthPath = /^\\\\\?\\/.test(path);
if (isExtendedLengthPath) {
return path;
}
return path.replace(/\\/g, '/');
}
export default slash;

View File

@@ -1,6 +1,6 @@
import fileDialog from 'file-dialog';
import { BrunoError } from 'utils/common/error';
import { validateSchema, updateUidsInCollection, hydrateSeqInCollection } from './common';
import { validateSchema, transformItemsInCollection, updateUidsInCollection, hydrateSeqInCollection } from './common';
const readFile = (files) => {
return new Promise((resolve, reject) => {
@@ -30,6 +30,7 @@ const importCollection = () => {
.then(parseJsonCollection)
.then(hydrateSeqInCollection)
.then(updateUidsInCollection)
.then(transformItemsInCollection)
.then(validateSchema)
.then((collection) => resolve(collection))
.catch((err) => {

View File

@@ -30,7 +30,11 @@ export const updateUidsInCollection = (_collection) => {
item.uid = uuid();
each(get(item, 'request.headers'), (header) => (header.uid = uuid()));
each(get(item, 'request.query'), (param) => (param.uid = uuid()));
each(get(item, 'request.params'), (param) => (param.uid = uuid()));
each(get(item, 'request.vars.req'), (v) => (v.uid = uuid()));
each(get(item, 'request.vars.res'), (v) => (v.uid = uuid()));
each(get(item, 'request.assertions'), (a) => (a.uid = uuid()));
each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid()));
@@ -48,7 +52,33 @@ export const updateUidsInCollection = (_collection) => {
});
};
updateEnvUids(collection.environments);
updateEnvUids(collection.environments);
return collection;
};
// todo
// need to eventually get rid of supporting old collection app models
// 1. start with making request type a constant fetched from a single place
// 2. move references of param and replace it with query inside the app
export const transformItemsInCollection = (collection) => {
const transformItems = (items = []) => {
each(items, (item) => {
if (['http', 'graphql'].includes(item.type)) {
item.type = `${item.type}-request`;
if(item.request.query) {
item.request.params = item.request.query;
}
delete item.request.query;
}
if (item.items && item.items.length) {
transformItems(item.items);
}
});
};
transformItems(collection.items);
return collection;
};

View File

@@ -0,0 +1,186 @@
import each from 'lodash/each';
import get from 'lodash/get';
import fileDialog from 'file-dialog';
import { uuid } from 'utils/common';
import { BrunoError } from 'utils/common/error';
import { validateSchema, transformItemsInCollection, hydrateSeqInCollection } from './common';
const readFile = (files) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => resolve(e.target.result);
fileReader.onerror = (err) => reject(err);
fileReader.readAsText(files[0]);
});
};
const parseGraphQL = (text) => {
try {
const graphql = JSON.parse(text);
return {
query: graphql.query,
variables: JSON.stringify(graphql.variables, null, 2)
}
} catch (e) {
return {
query: '',
variables: {}
}
}
}
const transformInsomniaRequestItem = (request) => {
const brunoRequestItem = {
uid: uuid(),
name: request.name,
type: 'http-request',
request: {
url: request.url,
method: request.method,
headers: [],
params: [],
body: {
mode: 'none',
json: null,
text: null,
xml: null,
formUrlEncoded: [],
multipartForm: []
}
}
};
each(request.headers, (header) => {
brunoRequestItem.request.headers.push({
uid: uuid(),
name: header.name,
value: header.value,
description: header.description,
enabled: !header.disabled
});
});
each(request.parameters, (param) => {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.name,
value: param.value,
description: param.description,
enabled: !param.disabled
});
});
const mimeType = get(request, 'body.mimeType', '');
if (mimeType === 'application/json') {
brunoRequestItem.request.body.mode = 'json';
brunoRequestItem.request.body.json = request.body.text;
} else if (mimeType === 'application/x-www-form-urlencoded') {
brunoRequestItem.request.body.mode = 'formUrlEncoded';
each(request.body.params, (param) => {
brunoRequestItem.request.body.formUrlEncoded.push({
uid: uuid(),
name: param.name,
value: param.value,
description: param.description,
enabled: !param.disabled
});
});
} else if (mimeType === 'multipart/form-data') {
brunoRequestItem.request.body.mode = 'multipartForm';
each(request.body.params, (param) => {
brunoRequestItem.request.body.multipartForm.push({
uid: uuid(),
name: param.name,
value: param.value,
description: param.description,
enabled: !param.disabled
});
});
} else if (mimeType === 'text/plain') {
brunoRequestItem.request.body.mode = 'text';
brunoRequestItem.request.body.text = request.body.text;
} else if (mimeType === 'text/xml') {
brunoRequestItem.request.body.mode = 'xml';
brunoRequestItem.request.body.xml = request.body.text;
} else if (mimeType === 'application/graphql') {
brunoRequestItem.type = 'graphql-request';
brunoRequestItem.request.body.mode = 'graphql';
brunoRequestItem.request.body.graphql = parseGraphQL(request.body.text);
}
return brunoRequestItem;
};
const parseInsomniaCollection = (data) => {
const brunoCollection = {
name: '',
uid: uuid(),
version: "1",
items: [],
environments: []
};
return new Promise((resolve, reject) => {
try {
const insomniaExport = JSON.parse(data);
const insomniaResources = get(insomniaExport, 'resources', []);
const insomniaCollection = insomniaResources.find(resource => resource._type === 'workspace' && resource.scope === 'collection');
if (!insomniaCollection) {
reject(new BrunoError('Collection not found inside Insomnia export'));
}
brunoCollection.name = insomniaCollection.name;
const requestsAndFolders = insomniaResources.filter(
(resource) => resource._type === 'request' || resource._type === 'request_group'
) || [];
function createFolderStructure(resources, parentId = null) {
const requestGroups = resources.filter((resource) => resource._type === 'request_group' && resource.parentId === parentId) || [];
const requests = resources.filter((resource) => resource._type === 'request' && resource.parentId === parentId);
const folders = requestGroups.map((folder) => {
const requests = resources.filter((resource) => resource._type === 'request' && resource.parentId === folder._id);
return {
uid: uuid(),
name: folder.name,
type: 'folder',
items: createFolderStructure(resources, folder._id).concat(requests.map(transformInsomniaRequestItem)),
}
});
return folders.concat(requests.map(transformInsomniaRequestItem));
}
brunoCollection.items = createFolderStructure(requestsAndFolders, insomniaCollection._id),
resolve(brunoCollection);
} catch (err) {
reject(new BrunoError('An error occurred while parsing the Insomnia collection'));
}
});
};
const importCollection = () => {
return new Promise((resolve, reject) => {
fileDialog({ accept: 'application/json' })
.then(readFile)
.then(parseInsomniaCollection)
.then(transformItemsInCollection)
.then(hydrateSeqInCollection)
.then(validateSchema)
.then((collection) => resolve(collection))
.catch((err) => {
console.log(err);
reject(new BrunoError('Import collection failed'));
});
});
};
export default importCollection;

View File

@@ -3,7 +3,7 @@ import get from 'lodash/get';
import fileDialog from 'file-dialog';
import { uuid } from 'utils/common';
import { BrunoError } from 'utils/common/error';
import { validateSchema, hydrateSeqInCollection } from './common';
import { validateSchema, transformItemsInCollection, hydrateSeqInCollection } from './common';
const readFile = (files) => {
return new Promise((resolve, reject) => {
@@ -178,6 +178,7 @@ const importCollection = () => {
fileDialog({ accept: 'application/json' })
.then(readFile)
.then(parsePostmanCollection)
.then(transformItemsInCollection)
.then(hydrateSeqInCollection)
.then(validateSchema)
.then((collection) => resolve(collection))

View File

@@ -40,3 +40,16 @@ export const stringifyQueryParams = (params) => {
return queryString.join('&');
};
export const splitOnFirst = (str, char) => {
if(!str || !str.length) {
return [str];
}
let index = str.indexOf(char);
if (index === -1) {
return [str];
}
return [str.slice(0, index), str.slice(index + 1)];
};

View File

@@ -1,4 +1,4 @@
import { parseQueryParams } from './index';
import { parseQueryParams, splitOnFirst } from './index';
describe('Url Utils - parseQueryParams', () => {
it('should parse query - case 1', () => {
@@ -41,3 +41,30 @@ describe('Url Utils - parseQueryParams', () => {
expect(params).toEqual([{name: 'a', value: '1'}, {name: 'b', value: '2'}]);
});
});
describe('Url Utils - splitOnFirst', () => {
it('should split on first - case 1', () => {
const params = splitOnFirst("a", "=");
expect(params).toEqual(['a']);
});
it('should split on first - case 2', () => {
const params = splitOnFirst("a=", "=");
expect(params).toEqual(['a', '']);
});
it('should split on first - case 3', () => {
const params = splitOnFirst("a=1", "=");
expect(params).toEqual(['a', '1']);
});
it('should split on first - case 4', () => {
const params = splitOnFirst("a=1&b=2", "=");
expect(params).toEqual(['a', '1&b=2']);
});
it('should split on first - case 5', () => {
const params = splitOnFirst("a=1&b=2", "&");
expect(params).toEqual(['a=1', 'b=2']);
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 953 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1,52 +0,0 @@
let currentTab = {
id: null,
url: null,
};
const getExtensionId = () => {
const matches = chrome.runtime.getURL('x').match(/.*\/\/(.*)\/x$/);
if (matches) {
return matches[1];
}
return chrome.runtime.id;
};
// Create a new tab for the extension
function createNewTab() {
chrome.tabs.create({ url: 'index.html' }, function (tab) {
currentTab = {
id: tab.id,
url: tab.url
};
});
}
// Focus on the open extension tab
function focusTab(tabId) {
var updateProperties = { "active": true };
chrome.tabs.update(tabId, updateProperties, function (tab) { });
}
// Open the extension tab when the extension icon is clicked
chrome.action.onClicked.addListener(function (tab) {
if (!currentTab || !currentTab.id) {
createNewTab();
} else {
chrome.tabs.get(currentTab.id, function (tab) {
console.log(chrome.runtime.id, tab.url);
if (tab && tab.url && tab.url.includes(getExtensionId())) {
focusTab(currentTab.id);
} else {
createNewTab();
}
});
}
});
// When a tab is closed, check if it is the extension tab that was closed, and unset currentTabId
chrome.tabs.onRemoved.addListener(function (tabId) {
if (tabId === currentTab.id) {
currentTab = {};
}
});

View File

@@ -1,26 +0,0 @@
{
"manifest_version": 3,
"version": "0.1.0",
"name": "Bruno API Client",
"short_name": "Bruno",
"description": "Opensource API Client",
"icons": {
"16": "assets/images/logo-16x16.png",
"48": "assets/images/logo-48x48.png",
"128": "assets/images/logo-128x128.png"
},
"background": {
"service_worker": "js/background.js"
},
"action": {
"default_icon": "assets/images/logo-128x128.png"
},
"permissions": [
"tabs",
"storage"
],
"host_permissions": [
"http://*/",
"https://*/"
]
}

3
packages/bruno-cli/bin/bru.js Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env node
require('../src').run();

View File

@@ -1,9 +1,27 @@
{
"name": "usebruno",
"version": "0.1.0",
"name": "@usebruno/cli",
"version": "0.3.0",
"main": "src/index.js",
"bin": {
"bru": "./bin/bru.js"
},
"files": [
"src",
"bin",
"package.json"
]
],
"dependencies": {
"@usebruno/js": "0.2.0",
"@usebruno/lang": "0.2.2",
"axios": "^1.3.2",
"chai": "^4.3.7",
"chalk": "^3.0.0",
"form-data": "^4.0.0",
"fs-extra": "^10.1.0",
"inquirer": "^9.1.4",
"lodash": "^4.17.21",
"mustache": "^4.2.0",
"qs": "^6.11.0",
"yargs": "^17.6.2"
}
}

View File

@@ -0,0 +1,8 @@
# bruno-cli
Bru CLI
### Publish to Npm Registry
```bash
npm publish --access=public
```

View File

@@ -0,0 +1,243 @@
const fs = require('fs');
const chalk = require('chalk');
const path = require('path');
const { exists, isFile, isDirectory, getSubDirectories } = require('../utils/filesystem');
const { runSingleRequest } = require('../runner/run-single-request');
const { bruToEnvJson, getEnvVars } = require('../utils/bru');
const { rpad } = require('../utils/common');
const { bruToJson } = require('../utils/bru');
const command = 'run [filename]';
const desc = 'Run a request';
const printRunSummary = (assertionResults, testResults) => {
// display assertion results and test results summary
const totalAssertions = assertionResults.length;
const passedAssertions = assertionResults.filter((result) => result.status === 'pass').length;
const failedAssertions = totalAssertions - passedAssertions;
const totalTests = testResults.length;
const passedTests = testResults.filter((result) => result.status === 'pass').length;
const failedTests = totalTests - passedTests;
const maxLength = 12;
let assertSummary = `${rpad('Tests:', maxLength)} ${chalk.green(`${passedTests} passed`)}`;
if (failedTests > 0) {
assertSummary += `, ${chalk.red(`${failedTests} failed`)}`;
}
assertSummary += `, ${totalTests} total`;
let testSummary = `${rpad('Assertions:', maxLength)} ${chalk.green(`${passedAssertions} passed`)}`;
if (failedAssertions > 0) {
testSummary += `, ${chalk.red(`${failedAssertions} failed`)}`;
}
testSummary += `, ${totalAssertions} total`;
console.log("\n" + chalk.bold(assertSummary));
console.log(chalk.bold(testSummary));
};
const getBruFilesRecursively = (dir) => {
const environmentsPath = 'environments';
const getFilesInOrder = (dir) => {
let bruJsons = [];
const traverse = (currentPath) => {
const filesInCurrentDir = fs.readdirSync(currentPath);
for (const file of filesInCurrentDir) {
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
// todo: we might need a ignore config inside bruno.json
if (stats.isDirectory() &&
filePath !== environmentsPath &&
!filePath.startsWith(".git") &&
!filePath.startsWith("node_modules")
) {
traverse(filePath);
}
}
const currentDirBruJsons = [];
for (const file of filesInCurrentDir) {
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
if (!stats.isDirectory() && path.extname(filePath) === '.bru') {
const bruContent = fs.readFileSync(filePath, 'utf8');
const bruJson = bruToJson(bruContent);
currentDirBruJsons.push({
bruFilepath: filePath,
bruJson
});
}
}
// order requests by sequence
currentDirBruJsons.sort((a, b) => {
const aSequence = a.bruJson.seq || 0;
const bSequence = b.bruJson.seq || 0;
return aSequence - bSequence;
});
bruJsons = bruJsons.concat(currentDirBruJsons);
};
traverse(dir);
return bruJsons;
};
const bruJsons = getFilesInOrder(dir);
return bruJsons;
};
const builder = async (yargs) => {
yargs
.option('r', {
describe: 'Indicates a recursive run',
type: 'boolean',
default: false
})
.option('env', {
describe: 'Environment variables',
type: 'string',
})
.example('$0 run request.bru', 'Run a request')
.example('$0 run request.bru --env local', 'Run a request with the environment set to local')
.example('$0 run folder', 'Run all requests in a folder')
.example('$0 run folder -r', 'Run all requests in a folder recursively')
};
const handler = async function (argv) {
try {
let {
filename,
env,
r: recursive
} = argv;
const collectionPath = process.cwd();
// todo
// right now, bru must be run from the root of the collection
// will add support in the future to run it from anywhere inside the collection
const brunoJsonPath = path.join(collectionPath, 'bruno.json');
const brunoJsonExists = await exists(brunoJsonPath);
if(!brunoJsonExists) {
console.error(chalk.red(`You can run only at the root of a collection`));
return;
}
if(filename && filename.length) {
const pathExists = await exists(filename);
if(!pathExists) {
console.error(chalk.red(`File or directory ${filename} does not exist`));
return;
}
} else {
filename = "./";
recursive = true;
}
const collectionVariables = {};
let envVars = {};
if(env) {
const envFile = path.join(collectionPath, 'environments', `${env}.bru`);
const envPathExists = await exists(envFile);
if(!envPathExists) {
console.error(chalk.red(`Environment file not found: `) + chalk.dim(`environments/${env}.bru`));
return;
}
const envBruContent = fs.readFileSync(envFile, 'utf8');
const envJson = bruToEnvJson(envBruContent);
envVars = getEnvVars(envJson);
}
const _isFile = await isFile(filename);
if(_isFile) {
console.log(chalk.yellow('Running Request \n'));
const bruContent = fs.readFileSync(filename, 'utf8');
const bruJson = bruToJson(bruContent);
const result = await runSingleRequest(filename, bruJson, collectionPath, collectionVariables, envVars);
if(result) {
const {
assertionResults,
testResults
} = result;
printRunSummary(assertionResults, testResults);
console.log(chalk.dim(chalk.grey('Done.')));
}
}
const _isDirectory = await isDirectory(filename);
if(_isDirectory) {
let bruJsons = [];
if(!recursive) {
console.log(chalk.yellow('Running Folder \n'));
const files = fs.readdirSync(filename);
const bruFiles = files.filter((file) => file.endsWith('.bru'));
for (const bruFile of bruFiles) {
const bruFilepath = path.join(filename, bruFile)
const bruContent = fs.readFileSync(bruFilepath, 'utf8');
const bruJson = bruToJson(bruContent);
bruJsons.push({
bruFilepath,
bruJson
});
}
// order requests by sequence
bruJsons.sort((a, b) => {
const aSequence = a.bruJson.seq || 0;
const bSequence = b.bruJson.seq || 0;
return aSequence - bSequence;
});
} else {
console.log(chalk.yellow('Running Folder Recursively \n'));
bruJsons = await getBruFilesRecursively(filename);
}
let assertionResults = [];
let testResults = [];
for (const iter of bruJsons) {
const {
bruFilepath,
bruJson
} = iter;
const result = await runSingleRequest(bruFilepath, bruJson, collectionPath, collectionVariables, envVars);
if(result) {
const {
assertionResults: _assertionResults,
testResults: _testResults
} = result;
assertionResults = assertionResults.concat(_assertionResults);
testResults = testResults.concat(_testResults);
}
}
printRunSummary(assertionResults, testResults);
console.log(chalk.dim(chalk.grey('Ran all requests.')));
}
} catch (err) {
console.log("Something went wrong");
console.error(chalk.red(err.message));
}
};
module.exports = {
command,
desc,
builder,
handler
};

View File

@@ -0,0 +1,9 @@
const { version } = require('../package.json');
const CLI_EPILOGUE = `Documentation: https://docs.usebruno.com (v${version})`;
const CLI_VERSION = version;
module.exports = {
CLI_EPILOGUE,
CLI_VERSION
};

View File

@@ -1 +1,30 @@
console.log("This is bruno cli");
const yargs = require('yargs');
const chalk = require('chalk');
const { CLI_EPILOGUE, CLI_VERSION } = require('./constants');
const printBanner = () => {
console.log(chalk.yellow(`Bru CLI ${CLI_VERSION}`));
}
const run = async () => {
const argLength = process.argv.length;
const commandsToPrintBanner = ['--help', '-h'];
if (argLength <= 2 || process.argv.find((arg) => commandsToPrintBanner.includes(arg))) {
printBanner();
}
const { argv } = yargs
.strict()
.commandDir('commands')
.epilogue(CLI_EPILOGUE)
.usage('Usage: $0 <command> [options]')
.demandCommand(1, "Woof !! Let's play with some apis !!")
.help('h')
.alias('h', 'help');
};
module.exports = {
run
};

View File

@@ -0,0 +1,54 @@
const Mustache = require('mustache');
const { each, forOwn } = require('lodash');
// override the default escape function to prevent escaping
Mustache.escape = function (value) {
return value;
};
const interpolateVars = (request, envVars = {}, collectionVariables ={}) => {
const interpolate = (str) => {
if(!str || !str.length || typeof str !== "string") {
return str;
}
// collectionVariables take precedence over envVars
const combinedVars = {
...envVars,
...collectionVariables
};
return Mustache.render(str, combinedVars);
};
request.url = interpolate(request.url);
forOwn(request.headers, (value, key) => {
request.headers[key] = interpolate(value);
});
if(request.headers["content-type"] === "application/json") {
if(typeof request.data === "object") {
try {
let parsed = JSON.stringify(request.data);
parsed = interpolate(parsed);
request.data = JSON.parse(parsed);
} catch (err) {
}
}
if(typeof request.data === "string") {
if(request.data.length) {
request.data = interpolate(request.data);
}
}
}
each(request.params, (param) => {
param.value = interpolate(param.value);
});
return request;
};
module.exports = interpolateVars;

View File

@@ -0,0 +1,71 @@
const { get, each, filter } = require('lodash');
const qs = require('qs');
const prepareRequest = (request) => {
const headers = {};
each(request.headers, (h) => {
if (h.enabled) {
headers[h.name] = h.value;
}
});
let axiosRequest = {
method: request.method,
url: request.url,
headers: headers
};
request.body = request.body || {};
if (request.body.mode === 'json') {
axiosRequest.headers['content-type'] = 'application/json';
try {
axiosRequest.data = JSON.parse(request.body.json);
} catch (ex) {
axiosRequest.data = request.body.json;
}
}
if (request.body.mode === 'text') {
axiosRequest.headers['content-type'] = 'text/plain';
axiosRequest.data = request.body.text;
}
if (request.body.mode === 'xml') {
axiosRequest.headers['content-type'] = 'text/xml';
axiosRequest.data = request.body.xml;
}
if (request.body.mode === 'formUrlEncoded') {
axiosRequest.headers['content-type'] = 'application/x-www-form-urlencoded';
const params = {};
const enabledParams = filter(request.body.formUrlEncoded, (p) => p.enabled);
each(enabledParams, (p) => (params[p.name] = p.value));
axiosRequest.data = qs.stringify(params);
}
if (request.body.mode === 'multipartForm') {
const params = {};
const enabledParams = filter(request.body.multipartForm, (p) => p.enabled);
each(enabledParams, (p) => (params[p.name] = p.value));
axiosRequest.headers['content-type'] = 'multipart/form-data';
axiosRequest.data = params;
}
if (request.body.mode === 'graphql') {
const graphqlQuery = {
query: get(request, 'body.graphql.query'),
variables: JSON.parse(get(request, 'body.graphql.variables') || '{}')
};
axiosRequest.headers['content-type'] = 'application/json';
axiosRequest.data = graphqlQuery;
}
if (request.script && request.script.length) {
axiosRequest.script = request.script;
}
return axiosRequest;
};
module.exports = prepareRequest;

View File

@@ -0,0 +1,108 @@
const chalk = require('chalk');
const { forOwn, each, extend, get } = require('lodash');
const FormData = require('form-data');
const axios = require('axios');
const prepareRequest = require('./prepare-request');
const interpolateVars = require('./interpolate-vars');
const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime } = require('@usebruno/js');
const { stripExtension } = require('../utils/filesystem');
const runSingleRequest = async function (filename, bruJson, collectionPath, collectionVariables, envVariables) {
try {
const request = prepareRequest(bruJson.request);
// make axios work in node using form data
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
if(request.headers && request.headers['content-type'] === 'multipart/form-data') {
const form = new FormData();
forOwn(request.data, (value, key) => {
form.append(key, value);
});
extend(request.headers, form.getHeaders());
request.data = form;
}
// run pre-request vars
const preRequestVars = get(bruJson, 'request.vars.req');
if(preRequestVars && preRequestVars.length) {
const varsRuntime = new VarsRuntime();
varsRuntime.runPreRequestVars(preRequestVars, request, envVariables, collectionVariables, collectionPath);
}
// run pre request script
const requestScriptFile = get(bruJson, 'request.script.req');
if(requestScriptFile && requestScriptFile.length) {
const scriptRuntime = new ScriptRuntime();
scriptRuntime.runRequestScript(requestScriptFile, request, envVariables, collectionVariables, collectionPath);
}
// interpolate variables inside request
interpolateVars(request, envVariables, collectionVariables);
// run request
const response = await axios(request);
console.log(chalk.green(stripExtension(filename)) + chalk.dim(` (${response.status} ${response.statusText})`));
// run post-response vars
const postResponseVars = get(bruJson, 'request.vars.res');
if(postResponseVars && postResponseVars.length) {
const varsRuntime = new VarsRuntime();
varsRuntime.runPostResponseVars(postResponseVars, request, response, envVariables, collectionVariables, collectionPath);
}
// run post response script
const responseScriptFile = get(bruJson, 'request.script.res');
if(responseScriptFile && responseScriptFile.length) {
const scriptRuntime = new ScriptRuntime();
scriptRuntime.runResponseScript(responseScriptFile, request, response, envVariables, collectionVariables, collectionPath);
}
// run assertions
let assertionResults = [];
const assertions = get(bruJson, 'request.assertions');
if(assertions && assertions.length) {
const assertRuntime = new AssertRuntime();
assertionResults = assertRuntime.runAssertions(assertions, request, response, envVariables, collectionVariables, collectionPath);
each(assertionResults, (r) => {
if(r.status === 'pass') {
console.log(chalk.green(``) + chalk.dim(`assert: ${r.lhsExpr}: ${r.rhsExpr}`));
} else {
console.log(chalk.red(``) + chalk.red(`assert: ${r.lhsExpr}: ${r.rhsExpr}`));
console.log(chalk.red(` ${r.error}`));
}
});
}
// run tests
let testResults = [];
const testFile = get(bruJson, 'request.tests');
if(testFile && testFile.length) {
const testRuntime = new TestRuntime();
const result = testRuntime.runTests(testFile, request, response, envVariables, collectionVariables, collectionPath);
testResults = get(result, 'results', []);
}
if(testResults && testResults.length) {
each(testResults, (testResult) => {
if(testResult.status === 'pass') {
console.log(chalk.green(``) + chalk.dim(testResult.description));
} else {
console.log(chalk.red(``) + chalk.red(testResult.description));
}
});
}
return {
assertionResults,
testResults
};
} catch (err) {
console.log(chalk.red(stripExtension(filename)) + chalk.dim(` (${err.message})`));
}
};
module.exports = {
runSingleRequest
};

View File

@@ -0,0 +1,87 @@
const _ = require('lodash');
const Mustache = require('mustache');
const { bruToEnvJsonV2, bruToJsonV2 } = require('@usebruno/lang');
// override the default escape function to prevent escaping
Mustache.escape = function (value) {
return value;
};
/**
* The transformer function for converting a BRU file to JSON.
*
* We map the json response from the bru lang and transform it into the DSL
* format that is used by the bruno app
*
* @param {string} bru The BRU file content.
* @returns {object} The JSON representation of the BRU file.
*/
const bruToJson = (bru) => {
try {
const json = bruToJsonV2(bru);
let requestType = _.get(json, "meta.type");
if(requestType === "http") {
requestType = "http-request"
} else if(requestType === "graphql") {
requestType = "graphql-request";
} else {
requestType = "http";
}
const sequence = _.get(json, "meta.seq")
const transformedJson = {
"type": requestType,
"name": _.get(json, "meta.name"),
"seq": !isNaN(sequence) ? Number(sequence) : 1,
"request": {
"method": _.upperCase(_.get(json, "http.method")),
"url": _.get(json, "http.url"),
"params": _.get(json, "query", []),
"headers": _.get(json, "headers", []),
"body": _.get(json, "body", {}),
"vars": _.get(json, "vars", []),
"assertions": _.get(json, "assertions", []),
"script": _.get(json, "script", ""),
"tests": _.get(json, "tests", "")
}
};
transformedJson.request.body.mode = _.get(json, "http.body", "none");
return transformedJson;
} catch (err) {
return Promise.reject(err);
}
};
const bruToEnvJson = (bru) => {
try {
return bruToEnvJsonV2(bru);
} catch (err) {
return Promise.reject(err);
}
};
const getEnvVars = (environment = {}) => {
const variables = environment.variables;
if (!variables || !variables.length) {
return {};
}
const envVars = {};
_.each(variables, (variable) => {
if(variable.enabled) {
envVars[variable.name] = Mustache.escape(variable.value);
}
});
return envVars;
};
module.exports = {
bruToJson,
bruToEnvJson,
getEnvVars
};

View File

@@ -0,0 +1,20 @@
const lpad = (str, width) => {
let paddedStr = str;
while (paddedStr.length < width) {
paddedStr = ' ' + paddedStr;
}
return paddedStr;
};
const rpad = (str, width) => {
let paddedStr = str;
while (paddedStr.length < width) {
paddedStr = paddedStr + ' ';
}
return paddedStr;
}
module.exports = {
lpad,
rpad
};

View File

@@ -0,0 +1,135 @@
const path = require('path');
const fs = require('fs-extra');
const fsPromises = require('fs/promises');
const exists = async p => {
try {
await fsPromises.access(p);
return true;
} catch (_) {
return false;
}
};
const isSymbolicLink = filepath => {
try {
return fs.existsSync(filepath) && fs.lstatSync(filepath).isSymbolicLink();
} catch (_) {
return false;
}
};
const isFile = filepath => {
try {
return fs.existsSync(filepath) && fs.lstatSync(filepath).isFile();
} catch (_) {
return false;
}
};
const isDirectory = dirPath => {
try {
return fs.existsSync(dirPath) && fs.lstatSync(dirPath).isDirectory();
} catch (_) {
return false;
}
};
const normalizeAndResolvePath = pathname => {
if (isSymbolicLink(pathname)) {
const absPath = path.dirname(pathname);
const targetPath = path.resolve(absPath, fs.readlinkSync(pathname));
if (isFile(targetPath) || isDirectory(targetPath)) {
return path.resolve(targetPath);
}
console.error(`Cannot resolve link target "${pathname}" (${targetPath}).`)
return '';
}
return path.resolve(pathname);
};
const writeFile = async (pathname, content) => {
try {
fs.writeFileSync(pathname, content, {
encoding: "utf8"
});
} catch (err) {
return Promise.reject(err);
}
};
const hasJsonExtension = filename => {
if (!filename || typeof filename !== 'string') return false
return ['json'].some(ext => filename.toLowerCase().endsWith(`.${ext}`))
}
const hasBruExtension = filename => {
if (!filename || typeof filename !== 'string') return false
return ['bru'].some(ext => filename.toLowerCase().endsWith(`.${ext}`))
}
const createDirectory = async (dir) => {
if(!dir) {
throw new Error(`directory: path is null`);
}
if (fs.existsSync(dir)){
throw new Error(`directory: ${dir} already exists`);
}
return fs.mkdirSync(dir);
};
const searchForFiles = (dir, extension) => {
let results = [];
const files = fs.readdirSync(dir);
for (const file of files) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
results = results.concat(searchForFiles(filePath, extension));
} else if (path.extname(file) === extension) {
results.push(filePath);
}
}
return results;
}
const searchForBruFiles = (dir) => {
return searchForFiles(dir, '.bru');
};
const stripExtension = (filename = '') => {
return filename.replace(/\.[^/.]+$/, "");
}
const getSubDirectories = (dir) => {
try {
const files = fs.readdirSync(dir);
const subDirectories = files
.filter((file) => {
return fs.lstatSync(path.join(dir, file)).isDirectory();
})
.sort();
return subDirectories;
} catch (err) {
return [];
}
};
module.exports = {
exists,
isSymbolicLink,
isFile,
isDirectory,
normalizeAndResolvePath,
writeFile,
hasJsonExtension,
hasBruExtension,
createDirectory,
searchForFiles,
searchForBruFiles,
stripExtension,
getSubDirectories
};

View File

@@ -1,6 +1,10 @@
node_modules
web
out
.env
// certs
sectigo.*
pnpm-lock.yaml
package-lock.json

View File

@@ -6,6 +6,7 @@ directories:
output: out
files:
- "**/*"
afterSign: notarize.js
mac:
artifactName: ${name}_${version}_${arch}_${os}.${ext}
category: public.app-category.developer-tools
@@ -20,6 +21,7 @@ mac:
- arm64
icon: resources/icons/mac/icon.icns
hardenedRuntime: true
identity: "Anoop MD (W7LPPWA48L)"
entitlements: resources/entitlements.mac.plist
entitlementsInherit: resources/entitlements.mac.plist
linux:
@@ -31,3 +33,5 @@ linux:
win:
artifactName: ${name}_${version}_${arch}_win.${ext}
icon: resources/icons/png
certificateFile: sectigo.pfx
certificatePassword: "secret"

View File

@@ -0,0 +1,36 @@
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const electron_notarize = require('electron-notarize');
const notarize = async function (params) {
if (process.platform !== 'darwin') {
return;
}
let appId = 'com.usebruno.app';
let appPath = path.join(params.appOutDir, `${params.packager.appInfo.productFilename}.app`);
if (!fs.existsSync(appPath)) {
console.error(`Cannot find application at: ${appPath}`);
return;
}
console.log(`Notarizing ${appId} found at ${appPath} using Apple ID ${process.env.APPLE_ID}`);
try {
await electron_notarize.notarize({
appBundleId: appId,
appPath: appPath,
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_ID_PASSWORD,
ascProvider: 'W7LPPWA48L'
});
} catch (error) {
console.error(error);
}
console.log(`Done notarizing ${appId}`);
};
module.exports = notarize;

View File

@@ -1,5 +1,5 @@
{
"version": "0.8.1",
"version": "0.10.2",
"name": "bruno",
"description": "Opensource API Client",
"homepage": "https://www.usebruno.com",
@@ -9,21 +9,19 @@
"scripts": {
"clean": "rimraf dist",
"dev": "electron .",
"dist": "electron-builder --mac --win --linux",
"pack-app": "electron-builder --dir"
"dist": "electron-builder --win --linux --mac",
"pack": "electron-builder --dir"
},
"dependencies": {
"@usebruno/js": "0.1.0",
"@usebruno/lang": "0.1.0",
"@usebruno/schema": "0.1.0",
"ajv": "^8.12.0",
"atob": "^2.1.2",
"@usebruno/js": "0.2.0",
"@usebruno/lang": "0.2.2",
"@usebruno/schema": "0.3.1",
"axios": "^0.26.0",
"btoa": "^1.2.1",
"chai": "^4.3.7",
"chokidar": "^3.5.3",
"crypto-js": "^4.1.1",
"dotenv": "^16.0.3",
"electron-is-dev": "^2.0.0",
"electron-notarize": "^1.2.2",
"electron-store": "^8.1.0",
"electron-util": "^0.17.2",
"form-data": "^4.0.0",
@@ -31,7 +29,6 @@
"graphql": "^16.6.0",
"is-valid-path": "^0.1.1",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"mustache": "^4.2.0",
"nanoid": "3.3.4",
"qs": "^6.11.0",

View File

@@ -3,4 +3,7 @@
```bash
# electron dev
npm start
# generate pfx file for signing windows build
openssl pkcs12 -export -inkey sectigo.key -in sectigo.pem -out sectigo.pfx
```

View File

@@ -18,7 +18,7 @@ const template = [
submenu: [
{ role: 'undo'},
{ role: 'redo'},
{ role: 'separator'},
{ type: 'separator'},
{ role: 'cut'},
{ role: 'copy'},
{ role: 'paste'}
@@ -27,13 +27,12 @@ const template = [
{
label: 'View',
submenu: [
{ role: 'reload'},
{ role: 'toggledevtools'},
{ role: 'separator'},
{ type: 'separator'},
{ role: 'resetzoom'},
{ role: 'zoomin'},
{ role: 'zoomout'},
{ role: 'separator'},
{ type: 'separator'},
{ role: 'togglefullscreen'}
]
},

View File

@@ -4,11 +4,18 @@ const path = require('path');
const chokidar = require('chokidar');
const { hasJsonExtension, hasBruExtension, writeFile } = require('../utils/filesystem');
const {
bruToJson,
jsonToBru,
bruToEnvJson,
envJsonToBru,
} = require('@usebruno/lang');
bruToJson,
jsonToBru
} = require('../bru');
const {
isLegacyEnvFile,
migrateLegacyEnvFile,
isLegacyBruFile,
migrateLegacyBruFile
} = require('../bru/migrate');
const { itemSchema } = require('@usebruno/schema');
const { uuid } = require('../utils/common');
const { getRequestUid } = require('../cache/requestUids');
@@ -33,11 +40,17 @@ const hydrateRequestWithUuid = (request, pathname) => {
const params = _.get(request, 'request.params', []);
const headers = _.get(request, 'request.headers', []);
const requestVars = _.get(request, 'request.vars.req', []);
const responseVars = _.get(request, 'request.vars.res', []);
const assertions = _.get(request, 'request.assertions', []);
const bodyFormUrlEncoded = _.get(request, 'request.body.formUrlEncoded', []);
const bodyMultipartForm = _.get(request, 'request.body.multipartForm', []);
params.forEach((param) => param.uid = uuid());
headers.forEach((header) => header.uid = uuid());
requestVars.forEach((variable) => variable.uid = uuid());
responseVars.forEach((variable) => variable.uid = uuid());
assertions.forEach((assertion) => assertion.uid = uuid());
bodyFormUrlEncoded.forEach((param) => param.uid = uuid());
bodyMultipartForm.forEach((param) => param.uid = uuid());
@@ -55,7 +68,13 @@ const addEnvironmentFile = async (win, pathname, collectionUid) => {
},
};
const bruContent = fs.readFileSync(pathname, 'utf8');
let bruContent = fs.readFileSync(pathname, 'utf8');
// migrate old env json to bru file
if(isLegacyEnvFile(bruContent)) {
bruContent = await migrateLegacyEnvFile(bruContent, pathname);
}
file.data = bruToEnvJson(bruContent);
file.data.name = basename.substring(0, basename.length - 4);
file.data.uid = getRequestUid(pathname);
@@ -117,11 +136,11 @@ const add = async (win, pathname, collectionUid, collectionPath) => {
console.log(`watcher add: ${pathname}`);
if(isJsonEnvironmentConfig(pathname, collectionPath)) {
// migrate old env json to bru file
try {
const dirname = path.dirname(pathname);
const jsonStr = fs.readFileSync(pathname, 'utf8');
const jsonData = JSON.parse(jsonStr);
const bruContent = fs.readFileSync(pathname, 'utf8');
const jsonData = JSON.parse(bruContent);
const envDirectory = path.join(dirname, 'environments');
if (!fs.existsSync(envDirectory)) {
@@ -177,8 +196,14 @@ const add = async (win, pathname, collectionUid, collectionPath) => {
}
try {
const bru = fs.readFileSync(pathname, 'utf8');
file.data = bruToJson(bru);
let bruContent = fs.readFileSync(pathname, 'utf8');
// migrate old bru format to new bru format
if(isLegacyBruFile(bruContent)) {
bruContent = await migrateLegacyBruFile(bruContent, pathname);
}
file.data = bruToJson(bruContent);
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
} catch (err) {

View File

@@ -0,0 +1,134 @@
const _ = require('lodash');
const {
bruToJsonV2,
jsonToBruV2,
bruToEnvJsonV2,
envJsonToBruV2
} = require('@usebruno/lang');
const { each } = require('lodash');
const bruToEnvJson = (bru) => {
try {
const json = bruToEnvJsonV2(bru);
// the app env format requires each variable to have a type
// this need to be evaulated and safely removed
// i don't see it being used in schema validation
if(json && json.variables && json.variables.length) {
each(json.variables, (v) => v.type = "text");
}
return json;
} catch (error) {
return Promise.reject(e);
}
}
const envJsonToBru = (json) => {
try {
const bru = envJsonToBruV2(json);
return bru;
} catch (error) {
return Promise.reject(e);
}
}
/**
* The transformer function for converting a BRU file to JSON.
*
* We map the json response from the bru lang and transform it into the DSL
* format that the app users
*
* @param {string} bru The BRU file content.
* @returns {object} The JSON representation of the BRU file.
*/
const bruToJson = (bru) => {
try {
const json = bruToJsonV2(bru);
let requestType = _.get(json, "meta.type");
if(requestType === "http") {
requestType = "http-request"
} else if(requestType === "graphql") {
requestType = "graphql-request";
} else {
requestType = "http-request";
}
const sequence = _.get(json, "meta.seq")
const transformedJson = {
"type": requestType,
"name": _.get(json, "meta.name"),
"seq": !isNaN(sequence) ? Number(sequence) : 1,
"request": {
"method": _.upperCase(_.get(json, "http.method")),
"url": _.get(json, "http.url"),
"params": _.get(json, "query", []),
"headers": _.get(json, "headers", []),
"body": _.get(json, "body", {}),
"script": _.get(json, "script", {}),
"vars": _.get(json, "vars", {}),
"assertions": _.get(json, "assertions", []),
"tests": _.get(json, "tests", "")
}
};
transformedJson.request.body.mode = _.get(json, "http.body", "none");
return transformedJson;
} catch (e) {
return Promise.reject(e);
}
};
/**
* The transformer function for converting a JSON to BRU file.
*
* We map the json response from the app and transform it into the DSL
* format that the bru lang understands
*
* @param {object} json The JSON representation of the BRU file.
* @returns {string} The BRU file content.
*/
const jsonToBru = (json) => {
let type = _.get(json, 'type');
if (type === 'http-request') {
type = "http";
} else if (type === 'graphql-request') {
type = "graphql";
} else {
type = "http";
}
const bruJson = {
meta: {
name: _.get(json, 'name'),
type: type,
seq: _.get(json, 'seq'),
},
http: {
method: _.lowerCase(_.get(json, 'request.method')),
url: _.get(json, 'request.url'),
body: _.get(json, 'request.body.mode', 'none')
},
query: _.get(json, 'request.params', []),
headers: _.get(json, 'request.headers', []),
body: _.get(json, 'request.body', {}),
script: _.get(json, 'request.script', {}),
vars: {
req: _.get(json, 'request.vars.req', []),
res: _.get(json, 'request.vars.res', [])
},
assertions: _.get(json, 'request.assertions', []),
tests: _.get(json, 'request.tests', ''),
};
return jsonToBruV2(bruJson);
};
module.exports = {
bruToJson,
jsonToBru,
bruToEnvJson,
envJsonToBru,
};

View File

@@ -0,0 +1,99 @@
const {
bruToEnvJson: bruToEnvJsonV1,
bruToJson: bruToJsonV1,
jsonToBruV2,
envJsonToBruV2
} = require('@usebruno/lang');
const _ = require('lodash');
const { writeFile } = require('../utils/filesystem');
const isLegacyEnvFile = (bruContent = '') => {
bruContent = bruContent.trim();
let regex = /^vars[\s\S]*\/vars$/;
return regex.test(bruContent);
};
const migrateLegacyEnvFile = async (bruContent, pathname) => {
const envJson = bruToEnvJsonV1(bruContent);
const newBruContent = envJsonToBruV2(envJson);
await writeFile(pathname, newBruContent);
return newBruContent;
};
const isLegacyBruFile = (bruContent = '') => {
bruContent = bruContent.trim();
let lines = bruContent.split(/\r?\n/);
let hasName = false;
let hasMethod = false;
let hasUrl = false;
for (let line of lines) {
line = line.trim();
if (line.startsWith("name")) {
hasName = true;
} else if (line.startsWith("method")) {
hasMethod = true;
} else if (line.startsWith("url")) {
hasUrl = true;
}
}
return hasName && hasMethod && hasUrl;
};
const migrateLegacyBruFile = async (bruContent, pathname) => {
const json = bruToJsonV1(bruContent);
let type = _.get(json, 'type');
if (type === 'http-request') {
type = "http";
} else if (type === 'graphql-request') {
type = "graphql";
} else {
type = "http";
}
let script = {};
let legacyScript = _.get(json, 'request.script');
if(legacyScript && legacyScript.trim().length > 0) {
script = {
res: legacyScript
};
}
const bruJson = {
meta: {
name: _.get(json, 'name'),
type: type,
seq: _.get(json, 'seq'),
},
http: {
method: _.lowerCase(_.get(json, 'request.method')),
url: _.get(json, 'request.url'),
body: _.get(json, 'request.body.mode', 'none')
},
query: _.get(json, 'request.params', []),
headers: _.get(json, 'request.headers', []),
body: _.get(json, 'request.body', {}),
script: script,
tests: _.get(json, 'request.tests', ''),
};
const newBruContent = jsonToBruV2(bruJson);
await writeFile(pathname, newBruContent);
return newBruContent;
}
module.exports = {
isLegacyEnvFile,
migrateLegacyEnvFile,
isLegacyBruFile,
migrateLegacyBruFile
};

View File

@@ -3,10 +3,11 @@ const fs = require('fs');
const path = require('path');
const { ipcMain } = require('electron');
const {
jsonToBru,
bruToJson,
envJsonToBru,
} = require('@usebruno/lang');
bruToJson,
jsonToBru
} = require('../bru');
const {
isValidPathname,
writeFile,
@@ -306,7 +307,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// Recursive function to parse the collection items and create files/folders
const parseCollectionItems = (items = [], currentPath) => {
items.forEach(item => {
if (item.type === 'http-request') {
if (['http-request', 'graphql-request'].includes(item.type)) {
const content = jsonToBru(item);
const filePath = path.join(currentPath, `${item.name}.bru`);
fs.writeFileSync(filePath, content);

View File

@@ -3,16 +3,13 @@ const Mustache = require('mustache');
const FormData = require('form-data');
const { ipcMain } = require('electron');
const { forOwn, extend, each, get } = require('lodash');
const { ScriptRuntime, TestRuntime } = require('@usebruno/js');
const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime } = require('@usebruno/js');
const prepareRequest = require('./prepare-request');
const prepareGqlIntrospectionRequest = require('./prepare-gql-introspection-request');
const { cancelTokens, saveCancelToken, deleteCancelToken } = require('../../utils/cancel-token');
const { uuid } = require('../../utils/common');
const interpolateVars = require('./interpolate-vars');
const {
sortFolder,
getAllRequestsInFolderRecursively
} = require('./helper');
const { sortFolder, getAllRequestsInFolderRecursively } = require('./helper');
// override the default escape function to prevent escaping
Mustache.escape = function (value) {
@@ -99,13 +96,27 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
const envVars = getEnvVars(environment);
if(request.script && request.script.length) {
let script = request.script + '\n if (typeof onRequest === "function") {onRequest(__brunoRequest);}';
const scriptRuntime = new ScriptRuntime();
const result = scriptRuntime.runRequestScript(script, request, envVars, collectionVariables, collectionPath);
// run pre-request vars
const preRequestVars = get(request, 'vars.req', []);
if(preRequestVars && preRequestVars.length) {
const varsRuntime = new VarsRuntime();
const result = varsRuntime.runPreRequestVars(preRequestVars, request, envVars, collectionVariables, collectionPath);
mainWindow.webContents.send('main:script-environment-update', {
environment: result.environment,
envVariables: result.envVariables,
collectionVariables: result.collectionVariables,
collectionUid
});
}
// run pre-request script
const requestScript = get(request, 'script.req');
if(requestScript && requestScript.length) {
const scriptRuntime = new ScriptRuntime();
const result = await scriptRuntime.runRequestScript(requestScript, request, envVars, collectionVariables, collectionPath);
mainWindow.webContents.send('main:script-environment-update', {
envVariables: result.envVariables,
collectionVariables: result.collectionVariables,
collectionUid
});
@@ -130,18 +141,44 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
const response = await axios(request);
if(request.script && request.script.length) {
let script = request.script + '\n if (typeof onResponse === "function") {onResponse(__brunoResponse);}';
const scriptRuntime = new ScriptRuntime();
const result = scriptRuntime.runResponseScript(script, response, envVars, collectionVariables, collectionPath);
// run post-response vars
const postResponseVars = get(request, 'vars.res', []);
if(postResponseVars && postResponseVars.length) {
const varsRuntime = new VarsRuntime();
const result = varsRuntime.runPostResponseVars(postResponseVars, request, response, envVars, collectionVariables, collectionPath);
mainWindow.webContents.send('main:script-environment-update', {
environment: result.environment,
envVariables: result.envVariables,
collectionVariables: result.collectionVariables,
collectionUid
});
}
// run post-response script
const responseScript = get(request, 'script.res');
if(responseScript && responseScript.length) {
const scriptRuntime = new ScriptRuntime();
const result = scriptRuntime.runResponseScript(responseScript, request, response, envVars, collectionVariables, collectionPath);
mainWindow.webContents.send('main:script-environment-update', {
envVariables: result.envVariables,
collectionVariables: result.collectionVariables,
collectionUid
});
}
// run assertions
const assertions = get(request, 'assertions');
const assertRuntime = new AssertRuntime();
const results = assertRuntime.runAssertions(assertions, request, response, envVars, collectionVariables, collectionPath);
mainWindow.webContents.send('main:assertion-results', {
results: results,
itemUid: item.uid,
collectionUid
});
// run tests
const testFile = get(item, 'request.tests');
if(testFile && testFile.length) {
const testRuntime = new TestRuntime();
@@ -229,6 +266,13 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
folder = collection;
}
mainWindow.webContents.send('main:run-folder-event', {
type: 'testrun-started',
isRecursive: recursive,
collectionUid,
folderUid
});
try {
const envVars = getEnvVars(environment);
let folderRequests = [];
@@ -280,18 +324,27 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
request.data = form;
}
if(request.script && request.script.length) {
let script = request.script + '\n if (typeof onRequest === "function") {onRequest(__brunoRequest);}';
// run pre-request vars
const preRequestVars = get(request, 'vars.req', []);
if(preRequestVars && preRequestVars.length) {
const varsRuntime = new VarsRuntime();
varsRuntime.runPreRequestVars(preRequestVars, request, envVars, collectionVariables, collectionPath);
}
// run pre-request script
const requestScript = get(request, 'script.req');
if(requestScript && requestScript.length) {
const scriptRuntime = new ScriptRuntime();
const result = scriptRuntime.runRequestScript(script, request, envVars, collectionVariables, collectionPath);
const result = scriptRuntime.runRequestScript(requestScript, request, envVars, collectionVariables, collectionPath);
mainWindow.webContents.send('main:script-environment-update', {
environment: result.environment,
envVariables: result.envVariables,
collectionVariables: result.collectionVariables,
collectionUid
});
}
// interpolate variables inside request
interpolateVars(request, envVars, collectionVariables);
// todo:
@@ -308,22 +361,52 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
...eventData
});
// send request
timeStart = Date.now();
const response = await axios(request);
timeEnd = Date.now();
if(request.script && request.script.length) {
let script = request.script + '\n if (typeof onResponse === "function") {onResponse(__brunoResponse);}';
const scriptRuntime = new ScriptRuntime();
const result = scriptRuntime.runResponseScript(script, response, envVars, collectionVariables, collectionPath);
// run post-response vars
const postResponseVars = get(request, 'vars.res', []);
if(postResponseVars && postResponseVars.length) {
const varsRuntime = new VarsRuntime();
const result = varsRuntime.runPostResponseVars(postResponseVars, request, response, envVars, collectionVariables, collectionPath);
mainWindow.webContents.send('main:script-environment-update', {
environment: result.environment,
envVariables: result.envVariables,
collectionVariables: result.collectionVariables,
collectionUid
});
}
// run response script
const responseScript = get(request, 'script.res');
if(responseScript && responseScript.length) {
const scriptRuntime = new ScriptRuntime();
const result = scriptRuntime.runResponseScript(responseScript, request, response, envVars, collectionVariables, collectionPath);
mainWindow.webContents.send('main:script-environment-update', {
envVariables: result.envVariables,
collectionVariables: result.collectionVariables,
collectionUid
});
}
// run assertions
const assertions = get(item, 'request.assertions');
if(assertions && assertions.length) {
const assertRuntime = new AssertRuntime();
const results = assertRuntime.runAssertions(assertions, request, response, envVars, collectionVariables, collectionPath);
mainWindow.webContents.send('main:run-folder-event', {
type: 'assertion-results',
assertionResults: results,
itemUid: item.uid,
collectionUid
});
}
// run tests
const testFile = get(item, 'request.tests');
if(testFile && testFile.length) {
const testRuntime = new TestRuntime();
@@ -374,6 +457,12 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
});
}
}
mainWindow.webContents.send('main:run-folder-event', {
type: 'testrun-ended',
collectionUid,
folderUid
});
} catch (error) {
mainWindow.webContents.send('main:run-folder-event', {
type: 'error',

View File

@@ -59,10 +59,13 @@ const prepareRequest = (request) => {
axiosRequest.data = graphqlQuery;
}
if (request.script && request.script.length) {
if (request.script) {
axiosRequest.script = request.script;
}
axiosRequest.vars = request.vars;
axiosRequest.assertions = request.assertions;
return axiosRequest;
};

View File

@@ -1,6 +1,6 @@
{
"name": "@usebruno/js",
"version": "0.1.0",
"version": "0.2.0",
"main": "src/index.js",
"files": [
"src",
@@ -8,5 +8,20 @@
],
"peerDependencies": {
"vm2": "^3.9.13"
},
"scripts": {
"test": "jest --testPathIgnorePatterns test.js"
},
"dependencies": {
"@usebruno/query": "0.1.0",
"ajv": "^8.12.0",
"atob": "^2.1.2",
"btoa": "^1.2.1",
"crypto-js": "^4.1.1",
"json-query": "^2.2.2",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"nanoid": "3.3.4",
"uuid": "^9.0.0"
}
}

View File

@@ -0,0 +1,8 @@
# bruno-js
Provides the script, test, vars and assert runtimes.
### Publish to Npm Registry
```bash
npm publish --access=public
```

View File

@@ -0,0 +1,38 @@
class Bru {
constructor(envVariables, collectionVariables) {
this._envVariables = envVariables;
this._collectionVariables = collectionVariables;
}
getEnvVar(key) {
return this._envVariables[key];
}
setEnvVar(key, value) {
if(!key) {
throw new Error('Key is required');
}
// gracefully ignore if key is not present in environment
if(!this._envVariables.hasOwnProperty(key)) {
return;
}
this._envVariables[key] = value;
}
setVar(key, value) {
if(!key) {
throw new Error('Key is required');
}
this._collectionVariables[key] = value;
}
getVar(key) {
return this._collectionVariables[key];
}
}
module.exports = Bru;

View File

@@ -0,0 +1,51 @@
class BrunoRequest {
constructor(req) {
this.req = req;
this.url = req.url;
this.method = req.method;
this.headers = req.headers;
this.body = req.data;
}
getUrl() {
return this.req.url;
}
setUrl(url) {
this.req.url = url;
}
getMethod() {
return this.req.method;
}
setMethod(method) {
this.req.method = method;
}
getHeaders() {
return this.req.headers;
}
setHeaders(headers) {
this.req.headers = headers;
}
getHeader(name) {
return this.req.headers[name];
}
setHeader(name, value) {
this.req.headers[name] = value;
}
getBody() {
return this.req.data;
}
setBody(data) {
this.req.data = data;
}
}
module.exports = BrunoRequest;

View File

@@ -0,0 +1,27 @@
class BrunoResponse {
constructor(res) {
this.res = res;
this.status = res.status;
this.statusText = res.statusText;
this.headers = res.headers;
this.body = res.data;
}
getStatus() {
return this.res.status;
}
getHeader(name) {
return this.res.header[name];
}
getHeaders() {
return this.res.headers;
}
getBody() {
return this.res.data;
}
}
module.exports = BrunoResponse;

View File

@@ -1,12 +1,11 @@
const {
ScriptRuntime
} = require('./scripts/script-runtime');
const {
TestRuntime
} = require('./scripts/test-runtime');
const ScriptRuntime = require('./runtime/script-runtime');
const TestRuntime = require('./runtime/test-runtime');
const VarsRuntime = require('./runtime/vars-runtime');
const AssertRuntime = require('./runtime/assert-runtime');
module.exports = {
ScriptRuntime,
TestRuntime
TestRuntime,
VarsRuntime,
AssertRuntime
};

View File

@@ -0,0 +1,302 @@
const _ = require('lodash');
const chai = require('chai');
const { nanoid } = require('nanoid');
const Bru = require('../bru');
const BrunoRequest = require('../bruno-request');
const { evaluateJsTemplateLiteral, evaluateJsExpression, createResponseParser } = require('../utils');
const { expect } = chai;
chai.use(function (chai, utils) {
// Custom assertion for checking if a variable is JSON
chai.Assertion.addProperty('json', function () {
const obj = this._obj;
const isJson = typeof obj === 'object' && obj !== null && !Array.isArray(obj) && obj.constructor === Object;
this.assert(
isJson,
`expected ${utils.inspect(obj)} to be JSON`,
`expected ${utils.inspect(obj)} not to be JSON`
);
});
});
// Custom assertion for matching regex
chai.use(function (chai, utils) {
chai.Assertion.addMethod('match', function (regex) {
const obj = this._obj;
let match = false;
if(obj === undefined) {
match = false;
} else {
match = regex.test(obj);
}
this.assert(
match,
`expected ${utils.inspect(obj)} to match ${regex}`,
`expected ${utils.inspect(obj)} not to match ${regex}`
);
});
});
/**
* Assertion operators
*
* eq : equal to
* neq : not equal to
* gt : greater than
* gte : greater than or equal to
* lt : less than
* lte : less than or equal to
* in : in
* notIn : not in
* contains : contains
* notContains : not contains
* length : length
* matches : matches
* notMatches : not matches
* startsWith : starts with
* endsWith : ends with
* between : between
* isEmpty : is empty
* isNull : is null
* isUndefined : is undefined
* isDefined : is defined
* isTruthy : is truthy
* isFalsy : is falsy
* isJson : is json
* isNumber : is number
* isString : is string
* isBoolean : is boolean
*/
const parseAssertionOperator = (str = '') => {
if(!str || typeof str !== 'string' || !str.length) {
return {
operator: 'eq',
value: str
};
}
const operators = [
'eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn',
'contains', 'notContains', 'length', 'matches', 'notMatches',
'startsWith', 'endsWith', 'between', 'isEmpty', 'isNull', 'isUndefined',
'isDefined', 'isTruthy', 'isFalsy', 'isJson', 'isNumber', 'isString', 'isBoolean'
];
const unaryOperators = [
'isEmpty', 'isNull', 'isUndefined', 'isDefined', 'isTruthy', 'isFalsy', 'isJson', 'isNumber', 'isString', 'isBoolean'
];
const [operator, ...rest] = str.trim().split(' ');
const value = rest.join(' ');
if(unaryOperators.includes(operator)) {
return {
operator,
value: ''
};
}
if(operators.includes(operator)) {
return {
operator,
value
};
}
return {
operator: 'eq',
value: str
};
};
const isUnaryOperator = (operator) => {
const unaryOperators = [
'isEmpty', 'isNull', 'isUndefined', 'isDefined', 'isTruthy', 'isFalsy', 'isJson', 'isNumber', 'isString', 'isBoolean'
];
return unaryOperators.includes(operator);
};
const evaluateRhsOperand = (rhsOperand, operator, context) => {
if(isUnaryOperator(operator)) {
return;
}
// gracefully allow both a,b as well as [a, b]
if(operator === 'in' || operator === 'notIn') {
if(rhsOperand.startsWith('[') && rhsOperand.endsWith(']')) {
rhsOperand = rhsOperand.substring(1, rhsOperand.length - 1);
}
return rhsOperand.split(',').map((v) => evaluateJsTemplateLiteral(v.trim(), context));
}
if(operator === 'between') {
const [lhs, rhs] = rhsOperand.split(',').map((v) => evaluateJsTemplateLiteral(v.trim(), context));
return [lhs, rhs];
}
// gracefully allow both ^[a-Z] as well as /^[a-Z]/
if(operator === 'matches' || operator === 'notMatches') {
if(rhsOperand.startsWith('/') && rhsOperand.endsWith('/')) {
rhsOperand = rhsOperand.substring(1, rhsOperand.length - 1);
}
return rhsOperand;
}
return evaluateJsTemplateLiteral(rhsOperand, context);
};
class AssertRuntime {
runAssertions(assertions, request, response, envVariables, collectionVariables, collectionPath) {
const enabledAssertions = _.filter(assertions, (a) => a.enabled);
if(!enabledAssertions.length) {
return [];
}
const bru = new Bru(envVariables, collectionVariables);
const req = new BrunoRequest(request);
const res = createResponseParser(response);
const bruContext = {
bru,
req,
res
};
const context = {
...envVariables,
...collectionVariables,
...bruContext
}
const assertionResults = [];
// parse assertion operators
for (const v of enabledAssertions) {
const lhsExpr = v.name;
const rhsExpr = v.value;
const {
operator,
value: rhsOperand
} = parseAssertionOperator(rhsExpr);
try {
const lhs = evaluateJsExpression(lhsExpr, context);
const rhs = evaluateRhsOperand(rhsOperand, operator, context);
switch(operator) {
case 'eq':
expect(lhs).to.equal(rhs);
break;
case 'neq':
expect(lhs).to.not.equal(rhs);
break;
case 'gt':
expect(lhs).to.be.greaterThan(rhs);
break;
case 'gte':
expect(lhs).to.be.greaterThanOrEqual(rhs);
break;
case 'lt':
expect(lhs).to.be.lessThan(rhs);
break;
case 'lte':
expect(lhs).to.be.lessThanOrEqual(rhs);
break;
case 'in':
expect(lhs).to.be.oneOf(rhs);
break;
case 'notIn':
expect(lhs).to.not.be.oneOf(rhs);
break;
case 'contains':
expect(lhs).to.include(rhs);
break;
case 'notContains':
expect(lhs).to.not.include(rhs);
break;
case 'length':
expect(lhs).to.have.lengthOf(rhs);
break;
case 'matches':
expect(lhs).to.match(new RegExp(rhs));
break;
case 'notMatches':
expect(lhs).to.not.match(new RegExp(rhs));
break;
case 'startsWith':
expect(lhs).to.startWith(rhs);
break;
case 'endsWith':
expect(lhs).to.endWith(rhs);
break;
case 'between':
const [min, max] = value.split(',');
expect(lhs).to.be.within(min, max);
break;
case 'isEmpty':
expect(lhs).to.be.empty;
break;
case 'isNull':
expect(lhs).to.be.null;
break;
case 'isUndefined':
expect(lhs).to.be.undefined;
break;
case 'isDefined':
expect(lhs).to.not.be.undefined;
break;
case 'isTruthy':
expect(lhs).to.be.true;
break;
case 'isFalsy':
expect(lhs).to.be.false;
break;
case 'isJson':
expect(lhs).to.be.json;
break;
case 'isNumber':
expect(lhs).to.be.a('number');
break;
case 'isString':
expect(lhs).to.be.a('string');
break;
case 'isBoolean':
expect(lhs).to.be.a('boolean');
break;
default:
expect(lhs).to.equal(rhs);
break;
}
assertionResults.push({
uid: nanoid(),
lhsExpr,
rhsExpr,
rhsOperand,
operator,
status: 'pass'
});
}
catch (err) {
assertionResults.push({
uid: nanoid(),
lhsExpr,
rhsExpr,
rhsOperand,
operator,
status: 'fail',
error: err.message
});
}
}
return assertionResults;
}
}
module.exports = AssertRuntime;

View File

@@ -0,0 +1,119 @@
const { NodeVM } = require('vm2');
const path = require('path');
const http = require('http');
const https = require('https');
const stream = require('stream');
const util = require('util');
const zlib = require('zlib');
const url = require('url');
const punycode = require('punycode');
const Bru = require('../bru');
const BrunoRequest = require('../bruno-request');
const BrunoResponse = require('../bruno-response');
// Inbuilt Library Support
const atob = require('atob');
const btoa = require('btoa');
const lodash = require('lodash');
const moment = require('moment');
const uuid = require('uuid');
const nanoid = require('nanoid');
const axios = require('axios');
const CryptoJS = require('crypto-js');
class ScriptRuntime {
constructor() {
}
async runRequestScript(script, request, envVariables, collectionVariables, collectionPath) {
const bru = new Bru(envVariables, collectionVariables);
const req = new BrunoRequest(request);
const context = {
bru,
req
};
const vm = new NodeVM({
sandbox: context,
require: {
context: 'sandbox',
external: true,
root: [collectionPath],
mock: {
// node libs
path,
stream,
util,
url,
http,
https,
punycode,
zlib,
// 3rd party libs
atob,
btoa,
lodash,
moment,
uuid,
nanoid,
axios,
'crypto-js': CryptoJS
}
}
});
// wrap script inside a async function that gets called
script = `return (async () => { ${script} })()`;
// bug that needs to be fixed
// vm.run is not awaiting the async function
// created an issue in vm2 repo: https://github.com/patriksimek/vm2/issues/513
const result = await vm.run(script, path.join(collectionPath, 'vm.js'));
console.log(result);
return {
request,
envVariables,
collectionVariables
};
}
runResponseScript(script, request, response, envVariables, collectionVariables, collectionPath) {
const bru = new Bru(envVariables, collectionVariables);
const req = new BrunoRequest(request);
const res = new BrunoResponse(response);
const context = {
bru,
req,
res
};
const vm = new NodeVM({
sandbox: context,
require: {
context: 'sandbox',
external: true,
root: [collectionPath],
mock: {
atob,
btoa,
lodash,
moment,
uuid,
nanoid,
'crypto-js': CryptoJS
}
}
});
vm.run(script, path.join(collectionPath, 'vm.js'));
return {
response,
envVariables,
collectionVariables
};
}
}
module.exports = ScriptRuntime;

View File

@@ -0,0 +1,70 @@
const { NodeVM } = require('vm2');
const chai = require('chai');
const path = require('path');
const Bru = require('../bru');
const BrunoRequest = require('../bruno-request');
const BrunoResponse = require('../bruno-response');
const Test = require('../test');
const TestResults = require('../test-results');
// Inbuilt Library Support
const atob = require('atob');
const btoa = require('btoa');
const lodash = require('lodash');
const moment = require('moment');
const uuid = require('uuid');
const nanoid = require('nanoid');
const CryptoJS = require('crypto-js');
class TestRuntime {
constructor() {
}
runTests(testsFile, request, response, envVariables, collectionVariables, collectionPath) {
const bru = new Bru(envVariables, collectionVariables);
const req = new BrunoRequest(request);
const res = new BrunoResponse(response);
const __brunoTestResults = new TestResults();
const test = Test(__brunoTestResults, chai);
const context = {
test,
bru,
req,
res,
expect: chai.expect,
assert: chai.assert,
__brunoTestResults: __brunoTestResults
};
const vm = new NodeVM({
sandbox: context,
require: {
context: 'sandbox',
external: true,
root: [collectionPath],
mock: {
atob,
btoa,
lodash,
moment,
uuid,
nanoid,
'crypto-js': CryptoJS
}
}
});
vm.run(testsFile, path.join(collectionPath, 'vm.js'));
return {
request,
envVariables,
collectionVariables,
results: __brunoTestResults.getResults()
};
}
}
module.exports = TestRuntime;

Some files were not shown because too many files have changed in this diff Show More