Revert "feat: ws multi message (#7719)"

This reverts commit a305b41c93.
This commit is contained in:
Sid
2026-05-06 10:42:23 +05:30
committed by GitHub
parent ba42c22aad
commit 8258552854
29 changed files with 302 additions and 1351 deletions

View File

@@ -2,19 +2,12 @@ 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 ActionIcon from 'ui/ActionIcon';
import ToolHint from 'components/ToolHint/index';
import { IconPlus, IconWand } from '@tabler/icons';
import { find, get } from 'lodash';
import { find } 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';
@@ -31,8 +24,6 @@ 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({
@@ -43,63 +34,6 @@ 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');
@@ -143,8 +77,9 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
<WsBody
item={item}
collection={collection}
hideModeSelector={true}
hidePrettifyButton={true}
handleRun={handleRun}
onAddMessage={addNewMessage}
/>
);
}
@@ -164,41 +99,17 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
return <div className="mt-4">404 | Not found</div>;
}
}
}, [requestPaneTab, item, collection, handleRun, addNewMessage]);
}, [requestPaneTab, item, collection, handleRun]);
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
let rightContent = null;
if (requestPaneTab === 'auth') {
rightContent = (
const rightContent = requestPaneTab === 'auth' ? (
<div ref={rightContentRef} className="flex flex-grow justify-start items-center">
<WSAuthMode item={item} collection={collection} />
</div>
);
} else if (requestPaneTab === 'body') {
rightContent = (
<div ref={rightContentRef} className="flex items-center gap-2">
<ToolHint text="Prettify All" toolhintId="prettify-all-ws">
<ActionIcon
data-testid="ws-prettify-all"
onClick={onPrettifyAll}
>
<IconWand size={14} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Add Message" toolhintId="add-msg-ws">
<ActionIcon
data-testid="ws-add-message"
onClick={addNewMessage}
>
<IconPlus size={15} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
</div>
);
}
) : null;
return (
<StyledWrapper className="flex flex-col h-full relative">

View File

@@ -1,71 +1,61 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
border-bottom: 1px solid ${(props) => props.theme.border.border0};
transition: opacity 0.15s ease;
display: flex;
flex-direction: column;
&.disabled {
opacity: 0.45;
&.single {
height: 100%;
.editor-container {
height: calc(100% - 32px);
}
}
.accordion-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
cursor: pointer;
user-select: none;
&:not(.single) {
min-height: 240px;
margin-bottom: 8px;
.accordion-left {
&.last {
margin-bottom: 0;
}
}
.message-toolbar {
display: flex;
align-items: center;
gap: 0.375rem;
flex: 1;
min-width: 0;
color: ${(props) => props.theme.text};
justify-content: flex-end;
gap: 4px;
padding: 4px 0px;
padding-top: 0px;
height: 32px;
flex-shrink: 0;
.message-label {
font-size: ${(props) => props.theme.font.size.sm};
cursor: default;
color: ${(props) => props.theme.colors.text.subtext1};
margin-right: auto;
}
.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;
}
}
.accordion-actions {
.toolbar-actions {
display: flex;
align-items: center;
gap: 0.125rem;
gap: 2px;
}
.hover-actions {
display: flex;
align-items: center;
gap: 0.125rem;
visibility: hidden;
opacity: 0;
transition: opacity 0.15s ease;
.hover-action-btn {
.toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.25rem;
color: ${(props) => props.theme.text};
width: 28px;
height: 28px;
border-radius: 4px;
color: ${(props) => props.theme.colors.text.muted};
transition: all 0.15s ease;
&:hover {
background-color: ${(props) => props.theme.dropdown.hoverBg};
color: ${(props) => props.theme.text};
}
&.delete:hover {
@@ -73,16 +63,10 @@ const StyledWrapper = styled.div`
}
}
}
}
&:hover .hover-actions {
visibility: visible;
opacity: 1;
}
}
&:not(.disabled) .accordion-header .message-label {
color: ${(props) => props.theme.primary.text};
.editor-container {
flex: 1;
min-height: 0;
}
`;

View File

@@ -1,117 +1,56 @@
import { IconTrash, IconSend, IconChevronRight, IconChevronDown } from '@tabler/icons';
import { IconTrash, IconWand } 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, { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { queueWsMessage, isWsConnectionActive, connectWS } from 'utils/network/index';
import { findCollectionByUid, findEnvironmentInCollection } from 'utils/collections/index';
import toast from 'react-hot-toast';
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 WSRequestBodyMode from '../BodyMode/index';
import StyledWrapper from './StyledWrapper';
const codemirrorMode = {
text: 'application/text',
xml: 'application/xml',
json: 'application/ld+json'
export const TYPE_BY_DECODER = {
base64: 'binary',
json: 'json',
xml: 'xml'
};
// Maps stored type to display mode
const typeToMode = (type) => {
switch (type) {
case 'json': return 'json';
case 'xml': return 'xml';
default: return 'text';
}
};
export const DECODER_BY_TYPE = invert(TYPE_BY_DECODER);
export const SingleWSMessage = ({
message,
item,
collection,
index,
methodType,
handleRun,
isExpanded,
onToggle,
isNew,
onNewRendered,
isSelected,
onSelect
canClientSendMultipleMessages,
isLast
}) => {
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 displayMode = typeToMode(type);
const displayName = name || `message ${index + 1}`;
const [messageFormat, setMessageFormat] = useState(autoDetectLang(content));
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(displayName);
const onUpdateMessageType = (type) => {
setMessageFormat(type);
// 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],
name: trimmed
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
setIsEditing(false);
type: DECODER_BY_TYPE[type]
};
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,
@@ -121,11 +60,13 @@ export const SingleWSMessage = ({
const onEdit = (value) => {
const currentMessages = [...(body.ws || [])];
currentMessages[index] = {
...currentMessages[index],
name: name || `message ${index + 1}`,
name: name ? name : `message ${index + 1}`,
type: DECODER_BY_TYPE[messageFormat],
content: value
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
@@ -137,7 +78,9 @@ export const SingleWSMessage = ({
const onDeleteMessage = () => {
const currentMessages = [...(body.ws || [])];
currentMessages.splice(index, 1);
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
@@ -145,98 +88,84 @@ export const SingleWSMessage = ({
}));
};
const onSendMessage = useCallback(async () => {
let codeType = messageFormat;
if (TYPE_BY_DECODER[type]) {
codeType = TYPE_BY_DECODER[type];
}
const codemirrorMode = {
text: 'application/text',
xml: 'application/xml',
json: 'application/ld+json'
};
const onPrettify = () => {
if (codeType === 'json') {
try {
const col = findCollectionByUid(collections, collection.uid);
const environment = findEnvironmentInCollection(col, col?.activeEnvironmentUid);
// Auto-connect if not already connected
const connectionStatus = await isWsConnectionActive(item.uid);
if (!connectionStatus.isActive) {
await connectWS(item, col, environment, col?.runtimeVariables, { connectOnly: true });
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.'));
}
}
const result = await queueWsMessage(item, col, environment, col?.runtimeVariables, index);
if (!result.success) {
toast.error(result.error || 'Failed to send message');
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.'));
}
} catch (err) {
toast.error(err.message || 'Failed to send message');
}
}, [collections]);
};
const isSingleMessage = !canClientSendMultipleMessages || body.ws.length === 1;
return (
<StyledWrapper
className={!isSelected ? 'disabled' : ''}
onMouseDownCapture={() => {
if (!isSelected) setTimeout(onSelect, 0);
}}
>
<div
className="accordion-header"
data-testid={`ws-message-header-${index}`}
role="button"
tabIndex={0}
onClick={onToggle}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onToggle();
}
}}
>
<div className="accordion-left">
{isExpanded ? (
<IconChevronDown size={14} strokeWidth={2} />
) : (
<IconChevronRight size={14} strokeWidth={2} />
)}
{isEditing ? (
<input
ref={(node) => 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()}
/>
) : (
<span
className="message-label"
data-testid={`ws-message-label-${index}`}
onClick={(e) => {
e.preventDefault();
onToggle();
}}
onDoubleClick={handleNameClick}
>
{displayName}
</span>
)}
</div>
<div className="accordion-actions" onClick={(e) => e.stopPropagation()}>
<div className="hover-actions">
<ToolHint text="Send" toolhintId={`send-msg-${index}`}>
<button onClick={onSendMessage} className="hover-action-btn" data-testid={`ws-send-msg-${index}`}>
<IconSend size={14} strokeWidth={1.5} />
<StyledWrapper className={`message-container ${isSingleMessage ? 'single' : ''} ${isLast ? 'last' : ''}`}>
<div className="message-toolbar">
<span className="message-label">Message {index + 1}</span>
<div className="toolbar-actions">
<WSRequestBodyMode mode={messageFormat} onModeChange={onUpdateMessageType} />
<ToolHint text="Format" toolhintId={`prettify-msg-${index}`}>
<button onClick={onPrettify} className="toolbar-btn">
<IconWand size={16} strokeWidth={1.5} />
</button>
</ToolHint>
{(body.ws || []).length > 1 && (
<ToolHint text="Delete" toolhintId={`delete-msg-${index}`}>
<button onClick={onDeleteMessage} className="hover-action-btn delete" data-testid={`ws-delete-msg-${index}`}>
<IconTrash size={14} strokeWidth={1.5} />
{index > 0 && (
<ToolHint text="Delete message" toolhintId={`delete-msg-${index}`}>
<button onClick={onDeleteMessage} className="toolbar-btn delete">
<IconTrash size={16} strokeWidth={1.5} />
</button>
</ToolHint>
)}
</div>
<WSRequestBodyMode mode={displayMode} onModeChange={onUpdateMessageType} />
</div>
</div>
{isExpanded && (
<div className="accordion-body" data-testid={`ws-message-body-${index}`} style={{ height: editorHeight }}>
<div className="editor-container">
<CodeEditor
collection={collection}
theme={displayedTheme}
@@ -246,11 +175,10 @@ export const SingleWSMessage = ({
onEdit={onEdit}
onRun={handleRun}
onSave={onSave}
mode={codemirrorMode[displayMode] ?? 'text/plain'}
mode={codemirrorMode[codeType] ?? 'text/plain'}
enableVariableHighlighting={true}
/>
</div>
)}
</StyledWrapper>
);
};

View File

@@ -5,10 +5,21 @@ 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;
}
}
.empty-state {
@@ -25,20 +36,13 @@ const Wrapper = styled.div`
}
}
.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;
}
.add-message-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 8px;
background: ${(props) => props.theme.bg};
}
`;

View File

@@ -1,124 +1,99 @@
import { get } from 'lodash';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
import { IconPlus } from '@tabler/icons';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import React, { useEffect, useRef } from 'react';
import { useDispatch } from 'react-redux';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
import { SingleWSMessage } from './SingleWSMessage/index';
const getSelectedIndex = (messages) => {
const idx = messages.findIndex((msg) => msg.selected);
return idx >= 0 ? idx : 0;
};
const WSBody = ({ item, collection, handleRun, onAddMessage }) => {
const WSBody = ({ item, collection, handleRun }) => {
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 selectedIndex = getSelectedIndex(messages);
const methodType = item.draft ? get(item, 'draft.request.methodType') : get(item, 'request.methodType');
const canClientSendMultipleMessages = false;
// 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: updated,
itemUid: item.uid,
collectionUid: collection.uid
}));
}, [body, dispatch, item.uid, collection.uid]);
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)
// Auto-scroll to the latest message when messages are added
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) {
if (messagesContainerRef.current && body?.ws?.length > 0) {
const container = messagesContainerRef.current;
container.scrollTop = container.scrollHeight;
}
}, [messages.length]);
}, [body?.ws?.length]);
if (!messages.length) {
const addNewMessage = () => {
const currentMessages = Array.isArray(body.ws) ? [...body.ws] : [];
currentMessages.push({
name: `message ${currentMessages.length + 1}`,
content: '{}'
});
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
};
if (!body?.ws || !Array.isArray(body.ws)) {
return (
<StyledWrapper>
<div className="empty-state">
<p>No WebSocket messages available</p>
<button className="add-message-link" data-testid="ws-add-message" onClick={onAddMessage}>
<IconPlus size={14} strokeWidth={1.5} />
<span>Add message</span>
</button>
<Button
onClick={addNewMessage}
variant="filled"
color="secondary"
size="sm"
icon={<IconPlus size={14} strokeWidth={1.5} />}
>
Add Message
</Button>
</div>
</StyledWrapper>
);
}
const messagesToShow = body.ws.filter((_, index) => canClientSendMultipleMessages || index === 0);
return (
<StyledWrapper>
<div ref={messagesContainerRef} className="messages-container">
{messages.map((message, index) => (
<div
ref={messagesContainerRef}
className={`messages-container ${canClientSendMultipleMessages && messagesToShow.length > 1 ? 'multi' : 'single'}`}
>
{messagesToShow.map((message, index) => (
<SingleWSMessage
key={message.uid}
id={`ws-message-${message.uid}`}
key={index}
message={message}
item={item}
collection={collection}
index={index}
methodType={methodType}
handleRun={handleRun}
isExpanded={expandedUids.has(message.uid)}
onToggle={() => toggleMessage(message.uid)}
isNew={newMessageUid === message.uid}
onNewRendered={handleNewMessageRendered}
isSelected={selectedIndex === index}
onSelect={() => handleSelect(index)}
canClientSendMultipleMessages={canClientSendMultipleMessages}
isLast={index === messagesToShow.length - 1}
/>
))}
</div>
{canClientSendMultipleMessages && (
<div className="add-message-footer">
<Button
onClick={addNewMessage}
variant="filled"
color="secondary"
size="sm"
fullWidth
icon={<IconPlus size={14} strokeWidth={1.5} />}
>
Add Message
</Button>
</div>
)}
</StyledWrapper>
);
};

View File

@@ -577,9 +577,7 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
toast.error(err.message);
});
} else if (isWsRequest) {
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)
sendWsRequest(itemCopy, collectionCopy, environment, collectionCopy.runtimeVariables)
.then(resolve)
.catch((err) => {
toast.error(err.message);
@@ -1606,7 +1604,6 @@ export const newWsRequest = (params) => (dispatch, getState) => {
mode: 'ws',
ws: [
{
uid: uuid(),
name: 'message 1',
type: 'json',
content: '{}'

View File

@@ -100,8 +100,7 @@ const REQUEST_UID_PATHS = [
'assertions',
'body.formUrlEncoded',
'body.multipartForm',
'body.file',
'body.ws'
'body.file'
];
const ROOT_UID_PATHS = ['request.headers', 'request.vars.req', 'request.vars.res'];

View File

@@ -785,11 +785,10 @@ 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, selected }, index) => ({
ws: itemToSave.request.body.ws.map(({ name, content, type }, index) => ({
name: name ? name : `message ${index + 1}`,
type,
content: replaceTabsWithSpaces(content),
selected: selected || false
content: replaceTabsWithSpaces(content)
}))
};
}
@@ -1015,7 +1014,6 @@ 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;

View File

@@ -224,7 +224,7 @@ export const connectWS = async (item, collection, environment, runtimeVariables,
});
};
export const sendWsRequest = async (item, collection, environment, runtimeVariables, selectedMessageIndex = 0) => {
export const sendWsRequest = async (item, collection, environment, runtimeVariables) => {
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();
// Send only the selected message by index
const result = await queueWsMessage(item, collection, environment, runtimeVariables, selectedMessageIndex);
// Use queueWsMessage helper to queue all messages with proper variable interpolation
const result = await queueWsMessage(item, collection, environment, runtimeVariables, null);
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 {number} selectedMessageIndex - Index of the message to queue
* @param {string} messageContent - The message content to queue (or null to queue all messages)
* @returns {Promise<Object>} - The result of the queue operation
*/
export const queueWsMessage = async (item, collection, environment, runtimeVariables, selectedMessageIndex) => {
export const queueWsMessage = async (item, collection, environment, runtimeVariables, messageContent) => {
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,
selectedMessageIndex
messageContent
}).then(resolve).catch(reject);
});
};

View File

@@ -45,8 +45,7 @@ export const fromOpenCollectionWebsocketItem = (item: WebSocketRequest): BrunoIt
wsMessages.push({
name: m.title || `message ${index + 1}`,
type: m.message?.type || 'json',
content: m.message?.data || '',
selected: m.selected || false
content: m.message?.data || ''
});
});
}
@@ -126,7 +125,6 @@ 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 || ''

View File

@@ -400,19 +400,35 @@ const registerWsEventHandlers = (window) => {
ipcMain.handle(
'renderer:ws:queue-message',
async (event, { item, collection, environment, runtimeVariables, selectedMessageIndex }) => {
async (event, { item, collection, environment, runtimeVariables, messageContent }) => {
try {
const itemCopy = cloneDeep(item);
const preparedRequest = await prepareWsRequest(itemCopy, collection, environment, runtimeVariables, {});
const messages = preparedRequest.body?.ws;
if (!messages || !Array.isArray(messages)) {
return { success: true };
}
// 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 message = messages[selectedMessageIndex];
if (message && message.content) {
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);
});
}
}
return { success: true };

View File

@@ -579,8 +579,6 @@ 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;

View File

@@ -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, WebSocketMessageVariant } from '@opencollection/types/requests/websocket';
import type { WebSocketRequest, WebSocketMessage } from '@opencollection/types/requests/websocket';
import { toBrunoAuth } from '../common/auth';
import { toBrunoHttpHeaders } from '../common/headers';
import { toBrunoVariables } from '../common/variables';
@@ -35,17 +35,6 @@ const parseWebsocketRequest = (ocRequest: WebSocketRequest): BrunoItem => {
// message
if (websocket?.message) {
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) {
@@ -56,7 +45,6 @@ const parseWebsocketRequest = (ocRequest: WebSocketRequest): BrunoItem => {
}];
}
}
}
// scripts
const scripts = toBrunoScripts(runtime?.scripts);

