diff --git a/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js b/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js
index 0433da1cd..8261d3755 100644
--- a/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js
+++ b/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js
@@ -39,7 +39,7 @@ const MessageToolbar = ({
-
diff --git a/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js b/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js
index 9f54e3c2e..e16eca92f 100644
--- a/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js
@@ -281,6 +281,7 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation, initi
{
dropdownTippyRef.current.hide();
setShowFileFormat(!showFileFormat);
diff --git a/packages/bruno-filestore/src/formats/yml/items/parseGrpcRequest.ts b/packages/bruno-filestore/src/formats/yml/items/parseGrpcRequest.ts
index f38a6ffe4..9ed478f7d 100644
--- a/packages/bruno-filestore/src/formats/yml/items/parseGrpcRequest.ts
+++ b/packages/bruno-filestore/src/formats/yml/items/parseGrpcRequest.ts
@@ -1,6 +1,6 @@
import type { Item as BrunoItem } from '@usebruno/schema-types/collection/item';
import type { GrpcRequest as BrunoGrpcRequest } from '@usebruno/schema-types/requests/grpc';
-import type { GrpcRequest, GrpcMetadata } from '@opencollection/types/requests/grpc';
+import type { GrpcRequest, GrpcMetadata, GrpcMessageVariant } from '@opencollection/types/requests/grpc';
import type { KeyValue as BrunoKeyValue } from '@usebruno/schema-types/common/key-value';
import { toBrunoAuth } from '../common/auth';
import { toBrunoVariables } from '../common/variables';
@@ -57,10 +57,16 @@ const parseGrpcRequest = (ocRequest: GrpcRequest): BrunoItem => {
};
// message
- if (isNonEmptyString(grpc?.message)) {
+ const rawMessage = grpc?.message;
+ if (Array.isArray(rawMessage)) {
+ brunoRequest.body.grpc = (rawMessage as GrpcMessageVariant[]).map(({ title, message }, index) => ({
+ name: title || `message ${index + 1}`,
+ content: ensureString(message)
+ }));
+ } else if (isNonEmptyString(rawMessage)) {
brunoRequest.body.grpc = [{
name: '',
- content: grpc?.message as string
+ content: rawMessage as string
}];
}
diff --git a/packages/bruno-filestore/src/formats/yml/items/stringifyGrpcRequest.ts b/packages/bruno-filestore/src/formats/yml/items/stringifyGrpcRequest.ts
index 603a16cd0..469584618 100644
--- a/packages/bruno-filestore/src/formats/yml/items/stringifyGrpcRequest.ts
+++ b/packages/bruno-filestore/src/formats/yml/items/stringifyGrpcRequest.ts
@@ -1,7 +1,7 @@
import type { Item as BrunoItem } from '@usebruno/schema-types/collection/item';
import type { KeyValue as BrunoKeyValue } from '@usebruno/schema-types/common/key-value';
import type { GrpcRequest as BrunoGrpcRequest } from '@usebruno/schema-types/requests/grpc';
-import type { GrpcRequest, GrpcMetadata, GrpcMessage, GrpcRequestInfo, GrpcRequestDetails, GrpcRequestRuntime } from '@opencollection/types/requests/grpc';
+import type { GrpcRequest, GrpcMetadata, GrpcMessageVariant, GrpcMessage, GrpcRequestInfo, GrpcRequestDetails, GrpcRequestRuntime } from '@opencollection/types/requests/grpc';
import type { Auth } from '@opencollection/types/common/auth';
import type { Scripts } from '@opencollection/types/common/scripts';
import type { Variable } from '@opencollection/types/common/variables';
@@ -73,16 +73,11 @@ const stringifyGrpcRequest = (item: BrunoItem): string => {
// message
if (brunoRequest.body?.mode === 'grpc' && brunoRequest.body.grpc?.length) {
- const messages = brunoRequest.body.grpc;
-
- // todo: bruno app supports only one message for now
- // update this when bruno app supports multiple messages
- if (messages.length) {
- const message: GrpcMessage = messages[0].content || '';
- if (message.trim().length) {
- grpc.message = message;
- }
- }
+ const messages: GrpcMessageVariant[] = brunoRequest.body.grpc.map(({ name, content }, index) => ({
+ title: name || `message ${index + 1}`,
+ message: content || ''
+ }));
+ grpc.message = messages;
}
// auth
diff --git a/tests/grpc/multi-message-yml/multi-message.spec.ts b/tests/grpc/multi-message-yml/multi-message.spec.ts
new file mode 100644
index 000000000..401861d3d
--- /dev/null
+++ b/tests/grpc/multi-message-yml/multi-message.spec.ts
@@ -0,0 +1,70 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import yaml from 'js-yaml';
+import { bruToJsonV2 } from '@usebruno/lang';
+import { expect, test } from '../../../playwright';
+import {
+ addGrpcMessage,
+ createCollection,
+ createRequest,
+ generateGrpcSampleMessage,
+ saveRequest,
+ selectGrpcMethod
+} from '../../utils/page/actions';
+
+const REQUEST_NAME = 'grpc-multi-msg';
+const GRPC_URL = 'grpcb.in:9000';
+const GRPC_METHOD = 'BidiHello';
+
+type GrpcRequestYml = {
+ grpc?: {
+ message?: { title: string; message: string }[];
+ };
+};
+
+const FORMATS = [
+ { format: 'yml', collectionName: 'grpc-yml-multi-msg', tmpDirPrefix: 'grpc-yml-collection' },
+ { format: 'bru', collectionName: 'grpc-bru-multi-msg', tmpDirPrefix: 'grpc-bru-collection' }
+] as const;
+
+for (const { format, collectionName, tmpDirPrefix } of FORMATS) {
+ test.describe.serial(`grpc multi-message (${format} format)`, () => {
+ let collectionPath: string;
+
+ test('creates a gRPC request with multiple messages and saves it', async ({ page, createTmpDir }) => {
+ collectionPath = await createTmpDir(tmpDirPrefix);
+
+ await createCollection(page, collectionName, collectionPath, format);
+ await createRequest(page, REQUEST_NAME, collectionName, { url: GRPC_URL, requestType: 'grpc' });
+ await selectGrpcMethod(page, GRPC_METHOD);
+
+ await addGrpcMessage(page);
+ await addGrpcMessage(page);
+ await generateGrpcSampleMessage(page, 0);
+ await generateGrpcSampleMessage(page, 1);
+ await generateGrpcSampleMessage(page, 2);
+
+ await saveRequest(page);
+
+ const messageContainers = page.getByTestId('grpc-messages-container').locator('.message-container');
+ await expect(messageContainers).toHaveCount(3, { timeout: 5000 });
+ });
+
+ test(`verifies all messages are saved in the request .${format} file`, async () => {
+ const requestFilePath = path.join(collectionPath, collectionName, `${REQUEST_NAME}.${format}`);
+ expect(fs.existsSync(requestFilePath)).toBe(true);
+
+ const fileContent = fs.readFileSync(requestFilePath, 'utf8');
+
+ if (format === 'yml') {
+ const parsed = yaml.load(fileContent) as GrpcRequestYml;
+ const messages = parsed.grpc?.message ?? [];
+ expect(messages.length).toBe(3);
+ } else if (format === 'bru') {
+ const parsed = bruToJsonV2(fileContent) as { body?: { grpc?: { name: string; content: string }[] } };
+ const messages = parsed.body?.grpc ?? [];
+ expect(messages.length).toBe(3);
+ }
+ });
+ });
+}
diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts
index bc8bfe451..ea958fa04 100644
--- a/tests/utils/page/actions.ts
+++ b/tests/utils/page/actions.ts
@@ -1,10 +1,12 @@
import { test, expect, Page, ElectronApplication, waitForReadyPage as waitForReadyPageImpl } from '../../../playwright';
import process from 'node:process';
import * as path from 'path';
-import { buildCommonLocators, buildScriptErrorLocators } from './locators';
+import { buildCommonLocators, buildScriptErrorLocators, buildGrpcCommonLocators } from './locators';
type SandboxMode = 'safe' | 'developer';
+type CollectionFormat = 'bru' | 'yml';
+
type WaitForAppReadyOptions = {
timeout?: number;
};
@@ -101,9 +103,9 @@ const createCollection = async (
page,
collectionName: string,
collectionLocation: string,
- format?: 'bru' | 'yml'
+ format: CollectionFormat = 'yml'
) => {
- await test.step(`Create collection "${collectionName}"`, async () => {
+ await test.step(`Create ${format} collection "${collectionName}"`, async () => {
await page.getByTestId('collections-header-add-menu').click();
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
@@ -1335,6 +1337,44 @@ const saveRequest = async (page: Page) => {
});
};
+/**
+ * Click the gRPC "Add Message" button to append a new message to the request
+ * @param page - The page object
+ */
+const addGrpcMessage = async (page: Page) => {
+ await test.step('Add gRPC message', async () => {
+ const locators = buildGrpcCommonLocators(page);
+ await locators.request.addMessageButton().click();
+ });
+};
+
+/**
+ * Click the "Generate sample" button on a gRPC message to populate it with a sample payload
+ * @param page - The page object
+ * @param index - The 0-based index of the message (default: 0)
+ */
+const generateGrpcSampleMessage = async (page: Page, index: number = 0) => {
+ await test.step(`Generate sample for gRPC message #${index}`, async () => {
+ const locators = buildGrpcCommonLocators(page);
+ await locators.request.regenerateMessage(index).click();
+ });
+};
+
+/**
+ * Open the gRPC method dropdown and select a method by name
+ * @param page - The page object
+ * @param methodName - The name of the gRPC method to select (e.g. "BidiHello")
+ */
+const selectGrpcMethod = async (page: Page, methodName: string) => {
+ await test.step(`Select gRPC method "${methodName}"`, async () => {
+ const locators = buildGrpcCommonLocators(page);
+ await locators.method.dropdownTrigger().click();
+ await locators.method.dropdown().waitFor({ state: 'visible', timeout: 5000 });
+ await locators.method.item(methodName).first().click();
+ await expect(locators.method.selectedName()).toContainText(methodName);
+ });
+};
+
/**
* Close all open request tabs using the right-click context menu
* @param page - The page object
@@ -1756,6 +1796,9 @@ export {
editAssertion,
deleteAssertion,
saveRequest,
+ addGrpcMessage,
+ generateGrpcSampleMessage,
+ selectGrpcMethod,
closeAllTabs,
createWorkspace,
switchWorkspace,
diff --git a/tests/utils/page/locators.ts b/tests/utils/page/locators.ts
index 5aed3501e..662c25af4 100644
--- a/tests/utils/page/locators.ts
+++ b/tests/utils/page/locators.ts
@@ -237,13 +237,18 @@ export const buildGrpcCommonLocators = (page: Page) => ({
...buildCommonLocators(page),
method: {
dropdownTrigger: () => page.getByTestId('grpc-method-dropdown-trigger'),
- indicator: () => page.getByTestId('grpc-method-indicator')
+ indicator: () => page.getByTestId('grpc-method-indicator'),
+ dropdown: () => page.getByTestId('grpc-methods-dropdown'),
+ item: (methodName: string) =>
+ page.getByTestId('grpc-methods-dropdown').getByTestId('grpc-method-item').filter({ hasText: methodName }),
+ selectedName: () => page.getByTestId('selected-grpc-method-name')
},
request: {
queryUrlContainer: () => page.getByTestId('grpc-query-url-container'),
sendButton: () => page.getByTestId('grpc-send-request-button'),
messagesContainer: () => page.getByTestId('grpc-messages-container'),
addMessageButton: () => page.getByTestId('grpc-add-message-button'),
+ regenerateMessage: (index: number) => page.getByTestId(`grpc-regenerate-message-${index}`),
sendMessage: (index: number) => page.getByTestId(`grpc-send-message-${index}`),
endConnectionButton: () => page.getByTestId('grpc-end-connection-button'),
cancelConnectionButton: () => page.getByTestId('grpc-cancel-connection-button')