diff --git a/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js b/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js index 8e3b1c661..f4f051637 100644 --- a/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js +++ b/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js @@ -10,6 +10,7 @@ import { IconX, IconLoader2, IconCheck, IconCaretDown } from '@tabler/icons'; import InfoTip from 'components/InfoTip/index'; import Help from 'components/Help'; import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; +import { addLog } from 'providers/ReduxStore/slices/logs'; import Dropdown from 'components/Dropdown'; import SelectionList from 'components/SelectionList'; import { postmanToBruno } from 'utils/importers/postman-collection'; @@ -19,6 +20,7 @@ import { processBrunoCollection } from 'utils/importers/bruno-collection'; import { wsdlToBruno } from '@usebruno/converters'; import StyledWrapper from './StyledWrapper'; import toast from 'react-hot-toast'; +import { showImportIssuesToast } from 'components/Toast/ImportIssuesToast'; import get from 'lodash/get'; const STATUS = { @@ -66,8 +68,10 @@ const getCollectionName = (format, rawData) => { }; // Convert raw data to Bruno collection format +// Returns { collection, issues } where issues tracks items that were skipped or degraded const convertCollection = async (format, rawData, groupingType) => { let collection; + let issues = []; switch (format) { case 'openapi': @@ -76,9 +80,12 @@ const convertCollection = async (format, rawData, groupingType) => { case 'wsdl': collection = await wsdlToBruno(rawData); break; - case 'postman': - collection = await postmanToBruno(rawData); + case 'postman': { + const result = await postmanToBruno(rawData); + collection = result.collection; + issues = result.issues || []; break; + } case 'insomnia': collection = convertInsomniaToBruno(rawData); break; @@ -89,7 +96,7 @@ const convertCollection = async (format, rawData, groupingType) => { throw new Error('Unknown collection format'); } - return collection; + return { collection, issues }; }; export function normalizeName(name) { @@ -150,6 +157,7 @@ export const BulkImportCollectionLocation = ({ const [collectionFormat, setCollectionFormat] = useState('bru'); const [renamedCollectionNames, setRenamedCollectionNames] = useState({}); const [renamedEnvironmentNames, setRenamedEnvironmentNames] = useState({}); + const [importIssues, setImportIssues] = useState({}); // Extract data based on import type const importType = importData?.type; @@ -160,6 +168,21 @@ export const BulkImportCollectionLocation = ({ const importedCollectionFromBulk = isBulkImport ? importData.collection : []; const importedEnvironmentFromBulk = isBulkImport ? (importData.environment || []) : []; + // Extract per-collection issues from bulk import data + useEffect(() => { + if (isBulkImport && importData.issues) { + const issuesMap = {}; + importData.issues.forEach((entry, index) => { + if (entry.issues && entry.issues.length > 0 && importedCollectionFromBulk[index]) { + issuesMap[importedCollectionFromBulk[index].uid] = entry.issues; + } + }); + setImportIssues(issuesMap); + } else { + setImportIssues({}); + } + }, [isBulkImport, importData]); + // For multiple files import const filesData = isMultipleImport ? importData.filesData : []; const hasOpenApiSpec = filesData.some((f) => f.type === 'openapi'); @@ -281,19 +304,53 @@ export const BulkImportCollectionLocation = ({ if (isMultipleImport) { // Convert selected files to collections at submit time + const collectedIssues = {}; for (const item of selectedItems) { try { - const collection = await convertCollection(item._fileData.type, item._fileData.data, groupingType); + const { collection, issues } = await convertCollection(item._fileData.type, item._fileData.data, groupingType); if (collection) { // Preserve the synthetic UID so status tracking, rename tracking, // and UI rendering all use the same key collection.uid = item.uid; filteredCollections.push(collection); + if (issues && issues.length > 0) { + collectedIssues[item.uid] = issues; + } } } catch (err) { console.warn(`Failed to convert file ${item._fileData.file.name}:`, err); } } + if (Object.keys(collectedIssues).length > 0) { + setImportIssues(collectedIssues); + + const allIssues = []; + const timestamp = new Date().toISOString(); + Object.entries(collectedIssues).forEach(([uid, issues]) => { + const item = selectedItems.find((s) => s.uid === uid); + const name = item?.name || uid; + const skipped = issues.filter((i) => i.severity === 'error').length; + const warnings = issues.filter((i) => i.severity === 'warning').length; + const parts = []; + if (skipped > 0) parts.push(`skipped ${skipped} item(s)`); + if (warnings > 0) parts.push(`${warnings} warning(s)`); + + // Per-collection summary header + dispatch(addLog({ type: 'warn', args: [`Import: ${name} — ${parts.join(', ')}`], timestamp })); + + // Individual issues for this collection + issues.forEach((issue) => { + allIssues.push({ ...issue, path: `${name} > ${issue.path}` }); + const logType = issue.severity === 'error' ? 'error' : 'warn'; + const logArgs = [`[${issue.path}] ${issue.message}`]; + if (issue.sourceItem) logArgs.push(issue.sourceItem); + dispatch(addLog({ type: logType, args: logArgs, timestamp })); + }); + }); + + // Single toast for all collections + showImportIssuesToast(allIssues); + } } else if (isBulkImport) { // For bulk import, use selected collections directly filteredCollections = selectedItems; @@ -608,6 +665,29 @@ export const BulkImportCollectionLocation = ({ See error )} + {status[collection.uid] === STATUS.SUCCESS && importIssues[collection.uid] && ( +
+ + {importIssues[collection.uid].filter((i) => i.severity === 'error').length} item(s) skipped + + +
+ )} ))} diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js b/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js index 0840880cd..1739a6961 100644 --- a/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js +++ b/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js @@ -13,11 +13,13 @@ import { processBrunoCollection } from 'utils/importers/bruno-collection'; import { processOpenCollection } from 'utils/importers/opencollection'; import { wsdlToBruno } from '@usebruno/converters'; import { toastError } from 'utils/common/error'; +import { addLog } from 'providers/ReduxStore/slices/logs'; import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features'; import Modal from 'components/Modal'; import Help from 'components/Help'; import Dropdown from 'components/Dropdown'; import StyledWrapper from './StyledWrapper'; +import { showImportIssuesToast } from 'components/Toast/ImportIssuesToast'; import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants'; // Extract collection name from raw data @@ -53,9 +55,11 @@ const getCollectionName = (format, rawData) => { }; // Convert raw data to Bruno collection format +// Returns { collection, issues } where issues tracks items that were skipped or degraded const convertCollection = async (format, rawData, groupingType, collectionFormat) => { try { let collection; + let issues = []; switch (format) { case 'openapi': @@ -64,9 +68,12 @@ const convertCollection = async (format, rawData, groupingType, collectionFormat case 'wsdl': collection = await wsdlToBruno(rawData); break; - case 'postman': - collection = await postmanToBruno(rawData); + case 'postman': { + const result = await postmanToBruno(rawData); + collection = result.collection; + issues = result.issues || []; break; + } case 'insomnia': collection = convertInsomniaToBruno(rawData); break; @@ -84,7 +91,7 @@ const convertCollection = async (format, rawData, groupingType, collectionFormat throw new Error('Unknown collection format'); } - return collection; + return { collection, issues }; } catch (err) { console.error('Conversion error:', err); toastError(err, 'Failed to convert collection'); @@ -135,7 +142,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format, sour .required('Location is required') }), onSubmit: async (values) => { - const convertedCollection = await convertCollection(format, rawData, groupingType, collectionFormat); + const { collection: convertedCollection, issues } = await convertCollection(format, rawData, groupingType, collectionFormat); const options = { format: collectionFormat }; if (showCheckForSpecUpdatesOption && enableCheckForSpecUpdates) { @@ -164,6 +171,26 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format, sour } handleSubmit(convertedCollection, values.collectionLocation, options); + + if (issues && issues.length > 0) { + // Show toast with copy/report actions + showImportIssuesToast(issues); + + // Log each issue to Bruno's internal console + const skipped = issues.filter((i) => i.severity === 'error').length; + const warnings = issues.filter((i) => i.severity === 'warning').length; + const parts = []; + if (skipped > 0) parts.push(`skipped ${skipped} item(s)`); + if (warnings > 0) parts.push(`${warnings} warning(s)`); + const timestamp = new Date().toISOString(); + dispatch(addLog({ type: 'warn', args: [`Import: ${collectionName} — ${parts.join(', ')}`], timestamp })); + issues.forEach((issue) => { + const logType = issue.severity === 'error' ? 'error' : 'warn'; + const logArgs = [`[${issue.path}] ${issue.message}`]; + if (issue.sourceItem) logArgs.push(issue.sourceItem); + dispatch(addLog({ type: logType, args: logArgs, timestamp })); + }); + } } }); diff --git a/packages/bruno-app/src/components/Toast/ImportIssuesToast/StyledWrapper.js b/packages/bruno-app/src/components/Toast/ImportIssuesToast/StyledWrapper.js new file mode 100644 index 000000000..8f4f7dc38 --- /dev/null +++ b/packages/bruno-app/src/components/Toast/ImportIssuesToast/StyledWrapper.js @@ -0,0 +1,143 @@ +import styled from 'styled-components'; +import { rgba } from 'polished'; + +const StyledWrapper = styled.div` + position: relative; + display: flex; + background: ${(props) => props.theme.background.base}; + color: ${(props) => props.theme.text}; + border: 1px solid ${(props) => props.theme.border.border2}; + border-radius: ${(props) => props.theme.border.radius.md}; + overflow: hidden; + max-width: 420px; + min-width: 380px; + margin-bottom: 2rem; + margin-right: 0.75rem; + box-shadow: ${(props) => props.theme.shadow.lg}; + transition: all 0.3s ease; + + .toast-accent { + width: 4px; + flex-shrink: 0; + border-radius: ${(props) => props.theme.border.radius.md} 0 0 ${(props) => props.theme.border.radius.md}; + background: ${(props) => props.theme.colors.text.danger}; + } + + .toast-body { + flex: 1; + padding: 12px 14px; + } + + .toast-close { + position: absolute; + top: 8px; + right: 8px; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: none; + border: none; + cursor: pointer; + color: ${(props) => props.theme.text}; + border-radius: ${(props) => props.theme.border.radius.sm}; + opacity: 0.7; + transition: opacity 0.2s ease, background-color 0.2s ease; + + &:hover { + opacity: 1; + background-color: ${(props) => rgba(props.theme.text, 0.1)}; + } + } + + .toast-title { + font-size: 13px; + font-weight: 500; + margin-bottom: 4px; + color: ${(props) => props.theme.text}; + } + + .toast-hint { + font-size: 12px; + color: ${(props) => props.theme.colors.text.subtext1}; + margin-bottom: 8px; + } + + .toast-checkbox { + display: flex; + align-items: flex-start; + gap: 6px; + cursor: pointer; + margin-bottom: 10px; + + input[type='checkbox'] { + accent-color: ${(props) => props.theme.primary.solid}; + cursor: pointer; + margin: 0; + margin-top: 2px; + flex-shrink: 0; + } + + .toast-checkbox-text { + display: flex; + flex-direction: column; + gap: 2px; + } + + .toast-checkbox-label { + font-size: 12px; + color: ${(props) => props.theme.text}; + } + + .toast-checkbox-desc { + font-size: 11px; + color: ${(props) => props.theme.colors.text.subtext2}; + line-height: 1.4; + } + } + + .toast-warning { + display: flex; + align-items: flex-start; + gap: 6px; + font-size: 11px; + color: ${(props) => props.theme.status.warning.text}; + background: ${(props) => props.theme.status.warning.background}; + border: 1px solid ${(props) => props.theme.status.warning.border}; + border-radius: ${(props) => props.theme.border.radius.sm}; + padding: 6px 8px; + margin-bottom: 8px; + line-height: 1.4; + + .toast-warning-icon { + flex-shrink: 0; + margin-top: 1px; + } + } + + .toast-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + } + + .toast-btn { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + padding: 4px 10px; + cursor: pointer; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.sm}; + background: ${(props) => props.theme.background.surface1}; + color: ${(props) => props.theme.text}; + + &:hover { + background: ${(props) => props.theme.background.surface2}; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Toast/ImportIssuesToast/index.js b/packages/bruno-app/src/components/Toast/ImportIssuesToast/index.js new file mode 100644 index 000000000..b84e28159 --- /dev/null +++ b/packages/bruno-app/src/components/Toast/ImportIssuesToast/index.js @@ -0,0 +1,176 @@ +import React, { useState, useMemo } from 'react'; +import toast from 'react-hot-toast'; +import { IconAlertCircle, IconBrandGithub, IconCopy, IconX } from '@tabler/icons'; +import StyledWrapper from './StyledWrapper'; + +const GITHUB_ISSUES_URL = 'https://github.com/usebruno/bruno/issues/new'; +const MAX_URL_LENGTH = 8000; + +const ImportIssuesToastContent = ({ t, issues, summary }) => { + const [includeItems, setIncludeItems] = useState(false); + const hasSourceItems = issues.some((i) => i.sourceItem); + + const issuesSummary = issues.map((i) => `[${i.severity.toUpperCase()}] ${i.path} — ${i.message}`).join('\n'); + + const buildIssueBody = () => { + const sections = [ + '### Description', + 'Postman collection import completed with issues. Some items could not be converted.', + '', + '### Import Issues', + '```', + issuesSummary, + '```' + ]; + + if (includeItems) { + const itemsWithSource = issues.filter((i) => i.sourceItem); + if (itemsWithSource.length > 0) { + const itemsJson = itemsWithSource + .map((i) => `// ${i.path}\n${JSON.stringify(i.sourceItem, null, 2)}`) + .join('\n\n'); + sections.push( + '', + '### Failed Items', + '> **Please redact any sensitive information (API keys, tokens, passwords, internal URLs) before submitting.**', + '```json', + itemsJson, + '```' + ); + } + } + + sections.push( + '', + '### Steps to Reproduce', + '1. Import the attached Postman collection (redact sensitive data before attaching)', + '2. ', + '', + '### Additional Context', + '' + ); + + return sections.join('\n'); + }; + + const isUrlTooLong = useMemo(() => { + const title = `Postman import: ${summary}`; + const body = buildIssueBody(); + const params = new URLSearchParams({ title, body, labels: 'bug' }); + return `${GITHUB_ISSUES_URL}?${params.toString()}`.length > MAX_URL_LENGTH; + }, [issues, summary, includeItems]); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(issuesSummary); + toast.success('Copied to clipboard', { duration: 2000 }); + } catch (err) { + toast.error('Failed to copy to clipboard', { duration: 3000 }); + } + }; + + const handleReport = async () => { + const title = `Postman import: ${summary}`; + const body = buildIssueBody(); + + if (!isUrlTooLong) { + const params = new URLSearchParams({ title, body, labels: 'bug' }); + window.open(`${GITHUB_ISSUES_URL}?${params.toString()}`, '_blank'); + return; + } + + try { + await navigator.clipboard.writeText(body); + toast.success('Issue details copied — paste them into the GitHub issue body', { duration: 5000 }); + } catch (err) { + toast.error('Failed to copy to clipboard', { duration: 3000 }); + } + const params = new URLSearchParams({ title, labels: 'bug' }); + window.open(`${GITHUB_ISSUES_URL}?${params.toString()}`, '_blank'); + }; + + return ( + +
+
+ +
Imported with issues: {summary}
+
Open DevTools console to see which items failed and why.
+ {hasSourceItems && ( + + )} + {isUrlTooLong && ( +
+ + Issue details are too long to embed in the URL. Clicking "Report on GitHub" will copy them to your clipboard — paste it once the GitHub issue page opens. +
+ )} +
+ + +
+
+ + ); +}; + +/** + * Show an import issues toast in the bottom-right corner. + * Aggregates all issues into a single toast — does not stack. + */ +let activeImportToastId = null; + +export const showImportIssuesToast = (issues) => { + if (activeImportToastId) { + toast.dismiss(activeImportToastId); + } + + const errors = issues.filter((i) => i.severity === 'error'); + const warnings = issues.filter((i) => i.severity === 'warning'); + const parts = []; + if (errors.length > 0) parts.push(`${errors.length} item(s) skipped`); + if (warnings.length > 0) parts.push(`${warnings.length} warning(s)`); + const summary = parts.join(', '); + + activeImportToastId = toast.custom( + (t) => ( + + ), + { duration: Infinity, position: 'bottom-right' } + ); + + return activeImportToastId; +}; + +export default ImportIssuesToastContent; diff --git a/packages/bruno-converters/src/postman/postman-to-bruno.js b/packages/bruno-converters/src/postman/postman-to-bruno.js index 5c6b3f7ed..5f27cb598 100644 --- a/packages/bruno-converters/src/postman/postman-to-bruno.js +++ b/packages/bruno-converters/src/postman/postman-to-bruno.js @@ -363,12 +363,20 @@ export const processAuth = (auth, requestObject, isCollection = false) => { } }; -const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false } = {}, scriptMap) => { +const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false } = {}, scriptMap, issues = [], parentPath = '') => { brunoParent.items = brunoParent.items || []; const folderMap = {}; const requestMap = {}; item.forEach((i, index) => { + if (typeof i !== 'object' || i === null) { + issues.push({ path: parentPath ? `${parentPath} / Item ${index + 1}` : `Item ${index + 1}`, severity: 'error', message: 'Malformed collection item (not an object)' }); + return; + } + + const itemName = i.name || `Item ${index + 1}`; + const itemPath = parentPath ? `${parentPath} / ${itemName}` : itemName; + if (isItemAFolder(i)) { const baseFolderName = i.name || 'Untitled Folder'; let folderName = baseFolderName; @@ -415,7 +423,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false } processAuth(i.auth, brunoFolderItem.root.request); if (i.item && i.item.length) { - importPostmanV2CollectionItem(brunoFolderItem, i.item, { useWorkers }, scriptMap); + importPostmanV2CollectionItem(brunoFolderItem, i.item, { useWorkers }, scriptMap, issues, itemPath); } if (i.event) { @@ -431,377 +439,381 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false } folderMap[folderName] = brunoFolderItem; } else if (i.request) { - const method = i?.request?.method?.toUpperCase(); - if (!method || typeof method !== 'string' || !method.trim()) { - console.warn('Missing or invalid request.method', method); + const rawMethod = i?.request?.method; + if (!rawMethod || typeof rawMethod !== 'string' || !rawMethod.trim()) { + issues.push({ path: itemPath, severity: 'error', message: 'Missing or invalid request method', sourceItem: i }); return; } + const method = rawMethod.toUpperCase(); - const baseRequestName = i.name || 'Untitled Request'; - let requestName = baseRequestName; - let count = 1; + try { + const baseRequestName = i.name || 'Untitled Request'; + let requestName = baseRequestName; + let count = 1; - while (requestMap[requestName]) { - requestName = `${baseRequestName}_${count}`; - count++; - } - - const url = constructUrl(i.request.url); - - const brunoRequestItem = { - uid: uuid(), - name: requestName, - type: 'http-request', - seq: index + 1, - request: { - url: url, - method: method, - auth: { - mode: 'inherit', - basic: null, - bearer: null, - awsv4: null, - apikey: null, - oauth1: null, - oauth2: null, - digest: null - }, - headers: [], - params: [], - body: { - mode: 'none', - json: null, - text: null, - xml: null, - formUrlEncoded: [], - multipartForm: [] - }, - docs: transformDescription(i.request.description) - } - }; - - const settings = { - encodeUrl: i.protocolProfileBehavior?.disableUrlEncoding !== true - }; - - // Handle followRedirects setting - if (i.protocolProfileBehavior?.followRedirects !== undefined) { - settings.followRedirects = i.protocolProfileBehavior.followRedirects; - } - - // Handle maxRedirects setting - if (i.protocolProfileBehavior?.maxRedirects !== undefined) { - settings.maxRedirects = i.protocolProfileBehavior.maxRedirects; - } - - brunoRequestItem.settings = settings; - - brunoParent.items.push(brunoRequestItem); - - if (i.event) { - if (useWorkers) { - scriptMap.set(brunoRequestItem.uid, { - events: i.event, - request: brunoRequestItem.request - }); - } else { - i.event.forEach((event) => { - if (event.listen === 'prerequest' && event.script && event.script.exec) { - if (!brunoRequestItem.request?.script) { - brunoRequestItem.request.script = {}; - } - if (event.script.exec && event.script.exec.length > 0) { - brunoRequestItem.request.script.req = postmanTranslation(event.script.exec); - } else { - brunoRequestItem.request.script.req = ''; - console.warn('Unexpected event.script.exec type', typeof event.script.exec); - } - } - if (event.listen === 'test' && event.script && event.script.exec) { - if (!brunoRequestItem.request?.script) { - brunoRequestItem.request.script = {}; - } - if (event.script.exec && event.script.exec.length > 0) { - brunoRequestItem.request.script.res = postmanTranslation(event.script.exec); - } else { - brunoRequestItem.request.script.res = ''; - console.warn('Unexpected event.script.exec type', typeof event.script.exec); - } - } - }); - } - } - - const bodyMode = get(i, 'request.body.mode'); - if (bodyMode) { - if (bodyMode === 'formdata') { - brunoRequestItem.request.body.mode = 'multipartForm'; - - each(i.request.body.formdata, (param) => { - if (param.key == null && param.value == null) return; - const isFile = param.type === 'file' || (param.type === 'default' && param.src); - const value = isFile - ? (Array.isArray(param.src) ? param.src : param.src ? [param.src] : []) - : (Array.isArray(param.value) ? param.value.join('') : ensureString(param.value)); - - brunoRequestItem.request.body.multipartForm.push({ - uid: uuid(), - type: isFile ? 'file' : 'text', - name: ensureString(param.key), - value, - description: transformDescription(param.description), - enabled: !param.disabled, - ...(param.contentType && { contentType: param.contentType }) - }); - }); + while (requestMap[requestName]) { + requestName = `${baseRequestName}_${count}`; + count++; } - if (bodyMode === 'urlencoded') { - brunoRequestItem.request.body.mode = 'formUrlEncoded'; - each(i.request.body.urlencoded, (param) => { - if (param.key == null && param.value == null) return; - brunoRequestItem.request.body.formUrlEncoded.push({ - uid: uuid(), - name: ensureString(param.key), - value: ensureString(param.value), - description: transformDescription(param.description), - enabled: !param.disabled - }); - }); - } + const url = constructUrl(i.request.url); - if (bodyMode === 'raw') { - let language = get(i, 'request.body.options.raw.language'); - if (!language) { - language = searchLanguageByHeader(i.request.header); - } - if (language === 'json') { - brunoRequestItem.request.body.mode = 'json'; - brunoRequestItem.request.body.json = i.request.body.raw; - } else if (language === 'xml') { - brunoRequestItem.request.body.mode = 'xml'; - brunoRequestItem.request.body.xml = i.request.body.raw; - } else { - brunoRequestItem.request.body.mode = 'text'; - brunoRequestItem.request.body.text = i.request.body.raw; - } - } - } - - if (bodyMode === 'graphql') { - brunoRequestItem.type = 'graphql-request'; - brunoRequestItem.request.body.mode = 'graphql'; - brunoRequestItem.request.body.graphql = parseGraphQLRequest(i.request.body.graphql); - } - - each(normalizeHeaders(i.request.header), (header) => { - if (header.key == null && header.value == null) return; - brunoRequestItem.request.headers.push({ + const brunoRequestItem = { uid: uuid(), - name: ensureString(header.key), - value: ensureString(header.value), - description: transformDescription(header.description), - enabled: !header.disabled - }); - }); - - // Request-level auth - processAuth(i.request.auth, brunoRequestItem.request); - - each(get(i, 'request.url.query'), (param) => { - if (param.key == null && param.value == null) { - return; - } - brunoRequestItem.request.params.push({ - uid: uuid(), - name: ensureString(param.key), - value: ensureString(param.value), - description: transformDescription(param.description), - type: 'query', - enabled: !param.disabled - }); - }); - - each(get(i, 'request.url.variable', []), (param) => { - if (!param.key) { - // If no key, skip this iteration and discard the param - return; - } - - brunoRequestItem.request.params.push({ - uid: uuid(), - name: ensureString(param.key), - value: ensureString(param.value), - description: transformDescription(param.description), - type: 'path', - enabled: true - }); - }); - - // Handle Postman examples (responses) - if (i.response && Array.isArray(i.response)) { - brunoRequestItem.examples = []; - - i.response.forEach((response, responseIndex) => { - const sanitized = String(response.name ?? '').replace(/\r?\n/g, ' ').trim(); - const exampleName = sanitized || `Example ${responseIndex + 1}`; - - // Convert originalRequest to Bruno request format - const originalRequest = response.originalRequest || {}; - const exampleUrl = constructUrl(originalRequest.url); - const exampleMethod = originalRequest.method?.toUpperCase() || method; - - const example = { - uid: uuid(), - itemUid: brunoRequestItem.uid, - name: exampleName, - description: '', - type: 'http-request', - request: { - url: exampleUrl, - method: exampleMethod, - headers: [], - params: [], - body: { - mode: 'none', - json: null, - text: null, - xml: null, - formUrlEncoded: [], - multipartForm: [] - } + name: requestName, + type: 'http-request', + seq: index + 1, + request: { + url: url, + method: method, + auth: { + mode: 'inherit', + basic: null, + bearer: null, + awsv4: null, + apikey: null, + oauth1: null, + oauth2: null, + digest: null }, - response: { - status: response.code || null, - statusText: response.status || '', - headers: [], - body: { - type: getBodyTypeFromContentTypeHeader(response.header), - content: response.body || '' - } - } - }; + headers: [], + params: [], + body: { + mode: 'none', + json: null, + text: null, + xml: null, + formUrlEncoded: [], + multipartForm: [] + }, + docs: transformDescription(i.request.description) + } + }; - // Convert original request headers - if (originalRequest.header) { - normalizeHeaders(originalRequest.header).forEach((header) => { - if (header.key == null && header.value == null) return; - example.request.headers.push({ + const settings = { + encodeUrl: i.protocolProfileBehavior?.disableUrlEncoding !== true + }; + + // Handle followRedirects setting + if (i.protocolProfileBehavior?.followRedirects !== undefined) { + settings.followRedirects = i.protocolProfileBehavior.followRedirects; + } + + // Handle maxRedirects setting + if (i.protocolProfileBehavior?.maxRedirects !== undefined) { + settings.maxRedirects = i.protocolProfileBehavior.maxRedirects; + } + + brunoRequestItem.settings = settings; + + if (i.event) { + if (useWorkers) { + scriptMap.set(brunoRequestItem.uid, { + events: i.event, + request: brunoRequestItem.request + }); + } else { + i.event.forEach((event) => { + if (event.listen === 'prerequest' && event.script && event.script.exec) { + if (!brunoRequestItem.request?.script) { + brunoRequestItem.request.script = {}; + } + if (event.script.exec && event.script.exec.length > 0) { + brunoRequestItem.request.script.req = postmanTranslation(event.script.exec); + } else { + brunoRequestItem.request.script.req = ''; + console.warn('Unexpected event.script.exec type', typeof event.script.exec); + } + } + if (event.listen === 'test' && event.script && event.script.exec) { + if (!brunoRequestItem.request?.script) { + brunoRequestItem.request.script = {}; + } + if (event.script.exec && event.script.exec.length > 0) { + brunoRequestItem.request.script.res = postmanTranslation(event.script.exec); + } else { + brunoRequestItem.request.script.res = ''; + console.warn('Unexpected event.script.exec type', typeof event.script.exec); + } + } + }); + } + } + + const bodyMode = get(i, 'request.body.mode'); + if (bodyMode) { + if (bodyMode === 'formdata') { + brunoRequestItem.request.body.mode = 'multipartForm'; + + each(i.request.body.formdata, (param) => { + if (param.key == null && param.value == null) return; + const isFile = param.type === 'file' || (param.type === 'default' && param.src); + const value = isFile + ? (Array.isArray(param.src) ? param.src : param.src ? [param.src] : []) + : (Array.isArray(param.value) ? param.value.join('') : ensureString(param.value)); + + brunoRequestItem.request.body.multipartForm.push({ uid: uuid(), - name: ensureString(header.key), - value: ensureString(header.value), - description: transformDescription(header.description), - enabled: !header.disabled + type: isFile ? 'file' : 'text', + name: ensureString(param.key), + value, + description: transformDescription(param.description), + enabled: !param.disabled, + ...(param.contentType && { contentType: param.contentType }) }); }); } - // Convert original request query parameters - if (originalRequest.url && originalRequest.url.query && Array.isArray(originalRequest.url.query)) { - originalRequest.url.query.forEach((param) => { - if (param.key == null && param.value == null) { - return; - } - example.request.params.push({ + if (bodyMode === 'urlencoded') { + brunoRequestItem.request.body.mode = 'formUrlEncoded'; + each(i.request.body.urlencoded, (param) => { + if (param.key == null && param.value == null) return; + brunoRequestItem.request.body.formUrlEncoded.push({ uid: uuid(), name: ensureString(param.key), value: ensureString(param.value), description: transformDescription(param.description), - type: 'query', enabled: !param.disabled }); }); } - if (originalRequest.url && originalRequest.url.variable && Array.isArray(originalRequest.url.variable)) { - originalRequest.url.variable.forEach((param) => { - if (!param.key) return; - example.request.params.push({ - uid: uuid(), - name: ensureString(param.key), - value: ensureString(param.value), - description: transformDescription(param.description), - type: 'path', - enabled: true - }); - }); - } - - // Convert original request body - if (originalRequest.body) { - const bodyMode = originalRequest.body.mode; - if (bodyMode === 'formdata') { - example.request.body.mode = 'multipartForm'; - if (originalRequest.body.formdata && Array.isArray(originalRequest.body.formdata)) { - originalRequest.body.formdata.forEach((param) => { - if (param.key == null && param.value == null) return; - const isFile = param.type === 'file' || (param.type === 'default' && param.src); - const value = isFile - ? (Array.isArray(param.src) ? param.src : param.src ? [param.src] : []) - : (Array.isArray(param.value) ? param.value.join('') : ensureString(param.value)); - - example.request.body.multipartForm.push({ - uid: uuid(), - type: isFile ? 'file' : 'text', - name: ensureString(param.key), - value, - description: transformDescription(param.description), - enabled: !param.disabled, - ...(param.contentType && { contentType: param.contentType }) - }); - }); - } - } else if (bodyMode === 'urlencoded') { - example.request.body.mode = 'formUrlEncoded'; - if (originalRequest.body.urlencoded && Array.isArray(originalRequest.body.urlencoded)) { - originalRequest.body.urlencoded.forEach((param) => { - if (param.key == null && param.value == null) return; - example.request.body.formUrlEncoded.push({ - uid: uuid(), - name: ensureString(param.key), - value: ensureString(param.value), - description: transformDescription(param.description), - enabled: !param.disabled - }); - }); - } - } else if (bodyMode === 'raw') { - let language = get(originalRequest, 'body.options.raw.language'); - if (!language) { - language = searchLanguageByHeader(originalRequest.header || []); - } - if (language === 'json') { - example.request.body.mode = 'json'; - example.request.body.json = originalRequest.body.raw; - } else if (language === 'xml') { - example.request.body.mode = 'xml'; - example.request.body.xml = originalRequest.body.raw; - } else { - example.request.body.mode = 'text'; - example.request.body.text = originalRequest.body.raw; - } + if (bodyMode === 'raw') { + let language = get(i, 'request.body.options.raw.language'); + if (!language) { + language = searchLanguageByHeader(i.request.header); + } + if (language === 'json') { + brunoRequestItem.request.body.mode = 'json'; + brunoRequestItem.request.body.json = i.request.body.raw; + } else if (language === 'xml') { + brunoRequestItem.request.body.mode = 'xml'; + brunoRequestItem.request.body.xml = i.request.body.raw; + } else { + brunoRequestItem.request.body.mode = 'text'; + brunoRequestItem.request.body.text = i.request.body.raw; } } + } - // Convert response headers - if (response.header) { - normalizeHeaders(response.header).forEach((header) => { - if (header.key == null && header.value == null) return; - example.response.headers.push({ - uid: uuid(), - name: ensureString(header.key), - value: ensureString(header.value), - description: transformDescription(header.description), - enabled: true - }); - }); + if (bodyMode === 'graphql') { + brunoRequestItem.type = 'graphql-request'; + brunoRequestItem.request.body.mode = 'graphql'; + brunoRequestItem.request.body.graphql = parseGraphQLRequest(i.request.body.graphql); + } + + each(normalizeHeaders(i.request.header), (header) => { + if (header.key == null && header.value == null) return; + brunoRequestItem.request.headers.push({ + uid: uuid(), + name: ensureString(header.key), + value: ensureString(header.value), + description: transformDescription(header.description), + enabled: !header.disabled + }); + }); + + // Request-level auth + processAuth(i.request.auth, brunoRequestItem.request); + + each(get(i, 'request.url.query'), (param) => { + if (param.key == null && param.value == null) { + return; + } + brunoRequestItem.request.params.push({ + uid: uuid(), + name: ensureString(param.key), + value: ensureString(param.value), + description: transformDescription(param.description), + type: 'query', + enabled: !param.disabled + }); + }); + + each(get(i, 'request.url.variable', []), (param) => { + if (!param.key) { + // If no key, skip this iteration and discard the param + return; } - brunoRequestItem.examples.push(example); + brunoRequestItem.request.params.push({ + uid: uuid(), + name: ensureString(param.key), + value: ensureString(param.value), + description: transformDescription(param.description), + type: 'path', + enabled: true + }); }); - } - requestMap[requestName] = brunoRequestItem; + // Handle Postman examples (responses) + if (i.response && Array.isArray(i.response)) { + brunoRequestItem.examples = []; + + i.response.forEach((response, responseIndex) => { + const sanitized = String(response.name ?? '').replace(/\r?\n/g, ' ').trim(); + const exampleName = sanitized || `Example ${responseIndex + 1}`; + + // Convert originalRequest to Bruno request format + const originalRequest = response.originalRequest || {}; + const exampleUrl = constructUrl(originalRequest.url); + const exampleMethod = originalRequest.method?.toUpperCase() || method; + + const example = { + uid: uuid(), + itemUid: brunoRequestItem.uid, + name: exampleName, + description: '', + type: 'http-request', + request: { + url: exampleUrl, + method: exampleMethod, + headers: [], + params: [], + body: { + mode: 'none', + json: null, + text: null, + xml: null, + formUrlEncoded: [], + multipartForm: [] + } + }, + response: { + status: response.code || null, + statusText: response.status || '', + headers: [], + body: { + type: getBodyTypeFromContentTypeHeader(response.header), + content: response.body || '' + } + } + }; + + // Convert original request headers + if (originalRequest.header) { + normalizeHeaders(originalRequest.header).forEach((header) => { + if (header.key == null && header.value == null) return; + example.request.headers.push({ + uid: uuid(), + name: ensureString(header.key), + value: ensureString(header.value), + description: transformDescription(header.description), + enabled: !header.disabled + }); + }); + } + + // Convert original request query parameters + if (originalRequest.url && originalRequest.url.query && Array.isArray(originalRequest.url.query)) { + originalRequest.url.query.forEach((param) => { + if (param.key == null && param.value == null) { + return; + } + example.request.params.push({ + uid: uuid(), + name: ensureString(param.key), + value: ensureString(param.value), + description: transformDescription(param.description), + type: 'query', + enabled: !param.disabled + }); + }); + } + + if (originalRequest.url && originalRequest.url.variable && Array.isArray(originalRequest.url.variable)) { + originalRequest.url.variable.forEach((param) => { + if (!param.key) return; + example.request.params.push({ + uid: uuid(), + name: ensureString(param.key), + value: ensureString(param.value), + description: transformDescription(param.description), + type: 'path', + enabled: true + }); + }); + } + + // Convert original request body + if (originalRequest.body) { + const bodyMode = originalRequest.body.mode; + if (bodyMode === 'formdata') { + example.request.body.mode = 'multipartForm'; + if (originalRequest.body.formdata && Array.isArray(originalRequest.body.formdata)) { + originalRequest.body.formdata.forEach((param) => { + if (param.key == null && param.value == null) return; + const isFile = param.type === 'file' || (param.type === 'default' && param.src); + const value = isFile + ? (Array.isArray(param.src) ? param.src : param.src ? [param.src] : []) + : (Array.isArray(param.value) ? param.value.join('') : ensureString(param.value)); + + example.request.body.multipartForm.push({ + uid: uuid(), + type: isFile ? 'file' : 'text', + name: ensureString(param.key), + value, + description: transformDescription(param.description), + enabled: !param.disabled, + ...(param.contentType && { contentType: param.contentType }) + }); + }); + } + } else if (bodyMode === 'urlencoded') { + example.request.body.mode = 'formUrlEncoded'; + if (originalRequest.body.urlencoded && Array.isArray(originalRequest.body.urlencoded)) { + originalRequest.body.urlencoded.forEach((param) => { + if (param.key == null && param.value == null) return; + example.request.body.formUrlEncoded.push({ + uid: uuid(), + name: ensureString(param.key), + value: ensureString(param.value), + description: transformDescription(param.description), + enabled: !param.disabled + }); + }); + } + } else if (bodyMode === 'raw') { + let language = get(originalRequest, 'body.options.raw.language'); + if (!language) { + language = searchLanguageByHeader(originalRequest.header || []); + } + if (language === 'json') { + example.request.body.mode = 'json'; + example.request.body.json = originalRequest.body.raw; + } else if (language === 'xml') { + example.request.body.mode = 'xml'; + example.request.body.xml = originalRequest.body.raw; + } else { + example.request.body.mode = 'text'; + example.request.body.text = originalRequest.body.raw; + } + } + } + + // Convert response headers + if (response.header) { + normalizeHeaders(response.header).forEach((header) => { + if (header.key == null && header.value == null) return; + example.response.headers.push({ + uid: uuid(), + name: ensureString(header.key), + value: ensureString(header.value), + description: transformDescription(header.description), + enabled: true + }); + }); + } + + brunoRequestItem.examples.push(example); + }); + } + + brunoParent.items.push(brunoRequestItem); + requestMap[requestName] = brunoRequestItem; + } catch (err) { + issues.push({ path: itemPath, severity: 'error', message: err.message, sourceItem: i }); + } } }); }; @@ -876,8 +888,14 @@ const importPostmanV2Collection = async (collection, { useWorkers = false }) => importScriptsFromEvents(collection.event, brunoCollection.root.request); } + const issues = []; + if (collection?.variable) { - importCollectionLevelVariables(collection.variable, brunoCollection.root.request); + try { + importCollectionLevelVariables(collection.variable, brunoCollection.root.request); + } catch (err) { + issues.push({ path: 'Collection Variables', severity: 'warning', message: err.message }); + } } // Collection level auth @@ -886,7 +904,7 @@ const importPostmanV2Collection = async (collection, { useWorkers = false }) => // Create a single scriptMap for all items const scriptMap = useWorkers ? new Map() : null; - importPostmanV2CollectionItem(brunoCollection, collection.item, { useWorkers }, scriptMap); + importPostmanV2CollectionItem(brunoCollection, collection.item, { useWorkers }, scriptMap, issues); // Process all scripts in a single call at the top level if (useWorkers && scriptMap && scriptMap.size > 0) { @@ -945,7 +963,7 @@ const importPostmanV2Collection = async (collection, { useWorkers = false }) => } } - return brunoCollection; + return { collection: brunoCollection, issues }; }; const parsePostmanCollection = async (collection, { useWorkers = false }) => { @@ -979,13 +997,13 @@ const parsePostmanCollection = async (collection, { useWorkers = false }) => { const postmanToBruno = async (postmanCollection, { useWorkers = false } = {}) => { try { - const parsedPostmanCollection = await parsePostmanCollection(postmanCollection, { useWorkers }); - const transformedCollection = transformItemsInCollection(parsedPostmanCollection); + 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); - return validatedCollection; + return { collection: validatedCollection, issues }; } catch (err) { console.log(err); throw new Error(`Import collection failed: ${err.message}`); diff --git a/packages/bruno-converters/tests/fixtures/postman-with-import-issues.json b/packages/bruno-converters/tests/fixtures/postman-with-import-issues.json new file mode 100644 index 000000000..d221613a7 --- /dev/null +++ b/packages/bruno-converters/tests/fixtures/postman-with-import-issues.json @@ -0,0 +1,210 @@ +{ + "info": { + "_postman_id": "test-import-issues-001", + "name": "Import Issues Test Collection", + "description": "A Postman collection designed to test partial import handling. Contains a mix of valid requests, requests with missing/invalid methods, and edge cases that should be gracefully handled.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Valid GET Request", + "request": { + "method": "GET", + "header": [ + { "key": "Accept", "value": "application/json" } + ], + "url": { + "raw": "https://api.example.com/users", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["users"] + } + } + }, + { + "name": "Missing Method (null)", + "request": { + "method": null, + "header": [], + "url": { + "raw": "https://api.example.com/should-be-skipped-1", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["should-be-skipped-1"] + } + } + }, + { + "name": "Missing Method (absent)", + "request": { + "header": [], + "url": { + "raw": "https://api.example.com/should-be-skipped-2", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["should-be-skipped-2"] + } + } + }, + { + "name": "Empty String Method", + "request": { + "method": "", + "header": [], + "url": { + "raw": "https://api.example.com/should-be-skipped-3", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["should-be-skipped-3"] + } + } + }, + { + "name": "Whitespace-Only Method", + "request": { + "method": " ", + "header": [], + "url": { + "raw": "https://api.example.com/should-be-skipped-4", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["should-be-skipped-4"] + } + } + }, + { + "name": "Valid POST Request", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\"name\": \"test\", \"email\": \"test@example.com\"}", + "options": { + "raw": { "language": "json" } + } + }, + "url": { + "raw": "https://api.example.com/users", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["users"] + } + } + }, + { + "name": "API Folder", + "item": [ + { + "name": "Valid Nested GET", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://api.example.com/nested/valid", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["nested", "valid"] + } + } + }, + { + "name": "Nested Missing Method", + "request": { + "method": null, + "header": [], + "url": { + "raw": "https://api.example.com/nested/should-be-skipped", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["nested", "should-be-skipped"] + } + } + }, + { + "name": "Deep Subfolder", + "item": [ + { + "name": "Deep Valid Request", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "https://api.example.com/deep/valid", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["deep", "valid"] + } + } + }, + { + "name": "Deep Bad Method", + "request": { + "method": null, + "header": [], + "url": { + "raw": "https://api.example.com/deep/should-be-skipped", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["deep", "should-be-skipped"] + } + } + } + ] + } + ] + }, + { + "name": "Valid PUT Request", + "request": { + "method": "PUT", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "body": { + "mode": "raw", + "raw": "{\"name\": \"updated\"}", + "options": { + "raw": { "language": "json" } + } + }, + "url": { + "raw": "https://api.example.com/users/{{userId}}", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["users", "{{userId}}"], + "variable": [ + { "key": "userId", "value": "123" } + ] + } + } + }, + { + "name": "No Request Object At All" + }, + { + "name": "Valid PATCH Request", + "request": { + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\"status\": \"active\"}" + }, + "url": { + "raw": "https://api.example.com/users/1/status", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["users", "1", "status"] + } + } + } + ], + "variable": [ + { "key": "baseUrl", "value": "https://api.example.com" }, + { "key": "token", "value": "test-token-123" } + ] +} diff --git a/packages/bruno-converters/tests/postman-with-examples.spec.js b/packages/bruno-converters/tests/postman-with-examples.spec.js index 15bbd52d8..670859042 100644 --- a/packages/bruno-converters/tests/postman-with-examples.spec.js +++ b/packages/bruno-converters/tests/postman-with-examples.spec.js @@ -101,7 +101,7 @@ describe('Postman to Bruno Converter with Examples', () => { }; test('should convert Postman collection with examples to Bruno format', async () => { - const brunoCollection = await postmanToBruno(postmanCollectionWithExamples); + const { collection: brunoCollection } = await postmanToBruno(postmanCollectionWithExamples); expect(brunoCollection).toBeDefined(); expect(brunoCollection.name).toBe('collection with examples'); @@ -180,7 +180,7 @@ describe('Postman to Bruno Converter with Examples', () => { ] }; - const brunoCollection = await postmanToBruno(postmanCollectionWithoutExamples); + const { collection: brunoCollection } = await postmanToBruno(postmanCollectionWithoutExamples); expect(brunoCollection).toBeDefined(); expect(brunoCollection.name).toBe('collection without examples'); @@ -218,7 +218,7 @@ describe('Postman to Bruno Converter with Examples', () => { ] }; - const brunoCollection = await postmanToBruno(postmanCollectionWithEmptyExamples); + const { collection: brunoCollection } = await postmanToBruno(postmanCollectionWithEmptyExamples); expect(brunoCollection).toBeDefined(); expect(brunoCollection.name).toBe('collection with empty examples'); diff --git a/packages/bruno-converters/tests/postman/postman-to-bruno/collection-auth.spec.js b/packages/bruno-converters/tests/postman/postman-to-bruno/collection-auth.spec.js index 95b5ce8b6..45d0d4f38 100644 --- a/packages/bruno-converters/tests/postman/postman-to-bruno/collection-auth.spec.js +++ b/packages/bruno-converters/tests/postman/postman-to-bruno/collection-auth.spec.js @@ -29,7 +29,7 @@ describe('Collection Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); // console.log('result', JSON.stringify(result, null, 2)); expect(result.root.request.auth).toEqual({ @@ -86,7 +86,7 @@ describe('Collection Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); // console.log('result', JSON.stringify(result, null, 2)); expect(result.root.request.auth).toEqual({ @@ -141,7 +141,7 @@ describe('Collection Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); // console.log('result', JSON.stringify(result, null, 2)); expect(result.root.request.auth).toEqual({ @@ -200,7 +200,7 @@ describe('Collection Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.root.request.auth).toEqual({ mode: 'apikey', @@ -265,7 +265,7 @@ describe('Collection Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.root.request.auth).toEqual({ mode: 'digest', @@ -312,7 +312,7 @@ describe('Collection Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.root.request.auth).toEqual({ mode: 'basic', @@ -360,7 +360,7 @@ describe('Collection Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.root.request.auth).toEqual({ mode: 'bearer', diff --git a/packages/bruno-converters/tests/postman/postman-to-bruno/folder-auth.spec.js b/packages/bruno-converters/tests/postman/postman-to-bruno/folder-auth.spec.js index 4807c9d01..8fc257a83 100644 --- a/packages/bruno-converters/tests/postman/postman-to-bruno/folder-auth.spec.js +++ b/packages/bruno-converters/tests/postman/postman-to-bruno/folder-auth.spec.js @@ -49,7 +49,7 @@ describe('Folder Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.items[0].root.request.auth).toEqual({ mode: 'inherit', @@ -113,7 +113,7 @@ describe('Folder Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.items[0].root.request.auth).toEqual({ mode: 'none', @@ -174,7 +174,7 @@ describe('Folder Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.items[0].root.request.auth).toEqual({ mode: 'basic', @@ -233,7 +233,7 @@ describe('Folder Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.items[0].root.request.auth).toEqual({ mode: 'bearer', @@ -294,7 +294,7 @@ describe('Folder Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.items[0].root.request.auth).toEqual({ mode: 'apikey', @@ -360,7 +360,7 @@ describe('Folder Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.items[0].root.request.auth).toEqual({ mode: 'digest', @@ -410,7 +410,7 @@ describe('Folder Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.items[0].root.request.auth).toEqual({ mode: 'basic', diff --git a/packages/bruno-converters/tests/postman/postman-to-bruno/partial-import.spec.js b/packages/bruno-converters/tests/postman/postman-to-bruno/partial-import.spec.js new file mode 100644 index 000000000..35bf9b662 --- /dev/null +++ b/packages/bruno-converters/tests/postman/postman-to-bruno/partial-import.spec.js @@ -0,0 +1,199 @@ +import { describe, it, expect } from '@jest/globals'; +import postmanToBruno from '../../../src/postman/postman-to-bruno'; + +const makeCollection = (items, overrides = {}) => ({ + info: { + _postman_id: 'test-id', + name: 'Test Collection', + schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json' + }, + item: items, + ...overrides +}); + +const makeRequest = (name, method = 'GET', url = 'https://example.com') => ({ + name, + request: { + method, + header: [], + url: { raw: url, protocol: 'https', host: ['example', 'com'] } + } +}); + +describe('partial-import', () => { + it('should import valid items and skip items with missing method', async () => { + const items = [ + makeRequest('Valid Request 1'), + { name: 'Bad Request', request: { method: null, header: [], url: { raw: 'https://example.com' } } }, + makeRequest('Valid Request 2') + ]; + + const { collection, issues } = await postmanToBruno(makeCollection(items)); + + expect(collection.items).toHaveLength(2); + expect(collection.items[0].name).toBe('Valid Request 1'); + expect(collection.items[1].name).toBe('Valid Request 2'); + + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + path: 'Bad Request', + severity: 'error', + message: 'Missing or invalid request method' + }); + }); + + it('should import valid items and record errors for items that throw', async () => { + // Create a request with a value that will cause ensureString to throw (circular ref) + const circular = {}; + circular.self = circular; + + const items = [ + makeRequest('Valid Request'), + { + name: 'Circular Request', + request: { + method: 'POST', + header: [{ key: circular, value: 'test' }], + url: { raw: 'https://example.com' } + } + } + ]; + + const { collection, issues } = await postmanToBruno(makeCollection(items)); + + expect(collection.items).toHaveLength(1); + expect(collection.items[0].name).toBe('Valid Request'); + + expect(issues).toHaveLength(1); + expect(issues[0].path).toBe('Circular Request'); + expect(issues[0].severity).toBe('error'); + }); + + it('should record issues for nested folder items with full path', async () => { + const items = [ + { + name: 'My Folder', + item: [ + { + name: 'Subfolder', + item: [ + { name: 'Bad Nested', request: { method: null, header: [], url: { raw: 'https://example.com' } } }, + makeRequest('Good Nested') + ] + } + ] + } + ]; + + const { collection, issues } = await postmanToBruno(makeCollection(items)); + + expect(collection.items).toHaveLength(1); + expect(collection.items[0].type).toBe('folder'); + expect(collection.items[0].items[0].type).toBe('folder'); + expect(collection.items[0].items[0].items).toHaveLength(1); + expect(collection.items[0].items[0].items[0].name).toBe('Good Nested'); + + expect(issues).toHaveLength(1); + expect(issues[0].path).toBe('My Folder / Subfolder / Bad Nested'); + expect(issues[0].severity).toBe('error'); + }); + + it('should return empty issues array for valid collections', async () => { + const items = [ + makeRequest('Request 1'), + makeRequest('Request 2') + ]; + + const { collection, issues } = await postmanToBruno(makeCollection(items)); + + expect(collection.items).toHaveLength(2); + expect(issues).toEqual([]); + }); + + it('should handle empty collections with no issues', async () => { + const { collection, issues } = await postmanToBruno(makeCollection([])); + + expect(collection.items).toEqual([]); + expect(issues).toEqual([]); + }); + + it('should handle all items being malformed without throwing', async () => { + const items = [ + { name: 'Bad 1', request: { method: null, header: [], url: { raw: 'https://example.com' } } }, + { name: 'Bad 2', request: { method: undefined, header: [], url: { raw: 'https://example.com' } } } + ]; + + const { collection, issues } = await postmanToBruno(makeCollection(items)); + + expect(collection.items).toEqual([]); + expect(issues).toHaveLength(2); + expect(issues.every((i) => i.severity === 'error')).toBe(true); + }); + + it('should record warning for malformed collection-level variables', async () => { + const collectionData = makeCollection([makeRequest('Valid')], { + variable: 'not-an-array' + }); + + const { collection, issues } = await postmanToBruno(collectionData); + + expect(collection.items).toHaveLength(1); + const warnings = issues.filter((i) => i.severity === 'warning'); + expect(warnings).toHaveLength(1); + expect(warnings[0].path).toBe('Collection Variables'); + }); + + it('should handle mixed valid/invalid items with folders and bad variables', async () => { + const items = [ + makeRequest('r1', 'POST'), + { name: 'r2-missing-method', request: { header: [], url: { raw: 'https://example.com' } } }, + makeRequest('r3', 'POST'), + { + name: 'API Folder', + item: [ + makeRequest('valid-in-folder', 'GET'), + { name: 'r4-null-method', request: { method: null, header: [], url: { raw: 'https://example.com' } } }, + { + name: 'Nested Subfolder', + item: [ + { name: 'r5-empty-method', request: { method: '', header: [], url: { raw: 'https://example.com' } } }, + makeRequest('valid-nested', 'DELETE') + ] + } + ] + } + ]; + + const { collection, issues } = await postmanToBruno(makeCollection(items, { variable: 'not-an-array' })); + + // 4 valid items: r1, r3, valid-in-folder, valid-nested + expect(collection.items).toHaveLength(3); // r1, r3, API Folder + expect(collection.items[0].name).toBe('r1'); + expect(collection.items[1].name).toBe('r3'); + expect(collection.items[2].name).toBe('API Folder'); + expect(collection.items[2].items).toHaveLength(2); // valid-in-folder, Nested Subfolder + expect(collection.items[2].items[0].name).toBe('valid-in-folder'); + expect(collection.items[2].items[1].name).toBe('Nested Subfolder'); + expect(collection.items[2].items[1].items).toHaveLength(1); // valid-nested + expect(collection.items[2].items[1].items[0].name).toBe('valid-nested'); + + // 4 issues: 1 warning (variables) + 3 errors (missing/null/empty method) + expect(issues).toHaveLength(4); + expect(issues.filter((i) => i.severity === 'warning')).toHaveLength(1); + expect(issues.filter((i) => i.severity === 'error')).toHaveLength(3); + expect(issues.find((i) => i.path === 'r2-missing-method')).toBeTruthy(); + expect(issues.find((i) => i.path === 'API Folder / r4-null-method')).toBeTruthy(); + expect(issues.find((i) => i.path === 'API Folder / Nested Subfolder / r5-empty-method')).toBeTruthy(); + }); + + it('should use fallback name for items without a name', async () => { + const items = [ + { request: { method: null, header: [], url: { raw: 'https://example.com' } } } + ]; + + const { issues } = await postmanToBruno(makeCollection(items)); + + expect(issues).toHaveLength(1); + expect(issues[0].path).toBe('Item 1'); + }); +}); diff --git a/packages/bruno-converters/tests/postman/postman-to-bruno/postman-to-bruno.spec.js b/packages/bruno-converters/tests/postman/postman-to-bruno/postman-to-bruno.spec.js index 1e02b4277..e6145969d 100644 --- a/packages/bruno-converters/tests/postman/postman-to-bruno/postman-to-bruno.spec.js +++ b/packages/bruno-converters/tests/postman/postman-to-bruno/postman-to-bruno.spec.js @@ -4,7 +4,7 @@ import { invalidVariableCharacterRegex } from '../../../src/constants'; describe('postman-collection', () => { it('should correctly import a valid Postman collection file', async () => { - const brunoCollection = await postmanToBruno(postmanCollection); + const { collection: brunoCollection } = await postmanToBruno(postmanCollection); expect(brunoCollection).toMatchObject(expectedOutput); }); @@ -55,7 +55,7 @@ describe('postman-collection', () => { item: [] }; - const brunoCollection = await postmanToBruno(collectionWithFalsyVars); + const { collection: brunoCollection } = await postmanToBruno(collectionWithFalsyVars); expect(brunoCollection.root.request.vars.req).toEqual([ { @@ -125,7 +125,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithFalsyVars); + const { collection: brunoCollection } = await postmanToBruno(collectionWithFalsyVars); expect(brunoCollection.items.map((item) => item.request.url)).toEqual([ 'https://httpbin.org/api/v1/resource' @@ -178,7 +178,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithFalsyVars); + const { collection: brunoCollection } = await postmanToBruno(collectionWithFalsyVars); expect(brunoCollection.items.map((item) => item.request.url)).toEqual([ 'https://httpbin.org/api/v1/resource/' @@ -231,7 +231,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithFalsyVars); + const { collection: brunoCollection } = await postmanToBruno(collectionWithFalsyVars); expect(brunoCollection.items.map((item) => item.request.url)).toEqual([ 'https://httpbin.org/api//resource' @@ -266,7 +266,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithNonStringVars); + const { collection: brunoCollection } = await postmanToBruno(collectionWithNonStringVars); const vars = brunoCollection.root.request.vars.req; expect(vars).toHaveLength(3); @@ -286,7 +286,7 @@ describe('postman-collection', () => { item: [] }; - const brunoCollection = await postmanToBruno(collectionWithEmptyVars); + const { collection: brunoCollection } = await postmanToBruno(collectionWithEmptyVars); expect(brunoCollection.root.request.vars.req).toEqual([]); }); @@ -348,7 +348,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithSettings); + const { collection: brunoCollection } = await postmanToBruno(collectionWithSettings); // Test request with all settings const requestWithAllSettings = brunoCollection.items[0]; @@ -402,7 +402,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithUndefinedAuthType); + const { collection: brunoCollection } = await postmanToBruno(collectionWithUndefinedAuthType); // Collection level auth should default to 'none' expect(brunoCollection.root.request.auth).toEqual({ @@ -459,7 +459,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithNullAuthType); + const { collection: brunoCollection } = await postmanToBruno(collectionWithNullAuthType); // Collection level auth should default to 'none' expect(brunoCollection.root.request.auth).toEqual({ @@ -505,7 +505,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithUnexpectedAuthType); + const { collection: brunoCollection } = await postmanToBruno(collectionWithUnexpectedAuthType); // Collection level auth should default to 'none' expect(brunoCollection.root.request.auth).toEqual({ @@ -562,7 +562,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithRequestUndefinedAuthType); + const { collection: brunoCollection } = await postmanToBruno(collectionWithRequestUndefinedAuthType); // Collection level auth should default to 'none' expect(brunoCollection.root.request.auth).toEqual({ @@ -624,7 +624,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithFolderUnexpectedAuthType); + const { collection: brunoCollection } = await postmanToBruno(collectionWithFolderUnexpectedAuthType); // Folder auth should default to 'none' expect(brunoCollection.items[0].root.request.auth).toEqual({ @@ -675,7 +675,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithNullHeaders); + const { collection: brunoCollection } = await postmanToBruno(collectionWithNullHeaders); const headers = brunoCollection.items[0].request.headers; expect(headers).toHaveLength(3); @@ -715,7 +715,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithNullUrlencoded); + const { collection: brunoCollection } = await postmanToBruno(collectionWithNullUrlencoded); const formUrlEncoded = brunoCollection.items[0].request.body.formUrlEncoded; expect(formUrlEncoded).toHaveLength(3); @@ -754,7 +754,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithNullFormdata); + const { collection: brunoCollection } = await postmanToBruno(collectionWithNullFormdata); const multipartForm = brunoCollection.items[0].request.body.multipartForm; expect(multipartForm).toHaveLength(2); @@ -794,7 +794,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithNullQueryParams); + const { collection: brunoCollection } = await postmanToBruno(collectionWithNullQueryParams); const params = brunoCollection.items[0].request.params; // Fully-null entry should be skipped @@ -871,7 +871,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithNumericValues); + const { collection: brunoCollection } = await postmanToBruno(collectionWithNumericValues); const item = brunoCollection.items[0]; // Headers should have string values @@ -950,7 +950,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithNumericExamples); + const { collection: brunoCollection } = await postmanToBruno(collectionWithNumericExamples); const example = brunoCollection.items[0].examples[0]; // Example request headers @@ -1011,7 +1011,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithNumericAuth); + const { collection: brunoCollection } = await postmanToBruno(collectionWithNumericAuth); // Bearer token should be stringified expect(brunoCollection.items[0].request.auth.mode).toBe('bearer'); @@ -1049,7 +1049,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithObjectAuth); + const { collection: brunoCollection } = await postmanToBruno(collectionWithObjectAuth); expect(brunoCollection.items[0].request.auth.mode).toBe('basic'); expect(brunoCollection.items[0].request.auth.basic.username).toBe('12345'); @@ -1079,7 +1079,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithStringHeaders); + const { collection: brunoCollection } = await postmanToBruno(collectionWithStringHeaders); const headers = brunoCollection.items[0].request.headers; expect(headers).toHaveLength(3); @@ -1110,7 +1110,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithConcatenatedHeaders); + const { collection: brunoCollection } = await postmanToBruno(collectionWithConcatenatedHeaders); const headers = brunoCollection.items[0].request.headers; expect(headers).toHaveLength(2); @@ -1125,7 +1125,7 @@ describe('postman-collection', () => { collection: { ...postmanCollection } }; - const brunoCollection = await postmanToBruno(wrappedCollection); + const { collection: brunoCollection } = await postmanToBruno(wrappedCollection); expect(brunoCollection).toMatchObject(expectedOutput); }); @@ -1148,7 +1148,7 @@ describe('postman-collection', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithNoValueHeader); + const { collection: brunoCollection } = await postmanToBruno(collectionWithNoValueHeader); const headers = brunoCollection.items[0].request.headers; expect(headers).toHaveLength(1); @@ -1247,7 +1247,7 @@ describe('postman-collection formdata import', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithFileFormdata); + const { collection: brunoCollection } = await postmanToBruno(collectionWithFileFormdata); const multipartForm = brunoCollection.items[0].request.body.multipartForm; expect(multipartForm).toHaveLength(1); @@ -1287,7 +1287,7 @@ describe('postman-collection formdata import', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithDefaultTypeAndSrc); + const { collection: brunoCollection } = await postmanToBruno(collectionWithDefaultTypeAndSrc); const multipartForm = brunoCollection.items[0].request.body.multipartForm; expect(multipartForm).toHaveLength(1); @@ -1327,7 +1327,7 @@ describe('postman-collection formdata import', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithDefaultTypeAndValueArray); + const { collection: brunoCollection } = await postmanToBruno(collectionWithDefaultTypeAndValueArray); const multipartForm = brunoCollection.items[0].request.body.multipartForm; expect(multipartForm).toHaveLength(1); @@ -1368,7 +1368,7 @@ describe('postman-collection formdata import', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithContentType); + const { collection: brunoCollection } = await postmanToBruno(collectionWithContentType); const multipartForm = brunoCollection.items[0].request.body.multipartForm; expect(multipartForm).toHaveLength(1); @@ -1412,7 +1412,7 @@ describe('postman-collection formdata import', () => { ] }; - const brunoCollection = await postmanToBruno(collectionWithMixedFormdata); + const { collection: brunoCollection } = await postmanToBruno(collectionWithMixedFormdata); const multipartForm = brunoCollection.items[0].request.body.multipartForm; expect(multipartForm).toHaveLength(2); diff --git a/packages/bruno-converters/tests/postman/postman-to-bruno/request-auth.spec.js b/packages/bruno-converters/tests/postman/postman-to-bruno/request-auth.spec.js index b6080f82d..dea2ee766 100644 --- a/packages/bruno-converters/tests/postman/postman-to-bruno/request-auth.spec.js +++ b/packages/bruno-converters/tests/postman/postman-to-bruno/request-auth.spec.js @@ -26,7 +26,7 @@ describe('Request Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.items[0].request.auth).toEqual({ mode: 'basic', @@ -69,7 +69,7 @@ describe('Request Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.items[0].items[0].request.auth).toEqual({ mode: 'inherit', @@ -110,7 +110,7 @@ describe('Request Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.items[0].items[0].request.auth).toEqual({ mode: 'inherit', @@ -156,7 +156,7 @@ describe('Request Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); // Check folder first expect(result.items[0].root.request.auth).toEqual({ @@ -199,7 +199,7 @@ describe('Request Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.items[0].items[0].request.auth).toEqual({ mode: 'none', // <<<< KEY CHECK @@ -250,7 +250,7 @@ describe('Request Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); // Check Folder Level 1 expect(result.items[0].root.request.auth).toEqual({ @@ -311,7 +311,7 @@ describe('Request Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); // Check Folder Level 1 expect(result.items[0].root.request.auth).toEqual({ @@ -366,7 +366,7 @@ describe('Request Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.items[0].request.auth).toEqual({ mode: 'oauth1', @@ -428,7 +428,7 @@ describe('Request Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.items[0].request.auth.mode).toBe('oauth1'); expect(result.items[0].request.auth.oauth1.placement).toBe('header'); @@ -471,7 +471,7 @@ describe('Request Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.items[0].request.auth.mode).toBe('oauth1'); expect(result.items[0].request.auth.oauth1.signatureMethod).toBe('RSA-SHA1'); @@ -508,7 +508,7 @@ describe('Request Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); // Collection root should have oauth1 expect(result.root.request.auth.mode).toBe('oauth1'); @@ -560,7 +560,7 @@ describe('Request Authentication', () => { ] }; - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); // Check Folder Level 1 expect(result.items[0].root.request.auth).toEqual({ @@ -606,7 +606,7 @@ describe('Request Authentication', () => { { key: 'in', value: 'query' } ]); - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.items[0].request.auth.mode).toBe('apikey'); expect(result.items[0].request.auth.apikey).toEqual({ @@ -623,7 +623,7 @@ describe('Request Authentication', () => { { key: 'in', value: 'header' } ]); - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.items[0].request.auth.apikey).toEqual({ key: 'X-API-Key', @@ -638,7 +638,7 @@ describe('Request Authentication', () => { { key: 'value', value: 'secret-token' } ]); - const result = await postmanToBruno(postmanCollection); + const { collection: result } = await postmanToBruno(postmanCollection); expect(result.items[0].request.auth.apikey.placement).toBe('header'); }); diff --git a/packages/bruno-converters/tests/postman/postman-to-bruno/transform-description.spec.js b/packages/bruno-converters/tests/postman/postman-to-bruno/transform-description.spec.js index d47a92163..42e203db7 100644 --- a/packages/bruno-converters/tests/postman/postman-to-bruno/transform-description.spec.js +++ b/packages/bruno-converters/tests/postman/postman-to-bruno/transform-description.spec.js @@ -12,7 +12,7 @@ describe('transformDescription function', () => { item: [] }; - const brunoCollection = await postmanToBruno(collection); + const { collection: brunoCollection } = await postmanToBruno(collection); expect(brunoCollection.root.docs).toBe(''); }); @@ -26,7 +26,7 @@ describe('transformDescription function', () => { item: [] }; - const brunoCollection = await postmanToBruno(collection); + const { collection: brunoCollection } = await postmanToBruno(collection); expect(brunoCollection.root.docs).toBe('This is a string description'); }); @@ -43,7 +43,7 @@ describe('transformDescription function', () => { item: [] }; - const brunoCollection = await postmanToBruno(collection); + const { collection: brunoCollection } = await postmanToBruno(collection); expect(brunoCollection.root.docs).toBe('This is the content from the new Postman format'); }); @@ -59,7 +59,7 @@ describe('transformDescription function', () => { item: [] }; - const brunoCollection = await postmanToBruno(collection); + const { collection: brunoCollection } = await postmanToBruno(collection); expect(brunoCollection.root.docs).toBe(''); }); @@ -84,7 +84,7 @@ describe('transformDescription function', () => { ] }; - const brunoCollection = await postmanToBruno(collection); + const { collection: brunoCollection } = await postmanToBruno(collection); expect(brunoCollection.items[0].request.docs).toBe('This is a request description in new format'); }); @@ -106,7 +106,7 @@ describe('transformDescription function', () => { ] }; - const brunoCollection = await postmanToBruno(collection); + const { collection: brunoCollection } = await postmanToBruno(collection); expect(brunoCollection.items[0].root.docs).toBe('This is a folder description in new format'); }); @@ -137,7 +137,7 @@ describe('transformDescription function', () => { ] }; - const brunoCollection = await postmanToBruno(collection); + const { collection: brunoCollection } = await postmanToBruno(collection); expect(brunoCollection.items[0].request.headers[0].description).toBe('Authorization header description'); }); @@ -172,7 +172,7 @@ describe('transformDescription function', () => { ] }; - const brunoCollection = await postmanToBruno(collection); + const { collection: brunoCollection } = await postmanToBruno(collection); expect(brunoCollection.items[0].request.params[0].description).toBe('Query parameter description'); }); @@ -207,7 +207,7 @@ describe('transformDescription function', () => { ] }; - const brunoCollection = await postmanToBruno(collection); + const { collection: brunoCollection } = await postmanToBruno(collection); expect(brunoCollection.items[0].request.params[0].description).toBe('User ID path variable'); }); @@ -241,7 +241,7 @@ describe('transformDescription function', () => { ] }; - const brunoCollection = await postmanToBruno(collection); + const { collection: brunoCollection } = await postmanToBruno(collection); expect(brunoCollection.items[0].request.body.multipartForm[0].description).toBe('Form field description'); }); @@ -275,7 +275,7 @@ describe('transformDescription function', () => { ] }; - const brunoCollection = await postmanToBruno(collection); + const { collection: brunoCollection } = await postmanToBruno(collection); expect(brunoCollection.items[0].request.body.formUrlEncoded[0].description).toBe('URL encoded field description'); }); @@ -317,7 +317,7 @@ describe('transformDescription function', () => { ] }; - const brunoCollection = await postmanToBruno(collection); + const { collection: brunoCollection } = await postmanToBruno(collection); // Collection description (string) expect(brunoCollection.root.docs).toBe('Collection with string description'); @@ -357,7 +357,7 @@ describe('transformDescription function', () => { ] }; - const brunoCollection = await postmanToBruno(collection); + const { collection: brunoCollection } = await postmanToBruno(collection); expect(brunoCollection.root.docs).toBe(''); expect(brunoCollection.items[0].request.docs).toBe('Description with special chars: !@#$%^&*()'); }); diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index e81662fb4..15006e6d5 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -2127,9 +2127,10 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { ipcMain.handle('renderer:convert-postman-to-bruno', async (event, postmanCollection) => { try { // Convert Postman collection to Bruno format - const brunoCollection = await postmanToBruno(postmanCollection, { useWorkers: true }); + // Returns { collection, issues } where issues tracks items that were skipped or degraded + const result = await postmanToBruno(postmanCollection, { useWorkers: true }); - return brunoCollection; + return result; } catch (error) { console.error('Error converting Postman to Bruno:', error); return Promise.reject(error); diff --git a/tests/import/postman/fixtures/postman-with-import-issues.json b/tests/import/postman/fixtures/postman-with-import-issues.json new file mode 100644 index 000000000..d221613a7 --- /dev/null +++ b/tests/import/postman/fixtures/postman-with-import-issues.json @@ -0,0 +1,210 @@ +{ + "info": { + "_postman_id": "test-import-issues-001", + "name": "Import Issues Test Collection", + "description": "A Postman collection designed to test partial import handling. Contains a mix of valid requests, requests with missing/invalid methods, and edge cases that should be gracefully handled.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Valid GET Request", + "request": { + "method": "GET", + "header": [ + { "key": "Accept", "value": "application/json" } + ], + "url": { + "raw": "https://api.example.com/users", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["users"] + } + } + }, + { + "name": "Missing Method (null)", + "request": { + "method": null, + "header": [], + "url": { + "raw": "https://api.example.com/should-be-skipped-1", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["should-be-skipped-1"] + } + } + }, + { + "name": "Missing Method (absent)", + "request": { + "header": [], + "url": { + "raw": "https://api.example.com/should-be-skipped-2", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["should-be-skipped-2"] + } + } + }, + { + "name": "Empty String Method", + "request": { + "method": "", + "header": [], + "url": { + "raw": "https://api.example.com/should-be-skipped-3", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["should-be-skipped-3"] + } + } + }, + { + "name": "Whitespace-Only Method", + "request": { + "method": " ", + "header": [], + "url": { + "raw": "https://api.example.com/should-be-skipped-4", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["should-be-skipped-4"] + } + } + }, + { + "name": "Valid POST Request", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\"name\": \"test\", \"email\": \"test@example.com\"}", + "options": { + "raw": { "language": "json" } + } + }, + "url": { + "raw": "https://api.example.com/users", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["users"] + } + } + }, + { + "name": "API Folder", + "item": [ + { + "name": "Valid Nested GET", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://api.example.com/nested/valid", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["nested", "valid"] + } + } + }, + { + "name": "Nested Missing Method", + "request": { + "method": null, + "header": [], + "url": { + "raw": "https://api.example.com/nested/should-be-skipped", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["nested", "should-be-skipped"] + } + } + }, + { + "name": "Deep Subfolder", + "item": [ + { + "name": "Deep Valid Request", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "https://api.example.com/deep/valid", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["deep", "valid"] + } + } + }, + { + "name": "Deep Bad Method", + "request": { + "method": null, + "header": [], + "url": { + "raw": "https://api.example.com/deep/should-be-skipped", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["deep", "should-be-skipped"] + } + } + } + ] + } + ] + }, + { + "name": "Valid PUT Request", + "request": { + "method": "PUT", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "body": { + "mode": "raw", + "raw": "{\"name\": \"updated\"}", + "options": { + "raw": { "language": "json" } + } + }, + "url": { + "raw": "https://api.example.com/users/{{userId}}", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["users", "{{userId}}"], + "variable": [ + { "key": "userId", "value": "123" } + ] + } + } + }, + { + "name": "No Request Object At All" + }, + { + "name": "Valid PATCH Request", + "request": { + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\"status\": \"active\"}" + }, + "url": { + "raw": "https://api.example.com/users/1/status", + "protocol": "https", + "host": ["api", "example", "com"], + "path": ["users", "1", "status"] + } + } + } + ], + "variable": [ + { "key": "baseUrl", "value": "https://api.example.com" }, + { "key": "token", "value": "test-token-123" } + ] +} diff --git a/tests/import/postman/fixtures/postman-with-many-import-issues.json b/tests/import/postman/fixtures/postman-with-many-import-issues.json new file mode 100644 index 000000000..0f7553f23 --- /dev/null +++ b/tests/import/postman/fixtures/postman-with-many-import-issues.json @@ -0,0 +1,70 @@ +{ + "info": { + "_postman_id": "test-many-import-issues-001", + "name": "Many Import Issues Collection", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Valid GET Request", + "request": { + "method": "GET", + "header": [], + "url": { "raw": "https://api.example.com/users", "protocol": "https", "host": ["api", "example", "com"], "path": ["users"] } + } + }, + { "name": "Bad Method 1", "request": { "method": null, "header": [], "url": { "raw": "https://api.example.com/bad-1", "protocol": "https", "host": ["api", "example", "com"], "path": ["bad-1"] } } }, + { "name": "Bad Method 2", "request": { "method": null, "header": [], "url": { "raw": "https://api.example.com/bad-2", "protocol": "https", "host": ["api", "example", "com"], "path": ["bad-2"] } } }, + { "name": "Bad Method 3", "request": { "method": null, "header": [], "url": { "raw": "https://api.example.com/bad-3", "protocol": "https", "host": ["api", "example", "com"], "path": ["bad-3"] } } }, + { "name": "Bad Method 4", "request": { "method": null, "header": [], "url": { "raw": "https://api.example.com/bad-4", "protocol": "https", "host": ["api", "example", "com"], "path": ["bad-4"] } } }, + { "name": "Bad Method 5", "request": { "method": null, "header": [], "url": { "raw": "https://api.example.com/bad-5", "protocol": "https", "host": ["api", "example", "com"], "path": ["bad-5"] } } }, + { "name": "Bad Method 6", "request": { "method": null, "header": [], "url": { "raw": "https://api.example.com/bad-6", "protocol": "https", "host": ["api", "example", "com"], "path": ["bad-6"] } } }, + { "name": "Bad Method 7", "request": { "method": null, "header": [], "url": { "raw": "https://api.example.com/bad-7", "protocol": "https", "host": ["api", "example", "com"], "path": ["bad-7"] } } }, + { "name": "Bad Method 8", "request": { "method": null, "header": [], "url": { "raw": "https://api.example.com/bad-8", "protocol": "https", "host": ["api", "example", "com"], "path": ["bad-8"] } } }, + { "name": "Bad Method 9", "request": { "method": null, "header": [], "url": { "raw": "https://api.example.com/bad-9", "protocol": "https", "host": ["api", "example", "com"], "path": ["bad-9"] } } }, + { "name": "Bad Method 10", "request": { "method": null, "header": [], "url": { "raw": "https://api.example.com/bad-10", "protocol": "https", "host": ["api", "example", "com"], "path": ["bad-10"] } } }, + { "name": "Empty Method 1", "request": { "method": "", "header": [], "url": { "raw": "https://api.example.com/empty-1", "protocol": "https", "host": ["api", "example", "com"], "path": ["empty-1"] } } }, + { "name": "Empty Method 2", "request": { "method": "", "header": [], "url": { "raw": "https://api.example.com/empty-2", "protocol": "https", "host": ["api", "example", "com"], "path": ["empty-2"] } } }, + { "name": "Empty Method 3", "request": { "method": "", "header": [], "url": { "raw": "https://api.example.com/empty-3", "protocol": "https", "host": ["api", "example", "com"], "path": ["empty-3"] } } }, + { "name": "Empty Method 4", "request": { "method": "", "header": [], "url": { "raw": "https://api.example.com/empty-4", "protocol": "https", "host": ["api", "example", "com"], "path": ["empty-4"] } } }, + { "name": "Empty Method 5", "request": { "method": "", "header": [], "url": { "raw": "https://api.example.com/empty-5", "protocol": "https", "host": ["api", "example", "com"], "path": ["empty-5"] } } }, + { + "name": "Valid POST Request", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { "mode": "raw", "raw": "{\"name\": \"test\"}" }, + "url": { "raw": "https://api.example.com/users", "protocol": "https", "host": ["api", "example", "com"], "path": ["users"] } + } + }, + { "name": "Whitespace Method 1", "request": { "method": " ", "header": [], "url": { "raw": "https://api.example.com/ws-1", "protocol": "https", "host": ["api", "example", "com"], "path": ["ws-1"] } } }, + { "name": "Whitespace Method 2", "request": { "method": " ", "header": [], "url": { "raw": "https://api.example.com/ws-2", "protocol": "https", "host": ["api", "example", "com"], "path": ["ws-2"] } } }, + { "name": "Whitespace Method 3", "request": { "method": " ", "header": [], "url": { "raw": "https://api.example.com/ws-3", "protocol": "https", "host": ["api", "example", "com"], "path": ["ws-3"] } } }, + { "name": "Whitespace Method 4", "request": { "method": " ", "header": [], "url": { "raw": "https://api.example.com/ws-4", "protocol": "https", "host": ["api", "example", "com"], "path": ["ws-4"] } } }, + { "name": "Whitespace Method 5", "request": { "method": " ", "header": [], "url": { "raw": "https://api.example.com/ws-5", "protocol": "https", "host": ["api", "example", "com"], "path": ["ws-5"] } } }, + { "name": "Missing Method 1", "request": { "header": [], "url": { "raw": "https://api.example.com/miss-1", "protocol": "https", "host": ["api", "example", "com"], "path": ["miss-1"] } } }, + { "name": "Missing Method 2", "request": { "header": [], "url": { "raw": "https://api.example.com/miss-2", "protocol": "https", "host": ["api", "example", "com"], "path": ["miss-2"] } } }, + { "name": "Missing Method 3", "request": { "header": [], "url": { "raw": "https://api.example.com/miss-3", "protocol": "https", "host": ["api", "example", "com"], "path": ["miss-3"] } } }, + { "name": "Missing Method 4", "request": { "header": [], "url": { "raw": "https://api.example.com/miss-4", "protocol": "https", "host": ["api", "example", "com"], "path": ["miss-4"] } } }, + { "name": "Missing Method 5", "request": { "header": [], "url": { "raw": "https://api.example.com/miss-5", "protocol": "https", "host": ["api", "example", "com"], "path": ["miss-5"] } } }, + { + "name": "API Folder", + "item": [ + { "name": "Nested Bad 1", "request": { "method": null, "header": [], "url": { "raw": "https://api.example.com/nested-bad-1", "protocol": "https", "host": ["api", "example", "com"], "path": ["nested-bad-1"] } } }, + { "name": "Nested Bad 2", "request": { "method": null, "header": [], "url": { "raw": "https://api.example.com/nested-bad-2", "protocol": "https", "host": ["api", "example", "com"], "path": ["nested-bad-2"] } } }, + { "name": "Nested Bad 3", "request": { "method": null, "header": [], "url": { "raw": "https://api.example.com/nested-bad-3", "protocol": "https", "host": ["api", "example", "com"], "path": ["nested-bad-3"] } } }, + { "name": "Nested Bad 4", "request": { "method": null, "header": [], "url": { "raw": "https://api.example.com/nested-bad-4", "protocol": "https", "host": ["api", "example", "com"], "path": ["nested-bad-4"] } } }, + { "name": "Nested Bad 5", "request": { "method": null, "header": [], "url": { "raw": "https://api.example.com/nested-bad-5", "protocol": "https", "host": ["api", "example", "com"], "path": ["nested-bad-5"] } } } + ] + }, + { + "name": "Valid PUT Request", + "request": { + "method": "PUT", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { "mode": "raw", "raw": "{\"name\": \"updated\"}" }, + "url": { "raw": "https://api.example.com/users/1", "protocol": "https", "host": ["api", "example", "com"], "path": ["users", "1"] } + } + } + ] +} diff --git a/tests/import/postman/import-many-issues-collection.spec.ts b/tests/import/postman/import-many-issues-collection.spec.ts new file mode 100644 index 000000000..14cd8e957 --- /dev/null +++ b/tests/import/postman/import-many-issues-collection.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from '../../../playwright'; +import * as path from 'path'; +import { closeAllCollections, dismissImportIssuesToasts, importCollection } from '../../utils/page'; +import { buildCommonLocators } from '../../utils/page/locators'; + +test.describe('Import Postman Collection with many issues (URL too long warning)', () => { + test.afterEach(async ({ page }) => { + await dismissImportIssuesToasts(page); + await closeAllCollections(page); + }); + + test('should show URL-too-long warning when include failed request data is checked', async ({ page, createTmpDir }) => { + const postmanFile = path.resolve(__dirname, 'fixtures', 'postman-with-many-import-issues.json'); + const tmpDir = await createTmpDir('postman-many-issues'); + const locators = buildCommonLocators(page); + + await importCollection(page, postmanFile, tmpDir, { + expectedCollectionName: 'Many Import Issues Collection', + expectIssues: true + }); + + await test.step('Verify toast title and action buttons are visible', async () => { + await expect(locators.import.issuesToastTitle()).toBeVisible(); + await expect(locators.import.issuesToastTitle()).toContainText('item(s) skipped'); + await expect(locators.import.issuesToastCopyBtn()).toBeVisible(); + await expect(locators.import.issuesToastReportBtn()).toBeVisible(); + }); + + await test.step('Check include failed request data checkbox', async () => { + const checkbox = locators.import.issuesToastIncludeItemsCheckbox(); + await expect(checkbox).toBeVisible(); + await checkbox.check(); + await expect(checkbox).toBeChecked(); + }); + + await test.step('Verify URL-too-long warning appears after checking include items', async () => { + const warning = locators.import.issuesToastUrlTooLongWarning(); + await expect(warning).toBeVisible(); + await expect(warning).toContainText('clipboard'); + }); + + await test.step('Verify valid requests were imported', async () => { + await expect(locators.sidebar.request('Valid GET Request')).toBeVisible(); + await expect(locators.sidebar.request('Valid POST Request')).toBeVisible(); + await expect(locators.sidebar.request('Valid PUT Request')).toBeVisible(); + }); + + await test.step('Report on GitHub copies to clipboard and opens URL without body', async () => { + await page.evaluate(() => { + (window as any).__capturedOpenUrl = null; + window.open = (url?: string | URL) => { + (window as any).__capturedOpenUrl = url != null ? String(url) : ''; + return null; + }; + }); + + await locators.import.issuesToastReportBtn().click(); + + // Should show clipboard success toast + await expect(page.getByText('Issue details copied')).toBeVisible({ timeout: 3000 }); + + // URL should have title but NOT body (since it was too long) + const openedUrl = await page.evaluate(() => (window as any).__capturedOpenUrl as string); + expect(openedUrl).toContain('https://github.com/usebruno/bruno/issues/new'); + expect(openedUrl).toContain('title='); + expect(openedUrl).toContain('labels=bug'); + expect(openedUrl).not.toContain('Missing+or+invalid+request+method'); + }); + }); +}); diff --git a/tests/import/postman/import-partial-collection.spec.ts b/tests/import/postman/import-partial-collection.spec.ts new file mode 100644 index 000000000..fb0c08d20 --- /dev/null +++ b/tests/import/postman/import-partial-collection.spec.ts @@ -0,0 +1,118 @@ +import { test, expect } from '../../../playwright'; +import * as path from 'path'; +import { closeAllCollections, dismissImportIssuesToasts, importCollection } from '../../utils/page'; +import { buildCommonLocators } from '../../utils/page/locators'; + +test.describe('Import Postman Collection with partial import issues', () => { + test.afterEach(async ({ page }) => { + await dismissImportIssuesToasts(page); + await closeAllCollections(page); + }); + + test('should import valid requests and show issues toast for skipped items', async ({ page, createTmpDir }) => { + const postmanFile = path.resolve(__dirname, 'fixtures', 'postman-with-import-issues.json'); + const tmpDir = await createTmpDir('postman-partial-import'); + const locators = buildCommonLocators(page); + + await importCollection(page, postmanFile, tmpDir, { + expectedCollectionName: 'Import Issues Test Collection', + expectIssues: true + }); + + await test.step('Verify import issues toast content', async () => { + const toastTitle = locators.import.issuesToastTitle(); + await expect(toastTitle).toBeVisible(); + await expect(toastTitle).toContainText('item(s) skipped'); + }); + + await test.step('Verify toast action buttons are visible', async () => { + await expect(locators.import.issuesToastCopyBtn()).toBeVisible(); + await expect(locators.import.issuesToastReportBtn()).toBeVisible(); + }); + + await test.step('Verify include items checkbox is visible', async () => { + await expect(locators.import.issuesToastIncludeItemsCheckbox()).toBeVisible(); + }); + + await test.step('Verify valid top-level requests were imported', async () => { + await expect(locators.sidebar.request('Valid GET Request')).toBeVisible(); + await expect(locators.sidebar.request('Valid POST Request')).toBeVisible(); + await expect(locators.sidebar.request('Valid PUT Request')).toBeVisible(); + await expect(locators.sidebar.request('Valid PATCH Request')).toBeVisible(); + }); + + await test.step('Verify skipped requests are NOT in the sidebar', async () => { + await expect(locators.sidebar.request('Missing Method (null)')).not.toBeVisible(); + await expect(locators.sidebar.request('Missing Method (absent)')).not.toBeVisible(); + await expect(locators.sidebar.request('Empty String Method')).not.toBeVisible(); + await expect(locators.sidebar.request('Whitespace-Only Method')).not.toBeVisible(); + }); + + await test.step('Verify folder and nested valid requests were imported', async () => { + const folder = locators.sidebar.folder('API Folder'); + await expect(folder).toBeVisible(); + await folder.click(); + + await expect(locators.sidebar.request('Valid Nested GET')).toBeVisible(); + + const subfolder = locators.sidebar.folder('Deep Subfolder'); + await expect(subfolder).toBeVisible(); + await subfolder.click(); + + await expect(locators.sidebar.request('Deep Valid Request')).toBeVisible(); + }); + + await test.step('Verify nested skipped requests are NOT in the sidebar', async () => { + await expect(locators.sidebar.request('Nested Missing Method')).not.toBeVisible(); + await expect(locators.sidebar.request('Deep Bad Method')).not.toBeVisible(); + }); + }); + + test('should allow copying import issues to clipboard', async ({ page, createTmpDir }) => { + const postmanFile = path.resolve(__dirname, 'fixtures', 'postman-with-import-issues.json'); + const tmpDir = await createTmpDir('postman-partial-import-copy'); + const locators = buildCommonLocators(page); + + await importCollection(page, postmanFile, tmpDir, { + expectedCollectionName: 'Import Issues Test Collection', + expectIssues: true + }); + + await test.step('Click copy button and verify success toast', async () => { + await locators.import.issuesToastCopyBtn().click(); + await expect(page.getByText('Copied to clipboard')).toBeVisible({ timeout: 3000 }); + }); + }); + + test('should open GitHub issue with prefilled details when clicking Report on GitHub', async ({ page, createTmpDir }) => { + const postmanFile = path.resolve(__dirname, 'fixtures', 'postman-with-import-issues.json'); + const tmpDir = await createTmpDir('postman-partial-import-report'); + const locators = buildCommonLocators(page); + + await importCollection(page, postmanFile, tmpDir, { + expectedCollectionName: 'Import Issues Test Collection', + expectIssues: true + }); + + await test.step('Mock window.open and click Report on GitHub', async () => { + // Mock window.open to capture the URL instead of opening a browser + await page.evaluate(() => { + (window as any).__capturedOpenUrl = null; + window.open = (url?: string | URL) => { + (window as any).__capturedOpenUrl = url != null ? String(url) : ''; + return null; + }; + }); + + await locators.import.issuesToastReportBtn().click(); + + const openedUrl = await page.evaluate(() => (window as any).__capturedOpenUrl as string); + + expect(openedUrl).toContain('https://github.com/usebruno/bruno/issues/new'); + expect(openedUrl).toContain('title='); + expect(openedUrl).toContain('Postman+import'); + expect(openedUrl).toContain('labels=bug'); + expect(openedUrl).toContain('Missing+or+invalid+request+method'); + }); + }); +}); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 11f14cb20..f3605d979 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -18,6 +18,22 @@ const waitForReadyPage = ( options: WaitForAppReadyOptions = {} ) => waitForReadyPageImpl(app, options); +/** + * Dismiss all import issues toasts (they use infinite duration and persist across tests). + * @param page - The page object + * @returns void + */ +const dismissImportIssuesToasts = async (page: Page) => { + await test.step('Dismiss import issues toasts', async () => { + const toasts = page.getByTestId('import-issues-toast'); + while (await toasts.count() > 0) { + const toast = toasts.first(); + await toast.getByTestId('import-issues-toast-close').click(); + await expect(toast).not.toBeVisible({ timeout: 5000 }); + } + }); +}; + /** * Close all collections * @param page - The page object @@ -428,6 +444,7 @@ const deleteCollectionFromOverview = async (page: Page, collectionName: string) */ type ImportCollectionOptions = { expectedCollectionName?: string; + expectIssues?: boolean; }; const importCollection = async ( @@ -471,6 +488,11 @@ const importCollection = async ( ).toBeVisible(); } + // Wait for import issues toast if expected + if (options.expectIssues) { + await expect(locators.import.issuesToast()).toBeVisible({ timeout: 10000 }); + } + if (options.expectedCollectionName) { await openCollection(page, options.expectedCollectionName); } @@ -1546,6 +1568,7 @@ const openWorkspaceFromDialog = async (app: any, page: any, targetPath: string) export { waitForReadyPage, + dismissImportIssuesToasts, closeAllCollections, openCollection, createCollection, diff --git a/tests/utils/page/locators.ts b/tests/utils/page/locators.ts index 4dda96a4f..7a2d4c4c1 100644 --- a/tests/utils/page/locators.ts +++ b/tests/utils/page/locators.ts @@ -129,7 +129,19 @@ export const buildCommonLocators = (page: Page) => ({ envOption: (name: string) => page.locator('.dropdown-item').getByText(name, { exact: true }), parsingError: () => page.getByTestId('import-error-message'), browseLink: (root?: Locator) => (root ?? page).getByTestId('import-collection-browse-link'), - importButton: (root?: Locator) => (root ?? page).getByTestId('import-collection-location-modal-submit-btn') + importButton: (root?: Locator) => (root ?? page).getByTestId('import-collection-location-modal-submit-btn'), + ...(() => { + const issuesToast = () => page.getByTestId('import-issues-toast').last(); + return { + issuesToast, + issuesToastTitle: () => issuesToast().getByTestId('import-issues-toast-title'), + issuesToastCopyBtn: () => issuesToast().getByTestId('import-issues-copy-btn'), + issuesToastReportBtn: () => issuesToast().getByTestId('import-issues-report-btn'), + issuesToastIncludeItemsCheckbox: () => issuesToast().getByTestId('import-issues-include-items-checkbox'), + issuesToastCloseBtn: () => issuesToast().getByTestId('import-issues-toast-close'), + issuesToastUrlTooLongWarning: () => issuesToast().getByTestId('import-issues-url-too-long-warning') + }; + })() }, /** * Build generic table locators for any table with a testId