mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 01:18:32 +00:00
Compare commits
14 Commits
feat/ci-de
...
v3.5.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13988c4c02 | ||
|
|
cd43a61a8e | ||
|
|
8cfe2dae4c | ||
|
|
bad7956cfb | ||
|
|
5dcbafde91 | ||
|
|
c6ae80fecd | ||
|
|
ecff76e950 | ||
|
|
9a7f664037 | ||
|
|
daa0df98b3 | ||
|
|
6717035dd2 | ||
|
|
f05bb9c49d | ||
|
|
a09ddedf90 | ||
|
|
0e46d60ec4 | ||
|
|
b8804afade |
73
package-lock.json
generated
73
package-lock.json
generated
@@ -50,7 +50,7 @@
|
||||
"globals": "^16.1.0",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lodash-es": "^4.17.23",
|
||||
"nano-staged": "^0.8.0",
|
||||
"playwright": "^1.51.1",
|
||||
"pretty-quick": "^3.1.3",
|
||||
@@ -10058,9 +10058,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
|
||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
|
||||
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
@@ -12309,7 +12309,7 @@
|
||||
"@swagger-api/apidom-core": "^1.4.0",
|
||||
"@swagger-api/apidom-error": "^1.4.0",
|
||||
"@types/ramda": "~0.30.0",
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"minimatch": "^7.4.3",
|
||||
"process": "^0.11.10",
|
||||
"ramda": "~0.30.0",
|
||||
@@ -14611,9 +14611,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
@@ -14627,7 +14627,7 @@
|
||||
"integrity": "sha512-CS6WE8chZpEDKxv4IFwr5zcG7InMC6Ek0aj2n2tHauBh+8KiYVC4qMn3N2arjR5tnyILQuTGlI0mc83hgWxS4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"des.js": "^1.1.0",
|
||||
"dev-null": "^0.1.1",
|
||||
"js-md4": "^0.3.2"
|
||||
@@ -16494,7 +16494,7 @@
|
||||
"date-fns": "^2.30.0",
|
||||
"lodash": "^4.17.21",
|
||||
"rxjs": "^7.8.1",
|
||||
"shell-quote": "^1.8.1",
|
||||
"shell-quote": "^1.8.4",
|
||||
"spawn-command": "0.0.2",
|
||||
"supports-color": "^8.1.1",
|
||||
"tree-kill": "^1.2.2",
|
||||
@@ -23724,15 +23724,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"version": "4.18.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.0.tgz",
|
||||
"integrity": "sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||
"version": "4.18.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.0.tgz",
|
||||
"integrity": "sha512-koAgswPPA+UTaPN64Etp+PGP+WT6oqOS2NMi5yDkMaiGw9qY4VxQbQF0mtKMyr4BlTznWyzePV5UpECTJQmSUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.camelcase": {
|
||||
@@ -26607,7 +26607,7 @@
|
||||
"integrity": "sha512-l+fsjYEkTik3m/G0pE7gMr4qBJP84LhK779oQm6MBzhBGpd4By4qieTW+4FUAlNCyzQTynn3Nhsa50c0IELSxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"rusha": "^0.8.14"
|
||||
},
|
||||
"engines": {
|
||||
@@ -26864,9 +26864,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz",
|
||||
"integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==",
|
||||
"version": "7.5.8",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz",
|
||||
"integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -29443,10 +29443,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz",
|
||||
"integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==",
|
||||
"dev": true,
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
|
||||
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -32844,7 +32843,7 @@
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"sass": "^1.46.0",
|
||||
"semver": "^7.7.1",
|
||||
"shell-quote": "^1.8.3",
|
||||
"shell-quote": "^1.8.4",
|
||||
"strip-json-comments": "^5.0.1",
|
||||
"styled-components": "^5.3.3",
|
||||
"swagger-ui-react": "^5.31.0",
|
||||
@@ -34335,18 +34334,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"packages/bruno-app/node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"packages/bruno-app/node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
@@ -34397,7 +34384,7 @@
|
||||
"@usebruno/lang": "0.12.0",
|
||||
"@usebruno/requests": "^0.1.0",
|
||||
"aws4-axios": "^3.3.15",
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"axios-ntlm": "^1.4.2",
|
||||
"chai": "^4.3.7",
|
||||
"chalk": "^3.0.0",
|
||||
@@ -35051,7 +35038,7 @@
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^7.0.1",
|
||||
"aws4-axios": "^3.3.15",
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"axios-ntlm": "^1.4.2",
|
||||
"chai": "^4.3.7",
|
||||
"chokidar": "^3.5.3",
|
||||
@@ -35826,7 +35813,7 @@
|
||||
"ajv": "^8.12.0",
|
||||
"ajv-formats": "^2.1.1",
|
||||
"atob": "^2.1.2",
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"btoa": "^1.2.1",
|
||||
"chai": "^4.3.7",
|
||||
"chai-string": "^1.5.0",
|
||||
@@ -36032,7 +36019,7 @@
|
||||
"@grpc/grpc-js": "^1.13.3",
|
||||
"@grpc/proto-loader": "^0.7.15",
|
||||
"@types/qs": "^6.9.18",
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"debug": "^4.4.3",
|
||||
"google-protobuf": "^4.0.0",
|
||||
"grpc-js-reflection-client": "^1.3.0",
|
||||
@@ -36240,7 +36227,7 @@
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"body-parser": "2.2.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
@@ -36508,4 +36495,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@
|
||||
"globals": "^16.1.0",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lodash-es": "^4.17.23",
|
||||
"nano-staged": "^0.8.0",
|
||||
"playwright": "^1.51.1",
|
||||
"pretty-quick": "^3.1.3",
|
||||
@@ -94,7 +94,7 @@
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"axios":"1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"rollup": "3.30.0",
|
||||
"pbkdf2":"3.1.5",
|
||||
"electron-store": {
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"sass": "^1.46.0",
|
||||
"semver": "^7.7.1",
|
||||
"shell-quote": "^1.8.3",
|
||||
"shell-quote": "^1.8.4",
|
||||
"strip-json-comments": "^5.0.1",
|
||||
"styled-components": "^5.3.3",
|
||||
"swagger-ui-react": "^5.31.0",
|
||||
|
||||
@@ -317,6 +317,7 @@ const StyledWrapper = styled.div`
|
||||
height: 100% !important;
|
||||
max-height: 400px !important;
|
||||
padding: 0.5rem !important;
|
||||
overflow: auto !important;
|
||||
|
||||
.network-logs-pre {
|
||||
color: ${(props) => props.theme.console.messageColor} !important;
|
||||
|
||||
@@ -22,7 +22,9 @@ const Script = ({ collection, folder }) => {
|
||||
const responseScript = folder.draft ? get(folder, 'draft.request.script.res', '') : get(folder, 'root.request.script.res', '');
|
||||
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const focusedTab = find(tabs, (t) => t.uid === folder.uid);
|
||||
const focusedTab = find(tabs, (tab) => tab.type === 'folder-settings' && (tab.uid === folder.uid || tab.folderUid === folder.uid))
|
||||
|| find(tabs, (tab) => tab.type === 'folder-settings' && tab.pathname === folder.pathname);
|
||||
const tabUid = focusedTab?.uid || folder.uid;
|
||||
const scriptPaneTab = focusedTab?.scriptPaneTab;
|
||||
|
||||
// Default to post-response if pre-request script is empty (only when scriptPaneTab is null/undefined)
|
||||
@@ -34,7 +36,7 @@ const Script = ({ collection, folder }) => {
|
||||
const activeTab = scriptPaneTab || getDefaultTab();
|
||||
|
||||
const setActiveTab = (tab) => {
|
||||
dispatch(updateScriptPaneTab({ uid: folder.uid, scriptPaneTab: tab }));
|
||||
dispatch(updateScriptPaneTab({ uid: tabUid, scriptPaneTab: tab }));
|
||||
};
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
|
||||
@@ -3,11 +3,11 @@ import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import debounce from 'lodash/debounce';
|
||||
import toast from 'react-hot-toast';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { savePreferences, refreshPacCache } from 'providers/ReduxStore/slices/app';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
import { IconEye, IconEyeOff, IconRefresh } from '@tabler/icons';
|
||||
import { useState } from 'react';
|
||||
import SystemProxy from './SystemProxy';
|
||||
|
||||
@@ -103,6 +103,12 @@ const ProxySettings = ({ close }) => {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRefreshPac = () => {
|
||||
dispatch(refreshPacCache())
|
||||
.then(() => toast.success('PAC cache refreshed'))
|
||||
.catch(() => toast.error('Failed to refresh PAC cache'));
|
||||
};
|
||||
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
const [proxyMode, setProxyMode] = useState(() => {
|
||||
if (preferences.proxy.disabled) return 'off';
|
||||
@@ -451,6 +457,15 @@ const ProxySettings = ({ close }) => {
|
||||
? 'Enter the URL to your PAC file'
|
||||
: 'Supports .pac files for automatic proxy configuration'}
|
||||
</p>
|
||||
{formik.values.pac.source ? (
|
||||
<span
|
||||
className="text-link cursor-pointer hover:underline flex flex-row items-center w-fit mt-2"
|
||||
onClick={handleRefreshPac}
|
||||
>
|
||||
<IconRefresh size={14} strokeWidth={1.5} className="mr-1" />
|
||||
Refetch
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -52,8 +52,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
|| tab.type === 'graphql-request'
|
||||
|| tab.type === 'grpc-request'
|
||||
|| tab.type === 'ws-request';
|
||||
const shouldSyncUid = isRequestType || tab.type === 'folder-settings';
|
||||
|
||||
if (!isRequestType || !tab.pathname || !item?.uid || tab.uid === item.uid) {
|
||||
if (!shouldSyncUid || !tab.pathname || !item?.uid || tab.uid === item.uid) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -206,7 +207,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
|
||||
// Close tab shortcut — draft-aware, only active for the focused tab
|
||||
useKeybinding('closeTab', () => {
|
||||
if (tab.type === 'request' || tab.type === 'grpc-request' || tab.type === 'ws-request' || tab.type === 'graphql-request') {
|
||||
if (tab.type === 'request' || tab.type === 'http-request' || tab.type === 'grpc-request' || tab.type === 'ws-request' || tab.type === 'graphql-request') {
|
||||
if (hasChanges) {
|
||||
setShowConfirmClose(true);
|
||||
} else {
|
||||
|
||||
@@ -36,7 +36,7 @@ const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, type })
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tl-empty">No Body found</div>
|
||||
<div className="tl-empty">No Body</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ const Headers = ({ headers }) => {
|
||||
</button>
|
||||
{isOpen && (
|
||||
count === 0
|
||||
? <div className="tl-empty">No Headers found</div>
|
||||
? <div className="tl-empty">No Headers</div>
|
||||
: (
|
||||
<table className="tl-headers-table">
|
||||
<tbody>
|
||||
|
||||
@@ -21,6 +21,7 @@ const Status = ({ statusCode }) => {
|
||||
return (
|
||||
<span
|
||||
className="timeline-status"
|
||||
data-testid="timeline-status"
|
||||
style={{
|
||||
color,
|
||||
background,
|
||||
|
||||
@@ -137,7 +137,7 @@ const TimelineItem = ({
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className={`tl-row-wrap ${isOauth2 ? 'tl-row-wrap--oauth2' : ''}`}>
|
||||
<div className={`tl-row-wrap ${isOauth2 ? 'tl-row-wrap--oauth2' : ''}`} data-testid="timeline-entry">
|
||||
<div
|
||||
className={`tl-row ${isExpanded ? 'is-expanded' : ''}`}
|
||||
role="button"
|
||||
@@ -155,9 +155,9 @@ const TimelineItem = ({
|
||||
<div className="tl-col-method">
|
||||
<Method method={method} />
|
||||
</div>
|
||||
<div className="tl-col-url" title={url}>{url}</div>
|
||||
<div className="tl-col-url" title={url} data-testid="timeline-url">{url}</div>
|
||||
<div className="tl-col-badge">
|
||||
<span className={badge.badgeClass}>{badge.badgeLabel}</span>
|
||||
<span className={badge.badgeClass} data-testid={`timeline-badge-${badge.kind}`}>{badge.badgeLabel}</span>
|
||||
</div>
|
||||
{!hideTimestamp && (
|
||||
<div className="tl-col-time">
|
||||
@@ -167,7 +167,7 @@ const TimelineItem = ({
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="tl-detail">
|
||||
<div className="tl-detail" data-testid="timeline-detail">
|
||||
<div className="tl-header">
|
||||
<div className="tl-header-url" title={`${method || ''} ${url}`}>
|
||||
<span className="tl-header-url-method">{method}</span>
|
||||
@@ -179,8 +179,9 @@ const TimelineItem = ({
|
||||
href="#"
|
||||
title={canNavigate ? `Open ${sourceFile}` : sourceFile}
|
||||
onClick={canNavigate ? handleNavigate : (ev) => ev.preventDefault()}
|
||||
data-testid="timeline-source-link"
|
||||
>
|
||||
<span className="tl-header-src-file">{sourceFile}</span>
|
||||
<span className="tl-header-src-file" data-testid="timeline-source-file">{sourceFile}</span>
|
||||
<span className="tl-header-src-icon">↗</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Keys must match getEntryKind() in buildEntries.js.
|
||||
// `kind` is a stable identifier used for data-testids (e.g. timeline-badge-pre).
|
||||
export const ENTRY_KINDS = {
|
||||
main: { chipLabel: 'Main', badgeLabel: 'main', badgeClass: 'tl-badge tl-badge--main' },
|
||||
oauth: { chipLabel: 'OAuth', badgeLabel: 'oauth2.0', badgeClass: 'tl-badge tl-badge--oauth2' },
|
||||
pre: { chipLabel: 'Pre-Request', badgeLabel: 'sendRequest', badgeClass: 'tl-badge tl-badge--scripted' },
|
||||
post: { chipLabel: 'Post-Response', badgeLabel: 'runRequest', badgeClass: 'tl-badge tl-badge--run-request' }
|
||||
main: { kind: 'main', chipLabel: 'Request', badgeLabel: 'request', badgeClass: 'tl-badge tl-badge--main' },
|
||||
oauth: { kind: 'oauth', chipLabel: 'OAuth', badgeLabel: 'oauth2.0', badgeClass: 'tl-badge tl-badge--oauth2' },
|
||||
pre: { kind: 'pre', chipLabel: 'Pre-Request', badgeLabel: 'sendRequest', badgeClass: 'tl-badge tl-badge--scripted' },
|
||||
post: { kind: 'post', chipLabel: 'Post-Response', badgeLabel: 'runRequest', badgeClass: 'tl-badge tl-badge--run-request' }
|
||||
};
|
||||
|
||||
export const FILTER_CHIPS = [
|
||||
|
||||
@@ -78,22 +78,23 @@ const Timeline = ({ collection, item }) => {
|
||||
ref={wrapperRef}
|
||||
>
|
||||
{showFilterBar && (
|
||||
<div className="timeline-filter-bar">
|
||||
<div className="timeline-filter-bar" data-testid="timeline-filter-bar">
|
||||
{visibleChips.map((chip) => (
|
||||
<button
|
||||
key={chip.id}
|
||||
type="button"
|
||||
className={`timeline-chip ${activeFilter === chip.id ? 'is-active' : ''}`}
|
||||
onClick={() => setActiveFilter(chip.id)}
|
||||
data-testid={`timeline-chip-${chip.id}`}
|
||||
>
|
||||
{chip.label}
|
||||
<span className="timeline-chip-count">{counts[chip.id] ?? 0}</span>
|
||||
<span className="timeline-chip-count" data-testid="timeline-chip-count">{counts[chip.id] ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="timeline-container">
|
||||
<div className="timeline-container" data-testid="timeline-container">
|
||||
{entries.map((entry, index) => {
|
||||
const kind = getEntryKind(entry);
|
||||
if (activeFilter !== 'all' && activeFilter !== kind) return null;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTheme } from 'providers/Theme';
|
||||
import { openApiSpec } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import ApiSpecItem from './ApiSpecItem';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { matchLoadedApiSpecs } from './matchLoadedApiSpecs';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const LinkStyle = styled.span`
|
||||
@@ -22,13 +23,11 @@ const ApiSpecs = () => {
|
||||
const apiSpecs = React.useMemo(() => {
|
||||
if (!activeWorkspace) return [];
|
||||
|
||||
const workspaceApiSpecs = activeWorkspace.apiSpecs || [];
|
||||
const workspaceApiSpecs = Array.isArray(activeWorkspace.apiSpecs) ? activeWorkspace.apiSpecs : [];
|
||||
|
||||
// Map workspace API specs to loaded API specs from Redux store
|
||||
return workspaceApiSpecs.map((ws) => {
|
||||
const loadedApiSpec = allApiSpecs.find((apiSpec) => apiSpec.pathname === ws.path);
|
||||
return loadedApiSpec;
|
||||
}).filter(Boolean);
|
||||
// Pair workspace API specs to loaded specs in redux, matching by normalized
|
||||
// path so Windows (backslash) and stored (forward-slash) paths line up.
|
||||
return matchLoadedApiSpecs(workspaceApiSpecs, allApiSpecs);
|
||||
}, [allApiSpecs, activeWorkspace, activeWorkspace?.apiSpecs]);
|
||||
|
||||
const handleOpenApiSpec = () => {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
|
||||
/**
|
||||
* Pairs each workspace API spec entry (from workspace.yml) with its loaded
|
||||
* counterpart in the redux store, matching by normalized (posixified) path.
|
||||
*
|
||||
* The two paths are derived independently: the workspace entry's path is stored
|
||||
* posixified (forward slashes) in workspace.yml, while the loaded spec's pathname
|
||||
* comes from the file watcher in native form (backslashes on Windows). A raw
|
||||
* `===` compare therefore fails on Windows (`C:/ws/api.yaml` !== `C:\ws\api.yaml`),
|
||||
* which hides the spec from the sidebar until a workspace switch. Normalizing both
|
||||
* sides makes them match on Windows while being a no-op on macOS/Linux.
|
||||
*
|
||||
* @param {Array} workspaceApiSpecs - spec entries from the active workspace (each has `path`)
|
||||
* @param {Array} allApiSpecs - loaded specs in redux (each has `pathname`)
|
||||
* @returns {Array} loaded specs that correspond to the workspace entries
|
||||
*/
|
||||
export const matchLoadedApiSpecs = (workspaceApiSpecs, allApiSpecs) => {
|
||||
if (!Array.isArray(workspaceApiSpecs)) return [];
|
||||
const loadedApiSpecs = Array.isArray(allApiSpecs) ? allApiSpecs : [];
|
||||
|
||||
return workspaceApiSpecs
|
||||
.map((ws) => {
|
||||
const wsPath = normalizePath(ws?.path);
|
||||
if (!wsPath) return undefined;
|
||||
return loadedApiSpecs.find((apiSpec) => normalizePath(apiSpec?.pathname) === wsPath);
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { matchLoadedApiSpecs } from './matchLoadedApiSpecs';
|
||||
|
||||
const loaded = (pathname, extra = {}) => ({ uid: pathname, pathname, ...extra });
|
||||
|
||||
describe('matchLoadedApiSpecs', () => {
|
||||
it('matches workspace specs to loaded specs by identical path (macOS/Linux)', () => {
|
||||
const ws = [{ name: 'a', path: '/Users/me/ws/a.yaml' }];
|
||||
const all = [loaded('/Users/me/ws/a.yaml'), loaded('/Users/me/ws/other.yaml')];
|
||||
expect(matchLoadedApiSpecs(ws, all)).toEqual([loaded('/Users/me/ws/a.yaml')]);
|
||||
});
|
||||
|
||||
it('matches when paths differ only by separator (Windows: backslash vs forward-slash)', () => {
|
||||
// workspace.yml stores forward-slash; the file watcher reports native backslash.
|
||||
const ws = [{ name: 'a', path: 'C:/Users/qa/Downloads/test.yaml' }];
|
||||
const all = [loaded('C:\\Users\\qa\\Downloads\\test.yaml')];
|
||||
const result = matchLoadedApiSpecs(ws, all);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].pathname).toBe('C:\\Users\\qa\\Downloads\\test.yaml');
|
||||
});
|
||||
|
||||
it('matches mixed separators within a single path', () => {
|
||||
const ws = [{ name: 'a', path: 'C:/ws/sub/a.yaml' }];
|
||||
const all = [loaded('C:\\ws/sub\\a.yaml')];
|
||||
expect(matchLoadedApiSpecs(ws, all)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('preserves workspace order and drops entries with no loaded counterpart', () => {
|
||||
const ws = [
|
||||
{ name: 'a', path: 'C:/ws/a.yaml' },
|
||||
{ name: 'missing', path: 'C:/ws/missing.yaml' },
|
||||
{ name: 'b', path: 'C:/ws/b.yaml' }
|
||||
];
|
||||
const all = [loaded('C:\\ws\\b.yaml'), loaded('C:\\ws\\a.yaml')];
|
||||
const result = matchLoadedApiSpecs(ws, all);
|
||||
expect(result.map((s) => s.pathname)).toEqual(['C:\\ws\\a.yaml', 'C:\\ws\\b.yaml']);
|
||||
});
|
||||
|
||||
it('does not match entries with a missing/empty path (no empty-string false positive)', () => {
|
||||
const ws = [{ name: 'noPath' }, { name: 'emptyPath', path: '' }];
|
||||
const all = [loaded(undefined), loaded('')];
|
||||
expect(matchLoadedApiSpecs(ws, all)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] when workspaceApiSpecs is not an array', () => {
|
||||
expect(matchLoadedApiSpecs(undefined, [])).toEqual([]);
|
||||
expect(matchLoadedApiSpecs({ broken: 'map' }, [])).toEqual([]);
|
||||
expect(matchLoadedApiSpecs('string', [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] when there are no loaded specs', () => {
|
||||
const ws = [{ name: 'a', path: 'C:/ws/a.yaml' }];
|
||||
expect(matchLoadedApiSpecs(ws, [])).toEqual([]);
|
||||
expect(matchLoadedApiSpecs(ws, undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
|
||||
.overview-layout {
|
||||
display: flex;
|
||||
|
||||
@@ -378,4 +378,11 @@ export const clearHttpHttpsAgentCache = () => () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const refreshPacCache = () => () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer.invoke('renderer:refresh-pac-cache').then(resolve).catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export default appSlice.reducer;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import reducer, { collectionAddEnvFileEvent } from 'providers/ReduxStore/slices/collections';
|
||||
|
||||
const COLLECTION_UID = 'col-1';
|
||||
const ENV_UID = 'env-1';
|
||||
|
||||
const externalSecrets = {
|
||||
type: 'my-vault',
|
||||
variables: [
|
||||
{ name: 'secret', value: 'secret/data/secret' },
|
||||
{ name: 'password', value: 'secret/data/password' }
|
||||
]
|
||||
};
|
||||
|
||||
const makeEnvironment = (overrides = {}) => ({
|
||||
uid: ENV_UID,
|
||||
name: 'test_env',
|
||||
pathname: '/coll/environments/test_env.bru',
|
||||
variables: [
|
||||
{ uid: 'var-1', name: 'env_str', value: 'env_string', type: 'text', enabled: true, secret: false },
|
||||
{ uid: 'var-2', name: 'env_num', value: '300', type: 'text', datatype: 'number', enabled: true, secret: false },
|
||||
{ uid: 'var-3', name: 'env_bool', value: 'true', type: 'text', datatype: 'boolean', enabled: true, secret: false },
|
||||
{ uid: 'var-4', name: 'env_obj', value: '{"scope":"env"}', type: 'text', datatype: 'object', enabled: true, secret: false }
|
||||
],
|
||||
color: null,
|
||||
...overrides
|
||||
});
|
||||
|
||||
const makeInitialState = (environments = []) => ({
|
||||
collections: [
|
||||
{
|
||||
uid: COLLECTION_UID,
|
||||
pathname: '/coll',
|
||||
items: [],
|
||||
environments
|
||||
}
|
||||
],
|
||||
collectionSortOrder: 'default',
|
||||
activeWorkspaceUid: null
|
||||
});
|
||||
|
||||
describe('collectionAddEnvFileEvent', () => {
|
||||
it('keeps externalSecrets when a new environment is added', () => {
|
||||
const state = makeInitialState();
|
||||
|
||||
const nextState = reducer(
|
||||
state,
|
||||
collectionAddEnvFileEvent({ environment: makeEnvironment({ externalSecrets }), collectionUid: COLLECTION_UID })
|
||||
);
|
||||
|
||||
expect(nextState.collections[0].environments[0].externalSecrets).toEqual(externalSecrets);
|
||||
});
|
||||
|
||||
it('keeps externalSecrets when an existing environment changes', () => {
|
||||
const state = makeInitialState([makeEnvironment()]);
|
||||
|
||||
const nextState = reducer(
|
||||
state,
|
||||
collectionAddEnvFileEvent({ environment: makeEnvironment({ externalSecrets }), collectionUid: COLLECTION_UID })
|
||||
);
|
||||
|
||||
expect(nextState.collections[0].environments[0].externalSecrets).toEqual(externalSecrets);
|
||||
});
|
||||
|
||||
it('keeps variable datatype when a new environment is added', () => {
|
||||
const state = makeInitialState();
|
||||
|
||||
const nextState = reducer(
|
||||
state,
|
||||
collectionAddEnvFileEvent({ environment: makeEnvironment(), collectionUid: COLLECTION_UID })
|
||||
);
|
||||
|
||||
const variables = nextState.collections[0].environments[0].variables;
|
||||
expect(variables.find((v) => v.name === 'env_num')).toMatchObject({ value: '300', datatype: 'number' });
|
||||
expect(variables.find((v) => v.name === 'env_bool')).toMatchObject({ value: 'true', datatype: 'boolean' });
|
||||
expect(variables.find((v) => v.name === 'env_obj')).toMatchObject({ value: '{"scope":"env"}', datatype: 'object' });
|
||||
expect(variables.find((v) => v.name === 'env_str')).not.toHaveProperty('datatype');
|
||||
});
|
||||
|
||||
it('keeps variable datatype when an existing environment changes', () => {
|
||||
const state = makeInitialState([makeEnvironment({ variables: [] })]);
|
||||
|
||||
const nextState = reducer(
|
||||
state,
|
||||
collectionAddEnvFileEvent({ environment: makeEnvironment(), collectionUid: COLLECTION_UID })
|
||||
);
|
||||
|
||||
const variables = nextState.collections[0].environments[0].variables;
|
||||
expect(variables.find((v) => v.name === 'env_num')).toMatchObject({ value: '300', datatype: 'number' });
|
||||
expect(variables.find((v) => v.name === 'env_str')).not.toHaveProperty('datatype');
|
||||
});
|
||||
|
||||
it('clears externalSecrets when the block is removed from the file', () => {
|
||||
const state = makeInitialState([makeEnvironment({ externalSecrets })]);
|
||||
|
||||
const nextState = reducer(
|
||||
state,
|
||||
collectionAddEnvFileEvent({ environment: makeEnvironment(), collectionUid: COLLECTION_UID })
|
||||
);
|
||||
|
||||
expect(nextState.collections[0].environments[0].externalSecrets).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -2066,11 +2066,13 @@ export const collectionsSlice = createSlice({
|
||||
item.draft = cloneDeep(item);
|
||||
}
|
||||
item.draft.request.vars = item.draft.request.vars || {};
|
||||
const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false }) => ({
|
||||
const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false, datatype, annotations }) => ({
|
||||
uid: uid || uuid(),
|
||||
name,
|
||||
value,
|
||||
enabled,
|
||||
...(datatype ? { datatype } : {}),
|
||||
...(annotations?.length ? { annotations } : {}),
|
||||
...(type === 'response' ? { local } : {})
|
||||
}));
|
||||
if (type === 'request') {
|
||||
@@ -2420,11 +2422,13 @@ export const collectionsSlice = createSlice({
|
||||
if (!folder.draft) {
|
||||
folder.draft = cloneDeep(folder.root);
|
||||
}
|
||||
const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false }) => ({
|
||||
const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false, datatype, annotations }) => ({
|
||||
uid: uid || uuid(),
|
||||
name,
|
||||
value,
|
||||
enabled,
|
||||
...(datatype ? { datatype } : {}),
|
||||
...(annotations?.length ? { annotations } : {}),
|
||||
...(type === 'response' ? { local } : {})
|
||||
}));
|
||||
if (type === 'request') {
|
||||
@@ -2658,11 +2662,13 @@ export const collectionsSlice = createSlice({
|
||||
root: cloneDeep(collection.root)
|
||||
};
|
||||
}
|
||||
const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false }) => ({
|
||||
const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false, datatype, annotations }) => ({
|
||||
uid: uid || uuid(),
|
||||
name,
|
||||
value,
|
||||
enabled,
|
||||
...(datatype ? { datatype } : {}),
|
||||
...(annotations?.length ? { annotations } : {}),
|
||||
...(type === 'response' ? { local } : {})
|
||||
}));
|
||||
if (type === 'request') {
|
||||
@@ -2911,6 +2917,7 @@ export const collectionsSlice = createSlice({
|
||||
existingEnv.pathname = environment.pathname;
|
||||
existingEnv.variables = environment.variables;
|
||||
existingEnv.color = environment.color;
|
||||
existingEnv.externalSecrets = environment.externalSecrets;
|
||||
/*
|
||||
Apply temporary (ephemeral) values only to variables that actually exist in the file. This prevents deleted temporaries from “popping back” after a save. If a variable is present in the file, we temporarily override the UI value while also remembering the on-disk value in persistedValue for future saves.
|
||||
*/
|
||||
|
||||
@@ -446,6 +446,9 @@ export const tabsSlice = createSlice({
|
||||
const tab = find(state.tabs, (t) => t.uid === oldUid);
|
||||
if (tab) {
|
||||
tab.uid = newUid;
|
||||
if (tab.type === 'folder-settings') {
|
||||
tab.folderUid = newUid;
|
||||
}
|
||||
if (state.activeTabUid === oldUid) {
|
||||
state.activeTabUid = newUid;
|
||||
}
|
||||
|
||||
@@ -550,10 +550,12 @@ export const loadWorkspaceApiSpecs = (workspaceUid) => {
|
||||
}));
|
||||
|
||||
const allApiSpecs = getState().apiSpec.apiSpecs;
|
||||
const alreadyOpenApiSpecs = allApiSpecs.map((a) => a.pathname);
|
||||
// Compare by normalized path so a spec already loaded under a native (Windows)
|
||||
// path isn't treated as "not open" and needlessly re-opened.
|
||||
const alreadyOpenApiSpecs = allApiSpecs.map((a) => normalizePath(a.pathname));
|
||||
|
||||
for (const apiSpec of apiSpecs) {
|
||||
if (apiSpec.path && !alreadyOpenApiSpecs.includes(apiSpec.path)) {
|
||||
if (apiSpec.path && !alreadyOpenApiSpecs.includes(normalizePath(apiSpec.path))) {
|
||||
try {
|
||||
await ipcRenderer.invoke('renderer:open-api-spec-file', apiSpec.path, workspace.pathname);
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,8 +4,8 @@ import { isValidUrl } from 'utils/url/index';
|
||||
const xml2js = require('xml2js');
|
||||
|
||||
export const exportApiSpec = ({ variables, items, name, environments }) => {
|
||||
// Filter out transient items and grpc requests
|
||||
items = items.filter((item) => !['grpc-request'].includes(item.type) && !item.isTransient);
|
||||
// Filter only include http-request and graphql-request items that aren't transient
|
||||
items = items.filter((item) => ['http-request', 'graphql-request'].includes(item.type) && !item.isTransient);
|
||||
|
||||
const components = {
|
||||
schemas: {},
|
||||
|
||||
@@ -881,3 +881,90 @@ describe('exportApiSpec - OAuth2 scope handling (BRU-3297)', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportApiSpec - non-HTTP request type filtering', () => {
|
||||
it('should keep only http-request and graphql-request items and not crash on others', () => {
|
||||
const items = [
|
||||
{
|
||||
name: 'HTTP Request',
|
||||
type: 'http-request',
|
||||
pathname: 'folder/http',
|
||||
depth: 2,
|
||||
request: {
|
||||
url: 'https://api.example.com/http',
|
||||
method: 'GET',
|
||||
params: [],
|
||||
headers: [],
|
||||
body: {},
|
||||
auth: {}
|
||||
},
|
||||
examples: []
|
||||
},
|
||||
{
|
||||
name: 'GraphQL Request',
|
||||
type: 'graphql-request',
|
||||
pathname: 'folder/graphql',
|
||||
depth: 2,
|
||||
request: {
|
||||
url: 'https://api.example.com/graphql',
|
||||
method: 'POST',
|
||||
params: [],
|
||||
headers: [],
|
||||
body: {},
|
||||
auth: {}
|
||||
},
|
||||
examples: []
|
||||
},
|
||||
{
|
||||
name: 'gRPC Request',
|
||||
type: 'grpc-request',
|
||||
request: { url: 'grpc://example.com/service' }
|
||||
},
|
||||
{
|
||||
name: 'WebSocket Request',
|
||||
type: 'ws-request',
|
||||
request: { url: 'wss://example.com/socket' }
|
||||
},
|
||||
{
|
||||
name: 'Folder',
|
||||
type: 'folder',
|
||||
items: []
|
||||
},
|
||||
{
|
||||
name: 'script.js',
|
||||
type: 'js'
|
||||
},
|
||||
{
|
||||
name: 'Transient',
|
||||
type: 'http-request',
|
||||
isTransient: true,
|
||||
pathname: 'folder/transient',
|
||||
depth: 2,
|
||||
request: {
|
||||
url: 'https://api.example.com/transient',
|
||||
method: 'GET',
|
||||
params: [],
|
||||
headers: [],
|
||||
body: {},
|
||||
auth: {}
|
||||
},
|
||||
examples: []
|
||||
}
|
||||
];
|
||||
|
||||
let result;
|
||||
expect(() => {
|
||||
result = exportApiSpec({ variables: {}, items, name: 'Test API' });
|
||||
}).not.toThrow();
|
||||
|
||||
const spec = require('js-yaml').load(result.content);
|
||||
const pathKeys = Object.keys(spec.paths);
|
||||
|
||||
expect(pathKeys).toHaveLength(2);
|
||||
expect(spec.paths['/http']).toBeDefined();
|
||||
expect(spec.paths['/http'].get).toBeDefined();
|
||||
expect(spec.paths['/graphql']).toBeDefined();
|
||||
expect(spec.paths['/graphql'].post).toBeDefined();
|
||||
expect(spec.paths['/transient']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"@usebruno/lang": "0.12.0",
|
||||
"@usebruno/requests": "^0.1.0",
|
||||
"aws4-axios": "^3.3.15",
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"axios-ntlm": "^1.4.2",
|
||||
"chai": "^4.3.7",
|
||||
"chalk": "^3.0.0",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^7.0.1",
|
||||
"aws4-axios": "^3.3.15",
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"axios-ntlm": "^1.4.2",
|
||||
"chai": "^4.3.7",
|
||||
"chokidar": "^3.5.3",
|
||||
|
||||
@@ -11,16 +11,6 @@ const {
|
||||
|
||||
const DEFAULT_WORKSPACE_NAME = 'My Workspace';
|
||||
|
||||
const normalizeWorkspaceConfig = (config) => {
|
||||
return {
|
||||
...config,
|
||||
name: config.info?.name,
|
||||
type: config.info?.type,
|
||||
collections: config.collections || [],
|
||||
apiSpecs: config.specs || []
|
||||
};
|
||||
};
|
||||
|
||||
const prepareWorkspaceConfigForClient = (workspaceConfig, isDefault) => {
|
||||
if (isDefault) {
|
||||
return {
|
||||
@@ -56,7 +46,7 @@ const openApiSpec = async (win, watcher, apiSpecPath, options = {}) => {
|
||||
|
||||
if (fs.existsSync(workspaceFilePath)) {
|
||||
const workspaceConfig = readWorkspaceConfig(options.workspacePath);
|
||||
const specs = workspaceConfig.specs || [];
|
||||
const specs = workspaceConfig.specs;
|
||||
|
||||
const specName = path.basename(apiSpecPath, path.extname(apiSpecPath));
|
||||
|
||||
@@ -75,10 +65,9 @@ const openApiSpec = async (win, watcher, apiSpecPath, options = {}) => {
|
||||
});
|
||||
|
||||
const updatedConfig = readWorkspaceConfig(options.workspacePath);
|
||||
const normalizedConfig = normalizeWorkspaceConfig(updatedConfig);
|
||||
const workspaceUid = getWorkspaceUid(options.workspacePath);
|
||||
const isDefault = workspaceUid === 'default';
|
||||
const configForClient = prepareWorkspaceConfigForClient(normalizedConfig, isDefault);
|
||||
const configForClient = prepareWorkspaceConfigForClient(updatedConfig, isDefault);
|
||||
win.webContents.send('main:workspace-config-updated', options.workspacePath, workspaceUid, configForClient);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ const path = require('path');
|
||||
const chokidar = require('chokidar');
|
||||
const yaml = require('js-yaml');
|
||||
const { generateUidBasedOnHash, uuid } = require('../utils/common');
|
||||
const { getWorkspaceUid } = require('../utils/workspace-config');
|
||||
const { getWorkspaceUid, normalizeWorkspaceConfig } = require('../utils/workspace-config');
|
||||
const { parseEnvironment } = require('@usebruno/filestore');
|
||||
const EnvironmentSecretsStore = require('../store/env-secrets');
|
||||
const { decryptStringSafe } = require('../utils/encryption');
|
||||
@@ -19,16 +19,6 @@ const envHasSecrets = (environment) => {
|
||||
return secrets && secrets.length > 0;
|
||||
};
|
||||
|
||||
const normalizeWorkspaceConfig = (config) => {
|
||||
return {
|
||||
...config,
|
||||
name: config.info?.name,
|
||||
type: config.info?.type,
|
||||
collections: config.collections || [],
|
||||
apiSpecs: config.specs || []
|
||||
};
|
||||
};
|
||||
|
||||
const handleWorkspaceFileChange = (win, workspacePath) => {
|
||||
try {
|
||||
const workspaceFilePath = path.join(workspacePath, 'workspace.yml');
|
||||
|
||||
@@ -61,6 +61,7 @@ const {
|
||||
const { getCollectionConfigFile, openCollectionDialog, openCollectionsByPathname, registerScratchCollectionPath } = require('../app/collections');
|
||||
const { generateUidBasedOnHash, stringifyJson, safeStringifyJSON, safeParseJSON } = require('../utils/common');
|
||||
const { isValidNpmPackageName, runNpmInstall } = require('../utils/install-packages');
|
||||
const { waitForShellEnv } = require('../store/shell-env-state');
|
||||
const { moveRequestUid, deleteRequestUid, syncExampleUidsCache } = require('../cache/requestUids');
|
||||
const { deleteCookiesForDomain, getDomainsWithCookies, addCookieForDomain, modifyCookieForDomain, parseCookieString, createCookieString, deleteCookie } = require('../utils/cookies');
|
||||
const EnvironmentSecretsStore = require('../store/env-secrets');
|
||||
@@ -2157,6 +2158,7 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
throw new Error(`Invalid package name(s): ${invalid.join(', ')}`);
|
||||
}
|
||||
|
||||
await waitForShellEnv();
|
||||
return runNpmInstall({ collectionPath: collectionPathname, packages });
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ const { resolveDefaultLocation } = require('../utils/default-location');
|
||||
const onboardUser = require('../app/onboarding');
|
||||
const LastOpenedCollections = require('../store/last-opened-collections');
|
||||
const WindowStateStore = require('../store/window-state');
|
||||
const { clearAgentCache } = require('@usebruno/requests');
|
||||
const { clearAgentCache, clearPacCache } = require('@usebruno/requests');
|
||||
|
||||
const registerPreferencesIpc = (mainWindow) => {
|
||||
const lastOpenedCollections = new LastOpenedCollections();
|
||||
@@ -67,6 +67,15 @@ const registerPreferencesIpc = (mainWindow) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:refresh-pac-cache', async () => {
|
||||
try {
|
||||
clearPacCache();
|
||||
clearAgentCache();
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('renderer:theme-change', (event, theme, themeBg) => {
|
||||
nativeTheme.themeSource = theme;
|
||||
const windowStateStore = new WindowStateStore();
|
||||
@@ -81,7 +90,10 @@ const registerPreferencesIpc = (mainWindow) => {
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:refresh-system-proxy', async () => {
|
||||
return await fetchSystemProxy({ refresh: true });
|
||||
const variables = await fetchSystemProxy({ refresh: true });
|
||||
clearPacCache();
|
||||
clearAgentCache();
|
||||
return variables;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -214,7 +214,7 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => {
|
||||
return [];
|
||||
}
|
||||
|
||||
const specs = workspaceConfig.specs || [];
|
||||
const specs = Array.isArray(workspaceConfig.specs) ? workspaceConfig.specs : [];
|
||||
|
||||
const resolvedSpecs = specs
|
||||
.map((spec) => {
|
||||
|
||||
@@ -274,8 +274,10 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => {
|
||||
displayPath: config.collectionFile
|
||||
};
|
||||
|
||||
const requestSegmentSource = request?.pathname && collection?.pathname
|
||||
? { displayPath: posixifyPath(path.relative(collection.pathname, request.pathname)) }
|
||||
const requestItem = requestTreePath?.[requestTreePath.length - 1];
|
||||
const requestPathname = request?.pathname || requestItem?.pathname;
|
||||
const requestSegmentSource = requestPathname && collection?.pathname
|
||||
? { displayPath: posixifyPath(path.relative(collection.pathname, requestPathname)) }
|
||||
: null;
|
||||
|
||||
const withContent = (source, script) =>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// npm package name grammar (scoped + unscoped). Conservative enough to prevent
|
||||
// shell-metachar smuggling even though spawn() runs without a shell.
|
||||
const NPM_NAME_REGEX = /^(?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i;
|
||||
|
||||
const shouldUseShellForNpmSpawn = (npmCommand, platform = process.platform) => {
|
||||
return platform === 'win32' && /\.(cmd|bat)$/i.test(npmCommand);
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // npm installs can legitimately take minutes
|
||||
const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024; // bound captured stdout/stderr
|
||||
const NODE_SHIM_ENV_KEYS = ['NVM_BIN', 'FNM_MULTISHELL_PATH'];
|
||||
const NPM_NOT_FOUND_MESSAGE = 'npm was not found on your PATH. Install Node.js/npm, then try again or run the command manually.';
|
||||
|
||||
let cachedNpmInvocation = null;
|
||||
|
||||
const isValidNpmPackageName = (name) => typeof name === 'string' && NPM_NAME_REGEX.test(name);
|
||||
|
||||
@@ -16,13 +24,81 @@ const appendCapped = (buffer, chunk, cap) => {
|
||||
return next.length > cap ? next.slice(next.length - cap) : next;
|
||||
};
|
||||
|
||||
const resolveNodeExecutable = () => {
|
||||
const nodeName = process.platform === 'win32' ? 'node.exe' : 'node';
|
||||
|
||||
for (const key of NODE_SHIM_ENV_KEYS) {
|
||||
const dir = process.env[key];
|
||||
if (!dir) continue;
|
||||
const candidate = path.join(dir, nodeName);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
|
||||
const candidate = path.join(dir, nodeName);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveNpmCli = (nodePath) => {
|
||||
const nodeDir = path.dirname(nodePath);
|
||||
const candidates = [
|
||||
path.join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
||||
path.join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')
|
||||
];
|
||||
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
|
||||
};
|
||||
|
||||
const resolveNpmInvocation = () => {
|
||||
if (cachedNpmInvocation) return cachedNpmInvocation;
|
||||
|
||||
const nodePath = resolveNodeExecutable();
|
||||
if (!nodePath) return null;
|
||||
|
||||
const npmCliPath = resolveNpmCli(nodePath);
|
||||
if (!npmCliPath) return null;
|
||||
|
||||
cachedNpmInvocation = { nodePath, npmCliPath };
|
||||
return cachedNpmInvocation;
|
||||
};
|
||||
|
||||
const clearNpmInvocationCache = () => {
|
||||
cachedNpmInvocation = null;
|
||||
};
|
||||
|
||||
const buildSafeEnv = (nodeBinDir) => {
|
||||
const existingPath = process.env.PATH || '';
|
||||
const newPath = [nodeBinDir, existingPath].filter(Boolean).join(path.delimiter);
|
||||
return { ...process.env, PATH: newPath };
|
||||
};
|
||||
|
||||
// CVE-2024-27980: Node.js rejects spawn/spawnSync of .cmd/.bat with shell:false on
|
||||
// Windows (EINVAL). npm.cmd is affected; node.exe + npm-cli.js is not.
|
||||
const isWindowsBatchFile = (filePath) => {
|
||||
if (process.platform !== 'win32') return false;
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return ext === '.cmd' || ext === '.bat';
|
||||
};
|
||||
|
||||
const buildSpawnOptions = ({ nodePath, collectionPath }) => ({
|
||||
cwd: collectionPath,
|
||||
env: buildSafeEnv(path.dirname(nodePath)),
|
||||
shell: isWindowsBatchFile(nodePath),
|
||||
windowsHide: true
|
||||
});
|
||||
|
||||
/**
|
||||
* Runs `npm install --save <packages>` in a collection directory and resolves
|
||||
* with a structured result. Never rejects - runtime failures (non-zero exit,
|
||||
* npm-not-found, timeout) come back as `{ success: false, ... }` so callers
|
||||
* can surface a useful message.
|
||||
*
|
||||
* `spawnFn` and `timeoutMs` are injectable for testing.
|
||||
* npm is invoked as `node <npm-cli.js> install --save ...` — not npm.cmd — so
|
||||
* shell:false is safe on Windows (CVE-2024-27980 only blocks .cmd/.bat without shell).
|
||||
*
|
||||
* `spawnFn`, `timeoutMs`, and `resolveNpmInvocationFn` are injectable for testing.
|
||||
*
|
||||
* @returns {Promise<{ success: boolean, exitCode: number, stdout: string,
|
||||
* stderr: string, installed: string[], errorCode?: string }>}
|
||||
@@ -33,10 +109,26 @@ const runNpmInstall = ({
|
||||
spawnFn = spawn,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES,
|
||||
npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||
resolveNpmInvocationFn = resolveNpmInvocation
|
||||
}) => {
|
||||
const installed = Array.from(new Set(packages));
|
||||
const args = ['install', '--save', ...installed];
|
||||
const npmArgs = ['install', '--save', ...installed];
|
||||
|
||||
const invocation = resolveNpmInvocationFn();
|
||||
if (!invocation) {
|
||||
return Promise.resolve({
|
||||
success: false,
|
||||
exitCode: -1,
|
||||
stdout: '',
|
||||
stderr: NPM_NOT_FOUND_MESSAGE,
|
||||
installed,
|
||||
errorCode: 'NPM_NOT_FOUND'
|
||||
});
|
||||
}
|
||||
|
||||
const { nodePath, npmCliPath } = invocation;
|
||||
const spawnArgs = [npmCliPath, ...npmArgs];
|
||||
const spawnOptions = buildSpawnOptions({ nodePath, collectionPath });
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let stdout = '';
|
||||
@@ -53,7 +145,7 @@ const runNpmInstall = ({
|
||||
|
||||
let child;
|
||||
try {
|
||||
child = spawnFn(npmCommand, args, { cwd: collectionPath, env: process.env, shell: false });
|
||||
child = spawnFn(nodePath, spawnArgs, spawnOptions);
|
||||
} catch (err) {
|
||||
finish({ success: false, exitCode: -1, stderr: err.message, errorCode: 'SPAWN_FAILED' });
|
||||
return;
|
||||
@@ -86,9 +178,7 @@ const runNpmInstall = ({
|
||||
success: false,
|
||||
exitCode: -1,
|
||||
errorCode: isMissingNpm ? 'NPM_NOT_FOUND' : 'SPAWN_ERROR',
|
||||
stderr: isMissingNpm
|
||||
? 'npm was not found on your PATH. Install Node.js/npm, then try again or run the command manually.'
|
||||
: `${stderr}\n${err.message}`
|
||||
stderr: isMissingNpm ? NPM_NOT_FOUND_MESSAGE : `${stderr}\n${err.message}`
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,8 +190,17 @@ const runNpmInstall = ({
|
||||
|
||||
module.exports = {
|
||||
isValidNpmPackageName,
|
||||
shouldUseShellForNpmSpawn,
|
||||
runNpmInstall,
|
||||
resolveNodeExecutable,
|
||||
resolveNpmCli,
|
||||
resolveNpmInvocation,
|
||||
clearNpmInvocationCache,
|
||||
buildSafeEnv,
|
||||
buildSpawnOptions,
|
||||
isWindowsBatchFile,
|
||||
NPM_NAME_REGEX,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
DEFAULT_MAX_OUTPUT_BYTES
|
||||
DEFAULT_MAX_OUTPUT_BYTES,
|
||||
NPM_NOT_FOUND_MESSAGE
|
||||
};
|
||||
|
||||
@@ -200,12 +200,19 @@ const createWorkspaceConfig = (workspaceName) => ({
|
||||
});
|
||||
|
||||
const normalizeWorkspaceConfig = (config) => {
|
||||
// Coerce `specs` to an array once. A malformed workspace.yml (e.g. `specs`
|
||||
// authored as a map) would otherwise flow through as a non-array and crash
|
||||
// both the renderer sidebar (.map) and the write paths (.findIndex/.filter).
|
||||
const specs = Array.isArray(config.specs) ? config.specs : [];
|
||||
return {
|
||||
...config,
|
||||
name: config.info?.name,
|
||||
type: config.info?.type,
|
||||
collections: config.collections || [],
|
||||
apiSpecs: config.specs || []
|
||||
specs,
|
||||
// Distinct array (not an alias of `specs`) so a later in-place mutation of
|
||||
// one field can't silently change the other.
|
||||
apiSpecs: [...specs]
|
||||
};
|
||||
};
|
||||
|
||||
@@ -686,6 +693,7 @@ module.exports = {
|
||||
validateWorkspacePath,
|
||||
validateWorkspaceDirectory,
|
||||
createWorkspaceConfig,
|
||||
normalizeWorkspaceConfig,
|
||||
readWorkspaceConfig,
|
||||
writeWorkspaceConfig,
|
||||
validateWorkspaceConfig,
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { EventEmitter } = require('events');
|
||||
const { isValidNpmPackageName, runNpmInstall } = require('../../src/utils/install-packages');
|
||||
const {
|
||||
isValidNpmPackageName,
|
||||
runNpmInstall,
|
||||
resolveNodeExecutable,
|
||||
resolveNpmCli,
|
||||
resolveNpmInvocation,
|
||||
clearNpmInvocationCache,
|
||||
buildSafeEnv,
|
||||
buildSpawnOptions,
|
||||
isWindowsBatchFile
|
||||
} = require('../../src/utils/install-packages');
|
||||
|
||||
const nodeExecutableName = () => (process.platform === 'win32' ? 'node.exe' : 'node');
|
||||
const fixturePath = (...segments) => path.join('fixtures', 'install-packages', ...segments);
|
||||
|
||||
const NODE_BIN = fixturePath('node', 'bin');
|
||||
const NODE_EXECUTABLE = path.join(NODE_BIN, nodeExecutableName());
|
||||
const NPM_SHIM = path.join(NODE_BIN, 'npm');
|
||||
const NPM_CLI_LIB_LAYOUT = path.join(NODE_BIN, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
||||
const NPM_CLI_BESIDE_NODE = path.join(NODE_BIN, 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
||||
const NODE_DIR_BESIDE = fixturePath('nodejs');
|
||||
const NODE_EXECUTABLE_BESIDE = path.join(NODE_DIR_BESIDE, nodeExecutableName());
|
||||
const NPM_CLI_BESIDE_LAYOUT = path.join(NODE_DIR_BESIDE, 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
||||
const NVM_BIN_DIR = fixturePath('nvm', 'v20', 'bin');
|
||||
const NVM_NODE_EXECUTABLE = path.join(NVM_BIN_DIR, nodeExecutableName());
|
||||
const SYSTEM_BIN_DIR = fixturePath('system', 'bin');
|
||||
const COLLECTION_DIR = fixturePath('collection');
|
||||
|
||||
const mockNpmInvocation = () => ({
|
||||
nodePath: NODE_EXECUTABLE,
|
||||
npmCliPath: NPM_CLI_LIB_LAYOUT
|
||||
});
|
||||
|
||||
// Minimal stand-in for a child_process handle: stdout/stderr are emitters and
|
||||
// the child itself emits 'close' / 'error'. Lets us drive npm outcomes
|
||||
@@ -39,12 +72,154 @@ describe('isValidNpmPackageName', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveNodeExecutable', () => {
|
||||
let existsSyncSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
delete process.env.NVM_BIN;
|
||||
delete process.env.FNM_MULTISHELL_PATH;
|
||||
delete process.env.PATH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
existsSyncSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('prefers NVM_BIN over PATH', () => {
|
||||
process.env.NVM_BIN = NVM_BIN_DIR;
|
||||
process.env.PATH = SYSTEM_BIN_DIR;
|
||||
existsSyncSpy.mockImplementation((candidate) => candidate === NVM_NODE_EXECUTABLE);
|
||||
|
||||
expect(resolveNodeExecutable()).toBe(NVM_NODE_EXECUTABLE);
|
||||
});
|
||||
|
||||
test('walks PATH when shim env vars are unset', () => {
|
||||
process.env.PATH = [NODE_BIN, SYSTEM_BIN_DIR].join(path.delimiter);
|
||||
existsSyncSpy.mockImplementation((candidate) => candidate === NODE_EXECUTABLE);
|
||||
|
||||
expect(resolveNodeExecutable()).toBe(NODE_EXECUTABLE);
|
||||
});
|
||||
|
||||
test('returns null when node is not found', () => {
|
||||
process.env.PATH = SYSTEM_BIN_DIR;
|
||||
expect(resolveNodeExecutable()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveNpmCli', () => {
|
||||
let existsSyncSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
existsSyncSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('skips bin/npm shim when npm-cli.js is present (nvm-windows)', () => {
|
||||
existsSyncSpy.mockImplementation(
|
||||
(candidate) => candidate === NPM_SHIM || candidate === NPM_CLI_BESIDE_NODE
|
||||
);
|
||||
|
||||
expect(resolveNpmCli(NODE_EXECUTABLE)).toBe(NPM_CLI_BESIDE_NODE);
|
||||
});
|
||||
|
||||
test('finds npm-cli via lib layout', () => {
|
||||
existsSyncSpy.mockImplementation((candidate) => candidate === NPM_CLI_LIB_LAYOUT);
|
||||
|
||||
expect(resolveNpmCli(NODE_EXECUTABLE)).toBe(NPM_CLI_LIB_LAYOUT);
|
||||
});
|
||||
|
||||
test('finds npm-cli via node_modules layout beside node', () => {
|
||||
existsSyncSpy.mockImplementation((candidate) => candidate === NPM_CLI_BESIDE_LAYOUT);
|
||||
|
||||
expect(resolveNpmCli(NODE_EXECUTABLE_BESIDE)).toBe(NPM_CLI_BESIDE_LAYOUT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveNpmInvocation', () => {
|
||||
beforeEach(() => {
|
||||
clearNpmInvocationCache();
|
||||
});
|
||||
|
||||
test('caches the resolved invocation', () => {
|
||||
const existsSyncSpy = jest.spyOn(fs, 'existsSync').mockImplementation((candidate) => {
|
||||
return candidate === NODE_EXECUTABLE || candidate === NPM_CLI_LIB_LAYOUT;
|
||||
});
|
||||
process.env.PATH = NODE_BIN;
|
||||
|
||||
const first = resolveNpmInvocation();
|
||||
existsSyncSpy.mockRestore();
|
||||
|
||||
expect(first).toEqual({ nodePath: NODE_EXECUTABLE, npmCliPath: NPM_CLI_LIB_LAYOUT });
|
||||
expect(resolveNpmInvocation()).toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSafeEnv', () => {
|
||||
test('prepends the node bin directory to PATH', () => {
|
||||
process.env.PATH = SYSTEM_BIN_DIR;
|
||||
process.env.HOME = fixturePath('home');
|
||||
|
||||
const env = buildSafeEnv(NODE_BIN);
|
||||
|
||||
expect(env.PATH).toBe([NODE_BIN, SYSTEM_BIN_DIR].join(path.delimiter));
|
||||
expect(env.HOME).toBe(fixturePath('home'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSpawnOptions', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
});
|
||||
|
||||
test('uses shell:false when spawning node.exe with npm-cli.js (CVE-2024-27980 safe)', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
const nodePath = path.join('fixtures', 'install-packages', 'node', 'bin', 'node.exe');
|
||||
const npmCliPath = path.join('fixtures', 'install-packages', 'node', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
||||
const options = buildSpawnOptions({ nodePath, collectionPath: COLLECTION_DIR });
|
||||
|
||||
expect(isWindowsBatchFile(nodePath)).toBe(false);
|
||||
expect(isWindowsBatchFile(npmCliPath)).toBe(false);
|
||||
expect(options.shell).toBe(false);
|
||||
expect(options.windowsHide).toBe(true);
|
||||
expect(options.cwd).toBe(COLLECTION_DIR);
|
||||
});
|
||||
|
||||
test('uses shell:true only when the executable is a Windows batch file', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
const npmCmd = path.join('fixtures', 'install-packages', 'node', 'bin', 'npm.cmd');
|
||||
expect(isWindowsBatchFile(npmCmd)).toBe(true);
|
||||
|
||||
const options = buildSpawnOptions({ nodePath: npmCmd, collectionPath: COLLECTION_DIR });
|
||||
expect(options.shell).toBe(true);
|
||||
});
|
||||
|
||||
test('does not treat batch extensions as special on non-Windows platforms', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
|
||||
const npmCmd = path.join('fixtures', 'install-packages', 'node', 'bin', 'npm.cmd');
|
||||
expect(isWindowsBatchFile(npmCmd)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runNpmInstall', () => {
|
||||
test('resolves success on exit code 0 and captures stdout', async () => {
|
||||
const child = makeFakeChild();
|
||||
const spawnFn = jest.fn(() => child);
|
||||
|
||||
const promise = runNpmInstall({ collectionPath: '/coll', packages: ['dayjs'], spawnFn });
|
||||
const promise = runNpmInstall({
|
||||
collectionPath: COLLECTION_DIR,
|
||||
packages: ['dayjs'],
|
||||
spawnFn,
|
||||
resolveNpmInvocationFn: mockNpmInvocation
|
||||
});
|
||||
child.stdout.emit('data', Buffer.from('added 1 package'));
|
||||
child.emit('close', 0);
|
||||
|
||||
@@ -55,29 +230,43 @@ describe('runNpmInstall', () => {
|
||||
expect(result.installed).toEqual(['dayjs']);
|
||||
});
|
||||
|
||||
test('passes the correct npm args, cwd, and runs without a shell', async () => {
|
||||
test('spawns node with npm-cli.js, correct args, cwd, and no shell', async () => {
|
||||
const child = makeFakeChild();
|
||||
const spawnFn = jest.fn(() => child);
|
||||
const systemPath = fixturePath('system', 'path');
|
||||
process.env.PATH = systemPath;
|
||||
|
||||
const promise = runNpmInstall({
|
||||
collectionPath: '/my/coll',
|
||||
collectionPath: COLLECTION_DIR,
|
||||
packages: ['dayjs', 'dayjs', 'zod'],
|
||||
spawnFn,
|
||||
npmCommand: 'npm'
|
||||
resolveNpmInvocationFn: mockNpmInvocation
|
||||
});
|
||||
child.emit('close', 0);
|
||||
await promise;
|
||||
|
||||
expect(spawnFn).toHaveBeenCalledWith(
|
||||
'npm',
|
||||
['install', '--save', 'dayjs', 'zod'],
|
||||
expect.objectContaining({ cwd: '/my/coll', shell: false })
|
||||
NODE_EXECUTABLE,
|
||||
[NPM_CLI_LIB_LAYOUT, 'install', '--save', 'dayjs', 'zod'],
|
||||
expect.objectContaining({
|
||||
cwd: COLLECTION_DIR,
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
env: expect.objectContaining({
|
||||
PATH: [NODE_BIN, systemPath].join(path.delimiter)
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('dedupes packages in the result', async () => {
|
||||
const child = makeFakeChild();
|
||||
const promise = runNpmInstall({ collectionPath: '/c', packages: ['a', 'a', 'b'], spawnFn: () => child });
|
||||
const promise = runNpmInstall({
|
||||
collectionPath: COLLECTION_DIR,
|
||||
packages: ['a', 'a', 'b'],
|
||||
spawnFn: () => child,
|
||||
resolveNpmInvocationFn: mockNpmInvocation
|
||||
});
|
||||
child.emit('close', 0);
|
||||
const result = await promise;
|
||||
expect(result.installed).toEqual(['a', 'b']);
|
||||
@@ -85,7 +274,12 @@ describe('runNpmInstall', () => {
|
||||
|
||||
test('resolves failure on a non-zero exit and surfaces stderr', async () => {
|
||||
const child = makeFakeChild();
|
||||
const promise = runNpmInstall({ collectionPath: '/c', packages: ['bad-pkg'], spawnFn: () => child });
|
||||
const promise = runNpmInstall({
|
||||
collectionPath: COLLECTION_DIR,
|
||||
packages: ['bad-pkg'],
|
||||
spawnFn: () => child,
|
||||
resolveNpmInvocationFn: mockNpmInvocation
|
||||
});
|
||||
child.stderr.emit('data', Buffer.from('npm ERR! 404 Not Found'));
|
||||
child.emit('close', 1);
|
||||
|
||||
@@ -95,10 +289,27 @@ describe('runNpmInstall', () => {
|
||||
expect(result.stderr).toContain('404 Not Found');
|
||||
});
|
||||
|
||||
test('reports NPM_NOT_FOUND when npm is missing from PATH (ENOENT)', async () => {
|
||||
test('reports NPM_NOT_FOUND when npm cannot be resolved', async () => {
|
||||
const result = await runNpmInstall({
|
||||
collectionPath: COLLECTION_DIR,
|
||||
packages: ['a'],
|
||||
resolveNpmInvocationFn: () => null
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('NPM_NOT_FOUND');
|
||||
expect(result.stderr).toMatch(/not found on your PATH/i);
|
||||
});
|
||||
|
||||
test('reports NPM_NOT_FOUND when spawn fails with ENOENT', async () => {
|
||||
const child = makeFakeChild();
|
||||
const promise = runNpmInstall({ collectionPath: '/c', packages: ['a'], spawnFn: () => child });
|
||||
const err = new Error('spawn npm ENOENT');
|
||||
const promise = runNpmInstall({
|
||||
collectionPath: COLLECTION_DIR,
|
||||
packages: ['a'],
|
||||
spawnFn: () => child,
|
||||
resolveNpmInvocationFn: mockNpmInvocation
|
||||
});
|
||||
const err = new Error('spawn node ENOENT');
|
||||
err.code = 'ENOENT';
|
||||
child.emit('error', err);
|
||||
|
||||
@@ -110,7 +321,12 @@ describe('runNpmInstall', () => {
|
||||
|
||||
test('reports SPAWN_ERROR for non-ENOENT spawn errors', async () => {
|
||||
const child = makeFakeChild();
|
||||
const promise = runNpmInstall({ collectionPath: '/c', packages: ['a'], spawnFn: () => child });
|
||||
const promise = runNpmInstall({
|
||||
collectionPath: COLLECTION_DIR,
|
||||
packages: ['a'],
|
||||
spawnFn: () => child,
|
||||
resolveNpmInvocationFn: mockNpmInvocation
|
||||
});
|
||||
const err = new Error('EACCES permission denied');
|
||||
err.code = 'EACCES';
|
||||
child.emit('error', err);
|
||||
@@ -124,7 +340,12 @@ describe('runNpmInstall', () => {
|
||||
const spawnFn = jest.fn(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const result = await runNpmInstall({ collectionPath: '/c', packages: ['a'], spawnFn });
|
||||
const result = await runNpmInstall({
|
||||
collectionPath: COLLECTION_DIR,
|
||||
packages: ['a'],
|
||||
spawnFn,
|
||||
resolveNpmInvocationFn: mockNpmInvocation
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('SPAWN_FAILED');
|
||||
expect(result.stderr).toContain('boom');
|
||||
@@ -134,9 +355,10 @@ describe('runNpmInstall', () => {
|
||||
jest.useFakeTimers();
|
||||
const child = makeFakeChild();
|
||||
const promise = runNpmInstall({
|
||||
collectionPath: '/c',
|
||||
collectionPath: COLLECTION_DIR,
|
||||
packages: ['a'],
|
||||
spawnFn: () => child,
|
||||
resolveNpmInvocationFn: mockNpmInvocation,
|
||||
timeoutMs: 1000
|
||||
});
|
||||
|
||||
@@ -152,9 +374,10 @@ describe('runNpmInstall', () => {
|
||||
test('caps captured output to the trailing maxOutputBytes', async () => {
|
||||
const child = makeFakeChild();
|
||||
const promise = runNpmInstall({
|
||||
collectionPath: '/c',
|
||||
collectionPath: COLLECTION_DIR,
|
||||
packages: ['a'],
|
||||
spawnFn: () => child,
|
||||
resolveNpmInvocationFn: mockNpmInvocation,
|
||||
maxOutputBytes: 10
|
||||
});
|
||||
child.stdout.emit('data', 'abcdefghijklmnop'); // 16 chars
|
||||
@@ -167,8 +390,13 @@ describe('runNpmInstall', () => {
|
||||
|
||||
test('only settles once even if close fires after error', async () => {
|
||||
const child = makeFakeChild();
|
||||
const promise = runNpmInstall({ collectionPath: '/c', packages: ['a'], spawnFn: () => child });
|
||||
const err = new Error('spawn npm ENOENT');
|
||||
const promise = runNpmInstall({
|
||||
collectionPath: COLLECTION_DIR,
|
||||
packages: ['a'],
|
||||
spawnFn: () => child,
|
||||
resolveNpmInvocationFn: mockNpmInvocation
|
||||
});
|
||||
const err = new Error('spawn node ENOENT');
|
||||
err.code = 'ENOENT';
|
||||
child.emit('error', err);
|
||||
child.emit('close', 1); // should be ignored
|
||||
|
||||
@@ -268,3 +268,105 @@ describe('Git remote on workspace collections', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspace specs normalization', () => {
|
||||
const {
|
||||
readWorkspaceConfig,
|
||||
addApiSpecToWorkspace,
|
||||
removeApiSpecFromWorkspace
|
||||
} = require('../../src/utils/workspace-config');
|
||||
let workspacePath;
|
||||
|
||||
// Writes workspace.yml with a verbatim `specs:` block so we control its YAML shape.
|
||||
const writeWorkspaceYml = (specsYaml) => {
|
||||
const content = [
|
||||
'opencollection: 1.0.0',
|
||||
'info:',
|
||||
' name: Test',
|
||||
' type: workspace',
|
||||
'collections: []',
|
||||
specsYaml,
|
||||
'docs: \'\''
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(workspacePath, 'workspace.yml'), content);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-ws-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(workspacePath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// --- Regression guard: the `|| []` -> `Array.isArray(...) ? ... : []` swap must
|
||||
// preserve behavior for every VALID shape, and only change non-array inputs. ---
|
||||
describe('readWorkspaceConfig coerces specs to an array', () => {
|
||||
const cases = [
|
||||
{
|
||||
name: 'valid populated list is preserved unchanged',
|
||||
yaml: ['specs:', ' - name: foo', ' path: foo.yaml', ' - name: bar', ' path: bar.yaml'].join('\n'),
|
||||
expected: [
|
||||
{ name: 'foo', path: 'foo.yaml' },
|
||||
{ name: 'bar', path: 'bar.yaml' }
|
||||
]
|
||||
},
|
||||
{ name: 'empty list stays empty', yaml: 'specs: []', expected: [] },
|
||||
{ name: 'missing specs key -> []', yaml: '# no specs key', expected: [] },
|
||||
{ name: 'null specs -> []', yaml: 'specs: null', expected: [] },
|
||||
{ name: 'map (object) specs -> []', yaml: ['specs:', ' brokenEntry: not a list'].join('\n'), expected: [] },
|
||||
{ name: 'string specs -> []', yaml: 'specs: "oops a string"', expected: [] },
|
||||
{ name: 'number specs -> []', yaml: 'specs: 42', expected: [] },
|
||||
{ name: 'boolean specs -> []', yaml: 'specs: true', expected: [] },
|
||||
{
|
||||
// An array of junk is still an array: coercion preserves it (no crash on .map);
|
||||
// invalid entries are dropped later by sanitizeSpecs on write, not here.
|
||||
name: 'array with non-object elements is preserved as-is',
|
||||
yaml: 'specs: [1, "two", null]',
|
||||
expected: [1, 'two', null]
|
||||
}
|
||||
];
|
||||
|
||||
test.each(cases)('$name', ({ yaml, expected }) => {
|
||||
writeWorkspaceYml(yaml);
|
||||
const config = readWorkspaceConfig(workspacePath);
|
||||
// Both the legacy `specs` field and the renderer-facing `apiSpecs` must be arrays.
|
||||
expect(Array.isArray(config.specs)).toBe(true);
|
||||
expect(Array.isArray(config.apiSpecs)).toBe(true);
|
||||
expect(config.specs).toEqual(expected);
|
||||
expect(config.apiSpecs).toEqual(expected);
|
||||
// apiSpecs mirrors specs by value but is a distinct array, so an in-place
|
||||
// mutation of one field can't silently change the other.
|
||||
expect(config.apiSpecs).not.toBe(config.specs);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Write paths must not throw on an already-malformed workspace.yml and must self-heal. ---
|
||||
describe('write paths survive a malformed (non-array) specs', () => {
|
||||
const malformedYaml = ['specs:', ' brokenEntry: not a list'].join('\n');
|
||||
const specsInYml = () => {
|
||||
const raw = fs.readFileSync(path.join(workspacePath, 'workspace.yml'), 'utf8');
|
||||
return yaml.load(raw).specs;
|
||||
};
|
||||
|
||||
test('addApiSpecToWorkspace does not throw and writes a valid list', async () => {
|
||||
writeWorkspaceYml(malformedYaml);
|
||||
const specPath = path.join(workspacePath, 'api.yaml');
|
||||
await expect(
|
||||
addApiSpecToWorkspace(workspacePath, { name: 'api', path: specPath })
|
||||
).resolves.toBeDefined();
|
||||
|
||||
const stored = specsInYml();
|
||||
expect(Array.isArray(stored)).toBe(true);
|
||||
expect(stored).toEqual([{ name: 'api', path: 'api.yaml' }]);
|
||||
});
|
||||
|
||||
test('removeApiSpecFromWorkspace does not throw on malformed specs', async () => {
|
||||
writeWorkspaceYml(malformedYaml);
|
||||
const result = await removeApiSpecFromWorkspace(workspacePath, path.join(workspacePath, 'whatever.yaml'));
|
||||
expect(result.removedApiSpec).toBeNull();
|
||||
// Round-trip through readWorkspaceConfig (which coerces) must yield a safe array.
|
||||
expect(Array.isArray(readWorkspaceConfig(workspacePath).specs)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { Variable } from '@opencollection/types/common/variables';
|
||||
import { Variable, VariableTypedValue } from '@opencollection/types/common/variables';
|
||||
import { FolderRequest as BrunoFolderRequest } from '@usebruno/schema-types/collection/folder';
|
||||
import { Variable as BrunoVariable, Variables as BrunoVariables } from '@usebruno/schema-types/common/variables';
|
||||
import { uuid, ensureString } from '../../../utils';
|
||||
|
||||
export const isTypedValue = (value: unknown): value is VariableTypedValue => {
|
||||
return (
|
||||
typeof value === 'object'
|
||||
&& value !== null
|
||||
&& !Array.isArray(value)
|
||||
&& 'type' in value
|
||||
&& 'data' in value
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert Bruno pre-request variables to OpenCollection variables format.
|
||||
* Note: Post-response variables are now converted to actions (see actions.ts).
|
||||
@@ -21,7 +31,10 @@ export const toOpenCollectionVariables = (variables: BrunoFolderRequest['vars']
|
||||
const ocVariables: Variable[] = reqVarsArray.map((v: BrunoVariable): Variable => {
|
||||
const variable: Variable = {
|
||||
name: v.name || '',
|
||||
value: v.value || ''
|
||||
value:
|
||||
v.datatype && v.datatype !== 'string'
|
||||
? { type: v.datatype, data: ensureString(v.value) }
|
||||
: v.value || ''
|
||||
};
|
||||
|
||||
if (v?.description?.trim().length) {
|
||||
@@ -52,11 +65,20 @@ export const toBrunoVariables = (variables: Variable[] | null | undefined): { re
|
||||
const variable: BrunoVariable = {
|
||||
uid: uuid(),
|
||||
name: ensureString(v.name),
|
||||
value: ensureString(v.value),
|
||||
value: '',
|
||||
enabled: v.disabled !== true,
|
||||
local: false
|
||||
};
|
||||
|
||||
if (isTypedValue(v.value)) {
|
||||
variable.value = ensureString(v.value.data);
|
||||
if (v.value.type !== 'string' && v.value.type !== 'null') {
|
||||
variable.datatype = v.value.type;
|
||||
}
|
||||
} else {
|
||||
variable.value = ensureString(v.value);
|
||||
}
|
||||
|
||||
if (v.description) {
|
||||
variable.description = typeof v.description === 'string' ? v.description : (v.description as any)?.content || '';
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Environment } from '@opencollection/types/config/environments';
|
||||
import type { Variable, SecretVariable } from '@opencollection/types/common/variables';
|
||||
import { parseYml } from './utils';
|
||||
import { uuid, ensureString } from '../../utils';
|
||||
import { isTypedValue } from './common/variables';
|
||||
|
||||
const isSecretVariable = (v: Variable | SecretVariable): v is SecretVariable => {
|
||||
return 'secret' in v && v.secret === true;
|
||||
@@ -15,7 +16,7 @@ const toBrunoEnvironmentVariables = (variables: (Variable | SecretVariable)[] |
|
||||
|
||||
return variables.map((v): BrunoEnvironmentVariable => {
|
||||
if (isSecretVariable(v)) {
|
||||
return {
|
||||
const variable: BrunoEnvironmentVariable = {
|
||||
uid: uuid(),
|
||||
name: ensureString(v.name),
|
||||
value: '',
|
||||
@@ -23,19 +24,55 @@ const toBrunoEnvironmentVariables = (variables: (Variable | SecretVariable)[] |
|
||||
enabled: v.disabled !== true,
|
||||
secret: true
|
||||
};
|
||||
|
||||
if (v.type && v.type !== 'string' && v.type !== 'null') {
|
||||
variable.datatype = v.type;
|
||||
}
|
||||
|
||||
return variable;
|
||||
}
|
||||
const variable: BrunoEnvironmentVariable = {
|
||||
uid: uuid(),
|
||||
name: ensureString(v.name),
|
||||
value: ensureString(v.value),
|
||||
value: '',
|
||||
type: 'text',
|
||||
enabled: v.disabled !== true,
|
||||
secret: false
|
||||
};
|
||||
|
||||
if (isTypedValue(v.value)) {
|
||||
variable.value = ensureString(v.value.data);
|
||||
if (v.value.type !== 'string' && v.value.type !== 'null') {
|
||||
variable.datatype = v.value.type;
|
||||
}
|
||||
} else {
|
||||
variable.value = ensureString(v.value);
|
||||
}
|
||||
|
||||
return variable;
|
||||
});
|
||||
};
|
||||
|
||||
const toBrunoExternalSecrets = (externalSecrets: any): BrunoEnvironment['externalSecrets'] | undefined => {
|
||||
if (!externalSecrets || typeof externalSecrets !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const variables = Array.isArray(externalSecrets.variables)
|
||||
? externalSecrets.variables.map((variable: any) => {
|
||||
const result: Record<string, string> = { name: ensureString(variable?.name) };
|
||||
Object.keys(variable || {}).forEach((key) => {
|
||||
if (key !== 'name') {
|
||||
result[key] = ensureString(variable[key]);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
})
|
||||
: [];
|
||||
|
||||
return { type: ensureString(externalSecrets.type), variables } as BrunoEnvironment['externalSecrets'];
|
||||
};
|
||||
|
||||
const parseEnvironment = (ymlString: string): BrunoEnvironment => {
|
||||
try {
|
||||
const ocEnvironment: Environment = parseYml(ymlString);
|
||||
@@ -47,6 +84,11 @@ const parseEnvironment = (ymlString: string): BrunoEnvironment => {
|
||||
color: ocEnvironment.color || null
|
||||
};
|
||||
|
||||
const externalSecrets = toBrunoExternalSecrets((ocEnvironment as any).externalSecrets);
|
||||
if (externalSecrets) {
|
||||
brunoEnvironment.externalSecrets = externalSecrets;
|
||||
}
|
||||
|
||||
return brunoEnvironment;
|
||||
} catch (error) {
|
||||
console.error('Error parsing environment:', error);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Environment as BrunoEnvironment, EnvironmentVariable as BrunoEnvir
|
||||
import type { Environment } from '@opencollection/types/config/environments';
|
||||
import type { Variable, SecretVariable } from '@opencollection/types/common/variables';
|
||||
import { stringifyYml } from './utils';
|
||||
import { ensureString } from '../../utils';
|
||||
|
||||
const toOpenCollectionEnvironmentVariables = (variables: BrunoEnvironmentVariable[]): (Variable | SecretVariable)[] | undefined => {
|
||||
if (!variables?.length) {
|
||||
@@ -20,6 +21,9 @@ const toOpenCollectionEnvironmentVariables = (variables: BrunoEnvironmentVariabl
|
||||
secret: true,
|
||||
name: v.name || ''
|
||||
};
|
||||
if (v.datatype && v.datatype !== 'string') {
|
||||
secretVar.type = v.datatype;
|
||||
}
|
||||
if (v.enabled === false) {
|
||||
secretVar.disabled = true;
|
||||
}
|
||||
@@ -28,7 +32,10 @@ const toOpenCollectionEnvironmentVariables = (variables: BrunoEnvironmentVariabl
|
||||
|
||||
const variable: Variable = {
|
||||
name: v.name || '',
|
||||
value: v.value as string
|
||||
value:
|
||||
v.datatype && v.datatype !== 'string'
|
||||
? { type: v.datatype, data: ensureString(v.value) }
|
||||
: ensureString(v.value)
|
||||
};
|
||||
|
||||
if (v.enabled === false) {
|
||||
@@ -58,6 +65,13 @@ const stringifyEnvironment = (environment: BrunoEnvironment): string => {
|
||||
}
|
||||
}
|
||||
|
||||
if (environment.externalSecrets) {
|
||||
(ocEnvironment as any).externalSecrets = {
|
||||
type: environment.externalSecrets.type,
|
||||
variables: (environment.externalSecrets.variables || []).map((variable) => ({ ...variable }))
|
||||
};
|
||||
}
|
||||
|
||||
return stringifyYml(ocEnvironment);
|
||||
} catch (error) {
|
||||
console.error('Error stringifying environment:', error);
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import parseEnvironment from '../parseEnvironment';
|
||||
import stringifyEnvironment from '../stringifyEnvironment';
|
||||
|
||||
const ENV_YML = `name: test_env
|
||||
variables:
|
||||
- name: env_str
|
||||
value: env_string
|
||||
- name: env_num
|
||||
value:
|
||||
type: number
|
||||
data: "300"
|
||||
- name: env_bool
|
||||
value:
|
||||
type: boolean
|
||||
data: "true"
|
||||
- name: env_obj
|
||||
value:
|
||||
type: object
|
||||
data: |-
|
||||
{
|
||||
"scope": "env"
|
||||
}
|
||||
- name: falsy_num
|
||||
value:
|
||||
type: number
|
||||
data: "0"
|
||||
- name: falsy_bool
|
||||
value:
|
||||
type: boolean
|
||||
data: "false"
|
||||
- secret: true
|
||||
name: env_secret_str
|
||||
- secret: true
|
||||
name: env_secret_num
|
||||
type: number
|
||||
- secret: true
|
||||
name: env_secret_bool
|
||||
type: boolean
|
||||
- secret: true
|
||||
name: env_secret_obj
|
||||
type: object
|
||||
- name: env_array_obj
|
||||
value:
|
||||
type: object
|
||||
data: "[1,2,3,4]"
|
||||
`;
|
||||
|
||||
const byName = (env) => Object.fromEntries(env.variables.map((v) => [v.name, v]));
|
||||
|
||||
describe('yml parseEnvironment - typed values', () => {
|
||||
it('keeps the value as a string and preserves the type via datatype', () => {
|
||||
const variables = byName(parseEnvironment(ENV_YML));
|
||||
|
||||
expect(variables.env_num).toMatchObject({ value: '300', type: 'text', datatype: 'number' });
|
||||
expect(typeof variables.env_num.value).toBe('string');
|
||||
|
||||
expect(variables.env_bool).toMatchObject({ value: 'true', datatype: 'boolean' });
|
||||
expect(variables.falsy_num).toMatchObject({ value: '0', datatype: 'number' });
|
||||
expect(variables.falsy_bool).toMatchObject({ value: 'false', datatype: 'boolean' });
|
||||
|
||||
expect(variables.env_obj.datatype).toBe('object');
|
||||
expect(typeof variables.env_obj.value).toBe('string');
|
||||
expect(variables.env_obj.value).toContain('"scope": "env"');
|
||||
|
||||
expect(variables.env_array_obj).toMatchObject({ value: '[1,2,3,4]', datatype: 'object' });
|
||||
});
|
||||
|
||||
it('does not set datatype for plain string values', () => {
|
||||
const variables = byName(parseEnvironment(ENV_YML));
|
||||
|
||||
expect(variables.env_str).toMatchObject({ value: 'env_string', type: 'text', secret: false });
|
||||
expect(variables.env_str).not.toHaveProperty('datatype');
|
||||
});
|
||||
|
||||
it('parses secret variables with no value or datatype', () => {
|
||||
const variables = byName(parseEnvironment(ENV_YML));
|
||||
|
||||
expect(variables.env_secret_str).toMatchObject({ name: 'env_secret_str', value: '', secret: true });
|
||||
expect(variables.env_secret_str).not.toHaveProperty('datatype');
|
||||
});
|
||||
|
||||
it('parses secret variables with a type, keeping the value empty and the type in datatype', () => {
|
||||
const variables = byName(parseEnvironment(ENV_YML));
|
||||
|
||||
expect(variables.env_secret_num).toMatchObject({ value: '', secret: true, datatype: 'number' });
|
||||
expect(variables.env_secret_bool).toMatchObject({ value: '', secret: true, datatype: 'boolean' });
|
||||
expect(variables.env_secret_obj).toMatchObject({ value: '', secret: true, datatype: 'object' });
|
||||
});
|
||||
|
||||
it('serializes secret variable datatype back to a type field, omitting it for plain secrets', () => {
|
||||
const yml = stringifyEnvironment(parseEnvironment(ENV_YML));
|
||||
|
||||
expect(yml).toContain('- secret: true\n name: env_secret_num\n type: number');
|
||||
expect(yml).toContain('- secret: true\n name: env_secret_bool\n type: boolean');
|
||||
expect(yml).toContain('- secret: true\n name: env_secret_obj\n type: object');
|
||||
expect(yml).toContain('- secret: true\n name: env_secret_str');
|
||||
expect(yml).not.toContain('name: env_secret_str\n type:');
|
||||
});
|
||||
|
||||
it('serializes datatype back to an OpenCollection { type, data } value', () => {
|
||||
const yml = stringifyEnvironment(parseEnvironment(ENV_YML));
|
||||
|
||||
expect(yml).toContain('type: number');
|
||||
expect(yml).toContain('data: "300"');
|
||||
expect(yml).toContain('type: boolean');
|
||||
expect(yml).toContain('type: object');
|
||||
// plain strings stay plain, never wrapped as a string datatype
|
||||
expect(yml).toContain('value: env_string');
|
||||
expect(yml).not.toContain('type: string');
|
||||
});
|
||||
|
||||
it('round-trips value and datatype through parse -> stringify -> parse', () => {
|
||||
const env = parseEnvironment(ENV_YML);
|
||||
const reparsed = parseEnvironment(stringifyEnvironment(env));
|
||||
|
||||
const withoutUid = (e) => e.variables.map(({ uid, ...rest }) => rest);
|
||||
expect(withoutUid(reparsed)).toEqual(withoutUid(env));
|
||||
});
|
||||
});
|
||||
|
||||
const EXTERNAL_SECRETS_YML = `name: test_env
|
||||
variables:
|
||||
- name: env_str
|
||||
value: env_string
|
||||
externalSecrets:
|
||||
type: my-vault
|
||||
variables:
|
||||
- name: by_path
|
||||
path: secret/data/secret
|
||||
- name: by_secret_name
|
||||
secretName: secret
|
||||
- name: by_vault_name
|
||||
vaultName: secret
|
||||
`;
|
||||
|
||||
describe('yml parseEnvironment - external secrets', () => {
|
||||
it('parses externalSecrets, preserving the type and arbitrary variable keys', () => {
|
||||
const env = parseEnvironment(EXTERNAL_SECRETS_YML);
|
||||
|
||||
expect(env.externalSecrets).toEqual({
|
||||
type: 'my-vault',
|
||||
variables: [
|
||||
{ name: 'by_path', path: 'secret/data/secret' },
|
||||
{ name: 'by_secret_name', secretName: 'secret' },
|
||||
{ name: 'by_vault_name', vaultName: 'secret' }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('does not set externalSecrets when the yml has none', () => {
|
||||
const env = parseEnvironment('name: test_env\nvariables: []\n');
|
||||
expect(env.externalSecrets).toBeUndefined();
|
||||
});
|
||||
|
||||
it('round-trips externalSecrets through parse -> stringify -> parse', () => {
|
||||
const env = parseEnvironment(EXTERNAL_SECRETS_YML);
|
||||
const reparsed = parseEnvironment(stringifyEnvironment(env));
|
||||
|
||||
expect(reparsed.externalSecrets).toEqual(env.externalSecrets);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { toBrunoVariables, toOpenCollectionVariables } from '../common/variables';
|
||||
|
||||
describe('yml variables - typed values (collection / folder / request vars)', () => {
|
||||
it('reads a typed value keeping the value as a string and the type in datatype', () => {
|
||||
const ocVariables = [
|
||||
{ name: 'var_str', value: 'plain' },
|
||||
{ name: 'var_num', value: { type: 'number', data: '300' } },
|
||||
{ name: 'var_bool', value: { type: 'boolean', data: 'false' } },
|
||||
{ name: 'var_obj', value: { type: 'object', data: '{"scope":"folder"}' } }
|
||||
];
|
||||
|
||||
const { req } = toBrunoVariables(ocVariables);
|
||||
|
||||
expect(req.find((v) => v.name === 'var_str')).toMatchObject({ value: 'plain' });
|
||||
expect(req.find((v) => v.name === 'var_str')).not.toHaveProperty('datatype');
|
||||
expect(req.find((v) => v.name === 'var_num')).toMatchObject({ value: '300', datatype: 'number' });
|
||||
expect(req.find((v) => v.name === 'var_bool')).toMatchObject({ value: 'false', datatype: 'boolean' });
|
||||
expect(req.find((v) => v.name === 'var_obj')).toMatchObject({ value: '{"scope":"folder"}', datatype: 'object' });
|
||||
});
|
||||
|
||||
it('writes datatype back as a { type, data } value, leaving plain strings untouched', () => {
|
||||
const brunoVariables = [
|
||||
{ uid: 'u1', name: 'var_str', value: 'plain', enabled: true, local: false },
|
||||
{ uid: 'u2', name: 'var_num', value: '300', datatype: 'number', enabled: true, local: false }
|
||||
];
|
||||
|
||||
const ocVariables = toOpenCollectionVariables(brunoVariables);
|
||||
|
||||
expect(ocVariables).toEqual([
|
||||
{ name: 'var_str', value: 'plain' },
|
||||
{ name: 'var_num', value: { type: 'number', data: '300' } }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@
|
||||
"ajv": "^8.12.0",
|
||||
"ajv-formats": "^2.1.1",
|
||||
"atob": "^2.1.2",
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"btoa": "^1.2.1",
|
||||
"chai": "^4.3.7",
|
||||
"chai-string": "^1.5.0",
|
||||
|
||||
@@ -11,7 +11,7 @@ const { newQuickJSWASMModule, memoizePromiseFactory } = require('quickjs-emscrip
|
||||
// execute `npm run sandbox:bundle-libraries` if the below file doesn't exist
|
||||
const getBundledCode = require('../bundle-browser-rollup');
|
||||
const addPathShimToContext = require('./shims/lib/path');
|
||||
const { marshallToVm } = require('./utils');
|
||||
const { marshallToVm, createManagedQuickJsContext } = require('./utils');
|
||||
const addCryptoUtilsShimToContext = require('./shims/lib/crypto-utils');
|
||||
const { wrapScriptInClosure, SANDBOX } = require('../../utils/sandbox');
|
||||
|
||||
@@ -56,9 +56,10 @@ const executeQuickJsVm = ({ script: externalScript, context: externalContext, sc
|
||||
|
||||
externalScript = removeQuotes(externalScript);
|
||||
}
|
||||
|
||||
let managedQuickJsContext;
|
||||
try {
|
||||
const vm = QuickJSModule.newContext();
|
||||
managedQuickJsContext = createManagedQuickJsContext(QuickJSModule);
|
||||
const vm = managedQuickJsContext.vm;
|
||||
const { bru, req, res, ...variables } = externalContext;
|
||||
|
||||
bru && addBruShimToContext(vm, bru);
|
||||
@@ -74,7 +75,7 @@ const executeQuickJsVm = ({ script: externalScript, context: externalContext, sc
|
||||
|
||||
let scriptText = scriptType === 'template-literal' ? templateLiteralText : jsExpressionText;
|
||||
|
||||
const result = vm.evalCode(scriptText);
|
||||
const result = vm.evalCodeRetained(scriptText);
|
||||
if (result.error) {
|
||||
let e = vm.dump(result.error);
|
||||
result.error.dispose();
|
||||
@@ -86,6 +87,8 @@ const executeQuickJsVm = ({ script: externalScript, context: externalContext, sc
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error executing the script!', error);
|
||||
} finally {
|
||||
managedQuickJsContext?.dispose();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -95,9 +98,11 @@ const executeQuickJsVmAsync = async ({ script: externalScript, context: external
|
||||
}
|
||||
externalScript = externalScript?.trim();
|
||||
|
||||
let managedQuickJsContext;
|
||||
try {
|
||||
const module = await loader();
|
||||
const vm = module.newContext();
|
||||
managedQuickJsContext = createManagedQuickJsContext(module);
|
||||
const vm = managedQuickJsContext.vm;
|
||||
|
||||
// add crypto utilities required by the crypto-js library in bundledCode
|
||||
await addCryptoUtilsShimToContext(vm);
|
||||
@@ -126,17 +131,27 @@ const executeQuickJsVmAsync = async ({ script: externalScript, context: external
|
||||
|
||||
const script = wrapScriptInClosure(externalScript, SANDBOX.QUICKJS);
|
||||
|
||||
const result = vm.evalCode(script, scriptPath);
|
||||
const result = vm.evalCodeRetained(script, scriptPath);
|
||||
const promiseHandle = vm.unwrapResult(result);
|
||||
const resolvedResult = await vm.resolvePromise(promiseHandle);
|
||||
promiseHandle.dispose();
|
||||
const resolvedHandle = vm.unwrapResult(resolvedResult);
|
||||
resolvedHandle.dispose();
|
||||
// vm.dispose();
|
||||
return;
|
||||
} catch (error) {
|
||||
error.__isQuickJS = true;
|
||||
throw error;
|
||||
} finally {
|
||||
// Wait for any in-flight async work (sendRequest, axios, cookie jar, timers,
|
||||
// un-awaited promises) to settle before tearing down the VM. Disposing while
|
||||
// a deferred is still pending lets its later host callback touch a freed
|
||||
// context, throwing `QuickJSUseAfterFree`.
|
||||
try {
|
||||
await managedQuickJsContext?.waitForPendingDeferreds?.();
|
||||
managedQuickJsContext?.dispose();
|
||||
} catch (teardownError) {
|
||||
throw teardownError;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -354,6 +354,10 @@ const addBruShimToContext = (vm, bru) => {
|
||||
const t = vm.getString(timer);
|
||||
const promise = vm.newPromise();
|
||||
setTimeout(() => {
|
||||
// The VM may have been disposed while this native timer was pending
|
||||
// (e.g. a setTimeout/sleep whose promise the script never awaited).
|
||||
// Touching the VM after teardown throws QuickJSUseAfterFree, so bail out.
|
||||
if (!vm.alive) return;
|
||||
promise.resolve(vm.newString('slept'));
|
||||
}, t);
|
||||
promise.settled.then(vm.runtime.executePendingJobs);
|
||||
|
||||
@@ -1,3 +1,130 @@
|
||||
/**
|
||||
* Creates a QuickJS context with centralized lifecycle management:
|
||||
* - vm.evalCode() auto-disposes result handles (for shim setup code)
|
||||
* - vm.evalCodeRetained() returns the raw result (for user script execution)
|
||||
* - all newObject/newFunction/newArray handles are tracked and disposed on teardown
|
||||
*/
|
||||
const createManagedQuickJsContext = (module) => {
|
||||
const vm = module.newContext();
|
||||
const disposeTracked = trackQuickJsContext(vm);
|
||||
const evalCodeRetained = vm.evalCode.bind(vm);
|
||||
const waitForPendingDeferreds = trackPendingDeferreds(vm);
|
||||
|
||||
vm.evalCode = (code, filename = 'eval.js') => {
|
||||
const result = evalCodeRetained(code, filename);
|
||||
if (result.error) {
|
||||
const error = vm.dump(result.error);
|
||||
result.error.dispose();
|
||||
throw error;
|
||||
}
|
||||
result.value.dispose();
|
||||
};
|
||||
|
||||
vm.evalCodeRetained = evalCodeRetained;
|
||||
|
||||
return {
|
||||
vm,
|
||||
waitForPendingDeferreds,
|
||||
dispose: () => disposeQuickJsContext(vm, disposeTracked)
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Track every deferred created by the async shims (sendRequest, axios, cookie
|
||||
* jar, sleep, ...) so teardown can wait for them to settle. A user script that
|
||||
* fires-and-forgets async work (e.g. an un-awaited setTimeout) resolves the
|
||||
* wrapping closure immediately; without this, the VM is disposed before the
|
||||
* deferred's host callback runs, and touching the freed context throws
|
||||
* `QuickJSUseAfterFree`. Each `.settled` resolves once the deferred is
|
||||
* resolved/rejected, so awaiting them keeps the context alive long enough.
|
||||
*
|
||||
* The hook is installed now (at context creation) so it captures promises as
|
||||
* the script runs. Returns a function that drains the captured deferreds at
|
||||
* teardown; new deferreds can be created while we wait (a chained timer), so it
|
||||
* drains in place until none remain.
|
||||
*/
|
||||
|
||||
const trackPendingDeferreds = (vm) => {
|
||||
const pendingDeferreds = [];
|
||||
const originalNewPromise = vm.newPromise.bind(vm);
|
||||
vm.newPromise = (...args) => {
|
||||
const deferred = originalNewPromise(...args);
|
||||
pendingDeferreds.push(deferred.settled.catch(() => { }));
|
||||
return deferred;
|
||||
};
|
||||
|
||||
return async () => {
|
||||
while (pendingDeferreds.length) {
|
||||
const batch = pendingDeferreds.splice(0);
|
||||
await Promise.all(batch);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks handles created via newObject/newFunction/newArray so they can all be
|
||||
* disposed before the context. quickjs-emscripten requires every heap handle to
|
||||
* be disposed individually; shims attach then drop their ref via .dispose().
|
||||
*/
|
||||
const trackQuickJsContext = (vm) => {
|
||||
const handles = [];
|
||||
|
||||
const track = (handle) => {
|
||||
handles.push(handle);
|
||||
return handle;
|
||||
};
|
||||
|
||||
// Replace an allocator with a wrapper that records every handle it returns,
|
||||
// so teardown can dispose them all. Behaviour is otherwise identical.
|
||||
const trackAllocations = (method) => {
|
||||
const original = vm[method]?.bind(vm);
|
||||
if (!original) {
|
||||
return;
|
||||
}
|
||||
|
||||
vm[method] = (...args) => track(original(...args));
|
||||
};
|
||||
|
||||
['newObject', 'newFunction', 'newArray'].forEach(trackAllocations);
|
||||
|
||||
// Dispose newest-first: later handles may reference earlier ones.
|
||||
return () => {
|
||||
for (const handle of handles.reverse()) {
|
||||
if (handle?.alive) {
|
||||
handle.dispose();
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears shim globals, drains pending QuickJS jobs, and disposes the context.
|
||||
* Pass disposeTracked from trackQuickJsContext() to free shim handles first.
|
||||
*/
|
||||
const disposeQuickJsContext = (vm, disposeTracked) => {
|
||||
if (!vm?.alive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof disposeTracked === 'function') {
|
||||
disposeTracked();
|
||||
}
|
||||
|
||||
// Drain the runtime's pending job queue (resolved/rejected promise callbacks)
|
||||
// before disposing. Executing a job can schedule more jobs (chained `.then()`s),
|
||||
// so we keep going until `hasPendingJob()` reports the queue is empty or a job
|
||||
// throws.
|
||||
while (vm.runtime?.hasPendingJob?.()) {
|
||||
const result = vm.runtime.executePendingJobs();
|
||||
// On error, dispose the error handle and stop draining.
|
||||
if (result.error) {
|
||||
result.error.dispose();
|
||||
break;
|
||||
}
|
||||
}
|
||||
vm.dispose();
|
||||
};
|
||||
|
||||
const marshallToVm = (value, vm) => {
|
||||
if (value === undefined) {
|
||||
return vm.undefined;
|
||||
@@ -79,5 +206,8 @@ async function invokeFunction(vm, quickFn, args = []) {
|
||||
|
||||
module.exports = {
|
||||
marshallToVm,
|
||||
invokeFunction
|
||||
invokeFunction,
|
||||
createManagedQuickJsContext,
|
||||
disposeQuickJsContext,
|
||||
trackQuickJsContext
|
||||
};
|
||||
|
||||
307
packages/bruno-js/tests/quickjs-handle-lifecycle.spec.js
Normal file
307
packages/bruno-js/tests/quickjs-handle-lifecycle.spec.js
Normal file
@@ -0,0 +1,307 @@
|
||||
const { describe, it, expect, beforeAll, afterAll, beforeEach } = require('@jest/globals');
|
||||
const { newQuickJSWASMModule } = require('quickjs-emscripten');
|
||||
const Bru = require('../src/bru');
|
||||
const {
|
||||
createManagedQuickJsContext,
|
||||
disposeQuickJsContext,
|
||||
trackQuickJsContext
|
||||
} = require('../src/sandbox/quickjs/utils');
|
||||
|
||||
describe('QuickJS handle lifecycle utils', () => {
|
||||
let module;
|
||||
|
||||
beforeAll(async () => {
|
||||
module = await newQuickJSWASMModule();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
module = null;
|
||||
});
|
||||
|
||||
describe('trackQuickJsContext', () => {
|
||||
it('tracks newObject, newArray, and newFunction handles', () => {
|
||||
const vm = module.newContext();
|
||||
const disposeTracked = trackQuickJsContext(vm);
|
||||
|
||||
const objectHandle = vm.newObject();
|
||||
const arrayHandle = vm.newArray();
|
||||
const functionHandle = vm.newFunction('trackedFn', () => vm.undefined);
|
||||
|
||||
expect(objectHandle.alive).toBe(true);
|
||||
expect(arrayHandle.alive).toBe(true);
|
||||
expect(functionHandle.alive).toBe(true);
|
||||
|
||||
disposeTracked();
|
||||
|
||||
expect(objectHandle.alive).toBe(false);
|
||||
expect(arrayHandle.alive).toBe(false);
|
||||
expect(functionHandle.alive).toBe(false);
|
||||
expect(vm.alive).toBe(true);
|
||||
|
||||
disposeQuickJsContext(vm);
|
||||
});
|
||||
|
||||
it('disposes nested tracked handles without throwing', () => {
|
||||
const vm = module.newContext();
|
||||
const disposeTracked = trackQuickJsContext(vm);
|
||||
|
||||
const child = vm.newObject();
|
||||
const parent = vm.newObject();
|
||||
vm.setProp(parent, 'child', child);
|
||||
|
||||
expect(parent.alive).toBe(true);
|
||||
expect(child.alive).toBe(true);
|
||||
|
||||
expect(() => disposeTracked()).not.toThrow();
|
||||
|
||||
expect(parent.alive).toBe(false);
|
||||
expect(child.alive).toBe(false);
|
||||
|
||||
disposeQuickJsContext(vm);
|
||||
});
|
||||
|
||||
it('can be called more than once safely', () => {
|
||||
const vm = module.newContext();
|
||||
const disposeTracked = trackQuickJsContext(vm);
|
||||
const handle = vm.newObject();
|
||||
|
||||
disposeTracked();
|
||||
expect(handle.alive).toBe(false);
|
||||
|
||||
expect(() => disposeTracked()).not.toThrow();
|
||||
expect(handle.alive).toBe(false);
|
||||
|
||||
disposeQuickJsContext(vm);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disposeQuickJsContext', () => {
|
||||
it('disposes tracked handles before disposing the context', () => {
|
||||
const vm = module.newContext();
|
||||
const disposeTracked = trackQuickJsContext(vm);
|
||||
const handle = vm.newArray();
|
||||
|
||||
disposeQuickJsContext(vm, disposeTracked);
|
||||
|
||||
expect(handle.alive).toBe(false);
|
||||
expect(vm.alive).toBe(false);
|
||||
});
|
||||
|
||||
it('is a no-op when the context is already disposed', () => {
|
||||
const vm = module.newContext();
|
||||
const disposeTracked = trackQuickJsContext(vm);
|
||||
const handle = vm.newObject();
|
||||
|
||||
disposeQuickJsContext(vm, disposeTracked);
|
||||
|
||||
expect(handle.alive).toBe(false);
|
||||
expect(vm.alive).toBe(false);
|
||||
expect(() => disposeQuickJsContext(vm, disposeTracked)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createManagedQuickJsContext', () => {
|
||||
it('clears tracked handles when dispose is called', () => {
|
||||
const managed = createManagedQuickJsContext(module);
|
||||
const { vm } = managed;
|
||||
|
||||
const objectHandle = vm.newObject();
|
||||
const functionHandle = vm.newFunction('shimFn', () => vm.true);
|
||||
|
||||
expect(objectHandle.alive).toBe(true);
|
||||
expect(functionHandle.alive).toBe(true);
|
||||
|
||||
managed.dispose();
|
||||
|
||||
expect(objectHandle.alive).toBe(false);
|
||||
expect(functionHandle.alive).toBe(false);
|
||||
expect(vm.alive).toBe(false);
|
||||
});
|
||||
|
||||
it('auto-disposes evalCode results while keeping the context alive', () => {
|
||||
const managed = createManagedQuickJsContext(module);
|
||||
const { vm } = managed;
|
||||
|
||||
expect(() => {
|
||||
vm.evalCode('1 + 1');
|
||||
}).not.toThrow();
|
||||
expect(vm.alive).toBe(true);
|
||||
|
||||
managed.dispose();
|
||||
expect(vm.alive).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes evalCodeRetained for callers that manage result handles themselves', () => {
|
||||
const managed = createManagedQuickJsContext(module);
|
||||
const { vm } = managed;
|
||||
|
||||
const result = vm.evalCodeRetained('42');
|
||||
const valueHandle = vm.unwrapResult(result);
|
||||
const value = vm.dump(valueHandle);
|
||||
|
||||
expect(value).toBe(42);
|
||||
expect(valueHandle.alive).toBe(true);
|
||||
|
||||
valueHandle.dispose();
|
||||
expect(valueHandle.alive).toBe(false);
|
||||
|
||||
managed.dispose();
|
||||
expect(vm.alive).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('QuickJS VM handle lifecycle', () => {
|
||||
let executeQuickJsVm;
|
||||
let executeQuickJsVmAsync;
|
||||
let loader;
|
||||
let createManagedQuickJsContextSpy;
|
||||
let lastManaged;
|
||||
let lastHandles;
|
||||
|
||||
const makeBru = () =>
|
||||
new Bru({
|
||||
runtime: 'quickjs',
|
||||
envVariables: {},
|
||||
runtimeVariables: {},
|
||||
processEnvVars: {},
|
||||
collectionPath: '/',
|
||||
collectionName: 'Test'
|
||||
});
|
||||
|
||||
const assertHandlesWereCleared = () => {
|
||||
expect(lastHandles.length).toBeGreaterThan(0);
|
||||
lastHandles.forEach((handle) => expect(handle.alive).toBe(false));
|
||||
expect(lastManaged.vm.alive).toBe(false);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const utils = require('../src/sandbox/quickjs/utils');
|
||||
const createManaged = utils.createManagedQuickJsContext;
|
||||
|
||||
createManagedQuickJsContextSpy = jest
|
||||
.spyOn(utils, 'createManagedQuickJsContext')
|
||||
.mockImplementation((moduleArg) => {
|
||||
lastHandles = [];
|
||||
lastManaged = createManaged(moduleArg);
|
||||
const { vm } = lastManaged;
|
||||
|
||||
['newObject', 'newArray', 'newFunction'].forEach((method) => {
|
||||
const allocate = vm[method];
|
||||
vm[method] = (...args) => {
|
||||
const handle = allocate(...args);
|
||||
lastHandles.push(handle);
|
||||
return handle;
|
||||
};
|
||||
});
|
||||
|
||||
return lastManaged;
|
||||
});
|
||||
|
||||
({ executeQuickJsVm, executeQuickJsVmAsync, loader } = require('../src/sandbox/quickjs'));
|
||||
await loader();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
createManagedQuickJsContextSpy.mockRestore();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
lastManaged = null;
|
||||
lastHandles = [];
|
||||
createManagedQuickJsContextSpy.mockClear();
|
||||
});
|
||||
|
||||
describe('executeQuickJsVm', () => {
|
||||
it('clears tracked handles when execution completes', () => {
|
||||
const result = executeQuickJsVm({
|
||||
script: 'hello world',
|
||||
context: { bru: makeBru() }
|
||||
});
|
||||
|
||||
expect(result).toBe('hello world');
|
||||
assertHandlesWereCleared();
|
||||
});
|
||||
|
||||
it('clears tracked handles when script evaluation fails', () => {
|
||||
const result = executeQuickJsVm({
|
||||
script: 'throw new Error("sync failure")',
|
||||
context: { bru: makeBru() },
|
||||
scriptType: 'expression'
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ message: 'sync failure' });
|
||||
assertHandlesWereCleared();
|
||||
});
|
||||
|
||||
it('does not create a managed context for literal early returns', () => {
|
||||
expect(executeQuickJsVm({ script: '42', context: {} })).toBe(42);
|
||||
expect(executeQuickJsVm({ script: 'true', context: {} })).toBe(true);
|
||||
expect(createManagedQuickJsContextSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears tracked handles on every invocation', () => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
executeQuickJsVm({
|
||||
script: `\`value-${i}\``,
|
||||
context: { bru: makeBru() },
|
||||
scriptType: 'expression'
|
||||
});
|
||||
|
||||
assertHandlesWereCleared();
|
||||
}
|
||||
expect(createManagedQuickJsContextSpy).toHaveBeenCalledTimes(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeQuickJsVmAsync', () => {
|
||||
it('clears tracked handles when async execution completes', async () => {
|
||||
await executeQuickJsVmAsync({
|
||||
script: 'console.log(bru.getCollectionName());',
|
||||
context: { bru: makeBru(), console: jest.fn() },
|
||||
collectionPath: '/tmp/collection'
|
||||
});
|
||||
|
||||
assertHandlesWereCleared();
|
||||
});
|
||||
|
||||
it('clears tracked handles when async script execution fails', async () => {
|
||||
await expect(
|
||||
executeQuickJsVmAsync({
|
||||
script: 'throw new Error("async failure");',
|
||||
context: { bru: makeBru() },
|
||||
collectionPath: '/tmp/collection'
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
message: 'async failure',
|
||||
__isQuickJS: true
|
||||
});
|
||||
|
||||
assertHandlesWereCleared();
|
||||
});
|
||||
|
||||
it('does not create a managed context for empty scripts', async () => {
|
||||
await expect(executeQuickJsVmAsync({ script: '', context: {} })).resolves.toBe('');
|
||||
expect(createManagedQuickJsContextSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears tracked handles on every async invocation', async () => {
|
||||
const consoleFn = jest.fn();
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await executeQuickJsVmAsync({
|
||||
script: `console.log('run-${i}');`,
|
||||
context: { bru: makeBru(), console: consoleFn },
|
||||
collectionPath: '/tmp/collection'
|
||||
});
|
||||
|
||||
assertHandlesWereCleared();
|
||||
}
|
||||
|
||||
expect(createManagedQuickJsContextSpy).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -263,7 +263,7 @@ const mapPairListToKeyValPairsMultipart = (pairList = [], parseEnabled = true) =
|
||||
if (pair.value.startsWith('@file(') && pair.value.endsWith(')')) {
|
||||
let filestr = pair.value.replace(/^@file\(/, '').replace(/\)$/, '');
|
||||
pair.type = 'file';
|
||||
pair.value = filestr.split('|');
|
||||
pair.value = filestr.split('|').filter(Boolean);
|
||||
}
|
||||
|
||||
return pair;
|
||||
|
||||
@@ -16,7 +16,7 @@ const ANNOTATIONS_KEY = Symbol('annotations');
|
||||
// }
|
||||
const indentLevel = 4;
|
||||
const grammar = ohm.grammar(`Bru {
|
||||
BruEnvFile = (vars | secretvars | color)*
|
||||
BruEnvFile = (vars | secretvars | externalsecrets | color)*
|
||||
|
||||
nl = "\\r"? "\\n"
|
||||
st = " " | "\\t"
|
||||
@@ -59,10 +59,13 @@ const grammar = ohm.grammar(`Bru {
|
||||
// Array Blocks
|
||||
array = st* "[" stnl* valuelist stnl* "]"
|
||||
valuelist = stnl* arrayvalue stnl* ("," stnl* arrayvalue)*
|
||||
arrayvalue = arrayvaluechar*
|
||||
arrayvalue = pairannotations st* arrayvaluechar*
|
||||
arrayvaluechar = ~(nl | st | "[" | "]" | ",") any
|
||||
|
||||
secretvars = "vars:secret" array
|
||||
externalsecrets = "vars:externalsecrets:" externalsecretsname dictionary
|
||||
externalsecretsname = externalsecretsnamechar+
|
||||
externalsecretsnamechar = ~(st | nl | "{") any
|
||||
vars = "vars" dictionary
|
||||
color = "color:" any*
|
||||
}`);
|
||||
@@ -91,25 +94,25 @@ const mapPairListToKeyValPairs = (pairList = []) => {
|
||||
};
|
||||
|
||||
const mapArrayListToKeyValPairs = (arrayList = []) => {
|
||||
arrayList = arrayList.filter((v) => v && v.length);
|
||||
arrayList = arrayList.filter((item) => item && item.name && item.name.length);
|
||||
|
||||
if (!arrayList.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _.map(arrayList, (value) => {
|
||||
let name = value;
|
||||
return _.map(arrayList, (item) => {
|
||||
let name = item.name;
|
||||
let enabled = true;
|
||||
if (name && name.length && name.charAt(0) === '~') {
|
||||
name = name.slice(1);
|
||||
enabled = false;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
value: '',
|
||||
enabled
|
||||
};
|
||||
const result = { name, value: '', enabled };
|
||||
if (item.annotations && item.annotations.length) {
|
||||
result.annotations = item.annotations;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -138,8 +141,13 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
array(_1, _2, _3, valuelist, _4, _5) {
|
||||
return valuelist.ast;
|
||||
},
|
||||
arrayvalue(chars) {
|
||||
return chars.sourceString ? chars.sourceString.trim() : '';
|
||||
arrayvalue(annotations, _st, chars) {
|
||||
const result = { name: chars.sourceString ? chars.sourceString.trim() : '' };
|
||||
const annotationList = annotations.ast;
|
||||
if (annotationList && annotationList.length > 0) {
|
||||
result.annotations = annotationList;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
valuelist(_1, value, _2, _3, _4, rest) {
|
||||
return [value.ast, ...rest.ast];
|
||||
@@ -261,6 +269,21 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
variables: vars
|
||||
};
|
||||
},
|
||||
externalsecrets(_1, name, dictionary) {
|
||||
const variables = mapPairListToKeyValPairs(dictionary.ast).map((pair) => ({
|
||||
name: pair.name,
|
||||
value: pair.value
|
||||
}));
|
||||
return {
|
||||
externalSecrets: {
|
||||
type: name.ast,
|
||||
variables
|
||||
}
|
||||
};
|
||||
},
|
||||
externalsecretsname(chars) {
|
||||
return chars.sourceString;
|
||||
},
|
||||
color: (_1, anystring) => {
|
||||
return {
|
||||
color: anystring.sourceString.trim()
|
||||
|
||||
@@ -560,7 +560,7 @@ ${indentString(body.sparql)}
|
||||
}
|
||||
|
||||
if (item.type === 'file') {
|
||||
const filepaths = Array.isArray(item.value) ? item.value : [];
|
||||
const filepaths = (Array.isArray(item.value) ? item.value : []).filter(Boolean);
|
||||
const filestr = filepaths.join('|');
|
||||
|
||||
const value = `@file(${filestr})`;
|
||||
|
||||
@@ -3,6 +3,7 @@ const { getValueString, indentString, serializeAnnotations } = require('./utils'
|
||||
|
||||
const envToJson = (json) => {
|
||||
const variables = _.get(json, 'variables', []);
|
||||
const externalSecrets = _.get(json, 'externalSecrets', null);
|
||||
const color = _.get(json, 'color', null);
|
||||
|
||||
const vars = variables
|
||||
@@ -17,9 +18,9 @@ const envToJson = (json) => {
|
||||
const secretVars = variables
|
||||
.filter((variable) => variable.secret)
|
||||
.map((variable) => {
|
||||
const { name, enabled } = variable;
|
||||
const { name, enabled, annotations } = variable;
|
||||
const prefix = enabled ? '' : '~';
|
||||
return indentString(`${prefix}${name}`);
|
||||
return indentString(`${serializeAnnotations(annotations)}${prefix}${name}`);
|
||||
});
|
||||
|
||||
let output = '';
|
||||
@@ -43,6 +44,18 @@ ${secretVars.join(',\n')}
|
||||
]
|
||||
`;
|
||||
}
|
||||
|
||||
if (externalSecrets && externalSecrets.type) {
|
||||
const serializedVariables = (externalSecrets.variables || []).map(({ name, value }) =>
|
||||
indentString(`${name}: ${getValueString(value)}`)
|
||||
);
|
||||
|
||||
output += `vars:externalsecrets:${externalSecrets.type} {
|
||||
${serializedVariables.join('\n')}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
if (color) {
|
||||
output += `color: ${color}
|
||||
`;
|
||||
|
||||
@@ -880,13 +880,82 @@ describe('env pair annotations', () => {
|
||||
expect(output.variables[0]).toEqual({ name: 'API_KEY', value: 'abc123', enabled: true, secret: false });
|
||||
});
|
||||
|
||||
it('secret vars are unaffected by annotation support', () => {
|
||||
it('multiple annotations on a secret var', () => {
|
||||
const input = `vars:secret [
|
||||
SECRET_KEY
|
||||
@string
|
||||
@description('api token')
|
||||
API_TOKEN
|
||||
]
|
||||
`;
|
||||
const output = envParser(input);
|
||||
expect(output.variables).toEqual([{ name: 'SECRET_KEY', value: '', enabled: true, secret: true }]);
|
||||
expect(output.variables[0].annotations).toEqual([{ name: 'string' }, { name: 'description', value: 'api token' }]);
|
||||
});
|
||||
|
||||
it('annotations on multiple secret vars', () => {
|
||||
const input = `vars:secret [
|
||||
env_secret_str,
|
||||
@number
|
||||
env_secret_num,
|
||||
@object
|
||||
env_secret_obj,
|
||||
@boolean
|
||||
env_secret_boolean,
|
||||
env_secret_new
|
||||
]
|
||||
`;
|
||||
const output = envParser(input);
|
||||
expect(output.variables).toEqual([
|
||||
{ name: 'env_secret_str', value: '', enabled: true, secret: true },
|
||||
{ name: 'env_secret_num', value: '', enabled: true, secret: true, annotations: [{ name: 'number' }] },
|
||||
{ name: 'env_secret_obj', value: '', enabled: true, secret: true, annotations: [{ name: 'object' }] },
|
||||
{ name: 'env_secret_boolean', value: '', enabled: true, secret: true, annotations: [{ name: 'boolean' }] },
|
||||
{ name: 'env_secret_new', value: '', enabled: true, secret: true }
|
||||
]);
|
||||
});
|
||||
|
||||
it('parseAndSerialise - bru sourced roundtrip check - multiple secret vars with annotations', () => {
|
||||
const input = `vars:secret [
|
||||
env_secret_str,
|
||||
@number
|
||||
env_secret_num,
|
||||
@object
|
||||
env_secret_obj,
|
||||
@boolean
|
||||
env_secret_boolean,
|
||||
env_secret_new
|
||||
]
|
||||
`;
|
||||
const parsed = envParser(input);
|
||||
expect(jsonToEnv(parsed)).toEqual(input);
|
||||
});
|
||||
|
||||
it('disabled secret var with annotation', () => {
|
||||
const input = `vars:secret [
|
||||
@deprecated
|
||||
~OLD_SECRET
|
||||
]
|
||||
`;
|
||||
const output = envParser(input);
|
||||
expect(output.variables).toEqual([
|
||||
{ name: 'OLD_SECRET', value: '', enabled: false, secret: true, annotations: [{ name: 'deprecated' }] }
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializeAnnotations in jsonToEnv — disabled secret var with annotation', () => {
|
||||
const json = {
|
||||
variables: [{ name: 'OLD_SECRET', value: '', enabled: false, secret: true, annotations: [{ name: 'deprecated' }] }]
|
||||
};
|
||||
const bru = jsonToEnv(json);
|
||||
expect(bru).toContain('@deprecated\n ~OLD_SECRET');
|
||||
});
|
||||
|
||||
it('parseAndSerialise - json sourced roundtrip check - secret env vars', () => {
|
||||
const input = {
|
||||
variables: [{ name: 'SECRET_KEY', value: '', enabled: true, secret: true, annotations: [{ name: 'description', value: 'my secret key' }] }]
|
||||
};
|
||||
const bru = jsonToEnv(input);
|
||||
const output = envParser(bru);
|
||||
expect(output).toEqual(input);
|
||||
});
|
||||
|
||||
it('serializeAnnotations in jsonToEnv — annotation without value', () => {
|
||||
@@ -942,6 +1011,44 @@ describe('env pair annotations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('env external secrets', () => {
|
||||
it('parses an external secrets block into { type, variables }', () => {
|
||||
const input = `vars:externalsecrets:my-vault {
|
||||
secret: secret/data/secret
|
||||
password: secret/data/password
|
||||
}
|
||||
`;
|
||||
const output = envParser(input);
|
||||
expect(output.externalSecrets).toEqual({
|
||||
type: 'my-vault',
|
||||
variables: [
|
||||
{ name: 'secret', value: 'secret/data/secret' },
|
||||
{ name: 'password', value: 'secret/data/password' }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('parses an external secrets block with no variables', () => {
|
||||
const input = `vars:externalsecrets:my-vault {
|
||||
}
|
||||
`;
|
||||
const output = envParser(input);
|
||||
expect(output.externalSecrets).toEqual({ type: 'my-vault', variables: [] });
|
||||
});
|
||||
|
||||
it('parseAndSerialise - bru sourced roundtrip check - external secrets', () => {
|
||||
const input = `vars {
|
||||
}
|
||||
vars:externalsecrets:my-vault {
|
||||
secret: secret/data/secret
|
||||
password: secret/data/password
|
||||
}
|
||||
`;
|
||||
const parsed = envParser(input);
|
||||
expect(jsonToEnv(parsed)).toEqual(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collection pair annotations', () => {
|
||||
it('above-line annotation on a header (collection)', () => {
|
||||
const input = `headers {
|
||||
|
||||
@@ -203,5 +203,55 @@ body:multipart-form {
|
||||
const output = parser(input);
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it('parses an empty multipart-form file value as an empty array', () => {
|
||||
const input = `
|
||||
body:multipart-form {
|
||||
file: @file()
|
||||
}
|
||||
`;
|
||||
|
||||
const expected = {
|
||||
body: {
|
||||
multipartForm: [
|
||||
{
|
||||
name: 'file',
|
||||
value: [],
|
||||
enabled: true,
|
||||
type: 'file',
|
||||
contentType: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const output = parser(input);
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it('drops empty entries when parsing multiple multipart-form file paths', () => {
|
||||
const input = `
|
||||
body:multipart-form {
|
||||
file: @file(a.txt||b.txt)
|
||||
}
|
||||
`;
|
||||
|
||||
const expected = {
|
||||
body: {
|
||||
multipartForm: [
|
||||
{
|
||||
name: 'file',
|
||||
value: ['a.txt', 'b.txt'],
|
||||
enabled: true,
|
||||
type: 'file',
|
||||
contentType: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const output = parser(input);
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +54,48 @@ describe('jsonToBru stringify', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('body:multipart-form file values', () => {
|
||||
it('stringifies an empty file value without a leading pipe', () => {
|
||||
const input = {
|
||||
body: {
|
||||
multipartForm: [
|
||||
{
|
||||
name: 'file',
|
||||
value: [],
|
||||
enabled: true,
|
||||
type: 'file',
|
||||
contentType: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const output = stringify(input);
|
||||
expect(output).toContain('file: @file()');
|
||||
expect(output).not.toContain('@file(|');
|
||||
});
|
||||
|
||||
it('drops empty entries when stringifying multiple file paths', () => {
|
||||
const input = {
|
||||
body: {
|
||||
multipartForm: [
|
||||
{
|
||||
name: 'file',
|
||||
value: ['', '/path/to/file.csv'],
|
||||
enabled: true,
|
||||
type: 'file',
|
||||
contentType: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const output = stringify(input);
|
||||
expect(output).toContain('file: @file(/path/to/file.csv)');
|
||||
expect(output).not.toContain('@file(|');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-line values', () => {
|
||||
it('handles multi-line values in URL, headers, params, and vars', () => {
|
||||
const input = {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@grpc/grpc-js": "^1.13.3",
|
||||
"@grpc/proto-loader": "^0.7.15",
|
||||
"@types/qs": "^6.9.18",
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"pac-resolver": "^7.0.1",
|
||||
"quickjs-emscripten": "^0.32.0",
|
||||
"debug": "^4.4.3",
|
||||
|
||||
@@ -278,4 +278,38 @@ describe('pac-resolver (shared)', () => {
|
||||
clearPacCache();
|
||||
expect(_CACHE.size).toBe(0);
|
||||
});
|
||||
|
||||
test('clearPacCache forces a re-read of updated PAC file content on next resolve', async () => {
|
||||
const scriptV1 = 'function FindProxyForURL() { return "PROXY a.example:8080"; }';
|
||||
const scriptV2 = 'function FindProxyForURL() { return "PROXY b.example:9090"; }';
|
||||
const readFileMock = jest.fn().mockResolvedValueOnce(scriptV1).mockResolvedValueOnce(scriptV2);
|
||||
jest.doMock('fs/promises', () => ({ readFile: readFileMock }));
|
||||
jest.doMock('url', () => ({ fileURLToPath: jest.fn(() => '/Users/test/proxy.pac') }));
|
||||
// resolver returns directives based on the exact script it was compiled from
|
||||
jest.doMock('pac-resolver', () => ({
|
||||
createPacResolver: jest.fn((_qjs: any, script: string) =>
|
||||
async () => (script === scriptV1 ? 'PROXY a.example:8080' : 'PROXY b.example:9090')
|
||||
)
|
||||
}));
|
||||
jest.doMock('quickjs-emscripten', () => ({ getQuickJS: jest.fn(async () => ({})) }));
|
||||
|
||||
const { getPacResolver, clearPacCache } = require('./pac-resolver');
|
||||
const pacSource = 'file:///Users/test/proxy.pac';
|
||||
|
||||
const w1 = await getPacResolver({ pacSource });
|
||||
expect(await w1.resolve('http://foo.example/')).toEqual(['PROXY a.example:8080']);
|
||||
expect(readFileMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Without refresh, the cached (stale) content is reused — the file is NOT re-read.
|
||||
const wCached = await getPacResolver({ pacSource });
|
||||
expect(wCached).toBe(w1);
|
||||
expect(readFileMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Refresh clears the cache, so the edited file is re-read and new directives take effect.
|
||||
clearPacCache();
|
||||
const w2 = await getPacResolver({ pacSource });
|
||||
expect(w2).not.toBe(w1);
|
||||
expect(readFileMock).toHaveBeenCalledTimes(2);
|
||||
expect(await w2.resolve('http://foo.example/')).toEqual(['PROXY b.example:9090']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from 'path';
|
||||
import { initializeShellEnv } from './shell-env';
|
||||
|
||||
let mockShellEnvResult: Record<string, string> = {};
|
||||
@@ -33,6 +34,32 @@ describe('initializeShellEnv', () => {
|
||||
delete process.env.http_proxy;
|
||||
});
|
||||
|
||||
test('should prepend shell PATH to existing process.env.PATH', async () => {
|
||||
const shellNodeBin = path.join('fixtures', 'shell-env', 'node-bin');
|
||||
const systemBin = path.join('fixtures', 'shell-env', 'system-bin');
|
||||
const otherBin = path.join('fixtures', 'shell-env', 'other-bin');
|
||||
process.env.PATH = [systemBin, otherBin].join(path.delimiter);
|
||||
mockShellEnvResult = { PATH: shellNodeBin };
|
||||
|
||||
await initializeShellEnv();
|
||||
|
||||
expect(process.env.PATH).toBe(
|
||||
[shellNodeBin, systemBin, otherBin].join(path.delimiter)
|
||||
);
|
||||
delete process.env.PATH;
|
||||
});
|
||||
|
||||
test('should set PATH from shell when not in process.env', async () => {
|
||||
const shellBin = path.join('fixtures', 'shell-env', 'shell-bin');
|
||||
delete process.env.PATH;
|
||||
mockShellEnvResult = { PATH: shellBin };
|
||||
|
||||
await initializeShellEnv();
|
||||
|
||||
expect(process.env.PATH).toBe(shellBin);
|
||||
delete process.env.PATH;
|
||||
});
|
||||
|
||||
test('should preserve multiple existing env vars while adding new ones', async () => {
|
||||
process.env.EXISTING_VAR = 'existing';
|
||||
delete process.env.NEW_VAR;
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* Fetches environment variables from the user's shell configuration files (e.g., .zshenv, .bashrc)
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
|
||||
const fetchShellEnv = async (): Promise<Record<string, string>> => {
|
||||
// Windows handles environment variables differently - skip
|
||||
if (process.platform === 'win32') {
|
||||
@@ -29,7 +31,9 @@ const fetchShellEnv = async (): Promise<Record<string, string>> => {
|
||||
export const initializeShellEnv = async (): Promise<Record<string, string>> => {
|
||||
const shellEnvVars = await fetchShellEnv();
|
||||
for (const [key, value] of Object.entries(shellEnvVars)) {
|
||||
if (!(key in process.env)) {
|
||||
if (key === 'PATH' && process.env.PATH) {
|
||||
process.env.PATH = `${value}${path.delimiter}${process.env.PATH}`;
|
||||
} else if (!(key in process.env)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { UID, Annotation } from '../common';
|
||||
|
||||
export type EnvironmentVariableDatatype = 'string' | 'number' | 'boolean' | 'object';
|
||||
|
||||
export interface EnvironmentVariable {
|
||||
uid: UID;
|
||||
name?: string | null;
|
||||
@@ -7,13 +9,25 @@ export interface EnvironmentVariable {
|
||||
type: 'text';
|
||||
enabled?: boolean;
|
||||
secret?: boolean;
|
||||
datatype?: EnvironmentVariableDatatype;
|
||||
annotations?: Annotation[] | null;
|
||||
}
|
||||
|
||||
export interface ExternalSecretVariables {
|
||||
name: string;
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export interface ExternalSecrets {
|
||||
type: string;
|
||||
variables: ExternalSecretVariables[];
|
||||
}
|
||||
|
||||
export interface Environment {
|
||||
uid: UID;
|
||||
name: string;
|
||||
variables: EnvironmentVariable[];
|
||||
externalSecrets?: ExternalSecrets;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Annotation } from './annotation';
|
||||
import type { UID } from './uid';
|
||||
|
||||
export type VariableDatatype = 'string' | 'number' | 'boolean' | 'object';
|
||||
|
||||
/**
|
||||
* Request-scoped variable entry.
|
||||
*/
|
||||
@@ -11,6 +13,7 @@ export interface Variable {
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
local?: boolean;
|
||||
datatype?: VariableDatatype;
|
||||
annotations?: Annotation[] | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
const { uuid } = require('../utils/testUtils');
|
||||
const { environmentSchema } = require('./index');
|
||||
|
||||
const buildVariable = (overrides = {}) => ({
|
||||
uid: uuid(),
|
||||
name: 'env_var',
|
||||
value: 'value',
|
||||
type: 'text',
|
||||
enabled: true,
|
||||
secret: false,
|
||||
...overrides
|
||||
});
|
||||
|
||||
const buildEnvironment = (overrides = {}) => ({
|
||||
uid: uuid(),
|
||||
name: 'My Environment',
|
||||
variables: [],
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('Environment Schema Validation', () => {
|
||||
describe('variable datatype', () => {
|
||||
it.each(['string', 'number', 'boolean', 'object'])('validates a variable with datatype %s', async (datatype) => {
|
||||
const env = buildEnvironment({ variables: [buildVariable({ datatype })] });
|
||||
|
||||
await expect(environmentSchema.validate(env)).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('preserves datatype after validation', async () => {
|
||||
const env = buildEnvironment({ variables: [buildVariable({ value: '300', datatype: 'number' })] });
|
||||
|
||||
const validated = await environmentSchema.validate(env);
|
||||
|
||||
expect(validated.variables[0].datatype).toBe('number');
|
||||
expect(validated.variables[0].value).toBe('300');
|
||||
});
|
||||
});
|
||||
|
||||
describe('external secrets', () => {
|
||||
it('preserves externalSecrets with provider-specific variable keys after validation', async () => {
|
||||
const externalSecrets = {
|
||||
type: 'my-vault',
|
||||
variables: [
|
||||
{ name: 'by_value', value: 'secret/data/secret' },
|
||||
{ name: 'by_path', path: 'secret/data/secret' },
|
||||
{ name: 'by_secret_name', secretName: 'secret' },
|
||||
{ name: 'by_vault_name', vaultName: 'secret' }
|
||||
]
|
||||
};
|
||||
const env = buildEnvironment({ externalSecrets });
|
||||
|
||||
const validated = await environmentSchema.validate(env);
|
||||
|
||||
expect(validated.externalSecrets).toEqual(externalSecrets);
|
||||
});
|
||||
|
||||
it('validates externalSecrets with no variables', async () => {
|
||||
const env = buildEnvironment({ externalSecrets: { type: 'my-vault', variables: [] } });
|
||||
|
||||
await expect(environmentSchema.validate(env)).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects unknown keys on the externalSecrets object', async () => {
|
||||
const env = buildEnvironment({
|
||||
externalSecrets: { type: 'my-vault', variables: [], provider: 'hashicorp' }
|
||||
});
|
||||
|
||||
await expect(environmentSchema.validate(env)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,16 +18,31 @@ const environmentVariablesSchema = Yup.object({
|
||||
)
|
||||
.nullable(),
|
||||
type: Yup.string().oneOf(['text']).required('type is required'),
|
||||
datatype: Yup.string().oneOf(['string', 'number', 'boolean', 'object']).nullable(),
|
||||
enabled: Yup.boolean().defined(),
|
||||
secret: Yup.boolean()
|
||||
})
|
||||
.noUnknown(true)
|
||||
.strict();
|
||||
|
||||
// External secret variables carry `name` plus a provider-specific reference key
|
||||
// (path / vaultName / secretName / ...), so unknown keys are allowed through.
|
||||
const externalSecretVariableSchema = Yup.object({
|
||||
name: Yup.string().nullable()
|
||||
}).strict();
|
||||
|
||||
const externalSecretsSchema = Yup.object({
|
||||
type: Yup.string().nullable(),
|
||||
variables: Yup.array().of(externalSecretVariableSchema)
|
||||
})
|
||||
.noUnknown(true)
|
||||
.strict();
|
||||
|
||||
const environmentSchema = Yup.object({
|
||||
uid: uidSchema,
|
||||
name: Yup.string().min(1).required('name is required'),
|
||||
variables: Yup.array().of(environmentVariablesSchema).required('variables are required'),
|
||||
externalSecrets: externalSecretsSchema.nullable().optional(),
|
||||
color: Yup.string().nullable().optional(),
|
||||
pathname: Yup.string().nullable()
|
||||
})
|
||||
@@ -96,6 +111,7 @@ const varsSchema = Yup.object({
|
||||
name: Yup.string().nullable(),
|
||||
value: Yup.string().nullable(),
|
||||
description: Yup.string().nullable(),
|
||||
datatype: Yup.string().oneOf(['string', 'number', 'boolean', 'object']).nullable(),
|
||||
// Optional annotations on variables
|
||||
annotations: Yup.array()
|
||||
.of(
|
||||
|
||||
@@ -18,6 +18,33 @@ describe('Request Schema Validation', () => {
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
it('request schema must validate successfully - vars with datatype', async () => {
|
||||
const request = {
|
||||
url: 'https://restcountries.com/v2/alpha/in',
|
||||
method: 'GET',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: {
|
||||
mode: 'none'
|
||||
},
|
||||
vars: {
|
||||
req: [
|
||||
{ uid: uuid(), name: 'var_num', value: '300', datatype: 'number', enabled: true, local: false },
|
||||
{ uid: uuid(), name: 'var_bool', value: 'true', datatype: 'boolean', enabled: true, local: false },
|
||||
{ uid: uuid(), name: 'var_obj', value: '{"scope":"req"}', datatype: 'object', enabled: true, local: false },
|
||||
{ uid: uuid(), name: 'var_str', value: 'plain', enabled: true, local: false }
|
||||
],
|
||||
res: []
|
||||
}
|
||||
};
|
||||
|
||||
const validated = await requestSchema.validate(request);
|
||||
expect(validated.vars.req[0].datatype).toBe('number');
|
||||
expect(validated.vars.req[1].datatype).toBe('boolean');
|
||||
expect(validated.vars.req[2].datatype).toBe('object');
|
||||
expect(validated.vars.req[3].datatype).toBeUndefined();
|
||||
});
|
||||
|
||||
it('request schema must validate successfully - custom method', async () => {
|
||||
const request = {
|
||||
url: 'https://restcountries.com/v2/alpha/in',
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"homepage": "https://github.com/usebruno/bruno-testbench#readme",
|
||||
"dependencies": {
|
||||
"axios": "1.13.6",
|
||||
"axios": "1.16.0",
|
||||
"body-parser": "2.2.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
9
tests/devtools/network/fixtures/collection/bruno.json
Normal file
9
tests/devtools/network/fixtures/collection/bruno.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "network-log-scroll",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
meta {
|
||||
name: network-log-scroll
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: http://localhost:8081/api/echo/headers
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
headers {
|
||||
X-Network-Log-1: value-1
|
||||
X-Network-Log-2: value-2
|
||||
X-Network-Log-3: value-3
|
||||
X-Network-Log-4: value-4
|
||||
X-Network-Log-5: value-5
|
||||
X-Network-Log-6: value-6
|
||||
X-Network-Log-7: value-7
|
||||
X-Network-Log-8: value-8
|
||||
X-Network-Log-9: value-9
|
||||
X-Network-Log-10: value-10
|
||||
X-Network-Log-11: value-11
|
||||
X-Network-Log-12: value-12
|
||||
X-Network-Log-13: value-13
|
||||
X-Network-Log-14: value-14
|
||||
X-Network-Log-15: value-15
|
||||
X-Network-Log-16: value-16
|
||||
X-Network-Log-17: value-17
|
||||
X-Network-Log-18: value-18
|
||||
X-Network-Log-19: value-19
|
||||
X-Network-Log-20: value-20
|
||||
X-Network-Log-21: value-21
|
||||
X-Network-Log-22: value-22
|
||||
X-Network-Log-23: value-23
|
||||
X-Network-Log-24: value-24
|
||||
X-Network-Log-25: value-25
|
||||
X-Network-Log-26: value-26
|
||||
X-Network-Log-27: value-27
|
||||
X-Network-Log-28: value-28
|
||||
X-Network-Log-29: value-29
|
||||
X-Network-Log-30: value-30
|
||||
X-Network-Log-31: value-31
|
||||
X-Network-Log-32: value-32
|
||||
X-Network-Log-33: value-33
|
||||
X-Network-Log-34: value-34
|
||||
X-Network-Log-35: value-35
|
||||
X-Network-Log-36: value-36
|
||||
X-Network-Log-37: value-37
|
||||
X-Network-Log-38: value-38
|
||||
X-Network-Log-39: value-39
|
||||
X-Network-Log-40: value-40
|
||||
X-Network-Log-41: value-41
|
||||
X-Network-Log-42: value-42
|
||||
X-Network-Log-43: value-43
|
||||
X-Network-Log-44: value-44
|
||||
X-Network-Log-45: value-45
|
||||
X-Network-Log-46: value-46
|
||||
X-Network-Log-47: value-47
|
||||
X-Network-Log-48: value-48
|
||||
X-Network-Log-49: value-49
|
||||
X-Network-Log-50: value-50
|
||||
X-Network-Log-51: value-51
|
||||
X-Network-Log-52: value-52
|
||||
X-Network-Log-53: value-53
|
||||
X-Network-Log-54: value-54
|
||||
X-Network-Log-55: value-55
|
||||
X-Network-Log-56: value-56
|
||||
X-Network-Log-57: value-57
|
||||
X-Network-Log-58: value-58
|
||||
X-Network-Log-59: value-59
|
||||
X-Network-Log-60: value-60
|
||||
X-Network-Log-61: value-61
|
||||
X-Network-Log-62: value-62
|
||||
X-Network-Log-63: value-63
|
||||
X-Network-Log-64: value-64
|
||||
X-Network-Log-65: value-65
|
||||
X-Network-Log-66: value-66
|
||||
X-Network-Log-67: value-67
|
||||
X-Network-Log-68: value-68
|
||||
X-Network-Log-69: value-69
|
||||
X-Network-Log-70: value-70
|
||||
X-Network-Log-71: value-71
|
||||
X-Network-Log-72: value-72
|
||||
X-Network-Log-73: value-73
|
||||
X-Network-Log-74: value-74
|
||||
X-Network-Log-75: value-75
|
||||
X-Network-Log-76: value-76
|
||||
X-Network-Log-77: value-77
|
||||
X-Network-Log-78: value-78
|
||||
X-Network-Log-79: value-79
|
||||
X-Network-Log-80: value-80
|
||||
X-Network-Log-81: value-81
|
||||
X-Network-Log-82: value-82
|
||||
X-Network-Log-83: value-83
|
||||
X-Network-Log-84: value-84
|
||||
X-Network-Log-85: value-85
|
||||
X-Network-Log-86: value-86
|
||||
X-Network-Log-87: value-87
|
||||
X-Network-Log-88: value-88
|
||||
X-Network-Log-89: value-89
|
||||
X-Network-Log-90: value-90
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"collections": [
|
||||
{
|
||||
"path": "{{collectionPath}}",
|
||||
"securityConfig": {
|
||||
"jsSandboxMode": "safe"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
12
tests/devtools/network/init-user-data/preferences.json
Normal file
12
tests/devtools/network/init-user-data/preferences.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"maximized": false,
|
||||
"lastOpenedCollections": [
|
||||
"{{collectionPath}}"
|
||||
],
|
||||
"preferences": {
|
||||
"onboarding": {
|
||||
"hasLaunchedBefore": true,
|
||||
"hasSeenWelcomeModal": true
|
||||
}
|
||||
}
|
||||
}
|
||||
101
tests/devtools/network/network-log-details-scroll.spec.ts
Normal file
101
tests/devtools/network/network-log-details-scroll.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { test, expect, type Locator } from '../../../playwright';
|
||||
import { openCollection, openRequest, sendRequest } from '../../utils/page';
|
||||
|
||||
const COLLECTION_NAME = 'network-log-scroll';
|
||||
const REQUEST_NAME = 'network-log-scroll';
|
||||
|
||||
const isEntryVisibleInScroller = async (scroller: Locator, entry: Locator) => {
|
||||
const entryHandle = await entry.elementHandle();
|
||||
if (!entryHandle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return scroller.evaluate((container, entryEl) => {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const entryRect = entryEl.getBoundingClientRect();
|
||||
|
||||
return (
|
||||
entryRect.height > 0
|
||||
&& entryRect.top >= containerRect.top - 1
|
||||
&& entryRect.bottom <= containerRect.bottom + 1
|
||||
);
|
||||
}, entryHandle);
|
||||
};
|
||||
|
||||
test.describe('DevTools Network Log Details Scroll', () => {
|
||||
test('last network log lines are visible and scrollable in request details panel', async ({ pageWithUserData: page }) => {
|
||||
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
|
||||
|
||||
await test.step('Open fixture request and send it', async () => {
|
||||
await openCollection(page, COLLECTION_NAME);
|
||||
await openRequest(page, COLLECTION_NAME, REQUEST_NAME);
|
||||
await sendRequest(page, 200);
|
||||
});
|
||||
|
||||
await test.step('Open DevTools Network tab and select the request', async () => {
|
||||
await page.locator('button[data-trigger="dev-tools"]').click();
|
||||
await expect(page.locator('.console-header')).toBeVisible();
|
||||
|
||||
const networkTab = page.locator('.console-tab').filter({ hasText: 'Network' });
|
||||
await expect(networkTab).toBeVisible();
|
||||
await networkTab.click();
|
||||
await expect(networkTab).toHaveClass(/active/);
|
||||
|
||||
const requestRow = page.getByTestId('network-request-row').first();
|
||||
await expect(requestRow).toBeVisible();
|
||||
await requestRow.click();
|
||||
});
|
||||
|
||||
const panel = page.locator('.details-panel-wrapper');
|
||||
const outerScroller = panel.locator('.panel-content');
|
||||
const innerScroller = panel.locator('.network-logs-wrapper .network-logs-container');
|
||||
const lastEntry = innerScroller.locator('.network-logs-entry').last();
|
||||
|
||||
await test.step('Open Network sub-tab in request details panel', async () => {
|
||||
await expect(panel.getByText('Request Details')).toBeVisible();
|
||||
const networkSubTab = panel.locator('.tab-button').filter({ hasText: 'Network' });
|
||||
await expect(networkSubTab).toBeVisible();
|
||||
await networkSubTab.click();
|
||||
await expect(networkSubTab).toHaveClass(/active/);
|
||||
await expect(innerScroller).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify nested inner scroller overflows', async () => {
|
||||
const innerOverflows = await innerScroller.evaluate((el) => el.scrollHeight > el.clientHeight);
|
||||
expect(innerOverflows).toBe(true);
|
||||
await expect(lastEntry).toContainText(/Request completed in/);
|
||||
});
|
||||
|
||||
await test.step('Outer panel scroll alone does not reveal the last log line', async () => {
|
||||
const initialOuterScrollTop = await outerScroller.evaluate((el) => el.scrollTop);
|
||||
expect(initialOuterScrollTop).toBe(0);
|
||||
|
||||
await outerScroller.evaluate((el) => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
|
||||
await expect.poll(() => isEntryVisibleInScroller(innerScroller, lastEntry)).toBe(false);
|
||||
|
||||
await outerScroller.evaluate((el) => {
|
||||
el.scrollTop = 0;
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Scroll nested inner container and verify last log line is visible in viewport', async () => {
|
||||
await expect(async () => {
|
||||
await innerScroller.evaluate((el) => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
|
||||
const scrollTop = await innerScroller.evaluate((el) => el.scrollTop);
|
||||
expect(scrollTop).toBeGreaterThan(0);
|
||||
|
||||
await expect(lastEntry).toBeVisible({ timeout: 1000 });
|
||||
expect(await isEntryVisibleInScroller(innerScroller, lastEntry)).toBe(true);
|
||||
}).toPass({ timeout: 10000 });
|
||||
|
||||
const outerScrollTop = await outerScroller.evaluate((el) => el.scrollTop);
|
||||
expect(outerScrollTop).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -55,32 +55,31 @@ test.describe('Timeline — nested bru.runRequest bubbles inner scripted entries
|
||||
await test.step('Outer Timeline shows three rows: main + runRequest + bubbled inner sendRequest', async () => {
|
||||
await selectResponsePaneTab(page, 'Timeline');
|
||||
|
||||
const rows = page.locator('.timeline-container .tl-row-wrap');
|
||||
const rows = page.getByTestId('timeline-container').getByTestId('timeline-entry');
|
||||
// Without the fix: 2 (main + runRequest); inner sendRequest is dropped.
|
||||
await expect(rows).toHaveCount(3);
|
||||
|
||||
// Badge mix guards against an accidental wrong-3-rows pass.
|
||||
await expect(rows.locator('.tl-badge--main')).toHaveCount(1);
|
||||
await expect(rows.locator('.tl-badge--run-request')).toHaveCount(1);
|
||||
await expect(rows.locator('.tl-badge--scripted')).toHaveCount(1);
|
||||
await expect(rows.getByTestId('timeline-badge-main')).toHaveCount(1);
|
||||
await expect(rows.getByTestId('timeline-badge-post')).toHaveCount(1);
|
||||
await expect(rows.getByTestId('timeline-badge-pre')).toHaveCount(1);
|
||||
});
|
||||
|
||||
await test.step('Bubbled sendRequest row targets the inner-script URL (proving it came from inner)', async () => {
|
||||
const rows = page.locator('.timeline-container .tl-row-wrap');
|
||||
const scriptedRow = rows.filter({ has: page.locator('.tl-badge--scripted') });
|
||||
const rows = page.getByTestId('timeline-container').getByTestId('timeline-entry');
|
||||
const scriptedRow = rows.filter({ has: page.getByTestId('timeline-badge-pre') });
|
||||
await expect(scriptedRow).toHaveCount(1);
|
||||
await expect(scriptedRow.locator('.tl-col-url')).toContainText('/headers');
|
||||
await expect(scriptedRow.getByTestId('timeline-url')).toContainText('/headers');
|
||||
});
|
||||
|
||||
await test.step('Filter chips count the bubbled entry under Pre-Request', async () => {
|
||||
const chips = page.locator('.timeline-filter-bar .timeline-chip');
|
||||
const countFor = (label: string) =>
|
||||
chips.filter({ hasText: label }).locator('.timeline-chip-count').first();
|
||||
const countFor = (id: string) =>
|
||||
page.getByTestId(`timeline-chip-${id}`).getByTestId('timeline-chip-count');
|
||||
|
||||
await expect(countFor('All')).toHaveText('3');
|
||||
await expect(countFor('Main')).toHaveText('1');
|
||||
await expect(countFor('all')).toHaveText('3');
|
||||
await expect(countFor('main')).toHaveText('1');
|
||||
// runRequest + bubbled sendRequest both ran during outer's pre-request.
|
||||
await expect(countFor('Pre-Request')).toHaveText('2');
|
||||
await expect(countFor('pre')).toHaveText('2');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,13 +118,13 @@ test.describe('Timeline — nested bru.runRequest bubbles inner scripted entries
|
||||
await test.step('Outer Timeline shows the bubbled post-response sendRequest row', async () => {
|
||||
await selectResponsePaneTab(page, 'Timeline');
|
||||
|
||||
const rows = page.locator('.timeline-container .tl-row-wrap');
|
||||
const rows = page.getByTestId('timeline-container').getByTestId('timeline-entry');
|
||||
await expect(rows).toHaveCount(3);
|
||||
|
||||
// URL match confirms the scripted row is the post-response one.
|
||||
const scriptedRow = rows.filter({ has: page.locator('.tl-badge--scripted') });
|
||||
const scriptedRow = rows.filter({ has: page.getByTestId('timeline-badge-pre') });
|
||||
await expect(scriptedRow).toHaveCount(1);
|
||||
await expect(scriptedRow.locator('.tl-col-url')).toContainText('/query');
|
||||
await expect(scriptedRow.getByTestId('timeline-url')).toContainText('/query');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,13 +47,13 @@ test.describe('Timeline — runRequest network-error row shows URL and error cod
|
||||
await test.step('Outer Timeline has the runRequest row with inner URL (URL fallback)', async () => {
|
||||
await selectResponsePaneTab(page, 'Timeline');
|
||||
|
||||
const rows = page.locator('.timeline-container .tl-row-wrap');
|
||||
const rows = page.getByTestId('timeline-container').getByTestId('timeline-entry');
|
||||
await expect(rows).toHaveCount(2); // main + runRequest
|
||||
|
||||
// Without the URL fallback this column would be empty.
|
||||
const runRequestRow = rows.filter({ has: page.locator('.tl-badge--run-request') });
|
||||
const runRequestRow = rows.filter({ has: page.getByTestId('timeline-badge-post') });
|
||||
await expect(runRequestRow).toHaveCount(1);
|
||||
await expect(runRequestRow.locator('.tl-col-url')).toContainText('localhost:9999');
|
||||
await expect(runRequestRow.getByTestId('timeline-url')).toContainText('localhost:9999');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,13 +42,13 @@ test.describe('Timeline — bru.runRequest skips unsupported item types', () =>
|
||||
await test.step('Timeline has main + two Skipped runRequest rows', async () => {
|
||||
await selectResponsePaneTab(page, 'Timeline');
|
||||
|
||||
const rows = page.locator('.timeline-container .tl-row-wrap');
|
||||
const rows = page.getByTestId('timeline-container').getByTestId('timeline-entry');
|
||||
await expect(rows).toHaveCount(3);
|
||||
|
||||
const skippedRows = rows.filter({ has: page.locator('.tl-badge--run-request') });
|
||||
const skippedRows = rows.filter({ has: page.getByTestId('timeline-badge-post') });
|
||||
await expect(skippedRows).toHaveCount(2);
|
||||
await expect(skippedRows.nth(0).locator('.timeline-status')).toContainText('Skipped');
|
||||
await expect(skippedRows.nth(1).locator('.timeline-status')).toContainText('Skipped');
|
||||
await expect(skippedRows.nth(0).getByTestId('timeline-status')).toContainText('Skipped');
|
||||
await expect(skippedRows.nth(1).getByTestId('timeline-status')).toContainText('Skipped');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import {
|
||||
closeAllCollections,
|
||||
createCollection,
|
||||
createFolder,
|
||||
createRequest,
|
||||
expandFolder,
|
||||
openRequest,
|
||||
addCollectionScript,
|
||||
addFolderScript,
|
||||
addPreRequestScript,
|
||||
saveRequest,
|
||||
sendRequest,
|
||||
selectResponsePaneTab
|
||||
} from '../../utils/page/actions';
|
||||
|
||||
test.describe('Timeline — scoped request attribution', () => {
|
||||
test.afterEach(async ({ page }) => {
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('request-level sendRequest is attributed to the request, not the collection script', async ({
|
||||
page,
|
||||
createTmpDir
|
||||
}) => {
|
||||
const collectionName = 'timeline-scope-collection';
|
||||
const requestName = 'scoped-driver';
|
||||
const url = 'http://localhost:8081/ping';
|
||||
|
||||
await test.step('Create collection with a (non-empty) collection-level pre-request script', async () => {
|
||||
await createCollection(page, collectionName, await createTmpDir(collectionName), 'yml');
|
||||
// Non-empty collection script => stamps the collection scope before the request runs.
|
||||
await addCollectionScript(page, collectionName, 'pre-request', `bru.setVar('collectionRan', true);`);
|
||||
});
|
||||
|
||||
await test.step('Create a request whose pre-request script issues a sendRequest', async () => {
|
||||
await createRequest(page, requestName, collectionName, { url });
|
||||
await openRequest(page, collectionName, requestName);
|
||||
await addPreRequestScript(page, `await bru.sendRequest({ url: "${url}", method: "GET" });`);
|
||||
await saveRequest(page);
|
||||
});
|
||||
|
||||
await test.step('Send the request', async () => {
|
||||
await sendRequest(page, 200);
|
||||
});
|
||||
|
||||
const scriptedRow = page
|
||||
.getByTestId('timeline-entry')
|
||||
.filter({ has: page.getByTestId('timeline-badge-pre') });
|
||||
|
||||
await test.step('Open Timeline and expand the scripted (sendRequest) row', async () => {
|
||||
await selectResponsePaneTab(page, 'Timeline');
|
||||
await expect(scriptedRow).toHaveCount(1);
|
||||
await scriptedRow.getByTestId('timeline-item-header').click();
|
||||
});
|
||||
|
||||
await test.step('Source file points to the request, not the collection', async () => {
|
||||
const sourceFile = scriptedRow.getByTestId('timeline-source-file');
|
||||
await expect(sourceFile).toBeVisible();
|
||||
await expect(sourceFile).toHaveText('scoped-driver.yml');
|
||||
await expect(sourceFile).not.toContainText('opencollection.yml');
|
||||
});
|
||||
|
||||
await test.step('Clicking the source link opens the request Script tab', async () => {
|
||||
await scriptedRow.getByTestId('timeline-source-link').click();
|
||||
await expect(page.locator('.request-tab.active')).toContainText(requestName);
|
||||
await expect(page.getByTestId('responsive-tab-script')).toHaveClass(/active/);
|
||||
});
|
||||
});
|
||||
|
||||
test('request-level sendRequest is attributed to the request, not the parent folder script', async ({
|
||||
page,
|
||||
createTmpDir
|
||||
}) => {
|
||||
const collectionName = 'timeline-scope-folder';
|
||||
const folderName = 'auth';
|
||||
const requestName = 'folder-driver';
|
||||
const url = 'http://localhost:8081/ping';
|
||||
|
||||
await test.step('Create a folder with a (non-empty) folder-level pre-request script', async () => {
|
||||
await createCollection(page, collectionName, await createTmpDir(collectionName), 'yml');
|
||||
await createFolder(page, folderName, collectionName);
|
||||
await expandFolder(page, folderName);
|
||||
// Folder script runs after the collection and overwrites the scope — used to
|
||||
// be what a nested request's sendRequest inherited.
|
||||
await addFolderScript(page, folderName, 'pre-request', `bru.setVar('folderRan', true);`);
|
||||
});
|
||||
|
||||
await test.step('Create a request inside the folder whose pre-request script issues a sendRequest', async () => {
|
||||
await createRequest(page, requestName, folderName, { url, inFolder: true });
|
||||
await page.locator('.collection-item-name').filter({ hasText: requestName }).first().click();
|
||||
await addPreRequestScript(page, `await bru.sendRequest({ url: "${url}", method: "GET" });`);
|
||||
await saveRequest(page);
|
||||
});
|
||||
|
||||
await test.step('Send the request', async () => {
|
||||
await sendRequest(page, 200);
|
||||
});
|
||||
|
||||
const scriptedRow = page
|
||||
.getByTestId('timeline-entry')
|
||||
.filter({ has: page.getByTestId('timeline-badge-pre') });
|
||||
|
||||
await test.step('Open Timeline and expand the scripted (sendRequest) row', async () => {
|
||||
await selectResponsePaneTab(page, 'Timeline');
|
||||
await expect(scriptedRow).toHaveCount(1);
|
||||
await scriptedRow.getByTestId('timeline-item-header').click();
|
||||
});
|
||||
|
||||
await test.step('Source file points to the request file, not the folder script', async () => {
|
||||
const sourceFile = scriptedRow.getByTestId('timeline-source-file');
|
||||
await expect(sourceFile).toBeVisible();
|
||||
await expect(sourceFile).toHaveText('auth/folder-driver.yml');
|
||||
await expect(sourceFile).not.toContainText('auth/folder.yml');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -54,55 +54,53 @@ test.describe('Timeline — scripted requests (sendRequest / runRequest)', () =>
|
||||
|
||||
await test.step('Open Timeline and assert four rows', async () => {
|
||||
await selectResponsePaneTab(page, 'Timeline');
|
||||
const rows = page.locator('.timeline-container .tl-row-wrap');
|
||||
const rows = page.getByTestId('timeline-container').getByTestId('timeline-entry');
|
||||
await expect(rows).toHaveCount(4);
|
||||
});
|
||||
|
||||
await test.step('Filter chips appear with correct counts (only Main + Pre-Request show)', async () => {
|
||||
const chips = page.locator('.timeline-filter-bar .timeline-chip');
|
||||
await expect(chips).toHaveCount(3); // All, Main, Pre-Request
|
||||
await test.step('Filter chips appear with correct counts (only Request + Pre-Request show)', async () => {
|
||||
const filterBar = page.getByTestId('timeline-filter-bar');
|
||||
await expect(filterBar.getByRole('button')).toHaveCount(3); // All, Request, Pre-Request
|
||||
|
||||
const countFor = (label: string) =>
|
||||
chips.filter({ hasText: label }).locator('.timeline-chip-count').first();
|
||||
const countFor = (id: string) =>
|
||||
page.getByTestId(`timeline-chip-${id}`).getByTestId('timeline-chip-count');
|
||||
|
||||
await expect(countFor('All')).toHaveText('4');
|
||||
await expect(countFor('Main')).toHaveText('1');
|
||||
await expect(countFor('Pre-Request')).toHaveText('3');
|
||||
await expect(countFor('all')).toHaveText('4');
|
||||
await expect(countFor('main')).toHaveText('1');
|
||||
await expect(countFor('pre')).toHaveText('3');
|
||||
});
|
||||
|
||||
await test.step('Rows are sorted newest-first; the collection-script row sits last', async () => {
|
||||
const rows = page.locator('.timeline-container .tl-row-wrap');
|
||||
const rows = page.getByTestId('timeline-container').getByTestId('timeline-entry');
|
||||
|
||||
// Execution order: collection → folder → request → main.
|
||||
// Newest-first: main → request-script → folder-script → collection-script.
|
||||
await expect(rows.nth(0).locator('.tl-badge--main')).toHaveCount(1);
|
||||
await expect(rows.nth(0).getByTestId('timeline-badge-main')).toHaveCount(1);
|
||||
|
||||
const requestScriptRow = rows.nth(1);
|
||||
await expect(requestScriptRow.locator('.tl-badge--scripted')).toHaveCount(1);
|
||||
await expect(requestScriptRow.locator('.tl-col-url')).toContainText('/query');
|
||||
await expect(requestScriptRow.getByTestId('timeline-badge-pre')).toHaveCount(1);
|
||||
await expect(requestScriptRow.getByTestId('timeline-url')).toContainText('/query');
|
||||
|
||||
const folderScriptRow = rows.nth(2);
|
||||
await expect(folderScriptRow.locator('.tl-badge--scripted')).toHaveCount(1);
|
||||
await expect(folderScriptRow.locator('.tl-col-url')).toContainText('/headers');
|
||||
await expect(folderScriptRow.getByTestId('timeline-badge-pre')).toHaveCount(1);
|
||||
await expect(folderScriptRow.getByTestId('timeline-url')).toContainText('/headers');
|
||||
|
||||
const collectionScriptRow = rows.nth(3);
|
||||
await expect(collectionScriptRow.locator('.tl-badge--scripted')).toHaveCount(1);
|
||||
await expect(collectionScriptRow.locator('.tl-col-url')).toContainText('/echo/path');
|
||||
await expect(collectionScriptRow.getByTestId('timeline-badge-pre')).toHaveCount(1);
|
||||
await expect(collectionScriptRow.getByTestId('timeline-url')).toContainText('/echo/path');
|
||||
});
|
||||
|
||||
await test.step('Clicking the Pre-Request chip narrows to the three sendRequest rows', async () => {
|
||||
const chips = page.locator('.timeline-filter-bar .timeline-chip');
|
||||
await chips.filter({ hasText: 'Pre-Request' }).click();
|
||||
await page.getByTestId('timeline-chip-pre').click();
|
||||
|
||||
const visibleRows = page.locator('.timeline-container .tl-row-wrap');
|
||||
const visibleRows = page.getByTestId('timeline-container').getByTestId('timeline-entry');
|
||||
await expect(visibleRows).toHaveCount(3);
|
||||
await expect(visibleRows.locator('.tl-badge--scripted')).toHaveCount(3);
|
||||
await expect(visibleRows.getByTestId('timeline-badge-pre')).toHaveCount(3);
|
||||
});
|
||||
|
||||
await test.step('Clicking All restores every row', async () => {
|
||||
const chips = page.locator('.timeline-filter-bar .timeline-chip');
|
||||
await chips.filter({ hasText: 'All' }).click();
|
||||
await expect(page.locator('.timeline-container .tl-row-wrap')).toHaveCount(4);
|
||||
await page.getByTestId('timeline-chip-all').click();
|
||||
await expect(page.getByTestId('timeline-container').getByTestId('timeline-entry')).toHaveCount(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,15 +137,15 @@ test.describe('Timeline — scripted requests (sendRequest / runRequest)', () =>
|
||||
});
|
||||
|
||||
await test.step('Runner timeline shows main + sendRequest + runRequest rows', async () => {
|
||||
const rows = page.locator('.tl-row-wrap');
|
||||
const rows = page.getByTestId('timeline-entry');
|
||||
await expect(rows).toHaveCount(3, { timeout: 10000 });
|
||||
|
||||
await expect(rows.locator('.tl-badge--main')).toHaveCount(1);
|
||||
await expect(rows.locator('.tl-badge--scripted')).toHaveCount(1);
|
||||
await expect(rows.locator('.tl-badge--run-request')).toHaveCount(1);
|
||||
await expect(rows.getByTestId('timeline-badge-main')).toHaveCount(1);
|
||||
await expect(rows.getByTestId('timeline-badge-pre')).toHaveCount(1);
|
||||
await expect(rows.getByTestId('timeline-badge-post')).toHaveCount(1);
|
||||
|
||||
// The runner view never shows the filter chip bar (no chip-bar UI here).
|
||||
await expect(page.locator('.timeline-filter-bar')).toHaveCount(0);
|
||||
await expect(page.getByTestId('timeline-filter-bar')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ test.describe('Timeline URL Update', () => {
|
||||
await selectResponsePaneTab(page, 'Timeline');
|
||||
|
||||
// Get all timeline entries
|
||||
const timelineItems = page.locator('.tl-row-wrap');
|
||||
const timelineItems = page.getByTestId('timeline-container').getByTestId('timeline-entry');
|
||||
await expect(timelineItems).toHaveCount(2, { timeout: 5000 });
|
||||
|
||||
// Most recent entry (first in list) should show the second URL
|
||||
|
||||
135
tests/snapshots/collection.spec.ts
Normal file
135
tests/snapshots/collection.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { test, expect, closeElectronApp } from '../../playwright';
|
||||
import {
|
||||
createCollection,
|
||||
createWorkspace,
|
||||
focusCollectionSettingsTab,
|
||||
openCollectionSettings,
|
||||
readSnapshot,
|
||||
findSnapshotCollectionTab,
|
||||
selectCollectionPaneTab,
|
||||
selectCollectionScriptPaneTab,
|
||||
switchWorkspace,
|
||||
waitForReadyPage,
|
||||
waitForSnapshotFile
|
||||
} from '../utils/page';
|
||||
|
||||
test.describe('Snapshot: collection Pane Interactivity', () => {
|
||||
test('collection pane tab interactivity is preserved after workspace switch', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const userDataPath = await createTmpDir('snap-collection-workspace-switch');
|
||||
const colPath = await createTmpDir('col');
|
||||
|
||||
const app = await launchElectronApp({ userDataPath });
|
||||
const page = await waitForReadyPage(app);
|
||||
|
||||
await test.step('Create collection and open collection settings', async () => {
|
||||
await createCollection(page, 'TestCol', colPath);
|
||||
await openCollectionSettings(page, 'TestCol', { persist: true });
|
||||
await selectCollectionPaneTab(page, 'auth');
|
||||
});
|
||||
|
||||
await test.step('Switch to a new workspace', async () => {
|
||||
// Background flushing takes about 2 seconds to complete
|
||||
await page.waitForTimeout(2000);
|
||||
await createWorkspace(page, 'SecondWorkspace');
|
||||
await expect(page.getByTestId('workspace-name')).toHaveText('SecondWorkspace', { timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Switch back to original workspace and verify collection pane interactivity', async () => {
|
||||
await switchWorkspace(page, 'My Workspace');
|
||||
await openCollectionSettings(page, 'TestCol', { persist: true });
|
||||
|
||||
await focusCollectionSettingsTab(page);
|
||||
|
||||
await selectCollectionPaneTab(page, 'auth');
|
||||
await selectCollectionPaneTab(page, 'headers');
|
||||
await selectCollectionPaneTab(page, 'overview');
|
||||
await selectCollectionPaneTab(page, 'script');
|
||||
await selectCollectionPaneTab(page, 'vars');
|
||||
});
|
||||
|
||||
await closeElectronApp(app);
|
||||
});
|
||||
|
||||
test('collection pane tab interactivity is preserved after app restart', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const userDataPath = await createTmpDir('snap-collection-restart');
|
||||
const colPath = await createTmpDir('col');
|
||||
|
||||
const app = await launchElectronApp({ userDataPath });
|
||||
const page = await waitForReadyPage(app);
|
||||
|
||||
await test.step('Create collection and open collection settings on auth tab', async () => {
|
||||
await createCollection(page, 'TestCol', colPath);
|
||||
await openCollectionSettings(page, 'TestCol', { persist: true });
|
||||
await selectCollectionPaneTab(page, 'auth');
|
||||
});
|
||||
|
||||
await test.step('Close app and verify snapshot stores collection-settings tab', async () => {
|
||||
await page.waitForTimeout(2000);
|
||||
await closeElectronApp(app);
|
||||
|
||||
await waitForSnapshotFile(userDataPath);
|
||||
|
||||
const snapshot = readSnapshot(userDataPath);
|
||||
const tab = findSnapshotCollectionTab(snapshot, colPath);
|
||||
expect(tab).toBeTruthy();
|
||||
expect(tab.type).toBe('collection-settings');
|
||||
expect(tab.permanent).toBe(true);
|
||||
});
|
||||
|
||||
await test.step('Restart app and verify collection pane interactivity is restored', async () => {
|
||||
const app2 = await launchElectronApp({ userDataPath });
|
||||
const page2 = await waitForReadyPage(app2);
|
||||
|
||||
await focusCollectionSettingsTab(page2, { timeout: 15000 });
|
||||
|
||||
await selectCollectionPaneTab(page2, 'auth');
|
||||
await selectCollectionPaneTab(page2, 'headers');
|
||||
await selectCollectionPaneTab(page2, 'overview');
|
||||
await selectCollectionPaneTab(page2, 'script');
|
||||
await selectCollectionPaneTab(page2, 'vars');
|
||||
|
||||
await closeElectronApp(app2);
|
||||
});
|
||||
});
|
||||
|
||||
test('collection script\'s tabs need to be interactive after app restart', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const userDataPath = await createTmpDir('snap-collection-restart');
|
||||
const colPath = await createTmpDir('col');
|
||||
|
||||
const app = await launchElectronApp({ userDataPath });
|
||||
const page = await waitForReadyPage(app);
|
||||
|
||||
await test.step('Create collection and open collection settings on script tab', async () => {
|
||||
await createCollection(page, 'TestCol', colPath);
|
||||
await openCollectionSettings(page, 'TestCol', { persist: true });
|
||||
await selectCollectionPaneTab(page, 'script');
|
||||
});
|
||||
|
||||
await test.step('Close app and verify snapshot stores collection-settings tab', async () => {
|
||||
await page.waitForTimeout(2000);
|
||||
await closeElectronApp(app);
|
||||
|
||||
await waitForSnapshotFile(userDataPath);
|
||||
|
||||
const snapshot = readSnapshot(userDataPath);
|
||||
const tab = findSnapshotCollectionTab(snapshot, colPath);
|
||||
expect(tab).toBeTruthy();
|
||||
expect(tab.type).toBe('collection-settings');
|
||||
expect(tab.permanent).toBe(true);
|
||||
});
|
||||
|
||||
await test.step('Restart app and verify collection script pane is interactive', async () => {
|
||||
const app2 = await launchElectronApp({ userDataPath });
|
||||
const page2 = await waitForReadyPage(app2);
|
||||
|
||||
await focusCollectionSettingsTab(page2, { timeout: 15000 });
|
||||
|
||||
await selectCollectionPaneTab(page2, 'script');
|
||||
|
||||
await selectCollectionScriptPaneTab(page2, 'pre-request');
|
||||
await selectCollectionScriptPaneTab(page2, 'post-response');
|
||||
|
||||
await closeElectronApp(app2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,34 +1,18 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { test, expect, closeElectronApp } from '../../playwright';
|
||||
import {
|
||||
createCollection,
|
||||
createFolder,
|
||||
createWorkspace,
|
||||
focusFolderSettingsTab,
|
||||
openfolder,
|
||||
readSnapshot,
|
||||
findSnapshotFolderTab,
|
||||
selectfolderPaneTab,
|
||||
selectFolderScriptPaneTab,
|
||||
switchWorkspace,
|
||||
waitForReadyPage
|
||||
waitForReadyPage,
|
||||
waitForSnapshotFile
|
||||
} from '../utils/page';
|
||||
import { buildCommonLocators } from '../utils/page/locators';
|
||||
|
||||
const readSnapshot = (userDataPath: string) => {
|
||||
const snapshotPath = path.join(userDataPath, 'ui-state-snapshot.json');
|
||||
if (!fs.existsSync(snapshotPath)) return null;
|
||||
return JSON.parse(fs.readFileSync(snapshotPath, 'utf-8'));
|
||||
};
|
||||
|
||||
const findSnapshotFolderTab = (snapshot: any, folderName: string) => {
|
||||
if (!snapshot || !Array.isArray(snapshot.collections)) return null;
|
||||
for (const collection of snapshot.collections) {
|
||||
if (!Array.isArray(collection?.tabs)) continue;
|
||||
const tab = collection.tabs.find(
|
||||
(t: any) => t?.type === 'folder-settings' && typeof t?.pathname === 'string' && t.pathname.includes(folderName)
|
||||
);
|
||||
if (tab) return tab;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
test.describe('Snapshot: folder Pane Interactivity', () => {
|
||||
test('folder pane tab interactivity is preserved after workspace switch', async ({ launchElectronApp, createTmpDir }) => {
|
||||
@@ -55,10 +39,7 @@ test.describe('Snapshot: folder Pane Interactivity', () => {
|
||||
await switchWorkspace(page, 'My Workspace');
|
||||
await openfolder(page, 'TestCol', 'TestFolder', { persist: true });
|
||||
|
||||
const locators = buildCommonLocators(page);
|
||||
|
||||
await expect(locators.tabs.folderTab('TestFolder')).toBeVisible({ timeout: 10000 });
|
||||
await locators.tabs.folderTab('TestFolder').click({ force: true });
|
||||
await focusFolderSettingsTab(page, 'TestFolder');
|
||||
|
||||
await selectfolderPaneTab(page, 'auth');
|
||||
await selectfolderPaneTab(page, 'headers');
|
||||
@@ -88,8 +69,7 @@ test.describe('Snapshot: folder Pane Interactivity', () => {
|
||||
await page.waitForTimeout(2000);
|
||||
await closeElectronApp(app);
|
||||
|
||||
const snapshotPath = path.join(userDataPath, 'ui-state-snapshot.json');
|
||||
await expect.poll(() => fs.existsSync(snapshotPath)).toBe(true);
|
||||
await waitForSnapshotFile(userDataPath);
|
||||
|
||||
const snapshot = readSnapshot(userDataPath);
|
||||
const tab = findSnapshotFolderTab(snapshot, 'TestFolder');
|
||||
@@ -102,9 +82,7 @@ test.describe('Snapshot: folder Pane Interactivity', () => {
|
||||
const app2 = await launchElectronApp({ userDataPath });
|
||||
const page2 = await waitForReadyPage(app2);
|
||||
|
||||
const locators = buildCommonLocators(page2);
|
||||
await expect(locators.tabs.folderTab('TestFolder')).toBeVisible({ timeout: 15000 });
|
||||
await locators.tabs.folderTab('TestFolder').click({ force: true });
|
||||
await focusFolderSettingsTab(page2, 'TestFolder', { timeout: 15000 });
|
||||
|
||||
await selectfolderPaneTab(page2, 'auth');
|
||||
await selectfolderPaneTab(page2, 'headers');
|
||||
@@ -115,4 +93,46 @@ test.describe('Snapshot: folder Pane Interactivity', () => {
|
||||
await closeElectronApp(app2);
|
||||
});
|
||||
});
|
||||
|
||||
test('folder script\'s tabs need to be interactive after app restart', async ({ launchElectronApp, createTmpDir }) => {
|
||||
const userDataPath = await createTmpDir('snap-folder-restart');
|
||||
const colPath = await createTmpDir('col');
|
||||
|
||||
const app = await launchElectronApp({ userDataPath });
|
||||
const page = await waitForReadyPage(app);
|
||||
|
||||
await test.step('Create collection and folder, open folder settings on auth tab', async () => {
|
||||
await createCollection(page, 'TestCol', colPath);
|
||||
await createFolder(page, 'TestFolder', 'TestCol');
|
||||
await openfolder(page, 'TestCol', 'TestFolder', { persist: true });
|
||||
await selectfolderPaneTab(page, 'script');
|
||||
});
|
||||
|
||||
await test.step('Close app and verify snapshot stores folder-settings tab', async () => {
|
||||
await page.waitForTimeout(2000);
|
||||
await closeElectronApp(app);
|
||||
|
||||
await waitForSnapshotFile(userDataPath);
|
||||
|
||||
const snapshot = readSnapshot(userDataPath);
|
||||
const tab = findSnapshotFolderTab(snapshot, 'TestFolder');
|
||||
expect(tab).toBeTruthy();
|
||||
expect(tab.type).toBe('folder-settings');
|
||||
expect(tab.permanent).toBe(true);
|
||||
});
|
||||
|
||||
await test.step('Restart app and verify folder script pane is interactive', async () => {
|
||||
const app2 = await launchElectronApp({ userDataPath });
|
||||
const page2 = await waitForReadyPage(app2);
|
||||
|
||||
await focusFolderSettingsTab(page2, 'TestFolder', { timeout: 15000 });
|
||||
|
||||
await selectfolderPaneTab(page2, 'script');
|
||||
|
||||
await selectFolderScriptPaneTab(page2, 'pre-request');
|
||||
await selectFolderScriptPaneTab(page2, 'post-response');
|
||||
|
||||
await closeElectronApp(app2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -857,6 +857,101 @@ const selectfolderPaneTab = async (page: Page, tabName: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Select a sub-tab in the folder script pane (Pre Request or Post Response)
|
||||
* @param page - The page object
|
||||
* @param tabName - 'pre-request' or 'post-response'
|
||||
* @returns void
|
||||
*/
|
||||
const selectFolderScriptPaneTab = async (page: Page, tabName: 'pre-request' | 'post-response') => {
|
||||
await test.step(`Select folder script pane tab "${tabName}"`, async () => {
|
||||
const locators = buildCommonLocators(page);
|
||||
const tab = locators.paneTabs.folderScriptTab(tabName);
|
||||
await tab.click();
|
||||
await expect(tab).toContainClass('active');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Open a collection's settings tab by clicking on it in the sidebar
|
||||
* @param page - The page object
|
||||
* @param collectionName - The name of the collection
|
||||
* @param options - Optional settings (persist: double-click to make tab permanent)
|
||||
* @returns void
|
||||
*/
|
||||
const openCollectionSettings = async (page: Page, collectionName: string, { persist = false } = {}) => {
|
||||
await test.step(`Open collection settings for "${collectionName}"`, async () => {
|
||||
const locators = buildCommonLocators(page);
|
||||
const collection = locators.sidebar.collection(collectionName);
|
||||
if (!persist) {
|
||||
await collection.click();
|
||||
} else {
|
||||
await collection.dblclick();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Select a tab in the collection settings pane
|
||||
* @param page - The page object
|
||||
* @param tabName - The tab name key (e.g. 'auth', 'headers', 'overview', 'script', 'vars')
|
||||
* @returns void
|
||||
*/
|
||||
const selectCollectionPaneTab = async (page: Page, tabName: string) => {
|
||||
await test.step(`Select collection pane tab "${tabName}"`, async () => {
|
||||
const locators = buildCommonLocators(page);
|
||||
const tab = locators.paneTabs.collectionSettingsTab(tabName.toLowerCase());
|
||||
await tab.click();
|
||||
await expect(tab).toContainClass('active');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Select a sub-tab in the collection script pane (Pre Request or Post Response)
|
||||
* @param page - The page object
|
||||
* @param tabName - 'pre-request' or 'post-response'
|
||||
* @returns void
|
||||
*/
|
||||
const selectCollectionScriptPaneTab = async (page: Page, tabName: 'pre-request' | 'post-response') => {
|
||||
await test.step(`Select collection script pane tab "${tabName}"`, async () => {
|
||||
const locators = buildCommonLocators(page);
|
||||
const tab = locators.paneTabs.tabTrigger(tabName);
|
||||
await tab.click();
|
||||
await expect(tab).toContainClass('active');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Focus the folder settings tab in the tab bar after restore
|
||||
* @param page - The page object
|
||||
* @param folderName - The name of the folder
|
||||
* @param options - Optional timeout in milliseconds
|
||||
* @returns void
|
||||
*/
|
||||
const focusFolderSettingsTab = async (page: Page, folderName: string, { timeout = 10000 } = {}) => {
|
||||
await test.step(`Focus folder settings tab "${folderName}"`, async () => {
|
||||
const locators = buildCommonLocators(page);
|
||||
const tab = locators.tabs.folderTab(folderName);
|
||||
await expect(tab).toBeVisible({ timeout });
|
||||
await tab.click({ force: true });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Focus the collection settings tab in the tab bar after restore
|
||||
* @param page - The page object
|
||||
* @param options - Optional timeout in milliseconds
|
||||
* @returns void
|
||||
*/
|
||||
const focusCollectionSettingsTab = async (page: Page, { timeout = 10000 } = {}) => {
|
||||
await test.step('Focus collection settings tab', async () => {
|
||||
const locators = buildCommonLocators(page);
|
||||
const tab = locators.tabs.collectionSettingsTab();
|
||||
await expect(tab).toBeVisible({ timeout });
|
||||
await tab.click({ force: true });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Open a request within a folder
|
||||
* @param page - The page object
|
||||
@@ -1667,6 +1762,12 @@ export {
|
||||
openfolder,
|
||||
openFolderRequest,
|
||||
selectfolderPaneTab,
|
||||
selectFolderScriptPaneTab,
|
||||
openCollectionSettings,
|
||||
selectCollectionPaneTab,
|
||||
selectCollectionScriptPaneTab,
|
||||
focusFolderSettingsTab,
|
||||
focusCollectionSettingsTab,
|
||||
getResponseBody,
|
||||
expectResponseContains,
|
||||
selectRequestPaneTab,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './actions';
|
||||
export * from './runner';
|
||||
export * from './locators';
|
||||
export * from '../snapshot';
|
||||
|
||||
@@ -38,6 +38,8 @@ export const buildCommonLocators = (page: Page) => ({
|
||||
tabs: {
|
||||
requestTab: (requestName: string) => page.locator('.request-tab .tab-label').filter({ hasText: requestName }),
|
||||
folderTab: (folderName: string) => page.locator('.request-tab .tab-label').filter({ hasText: folderName }),
|
||||
collectionSettingsTab: () =>
|
||||
page.locator('.request-tab').filter({ has: page.locator('.tab-label', { hasText: 'Collection' }) }),
|
||||
activeRequestTab: () => page.locator('.request-tab.active'),
|
||||
closeTab: (requestName: string) => page.locator('.request-tab').filter({ hasText: requestName }).getByTestId('request-tab-close-icon'),
|
||||
draftIndicator: () => page.locator('.request-tab.active .has-changes-icon')
|
||||
@@ -46,6 +48,7 @@ export const buildCommonLocators = (page: Page) => ({
|
||||
responsiveTab: (key: string) => page.getByTestId(`responsive-tab-${key}`),
|
||||
collectionSettingsTab: (key: string) => page.getByTestId(`collection-settings-tab-${key}`),
|
||||
folderSettingsTab: (key: string) => page.getByTestId(`folder-settings-tab-${key}`),
|
||||
folderScriptTab: (key: 'pre-request' | 'post-response') => page.getByTestId(`tab-trigger-${key}`),
|
||||
tabTrigger: (key: string) => page.getByTestId(`tab-trigger-${key}`)
|
||||
},
|
||||
folder: {
|
||||
|
||||
43
tests/utils/snapshot.ts
Normal file
43
tests/utils/snapshot.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { expect } from '../../playwright';
|
||||
|
||||
export const getSnapshotPath = (userDataPath: string) =>
|
||||
path.join(userDataPath, 'ui-state-snapshot.json');
|
||||
|
||||
/**
|
||||
* Read the snapshot JSON from the user data directory.
|
||||
* electron-store saves it as `ui-state-snapshot.json`.
|
||||
*/
|
||||
export const readSnapshot = (userDataPath: string) => {
|
||||
const snapshotPath = getSnapshotPath(userDataPath);
|
||||
if (!fs.existsSync(snapshotPath)) return null;
|
||||
return JSON.parse(fs.readFileSync(snapshotPath, 'utf-8'));
|
||||
};
|
||||
|
||||
export const waitForSnapshotFile = async (userDataPath: string) => {
|
||||
await expect.poll(() => fs.existsSync(getSnapshotPath(userDataPath))).toBe(true);
|
||||
};
|
||||
|
||||
export const findSnapshotFolderTab = (snapshot: any, folderName: string) => {
|
||||
if (!snapshot || !Array.isArray(snapshot.collections)) return null;
|
||||
for (const collection of snapshot.collections) {
|
||||
if (!Array.isArray(collection?.tabs)) continue;
|
||||
const tab = collection.tabs.find(
|
||||
(t: any) => t?.type === 'folder-settings' && typeof t?.pathname === 'string' && t.pathname.includes(folderName)
|
||||
);
|
||||
if (tab) return tab;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const findSnapshotCollectionTab = (snapshot: any, collectionPath: string) => {
|
||||
if (!snapshot || !Array.isArray(snapshot.collections)) return null;
|
||||
for (const collection of snapshot.collections) {
|
||||
if (collectionPath && collection?.pathname && !collection.pathname.includes(collectionPath)) continue;
|
||||
if (!Array.isArray(collection?.tabs)) continue;
|
||||
const tab = collection.tabs.find((t: any) => t?.type === 'collection-settings');
|
||||
if (tab) return tab;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
Reference in New Issue
Block a user