View File

@@ -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, WebSocketRequestInfo, WebSocketRequestDetails, WebSocketRequestRuntime, WebSocketMessage, WebSocketMessageVariant } from '@opencollection/types/requests/websocket';
import type { WebSocketRequest, WebSocketMessage, WebSocketRequestInfo, WebSocketRequestDetails, WebSocketRequestRuntime } 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,31 +41,21 @@ const stringifyWebsocketRequest = (item: BrunoItem): string => {
websocket.headers = headers;
}
// message: single message without a custom name uses flat WebSocketMessage (backward compatible),
// otherwise uses WebSocketMessageVariant[] to preserve names
// message
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;
const hasContent = messages.length === 1 && (messages[0].content || '').trim().length > 0;
if (messages.length === 1 && !hasCustomName && hasContent) {
// todo: bruno app supports only one message for now
// update this when bruno app supports multiple messages
if (messages.length) {
const msg = messages[0];
const message: WebSocketMessage = {
type: (msg.type as WebSocketMessage['type']) || 'text',
type: (msg.type as 'text' | 'json' | 'xml' | 'binary') || 'text',
data: msg.content || ''
};
if (message.data.trim().length) {
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;
}
}

View File

@@ -1159,12 +1159,10 @@ 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: {
@@ -1173,8 +1171,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
{
name: messageName,
type: messageTypeContent,
content: messageContent,
selected: messageSelected
content: messageContent
}
]
}

