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')