mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
feat: npm package report and installation support (#8143)
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
import styled, { keyframes } from 'styled-components';
|
||||
|
||||
const spin = keyframes`
|
||||
to { transform: rotate(360deg); }
|
||||
`;
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.bruno-modal-card {
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
.pkg-section {
|
||||
border: 1px solid ${(props) => props.theme.border.border2};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
background-color: ${(props) => props.theme.background.mantle};
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.pkg-section + .pkg-section,
|
||||
.pkg-section + .pkg-status,
|
||||
.pkg-status + .pkg-status {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.pkg-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.pkg-section-title {
|
||||
flex: 1;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.pkg-section-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
font-weight: 600;
|
||||
background-color: ${(props) => props.theme.background.base};
|
||||
border: 1px solid ${(props) => props.theme.border.border2};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.pkg-section-help {
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
line-height: 1.45;
|
||||
margin: 0 0 10px 0;
|
||||
|
||||
code {
|
||||
background-color: ${(props) => props.theme.background.base};
|
||||
border: 1px solid ${(props) => props.theme.border.border2};
|
||||
padding: 1px 5px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: ${(props) => props.theme.text};
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.pkg-section-danger .pkg-section-head {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
|
||||
.pkg-devmode {
|
||||
margin-top: 10px;
|
||||
border-color: ${(props) => props.theme.primary.solid};
|
||||
}
|
||||
|
||||
.pkg-devmode-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pkg-devmode-head svg {
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pkg-devmode-title {
|
||||
font-size: ${(props) => props.theme.font.size.md};
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.pkg-devmode-desc {
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
line-height: 1.5;
|
||||
margin: 0 0 12px 0;
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: ${(props) => props.theme.text};
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.pkg-devmode-trust {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid ${(props) => props.theme.primary.solid};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pkg-inline-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-top: 12px;
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
}
|
||||
|
||||
.pkg-inline-info {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.pkg-inline-success {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
|
||||
.pkg-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pkg-list-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 8px 3px 6px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background-color: ${(props) => props.theme.background.base};
|
||||
border: 1px solid ${(props) => props.theme.border.border2};
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.pkg-list-item svg {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.pkg-section-danger .pkg-list-item {
|
||||
border-color: ${(props) => props.theme.status.danger.border};
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
|
||||
.pkg-cmd-block {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pkg-cmd-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pkg-cmd-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background-color: ${(props) => props.theme.background.base};
|
||||
border: 1px solid ${(props) => props.theme.border.border2};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pkg-cmd-code {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.text};
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
background: transparent;
|
||||
|
||||
&::before {
|
||||
content: '$ ';
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
}
|
||||
|
||||
.pkg-cmd-copy {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 1px solid ${(props) => props.theme.border.border2};
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.background.mantle};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.pkg-status {
|
||||
padding: 10px 12px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
line-height: 1.4;
|
||||
border: 1px solid ${(props) => props.theme.border.border2};
|
||||
background-color: ${(props) => props.theme.background.mantle};
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.pkg-status-info svg:first-child {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.pkg-status-success {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
|
||||
.pkg-status-success svg:first-child {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
|
||||
.pkg-status-danger {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pkg-status-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pkg-status-log {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
background-color: ${(props) => props.theme.background.base};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.border.border2};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.text};
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pkg-spin {
|
||||
animation: ${spin} 0.8s linear infinite;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,341 @@
|
||||
import React, { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBan,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconCode,
|
||||
IconCopy,
|
||||
IconLoader2,
|
||||
IconPackage,
|
||||
IconShieldLock,
|
||||
IconTerminal2
|
||||
} from '@tabler/icons';
|
||||
import Modal from 'components/Modal';
|
||||
import Button from 'ui/Button';
|
||||
import { saveCollectionSecurityConfig } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { findCollectionByPathname } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const PackageList = ({ items }) => (
|
||||
<ul className="pkg-list">
|
||||
{items.map((name) => (
|
||||
<li key={name} className="pkg-list-item">
|
||||
<IconPackage size={12} strokeWidth={1.75} />
|
||||
<span>{name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
// Renders "`a` and `b`" / "`a`, `b` and `c`" / "`a`, `b` and 3 more" as inline
|
||||
// code spans for use inside a sentence.
|
||||
const renderPackageExamples = (names = []) => {
|
||||
const shown = names.slice(0, 3);
|
||||
const remainder = names.length - shown.length;
|
||||
return shown.map((name, idx) => {
|
||||
let separator = '';
|
||||
if (idx > 0) {
|
||||
separator = idx === shown.length - 1 && remainder === 0 ? ' and ' : ', ';
|
||||
}
|
||||
return (
|
||||
<Fragment key={name}>
|
||||
{separator}
|
||||
<code>{name}</code>
|
||||
{idx === shown.length - 1 && remainder > 0 ? ` and ${remainder} more` : ''}
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Maps an install result's errorCode to a user-facing message. Falls back to a
|
||||
// generic exit-code message for plain non-zero exits.
|
||||
const getInstallFailureMessage = (result) => {
|
||||
switch (result?.errorCode) {
|
||||
case 'NPM_NOT_FOUND':
|
||||
return 'npm was not found on your PATH. Install Node.js/npm, then retry or run the command manually.';
|
||||
case 'TIMEOUT':
|
||||
return 'npm install timed out. Try running the command manually in a terminal.';
|
||||
case 'SPAWN_FAILED':
|
||||
case 'SPAWN_ERROR':
|
||||
return 'Could not start npm install. Try running the command manually.';
|
||||
default:
|
||||
return `npm install failed (exit code ${result?.exitCode}). Try the manual command above.`;
|
||||
}
|
||||
};
|
||||
|
||||
const PostmanPackageReport = ({ report, collectionPath, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const collection = useMemo(
|
||||
() => findCollectionByPathname(collections, collectionPath),
|
||||
[collections, collectionPath]
|
||||
);
|
||||
const sandboxMode = collection?.securityConfig?.jsSandboxMode || 'safe';
|
||||
const isDeveloperMode = sandboxMode === 'developer';
|
||||
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [installResult, setInstallResult] = useState(null);
|
||||
const [switchingMode, setSwitchingMode] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const needsInstall = report?.needsInstall || [];
|
||||
const unsupported = report?.unsupported || [];
|
||||
const devMode = report?.devMode || [];
|
||||
|
||||
const installCommand = useMemo(
|
||||
() => (needsInstall.length ? `npm install --save ${needsInstall.join(' ')}` : ''),
|
||||
[needsInstall]
|
||||
);
|
||||
|
||||
const needsDevModeOnly
|
||||
= needsInstall.length === 0 && devMode.length > 0 && !isDeveloperMode;
|
||||
const hasActionable
|
||||
= needsInstall.length > 0 || unsupported.length > 0 || needsDevModeOnly;
|
||||
|
||||
useEffect(() => {
|
||||
if (report && !hasActionable) onClose();
|
||||
}, [report, hasActionable, onClose]);
|
||||
|
||||
if (!report || !hasActionable) return null;
|
||||
|
||||
const installDone = installResult && installResult.success;
|
||||
const installFailed = installResult && !installResult.success;
|
||||
const installFailureMessage = installFailed ? getInstallFailureMessage(installResult) : '';
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!collectionPath) {
|
||||
toast.error('Cannot install: collection path not available.');
|
||||
return;
|
||||
}
|
||||
if (needsInstall.length === 0) return;
|
||||
|
||||
setInstalling(true);
|
||||
setInstallResult(null);
|
||||
try {
|
||||
const result = await window.ipcRenderer.invoke(
|
||||
'renderer:install-postman-packages',
|
||||
collectionPath,
|
||||
needsInstall
|
||||
);
|
||||
setInstallResult(result);
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
`Installed ${needsInstall.length} package${needsInstall.length === 1 ? '' : 's'}`
|
||||
);
|
||||
} else {
|
||||
toast.error('npm install failed. See details below.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Install failed:', err);
|
||||
setInstallResult({ success: false, stderr: err?.message || String(err), exitCode: -1 });
|
||||
toast.error('Failed to start npm install');
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchToDeveloperMode = () => {
|
||||
if (!collection?.uid) {
|
||||
toast.error('Could not locate the imported collection to switch modes.');
|
||||
return;
|
||||
}
|
||||
setSwitchingMode(true);
|
||||
dispatch(saveCollectionSecurityConfig(collection.uid, { jsSandboxMode: 'developer' }))
|
||||
.then(() => toast.success('Developer Mode enabled'))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error('Failed to switch sandbox mode');
|
||||
})
|
||||
.finally(() => setSwitchingMode(false));
|
||||
};
|
||||
|
||||
const handleCopyCommand = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(installCommand);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error('Could not copy to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
const isDismissAction = installDone || needsInstall.length === 0;
|
||||
const confirmText = installDone
|
||||
? 'Done'
|
||||
: installing
|
||||
? 'Installing…'
|
||||
: needsInstall.length > 0
|
||||
? `Install ${needsInstall.length} package${needsInstall.length === 1 ? '' : 's'}`
|
||||
: 'Done';
|
||||
const handleConfirm = isDismissAction ? onClose : handleInstall;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<Modal
|
||||
size="md"
|
||||
title="Install packages"
|
||||
confirmText={confirmText}
|
||||
cancelText="Skip"
|
||||
hideCancel={installDone || (needsInstall.length === 0 && !installFailed)}
|
||||
confirmDisabled={installing}
|
||||
confirmButtonColor={isDismissAction ? 'secondary' : 'primary'}
|
||||
handleConfirm={handleConfirm}
|
||||
handleCancel={onClose}
|
||||
dataTestId="postman-package-report-modal"
|
||||
disableCloseOnOutsideClick
|
||||
>
|
||||
{needsInstall.length > 0 && (
|
||||
<div className="pkg-section">
|
||||
<div className="pkg-section-head">
|
||||
<span className="pkg-section-title">Packages used in scripts</span>
|
||||
<span className="pkg-section-count">{needsInstall.length}</span>
|
||||
</div>
|
||||
{!installing && !installDone && (
|
||||
<p className="pkg-section-help">
|
||||
These npm packages are referenced by scripts in your imported collection but aren't
|
||||
installed in this collection's folder.
|
||||
</p>
|
||||
)}
|
||||
<PackageList items={needsInstall} />
|
||||
|
||||
{!installing && !installDone && (
|
||||
<div className="pkg-cmd-block">
|
||||
<div className="pkg-cmd-label">
|
||||
<IconTerminal2 size={12} strokeWidth={1.75} />
|
||||
<span>Or install manually</span>
|
||||
</div>
|
||||
<div className="pkg-cmd-row">
|
||||
<code className="pkg-cmd-code">{installCommand}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="pkg-cmd-copy"
|
||||
onClick={handleCopyCommand}
|
||||
aria-label="Copy command"
|
||||
>
|
||||
{copied ? <IconCheck size={14} strokeWidth={1.75} /> : <IconCopy size={14} strokeWidth={1.5} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{installing && (
|
||||
<div className="pkg-inline-status pkg-inline-info">
|
||||
<IconLoader2 size={14} strokeWidth={1.75} className="pkg-spin" />
|
||||
<span>Installing {needsInstall.length} package{needsInstall.length === 1 ? '' : 's'}…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{installDone && (
|
||||
<div className="pkg-inline-status pkg-inline-success">
|
||||
<IconCircleCheck size={14} strokeWidth={1.75} />
|
||||
<span>
|
||||
Installed {(installResult.installed || needsInstall).length} package
|
||||
{(installResult.installed || needsInstall).length === 1 ? '' : 's'} into this collection.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsDevModeOnly && !installDone && !installing && (
|
||||
<div className="pkg-section pkg-devmode">
|
||||
<div className="pkg-devmode-head">
|
||||
<IconAlertTriangle size={18} strokeWidth={1.75} />
|
||||
<span className="pkg-devmode-title">Scripts use libraries that need Developer Mode</span>
|
||||
</div>
|
||||
<p className="pkg-devmode-desc">
|
||||
Your imported scripts call {renderPackageExamples(devMode)}
|
||||
{', '}which need <strong>Developer Mode</strong> to run.
|
||||
</p>
|
||||
<PackageList items={devMode} />
|
||||
<div className="pkg-devmode-trust">
|
||||
<IconShieldLock size={15} strokeWidth={1.75} />
|
||||
<span>Only enable Developer Mode for collections you trust.</span>
|
||||
</div>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
loading={switchingMode}
|
||||
icon={<IconCode size={15} strokeWidth={2} />}
|
||||
onClick={handleSwitchToDeveloperMode}
|
||||
data-testid="switch-to-developer-mode"
|
||||
>
|
||||
Switch to Developer Mode
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unsupported.length > 0 && !installDone && !installing && (
|
||||
<div className="pkg-section pkg-section-danger">
|
||||
<div className="pkg-section-head">
|
||||
<IconBan size={14} strokeWidth={1.75} />
|
||||
<span className="pkg-section-title">Not supported in Bruno</span>
|
||||
<span className="pkg-section-count">{unsupported.length}</span>
|
||||
</div>
|
||||
<p className="pkg-section-help">
|
||||
Postman-specific packages without a Bruno equivalent. Scripts that call these will
|
||||
fail at runtime.
|
||||
</p>
|
||||
<PackageList items={unsupported} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{installDone && (
|
||||
isDeveloperMode ? (
|
||||
<div className="pkg-status pkg-status-success">
|
||||
<IconCircleCheck size={14} strokeWidth={1.75} />
|
||||
<span>
|
||||
This collection runs in <strong>Developer Mode</strong> - your scripts can use these
|
||||
packages right away.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pkg-section pkg-devmode">
|
||||
<div className="pkg-devmode-head">
|
||||
<IconAlertTriangle size={18} strokeWidth={1.75} />
|
||||
<span className="pkg-devmode-title">External modules require Developer Mode</span>
|
||||
</div>
|
||||
<p className="pkg-devmode-desc">
|
||||
Custom npm packages (such as {renderPackageExamples(installResult.installed || needsInstall)})
|
||||
{' '}are installed, but this collection is currently running in <strong>Safe Mode</strong>.
|
||||
</p>
|
||||
<div className="pkg-devmode-trust">
|
||||
<IconShieldLock size={15} strokeWidth={1.75} />
|
||||
<span>Only enable Developer Mode for collections you trust.</span>
|
||||
</div>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
loading={switchingMode}
|
||||
icon={<IconCode size={15} strokeWidth={2} />}
|
||||
onClick={handleSwitchToDeveloperMode}
|
||||
data-testid="switch-to-developer-mode"
|
||||
>
|
||||
Switch to Developer Mode
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{installFailed && (
|
||||
<div className="pkg-status pkg-status-danger" data-testid="postman-package-install-error">
|
||||
<div className="pkg-status-head">
|
||||
<IconAlertTriangle size={14} strokeWidth={1.75} />
|
||||
<span>{installFailureMessage}</span>
|
||||
</div>
|
||||
{(installResult.stderr || installResult.stdout) && (
|
||||
<pre className="pkg-status-log">
|
||||
{(installResult.stderr || installResult.stdout).slice(-1200)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostmanPackageReport;
|
||||
@@ -0,0 +1,217 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore, createSlice } from '@reduxjs/toolkit';
|
||||
import { ThemeProvider } from 'providers/Theme';
|
||||
import PostmanPackageReport from './index';
|
||||
|
||||
const mockSaveSecurityConfig = jest.fn();
|
||||
jest.mock('providers/ReduxStore/slices/collections/actions', () => ({
|
||||
saveCollectionSecurityConfig: (...args) => mockSaveSecurityConfig(...args)
|
||||
}));
|
||||
|
||||
let mockCollection;
|
||||
jest.mock('utils/collections', () => ({
|
||||
findCollectionByPathname: () => mockCollection
|
||||
}));
|
||||
|
||||
jest.mock('react-hot-toast', () => ({
|
||||
__esModule: true,
|
||||
default: { success: jest.fn(), error: jest.fn() }
|
||||
}));
|
||||
|
||||
const baseReport = {
|
||||
hasAny: true,
|
||||
needsInstall: ['dayjs', 'zod'],
|
||||
unsupported: [],
|
||||
safeMode: [],
|
||||
devMode: []
|
||||
};
|
||||
|
||||
const createStore = () => {
|
||||
const slice = createSlice({
|
||||
name: 'collections',
|
||||
initialState: { collections: [] },
|
||||
reducers: {}
|
||||
});
|
||||
return configureStore({ reducer: { collections: slice.reducer } });
|
||||
};
|
||||
|
||||
const renderModal = (props = {}) =>
|
||||
render(
|
||||
<Provider store={createStore()}>
|
||||
<ThemeProvider>
|
||||
<PostmanPackageReport
|
||||
report={baseReport}
|
||||
collectionPath="/collections/demo"
|
||||
onClose={props.onClose || jest.fn()}
|
||||
{...props}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn()
|
||||
}))
|
||||
});
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: { getItem: jest.fn(() => null), setItem: jest.fn(), removeItem: jest.fn() }
|
||||
});
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: jest.fn().mockResolvedValue() },
|
||||
configurable: true
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockSaveSecurityConfig.mockReset();
|
||||
mockSaveSecurityConfig.mockReturnValue(() => Promise.resolve());
|
||||
mockCollection = { uid: 'col-1', pathname: '/collections/demo', securityConfig: { jsSandboxMode: 'safe' } };
|
||||
window.ipcRenderer = {
|
||||
invoke: jest.fn().mockResolvedValue({ success: true, installed: ['dayjs', 'zod'] }),
|
||||
send: jest.fn()
|
||||
};
|
||||
});
|
||||
|
||||
describe('PostmanPackageReport', () => {
|
||||
it('renders the needs-install packages and the install action', () => {
|
||||
renderModal();
|
||||
expect(screen.getByText('Packages used in scripts')).toBeInTheDocument();
|
||||
expect(screen.getByText('dayjs')).toBeInTheDocument();
|
||||
expect(screen.getByText('zod')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('postman-package-report-modal-submit-btn')).toHaveTextContent('Install 2 packages');
|
||||
});
|
||||
|
||||
it('renders the manual install command', () => {
|
||||
renderModal();
|
||||
expect(screen.getByText('npm install --save dayjs zod')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns nothing when there is no actionable package', () => {
|
||||
const { container } = renderModal({
|
||||
report: { hasAny: false, needsInstall: [], unsupported: [], safeMode: ['uuid'], devMode: [] }
|
||||
});
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('prompts to switch to Developer Mode when only dev-mode libs are referenced (Safe Mode)', () => {
|
||||
renderModal({
|
||||
report: {
|
||||
hasAny: true,
|
||||
needsInstall: [],
|
||||
unsupported: [],
|
||||
safeMode: [],
|
||||
devMode: ['lodash', 'moment']
|
||||
}
|
||||
});
|
||||
expect(screen.getByText('Scripts use libraries that need Developer Mode')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('lodash').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('moment').length).toBeGreaterThan(0);
|
||||
expect(screen.getByTestId('switch-to-developer-mode')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('postman-package-report-modal-submit-btn')).toHaveTextContent('Done');
|
||||
});
|
||||
|
||||
it('auto-dismisses when only dev-mode libs are referenced and the collection is already in Developer Mode', () => {
|
||||
mockCollection = {
|
||||
uid: 'col-1',
|
||||
pathname: '/collections/demo',
|
||||
securityConfig: { jsSandboxMode: 'developer' }
|
||||
};
|
||||
const onClose = jest.fn();
|
||||
const { container } = renderModal({
|
||||
onClose,
|
||||
report: {
|
||||
hasAny: true,
|
||||
needsInstall: [],
|
||||
unsupported: [],
|
||||
safeMode: [],
|
||||
devMode: ['lodash']
|
||||
}
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('shows the unsupported section when present', () => {
|
||||
renderModal({
|
||||
report: { ...baseReport, unsupported: ['postman-collection'] }
|
||||
});
|
||||
expect(screen.getByText('Not supported in Bruno')).toBeInTheDocument();
|
||||
expect(screen.getByText('postman-collection')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('installs packages and then prompts to enable Developer Mode (Safe Mode collection)', async () => {
|
||||
renderModal();
|
||||
|
||||
fireEvent.click(screen.getByTestId('postman-package-report-modal-submit-btn'));
|
||||
|
||||
expect(window.ipcRenderer.invoke).toHaveBeenCalledWith(
|
||||
'renderer:install-postman-packages',
|
||||
'/collections/demo',
|
||||
['dayjs', 'zod']
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/Installed 2 packages into this collection/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('External modules require Developer Mode')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('switch-to-developer-mode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dispatches the Developer Mode switch when the user opts in', async () => {
|
||||
renderModal();
|
||||
fireEvent.click(screen.getByTestId('postman-package-report-modal-submit-btn'));
|
||||
const switchBtn = await screen.findByTestId('switch-to-developer-mode');
|
||||
|
||||
fireEvent.click(switchBtn);
|
||||
await waitFor(() => {
|
||||
expect(mockSaveSecurityConfig).toHaveBeenCalledWith('col-1', { jsSandboxMode: 'developer' });
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the Developer Mode prompt when the collection is already in Developer Mode', async () => {
|
||||
mockCollection = {
|
||||
uid: 'col-1',
|
||||
pathname: '/collections/demo',
|
||||
securityConfig: { jsSandboxMode: 'developer' }
|
||||
};
|
||||
renderModal();
|
||||
fireEvent.click(screen.getByTestId('postman-package-report-modal-submit-btn'));
|
||||
|
||||
expect(await screen.findByText(/runs in/i)).toHaveTextContent(/Developer Mode/i);
|
||||
expect(screen.queryByTestId('switch-to-developer-mode')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces a friendly message when npm is not on PATH', async () => {
|
||||
window.ipcRenderer.invoke = jest.fn().mockResolvedValue({
|
||||
success: false,
|
||||
exitCode: -1,
|
||||
errorCode: 'NPM_NOT_FOUND',
|
||||
stderr: 'npm was not found on your PATH.'
|
||||
});
|
||||
renderModal();
|
||||
fireEvent.click(screen.getByTestId('postman-package-report-modal-submit-btn'));
|
||||
|
||||
const error = await screen.findByTestId('postman-package-install-error');
|
||||
expect(error).toHaveTextContent(/not found on your PATH/i);
|
||||
});
|
||||
|
||||
it('shows the exit code for a generic install failure', async () => {
|
||||
window.ipcRenderer.invoke = jest.fn().mockResolvedValue({
|
||||
success: false,
|
||||
exitCode: 1,
|
||||
stderr: 'npm ERR! 404'
|
||||
});
|
||||
renderModal();
|
||||
fireEvent.click(screen.getByTestId('postman-package-report-modal-submit-btn'));
|
||||
|
||||
const error = await screen.findByTestId('postman-package-install-error');
|
||||
expect(error).toHaveTextContent(/exit code 1/i);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,8 @@ import BulkImportCollectionLocation from 'components/Sidebar/BulkImportCollectio
|
||||
import CloneGitRepository from 'components/Sidebar/CloneGitRespository';
|
||||
import RemoveCollectionsModal from 'components/Sidebar/Collections/RemoveCollectionsModal/index';
|
||||
import CreateCollection from 'components/Sidebar/CreateCollection';
|
||||
import PostmanPackageReport from 'components/Sidebar/PostmanPackageReport';
|
||||
import usePostmanPackagePrompt from 'hooks/usePostmanPackagePrompt';
|
||||
import WelcomeModal from 'components/WelcomeModal';
|
||||
import Collections from 'components/Sidebar/Collections';
|
||||
import SidebarSection from 'components/Sidebar/SidebarSection';
|
||||
@@ -58,6 +60,7 @@ const CollectionsSection = () => {
|
||||
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
|
||||
const [showCloneGitModal, setShowCloneGitModal] = useState(false);
|
||||
const [gitRepositoryUrl, setGitRepositoryUrl] = useState(null);
|
||||
const { postmanPackagePrompt, clearPostmanPackagePrompt, handleImportResolved } = usePostmanPackagePrompt();
|
||||
|
||||
// Import collection shortcut
|
||||
useKeybinding('importCollection', () => {
|
||||
@@ -115,9 +118,10 @@ const CollectionsSection = () => {
|
||||
: importCollection(convertedCollection, collectionLocation, options);
|
||||
|
||||
dispatch(importAction)
|
||||
.then(() => {
|
||||
.then((importedItem) => {
|
||||
setImportCollectionLocationModalOpen(false);
|
||||
setImportData(null);
|
||||
handleImportResolved(convertedCollection, importedItem);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -396,6 +400,14 @@ const CollectionsSection = () => {
|
||||
collectionRepositoryUrl={gitRepositoryUrl}
|
||||
/>
|
||||
)}
|
||||
{postmanPackagePrompt && (
|
||||
<PostmanPackageReport
|
||||
key={postmanPackagePrompt.collectionPath}
|
||||
report={postmanPackagePrompt.report}
|
||||
collectionPath={postmanPackagePrompt.collectionPath}
|
||||
onClose={clearPostmanPackagePrompt}
|
||||
/>
|
||||
)}
|
||||
<SidebarSection
|
||||
id="collections"
|
||||
title="Collections"
|
||||
|
||||
@@ -8,6 +8,8 @@ import ImportCollection from 'components/Sidebar/ImportCollection';
|
||||
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
|
||||
import BulkImportCollectionLocation from 'components/Sidebar/BulkImportCollectionLocation';
|
||||
import CloneGitRepository from 'components/Sidebar/CloneGitRespository';
|
||||
import PostmanPackageReport from 'components/Sidebar/PostmanPackageReport';
|
||||
import usePostmanPackagePrompt from 'hooks/usePostmanPackagePrompt';
|
||||
import Button from 'ui/Button';
|
||||
import CollectionsList from './CollectionsList';
|
||||
import WorkspaceDocs from '../WorkspaceDocs';
|
||||
@@ -23,6 +25,7 @@ const WorkspaceOverview = ({ workspace }) => {
|
||||
const [importData, setImportData] = useState(null);
|
||||
const [showCloneGitModal, setShowCloneGitModal] = useState(false);
|
||||
const [gitRepositoryUrl, setGitRepositoryUrl] = useState(null);
|
||||
const { postmanPackagePrompt, clearPostmanPackagePrompt, handleImportResolved } = usePostmanPackagePrompt();
|
||||
|
||||
const workspaceCollectionsCount = workspace?.collections?.length || 0;
|
||||
|
||||
@@ -81,9 +84,10 @@ const WorkspaceOverview = ({ workspace }) => {
|
||||
: importCollection(convertedCollection, collectionLocation, options);
|
||||
|
||||
dispatch(importAction)
|
||||
.then(() => {
|
||||
.then((importedItem) => {
|
||||
setImportCollectionLocationModalOpen(false);
|
||||
setImportData(null);
|
||||
handleImportResolved(convertedCollection, importedItem);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -126,6 +130,14 @@ const WorkspaceOverview = ({ workspace }) => {
|
||||
collectionRepositoryUrl={gitRepositoryUrl}
|
||||
/>
|
||||
)}
|
||||
{postmanPackagePrompt && (
|
||||
<PostmanPackageReport
|
||||
key={postmanPackagePrompt.collectionPath}
|
||||
report={postmanPackagePrompt.report}
|
||||
collectionPath={postmanPackagePrompt.collectionPath}
|
||||
onClose={clearPostmanPackagePrompt}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="overview-layout">
|
||||
<div className="overview-main">
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
const toPairs = (converted, imported) => {
|
||||
const convertedList = Array.isArray(converted) ? converted : [converted];
|
||||
const importedList = Array.isArray(imported) ? imported : [imported];
|
||||
return convertedList
|
||||
.map((c, i) => ({
|
||||
report: c?.packageReport,
|
||||
collectionPath: importedList[i]?.path
|
||||
}))
|
||||
.filter((entry) => entry.report?.hasAny && entry.collectionPath);
|
||||
};
|
||||
|
||||
const usePostmanPackagePrompt = () => {
|
||||
const [queue, setQueue] = useState([]);
|
||||
|
||||
const clearPostmanPackagePrompt = useCallback(() => {
|
||||
setQueue((prev) => prev.slice(1));
|
||||
}, []);
|
||||
|
||||
const handleImportResolved = useCallback((convertedCollection, importedItem) => {
|
||||
const pairs = toPairs(convertedCollection, importedItem);
|
||||
if (pairs.length === 0) return;
|
||||
setQueue((prev) => [...prev, ...pairs]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
postmanPackagePrompt: queue[0] || null,
|
||||
clearPostmanPackagePrompt,
|
||||
handleImportResolved
|
||||
};
|
||||
};
|
||||
|
||||
export default usePostmanPackagePrompt;
|
||||
@@ -0,0 +1,113 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import usePostmanPackagePrompt from './index';
|
||||
|
||||
const reportWith = (needsInstall = ['dayjs'], hasAny = true) => ({
|
||||
hasAny,
|
||||
needsInstall,
|
||||
unsupported: [],
|
||||
safeMode: [],
|
||||
devMode: []
|
||||
});
|
||||
|
||||
describe('usePostmanPackagePrompt', () => {
|
||||
it('starts with no prompt', () => {
|
||||
const { result } = renderHook(() => usePostmanPackagePrompt());
|
||||
expect(result.current.postmanPackagePrompt).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the prompt when the report is actionable and a collection path exists', () => {
|
||||
const { result } = renderHook(() => usePostmanPackagePrompt());
|
||||
const report = reportWith(['dayjs', 'zod']);
|
||||
|
||||
act(() => {
|
||||
result.current.handleImportResolved({ packageReport: report }, { path: '/collections/demo' });
|
||||
});
|
||||
|
||||
expect(result.current.postmanPackagePrompt).toEqual({
|
||||
report,
|
||||
collectionPath: '/collections/demo'
|
||||
});
|
||||
});
|
||||
|
||||
it('does not open when the report has nothing actionable', () => {
|
||||
const { result } = renderHook(() => usePostmanPackagePrompt());
|
||||
act(() => {
|
||||
result.current.handleImportResolved(
|
||||
{ packageReport: reportWith([], false) },
|
||||
{ path: '/collections/demo' }
|
||||
);
|
||||
});
|
||||
expect(result.current.postmanPackagePrompt).toBeNull();
|
||||
});
|
||||
|
||||
it('does not open when there is no packageReport (non-Postman import)', () => {
|
||||
const { result } = renderHook(() => usePostmanPackagePrompt());
|
||||
act(() => {
|
||||
result.current.handleImportResolved({}, { path: '/collections/demo' });
|
||||
});
|
||||
expect(result.current.postmanPackagePrompt).toBeNull();
|
||||
});
|
||||
|
||||
it('does not open when the imported item has no path', () => {
|
||||
const { result } = renderHook(() => usePostmanPackagePrompt());
|
||||
act(() => {
|
||||
result.current.handleImportResolved({ packageReport: reportWith() }, undefined);
|
||||
});
|
||||
expect(result.current.postmanPackagePrompt).toBeNull();
|
||||
});
|
||||
|
||||
it('clears an open prompt', () => {
|
||||
const { result } = renderHook(() => usePostmanPackagePrompt());
|
||||
act(() => {
|
||||
result.current.handleImportResolved({ packageReport: reportWith() }, { path: '/c' });
|
||||
});
|
||||
expect(result.current.postmanPackagePrompt).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
result.current.clearPostmanPackagePrompt();
|
||||
});
|
||||
expect(result.current.postmanPackagePrompt).toBeNull();
|
||||
});
|
||||
|
||||
it('queues a prompt per collection on bulk import and steps through them', () => {
|
||||
const { result } = renderHook(() => usePostmanPackagePrompt());
|
||||
const reportA = reportWith(['ajv']);
|
||||
const reportB = reportWith(['zod']);
|
||||
|
||||
act(() => {
|
||||
result.current.handleImportResolved(
|
||||
[{ packageReport: reportA }, { packageReport: reportB }],
|
||||
[{ path: '/c/a' }, { path: '/c/b' }]
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.postmanPackagePrompt).toEqual({ report: reportA, collectionPath: '/c/a' });
|
||||
|
||||
act(() => result.current.clearPostmanPackagePrompt());
|
||||
expect(result.current.postmanPackagePrompt).toEqual({ report: reportB, collectionPath: '/c/b' });
|
||||
|
||||
act(() => result.current.clearPostmanPackagePrompt());
|
||||
expect(result.current.postmanPackagePrompt).toBeNull();
|
||||
});
|
||||
|
||||
it('skips collections in a bulk import that have nothing actionable', () => {
|
||||
const { result } = renderHook(() => usePostmanPackagePrompt());
|
||||
const empty = reportWith([], false);
|
||||
const actionable = reportWith(['ajv']);
|
||||
|
||||
act(() => {
|
||||
result.current.handleImportResolved(
|
||||
[{ packageReport: empty }, { packageReport: actionable }, { packageReport: empty }],
|
||||
[{ path: '/c/empty1' }, { path: '/c/real' }, { path: '/c/empty2' }]
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.postmanPackagePrompt).toEqual({
|
||||
report: actionable,
|
||||
collectionPath: '/c/real'
|
||||
});
|
||||
|
||||
act(() => result.current.clearPostmanPackagePrompt());
|
||||
expect(result.current.postmanPackagePrompt).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Detection, translation and classification of `pm.require()` / `require()`
|
||||
* calls inside Postman scripts being imported into Bruno.
|
||||
*/
|
||||
|
||||
// String literals inside pm.require / require - single, double, or backtick
|
||||
// quoted. We deliberately keep this simple and do not attempt to handle
|
||||
// template strings with interpolation; those are not a Postman pattern.
|
||||
const PM_REQUIRE_REGEX = /pm\.require\s*\(\s*(['"`])([^'"`]+)\1\s*\)/g;
|
||||
const BARE_REQUIRE_REGEX = /(?<![\w$.])require\s*\(\s*(['"`])([^'"`]+)\1\s*\)/g;
|
||||
|
||||
/**
|
||||
* Normalize a Postman/npm specifier into a plain package name.
|
||||
*
|
||||
* "lodash" -> "lodash"
|
||||
* "npm:lodash" -> "lodash"
|
||||
* "npm:lodash@4.17.21" -> "lodash"
|
||||
* "lodash/get" -> "lodash"
|
||||
* "node:crypto" -> "crypto"
|
||||
* "@scope/pkg" -> "@scope/pkg"
|
||||
* "@scope/pkg/sub" -> "@scope/pkg"
|
||||
* "npm:@scope/pkg@1.2.3" -> "@scope/pkg"
|
||||
* "./helpers" -> null (relative, not a package)
|
||||
*
|
||||
* Returns null when the input doesn't resolve to a recognizable package.
|
||||
*/
|
||||
const normalizePackageName = (raw) => {
|
||||
if (typeof raw !== 'string') return null;
|
||||
let name = raw.trim();
|
||||
if (!name) return null;
|
||||
if (name.startsWith('./') || name.startsWith('../') || name.startsWith('/')) {
|
||||
return null;
|
||||
}
|
||||
if (name.startsWith('npm:')) name = name.slice(4);
|
||||
if (name.startsWith('node:')) name = name.slice(5);
|
||||
// Scoped packages keep the leading '@'; only strip a *second* '@' as a version separator.
|
||||
const searchStart = name.startsWith('@') ? 1 : 0;
|
||||
const atIndex = name.indexOf('@', searchStart);
|
||||
if (atIndex !== -1) name = name.slice(0, atIndex);
|
||||
// Strip subpath imports so `lodash/get` and `@scope/pkg/sub` resolve to their package roots.
|
||||
if (name.startsWith('@')) {
|
||||
name = name.split('/').slice(0, 2).join('/');
|
||||
} else {
|
||||
name = name.split('/')[0];
|
||||
}
|
||||
return name || null;
|
||||
};
|
||||
|
||||
const extractPackagesFromScript = (scriptSource) => {
|
||||
if (scriptSource == null) {
|
||||
return { translatedSource: scriptSource, packages: [] };
|
||||
}
|
||||
const sourceText = Array.isArray(scriptSource) ? scriptSource.join('\n') : String(scriptSource);
|
||||
const packages = new Set();
|
||||
|
||||
const translated = sourceText.replace(PM_REQUIRE_REGEX, (_match, quote, rawName) => {
|
||||
const pkg = normalizePackageName(rawName);
|
||||
if (!pkg) {
|
||||
// Malformed/relative - drop the pm. prefix but leave the argument alone.
|
||||
return `require(${quote}${rawName}${quote})`;
|
||||
}
|
||||
packages.add(pkg);
|
||||
return `require(${quote}${pkg}${quote})`;
|
||||
});
|
||||
|
||||
BARE_REQUIRE_REGEX.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = BARE_REQUIRE_REGEX.exec(translated)) !== null) {
|
||||
const pkg = normalizePackageName(match[2]);
|
||||
if (pkg) packages.add(pkg);
|
||||
}
|
||||
|
||||
return { translatedSource: translated, packages: Array.from(packages) };
|
||||
};
|
||||
|
||||
// Packages exposed in Bruno's safe-mode (QuickJS) sandbox via shims.
|
||||
// Source of truth: packages/bruno-js/src/sandbox/quickjs/shims/lib/index.js
|
||||
const SAFE_MODE_PACKAGES = new Set([
|
||||
'uuid',
|
||||
'axios',
|
||||
'jsonwebtoken',
|
||||
'path',
|
||||
'nanoid'
|
||||
]);
|
||||
|
||||
// Node.js built-ins. Available in Developer Mode via Node's CJS loader.
|
||||
const NODE_BUILTINS = new Set([
|
||||
'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console',
|
||||
'constants', 'crypto', 'dgram', 'diagnostics_channel', 'dns', 'domain',
|
||||
'events', 'fs', 'http', 'http2', 'https', 'inspector', 'module', 'net',
|
||||
'os', 'path', 'perf_hooks', 'process', 'punycode', 'querystring',
|
||||
'readline', 'repl', 'stream', 'string_decoder', 'sys', 'timers', 'tls',
|
||||
'trace_events', 'tty', 'url', 'util', 'v8', 'vm', 'wasi', 'worker_threads',
|
||||
'zlib'
|
||||
]);
|
||||
|
||||
// Libraries reliably available in Developer Mode without an explicit install.
|
||||
const BUNDLED_LIBRARIES = new Set([
|
||||
'chai',
|
||||
'moment',
|
||||
'lodash',
|
||||
'crypto-js'
|
||||
]);
|
||||
|
||||
// Postman sandbox globals the Bruno translator turns into `require()` calls
|
||||
// (see postman-to-bruno-translator.js :: POSTMAN_LIBRARY_GLOBALS). Scripts
|
||||
// that use these as bare globals (`cheerio.load(...)`, `_.map(...)`) won't
|
||||
// surface in the raw `pm.require`/`require` pre-scan, so we re-scan the
|
||||
// translated source for these specific names. Listed explicitly so the
|
||||
// post-scan can't pick up mangled artifacts of the translator's
|
||||
// `s/\bpostman\b/pm/g` pass (e.g. `pm-collection` from `postman-collection`).
|
||||
const TRANSLATOR_INJECTED_GLOBALS = new Set([
|
||||
'cheerio',
|
||||
'tv4',
|
||||
'crypto-js',
|
||||
'lodash',
|
||||
'moment'
|
||||
]);
|
||||
|
||||
// Packages that don't have a meaningful equivalent in Bruno, these are
|
||||
// Postman-specific runtime bits that ship with their app.
|
||||
const UNSUPPORTED_EXACT = new Set([
|
||||
'postman-collection',
|
||||
'postman-runtime',
|
||||
'postman-request',
|
||||
'newman'
|
||||
]);
|
||||
const UNSUPPORTED_PREFIXES = ['@postman/', '@team/'];
|
||||
|
||||
const isUnsupported = (name) => {
|
||||
if (UNSUPPORTED_EXACT.has(name)) return true;
|
||||
return UNSUPPORTED_PREFIXES.some((prefix) => name.startsWith(prefix));
|
||||
};
|
||||
|
||||
const classifyPackages = (packages) => {
|
||||
const unique = Array.from(new Set((packages || []).filter(Boolean))).sort();
|
||||
const report = {
|
||||
safeMode: [],
|
||||
devMode: [],
|
||||
needsInstall: [],
|
||||
unsupported: []
|
||||
};
|
||||
|
||||
for (const name of unique) {
|
||||
if (isUnsupported(name)) {
|
||||
report.unsupported.push(name);
|
||||
} else if (SAFE_MODE_PACKAGES.has(name)) {
|
||||
report.safeMode.push(name);
|
||||
} else if (NODE_BUILTINS.has(name) || BUNDLED_LIBRARIES.has(name)) {
|
||||
report.devMode.push(name);
|
||||
} else {
|
||||
report.needsInstall.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
return report;
|
||||
};
|
||||
|
||||
const buildPackageReport = (packages) => {
|
||||
const classified = classifyPackages(packages);
|
||||
const hasAny
|
||||
= classified.needsInstall.length
|
||||
+ classified.unsupported.length
|
||||
+ classified.devMode.length
|
||||
> 0;
|
||||
return { ...classified, hasAny };
|
||||
};
|
||||
|
||||
export {
|
||||
normalizePackageName,
|
||||
extractPackagesFromScript,
|
||||
classifyPackages,
|
||||
buildPackageReport,
|
||||
SAFE_MODE_PACKAGES,
|
||||
NODE_BUILTINS,
|
||||
BUNDLED_LIBRARIES,
|
||||
TRANSLATOR_INJECTED_GLOBALS
|
||||
};
|
||||
@@ -3,6 +3,11 @@ import { validateSchema, transformItemsInCollection, hydrateSeqInCollection, uui
|
||||
import { transformExampleStatusInCollection } from '@usebruno/common';
|
||||
import each from 'lodash/each';
|
||||
import postmanTranslation from './postman-translations';
|
||||
import {
|
||||
extractPackagesFromScript,
|
||||
buildPackageReport,
|
||||
TRANSLATOR_INJECTED_GLOBALS
|
||||
} from './postman-package-detector';
|
||||
import { invalidVariableCharacterRegex } from '../constants/index';
|
||||
|
||||
const AUTH_TYPES = Object.freeze({
|
||||
@@ -853,6 +858,83 @@ const getBodyTypeFromContentTypeHeader = (headers) => {
|
||||
return 'text';
|
||||
};
|
||||
|
||||
const collectPackagesFromPostmanCollection = (postmanCollection) => {
|
||||
const allPackages = new Set();
|
||||
|
||||
const collectFromEvents = (events) => {
|
||||
if (!Array.isArray(events)) return;
|
||||
events.forEach((event) => {
|
||||
const exec = event?.script?.exec;
|
||||
if (!exec) return;
|
||||
const { packages } = extractPackagesFromScript(exec);
|
||||
packages.forEach((pkg) => allPackages.add(pkg));
|
||||
});
|
||||
};
|
||||
|
||||
const visitItems = (items) => {
|
||||
if (!Array.isArray(items)) return;
|
||||
items.forEach((item) => {
|
||||
collectFromEvents(item?.event);
|
||||
if (item.item && item.item.length) {
|
||||
visitItems(item.item);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
collectFromEvents(postmanCollection?.event);
|
||||
visitItems(postmanCollection?.item);
|
||||
|
||||
return Array.from(allPackages);
|
||||
};
|
||||
|
||||
const rewriteRequiresInBrunoCollection = (brunoCollection) => {
|
||||
const injected = new Set();
|
||||
|
||||
const processScriptString = (source) => {
|
||||
const { translatedSource, packages } = extractPackagesFromScript(source);
|
||||
for (const pkg of packages) {
|
||||
if (TRANSLATOR_INJECTED_GLOBALS.has(pkg)) injected.add(pkg);
|
||||
}
|
||||
return translatedSource;
|
||||
};
|
||||
|
||||
const processScriptField = (scriptObj, key) => {
|
||||
if (!scriptObj || typeof scriptObj[key] !== 'string' || !scriptObj[key]) return;
|
||||
const next = processScriptString(scriptObj[key]);
|
||||
if (next !== scriptObj[key]) scriptObj[key] = next;
|
||||
};
|
||||
|
||||
const visitRequest = (request) => {
|
||||
if (!request) return;
|
||||
if (request.script) {
|
||||
processScriptField(request.script, 'req');
|
||||
processScriptField(request.script, 'res');
|
||||
}
|
||||
if (typeof request.tests === 'string' && request.tests) {
|
||||
const next = processScriptString(request.tests);
|
||||
if (next !== request.tests) request.tests = next;
|
||||
}
|
||||
};
|
||||
|
||||
visitRequest(brunoCollection?.root?.request);
|
||||
|
||||
const visitItems = (items) => {
|
||||
if (!Array.isArray(items)) return;
|
||||
items.forEach((item) => {
|
||||
if (item.type === 'folder') {
|
||||
visitRequest(item?.root?.request);
|
||||
visitItems(item.items);
|
||||
} else {
|
||||
visitRequest(item.request);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
visitItems(brunoCollection.items);
|
||||
|
||||
return Array.from(injected);
|
||||
};
|
||||
|
||||
const importPostmanV2Collection = async (collection, { useWorkers = false }) => {
|
||||
const brunoCollection = {
|
||||
name: collection.info.name || 'Untitled Collection',
|
||||
@@ -997,12 +1079,29 @@ const parsePostmanCollection = async (collection, { useWorkers = false }) => {
|
||||
|
||||
const postmanToBruno = async (postmanCollection, { useWorkers = false } = {}) => {
|
||||
try {
|
||||
// Resolve the actual collection envelope (Postman wraps newer exports
|
||||
// in a `{ collection: {...} }` shell) so the raw scan sees real events.
|
||||
const rawCollectionForScan = postmanCollection?.collection?.info
|
||||
? postmanCollection.collection
|
||||
: postmanCollection;
|
||||
const rawPackages = collectPackagesFromPostmanCollection(rawCollectionForScan);
|
||||
|
||||
const { collection: parsedCollection, issues } = await parsePostmanCollection(postmanCollection, { useWorkers });
|
||||
const transformedCollection = transformItemsInCollection(parsedCollection);
|
||||
const hydratedCollection = hydrateSeqInCollection(transformedCollection);
|
||||
// Apply backward compatibility transformation for string status to number
|
||||
const statusTransformedCollection = transformExampleStatusInCollection(hydratedCollection);
|
||||
const validatedCollection = validateSchema(statusTransformedCollection);
|
||||
|
||||
// Rewrite any pm.require() calls that survived the Bruno-side translator
|
||||
// so the imported scripts use plain require(). The post-scan also picks
|
||||
// up translator-injected globals (cheerio, tv4, ...) - packages Postman
|
||||
// exposed as sandbox globals that the raw pre-scan can't see. The
|
||||
// schema is strict + noUnknown so we attach the report by mutating
|
||||
// the already-validated collection.
|
||||
const injectedPackages = rewriteRequiresInBrunoCollection(validatedCollection);
|
||||
validatedCollection.packageReport = buildPackageReport([...rawPackages, ...injectedPackages]);
|
||||
|
||||
return { collection: validatedCollection, issues };
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import postmanToBruno from '../../../src/postman/postman-to-bruno';
|
||||
|
||||
const buildCollection = ({ folderEvent, requestEvent, collectionEvent } = {}) => ({
|
||||
info: {
|
||||
name: 'Pkg Detection Test',
|
||||
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
|
||||
},
|
||||
...(collectionEvent ? { event: [collectionEvent] } : {}),
|
||||
item: [
|
||||
{
|
||||
name: 'Sample Folder',
|
||||
...(folderEvent ? { event: [folderEvent] } : {}),
|
||||
item: [
|
||||
{
|
||||
name: 'Sample Request',
|
||||
...(requestEvent ? { event: [requestEvent] } : {}),
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: { raw: 'https://example.com/', protocol: 'https', host: ['example', 'com'], path: [''] },
|
||||
header: []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const preRequestEvent = (lines) => ({
|
||||
listen: 'prerequest',
|
||||
script: { type: 'text/javascript', exec: lines }
|
||||
});
|
||||
|
||||
const testEvent = (lines) => ({
|
||||
listen: 'test',
|
||||
script: { type: 'text/javascript', exec: lines }
|
||||
});
|
||||
|
||||
describe('postman-to-bruno :: package detection integration', () => {
|
||||
it('rewrites pm.require to require in the converted scripts', async () => {
|
||||
const collection = buildCollection({
|
||||
requestEvent: preRequestEvent([
|
||||
`const _ = pm.require('npm:lodash@4.17.21');`,
|
||||
`const ajv = pm.require('ajv');`
|
||||
])
|
||||
});
|
||||
|
||||
const { collection: converted } = await postmanToBruno(collection);
|
||||
const requestScript = converted.items[0].items[0].request.script.req;
|
||||
|
||||
expect(requestScript).toContain(`require('lodash')`);
|
||||
expect(requestScript).toContain(`require('ajv')`);
|
||||
expect(requestScript).not.toContain('pm.require');
|
||||
});
|
||||
|
||||
it('aggregates packages across collection, folder, and request scripts', async () => {
|
||||
const collection = buildCollection({
|
||||
collectionEvent: preRequestEvent([`const path = require('path');`]),
|
||||
folderEvent: testEvent([`const _ = pm.require('lodash');`]),
|
||||
requestEvent: preRequestEvent([`const ajv = pm.require('npm:ajv@8');`])
|
||||
});
|
||||
|
||||
const { collection: converted } = await postmanToBruno(collection);
|
||||
const report = converted.packageReport;
|
||||
|
||||
expect(report.hasAny).toBe(true);
|
||||
expect(report.safeMode).toEqual(['path']);
|
||||
expect(report.devMode).toEqual(['lodash']);
|
||||
expect(report.needsInstall).toEqual(['ajv']);
|
||||
expect(report.unsupported).toEqual([]);
|
||||
});
|
||||
|
||||
it('attaches an empty packageReport when no requires are present', async () => {
|
||||
const collection = buildCollection({
|
||||
requestEvent: preRequestEvent([`console.log('no requires here');`])
|
||||
});
|
||||
|
||||
const { collection: converted } = await postmanToBruno(collection);
|
||||
expect(converted.packageReport).toBeDefined();
|
||||
expect(converted.packageReport.hasAny).toBe(false);
|
||||
});
|
||||
|
||||
it('flags Postman-specific packages as unsupported', async () => {
|
||||
const collection = buildCollection({
|
||||
requestEvent: testEvent([`const pc = pm.require('postman-collection');`])
|
||||
});
|
||||
|
||||
const { collection: converted } = await postmanToBruno(collection);
|
||||
expect(converted.packageReport.unsupported).toEqual(['postman-collection']);
|
||||
expect(converted.packageReport.needsInstall).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects translator-injected sandbox globals (cheerio used as a bare identifier)', async () => {
|
||||
// No explicit require - Postman exposes `cheerio` as a sandbox global.
|
||||
// The Bruno translator injects `const cheerio = require('cheerio')`,
|
||||
// which the post-translation scan should surface as needsInstall.
|
||||
const collection = buildCollection({
|
||||
requestEvent: testEvent([
|
||||
`const $ = cheerio.load('<div>hi</div>');`,
|
||||
`console.log($('div').text());`
|
||||
])
|
||||
});
|
||||
|
||||
const { collection: converted } = await postmanToBruno(collection);
|
||||
expect(converted.packageReport.needsInstall).toContain('cheerio');
|
||||
expect(converted.packageReport.hasAny).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import {
|
||||
normalizePackageName,
|
||||
extractPackagesFromScript,
|
||||
classifyPackages,
|
||||
buildPackageReport
|
||||
} from '../../../src/postman/postman-package-detector';
|
||||
|
||||
describe('postman-package-detector :: normalizePackageName', () => {
|
||||
test('returns plain package names unchanged', () => {
|
||||
expect(normalizePackageName('lodash')).toBe('lodash');
|
||||
});
|
||||
|
||||
test('strips npm: prefix', () => {
|
||||
expect(normalizePackageName('npm:lodash')).toBe('lodash');
|
||||
});
|
||||
|
||||
test('strips @version suffix', () => {
|
||||
expect(normalizePackageName('lodash@4.17.21')).toBe('lodash');
|
||||
});
|
||||
|
||||
test('strips both npm: prefix and @version suffix', () => {
|
||||
expect(normalizePackageName('npm:lodash@4.17.21')).toBe('lodash');
|
||||
});
|
||||
|
||||
test('preserves the leading @ of scoped packages', () => {
|
||||
expect(normalizePackageName('@scope/pkg')).toBe('@scope/pkg');
|
||||
});
|
||||
|
||||
test('strips @version from scoped packages without touching the scope', () => {
|
||||
expect(normalizePackageName('npm:@scope/pkg@1.2.3')).toBe('@scope/pkg');
|
||||
});
|
||||
|
||||
test('returns null for relative imports', () => {
|
||||
expect(normalizePackageName('./helpers')).toBeNull();
|
||||
expect(normalizePackageName('../shared/util')).toBeNull();
|
||||
expect(normalizePackageName('/abs/path')).toBeNull();
|
||||
});
|
||||
|
||||
test('strips node: prefix from Node builtin specifiers', () => {
|
||||
expect(normalizePackageName('node:crypto')).toBe('crypto');
|
||||
expect(normalizePackageName('node:fs/promises')).toBe('fs');
|
||||
});
|
||||
|
||||
test('drops subpath imports to the package root', () => {
|
||||
expect(normalizePackageName('lodash/get')).toBe('lodash');
|
||||
expect(normalizePackageName('lodash/fp/map')).toBe('lodash');
|
||||
});
|
||||
|
||||
test('drops subpath imports on scoped packages but keeps the scope', () => {
|
||||
expect(normalizePackageName('@scope/pkg/sub')).toBe('@scope/pkg');
|
||||
expect(normalizePackageName('npm:@scope/pkg/sub')).toBe('@scope/pkg');
|
||||
});
|
||||
|
||||
test('returns null for non-string or empty inputs', () => {
|
||||
expect(normalizePackageName(null)).toBeNull();
|
||||
expect(normalizePackageName(undefined)).toBeNull();
|
||||
expect(normalizePackageName(123)).toBeNull();
|
||||
expect(normalizePackageName('')).toBeNull();
|
||||
expect(normalizePackageName(' ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('postman-package-detector :: extractPackagesFromScript', () => {
|
||||
test('rewrites pm.require to require and reports the package', () => {
|
||||
const { translatedSource, packages } = extractPackagesFromScript(
|
||||
`const _ = pm.require('lodash');`
|
||||
);
|
||||
expect(translatedSource).toBe(`const _ = require('lodash');`);
|
||||
expect(packages).toEqual(['lodash']);
|
||||
});
|
||||
|
||||
test('strips the npm: prefix during rewrite', () => {
|
||||
const { translatedSource, packages } = extractPackagesFromScript(
|
||||
`const _ = pm.require('npm:lodash');`
|
||||
);
|
||||
expect(translatedSource).toBe(`const _ = require('lodash');`);
|
||||
expect(packages).toEqual(['lodash']);
|
||||
});
|
||||
|
||||
test('strips the @version suffix during rewrite', () => {
|
||||
const { translatedSource, packages } = extractPackagesFromScript(
|
||||
`const _ = pm.require("npm:lodash@4.17.21");`
|
||||
);
|
||||
expect(translatedSource).toBe(`const _ = require("lodash");`);
|
||||
expect(packages).toEqual(['lodash']);
|
||||
});
|
||||
|
||||
test('preserves scoped packages and strips their version', () => {
|
||||
const { translatedSource, packages } = extractPackagesFromScript(
|
||||
`const x = pm.require('npm:@scope/pkg@1.2.3');`
|
||||
);
|
||||
expect(translatedSource).toBe(`const x = require('@scope/pkg');`);
|
||||
expect(packages).toEqual(['@scope/pkg']);
|
||||
});
|
||||
|
||||
test('detects plain require() calls without rewriting them', () => {
|
||||
const { translatedSource, packages } = extractPackagesFromScript(
|
||||
`const ajv = require('ajv');`
|
||||
);
|
||||
expect(translatedSource).toBe(`const ajv = require('ajv');`);
|
||||
expect(packages).toEqual(['ajv']);
|
||||
});
|
||||
|
||||
test('detects multiple packages across pm.require and require', () => {
|
||||
const script = `
|
||||
const _ = pm.require('lodash');
|
||||
const cheerio = pm.require('npm:cheerio');
|
||||
const xml2js = require('xml2js');
|
||||
`;
|
||||
const { translatedSource, packages } = extractPackagesFromScript(script);
|
||||
expect(translatedSource).toContain(`require('lodash')`);
|
||||
expect(translatedSource).toContain(`require('cheerio')`);
|
||||
expect(translatedSource).toContain(`require('xml2js')`);
|
||||
expect(translatedSource).not.toContain('pm.require');
|
||||
expect(new Set(packages)).toEqual(new Set(['lodash', 'cheerio', 'xml2js']));
|
||||
});
|
||||
|
||||
test('does not report relative requires as packages', () => {
|
||||
const script = `
|
||||
const helper = require('./helpers');
|
||||
const shared = require('../shared');
|
||||
const ajv = require('ajv');
|
||||
`;
|
||||
const { packages } = extractPackagesFromScript(script);
|
||||
expect(packages).toEqual(['ajv']);
|
||||
});
|
||||
|
||||
test('accepts the Postman script.exec array form', () => {
|
||||
const { translatedSource, packages } = extractPackagesFromScript([
|
||||
`const _ = pm.require('lodash');`,
|
||||
`const x = require('xml2js');`
|
||||
]);
|
||||
expect(translatedSource.split('\n')).toEqual([
|
||||
`const _ = require('lodash');`,
|
||||
`const x = require('xml2js');`
|
||||
]);
|
||||
expect(new Set(packages)).toEqual(new Set(['lodash', 'xml2js']));
|
||||
});
|
||||
|
||||
test('returns input unchanged for null / undefined script', () => {
|
||||
expect(extractPackagesFromScript(null)).toEqual({
|
||||
translatedSource: null,
|
||||
packages: []
|
||||
});
|
||||
expect(extractPackagesFromScript(undefined)).toEqual({
|
||||
translatedSource: undefined,
|
||||
packages: []
|
||||
});
|
||||
});
|
||||
|
||||
test('does not falsely match identifiers ending in "require"', () => {
|
||||
// e.g. `myrequire('foo')` or `obj.require('foo')` should not be picked up.
|
||||
const script = `obj.require('foo'); myrequire('bar');`;
|
||||
const { packages } = extractPackagesFromScript(script);
|
||||
expect(packages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('postman-package-detector :: classifyPackages', () => {
|
||||
test('routes safe-mode packages into safeMode bucket', () => {
|
||||
const report = classifyPackages(['uuid', 'axios', 'jsonwebtoken', 'nanoid']);
|
||||
expect(report.safeMode).toEqual(['axios', 'jsonwebtoken', 'nanoid', 'uuid']);
|
||||
expect(report.needsInstall).toEqual([]);
|
||||
});
|
||||
|
||||
test('routes Node builtins and bundled libs into devMode bucket', () => {
|
||||
const report = classifyPackages(['fs', 'crypto', 'chai', 'moment', 'lodash']);
|
||||
expect(report.devMode).toEqual(expect.arrayContaining(['chai', 'crypto', 'fs', 'lodash', 'moment']));
|
||||
expect(report.needsInstall).toEqual([]);
|
||||
});
|
||||
|
||||
test('routes unknown external packages into needsInstall bucket', () => {
|
||||
const report = classifyPackages(['ajv', 'cheerio', 'xml2js', 'csv-parse']);
|
||||
expect(report.needsInstall).toEqual(['ajv', 'cheerio', 'csv-parse', 'xml2js']);
|
||||
});
|
||||
|
||||
test('flags Postman-specific packages as unsupported', () => {
|
||||
const report = classifyPackages([
|
||||
'postman-collection',
|
||||
'@postman/foo',
|
||||
'@team/secret'
|
||||
]);
|
||||
expect(report.unsupported).toEqual(expect.arrayContaining([
|
||||
'postman-collection',
|
||||
'@postman/foo',
|
||||
'@team/secret'
|
||||
]));
|
||||
expect(report.needsInstall).toEqual([]);
|
||||
});
|
||||
|
||||
test('dedupes inputs across all buckets', () => {
|
||||
const report = classifyPackages(['ajv', 'ajv', 'lodash', 'lodash', 'uuid']);
|
||||
expect(report.needsInstall).toEqual(['ajv']);
|
||||
expect(report.devMode).toEqual(['lodash']);
|
||||
expect(report.safeMode).toEqual(['uuid']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('postman-package-detector :: buildPackageReport', () => {
|
||||
test('sets hasAny=false when no packages are referenced', () => {
|
||||
const report = buildPackageReport([]);
|
||||
expect(report.hasAny).toBe(false);
|
||||
});
|
||||
|
||||
test('sets hasAny=true when there is something to install', () => {
|
||||
const report = buildPackageReport(['ajv']);
|
||||
expect(report.hasAny).toBe(true);
|
||||
expect(report.needsInstall).toEqual(['ajv']);
|
||||
});
|
||||
|
||||
test('sets hasAny=true when there are unsupported packages to flag', () => {
|
||||
const report = buildPackageReport(['postman-collection']);
|
||||
expect(report.hasAny).toBe(true);
|
||||
expect(report.unsupported).toEqual(['postman-collection']);
|
||||
});
|
||||
|
||||
test('sets hasAny=true when only dev-mode libs are referenced', () => {
|
||||
// Libraries like lodash work only in Developer Mode, so a Safe-Mode
|
||||
// collection still needs a prompt — the modal decides whether to show a
|
||||
// switch CTA based on the collection's current sandbox mode.
|
||||
const report = buildPackageReport(['lodash']);
|
||||
expect(report.hasAny).toBe(true);
|
||||
expect(report.devMode).toEqual(['lodash']);
|
||||
expect(report.needsInstall).toEqual([]);
|
||||
});
|
||||
|
||||
test('sets hasAny=false when only safe-mode packages are referenced', () => {
|
||||
// Safe-mode shims (uuid, axios, etc.) work out of the box regardless of
|
||||
// sandbox mode, so surfacing a prompt would be noise.
|
||||
const report = buildPackageReport(['uuid', 'path']);
|
||||
expect(report.hasAny).toBe(false);
|
||||
expect(report.needsInstall).toEqual([]);
|
||||
expect(report.unsupported).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,7 @@ const {
|
||||
} = require('../utils/filesystem');
|
||||
const { getCollectionConfigFile, openCollectionDialog, openCollectionsByPathname, registerScratchCollectionPath } = require('../app/collections');
|
||||
const { generateUidBasedOnHash, stringifyJson, safeStringifyJSON, safeParseJSON } = require('../utils/common');
|
||||
const { isValidNpmPackageName, runNpmInstall } = require('../utils/install-packages');
|
||||
const { moveRequestUid, deleteRequestUid, syncExampleUidsCache } = require('../cache/requestUids');
|
||||
const { deleteCookiesForDomain, getDomainsWithCookies, addCookieForDomain, modifyCookieForDomain, parseCookieString, createCookieString, deleteCookie } = require('../utils/cookies');
|
||||
const EnvironmentSecretsStore = require('../store/env-secrets');
|
||||
@@ -2137,6 +2138,25 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:install-postman-packages', async (_event, collectionPathname, packages) => {
|
||||
if (typeof collectionPathname !== 'string' || !collectionPathname) {
|
||||
throw new Error('collectionPathname is required');
|
||||
}
|
||||
if (!Array.isArray(packages) || packages.length === 0) {
|
||||
throw new Error('packages must be a non-empty array');
|
||||
}
|
||||
if (!fs.existsSync(collectionPathname) || !fs.statSync(collectionPathname).isDirectory()) {
|
||||
throw new Error(`Collection path does not exist: ${collectionPathname}`);
|
||||
}
|
||||
|
||||
const invalid = packages.filter((p) => !isValidNpmPackageName(p));
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(`Invalid package name(s): ${invalid.join(', ')}`);
|
||||
}
|
||||
|
||||
return runNpmInstall({ collectionPath: collectionPathname, packages });
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:get-collection-json', async (event, collectionPath) => {
|
||||
let variables = {};
|
||||
let name = '';
|
||||
|
||||
107
packages/bruno-electron/src/utils/install-packages.js
Normal file
107
packages/bruno-electron/src/utils/install-packages.js
Normal file
@@ -0,0 +1,107 @@
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// 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 DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // npm installs can legitimately take minutes
|
||||
const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024; // bound captured stdout/stderr
|
||||
|
||||
const isValidNpmPackageName = (name) => typeof name === 'string' && NPM_NAME_REGEX.test(name);
|
||||
|
||||
// Keep only the trailing `cap` bytes - npm surfaces the actionable error at the
|
||||
// end of its output, so the tail is what we want to show the user.
|
||||
const appendCapped = (buffer, chunk, cap) => {
|
||||
const next = buffer + chunk;
|
||||
return next.length > cap ? next.slice(next.length - cap) : next;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @returns {Promise<{ success: boolean, exitCode: number, stdout: string,
|
||||
* stderr: string, installed: string[], errorCode?: string }>}
|
||||
*/
|
||||
const runNpmInstall = ({
|
||||
collectionPath,
|
||||
packages,
|
||||
spawnFn = spawn,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES,
|
||||
npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||
}) => {
|
||||
const installed = Array.from(new Set(packages));
|
||||
const args = ['install', '--save', ...installed];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
let timer = null;
|
||||
|
||||
const finish = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
resolve({ stdout, stderr, installed, ...result });
|
||||
};
|
||||
|
||||
let child;
|
||||
try {
|
||||
child = spawnFn(npmCommand, args, { cwd: collectionPath, env: process.env, shell: false });
|
||||
} catch (err) {
|
||||
finish({ success: false, exitCode: -1, stderr: err.message, errorCode: 'SPAWN_FAILED' });
|
||||
return;
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// ignore - process may have already exited
|
||||
}
|
||||
finish({
|
||||
success: false,
|
||||
exitCode: -1,
|
||||
errorCode: 'TIMEOUT',
|
||||
stderr: `${stderr}\nnpm install timed out after ${Math.round(timeoutMs / 1000)}s.`
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout = appendCapped(stdout, chunk.toString(), maxOutputBytes);
|
||||
});
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr = appendCapped(stderr, chunk.toString(), maxOutputBytes);
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
const isMissingNpm = err.code === 'ENOENT';
|
||||
finish({
|
||||
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}`
|
||||
});
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
finish({ success: code === 0, exitCode: code });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
isValidNpmPackageName,
|
||||
runNpmInstall,
|
||||
NPM_NAME_REGEX,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
DEFAULT_MAX_OUTPUT_BYTES
|
||||
};
|
||||
179
packages/bruno-electron/tests/utils/install-packages.spec.js
Normal file
179
packages/bruno-electron/tests/utils/install-packages.spec.js
Normal file
@@ -0,0 +1,179 @@
|
||||
const { EventEmitter } = require('events');
|
||||
const { isValidNpmPackageName, runNpmInstall } = require('../../src/utils/install-packages');
|
||||
|
||||
// Minimal stand-in for a child_process handle: stdout/stderr are emitters and
|
||||
// the child itself emits 'close' / 'error'. Lets us drive npm outcomes
|
||||
// deterministically without spawning a real process.
|
||||
const makeFakeChild = () => {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = jest.fn();
|
||||
return child;
|
||||
};
|
||||
|
||||
describe('isValidNpmPackageName', () => {
|
||||
test.each([
|
||||
'lodash',
|
||||
'dayjs',
|
||||
'uuid',
|
||||
'@scope/pkg',
|
||||
'csv-parse',
|
||||
'package.name',
|
||||
'@team/secret-sauce'
|
||||
])('accepts valid package name: %s', (name) => {
|
||||
expect(isValidNpmPackageName(name)).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['empty string', ''],
|
||||
['whitespace', 'foo bar'],
|
||||
['shell injection', 'foo; rm -rf /'],
|
||||
['command substitution', '$(whoami)'],
|
||||
['leading dot', '.hidden'],
|
||||
['non-string', 123],
|
||||
['null', null],
|
||||
['undefined', undefined]
|
||||
])('rejects %s', (_label, name) => {
|
||||
expect(isValidNpmPackageName(name)).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 });
|
||||
child.stdout.emit('data', Buffer.from('added 1 package'));
|
||||
child.emit('close', 0);
|
||||
|
||||
const result = await promise;
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('added 1 package');
|
||||
expect(result.installed).toEqual(['dayjs']);
|
||||
});
|
||||
|
||||
test('passes the correct npm args, cwd, and runs without a shell', async () => {
|
||||
const child = makeFakeChild();
|
||||
const spawnFn = jest.fn(() => child);
|
||||
|
||||
const promise = runNpmInstall({
|
||||
collectionPath: '/my/coll',
|
||||
packages: ['dayjs', 'dayjs', 'zod'],
|
||||
spawnFn,
|
||||
npmCommand: 'npm'
|
||||
});
|
||||
child.emit('close', 0);
|
||||
await promise;
|
||||
|
||||
expect(spawnFn).toHaveBeenCalledWith(
|
||||
'npm',
|
||||
['install', '--save', 'dayjs', 'zod'],
|
||||
expect.objectContaining({ cwd: '/my/coll', shell: false })
|
||||
);
|
||||
});
|
||||
|
||||
test('dedupes packages in the result', async () => {
|
||||
const child = makeFakeChild();
|
||||
const promise = runNpmInstall({ collectionPath: '/c', packages: ['a', 'a', 'b'], spawnFn: () => child });
|
||||
child.emit('close', 0);
|
||||
const result = await promise;
|
||||
expect(result.installed).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
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 });
|
||||
child.stderr.emit('data', Buffer.from('npm ERR! 404 Not Found'));
|
||||
child.emit('close', 1);
|
||||
|
||||
const result = await promise;
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain('404 Not Found');
|
||||
});
|
||||
|
||||
test('reports NPM_NOT_FOUND when npm is missing from PATH (ENOENT)', async () => {
|
||||
const child = makeFakeChild();
|
||||
const promise = runNpmInstall({ collectionPath: '/c', packages: ['a'], spawnFn: () => child });
|
||||
const err = new Error('spawn npm ENOENT');
|
||||
err.code = 'ENOENT';
|
||||
child.emit('error', err);
|
||||
|
||||
const result = await promise;
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('NPM_NOT_FOUND');
|
||||
expect(result.stderr).toMatch(/not found on your PATH/i);
|
||||
});
|
||||
|
||||
test('reports SPAWN_ERROR for non-ENOENT spawn errors', async () => {
|
||||
const child = makeFakeChild();
|
||||
const promise = runNpmInstall({ collectionPath: '/c', packages: ['a'], spawnFn: () => child });
|
||||
const err = new Error('EACCES permission denied');
|
||||
err.code = 'EACCES';
|
||||
child.emit('error', err);
|
||||
|
||||
const result = await promise;
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('SPAWN_ERROR');
|
||||
});
|
||||
|
||||
test('reports SPAWN_FAILED when spawn throws synchronously', async () => {
|
||||
const spawnFn = jest.fn(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const result = await runNpmInstall({ collectionPath: '/c', packages: ['a'], spawnFn });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('SPAWN_FAILED');
|
||||
expect(result.stderr).toContain('boom');
|
||||
});
|
||||
|
||||
test('times out and kills the process if npm never exits', async () => {
|
||||
jest.useFakeTimers();
|
||||
const child = makeFakeChild();
|
||||
const promise = runNpmInstall({
|
||||
collectionPath: '/c',
|
||||
packages: ['a'],
|
||||
spawnFn: () => child,
|
||||
timeoutMs: 1000
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
const result = await promise;
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('TIMEOUT');
|
||||
expect(child.kill).toHaveBeenCalled();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('caps captured output to the trailing maxOutputBytes', async () => {
|
||||
const child = makeFakeChild();
|
||||
const promise = runNpmInstall({
|
||||
collectionPath: '/c',
|
||||
packages: ['a'],
|
||||
spawnFn: () => child,
|
||||
maxOutputBytes: 10
|
||||
});
|
||||
child.stdout.emit('data', 'abcdefghijklmnop'); // 16 chars
|
||||
child.emit('close', 0);
|
||||
|
||||
const result = await promise;
|
||||
expect(result.stdout.length).toBeLessThanOrEqual(10);
|
||||
expect(result.stdout).toBe('ghijklmnop'); // keeps the tail
|
||||
});
|
||||
|
||||
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');
|
||||
err.code = 'ENOENT';
|
||||
child.emit('error', err);
|
||||
child.emit('close', 1); // should be ignored
|
||||
|
||||
const result = await promise;
|
||||
expect(result.errorCode).toBe('NPM_NOT_FOUND');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user