View File

@@ -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 = '', selected } = message;
const { name, content, type = '' } = message;
bru += `body:ws {\n`;
@@ -642,9 +642,6 @@ ${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 || '{}';

View File

@@ -24,8 +24,7 @@ settings {
{
content: '{"foo":"bar"}',
name: 'message 1',
type: 'json',
selected: false
type: 'json'
}
]
},
@@ -38,153 +37,6 @@ 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: '''
<ping/>
'''
}
`;
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: '<ping/>',
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', () => {

View File

@@ -4,7 +4,6 @@ export interface WebSocketMessage {
name?: string | null;
type?: string | null;
content?: string | null;
selected?: boolean | null;
}
export interface WebSocketRequestBody {

View File

@@ -1,5 +0,0 @@
{
"version": "1",
"name": "ws-multi-message",
"type": "collection"
}

View File

@@ -1,3 +0,0 @@
vars:pre-request {
variable: Variable Value
}

View File

@@ -1,29 +0,0 @@
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
'''
}

View File

@@ -1,21 +0,0 @@
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"
}
'''
}

View File

@@ -1,12 +0,0 @@
{
"maximized": false,
"lastOpenedCollections": [
"{{projectRoot}}/tests/websockets/multi-message-bru/fixtures/collection"
],
"preferences": {
"onboarding": {
"hasLaunchedBefore": true,
"hasSeenWelcomeModal": true
}
}
}

View File

@@ -1,256 +0,0 @@
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');
});
});

View File

@@ -1,6 +0,0 @@
opencollection: '1.0.0'
info:
name: ws-multi-message-yml
bundled: false

View File

@@ -1,24 +0,0 @@
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

View File

@@ -1,18 +0,0 @@
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

View File

@@ -1,12 +0,0 @@
{
"maximized": false,
"lastOpenedCollections": [
"{{projectRoot}}/tests/websockets/multi-message-yml/fixtures/collection"
],
"preferences": {
"onboarding": {
"hasLaunchedBefore": true,
"hasSeenWelcomeModal": true
}
}
}

View File

@@ -1,294 +0,0 @@
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');
});
});