Compare commits

..

52 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
David Coomber
9d3762702f Removed redundant local dev environment instructions 2023-02-16 21:22:35 +02:00
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
85 changed files with 2053 additions and 243 deletions

View File

@@ -15,6 +15,10 @@ 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

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

@@ -15,19 +15,33 @@ nvm use
npm i --legacy-peer-deps
# build graphql docs
# note: you can for now ignore the error thrown while building the graphql docs
npm run build:graphql-docs
# build bruno query
npm run build:bruno-query
# run next app (terminal 1)
npm run dev --workspace=packages/bruno-app
npm run dev:web
# run electron app (terminal 2)
npm run dev --workspace=packages/bruno-electron
npm run dev:electron
```
### 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.
```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
@@ -35,6 +49,5 @@ You might encounter a `Unsupported platform` error when you run `npm install`. T
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-schema
npm test --workspace=packages/bruno-lang
```

View File

@@ -7,6 +7,7 @@
"packages/bruno-cli",
"packages/bruno-tauri",
"packages/bruno-schema",
"packages/bruno-query",
"packages/bruno-js",
"packages/bruno-lang",
"packages/bruno-testbench",
@@ -14,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"
@@ -32,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,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

@@ -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 ${focusedTab.requestPaneTab === 'script' ? '' : '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

@@ -6,8 +6,7 @@ const StyledWrapper = styled.div`
}
div.title {
color: rgb(155 155 155);
font-weight: 500;
color: var(--color-tab-inactive);
}
`;

View File

@@ -41,8 +41,8 @@ const Script = ({ item, collection }) => {
return (
<StyledWrapper className="w-full flex flex-col">
<div className='flex-1'>
<div className='mb-1 title'>Request</div>
<div className='flex-1 mt-2'>
<div className='mb-1 title text-xs'>Pre Request</div>
<CodeEditor
collection={collection} value={requestScript || ''}
theme={storedTheme}
@@ -53,7 +53,7 @@ const Script = ({ item, collection }) => {
/>
</div>
<div className='flex-1 mt-6'>
<div className='mt-1 mb-1 title'>Response</div>
<div className='mt-1 mb-1 title text-xs'>Post Response</div>
<CodeEditor
collection={collection} value={responseScript || ''}
theme={storedTheme}

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

@@ -50,6 +50,14 @@ 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';
}
}
});
@@ -68,8 +76,12 @@ export default function RunnerResults({collection}) {
};
const totalRequestsInCollection = getTotalRequestCountInCollection(collectionCopy);
const passedRequests = items.filter((item) => item.status !== "error" && item.testStatus === 'pass');
const failedRequests = items.filter((item) => item.status !== "error" && item.testStatus === 'fail');
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 (
@@ -139,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"/>
@@ -158,6 +170,26 @@ 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>

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

@@ -102,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
@@ -118,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>
@@ -145,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.9.4</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

@@ -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

@@ -10,6 +10,7 @@ import {
requestSentEvent,
requestQueuedEvent,
testResultsEvent,
assertionResultsEvent,
scriptEnvironmentUpdateEvent,
collectionRenamedEvent,
runFolderEvent
@@ -111,6 +112,10 @@ const useCollectionTreeSync = () => {
dispatch(testResultsEvent(val));
};
const _assertionResults = (val) => {
dispatch(assertionResultsEvent(val));
};
const _collectionRenamed = (val) => {
dispatch(collectionRenamedEvent(val));
};
@@ -129,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();
@@ -143,6 +149,7 @@ const useCollectionTreeSync = () => {
removeListener8();
removeListener9();
removeListener10();
removeListener11();
};
}, [isElectron]);
};

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

@@ -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,
@@ -183,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) {
@@ -191,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) {
@@ -201,7 +201,6 @@ export const collectionsSlice = createSlice({
}
collection.collectionVariables = collectionVariables;
}
},
requestCancelled: (state, action) => {
@@ -707,6 +706,153 @@ 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);
@@ -876,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);
@@ -966,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;
@@ -1028,6 +1192,12 @@ export const {
updateResponseScript,
updateRequestTests,
updateRequestMethod,
addAssertion,
updateAssertion,
deleteAssertion,
addVar,
updateVar,
deleteVar,
collectionAddFileEvent,
collectionAddDirectoryEvent,
collectionChangeFileEvent,
@@ -1035,6 +1205,7 @@ export const {
collectionUnlinkDirectoryEvent,
collectionAddEnvFileEvent,
testResultsEvent,
assertionResultsEvent,
collectionRenamedEvent,
toggleRunnerView,
showRunnerView,

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);
}

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': {

View File

@@ -32,6 +32,9 @@ export const updateUidsInCollection = (_collection) => {
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()));

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

@@ -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://*/"
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "@usebruno/cli",
"version": "0.2.3",
"version": "0.3.0",
"main": "src/index.js",
"bin": {
"bru": "./bin/bru.js"
@@ -11,7 +11,7 @@
"package.json"
],
"dependencies": {
"@usebruno/js": "0.1.1",
"@usebruno/js": "0.2.0",
"@usebruno/lang": "0.2.2",
"axios": "^1.3.2",
"chai": "^4.3.7",

View File

@@ -60,7 +60,7 @@ const runSingleRequest = async function (filename, bruJson, collectionPath, coll
// run assertions
let assertionResults = [];
const assertions = get(bruJson, 'request.assert');
const assertions = get(bruJson, 'request.assertions');
if(assertions && assertions.length) {
const assertRuntime = new AssertRuntime();
assertionResults = assertRuntime.runAssertions(assertions, request, response, envVariables, collectionVariables, collectionPath);

View File

@@ -42,7 +42,7 @@ const bruToJson = (bru) => {
"headers": _.get(json, "headers", []),
"body": _.get(json, "body", {}),
"vars": _.get(json, "vars", []),
"assert": _.get(json, "assert", []),
"assertions": _.get(json, "assertions", []),
"script": _.get(json, "script", ""),
"tests": _.get(json, "tests", "")
}

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.9.4",
"version": "0.10.2",
"name": "bruno",
"description": "Opensource API Client",
"homepage": "https://www.usebruno.com",
@@ -9,17 +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/js": "0.2.0",
"@usebruno/lang": "0.2.2",
"@usebruno/schema": "0.1.0",
"@usebruno/schema": "0.3.1",
"axios": "^0.26.0",
"chai": "^4.3.7",
"chokidar": "^3.5.3",
"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",

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

@@ -40,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());

View File

@@ -68,6 +68,8 @@ const bruToJson = (bru) => {
"headers": _.get(json, "headers", []),
"body": _.get(json, "body", {}),
"script": _.get(json, "script", {}),
"vars": _.get(json, "vars", {}),
"assertions": _.get(json, "assertions", []),
"tests": _.get(json, "tests", "")
}
};
@@ -113,6 +115,11 @@ const jsonToBru = (json) => {
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', ''),
};

View File

@@ -3,7 +3,7 @@ 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');
@@ -96,13 +96,27 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
const envVars = getEnvVars(environment);
// 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', {
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 = scriptRuntime.runRequestScript(requestScript, request, envVars, collectionVariables, collectionPath);
const result = await scriptRuntime.runRequestScript(requestScript, request, envVars, collectionVariables, collectionPath);
mainWindow.webContents.send('main:script-environment-update', {
environment: result.environment,
envVariables: result.envVariables,
collectionVariables: result.collectionVariables,
collectionUid
});
@@ -127,18 +141,44 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
const response = await axios(request);
// 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', {
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', {
environment: result.environment,
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();
@@ -284,18 +324,27 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
request.data = form;
}
// 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(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:
@@ -312,22 +361,52 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
...eventData
});
// send request
timeStart = Date.now();
const response = await axios(request);
timeEnd = Date.now();
// 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', {
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', {
environment: result.environment,
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();

View File

@@ -63,6 +63,9 @@ const prepareRequest = (request) => {
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.1",
"version": "0.2.0",
"main": "src/index.js",
"files": [
"src",
@@ -13,8 +13,9 @@
"test": "jest --testPathIgnorePatterns test.js"
},
"dependencies": {
"atob": "^2.1.2",
"@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",

View File

@@ -1,17 +1,49 @@
const _ = require('lodash');
const chai = require('chai');
const chai = require('chai');
const { nanoid } = require('nanoid');
const Bru = require('../bru');
const BrunoRequest = require('../bruno-request');
const { evaluateJsExpression, createResponseParser } = require('../utils');
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
* like : like
* gt : greater than
* gte : greater than or equal to
* lt : less than
@@ -20,7 +52,6 @@ const { expect } = chai;
* notIn : not in
* contains : contains
* notContains : not contains
* count : count
* length : length
* matches : matches
* notMatches : not matches
@@ -47,15 +78,26 @@ const parseAssertionOperator = (str = '') => {
}
const operators = [
'eq', 'neq', 'like', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn',
'contains', 'notContains', 'count', 'length', 'matches', 'notMatches',
'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,
@@ -69,6 +111,45 @@ const parseAssertionOperator = (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);
@@ -105,7 +186,7 @@ class AssertRuntime {
try {
const lhs = evaluateJsExpression(lhsExpr, context);
const rhs = evaluateJsExpression(rhsOperand, context);
const rhs = evaluateRhsOperand(rhsOperand, operator, context);
switch(operator) {
case 'eq':
@@ -114,9 +195,6 @@ class AssertRuntime {
case 'neq':
expect(lhs).to.not.equal(rhs);
break;
case 'like':
expect(lhs).to.match(new RegExp(rhs));
break;
case 'gt':
expect(lhs).to.be.greaterThan(rhs);
break;
@@ -141,9 +219,6 @@ class AssertRuntime {
case 'notContains':
expect(lhs).to.not.include(rhs);
break;
case 'count':
expect(lhs).to.have.lengthOf(rhs);
break;
case 'length':
expect(lhs).to.have.lengthOf(rhs);
break;
@@ -160,7 +235,7 @@ class AssertRuntime {
expect(lhs).to.endWith(rhs);
break;
case 'between':
const [min, max] = value.split(' ');
const [min, max] = value.split(',');
expect(lhs).to.be.within(min, max);
break;
case 'isEmpty':
@@ -199,6 +274,7 @@ class AssertRuntime {
}
assertionResults.push({
uid: nanoid(),
lhsExpr,
rhsExpr,
rhsOperand,
@@ -208,6 +284,7 @@ class AssertRuntime {
}
catch (err) {
assertionResults.push({
uid: nanoid(),
lhsExpr,
rhsExpr,
rhsOperand,
@@ -222,4 +299,4 @@ class AssertRuntime {
}
}
module.exports = AssertRuntime;
module.exports = AssertRuntime;

View File

@@ -1,5 +1,12 @@
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');
@@ -11,14 +18,15 @@ 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() {
}
runRequestScript(script, request, environment, collectionVariables, collectionPath) {
const bru = new Bru(environment, collectionVariables);
async runRequestScript(script, request, envVariables, collectionVariables, collectionPath) {
const bru = new Bru(envVariables, collectionVariables);
const req = new BrunoRequest(request);
const context = {
@@ -32,28 +40,46 @@ class ScriptRuntime {
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
}
}
});
vm.run(script, path.join(collectionPath, 'vm.js'));
// 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,
environment,
envVariables,
collectionVariables
};
}
runResponseScript(script, request, response, environment, collectionVariables, collectionPath) {
const bru = new Bru(environment, collectionVariables);
runResponseScript(script, request, response, envVariables, collectionVariables, collectionPath) {
const bru = new Bru(envVariables, collectionVariables);
const req = new BrunoRequest(request);
const res = new BrunoResponse(response);
@@ -84,7 +110,7 @@ class ScriptRuntime {
return {
response,
environment,
envVariables,
collectionVariables
};
}

View File

@@ -20,8 +20,8 @@ class TestRuntime {
constructor() {
}
runTests(testsFile, request, response, environment, collectionVariables, collectionPath) {
const bru = new Bru(environment, collectionVariables);
runTests(testsFile, request, response, envVariables, collectionVariables, collectionPath) {
const bru = new Bru(envVariables, collectionVariables);
const req = new BrunoRequest(request);
const res = new BrunoResponse(response);
@@ -60,7 +60,7 @@ class TestRuntime {
return {
request,
environment,
envVariables,
collectionVariables,
results: __brunoTestResults.getResults()
};

View File

@@ -1,7 +1,7 @@
const _ = require('lodash');
const Bru = require('../bru');
const BrunoRequest = require('../bruno-request');
const { evaluateJsExpression, createResponseParser } = require('../utils');
const { evaluateJsTemplateLiteral, evaluateJsExpression, createResponseParser } = require('../utils');
class VarsRuntime {
runPreRequestVars(vars, request, envVariables, collectionVariables, collectionPath) {
@@ -25,9 +25,13 @@ class VarsRuntime {
}
_.each(enabledVars, (v) => {
const value = evaluateJsExpression(v.value, context);
const value = evaluateJsTemplateLiteral(v.value, context);
bru.setVar(v.name, value);
});
return {
collectionVariables
};
}
runPostResponseVars(vars, request, response, envVariables, collectionVariables, collectionPath) {
@@ -56,6 +60,11 @@ class VarsRuntime {
const value = evaluateJsExpression(v.value, context);
bru.setVar(v.name, value);
});
return {
envVariables,
collectionVariables
};
}
}

View File

@@ -1,4 +1,5 @@
const jsonQuery = require('json-query');
const { get } = require("@usebruno/query");
const JS_KEYWORDS = `
break case catch class const continue debugger default delete do
@@ -59,10 +60,49 @@ const evaluateJsExpression = (expression, context) => {
return fn(context);
};
const evaluateJsTemplateLiteral = (templateLiteral, context) => {
if(!templateLiteral || !templateLiteral.length || typeof templateLiteral !== 'string') {
return templateLiteral;
}
templateLiteral = templateLiteral.trim();
if(templateLiteral === 'true') {
return true;
}
if(templateLiteral === 'false') {
return false;
}
if(templateLiteral === 'null') {
return null;
}
if(templateLiteral === 'undefined') {
return undefined;
}
if(templateLiteral.startsWith('"') && templateLiteral.endsWith('"')) {
return templateLiteral.slice(1, -1);
}
if(templateLiteral.startsWith("'") && templateLiteral.endsWith("'")) {
return templateLiteral.slice(1, -1);
}
if(!isNaN(templateLiteral)) {
return Number(templateLiteral);
}
templateLiteral = "`" + templateLiteral + "`";
return evaluateJsExpression(templateLiteral, context);
};
const createResponseParser = (response = {}) => {
const res = (expr) => {
const output = jsonQuery(expr, { data: response.data });
return output ? output.value : null;
const res = (expr, ...fns) => {
return get(response.data, expr, ...fns);
};
res.status = response.status;
@@ -70,11 +110,17 @@ const createResponseParser = (response = {}) => {
res.headers = response.headers;
res.body = response.data;
res.jq = (expr) => {
const output = jsonQuery(expr, { data: response.data });
return output ? output.value : null;
};
return res;
};
module.exports = {
evaluateJsExpression,
evaluateJsTemplateLiteral,
createResponseParser,
internalExpressionCache
};

View File

@@ -1,4 +1,5 @@
const { evaluateJsExpression, internalExpressionCache: cache } = require("../src/utils");
const { describe, it, expect } = require("@jest/globals");
const { evaluateJsExpression, internalExpressionCache: cache, createResponseParser } = require("../src/utils");
describe("utils", () => {
describe("expression evaluation", () => {
@@ -112,4 +113,28 @@ describe("utils", () => {
expect(result).toBe(startTime);
});
});
describe("response parser", () => {
const res = createResponseParser({
status: 200,
data: {
order: {
items: [
{ id: 1, amount: 10 },
{ id: 2, amount: 20 }
]
}
}
});
it("should default to bruno query", () => {
const value = res("..items[?].amount[0]", i => i.amount > 10);
expect(value).toBe(20);
});
it("should allow json-query", () => {
const value = res.jq("order.items[amount > 10].amount");
expect(value).toBe(20);
});
});
});

View File

@@ -381,7 +381,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
},
assert(_1, dictionary) {
return {
assert: mapPairListToKeyValPairs(dictionary.ast)
assertions: mapPairListToKeyValPairs(dictionary.ast)
};
},
scriptreq(_1, _2, _3, _4, textblock, _5) {

View File

@@ -24,7 +24,7 @@ const jsonToBru = (json) => {
script,
tests,
vars,
assert,
assertions,
docs
} = json;
@@ -196,15 +196,15 @@ ${indentString(body.xml)}
bru += '\n}\n\n';
}
if(assert && assert.length) {
if(assertions && assertions.length) {
bru += `assert {`;
if(enabled(assert).length) {
bru += `\n${indentString(enabled(assert).map(item => `${item.name}: ${item.value}`).join('\n'))}`;
if(enabled(assertions).length) {
bru += `\n${indentString(enabled(assertions).map(item => `${item.name}: ${item.value}`).join('\n'))}`;
}
if(disabled(assert).length) {
bru += `\n${indentString(disabled(assert).map(item => `~${item.name}: ${item.value}`).join('\n'))}`;
if(disabled(assertions).length) {
bru += `\n${indentString(disabled(assertions).map(item => `~${item.name}: ${item.value}`).join('\n'))}`;
}
bru += '\n}\n\n';

View File

@@ -13,7 +13,7 @@ assert {
const output = parser(input);
const expected = {
"assert": [{
"assertions": [{
name: "res(\"data.airports\").filter(a => a.code ===\"BLR\").name",
value: '"Bangalore International Airport"',
enabled: true

View File

@@ -124,7 +124,7 @@
}
]
},
"assert": [
"assertions": [
{
"name": "$res.status",
"value": "200",

22
packages/bruno-query/.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
# dependencies
node_modules
yarn.lock
pnpm-lock.yaml
package-lock.json
.pnp
.pnp.js
# testing
coverage
# production
dist
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

View File

@@ -0,0 +1,32 @@
{
"name": "@usebruno/query",
"version": "0.1.0",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"src",
"package.json"
],
"scripts": {
"clean": "rimraf dist",
"test": "jest",
"prebuild": "npm run clean",
"build": "rollup -c",
"prepack": "npm run test && npm run build"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^9.0.2",
"rollup": "3.2.5",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-terser": "^7.0.2",
"typescript": "^4.8.4"
},
"overrides": {
"rollup": "3.2.5"
}
}

View File

@@ -0,0 +1,33 @@
# bruno-query
Bruno query with deep navigation, filter and map support
Easy array navigation
```js
get(data, 'customer.orders.items.amount')
```
Deep navigation .. double dots
```js
get(data, '..items.amount')
```
Array indexing
```js
get(data, '..items[0].amount')
```
Array filtering [?] with corresponding filter function
```js
get(data, '..items[?].amount', i => i.amount > 20)
```
Array filtering [?] with simple object predicate, same as (i => i.id === 2 && i.amount === 20)
```js
get(data, '..items[?]', { id: 2, amount: 20 })
```
Array mapping [?] with corresponding mapper function
```js
get(data, '..items[?].amount', i => i.amount + 10)
```
### Publish to Npm Registry
```bash
npm publish --access=public
```

View File

@@ -0,0 +1,40 @@
const { nodeResolve } = require("@rollup/plugin-node-resolve");
const commonjs = require("@rollup/plugin-commonjs");
const typescript = require("@rollup/plugin-typescript");
const dts = require("rollup-plugin-dts");
const { terser } = require("rollup-plugin-terser");
const peerDepsExternal = require('rollup-plugin-peer-deps-external');
const packageJson = require("./package.json");
module.exports = [
{
input: "src/index.ts",
output: [
{
file: packageJson.main,
format: "cjs",
sourcemap: true,
},
{
file: packageJson.module,
format: "esm",
sourcemap: true,
},
],
plugins: [
peerDepsExternal(),
nodeResolve({
extensions: ['.css']
}),
commonjs(),
typescript({ tsconfig: "./tsconfig.json" }),
terser()
]
},
{
input: "dist/esm/index.d.ts",
output: [{ file: "dist/index.d.ts", format: "esm" }],
plugins: [dts.default()],
}
];

View File

@@ -0,0 +1,151 @@
/**
* If value is an array returns the deeply flattened array, otherwise value
*/
function normalize(value: any) {
if (!Array.isArray(value)) return value;
const values = [] as any[];
value.forEach(item => {
const value = normalize(item);
if (value != null) {
values.push(...Array.isArray(value) ? value : [value]);
}
});
return values.length ? values : undefined;
}
/**
* Gets value of a prop from source.
*
* If source is an array get value from each item.
*
* If deep is true then recursively gets values for prop in nested objects.
*
* Once a value is found will not recurse further into that value.
*/
function getValue(source: any, prop: string, deep = false): any {
if (typeof source !== 'object') return;
let value;
if (Array.isArray(source)) {
value = source.map(item => getValue(item, prop, deep));
} else {
value = source[prop];
if (deep) {
value = [value];
for (const [key, item] of Object.entries(source)) {
if (key !== prop && typeof item === 'object') {
value.push(getValue(source[key], prop, deep));
}
}
}
}
return normalize(value);
}
type PredicateOrMapper = ((obj: any) => any) | Record<string, any>;
/**
* Make a predicate function that checks scalar properties for equality
*/
function objectPredicate(obj: Record<string, any>) {
return (item: any) => {
for (const [key, value] of Object.entries(obj)) {
if (item[key] !== value) return false;
}
return true;
};
}
/**
* Apply filter on source array or object
*
* If the filter returns a non boolean non null value it is treated as a mapped value
*/
function filterOrMap(source: any, funOrObj: PredicateOrMapper) {
const fun = typeof funOrObj === 'object' ? objectPredicate(funOrObj) : funOrObj;
const isArray = Array.isArray(source);
const list = isArray ? source : [source];
const result = [] as any[];
for (const item of list) {
if (item == null) continue;
const value = fun(item);
if (value === true) {
result.push(item); // predicate
} else if (value != null && value !== false) {
result.push(value); // mapper
}
}
return normalize(isArray ? result : result[0]);
}
/**
* Getter with deep navigation, filter and map support
*
* 1. Easy array navigation
* ```js
* get(data, 'customer.orders.items.amount')
* ```
* 2. Deep navigation .. double dots
* ```js
* get(data, '..items.amount')
* ```
* 3. Array indexing
* ```js
* get(data, '..items[0].amount')
* ```
* 4. Array filtering [?] with corresponding filter function
* ```js
* get(data, '..items[?].amount', i => i.amount > 20)
* ```
* 5. Array filtering [?] with simple object predicate, same as (i => i.id === 2 && i.amount === 20)
* ```js
* get(data, '..items[?]', { id: 2, amount: 20 })
* ```
* 6. Array mapping [?] with corresponding mapper function
* ```js
* get(data, '..items[?].amount', i => i.amount + 10)
* ```
*/
export function get(source: any, path: string, ...fns: PredicateOrMapper[]) {
const paths = path
.replace(/\s+/g, '')
.split(/(\.{1,2}|\[\?\]|\[\d+\])/g) // ["..", "items", "[?]", ".", "amount", "[0]" ]
.filter(s => s.length > 0)
.map(str => {
str = str.replace(/\[|\]/g, '');
const index = parseInt(str);
return isNaN(index) ? str : index;
});
let index = 0, lookbehind = '' as string | number, funIndex = 0;
while (source != null && index < paths.length) {
const token = paths[index++];
switch (true) {
case token === "..":
case token === ".":
break;
case token === "?":
const fun = fns[funIndex++];
if (fun == null)
throw new Error(`missing function for ${lookbehind}`);
source = filterOrMap(source, fun);
break;
case typeof token === 'number':
source = normalize(source[token]);
break;
default:
source = getValue(source, token as string, lookbehind === "..");
}
lookbehind = token;
}
return source;
}

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from '@jest/globals';
import { get } from '../src/index';
const data = {
customer: {
address: {
city: "bangalore"
},
orders: [
{
id: "order-1",
items: [
{ id: 1, amount: 10 },
{ id: 2, amount: 20 },
]
},
{
id: "order-2",
items: [
{ id: 3, amount: 30, },
{ id: 4, amount: 40 }
]
}
],
},
};
describe("get", () => {
it.each([
["customer.address.city", "bangalore"],
["customer.orders.items.amount", [10, 20, 30, 40]],
["customer.orders.items.amount[0]", 10],
["..items.amount", [10, 20, 30, 40]],
["..amount", [10, 20, 30, 40]],
["..items.amount[0]", 10],
["..items[0].amount", 10],
["..items[5].amount", undefined], // invalid index
["..id", ["order-1", 1, 2, "order-2", 3, 4]], // all ids
["customer.orders.foo", undefined],
["..customer.foo", undefined],
["..address", [{ city: "bangalore" }]], // .. will return array
["..address[0]", { city: "bangalore" }],
])("%s should be %j", (expr, result) => {
expect(get(data, expr)).toEqual(result);
});
// filter and map
it.each([
["..items[?].amount", [40], (i: any) => i.amount > 30], // [?] filter
["..items[?].amount", [40], { id: 4, amount: 40 }], // object filter
["..items[?].amount", undefined, { id: 5, amount: 40 }],
["..items..amount[?][0]", 40, (amt: number) => amt > 30],
["..items..amount[0][?]", undefined, (amt: number) => amt > 30], // filter on single value
["..items..amount[?]", [11, 21, 31, 41], (amt: number) => amt + 1], // [?] mapper
["..items..amount[0][?]", 11, (amt: number) => amt + 1], // [?] map on single value
])("%s should be %j for %s", (expr, result, filter) => {
expect(get(data, expr, filter)).toEqual(result);
});
});

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES6",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"jsx": "react",
"module": "ESNext",
"declaration": true,
"declarationDir": "types",
"sourceMap": true,
"outDir": "dist",
"moduleResolution": "node",
"emitDeclarationOnly": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true
},
"exclude": [
"dist",
"node_modules",
"tests"
],
}

View File

@@ -1,6 +1,6 @@
{
"name": "@usebruno/schema",
"version": "0.3.0",
"version": "0.3.1",
"main": "src/index.js",
"files": [
"src",

View File

@@ -26,6 +26,15 @@ const keyValueSchema = Yup.object({
enabled: Yup.boolean()
}).noUnknown(true).strict();
const varsSchema = Yup.object({
uid: uidSchema,
name: Yup.string().nullable(),
value: Yup.string().nullable(),
description: Yup.string().nullable(),
local: Yup.boolean(),
enabled: Yup.boolean()
}).noUnknown(true).strict();
const requestUrlSchema = Yup.string().min(0).defined();
const requestMethodSchema = Yup.string().oneOf(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']).required('method is required');
@@ -57,6 +66,11 @@ const requestSchema = Yup.object({
req: Yup.string().nullable(),
res: Yup.string().nullable()
}).noUnknown(true).strict(),
vars: Yup.object({
req: Yup.array().of(varsSchema).nullable(),
res: Yup.array().of(varsSchema).nullable()
}).noUnknown(true).strict().nullable(),
assertions: Yup.array().of(keyValueSchema).nullable(),
tests: Yup.string().nullable()
}).noUnknown(true).strict();

View File

@@ -1,26 +0,0 @@
#!/bin/bash
# Remove any chrome-extension directory
rm -rf chrome-extension
# Remove any bruno.zip files
rm bruno.zip
# Create a new chrome-extension directory
mkdir chrome-extension
# Copy build
cp -r packages/bruno-app/out/* chrome-extension
# Copy the chrome extension files
cp -r packages/bruno-chrome-extension/* chrome-extension
# Filenames starting with "_" are reserved for use by the system
mv chrome-extension/_next chrome-extension/next
sed -i'' -e 's@/_next/@/next/@g' chrome-extension/**.html
# Remove sourcemaps
find chrome-extension -name '*.map' -type f -delete
# Compress the chrome-extension directory into a zip file
zip -r bruno.zip chrome-extension