mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
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 <siddharth@usebruno.com>
This commit is contained in:
@@ -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 }) => {
|
||||
<WsBody
|
||||
item={item}
|
||||
collection={collection}
|
||||
hideModeSelector={true}
|
||||
hidePrettifyButton={true}
|
||||
handleRun={handleRun}
|
||||
onAddMessage={addNewMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -99,17 +164,41 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
|
||||
return <div className="mt-4">404 | Not found</div>;
|
||||
}
|
||||
}
|
||||
}, [requestPaneTab, item, collection, handleRun]);
|
||||
}, [requestPaneTab, item, collection, handleRun, addNewMessage]);
|
||||
|
||||
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
|
||||
return <div className="pb-4 px-4">An error occurred!</div>;
|
||||
}
|
||||
|
||||
const rightContent = requestPaneTab === 'auth' ? (
|
||||
let rightContent = null;
|
||||
if (requestPaneTab === 'auth') {
|
||||
rightContent = (
|
||||
<div ref={rightContentRef} className="flex flex-grow justify-start items-center">
|
||||
<WSAuthMode item={item} collection={collection} />
|
||||
</div>
|
||||
) : null;
|
||||
);
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col h-full relative">
|
||||
|
||||
@@ -1,61 +1,75 @@
|
||||
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;
|
||||
|
||||
.accordion-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.message-label {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.subtext1};
|
||||
margin-right: auto;
|
||||
cursor: text;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
gap: 0.125rem;
|
||||
|
||||
.toolbar-btn {
|
||||
.hover-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
.hover-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
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};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&.delete:hover {
|
||||
@@ -63,10 +77,16 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
&:hover .hover-actions {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.disabled) .accordion-header .message-label {
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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,84 +145,98 @@ export const SingleWSMessage = ({
|
||||
}));
|
||||
};
|
||||
|
||||
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') {
|
||||
const onSendMessage = useCallback(async () => {
|
||||
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.'));
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
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 (
|
||||
<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} />
|
||||
<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} />
|
||||
</button>
|
||||
</ToolHint>
|
||||
|
||||
{index > 0 && (
|
||||
<ToolHint text="Delete message" toolhintId={`delete-msg-${index}`}>
|
||||
<button onClick={onDeleteMessage} className="toolbar-btn delete">
|
||||
<IconTrash size={16} strokeWidth={1.5} />
|
||||
{(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} />
|
||||
</button>
|
||||
</ToolHint>
|
||||
)}
|
||||
</div>
|
||||
<WSRequestBodyMode mode={displayMode} onModeChange={onUpdateMessageType} />
|
||||
</div>
|
||||
<div className="editor-container">
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="accordion-body" data-testid={`ws-message-body-${index}`} style={{ height: editorHeight }}>
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
@@ -175,10 +246,11 @@ export const SingleWSMessage = ({
|
||||
onEdit={onEdit}
|
||||
onRun={handleRun}
|
||||
onSave={onSave}
|
||||
mode={codemirrorMode[codeType] ?? 'text/plain'}
|
||||
mode={codemirrorMode[displayMode] ?? 'text/plain'}
|
||||
enableVariableHighlighting={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<StyledWrapper>
|
||||
<div className="empty-state">
|
||||
<p>No WebSocket messages available</p>
|
||||
<Button
|
||||
onClick={addNewMessage}
|
||||
variant="filled"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon={<IconPlus size={14} strokeWidth={1.5} />}
|
||||
>
|
||||
Add Message
|
||||
</Button>
|
||||
<button className="add-message-link" data-testid="ws-add-message" onClick={onAddMessage}>
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
<span>Add message</span>
|
||||
</button>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const messagesToShow = body.ws.filter((_, index) => canClientSendMultipleMessages || index === 0);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className={`messages-container ${canClientSendMultipleMessages && messagesToShow.length > 1 ? 'multi' : 'single'}`}
|
||||
>
|
||||
{messagesToShow.map((message, index) => (
|
||||
<div ref={messagesContainerRef} className="messages-container">
|
||||
{messages.map((message, index) => (
|
||||
<SingleWSMessage
|
||||
key={index}
|
||||
key={message.uid}
|
||||
id={`ws-message-${message.uid}`}
|
||||
message={message}
|
||||
item={item}
|
||||
collection={collection}
|
||||
index={index}
|
||||
methodType={methodType}
|
||||
handleRun={handleRun}
|
||||
canClientSendMultipleMessages={canClientSendMultipleMessages}
|
||||
isLast={index === messagesToShow.length - 1}
|
||||
isExpanded={expandedUids.has(message.uid)}
|
||||
onToggle={() => toggleMessage(message.uid)}
|
||||
isNew={newMessageUid === message.uid}
|
||||
onNewRendered={handleNewMessageRendered}
|
||||
isSelected={selectedIndex === index}
|
||||
onSelect={() => handleSelect(index)}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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: '{}'
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Object>} - 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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 || ''
|
||||
|
||||
@@ -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];
|
||||
const message = messages[selectedMessageIndex];
|
||||
if (message && message.content) {
|
||||
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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,6 +35,17 @@ 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) {
|
||||
@@ -45,6 +56,7 @@ const parseWebsocketRequest = (ocRequest: WebSocketRequest): BrunoItem => {
|
||||
}];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scripts
|
||||
const scripts = toBrunoScripts(runtime?.scripts);
|
||||
|
||||
@@ -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;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 || '{}';
|
||||
|
||||
@@ -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: '''
|
||||
<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', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface WebSocketMessage {
|
||||
name?: string | null;
|
||||
type?: string | null;
|
||||
content?: string | null;
|
||||
selected?: boolean | null;
|
||||
}
|
||||
|
||||
export interface WebSocketRequestBody {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "ws-multi-message",
|
||||
"type": "collection"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
vars:pre-request {
|
||||
variable: Variable Value
|
||||
}
|
||||
@@ -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
|
||||
'''
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
'''
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"maximized": false,
|
||||
"lastOpenedCollections": [
|
||||
"{{projectRoot}}/tests/websockets/multi-message-bru/fixtures/collection"
|
||||
],
|
||||
"preferences": {
|
||||
"onboarding": {
|
||||
"hasLaunchedBefore": true,
|
||||
"hasSeenWelcomeModal": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
256
tests/websockets/multi-message-bru/multi-message.spec.ts
Normal file
256
tests/websockets/multi-message-bru/multi-message.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
opencollection: '1.0.0'
|
||||
|
||||
info:
|
||||
name: ws-multi-message-yml
|
||||
|
||||
bundled: false
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"maximized": false,
|
||||
"lastOpenedCollections": [
|
||||
"{{projectRoot}}/tests/websockets/multi-message-yml/fixtures/collection"
|
||||
],
|
||||
"preferences": {
|
||||
"onboarding": {
|
||||
"hasLaunchedBefore": true,
|
||||
"hasSeenWelcomeModal": true
|
||||
}
|
||||
}
|
||||
}
|
||||
294
tests/websockets/multi-message-yml/multi-message.spec.ts
Normal file
294
tests/websockets/multi-message-yml/multi-message.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user