From a798b32f25953834410422803cbf3d2290d61e48 Mon Sep 17 00:00:00 2001 From: Abhishek S Lal Date: Tue, 9 Dec 2025 23:45:01 +0530 Subject: [PATCH] feat: add response data type selector in response viewer (#6100) * feat: add response data type selector in response viewer * chore: fixed lint issue * test: add test for resonse format change and preview. * refactor: streamline response format tests with utility functions for navigation and format switching * refactor: simplify ButtonDropdown component and enhance QueryResultTypeSelector with header and toggle switch * feat: enhance ButtonDropdown with prefix and suffix props; implement content type detection and update QueryResult for improved format handling * fix: lint errors resolved * fix: remove unnecessary blank line to resolve lint issues * fix: update response format tests * refactor: remove preview tab locator from response format tests * fix: update dependency in useEffect to include previewFormatOptions for accurate format handling * refactor: reorganize imports and enhance QueryResult component for improved format handling and error display * fix: update error messages in response format preview tests and adjust version in JSON fixture * feat: add drag detection to HtmlPreview component and update structure for improved user interaction * refactor: update ResponsePane components for improved structure and functionality; replace QueryResult with QueryResponse, enhance layout handling, and streamline response actions * refactor: remove ButtonDropdown component and associated styles; * refactor: moved ErrorAlert to ui folder * fix: lint error * feat: add data-testid attributes to Collection and CollectionItem components for improved testability * feat: hide dropdown on select in response selector * fix: update QueryResult component to use detectedContentType for format handling * test: update ResponseLayoutToggle tests to use data-testid for button selection * feat: add data-testid attribute to ResponseClear component for improved testability * refactor: implement clickResponseAction utility for streamlined response action handling in tests * feat: add data-testid attribute to ResponseCopy component for enhanced testability * fix: unwanted code in test --- .../RequestDetailsPanel/StyledWrapper.js | 19 +- .../Console/RequestDetailsPanel/index.js | 4 +- .../ResponsePane/ClearTimeline/index.js | 2 +- .../ResponsePane/GrpcResponsePane/index.js | 1 - .../QueryResponse/StyledWrapper.js | 16 + .../ResponsePane/QueryResponse/index.js | 60 +++ .../QueryResultPreview/HtmlPreview.js | 78 ++++ .../QueryResultPreview/JsonPreview.js | 63 +++ .../QueryResultPreview/TextPreview.js | 25 ++ .../QueryResultPreview/VideoPreview.js | 31 ++ .../XmlPreview/StyledWrapper.js | 77 ++++ .../QueryResultPreview/XmlPreview/index.js | 396 ++++++++++++++++++ .../QueryResult/QueryResultPreview/index.js | 115 +++-- .../QueryResultTypeSelector/StyledWrapper.js | 13 + .../QueryResultTypeSelector/index.jsx | 46 ++ .../ResponsePane/QueryResult/StyledWrapper.js | 5 +- .../ResponsePane/QueryResult/index.js | 225 ++++++---- .../ResponsePane/ResponseActions/index.js | 4 +- .../ResponseBookmark/StyledWrapper.js | 7 + .../ResponsePane/ResponseBookmark/index.js | 65 ++- .../ResponseClear/StyledWrapper.js | 8 +- .../ResponsePane/ResponseClear/index.js | 38 +- .../ResponseCopy/StyledWrapper.js | 8 +- .../ResponsePane/ResponseCopy/index.js | 44 +- .../ResponseDownload/StyledWrapper.js | 14 + .../ResponsePane/ResponseDownload/index.js | 63 +++ .../ResponseLayoutToggle/StyledWrapper.js | 8 +- .../ResponseLayoutToggle/index.js | 58 ++- .../ResponseLayoutToggle/index.spec.js | 8 +- .../ResponsePaneActions/StyledWrapper.js | 7 + .../ResponsePane/ResponsePaneActions/index.js | 188 +++++++++ .../ResponseSave/StyledWrapper.js | 8 - .../ResponsePane/ResponseSave/index.js | 47 --- .../ResponsePane/ResponseSize/index.js | 2 +- .../ResponseStopWatch/StyledWrapper.js | 2 +- .../ResponsePane/ResponseStopWatch/index.js | 2 +- .../ResponseTime/StyledWrapper.js | 2 +- .../ResponsePane/ResponseTime/index.js | 2 +- .../ResponsePane/StatusCode/StyledWrapper.js | 3 +- .../ResponsePane/StatusCode/index.js | 2 +- .../components/ResponsePane/StyledWrapper.js | 6 + .../TimelineItem/Common/Body/index.js | 4 +- .../src/components/ResponsePane/index.js | 69 ++- .../RunnerResults/ResponsePane/index.js | 4 +- .../Collection/CollectionItem/index.js | 1 + .../Sidebar/Collections/Collection/index.js | 1 + .../src/ui/ButtonDropdown/StyledWrapper.js | 24 ++ .../bruno-app/src/ui/ButtonDropdown/index.jsx | 146 +++++++ .../src/ui/ErrorAlert/StyledWrapper.js | 44 ++ packages/bruno-app/src/ui/ErrorAlert/index.js | 25 ++ .../bruno-app/src/utils/common/codemirror.js | 2 + packages/bruno-app/src/utils/common/index.js | 198 +++++++++ .../bruno-app/src/utils/response/index.js | 260 ++++++++++++ .../response-examples/create-example.spec.ts | 13 +- tests/response-examples/edit-example.spec.ts | 11 +- .../response-examples/menu-operations.spec.ts | 7 +- .../large-response-crash-prevention.spec.ts | 2 +- tests/response/response-actions.spec.ts | 4 +- .../fixtures/collection/bruno.json | 9 + .../fixtures/collection/request-html.bru | 23 + .../fixtures/collection/request-json.bru | 17 + .../init-user-data/collection-security.json | 10 + .../init-user-data/preferences.json | 6 + ...response-format-select-and-preview.spec.ts | 161 +++++++ tests/utils/page/actions.ts | 104 ++++- tests/utils/page/locators.ts | 14 +- 66 files changed, 2590 insertions(+), 341 deletions(-) create mode 100644 packages/bruno-app/src/components/ResponsePane/QueryResponse/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/QueryResponse/index.js create mode 100644 packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/HtmlPreview.js create mode 100644 packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/JsonPreview.js create mode 100644 packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/TextPreview.js create mode 100644 packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/VideoPreview.js create mode 100644 packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/XmlPreview/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/XmlPreview/index.js create mode 100644 packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultTypeSelector/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultTypeSelector/index.jsx create mode 100644 packages/bruno-app/src/components/ResponsePane/ResponseDownload/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/ResponseDownload/index.js create mode 100644 packages/bruno-app/src/components/ResponsePane/ResponsePaneActions/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/ResponsePaneActions/index.js delete mode 100644 packages/bruno-app/src/components/ResponsePane/ResponseSave/StyledWrapper.js delete mode 100644 packages/bruno-app/src/components/ResponsePane/ResponseSave/index.js create mode 100644 packages/bruno-app/src/ui/ButtonDropdown/StyledWrapper.js create mode 100644 packages/bruno-app/src/ui/ButtonDropdown/index.jsx create mode 100644 packages/bruno-app/src/ui/ErrorAlert/StyledWrapper.js create mode 100644 packages/bruno-app/src/ui/ErrorAlert/index.js create mode 100644 packages/bruno-app/src/utils/response/index.js create mode 100644 tests/response/response-format-select-and-preview/fixtures/collection/bruno.json create mode 100644 tests/response/response-format-select-and-preview/fixtures/collection/request-html.bru create mode 100644 tests/response/response-format-select-and-preview/fixtures/collection/request-json.bru create mode 100644 tests/response/response-format-select-and-preview/init-user-data/collection-security.json create mode 100644 tests/response/response-format-select-and-preview/init-user-data/preferences.json create mode 100644 tests/response/response-format-select-and-preview/response-format-select-and-preview.spec.ts diff --git a/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/StyledWrapper.js b/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/StyledWrapper.js index b03b1bd3e..5d66cf493 100644 --- a/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/StyledWrapper.js +++ b/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/StyledWrapper.js @@ -168,7 +168,7 @@ const StyledWrapper = styled.div` position: sticky; top: 0; z-index: 10; - + td { padding: 8px 12px; font-weight: 500; @@ -256,10 +256,8 @@ const StyledWrapper = styled.div` } .response-body-container { - border: 1px solid ${(props) => props.theme.console.border}; border-radius: 4px; overflow: hidden; - background: ${(props) => props.theme.console.headerBg}; height: 400px; display: flex; flex-direction: column; @@ -267,13 +265,11 @@ const StyledWrapper = styled.div` .w-full.h-full.relative.flex { height: 100% !important; width: 100% !important; - background: ${(props) => props.theme.console.headerBg} !important; display: flex !important; flex-direction: column !important; } div[role="tablist"] { - background: ${(props) => props.theme.console.dropdownHeaderBg}; padding: 8px 12px; border-bottom: 1px solid ${(props) => props.theme.console.border}; display: flex !important; @@ -282,28 +278,17 @@ const StyledWrapper = styled.div` align-items: center !important; min-height: 40px !important; flex-shrink: 0 !important; - + > div { color: ${(props) => props.theme.console.buttonColor}; font-size: ${(props) => props.theme.font.size.sm} !important; - padding: 6px 12px !important; - border-radius: 4px; - transition: all 0.2s ease; cursor: pointer; - border: 1px solid ${(props) => props.theme.console.border}; - background: ${(props) => props.theme.console.contentBg}; white-space: nowrap !important; min-width: auto !important; height: auto !important; line-height: 1.2 !important; font-weight: 500 !important; - &:hover { - background: ${(props) => props.theme.console.buttonHoverBg}; - color: ${(props) => props.theme.console.buttonHoverColor}; - border-color: ${(props) => props.theme.console.buttonHoverBg}; - } - &.active { background: ${(props) => props.theme.console.checkboxColor}; color: white; diff --git a/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/index.js b/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/index.js index f6f45569c..077c319dc 100644 --- a/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/index.js +++ b/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/index.js @@ -7,7 +7,7 @@ import { IconNetwork } from '@tabler/icons'; import { clearSelectedRequest } from 'providers/ReduxStore/slices/logs'; -import QueryResult from 'components/ResponsePane/QueryResult'; +import QueryResponse from 'components/ResponsePane/QueryResponse/index'; import Network from 'components/ResponsePane/Timeline/TimelineItem/Network'; import StyledWrapper from './StyledWrapper'; import { uuid } from 'utils/common/index'; @@ -116,7 +116,7 @@ const ResponseTab = ({ response, request, collection }) => {

Response Body

{response?.data || response?.dataBuffer ? ( - { ); return ( - + diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/index.js index b0dd18c09..dd45e355a 100644 --- a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/index.js +++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/index.js @@ -10,7 +10,6 @@ import GrpcStatusCode from './GrpcStatusCode'; import ResponseTime from '../ResponseTime/index'; import Timeline from '../Timeline'; import ClearTimeline from '../ClearTimeline'; -import ResponseSave from '../ResponseSave'; import ResponseClear from '../ResponseClear'; import StyledWrapper from './StyledWrapper'; import ResponseTrailers from './ResponseTrailers'; diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResponse/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/QueryResponse/StyledWrapper.js new file mode 100644 index 000000000..4c68464eb --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/QueryResponse/StyledWrapper.js @@ -0,0 +1,16 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + border-radius: 4px; + border: 1px solid ${(props) => props.theme.console.border}; + + .query-response-content { + border-top: 1px solid ${(props) => props.theme.console.border}; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResponse/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResponse/index.js new file mode 100644 index 000000000..283f98228 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/QueryResponse/index.js @@ -0,0 +1,60 @@ +import React, { useEffect, useState } from 'react'; +import QueryResult from '../QueryResult'; +import { useInitialResponseFormat, useResponsePreviewFormatOptions } from '../QueryResult/index'; +import QueryResultTypeSelector from '../QueryResult/QueryResultTypeSelector/index'; +import StyledWrapper from './StyledWrapper'; +import classnames from 'classnames'; + +const QueryResponse = ({ + item, + collection, + data, + dataBuffer, + disableRunEventListener, + headers, + error +}) => { + const { initialFormat, initialTab } = useInitialResponseFormat(dataBuffer, headers); + const previewFormatOptions = useResponsePreviewFormatOptions(dataBuffer, headers); + const [selectedFormat, setSelectedFormat] = useState('raw'); + const [selectedTab, setSelectedTab] = useState('editor'); + + useEffect(() => { + if (initialFormat !== null && initialTab !== null) { + setSelectedFormat(initialFormat); + setSelectedTab(initialTab); + } + }, [initialFormat, initialTab]); + return ( + +
+ { + setSelectedFormat(newFormat); + }} + onPreviewTabSelect={() => { + setSelectedTab((prev) => prev === 'editor' ? 'preview' : 'editor'); + }} + selectedTab={selectedTab} + /> +
+
+ +
+
+ ); +}; + +export default QueryResponse; diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/HtmlPreview.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/HtmlPreview.js new file mode 100644 index 000000000..03247b6fb --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/HtmlPreview.js @@ -0,0 +1,78 @@ +import React, { useRef, useState, useEffect } from 'react'; +import { isValidHtml } from 'utils/common/index'; +import { escapeHtml, isValidHtmlSnippet } from 'utils/response/index'; + +const HtmlPreview = React.memo(({ data, baseUrl }) => { + const webviewContainerRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + if (!webviewContainerRef.current) return; + + const checkDragging = () => { + const hasDraggingParent = webviewContainerRef.current?.closest('.dragging'); + setIsDragging(!!hasDraggingParent); + }; + + // Watch from a common ancestor where .dragging gets added + const watchTarget = webviewContainerRef.current.closest('.main-section') + || document.body; + + const mutationObserver = new MutationObserver(checkDragging); + mutationObserver.observe(watchTarget, { + attributes: true, + attributeFilter: ['class'], + subtree: true + }); + + // Check initial state + checkDragging(); + + return () => mutationObserver.disconnect(); + }, []); + + if (isValidHtml(data) || isValidHtmlSnippet(data)) { + const htmlContent = data.includes('') + ? data.replace('', ``) + : `${data}`; + + const dragStyles = isDragging ? { pointerEvents: 'none', userSelect: 'none' } : {}; + + return ( +
+ +
+ ); + } + + // For all other data types, render safely as formatted text + let displayContent = ''; + if (data === null || data === undefined) { + displayContent = String(data); + } else if (typeof data === 'object') { + displayContent = JSON.stringify(data, null); + } else if (typeof data === 'string') { + displayContent = data; + } else { + displayContent = String(data); + } + + return ( +
+      {displayContent}
+    
+ ); +}); + +export default HtmlPreview; diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/JsonPreview.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/JsonPreview.js new file mode 100644 index 000000000..2d24abcb0 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/JsonPreview.js @@ -0,0 +1,63 @@ +import React from 'react'; +import ReactJson from 'react-json-view'; +import ErrorAlert from 'ui/ErrorAlert/index'; + +const JsonPreview = ({ data, displayedTheme }) => { + // Helper function to validate and parse JSON data + const validateJsonData = (data) => { + // If data is already an object or array, use it directly + if (typeof data === 'object' && data !== null) { + return { data, error: null }; + } + + // If data is a string, try to parse it + if (typeof data === 'string') { + try { + const parsed = JSON.parse(data); + return { data: parsed, error: null }; + } catch (e) { + return { data: null, error: `Invalid JSON format: ${e.message}` }; + } + } + + // For other types, return error + return { data: null, error: 'Invalid input. Expected a JSON object, array, or valid JSON string.' }; + }; + + // Validate and parse JSON data + const jsonData = validateJsonData(data); + + // Show error if parsing failed + if (jsonData.error) { + return ; + } + + // Validate that data can be rendered as JSON tree + if (jsonData.data === null || jsonData.data === undefined) { + return ; + } + + if (typeof jsonData.data !== 'object') { + return ; + } + + return ( + + ); +}; + +export default JsonPreview; diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/TextPreview.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/TextPreview.js new file mode 100644 index 000000000..3c82d32e7 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/TextPreview.js @@ -0,0 +1,25 @@ +import React, { memo, useMemo } from 'react'; + +const TextPreview = memo(({ data }) => { + const displayData = useMemo(() => { + if (data === null || data === undefined) { + return String(data); + } + if (typeof data === 'object') { + try { + return JSON.stringify(data); + } catch { + return String(data); + } + } + return String(data); + }, [data]); + + return ( +
+ {displayData} +
+ ); +}); + +export default TextPreview; diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/VideoPreview.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/VideoPreview.js new file mode 100644 index 000000000..bdaec70e3 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/VideoPreview.js @@ -0,0 +1,31 @@ +import React from 'react'; +import { useEffect, useState } from 'react'; +import ReactPlayer from 'react-player'; + +const VideoPreview = React.memo(({ contentType, dataBuffer }) => { + const [videoUrl, setVideoUrl] = useState(null); + + useEffect(() => { + const videoType = contentType.split(';')[0]; + const byteArray = Buffer.from(dataBuffer, 'base64'); + const blob = new Blob([byteArray], { type: videoType }); + const url = URL.createObjectURL(blob); + setVideoUrl(url); + return () => URL.revokeObjectURL(url); + }, [contentType, dataBuffer]); + + if (!videoUrl) return
Loading video...
; + + return ( + console.error('Error loading video:', e)} + /> + ); +}); + +export default VideoPreview; diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/XmlPreview/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/XmlPreview/StyledWrapper.js new file mode 100644 index 000000000..0b0c652de --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/XmlPreview/StyledWrapper.js @@ -0,0 +1,77 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + font-size: 12px; + line-height: 20px; + padding: 16px; + overflow: auto; + color: ${(props) => props.theme.text}; + + .xml-container { + color: ${(props) => props.theme.text}; + } + + .xml-node-name { + color: ${(props) => props.theme.codemirror.tokens.property}; + font-weight: 500; + } + + .xml-separator { + color: ${(props) => props.theme.codemirror.tokens.operator}; + margin: 0 8px; + } + + .xml-value { + color: ${(props) => props.theme.codemirror.tokens.string}; + white-space: pre-wrap; + word-break: break-all; + } + + .xml-empty-value { + color: ${(props) => props.theme.codemirror.tokens.comment}; + } + + .xml-count { + color: ${(props) => props.theme.codemirror.tokens.comment}; + margin-left: 8px; + } + + .xml-toggle-button { + margin-right: 8px; + cursor: pointer; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + color: ${(props) => props.theme.codemirror.tokens.atom}; + flex-shrink: 0; + border-radius: 4px; + transition: background-color 0.2s; + + &:hover { + background-color: ${(props) => props.theme.console.buttonHoverBg}; + } + } + + .xml-array-toggle-button { + margin-right: 8px; + cursor: pointer; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + color: ${(props) => props.theme.codemirror.tokens.atom}; + flex-shrink: 0; + border-radius: 4px; + transition: background-color 0.2s; + + &:hover { + background-color: ${(props) => props.theme.console.buttonHoverBg}; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/XmlPreview/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/XmlPreview/index.js new file mode 100644 index 000000000..41c0ac0a3 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/XmlPreview/index.js @@ -0,0 +1,396 @@ +import ErrorAlert from 'ui/ErrorAlert/index'; +import React, { useState, useMemo } from 'react'; +import StyledWrapper from './StyledWrapper'; + +// The expected "data" prop must be an XML string. +export default function XmlPreview({ data, defaultExpanded = true }) { + // Parse XML string + const parsedData = useMemo(() => { + if (typeof data !== 'string') { + return { error: 'Invalid input. Expected an XML string.' }; + } + + const parsed = parseXMLString(data); + if (parsed === null) { + return { error: 'Failed to parse XML string. Invalid XML format.' }; + } + return parsed; + }, [data]); + + // Check for parsing error + if (parsedData && typeof parsedData === 'object' && parsedData.error) { + return ( + + ); + } + + // Validate that data can be rendered as a tree + const isValidTreeData = (data) => { + if (data === null || data === undefined) return false; + if (typeof data === 'object' && !Array.isArray(data)) return true; + if (Array.isArray(data)) return true; + return false; + }; + + if (!isValidTreeData(parsedData)) { + return ( + + ); + } + + // If root is an object with a single key, unwrap it to show the actual root element + let rootNode = parsedData; + let rootNodeName = ''; + + if (typeof parsedData === 'object' && !Array.isArray(parsedData) && parsedData !== null) { + const keys = Object.keys(parsedData).filter((k) => k !== '$' && k !== '@_' && k !== '#text'); + if (keys.length === 1) { + rootNodeName = keys[0]; + rootNode = parsedData[keys[0]]; + } else if (keys.length === 0) { + // Empty object with no children + return ( + + ); + } + } + + return ( + +
+ +
+
+ ); +} + +// Component for rendering array entries with expand/collapse functionality +const XmlArrayNode = ({ arrayKey, items, depth, defaultExpanded = true }) => { + const [expanded, setExpanded] = useState(defaultExpanded); + + const toggle = (e) => { + e.stopPropagation(); + setExpanded((v) => !v); + }; + + return ( +
+
+ + {arrayKey} + [{items.length}] +
+ {expanded && ( +
+ {items.map((item, itemIdx) => ( + + ))} +
+ )} +
+ ); +}; + +const XmlNode = ({ + node, + nodeName = '', + isRoot = false, + isLast = true, + defaultExpanded = true, + depth = 0 +}) => { + const [expanded, setExpanded] = useState(defaultExpanded); + + let displayNodeName = nodeName; + + if (Array.isArray(node)) { + // For repeated XML elements with same name (e.g. ......) + return ( + <> + {node.map((item, idx) => ( + + ))} + + ); + } + + const childEntries = getChildrenEntries(node); + const childCount = getChildCount(node); + const isLeaf = isTextNode(node) || (typeof node === 'object' && childCount === 0); + + const toggle = (e) => { + e.stopPropagation(); + setExpanded((v) => !v); + }; + + // For leaf nodes with text content or attributes with empty values + if (isLeaf && isTextNode(node)) { + const value = String(node); + + return ( +
+ {displayNodeName && ( + <> + {displayNodeName} + : + + )} + {value} +
+ ); + } + + // For empty leaf nodes (attributes without values, etc) + if (isLeaf && !isTextNode(node)) { + // Check if this is an attribute-only node with _text + if (typeof node === 'object' && node !== null && '_text' in node) { + // This node has both attributes and text, handle in expandable section + // Fall through to expandable node rendering + } else { + return ( +
+ {displayNodeName && ( + <> + {displayNodeName} + : + {'{}'} + + )} +
+ ); + } + } + + // For expandable nodes - show as tree structure + // If no node name at root level, render children directly + if (!displayNodeName && depth === 0) { + if (childEntries.length > 0) { + return ( +
+ {childEntries.map(([key, value], idx) => ( + + ))} +
+ ); + } + return null; + } + + // If no display name at non-root level, use a fallback + if (!displayNodeName) { + displayNodeName = '(unnamed)'; + } + + // Determine if this node's value is an array + const hasArrayValue = Array.isArray(node); + const arrayLength = hasArrayValue ? node.length : 0; + + return ( +
+
+ + + + {displayNodeName} + + + {childCount > 0 && ( + + {`{${childCount}}`} + + )} +
+ + {expanded && childEntries.length > 0 && ( +
+ {childEntries.map(([key, value], idx) => { + // Check if this is an attribute (starts with _) + const isAttribute = key.startsWith('_'); + + // Handle attributes + if (isAttribute) { + const displayValue = value === '' ? 'value' : value; + + return ( +
+ {key} + : + {displayValue} +
+ ); + } + + // Check if this child is an array + const isArrayChild = Array.isArray(value); + + if (isArrayChild) { + return ( + + ); + } + + return ( + + ); + })} +
+ )} +
+ ); +}; + +// Helper function to parse XML string to object +function parseXMLString(xmlString) { + if (typeof xmlString !== 'string') return null; + + try { + const parser = new DOMParser(); + // Parse as XML only + const xmlDoc = parser.parseFromString(xmlString, 'text/xml'); + + // Check for parsing errors + const parserError = xmlDoc.querySelector('parsererror'); + if (parserError) { + return null; + } + + // Convert XML DOM to object + function xmlToObject(node) { + if (node.nodeType !== 1) return null; // Not an element node + + const result = {}; + + // Get attributes - store them directly with underscore prefix + if (node.attributes && node.attributes.length > 0) { + for (let i = 0; i < node.attributes.length; i++) { + const attr = node.attributes[i]; + result[`_${attr.name}`] = attr.value || ''; + } + } + + // Get child nodes + const childNodes = Array.from(node.childNodes); + const elementChildren = childNodes.filter((child) => child.nodeType === 1); + const textChildren = childNodes.filter((child) => child.nodeType === 3 && child.textContent.trim()); + + // If only text children and no element children, return text content + if (elementChildren.length === 0 && textChildren.length > 0) { + const textContent = textChildren.map((t) => t.textContent.trim()).join(' ').trim(); + // If has attributes, store text as a special property + if (Object.keys(result).length > 0) { + result['_text'] = textContent; + return result; + } + return textContent || null; + } + + // Process element children + if (elementChildren.length > 0) { + const childMap = {}; + elementChildren.forEach((child) => { + const childName = child.nodeName; // Preserve original casing + const childValue = xmlToObject(child); + + if (childValue !== null || elementChildren.filter((c) => c.nodeName.toLowerCase() === childName).length > 1) { + if (childMap[childName]) { + // Multiple children with same name - convert to array + if (!Array.isArray(childMap[childName])) { + childMap[childName] = [childMap[childName]]; + } + childMap[childName].push(childValue); + } else { + childMap[childName] = childValue; + } + } + }); + + // Merge children into result + Object.assign(result, childMap); + } + + return Object.keys(result).length > 0 ? result : null; + } + + const rootElement = xmlDoc.documentElement; + if (!rootElement) return null; + + const parsed = xmlToObject(rootElement); + return parsed ? { [rootElement.nodeName]: parsed } : null; + } catch (error) { + return null; + } +} + +function isTextNode(node) { + return typeof node === 'string' || typeof node === 'number' || node === null; +} + +function getChildrenEntries(node) { + // Given an XML-like JS object, return an array of [key, value] for all properties + // This includes attributes (with _ prefix) and child elements + if (typeof node !== 'object' || node === null) return []; + return Object.entries(node); +} + +function getChildCount(node) { + if (Array.isArray(node)) { + return node.length; + } + const children = getChildrenEntries(node); + return children.length; +} diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js index a97d6a7dd..f1bf190bb 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useMemo } from 'react'; import CodeEditor from 'components/CodeEditor/index'; import { get } from 'lodash'; import find from 'lodash/find'; @@ -11,44 +11,22 @@ import 'react-pdf/dist/esm/Page/AnnotationLayer.css'; import 'react-pdf/dist/esm/Page/TextLayer.css'; import { GlobalWorkerOptions } from 'pdfjs-dist/build/pdf'; GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.min.mjs'; -import ReactPlayer from 'react-player'; - -const VideoPreview = React.memo(({ contentType, dataBuffer }) => { - const [videoUrl, setVideoUrl] = useState(null); - - useEffect(() => { - const videoType = contentType.split(';')[0]; - const byteArray = Buffer.from(dataBuffer, 'base64'); - const blob = new Blob([byteArray], { type: videoType }); - const url = URL.createObjectURL(blob); - setVideoUrl(url); - return () => URL.revokeObjectURL(url); - }, [contentType, dataBuffer]); - - if (!videoUrl) return
Loading video...
; - - return ( - console.error('Error loading video:', e)} - /> - ); -}); +import XmlPreview from './XmlPreview/index'; +import TextPreview from './TextPreview'; +import HtmlPreview from './HtmlPreview'; +import VideoPreview from './VideoPreview'; +import JsonPreview from './JsonPreview'; const QueryResultPreview = ({ - previewTab, - allowedPreviewModes, + selectedTab, data, dataBuffer, formattedData, item, contentType, collection, - mode, + codeMirrorMode, + previewMode, disableRunEventListener, displayedTheme }) => { @@ -63,10 +41,6 @@ const QueryResultPreview = ({ function onDocumentLoadSuccess({ numPages }) { setNumPages(numPages); } - // Fail safe, so we don't render anything with an invalid tab - if (!allowedPreviewModes.find((previewMode) => previewMode?.uid == previewTab?.uid)) { - return null; - } const onRun = () => { if (disableRunEventListener) { @@ -87,19 +61,31 @@ const QueryResultPreview = ({ ); }; - switch (previewTab?.mode) { + if (selectedTab === 'editor') { + return ( + + ); + } + + switch (previewMode) { case 'preview-web': { - const webViewSrc = data.replace('', ``); - return ( - - ); + const baseUrl = item.requestSent?.url || ''; + return ; } case 'preview-image': { - return ; + return ; } case 'preview-pdf': { return ( @@ -120,24 +106,29 @@ const QueryResultPreview = ({ case 'preview-video': { return ; } - default: - case 'raw': { - return ( - - ); + case 'preview-json': { + return ; } + + case 'preview-text': { + return ; + } + + case 'preview-xml': { + return ; + } + + default: + return ( +
+
+ No Preview Available +
+
+ Sorry, no preview is available for this content type. +
+
+ ); } }; diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultTypeSelector/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultTypeSelector/StyledWrapper.js new file mode 100644 index 000000000..c8f42bf0a --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultTypeSelector/StyledWrapper.js @@ -0,0 +1,13 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .active { + color: ${(props) => props.theme.colors.text.yellow}; + } + + .preview-response-tab-label { + color: ${(props) => props.theme.colors.text.muted}; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultTypeSelector/index.jsx b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultTypeSelector/index.jsx new file mode 100644 index 000000000..88b9eba5e --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultTypeSelector/index.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { IconEye } from '@tabler/icons'; +import ButtonDropdown from 'ui/ButtonDropdown'; +import ToggleSwitch from 'components/ToggleSwitch'; +import StyledWrapper from './StyledWrapper'; + +const QueryResultTypeSelector = ({ + formatOptions, + formatValue, + onFormatChange, + onPreviewTabSelect, + selectedTab +}) => { + const header = ( +
+ Preview + { + e.preventDefault(); + // e.stopPropagation(); + onPreviewTabSelect(); + }} + size="2xs" + data-testid="preview-response-tab" + title={selectedTab === 'preview' ? 'Turn off Preview Mode' : 'Turn on Preview Mode'} + /> +
+ ); + return ( + + : null} + /> + + ); +}; + +export default QueryResultTypeSelector; diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/StyledWrapper.js index 5b798a317..5f5652e35 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/StyledWrapper.js @@ -1,9 +1,8 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - display: grid; - grid-template-columns: 100%; - grid-template-rows: 1.25rem 1fr; + display: flex; + flex-direction: column; /* This is a hack to force Codemirror to use all available space */ > div { diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js index 0917d7b86..038205ed1 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js @@ -1,15 +1,34 @@ import { debounce } from 'lodash'; +import { useTheme } from 'providers/Theme/index'; +import React, { useMemo, useState } from 'react'; +import { formatResponse, getContentType } from 'utils/common'; +import { getEncoding } from 'utils/common/index'; +import { getDefaultResponseFormat } from 'utils/response'; +import LargeResponseWarning from '../LargeResponseWarning'; import QueryResultFilter from './QueryResultFilter'; -import React from 'react'; -import classnames from 'classnames'; -import { getContentType, formatResponse } from 'utils/common'; -import { getCodeMirrorModeBasedOnContentType } from 'utils/common/codemirror'; import QueryResultPreview from './QueryResultPreview'; import StyledWrapper from './StyledWrapper'; -import { useState, useMemo, useEffect } from 'react'; -import { useTheme } from 'providers/Theme/index'; -import { getEncoding, uuid } from 'utils/common/index'; -import LargeResponseWarning from '../LargeResponseWarning'; +import { detectContentTypeFromBuffer } from 'utils/response/index'; + +const PREVIEW_FORMAT_OPTIONS = [ + { + // name: 'Structured', + options: [ + { label: 'JSON', value: 'json', codeMirrorMode: 'application/ld+json' }, + { label: 'HTML', value: 'html', codeMirrorMode: 'xml' }, + { label: 'XML', value: 'xml', codeMirrorMode: 'xml' }, + { label: 'JavaScript', value: 'javascript', codeMirrorMode: 'javascript' } + ] + }, + { + // name: 'Raw', + options: [ + { label: 'Raw', value: 'raw', codeMirrorMode: 'text/plain' }, + { label: 'Hex', value: 'hex', codeMirrorMode: 'text/plain' }, + { label: 'Base64', value: 'base64', codeMirrorMode: 'text/plain' } + ] + } +]; const formatErrorMessage = (error) => { if (!error) return 'Something went wrong'; @@ -24,9 +43,87 @@ const formatErrorMessage = (error) => { return error; }; -const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListener, headers, error }) => { +// Custom hook to determine the initial format and tab based on the data buffer and headers +export const useInitialResponseFormat = (dataBuffer, headers) => { + return useMemo(() => { + let buffer = null; + try { + buffer = dataBuffer ? Buffer.from(dataBuffer, 'base64') : null; + } catch (error) { + console.error('Error converting dataBuffer to Buffer:', error); + buffer = null; + } + + const detectedContentType = detectContentTypeFromBuffer(buffer); + const contentType = getContentType(headers); + + // Wait until both content types are available + if (detectedContentType === null || contentType === undefined) { + return { initialFormat: null, initialTab: null }; + } + + const initial = getDefaultResponseFormat(contentType); + return { initialFormat: initial.format, initialTab: initial.tab }; + }, [dataBuffer, headers]); +}; + +// Custom hook to determine preview format options based on content type +export const useResponsePreviewFormatOptions = (dataBuffer, headers) => { + return useMemo(() => { + let buffer = null; + try { + buffer = dataBuffer ? Buffer.from(dataBuffer, 'base64') : null; + } catch (error) { + console.error('Error converting dataBuffer to Buffer:', error); + buffer = null; + } + + const detectedContentType = detectContentTypeFromBuffer(buffer); + const contentType = getContentType(headers); + + const byteFormatTypes = ['image', 'video', 'audio', 'pdf', 'zip']; + + const isByteFormatType = (contentType) => { + return byteFormatTypes.some((type) => contentType.includes(type)); + }; + + const getContentTypeToCheck = () => { + if (detectedContentType) { + return detectedContentType; + } + return contentType; + }; + + const contentTypeToCheck = getContentTypeToCheck(); + + if (contentTypeToCheck && isByteFormatType(contentTypeToCheck)) { + return PREVIEW_FORMAT_OPTIONS.slice(1, 2); // Remove structured format options + } + + return PREVIEW_FORMAT_OPTIONS; + }, [dataBuffer, headers]); +}; + +const QueryResult = ({ + item, + collection, + data, + dataBuffer, + disableRunEventListener, + headers, + error, + selectedFormat, // one of the options in PREVIEW_FORMAT_OPTIONS + selectedTab // 'editor' or 'preview' +}) => { + let buffer = null; + try { + buffer = Buffer.from(dataBuffer, 'base64'); // dataBuffer is already a base64 string, convert it to actual Buffer + } catch (error) { + console.error('Error converting dataBuffer to Buffer:', error); + buffer = null; + } + const detectedContentType = detectContentTypeFromBuffer(buffer); const contentType = getContentType(headers); - const mode = getCodeMirrorModeBasedOnContentType(contentType, data); const [filter, setFilter] = useState(null); const [showLargeResponse, setShowLargeResponse] = useState(false); const responseEncoding = getEncoding(headers); @@ -56,65 +153,44 @@ const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListen if (isLargeResponse && !showLargeResponse) { return ''; } - return formatResponse(data, dataBuffer, mode, filter); + return formatResponse(data, dataBuffer, selectedFormat, filter); }, - [data, dataBuffer, responseEncoding, mode, filter, isLargeResponse, showLargeResponse] + [data, dataBuffer, responseEncoding, selectedFormat, filter, isLargeResponse, showLargeResponse] ); const debouncedResultFilterOnChange = debounce((e) => { setFilter(e.target.value); }, 250); - const allowedPreviewModes = useMemo(() => { - // Always show raw - const allowedPreviewModes = [{ mode: 'raw', name: 'Raw', uid: uuid() }]; + const previewMode = useMemo(() => { + // Derive preview mode based on selected format + if (selectedFormat === 'html') return 'preview-web'; + if (selectedFormat === 'json') return 'preview-json'; + if (selectedFormat === 'xml') return 'preview-xml'; + if (selectedFormat === 'raw') return 'preview-text'; + if (selectedFormat === 'javascript') return 'preview-web'; - if (!mode || !contentType) return allowedPreviewModes; - - if (mode?.includes('html') && typeof data === 'string') { - allowedPreviewModes.unshift({ mode: 'preview-web', name: 'Web', uid: uuid() }); - } else if (mode.includes('image')) { - allowedPreviewModes.unshift({ mode: 'preview-image', name: 'Image', uid: uuid() }); - } else if (contentType.includes('pdf')) { - allowedPreviewModes.unshift({ mode: 'preview-pdf', name: 'PDF', uid: uuid() }); - } else if (contentType.includes('audio')) { - allowedPreviewModes.unshift({ mode: 'preview-audio', name: 'Audio', uid: uuid() }); - } else if (contentType.includes('video')) { - allowedPreviewModes.unshift({ mode: 'preview-video', name: 'Video', uid: uuid() }); + // For base64/hex, check content type to determine binary preview type + if (selectedFormat === 'base64' || selectedFormat === 'hex') { + if (detectedContentType) { + if (detectedContentType.includes('image')) return 'preview-image'; + if (detectedContentType.includes('pdf')) return 'preview-pdf'; + if (detectedContentType.includes('audio')) return 'preview-audio'; + if (detectedContentType.includes('video')) return 'preview-video'; + } + // for all other content types, return preview-text + return 'preview-text'; } + return 'preview-text'; + }, [selectedFormat, detectedContentType]); - return allowedPreviewModes; - }, [mode, data, formattedData]); + const codeMirrorMode = useMemo(() => { + return PREVIEW_FORMAT_OPTIONS + .flatMap((option) => option.options) + .find((option) => option.value === selectedFormat)?.codeMirrorMode || 'text/plain'; + }, [selectedFormat]); - const [previewTab, setPreviewTab] = useState(allowedPreviewModes[0]); - // Ensure the active Tab is always allowed - useEffect(() => { - if (!allowedPreviewModes.find((previewMode) => previewMode?.uid == previewTab?.uid)) { - setPreviewTab(allowedPreviewModes[0]); - } - }, [previewTab, allowedPreviewModes]); - - const tabs = useMemo(() => { - if (allowedPreviewModes.length === 1) { - return null; - } - - return allowedPreviewModes.map((previewMode) => ( -
setPreviewTab(previewMode)} - key={previewMode?.uid} - > - {previewMode?.name} -
- )); - }, [allowedPreviewModes, previewTab]); - - const queryFilterEnabled = useMemo(() => mode.includes('json'), [mode]); + const queryFilterEnabled = useMemo(() => codeMirrorMode.includes('json') && selectedFormat === 'json' && selectedTab === 'editor', [codeMirrorMode, selectedFormat, selectedTab]); const hasScriptError = item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage; return ( @@ -122,9 +198,6 @@ const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListen className="w-full h-full relative flex" queryFilterEnabled={queryFilterEnabled} > -
- {tabs} -
{error ? (
{hasScriptError ? null : ( @@ -147,21 +220,23 @@ const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListen ) : (
- +
+ +
{queryFilterEnabled && ( - + )}
diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseActions/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseActions/index.js index a430153a0..f6f140f6f 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseActions/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseActions/index.js @@ -3,7 +3,7 @@ import { IconDots } from '@tabler/icons'; import Dropdown from 'components/Dropdown'; import StyledWrapper from './StyledWrapper'; import ResponseClear from 'src/components/ResponsePane/ResponseClear'; -import ResponseSave from 'src/components/ResponsePane/ResponseSave'; +import ResponseDownload from 'src/components/ResponsePane/ResponseDownload'; const ResponseActions = ({ collection, item }) => { const menuDropdownTippyRef = useRef(); @@ -26,7 +26,7 @@ const ResponseActions = ({ collection, item }) => { } placement="bottom-end"> - + ); diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/StyledWrapper.js index f5c6b0b1d..c1f02904e 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/StyledWrapper.js @@ -3,6 +3,13 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` display: flex; align-items: center; + color: ${(props) => props.theme.codemirror.variable.info.iconColor}; + border-radius: 4px; + + &:hover { + background-color: ${(props) => props.theme.workspace.button.bg}; + color: ${(props) => props.theme.text}; + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/index.js index 9efe51f2e..0397c43e2 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/index.js @@ -24,27 +24,51 @@ const getTitleText = ({ isResponseTooLarge, isStreamingResponse }) => { return 'Save current response as example'; }; -const ResponseBookmark = ({ item, collection, responseSize }) => { +const ResponseBookmark = ({ item, collection, responseSize, children }) => { const dispatch = useDispatch(); const [showSaveResponseExampleModal, setShowSaveResponseExampleModal] = useState(false); const response = item.response || {}; const isResponseTooLarge = responseSize >= 5 * 1024 * 1024; // 5 MB const isStreamingResponse = response.stream; + const isDisabled = isResponseTooLarge || isStreamingResponse; // Only show for HTTP requests if (item.type !== 'http-request') { return null; } - const handleSaveClick = () => { + const handleKeyDown = (e) => { + if (isDisabled) { + e.preventDefault(); + e.stopPropagation(); + return; + } + + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleSaveClick(e); + } + }; + + const handleSaveClick = (e) => { if (!response || response.error) { toast.error('No valid response to save as example'); + e.preventDefault(); + e.stopPropagation(); return; } if (isResponseTooLarge) { toast.error('Response size exceeds 5MB limit. Cannot save as example.'); + e.preventDefault(); + e.stopPropagation(); + return; + } + + if (isDisabled) { + e.preventDefault(); + e.stopPropagation(); return; } @@ -116,21 +140,28 @@ const ResponseBookmark = ({ item, collection, responseSize }) => { return ( <> - - - +
+ {children ?? ( + + + + )} +
props.theme.font.size.base}; - color: ${(props) => props.theme.requestTabPanel.responseStatus}; + color: ${(props) => props.theme.codemirror.variable.info.iconColor}; + border-radius: 4px; + + &:hover { + background-color: ${(props) => props.theme.workspace.button.bg}; + color: ${(props) => props.theme.text}; + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseClear/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseClear/index.js index b18418592..944de0a98 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseClear/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseClear/index.js @@ -4,11 +4,11 @@ import { useDispatch } from 'react-redux'; import StyledWrapper from './StyledWrapper'; import { responseCleared } from 'providers/ReduxStore/slices/collections/index'; -const ResponseClear = ({ collection, item, asDropdownItem, onClose }) => { +// Hook to get clear response function +export const useResponseClear = (item, collection) => { const dispatch = useDispatch(); const clearResponse = () => { - if (onClose) onClose(); dispatch( responseCleared({ itemUid: item.uid, @@ -18,21 +18,29 @@ const ResponseClear = ({ collection, item, asDropdownItem, onClose }) => { ); }; - if (asDropdownItem) { - return ( -
- - Clear -
- ); - } + return { clearResponse }; +}; + +const ResponseClear = ({ collection, item, children }) => { + const { clearResponse } = useResponseClear(item, collection); + + const handleKeyDown = (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + clearResponse(); + } + }; return ( - - - +
+ {children ? children : ( + + + + )} +
); }; export default ResponseClear; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseCopy/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ResponseCopy/StyledWrapper.js index 8c32a8bab..c351283b4 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseCopy/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseCopy/StyledWrapper.js @@ -2,7 +2,13 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` font-size: 0.8125rem; - color: ${(props) => props.theme.requestTabPanel.responseStatus}; + color: ${(props) => props.theme.codemirror.variable.info.iconColor}; + border-radius: 4px; + + &:hover { + background-color: ${(props) => props.theme.workspace.button.bg}; + color: ${(props) => props.theme.text}; + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseCopy/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseCopy/index.js index 7c59301ef..3b40b2ff8 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseCopy/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseCopy/index.js @@ -3,7 +3,8 @@ import StyledWrapper from './StyledWrapper'; import toast from 'react-hot-toast'; import { IconCopy, IconCheck } from '@tabler/icons'; -const ResponseCopy = ({ item }) => { +// Hook to get copy response function +export const useResponseCopy = (item) => { const response = item.response || {}; const [copied, setCopied] = useState(false); @@ -30,16 +31,39 @@ const ResponseCopy = ({ item }) => { } }; + return { copyResponse, copied, hasData: !!response.data }; +}; + +const ResponseCopy = ({ item, children }) => { + const { copyResponse, copied, hasData } = useResponseCopy(item); + + const handleKeyDown = (e) => { + if ((e.key === 'Enter' || e.key === ' ') && hasData) { + e.preventDefault(); + copyResponse(); + } + }; + + const handleClick = () => { + if (hasData) { + copyResponse(); + } + }; + return ( - - - +
+ {children ? children : ( + + + + )} +
); }; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseDownload/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ResponseDownload/StyledWrapper.js new file mode 100644 index 000000000..abe48470e --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/ResponseDownload/StyledWrapper.js @@ -0,0 +1,14 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + font-size: ${(props) => props.theme.font.size.base}; + color: ${(props) => props.theme.codemirror.variable.info.iconColor}; + border-radius: 4px; + + &:hover { + background-color: ${(props) => props.theme.workspace.button.bg}; + color: ${(props) => props.theme.text}; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseDownload/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseDownload/index.js new file mode 100644 index 000000000..8efbd58af --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/ResponseDownload/index.js @@ -0,0 +1,63 @@ +import React from 'react'; +import StyledWrapper from './StyledWrapper'; +import toast from 'react-hot-toast'; +import get from 'lodash/get'; +import { IconDownload } from '@tabler/icons'; +import classnames from 'classnames'; + +const ResponseDownload = ({ item, children }) => { + const { ipcRenderer } = window; + const response = item.response || {}; + const isDisabled = !response.dataBuffer; + + const saveResponseToFile = () => { + if (isDisabled) { + return; + } + return new Promise((resolve, reject) => { + ipcRenderer + .invoke('renderer:save-response-to-file', response, item?.requestSent?.url, item.pathname) + .then(resolve) + .catch((err) => { + toast.error(get(err, 'error.message') || 'Something went wrong!'); + reject(err); + }); + }); + }; + + const handleKeyDown = (e) => { + if (isDisabled) { + e.preventDefault(); + e.stopPropagation(); + return; + } + + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + saveResponseToFile(); + } + }; + + return ( +
+ {children ? children : ( + + + + )} +
+ ); +}; +export default ResponseDownload; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/StyledWrapper.js index 8e77b37d3..b74ccc386 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/StyledWrapper.js @@ -8,7 +8,13 @@ const Wrapper = styled.div` background: transparent; border: none; cursor: pointer; - color: ${(props) => props.theme.colors.text.muted}; + color: ${(props) => props.theme.codemirror.variable.info.iconColor}; + border-radius: 4px; + + &:hover { + background-color: ${(props) => props.theme.workspace.button.bg}; + color: ${(props) => props.theme.text}; + } } `; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.js index 709666765..4125dc0bc 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.js @@ -3,14 +3,14 @@ import { useDispatch, useSelector } from 'react-redux'; import { savePreferences } from 'providers/ReduxStore/slices/app'; import StyledWrapper from './StyledWrapper'; -const IconDockToBottom = () => { +export const IconDockToBottom = () => { return ( @@ -25,14 +25,14 @@ const IconDockToBottom = () => { ); }; -const IconDockToRight = () => { +export const IconDockToRight = () => { return ( @@ -48,7 +48,8 @@ const IconDockToRight = () => { ); }; -const ResponseLayoutToggle = () => { +// Hook to get orientation and toggle function +export const useResponseLayoutToggle = () => { const dispatch = useDispatch(); const preferences = useSelector((state) => state.app.preferences); const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal'; @@ -65,19 +66,42 @@ const ResponseLayoutToggle = () => { dispatch(savePreferences(updatedPreferences)); }; + return { orientation, toggleOrientation }; +}; + +const ResponseLayoutToggle = ({ children }) => { + const { orientation, toggleOrientation } = useResponseLayoutToggle(); + + const handleKeyDown = (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleOrientation(); + } + }; + + const title = !children ? (orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout') : null; + return ( - - - +
+ {children ? children : ( + + + + )} +
); }; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.spec.js b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.spec.js index 3131c3247..3e49d7618 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.spec.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.spec.js @@ -84,7 +84,7 @@ describe('ResponseLayoutToggle', () => { describe('Initial Render', () => { it('should render with horizontal orientation by default', () => { renderWithProviders(); - const button = screen.getByRole('button'); + const button = screen.getByTestId('response-layout-toggle-button'); expect(button).toBeInTheDocument(); expect(button).toHaveAttribute('title', 'Switch to vertical layout'); }); @@ -100,7 +100,7 @@ describe('ResponseLayoutToggle', () => { } }; renderWithProviders(, customState); - const button = screen.getByRole('button'); + const button = screen.getByTestId('response-layout-toggle-button'); expect(button).toBeInTheDocument(); expect(button).toHaveAttribute('title', 'Switch to horizontal layout'); }); @@ -109,7 +109,7 @@ describe('ResponseLayoutToggle', () => { describe('Interaction', () => { it('should switch to vertical layout when clicked in horizontal mode', () => { const { store } = renderWithProviders(); - const button = screen.getByRole('button'); + const button = screen.getByTestId('response-layout-toggle-button'); // Initial state check expect(button).toHaveAttribute('title', 'Switch to vertical layout'); @@ -145,7 +145,7 @@ describe('ResponseLayoutToggle', () => { } }; const { store } = renderWithProviders(, customState); - const button = screen.getByRole('button'); + const button = screen.getByTestId('response-layout-toggle-button'); // Initial state check expect(button).toHaveAttribute('title', 'Switch to horizontal layout'); diff --git a/packages/bruno-app/src/components/ResponsePane/ResponsePaneActions/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ResponsePaneActions/StyledWrapper.js new file mode 100644 index 000000000..b3731fc87 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/ResponsePaneActions/StyledWrapper.js @@ -0,0 +1,7 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponsePaneActions/index.js b/packages/bruno-app/src/components/ResponsePane/ResponsePaneActions/index.js new file mode 100644 index 000000000..1bff9aae9 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/ResponsePaneActions/index.js @@ -0,0 +1,188 @@ +import React, { useState, useEffect, useRef, forwardRef, useCallback, useMemo } from 'react'; +import { debounce } from 'lodash'; +import styled from 'styled-components'; +import { IconDots, IconDownload, IconEraser, IconBookmark, IconCopy } from '@tabler/icons'; +import Dropdown from 'components/Dropdown'; +import ResponseDownload from '../ResponseDownload'; +import ResponseBookmark from '../ResponseBookmark'; +import ResponseClear from '../ResponseClear'; +import ResponseLayoutToggle, { useResponseLayoutToggle, IconDockToBottom, IconDockToRight } from '../ResponseLayoutToggle'; +import ResponseCopy from '../ResponseCopy/index'; +import StyledWrapper from '../StyledWrapper'; + +const PADDING = 48; + +const StyledMenuIcon = styled.button` + display: flex; + align-items: center; + justify-content: center; + height: 1.25rem; + width: 1.5rem; + border: 1px solid ${(props) => props.theme.workspace.border}; + color: ${(props) => props.theme.codemirror.variable.info.iconColor}; + border-radius: 4px; + + &:hover { + background-color: ${(props) => props.theme.workspace.button.bg}; + color: ${(props) => props.theme.text}; + } +`; + +const MenuIcon = forwardRef((props, ref) => ( + + + +)); + +MenuIcon.displayName = 'MenuIcon'; + +const ResponsePaneActions = ({ item, collection, responseSize }) => { + const { orientation } = useResponseLayoutToggle(); + const [showMenu, setShowMenu] = useState(false); + const actionsRef = useRef(null); + const dropdownTippyRef = useRef(); + const individualButtonsWidthRef = useRef(null); + const showMenuRef = useRef(showMenu); + + const checkSpace = useCallback(() => { + const actionsContainer = actionsRef.current?.parentElement; + const rightSideContainer = actionsContainer?.closest('.right-side-container'); + + if (!actionsContainer || !rightSideContainer) return; + + const currentActionsWidth = actionsContainer.offsetWidth || 0; + + // Store individual buttons width when they're visible + if (!showMenuRef.current && currentActionsWidth > 0) { + individualButtonsWidthRef.current = currentActionsWidth; + } + + // Calculate siblings total width + let siblingsTotalWidth = 0; + let sibling = actionsContainer.previousElementSibling; + while (sibling) { + siblingsTotalWidth += sibling.offsetWidth || 0; + sibling = sibling.previousElementSibling; + } + + const actionsWidth = individualButtonsWidthRef.current || currentActionsWidth; + const requiredWidth = actionsWidth + siblingsTotalWidth + PADDING; + const shouldShowMenu = rightSideContainer.offsetWidth < requiredWidth; + + if (showMenuRef.current !== shouldShowMenu) { + showMenuRef.current = shouldShowMenu; + setShowMenu(shouldShowMenu); + } + }, []); + + const debouncedCheckSpace = useMemo( + () => debounce(checkSpace, 50), + [checkSpace] + ); + + useEffect(() => { + showMenuRef.current = showMenu; + }, [showMenu]); + + useEffect(() => { + checkSpace(); + + const rightSideContainer = actionsRef.current?.closest('.right-side-container'); + if (!rightSideContainer) return; + + const resizeObserver = new ResizeObserver(debouncedCheckSpace); + resizeObserver.observe(rightSideContainer); + + return () => { + resizeObserver.disconnect(); + debouncedCheckSpace.cancel(); + }; + }, [item, debouncedCheckSpace]); + + const onDropdownCreate = (ref) => { + dropdownTippyRef.current = ref; + }; + + const closeDropdown = () => { + if (dropdownTippyRef.current) { + dropdownTippyRef.current.hide(); + } + }; + + if (item.type !== 'http-request') { + return null; + } + + return ( + + {showMenu ? ( + } placement="bottom-end"> + + {/* Response Copy */} + +
+ + + + Copy response +
+
+ + {/* Response Save as Example */} + +
+ + + + Save response +
+
+ + {/* Response Download */} + +
+ + + + Download response +
+
+ + {/* Response Clear */} + +
+ + + + Clear response +
+
+ + {/* Response Layout Toggle */} + +
+ + {orientation === 'horizontal' ? : } + + Change layout +
+
+
+ ) : ( +
+ + + + + +
+ )} +
+ ); +}; + +export default ResponsePaneActions; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseSave/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ResponseSave/StyledWrapper.js deleted file mode 100644 index 73c1ba290..000000000 --- a/packages/bruno-app/src/components/ResponsePane/ResponseSave/StyledWrapper.js +++ /dev/null @@ -1,8 +0,0 @@ -import styled from 'styled-components'; - -const StyledWrapper = styled.div` - font-size: ${(props) => props.theme.font.size.base}; - color: ${(props) => props.theme.requestTabPanel.responseStatus}; -`; - -export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseSave/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseSave/index.js deleted file mode 100644 index c791463cd..000000000 --- a/packages/bruno-app/src/components/ResponsePane/ResponseSave/index.js +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import StyledWrapper from './StyledWrapper'; -import toast from 'react-hot-toast'; -import get from 'lodash/get'; -import { IconDownload } from '@tabler/icons'; - -const ResponseSave = ({ item, asDropdownItem, onClose }) => { - const { ipcRenderer } = window; - const response = item.response || {}; - - const saveResponseToFile = () => { - if (!response.dataBuffer) return; - if (onClose) onClose(); - return new Promise((resolve, reject) => { - ipcRenderer - .invoke('renderer:save-response-to-file', response, item?.requestSent?.url, item.pathname) - .then(resolve) - .catch((err) => { - toast.error(get(err, 'error.message') || 'Something went wrong!'); - reject(err); - }); - }); - }; - - if (asDropdownItem) { - return ( -
- - Download -
- ); - } - - return ( - - - - ); -}; -export default ResponseSave; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js index 7707669c4..0be357097 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js @@ -19,7 +19,7 @@ const ResponseSize = ({ size }) => { } return ( - + {sizeToDisplay} ); diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseStopWatch/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ResponseStopWatch/StyledWrapper.js index ba919c56f..71e1d0591 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseStopWatch/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseStopWatch/StyledWrapper.js @@ -2,7 +2,7 @@ import styled from 'styled-components'; const Wrapper = styled.div` font-size: ${(props) => props.theme.font.size.sm}; - font-weight: 500; + font-weight: 600; color: ${(props) => props.theme.requestTabPanel.responseStatus}; text-align: center; `; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseStopWatch/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseStopWatch/index.js index f434baccc..0ebd8cfaa 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseStopWatch/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseStopWatch/index.js @@ -21,7 +21,7 @@ const ResponseStopWatch = ({ startMillis }) => { let seconds = milliseconds / 1000; let secondsFormatted = `${seconds.toFixed(1)}s`; let width = secondsFormatted.length * 0.4; // Calculate width manually to stop parent layout from "flickering" by changing width too fast - return {secondsFormatted}; + return {secondsFormatted}; }; export default React.memo(ResponseStopWatch); diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseTime/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ResponseTime/StyledWrapper.js index 13bf109f6..3bb71610d 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseTime/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseTime/StyledWrapper.js @@ -2,7 +2,7 @@ import styled from 'styled-components'; const Wrapper = styled.div` font-size: ${(props) => props.theme.font.size.sm}; - font-weight: 500; + font-weight: 600; color: ${(props) => props.theme.requestTabPanel.responseStatus}; `; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseTime/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseTime/index.js index 52b8b84a3..8b609f11a 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseTime/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseTime/index.js @@ -17,6 +17,6 @@ const ResponseTime = ({ duration }) => { return null; } - return {durationToDisplay}; + return {durationToDisplay}; }; export default ResponseTime; diff --git a/packages/bruno-app/src/components/ResponsePane/StatusCode/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/StatusCode/StyledWrapper.js index a4a03f82d..c022683af 100644 --- a/packages/bruno-app/src/components/ResponsePane/StatusCode/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/StatusCode/StyledWrapper.js @@ -2,7 +2,8 @@ import styled from 'styled-components'; const Wrapper = styled.div` font-size: ${(props) => props.theme.font.size.sm}; - font-weight: 500; + font-weight: 600; + white-space: nowrap; &.text-ok { color: ${(props) => props.theme.requestTabPanel.responseOk}; diff --git a/packages/bruno-app/src/components/ResponsePane/StatusCode/index.js b/packages/bruno-app/src/components/ResponsePane/StatusCode/index.js index 302b9a5e2..5d2176ec9 100644 --- a/packages/bruno-app/src/components/ResponsePane/StatusCode/index.js +++ b/packages/bruno-app/src/components/ResponsePane/StatusCode/index.js @@ -6,7 +6,7 @@ import StyledWrapper from './StyledWrapper'; // Todo: text-error class is not getting pulled in for 500 errors const StatusCode = ({ status, statusText, isStreaming }) => { const getTabClassname = (status) => { - return classnames('ml-2', { + return classnames({ 'text-ok': status >= 100 && status < 200, 'text-ok': status >= 200 && status < 300, 'text-error': status >= 300 && status < 400, diff --git a/packages/bruno-app/src/components/ResponsePane/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/StyledWrapper.js index f41bfb5d4..584588821 100644 --- a/packages/bruno-app/src/components/ResponsePane/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/StyledWrapper.js @@ -33,6 +33,12 @@ const StyledWrapper = styled.div` .all-tests-passed { color: ${(props) => props.theme.colors.text.green} !important; } + + .separator { + height: 16px; + border-left: 1px solid ${(props) => props.theme.preferences.sidebar.border}; + margin: 0 8px; + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js index 111ec6bb7..3baec39f0 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js @@ -1,4 +1,4 @@ -import QueryResult from 'components/ResponsePane/QueryResult/index'; +import QueryResponse from 'components/ResponsePane/QueryResponse/index'; import { useState } from 'react'; const BodyBlock = ({ collection, data, dataBuffer, headers, error, item }) => { @@ -14,7 +14,7 @@ const BodyBlock = ({ collection, data, dataBuffer, headers, error, item }) => {
{data || dataBuffer ? (
- { const activeTabUid = useSelector((state) => state.tabs.activeTabUid); const isLoading = ['queued', 'sending'].includes(item.requestState); const [showScriptErrorCard, setShowScriptErrorCard] = useState(false); + const [selectedFormat, setSelectedFormat] = useState('raw'); + const [selectedTab, setSelectedTab] = useState('editor'); + + // Initialize format and tab only once when data loads + const { initialFormat, initialTab } = useInitialResponseFormat(item.response?.dataBuffer, item.response?.headers); + const previewFormatOptions = useResponsePreviewFormatOptions(item.response?.dataBuffer, item.response?.headers); + + useEffect(() => { + if (initialFormat !== null && initialTab !== null) { + setSelectedFormat(initialFormat); + setSelectedTab(initialTab); + } + }, [initialFormat, initialTab]); const requestTimeline = ([...(collection.timeline || [])]).filter((obj) => { if (obj.itemUid === item.uid) return true; @@ -86,6 +98,8 @@ const ResponsePane = ({ item, collection }) => { headers={response.headers} error={response.error} key={item.filename} + selectedFormat={selectedFormat} + selectedTab={selectedTab} /> ); } @@ -157,7 +171,7 @@ const ResponsePane = ({ item, collection }) => { return ( -
+
selectTab('response')}> Response
@@ -177,33 +191,50 @@ const ResponsePane = ({ item, collection }) => { />
{!isLoading ? ( -
+
{hasScriptError && !showScriptErrorCard && ( setShowScriptErrorCard(true)} /> )} - - {focusedTab?.responsePaneTab === 'timeline' ? ( - - ) : (item?.response && !item?.response?.error) ? ( + {focusedTab?.responsePaneTab === 'response' ? ( <> - - - - - {item.response?.stream?.running - ? - : } - + { + setSelectedFormat(newFormat); + }} + onPreviewTabSelect={() => { + setSelectedTab((prev) => prev === 'editor' ? 'preview' : 'editor'); + }} + selectedTab={selectedTab} + /> +
) : null} +
+ + {item.response?.stream?.running + ? + : } + +
+ +
+
+ {focusedTab?.responsePaneTab === 'timeline' ? ( + + ) : (item?.response && !item?.response?.error) ? ( + + ) : null} +
) : null}
{ switch (tab) { case 'response': { return ( -
{indents && indents.length diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js index 3cdffdc55..96ebe5477 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -293,6 +293,7 @@ const Collection = ({ collection, searchText }) => { onKeyDown={handleKeyDown} onFocus={handleFocus} onBlur={handleBlur} + data-testid="sidebar-collection-row" >
props.theme.dropdown.primaryText}; + border-color: ${(props) => props.theme.workspace.border}; + + &:hover { + background-color: ${(props) => props.theme.dropdown.hoverBg}; + } + } + + .dropdown-divider { + background-color: ${(props) => props.theme.dropdown.separator}; + height: 1px; + margin: 4px 0; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/ui/ButtonDropdown/index.jsx b/packages/bruno-app/src/ui/ButtonDropdown/index.jsx new file mode 100644 index 000000000..0b373670a --- /dev/null +++ b/packages/bruno-app/src/ui/ButtonDropdown/index.jsx @@ -0,0 +1,146 @@ +import React, { useRef, forwardRef } from 'react'; +import { IconCaretDown } from '@tabler/icons'; +import classnames from 'classnames'; +import StyledWrapper from './StyledWrapper'; +import Dropdown from 'components/Dropdown'; + +const ButtonIcon = forwardRef(({ disabled, className, style, prefix, selectedLabel, suffix, ...props }, ref) => { + return ( + + ); +}); +ButtonIcon.displayName = 'ButtonIcon'; + +const ButtonDropdown = ({ + label, + options, + onChange, + value, + disabled, + className, + style, + header, + prefix, + suffix, + ...props +}) => { + const dropdownTippyRef = useRef(null); + // Check if options is a group array + const isGrouped = Array.isArray(options) && options.length > 0 && 'options' in options[0]; + + // Find the selected option's label + const findSelectedLabel = () => { + if (isGrouped) { + const groups = options; + for (const group of groups) { + const option = group.options.find((opt) => opt.value === value); + if (option) return option.label; + } + } else { + const flatOptions = options; + const option = flatOptions.find((opt) => opt.value === value); + if (option) return option.label; + } + return label; + }; + + const selectedLabel = findSelectedLabel(); + + const onDropdownCreate = (ref) => { + dropdownTippyRef.current = ref; + }; + + const handleOptionSelect = (optionValue) => { + onChange(optionValue); + dropdownTippyRef.current?.hide(); + }; + + // Flatten options for rendering + const renderOptions = () => { + if (isGrouped) { + const groups = options; + return groups.map((group, groupIndex) => ( + + {group.options.map((option, optionIndex) => { + const isFirstInGroup = optionIndex === 0; + const isFirstGroup = groupIndex === 0; + const showSeparator = !isFirstGroup && isFirstInGroup; + + return ( +
handleOptionSelect(option.value)} + > + {option.label} + {option.value === value && ( + + )} +
+ ); + })} +
+ )); + } else { + const flatOptions = options; + return flatOptions.map((option) => ( +
handleOptionSelect(option.value)} + > + {option.label} + {option.value === value && ( + + )} +
+ )); + } + }; + + return ( + + } + placement="bottom-end" + disabled={disabled} + > +
+ {header && ( +
dropdownTippyRef.current?.hide()}> + {header} +
+
+ )} + {renderOptions()} +
+
+
+ ); +}; + +export default ButtonDropdown; diff --git a/packages/bruno-app/src/ui/ErrorAlert/StyledWrapper.js b/packages/bruno-app/src/ui/ErrorAlert/StyledWrapper.js new file mode 100644 index 000000000..ddd0a40dc --- /dev/null +++ b/packages/bruno-app/src/ui/ErrorAlert/StyledWrapper.js @@ -0,0 +1,44 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + border-left: 4px solid ${(props) => props.theme.colors.text.danger}; + border-top: 1px solid transparent; + border-right: 1px solid transparent; + border-bottom: 1px solid transparent; + border-radius: ${(props) => props.theme.border.radius.base}; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + max-height: 200px; + min-height: 70px; + overflow-y: auto; + background-color: ${(props) => (props.theme.bg === '#1e1e1e' ? 'rgba(40, 40, 40, 0.5)' : 'rgba(250, 250, 250, 0.9)')}; + + .close-button { + opacity: 0.7; + transition: opacity 0.2s; + + &:hover { + opacity: 1; + } + + svg { + color: ${(props) => props.theme.text}; + } + } + + .error-title { + font-weight: 500; + margin-bottom: 0.375rem; + color: ${(props) => props.theme.colors.text.danger}; + } + + .error-message { + font-family: monospace; + font-size: ${(props) => props.theme.font.size.xs}; + line-height: 1.25rem; + white-space: pre-wrap; + word-break: break-all; + color: ${(props) => props.theme.text}; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/ui/ErrorAlert/index.js b/packages/bruno-app/src/ui/ErrorAlert/index.js new file mode 100644 index 000000000..177b091a3 --- /dev/null +++ b/packages/bruno-app/src/ui/ErrorAlert/index.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { IconX } from '@tabler/icons'; +import StyledWrapper from './StyledWrapper'; + +const ErrorAlert = ({ title, message, onClose }) => { + if (!message) return null; + + return ( + +
+
+ {title &&
{title}
} +
{typeof message === 'string' ? message : JSON.stringify(message, null, 2)}
+
+ {onClose && ( +
+ +
+ )} +
+
+ ); +}; + +export default ErrorAlert; diff --git a/packages/bruno-app/src/utils/common/codemirror.js b/packages/bruno-app/src/utils/common/codemirror.js index 56729ee39..b6fb0deb5 100644 --- a/packages/bruno-app/src/utils/common/codemirror.js +++ b/packages/bruno-app/src/utils/common/codemirror.js @@ -103,6 +103,8 @@ export const getCodeMirrorModeBasedOnContentType = (contentType, body) => { if (contentType.includes('json')) { return 'application/ld+json'; + } else if (contentType.includes('javascript') || contentType.includes('ecmascript')) { + return 'application/javascript'; } else if (contentType.includes('image')) { return 'application/image'; } else if (contentType.includes('xml')) { diff --git a/packages/bruno-app/src/utils/common/index.js b/packages/bruno-app/src/utils/common/index.js index 2190db237..00243c98e 100644 --- a/packages/bruno-app/src/utils/common/index.js +++ b/packages/bruno-app/src/utils/common/index.js @@ -4,6 +4,8 @@ import { JSONPath } from 'jsonpath-plus'; import fastJsonFormat from 'fast-json-format'; import { format, applyEdits } from 'jsonc-parser'; import { patternHasher } from '@usebruno/common/utils'; +import prettierFormat from 'prettier/standalone'; +import parserBabel from 'prettier/parser-babel'; export const isPlaywright = () => { return typeof window !== 'undefined' && window.isPlaywright === true; @@ -104,6 +106,8 @@ export const getContentType = (headers) => { const SVG_PATTERN = /^image\/svg/i; // This pattern matches content types like application/xml, text/xml, application/atom+xml, etc. const XML_PATTERN = /^[\w\-]+\/([\w\-]+\+)?xml/; + // This pattern matches JavaScript content types: application/javascript, text/javascript, application/ecmascript, text/ecmascript + const JAVASCRIPT_PATTERN = /^(application|text)\/(javascript|ecmascript)/i; if (JSON_PATTERN.test(contentType)) { return 'application/ld+json'; @@ -111,6 +115,8 @@ export const getContentType = (headers) => { return 'image/svg+xml'; } else if (XML_PATTERN.test(contentType)) { return 'application/xml'; + } else if (JAVASCRIPT_PATTERN.test(contentType)) { + return 'application/javascript'; } return contentType; @@ -320,6 +326,105 @@ export const formatResponse = (data, dataBufferString, mode, filter, bufferThres return safeStringifyJSON(parsed, true); } + if (mode.includes('html')) { + if (isVeryLargeResponse) { + if (typeof data === 'string') { + return data; + } + if (data === null || data === undefined) { + return String(data); + } + if (typeof data === 'object') { + return safeStringifyJSON(data, false); + } + return String(data); + } + + // Get HTML string from rawData + let htmlString = rawData; + // Prettify HTML + try { + return prettifyHtmlString(htmlString); + } catch (error) { + return htmlString; + } + } + + if (mode.includes('javascript')) { + if (isVeryLargeResponse) { + if (typeof data === 'string') { + return data; + } + if (data === null || data === undefined) { + return String(data); + } + if (typeof data === 'object') { + return safeStringifyJSON(data, false); + } + return String(data); + } + + // Get JavaScript string from rawData + let jsString = rawData; + + // Prettify JavaScript + try { + return prettifyJavaScriptString(jsString); + } catch (error) { + return jsString; + } + } + + // Handle hex format - return hex representation + if (mode.includes('hex')) { + // Check if data is already in hex format + if (typeof data === 'string' && isHexFormat(data)) { + // Data is already in hex format, return it as-is + return data; + } + + // Data is not in hex format, encode it to hex + try { + const dataBuffer = Buffer.from(dataBufferString, 'base64'); + const hexView = formatHexView(dataBuffer); + return hexView; + } catch (error) { + // If buffer conversion fails, try to encode the string data directly + if (typeof data === 'string') { + try { + const stringBuffer = Buffer.from(data, 'utf8'); + return formatHexView(stringBuffer); + } catch (stringError) { + return ''; + } + } + return ''; + } + } + + // Handle base64 format - return base64 string as-is + if (mode.includes('base64')) { + return dataBufferString; + } + + // Handle raw format - return data as-is without any formatting + if (mode.includes('text') || mode.includes('raw')) { + if (isVeryLargeResponse) { + if (typeof data === 'string') { + return data; + } + if (data === null || data === undefined) { + return String(data); + } + if (typeof data === 'object') { + return safeStringifyJSON(data, false); + } + return String(data); + } + // Return the raw decoded buffer data + return rawData; + } + if (typeof data === 'string') { return data; } @@ -362,3 +467,96 @@ export const toTitleCase = (str) => { .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' '); }; +// Simple HTML formatter that indents HTML properly +export function prettifyHtmlString(htmlString) { + if (typeof htmlString !== 'string') return htmlString; + + try { + // Use xml-formatter which works well for HTML + return xmlFormat(htmlString, { + collapseContent: true, + lineSeparator: '\n', + whiteSpaceAtEndOfSelfClosingTag: true + }); + } catch (error) { + console.log('error formatting html data!'); + console.error(error); + // Fallback: return original string if formatting fails + return htmlString; + } +}; + +// Simple JavaScript formatter that uses prettier +export function prettifyJavaScriptString(jsString) { + if (typeof jsString !== 'string') return jsString; + + try { + return prettierFormat.format(jsString, { + parser: 'babel', + plugins: [parserBabel], + semi: true, + singleQuote: true, + tabWidth: 2, + trailingComma: 'none', + printWidth: 120 + }); + } catch (error) { + // If prettier fails, return the original string + return jsString; + } +}; + +// Check if string contains valid HTML structure +export const isValidHtml = (str) => { + if (typeof str !== 'string' || !str.trim()) return false; + return /<\s*html[\s>]/i.test(str); +}; + +export function formatHexView(buffer) { + const width = 16; + let output = ''; + + for (let i = 0; i < buffer.length; i += width) { + const slice = buffer.slice(i, i + width); + const hex = Array.from(slice) + .map((b) => b.toString(16).padStart(2, '0').toUpperCase()) + .join(' '); + const ascii = Array.from(slice) + .map((b) => (b >= 32 && b <= 126 ? String.fromCharCode(b) : '.')) + .join(''); + + output += `${i.toString(16).padStart(8, '0')}: ${hex.padEnd(48)} ${ascii}\n`; + } + + return output; +} + +// Function to detect if a string is already in hex format +// Checks if the string looks like hex dump format (with addresses and ASCII) or plain hex +export function isHexFormat(str) { + if (typeof str !== 'string' || !str.trim()) { + return false; + } + + const trimmed = str.trim(); + + // Check for hex dump format (e.g., "00000000: 48 65 6C 6C 6F 20 57 6F 72 6C 64 21 00 00 00 00 Hello World!....") + const hexDumpPattern = /^[0-9a-fA-F]{8}:\s+([0-9a-fA-F]{2}\s+){1,16}/m; + if (hexDumpPattern.test(trimmed)) { + return true; + } + + // Check for plain hex string (only hex characters, possibly with spaces) + // Remove spaces and check if all characters are hex + const hexOnly = trimmed.replace(/\s+/g, ''); + if (hexOnly.length > 0 && /^[0-9a-fA-F]+$/i.test(hexOnly)) { + // Make sure it's not too short (could be a regular number) and has even length + // Require minimum length of 6 to reduce false positives (e.g., "dead", "beef") + // Also require at least one digit 0-9 to avoid matching all-letter words + if (hexOnly.length >= 6 && hexOnly.length % 2 === 0 && /[0-9]/.test(hexOnly)) { + return true; + } + } + + return false; +} diff --git a/packages/bruno-app/src/utils/response/index.js b/packages/bruno-app/src/utils/response/index.js new file mode 100644 index 000000000..c3f8fc666 --- /dev/null +++ b/packages/bruno-app/src/utils/response/index.js @@ -0,0 +1,260 @@ +// Normalize & extract MIME type from full header +const extractMimeType = (contentType = '') => { + const cleaned = String(contentType).trim().toLowerCase(); + const match = cleaned.match(/^[^;]+/); // strip "; charset=utf-8" + return match ? match[0] : cleaned; +}; + +export const getDefaultResponseFormat = (contentType) => { + const mime = extractMimeType(contentType); + + const rules = [ + // ====== HTML ====== + { test: /^text\/html$/, result: { format: 'html', tab: 'preview' } }, + + // ====== JSON (including custom +json types) ====== + { + test: /^application\/(json|.+\+json)$/, + result: { format: 'json', tab: 'editor' } + }, + { + test: /^text\/(json|.+\+json)$/, + result: { format: 'json', tab: 'editor' } + }, + + // ====== XML (including custom +xml types) ====== + { + test: /^application\/(xml|.+\+xml)$/, + result: { format: 'xml', tab: 'editor' } + }, + { + test: /^text\/(xml|.+\+xml)$/, + result: { format: 'xml', tab: 'editor' } + }, + + // ====== JavaScript ====== + { + test: /^(application|text)\/javascript$/, + result: { format: 'javascript', tab: 'editor' } + }, + + // ====== Images, audio, video, PDFs → preview (base64) ====== + { test: /^image\//, result: { format: 'base64', tab: 'preview' } }, + { test: /^audio\//, result: { format: 'base64', tab: 'preview' } }, + { test: /^video\//, result: { format: 'base64', tab: 'preview' } }, + { test: /^application\/pdf$/, result: { format: 'base64', tab: 'preview' } }, + + // ====== Any other text types ====== + { test: /^text\//, result: { format: 'raw', tab: 'editor' } } + ]; + + for (const rule of rules) { + if (rule.test.test(mime)) { + return rule.result; + } + } + + // ====== Fallback ====== + return { format: 'raw', tab: 'editor' }; +}; + +// Safe HTML escaping for webview content +export const escapeHtml = (text) => { + if (typeof text !== 'string') return text; + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +}; + +/** + * Helper to detect if buffer contains text data + */ +const isLikelyText = (buffer) => { + if (!buffer || buffer.length === 0) return false; + let textChars = 0; + const sampleSize = Math.min(buffer.length, 512); + + for (let i = 0; i < sampleSize; i++) { + const byte = buffer[i]; + // Check for common text characters (printable ASCII + common control chars) + if ((byte >= 0x20 && byte <= 0x7E) // Printable ASCII + || byte === 0x09 // Tab + || byte === 0x0A // Line feed + || byte === 0x0D) { // Carriage return + textChars++; + } + } + + // If more than 85% are text characters, likely text + return (textChars / sampleSize) > 0.85; +}; + +/** + * Helper to detect if snippet is valid HTML + */ +export const isValidHtmlSnippet = (snippet) => { + if (!snippet || typeof snippet !== 'string') { + return false; + } + + const trimmed = snippet.trim(); + + // Check for XML declaration + if (trimmed.startsWith(' match[1].toLowerCase()); + + if (tags.length === 0) { + return false; // No tags found + } + + // Define recognized HTML tags + const validHtmlTags = new Set([ + 'a', 'abbr', 'address', 'area', 'article', 'aside', 'audio', + 'b', 'base', 'bdi', 'bdo', 'blockquote', 'body', 'br', 'button', + 'canvas', 'caption', 'cite', 'code', 'col', 'colgroup', + 'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dl', 'dt', + 'em', 'embed', + 'fieldset', 'figcaption', 'figure', 'footer', 'form', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', + 'i', 'iframe', 'img', 'input', 'ins', + 'kbd', + 'label', 'legend', 'li', 'link', + 'main', 'map', 'mark', 'meta', 'meter', + 'nav', 'noscript', + 'object', 'ol', 'optgroup', 'option', 'output', + 'p', 'param', 'picture', 'pre', 'progress', + 'q', + 'rp', 'rt', 'ruby', + 's', 'samp', 'script', 'section', 'select', 'slot', 'small', 'source', 'span', 'strong', 'style', 'sub', 'summary', 'sup', 'svg', + 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track', + 'u', 'ul', + 'var', 'video', + 'wbr' + ]); + + // Check if all tags are valid HTML tags + const allTagsValid = tags.every((tag) => validHtmlTags.has(tag)); + + if (!allTagsValid) { + return false; // Contains non-HTML tags + } + + try { + // Parse with DOMParser + const parser = new DOMParser(); + const doc = parser.parseFromString(trimmed, 'text/html'); + + // Check for parsing errors + const parseError = doc.querySelector('parsererror'); + if (parseError) { + return false; + } + + // HTML parser is lenient; if we reach here with valid tags, consider it valid + return true; + } catch (error) { + return false; + } +}; + +/** +* Detects content type from buffer by checking magic numbers (file signatures) +* @param {Buffer} buffer - The data buffer to analyze +* @returns {string|null} - Detected MIME type or null +*/ +export const detectContentTypeFromBuffer = (buffer) => { + if (!buffer || buffer.length < 4) { + return null; + } + + // Get first few bytes for magic number checking + const bytes = buffer.subarray(0, 12); + + // Image formats + if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) { + return 'image/jpeg'; + } + if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) { + return 'image/png'; + } + if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) { + return 'image/gif'; + } + if (bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) { + return 'image/webp'; + } + if (bytes[0] === 0x42 && bytes[1] === 0x4D) { + return 'image/bmp'; + } + if ((bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2A && bytes[3] === 0x00) + || (bytes[0] === 0x4D && bytes[1] === 0x4D && bytes[2] === 0x00 && bytes[3] === 0x2A)) { + return 'image/tiff'; + } + if (bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0x01 && bytes[3] === 0x00) { + return 'image/x-icon'; + } + + // PDF + if (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) { + return 'application/pdf'; + } + + // Video formats + if (bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0x00 + && (bytes[3] === 0x18 || bytes[3] === 0x20) + && bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70) { + return 'video/mp4'; + } + if ((bytes[0] === 0x1A && bytes[1] === 0x45 && bytes[2] === 0xDF && bytes[3] === 0xA3)) { + return 'video/webm'; + } + if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 + && bytes[8] === 0x41 && bytes[9] === 0x56 && bytes[10] === 0x49 && bytes[11] === 0x20) { + return 'video/x-msvideo'; // AVI + } + + // Audio formats + if (bytes[0] === 0xFF && (bytes[1] & 0xE0) === 0xE0) { + return 'audio/mpeg'; // MP3 + } + if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 + && bytes[8] === 0x57 && bytes[9] === 0x41 && bytes[10] === 0x56 && bytes[11] === 0x45) { + return 'audio/wav'; + } + if (bytes[0] === 0x4F && bytes[1] === 0x67 && bytes[2] === 0x67 && bytes[3] === 0x53) { + return 'audio/ogg'; + } + if (bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70 + && bytes[8] === 0x4D && bytes[9] === 0x34 && bytes[10] === 0x41) { + return 'audio/m4a'; + } + + // Archive formats + if (bytes[0] === 0x50 && bytes[1] === 0x4B + && (bytes[2] === 0x03 || bytes[2] === 0x05 || bytes[2] === 0x07)) { + return 'application/zip'; + } + if (bytes[0] === 0x1F && bytes[1] === 0x8B) { + return 'application/gzip'; + } + + // Check if it's likely text (UTF-8) + if (isLikelyText(buffer.slice(0, Math.min(512, buffer.length)))) { + return 'text/plain'; + } + + return null; +}; diff --git a/tests/response-examples/create-example.spec.ts b/tests/response-examples/create-example.spec.ts index 54b996b3e..b2d56bdec 100644 --- a/tests/response-examples/create-example.spec.ts +++ b/tests/response-examples/create-example.spec.ts @@ -1,6 +1,7 @@ import { execSync } from 'child_process'; import { test, expect } from '../../playwright'; import path from 'path'; +import { clickResponseAction } from '../utils/page/actions'; test.describe.serial('Create and Delete Response Examples', () => { test.afterAll(async () => { @@ -17,7 +18,7 @@ test.describe.serial('Create and Delete Response Examples', () => { await test.step('Send request and validate example creation', async () => { await page.getByTestId('send-arrow-icon').click(); // Wait for 30 seconds for the response bookmark button to be visible, on slower internet connections it may take longer to get the response. - await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 }); + await clickResponseAction(page, 'response-bookmark-btn'); await expect(page.getByText('Save Response as Example')).toBeVisible(); await expect(page.getByTestId('create-example-name-input')).toBeVisible(); @@ -39,7 +40,7 @@ test.describe.serial('Create and Delete Response Examples', () => { await test.step('Validate error when name is empty', async () => { await page.getByTestId('send-arrow-icon').click(); - await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 }); + await clickResponseAction(page, 'response-bookmark-btn'); await expect(page.getByRole('button', { name: 'Create Example' })).toBeEnabled(); @@ -63,7 +64,7 @@ test.describe.serial('Create and Delete Response Examples', () => { await test.step('Test modal cancellation', async () => { await page.locator('.collection-item-name').getByText('create-example').click(); await page.getByTestId('send-arrow-icon').click(); - await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 }); + await clickResponseAction(page, 'response-bookmark-btn'); await page.getByRole('button', { name: 'Cancel' }).click(); await expect(page.getByText('Save Response as Example')).not.toBeVisible(); }); @@ -77,13 +78,13 @@ test.describe.serial('Create and Delete Response Examples', () => { await test.step('Test form reset', async () => { await page.locator('#send-request').getByRole('img').nth(2).click(); - await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 }); + await clickResponseAction(page, 'response-bookmark-btn'); await page.getByTestId('create-example-name-input').fill('Test Name'); await page.getByTestId('create-example-description-input').fill('Test Description'); await page.getByRole('button', { name: 'Cancel' }).click(); - await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 }); + await clickResponseAction(page, 'response-bookmark-btn'); // The name field should have the pre-filled default value await expect(page.getByTestId('create-example-name-input')).toHaveValue('example'); // Description should still be empty @@ -100,7 +101,7 @@ test.describe.serial('Create and Delete Response Examples', () => { await test.step('Create example and verify sidebar visibility', async () => { await page.getByTestId('send-arrow-icon').click(); - await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 }); + await clickResponseAction(page, 'response-bookmark-btn'); await page.getByTestId('create-example-name-input').clear(); await page.getByTestId('create-example-name-input').fill('Sidebar Test Example'); diff --git a/tests/response-examples/edit-example.spec.ts b/tests/response-examples/edit-example.spec.ts index 8abf742c1..41abc3b9d 100644 --- a/tests/response-examples/edit-example.spec.ts +++ b/tests/response-examples/edit-example.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '../../playwright'; import { execSync } from 'child_process'; import path from 'path'; +import { clickResponseAction } from '../utils/page/actions'; test.describe.serial('Edit Response Examples', () => { test.afterAll(async () => { @@ -16,7 +17,7 @@ test.describe.serial('Edit Response Examples', () => { await test.step('Make a successful request and create an example', async () => { await page.getByTestId('send-arrow-icon').click(); - await page.getByTestId('response-bookmark-btn').click(); + await clickResponseAction(page, 'response-bookmark-btn'); await page.getByTestId('create-example-name-input').clear(); await page.getByTestId('create-example-name-input').fill('Test Example'); await page.getByTestId('create-example-description-input').fill('This is a test example'); @@ -51,7 +52,7 @@ test.describe.serial('Edit Response Examples', () => { await test.step('Create example to update', async () => { await page.getByTestId('send-arrow-icon').click(); - await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 }); + await clickResponseAction(page, 'response-bookmark-btn'); await page.getByTestId('create-example-name-input').clear(); await page.getByTestId('create-example-name-input').fill('Original Example Name'); await page.getByTestId('create-example-description-input').fill('Original description'); @@ -85,7 +86,7 @@ test.describe.serial('Edit Response Examples', () => { await test.step('Create example to update description', async () => { await page.getByTestId('send-arrow-icon').click(); - await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 }); + await clickResponseAction(page, 'response-bookmark-btn'); await page.getByTestId('create-example-name-input').clear(); await page.getByTestId('create-example-name-input').fill('Description Test Example'); await page.getByTestId('create-example-description-input').fill('Original description'); @@ -119,7 +120,7 @@ test.describe.serial('Edit Response Examples', () => { await test.step('Create example to test cancel functionality', async () => { await page.getByTestId('send-arrow-icon').click(); - await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 }); + await clickResponseAction(page, 'response-bookmark-btn'); await page.getByTestId('create-example-name-input').clear(); await page.getByTestId('create-example-name-input').fill('Cancel Test Example'); await page.getByTestId('create-example-description-input').fill('Original description for cancel test'); @@ -154,7 +155,7 @@ test.describe.serial('Edit Response Examples', () => { await test.step('Create example to test keyboard shortcut', async () => { await page.getByTestId('send-arrow-icon').click(); - await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 }); + await clickResponseAction(page, 'response-bookmark-btn'); await page.getByTestId('create-example-name-input').clear(); await page.getByTestId('create-example-name-input').fill('Keyboard Shortcut Test Example'); await page.getByTestId('create-example-description-input').fill('Original description for keyboard test'); diff --git a/tests/response-examples/menu-operations.spec.ts b/tests/response-examples/menu-operations.spec.ts index 8dc7f6662..782cc4930 100644 --- a/tests/response-examples/menu-operations.spec.ts +++ b/tests/response-examples/menu-operations.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '../../playwright'; import { execSync } from 'child_process'; import path from 'path'; +import { clickResponseAction } from '../utils/page/actions'; test.describe.serial('Response Example Menu Operations', () => { test.setTimeout(1 * 60 * 1000); // 1 minute for all tests in this describe block, default is 30 seconds. @@ -17,7 +18,7 @@ test.describe.serial('Response Example Menu Operations', () => { await test.step('Create example', async () => { await page.getByTestId('send-arrow-icon').click(); - await page.getByTestId('response-bookmark-btn').click(); + await clickResponseAction(page, 'response-bookmark-btn'); await page.getByTestId('create-example-name-input').clear(); await page.getByTestId('create-example-name-input').fill('Example to Clone'); await page.getByRole('button', { name: 'Create Example' }).click(); @@ -48,7 +49,7 @@ test.describe.serial('Response Example Menu Operations', () => { await test.step('Create example to delete', async () => { await page.getByTestId('send-arrow-icon').click(); - await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 }); + await clickResponseAction(page, 'response-bookmark-btn'); await page.getByTestId('create-example-name-input').clear(); await page.getByTestId('create-example-name-input').fill('Example to Delete'); await page.getByTestId('create-example-description-input').fill('This example will be deleted'); @@ -81,7 +82,7 @@ test.describe.serial('Response Example Menu Operations', () => { await test.step('Create example to rename', async () => { await page.getByTestId('send-arrow-icon').click(); - await page.getByTestId('response-bookmark-btn').click({ timeout: 30000 }); + await clickResponseAction(page, 'response-bookmark-btn'); await page.getByTestId('create-example-name-input').clear(); await page.getByTestId('create-example-name-input').fill('Example to Rename'); await page.getByTestId('create-example-description-input').fill('This example will be renamed'); diff --git a/tests/response/large-response-crash-prevention.spec.ts b/tests/response/large-response-crash-prevention.spec.ts index 39df5105c..e3d94984f 100644 --- a/tests/response/large-response-crash-prevention.spec.ts +++ b/tests/response/large-response-crash-prevention.spec.ts @@ -35,6 +35,6 @@ test.describe('Large Response Crash/High Memory Usage Prevention', () => { await expect(page.getByText('could degrade performance')).toBeVisible(); // Verify action button - await expect(page.getByRole('button', { name: 'View' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'View', exact: true })).toBeVisible(); }); }); diff --git a/tests/response/response-actions.spec.ts b/tests/response/response-actions.spec.ts index 73c8b0d08..db6ef75e4 100644 --- a/tests/response/response-actions.spec.ts +++ b/tests/response/response-actions.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '../../playwright'; import { + clickResponseAction, closeAllCollections, createCollection, createRequest, @@ -26,8 +27,7 @@ test.describe('Response Pane Actions', () => { }); await test.step('Copy response to clipboard', async () => { - await expect(locators.response.copyButton()).toBeVisible(); - await locators.response.copyButton().click(); + await clickResponseAction(page, 'response-copy-btn'); await expect(page.getByText('Response copied to clipboard')).toBeVisible(); }); }); diff --git a/tests/response/response-format-select-and-preview/fixtures/collection/bruno.json b/tests/response/response-format-select-and-preview/fixtures/collection/bruno.json new file mode 100644 index 000000000..dd099fd15 --- /dev/null +++ b/tests/response/response-format-select-and-preview/fixtures/collection/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "collection", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} diff --git a/tests/response/response-format-select-and-preview/fixtures/collection/request-html.bru b/tests/response/response-format-select-and-preview/fixtures/collection/request-html.bru new file mode 100644 index 000000000..99340d0d6 --- /dev/null +++ b/tests/response/response-format-select-and-preview/fixtures/collection/request-html.bru @@ -0,0 +1,23 @@ +meta { + name: request-html + type: http + seq: 5 +} + +post { + url: https://www.httpfaker.org/api/echo/custom + body: json + auth: inherit +} + +body:json { + { + "headers": { "content-type": "text/html" }, + "content": "

hello

" + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/tests/response/response-format-select-and-preview/fixtures/collection/request-json.bru b/tests/response/response-format-select-and-preview/fixtures/collection/request-json.bru new file mode 100644 index 000000000..8a6953020 --- /dev/null +++ b/tests/response/response-format-select-and-preview/fixtures/collection/request-json.bru @@ -0,0 +1,17 @@ +meta { + name: request-json + type: http + seq: 1 +} + +post { + url: https://echo.usebruno.com + body: json + auth: none +} + +body:json { + { + "hello": "bruno" + } +} diff --git a/tests/response/response-format-select-and-preview/init-user-data/collection-security.json b/tests/response/response-format-select-and-preview/init-user-data/collection-security.json new file mode 100644 index 000000000..1bf67476e --- /dev/null +++ b/tests/response/response-format-select-and-preview/init-user-data/collection-security.json @@ -0,0 +1,10 @@ +{ + "collections": [ + { + "path": "{{projectRoot}}/tests/response/response-format-select-and-preview/fixtures/collection", + "securityConfig": { + "jsSandboxMode": "safe" + } + } + ] +} diff --git a/tests/response/response-format-select-and-preview/init-user-data/preferences.json b/tests/response/response-format-select-and-preview/init-user-data/preferences.json new file mode 100644 index 000000000..e61c5cccf --- /dev/null +++ b/tests/response/response-format-select-and-preview/init-user-data/preferences.json @@ -0,0 +1,6 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{projectRoot}}/tests/response/response-format-select-and-preview/fixtures/collection" + ] +} diff --git a/tests/response/response-format-select-and-preview/response-format-select-and-preview.spec.ts b/tests/response/response-format-select-and-preview/response-format-select-and-preview.spec.ts new file mode 100644 index 000000000..3cc136bf0 --- /dev/null +++ b/tests/response/response-format-select-and-preview/response-format-select-and-preview.spec.ts @@ -0,0 +1,161 @@ +import { test, expect } from '../../../playwright'; +import { closeAllCollections } from '../../utils/page'; +import { buildCommonLocators } from '../../utils/page/locators'; +import { + openRequest, + sendRequestAndWaitForResponse, + switchResponseFormat, + switchToPreviewTab, + switchToEditorTab +} from '../../utils/page/actions'; + +test.describe.serial('Response Format Select and Preview', () => { + test.afterAll(async ({ pageWithUserData: page }) => { + // cleanup: close all collections + await closeAllCollections(page); + }); + + test('Verify Response Format Select and Preview features are rendering properly for JSON response', async ({ pageWithUserData: page }) => { + await openRequest(page, 'collection', 'request-json'); + await sendRequestAndWaitForResponse(page); + + const locators = buildCommonLocators(page); + const responseBody = locators.response.body(); + const editorContainer = locators.response.editorContainer(); + const responseFormatTab = locators.response.formatTab(); + const codeLine = locators.response.codeLine(); + const previewContainer = locators.response.previewContainer(); + + await test.step('Verify response pane and default JSON editor formatting', async () => { + await expect(responseBody).toBeVisible(); + await expect(responseFormatTab).toHaveText('JSON'); + await expect(codeLine.nth(1)).toContainText('"hello": "bruno"'); + }); + + await test.step('Switch to Preview tab and check formatted object', async () => { + await switchToPreviewTab(page); + const jsonTreeLines = locators.response.jsonTreeLine(); + await expect(jsonTreeLines.nth(1)).toContainText('"hello":"bruno"'); + }); + + await test.step('Switch to Editor, select HTML, verify editor and preview', async () => { + await switchToEditorTab(page); + await switchResponseFormat(page, 'HTML'); + await expect(codeLine.nth(1)).toContainText('"hello": "bruno"'); + await switchToPreviewTab(page); + await expect(previewContainer).toContainText('{"hello":"bruno"}'); + }); + + await test.step('Switch to Editor, select XML, verify editor and preview error', async () => { + await switchToEditorTab(page); + await switchResponseFormat(page, 'XML'); + await expect(codeLine.nth(1)).toContainText('"hello": "bruno"'); + await switchToPreviewTab(page); + await expect(previewContainer).toContainText('Cannot preview as XML'); + }); + + await test.step('Switch to Editor, select JavaScript, verify editor and preview fallback', async () => { + await switchToEditorTab(page); + await switchResponseFormat(page, 'JavaScript'); + await expect(codeLine.nth(1)).toContainText('"hello": "bruno"'); + await switchToPreviewTab(page); + await expect(previewContainer).toContainText('{"hello":"bruno"}'); + }); + + await test.step('Switch to Editor, select Raw, verify editor and preview', async () => { + await switchToEditorTab(page); + await switchResponseFormat(page, 'Raw'); + await expect(codeLine.nth(1)).toContainText('"hello": "bruno"'); + await switchToPreviewTab(page); + await expect(previewContainer).toContainText('{"hello":"bruno"}'); + }); + + await test.step('Switch to Editor, select Hex, verify editor and preview', async () => { + await switchToEditorTab(page); + await switchResponseFormat(page, 'Hex'); + await expect(editorContainer).toContainText('00000000: 7B 0A 20 20 22 68 65 6C 6C 6F 22 3A 20 22'); + await switchToPreviewTab(page); + await expect(previewContainer).toContainText('{"hello":"bruno"}'); + }); + + await test.step('Switch to Editor, select Base64, verify editor and preview', async () => { + await switchToEditorTab(page); + await switchResponseFormat(page, 'Base64'); + await expect(editorContainer).toContainText('ewogICJoZWxsbyI6ICJicnVubyIKfQ=='); + await switchToPreviewTab(page); + await expect(previewContainer).toContainText('{"hello":"bruno"}'); + }); + }); + + test('Verify Response Format Select and Preview features are rendering properly for HTML response', async ({ pageWithUserData: page }) => { + await openRequest(page, 'collection', 'request-html'); + await sendRequestAndWaitForResponse(page); + + const locators = buildCommonLocators(page); + const responseBody = locators.response.body(); + const editorContainer = locators.response.editorContainer(); + const responseFormatTab = locators.response.formatTab(); + const codeLine = locators.response.codeLine(); + const previewContainer = locators.response.previewContainer(); + + await test.step('Verify response pane and default HTML preview', async () => { + await expect(responseBody).toBeVisible(); + await expect(previewContainer.locator('webview')).toBeVisible(); + }); + + await test.step('Switch to Editor tab and check formatted HTML', async () => { + await expect(responseFormatTab).toHaveText('HTML'); + await switchToEditorTab(page); + await expect(codeLine.first()).toContainText('

hello

'); + }); + + await test.step('Select JSON, verify editor and preview', async () => { + await switchResponseFormat(page, 'JSON'); + await expect(codeLine.first()).toContainText('

hello

'); + await switchToPreviewTab(page); + await expect(previewContainer).toContainText('Cannot preview as JSON'); + }); + + await test.step('Switch to Editor, select XML, verify editor and preview', async () => { + await switchToEditorTab(page); + await switchResponseFormat(page, 'XML'); + await expect(codeLine.first()).toContainText('

hello

'); + await switchToPreviewTab(page); + await expect(previewContainer).toContainText('h1'); + await expect(previewContainer).toContainText(':'); + await expect(previewContainer).toContainText('hello'); + }); + + await test.step('Switch to Editor, select JavaScript, verify editor and preview fallback', async () => { + await switchToEditorTab(page); + await switchResponseFormat(page, 'JavaScript'); + await expect(codeLine.first()).toContainText('

hello

'); + await switchToPreviewTab(page); + await expect(previewContainer.locator('webview')).toBeVisible(); + }); + + await test.step('Switch to Editor, select Raw, verify editor and preview', async () => { + await switchToEditorTab(page); + await switchResponseFormat(page, 'Raw'); + await expect(codeLine.first()).toContainText('

hello

'); + await switchToPreviewTab(page); + await expect(previewContainer).toContainText('

hello

'); + }); + + await test.step('Switch to Editor, select Hex, verify editor and preview', async () => { + await switchToEditorTab(page); + await switchResponseFormat(page, 'Hex'); + await expect(editorContainer).toContainText('00000000: 3C 68 31 3E 68 65 6C 6C 6F 3C 2F 68 31 3E'); + await switchToPreviewTab(page); + await expect(previewContainer).toContainText('

hello

'); + }); + + await test.step('Switch to Editor, select Base64, verify editor and preview', async () => { + await switchToEditorTab(page); + await switchResponseFormat(page, 'Base64'); + await expect(editorContainer).toContainText('PGgxPmhlbGxvPC9oMT4='); + await switchToPreviewTab(page); + await expect(previewContainer).toContainText('

hello

'); + }); + }); +}); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 59bf55c0a..76750fd28 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -531,14 +531,29 @@ const sendRequest = async ( * @param requestName - The name of the request to open * @returns void */ -const openRequest = async (page: Page, requestName: string) => { - await test.step(`Open request "${requestName}"`, async () => { - const locators = buildCommonLocators(page); - await locators.sidebar.request(requestName).click(); - await expect(locators.tabs.activeRequestTab()).toContainText(requestName); +// const openRequest = async (page: Page, requestName: string) => { +// await test.step(`Open request "${requestName}"`, async () => { +// const locators = buildCommonLocators(page); +// await locators.sidebar.request(requestName).click(); +// await expect(locators.tabs.activeRequestTab()).toContainText(requestName); +// }); +// }; + +/** +* Navigate to a collection and open a request +* @param page - The page object +* @param collectionName - The name of the collection +* @param requestName - The name of the request +*/ +const openRequest = async (page: Page, collectionName: string, requestName: string) => { + await test.step(`Navigate to collection "${collectionName}" and open request "${requestName}"`, async () => { + const collectionContainer = page.getByTestId('sidebar-collection-row').filter({ hasText: collectionName }); + await collectionContainer.click(); + const collectionWrapper = collectionContainer.locator('..'); + const request = collectionWrapper.getByTestId('sidebar-collection-item-row').filter({ hasText: requestName }); + await request.click(); }); }; - /** * Open a request within a folder * @param page - The page object @@ -554,6 +569,64 @@ const openFolderRequest = async (page: Page, folderName: string, requestName: st }); }; +/** +* Send a request and wait for the response + * @param page - The page object + * @param expectedStatusCode - The expected status code (default: '200') + * @param options - The options for sending the request (default: { timeout: 15000 }) + */ +const sendRequestAndWaitForResponse = async (page: Page, + expectedStatusCode: string = '200', + options: { + ignoreCase?: boolean; + timeout?: number; + useInnerText?: boolean; + } = { timeout: 15000 }) => { + await test.step(`Send request and wait for status code ${expectedStatusCode}`, async () => { + await page.getByTestId('send-arrow-icon').click(); + await expect(page.getByTestId('response-status-code')).toContainText(expectedStatusCode, options); + }); +}; + +/** + * Switch the response format + * @param page - The page object + * @param format - The format to switch to (e.g., 'JSON', 'HTML', 'XML', 'JavaScript', 'Raw', 'Hex', 'Base64') + */ +const switchResponseFormat = async (page: Page, format: string) => { + await test.step(`Switch response format to ${format}`, async () => { + const responseFormatTab = page.getByTestId('format-response-tab'); + await responseFormatTab.click(); + await page.getByTestId('format-response-tab-dropdown').getByText(format).click(); + }); +}; + +/** + * Switch to the preview tab + * @param page - The page object + */ +const switchToPreviewTab = async (page: Page) => { + await test.step('Switch to preview tab', async () => { + const responseFormatTab = page.getByTestId('format-response-tab'); + await responseFormatTab.click(); + const previewTab = page.getByTestId('preview-response-tab'); + await previewTab.click(); + }); +}; + +/** + * Switch to the editor tab + * @param page - The page object + */ +const switchToEditorTab = async (page: Page) => { + await test.step('Switch to editor tab', async () => { + const responseFormatTab = page.getByTestId('format-response-tab'); + await responseFormatTab.click(); + const previewTab = page.getByTestId('preview-response-tab'); + await previewTab.click(); + }); +}; + /** * Get the response body text * @param page - The page object @@ -605,6 +678,18 @@ const expectResponseContains = async (page: Page, texts: string[]) => { }); }; +// Create a action to click a response action +const clickResponseAction = async (page: Page, actionTestId: string) => { + const actionButton = await page.getByTestId(actionTestId); + if (await actionButton.isVisible()) { + await actionButton.click(); + } else { + const menu = await page.getByTestId('response-actions-menu'); + await menu.click(); + await actionButton.click(); + } +}; + export { closeAllCollections, openCollectionAndAcceptSandbox, @@ -627,7 +712,12 @@ export { openFolderRequest, getResponseBody, expectResponseContains, - selectRequestPaneTab + selectRequestPaneTab, + sendRequestAndWaitForResponse, + switchResponseFormat, + switchToPreviewTab, + switchToEditorTab, + clickResponseAction }; export type { SandboxMode, EnvironmentType, EnvironmentVariable, CreateCollectionOptions, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions }; diff --git a/tests/utils/page/locators.ts b/tests/utils/page/locators.ts index 68823c2bc..4101f895b 100644 --- a/tests/utils/page/locators.ts +++ b/tests/utils/page/locators.ts @@ -78,7 +78,14 @@ export const buildCommonLocators = (page: Page) => ({ response: { statusCode: () => page.getByTestId('response-status-code'), pane: () => page.locator('.response-pane'), - copyButton: () => page.locator('button[title="Copy response to clipboard"]') + copyButton: () => page.locator('button[title="Copy response to clipboard"]'), + body: () => page.locator('.response-pane'), + editorContainer: () => page.locator('.response-pane .editor-container'), + formatTab: () => page.getByTestId('format-response-tab'), + formatTabDropdown: () => page.getByTestId('format-response-tab-dropdown'), + previewContainer: () => page.getByTestId('response-preview-container'), + codeLine: () => page.locator('.response-pane .editor-container .CodeMirror-line'), + jsonTreeLine: () => page.locator('.response-pane .object-content') }, plusMenu: { button: () => page.getByTestId('collections-header-add-menu'), @@ -89,7 +96,8 @@ export const buildCommonLocators = (page: Page) => ({ modal: () => page.locator('[data-testid="import-collection-modal"]'), locationModal: () => page.locator('[data-testid="import-collection-location-modal"]'), locationInput: () => page.locator('#collection-location'), - fileInput: () => page.locator('input[type="file"]') + fileInput: () => page.locator('input[type="file"]'), + envOption: (name: string) => page.locator('.dropdown-item').getByText(name, { exact: true }) } }); @@ -111,7 +119,7 @@ export const buildWebsocketCommonLocators = (page: Page) => ({ toolbar: { latestFirst: () => page.getByRole('button', { name: 'Latest First' }), latestLast: () => page.getByRole('button', { name: 'Latest Last' }), - clearResponse: () => page.getByRole('button', { name: 'Clear Response' }) + clearResponse: () => page.getByTestId('response-clear-button') } });