Compare commits

...

14 Commits

Author SHA1 Message Date
Sid
13988c4c02 feat: enhance multipart-form file handling by filtering empty values in parser and stringifier (#8444) 2026-07-01 13:31:54 +05:30
Sid
cd43a61a8e fix(devtools): network tab panel inner scroll (#8329)
* fix: ui overflow for the network tab

* test(e2e): scroller behaviour for network tab
2026-06-23 19:48:58 +05:30
Utkarsh
8cfe2dae4c fix(postman-migration): install packages report npm install failed (#8284) 2026-06-22 14:19:02 +05:30
Abhishek Patil
bad7956cfb fix(perf):quickjs-memory-leak (#8219) 2026-06-22 14:18:34 +05:30
Bhavik Mehta
5dcbafde91 fix: show unsaved changes prompt when closing tab with Cmd+W (#8245) 2026-06-22 14:18:11 +05:30
lohit
c6ae80fecd feat: support annotations for secret environment variables in bru and preserve variable value type in yml (#8251) 2026-06-15 18:50:53 +05:30
Sid
ecff76e950 chore: sec updates (#8193)
* chore: reset + atomic updates

* chore: surgically update protobufjs

* chore: dedupe axios
2026-06-15 18:50:35 +05:30
gopu-bruno
9a7f664037 fix(workspace): keep workspace nav tabs visible when editing docs (#8249) 2026-06-15 18:50:10 +05:30
Sundram
daa0df98b3 fix(apispec): prevent crash on non-array specs and fix Windows spec listing (BRU-3556) (#8255) 2026-06-15 18:49:50 +05:30
Pooja
6717035dd2 fix(timeline): scope scripted requests to their own request (#8210)
* fix(timeline): scope scripted requests to their own request

* fix: oauth playwright test
2026-06-15 18:49:33 +05:30
Sid
f05bb9c49d fix(snapshot): folder nested script tab interactivity and tests (#8225)
* fix(snapshot): folder script interactivity

* fix: add tests for collection scripts
2026-06-15 18:49:01 +05:30
Utkarsh
a09ddedf90 Merge pull request #8253 from utkarsh-bruno/fix/BRU-3531 2026-06-15 18:48:16 +05:30
Pooja
0e46d60ec4 fix(proxy): refresh cached PAC content on demand (#8173) 2026-06-15 18:46:17 +05:30
prateek-bruno
b8804afade fix: openapi spec export crash on websocket request (#8132)
* fix: only accept http and graphql for openapi spec

* chore: add test

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

---------

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>
2026-06-15 18:45:49 +05:30
80 changed files with 2809 additions and 262 deletions

73
package-lock.json generated
View File

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

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,7 @@ const Status = ({ statusCode }) => {
return (
<span
className="timeline-status"
data-testid="timeline-status"
style={{
color,
background,

View File

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

View File

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

View File

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

View File

@@ -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 = () => {

View File

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

View File

@@ -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([]);
});
});

View File

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

View File

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

View File

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

View File

@@ -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.
*/

View File

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

View File

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

View File

@@ -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: {},

View File

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

View File

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

View File

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

View File

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

View File

@@ -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');

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 || '';
}

View File

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

View File

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

View File

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

View File

@@ -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' } }
]);
});
});

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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}
`;

View File

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

View File

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

View File

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

View File

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

View File

@@ -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']);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
{
"version": "1",
"name": "network-log-scroll",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

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

View File

@@ -0,0 +1,10 @@
{
"collections": [
{
"path": "{{collectionPath}}",
"securityConfig": {
"jsSandboxMode": "safe"
}
}
]
}

View File

@@ -0,0 +1,12 @@
{
"maximized": false,
"lastOpenedCollections": [
"{{collectionPath}}"
],
"preferences": {
"onboarding": {
"hasLaunchedBefore": true,
"hasSeenWelcomeModal": true
}
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
export * from './actions';
export * from './runner';
export * from './locators';
export * from '../snapshot';

View File

@@ -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
View 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;
};