From b9d8bdf2ec4a9a9a0a17654b291fab9e69cf29e9 Mon Sep 17 00:00:00 2001 From: Pooja Date: Mon, 8 Jun 2026 16:03:43 +0530 Subject: [PATCH] feat(ws): multiple messages support in websockets (#8115) * feat: ws multi message * fix * fix * fix * improve: UX * improve: new message ui * fix * fix * fix * fix * fix * fix: rename message title * chore: cleanup * change: add message color * fix(websocket): correct cursor and truncate long message names --------- Co-authored-by: Sid --- .../RequestPane/WSRequestPane/index.js | 107 ++++++- .../WsBody/SingleWSMessage/StyledWrapper.js | 116 ++++--- .../WsBody/SingleWSMessage/index.js | 286 ++++++++++------- .../RequestPane/WsBody/StyledWrapper.js | 34 +- .../components/RequestPane/WsBody/index.js | 141 +++++---- .../ReduxStore/slices/collections/actions.js | 5 +- .../ReduxStore/slices/collections/index.js | 3 +- .../bruno-app/src/utils/collections/index.js | 6 +- packages/bruno-app/src/utils/network/index.js | 12 +- .../src/opencollection/items/websocket.ts | 4 +- .../src/ipc/network/ws-event-handlers.js | 32 +- .../bruno-electron/src/utils/collection.js | 2 + .../yml/items/parseWebsocketRequest.ts | 30 +- .../yml/items/stringifyWebsocketRequest.ts | 28 +- packages/bruno-lang/v2/src/bruToJson.js | 5 +- packages/bruno-lang/v2/src/jsonToBru.js | 5 +- .../bruno-lang/v2/tests/bruToJson.spec.js | 152 ++++++++- .../src/requests/websocket.ts | 1 + .../fixtures/collection/bruno.json | 5 + .../fixtures/collection/collection.bru | 3 + .../fixtures/collection/ws-multi-msg.bru | 29 ++ .../fixtures/collection/ws-single-msg.bru | 21 ++ .../init-user-data/preferences.json | 12 + .../message-name-style.spec.ts | 36 +++ .../multi-message-bru/multi-message.spec.ts | 256 +++++++++++++++ .../fixtures/collection/opencollection.yml | 6 + .../fixtures/collection/ws-multi-msg.yml | 24 ++ .../fixtures/collection/ws-single-msg.yml | 18 ++ .../init-user-data/preferences.json | 12 + .../multi-message-yml/multi-message.spec.ts | 294 ++++++++++++++++++ 30 files changed, 1387 insertions(+), 298 deletions(-) create mode 100644 tests/websockets/multi-message-bru/fixtures/collection/bruno.json create mode 100644 tests/websockets/multi-message-bru/fixtures/collection/collection.bru create mode 100644 tests/websockets/multi-message-bru/fixtures/collection/ws-multi-msg.bru create mode 100644 tests/websockets/multi-message-bru/fixtures/collection/ws-single-msg.bru create mode 100644 tests/websockets/multi-message-bru/init-user-data/preferences.json create mode 100644 tests/websockets/multi-message-bru/message-name-style.spec.ts create mode 100644 tests/websockets/multi-message-bru/multi-message.spec.ts create mode 100644 tests/websockets/multi-message-yml/fixtures/collection/opencollection.yml create mode 100644 tests/websockets/multi-message-yml/fixtures/collection/ws-multi-msg.yml create mode 100644 tests/websockets/multi-message-yml/fixtures/collection/ws-single-msg.yml create mode 100644 tests/websockets/multi-message-yml/init-user-data/preferences.json create mode 100644 tests/websockets/multi-message-yml/multi-message.spec.ts diff --git a/packages/bruno-app/src/components/RequestPane/WSRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/WSRequestPane/index.js index 66825181a..432d07c95 100644 --- a/packages/bruno-app/src/components/RequestPane/WSRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/WSRequestPane/index.js @@ -2,12 +2,19 @@ import React, { useMemo, useCallback, useRef } from 'react'; import Documentation from 'components/Documentation/index'; import RequestHeaders from 'components/RequestPane/RequestHeaders'; import StatusDot from 'components/StatusDot/index'; -import { find } from 'lodash'; +import ActionIcon from 'ui/ActionIcon'; +import ToolHint from 'components/ToolHint/index'; +import { IconPlus, IconWand } from '@tabler/icons'; +import { find, get } from 'lodash'; import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs'; +import { updateRequestBody } from 'providers/ReduxStore/slices/collections'; import { useDispatch, useSelector } from 'react-redux'; import HeightBoundContainer from 'ui/HeightBoundContainer'; import ResponsiveTabs from 'ui/ResponsiveTabs'; import { getPropertyFromDraftOrRequest } from 'utils/collections/index'; +import { prettifyJsonString, uuid } from 'utils/common/index'; +import xmlFormat from 'xml-formatter'; +import toast from 'react-hot-toast'; import WsBody from '../WsBody/index'; import StyledWrapper from './StyledWrapper'; import WSAuth from './WSAuth'; @@ -24,6 +31,8 @@ const WSRequestPane = ({ item, collection, handleRun }) => { const focusedTab = find(tabs, (t) => t.uid === activeTabUid); const requestPaneTab = focusedTab?.requestPaneTab; + const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body'); + const selectTab = useCallback( (tab) => { dispatch(updateRequestPaneTab({ @@ -34,6 +43,63 @@ const WSRequestPane = ({ item, collection, handleRun }) => { [dispatch, item.uid] ); + const addNewMessage = useCallback(() => { + const currentMessages = Array.isArray(body?.ws) + ? body.ws.map((msg) => ({ ...msg, selected: false })) + : []; + currentMessages.push({ + uid: uuid(), + name: `message ${currentMessages.length + 1}`, + content: '{}', + type: 'json', + selected: true + }); + dispatch(updateRequestBody({ + content: currentMessages, + itemUid: item.uid, + collectionUid: collection.uid + })); + }, [body, dispatch, item.uid, collection.uid]); + + const onPrettifyAll = useCallback(() => { + const currentMessages = [...(body?.ws || [])]; + let changed = false; + + currentMessages.forEach((msg, i) => { + if (msg.type === 'json') { + try { + const pretty = prettifyJsonString(msg.content); + if (pretty !== msg.content) { + currentMessages[i] = { ...msg, content: pretty }; + changed = true; + } + } catch (e) { + // skip invalid json + } + } else if (msg.type === 'xml') { + try { + const pretty = xmlFormat(msg.content, { collapseContent: true }); + if (pretty !== msg.content) { + currentMessages[i] = { ...msg, content: pretty }; + changed = true; + } + } catch (e) { + // skip invalid xml + } + } + }); + + if (changed) { + dispatch(updateRequestBody({ + content: currentMessages, + itemUid: item.uid, + collectionUid: collection.uid + })); + } else { + toast.error('Nothing to prettify'); + } + }, [body, dispatch, item.uid, collection.uid]); + const headers = getPropertyFromDraftOrRequest(item, 'request.headers'); const docs = getPropertyFromDraftOrRequest(item, 'request.docs'); const auth = getPropertyFromDraftOrRequest(item, 'request.auth'); @@ -77,9 +143,8 @@ const WSRequestPane = ({ item, collection, handleRun }) => { ); } @@ -99,17 +164,41 @@ const WSRequestPane = ({ item, collection, handleRun }) => { return
404 | Not found
; } } - }, [requestPaneTab, item, collection, handleRun]); + }, [requestPaneTab, item, collection, handleRun, addNewMessage]); if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) { return
An error occurred!
; } - const rightContent = requestPaneTab === 'auth' ? ( -
- -
- ) : null; + let rightContent = null; + if (requestPaneTab === 'auth') { + rightContent = ( +
+ +
+ ); + } else if (requestPaneTab === 'body') { + rightContent = ( +
+ + + + + + + + + + +
+ ); + } return ( diff --git a/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/StyledWrapper.js index a2922b8a3..e2ce8e423 100644 --- a/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/StyledWrapper.js @@ -1,72 +1,92 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - display: flex; - flex-direction: column; + border-bottom: 1px solid ${(props) => props.theme.border.border0}; + transition: opacity 0.15s ease; - &.single { - height: 100%; - - .editor-container { - height: calc(100% - 32px); - } + &.disabled { + opacity: 0.45; } - &:not(.single) { - min-height: 240px; - margin-bottom: 8px; - - &.last { - margin-bottom: 0; - } - } - - .message-toolbar { + .accordion-header { display: flex; align-items: center; - justify-content: flex-end; - gap: 4px; - padding: 4px 0px; - padding-top: 0px; - height: 32px; - flex-shrink: 0; + justify-content: space-between; + padding: 0.5rem 0; + cursor: pointer; + user-select: none; - .message-label { - font-size: ${(props) => props.theme.font.size.sm}; - color: ${(props) => props.theme.colors.text.subtext1}; - margin-right: auto; - } - - .toolbar-actions { + .accordion-left { display: flex; align-items: center; - gap: 2px; + gap: 0.375rem; + flex: 1; + min-width: 0; + color: ${(props) => props.theme.text}; + + .message-label { + font-size: ${(props) => props.theme.font.size.sm}; + cursor: text; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .name-input { + font-size: ${(props) => props.theme.font.size.sm}; + color: inherit; + background: ${(props) => props.theme.background.surface1}; + border: none; + border-radius: 0.375rem; + padding: 0.25rem 0.5rem; + outline: none; + flex: 1; + } } - .toolbar-btn { + .accordion-actions { display: flex; align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 4px; - color: ${(props) => props.theme.colors.text.muted}; - transition: all 0.15s ease; + gap: 0.125rem; - &:hover { - background-color: ${(props) => props.theme.dropdown.hoverBg}; - color: ${(props) => props.theme.text}; - } + .hover-actions { + display: flex; + align-items: center; + gap: 0.125rem; + visibility: hidden; + opacity: 0; + transition: opacity 0.15s ease; - &.delete:hover { - color: ${(props) => props.theme.colors.text.danger}; + .hover-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 0.25rem; + color: ${(props) => props.theme.text}; + transition: all 0.15s ease; + + &:hover { + background-color: ${(props) => props.theme.dropdown.hoverBg}; + } + + &.delete:hover { + color: ${(props) => props.theme.colors.text.danger}; + } + } } } + + &:hover .hover-actions { + visibility: visible; + opacity: 1; + } } - .editor-container { - flex: 1; - min-height: 0; + &:not(.disabled) .accordion-header .message-label { + color: ${(props) => props.theme.primary.text}; } `; diff --git a/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/index.js b/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/index.js index 275665dc0..ebf9e1d04 100644 --- a/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/index.js +++ b/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/index.js @@ -1,56 +1,117 @@ -import { IconTrash, IconWand } from '@tabler/icons'; +import { IconTrash, IconSend, IconChevronRight, IconChevronDown } from '@tabler/icons'; import CodeEditor from 'components/CodeEditor/index'; import ToolHint from 'components/ToolHint/index'; import { get } from 'lodash'; -import invert from 'lodash/invert'; import { updateRequestBody } from 'providers/ReduxStore/slices/collections'; import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { useTheme } from 'providers/Theme'; -import React, { useState } from 'react'; +import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { autoDetectLang } from 'utils/codemirror/lang-detect'; -import { toastError } from 'utils/common/error'; -import { prettifyJsonString } from 'utils/common/index'; -import xmlFormat from 'xml-formatter'; +import { queueWsMessage, isWsConnectionActive, connectWS } from 'utils/network/index'; +import { findCollectionByUid, findEnvironmentInCollection } from 'utils/collections/index'; +import toast from 'react-hot-toast'; import WSRequestBodyMode from '../BodyMode/index'; import StyledWrapper from './StyledWrapper'; -export const TYPE_BY_DECODER = { - base64: 'binary', - json: 'json', - xml: 'xml' +const codemirrorMode = { + text: 'application/text', + xml: 'application/xml', + json: 'application/ld+json' }; -export const DECODER_BY_TYPE = invert(TYPE_BY_DECODER); +// Maps stored type to display mode +const typeToMode = (type) => { + switch (type) { + case 'json': return 'json'; + case 'xml': return 'xml'; + default: return 'text'; + } +}; export const SingleWSMessage = ({ message, item, collection, index, - methodType, handleRun, - canClientSendMultipleMessages, - isLast + isExpanded, + onToggle, + isNew, + onNewRendered, + isSelected, + onSelect }) => { const dispatch = useDispatch(); const { displayedTheme } = useTheme(); const preferences = useSelector((state) => state.app.preferences); const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body'); + const collections = useSelector((state) => state.collections.collections); const { name, content, type } = message; - const [messageFormat, setMessageFormat] = useState(autoDetectLang(content)); + const displayMode = typeToMode(type); + const displayName = name || `message ${index + 1}`; - const onUpdateMessageType = (type) => { - setMessageFormat(type); + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(displayName); + // Auto-focus the name input when this is a newly created message + useEffect(() => { + if (isNew) { + setIsEditing(true); + setEditValue(displayName); + onNewRendered(); + } + }, [isNew]); + + const saveName = (value) => { + const trimmed = value.trim() || `message ${index + 1}`; const currentMessages = [...(body.ws || [])]; - currentMessages[index] = { ...currentMessages[index], - type: DECODER_BY_TYPE[type] + name: trimmed }; + dispatch(updateRequestBody({ + content: currentMessages, + itemUid: item.uid, + collectionUid: collection.uid + })); + setIsEditing(false); + }; + const handleNameKeyDown = (e) => { + if (e.key === 'Enter') { + saveName(editValue); + } else if (e.key === 'Escape') { + setEditValue(displayName); + setIsEditing(false); + } + }; + + const handleNameBlur = () => { + saveName(editValue); + }; + + const handleNameClick = useCallback((e) => { + e.stopPropagation(); + setEditValue(displayName); + setIsEditing(true); + }, [displayName, onToggle]); + + const fontSize = get(preferences, 'font.codeFontSize', 14); + const lineHeight = fontSize * 1.5; + + const editorHeight = useMemo(() => { + const lineCount = (content || '').split('\n').length; + const lines = lineCount + 1; + return `${lines * lineHeight + 10}px`; + }, [content, lineHeight]); + + const onUpdateMessageType = (newMode) => { + const currentMessages = [...(body.ws || [])]; + currentMessages[index] = { + ...currentMessages[index], + type: typeToMode(newMode) + }; dispatch(updateRequestBody({ content: currentMessages, itemUid: item.uid, @@ -60,13 +121,11 @@ export const SingleWSMessage = ({ const onEdit = (value) => { const currentMessages = [...(body.ws || [])]; - currentMessages[index] = { - name: name ? name : `message ${index + 1}`, - type: DECODER_BY_TYPE[messageFormat], + ...currentMessages[index], + name: name || `message ${index + 1}`, content: value }; - dispatch(updateRequestBody({ content: currentMessages, itemUid: item.uid, @@ -78,9 +137,7 @@ export const SingleWSMessage = ({ const onDeleteMessage = () => { const currentMessages = [...(body.ws || [])]; - currentMessages.splice(index, 1); - dispatch(updateRequestBody({ content: currentMessages, itemUid: item.uid, @@ -88,97 +145,112 @@ export const SingleWSMessage = ({ })); }; - let codeType = messageFormat; - if (TYPE_BY_DECODER[type]) { - codeType = TYPE_BY_DECODER[type]; - } + const onSendMessage = useCallback(async () => { + try { + const col = findCollectionByUid(collections, collection.uid); + const environment = findEnvironmentInCollection(col, col?.activeEnvironmentUid); - const codemirrorMode = { - text: 'application/text', - xml: 'application/xml', - json: 'application/ld+json' - }; - - const onPrettify = () => { - if (codeType === 'json') { - try { - const prettyBodyJson = prettifyJsonString(content); - const currentMessages = [...(body.ws || [])]; - currentMessages[index] = { - ...currentMessages[index], - name: name ? name : `message ${index + 1}`, - content: prettyBodyJson - }; - dispatch(updateRequestBody({ - content: currentMessages, - itemUid: item.uid, - collectionUid: collection.uid - })); - } catch (e) { - toastError(new Error('Unable to prettify. Invalid JSON format.')); + // Auto-connect if not already connected + const connectionStatus = await isWsConnectionActive(item.uid); + if (!connectionStatus.isActive) { + await connectWS(item, col, environment, col?.runtimeVariables, { connectOnly: true }); } - } - if (codeType === 'xml') { - try { - const prettyBodyXML = xmlFormat(content, { collapseContent: true }); - - const currentMessages = [...(body.ws || [])]; - currentMessages[index] = { - ...currentMessages[index], - name: name ? name : `message ${index + 1}`, - content: prettyBodyXML - }; - - dispatch(updateRequestBody({ - content: currentMessages, - itemUid: item.uid, - collectionUid: collection.uid - })); - } catch (e) { - toastError(new Error('Unable to prettify. Invalid XML format.')); + const result = await queueWsMessage(item, col, environment, col?.runtimeVariables, index); + if (!result.success) { + toast.error(result.error || 'Failed to send message'); } + } catch (err) { + toast.error(err.message || 'Failed to send message'); } - }; - - const isSingleMessage = !canClientSendMultipleMessages || body.ws.length === 1; + }, [collections]); return ( - -
- Message {index + 1} -
- - - - - - - {index > 0 && ( - - - + { + if (!isSelected) setTimeout(onSelect, 0); + }} + > +
{ + if (e.target !== e.currentTarget) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onToggle(); + } + }} + > +
+ {isExpanded ? ( + + ) : ( + + )} + {isEditing ? ( + node?.focus()} + className="name-input" + data-testid={`ws-message-name-input-${index}`} + value={editValue} + onChange={(e) => setEditValue(e.target.value)} + onKeyDown={handleNameKeyDown} + onBlur={handleNameBlur} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + { + e.preventDefault(); + onToggle(); + }} + onDoubleClick={handleNameClick} + > + {displayName} + )}
+
e.stopPropagation()}> +
+ + + + {(body.ws || []).length > 1 && ( + + + + )} +
+ +
-
- -
+ {isExpanded && ( +
+ +
+ )}
); }; diff --git a/packages/bruno-app/src/components/RequestPane/WsBody/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/WsBody/StyledWrapper.js index b0ae614d9..e08f17db7 100644 --- a/packages/bruno-app/src/components/RequestPane/WsBody/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/WsBody/StyledWrapper.js @@ -5,21 +5,10 @@ const Wrapper = styled.div` flex-direction: column; width: 100%; height: 100%; - position: relative; .messages-container { flex: 1; - display: flex; - flex-direction: column; - - &.single { - height: 100%; - } - - &.multi { - overflow-y: auto; - padding-bottom: 48px; - } + overflow-y: auto; } .empty-state { @@ -36,13 +25,20 @@ const Wrapper = styled.div` } } - .add-message-footer { - position: absolute; - bottom: 0; - left: 0; - right: 0; - padding: 8px; - background: ${(props) => props.theme.bg}; + .add-message-link { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.875rem; + color: ${(props) => props.theme.primary.text}; + cursor: pointer; + background: none; + border: none; + padding: 4px 0; + + &:hover { + opacity: 0.8; + } } `; diff --git a/packages/bruno-app/src/components/RequestPane/WsBody/index.js b/packages/bruno-app/src/components/RequestPane/WsBody/index.js index 67ca5abc8..479ed05d4 100644 --- a/packages/bruno-app/src/components/RequestPane/WsBody/index.js +++ b/packages/bruno-app/src/components/RequestPane/WsBody/index.js @@ -1,99 +1,124 @@ import { get } from 'lodash'; import { updateRequestBody } from 'providers/ReduxStore/slices/collections'; import { IconPlus } from '@tabler/icons'; -import React, { useEffect, useRef } from 'react'; +import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useDispatch } from 'react-redux'; -import Button from 'ui/Button'; import StyledWrapper from './StyledWrapper'; import { SingleWSMessage } from './SingleWSMessage/index'; -const WSBody = ({ item, collection, handleRun }) => { +const getSelectedIndex = (messages) => { + const idx = messages.findIndex((msg) => msg.selected); + return idx >= 0 ? idx : 0; +}; + +const WSBody = ({ item, collection, handleRun, onAddMessage }) => { const dispatch = useDispatch(); const messagesContainerRef = useRef(null); const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body'); + const messages = body?.ws || []; - const methodType = item.draft ? get(item, 'draft.request.methodType') : get(item, 'request.methodType'); - const canClientSendMultipleMessages = false; + const selectedIndex = getSelectedIndex(messages); - // Auto-scroll to the latest message when messages are added - useEffect(() => { - if (messagesContainerRef.current && body?.ws?.length > 0) { - const container = messagesContainerRef.current; - container.scrollTop = container.scrollHeight; - } - }, [body?.ws?.length]); - - const addNewMessage = () => { - const currentMessages = Array.isArray(body.ws) ? [...body.ws] : []; - - currentMessages.push({ - name: `message ${currentMessages.length + 1}`, - content: '{}' - }); + // Expand the selected message by default (falls back to first) + const [expandedUids, setExpandedUids] = useState(() => { + const uid = messages[selectedIndex]?.uid || messages[0]?.uid; + return new Set(uid ? [uid] : []); + }); + const [newMessageUid, setNewMessageUid] = useState(null); + const prevMessagesLengthRef = useRef(messages.length); + const setSelectedIndex = useCallback((index) => { + const currentMessages = [...(body?.ws || [])]; + const updated = currentMessages.map((msg, i) => ({ + ...msg, + selected: i === index + })); dispatch(updateRequestBody({ - content: currentMessages, + content: updated, itemUid: item.uid, collectionUid: collection.uid })); - }; + }, [body, dispatch, item.uid, collection.uid]); - if (!body?.ws || !Array.isArray(body.ws)) { + const toggleMessage = useCallback((uid) => { + if (!uid) return; + setExpandedUids((prev) => { + const next = new Set(prev); + if (next.has(uid)) { + next.delete(uid); + } else { + next.add(uid); + } + return next; + }); + }, []); + + const handleSelect = useCallback((index) => { + if (index !== selectedIndex) { + setSelectedIndex(index); + } + }, [selectedIndex, setSelectedIndex]); + + // React to new message being added (messages.length increased) + useEffect(() => { + if (messages.length > prevMessagesLengthRef.current) { + const newMsg = messages[messages.length - 1]; + if (newMsg?.uid) { + setExpandedUids((prev) => new Set(prev).add(newMsg.uid)); + setNewMessageUid(newMsg.uid); + setSelectedIndex(messages.length - 1); + } + } + prevMessagesLengthRef.current = messages.length; + }, [messages.length]); + + const handleNewMessageRendered = useCallback(() => { + setNewMessageUid(null); + }, []); + + // Auto-scroll to bottom when new message is added + useEffect(() => { + if (messagesContainerRef.current && messages.length > 0) { + const container = messagesContainerRef.current; + container.scrollTop = container.scrollHeight; + } + }, [messages.length]); + + if (!messages.length) { return (

No WebSocket messages available

- +
); } - const messagesToShow = body.ws.filter((_, index) => canClientSendMultipleMessages || index === 0); - return ( -
1 ? 'multi' : 'single'}`} - > - {messagesToShow.map((message, index) => ( +
+ {messages.map((message, index) => ( toggleMessage(message.uid)} + isNew={newMessageUid === message.uid} + onNewRendered={handleNewMessageRendered} + isSelected={selectedIndex === index} + onSelect={() => handleSelect(index)} /> ))}
- - {canClientSendMultipleMessages && ( -
- -
- )} ); }; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 1e9842e10..f1e915863 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -582,7 +582,9 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => { toast.error(err.message); }); } else if (isWsRequest) { - sendWsRequest(itemCopy, collectionCopy, environment, collectionCopy.runtimeVariables) + const wsMessages = itemCopy.draft?.request?.body?.ws || itemCopy.request?.body?.ws || []; + const wsSelectedMessageIndex = Math.max(0, wsMessages.findIndex((msg) => msg.selected)); + sendWsRequest(itemCopy, collectionCopy, environment, collectionCopy.runtimeVariables, wsSelectedMessageIndex) .then(resolve) .catch((err) => { toast.error(err.message); @@ -1609,6 +1611,7 @@ export const newWsRequest = (params) => (dispatch, getState) => { mode: 'ws', ws: [ { + uid: uuid(), name: 'message 1', type: 'json', content: '{}' diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 0d3f45a9b..5586e491e 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -101,7 +101,8 @@ const REQUEST_UID_PATHS = [ 'assertions', 'body.formUrlEncoded', 'body.multipartForm', - 'body.file' + 'body.file', + 'body.ws' ]; const ROOT_UID_PATHS = ['request.headers', 'request.vars.req', 'request.vars.res']; diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 0cc1b508e..0b551b53b 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -785,10 +785,11 @@ export const transformRequestToSaveToFilesystem = (item) => { if (itemToSave.request.body.mode === 'ws') { itemToSave.request.body = { ...itemToSave.request.body, - ws: itemToSave.request.body.ws.map(({ name, content, type }, index) => ({ + ws: itemToSave.request.body.ws.map(({ name, content, type, selected }, index) => ({ name: name ? name : `message ${index + 1}`, type, - content: replaceTabsWithSpaces(content) + content: replaceTabsWithSpaces(content), + selected: selected || false })) }; } @@ -1014,6 +1015,7 @@ export const refreshUidsInItem = (item) => { each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid())); each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid())); each(get(item, 'request.body.file'), (param) => (param.uid = uuid())); + each(get(item, 'request.body.ws'), (msg) => (msg.uid = uuid())); each(get(item, 'request.assertions'), (assertion) => (assertion.uid = uuid())); return item; diff --git a/packages/bruno-app/src/utils/network/index.js b/packages/bruno-app/src/utils/network/index.js index b1a265276..3e9c906bd 100644 --- a/packages/bruno-app/src/utils/network/index.js +++ b/packages/bruno-app/src/utils/network/index.js @@ -224,7 +224,7 @@ export const connectWS = async (item, collection, environment, runtimeVariables, }); }; -export const sendWsRequest = async (item, collection, environment, runtimeVariables) => { +export const sendWsRequest = async (item, collection, environment, runtimeVariables, selectedMessageIndex = 0) => { const ensureConnection = async () => { const connectionStatus = await isWsConnectionActive(item.uid); if (!connectionStatus.isActive) { @@ -234,8 +234,8 @@ export const sendWsRequest = async (item, collection, environment, runtimeVariab await ensureConnection(); - // Use queueWsMessage helper to queue all messages with proper variable interpolation - const result = await queueWsMessage(item, collection, environment, runtimeVariables, null); + // Send only the selected message by index + const result = await queueWsMessage(item, collection, environment, runtimeVariables, selectedMessageIndex); if (result.success) { return {}; @@ -250,10 +250,10 @@ export const sendWsRequest = async (item, collection, environment, runtimeVariab * @param {Object} collection - The collection object * @param {Object} environment - The environment variables * @param {Object} runtimeVariables - The runtime variables - * @param {string} messageContent - The message content to queue (or null to queue all messages) + * @param {number} selectedMessageIndex - Index of the message to queue * @returns {Promise} - The result of the queue operation */ -export const queueWsMessage = async (item, collection, environment, runtimeVariables, messageContent) => { +export const queueWsMessage = async (item, collection, environment, runtimeVariables, selectedMessageIndex) => { return new Promise((resolve, reject) => { const { ipcRenderer } = window; ipcRenderer.invoke('renderer:ws:queue-message', { @@ -261,7 +261,7 @@ export const queueWsMessage = async (item, collection, environment, runtimeVaria collection, environment, runtimeVariables, - messageContent + selectedMessageIndex }).then(resolve).catch(reject); }); }; diff --git a/packages/bruno-converters/src/opencollection/items/websocket.ts b/packages/bruno-converters/src/opencollection/items/websocket.ts index 13055e2ce..15ddef9fe 100644 --- a/packages/bruno-converters/src/opencollection/items/websocket.ts +++ b/packages/bruno-converters/src/opencollection/items/websocket.ts @@ -45,7 +45,8 @@ export const fromOpenCollectionWebsocketItem = (item: WebSocketRequest): BrunoIt wsMessages.push({ name: m.title || `message ${index + 1}`, type: m.message?.type || 'json', - content: m.message?.data || '' + content: m.message?.data || '', + selected: m.selected || false }); }); } @@ -125,6 +126,7 @@ export const toOpenCollectionWebsocketItem = (item: BrunoItem): WebSocketRequest } else { websocket.message = messages.map((msg): WebSocketMessageVariant => ({ title: msg.name || 'Untitled', + ...(msg.selected ? { selected: true } : {}), message: { type: (msg.type as WebSocketMessage['type']) || 'json', data: msg.content || '' diff --git a/packages/bruno-electron/src/ipc/network/ws-event-handlers.js b/packages/bruno-electron/src/ipc/network/ws-event-handlers.js index b9aad047e..b93198383 100644 --- a/packages/bruno-electron/src/ipc/network/ws-event-handlers.js +++ b/packages/bruno-electron/src/ipc/network/ws-event-handlers.js @@ -400,35 +400,19 @@ const registerWsEventHandlers = (window) => { ipcMain.handle( 'renderer:ws:queue-message', - async (event, { item, collection, environment, runtimeVariables, messageContent }) => { + async (event, { item, collection, environment, runtimeVariables, selectedMessageIndex }) => { try { const itemCopy = cloneDeep(item); const preparedRequest = await prepareWsRequest(itemCopy, collection, environment, runtimeVariables, {}); - // If messageContent is provided, find and queue that specific message (interpolated) - // Otherwise, queue all messages - if (messageContent !== undefined && messageContent !== null) { - // Find the message index in the original request - const originalMessages = itemCopy.draft?.request?.body?.ws || itemCopy.request?.body?.ws || []; - const messageIndex = originalMessages.findIndex((msg) => msg.content === messageContent); + const messages = preparedRequest.body?.ws; + if (!messages || !Array.isArray(messages)) { + return { success: true }; + } - if (messageIndex >= 0 && preparedRequest.body?.ws?.[messageIndex]) { - // Queue the interpolated version of the specific message - const message = preparedRequest.body.ws[messageIndex]; - wsClient.queueMessage(preparedRequest.uid, collection.uid, message.content, message.type); - } else { - // Message not found in request body, queue as-is (shouldn't happen in normal flow) - wsClient.queueMessage(preparedRequest.uid, collection.uid, messageContent); - } - } else { - // Queue all messages (they are already interpolated by prepareWsRequest -> interpolateVars) - if (preparedRequest.body && preparedRequest.body.ws && Array.isArray(preparedRequest.body.ws)) { - preparedRequest.body.ws - .filter((message) => message && message.content) - .forEach((message) => { - wsClient.queueMessage(preparedRequest.uid, collection.uid, message.content, message.type); - }); - } + const message = messages[selectedMessageIndex]; + if (message && message.content) { + wsClient.queueMessage(preparedRequest.uid, collection.uid, message.content, message.type); } return { success: true }; diff --git a/packages/bruno-electron/src/utils/collection.js b/packages/bruno-electron/src/utils/collection.js index 39018e6cc..dc08f1b37 100644 --- a/packages/bruno-electron/src/utils/collection.js +++ b/packages/bruno-electron/src/utils/collection.js @@ -594,6 +594,8 @@ const hydrateRequestWithUuid = (request, pathname) => { bodyFormUrlEncoded.forEach((param) => (param.uid = uuid())); bodyMultipartForm.forEach((param) => (param.uid = uuid())); file.forEach((param) => (param.uid = uuid())); + const wsMessages = get(request, 'request.body.ws', []); + wsMessages.forEach((msg) => (msg.uid = uuid())); examples.forEach((example, eIndex) => { example.uid = getExampleUid(pathname, eIndex); example.itemUid = request.uid; diff --git a/packages/bruno-filestore/src/formats/yml/items/parseWebsocketRequest.ts b/packages/bruno-filestore/src/formats/yml/items/parseWebsocketRequest.ts index 14799ef56..79cceb416 100644 --- a/packages/bruno-filestore/src/formats/yml/items/parseWebsocketRequest.ts +++ b/packages/bruno-filestore/src/formats/yml/items/parseWebsocketRequest.ts @@ -1,6 +1,6 @@ import type { Item as BrunoItem } from '@usebruno/schema-types/collection/item'; import type { WebSocketRequest as BrunoWebSocketRequest } from '@usebruno/schema-types/requests/websocket'; -import type { WebSocketRequest, WebSocketMessage } from '@opencollection/types/requests/websocket'; +import type { WebSocketRequest, WebSocketMessage, WebSocketMessageVariant } from '@opencollection/types/requests/websocket'; import { toBrunoAuth } from '../common/auth'; import { toBrunoHttpHeaders } from '../common/headers'; import { toBrunoVariables } from '../common/variables'; @@ -35,14 +35,26 @@ const parseWebsocketRequest = (ocRequest: WebSocketRequest): BrunoItem => { // message if (websocket?.message) { - const message = websocket.message as WebSocketMessage; - const messageData = ensureString(message.data); - if (messageData.trim().length) { - brunoRequest.body.ws = [{ - name: '', - type: message.type || 'text', - content: messageData - }]; + if (Array.isArray(websocket.message)) { + // multiple messages: WebSocketMessageVariant[] + const variants = websocket.message as WebSocketMessageVariant[]; + brunoRequest.body.ws = variants.map((variant, index) => ({ + name: variant.title || `message ${index + 1}`, + type: variant.message?.type || 'text', + content: ensureString(variant.message?.data), + selected: variant.selected || false + })); + } else { + // single message uses flat WebSocketMessage + const message = websocket.message as WebSocketMessage; + const messageData = ensureString(message.data); + if (messageData.trim().length) { + brunoRequest.body.ws = [{ + name: '', + type: message.type || 'text', + content: messageData + }]; + } } } diff --git a/packages/bruno-filestore/src/formats/yml/items/stringifyWebsocketRequest.ts b/packages/bruno-filestore/src/formats/yml/items/stringifyWebsocketRequest.ts index 6a3d00fe7..0d3ead549 100644 --- a/packages/bruno-filestore/src/formats/yml/items/stringifyWebsocketRequest.ts +++ b/packages/bruno-filestore/src/formats/yml/items/stringifyWebsocketRequest.ts @@ -1,6 +1,6 @@ import type { Item as BrunoItem } from '@usebruno/schema-types/collection/item'; import type { WebSocketRequest as BrunoWebSocketRequest } from '@usebruno/schema-types/requests/websocket'; -import type { WebSocketRequest, WebSocketMessage, WebSocketRequestInfo, WebSocketRequestDetails, WebSocketRequestRuntime } from '@opencollection/types/requests/websocket'; +import type { WebSocketRequest, WebSocketRequestInfo, WebSocketRequestDetails, WebSocketRequestRuntime, WebSocketMessage, WebSocketMessageVariant } from '@opencollection/types/requests/websocket'; import type { Auth } from '@opencollection/types/common/auth'; import type { Scripts } from '@opencollection/types/common/scripts'; import type { Variable } from '@opencollection/types/common/variables'; @@ -41,21 +41,31 @@ const stringifyWebsocketRequest = (item: BrunoItem): string => { websocket.headers = headers; } - // message + // message: single message without a custom name uses flat WebSocketMessage (backward compatible), + // otherwise uses WebSocketMessageVariant[] to preserve names if (brunoRequest.body?.mode === 'ws' && brunoRequest.body.ws?.length) { const messages = brunoRequest.body.ws; + const hasCustomName = messages.length === 1 && messages[0].name && messages[0].name.trim().length > 0; - // todo: bruno app supports only one message for now - // update this when bruno app supports multiple messages - if (messages.length) { + const hasContent = messages.length === 1 && (messages[0].content || '').trim().length > 0; + + if (messages.length === 1 && !hasCustomName && hasContent) { const msg = messages[0]; const message: WebSocketMessage = { - type: (msg.type as 'text' | 'json' | 'xml' | 'binary') || 'text', + type: (msg.type as WebSocketMessage['type']) || 'text', data: msg.content || '' }; - if (message.data.trim().length) { - websocket.message = message; - } + websocket.message = message; + } else { + const variants: WebSocketMessageVariant[] = messages.map((msg, index) => ({ + title: msg.name || `message ${index + 1}`, + selected: msg.selected || false, + message: { + type: (msg.type as WebSocketMessage['type']) || 'text', + data: msg.content || '' + } + })); + websocket.message = variants; } } diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js index ecab5eee6..a07e09ca5 100644 --- a/packages/bruno-lang/v2/src/bruToJson.js +++ b/packages/bruno-lang/v2/src/bruToJson.js @@ -1159,10 +1159,12 @@ const sem = grammar.createSemantics().addAttribute('ast', { const namePair = _.find(pairs, { name: 'name' }); const contentPair = _.find(pairs, { name: 'content' }); const typePair = _.find(pairs, { name: 'type' }); + const selectedPair = _.find(pairs, { name: 'selected' }); const messageName = namePair ? namePair.value : ''; const messageContent = contentPair ? contentPair.value : ''; const messageTypeContent = typePair ? typePair.value : ''; + const messageSelected = selectedPair ? selectedPair.value === 'true' : false; return { body: { @@ -1171,7 +1173,8 @@ const sem = grammar.createSemantics().addAttribute('ast', { { name: messageName, type: messageTypeContent, - content: messageContent + content: messageContent, + selected: messageSelected } ] } diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/v2/src/jsonToBru.js index 0cce61d1f..de35e01d1 100644 --- a/packages/bruno-lang/v2/src/jsonToBru.js +++ b/packages/bruno-lang/v2/src/jsonToBru.js @@ -634,7 +634,7 @@ ${indentString(body.sparql)} // Convert each ws message to a separate body:ws block if (Array.isArray(body.ws)) { body.ws.forEach((message) => { - const { name, content, type = '' } = message; + const { name, content, type = '', selected } = message; bru += `body:ws {\n`; @@ -642,6 +642,9 @@ ${indentString(body.sparql)} if (type.length) { bru += `${indentString(`type: ${getValueString(type)}`)}\n`; } + if (selected) { + bru += `${indentString(`selected: true`)}\n`; + } // Convert content to JSON string if it's an object let contentValue = typeof content === 'object' ? JSON.stringify(content, null, 2) : content || '{}'; diff --git a/packages/bruno-lang/v2/tests/bruToJson.spec.js b/packages/bruno-lang/v2/tests/bruToJson.spec.js index b9b27a685..c018289da 100644 --- a/packages/bruno-lang/v2/tests/bruToJson.spec.js +++ b/packages/bruno-lang/v2/tests/bruToJson.spec.js @@ -9,7 +9,7 @@ body:ws { name: message 1 content: ''' {"foo":"bar"} - ''' + ''' } settings { @@ -24,7 +24,8 @@ settings { { content: '{"foo":"bar"}', name: 'message 1', - type: 'json' + type: 'json', + selected: false } ] }, @@ -37,6 +38,153 @@ settings { const output = parser(input); expect(output).toEqual(expected); }); + + it('parses a single message flagged with selected: true', () => { + const input = ` +body:ws { + type: json + name: message 1 + selected: true + content: ''' + {"foo":"bar"} + ''' +} +`; + + const expected = { + body: { + mode: 'ws', + ws: [ + { + content: '{"foo":"bar"}', + name: 'message 1', + type: 'json', + selected: true + } + ] + } + }; + + const output = parser(input); + expect(output).toEqual(expected); + }); + + it('parses multiple messages with none marked as selected', () => { + const input = ` +body:ws { + name: message 1 + type: json + content: ''' + {"action":"subscribe"} + ''' +} + +body:ws { + name: message 2 + type: text + content: ''' + hello world + ''' +} +`; + + const expected = { + body: { + mode: 'ws', + ws: [ + { + name: 'message 1', + type: 'json', + content: '{"action":"subscribe"}', + selected: false + }, + { + name: 'message 2', + type: 'text', + content: 'hello world', + selected: false + } + ] + } + }; + + const output = parser(input); + expect(output).toEqual(expected); + }); + + it('parses multiple messages with exactly one marked as selected', () => { + const input = ` +body:ws { + name: message 1 + type: json + content: ''' + {"action":"subscribe"} + ''' +} + +body:ws { + name: message 2 + type: text + selected: true + content: ''' + hello world + ''' +} + +body:ws { + name: message 3 + type: xml + content: ''' + + ''' +} +`; + + const expected = { + body: { + mode: 'ws', + ws: [ + { + name: 'message 1', + type: 'json', + content: '{"action":"subscribe"}', + selected: false + }, + { + name: 'message 2', + type: 'text', + content: 'hello world', + selected: true + }, + { + name: 'message 3', + type: 'xml', + content: '', + selected: false + } + ] + } + }; + + const output = parser(input); + expect(output).toEqual(expected); + }); + + it('treats selected: false as not selected', () => { + const input = ` +body:ws { + name: message 1 + type: text + selected: false + content: ''' + hello + ''' +} +`; + + const output = parser(input); + expect(output.body.ws[0].selected).toBe(false); + }); }); describe('body:grpc', () => { diff --git a/packages/bruno-schema-types/src/requests/websocket.ts b/packages/bruno-schema-types/src/requests/websocket.ts index 1781ebfc8..ce928f341 100644 --- a/packages/bruno-schema-types/src/requests/websocket.ts +++ b/packages/bruno-schema-types/src/requests/websocket.ts @@ -4,6 +4,7 @@ export interface WebSocketMessage { name?: string | null; type?: string | null; content?: string | null; + selected?: boolean | null; } export interface WebSocketRequestBody { diff --git a/tests/websockets/multi-message-bru/fixtures/collection/bruno.json b/tests/websockets/multi-message-bru/fixtures/collection/bruno.json new file mode 100644 index 000000000..95b292bea --- /dev/null +++ b/tests/websockets/multi-message-bru/fixtures/collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "ws-multi-message", + "type": "collection" +} \ No newline at end of file diff --git a/tests/websockets/multi-message-bru/fixtures/collection/collection.bru b/tests/websockets/multi-message-bru/fixtures/collection/collection.bru new file mode 100644 index 000000000..2492c42b1 --- /dev/null +++ b/tests/websockets/multi-message-bru/fixtures/collection/collection.bru @@ -0,0 +1,3 @@ +vars:pre-request { + variable: Variable Value +} diff --git a/tests/websockets/multi-message-bru/fixtures/collection/ws-multi-msg.bru b/tests/websockets/multi-message-bru/fixtures/collection/ws-multi-msg.bru new file mode 100644 index 000000000..cada227df --- /dev/null +++ b/tests/websockets/multi-message-bru/fixtures/collection/ws-multi-msg.bru @@ -0,0 +1,29 @@ +meta { + name: ws-multi-msg + type: ws + seq: 1 +} + +ws { + url: ws://localhost:8081/ws/echo + body: ws + auth: inherit +} + +body:ws { + name: message 1 + type: json + content: ''' + { + "action": "subscribe" + } + ''' +} + +body:ws { + name: message 2 + type: text + content: ''' + hello world + ''' +} diff --git a/tests/websockets/multi-message-bru/fixtures/collection/ws-single-msg.bru b/tests/websockets/multi-message-bru/fixtures/collection/ws-single-msg.bru new file mode 100644 index 000000000..297016c04 --- /dev/null +++ b/tests/websockets/multi-message-bru/fixtures/collection/ws-single-msg.bru @@ -0,0 +1,21 @@ +meta { + name: ws-single-msg + type: ws + seq: 2 +} + +ws { + url: ws://localhost:8081/ws/echo + body: ws + auth: inherit +} + +body:ws { + name: message 1 + type: json + content: ''' + { + "foo": "bar" + } + ''' +} diff --git a/tests/websockets/multi-message-bru/init-user-data/preferences.json b/tests/websockets/multi-message-bru/init-user-data/preferences.json new file mode 100644 index 000000000..73d96b809 --- /dev/null +++ b/tests/websockets/multi-message-bru/init-user-data/preferences.json @@ -0,0 +1,12 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{projectRoot}}/tests/websockets/multi-message-bru/fixtures/collection" + ], + "preferences": { + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + } + } +} \ No newline at end of file diff --git a/tests/websockets/multi-message-bru/message-name-style.spec.ts b/tests/websockets/multi-message-bru/message-name-style.spec.ts new file mode 100644 index 000000000..99d6f6111 --- /dev/null +++ b/tests/websockets/multi-message-bru/message-name-style.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from '../../../playwright'; +import { openRequest, closeAllCollections } from '../../utils/page/actions'; + +const COLLECTION_NAME = 'ws-multi-message'; +const SINGLE_MSG_REQ = 'ws-single-msg'; + +test.describe('websocket message name styling', () => { + test.afterAll(async ({ pageWithUserData: page }) => { + await closeAllCollections(page); + }); + + test('editable message name uses the text (I-beam) cursor', async ({ pageWithUserData: page }) => { + await openRequest(page, COLLECTION_NAME, SINGLE_MSG_REQ); + + await expect(page.getByTestId('ws-message-label-0')).toHaveCSS('cursor', 'text'); + }); + + test('long message name truncates instead of overflowing', async ({ pageWithUserData: page }) => { + await openRequest(page, COLLECTION_NAME, SINGLE_MSG_REQ); + + const longName = 'this is a very long websocket message name that should be truncated with an ellipsis'; + + // Rename the message to a name far wider than the row + await page.getByTestId('ws-message-label-0').dblclick(); + const nameInput = page.getByTestId('ws-message-name-input-0'); + await expect(nameInput).toBeVisible(); + await nameInput.selectText(); + await page.keyboard.type(longName); + await nameInput.press('Enter'); + + const messageLabel = page.getByTestId('ws-message-label-0').filter({ hasText: longName }); + await expect(messageLabel).toBeVisible(); + await expect(messageLabel).toHaveCSS('white-space', 'nowrap'); + await expect(messageLabel).toHaveCSS('text-overflow', 'ellipsis'); + }); +}); diff --git a/tests/websockets/multi-message-bru/multi-message.spec.ts b/tests/websockets/multi-message-bru/multi-message.spec.ts new file mode 100644 index 000000000..fbc41d983 --- /dev/null +++ b/tests/websockets/multi-message-bru/multi-message.spec.ts @@ -0,0 +1,256 @@ +import { expect, test } from '../../../playwright'; +import { buildWebsocketCommonLocators } from '../../utils/page/locators'; +import { openRequest, saveRequest, closeAllCollections } from '../../utils/page/actions'; +import { readFile, writeFile } from 'fs/promises'; +import { join } from 'path'; + +const COLLECTION_NAME = 'ws-multi-message'; +const MULTI_MSG_REQ = 'ws-multi-msg'; +const SINGLE_MSG_REQ = 'ws-single-msg'; +const MULTI_MSG_BRU_PATH = join(__dirname, 'fixtures/collection/ws-multi-msg.bru'); +const SINGLE_MSG_BRU_PATH = join(__dirname, 'fixtures/collection/ws-single-msg.bru'); +const MAX_CONNECTION_TIME = 3000; + +test.describe('websocket multi-message (bru format)', () => { + let originalMultiMsgData = ''; + let originalSingleMsgData = ''; + + test.beforeAll(async () => { + originalMultiMsgData = await readFile(MULTI_MSG_BRU_PATH, 'utf8'); + originalSingleMsgData = await readFile(SINGLE_MSG_BRU_PATH, 'utf8'); + }); + + test.afterEach(async () => { + await writeFile(MULTI_MSG_BRU_PATH, originalMultiMsgData, 'utf8'); + await writeFile(SINGLE_MSG_BRU_PATH, originalSingleMsgData, 'utf8'); + }); + + test.afterAll(async ({ pageWithUserData: page }) => { + await closeAllCollections(page); + }); + + test('add a new message and save', async ({ pageWithUserData: page }) => { + await openRequest(page, COLLECTION_NAME, MULTI_MSG_REQ); + + await page.getByTestId('ws-add-message').click(); + + const nameInput = page.getByTestId(/^ws-message-name-input-/); + await expect(nameInput).toBeVisible(); + + await nameInput.selectText(); + await page.keyboard.type('ping message'); + await nameInput.press('Enter'); + + await expect(page.getByTestId(/^ws-message-label-/).filter({ hasText: 'ping message' })).toBeVisible(); + await expect(page.getByTestId(/^ws-message-header-/)).toHaveCount(3); + + await saveRequest(page); + + const bruContent = await readFile(MULTI_MSG_BRU_PATH, 'utf8'); + expect(bruContent).toContain('name: ping message'); + }); + + test('edit message content and verify persistence', async ({ pageWithUserData: page }) => { + const selectAllShortcut = process.platform === 'darwin' ? 'Meta+a' : 'Control+a'; + + await openRequest(page, COLLECTION_NAME, SINGLE_MSG_REQ); + + // Expand the first message if not already expanded + const editorBody = page.getByTestId('ws-message-body-0'); + if (!(await editorBody.isVisible())) { + await page.getByTestId('ws-message-header-0').click(); + } + const editor = editorBody.locator('.CodeMirror'); + await editor.click(); + const textarea = editor.locator('textarea'); + await textarea.focus(); + await page.keyboard.press(selectAllShortcut); + await page.keyboard.insertText('{"updated": "content"}'); + + await saveRequest(page); + + const bruContent = await readFile(SINGLE_MSG_BRU_PATH, 'utf8'); + expect(bruContent).toContain('{"updated": "content"}'); + }); + + test('messages with different types persist correctly', async ({ pageWithUserData: page }) => { + await openRequest(page, COLLECTION_NAME, MULTI_MSG_REQ); + + const firstHeader = page.getByTestId('ws-message-header-0'); + await expect(firstHeader.locator('.selected-body-mode')).toContainText('JSON'); + + const secondHeader = page.getByTestId('ws-message-header-1'); + await expect(secondHeader.locator('.selected-body-mode')).toContainText('TEXT'); + + // Change message 1 type from json to xml + await firstHeader.locator('.body-mode-selector').click(); + await page.locator('.dropdown-item').filter({ hasText: 'XML' }).click(); + + await expect(firstHeader.locator('.selected-body-mode')).toContainText('XML'); + + await saveRequest(page); + + const bruContent = await readFile(MULTI_MSG_BRU_PATH, 'utf8'); + expect(bruContent).toContain('type: xml'); + expect(bruContent).toContain('type: text'); + + // Re-open to verify persistence + await openRequest(page, COLLECTION_NAME, SINGLE_MSG_REQ); + await openRequest(page, COLLECTION_NAME, MULTI_MSG_REQ); + + await expect(page.getByTestId('ws-message-header-0').locator('.selected-body-mode')).toContainText('XML'); + await expect(page.getByTestId('ws-message-header-1').locator('.selected-body-mode')).toContainText('TEXT'); + }); + + test('send selected message to active connection', async ({ pageWithUserData: page }) => { + const locators = buildWebsocketCommonLocators(page); + + await openRequest(page, COLLECTION_NAME, MULTI_MSG_REQ); + + await locators.connectionControls.connect().click(); + await expect(locators.connectionControls.disconnect()).toBeAttached({ + timeout: MAX_CONNECTION_TIME + }); + + const messageItems = locators.messages().locator('.text-ellipsis'); + const beforeCount = await messageItems.count(); + + // Click the main send button — sends the currently selected message + await page.getByTestId('run-button').click(); + + // Expect at least one new message (outgoing + echo response from server) + await expect.poll(() => messageItems.count(), { timeout: MAX_CONNECTION_TIME }).toBeGreaterThan(beforeCount); + + await locators.connectionControls.disconnect().click(); + await expect(locators.connectionControls.connect()).toBeVisible(); + }); + + test('first message is implicitly selected when no message is marked selected', async ({ pageWithUserData: page }) => { + const locators = buildWebsocketCommonLocators(page); + + await openRequest(page, COLLECTION_NAME, MULTI_MSG_REQ); + + // ws-multi-msg.bru has two messages with no `selected: true` flag. The + // main send button should therefore dispatch the first message. + await locators.connectionControls.connect().click(); + await expect(locators.connectionControls.disconnect()).toBeAttached({ + timeout: MAX_CONNECTION_TIME + }); + + await page.getByTestId('run-button').click(); + + // the first message's content ("subscribe"), and none should carry the + // second message's content ("hello world"). + await expect(locators.messages().filter({ hasText: 'subscribe' }).first()).toBeAttached({ + timeout: MAX_CONNECTION_TIME + }); + await expect(locators.messages().filter({ hasText: 'hello world' })).toHaveCount(0); + + await locators.connectionControls.disconnect().click(); + }); + + test('selecting a different message routes run-button to that message', async ({ pageWithUserData: page }) => { + const locators = buildWebsocketCommonLocators(page); + + await openRequest(page, COLLECTION_NAME, MULTI_MSG_REQ); + + // Select the second message by clicking its header + await page.getByTestId('ws-message-header-1').click(); + + await locators.connectionControls.connect().click(); + await expect(locators.connectionControls.disconnect()).toBeAttached({ + timeout: MAX_CONNECTION_TIME + }); + + await page.getByTestId('run-button').click(); + + await expect(locators.messages().filter({ hasText: 'hello world' }).first()).toBeAttached({ + timeout: MAX_CONNECTION_TIME + }); + await expect(locators.messages().filter({ hasText: 'subscribe' })).toHaveCount(0); + + await locators.connectionControls.disconnect().click(); + }); + + test('per-message send button sends that specific message', async ({ pageWithUserData: page }) => { + const locators = buildWebsocketCommonLocators(page); + + await openRequest(page, COLLECTION_NAME, MULTI_MSG_REQ); + + // Hover the header to reveal hover-actions, then click the second + await page.getByTestId('ws-message-header-1').hover(); + await page.getByTestId('ws-send-msg-1').click(); + + await expect(locators.connectionControls.disconnect()).toBeAttached({ + timeout: MAX_CONNECTION_TIME + }); + await expect(locators.messages().filter({ hasText: 'hello world' }).first()).toBeAttached({ + timeout: MAX_CONNECTION_TIME + }); + + await locators.connectionControls.disconnect().click(); + }); + + test('prettify json message content', async ({ pageWithUserData: page }) => { + const selectAllShortcut = process.platform === 'darwin' ? 'Meta+a' : 'Control+a'; + + await openRequest(page, COLLECTION_NAME, SINGLE_MSG_REQ); + + // Expand the first message if not already expanded + const editorBody = page.getByTestId('ws-message-body-0'); + if (!(await editorBody.isVisible())) { + await page.getByTestId('ws-message-header-0').click(); + } + const editor = editorBody.locator('.CodeMirror'); + await editor.click(); + const textarea = editor.locator('textarea'); + await textarea.focus(); + await page.keyboard.press(selectAllShortcut); + await page.keyboard.insertText('{"name":"bruno","version":"1.0"}'); + + await page.getByTestId('ws-prettify-all').click(); + + // Verify prettification split single line into multiple lines + const lineNumbers = await editor.locator('.CodeMirror-linenumber').count(); + expect(lineNumbers).toBeGreaterThan(1); + }); + + test('delete a message', async ({ pageWithUserData: page }) => { + await openRequest(page, COLLECTION_NAME, MULTI_MSG_REQ); + + await expect(page.getByTestId(/^ws-message-header-/)).toHaveCount(2); + + // Hover over the message header to reveal the delete button + await page.getByTestId('ws-message-header-1').hover(); + await page.getByTestId('ws-delete-msg-1').click(); + + await expect(page.getByTestId(/^ws-message-header-/)).toHaveCount(1); + + await saveRequest(page); + + const bruContent = await readFile(MULTI_MSG_BRU_PATH, 'utf8'); + const bodyWsCount = (bruContent.match(/body:ws/g) || []).length; + expect(bodyWsCount).toBe(1); + }); + + test('rename a message via double-click', async ({ pageWithUserData: page }) => { + await openRequest(page, COLLECTION_NAME, SINGLE_MSG_REQ); + + const messageLabel = page.getByTestId('ws-message-label-0'); + await messageLabel.dblclick(); + + const nameInput = page.getByTestId('ws-message-name-input-0'); + await expect(nameInput).toBeVisible(); + + await nameInput.selectText(); + await page.keyboard.type('subscribe request'); + await nameInput.press('Enter'); + + await expect(page.getByTestId('ws-message-label-0').filter({ hasText: 'subscribe request' })).toBeVisible(); + + await saveRequest(page); + + const bruContent = await readFile(SINGLE_MSG_BRU_PATH, 'utf8'); + expect(bruContent).toContain('name: subscribe request'); + }); +}); diff --git a/tests/websockets/multi-message-yml/fixtures/collection/opencollection.yml b/tests/websockets/multi-message-yml/fixtures/collection/opencollection.yml new file mode 100644 index 000000000..48e8110d4 --- /dev/null +++ b/tests/websockets/multi-message-yml/fixtures/collection/opencollection.yml @@ -0,0 +1,6 @@ +opencollection: '1.0.0' + +info: + name: ws-multi-message-yml + +bundled: false diff --git a/tests/websockets/multi-message-yml/fixtures/collection/ws-multi-msg.yml b/tests/websockets/multi-message-yml/fixtures/collection/ws-multi-msg.yml new file mode 100644 index 000000000..e4c1ac45b --- /dev/null +++ b/tests/websockets/multi-message-yml/fixtures/collection/ws-multi-msg.yml @@ -0,0 +1,24 @@ +info: + name: ws-multi-msg + type: websocket + seq: 1 + +websocket: + url: ws://localhost:8081/ws/echo + message: + - title: message 1 + message: + type: json + data: |- + { + "action": "subscribe" + } + - title: message 2 + message: + type: text + data: hello world + auth: inherit + +settings: + timeout: 0 + keepAliveInterval: 0 diff --git a/tests/websockets/multi-message-yml/fixtures/collection/ws-single-msg.yml b/tests/websockets/multi-message-yml/fixtures/collection/ws-single-msg.yml new file mode 100644 index 000000000..7afd34771 --- /dev/null +++ b/tests/websockets/multi-message-yml/fixtures/collection/ws-single-msg.yml @@ -0,0 +1,18 @@ +info: + name: ws-single-msg + type: websocket + seq: 2 + +websocket: + url: ws://localhost:8081/ws/echo + message: + type: json + data: |- + { + "foo": "bar" + } + auth: inherit + +settings: + timeout: 0 + keepAliveInterval: 0 diff --git a/tests/websockets/multi-message-yml/init-user-data/preferences.json b/tests/websockets/multi-message-yml/init-user-data/preferences.json new file mode 100644 index 000000000..b62afc1e9 --- /dev/null +++ b/tests/websockets/multi-message-yml/init-user-data/preferences.json @@ -0,0 +1,12 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{projectRoot}}/tests/websockets/multi-message-yml/fixtures/collection" + ], + "preferences": { + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + } + } +} \ No newline at end of file diff --git a/tests/websockets/multi-message-yml/multi-message.spec.ts b/tests/websockets/multi-message-yml/multi-message.spec.ts new file mode 100644 index 000000000..5fdaab951 --- /dev/null +++ b/tests/websockets/multi-message-yml/multi-message.spec.ts @@ -0,0 +1,294 @@ +import { expect, test } from '../../../playwright'; +import { buildWebsocketCommonLocators } from '../../utils/page/locators'; +import { openRequest, saveRequest, closeAllCollections } from '../../utils/page/actions'; +import { readFile, writeFile } from 'fs/promises'; +import { join } from 'path'; + +const COLLECTION_NAME = 'ws-multi-message-yml'; +const MULTI_MSG_REQ = 'ws-multi-msg'; +const SINGLE_MSG_REQ = 'ws-single-msg'; +const MULTI_MSG_YML_PATH = join(__dirname, 'fixtures/collection/ws-multi-msg.yml'); +const SINGLE_MSG_YML_PATH = join(__dirname, 'fixtures/collection/ws-single-msg.yml'); +const MAX_CONNECTION_TIME = 3000; + +test.describe('websocket multi-message (yml format)', () => { + let originalMultiMsgData = ''; + let originalSingleMsgData = ''; + + test.beforeAll(async () => { + originalMultiMsgData = await readFile(MULTI_MSG_YML_PATH, 'utf8'); + originalSingleMsgData = await readFile(SINGLE_MSG_YML_PATH, 'utf8'); + }); + + test.afterEach(async () => { + await writeFile(MULTI_MSG_YML_PATH, originalMultiMsgData, 'utf8'); + await writeFile(SINGLE_MSG_YML_PATH, originalSingleMsgData, 'utf8'); + }); + + test.afterAll(async ({ pageWithUserData: page }) => { + await closeAllCollections(page); + }); + + test('backward compatibility: old single-message format loads correctly', async ({ pageWithUserData: page }) => { + await openRequest(page, COLLECTION_NAME, SINGLE_MSG_REQ); + + // The old format (message: { type, data }) should load as a single accordion + await expect(page.getByTestId(/^ws-message-header-/)).toHaveCount(1); + + // Expand the first message if not already expanded + if (!(await page.getByTestId('ws-message-body-0').isVisible())) { + await page.getByTestId('ws-message-header-0').click(); + } + await expect(page.getByTestId('ws-message-body-0')).toBeVisible(); + + // Verify the type is correctly read from the old format + await expect(page.getByTestId('ws-message-header-0').locator('.selected-body-mode')).toContainText('JSON'); + + // Add a second message to trigger format migration + await page.getByTestId('ws-add-message').click(); + const nameInput = page.getByTestId(/^ws-message-name-input-/); + await expect(nameInput).toBeVisible(); + await nameInput.selectText(); + await page.keyboard.type('new message'); + await nameInput.press('Enter'); + + await saveRequest(page); + + // Verify the yml file now uses the array format (WebSocketMessageVariant[]) + const ymlContent = await readFile(SINGLE_MSG_YML_PATH, 'utf8'); + expect(ymlContent).toContain('- title:'); + expect(ymlContent).toContain('new message'); + + // Re-open to verify it still loads correctly after format migration + await openRequest(page, COLLECTION_NAME, MULTI_MSG_REQ); + await openRequest(page, COLLECTION_NAME, SINGLE_MSG_REQ); + + await expect(page.getByTestId(/^ws-message-header-/)).toHaveCount(2); + }); + + test('add a new message and save', async ({ pageWithUserData: page }) => { + await openRequest(page, COLLECTION_NAME, MULTI_MSG_REQ); + + await page.getByTestId('ws-add-message').click(); + + const nameInput = page.getByTestId(/^ws-message-name-input-/); + await expect(nameInput).toBeVisible(); + + await nameInput.selectText(); + await page.keyboard.type('ping message'); + await nameInput.press('Enter'); + + await expect(page.getByTestId(/^ws-message-label-/).filter({ hasText: 'ping message' })).toBeVisible(); + await expect(page.getByTestId(/^ws-message-header-/)).toHaveCount(3); + + await saveRequest(page); + + const ymlContent = await readFile(MULTI_MSG_YML_PATH, 'utf8'); + expect(ymlContent).toContain('ping message'); + }); + + test('edit message content and verify persistence', async ({ pageWithUserData: page }) => { + const selectAllShortcut = process.platform === 'darwin' ? 'Meta+a' : 'Control+a'; + + await openRequest(page, COLLECTION_NAME, SINGLE_MSG_REQ); + + // Expand the first message if not already expanded + const editorBody = page.getByTestId('ws-message-body-0'); + if (!(await editorBody.isVisible())) { + await page.getByTestId('ws-message-header-0').click(); + } + const editor = editorBody.locator('.CodeMirror'); + await editor.click(); + const textarea = editor.locator('textarea'); + await textarea.focus(); + await page.keyboard.press(selectAllShortcut); + await page.keyboard.insertText('{"updated": "content"}'); + + await saveRequest(page); + + const ymlContent = await readFile(SINGLE_MSG_YML_PATH, 'utf8'); + expect(ymlContent).toContain('{"updated": "content"}'); + }); + + test('messages with different types persist correctly', async ({ pageWithUserData: page }) => { + await openRequest(page, COLLECTION_NAME, MULTI_MSG_REQ); + + const firstHeader = page.getByTestId('ws-message-header-0'); + await expect(firstHeader.locator('.selected-body-mode')).toContainText('JSON'); + + const secondHeader = page.getByTestId('ws-message-header-1'); + await expect(secondHeader.locator('.selected-body-mode')).toContainText('TEXT'); + + // Change message 1 type from json to xml + await firstHeader.locator('.body-mode-selector').click(); + await page.locator('.dropdown-item').filter({ hasText: 'XML' }).click(); + + await expect(firstHeader.locator('.selected-body-mode')).toContainText('XML'); + + await saveRequest(page); + + const ymlContent = await readFile(MULTI_MSG_YML_PATH, 'utf8'); + expect(ymlContent).toContain('type: xml'); + expect(ymlContent).toContain('type: text'); + + // Re-open to verify persistence + await openRequest(page, COLLECTION_NAME, SINGLE_MSG_REQ); + await openRequest(page, COLLECTION_NAME, MULTI_MSG_REQ); + + await expect(page.getByTestId('ws-message-header-0').locator('.selected-body-mode')).toContainText('XML'); + await expect(page.getByTestId('ws-message-header-1').locator('.selected-body-mode')).toContainText('TEXT'); + }); + + test('send selected message to active connection', async ({ pageWithUserData: page }) => { + const locators = buildWebsocketCommonLocators(page); + + await openRequest(page, COLLECTION_NAME, MULTI_MSG_REQ); + + await locators.connectionControls.connect().click(); + await expect(locators.connectionControls.disconnect()).toBeAttached({ + timeout: MAX_CONNECTION_TIME + }); + + const messageItems = locators.messages().locator('.text-ellipsis'); + const beforeCount = await messageItems.count(); + + // Click the main send button — sends the currently selected message + await page.getByTestId('run-button').click(); + + // Expect at least one new message (outgoing + echo response from server) + await expect.poll(() => messageItems.count(), { timeout: MAX_CONNECTION_TIME }).toBeGreaterThan(beforeCount); + + await locators.connectionControls.disconnect().click(); + await expect(locators.connectionControls.connect()).toBeVisible(); + }); + + test('first message is implicitly selected when no message is marked selected', async ({ pageWithUserData: page }) => { + const locators = buildWebsocketCommonLocators(page); + + await openRequest(page, COLLECTION_NAME, MULTI_MSG_REQ); + + // ws-multi-msg.yml has two messages with no `selected: true` flag. The + // main send button should therefore dispatch the first message. + await locators.connectionControls.connect().click(); + await expect(locators.connectionControls.disconnect()).toBeAttached({ + timeout: MAX_CONNECTION_TIME + }); + + await page.getByTestId('run-button').click(); + + // the first message's content ("subscribe"), and none should carry the + // second message's content ("hello world"). + await expect(locators.messages().filter({ hasText: 'subscribe' }).first()).toBeAttached({ + timeout: MAX_CONNECTION_TIME + }); + await expect(locators.messages().filter({ hasText: 'hello world' })).toHaveCount(0); + + await locators.connectionControls.disconnect().click(); + }); + + test('selecting a different message routes run-button to that message', async ({ pageWithUserData: page }) => { + const locators = buildWebsocketCommonLocators(page); + + await openRequest(page, COLLECTION_NAME, MULTI_MSG_REQ); + + // Select the second message by clicking its header + await page.getByTestId('ws-message-header-1').click(); + + await locators.connectionControls.connect().click(); + await expect(locators.connectionControls.disconnect()).toBeAttached({ + timeout: MAX_CONNECTION_TIME + }); + + await page.getByTestId('run-button').click(); + + await expect(locators.messages().filter({ hasText: 'hello world' }).first()).toBeAttached({ + timeout: MAX_CONNECTION_TIME + }); + await expect(locators.messages().filter({ hasText: 'subscribe' })).toHaveCount(0); + + await locators.connectionControls.disconnect().click(); + }); + + test('per-message send button sends that specific message', async ({ pageWithUserData: page }) => { + const locators = buildWebsocketCommonLocators(page); + + await openRequest(page, COLLECTION_NAME, MULTI_MSG_REQ); + + // Hover the header to reveal hover-actions, then click the second + // message's send button + await page.getByTestId('ws-message-header-1').hover(); + await page.getByTestId('ws-send-msg-1').click(); + + await expect(locators.connectionControls.disconnect()).toBeAttached({ + timeout: MAX_CONNECTION_TIME + }); + await expect(locators.messages().filter({ hasText: 'hello world' }).first()).toBeAttached({ + timeout: MAX_CONNECTION_TIME + }); + + await locators.connectionControls.disconnect().click(); + }); + + test('prettify json message content', async ({ pageWithUserData: page }) => { + const selectAllShortcut = process.platform === 'darwin' ? 'Meta+a' : 'Control+a'; + + await openRequest(page, COLLECTION_NAME, SINGLE_MSG_REQ); + + // Expand the first message if not already expanded + const editorBody = page.getByTestId('ws-message-body-0'); + if (!(await editorBody.isVisible())) { + await page.getByTestId('ws-message-header-0').click(); + } + const editor = editorBody.locator('.CodeMirror'); + await editor.click(); + const textarea = editor.locator('textarea'); + await textarea.focus(); + await page.keyboard.press(selectAllShortcut); + await page.keyboard.insertText('{"name":"bruno","version":"1.0"}'); + + await page.getByTestId('ws-prettify-all').click(); + + // Verify prettification split single line into multiple lines + const lineNumbers = await editor.locator('.CodeMirror-linenumber').count(); + expect(lineNumbers).toBeGreaterThan(1); + }); + + test('delete a message', async ({ pageWithUserData: page }) => { + await openRequest(page, COLLECTION_NAME, MULTI_MSG_REQ); + + await expect(page.getByTestId(/^ws-message-header-/)).toHaveCount(2); + + // Hover over the message header to reveal the delete button + await page.getByTestId('ws-message-header-1').hover(); + await page.getByTestId('ws-delete-msg-1').click(); + + await expect(page.getByTestId(/^ws-message-header-/)).toHaveCount(1); + + await saveRequest(page); + + const ymlContent = await readFile(MULTI_MSG_YML_PATH, 'utf8'); + const titleCount = (ymlContent.match(/- title:/g) || []).length; + expect(titleCount).toBeLessThanOrEqual(1); + }); + + test('rename a message via double-click', async ({ pageWithUserData: page }) => { + await openRequest(page, COLLECTION_NAME, MULTI_MSG_REQ); + + const messageLabel = page.getByTestId('ws-message-label-0'); + await messageLabel.dblclick(); + + const nameInput = page.getByTestId('ws-message-name-input-0'); + await expect(nameInput).toBeVisible(); + + await nameInput.selectText(); + await page.keyboard.type('subscribe request'); + await nameInput.press('Enter'); + + await expect(page.getByTestId('ws-message-label-0').filter({ hasText: 'subscribe request' })).toBeVisible(); + + await saveRequest(page); + + const ymlContent = await readFile(MULTI_MSG_YML_PATH, 'utf8'); + expect(ymlContent).toContain('subscribe request'); + }); +});