feat: npm package report and installation support (#8143)

This commit is contained in:
naman-bruno
2026-06-01 13:38:22 +05:30
committed by GitHub
parent 7413465bb4
commit db91dbf192
14 changed files with 1962 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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