diff --git a/packages/bruno-app/src/components/Sidebar/PostmanPackageReport/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/PostmanPackageReport/StyledWrapper.js new file mode 100644 index 000000000..6f3c11793 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/PostmanPackageReport/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/Sidebar/PostmanPackageReport/index.js b/packages/bruno-app/src/components/Sidebar/PostmanPackageReport/index.js new file mode 100644 index 000000000..e19d98782 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/PostmanPackageReport/index.js @@ -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 }) => ( + +); + +// 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 ( + + {separator} + {name} + {idx === shown.length - 1 && remainder > 0 ? ` and ${remainder} more` : ''} + + ); + }); +}; + +// 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 ( + + + {needsInstall.length > 0 && ( +
+
+ Packages used in scripts + {needsInstall.length} +
+ {!installing && !installDone && ( +

+ These npm packages are referenced by scripts in your imported collection but aren't + installed in this collection's folder. +

+ )} + + + {!installing && !installDone && ( +
+
+ + Or install manually +
+
+ {installCommand} + +
+
+ )} + + {installing && ( +
+ + Installing {needsInstall.length} package{needsInstall.length === 1 ? '' : 's'}… +
+ )} + + {installDone && ( +
+ + + Installed {(installResult.installed || needsInstall).length} package + {(installResult.installed || needsInstall).length === 1 ? '' : 's'} into this collection. + +
+ )} +
+ )} + + {needsDevModeOnly && !installDone && !installing && ( +
+
+ + Scripts use libraries that need Developer Mode +
+

+ Your imported scripts call {renderPackageExamples(devMode)} + {', '}which need Developer Mode to run. +

+ +
+ + Only enable Developer Mode for collections you trust. +
+ +
+ )} + + {unsupported.length > 0 && !installDone && !installing && ( +
+
+ + Not supported in Bruno + {unsupported.length} +
+

+ Postman-specific packages without a Bruno equivalent. Scripts that call these will + fail at runtime. +

+ +
+ )} + + {installDone && ( + isDeveloperMode ? ( +
+ + + This collection runs in Developer Mode - your scripts can use these + packages right away. + +
+ ) : ( +
+
+ + External modules require Developer Mode +
+

+ Custom npm packages (such as {renderPackageExamples(installResult.installed || needsInstall)}) + {' '}are installed, but this collection is currently running in Safe Mode. +

+
+ + Only enable Developer Mode for collections you trust. +
+ +
+ ) + )} + + {installFailed && ( +
+
+ + {installFailureMessage} +
+ {(installResult.stderr || installResult.stdout) && ( +
+                {(installResult.stderr || installResult.stdout).slice(-1200)}
+              
+ )} +
+ )} +
+
+ ); +}; + +export default PostmanPackageReport; diff --git a/packages/bruno-app/src/components/Sidebar/PostmanPackageReport/index.spec.jsx b/packages/bruno-app/src/components/Sidebar/PostmanPackageReport/index.spec.jsx new file mode 100644 index 000000000..9accd8458 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/PostmanPackageReport/index.spec.jsx @@ -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( + + + + + + ); + +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); + }); +}); diff --git a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js index 30530367e..8c37a9d5d 100644 --- a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js @@ -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 && ( + + )} { 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 && ( + + )}
diff --git a/packages/bruno-app/src/hooks/usePostmanPackagePrompt/index.js b/packages/bruno-app/src/hooks/usePostmanPackagePrompt/index.js new file mode 100644 index 000000000..70e9dc26c --- /dev/null +++ b/packages/bruno-app/src/hooks/usePostmanPackagePrompt/index.js @@ -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; diff --git a/packages/bruno-app/src/hooks/usePostmanPackagePrompt/index.spec.js b/packages/bruno-app/src/hooks/usePostmanPackagePrompt/index.spec.js new file mode 100644 index 000000000..3fa58a15a --- /dev/null +++ b/packages/bruno-app/src/hooks/usePostmanPackagePrompt/index.spec.js @@ -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(); + }); +}); diff --git a/packages/bruno-converters/src/postman/postman-package-detector.js b/packages/bruno-converters/src/postman/postman-package-detector.js new file mode 100644 index 000000000..f4a103be3 --- /dev/null +++ b/packages/bruno-converters/src/postman/postman-package-detector.js @@ -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 = /(? "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 +}; diff --git a/packages/bruno-converters/src/postman/postman-to-bruno.js b/packages/bruno-converters/src/postman/postman-to-bruno.js index 5f27cb598..67cae27fc 100644 --- a/packages/bruno-converters/src/postman/postman-to-bruno.js +++ b/packages/bruno-converters/src/postman/postman-to-bruno.js @@ -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); diff --git a/packages/bruno-converters/tests/postman/postman-translations/postman-package-detector-integration.spec.js b/packages/bruno-converters/tests/postman/postman-translations/postman-package-detector-integration.spec.js new file mode 100644 index 000000000..2071aacce --- /dev/null +++ b/packages/bruno-converters/tests/postman/postman-translations/postman-package-detector-integration.spec.js @@ -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('
hi
');`, + `console.log($('div').text());` + ]) + }); + + const { collection: converted } = await postmanToBruno(collection); + expect(converted.packageReport.needsInstall).toContain('cheerio'); + expect(converted.packageReport.hasAny).toBe(true); + }); +}); diff --git a/packages/bruno-converters/tests/postman/postman-translations/postman-package-detector.spec.js b/packages/bruno-converters/tests/postman/postman-translations/postman-package-detector.spec.js new file mode 100644 index 000000000..756d375d9 --- /dev/null +++ b/packages/bruno-converters/tests/postman/postman-translations/postman-package-detector.spec.js @@ -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([]); + }); +}); diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 15006e6d5..9cade6668 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -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 = ''; diff --git a/packages/bruno-electron/src/utils/install-packages.js b/packages/bruno-electron/src/utils/install-packages.js new file mode 100644 index 000000000..5f81d483f --- /dev/null +++ b/packages/bruno-electron/src/utils/install-packages.js @@ -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 ` 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 +}; diff --git a/packages/bruno-electron/tests/utils/install-packages.spec.js b/packages/bruno-electron/tests/utils/install-packages.spec.js new file mode 100644 index 000000000..e0d369211 --- /dev/null +++ b/packages/bruno-electron/tests/utils/install-packages.spec.js @@ -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'); + }); +});