From 3b8fe0cf36ba8e0d2eee3876af0fc4ecde970d38 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Fri, 7 Aug 2026 18:19:09 +0530 Subject: [PATCH 01/18] feat(docs): render gRPC requests as read-only documentation pages gRPC requests previously fell through to the "preview not available" state. They now get a docs page showing the proto file name, the RPC method and its call type, request messages, metadata and auth, with generated grpcURL and JavaScript snippets alongside. The JavaScript snippet is only offered when a proto file is attached, since @grpc/proto-loader builds the client from a file on disk and cannot read a service definition from server reflection. The playground still cannot execute gRPC, so the unsupported-request predicate is split into separate docs and playground checks. Ref: BRU-3719 --- .../e2e/pages/grpc-request.page.ts | 51 +++ .../e2e/playwright/pages.fixture.ts | 5 + .../e2e/tests/folder/folder.spec.ts | 2 +- .../e2e/tests/overview/overview.spec.ts | 4 +- .../e2e/tests/request/grpc-request.spec.ts | 144 ++++++++ .../tests/request/unsupported-request.spec.ts | 3 +- .../src/assets/icons/BidiStreamingIcon.tsx | 22 ++ .../src/assets/icons/ClientStreamingIcon.tsx | 15 + .../src/assets/icons/ServerStreamingIcon.tsx | 22 ++ .../src/assets/icons/UnaryIcon.tsx | 15 + .../bruno-api-docs/src/assets/icons/index.ts | 4 + .../CodeSnippetTabs/CodeSnippetTabs.tsx | 125 ++----- .../GrpcMessageCard/GrpcMessageCard.spec.tsx | 24 ++ .../GrpcMessageCard/GrpcMessageCard.tsx | 65 ++++ .../GrpcMessageCard/StyledWrapper.ts | 71 ++++ .../GrpcMessages/GrpcMessages.spec.tsx | 35 ++ .../GrpcMessages/GrpcMessages.tsx | 76 ++++ .../GrpcMessages/StyledWrapper.ts | 39 ++ .../GrpcMetadataTable.spec.tsx | 51 +++ .../GrpcMetadataTable/GrpcMetadataTable.tsx | 56 +++ .../GrpcMetadataTable/StyledWrapper.ts | 25 ++ .../GrpcMethodTypeIcon.spec.tsx | 26 ++ .../GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx | 36 ++ .../GrpcMethodTypeIcon/StyledWrapper.ts | 6 + .../GrpcRequestContent.spec.tsx | 301 ++++++++++++++++ .../GrpcRequestContent/GrpcRequestContent.tsx | 224 ++++++++++++ .../GrpcRequestContent/StyledWrapper.ts | 87 +++++ .../components/MethodBadge/MethodBadge.tsx | 6 +- .../components/MethodBadge/StyledWrapper.ts | 4 + .../src/components/PageRouter/PageRouter.tsx | 3 +- .../Views/PlaygroundView/PlaygroundView.tsx | 4 +- .../components/SnippetTabs/SnippetTabs.tsx | 134 +++++++ .../StyledWrapper.ts | 15 + .../bruno-api-docs/src/constants/index.ts | 3 +- .../bruno-api-docs/src/constants/request.ts | 7 + .../src/pages/Request/Request.tsx | 47 ++- .../bruno-api-docs/src/sampleCollection.ts | 339 +++++++++++++++++- .../bruno-api-docs/src/ui/Table/Table.tsx | 32 +- .../src/utils/grpcSnippets.spec.ts | 189 ++++++++++ .../bruno-api-docs/src/utils/grpcSnippets.ts | 167 +++++++++ .../src/utils/schemaHelpers.spec.ts | 133 ++++++- .../bruno-api-docs/src/utils/schemaHelpers.ts | 59 ++- 42 files changed, 2521 insertions(+), 155 deletions(-) create mode 100644 packages/bruno-api-docs/e2e/pages/grpc-request.page.ts create mode 100644 packages/bruno-api-docs/e2e/tests/request/grpc-request.spec.ts create mode 100644 packages/bruno-api-docs/src/assets/icons/BidiStreamingIcon.tsx create mode 100644 packages/bruno-api-docs/src/assets/icons/ClientStreamingIcon.tsx create mode 100644 packages/bruno-api-docs/src/assets/icons/ServerStreamingIcon.tsx create mode 100644 packages/bruno-api-docs/src/assets/icons/UnaryIcon.tsx create mode 100644 packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.spec.tsx create mode 100644 packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx create mode 100644 packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/StyledWrapper.ts create mode 100644 packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.spec.tsx create mode 100644 packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.tsx create mode 100644 packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/StyledWrapper.ts create mode 100644 packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.spec.tsx create mode 100644 packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.tsx create mode 100644 packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/StyledWrapper.ts create mode 100644 packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx create mode 100644 packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx create mode 100644 packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/StyledWrapper.ts create mode 100644 packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx create mode 100644 packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx create mode 100644 packages/bruno-api-docs/src/components/GrpcRequestContent/StyledWrapper.ts create mode 100644 packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx rename packages/bruno-api-docs/src/components/{CodeSnippetTabs => SnippetTabs}/StyledWrapper.ts (87%) create mode 100644 packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts create mode 100644 packages/bruno-api-docs/src/utils/grpcSnippets.ts diff --git a/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts b/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts new file mode 100644 index 00000000..e51c8334 --- /dev/null +++ b/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts @@ -0,0 +1,51 @@ +import { BasePage } from './base.page'; +import { SidebarComponent } from '../components/sidebar.component'; +import { BreadcrumbComponent } from '../components/breadcrumb.component'; +import { RequestUrlBarComponent } from '../components/request/url-bar.component'; + +export class GrpcRequestPage extends BasePage { + readonly sidebar = new SidebarComponent(this.page); + readonly breadcrumb = new BreadcrumbComponent(this.page, 'grpc-request-breadcrumb'); + readonly urlBar = new RequestUrlBarComponent(this.page); + + readonly root = this.page.getByTestId('grpc-request-page'); + readonly title = this.page.getByTestId('grpc-request-title'); + readonly description = this.page.getByTestId('grpc-request-description'); + + readonly protoFileSection = this.page.getByTestId('grpc-request-section-proto-file'); + readonly protoFile = this.page.getByTestId('grpc-request-proto-file'); + + readonly methodSection = this.page.getByTestId('grpc-request-section-method'); + readonly method = this.page.getByTestId('grpc-request-method'); + + readonly messagesSection = this.page.getByTestId('grpc-request-section-messages'); + readonly messages = this.page.getByTestId('grpc-messages'); + readonly showToggle = this.page.getByTestId('grpc-messages-show-toggle'); + + readonly metadataSection = this.page.getByTestId('grpc-request-section-metadata'); + readonly metadata = this.page.getByTestId('grpc-request-metadata'); + + readonly authSection = this.page.getByTestId('grpc-request-section-auth'); + readonly auth = this.page.getByTestId('grpc-request-auth'); + readonly authInheritedBadge = this.page.getByTestId('grpc-request-auth-inherited'); + + readonly emptyState = this.page.getByTestId('grpc-request-config-empty'); + + messageCard(index: number) { + return this.page.getByTestId(`grpc-messages-card-${index}`); + } + + messageToggle(index: number) { + return this.page.getByTestId(`grpc-messages-card-${index}-toggle`); + } + + messageCode(index: number) { + return this.page.getByTestId(`grpc-messages-card-${index}-code`); + } + + async open(path: string[]): Promise { + await this.navigate('/'); + await this.sidebar.open(path); + await this.root.waitFor({ state: 'visible' }); + } +} diff --git a/packages/bruno-api-docs/e2e/playwright/pages.fixture.ts b/packages/bruno-api-docs/e2e/playwright/pages.fixture.ts index 93a39f79..4547295d 100644 --- a/packages/bruno-api-docs/e2e/playwright/pages.fixture.ts +++ b/packages/bruno-api-docs/e2e/playwright/pages.fixture.ts @@ -5,6 +5,7 @@ import { RequestPage } from '../pages/request.page'; import { ScriptPage } from '../pages/script.page'; import { FolderPage } from '../pages/folder.page'; import { UnsupportedRequestPage } from '../pages/unsupported-request.page'; +import { GrpcRequestPage } from '../pages/grpc-request.page'; import { SidebarComponent } from '../components/sidebar.component'; import { TooltipComponent } from '../components/tooltip.component'; import { PlaygroundComponent } from '../components/playground.component'; @@ -24,6 +25,7 @@ type Fixtures = { scriptPage: ScriptPage; folderPage: FolderPage; unsupportedRequestPage: UnsupportedRequestPage; + grpcRequestPage: GrpcRequestPage; sidebar: SidebarComponent; tooltip: TooltipComponent; playground: PlaygroundComponent; @@ -56,6 +58,9 @@ export const test = base.extend({ unsupportedRequestPage: async ({ page }, use) => { await use(new UnsupportedRequestPage(page)); }, + grpcRequestPage: async ({ page }, use) => { + await use(new GrpcRequestPage(page)); + }, sidebar: async ({ page }, use) => { await use(new SidebarComponent(page)); }, diff --git a/packages/bruno-api-docs/e2e/tests/folder/folder.spec.ts b/packages/bruno-api-docs/e2e/tests/folder/folder.spec.ts index 66f546f4..99c39221 100644 --- a/packages/bruno-api-docs/e2e/tests/folder/folder.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/folder/folder.spec.ts @@ -4,7 +4,7 @@ test.describe('Folder page', () => { test('displays the folder name and how many requests it contains', async ({ folderPage }) => { await folderPage.open(['Realtime']); await expect(folderPage.title).toHaveText('Realtime'); - await expect(folderPage.requestCount).toHaveText('3 requests'); + await expect(folderPage.requestCount).toHaveText('10 requests'); }); test('shows config inherited from the collection even when the folder has no own config', async ({ folderPage }) => { diff --git a/packages/bruno-api-docs/e2e/tests/overview/overview.spec.ts b/packages/bruno-api-docs/e2e/tests/overview/overview.spec.ts index 7cb616a8..02b414ad 100644 --- a/packages/bruno-api-docs/e2e/tests/overview/overview.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/overview/overview.spec.ts @@ -22,9 +22,9 @@ test.describe('Collection Overview', () => { }); }); - test('shows three stat cards with the request (41), folder (7) and environment (2) counts', async ({ overviewPage }) => { + test('shows three stat cards with the request (48), folder (7) and environment (2) counts', async ({ overviewPage }) => { await expect(overviewPage.stats.cards).toHaveCount(3); - await expect(overviewPage.stats.valueFor('Requests')).toHaveText('41'); + await expect(overviewPage.stats.valueFor('Requests')).toHaveText('48'); await expect(overviewPage.stats.valueFor('Folders')).toHaveText('7'); await expect(overviewPage.stats.valueFor('Environments')).toHaveText('2'); }); diff --git a/packages/bruno-api-docs/e2e/tests/request/grpc-request.spec.ts b/packages/bruno-api-docs/e2e/tests/request/grpc-request.spec.ts new file mode 100644 index 00000000..c28d5d07 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/request/grpc-request.spec.ts @@ -0,0 +1,144 @@ +import { test, expect } from '../../playwright'; + +const REALTIME = 'Realtime'; + +test.describe('Request page — gRPC requests', () => { + test('renders the request identity without offering to run it', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + + await expect(grpcRequestPage.title).toHaveText('Order Service'); + await expect(grpcRequestPage.breadcrumb.current).toHaveText('Order Service'); + await expect(grpcRequestPage.urlBar.method).toHaveText('gRPC'); + await expect(grpcRequestPage.urlBar.url).toContainText('grpcUrl'); + await expect(grpcRequestPage.urlBar.tryButton).toHaveCount(0); + }); + + test('renders the request docs when the request provides them', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + + await expect(grpcRequestPage.description).toContainText('Fetches a single order by id over gRPC'); + }); + + test('shows the method with its streaming type', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Send Greetings']); + + await expect(grpcRequestPage.method).toContainText('hello.HelloService/LotsOfGreetings'); + await expect(grpcRequestPage.method).not.toContainText('/hello.HelloService'); + await expect(grpcRequestPage.method).toContainText('Client Streaming'); + }); + + test('shows the proto file name when one is attached', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Get Book']); + + await expect(grpcRequestPage.protoFile).toHaveText('book.proto'); + }); + + test('omits the proto file section when the request uses reflection', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + + await expect(grpcRequestPage.protoFileSection).toHaveCount(0); + await expect(grpcRequestPage.methodSection).toBeVisible(); + }); + + test('lists metadata with its descriptions and marks disabled rows', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + + await expect(grpcRequestPage.metadata).toContainText('authorization'); + await expect(grpcRequestPage.metadata).toContainText('Auth token forwarded to the service'); + await expect(grpcRequestPage.metadata).toContainText('x-legacy-flag'); + await expect(grpcRequestPage.metadata.getByTestId('disabled-badge')).toBeVisible(); + await expect(grpcRequestPage.metadataSection).toContainText('2 fields'); + }); + + test('resolves inherited auth and names where it came from', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + + await expect(grpcRequestPage.authInheritedBadge).toHaveText(`Inherited from folder: ${REALTIME}`); + await expect(grpcRequestPage.auth).toContainText('No auth'); + }); + + test('navigates to the folder the auth came from', async ({ grpcRequestPage, folderPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + await grpcRequestPage.authInheritedBadge.click(); + + await expect(folderPage.root).toBeVisible(); + await expect(folderPage.title).toHaveText(REALTIME); + }); + + test('shows concrete auth with its secret masked', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Get Book']); + + await expect(grpcRequestPage.auth).toContainText('Basic Auth'); + await expect(grpcRequestPage.auth).toContainText('reader'); + await expect(grpcRequestPage.auth).not.toContainText('s3cret'); + }); + + test('omits the auth section when the request has none', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Chat']); + + await expect(grpcRequestPage.authSection).toHaveCount(0); + }); + + test('shows one empty state when the request has no configuration', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Bare Method']); + + await expect(grpcRequestPage.emptyState).toContainText('No request configuration'); + await expect(grpcRequestPage.methodSection).toHaveCount(0); + await expect(grpcRequestPage.messagesSection).toHaveCount(0); + }); +}); + +test.describe('Request page — gRPC messages', () => { + test('opens the first message and leaves the rest closed', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Send Greetings']); + + await expect(grpcRequestPage.messageToggle(0)).toHaveAttribute('aria-expanded', 'true'); + await expect(grpcRequestPage.messageToggle(1)).toHaveAttribute('aria-expanded', 'false'); + await expect(grpcRequestPage.messageCode(0)).toBeVisible(); + }); + + test('offers no show-more control when every message already fits', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Send Greetings']); + + await expect(grpcRequestPage.showToggle).toHaveCount(0); + }); + + test('collapses a message that was open', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Send Greetings']); + await grpcRequestPage.messageToggle(0).click(); + + await expect(grpcRequestPage.messageToggle(0)).toHaveAttribute('aria-expanded', 'false'); + }); + + test('shows only the first three messages until show more is used', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Bulk Upload']); + + await expect(grpcRequestPage.messagesSection).toContainText('6 messages'); + await expect(grpcRequestPage.messageCard(2)).toBeVisible(); + await expect(grpcRequestPage.messageCard(3)).toHaveCount(0); + await expect(grpcRequestPage.showToggle).toHaveText('Show more'); + + await grpcRequestPage.showToggle.click(); + + await expect(grpcRequestPage.messageCard(5)).toBeVisible(); + await expect(grpcRequestPage.showToggle).toHaveText('Show less'); + }); + + test('keeps every expanded message open across show more and show less', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Bulk Upload']); + + await grpcRequestPage.messageToggle(1).click(); + await grpcRequestPage.messageToggle(2).click(); + await grpcRequestPage.showToggle.click(); + await grpcRequestPage.messageToggle(4).click(); + await grpcRequestPage.showToggle.click(); + + await expect(grpcRequestPage.messageCard(3)).toHaveCount(0); + await expect(grpcRequestPage.messageToggle(1)).toHaveAttribute('aria-expanded', 'true'); + await expect(grpcRequestPage.messageToggle(2)).toHaveAttribute('aria-expanded', 'true'); + + await grpcRequestPage.showToggle.click(); + + await expect(grpcRequestPage.messageToggle(4)).toHaveAttribute('aria-expanded', 'true'); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/request/unsupported-request.spec.ts b/packages/bruno-api-docs/e2e/tests/request/unsupported-request.spec.ts index 3ee25718..209e5ae1 100644 --- a/packages/bruno-api-docs/e2e/tests/request/unsupported-request.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/request/unsupported-request.spec.ts @@ -2,8 +2,7 @@ import { test, expect } from '../../playwright'; const UNSUPPORTED_REQUESTS = [ { paths: ['Realtime', 'Live Updates'], name: 'Live Updates', typeLabel: 'Websocket', shortName: 'WS', url: '/ws/updates' }, - { paths: ['Realtime', 'GraphQL API'], name: 'GraphQL API', typeLabel: 'GraphQL', shortName: 'GQL', url: '/graphql' }, - { paths: ['Realtime', 'Order Service'], name: 'Order Service', typeLabel: 'gRPC', shortName: 'GRPC', url: '/orders.OrderService' } + { paths: ['Realtime', 'GraphQL API'], name: 'GraphQL API', typeLabel: 'GraphQL', shortName: 'GQL', url: '/graphql' } ]; test.describe('Request page — unsupported request types', () => { diff --git a/packages/bruno-api-docs/src/assets/icons/BidiStreamingIcon.tsx b/packages/bruno-api-docs/src/assets/icons/BidiStreamingIcon.tsx new file mode 100644 index 00000000..0420d071 --- /dev/null +++ b/packages/bruno-api-docs/src/assets/icons/BidiStreamingIcon.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +export const BidiStreamingIcon: React.FC = () => ( + +); + +export default BidiStreamingIcon; diff --git a/packages/bruno-api-docs/src/assets/icons/ClientStreamingIcon.tsx b/packages/bruno-api-docs/src/assets/icons/ClientStreamingIcon.tsx new file mode 100644 index 00000000..80b49342 --- /dev/null +++ b/packages/bruno-api-docs/src/assets/icons/ClientStreamingIcon.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +export const ClientStreamingIcon: React.FC = () => ( + +); + +export default ClientStreamingIcon; diff --git a/packages/bruno-api-docs/src/assets/icons/ServerStreamingIcon.tsx b/packages/bruno-api-docs/src/assets/icons/ServerStreamingIcon.tsx new file mode 100644 index 00000000..534cefe4 --- /dev/null +++ b/packages/bruno-api-docs/src/assets/icons/ServerStreamingIcon.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +export const ServerStreamingIcon: React.FC = () => ( + +); + +export default ServerStreamingIcon; diff --git a/packages/bruno-api-docs/src/assets/icons/UnaryIcon.tsx b/packages/bruno-api-docs/src/assets/icons/UnaryIcon.tsx new file mode 100644 index 00000000..8a6ee5b5 --- /dev/null +++ b/packages/bruno-api-docs/src/assets/icons/UnaryIcon.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +export const UnaryIcon: React.FC = () => ( + +); + +export default UnaryIcon; diff --git a/packages/bruno-api-docs/src/assets/icons/index.ts b/packages/bruno-api-docs/src/assets/icons/index.ts index ca193964..dd123ba7 100644 --- a/packages/bruno-api-docs/src/assets/icons/index.ts +++ b/packages/bruno-api-docs/src/assets/icons/index.ts @@ -31,3 +31,7 @@ export * from './ExampleIcon'; export * from './DotIcon'; export * from './ChevronsRightIcon'; export * from './CheckIcon'; +export * from './UnaryIcon'; +export * from './ServerStreamingIcon'; +export * from './ClientStreamingIcon'; +export * from './BidiStreamingIcon'; diff --git a/packages/bruno-api-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.tsx b/packages/bruno-api-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.tsx index f24015be..d3bb4fa2 100644 --- a/packages/bruno-api-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.tsx +++ b/packages/bruno-api-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.tsx @@ -1,12 +1,7 @@ -import React, { useMemo, useRef, useState } from 'react'; +import React, { useMemo } from 'react'; import type { HttpRequestBody, HttpRequestBodyVariant, HttpRequestHeader } from '@opencollection/types/requests/http'; import type { Auth } from '@opencollection/types/common/auth'; -import { Code } from '../Code/Code'; -import { CopyButton } from '../../ui/CopyButton/CopyButton'; -import { useResolvedVariables } from '../../hooks'; -import { SectionLabel } from '../SectionLabel/SectionLabel'; -import { Modal } from '../../ui/Modal/Modal'; -import { ExpandIcon } from '../../assets/icons'; +import { SnippetTabs, type Snippet } from '../SnippetTabs/SnippetTabs'; import { generateCurlCommand, generateJavaScriptCode, @@ -14,9 +9,6 @@ import { type SnippetHeader, type SnippetInput } from '../../utils/codeSnippets'; -import { StyledWrapper } from './StyledWrapper'; -import { IconCode } from '@tabler/icons'; -import { cx } from '@/utils/cx'; interface CodeSnippetTabsProps { method: string; @@ -35,13 +27,16 @@ const LANGUAGES = [ { id: 'python', label: 'Python', language: 'python', generate: generatePythonCode } ] as const; -export const CodeSnippetTabs: React.FC = ({ method, url, headers, body, auth, variant = 'inline', className, testId = 'request-code-snippet' }) => { - const [active, setActive] = useState(LANGUAGES[0].id); - const [modalActive, setModalActive] = useState(LANGUAGES[0].id); - const [expanded, setExpanded] = useState(false); - const triggerRef = useRef(null); - const { showVars, resolve } = useResolvedVariables(); - +export const CodeSnippetTabs: React.FC = ({ + method, + url, + headers, + body, + auth, + variant = 'inline', + className, + testId +}) => { const snippetHeaders: SnippetHeader[] = useMemo( () => (headers ?? []) @@ -50,97 +45,17 @@ export const CodeSnippetTabs: React.FC = ({ method, url, h [headers] ); - const snippets = useMemo(() => { + const snippets: Snippet[] = useMemo(() => { const input: SnippetInput = { method, url, headers: snippetHeaders, body, auth }; - return LANGUAGES.reduce>((acc, lang) => { - acc[lang.id] = lang.generate(input); - return acc; - }, {}); + return LANGUAGES.map((lang) => ({ + id: lang.id, + label: lang.label, + language: lang.language, + code: lang.generate(input) + })); }, [method, url, snippetHeaders, body, auth]); - const openModal = () => { - setModalActive(active); - setExpanded(true); - }; - - const closeModal = () => { - setExpanded(false); - triggerRef.current?.focus(); - }; - - const renderSnippetBox = ( - placement: 'inline' | 'modal', - activeId: string, - setActiveId: (id: string) => void - ) => { - const activeLang = LANGUAGES.find((lang) => lang.id === activeId) ?? LANGUAGES[0]; - const snippet = snippets[activeId]; - const copyText = showVars ? resolve(snippet) : snippet; - return ( -
-
-
- {LANGUAGES.map((lang) => ( - - ))} -
- - {placement === 'inline' ? ( - - ) : ( - - )} -
- -
- ); - }; - - return ( - - {variant === 'inline' ? ( - renderSnippetBox('inline', active, setActive) - ) : ( - - )} - Code snippet} ariaLabel="Code snippet"> - {expanded && ( - - {renderSnippetBox('modal', modalActive, setModalActive)} - - )} - - - ); + return ; }; export default CodeSnippetTabs; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.spec.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.spec.tsx new file mode 100644 index 00000000..0532ff8d --- /dev/null +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.spec.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { GrpcMessageCard } from './GrpcMessageCard'; + +describe('GrpcMessageCard', () => { + it('renders the title and the message when expanded', () => { + const html = renderToStaticMarkup( + {}} /> + ); + expect(html).toContain('Message 1'); + expect(html).toContain('SKU-1001'); + expect(html).toContain('aria-expanded="true"'); + }); + + it('renders the title but not the message when collapsed', () => { + const html = renderToStaticMarkup( + {}} /> + ); + expect(html).toContain('Message 2'); + expect(html).not.toContain('SKU-1002'); + expect(html).toContain('aria-expanded="false"'); + }); +}); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx new file mode 100644 index 00000000..925d74fd --- /dev/null +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx @@ -0,0 +1,65 @@ +import React, { useEffect, useId, useRef, useState } from 'react'; +import { ChevronArrow } from '../../../ChevronArrow/ChevronArrow'; +import { Code } from '../../../Code/Code'; +import { StyledWrapper } from './StyledWrapper'; + +interface GrpcMessageCardProps { + title: string; + message: string; + expanded: boolean; + onToggle: () => void; + testId?: string; +} + +export const GrpcMessageCard: React.FC = ({ + title, + message, + expanded, + onToggle, + testId = 'grpc-message-card' +}) => { + const [mounted, setMounted] = useState(expanded); + if (expanded && !mounted) { + setMounted(true); + } + + const detailId = useId(); + const detailRef = useRef(null); + + useEffect(() => { + const el = detailRef.current; + if (!el) return; + if (expanded) el.removeAttribute('inert'); + else el.setAttribute('inert', ''); + }, [expanded, mounted]); + + return ( + +
+ +
+ +
+
+ {mounted && ( +
+ +
+ )} +
+
+
+ ); +}; + +export default GrpcMessageCard; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/StyledWrapper.ts b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/StyledWrapper.ts new file mode 100644 index 00000000..5f5b4157 --- /dev/null +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/StyledWrapper.ts @@ -0,0 +1,71 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.div` + border: 1px solid var(--border-color); + border-radius: var(--oc-radius); + overflow: hidden; + background: var(--oc-background-base); + + &:not(:first-of-type) { + margin-top: 0.75rem; + } + + .grpc-message-summary { + display: flex; + align-items: center; + padding: 0.5rem; + } + + .grpc-message-toggle { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0; + margin: 0; + background: none; + border: none; + cursor: pointer; + text-align: left; + color: inherit; + font: inherit; + } + .grpc-message-toggle:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + border-radius: 4px; + } + + .grpc-message-chevron { + flex: 0 0 auto; + color: var(--text-muted); + transition: transform 0.15s ease; + } + .grpc-message-chevron.is-open { + transform: rotate(90deg); + } + + .grpc-message-title { + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1.125rem; + color: var(--text-primary); + } + + .grpc-message-detail { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.22s ease; + } + .grpc-message-detail.is-open { + grid-template-rows: 1fr; + } + .grpc-message-detail-clip { + overflow: hidden; + min-height: 0; + } + .grpc-message-detail-body { + border-top: 1px solid var(--border-color); + } +`; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.spec.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.spec.tsx new file mode 100644 index 00000000..bcc5d7a7 --- /dev/null +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.spec.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { GrpcMessages } from './GrpcMessages'; + +const entries = (count: number) => + Array.from({ length: count }, (_, index) => ({ + title: `Message ${index + 1}`, + message: `{"payload":"body-${index + 1}"}` + })); + +describe('GrpcMessages', () => { + it('renders nothing when there are no messages', () => { + expect(renderToStaticMarkup()).toBe(''); + }); + + it('opens the first message and leaves the rest closed', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('body-1'); + expect(html).not.toContain('body-2'); + expect(html).not.toContain('body-3'); + }); + + it('shows only the first three messages and offers to show more', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('Message 3'); + expect(html).not.toContain('Message 4'); + expect(html).toContain('Show more'); + }); + + it('offers no show-more control when everything already fits', () => { + const html = renderToStaticMarkup(); + expect(html).not.toContain('Show more'); + }); +}); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.tsx new file mode 100644 index 00000000..8a04eeaa --- /dev/null +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.tsx @@ -0,0 +1,76 @@ +import React, { useState } from 'react'; +import type { GrpcMessageEntry } from '../../../utils/schemaHelpers'; +import { GrpcMessageCard } from './GrpcMessageCard/GrpcMessageCard'; +import { StyledWrapper } from './StyledWrapper'; + +const COLLAPSED_COUNT = 3; + +interface GrpcMessagesProps { + messages: GrpcMessageEntry[]; + testId?: string; +} + +export const GrpcMessages: React.FC = ({ messages, testId = 'grpc-messages' }) => { + const [expandedIndexes, setExpandedIndexes] = useState>(() => new Set([0])); + const [showAll, setShowAll] = useState(false); + + if (messages.length === 0) return null; + + const visible = showAll ? messages : messages.slice(0, COLLAPSED_COUNT); + const hasOverflow = messages.length > COLLAPSED_COUNT; + + const toggle = (index: number) => { + setExpandedIndexes((previous) => { + const next = new Set(previous); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }; + + return ( + + {visible.map((entry, index) => ( + toggle(index)} + testId={`${testId}-card-${index}`} + /> + ))} + + {hasOverflow && ( + + )} + + ); +}; + +export default GrpcMessages; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/StyledWrapper.ts b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/StyledWrapper.ts new file mode 100644 index 00000000..a9f59f04 --- /dev/null +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/StyledWrapper.ts @@ -0,0 +1,39 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.div` + .grpc-messages-show-toggle { + display: inline-flex; + align-items: center; + gap: 0.25rem; + margin-top: 0.75rem; + padding: 0; + border: none; + background: none; + cursor: pointer; + font-family: var(--font-sans); + font-weight: 500; + font-size: 0.8125rem; + line-height: 1; + letter-spacing: 0; + color: var(--primary-text); + } + .grpc-messages-show-toggle:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + border-radius: 2px; + } + + .grpc-messages-show-chevron { + flex-shrink: 0; + transition: transform 0.15s ease; + } + .grpc-messages-show-toggle[aria-expanded='true'] .grpc-messages-show-chevron { + transform: rotate(180deg); + } + + @media (prefers-reduced-motion: reduce) { + .grpc-messages-show-chevron { + transition: none; + } + } +`; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.spec.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.spec.tsx new file mode 100644 index 00000000..de347ca7 --- /dev/null +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.spec.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import type { GrpcMetadata } from '@opencollection/types/requests/grpc'; +import { GrpcMetadataTable } from './GrpcMetadataTable'; + +const rows = (entries: Record[]) => entries as unknown as GrpcMetadata[]; + +describe('GrpcMetadataTable', () => { + it('renders nothing when there is no metadata', () => { + expect(renderToStaticMarkup()).toBe(''); + }); + + it('renders a name, value and description for every row', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('authorization'); + expect(html).toContain('Bearer token'); + expect(html).toContain('Auth token'); + expect(html).toContain('x-request-id'); + expect(html).toContain('req-001'); + }); + + it('reads a description given as an object', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Client name'); + }); + + it('marks a disabled row', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('x-legacy-flag'); + expect(html).toContain('disabled-badge'); + }); + + it('highlights a variable in a value', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('var-text'); + }); +}); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.tsx new file mode 100644 index 00000000..6b4bda43 --- /dev/null +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import type { GrpcMetadata } from '@opencollection/types/requests/grpc'; +import { getDescription } from '../../../utils/request'; +import { Table, type TableColumn } from '../../../ui/Table/Table'; +import { TruncatedText } from '../../TruncatedText/TruncatedText'; +import { VariableText } from '../../VariableText/VariableText'; +import { DisabledBadge } from '../../DisabledBadge/DisabledBadge'; +import { StyledWrapper } from './StyledWrapper'; + +const COLUMNS: TableColumn[] = [ + { key: 'name', header: 'Name', width: '22%' }, + { key: 'value', header: 'Value', width: '36%' }, + { key: 'description', header: 'Description', width: '42%' } +]; + +interface GrpcMetadataTableProps { + metadata: GrpcMetadata[]; + testId?: string; +} + +export const GrpcMetadataTable: React.FC = ({ + metadata, + testId = 'grpc-metadata-table' +}) => { + if (metadata.length === 0) return null; + + return ( + + { + const description = getDescription(entry); + return { + id: `${entry.name}-${index}`, + cells: { + name: , + value: ( + + + + + {entry.disabled ? : null} + + ), + description: description ? : null + } + }; + })} + /> + + ); +}; + +export default GrpcMetadataTable; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/StyledWrapper.ts b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/StyledWrapper.ts new file mode 100644 index 00000000..698947b9 --- /dev/null +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/StyledWrapper.ts @@ -0,0 +1,25 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.div` + .table-cell { + font-size: 0.75rem; + line-height: 1.2; + } + + .grpc-metadata-value { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + font-family: 'Fira Code', var(--font-mono); + color: var(--oc-colors-text-subtext2); + } + .grpc-metadata-value .disabled-badge { + margin-left: auto; + flex-shrink: 0; + } + + .table-cell:last-child { + color: var(--oc-colors-text-subtext0); + } +`; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx new file mode 100644 index 00000000..a891bb25 --- /dev/null +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { GrpcMethodTypeIcon } from './GrpcMethodTypeIcon'; + +describe('GrpcMethodTypeIcon', () => { + it('colours each method type from its theme variable', () => { + expect(renderToStaticMarkup()).toContain( + 'color:var(--oc-request-methods-get)' + ); + expect(renderToStaticMarkup()).toContain( + 'color:var(--oc-request-methods-put)' + ); + expect(renderToStaticMarkup()).toContain( + 'color:var(--oc-request-methods-head)' + ); + expect(renderToStaticMarkup()).toContain( + 'color:var(--oc-request-methods-post)' + ); + }); + + it('renders nothing when the method type is missing or unknown', () => { + expect(renderToStaticMarkup()).toBe(''); + expect(renderToStaticMarkup()).toBe(''); + }); +}); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx new file mode 100644 index 00000000..1c4ad9dc --- /dev/null +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import type { GrpcMethodType } from '@opencollection/types/requests/grpc'; +import { + UnaryIcon, + ServerStreamingIcon, + ClientStreamingIcon, + BidiStreamingIcon +} from '../../../assets/icons'; +import { StyledWrapper } from './StyledWrapper'; + +// Method types borrow the HTTP method colours instead of defining their own, so +// both themes stay in step without new tokens. The Bruno app does the same, except +// it colours client-streaming as POST; here it follows the design's cyan. +const ICON_BY_METHOD_TYPE: Record = { + 'unary': { icon: UnaryIcon, color: 'var(--oc-request-methods-get)' }, + 'server-streaming': { icon: ServerStreamingIcon, color: 'var(--oc-request-methods-put)' }, + 'client-streaming': { icon: ClientStreamingIcon, color: 'var(--oc-request-methods-head)' }, + 'bidi-streaming': { icon: BidiStreamingIcon, color: 'var(--oc-request-methods-post)' } +}; + +interface GrpcMethodTypeIconProps { + methodType?: GrpcMethodType; + className?: string; +} + +export const GrpcMethodTypeIcon: React.FC = ({ methodType, className }) => { + const entry = methodType ? ICON_BY_METHOD_TYPE[methodType] : undefined; + if (!entry) return null; + + const Icon = entry.icon; + return ( + + + + ); +}; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/StyledWrapper.ts b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/StyledWrapper.ts new file mode 100644 index 00000000..a51e2796 --- /dev/null +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/StyledWrapper.ts @@ -0,0 +1,6 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.span` + display: inline-flex; + flex-shrink: 0; +`; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx new file mode 100644 index 00000000..f344cd98 --- /dev/null +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx @@ -0,0 +1,301 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import type { GrpcRequest } from '@opencollection/types/requests/grpc'; +import { GrpcRequestContent } from './GrpcRequestContent'; + +const grpcItem = (data: Record): GrpcRequest => data as unknown as GrpcRequest; + +describe('GrpcRequestContent', () => { + it('renders the request name, the GRPC badge and the url', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Order Service'); + expect(html).toContain('gRPC'); + expect(html).toContain('grpc://localhost:50051'); + }); + + it('renders a request that has no grpc block at all', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Bare Method'); + expect(html).toContain('{{grpcUrl}}'); + }); + + it('falls back to a placeholder name and never offers a Try button', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('Untitled Request'); + expect(html).not.toContain('Try'); + }); + + it('renders the docs markdown as html', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('markdown-documentation'); + expect(html).toContain('>Order Service'); + expect(html).toContain('

Fetches a single order.

'); + }); + + it('omits the description block when there are no docs', () => { + const html = renderToStaticMarkup( + + ); + expect(html).not.toContain('markdown-documentation'); + }); + + it('renders a request with a method', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('GetOrder'); + }); + + it('renders the proto file name and the method with its type label', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('book.proto'); + expect(html).toContain('>com.bookstore.BookService/GetBook<'); + expect(html).toContain('Unary'); + }); + + it('hides the proto file path when the request uses reflection', () => { + const html = renderToStaticMarkup( + + ); + expect(html).not.toContain('grpc-request-section-proto-file'); + expect(html).toContain('Bidirectional Streaming'); + }); + + it('hides the method section when no method is selected', () => { + const html = renderToStaticMarkup( + + ); + expect(html).not.toContain('grpc-request-section-method'); + expect(html).toContain('Bare Method'); + }); + + it('renders metadata rows with their descriptions and counts only enabled ones', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('authorization'); + expect(html).toContain('Auth token'); + expect(html).toContain('x-legacy-flag'); + expect(html).toContain('2 fields'); + }); + + it('reads a metadata description given as an object', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Client name'); + expect(html).toContain('1 field'); + }); + + it('hides the metadata section when there is none', () => { + const html = renderToStaticMarkup( + + ); + expect(html).not.toContain('grpc-request-section-metadata'); + }); + + it('shows concrete auth with no inherited badge', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Basic Auth'); + expect(html).toContain('reader'); + expect(html).not.toContain('Inherited from'); + }); + + it('resolves inherited auth up to the collection and says where it came from', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Inherited from collection'); + expect(html).toContain('Bearer Token'); + }); + + it('masks a secret rather than printing it', () => { + const html = renderToStaticMarkup( + + ); + expect(html).not.toContain('s3cret'); + }); + + it('hides the auth section when the request has no auth', () => { + const html = renderToStaticMarkup( + + ); + expect(html).not.toContain('grpc-request-section-auth'); + }); + + it('shows a single empty state when the request has no configuration', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('grpc-request-config-empty'); + expect(html).toContain('No request configuration'); + expect(html).toContain('Bare Method'); + }); + + it('builds a grpcurl snippet from the request', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('grpcURL'); + expect(html).toContain('grpcurl'); + expect(html).toContain('localhost:50051'); + expect(html).toContain('orders.OrderService/GetOrder'); + }); + + it('omits the code snippet when the request has no method', () => { + const html = renderToStaticMarkup( + + ); + expect(html).not.toContain('grpc-request-section-code-snippet'); + }); + + it('shows sections instead of the empty state when there is any configuration', () => { + const html = renderToStaticMarkup( + + ); + expect(html).not.toContain('grpc-request-config-empty'); + expect(html).toContain('grpc-request-section-method'); + }); +}); + +it('offers a JavaScript snippet only when a proto file is attached', () => { + const withProto = renderToStaticMarkup( + + ); + expect(withProto).toContain('code-snippet-tab-javascript'); + + const reflectionOnly = renderToStaticMarkup( + + ); + expect(reflectionOnly).toContain('code-snippet-tab-grpcurl'); + expect(reflectionOnly).not.toContain('code-snippet-tab-javascript'); +}); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx new file mode 100644 index 00000000..c6976471 --- /dev/null +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx @@ -0,0 +1,224 @@ +import React, { useMemo } from 'react'; +import type { OpenCollection } from '@opencollection/types'; +import type { Item } from '@opencollection/types/collection/item'; +import type { GrpcRequest } from '@opencollection/types/requests/grpc'; +import type { Auth } from '@opencollection/types/common/auth'; +import { + getItemName, + getRequestUrl, + getItemDocs, + getRequestAuth, + getGrpcMethod, + getGrpcMethodType, + getGrpcMetadata, + getGrpcMessages, + getGrpcProtoFileName, + getGrpcProtoFilePath, + countEnabled +} from '../../utils/schemaHelpers'; +import { resolveInheritedAuth } from '../../utils/request'; +import { generateGrpcurlCommand, generateGrpcJavaScriptCode } from '../../utils/grpcSnippets'; +import { SnippetTabs, type Snippet } from '../SnippetTabs/SnippetTabs'; +import { useMarkdownRenderer } from '../../hooks'; +import { buildBreadcrumbSegments } from '../../utils/common'; +import { AUTH_MODE_LABELS, GRPC_METHOD_TYPE_LABELS } from '../../constants'; +import { Section } from '../Section/Section'; +import { ContentTypeBadge } from '../ContentTypeBadge/ContentTypeBadge'; +import { InheritedAuthBadge } from '../InheritedAuthBadge/InheritedAuthBadge'; +import { AuthDetails } from '../AuthDetails/AuthDetails'; +import { GrpcMethodTypeIcon } from './GrpcMethodTypeIcon/GrpcMethodTypeIcon'; +import { GrpcMessages } from './GrpcMessages/GrpcMessages'; +import { GrpcMetadataTable } from './GrpcMetadataTable/GrpcMetadataTable'; +import { PageWrapper } from '../PageWrapper/PageWrapper'; +import { Heading } from '../Heading/Heading'; +import { ViewMore } from '../ViewMore/ViewMore'; +import { Breadcrumb, type BreadcrumbSegment } from '../../ui/Breadcrumb/Breadcrumb'; +import { EmptyState } from '../../ui/EmptyState/EmptyState'; +import { RequestUrlBar } from '../Request/RequestUrlBar/RequestUrlBar'; +import { StyledWrapper } from './StyledWrapper'; +import { FileIcon } from '../../assets/icons'; + +interface GrpcRequestContentProps { + item: GrpcRequest; + collection?: OpenCollection | null; + ancestry?: Item[]; + onBreadcrumbClick?: (uuid: string) => void; + testId?: string; +} + +export const GrpcRequestContent: React.FC = ({ + item, + ancestry = [], + collection, + onBreadcrumbClick, + testId = 'grpc-request-page' +}) => { + const name = getItemName(item) || 'Untitled Request'; + const url = getRequestUrl(item); + + const method = getGrpcMethod(item); + const methodType = getGrpcMethodType(item); + const protoFileName = getGrpcProtoFileName(item); + const protoFilePath = getGrpcProtoFilePath(item); + const methodTypeLabel = methodType ? GRPC_METHOD_TYPE_LABELS[methodType] : undefined; + const messages = getGrpcMessages(item); + const metadata = getGrpcMetadata(item); + const enabledMetadataCount = countEnabled(metadata); + + const ownAuth = getRequestAuth(item) as Auth | undefined; + const resolvedAuth = useMemo(() => resolveInheritedAuth(collection, ancestry, item), [collection, ancestry, item]); + const effectiveAuth = ownAuth === 'inherit' ? resolvedAuth.auth : ownAuth; + const showAuth = ownAuth !== undefined; + const authBadge + = ownAuth === 'inherit' ? ( + resolvedAuth.source ? ( + + ) : ( + + ) + ) : undefined; + + const hasLeftColumn + = Boolean(protoFileName) || Boolean(method) || messages.length > 0 || metadata.length > 0 || showAuth; + + const snippets = useMemo(() => { + if (!method) return []; + const input = { url, method, methodType, protoFilePath, metadata, messages }; + const built: Snippet[] = [ + { id: 'grpcurl', label: 'grpcURL', language: 'bash', code: generateGrpcurlCommand(input) } + ]; + + const javaScript = generateGrpcJavaScriptCode(input); + if (javaScript) { + built.push({ id: 'javascript', label: 'JavaScript', language: 'javascript', code: javaScript }); + } + + return built; + }, [url, method, methodType, protoFilePath, metadata, messages]); + + const md = useMarkdownRenderer(); + + const descHtml = useMemo(() => { + const docs = getItemDocs(item); + return docs ? md.render(docs) : ''; + }, [item, md]); + + const segments = useMemo( + () => buildBreadcrumbSegments(collection, ancestry), + [collection, ancestry] + ); + + return ( + + + + + {name} + + + {descHtml && ( + +
+ + )} + + {hasLeftColumn ? ( +
+
+ {protoFileName && ( +
+
+ + + + {protoFileName} +
+
+ )} + + {method && ( +
+
+ + {method.replace(/^\//, '')} + {methodTypeLabel && {methodTypeLabel}} +
+
+ )} + + {messages.length > 0 && ( +
+ } + > + +
+ )} + + {metadata.length > 0 && ( +
+ ) : undefined + } + > + +
+ )} + + {showAuth && ( +
+ +
+ )} +
+ + {snippets.length > 0 && ( +
+
+ +
+
+ )} +
+ ) : ( + } + heading="No request configuration" + subheading="This request has no method, messages, metadata, or authentication configured." + /> + )} + + + ); +}; + +export default GrpcRequestContent; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/StyledWrapper.ts b/packages/bruno-api-docs/src/components/GrpcRequestContent/StyledWrapper.ts new file mode 100644 index 00000000..a9f038b2 --- /dev/null +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/StyledWrapper.ts @@ -0,0 +1,87 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.div` + max-width: 100rem; + margin: 0 auto; + color: var(--text-primary); + padding-top: 0.1rem; + padding-bottom: 0.1rem; + + .grpc-request-empty { + margin-top: 1.5rem; + } + + .grpc-request-columns { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); + gap: 2.75rem; + align-items: start; + margin-top: 1.25rem; + } + + .grpc-request-col-left { + min-width: 0; + display: flex; + flex-direction: column; + gap: 1.5rem; + } + + .grpc-request-col-right { + min-width: 0; + position: sticky; + top: 1.25rem; + align-self: start; + } + + .grpc-field { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.625rem; + border: 1px solid var(--border-color); + border-radius: var(--oc-radius); + background-color: var(--oc-background-mantle); + } + + .grpc-field-icon { + flex-shrink: 0; + display: inline-flex; + color: var(--text-tertiary); + } + + .grpc-field-icon svg { + width: 1rem; + height: 1rem; + } + + .grpc-field-text { + flex: 1; + min-width: 0; + font-family: var(--font-mono); + font-weight: 400; + font-size: 0.75rem; + line-height: 1.125rem; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .grpc-field-meta { + flex-shrink: 0; + font-family: var(--font-sans); + font-size: 0.75rem; + line-height: 1.125rem; + color: var(--text-tertiary); + } + + @container docs (max-width: 1024px) { + .grpc-request-columns { + grid-template-columns: 1fr; + gap: 1.75rem; + } + .grpc-request-col-right { + position: static; + } + } +`; diff --git a/packages/bruno-api-docs/src/components/MethodBadge/MethodBadge.tsx b/packages/bruno-api-docs/src/components/MethodBadge/MethodBadge.tsx index a7d2c21a..4d44b0bf 100644 --- a/packages/bruno-api-docs/src/components/MethodBadge/MethodBadge.tsx +++ b/packages/bruno-api-docs/src/components/MethodBadge/MethodBadge.tsx @@ -10,12 +10,14 @@ interface MethodBadgeProps { export const MethodBadge: React.FC = ({ method, className }) => { const resolvedMethod = method || 'GET'; + const asWritten = resolvedMethod !== resolvedMethod.toLowerCase() && resolvedMethod !== resolvedMethod.toUpperCase(); + return ( - {resolvedMethod.toUpperCase()} + {asWritten ? resolvedMethod : resolvedMethod.toUpperCase()} ); }; diff --git a/packages/bruno-api-docs/src/components/MethodBadge/StyledWrapper.ts b/packages/bruno-api-docs/src/components/MethodBadge/StyledWrapper.ts index da75792e..eb9dc9ac 100644 --- a/packages/bruno-api-docs/src/components/MethodBadge/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/MethodBadge/StyledWrapper.ts @@ -8,4 +8,8 @@ export const StyledWrapper = styled.span` font-size: 0.75rem; letter-spacing: 0.02em; text-transform: uppercase; + + &.method-badge--as-written { + text-transform: none; + } `; diff --git a/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx b/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx index 1b47a8af..36e964e3 100644 --- a/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx +++ b/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx @@ -1,6 +1,5 @@ import React, { useMemo, useRef } from 'react'; import { Navigate } from 'react-router-dom'; -import type { HttpRequest } from '@opencollection/types/requests/http'; import type { ScriptFile, Folder as FolderItem } from '@opencollection/types/collection/item'; import { useActiveResolution, useNavModel } from '../../routing/hooks'; import { useAppSelector } from '../../store/hooks'; @@ -93,7 +92,7 @@ const PageRouter: React.FC = ({ onOpenPlayground, testId = 'pag return item ? ( = ({ item, collec }; const PlaygroundView: React.FC = ({ item, ...otherProps }) => { - if (isUnsupportedRequest(item)) { + if (isUnsupportedRequestInPlayground(item)) { return ( = ({ + snippets, + variant = 'inline', + className, + testId = 'request-code-snippet' +}) => { + const [active, setActive] = useState(snippets[0]?.id ?? ''); + const [modalActive, setModalActive] = useState(snippets[0]?.id ?? ''); + const [expanded, setExpanded] = useState(false); + const triggerRef = useRef(null); + const { showVars, resolve } = useResolvedVariables(); + + if (snippets.length === 0) return null; + + const openModal = () => { + setModalActive(active); + setExpanded(true); + }; + + const closeModal = () => { + setExpanded(false); + triggerRef.current?.focus(); + }; + + const renderSnippetBox = (placement: 'inline' | 'modal', activeId: string, setActiveId: (id: string) => void) => { + const activeSnippet = snippets.find((snippet) => snippet.id === activeId) ?? snippets[0]; + const code = activeSnippet.code; + const copyText = showVars ? resolve(code) : code; + return ( +
+
+
+ {snippets.map((snippet) => ( + + ))} +
+ + {placement === 'inline' ? ( + + ) : ( + + )} +
+ +
+ ); + }; + + return ( + + {variant === 'inline' ? ( + renderSnippetBox('inline', active, setActive) + ) : ( + + )} + Code snippet} + ariaLabel="Code snippet" + > + {expanded && ( + + {renderSnippetBox('modal', modalActive, setModalActive)} + + )} + + + ); +}; + +export default SnippetTabs; diff --git a/packages/bruno-api-docs/src/components/CodeSnippetTabs/StyledWrapper.ts b/packages/bruno-api-docs/src/components/SnippetTabs/StyledWrapper.ts similarity index 87% rename from packages/bruno-api-docs/src/components/CodeSnippetTabs/StyledWrapper.ts rename to packages/bruno-api-docs/src/components/SnippetTabs/StyledWrapper.ts index a32bd367..9c695b9b 100644 --- a/packages/bruno-api-docs/src/components/CodeSnippetTabs/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/SnippetTabs/StyledWrapper.ts @@ -85,6 +85,21 @@ export const StyledWrapper = styled.div` outline-offset: 2px; } + .snippet-box .code-content-numbered, + .snippet-box .code-content:not(.code-content--numbered) { + max-height: calc(100vh - 12rem); + overflow-y: auto; + } + + .snippet-box .code-content-numbered { + align-items: flex-start; + } + + &.is-modal .snippet-box .code-content-numbered, + &.is-modal .snippet-box .code-content:not(.code-content--numbered) { + max-height: none; + } + .snippet-copy { align-self: center; flex: 0 0 auto; diff --git a/packages/bruno-api-docs/src/constants/index.ts b/packages/bruno-api-docs/src/constants/index.ts index aae92b31..b74f3850 100644 --- a/packages/bruno-api-docs/src/constants/index.ts +++ b/packages/bruno-api-docs/src/constants/index.ts @@ -15,7 +15,8 @@ export { PROTOCOL_BADGE_LABELS, REQUEST_TYPE_LABELS, BODY_LANGUAGE, - BODY_CONTENT_TYPE + BODY_CONTENT_TYPE, + GRPC_METHOD_TYPE_LABELS } from './request'; export { RESPONSE_LANGUAGE, RESPONSE_CONTENT_TYPE, STATUS_CODE_PHRASES } from './response'; diff --git a/packages/bruno-api-docs/src/constants/request.ts b/packages/bruno-api-docs/src/constants/request.ts index 00b0e006..05d96089 100644 --- a/packages/bruno-api-docs/src/constants/request.ts +++ b/packages/bruno-api-docs/src/constants/request.ts @@ -37,6 +37,13 @@ export const STANDARD_HTTP_METHODS = [ 'CONNECT' ] as const; +export const GRPC_METHOD_TYPE_LABELS: Record = { + 'unary': 'Unary', + 'client-streaming': 'Client Streaming', + 'server-streaming': 'Server Streaming', + 'bidi-streaming': 'Bidirectional Streaming' +}; + export const PROTOCOL_BADGE_LABELS: Record = { GRAPHQL: 'GQL', GRPC: 'GRPC', diff --git a/packages/bruno-api-docs/src/pages/Request/Request.tsx b/packages/bruno-api-docs/src/pages/Request/Request.tsx index 06e36a9b..afdc989a 100644 --- a/packages/bruno-api-docs/src/pages/Request/Request.tsx +++ b/packages/bruno-api-docs/src/pages/Request/Request.tsx @@ -3,9 +3,6 @@ import type { OpenCollection } from '@opencollection/types'; import type { Item } from '@opencollection/types/collection/item'; import type { HttpRequest, HttpRequestParam, HttpRequestHeader } from '@opencollection/types/requests/http'; import type { Auth } from '@opencollection/types/common/auth'; -import type { GraphQLRequest } from '@opencollection/types/requests/graphql'; -import type { GrpcRequest } from '@opencollection/types/requests/grpc'; -import type { WebSocketRequest } from '@opencollection/types/requests/websocket'; import { useMarkdownRenderer } from '../../hooks'; import { AUTH_MODE_LABELS } from '../../constants'; import { @@ -19,7 +16,9 @@ import { getItemDocs, getItemDescription, getRequestExamples, - isUnsupportedRequest + isHttpRequest, + isUnsupportedRequestInDocs, + isGrpcRequest } from '../../utils/schemaHelpers'; import { resolveInheritedAuth, @@ -55,10 +54,11 @@ import { CodeSnippetTabs } from '../../components/CodeSnippetTabs/CodeSnippetTab import { Examples } from '../../components/Examples/Examples'; import { ExecutionContext } from '../../components/ExecutionContext/ExecutionContext'; import { UnsupportedRequest } from '../../components/UnsupportedRequest/UnsupportedRequest'; +import { GrpcRequestContent } from '../../components/GrpcRequestContent/GrpcRequestContent'; import { StyledWrapper } from './StyledWrapper'; interface RequestProps { - item: HttpRequest | WebSocketRequest | GraphQLRequest | GrpcRequest; + item: Item; ancestry?: Item[]; collection?: OpenCollection | null; onTryClick?: () => void; @@ -306,7 +306,18 @@ export const Request: React.FC = ({ onBreadcrumbClick, highlightedExampleIndex }) => { - if (isUnsupportedRequest(item)) { + if (isGrpcRequest(item)) { + return ( + + ); + } + + if (isUnsupportedRequestInDocs(item)) { return ( = ({ ); } - return ( - - ); + if (isHttpRequest(item)) { + return ( + + ); + } + + return null; }; export default Request; diff --git a/packages/bruno-api-docs/src/sampleCollection.ts b/packages/bruno-api-docs/src/sampleCollection.ts index 6e685de7..4a9252ef 100644 --- a/packages/bruno-api-docs/src/sampleCollection.ts +++ b/packages/bruno-api-docs/src/sampleCollection.ts @@ -11,6 +11,8 @@ config: variables: - name: "host" value: "http://localhost:8081" + - name: "grpcUrl" + value: "grpc://127.0.0.1:50051" - name: "retryCount" value: type: "number" @@ -31,6 +33,8 @@ config: variables: - name: "host" value: "https://echo.usebruno.com" + - name: "grpcUrl" + value: "grpc://echo.usebruno.com:443" - name: "bearer_auth_token" secret: true type: "string" @@ -145,9 +149,340 @@ items: - name: "GraphQL API" type: "graphql" url: "{{host}}/graphql" - - name: "Order Service" + - info: + name: "Order Service" + type: "grpc" + grpc: + url: "{{grpcUrl}}" + method: "/orders.OrderService/GetOrder" + methodType: "unary" + metadata: + - name: "authorization" + value: "Bearer {{bearer_auth_token}}" + description: "Auth token forwarded to the service" + - name: "x-request-id" + value: "req-001" + - name: "x-legacy-flag" + value: "off" + description: "Kept for the old gateway" + disabled: true + message: |- + { + "orderId": "12345", + "includeItems" : true + } + auth: inherit + docs: | + # Order Service + + Fetches a single order by id over gRPC. + + - Uses **server reflection**, so no proto file is attached. + - Auth is inherited from the collection. + - info: + name: "Get Book" + type: "grpc" + grpc: + url: "grpc://localhost:9000" + method: "/com.book.BookService/GetBook" + methodType: "unary" + protoFilePath: "../../Downloads/book.proto" + message: |- + { + "isbn": 9780134685991 + } + auth: + type: "basic" + username: "reader" + password: "s3cret" + - info: + name: "Stream Replies" + type: "grpc" + grpc: + url: "{{grpcUrl}}" + method: "/hello.HelloService/LotsOfReplies" + methodType: "server-streaming" + message: + - title: "message 1" + message: |- + { + "greeting": "suadeo" + } + auth: inherit + - info: + name: "Send Greetings" + type: "grpc" + grpc: + url: "{{grpcUrl}}" + method: "/hello.HelloService/LotsOfGreetings" + methodType: "client-streaming" + message: + - title: "message 1" + message: |- + { + "greeting": "sortitus" + } + - title: "message 2" + message: |- + { + "greeting": "porro" + } + auth: inherit + - info: + name: "Chat" + type: "grpc" + grpc: + url: "{{grpcUrl}}" + method: "/hello.HelloService/BidiHello" + methodType: "bidi-streaming" + metadata: + - name: "x-client" + value: "Bruno" + message: + - title: "message 1" + message: '{ "greeting": "cuius" }' + - title: "message 2" + message: '{ "greeting": "adfectus" }' + - info: + name: "Bulk Upload" + type: "grpc" + grpc: + url: "{{grpcUrl}}" + method: "/inventory.InventoryService/BulkUpload" + methodType: "client-streaming" + protoFilePath: "protos/inventory.proto" + metadata: + - name: "x-batch-id" + value: "batch-2026-08" + description: "Groups the uploaded rows into one batch" + message: + - title: "message 1" + message: |- + { + "sku": "SKU-1001", + "quantity": 12 + } + - title: "message 2" + message: |- + { + "sku": "SKU-1002", + "quantity": 40 + } + - title: "message 3" + message: |- + { + "sku": "SKU-1003", + "quantity": 7 + } + - title: "message 4" + message: |- + { + "sku": "SKU-1004", + "quantity": 250 + } + - title: "message 5" + message: |- + { + "sku": "SKU-1005", + "quantity": 3 + } + - title: "message 6" + message: |- + { + "sku": "SKU-1006", + "quantity": 88 + } + auth: inherit + - info: + name: "Ingest Events" + type: "grpc" + grpc: + url: "{{grpcUrl}}" + method: "/telemetry.v1.TelemetryService/IngestEvents" + methodType: "bidi-streaming" + protoFilePath: "protos/telemetry/v1/telemetry.proto" + metadata: + - name: "authorization" + value: "Bearer {{bearer_auth_token}}" + description: "Service account token for the ingest pipeline" + - name: "x-tenant-id" + value: "acme-eu-west" + description: "Tenant the events belong to" + - name: "x-schema-version" + value: "2026-05-01" + description: "Event envelope version the client is emitting" + - name: "x-compression" + value: "gzip" + description: "Superseded by the transport-level setting" + disabled: true + message: + - title: "message 1" + message: |- + { + "eventId": "evt_01HZ8QK3M4N5P6Q7R8S9T0", + "occurredAt": "2026-05-01T09:14:22.481Z", + "source": { "service": "checkout", "region": "eu-west-1", "version": "4.12.0" }, + "actor": { "type": "user", "id": "usr_8412", "sessionId": "sess_a91f3c" }, + "payload": { + "kind": "cart.item_added", + "cartId": "cart_55210", + "sku": "SKU-1001", + "quantity": 2, + "unitPriceMinor": 4999, + "currency": "EUR" + }, + "tags": ["checkout", "experiment:pricing-v3"] + } + - title: "message 2" + message: |- + { + "eventId": "evt_01HZ8QK5A1B2C3D4E5F6G7", + "occurredAt": "2026-05-01T09:14:23.902Z", + "source": { "service": "checkout", "region": "eu-west-1", "version": "4.12.0" }, + "actor": { "type": "user", "id": "usr_8412", "sessionId": "sess_a91f3c" }, + "payload": { + "kind": "cart.item_removed", + "cartId": "cart_55210", + "sku": "SKU-1003", + "quantity": 1, + "reason": "changed_mind" + }, + "tags": ["checkout"] + } + - title: "message 3" + message: |- + { + "eventId": "evt_01HZ8QK7H8J9K0L1M2N3P4", + "occurredAt": "2026-05-01T09:14:31.117Z", + "source": { "service": "payments", "region": "eu-west-1", "version": "2.8.3" }, + "actor": { "type": "user", "id": "usr_8412", "sessionId": "sess_a91f3c" }, + "payload": { + "kind": "payment.authorized", + "paymentId": "pay_77031", + "amountMinor": 9998, + "currency": "EUR", + "method": "card", + "processor": "stripe" + }, + "tags": ["payments", "3ds:frictionless"] + } + - title: "message 4" + message: |- + { + "eventId": "evt_01HZ8QKA5B6C7D8E9F0G1H", + "occurredAt": "2026-05-01T09:14:33.640Z", + "source": { "service": "orders", "region": "eu-west-1", "version": "6.1.0" }, + "actor": { "type": "system", "id": "svc_order_worker" }, + "payload": { + "kind": "order.created", + "orderId": "ord_1042", + "cartId": "cart_55210", + "totalMinor": 9998, + "currency": "EUR", + "lineItemCount": 2 + }, + "tags": ["orders"] + } + - title: "message 5" + message: |- + { + "eventId": "evt_01HZ8QKC2D3E4F5G6H7J8K", + "occurredAt": "2026-05-01T09:15:02.008Z", + "source": { "service": "fulfilment", "region": "eu-west-1", "version": "3.4.1" }, + "actor": { "type": "system", "id": "svc_warehouse_sync" }, + "payload": { + "kind": "shipment.allocated", + "orderId": "ord_1042", + "warehouse": "WH-AMS-02", + "carrier": "dhl", + "estimatedDispatch": "2026-05-02T06:00:00Z" + }, + "tags": ["fulfilment"] + } + - title: "message 6" + message: |- + { + "eventId": "evt_01HZ8QKE9F0G1H2J3K4L5M", + "occurredAt": "2026-05-01T09:15:44.213Z", + "source": { "service": "notifications", "region": "eu-west-1", "version": "1.9.7" }, + "actor": { "type": "system", "id": "svc_notifier" }, + "payload": { + "kind": "email.queued", + "template": "order_confirmation", + "orderId": "ord_1042", + "recipientHash": "sha256:9f2b7c1e", + "locale": "en-GB" + }, + "tags": ["notifications"] + } + - title: "message 7" + message: |- + { + "eventId": "evt_01HZ8QKG6H7J8K9L0M1N2P", + "occurredAt": "2026-05-01T09:16:10.774Z", + "source": { "service": "inventory", "region": "eu-west-1", "version": "5.0.2" }, + "actor": { "type": "system", "id": "svc_stock_ledger" }, + "payload": { + "kind": "stock.decremented", + "sku": "SKU-1001", + "warehouse": "WH-AMS-02", + "delta": -2, + "remaining": 118 + }, + "tags": ["inventory"] + } + - title: "message 8" + message: |- + { + "eventId": "evt_01HZ8QKJ3K4L5M6N7P8Q9R", + "occurredAt": "2026-05-01T09:18:55.301Z", + "source": { "service": "payments", "region": "eu-west-1", "version": "2.8.3" }, + "actor": { "type": "system", "id": "svc_settlement" }, + "payload": { + "kind": "payment.captured", + "paymentId": "pay_77031", + "amountMinor": 9998, + "currency": "EUR", + "settlementBatch": "btch_2026_05_01_a" + }, + "tags": ["payments"] + } + - title: "message 9" + message: |- + { + "eventId": "evt_01HZ8QKL0M1N2P3Q4R5S6T", + "occurredAt": "2026-05-02T06:04:12.559Z", + "source": { "service": "fulfilment", "region": "eu-west-1", "version": "3.4.1" }, + "actor": { "type": "system", "id": "svc_warehouse_sync" }, + "payload": { + "kind": "shipment.dispatched", + "orderId": "ord_1042", + "trackingNumber": "JD0002216990123456", + "carrier": "dhl", + "warehouse": "WH-AMS-02" + }, + "tags": ["fulfilment", "sla:next-day"] + } + - title: "message 10" + message: |- + { + "eventId": "evt_01HZ8QKN7P8Q9R0S1T2U3V", + "occurredAt": "2026-05-03T11:27:03.884Z", + "source": { "service": "orders", "region": "eu-west-1", "version": "6.1.0" }, + "actor": { "type": "user", "id": "usr_8412", "sessionId": "sess_d47b21" }, + "payload": { + "kind": "order.delivered", + "orderId": "ord_1042", + "deliveredAt": "2026-05-03T11:26:58Z", + "signedBy": "reception", + "npsPrompted": true + }, + "tags": ["orders", "lifecycle:complete"] + } + auth: inherit + - name: "Bare Method" type: "grpc" - url: "{{host}}/orders.OrderService" + url: "{{grpcUrl}}" - info: name: billing type: folder diff --git a/packages/bruno-api-docs/src/ui/Table/Table.tsx b/packages/bruno-api-docs/src/ui/Table/Table.tsx index d8043d7e..672de5d1 100644 --- a/packages/bruno-api-docs/src/ui/Table/Table.tsx +++ b/packages/bruno-api-docs/src/ui/Table/Table.tsx @@ -32,6 +32,7 @@ interface TableProps { caption?: string; emptyMessage?: string; minWidth?: string; + hideHeader?: boolean; className?: string; testId?: string; } @@ -89,6 +90,7 @@ export const Table: React.FC = ({ caption, emptyMessage, minWidth, + hideHeader = false, className, testId }) => { @@ -112,20 +114,22 @@ export const Table: React.FC = ({
))} - - - {columns.map((column) => ( - - ))} - - + {!hideHeader && ( + + + {columns.map((column) => ( + + ))} + + + )} {groupList.map((group) => ( {group.label !== undefined && ( diff --git a/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts b/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts new file mode 100644 index 00000000..fad9bff1 --- /dev/null +++ b/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from 'vitest'; +import type { GrpcMetadata } from '@opencollection/types/requests/grpc'; +import { generateGrpcJavaScriptCode, generateGrpcurlCommand, type GrpcSnippetInput } from './grpcSnippets'; + +const input = (overrides: Partial = {}): GrpcSnippetInput => ({ + url: 'grpc://localhost:50051', + method: '/hello.HelloService/SayHello', + metadata: [] as GrpcMetadata[], + messages: [{ title: 'Message 1', message: '{"greeting":"hi"}' }], + ...overrides +}); + +describe('generateGrpcurlCommand', () => { + it('builds a unary command with the message inline', () => { + const command = generateGrpcurlCommand(input({ methodType: 'unary' })); + expect(command).toContain('grpcurl'); + expect(command).toContain('-plaintext'); + expect(command).toContain(`-d '{"greeting":"hi"}'`); + expect(command).toContain('localhost:50051'); + expect(command).toContain('hello.HelloService/SayHello'); + }); + + it('strips the scheme and the leading slash of the method', () => { + const command = generateGrpcurlCommand(input()); + expect(command).not.toContain('grpc://'); + expect(command).not.toContain('/hello.HelloService'); + }); + + it('omits plaintext for a TLS target', () => { + const command = generateGrpcurlCommand(input({ url: 'grpcs://api.example.com:443' })); + expect(command).not.toContain('-plaintext'); + expect(command).toContain('api.example.com:443'); + }); + + it('passes enabled metadata as headers and skips disabled ones', () => { + const command = generateGrpcurlCommand( + input({ + metadata: [ + { name: 'authorization', value: 'Bearer t' }, + { name: 'x-legacy', value: 'off', disabled: true } + ] as GrpcMetadata[] + }) + ); + expect(command).toContain(`-H 'authorization: Bearer t'`); + expect(command).not.toContain('x-legacy'); + }); + + it('adds the import path and proto file when one is attached', () => { + const command = generateGrpcurlCommand(input({ protoFilePath: 'protos/telemetry/v1/telemetry.proto' })); + expect(command).toContain('-import-path protos/telemetry/v1'); + expect(command).toContain('-proto telemetry.proto'); + }); + + it('omits proto flags when the request uses reflection', () => { + const command = generateGrpcurlCommand(input()); + expect(command).not.toContain('-proto'); + expect(command).not.toContain('-import-path'); + }); + + it('pipes every message through stdin for client streaming', () => { + const command = generateGrpcurlCommand( + input({ + methodType: 'client-streaming', + messages: [ + { title: 'Message 1', message: '{"n":1}' }, + { title: 'Message 2', message: '{"n":2}' } + ] + }) + ); + expect(command).toContain('-d @'); + expect(command).toContain(`<< 'EOF'`); + expect(command).toContain('{"n":1}'); + expect(command).toContain('{"n":2}'); + expect(command.trimEnd().endsWith('EOF')).toBe(true); + }); + + it('pipes messages through stdin for bidi streaming too', () => { + const command = generateGrpcurlCommand( + input({ methodType: 'bidi-streaming', messages: [{ title: 'Message 1', message: '{"n":1}' }] }) + ); + expect(command).toContain('-d @'); + expect(command).toContain(`<< 'EOF'`); + }); + + it('leaves out the data flag when the request has no messages', () => { + const command = generateGrpcurlCommand(input({ messages: [] })); + expect(command).not.toContain('-d'); + expect(command).toContain('hello.HelloService/SayHello'); + }); + + it('keeps a quote in a metadata value inside the quoted header', () => { + const command = generateGrpcurlCommand( + input({ metadata: [{ name: 'x-note', value: 'it\'s here' }] as GrpcMetadata[] }) + ); + expect(command).toContain(`-H 'x-note: it'\\''s here'`); + }); + + it('keeps a quote in a message inside the quoted data flag', () => { + const command = generateGrpcurlCommand(input({ messages: [{ title: 'Message 1', message: `{"note":"it's"}` }] })); + expect(command).toContain(`-d '{"note":"it'\\''s"}'`); + }); +}); + +describe('generateGrpcJavaScriptCode', () => { + const withProto = (overrides: Partial = {}) => + input({ protoFilePath: 'protos/hello.proto', ...overrides }); + + it('loads the proto file and builds a client for the service', () => { + const code = generateGrpcJavaScriptCode(withProto({ methodType: 'unary' })); + expect(code).toContain(`protoLoader.loadSync('protos/hello.proto')`); + expect(code).toContain(`new proto.hello.HelloService('localhost:50051', grpc.credentials.createInsecure())`); + expect(code).toContain('client.SayHello('); + }); + + it('uses TLS credentials for a grpcs target', () => { + const code = generateGrpcJavaScriptCode(withProto({ url: 'grpcs://api.example.com:443' })); + expect(code).toContain('grpc.credentials.createSsl()'); + }); + + it('adds enabled metadata and skips disabled rows', () => { + const code = generateGrpcJavaScriptCode( + withProto({ + metadata: [ + { name: 'authorization', value: 'Bearer t' }, + { name: 'x-legacy', value: 'off', disabled: true } + ] as GrpcMetadata[] + }) + ); + expect(code).toContain(`metadata.set('authorization', 'Bearer t')`); + expect(code).not.toContain('x-legacy'); + }); + + it('listens for data on a server-streaming call', () => { + const code = generateGrpcJavaScriptCode(withProto({ methodType: 'server-streaming' })); + expect(code).toContain('const call = client.SayHello('); + expect(code).toContain(`call.on('data'`); + expect(code).not.toContain('call.write'); + }); + + it('writes every message for a client-streaming call', () => { + const code = generateGrpcJavaScriptCode( + withProto({ + methodType: 'client-streaming', + messages: [ + { title: 'Message 1', message: '{"n":1}' }, + { title: 'Message 2', message: '{"n":2}' } + ] + }) + ); + expect(code).toContain('const messages = ['); + expect(code).toContain('{"n":1}'); + expect(code).toContain('{"n":2}'); + expect(code).toContain('call.write(message)'); + expect(code).toContain('call.end()'); + }); + + it('both reads and writes on a bidi call', () => { + const code = generateGrpcJavaScriptCode(withProto({ methodType: 'bidi-streaming' })); + expect(code).toContain(`call.on('data'`); + expect(code).toContain('call.write(message)'); + }); + + it('declares an empty message when the request carries none', () => { + const code = generateGrpcJavaScriptCode(withProto({ methodType: 'unary', messages: [] })); + expect(code).toContain('const message = {};'); + expect(code).toContain('client.SayHello(message, '); + }); + + it('declares an empty message for a server-streaming request that carries none', () => { + const code = generateGrpcJavaScriptCode(withProto({ methodType: 'server-streaming', messages: [] })); + expect(code).toContain('const message = {};'); + expect(code).toContain('const call = client.SayHello(message);'); + }); + + it('generates nothing when the method names no service', () => { + expect(generateGrpcJavaScriptCode(withProto({ method: 'SayHello' }))).toBe(''); + }); + + it('generates nothing when the request has no proto file', () => { + expect(generateGrpcJavaScriptCode(input())).toBe(''); + }); + + it('escapes a quote in a metadata value', () => { + const code = generateGrpcJavaScriptCode( + withProto({ metadata: [{ name: 'x-note', value: 'it\'s here' }] as GrpcMetadata[] }) + ); + expect(code).toContain(`metadata.set('x-note', 'it\\'s here');`); + }); +}); diff --git a/packages/bruno-api-docs/src/utils/grpcSnippets.ts b/packages/bruno-api-docs/src/utils/grpcSnippets.ts new file mode 100644 index 00000000..4663ea1f --- /dev/null +++ b/packages/bruno-api-docs/src/utils/grpcSnippets.ts @@ -0,0 +1,167 @@ +import type { GrpcMetadata, GrpcMethodType } from '@opencollection/types/requests/grpc'; +import type { GrpcMessageEntry } from './schemaHelpers'; + +export interface GrpcSnippetInput { + url: string; + method: string; + methodType?: GrpcMethodType; + protoFilePath?: string; + metadata: GrpcMetadata[]; + messages: GrpcMessageEntry[]; +} + +const parseTarget = (url: string): { target: string; plaintext: boolean } => { + const trimmed = url.trim(); + const match = trimmed.match(/^(grpcs?|https?):\/\/(.*)$/i); + if (!match) { + return { target: trimmed, plaintext: true }; + } + const scheme = match[1].toLowerCase(); + return { target: match[2], plaintext: scheme === 'grpc' || scheme === 'http' }; +}; + +const parseMethod = (method: string): string => method.replace(/^\//, ''); + +const parseProtoFlags = (protoFilePath: string): string[] => { + const segments = protoFilePath.split(/[\\/]/).filter(Boolean); + const file = segments[segments.length - 1]; + const dir = segments.slice(0, -1).join('/'); + return dir ? [`-import-path ${dir}`, `-proto ${file}`] : [`-proto ${file}`]; +}; + +const isClientStreaming = (methodType?: GrpcMethodType): boolean => + methodType === 'client-streaming' || methodType === 'bidi-streaming'; + +const parseService = (method: string): { servicePath: string; methodName: string } => { + const trimmed = method.replace(/^\//, ''); + const slash = trimmed.lastIndexOf('/'); + if (slash === -1) return { servicePath: '', methodName: trimmed }; + return { servicePath: trimmed.slice(0, slash), methodName: trimmed.slice(slash + 1) }; +}; + +const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`; + +const jsQuote = (value: string): string => + `'${value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'').replace(/\r/g, '\\r').replace(/\n/g, '\\n')}'`; + +const indent = (text: string, spaces: number): string => + text + .split('\n') + .map((line, index) => (index === 0 ? line : `${' '.repeat(spaces)}${line}`)) + .join('\n'); + +export const generateGrpcurlCommand = ({ + url, + method, + methodType, + protoFilePath, + metadata, + messages +}: GrpcSnippetInput): string => { + const { target, plaintext } = parseTarget(url); + const parts: string[] = ['grpcurl']; + + if (plaintext) { + parts.push('-plaintext'); + } + + for (const entry of metadata.filter((item) => !item.disabled)) { + parts.push(`-H ${shellQuote(`${entry.name}: ${entry.value}`)}`); + } + + if (protoFilePath) { + parts.push(...parseProtoFlags(protoFilePath)); + } + + const streaming = isClientStreaming(methodType); + + if (messages.length > 0) { + parts.push(streaming ? '-d @' : `-d ${shellQuote(messages[0].message)}`); + } + + parts.push(target); + parts.push(parseMethod(method)); + + const command = parts.join(' \\\n '); + + if (streaming && messages.length > 0) { + return `${command} << 'EOF'\n${messages.map((entry) => entry.message).join('\n')}\nEOF`; + } + + return command; +}; + +export const generateGrpcJavaScriptCode = ({ + url, + method, + methodType, + protoFilePath, + metadata, + messages +}: GrpcSnippetInput): string => { + const { servicePath, methodName } = parseService(method); + if (!protoFilePath || !servicePath) return ''; + + const { target, plaintext } = parseTarget(url); + const enabled = metadata.filter((entry) => !entry.disabled); + const credentials = plaintext ? 'grpc.credentials.createInsecure()' : 'grpc.credentials.createSsl()'; + + const lines: string[] = [ + `const grpc = require('@grpc/grpc-js');`, + `const protoLoader = require('@grpc/proto-loader');`, + '', + `const packageDefinition = protoLoader.loadSync(${jsQuote(protoFilePath)});`, + 'const proto = grpc.loadPackageDefinition(packageDefinition);', + '', + `const client = new proto.${servicePath}(${jsQuote(target)}, ${credentials});` + ]; + + if (enabled.length > 0) { + lines.push('', 'const metadata = new grpc.Metadata();'); + enabled.forEach((entry) => lines.push(`metadata.set(${jsQuote(entry.name)}, ${jsQuote(entry.value)});`)); + } + + const metadataArg = enabled.length > 0 ? 'metadata' : ''; + const streamsIn = methodType === 'client-streaming' || methodType === 'bidi-streaming'; + const streamsOut = methodType === 'server-streaming' || methodType === 'bidi-streaming'; + + if (streamsIn) { + lines.push('', 'const messages = ['); + messages.forEach((entry, index) => { + const comma = index === messages.length - 1 ? '' : ','; + lines.push(` ${indent(entry.message, 2)}${comma}`); + }); + lines.push('];'); + } else { + lines.push('', `const message = ${messages.length > 0 ? messages[0].message : '{}'};`); + } + + lines.push(''); + + const callArgs = [streamsIn ? '' : 'message', metadataArg].filter(Boolean).join(', '); + + if (!streamsIn && !streamsOut) { + lines.push( + `client.${methodName}(${callArgs}${callArgs ? ', ' : ''}(error, response) => {`, + ' console.log(error ?? response);', + '});' + ); + } else { + const opener = streamsIn && !streamsOut ? `${callArgs}${callArgs ? ', ' : ''}(error, response) => {` : callArgs; + if (streamsIn && !streamsOut) { + lines.push(`const call = client.${methodName}(${opener}`, ' console.log(error ?? response);', '});'); + } else { + lines.push(`const call = client.${methodName}(${opener});`); + } + + if (streamsOut) { + lines.push(`call.on('data', (response) => console.log(response));`, `call.on('end', () => console.log('done'));`); + } + + if (streamsIn) { + lines.push('', 'for (const message of messages) {', ' call.write(message);', '}', 'call.end();'); + } + } + + return lines.join('\n'); +}; diff --git a/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts b/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts index d78fc2ae..1fff8c88 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts @@ -1,10 +1,18 @@ import { describe, it, expect } from 'vitest'; import type { Item as OpenCollectionItem } from '@opencollection/types/collection/item'; -import { getItemDescription, getRequestBadgeLabel, getRequestAuth } from './schemaHelpers'; +import { + getItemDescription, + getRequestBadgeLabel, + getRequestAuth, + getGrpcMessages, + getGrpcMethod, + getGrpcMethodType, + getGrpcMetadata, + getGrpcProtoFileName +} from './schemaHelpers'; const item = (data: Record): OpenCollectionItem => data as unknown as OpenCollectionItem; -// getRequestAuth accepts only request items (not folders); cast fabricated shapes to its param type. const requestItem = (data: Record) => data as unknown as Parameters[0]; describe('getItemDescription', () => { @@ -68,3 +76,124 @@ describe('getRequestAuth', () => { expect(getRequestAuth(requestItem({ method: 'POST', request: { auth: undefined } }))).toBeUndefined(); }); }); + +describe('getGrpcMethod', () => { + it('reads the method from the grpc block', () => { + expect(getGrpcMethod(item({ grpc: { method: '/hello.HelloService/SayHello' } }))).toBe( + '/hello.HelloService/SayHello' + ); + }); + + it('returns an empty string when there is no method or no grpc block', () => { + expect(getGrpcMethod(item({ grpc: {} }))).toBe(''); + expect(getGrpcMethod(item({ type: 'grpc', url: 'grpc://localhost:50051' }))).toBe(''); + expect(getGrpcMethod(null)).toBe(''); + }); +}); + +describe('getGrpcMethodType', () => { + it('reads the method type from the grpc block', () => { + expect(getGrpcMethodType(item({ grpc: { methodType: 'bidi-streaming' } }))).toBe('bidi-streaming'); + }); + + it('returns undefined when the method type is absent', () => { + expect(getGrpcMethodType(item({ grpc: {} }))).toBeUndefined(); + expect(getGrpcMethodType(item({ type: 'grpc' }))).toBeUndefined(); + }); +}); + +describe('getGrpcMetadata', () => { + it('reads metadata rows, keeping descriptions and disabled flags', () => { + expect( + getGrpcMetadata( + item({ + grpc: { + metadata: [ + { name: 'authorization', value: 'Bearer t', description: 'Auth token' }, + { name: 'x-legacy', value: 'off', disabled: true } + ] + } + }) + ) + ).toEqual([ + { name: 'authorization', value: 'Bearer t', description: 'Auth token' }, + { name: 'x-legacy', value: 'off', disabled: true } + ]); + }); + + it('returns an empty list when there is no metadata', () => { + expect(getGrpcMetadata(item({ grpc: {} }))).toEqual([]); + expect(getGrpcMetadata(item({ type: 'grpc' }))).toEqual([]); + }); +}); + +describe('getGrpcMessages', () => { + it('wraps a single stored string as one numbered message', () => { + expect(getGrpcMessages(item({ grpc: { message: '{"a":1}' } }))).toEqual([ + { title: 'Message 1', message: '{"a":1}' } + ]); + }); + + it('reads a one-item list identically to a stored string', () => { + expect(getGrpcMessages(item({ grpc: { message: [{ title: 'message 1', message: '{"a":1}' }] } }))).toEqual([ + { title: 'message 1', message: '{"a":1}' } + ]); + }); + + it('keeps the order of a streaming message list', () => { + expect( + getGrpcMessages( + item({ + grpc: { + message: [ + { title: 'message 1', message: '{"greeting":"sortitus"}' }, + { title: 'message 2', message: '{"greeting":"porro"}' } + ] + } + }) + ) + ).toEqual([ + { title: 'message 1', message: '{"greeting":"sortitus"}' }, + { title: 'message 2', message: '{"greeting":"porro"}' } + ]); + }); + + it('numbers entries that have no title, using the stored position', () => { + expect(getGrpcMessages(item({ grpc: { message: [{ message: 'a' }, { title: '', message: 'b' }] } }))).toEqual([ + { title: 'Message 1', message: 'a' }, + { title: 'Message 2', message: 'b' } + ]); + }); + + it('drops blank messages but keeps the numbering of the ones that remain', () => { + expect(getGrpcMessages(item({ grpc: { message: ' ' } }))).toEqual([]); + expect(getGrpcMessages(item({ grpc: { message: [{ message: '' }, { message: 'b' }] } }))).toEqual([ + { title: 'Message 2', message: 'b' } + ]); + }); + + it('returns an empty list when there is no message or no grpc block', () => { + expect(getGrpcMessages(item({ grpc: {} }))).toEqual([]); + expect(getGrpcMessages(item({ type: 'grpc' }))).toEqual([]); + expect(getGrpcMessages(null)).toEqual([]); + }); +}); + +describe('getGrpcProtoFileName', () => { + it('returns just the file name from a stored path', () => { + expect(getGrpcProtoFileName(item({ grpc: { protoFilePath: 'protos/hello.proto' } }))).toBe('hello.proto'); + }); + + it('handles a path that climbs out of the collection folder', () => { + expect(getGrpcProtoFileName(item({ grpc: { protoFilePath: '../../Downloads/book.proto' } }))).toBe('book.proto'); + }); + + it('handles a windows-style path', () => { + expect(getGrpcProtoFileName(item({ grpc: { protoFilePath: 'protos\\book.proto' } }))).toBe('book.proto'); + }); + + it('returns undefined when no proto file is attached', () => { + expect(getGrpcProtoFileName(item({ grpc: {} }))).toBeUndefined(); + expect(getGrpcProtoFileName(item({ type: 'grpc' }))).toBeUndefined(); + }); +}); diff --git a/packages/bruno-api-docs/src/utils/schemaHelpers.ts b/packages/bruno-api-docs/src/utils/schemaHelpers.ts index 1e90629c..a56ded95 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.ts @@ -15,7 +15,7 @@ import type { OpenCollection } from '@opencollection/types'; import type { Item as OpenCollectionItem, Folder, ScriptFile } from '@opencollection/types/collection/item'; import type { HttpRequest, HttpRequestHeader, HttpRequestExample, HttpRequestBody, HttpRequestBodyVariant } from '@opencollection/types/requests/http'; import type { GraphQLRequest } from '@opencollection/types/requests/graphql'; -import type { GrpcRequest } from '@opencollection/types/requests/grpc'; +import type { GrpcRequest, GrpcRequestDetails, GrpcMetadata, GrpcMethodType } from '@opencollection/types/requests/grpc'; import type { WebSocketRequest } from '@opencollection/types/requests/websocket'; import type { Script, Scripts, ScriptType } from '@opencollection/types/common/scripts'; import { PROTOCOL_BADGE_LABELS } from '../constants'; @@ -119,8 +119,13 @@ export const isWebSocketRequest = (item: OpenCollectionItem | null | undefined): return getItemType(item) === 'websocket'; }; -// Check if an item is a request the docs viewer can't render (GraphQL, gRPC or WebSocket). -export const isUnsupportedRequest = ( +export const isUnsupportedRequestInDocs = ( + item: OpenCollectionItem | null | undefined +): item is GraphQLRequest | WebSocketRequest => { + return isGraphQLRequest(item) || isWebSocketRequest(item); +}; + +export const isUnsupportedRequestInPlayground = ( item: OpenCollectionItem | null | undefined ): item is GraphQLRequest | GrpcRequest | WebSocketRequest => { return isGraphQLRequest(item) || isGrpcRequest(item) || isWebSocketRequest(item); @@ -186,6 +191,52 @@ export const getRequestUrl = (item: RequestItem | null | undefined): string => { return ''; }; +export interface GrpcMessageEntry { + title: string; + message: string; +} + +const getGrpcDetails = (item: OpenCollectionItem | null | undefined): GrpcRequestDetails => + (item && 'grpc' in item ? (item as GrpcRequest).grpc : undefined) ?? {}; + +export const getGrpcMethod = (item: OpenCollectionItem | null | undefined): string => + getGrpcDetails(item).method ?? ''; + +export const getGrpcMethodType = (item: OpenCollectionItem | null | undefined): GrpcMethodType | undefined => + getGrpcDetails(item).methodType; + +export const getGrpcMetadata = (item: OpenCollectionItem | null | undefined): GrpcMetadata[] => + getGrpcDetails(item).metadata ?? []; + +export const getGrpcMessages = (item: OpenCollectionItem | null | undefined): GrpcMessageEntry[] => { + const message = getGrpcDetails(item).message; + + if (typeof message === 'string') { + return message.trim() ? [{ title: 'Message 1', message }] : []; + } + + if (Array.isArray(message)) { + return message + .map((variant, index) => ({ + title: variant.title || `Message ${index + 1}`, + message: variant.message ?? '' + })) + .filter((entry) => entry.message.trim().length > 0); + } + + return []; +}; + +export const getGrpcProtoFilePath = (item: OpenCollectionItem | null | undefined): string | undefined => + getGrpcDetails(item).protoFilePath || undefined; + +export const getGrpcProtoFileName = (item: OpenCollectionItem | null | undefined): string | undefined => { + const protoFilePath = getGrpcDetails(item).protoFilePath; + if (!protoFilePath) return undefined; + const segments = protoFilePath.split(/[\\/]/).filter(Boolean); + return segments[segments.length - 1]; +}; + /** * Get headers from an HTTP request (from http block or root) */ @@ -247,7 +298,7 @@ export const getHttpParams = (item: HttpRequest | null | undefined): HttpRequest // and writes (AuthTab) share this list so the two can't drift. export const REQUEST_PROTOCOL_KEYS = ['http', 'graphql', 'grpc', 'websocket'] as const; -export const getRequestAuth = (item: RequestItem | null | undefined): any => { +export const getRequestAuth = (item: OpenCollectionItem | null | undefined): any => { if (!item) return undefined; // Current schema: auth is part of the protocol-detail block. From 3eb5bfcef80961f961ea825e567943b4e0efbf4e Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Sun, 9 Aug 2026 18:00:00 +0530 Subject: [PATCH 02/18] fix(docs): quote the proto path, target and method in the grpcurl snippet The proto folder, proto filename, server address and method name were interpolated into the command unquoted, so a value carrying a space split into several shell words and one carrying shell syntax executed when the reader pasted the command. Headers and the message body were already quoted; these three were missed. Verified against grpcurl 1.9.3: the unquoted form of a spaced proto path fails with "Too many arguments", the quoted form completes the call. --- .../src/utils/grpcSnippets.spec.ts | 23 +++++++++++++++++-- .../bruno-api-docs/src/utils/grpcSnippets.ts | 6 ++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts b/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts index fad9bff1..f60991ab 100644 --- a/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts +++ b/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts @@ -47,8 +47,8 @@ describe('generateGrpcurlCommand', () => { it('adds the import path and proto file when one is attached', () => { const command = generateGrpcurlCommand(input({ protoFilePath: 'protos/telemetry/v1/telemetry.proto' })); - expect(command).toContain('-import-path protos/telemetry/v1'); - expect(command).toContain('-proto telemetry.proto'); + expect(command).toContain(`-import-path 'protos/telemetry/v1'`); + expect(command).toContain(`-proto 'telemetry.proto'`); }); it('omits proto flags when the request uses reflection', () => { @@ -99,6 +99,25 @@ describe('generateGrpcurlCommand', () => { const command = generateGrpcurlCommand(input({ messages: [{ title: 'Message 1', message: `{"note":"it's"}` }] })); expect(command).toContain(`-d '{"note":"it'\\''s"}'`); }); + + it('keeps a proto path with spaces as one shell word', () => { + const command = generateGrpcurlCommand(input({ protoFilePath: 'my protos/book service.proto' })); + expect(command).toContain(`-import-path 'my protos'`); + expect(command).toContain(`-proto 'book service.proto'`); + }); + + it('quotes the target and the method so neither can split or execute', () => { + const command = generateGrpcurlCommand( + input({ url: 'grpc://host$(whoami):50051', method: '/pkg.Svc/Do$(whoami)' }) + ); + expect(command).toContain(`'host$(whoami):50051'`); + expect(command).toContain(`'pkg.Svc/Do$(whoami)'`); + }); + + it('neutralises a proto path that carries a shell command', () => { + const command = generateGrpcurlCommand(input({ protoFilePath: 'protos/book$(rm -rf ~).proto' })); + expect(command).toContain(`-proto 'book$(rm -rf ~).proto'`); + }); }); describe('generateGrpcJavaScriptCode', () => { diff --git a/packages/bruno-api-docs/src/utils/grpcSnippets.ts b/packages/bruno-api-docs/src/utils/grpcSnippets.ts index 4663ea1f..55935fbf 100644 --- a/packages/bruno-api-docs/src/utils/grpcSnippets.ts +++ b/packages/bruno-api-docs/src/utils/grpcSnippets.ts @@ -26,7 +26,7 @@ const parseProtoFlags = (protoFilePath: string): string[] => { const segments = protoFilePath.split(/[\\/]/).filter(Boolean); const file = segments[segments.length - 1]; const dir = segments.slice(0, -1).join('/'); - return dir ? [`-import-path ${dir}`, `-proto ${file}`] : [`-proto ${file}`]; + return dir ? [`-import-path ${shellQuote(dir)}`, `-proto ${shellQuote(file)}`] : [`-proto ${shellQuote(file)}`]; }; const isClientStreaming = (methodType?: GrpcMethodType): boolean => @@ -79,8 +79,8 @@ export const generateGrpcurlCommand = ({ parts.push(streaming ? '-d @' : `-d ${shellQuote(messages[0].message)}`); } - parts.push(target); - parts.push(parseMethod(method)); + parts.push(shellQuote(target)); + parts.push(shellQuote(parseMethod(method))); const command = parts.join(' \\\n '); From a505c4b5a9b67812734d874561f5367babb00e3f Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Sun, 9 Aug 2026 18:25:15 +0530 Subject: [PATCH 03/18] fix(docs): decide grpcurl TLS from the resolved address The plaintext flag was read from the url as written. A collection that keeps its address in a variable hides the scheme there, so the flag fell back to assuming no encryption and every such request was sent -plaintext. Against a TLS server grpcurl then waits for the timeout and reports a context deadline, naming neither TLS nor the flag, so the failure reads as an unreachable server. The flag now comes from the variable's value, read through lookup rather than resolve so the show-variables toggle cannot change it. A secret value is never read, and the address itself is still emitted as written, so the token stays hoverable. The Prod environment pointed grpcUrl at port 443 over grpc://, which is the TLS port; it now reads grpcs://. --- .../e2e/pages/grpc-request.page.ts | 2 ++ .../e2e/tests/request/grpc-request.spec.ts | 15 +++++++++ .../GrpcRequestContent/GrpcRequestContent.tsx | 17 ++++++++-- .../bruno-api-docs/src/sampleCollection.ts | 2 +- .../src/utils/grpcSnippets.spec.ts | 33 +++++++++++++++++++ .../bruno-api-docs/src/utils/grpcSnippets.ts | 31 +++++++++++------ 6 files changed, 86 insertions(+), 14 deletions(-) diff --git a/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts b/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts index e51c8334..75785336 100644 --- a/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts +++ b/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts @@ -31,6 +31,8 @@ export class GrpcRequestPage extends BasePage { readonly emptyState = this.page.getByTestId('grpc-request-config-empty'); + readonly codeSnippet = this.page.getByTestId('grpc-request-code-snippet').getByTestId('code-snippet-code'); + messageCard(index: number) { return this.page.getByTestId(`grpc-messages-card-${index}`); } diff --git a/packages/bruno-api-docs/e2e/tests/request/grpc-request.spec.ts b/packages/bruno-api-docs/e2e/tests/request/grpc-request.spec.ts index c28d5d07..11e66880 100644 --- a/packages/bruno-api-docs/e2e/tests/request/grpc-request.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/request/grpc-request.spec.ts @@ -13,6 +13,21 @@ test.describe('Request page — gRPC requests', () => { await expect(grpcRequestPage.urlBar.tryButton).toHaveCount(0); }); + test('marks the grpcurl command plaintext for an unencrypted environment', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + + await expect(grpcRequestPage.codeSnippet).toContainText('-plaintext'); + await expect(grpcRequestPage.codeSnippet).toContainText('{{grpcUrl}}'); + }); + + test('drops plaintext when the environment resolves the address to TLS', async ({ grpcRequestPage, envSwitcher }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + await envSwitcher.selectEnvironment('Prod'); + + await expect(grpcRequestPage.codeSnippet).not.toContainText('-plaintext'); + await expect(grpcRequestPage.codeSnippet).toContainText('{{grpcUrl}}'); + }); + test('renders the request docs when the request provides them', async ({ grpcRequestPage }) => { await grpcRequestPage.open([REALTIME, 'Order Service']); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx index c6976471..7ebbbe7f 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx @@ -19,7 +19,8 @@ import { import { resolveInheritedAuth } from '../../utils/request'; import { generateGrpcurlCommand, generateGrpcJavaScriptCode } from '../../utils/grpcSnippets'; import { SnippetTabs, type Snippet } from '../SnippetTabs/SnippetTabs'; -import { useMarkdownRenderer } from '../../hooks'; +import { useMarkdownRenderer, useResolvedVariables } from '../../hooks'; +import { singleReferenceName } from '../../utils/variableResolution'; import { buildBreadcrumbSegments } from '../../utils/common'; import { AUTH_MODE_LABELS, GRPC_METHOD_TYPE_LABELS } from '../../constants'; import { Section } from '../Section/Section'; @@ -85,9 +86,19 @@ export const GrpcRequestContent: React.FC = ({ const hasLeftColumn = Boolean(protoFileName) || Boolean(method) || messages.length > 0 || metadata.length > 0 || showAuth; + const { lookup } = useResolvedVariables(); + + // Only the TLS flag reads this. A secret value is never looked at, so it cannot reach the snippet. + const resolvedUrl = useMemo(() => { + const name = singleReferenceName(url); + if (!name) return undefined; + const entry = lookup(name); + return entry.secret ? undefined : entry.value || undefined; + }, [url, lookup]); + const snippets = useMemo(() => { if (!method) return []; - const input = { url, method, methodType, protoFilePath, metadata, messages }; + const input = { url, resolvedUrl, method, methodType, protoFilePath, metadata, messages }; const built: Snippet[] = [ { id: 'grpcurl', label: 'grpcURL', language: 'bash', code: generateGrpcurlCommand(input) } ]; @@ -98,7 +109,7 @@ export const GrpcRequestContent: React.FC = ({ } return built; - }, [url, method, methodType, protoFilePath, metadata, messages]); + }, [url, resolvedUrl, method, methodType, protoFilePath, metadata, messages]); const md = useMarkdownRenderer(); diff --git a/packages/bruno-api-docs/src/sampleCollection.ts b/packages/bruno-api-docs/src/sampleCollection.ts index 4a9252ef..0ad0ed5b 100644 --- a/packages/bruno-api-docs/src/sampleCollection.ts +++ b/packages/bruno-api-docs/src/sampleCollection.ts @@ -34,7 +34,7 @@ config: - name: "host" value: "https://echo.usebruno.com" - name: "grpcUrl" - value: "grpc://echo.usebruno.com:443" + value: "grpcs://echo.usebruno.com:443" - name: "bearer_auth_token" secret: true type: "string" diff --git a/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts b/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts index f60991ab..3935d629 100644 --- a/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts +++ b/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts @@ -114,6 +114,33 @@ describe('generateGrpcurlCommand', () => { expect(command).toContain(`'pkg.Svc/Do$(whoami)'`); }); + it('drops plaintext when the scheme is TLS but hidden inside a variable', () => { + const command = generateGrpcurlCommand(input({ url: '{{host}}', resolvedUrl: 'grpcs://grpcb.in:9001' })); + expect(command).not.toContain('-plaintext'); + expect(command).toContain(`'{{host}}'`); + }); + + it('keeps plaintext when the variable resolves to an unencrypted scheme', () => { + const command = generateGrpcurlCommand(input({ url: '{{host}}', resolvedUrl: 'grpc://grpcb.in:9000' })); + expect(command).toContain('-plaintext'); + expect(command).toContain(`'{{host}}'`); + }); + + it('assumes plaintext when the variable resolves to a bare address', () => { + const command = generateGrpcurlCommand(input({ url: '{{host}}', resolvedUrl: 'grpcb.in:9000' })); + expect(command).toContain('-plaintext'); + }); + + it('assumes plaintext when the variable cannot be resolved', () => { + expect(generateGrpcurlCommand(input({ url: '{{host}}' }))).toContain('-plaintext'); + }); + + it('lets a scheme written into the url win over the resolved value', () => { + const command = generateGrpcurlCommand(input({ url: 'grpcs://api.example.com:443', resolvedUrl: 'grpc://ignored' })); + expect(command).not.toContain('-plaintext'); + expect(command).toContain(`'api.example.com:443'`); + }); + it('neutralises a proto path that carries a shell command', () => { const command = generateGrpcurlCommand(input({ protoFilePath: 'protos/book$(rm -rf ~).proto' })); expect(command).toContain(`-proto 'book$(rm -rf ~).proto'`); @@ -136,6 +163,12 @@ describe('generateGrpcJavaScriptCode', () => { expect(code).toContain('grpc.credentials.createSsl()'); }); + it('uses TLS credentials when the scheme is hidden inside a variable', () => { + const code = generateGrpcJavaScriptCode(withProto({ url: '{{host}}', resolvedUrl: 'grpcs://grpcb.in:9001' })); + expect(code).toContain('grpc.credentials.createSsl()'); + expect(code).toContain(`'{{host}}'`); + }); + it('adds enabled metadata and skips disabled rows', () => { const code = generateGrpcJavaScriptCode( withProto({ diff --git a/packages/bruno-api-docs/src/utils/grpcSnippets.ts b/packages/bruno-api-docs/src/utils/grpcSnippets.ts index 55935fbf..82691bde 100644 --- a/packages/bruno-api-docs/src/utils/grpcSnippets.ts +++ b/packages/bruno-api-docs/src/utils/grpcSnippets.ts @@ -8,16 +8,25 @@ export interface GrpcSnippetInput { protoFilePath?: string; metadata: GrpcMetadata[]; messages: GrpcMessageEntry[]; + /** What `url` resolves to, when a variable hides the scheme. Decides TLS only; never shown. */ + resolvedUrl?: string; } -const parseTarget = (url: string): { target: string; plaintext: boolean } => { +const schemeOf = (value: string): string | undefined => + value.trim().match(/^(grpcs?|https?):\/\//i)?.[1]?.toLowerCase(); + +/** + * The target is emitted as written, so a `{{host}}` stays a variable the reader can hover. + * TLS is a different question: it is decided here and never displayed, so it reads the + * resolved value when the scheme sits inside the variable. Guessing plaintext there makes + * grpcurl hang against a TLS server until it times out, blaming the network. + */ +const parseTarget = (url: string, resolvedUrl?: string): { target: string; plaintext: boolean } => { const trimmed = url.trim(); const match = trimmed.match(/^(grpcs?|https?):\/\/(.*)$/i); - if (!match) { - return { target: trimmed, plaintext: true }; - } - const scheme = match[1].toLowerCase(); - return { target: match[2], plaintext: scheme === 'grpc' || scheme === 'http' }; + const scheme = match ? match[1].toLowerCase() : schemeOf(resolvedUrl ?? ''); + const plaintext = !scheme || scheme === 'grpc' || scheme === 'http'; + return { target: match ? match[2] : trimmed, plaintext }; }; const parseMethod = (method: string): string => method.replace(/^\//, ''); @@ -56,9 +65,10 @@ export const generateGrpcurlCommand = ({ methodType, protoFilePath, metadata, - messages + messages, + resolvedUrl }: GrpcSnippetInput): string => { - const { target, plaintext } = parseTarget(url); + const { target, plaintext } = parseTarget(url, resolvedUrl); const parts: string[] = ['grpcurl']; if (plaintext) { @@ -97,12 +107,13 @@ export const generateGrpcJavaScriptCode = ({ methodType, protoFilePath, metadata, - messages + messages, + resolvedUrl }: GrpcSnippetInput): string => { const { servicePath, methodName } = parseService(method); if (!protoFilePath || !servicePath) return ''; - const { target, plaintext } = parseTarget(url); + const { target, plaintext } = parseTarget(url, resolvedUrl); const enabled = metadata.filter((entry) => !entry.disabled); const credentials = plaintext ? 'grpc.credentials.createInsecure()' : 'grpc.credentials.createSsl()'; From d4d1ce1fb7d7e51fcd7467eea0184b9192703397 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Sun, 9 Aug 2026 18:57:37 +0530 Subject: [PATCH 04/18] fix(docs): harden the generated gRPC snippets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A heredoc ends at the first line equal to its delimiter, so a streaming message containing a bare EOF line closed it early and handed the rest of the message to the shell as commands. The delimiter is now chosen to avoid every line the messages contain. The service path and method name are written into JavaScript positions no string escape can protect — `new proto.()` and `client.()` — so a method that is not a plain identifier path produced code that both ran arbitrary statements and failed to parse. Both are validated, and anything else yields no snippet, matching the existing no-service behaviour. parseProtoFlags dropped the leading empty segment when splitting, so an absolute /protos/book.proto silently pointed grpcurl at a relative protos. Also folds three copies of the leading-slash strip into one exported helper, renames isClientStreaming to streamsInFor since it covered bidi too, and removes a branch that re-tested the condition it had just used. --- .../src/utils/grpcSnippets.spec.ts | 52 +++++++++++ .../bruno-api-docs/src/utils/grpcSnippets.ts | 90 +++++++++++-------- 2 files changed, 105 insertions(+), 37 deletions(-) diff --git a/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts b/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts index 3935d629..90cb3beb 100644 --- a/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts +++ b/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts @@ -239,3 +239,55 @@ describe('generateGrpcJavaScriptCode', () => { expect(code).toContain(`metadata.set('x-note', 'it\\'s here');`); }); }); + +describe('grpcurl and JavaScript hardening', () => { + it('picks a heredoc delimiter no message line can close early', () => { + const command = generateGrpcurlCommand( + input({ + methodType: 'client-streaming', + messages: [ + { title: 'a', message: '{"a":1}\nEOF\nrm -rf ~' }, + { title: 'b', message: '{"b":2}' } + ] + }) + ); + expect(command).toContain(`<< 'EOF2'`); + expect(command.trimEnd().endsWith('EOF2')).toBe(true); + expect(command).toContain('rm -rf ~'); + }); + + it('keeps the plain delimiter when no message collides with it', () => { + const command = generateGrpcurlCommand( + input({ methodType: 'client-streaming', messages: [{ title: 'a', message: '{"a":1}' }] }) + ); + expect(command).toContain(`<< 'EOF'`); + }); + + it('keeps the root of an absolute proto path', () => { + const command = generateGrpcurlCommand(input({ protoFilePath: '/protos/book.proto' })); + expect(command).toContain(`-import-path '/protos'`); + expect(command).toContain(`-proto 'book.proto'`); + }); + + it('keeps a bare root proto path', () => { + const command = generateGrpcurlCommand(input({ protoFilePath: '/book.proto' })); + expect(command).toContain(`-import-path '/'`); + }); + + it('generates no JavaScript when the method is not a plain identifier path', () => { + const hostile = generateGrpcJavaScriptCode( + input({ protoFilePath: 'a.proto', method: '/pkg.Svc/Do(); process.exit(1); //' }) + ); + expect(hostile).toBe(''); + }); + + it('generates no JavaScript when the service segment is not an identifier path', () => { + expect(generateGrpcJavaScriptCode(input({ protoFilePath: 'a.proto', method: '/pkg-svc!/Do' }))).toBe(''); + }); + + it('still generates JavaScript for an ordinary dotted service path', () => { + const code = generateGrpcJavaScriptCode(input({ protoFilePath: 'a.proto', method: '/com.book.BookService/GetBook' })); + expect(code).toContain('new proto.com.book.BookService('); + expect(code).toContain('client.GetBook('); + }); +}); diff --git a/packages/bruno-api-docs/src/utils/grpcSnippets.ts b/packages/bruno-api-docs/src/utils/grpcSnippets.ts index 82691bde..24119dae 100644 --- a/packages/bruno-api-docs/src/utils/grpcSnippets.ts +++ b/packages/bruno-api-docs/src/utils/grpcSnippets.ts @@ -12,8 +12,9 @@ export interface GrpcSnippetInput { resolvedUrl?: string; } -const schemeOf = (value: string): string | undefined => - value.trim().match(/^(grpcs?|https?):\/\//i)?.[1]?.toLowerCase(); +const SCHEME_PATTERN = /^(grpcs?|https?):\/\//i; + +const schemeOf = (value: string): string | undefined => value.trim().match(SCHEME_PATTERN)?.[1]?.toLowerCase(); /** * The target is emitted as written, so a `{{host}}` stays a variable the reader can hover. @@ -23,31 +24,50 @@ const schemeOf = (value: string): string | undefined => */ const parseTarget = (url: string, resolvedUrl?: string): { target: string; plaintext: boolean } => { const trimmed = url.trim(); - const match = trimmed.match(/^(grpcs?|https?):\/\/(.*)$/i); - const scheme = match ? match[1].toLowerCase() : schemeOf(resolvedUrl ?? ''); + const scheme = schemeOf(trimmed) ?? schemeOf(resolvedUrl ?? ''); const plaintext = !scheme || scheme === 'grpc' || scheme === 'http'; - return { target: match ? match[2] : trimmed, plaintext }; + return { target: trimmed.replace(SCHEME_PATTERN, ''), plaintext }; }; -const parseMethod = (method: string): string => method.replace(/^\//, ''); +/** `/pkg.Service/Method` as grpcurl and the docs page both want it, without the leading slash. */ +export const grpcMethodPath = (method: string): string => method.replace(/^\//, ''); const parseProtoFlags = (protoFilePath: string): string[] => { - const segments = protoFilePath.split(/[\\/]/).filter(Boolean); - const file = segments[segments.length - 1]; - const dir = segments.slice(0, -1).join('/'); + const normalised = protoFilePath.replace(/\\/g, '/').replace(/\/{2,}/g, '/'); + const lastSlash = normalised.lastIndexOf('/'); + const file = lastSlash === -1 ? normalised : normalised.slice(lastSlash + 1); + // An absolute path keeps its root: slicing to index 0 would drop the leading slash. + const dir = lastSlash === -1 ? '' : normalised.slice(0, lastSlash) || '/'; return dir ? [`-import-path ${shellQuote(dir)}`, `-proto ${shellQuote(file)}`] : [`-proto ${shellQuote(file)}`]; }; -const isClientStreaming = (methodType?: GrpcMethodType): boolean => +const streamsInFor = (methodType?: GrpcMethodType): boolean => methodType === 'client-streaming' || methodType === 'bidi-streaming'; +const streamsOutFor = (methodType?: GrpcMethodType): boolean => + methodType === 'server-streaming' || methodType === 'bidi-streaming'; + const parseService = (method: string): { servicePath: string; methodName: string } => { - const trimmed = method.replace(/^\//, ''); + const trimmed = grpcMethodPath(method); const slash = trimmed.lastIndexOf('/'); if (slash === -1) return { servicePath: '', methodName: trimmed }; return { servicePath: trimmed.slice(0, slash), methodName: trimmed.slice(slash + 1) }; }; +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const IDENTIFIER_PATH = /^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*$/; + +/** + * A heredoc ends at the first line equal to its delimiter, so a message containing a bare + * `EOF` line would close it early and hand the rest to the shell. Pick one no message uses. + */ +const heredocDelimiter = (messages: string[]): string => { + const lines = new Set(messages.flatMap((message) => message.split('\n').map((line) => line.trim()))); + let delimiter = 'EOF'; + for (let suffix = 2; lines.has(delimiter); suffix += 1) delimiter = `EOF${suffix}`; + return delimiter; +}; + const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`; const jsQuote = (value: string): string => @@ -83,19 +103,21 @@ export const generateGrpcurlCommand = ({ parts.push(...parseProtoFlags(protoFilePath)); } - const streaming = isClientStreaming(methodType); + const streaming = streamsInFor(methodType); if (messages.length > 0) { parts.push(streaming ? '-d @' : `-d ${shellQuote(messages[0].message)}`); } parts.push(shellQuote(target)); - parts.push(shellQuote(parseMethod(method))); + parts.push(shellQuote(grpcMethodPath(method))); const command = parts.join(' \\\n '); if (streaming && messages.length > 0) { - return `${command} << 'EOF'\n${messages.map((entry) => entry.message).join('\n')}\nEOF`; + const bodies = messages.map((entry) => entry.message); + const delimiter = heredocDelimiter(bodies); + return `${command} << '${delimiter}'\n${bodies.join('\n')}\n${delimiter}`; } return command; @@ -111,7 +133,9 @@ export const generateGrpcJavaScriptCode = ({ resolvedUrl }: GrpcSnippetInput): string => { const { servicePath, methodName } = parseService(method); - if (!protoFilePath || !servicePath) return ''; + // Both land in code positions a string cannot be escaped into, so anything that is not a + // plain identifier path yields no snippet rather than a corrupted one. + if (!protoFilePath || !IDENTIFIER_PATH.test(servicePath) || !IDENTIFIER.test(methodName)) return ''; const { target, plaintext } = parseTarget(url, resolvedUrl); const enabled = metadata.filter((entry) => !entry.disabled); @@ -133,8 +157,8 @@ export const generateGrpcJavaScriptCode = ({ } const metadataArg = enabled.length > 0 ? 'metadata' : ''; - const streamsIn = methodType === 'client-streaming' || methodType === 'bidi-streaming'; - const streamsOut = methodType === 'server-streaming' || methodType === 'bidi-streaming'; + const streamsIn = streamsInFor(methodType); + const streamsOut = streamsOutFor(methodType); if (streamsIn) { lines.push('', 'const messages = ['); @@ -151,27 +175,19 @@ export const generateGrpcJavaScriptCode = ({ const callArgs = [streamsIn ? '' : 'message', metadataArg].filter(Boolean).join(', '); - if (!streamsIn && !streamsOut) { - lines.push( - `client.${methodName}(${callArgs}${callArgs ? ', ' : ''}(error, response) => {`, - ' console.log(error ?? response);', - '});' - ); + const withCallback = `${callArgs}${callArgs ? ', ' : ''}(error, response) => {`; + + if (!streamsOut) { + // Unary and client-streaming both end in a single response, so both take a callback. + const opening = streamsIn ? `const call = client.${methodName}(` : `client.${methodName}(`; + lines.push(`${opening}${withCallback}`, ' console.log(error ?? response);', '});'); } else { - const opener = streamsIn && !streamsOut ? `${callArgs}${callArgs ? ', ' : ''}(error, response) => {` : callArgs; - if (streamsIn && !streamsOut) { - lines.push(`const call = client.${methodName}(${opener}`, ' console.log(error ?? response);', '});'); - } else { - lines.push(`const call = client.${methodName}(${opener});`); - } - - if (streamsOut) { - lines.push(`call.on('data', (response) => console.log(response));`, `call.on('end', () => console.log('done'));`); - } - - if (streamsIn) { - lines.push('', 'for (const message of messages) {', ' call.write(message);', '}', 'call.end();'); - } + lines.push(`const call = client.${methodName}(${callArgs});`); + lines.push(`call.on('data', (response) => console.log(response));`, `call.on('end', () => console.log('done'));`); + } + + if (streamsIn) { + lines.push('', 'for (const message of messages) {', ' call.write(message);', '}', 'call.end();'); } return lines.join('\n'); From af61853d5fb1d567f52101f25694c8b0e07e1b4e Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Sun, 9 Aug 2026 18:57:51 +0530 Subject: [PATCH 05/18] feat(docs): render the execution context on gRPC request pages A gRPC request carries variables, scripts, assertions and post-response captures in its runtime block, and the converter writes all four, but the docs page dropped the lot. An HTTP request in the same collection showed them, so a scripted gRPC request silently documented less than its neighbour. The collectors already read the generic runtime paths and were only typed to HttpRequest, so they are widened to Item rather than duplicated. Post- response captures live in runtime.actions for every protocol; the published GrpcRequestRuntime omits that field, so it is read structurally until the types catch up. The sample Order Service gains a runtime block so the section renders real content in the docs and the tests assert against it. --- .../e2e/pages/grpc-request.page.ts | 5 ++ .../e2e/tests/request/grpc-request.spec.ts | 40 ++++++++++++ .../GrpcRequestContent.spec.tsx | 59 +++++++++++++++++ .../GrpcRequestContent/GrpcRequestContent.tsx | 65 +++++++++++++++++-- .../GrpcRequestContent/StyledWrapper.ts | 5 ++ .../bruno-api-docs/src/sampleCollection.ts | 36 ++++++++++ .../bruno-api-docs/src/utils/assertions.ts | 8 +-- .../bruno-api-docs/src/utils/fileUtils.ts | 11 ++-- packages/bruno-api-docs/src/utils/request.ts | 21 ++++-- .../bruno-api-docs/src/utils/schemaHelpers.ts | 2 +- 10 files changed, 230 insertions(+), 22 deletions(-) diff --git a/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts b/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts index 75785336..552d445e 100644 --- a/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts +++ b/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts @@ -2,6 +2,7 @@ import { BasePage } from './base.page'; import { SidebarComponent } from '../components/sidebar.component'; import { BreadcrumbComponent } from '../components/breadcrumb.component'; import { RequestUrlBarComponent } from '../components/request/url-bar.component'; +import { ExecutionContextComponent } from '../components/request/execution-context.component'; export class GrpcRequestPage extends BasePage { readonly sidebar = new SidebarComponent(this.page); @@ -33,6 +34,10 @@ export class GrpcRequestPage extends BasePage { readonly codeSnippet = this.page.getByTestId('grpc-request-code-snippet').getByTestId('code-snippet-code'); + readonly executionContext = new ExecutionContextComponent(this.page); + readonly executionContextSection = this.page.getByTestId('grpc-request-section-execution-context'); + readonly executionContextEmpty = this.page.getByTestId('grpc-request-execution-context-empty'); + messageCard(index: number) { return this.page.getByTestId(`grpc-messages-card-${index}`); } diff --git a/packages/bruno-api-docs/e2e/tests/request/grpc-request.spec.ts b/packages/bruno-api-docs/e2e/tests/request/grpc-request.spec.ts index 11e66880..dd736439 100644 --- a/packages/bruno-api-docs/e2e/tests/request/grpc-request.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/request/grpc-request.spec.ts @@ -157,3 +157,43 @@ test.describe('Request page — gRPC messages', () => { await expect(grpcRequestPage.messageToggle(4)).toHaveAttribute('aria-expanded', 'true'); }); }); + +test.describe('Request page — gRPC execution context', () => { + test('lists the variables the request defines', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + await grpcRequestPage.executionContext.openTab('variables'); + + await expect(grpcRequestPage.executionContext.variable('orderId')).toBeVisible(); + await expect(grpcRequestPage.executionContext.variable('lastOrderStatus')).toBeVisible(); + }); + + test('lists the scripts that run around the call', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + await grpcRequestPage.executionContext.openTab('scripts'); + + await expect(grpcRequestPage.executionContext.scriptStep('Request Pre-Request')).toBeVisible(); + await expect(grpcRequestPage.executionContext.scriptStep('Request Post-Response')).toBeVisible(); + }); + + test('lists the assertions the request declares', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + await grpcRequestPage.executionContext.openTab('asserts'); + + await expect(grpcRequestPage.executionContext.assertion('res.body.orderId')).toBeVisible(); + }); + + test('lists the tests the request declares', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + await grpcRequestPage.executionContext.openTab('tests'); + + await expect(grpcRequestPage.executionContext.testsPanel).toContainText('returns the requested order'); + }); + + test('still shows the inherited chain for a request with no runtime of its own', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Chat']); + await grpcRequestPage.executionContext.openTab('scripts'); + + await expect(grpcRequestPage.executionContext.scriptStep('Collection Pre-Request')).toBeVisible(); + await expect(grpcRequestPage.executionContext.scriptStep('Request Pre-Request')).toHaveCount(0); + }); +}); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx index f344cd98..fc7cde9a 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx @@ -299,3 +299,62 @@ it('offers a JavaScript snippet only when a proto file is attached', () => { expect(reflectionOnly).toContain('code-snippet-tab-grpcurl'); expect(reflectionOnly).not.toContain('code-snippet-tab-javascript'); }); + +describe('GrpcRequestContent — execution context', () => { + const withRuntime = (runtime: Record) => + renderToStaticMarkup( + + ); + + it('renders an empty state when the request carries no runtime', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('grpc-request-section-execution-context'); + expect(html).toContain('grpc-request-execution-context-empty'); + expect(html).toContain('No execution context'); + }); + + it('renders pre-request variables from the runtime block', () => { + const html = withRuntime({ variables: [{ name: 'orderId', value: '12345' }] }); + expect(html).not.toContain('grpc-request-execution-context-empty'); + expect(html).toContain('orderId'); + }); + + it('renders post-response captures stored as actions', () => { + const html = withRuntime({ + actions: [ + { + type: 'set-variable', + trigger: 'after-response', + variable: { name: 'lastOrderStatus', scope: 'runtime' }, + selector: { expression: 'res.body.status' } + } + ] + }); + expect(html).not.toContain('grpc-request-execution-context-empty'); + expect(html).toContain('lastOrderStatus'); + }); + + it('renders assertions from the runtime block', () => { + const html = withRuntime({ assertions: [{ expression: 'res.body.orderId', operator: 'eq', value: '12345' }] }); + expect(html).not.toContain('grpc-request-execution-context-empty'); + expect(html).toContain('res.body.orderId'); + }); + + it('renders scripts from the runtime block', () => { + const html = withRuntime({ scripts: [{ type: 'before-request', code: 'bru.setVar(\'requestedAt\', Date.now());' }] }); + expect(html).not.toContain('grpc-request-execution-context-empty'); + }); +}); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx index 7ebbbe7f..d29119db 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx @@ -16,8 +16,18 @@ import { getGrpcProtoFilePath, countEnabled } from '../../utils/schemaHelpers'; -import { resolveInheritedAuth } from '../../utils/request'; -import { generateGrpcurlCommand, generateGrpcJavaScriptCode } from '../../utils/grpcSnippets'; +import { + resolveInheritedAuth, + getPreRequestVars, + getPostResponseVars, + buildScriptChain, + getScriptFlow +} from '../../utils/request'; +import { collectAssertions } from '../../utils/assertions'; +import { collectTests, collectRawTestScripts } from '../../utils/fileUtils'; +import { ExecutionContext } from '../ExecutionContext/ExecutionContext'; +import { RefreshIcon } from '../../assets/icons'; +import { generateGrpcurlCommand, generateGrpcJavaScriptCode, grpcMethodPath } from '../../utils/grpcSnippets'; import { SnippetTabs, type Snippet } from '../SnippetTabs/SnippetTabs'; import { useMarkdownRenderer, useResolvedVariables } from '../../hooks'; import { singleReferenceName } from '../../utils/variableResolution'; @@ -88,7 +98,8 @@ export const GrpcRequestContent: React.FC = ({ const { lookup } = useResolvedVariables(); - // Only the TLS flag reads this. A secret value is never looked at, so it cannot reach the snippet. + // Only the TLS flag reads this. `lookup` returns a secret's real value, so the guard below is + // what keeps it out of the snippet — the resolver does not mask it here. const resolvedUrl = useMemo(() => { const name = singleReferenceName(url); if (!name) return undefined; @@ -123,6 +134,23 @@ export const GrpcRequestContent: React.FC = ({ [collection, ancestry] ); + const preVars = useMemo(() => getPreRequestVars(item), [item]); + const postVars = useMemo(() => getPostResponseVars(item), [item]); + const scriptChain = useMemo(() => buildScriptChain(collection, ancestry, item), [collection, ancestry, item]); + const scriptFlow = useMemo(() => getScriptFlow(collection), [collection]); + const assertions = useMemo(() => collectAssertions(item), [item]); + const tests = useMemo( + () => collectTests(collection, ancestry, item, scriptFlow), + [collection, ancestry, item, scriptFlow] + ); + const testScripts = useMemo( + () => collectRawTestScripts(collection, ancestry, item, scriptFlow), + [collection, ancestry, item, scriptFlow] + ); + + const hasExecutionContext + = scriptChain.length > 0 || preVars.length > 0 || postVars.length > 0 || assertions.length > 0 || tests.length > 0; + return ( @@ -160,7 +188,7 @@ export const GrpcRequestContent: React.FC = ({
- {method.replace(/^\//, '')} + {grpcMethodPath(method)} {methodTypeLabel && {methodTypeLabel}}
@@ -227,6 +255,35 @@ export const GrpcRequestContent: React.FC = ({ subheading="This request has no method, messages, metadata, or authentication configured." /> )} + +
+ {hasExecutionContext ? ( + + ) : ( + } + heading="No execution context" + subheading="This request has no scripts, variables, asserts, or tests configured." + /> + )} +
); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/StyledWrapper.ts b/packages/bruno-api-docs/src/components/GrpcRequestContent/StyledWrapper.ts index a9f038b2..490753b5 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/StyledWrapper.ts @@ -11,6 +11,11 @@ export const StyledWrapper = styled.div` margin-top: 1.5rem; } + .grpc-request-fullwidth { + margin-top: 2rem; + padding-top: 2rem; + } + .grpc-request-columns { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); diff --git a/packages/bruno-api-docs/src/sampleCollection.ts b/packages/bruno-api-docs/src/sampleCollection.ts index 0ad0ed5b..bffbec30 100644 --- a/packages/bruno-api-docs/src/sampleCollection.ts +++ b/packages/bruno-api-docs/src/sampleCollection.ts @@ -172,6 +172,42 @@ items: "includeItems" : true } auth: inherit + runtime: + variables: + - name: "orderId" + value: "12345" + description: "Order the call fetches" + - name: "retryBudget" + value: "2" + disabled: true + scripts: + - type: "before-request" + code: |- + bru.setVar('requestedAt', Date.now()); + - type: "after-response" + code: |- + bru.setVar('orderStatus', res.body.status); + - type: "tests" + code: |- + test('returns the requested order', function () { + expect(res.body.orderId).to.equal(bru.getVar('orderId')); + }); + assertions: + - expression: "res.body.orderId" + operator: "eq" + value: "12345" + description: "Echoes back the order it was asked for" + - expression: "res.body.items" + operator: "isDefined" + actions: + - type: "set-variable" + trigger: "after-response" + variable: + name: "lastOrderStatus" + scope: "runtime" + selector: + expression: "res.body.status" + description: "Captured for the next request in the folder" docs: | # Order Service diff --git a/packages/bruno-api-docs/src/utils/assertions.ts b/packages/bruno-api-docs/src/utils/assertions.ts index 29fb1a15..00362ae6 100644 --- a/packages/bruno-api-docs/src/utils/assertions.ts +++ b/packages/bruno-api-docs/src/utils/assertions.ts @@ -1,6 +1,6 @@ -import type { HttpRequest } from '@opencollection/types/requests/http'; +import type { Item } from '@opencollection/types/collection/item'; import type { Assertion } from '@opencollection/types/common/assertions'; -import { getRequestAssertions } from './schemaHelpers'; +import { getRequestAssertions, type RequestItem } from './schemaHelpers'; import { getDescription } from './request'; const OPERATOR_LABELS: Record = { @@ -52,8 +52,8 @@ export interface AssertionRow { disabled?: boolean; } -export const collectAssertions = (item: HttpRequest): AssertionRow[] => - getRequestAssertions(item).map((assertion: Assertion) => { +export const collectAssertions = (item: Item): AssertionRow[] => + getRequestAssertions(item as RequestItem).map((assertion: Assertion) => { const unary = isUnaryOperator(assertion.operator); return { level: 'request', diff --git a/packages/bruno-api-docs/src/utils/fileUtils.ts b/packages/bruno-api-docs/src/utils/fileUtils.ts index 043a55a3..5c80b7c6 100644 --- a/packages/bruno-api-docs/src/utils/fileUtils.ts +++ b/packages/bruno-api-docs/src/utils/fileUtils.ts @@ -1,8 +1,7 @@ import type { OpenCollection } from '@opencollection/types'; import type { Item, Folder } from '@opencollection/types/collection/item'; -import type { HttpRequest } from '@opencollection/types/requests/http'; import type { Scripts } from '@opencollection/types/common/scripts'; -import { getItemName, getRequestScripts, scriptsArrayToObject, isFolder } from './schemaHelpers'; +import { getItemName, getRequestScripts, scriptsArrayToObject, isFolder, type RequestItem } from './schemaHelpers'; import { isYamlFile, parseYaml } from './yamlUtils'; import type { ScriptFlow } from './request'; @@ -306,7 +305,7 @@ interface TestSource { const forEachTestSource = ( collection: OpenCollection | null | undefined, ancestors: Item[], - item: HttpRequest, + item: Item, flow: ScriptFlow, visit: (level: TestRow['level'], code: string | undefined, sourceName?: string) => void ): void => { @@ -317,7 +316,7 @@ const forEachTestSource = ( code: testsCode(folderScripts(folder)), sourceName: getItemName(folder) })), - { level: 'request', code: testsCode(getRequestScripts(item)) } + { level: 'request', code: testsCode(getRequestScripts(item as RequestItem)) } ]; const ordered = flow === 'sequential' ? sources : [...sources].reverse(); @@ -327,7 +326,7 @@ const forEachTestSource = ( export const collectTests = ( collection: OpenCollection | null | undefined, ancestors: Item[], - item: HttpRequest, + item: Item, flow: ScriptFlow = 'sandwich' ): TestRow[] => { const rows: TestRow[] = []; @@ -356,7 +355,7 @@ export interface RawTestScript { export const collectRawTestScripts = ( collection: OpenCollection | null | undefined, ancestors: Item[], - item: HttpRequest, + item: Item, flow: ScriptFlow = 'sandwich' ): RawTestScript[] => { const scripts: RawTestScript[] = []; diff --git a/packages/bruno-api-docs/src/utils/request.ts b/packages/bruno-api-docs/src/utils/request.ts index 14bd3710..096ca246 100644 --- a/packages/bruno-api-docs/src/utils/request.ts +++ b/packages/bruno-api-docs/src/utils/request.ts @@ -20,7 +20,8 @@ import { getRequestScripts, scriptsArrayToObject, getRequestVariables, - getHttpHeaders + getHttpHeaders, + type RequestItem } from './schemaHelpers'; import { getItemUuid } from './itemUtils'; import { isSecretVariable, unwrapVariableValue } from './variableResolution'; @@ -275,7 +276,7 @@ const stepLabel = (level: ScriptLevel, phase: ScriptPhase): string => { export const buildScriptChain = ( collection: OpenCollection | null | undefined, ancestors: Item[], - item: HttpRequest + item: Item ): ScriptChainStep[] => { const collectionScripts = scriptsArrayToObject(collection?.request?.scripts); const sources: ScriptSource[] = [ @@ -285,7 +286,7 @@ export const buildScriptChain = ( const s = scriptsArrayToObject(folderScripts(folder)); sources.push({ level: 'folder', order: sources.length, sourceName: getItemName(folder), sourceUuid: getItemUuid(folder), pre: s.preRequest, post: s.postResponse }); }); - const requestScripts = scriptsArrayToObject(getRequestScripts(item)); + const requestScripts = scriptsArrayToObject(getRequestScripts(item as RequestItem)); sources.push({ level: 'request', order: sources.length, pre: requestScripts.preRequest, post: requestScripts.postResponse }); const steps: ScriptChainStep[] = []; @@ -342,11 +343,17 @@ const toPostResponseVarRow = (action: Action): PostResponseVarRow => ({ disabled: action.disabled }); -export const getPreRequestVars = (item: HttpRequest): PreRequestVarRow[] => - getRequestVariables(item).map(toPreRequestVarRow); +export const getPreRequestVars = (item: Item): PreRequestVarRow[] => + getRequestVariables(item as RequestItem).map(toPreRequestVarRow); -export const getPostResponseVars = (item: HttpRequest): PostResponseVarRow[] => - (item.runtime?.actions ?? []).filter(isAfterResponseSetVariable).map(toPostResponseVarRow); +/** + * Post-response captures live in `runtime.actions` for every protocol — the converter writes them + * for gRPC too — but the published `GrpcRequestRuntime` omits the field, so it is read structurally. + */ +export const getPostResponseVars = (item: Item): PostResponseVarRow[] => + ((item as { runtime?: { actions?: Action[] } }).runtime?.actions ?? []) + .filter(isAfterResponseSetVariable) + .map(toPostResponseVarRow); // Bridge the OC actions model (after-response set-variable) to the editable Variables rows, and back. export const actionsToPostResponseVars = (actions: Action[] = []): PostResponseVar[] => diff --git a/packages/bruno-api-docs/src/utils/schemaHelpers.ts b/packages/bruno-api-docs/src/utils/schemaHelpers.ts index a56ded95..e0ed0c9f 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.ts @@ -20,7 +20,7 @@ import type { WebSocketRequest } from '@opencollection/types/requests/websocket' import type { Script, Scripts, ScriptType } from '@opencollection/types/common/scripts'; import { PROTOCOL_BADGE_LABELS } from '../constants'; -type RequestItem = HttpRequest | GraphQLRequest | GrpcRequest | WebSocketRequest; +export type RequestItem = HttpRequest | GraphQLRequest | GrpcRequest | WebSocketRequest; /** A request body as stored on an item: a single body, a list of body variants, or none. */ export type RequestBody = HttpRequestBody | HttpRequestBodyVariant[] | undefined; From cb5689505b57442b4e99e58d1b900501a6d5e568 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Sun, 9 Aug 2026 19:16:16 +0530 Subject: [PATCH 06/18] fix(docs): correct motion, memo and hygiene defects on the gRPC page The message card re-declared the chevron's transition and rotation, which ChevronArrow already owns. Because the copy sat outside a media query it could override ChevronArrow's prefers-reduced-motion opt-out and animate for a reader who asked for no motion. The card's own open/close transition had no such guard at all, unlike the example card it was modelled on. The snippets memo listed metadata and messages, which the getters rebuild on every render, so both generators re-ran every time; the getters are memoized on the item instead. An omitted ancestry prop defaulted to a fresh array, invalidating four more memos for the same reason. Also documents why the message body mounts during render and why a collapsed body is marked inert, neither of which is apparent from the code, and adds the default export every sibling component has. --- .../GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx | 6 ++++++ .../GrpcMessages/GrpcMessageCard/StyledWrapper.ts | 10 ++++++---- .../GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx | 2 ++ .../GrpcRequestContent/GrpcRequestContent.tsx | 9 ++++++--- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx index 925d74fd..a54f4d14 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx @@ -18,6 +18,10 @@ export const GrpcMessageCard: React.FC = ({ onToggle, testId = 'grpc-message-card' }) => { + // The body mounts on first expand and stays mounted: the open/close animation runs on a + // grid-template-rows transition, which needs both states to exist to interpolate between. + // Setting it during render rather than in an effect keeps the first frame at the closed + // size, so opening animates instead of snapping. const [mounted, setMounted] = useState(expanded); if (expanded && !mounted) { setMounted(true); @@ -26,6 +30,8 @@ export const GrpcMessageCard: React.FC = ({ const detailId = useId(); const detailRef = useRef(null); + // A collapsed body is only clipped, not unmounted, so it stays focusable and reachable to a + // screen reader without `inert`. useEffect(() => { const el = detailRef.current; if (!el) return; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/StyledWrapper.ts b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/StyledWrapper.ts index 5f5b4157..817c46f1 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/StyledWrapper.ts @@ -40,10 +40,6 @@ export const StyledWrapper = styled.div` .grpc-message-chevron { flex: 0 0 auto; color: var(--text-muted); - transition: transform 0.15s ease; - } - .grpc-message-chevron.is-open { - transform: rotate(90deg); } .grpc-message-title { @@ -68,4 +64,10 @@ export const StyledWrapper = styled.div` .grpc-message-detail-body { border-top: 1px solid var(--border-color); } + + @media (prefers-reduced-motion: reduce) { + .grpc-message-detail { + transition: none; + } + } `; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx index 1c4ad9dc..4606bb74 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx @@ -34,3 +34,5 @@ export const GrpcMethodTypeIcon: React.FC = ({ methodTy ); }; + +export default GrpcMethodTypeIcon; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx index d29119db..9c15cd97 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx @@ -49,6 +49,9 @@ import { RequestUrlBar } from '../Request/RequestUrlBar/RequestUrlBar'; import { StyledWrapper } from './StyledWrapper'; import { FileIcon } from '../../assets/icons'; +/** Shared empty ancestry so an omitted prop keeps the same reference and the memos below hold. */ +const NO_ANCESTRY: Item[] = []; + interface GrpcRequestContentProps { item: GrpcRequest; collection?: OpenCollection | null; @@ -59,7 +62,7 @@ interface GrpcRequestContentProps { export const GrpcRequestContent: React.FC = ({ item, - ancestry = [], + ancestry = NO_ANCESTRY, collection, onBreadcrumbClick, testId = 'grpc-request-page' @@ -72,8 +75,8 @@ export const GrpcRequestContent: React.FC = ({ const protoFileName = getGrpcProtoFileName(item); const protoFilePath = getGrpcProtoFilePath(item); const methodTypeLabel = methodType ? GRPC_METHOD_TYPE_LABELS[methodType] : undefined; - const messages = getGrpcMessages(item); - const metadata = getGrpcMetadata(item); + const messages = useMemo(() => getGrpcMessages(item), [item]); + const metadata = useMemo(() => getGrpcMetadata(item), [item]); const enabledMetadataCount = countEnabled(metadata); const ownAuth = getRequestAuth(item) as Auth | undefined; From 87cd910d7ea6116f175c54783b05654f6f7512be Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Sun, 9 Aug 2026 19:31:25 +0530 Subject: [PATCH 07/18] fix(docs): keep rendering a request whose type the viewer does not know MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding an isHttpRequest guard to route gRPC away from the HTTP page turned the previously unconditional fall-through into a bare null, so an item with an unrecognised or missing type rendered a completely blank page — no breadcrumb, no title, nothing to distinguish it from a broken app. Such an item is reachable: navModel routes anything that is not a folder or a script to the request page. Restores the fall-through to RequestContent, which is what shipped before, and covers it so the next type guard cannot bring the blank page back. --- .../src/pages/Request/Request.spec.tsx | 26 ++++++++++++++++++ .../src/pages/Request/Request.tsx | 27 +++++++++---------- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx b/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx index 0fe87cef..ba3ad375 100644 --- a/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx +++ b/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx @@ -271,3 +271,29 @@ describe('Request page', () => { expect(description.text).not.toContain('©'); // © }); }); + +describe('Request — unrecognised request types', () => { + const unknownItem = (data: Record): Item => data as unknown as Item; + + it('renders a type the viewer has no page for as a request rather than a blank page', () => { + const root = useRenderToDom( + + + + ); + + expect(getByTestId(root, 'request-page')).toBeTruthy(); + expect(getByTestId(root, 'request-title').text).toContain('Quantum Ping'); + }); + + it('does the same for an item that names no type at all', () => { + const root = useRenderToDom( + + + + ); + + expect(getByTestId(root, 'request-page')).toBeTruthy(); + expect(getByTestId(root, 'request-title').text).toContain('Mystery'); + }); +}); diff --git a/packages/bruno-api-docs/src/pages/Request/Request.tsx b/packages/bruno-api-docs/src/pages/Request/Request.tsx index afdc989a..1049c066 100644 --- a/packages/bruno-api-docs/src/pages/Request/Request.tsx +++ b/packages/bruno-api-docs/src/pages/Request/Request.tsx @@ -16,7 +16,6 @@ import { getItemDocs, getItemDescription, getRequestExamples, - isHttpRequest, isUnsupportedRequestInDocs, isGrpcRequest } from '../../utils/schemaHelpers'; @@ -338,20 +337,18 @@ export const Request: React.FC = ({ ); } - if (isHttpRequest(item)) { - return ( - - ); - } - - return null; + // Anything else, including a type this viewer has no page for, renders as a request the way + // it always has: the HTTP getters read from the root and fall back to GET. + return ( + + ); }; export default Request; From 6c8978be15cc808a4a9e259b111949f77bcbf791 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Sun, 9 Aug 2026 20:28:42 +0530 Subject: [PATCH 08/18] refactored code --- .../GrpcMessageCard/GrpcMessageCard.tsx | 6 ------ .../GrpcRequestContent/GrpcRequestContent.tsx | 3 --- .../src/components/SnippetTabs/SnippetTabs.tsx | 1 - .../bruno-api-docs/src/pages/Request/Request.tsx | 2 -- .../bruno-api-docs/src/utils/grpcSnippets.ts | 16 ---------------- packages/bruno-api-docs/src/utils/request.ts | 4 ---- 6 files changed, 32 deletions(-) diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx index a54f4d14..925d74fd 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx @@ -18,10 +18,6 @@ export const GrpcMessageCard: React.FC = ({ onToggle, testId = 'grpc-message-card' }) => { - // The body mounts on first expand and stays mounted: the open/close animation runs on a - // grid-template-rows transition, which needs both states to exist to interpolate between. - // Setting it during render rather than in an effect keeps the first frame at the closed - // size, so opening animates instead of snapping. const [mounted, setMounted] = useState(expanded); if (expanded && !mounted) { setMounted(true); @@ -30,8 +26,6 @@ export const GrpcMessageCard: React.FC = ({ const detailId = useId(); const detailRef = useRef(null); - // A collapsed body is only clipped, not unmounted, so it stays focusable and reachable to a - // screen reader without `inert`. useEffect(() => { const el = detailRef.current; if (!el) return; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx index 9c15cd97..baad7eab 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx @@ -49,7 +49,6 @@ import { RequestUrlBar } from '../Request/RequestUrlBar/RequestUrlBar'; import { StyledWrapper } from './StyledWrapper'; import { FileIcon } from '../../assets/icons'; -/** Shared empty ancestry so an omitted prop keeps the same reference and the memos below hold. */ const NO_ANCESTRY: Item[] = []; interface GrpcRequestContentProps { @@ -101,8 +100,6 @@ export const GrpcRequestContent: React.FC = ({ const { lookup } = useResolvedVariables(); - // Only the TLS flag reads this. `lookup` returns a secret's real value, so the guard below is - // what keeps it out of the snippet — the resolver does not mask it here. const resolvedUrl = useMemo(() => { const name = singleReferenceName(url); if (!name) return undefined; diff --git a/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx b/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx index 02e14905..006b3417 100644 --- a/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx +++ b/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx @@ -17,7 +17,6 @@ export interface Snippet { interface SnippetTabsProps { snippets: Snippet[]; - /** `embedded` collapses the box to a button that opens the snippet in the modal. */ variant?: 'inline' | 'embedded'; className?: string; testId?: string; diff --git a/packages/bruno-api-docs/src/pages/Request/Request.tsx b/packages/bruno-api-docs/src/pages/Request/Request.tsx index 1049c066..6824373e 100644 --- a/packages/bruno-api-docs/src/pages/Request/Request.tsx +++ b/packages/bruno-api-docs/src/pages/Request/Request.tsx @@ -337,8 +337,6 @@ export const Request: React.FC = ({ ); } - // Anything else, including a type this viewer has no page for, renders as a request the way - // it always has: the HTTP getters read from the root and fall back to GET. return ( value.trim().match(SCHEME_PATTERN)?.[1]?.toLowerCase(); -/** - * The target is emitted as written, so a `{{host}}` stays a variable the reader can hover. - * TLS is a different question: it is decided here and never displayed, so it reads the - * resolved value when the scheme sits inside the variable. Guessing plaintext there makes - * grpcurl hang against a TLS server until it times out, blaming the network. - */ const parseTarget = (url: string, resolvedUrl?: string): { target: string; plaintext: boolean } => { const trimmed = url.trim(); const scheme = schemeOf(trimmed) ?? schemeOf(resolvedUrl ?? ''); @@ -29,14 +22,12 @@ const parseTarget = (url: string, resolvedUrl?: string): { target: string; plain return { target: trimmed.replace(SCHEME_PATTERN, ''), plaintext }; }; -/** `/pkg.Service/Method` as grpcurl and the docs page both want it, without the leading slash. */ export const grpcMethodPath = (method: string): string => method.replace(/^\//, ''); const parseProtoFlags = (protoFilePath: string): string[] => { const normalised = protoFilePath.replace(/\\/g, '/').replace(/\/{2,}/g, '/'); const lastSlash = normalised.lastIndexOf('/'); const file = lastSlash === -1 ? normalised : normalised.slice(lastSlash + 1); - // An absolute path keeps its root: slicing to index 0 would drop the leading slash. const dir = lastSlash === -1 ? '' : normalised.slice(0, lastSlash) || '/'; return dir ? [`-import-path ${shellQuote(dir)}`, `-proto ${shellQuote(file)}`] : [`-proto ${shellQuote(file)}`]; }; @@ -57,10 +48,6 @@ const parseService = (method: string): { servicePath: string; methodName: string const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; const IDENTIFIER_PATH = /^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*$/; -/** - * A heredoc ends at the first line equal to its delimiter, so a message containing a bare - * `EOF` line would close it early and hand the rest to the shell. Pick one no message uses. - */ const heredocDelimiter = (messages: string[]): string => { const lines = new Set(messages.flatMap((message) => message.split('\n').map((line) => line.trim()))); let delimiter = 'EOF'; @@ -133,8 +120,6 @@ export const generateGrpcJavaScriptCode = ({ resolvedUrl }: GrpcSnippetInput): string => { const { servicePath, methodName } = parseService(method); - // Both land in code positions a string cannot be escaped into, so anything that is not a - // plain identifier path yields no snippet rather than a corrupted one. if (!protoFilePath || !IDENTIFIER_PATH.test(servicePath) || !IDENTIFIER.test(methodName)) return ''; const { target, plaintext } = parseTarget(url, resolvedUrl); @@ -178,7 +163,6 @@ export const generateGrpcJavaScriptCode = ({ const withCallback = `${callArgs}${callArgs ? ', ' : ''}(error, response) => {`; if (!streamsOut) { - // Unary and client-streaming both end in a single response, so both take a callback. const opening = streamsIn ? `const call = client.${methodName}(` : `client.${methodName}(`; lines.push(`${opening}${withCallback}`, ' console.log(error ?? response);', '});'); } else { diff --git a/packages/bruno-api-docs/src/utils/request.ts b/packages/bruno-api-docs/src/utils/request.ts index 096ca246..00828b4c 100644 --- a/packages/bruno-api-docs/src/utils/request.ts +++ b/packages/bruno-api-docs/src/utils/request.ts @@ -346,10 +346,6 @@ const toPostResponseVarRow = (action: Action): PostResponseVarRow => ({ export const getPreRequestVars = (item: Item): PreRequestVarRow[] => getRequestVariables(item as RequestItem).map(toPreRequestVarRow); -/** - * Post-response captures live in `runtime.actions` for every protocol — the converter writes them - * for gRPC too — but the published `GrpcRequestRuntime` omits the field, so it is read structurally. - */ export const getPostResponseVars = (item: Item): PostResponseVarRow[] => ((item as { runtime?: { actions?: Action[] } }).runtime?.actions ?? []) .filter(isAfterResponseSetVariable) From 10edd56e30c7a5bd4b11a86dbf6b349c225e9c07 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Sun, 9 Aug 2026 20:57:58 +0530 Subject: [PATCH 09/18] fix(docs): address review on the method badge, snippet bodies and test ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The method badge inferred "render as written" from the casing of the value, so any collection storing a mixed-case HTTP method rendered it unchanged — Patch stayed Patch instead of PATCH. The escape hatch exists for gRPC alone, so it is now an explicit prop the gRPC url bar opts into. A message body was pasted straight into a code position in the JavaScript snippet. It is now parsed first: a body that is not JSON becomes a quoted string rather than executable text, while a templated body stays verbatim so its variables survive. Snippet child test ids derive from the testId prop instead of a hardcoded prefix, so the same component can be located under the request, example and gRPC bases; CodeSnippetComponent takes that base as a parameter. Also drops a spec helper that became identical to its neighbour once getRequestAuth was widened, re-homes a test that sat outside its describe, removes an unused e2e locator, and restates the method-colour comment as a timeless fact. --- .../request/code-snippet.component.ts | 34 +++++++----- .../components/request/examples.component.ts | 4 +- .../e2e/pages/grpc-request.page.ts | 3 +- .../tests/request/request-examples.spec.ts | 2 +- .../GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx | 6 +-- .../GrpcRequestContent.spec.tsx | 52 +++++++++---------- .../GrpcRequestContent/GrpcRequestContent.tsx | 2 +- .../components/MethodBadge/MethodBadge.tsx | 4 +- .../Request/RequestUrlBar/RequestUrlBar.tsx | 4 +- .../components/SnippetTabs/SnippetTabs.tsx | 8 +-- .../src/utils/grpcSnippets.spec.ts | 26 ++++++++++ .../bruno-api-docs/src/utils/grpcSnippets.ts | 17 +++++- .../src/utils/schemaHelpers.spec.ts | 10 ++-- 13 files changed, 108 insertions(+), 64 deletions(-) diff --git a/packages/bruno-api-docs/e2e/components/request/code-snippet.component.ts b/packages/bruno-api-docs/e2e/components/request/code-snippet.component.ts index 317d37dc..0ec3c85d 100644 --- a/packages/bruno-api-docs/e2e/components/request/code-snippet.component.ts +++ b/packages/bruno-api-docs/e2e/components/request/code-snippet.component.ts @@ -1,18 +1,24 @@ -import type { Locator } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; import { BaseComponent } from '../base.component'; export class CodeSnippetComponent extends BaseComponent { - readonly root = this.page.getByTestId('request-code-snippet'); - - readonly code = this.root.getByTestId('code-snippet-code'); - - readonly copyButton = this.root.getByTestId('code-snippet-code-copy'); - - readonly expandButton = this.root.getByTestId('code-snippet-expand'); - - readonly modal = this.page.getByTestId('code-snippet-modal'); - - readonly modalCode = this.modal.getByTestId('code-snippet-code'); + readonly code: Locator; + readonly copyButton: Locator; + readonly expandButton: Locator; + readonly modal: Locator; + readonly modalCode: Locator; + + constructor( + page: Page, + private readonly base = 'request-code-snippet' + ) { + super(page, page.getByTestId(base)); + this.code = this.root.getByTestId(`${base}-code`); + this.copyButton = this.root.getByTestId(`${base}-code-copy`); + this.expandButton = this.root.getByTestId(`${base}-expand`); + this.modal = page.getByTestId(`${base}-modal`); + this.modalCode = this.modal.getByTestId(`${base}-code`); + } variableToken(name: string): Locator { return this.code.getByTestId(`variable-token-${name}`).first(); @@ -23,11 +29,11 @@ export class CodeSnippetComponent extends BaseComponent { } languageTab(language: string): Locator { - return this.root.getByTestId(`code-snippet-tab-${language}`); + return this.root.getByTestId(`${this.base}-tab-${language}`); } modalLanguageTab(language: string): Locator { - return this.modal.getByTestId(`code-snippet-tab-${language}`); + return this.modal.getByTestId(`${this.base}-tab-${language}`); } async selectLanguage(language: string): Promise { diff --git a/packages/bruno-api-docs/e2e/components/request/examples.component.ts b/packages/bruno-api-docs/e2e/components/request/examples.component.ts index a90b3988..077844bf 100644 --- a/packages/bruno-api-docs/e2e/components/request/examples.component.ts +++ b/packages/bruno-api-docs/e2e/components/request/examples.component.ts @@ -32,14 +32,14 @@ export class ExamplesComponent extends BaseComponent { // The snippet dialog is portalled to , so it is scoped to the page, not the card. readonly snippetModal = this.page.getByRole('dialog', { name: 'Code snippet' }); - readonly snippetCode = this.snippetModal.getByTestId('code-snippet-code'); + readonly snippetCode = this.snippetModal.getByTestId('example-code-snippet-code'); snippetButton(name: string): Locator { return this.example(name).getByTestId('example-code-snippet-trigger'); } snippetLanguageTab(language: string): Locator { - return this.snippetModal.getByTestId(`code-snippet-tab-${language}`); + return this.snippetModal.getByTestId(`example-code-snippet-tab-${language}`); } async openSnippet(name: string): Promise { diff --git a/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts b/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts index 552d445e..0535f30f 100644 --- a/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts +++ b/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts @@ -20,7 +20,6 @@ export class GrpcRequestPage extends BasePage { readonly method = this.page.getByTestId('grpc-request-method'); readonly messagesSection = this.page.getByTestId('grpc-request-section-messages'); - readonly messages = this.page.getByTestId('grpc-messages'); readonly showToggle = this.page.getByTestId('grpc-messages-show-toggle'); readonly metadataSection = this.page.getByTestId('grpc-request-section-metadata'); @@ -32,7 +31,7 @@ export class GrpcRequestPage extends BasePage { readonly emptyState = this.page.getByTestId('grpc-request-config-empty'); - readonly codeSnippet = this.page.getByTestId('grpc-request-code-snippet').getByTestId('code-snippet-code'); + readonly codeSnippet = this.page.getByTestId('grpc-request-code-snippet').getByTestId('grpc-request-code-snippet-code'); readonly executionContext = new ExecutionContextComponent(this.page); readonly executionContextSection = this.page.getByTestId('grpc-request-section-execution-context'); diff --git a/packages/bruno-api-docs/e2e/tests/request/request-examples.spec.ts b/packages/bruno-api-docs/e2e/tests/request/request-examples.spec.ts index 3782b2f5..c883dc74 100644 --- a/packages/bruno-api-docs/e2e/tests/request/request-examples.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/request/request-examples.spec.ts @@ -128,7 +128,7 @@ test.describe('Request page — Examples', () => { const { examples } = requestPage; await examples.openSnippet(OK_EXAMPLE); await expect(page.getByRole('dialog')).toHaveCount(1); - await expect(examples.snippetModal.getByTestId('code-snippet-expand')).toHaveCount(0); + await expect(examples.snippetModal.getByTestId('example-code-snippet-expand')).toHaveCount(0); }); test('dismisses on Escape and returns focus to the trigger', async ({ requestPage, page }) => { diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx index 4606bb74..5c4a7a42 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx @@ -8,9 +8,9 @@ import { } from '../../../assets/icons'; import { StyledWrapper } from './StyledWrapper'; -// Method types borrow the HTTP method colours instead of defining their own, so -// both themes stay in step without new tokens. The Bruno app does the same, except -// it colours client-streaming as POST; here it follows the design's cyan. +// Method types borrow the HTTP method colour tokens rather than defining their own, so both +// themes stay in step without new tokens. Client-streaming maps to the head colour, which is +// where this differs from the Bruno app's own mapping. const ICON_BY_METHOD_TYPE: Record = { 'unary': { icon: UnaryIcon, color: 'var(--oc-request-methods-get)' }, 'server-streaming': { icon: ServerStreamingIcon, color: 'var(--oc-request-methods-put)' }, diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx index fc7cde9a..b5168a02 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx @@ -270,34 +270,34 @@ describe('GrpcRequestContent', () => { expect(html).not.toContain('grpc-request-config-empty'); expect(html).toContain('grpc-request-section-method'); }); -}); -it('offers a JavaScript snippet only when a proto file is attached', () => { - const withProto = renderToStaticMarkup( - - ); - expect(withProto).toContain('code-snippet-tab-javascript'); + it('offers a JavaScript snippet only when a proto file is attached', () => { + const withProto = renderToStaticMarkup( + + ); + expect(withProto).toContain('grpc-request-code-snippet-tab-javascript'); - const reflectionOnly = renderToStaticMarkup( - - ); - expect(reflectionOnly).toContain('code-snippet-tab-grpcurl'); - expect(reflectionOnly).not.toContain('code-snippet-tab-javascript'); + const reflectionOnly = renderToStaticMarkup( + + ); + expect(reflectionOnly).toContain('grpc-request-code-snippet-tab-grpcurl'); + expect(reflectionOnly).not.toContain('grpc-request-code-snippet-tab-javascript'); + }); }); describe('GrpcRequestContent — execution context', () => { diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx index baad7eab..e8fc85d3 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx @@ -163,7 +163,7 @@ export const GrpcRequestContent: React.FC = ({ {name} - + {descHtml && (
diff --git a/packages/bruno-api-docs/src/components/MethodBadge/MethodBadge.tsx b/packages/bruno-api-docs/src/components/MethodBadge/MethodBadge.tsx index 4d44b0bf..20995c0d 100644 --- a/packages/bruno-api-docs/src/components/MethodBadge/MethodBadge.tsx +++ b/packages/bruno-api-docs/src/components/MethodBadge/MethodBadge.tsx @@ -6,11 +6,11 @@ import { StyledWrapper } from './StyledWrapper'; interface MethodBadgeProps { method: string; className?: string; + asWritten?: boolean; } -export const MethodBadge: React.FC = ({ method, className }) => { +export const MethodBadge: React.FC = ({ method, className, asWritten = false }) => { const resolvedMethod = method || 'GET'; - const asWritten = resolvedMethod !== resolvedMethod.toLowerCase() && resolvedMethod !== resolvedMethod.toUpperCase(); return ( void; tryLabel?: string; @@ -18,6 +19,7 @@ interface RequestUrlBarProps { export const RequestUrlBar: React.FC = ({ method, + methodAsWritten = false, url, onTry, tryLabel = 'Try', @@ -27,7 +29,7 @@ export const RequestUrlBar: React.FC = ({ }) => ( - + diff --git a/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx b/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx index 006b3417..8f97ad15 100644 --- a/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx +++ b/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx @@ -60,7 +60,7 @@ export const SnippetTabs: React.FC = ({ type="button" role="tab" aria-selected={activeSnippet.id === snippet.id} - data-testid={`code-snippet-tab-${snippet.id}`} + data-testid={`${testId}-tab-${snippet.id}`} className={['snippet-tab', activeSnippet.id === snippet.id ? 'is-active' : ''].filter(Boolean).join(' ')} onClick={() => setActiveId(snippet.id)} > @@ -75,7 +75,7 @@ export const SnippetTabs: React.FC = ({ type="button" className="code-snippet-expand" aria-label="Expand code snippet" - data-testid="code-snippet-expand" + data-testid={`${testId}-expand`} onClick={openModal} > @@ -91,7 +91,7 @@ export const SnippetTabs: React.FC = ({ showCopy={placement === 'inline'} variableAware copyText={copyText} - testId="code-snippet-code" + testId={`${testId}-code`} />
); @@ -121,7 +121,7 @@ export const SnippetTabs: React.FC = ({ ariaLabel="Code snippet" > {expanded && ( - + {renderSnippetBox('modal', modalActive, setModalActive)} )} diff --git a/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts b/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts index 90cb3beb..e22b996e 100644 --- a/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts +++ b/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts @@ -291,3 +291,29 @@ describe('grpcurl and JavaScript hardening', () => { expect(code).toContain('client.GetBook('); }); }); + +describe('message bodies in the JavaScript snippet', () => { + const withProto = (overrides: Partial = {}) => + input({ protoFilePath: 'a.proto', method: '/pkg.Svc/Do', ...overrides }); + + it('keeps a valid JSON body exactly as the author wrote it', () => { + const code = generateGrpcJavaScriptCode(withProto({ messages: [{ title: 'a', message: '{\n "n": 1\n}' }] })); + expect(code).toContain('const message = {\n "n": 1\n};'); + }); + + it('keeps a templated body so its variables survive', () => { + const code = generateGrpcJavaScriptCode(withProto({ messages: [{ title: 'a', message: '{"id":"{{orderId}}"}' }] })); + expect(code).toContain('const message = {"id":"{{orderId}}"};'); + }); + + it('quotes a body that is not JSON so it cannot become executable code', () => { + const code = generateGrpcJavaScriptCode(withProto({ messages: [{ title: 'a', message: '};process.exit(1);//' }] })); + expect(code).toContain(`const message = '};process.exit(1);//';`); + expect(code).not.toContain('const message = };'); + }); + + it('falls back to an empty object for a blank body', () => { + const code = generateGrpcJavaScriptCode(withProto({ messages: [{ title: 'a', message: ' ' }] })); + expect(code).toContain('const message = {};'); + }); +}); diff --git a/packages/bruno-api-docs/src/utils/grpcSnippets.ts b/packages/bruno-api-docs/src/utils/grpcSnippets.ts index 560d2e67..9c15e482 100644 --- a/packages/bruno-api-docs/src/utils/grpcSnippets.ts +++ b/packages/bruno-api-docs/src/utils/grpcSnippets.ts @@ -1,5 +1,6 @@ import type { GrpcMetadata, GrpcMethodType } from '@opencollection/types/requests/grpc'; import type { GrpcMessageEntry } from './schemaHelpers'; +import { templateVariableGlobalRegex } from './common'; export interface GrpcSnippetInput { url: string; @@ -60,6 +61,18 @@ const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}' const jsQuote = (value: string): string => `'${value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'').replace(/\r/g, '\\r').replace(/\n/g, '\\n')}'`; +const jsObjectLiteral = (message: string): string => { + const trimmed = message.trim(); + if (!trimmed) return '{}'; + if (templateVariableGlobalRegex().test(trimmed)) return trimmed; + try { + JSON.parse(trimmed); + return trimmed; + } catch { + return jsQuote(trimmed); + } +}; + const indent = (text: string, spaces: number): string => text .split('\n') @@ -149,11 +162,11 @@ export const generateGrpcJavaScriptCode = ({ lines.push('', 'const messages = ['); messages.forEach((entry, index) => { const comma = index === messages.length - 1 ? '' : ','; - lines.push(` ${indent(entry.message, 2)}${comma}`); + lines.push(` ${indent(jsObjectLiteral(entry.message), 2)}${comma}`); }); lines.push('];'); } else { - lines.push('', `const message = ${messages.length > 0 ? messages[0].message : '{}'};`); + lines.push('', `const message = ${messages.length > 0 ? jsObjectLiteral(messages[0].message) : '{}'};`); } lines.push(''); diff --git a/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts b/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts index 1fff8c88..2532cfb1 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts @@ -13,8 +13,6 @@ import { const item = (data: Record): OpenCollectionItem => data as unknown as OpenCollectionItem; -const requestItem = (data: Record) => data as unknown as Parameters[0]; - describe('getItemDescription', () => { it('reads a plain string description from the info block', () => { expect(getItemDescription({ info: { description: 'Short summary.' } } as any)).toBe('Short summary.'); @@ -56,24 +54,24 @@ describe('getRequestBadgeLabel', () => { describe('getRequestAuth', () => { it('lets the protocol block win over a request-block auth', () => { expect( - getRequestAuth(requestItem({ http: { auth: { type: 'bearer' } }, request: { auth: { type: 'apikey' } } })) + getRequestAuth(item({ http: { auth: { type: 'bearer' } }, request: { auth: { type: 'apikey' } } })) ).toEqual({ type: 'bearer' }); }); it('reads auth nested under a request block (flat-shape requests)', () => { - expect(getRequestAuth(requestItem({ method: 'POST', request: { auth: { type: 'apikey' } } }))).toEqual({ + expect(getRequestAuth(item({ method: 'POST', request: { auth: { type: 'apikey' } } }))).toEqual({ type: 'apikey' }); }); it('falls back to request.auth when a protocol block exists without auth', () => { expect( - getRequestAuth(requestItem({ http: { body: { type: 'json' } }, request: { auth: { type: 'apikey' } } })) + getRequestAuth(item({ http: { body: { type: 'json' } }, request: { auth: { type: 'apikey' } } })) ).toEqual({ type: 'apikey' }); }); it('treats a cleared request-block auth as no auth', () => { - expect(getRequestAuth(requestItem({ method: 'POST', request: { auth: undefined } }))).toBeUndefined(); + expect(getRequestAuth(item({ method: 'POST', request: { auth: undefined } }))).toBeUndefined(); }); }); From e01a3462ab01cfd4a0934b4d4def2303b4e419b1 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Sun, 9 Aug 2026 21:05:54 +0530 Subject: [PATCH 10/18] fix(docs): keep table header semantics when hidden, and cover SnippetTabs hideHeader removed the thead outright, taking the th scope="col" cells with it, so a screen reader lost the column each metadata cell belonged to. The header now stays in the markup and is hidden visually, reusing the same clip technique the table caption already uses. SnippetTabs had no spec of its own and was only exercised through the HTTP wrapper, so the empty, embedded and test-id-derivation branches were unread. --- .../SnippetTabs/SnippetTabs.spec.tsx | 72 +++++++++++++++++++ .../src/ui/Table/StyledWrapper.ts | 12 ++++ .../src/ui/Table/Table.spec.tsx | 20 ++++++ .../bruno-api-docs/src/ui/Table/Table.tsx | 30 ++++---- 4 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.spec.tsx diff --git a/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.spec.tsx b/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.spec.tsx new file mode 100644 index 00000000..3e2a3bae --- /dev/null +++ b/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.spec.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { SnippetTabs, type Snippet } from './SnippetTabs'; + +const snippets: Snippet[] = [ + { id: 'grpcurl', label: 'grpcURL', language: 'bash', code: 'grpcurl -plaintext {{host}} pkg.Svc/Do' }, + { id: 'javascript', label: 'JavaScript', language: 'javascript', code: 'const grpc = require(\'@grpc/grpc-js\');' } +]; + +describe('SnippetTabs', () => { + it('renders a tab per snippet and shows the first one', () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('grpcURL'); + expect(html).toContain('JavaScript'); + expect(html).toContain('pkg.Svc/Do'); + expect(html).not.toContain('@grpc/grpc-js'); + }); + + it('renders nothing when there are no snippets', () => { + expect(renderToStaticMarkup()).toBe(''); + }); + + it('derives every child test id from the testId it is given', () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('data-testid="grpc-request-code-snippet"'); + expect(html).toContain('data-testid="grpc-request-code-snippet-tab-grpcurl"'); + expect(html).toContain('data-testid="grpc-request-code-snippet-tab-javascript"'); + expect(html).toContain('data-testid="grpc-request-code-snippet-expand"'); + expect(html).toContain('data-testid="grpc-request-code-snippet-code"'); + }); + + it('falls back to the request base when no testId is given', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('data-testid="request-code-snippet-tab-grpcurl"'); + }); + + it('marks the active tab as selected', () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('aria-selected="true"'); + expect(html).toContain('aria-selected="false"'); + }); + + it('collapses to a trigger instead of the code box when embedded', () => { + const html = renderToStaticMarkup( + + ); + + expect(html).toContain('data-testid="example-code-snippet-trigger"'); + expect(html).toContain('Code Snippet'); + expect(html).not.toContain('pkg.Svc/Do'); + expect(html).not.toContain('data-testid="example-code-snippet-expand"'); + }); + + it('renders variables in the code as hover tokens', () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('data-var-name="host"'); + expect(html).toContain('{{host}}'); + }); + + it('passes the snippet language through to the highlighter', () => { + const html = renderToStaticMarkup( + + ); + + expect(html).toContain('language-json'); + }); +}); diff --git a/packages/bruno-api-docs/src/ui/Table/StyledWrapper.ts b/packages/bruno-api-docs/src/ui/Table/StyledWrapper.ts index fdf44d51..474af2ec 100644 --- a/packages/bruno-api-docs/src/ui/Table/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/ui/Table/StyledWrapper.ts @@ -60,6 +60,18 @@ export const StyledWrapper = styled.div` border: 0; } + .table-head--hidden .table-head-cell { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + .table-head-cell { padding: 0.55rem 0.9rem; font-weight: 600; diff --git a/packages/bruno-api-docs/src/ui/Table/Table.spec.tsx b/packages/bruno-api-docs/src/ui/Table/Table.spec.tsx index bd4c21fe..36a9a2eb 100644 --- a/packages/bruno-api-docs/src/ui/Table/Table.spec.tsx +++ b/packages/bruno-api-docs/src/ui/Table/Table.spec.tsx @@ -52,3 +52,23 @@ describe('Table', () => { expect(root.querySelector('table')).toBeNull(); }); }); + +describe('Table — hidden header', () => { + const rows = [{ id: 'r1', cells: { name: 'authorization', value: 'Bearer t' } }]; + + it('keeps the header cells in the markup so their scope survives', () => { + const root = useRenderToDom(
- {column.header} -
+ {column.header} +
); + + const headers = root.querySelectorAll('th[scope="col"]'); + expect(headers.length).toBe(2); + expect(headers[0].text).toContain('Name'); + }); + + it('marks the header row hidden rather than dropping it', () => { + const hidden = useRenderToDom(
); + expect(hidden.querySelector('thead')?.attributes.class).toContain('table-head--hidden'); + + const shown = useRenderToDom(
); + expect(shown.querySelector('thead')?.attributes.class).not.toContain('table-head--hidden'); + }); +}); diff --git a/packages/bruno-api-docs/src/ui/Table/Table.tsx b/packages/bruno-api-docs/src/ui/Table/Table.tsx index 672de5d1..0cf99d60 100644 --- a/packages/bruno-api-docs/src/ui/Table/Table.tsx +++ b/packages/bruno-api-docs/src/ui/Table/Table.tsx @@ -114,22 +114,20 @@ export const Table: React.FC = ({ ))} - {!hideHeader && ( - - - {columns.map((column) => ( - - ))} - - - )} + + + {columns.map((column) => ( + + ))} + + {groupList.map((group) => ( {group.label !== undefined && ( From 3e41751954fbb9e56b0e971eea68a395c9ed8286 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Sun, 9 Aug 2026 22:12:27 +0530 Subject: [PATCH 11/18] fix(docs): guard the method-type lookup, contain templated bodies, carry auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A methodType read straight from a collection indexed the icon map without a guard, so a request typed "toString" resolved Object.prototype.toString, passed the truthiness check and rendered undefined as a component — the whole request page threw. The lookup is now an own-property check, the same class of hole as the unrecognised-request-type fall-through fixed earlier on this branch. A message body containing a variable skipped the JSON check and was written into the JavaScript snippet verbatim, so a body could append statements that run when the reader pastes it. The body is now probed with its variables replaced by a JSON-safe value, which keeps a genuine templated body intact and quotes anything that is not a lone JSON document. The page showed an Auth section while both snippets dialled with no credentials. They now carry it through the converter the HTTP snippets already use, so the two protocols express the same auth the same way, including the note for auth that cannot become a header. Also groups the config sections under Configuration in the section nav as the HTTP page does, renames a shadowed local and parseProtoFlags, and composes the parameterised CodeSnippetComponent in the gRPC page object. --- .../e2e/pages/grpc-request.page.ts | 4 +- .../GrpcMethodTypeIcon.spec.tsx | 9 ++++ .../GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx | 5 +- .../GrpcRequestContent/GrpcRequestContent.tsx | 42 ++++++++++++---- .../bruno-api-docs/src/utils/codeSnippets.ts | 2 +- .../src/utils/grpcSnippets.spec.ts | 50 +++++++++++++++++++ .../bruno-api-docs/src/utils/grpcSnippets.ts | 40 +++++++++++---- 7 files changed, 130 insertions(+), 22 deletions(-) diff --git a/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts b/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts index 0535f30f..d75972eb 100644 --- a/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts +++ b/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts @@ -3,6 +3,7 @@ import { SidebarComponent } from '../components/sidebar.component'; import { BreadcrumbComponent } from '../components/breadcrumb.component'; import { RequestUrlBarComponent } from '../components/request/url-bar.component'; import { ExecutionContextComponent } from '../components/request/execution-context.component'; +import { CodeSnippetComponent } from '../components/request/code-snippet.component'; export class GrpcRequestPage extends BasePage { readonly sidebar = new SidebarComponent(this.page); @@ -31,7 +32,8 @@ export class GrpcRequestPage extends BasePage { readonly emptyState = this.page.getByTestId('grpc-request-config-empty'); - readonly codeSnippet = this.page.getByTestId('grpc-request-code-snippet').getByTestId('grpc-request-code-snippet-code'); + readonly snippet = new CodeSnippetComponent(this.page, 'grpc-request-code-snippet'); + readonly codeSnippet = this.snippet.code; readonly executionContext = new ExecutionContextComponent(this.page); readonly executionContextSection = this.page.getByTestId('grpc-request-section-execution-context'); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx index a891bb25..7a9a4acc 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx @@ -24,3 +24,12 @@ describe('GrpcMethodTypeIcon', () => { expect(renderToStaticMarkup()).toBe(''); }); }); + +describe('GrpcMethodTypeIcon — untrusted method types', () => { + it.each(['toString', 'constructor', 'hasOwnProperty', '__proto__'])( + 'renders nothing for a methodType named %s instead of crashing', + (methodType) => { + expect(renderToStaticMarkup()).toBe(''); + } + ); +}); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx index 5c4a7a42..f5ad5cb4 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx @@ -24,7 +24,10 @@ interface GrpcMethodTypeIconProps { } export const GrpcMethodTypeIcon: React.FC = ({ methodType, className }) => { - const entry = methodType ? ICON_BY_METHOD_TYPE[methodType] : undefined; + const entry + = methodType && Object.prototype.hasOwnProperty.call(ICON_BY_METHOD_TYPE, methodType) + ? ICON_BY_METHOD_TYPE[methodType] + : undefined; if (!entry) return null; const Icon = entry.icon; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx index e8fc85d3..e21db816 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx @@ -51,6 +51,9 @@ import { FileIcon } from '../../assets/icons'; const NO_ANCESTRY: Item[] = []; +const NAV_GROUP = { configuration: 'Configuration' } as const; +const NAV_LEVEL = { section: 1, configItem: 2 } as const; + interface GrpcRequestContentProps { item: GrpcRequest; collection?: OpenCollection | null; @@ -101,15 +104,15 @@ export const GrpcRequestContent: React.FC = ({ const { lookup } = useResolvedVariables(); const resolvedUrl = useMemo(() => { - const name = singleReferenceName(url); - if (!name) return undefined; - const entry = lookup(name); + const variableName = singleReferenceName(url); + if (!variableName) return undefined; + const entry = lookup(variableName); return entry.secret ? undefined : entry.value || undefined; }, [url, lookup]); const snippets = useMemo(() => { if (!method) return []; - const input = { url, resolvedUrl, method, methodType, protoFilePath, metadata, messages }; + const input = { url, resolvedUrl, method, methodType, protoFilePath, metadata, messages, auth: effectiveAuth }; const built: Snippet[] = [ { id: 'grpcurl', label: 'grpcURL', language: 'bash', code: generateGrpcurlCommand(input) } ]; @@ -120,7 +123,7 @@ export const GrpcRequestContent: React.FC = ({ } return built; - }, [url, resolvedUrl, method, methodType, protoFilePath, metadata, messages]); + }, [url, resolvedUrl, method, methodType, protoFilePath, metadata, messages, effectiveAuth]); const md = useMarkdownRenderer(); @@ -166,7 +169,12 @@ export const GrpcRequestContent: React.FC = ({ {descHtml && ( -
+
)} @@ -174,7 +182,12 @@ export const GrpcRequestContent: React.FC = ({
{protoFileName && ( -
+
@@ -185,7 +198,12 @@ export const GrpcRequestContent: React.FC = ({ )} {method && ( -
+
{grpcMethodPath(method)} @@ -198,6 +216,8 @@ export const GrpcRequestContent: React.FC = ({
} @@ -210,6 +230,8 @@ export const GrpcRequestContent: React.FC = ({
= ({
= ({ {snippets.length > 0 && (
-
+
diff --git a/packages/bruno-api-docs/src/utils/codeSnippets.ts b/packages/bruno-api-docs/src/utils/codeSnippets.ts index dfb8950b..f98b097d 100644 --- a/packages/bruno-api-docs/src/utils/codeSnippets.ts +++ b/packages/bruno-api-docs/src/utils/codeSnippets.ts @@ -89,7 +89,7 @@ const normalizeBody = (raw: SnippetInput['body']): NormalizedBody => { * (api-key in the query, awsv4/digest, basic with variables, …) returns a * `comment` instead, which the caller renders as a leading comment in the snippet. */ -const authToHeaders = (auth: Auth | undefined): { headers: SnippetHeader[]; comment?: string } => { +export const authToHeaders = (auth: Auth | undefined): { headers: SnippetHeader[]; comment?: string } => { if (!auth || auth === 'inherit') return { headers: [] }; switch (auth.type) { case AUTH_TYPES.BASIC: { diff --git a/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts b/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts index e22b996e..d90c7534 100644 --- a/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts +++ b/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts @@ -317,3 +317,53 @@ describe('message bodies in the JavaScript snippet', () => { expect(code).toContain('const message = {};'); }); }); + +describe('auth in the generated snippets', () => { + const bearer = { type: 'bearer', token: 'abc123' } as never; + + it('sends bearer auth as grpcurl metadata', () => { + const command = generateGrpcurlCommand(input({ auth: bearer })); + expect(command).toContain(`-H 'Authorization: Bearer abc123'`); + }); + + it('sends bearer auth as JavaScript metadata', () => { + const code = generateGrpcJavaScriptCode(input({ protoFilePath: 'a.proto', auth: bearer })); + expect(code).toContain(`metadata.set('Authorization', 'Bearer abc123');`); + expect(code).toContain('const metadata = new grpc.Metadata();'); + }); + + it('does not overwrite metadata the request already declares', () => { + const command = generateGrpcurlCommand( + input({ auth: bearer, metadata: [{ name: 'authorization', value: 'Bearer mine' }] as GrpcMetadata[] }) + ); + expect(command).toContain(`-H 'authorization: Bearer mine'`); + expect(command).not.toContain('abc123'); + }); + + it('notes auth it cannot express as metadata instead of dropping it silently', () => { + const command = generateGrpcurlCommand(input({ auth: { type: 'awsv4' } as never })); + expect(command).toContain('# auth: awsv4'); + }); + + it('leaves the snippets untouched when there is no auth', () => { + expect(generateGrpcurlCommand(input())).not.toContain('Authorization'); + }); + + it('contains a templated body that appends statements', () => { + const code = generateGrpcJavaScriptCode( + input({ + protoFilePath: 'a.proto', + messages: [{ title: 'a', message: `{"a":"{{t}}"}; require('child_process').execSync('x')` }] + }) + ); + expect(code).toContain(`const message = '{"a":"{{t}}"}`); + expect(code).not.toContain('const message = {"a":"{{t}}"}; require'); + }); + + it('still keeps an ordinary templated body verbatim', () => { + const code = generateGrpcJavaScriptCode( + input({ protoFilePath: 'a.proto', messages: [{ title: 'a', message: '{"n":{{count}}}' }] }) + ); + expect(code).toContain('const message = {"n":{{count}}};'); + }); +}); diff --git a/packages/bruno-api-docs/src/utils/grpcSnippets.ts b/packages/bruno-api-docs/src/utils/grpcSnippets.ts index 9c15e482..1d4e40cc 100644 --- a/packages/bruno-api-docs/src/utils/grpcSnippets.ts +++ b/packages/bruno-api-docs/src/utils/grpcSnippets.ts @@ -1,6 +1,8 @@ import type { GrpcMetadata, GrpcMethodType } from '@opencollection/types/requests/grpc'; +import type { Auth } from '@opencollection/types/common/auth'; import type { GrpcMessageEntry } from './schemaHelpers'; import { templateVariableGlobalRegex } from './common'; +import { authToHeaders } from './codeSnippets'; export interface GrpcSnippetInput { url: string; @@ -10,6 +12,7 @@ export interface GrpcSnippetInput { metadata: GrpcMetadata[]; messages: GrpcMessageEntry[]; resolvedUrl?: string; + auth?: Auth; } const SCHEME_PATTERN = /^(grpcs?|https?):\/\//i; @@ -25,7 +28,7 @@ const parseTarget = (url: string, resolvedUrl?: string): { target: string; plain export const grpcMethodPath = (method: string): string => method.replace(/^\//, ''); -const parseProtoFlags = (protoFilePath: string): string[] => { +const buildProtoFlags = (protoFilePath: string): string[] => { const normalised = protoFilePath.replace(/\\/g, '/').replace(/\/{2,}/g, '/'); const lastSlash = normalised.lastIndexOf('/'); const file = lastSlash === -1 ? normalised : normalised.slice(lastSlash + 1); @@ -49,6 +52,19 @@ const parseService = (method: string): { servicePath: string; methodName: string const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; const IDENTIFIER_PATH = /^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*$/; +const effectiveMetadata = ( + metadata: GrpcMetadata[], + auth: Auth | undefined +): { entries: GrpcMetadata[]; note?: string } => { + const entries = metadata.filter((entry) => !entry.disabled); + const { headers, comment } = authToHeaders(auth); + headers.forEach((header) => { + const present = entries.some((entry) => (entry.name || '').toLowerCase() === header.name.toLowerCase()); + if (!present) entries.push({ name: header.name, value: header.value } as GrpcMetadata); + }); + return { entries, note: comment }; +}; + const heredocDelimiter = (messages: string[]): string => { const lines = new Set(messages.flatMap((message) => message.split('\n').map((line) => line.trim()))); let delimiter = 'EOF'; @@ -64,9 +80,8 @@ const jsQuote = (value: string): string => const jsObjectLiteral = (message: string): string => { const trimmed = message.trim(); if (!trimmed) return '{}'; - if (templateVariableGlobalRegex().test(trimmed)) return trimmed; try { - JSON.parse(trimmed); + JSON.parse(trimmed.replace(templateVariableGlobalRegex(), '0')); return trimmed; } catch { return jsQuote(trimmed); @@ -86,21 +101,23 @@ export const generateGrpcurlCommand = ({ protoFilePath, metadata, messages, - resolvedUrl + resolvedUrl, + auth }: GrpcSnippetInput): string => { const { target, plaintext } = parseTarget(url, resolvedUrl); + const { entries, note } = effectiveMetadata(metadata, auth); const parts: string[] = ['grpcurl']; if (plaintext) { parts.push('-plaintext'); } - for (const entry of metadata.filter((item) => !item.disabled)) { + for (const entry of entries) { parts.push(`-H ${shellQuote(`${entry.name}: ${entry.value}`)}`); } if (protoFilePath) { - parts.push(...parseProtoFlags(protoFilePath)); + parts.push(...buildProtoFlags(protoFilePath)); } const streaming = streamsInFor(methodType); @@ -113,14 +130,15 @@ export const generateGrpcurlCommand = ({ parts.push(shellQuote(grpcMethodPath(method))); const command = parts.join(' \\\n '); + const prefix = note ? `# ${note}\n` : ''; if (streaming && messages.length > 0) { const bodies = messages.map((entry) => entry.message); const delimiter = heredocDelimiter(bodies); - return `${command} << '${delimiter}'\n${bodies.join('\n')}\n${delimiter}`; + return `${prefix}${command} << '${delimiter}'\n${bodies.join('\n')}\n${delimiter}`; } - return command; + return `${prefix}${command}`; }; export const generateGrpcJavaScriptCode = ({ @@ -130,16 +148,18 @@ export const generateGrpcJavaScriptCode = ({ protoFilePath, metadata, messages, - resolvedUrl + resolvedUrl, + auth }: GrpcSnippetInput): string => { const { servicePath, methodName } = parseService(method); if (!protoFilePath || !IDENTIFIER_PATH.test(servicePath) || !IDENTIFIER.test(methodName)) return ''; const { target, plaintext } = parseTarget(url, resolvedUrl); - const enabled = metadata.filter((entry) => !entry.disabled); + const { entries: enabled, note } = effectiveMetadata(metadata, auth); const credentials = plaintext ? 'grpc.credentials.createInsecure()' : 'grpc.credentials.createSsl()'; const lines: string[] = [ + ...(note ? [`// ${note}`] : []), `const grpc = require('@grpc/grpc-js');`, `const protoLoader = require('@grpc/proto-loader');`, '', From 7847ef73b1f658dd8c7e1bbb74be2059e54bda12 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Mon, 10 Aug 2026 01:22:34 +0530 Subject: [PATCH 12/18] refactor(docs): share the expand toggle and guard the method-type labels The label lookup indexed a plain object with a value read from the collection, the same unguarded shape as the icon map fixed alongside it, so a methodType named after an Object member resolved a function and rendered it as a child. The show-more control in the messages list was the view-more control with different words: same button, same chevron, same rotation and reduced-motion rules. Both now render one ExpandToggle, so the two cannot drift and the reduced-motion opt-out lives in a single place. The message card also mounts its body from the toggle handler rather than during render, which is how the example card it was modelled on does it. On the test side, the messages locators become a component that derives its child ids from a base, the gRPC page object annotates its locators, and the examples component composes the parameterised snippet component instead of naming its ids by hand. --- .../components/request/examples.component.ts | 7 ++- .../request/grpc-messages.component.ts | 34 ++++++++++++ .../e2e/pages/grpc-request.page.ts | 55 ++++++++----------- .../e2e/tests/request/grpc-request.spec.ts | 44 +++++++-------- .../components/ExpandToggle/ExpandToggle.tsx | 49 +++++++++++++++++ .../components/ExpandToggle/StyledWrapper.ts | 37 +++++++++++++ .../GrpcMessageCard/GrpcMessageCard.tsx | 10 ++-- .../GrpcMessages/GrpcMessages.tsx | 30 +++------- .../GrpcMessages/StyledWrapper.ts | 32 ----------- .../GrpcRequestContent/GrpcRequestContent.tsx | 8 ++- .../src/components/ViewMore/StyledWrapper.ts | 26 --------- .../src/components/ViewMore/ViewMore.tsx | 32 +++-------- .../bruno-api-docs/src/utils/grpcSnippets.ts | 2 +- 13 files changed, 198 insertions(+), 168 deletions(-) create mode 100644 packages/bruno-api-docs/e2e/components/request/grpc-messages.component.ts create mode 100644 packages/bruno-api-docs/src/components/ExpandToggle/ExpandToggle.tsx create mode 100644 packages/bruno-api-docs/src/components/ExpandToggle/StyledWrapper.ts diff --git a/packages/bruno-api-docs/e2e/components/request/examples.component.ts b/packages/bruno-api-docs/e2e/components/request/examples.component.ts index 077844bf..b37e16b7 100644 --- a/packages/bruno-api-docs/e2e/components/request/examples.component.ts +++ b/packages/bruno-api-docs/e2e/components/request/examples.component.ts @@ -1,7 +1,10 @@ import type { Locator } from '@playwright/test'; import { BaseComponent } from '../base.component'; +import { CodeSnippetComponent } from './code-snippet.component'; export class ExamplesComponent extends BaseComponent { + readonly snippet = new CodeSnippetComponent(this.page, 'example-code-snippet'); + readonly root = this.page.getByTestId('request-examples'); readonly items = this.root.getByTestId('example-card'); @@ -32,14 +35,14 @@ export class ExamplesComponent extends BaseComponent { // The snippet dialog is portalled to , so it is scoped to the page, not the card. readonly snippetModal = this.page.getByRole('dialog', { name: 'Code snippet' }); - readonly snippetCode = this.snippetModal.getByTestId('example-code-snippet-code'); + readonly snippetCode = this.snippet.modalCode; snippetButton(name: string): Locator { return this.example(name).getByTestId('example-code-snippet-trigger'); } snippetLanguageTab(language: string): Locator { - return this.snippetModal.getByTestId(`example-code-snippet-tab-${language}`); + return this.snippet.modalLanguageTab(language); } async openSnippet(name: string): Promise { diff --git a/packages/bruno-api-docs/e2e/components/request/grpc-messages.component.ts b/packages/bruno-api-docs/e2e/components/request/grpc-messages.component.ts new file mode 100644 index 00000000..e68c373f --- /dev/null +++ b/packages/bruno-api-docs/e2e/components/request/grpc-messages.component.ts @@ -0,0 +1,34 @@ +import type { Locator, Page } from '@playwright/test'; +import { BaseComponent } from '../base.component'; + +export class GrpcMessagesComponent extends BaseComponent { + readonly showToggle: Locator; + + constructor( + page: Page, + private readonly base = 'grpc-messages' + ) { + super(page, page.getByTestId(base)); + this.showToggle = page.getByTestId(`${base}-show-toggle`); + } + + card(index: number): Locator { + return this.page.getByTestId(`${this.base}-card-${index}`); + } + + toggle(index: number): Locator { + return this.page.getByTestId(`${this.base}-card-${index}-toggle`); + } + + code(index: number): Locator { + return this.page.getByTestId(`${this.base}-card-${index}-code`); + } + + async expand(index: number): Promise { + await this.toggle(index).click(); + } + + async showMore(): Promise { + await this.showToggle.click(); + } +} diff --git a/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts b/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts index d75972eb..ea94cd63 100644 --- a/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts +++ b/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts @@ -1,55 +1,44 @@ +import type { Locator } from '@playwright/test'; import { BasePage } from './base.page'; import { SidebarComponent } from '../components/sidebar.component'; import { BreadcrumbComponent } from '../components/breadcrumb.component'; import { RequestUrlBarComponent } from '../components/request/url-bar.component'; import { ExecutionContextComponent } from '../components/request/execution-context.component'; import { CodeSnippetComponent } from '../components/request/code-snippet.component'; +import { GrpcMessagesComponent } from '../components/request/grpc-messages.component'; export class GrpcRequestPage extends BasePage { readonly sidebar = new SidebarComponent(this.page); readonly breadcrumb = new BreadcrumbComponent(this.page, 'grpc-request-breadcrumb'); readonly urlBar = new RequestUrlBarComponent(this.page); + readonly messages = new GrpcMessagesComponent(this.page); + readonly snippet = new CodeSnippetComponent(this.page, 'grpc-request-code-snippet'); + readonly executionContext = new ExecutionContextComponent(this.page); - readonly root = this.page.getByTestId('grpc-request-page'); - readonly title = this.page.getByTestId('grpc-request-title'); - readonly description = this.page.getByTestId('grpc-request-description'); - - readonly protoFileSection = this.page.getByTestId('grpc-request-section-proto-file'); - readonly protoFile = this.page.getByTestId('grpc-request-proto-file'); - - readonly methodSection = this.page.getByTestId('grpc-request-section-method'); - readonly method = this.page.getByTestId('grpc-request-method'); + readonly root: Locator = this.page.getByTestId('grpc-request-page'); + readonly title: Locator = this.page.getByTestId('grpc-request-title'); + readonly description: Locator = this.page.getByTestId('grpc-request-description'); - readonly messagesSection = this.page.getByTestId('grpc-request-section-messages'); - readonly showToggle = this.page.getByTestId('grpc-messages-show-toggle'); + readonly protoFileSection: Locator = this.page.getByTestId('grpc-request-section-proto-file'); + readonly protoFile: Locator = this.page.getByTestId('grpc-request-proto-file'); - readonly metadataSection = this.page.getByTestId('grpc-request-section-metadata'); - readonly metadata = this.page.getByTestId('grpc-request-metadata'); + readonly methodSection: Locator = this.page.getByTestId('grpc-request-section-method'); + readonly method: Locator = this.page.getByTestId('grpc-request-method'); - readonly authSection = this.page.getByTestId('grpc-request-section-auth'); - readonly auth = this.page.getByTestId('grpc-request-auth'); - readonly authInheritedBadge = this.page.getByTestId('grpc-request-auth-inherited'); + readonly messagesSection: Locator = this.page.getByTestId('grpc-request-section-messages'); - readonly emptyState = this.page.getByTestId('grpc-request-config-empty'); + readonly metadataSection: Locator = this.page.getByTestId('grpc-request-section-metadata'); + readonly metadata: Locator = this.page.getByTestId('grpc-request-metadata'); - readonly snippet = new CodeSnippetComponent(this.page, 'grpc-request-code-snippet'); - readonly codeSnippet = this.snippet.code; + readonly authSection: Locator = this.page.getByTestId('grpc-request-section-auth'); + readonly auth: Locator = this.page.getByTestId('grpc-request-auth'); + readonly authInheritedBadge: Locator = this.page.getByTestId('grpc-request-auth-inherited'); - readonly executionContext = new ExecutionContextComponent(this.page); - readonly executionContextSection = this.page.getByTestId('grpc-request-section-execution-context'); - readonly executionContextEmpty = this.page.getByTestId('grpc-request-execution-context-empty'); + readonly emptyState: Locator = this.page.getByTestId('grpc-request-config-empty'); + readonly codeSnippet: Locator = this.snippet.code; - messageCard(index: number) { - return this.page.getByTestId(`grpc-messages-card-${index}`); - } - - messageToggle(index: number) { - return this.page.getByTestId(`grpc-messages-card-${index}-toggle`); - } - - messageCode(index: number) { - return this.page.getByTestId(`grpc-messages-card-${index}-code`); - } + readonly executionContextSection: Locator = this.page.getByTestId('grpc-request-section-execution-context'); + readonly executionContextEmpty: Locator = this.page.getByTestId('grpc-request-execution-context-empty'); async open(path: string[]): Promise { await this.navigate('/'); diff --git a/packages/bruno-api-docs/e2e/tests/request/grpc-request.spec.ts b/packages/bruno-api-docs/e2e/tests/request/grpc-request.spec.ts index dd736439..4c1bcb8b 100644 --- a/packages/bruno-api-docs/e2e/tests/request/grpc-request.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/request/grpc-request.spec.ts @@ -107,54 +107,54 @@ test.describe('Request page — gRPC messages', () => { test('opens the first message and leaves the rest closed', async ({ grpcRequestPage }) => { await grpcRequestPage.open([REALTIME, 'Send Greetings']); - await expect(grpcRequestPage.messageToggle(0)).toHaveAttribute('aria-expanded', 'true'); - await expect(grpcRequestPage.messageToggle(1)).toHaveAttribute('aria-expanded', 'false'); - await expect(grpcRequestPage.messageCode(0)).toBeVisible(); + await expect(grpcRequestPage.messages.toggle(0)).toHaveAttribute('aria-expanded', 'true'); + await expect(grpcRequestPage.messages.toggle(1)).toHaveAttribute('aria-expanded', 'false'); + await expect(grpcRequestPage.messages.code(0)).toBeVisible(); }); test('offers no show-more control when every message already fits', async ({ grpcRequestPage }) => { await grpcRequestPage.open([REALTIME, 'Send Greetings']); - await expect(grpcRequestPage.showToggle).toHaveCount(0); + await expect(grpcRequestPage.messages.showToggle).toHaveCount(0); }); test('collapses a message that was open', async ({ grpcRequestPage }) => { await grpcRequestPage.open([REALTIME, 'Send Greetings']); - await grpcRequestPage.messageToggle(0).click(); + await grpcRequestPage.messages.toggle(0).click(); - await expect(grpcRequestPage.messageToggle(0)).toHaveAttribute('aria-expanded', 'false'); + await expect(grpcRequestPage.messages.toggle(0)).toHaveAttribute('aria-expanded', 'false'); }); test('shows only the first three messages until show more is used', async ({ grpcRequestPage }) => { await grpcRequestPage.open([REALTIME, 'Bulk Upload']); await expect(grpcRequestPage.messagesSection).toContainText('6 messages'); - await expect(grpcRequestPage.messageCard(2)).toBeVisible(); - await expect(grpcRequestPage.messageCard(3)).toHaveCount(0); - await expect(grpcRequestPage.showToggle).toHaveText('Show more'); + await expect(grpcRequestPage.messages.card(2)).toBeVisible(); + await expect(grpcRequestPage.messages.card(3)).toHaveCount(0); + await expect(grpcRequestPage.messages.showToggle).toHaveText('Show more'); - await grpcRequestPage.showToggle.click(); + await grpcRequestPage.messages.showToggle.click(); - await expect(grpcRequestPage.messageCard(5)).toBeVisible(); - await expect(grpcRequestPage.showToggle).toHaveText('Show less'); + await expect(grpcRequestPage.messages.card(5)).toBeVisible(); + await expect(grpcRequestPage.messages.showToggle).toHaveText('Show less'); }); test('keeps every expanded message open across show more and show less', async ({ grpcRequestPage }) => { await grpcRequestPage.open([REALTIME, 'Bulk Upload']); - await grpcRequestPage.messageToggle(1).click(); - await grpcRequestPage.messageToggle(2).click(); - await grpcRequestPage.showToggle.click(); - await grpcRequestPage.messageToggle(4).click(); - await grpcRequestPage.showToggle.click(); + await grpcRequestPage.messages.toggle(1).click(); + await grpcRequestPage.messages.toggle(2).click(); + await grpcRequestPage.messages.showToggle.click(); + await grpcRequestPage.messages.toggle(4).click(); + await grpcRequestPage.messages.showToggle.click(); - await expect(grpcRequestPage.messageCard(3)).toHaveCount(0); - await expect(grpcRequestPage.messageToggle(1)).toHaveAttribute('aria-expanded', 'true'); - await expect(grpcRequestPage.messageToggle(2)).toHaveAttribute('aria-expanded', 'true'); + await expect(grpcRequestPage.messages.card(3)).toHaveCount(0); + await expect(grpcRequestPage.messages.toggle(1)).toHaveAttribute('aria-expanded', 'true'); + await expect(grpcRequestPage.messages.toggle(2)).toHaveAttribute('aria-expanded', 'true'); - await grpcRequestPage.showToggle.click(); + await grpcRequestPage.messages.showToggle.click(); - await expect(grpcRequestPage.messageToggle(4)).toHaveAttribute('aria-expanded', 'true'); + await expect(grpcRequestPage.messages.toggle(4)).toHaveAttribute('aria-expanded', 'true'); }); }); diff --git a/packages/bruno-api-docs/src/components/ExpandToggle/ExpandToggle.tsx b/packages/bruno-api-docs/src/components/ExpandToggle/ExpandToggle.tsx new file mode 100644 index 00000000..1f5c3fb7 --- /dev/null +++ b/packages/bruno-api-docs/src/components/ExpandToggle/ExpandToggle.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { StyledWrapper } from './StyledWrapper'; + +interface ExpandToggleProps { + expanded: boolean; + moreLabel: string; + lessLabel: string; + onToggle: () => void; + controls?: string; + className?: string; + testId?: string; +} + +export const ExpandToggle: React.FC = ({ + expanded, + moreLabel, + lessLabel, + onToggle, + controls, + className, + testId +}) => ( + + {expanded ? lessLabel : moreLabel} + + +); + +export default ExpandToggle; diff --git a/packages/bruno-api-docs/src/components/ExpandToggle/StyledWrapper.ts b/packages/bruno-api-docs/src/components/ExpandToggle/StyledWrapper.ts new file mode 100644 index 00000000..f17c9312 --- /dev/null +++ b/packages/bruno-api-docs/src/components/ExpandToggle/StyledWrapper.ts @@ -0,0 +1,37 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.button` + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0; + border: none; + background: none; + cursor: pointer; + font-family: var(--font-sans); + font-weight: 500; + font-size: 0.8125rem; + line-height: 1; + color: var(--primary-text); + + .expand-toggle-chevron { + flex-shrink: 0; + transition: transform 0.15s ease; + } + + &[aria-expanded='true'] .expand-toggle-chevron { + transform: rotate(180deg); + } + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + border-radius: 2px; + } + + @media (prefers-reduced-motion: reduce) { + .expand-toggle-chevron { + transition: none; + } + } +`; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx index 925d74fd..4e1d6a93 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx @@ -19,13 +19,15 @@ export const GrpcMessageCard: React.FC = ({ testId = 'grpc-message-card' }) => { const [mounted, setMounted] = useState(expanded); - if (expanded && !mounted) { - setMounted(true); - } const detailId = useId(); const detailRef = useRef(null); + const handleToggle = () => { + if (!expanded) setMounted(true); + onToggle(); + }; + useEffect(() => { const el = detailRef.current; if (!el) return; @@ -42,7 +44,7 @@ export const GrpcMessageCard: React.FC = ({ aria-expanded={expanded} aria-controls={detailId} data-testid={`${testId}-toggle`} - onClick={onToggle} + onClick={handleToggle} > {title} diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.tsx index 8a04eeaa..f7e11083 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import type { GrpcMessageEntry } from '../../../utils/schemaHelpers'; import { GrpcMessageCard } from './GrpcMessageCard/GrpcMessageCard'; +import { ExpandToggle } from '../../ExpandToggle/ExpandToggle'; import { StyledWrapper } from './StyledWrapper'; const COLLAPSED_COUNT = 3; @@ -45,29 +46,14 @@ export const GrpcMessages: React.FC = ({ messages, testId = ' ))} {hasOverflow && ( - + testId={`${testId}-show-toggle`} + /> )} ); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/StyledWrapper.ts b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/StyledWrapper.ts index a9f59f04..c1c454e9 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/StyledWrapper.ts @@ -2,38 +2,6 @@ import styled from '@emotion/styled'; export const StyledWrapper = styled.div` .grpc-messages-show-toggle { - display: inline-flex; - align-items: center; - gap: 0.25rem; margin-top: 0.75rem; - padding: 0; - border: none; - background: none; - cursor: pointer; - font-family: var(--font-sans); - font-weight: 500; - font-size: 0.8125rem; - line-height: 1; - letter-spacing: 0; - color: var(--primary-text); - } - .grpc-messages-show-toggle:focus-visible { - outline: 2px solid var(--primary-color); - outline-offset: 2px; - border-radius: 2px; - } - - .grpc-messages-show-chevron { - flex-shrink: 0; - transition: transform 0.15s ease; - } - .grpc-messages-show-toggle[aria-expanded='true'] .grpc-messages-show-chevron { - transform: rotate(180deg); - } - - @media (prefers-reduced-motion: reduce) { - .grpc-messages-show-chevron { - transition: none; - } } `; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx index e21db816..651f22ec 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx @@ -26,7 +26,6 @@ import { import { collectAssertions } from '../../utils/assertions'; import { collectTests, collectRawTestScripts } from '../../utils/fileUtils'; import { ExecutionContext } from '../ExecutionContext/ExecutionContext'; -import { RefreshIcon } from '../../assets/icons'; import { generateGrpcurlCommand, generateGrpcJavaScriptCode, grpcMethodPath } from '../../utils/grpcSnippets'; import { SnippetTabs, type Snippet } from '../SnippetTabs/SnippetTabs'; import { useMarkdownRenderer, useResolvedVariables } from '../../hooks'; @@ -47,7 +46,7 @@ import { Breadcrumb, type BreadcrumbSegment } from '../../ui/Breadcrumb/Breadcru import { EmptyState } from '../../ui/EmptyState/EmptyState'; import { RequestUrlBar } from '../Request/RequestUrlBar/RequestUrlBar'; import { StyledWrapper } from './StyledWrapper'; -import { FileIcon } from '../../assets/icons'; +import { FileIcon, RefreshIcon } from '../../assets/icons'; const NO_ANCESTRY: Item[] = []; @@ -76,7 +75,10 @@ export const GrpcRequestContent: React.FC = ({ const methodType = getGrpcMethodType(item); const protoFileName = getGrpcProtoFileName(item); const protoFilePath = getGrpcProtoFilePath(item); - const methodTypeLabel = methodType ? GRPC_METHOD_TYPE_LABELS[methodType] : undefined; + const methodTypeLabel + = methodType && Object.prototype.hasOwnProperty.call(GRPC_METHOD_TYPE_LABELS, methodType) + ? GRPC_METHOD_TYPE_LABELS[methodType] + : undefined; const messages = useMemo(() => getGrpcMessages(item), [item]); const metadata = useMemo(() => getGrpcMetadata(item), [item]); const enabledMetadataCount = countEnabled(metadata); diff --git a/packages/bruno-api-docs/src/components/ViewMore/StyledWrapper.ts b/packages/bruno-api-docs/src/components/ViewMore/StyledWrapper.ts index 06e1ca6a..e67f02ff 100644 --- a/packages/bruno-api-docs/src/components/ViewMore/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/ViewMore/StyledWrapper.ts @@ -23,37 +23,11 @@ export const StyledWrapper = styled.div` &.is-animating .view-more-content { transition: none; } - .view-more-chevron { - transition: none; - } } .view-more-toggle { - display: inline-flex; - align-items: center; - gap: 0.25rem; margin-top: 0.75rem; - padding: 0; - border: none; - background: none; - cursor: pointer; - font-family: var(--font-sans); - font-weight: 500; - font-size: 0.8125rem; - line-height: 1; letter-spacing: 0; color: var(--primary-text); } - .view-more-chevron { - flex-shrink: 0; - transition: transform 0.15s ease; - } - .view-more-toggle[aria-expanded='true'] .view-more-chevron { - transform: rotate(180deg); - } - .view-more-toggle:focus-visible { - outline: 2px solid var(--primary-color); - outline-offset: 2px; - border-radius: 2px; - } `; diff --git a/packages/bruno-api-docs/src/components/ViewMore/ViewMore.tsx b/packages/bruno-api-docs/src/components/ViewMore/ViewMore.tsx index 0dbf6b3d..89a9b13c 100644 --- a/packages/bruno-api-docs/src/components/ViewMore/ViewMore.tsx +++ b/packages/bruno-api-docs/src/components/ViewMore/ViewMore.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useId, useRef, useState } from 'react'; import { prefersReducedMotion } from '../../utils/motion'; +import { ExpandToggle } from '../ExpandToggle/ExpandToggle'; import { StyledWrapper } from './StyledWrapper'; interface ViewMoreProps { @@ -99,30 +100,15 @@ export const ViewMore: React.FC = ({ {children}
{overflowing && ( - + testId={testId ? `${testId}-toggle` : undefined} + /> )} ); diff --git a/packages/bruno-api-docs/src/utils/grpcSnippets.ts b/packages/bruno-api-docs/src/utils/grpcSnippets.ts index 1d4e40cc..958049e5 100644 --- a/packages/bruno-api-docs/src/utils/grpcSnippets.ts +++ b/packages/bruno-api-docs/src/utils/grpcSnippets.ts @@ -60,7 +60,7 @@ const effectiveMetadata = ( const { headers, comment } = authToHeaders(auth); headers.forEach((header) => { const present = entries.some((entry) => (entry.name || '').toLowerCase() === header.name.toLowerCase()); - if (!present) entries.push({ name: header.name, value: header.value } as GrpcMetadata); + if (!present) entries.push({ name: header.name, value: header.value }); }); return { entries, note: comment }; }; From ff81466f21f4399631d9632bfba01e50a65c1dbb Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Mon, 10 Aug 2026 01:52:35 +0530 Subject: [PATCH 13/18] test(docs): cover the shared expand toggle --- .../ExpandToggle/ExpandToggle.spec.tsx | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 packages/bruno-api-docs/src/components/ExpandToggle/ExpandToggle.spec.tsx diff --git a/packages/bruno-api-docs/src/components/ExpandToggle/ExpandToggle.spec.tsx b/packages/bruno-api-docs/src/components/ExpandToggle/ExpandToggle.spec.tsx new file mode 100644 index 00000000..2af51fb2 --- /dev/null +++ b/packages/bruno-api-docs/src/components/ExpandToggle/ExpandToggle.spec.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '../../hooks/useRenderToDom'; +import { query } from '../../test-utils/dom'; +import { ExpandToggle } from './ExpandToggle'; + +const noop = () => {}; + +describe('ExpandToggle', () => { + it('shows the more label while collapsed', () => { + const root = useRenderToDom( + + ); + + const button = query(root, '[data-testid="t"]'); + expect(button.text).toContain('Show more'); + expect(button.text).not.toContain('Show less'); + expect(button.attributes['aria-expanded']).toBe('false'); + }); + + it('swaps to the less label while expanded', () => { + const root = useRenderToDom( + + ); + + const button = query(root, '[data-testid="t"]'); + expect(button.text).toContain('Show less'); + expect(button.attributes['aria-expanded']).toBe('true'); + }); + + it('is a button so it is reachable by keyboard', () => { + const root = useRenderToDom( + + ); + + const button = query(root, '[data-testid="t"]'); + expect(button.tagName.toLowerCase()).toBe('button'); + expect(button.attributes.type).toBe('button'); + }); + + it('points at the region it controls when given one', () => { + const root = useRenderToDom( + + ); + + expect(query(root, '[data-testid="t"]').attributes['aria-controls']).toBe('panel-1'); + }); + + it('omits aria-controls when there is no region to name', () => { + const root = useRenderToDom( + + ); + + expect(query(root, '[data-testid="t"]').attributes['aria-controls']).toBeUndefined(); + }); + + it('hides the chevron from assistive tech and keeps the caller class', () => { + const root = useRenderToDom( + + ); + + const button = query(root, '[data-testid="t"]'); + expect(button.attributes.class).toContain('expand-toggle'); + expect(button.attributes.class).toContain('grpc-messages-show-toggle'); + expect(query(root, '.expand-toggle-chevron').attributes['aria-hidden']).toBe('true'); + }); +}); From 3b0c122ce9804c2e9d48def71ef72356e020b86e Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Mon, 10 Aug 2026 18:42:11 +0530 Subject: [PATCH 14/18] refactor(docs): review comments handled Narrows the request helpers to HttpRequest | GrpcRequest rather than Item, so the signature carries what the caller actually holds and the casts each call site needed disappear. resolveInheritedAuth and getInheritedAuthSummary stay Item: the folder settings view passes a folder to both, so narrowing them would break it. The four streaming icons take their stroke styling from baseIconProps, and the expand toggle reuses ChevronDownIcon instead of inlining a copy of it, which also lets the icon size come from a prop rather than a css override. The specs this PR added render through useRenderToDom, imports in the files it created use the path alias, class names go through cx, and the two margin-only rules become utility classes. MethodBadge reads capitalizeMethod, defaulting to true, so the gRPC call site says what it wants rather than naming the outcome. --- .../src/assets/icons/BidiStreamingIcon.tsx | 21 ++----- .../src/assets/icons/ChevronDownIcon.tsx | 8 ++- .../src/assets/icons/ClientStreamingIcon.tsx | 13 ++-- .../src/assets/icons/ServerStreamingIcon.tsx | 21 ++----- .../src/assets/icons/UnaryIcon.tsx | 13 ++-- .../CodeSnippetTabs/CodeSnippetTabs.tsx | 2 +- .../components/ExpandToggle/ExpandToggle.tsx | 19 ++---- .../GrpcMessageCard/GrpcMessageCard.spec.tsx | 8 ++- .../GrpcMessageCard/GrpcMessageCard.tsx | 52 +++++++++++----- .../GrpcMessages/GrpcMessages.spec.tsx | 12 ++-- .../GrpcMessages/GrpcMessages.tsx | 9 ++- .../GrpcMessages/StyledWrapper.ts | 7 --- .../GrpcMetadataTable.spec.tsx | 14 +++-- .../GrpcMetadataTable/GrpcMetadataTable.tsx | 4 +- .../GrpcMethodTypeIcon.spec.tsx | 38 ++++++------ .../GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx | 7 ++- .../GrpcRequestContent.spec.tsx | 62 ++++++++++--------- .../GrpcRequestContent/GrpcRequestContent.tsx | 30 ++++----- .../GrpcRequestContent/StyledWrapper.ts | 5 -- .../components/MethodBadge/MethodBadge.tsx | 12 ++-- .../components/MethodBadge/StyledWrapper.ts | 1 - .../src/components/PageRouter/PageRouter.tsx | 3 +- .../Request/RequestUrlBar/RequestUrlBar.tsx | 6 +- .../SnippetTabs/SnippetTabs.spec.tsx | 20 +++--- .../components/SnippetTabs/SnippetTabs.tsx | 8 +-- .../src/components/ViewMore/ViewMore.tsx | 2 +- .../src/pages/Request/Request.spec.tsx | 3 +- .../src/pages/Request/Request.tsx | 5 +- .../bruno-api-docs/src/utils/assertions.ts | 9 +-- .../bruno-api-docs/src/utils/fileUtils.ts | 12 ++-- .../bruno-api-docs/src/utils/grpcSnippets.ts | 6 +- packages/bruno-api-docs/src/utils/request.ts | 15 ++--- .../src/utils/schemaHelpers.spec.ts | 13 ++-- .../bruno-api-docs/src/utils/schemaHelpers.ts | 2 +- 34 files changed, 224 insertions(+), 238 deletions(-) delete mode 100644 packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/StyledWrapper.ts diff --git a/packages/bruno-api-docs/src/assets/icons/BidiStreamingIcon.tsx b/packages/bruno-api-docs/src/assets/icons/BidiStreamingIcon.tsx index 0420d071..2af4abdd 100644 --- a/packages/bruno-api-docs/src/assets/icons/BidiStreamingIcon.tsx +++ b/packages/bruno-api-docs/src/assets/icons/BidiStreamingIcon.tsx @@ -1,21 +1,10 @@ import React from 'react'; +import { baseIconProps } from './baseIconProps'; -export const BidiStreamingIcon: React.FC = () => ( - {expanded ? lessLabel : moreLabel} - + ); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.spec.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.spec.tsx index 0532ff8d..a24b89d8 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.spec.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.spec.tsx @@ -1,11 +1,13 @@ import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; + +const useMarkup = (element: React.ReactElement): string => useRenderToDom(element).innerHTML; import { GrpcMessageCard } from './GrpcMessageCard'; describe('GrpcMessageCard', () => { it('renders the title and the message when expanded', () => { - const html = renderToStaticMarkup( + const html = useMarkup( {}} /> ); expect(html).toContain('Message 1'); @@ -14,7 +16,7 @@ describe('GrpcMessageCard', () => { }); it('renders the title but not the message when collapsed', () => { - const html = renderToStaticMarkup( + const html = useMarkup( {}} /> ); expect(html).toContain('Message 2'); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx index 4e1d6a93..b703a147 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx @@ -1,4 +1,6 @@ import React, { useEffect, useId, useRef, useState } from 'react'; +import cx from '@/utils/cx'; +import { prefersReducedMotion } from '@/utils/motion'; import { ChevronArrow } from '../../../ChevronArrow/ChevronArrow'; import { Code } from '../../../Code/Code'; import { StyledWrapper } from './StyledWrapper'; @@ -11,6 +13,8 @@ interface GrpcMessageCardProps { testId?: string; } +const COLLAPSE_MS = 220; + export const GrpcMessageCard: React.FC = ({ title, message, @@ -18,22 +22,40 @@ export const GrpcMessageCard: React.FC = ({ onToggle, testId = 'grpc-message-card' }) => { - const [mounted, setMounted] = useState(expanded); - + const [collapsing, setCollapsing] = useState(false); + const timerRef = useRef(0); const detailId = useId(); - const detailRef = useRef(null); - const handleToggle = () => { - if (!expanded) setMounted(true); + const isOpen = expanded && !collapsing; + + useEffect(() => () => window.clearTimeout(timerRef.current), []); + + const finishCollapse = () => { + window.clearTimeout(timerRef.current); + setCollapsing(false); onToggle(); }; - useEffect(() => { - const el = detailRef.current; - if (!el) return; - if (expanded) el.removeAttribute('inert'); - else el.setAttribute('inert', ''); - }, [expanded, mounted]); + const handleToggle = () => { + if (collapsing) { + window.clearTimeout(timerRef.current); + setCollapsing(false); + return; + } + if (!expanded || prefersReducedMotion()) { + onToggle(); + return; + } + setCollapsing(true); + timerRef.current = window.setTimeout(finishCollapse, COLLAPSE_MS + 60); + }; + + const handleTransitionEnd = (event: React.TransitionEvent) => { + if (!collapsing) return; + if (event.propertyName !== 'grid-template-rows') return; + if (event.target !== event.currentTarget) return; + finishCollapse(); + }; return ( @@ -41,19 +63,19 @@ export const GrpcMessageCard: React.FC = ({
-
+
- {mounted && ( + {expanded && (
diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.spec.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.spec.tsx index bcc5d7a7..3dfabd12 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.spec.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.spec.tsx @@ -1,6 +1,8 @@ import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; + +const useMarkup = (element: React.ReactElement): string => useRenderToDom(element).innerHTML; import { GrpcMessages } from './GrpcMessages'; const entries = (count: number) => @@ -11,25 +13,25 @@ const entries = (count: number) => describe('GrpcMessages', () => { it('renders nothing when there are no messages', () => { - expect(renderToStaticMarkup()).toBe(''); + expect(useMarkup()).toBe(''); }); it('opens the first message and leaves the rest closed', () => { - const html = renderToStaticMarkup(); + const html = useMarkup(); expect(html).toContain('body-1'); expect(html).not.toContain('body-2'); expect(html).not.toContain('body-3'); }); it('shows only the first three messages and offers to show more', () => { - const html = renderToStaticMarkup(); + const html = useMarkup(); expect(html).toContain('Message 3'); expect(html).not.toContain('Message 4'); expect(html).toContain('Show more'); }); it('offers no show-more control when everything already fits', () => { - const html = renderToStaticMarkup(); + const html = useMarkup(); expect(html).not.toContain('Show more'); }); }); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.tsx index f7e11083..1a677b28 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.tsx @@ -1,8 +1,7 @@ import React, { useState } from 'react'; -import type { GrpcMessageEntry } from '../../../utils/schemaHelpers'; +import type { GrpcMessageEntry } from '@/utils/schemaHelpers'; import { GrpcMessageCard } from './GrpcMessageCard/GrpcMessageCard'; import { ExpandToggle } from '../../ExpandToggle/ExpandToggle'; -import { StyledWrapper } from './StyledWrapper'; const COLLAPSED_COUNT = 3; @@ -33,7 +32,7 @@ export const GrpcMessages: React.FC = ({ messages, testId = ' }; return ( - +
{visible.map((entry, index) => ( = ({ messages, testId = ' moreLabel="Show more" lessLabel="Show less" onToggle={() => setShowAll((value) => !value)} - className="grpc-messages-show-toggle" + className="mt-3" testId={`${testId}-show-toggle`} /> )} - +
); }; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/StyledWrapper.ts b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/StyledWrapper.ts deleted file mode 100644 index c1c454e9..00000000 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/StyledWrapper.ts +++ /dev/null @@ -1,7 +0,0 @@ -import styled from '@emotion/styled'; - -export const StyledWrapper = styled.div` - .grpc-messages-show-toggle { - margin-top: 0.75rem; - } -`; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.spec.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.spec.tsx index de347ca7..3fb64c93 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.spec.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.spec.tsx @@ -1,6 +1,8 @@ import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; + +const useMarkup = (element: React.ReactElement): string => useRenderToDom(element).innerHTML; import type { GrpcMetadata } from '@opencollection/types/requests/grpc'; import { GrpcMetadataTable } from './GrpcMetadataTable'; @@ -8,11 +10,11 @@ const rows = (entries: Record[]) => entries as unknown as GrpcM describe('GrpcMetadataTable', () => { it('renders nothing when there is no metadata', () => { - expect(renderToStaticMarkup()).toBe(''); + expect(useMarkup()).toBe(''); }); it('renders a name, value and description for every row', () => { - const html = renderToStaticMarkup( + const html = useMarkup( { }); it('reads a description given as an object', () => { - const html = renderToStaticMarkup( + const html = useMarkup( ); expect(html).toContain('Client name'); }); it('marks a disabled row', () => { - const html = renderToStaticMarkup( + const html = useMarkup( ); expect(html).toContain('x-legacy-flag'); @@ -43,7 +45,7 @@ describe('GrpcMetadataTable', () => { }); it('highlights a variable in a value', () => { - const html = renderToStaticMarkup( + const html = useMarkup( ); expect(html).toContain('var-text'); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.tsx index 6b4bda43..765330b1 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type { GrpcMetadata } from '@opencollection/types/requests/grpc'; -import { getDescription } from '../../../utils/request'; -import { Table, type TableColumn } from '../../../ui/Table/Table'; +import { getDescription } from '@/utils/request'; +import { Table, type TableColumn } from '@/ui/Table/Table'; import { TruncatedText } from '../../TruncatedText/TruncatedText'; import { VariableText } from '../../VariableText/VariableText'; import { DisabledBadge } from '../../DisabledBadge/DisabledBadge'; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx index 7a9a4acc..70982727 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx @@ -1,35 +1,33 @@ import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; import { GrpcMethodTypeIcon } from './GrpcMethodTypeIcon'; +const useColourOf = (methodType: string): string => { + const root = useRenderToDom(); + return root.innerHTML; +}; + describe('GrpcMethodTypeIcon', () => { - it('colours each method type from its theme variable', () => { - expect(renderToStaticMarkup()).toContain( - 'color:var(--oc-request-methods-get)' - ); - expect(renderToStaticMarkup()).toContain( - 'color:var(--oc-request-methods-put)' - ); - expect(renderToStaticMarkup()).toContain( - 'color:var(--oc-request-methods-head)' - ); - expect(renderToStaticMarkup()).toContain( - 'color:var(--oc-request-methods-post)' - ); + it.each([ + ['unary', 'get'], + ['server-streaming', 'put'], + ['client-streaming', 'head'], + ['bidi-streaming', 'post'] + ])('colours %s from the %s method variable', (methodType, token) => { + expect(useColourOf(methodType)).toContain(`var(--oc-request-methods-${token})`); }); - it('renders nothing when the method type is missing or unknown', () => { - expect(renderToStaticMarkup()).toBe(''); - expect(renderToStaticMarkup()).toBe(''); + it('renders nothing when the method type is absent', () => { + const root = useRenderToDom(); + expect(root.querySelector('svg')).toBeNull(); }); -}); -describe('GrpcMethodTypeIcon — untrusted method types', () => { it.each(['toString', 'constructor', 'hasOwnProperty', '__proto__'])( 'renders nothing for a methodType named %s instead of crashing', (methodType) => { - expect(renderToStaticMarkup()).toBe(''); + const root = useRenderToDom(); + expect(root.querySelector('svg')).toBeNull(); } ); }); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx index f5ad5cb4..9c623875 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx @@ -11,7 +11,7 @@ import { StyledWrapper } from './StyledWrapper'; // Method types borrow the HTTP method colour tokens rather than defining their own, so both // themes stay in step without new tokens. Client-streaming maps to the head colour, which is // where this differs from the Bruno app's own mapping. -const ICON_BY_METHOD_TYPE: Record = { +const ICON_BY_METHOD_TYPE: Record; color: string }> = { 'unary': { icon: UnaryIcon, color: 'var(--oc-request-methods-get)' }, 'server-streaming': { icon: ServerStreamingIcon, color: 'var(--oc-request-methods-put)' }, 'client-streaming': { icon: ClientStreamingIcon, color: 'var(--oc-request-methods-head)' }, @@ -20,10 +20,11 @@ const ICON_BY_METHOD_TYPE: Record = { interface GrpcMethodTypeIconProps { methodType?: GrpcMethodType; + size?: number; className?: string; } -export const GrpcMethodTypeIcon: React.FC = ({ methodType, className }) => { +export const GrpcMethodTypeIcon: React.FC = ({ methodType, size = 16, className }) => { const entry = methodType && Object.prototype.hasOwnProperty.call(ICON_BY_METHOD_TYPE, methodType) ? ICON_BY_METHOD_TYPE[methodType] @@ -33,7 +34,7 @@ export const GrpcMethodTypeIcon: React.FC = ({ methodTy const Icon = entry.icon; return ( - + ); }; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx index b5168a02..19b04a65 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx @@ -1,6 +1,8 @@ import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; + +const useMarkup = (element: React.ReactElement): string => useRenderToDom(element).innerHTML; import type { GrpcRequest } from '@opencollection/types/requests/grpc'; import { GrpcRequestContent } from './GrpcRequestContent'; @@ -8,7 +10,7 @@ const grpcItem = (data: Record): GrpcRequest => data as unknown describe('GrpcRequestContent', () => { it('renders the request name, the GRPC badge and the url', () => { - const html = renderToStaticMarkup( + const html = useMarkup( @@ -19,7 +21,7 @@ describe('GrpcRequestContent', () => { }); it('renders a request that has no grpc block at all', () => { - const html = renderToStaticMarkup( + const html = useMarkup( ); expect(html).toContain('Bare Method'); @@ -27,13 +29,13 @@ describe('GrpcRequestContent', () => { }); it('falls back to a placeholder name and never offers a Try button', () => { - const html = renderToStaticMarkup(); + const html = useMarkup(); expect(html).toContain('Untitled Request'); expect(html).not.toContain('Try'); }); it('renders the docs markdown as html', () => { - const html = renderToStaticMarkup( + const html = useMarkup( { }); it('omits the description block when there are no docs', () => { - const html = renderToStaticMarkup( + const html = useMarkup( ); expect(html).not.toContain('markdown-documentation'); }); it('renders a request with a method', () => { - const html = renderToStaticMarkup( + const html = useMarkup( ); expect(html).toContain('GetOrder'); }); it('renders the proto file name and the method with its type label', () => { - const html = renderToStaticMarkup( + const html = useMarkup( { }); it('hides the proto file path when the request uses reflection', () => { - const html = renderToStaticMarkup( + const html = useMarkup( { }); it('hides the method section when no method is selected', () => { - const html = renderToStaticMarkup( + const html = useMarkup( ); expect(html).not.toContain('grpc-request-section-method'); @@ -106,7 +108,7 @@ describe('GrpcRequestContent', () => { }); it('renders metadata rows with their descriptions and counts only enabled ones', () => { - const html = renderToStaticMarkup( + const html = useMarkup( { }); it('reads a metadata description given as an object', () => { - const html = renderToStaticMarkup( + const html = useMarkup( { }); it('hides the metadata section when there is none', () => { - const html = renderToStaticMarkup( + const html = useMarkup( { }); it('shows concrete auth with no inherited badge', () => { - const html = renderToStaticMarkup( + const html = useMarkup( { }); it('resolves inherited auth up to the collection and says where it came from', () => { - const html = renderToStaticMarkup( + const html = useMarkup( { }); it('masks a secret rather than printing it', () => { - const html = renderToStaticMarkup( + const html = useMarkup( { }); it('hides the auth section when the request has no auth', () => { - const html = renderToStaticMarkup( + const html = useMarkup( { }); it('shows a single empty state when the request has no configuration', () => { - const html = renderToStaticMarkup( + const html = useMarkup( ); expect(html).toContain('grpc-request-config-empty'); @@ -227,7 +229,7 @@ describe('GrpcRequestContent', () => { }); it('builds a grpcurl snippet from the request', () => { - const html = renderToStaticMarkup( + const html = useMarkup( { }); it('omits the code snippet when the request has no method', () => { - const html = renderToStaticMarkup( + const html = useMarkup( { }); it('shows sections instead of the empty state when there is any configuration', () => { - const html = renderToStaticMarkup( + const html = useMarkup( { }); it('offers a JavaScript snippet only when a proto file is attached', () => { - const withProto = renderToStaticMarkup( + const withProto = useMarkup( { ); expect(withProto).toContain('grpc-request-code-snippet-tab-javascript'); - const reflectionOnly = renderToStaticMarkup( + const reflectionOnly = useMarkup( { }); describe('GrpcRequestContent — execution context', () => { - const withRuntime = (runtime: Record) => - renderToStaticMarkup( + const useWithRuntime = (runtime: Record) => + useMarkup( { ); it('renders an empty state when the request carries no runtime', () => { - const html = renderToStaticMarkup( + const html = useMarkup( { }); it('renders pre-request variables from the runtime block', () => { - const html = withRuntime({ variables: [{ name: 'orderId', value: '12345' }] }); + const html = useWithRuntime({ variables: [{ name: 'orderId', value: '12345' }] }); expect(html).not.toContain('grpc-request-execution-context-empty'); expect(html).toContain('orderId'); }); it('renders post-response captures stored as actions', () => { - const html = withRuntime({ + const html = useWithRuntime({ actions: [ { type: 'set-variable', @@ -348,13 +350,13 @@ describe('GrpcRequestContent — execution context', () => { }); it('renders assertions from the runtime block', () => { - const html = withRuntime({ assertions: [{ expression: 'res.body.orderId', operator: 'eq', value: '12345' }] }); + const html = useWithRuntime({ assertions: [{ expression: 'res.body.orderId', operator: 'eq', value: '12345' }] }); expect(html).not.toContain('grpc-request-execution-context-empty'); expect(html).toContain('res.body.orderId'); }); it('renders scripts from the runtime block', () => { - const html = withRuntime({ scripts: [{ type: 'before-request', code: 'bru.setVar(\'requestedAt\', Date.now());' }] }); + const html = useWithRuntime({ scripts: [{ type: 'before-request', code: 'bru.setVar(\'requestedAt\', Date.now());' }] }); expect(html).not.toContain('grpc-request-execution-context-empty'); }); }); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx index 651f22ec..a0972546 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx @@ -15,23 +15,23 @@ import { getGrpcProtoFileName, getGrpcProtoFilePath, countEnabled -} from '../../utils/schemaHelpers'; +} from '@/utils/schemaHelpers'; import { resolveInheritedAuth, getPreRequestVars, getPostResponseVars, buildScriptChain, getScriptFlow -} from '../../utils/request'; -import { collectAssertions } from '../../utils/assertions'; -import { collectTests, collectRawTestScripts } from '../../utils/fileUtils'; +} from '@/utils/request'; +import { collectAssertions } from '@/utils/assertions'; +import { collectTests, collectRawTestScripts } from '@/utils/fileUtils'; import { ExecutionContext } from '../ExecutionContext/ExecutionContext'; -import { generateGrpcurlCommand, generateGrpcJavaScriptCode, grpcMethodPath } from '../../utils/grpcSnippets'; +import { generateGrpcurlCommand, generateGrpcJavaScriptCode, grpcMethodPath } from '@/utils/grpcSnippets'; import { SnippetTabs, type Snippet } from '../SnippetTabs/SnippetTabs'; -import { useMarkdownRenderer, useResolvedVariables } from '../../hooks'; -import { singleReferenceName } from '../../utils/variableResolution'; -import { buildBreadcrumbSegments } from '../../utils/common'; -import { AUTH_MODE_LABELS, GRPC_METHOD_TYPE_LABELS } from '../../constants'; +import { useMarkdownRenderer, useResolvedVariables } from '@/hooks'; +import { singleReferenceName } from '@/utils/variableResolution'; +import { buildBreadcrumbSegments } from '@/utils/common'; +import { AUTH_MODE_LABELS, GRPC_METHOD_TYPE_LABELS } from '@/constants'; import { Section } from '../Section/Section'; import { ContentTypeBadge } from '../ContentTypeBadge/ContentTypeBadge'; import { InheritedAuthBadge } from '../InheritedAuthBadge/InheritedAuthBadge'; @@ -42,11 +42,11 @@ import { GrpcMetadataTable } from './GrpcMetadataTable/GrpcMetadataTable'; import { PageWrapper } from '../PageWrapper/PageWrapper'; import { Heading } from '../Heading/Heading'; import { ViewMore } from '../ViewMore/ViewMore'; -import { Breadcrumb, type BreadcrumbSegment } from '../../ui/Breadcrumb/Breadcrumb'; -import { EmptyState } from '../../ui/EmptyState/EmptyState'; +import { Breadcrumb, type BreadcrumbSegment } from '@/ui/Breadcrumb/Breadcrumb'; +import { EmptyState } from '@/ui/EmptyState/EmptyState'; import { RequestUrlBar } from '../Request/RequestUrlBar/RequestUrlBar'; import { StyledWrapper } from './StyledWrapper'; -import { FileIcon, RefreshIcon } from '../../assets/icons'; +import { FileIcon, RefreshIcon } from '@/assets/icons'; const NO_ANCESTRY: Item[] = []; @@ -166,9 +166,9 @@ export const GrpcRequestContent: React.FC = ({ testId="grpc-request-breadcrumb" /> - {name} + {name} - + {descHtml && (
= ({ - ) : undefined + ) : null } > diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/StyledWrapper.ts b/packages/bruno-api-docs/src/components/GrpcRequestContent/StyledWrapper.ts index 490753b5..1c86301d 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/StyledWrapper.ts @@ -54,11 +54,6 @@ export const StyledWrapper = styled.div` color: var(--text-tertiary); } - .grpc-field-icon svg { - width: 1rem; - height: 1rem; - } - .grpc-field-text { flex: 1; min-width: 0; diff --git a/packages/bruno-api-docs/src/components/MethodBadge/MethodBadge.tsx b/packages/bruno-api-docs/src/components/MethodBadge/MethodBadge.tsx index 20995c0d..f1cbbcbb 100644 --- a/packages/bruno-api-docs/src/components/MethodBadge/MethodBadge.tsx +++ b/packages/bruno-api-docs/src/components/MethodBadge/MethodBadge.tsx @@ -1,23 +1,23 @@ import React from 'react'; -import cx from '../../utils/cx'; -import { getMethodColorVar } from '../../theme/methodColors'; +import cx from '@/utils/cx'; +import { getMethodColorVar } from '@/theme/methodColors'; import { StyledWrapper } from './StyledWrapper'; interface MethodBadgeProps { method: string; className?: string; - asWritten?: boolean; + capitalizeMethod?: boolean; } -export const MethodBadge: React.FC = ({ method, className, asWritten = false }) => { +export const MethodBadge: React.FC = ({ method, className, capitalizeMethod = true }) => { const resolvedMethod = method || 'GET'; return ( - {asWritten ? resolvedMethod : resolvedMethod.toUpperCase()} + {capitalizeMethod ? resolvedMethod.toUpperCase() : resolvedMethod} ); }; diff --git a/packages/bruno-api-docs/src/components/MethodBadge/StyledWrapper.ts b/packages/bruno-api-docs/src/components/MethodBadge/StyledWrapper.ts index eb9dc9ac..223de02d 100644 --- a/packages/bruno-api-docs/src/components/MethodBadge/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/MethodBadge/StyledWrapper.ts @@ -7,7 +7,6 @@ export const StyledWrapper = styled.span` font-weight: 700; font-size: 0.75rem; letter-spacing: 0.02em; - text-transform: uppercase; &.method-badge--as-written { text-transform: none; diff --git a/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx b/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx index 36e964e3..21c62f34 100644 --- a/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx +++ b/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx @@ -1,6 +1,7 @@ import React, { useMemo, useRef } from 'react'; import { Navigate } from 'react-router-dom'; import type { ScriptFile, Folder as FolderItem } from '@opencollection/types/collection/item'; +import type { RequestItem } from '../../utils/schemaHelpers'; import { useActiveResolution, useNavModel } from '../../routing/hooks'; import { useAppSelector } from '../../store/hooks'; import { selectDocsCollection } from '../../store/slices/docs'; @@ -92,7 +93,7 @@ const PageRouter: React.FC = ({ onOpenPlayground, testId = 'pag return item ? ( void; tryLabel?: string; @@ -19,7 +19,7 @@ interface RequestUrlBarProps { export const RequestUrlBar: React.FC = ({ method, - methodAsWritten = false, + capitalizeMethod = true, url, onTry, tryLabel = 'Try', @@ -29,7 +29,7 @@ export const RequestUrlBar: React.FC = ({ }) => ( - + diff --git a/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.spec.tsx b/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.spec.tsx index 3e2a3bae..3fafa213 100644 --- a/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.spec.tsx +++ b/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.spec.tsx @@ -1,6 +1,8 @@ import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; + +const useMarkup = (element: React.ReactElement): string => useRenderToDom(element).innerHTML; import { SnippetTabs, type Snippet } from './SnippetTabs'; const snippets: Snippet[] = [ @@ -10,7 +12,7 @@ const snippets: Snippet[] = [ describe('SnippetTabs', () => { it('renders a tab per snippet and shows the first one', () => { - const html = renderToStaticMarkup(); + const html = useMarkup(); expect(html).toContain('grpcURL'); expect(html).toContain('JavaScript'); @@ -19,11 +21,11 @@ describe('SnippetTabs', () => { }); it('renders nothing when there are no snippets', () => { - expect(renderToStaticMarkup()).toBe(''); + expect(useMarkup()).toBe(''); }); it('derives every child test id from the testId it is given', () => { - const html = renderToStaticMarkup(); + const html = useMarkup(); expect(html).toContain('data-testid="grpc-request-code-snippet"'); expect(html).toContain('data-testid="grpc-request-code-snippet-tab-grpcurl"'); @@ -33,19 +35,19 @@ describe('SnippetTabs', () => { }); it('falls back to the request base when no testId is given', () => { - const html = renderToStaticMarkup(); + const html = useMarkup(); expect(html).toContain('data-testid="request-code-snippet-tab-grpcurl"'); }); it('marks the active tab as selected', () => { - const html = renderToStaticMarkup(); + const html = useMarkup(); expect(html).toContain('aria-selected="true"'); expect(html).toContain('aria-selected="false"'); }); it('collapses to a trigger instead of the code box when embedded', () => { - const html = renderToStaticMarkup( + const html = useMarkup( ); @@ -56,14 +58,14 @@ describe('SnippetTabs', () => { }); it('renders variables in the code as hover tokens', () => { - const html = renderToStaticMarkup(); + const html = useMarkup(); expect(html).toContain('data-var-name="host"'); expect(html).toContain('{{host}}'); }); it('passes the snippet language through to the highlighter', () => { - const html = renderToStaticMarkup( + const html = useMarkup( ); diff --git a/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx b/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx index 8f97ad15..1ca5f0ab 100644 --- a/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx +++ b/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx @@ -1,11 +1,11 @@ import React, { useRef, useState } from 'react'; import { IconCode } from '@tabler/icons'; import { Code } from '../Code/Code'; -import { CopyButton } from '../../ui/CopyButton/CopyButton'; -import { useResolvedVariables } from '../../hooks'; +import { CopyButton } from '@/ui/CopyButton/CopyButton'; +import { useResolvedVariables } from '@/hooks'; import { SectionLabel } from '../SectionLabel/SectionLabel'; -import { Modal } from '../../ui/Modal/Modal'; -import { ExpandIcon } from '../../assets/icons'; +import { Modal } from '@/ui/Modal/Modal'; +import { ExpandIcon } from '@/assets/icons'; import { StyledWrapper } from './StyledWrapper'; export interface Snippet { diff --git a/packages/bruno-api-docs/src/components/ViewMore/ViewMore.tsx b/packages/bruno-api-docs/src/components/ViewMore/ViewMore.tsx index 89a9b13c..6cdad932 100644 --- a/packages/bruno-api-docs/src/components/ViewMore/ViewMore.tsx +++ b/packages/bruno-api-docs/src/components/ViewMore/ViewMore.tsx @@ -107,7 +107,7 @@ export const ViewMore: React.FC = ({ onToggle={toggle} controls={contentId} className="view-more-toggle" - testId={testId ? `${testId}-toggle` : undefined} + testId={testId && `${testId}-toggle`} /> )} diff --git a/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx b/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx index ba3ad375..96aa0fd5 100644 --- a/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx +++ b/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx @@ -4,6 +4,7 @@ import type { OpenCollection } from '@opencollection/types'; import type { HttpRequest } from '@opencollection/types/requests/http'; import type { Item } from '@opencollection/types/collection/item'; import { MemoryRouter } from 'react-router-dom'; +import type { RequestItem } from '../../utils/schemaHelpers'; import { Request } from './Request'; import { useRenderToDom } from '../../hooks/useRenderToDom'; import { getByTestId, queryByTestId } from '../../test-utils/dom'; @@ -273,7 +274,7 @@ describe('Request page', () => { }); describe('Request — unrecognised request types', () => { - const unknownItem = (data: Record): Item => data as unknown as Item; + const unknownItem = (data: Record): RequestItem => data as unknown as RequestItem; it('renders a type the viewer has no page for as a request rather than a blank page', () => { const root = useRenderToDom( diff --git a/packages/bruno-api-docs/src/pages/Request/Request.tsx b/packages/bruno-api-docs/src/pages/Request/Request.tsx index 6824373e..18da9397 100644 --- a/packages/bruno-api-docs/src/pages/Request/Request.tsx +++ b/packages/bruno-api-docs/src/pages/Request/Request.tsx @@ -17,7 +17,8 @@ import { getItemDescription, getRequestExamples, isUnsupportedRequestInDocs, - isGrpcRequest + isGrpcRequest, + type RequestItem } from '../../utils/schemaHelpers'; import { resolveInheritedAuth, @@ -57,7 +58,7 @@ import { GrpcRequestContent } from '../../components/GrpcRequestContent/GrpcRequ import { StyledWrapper } from './StyledWrapper'; interface RequestProps { - item: Item; + item: RequestItem; ancestry?: Item[]; collection?: OpenCollection | null; onTryClick?: () => void; diff --git a/packages/bruno-api-docs/src/utils/assertions.ts b/packages/bruno-api-docs/src/utils/assertions.ts index 00362ae6..89c47f65 100644 --- a/packages/bruno-api-docs/src/utils/assertions.ts +++ b/packages/bruno-api-docs/src/utils/assertions.ts @@ -1,6 +1,7 @@ -import type { Item } from '@opencollection/types/collection/item'; +import type { HttpRequest } from '@opencollection/types/requests/http'; +import type { GrpcRequest } from '@opencollection/types/requests/grpc'; import type { Assertion } from '@opencollection/types/common/assertions'; -import { getRequestAssertions, type RequestItem } from './schemaHelpers'; +import { getRequestAssertions } from './schemaHelpers'; import { getDescription } from './request'; const OPERATOR_LABELS: Record = { @@ -52,8 +53,8 @@ export interface AssertionRow { disabled?: boolean; } -export const collectAssertions = (item: Item): AssertionRow[] => - getRequestAssertions(item as RequestItem).map((assertion: Assertion) => { +export const collectAssertions = (item: HttpRequest | GrpcRequest): AssertionRow[] => + getRequestAssertions(item).map((assertion: Assertion) => { const unary = isUnaryOperator(assertion.operator); return { level: 'request', diff --git a/packages/bruno-api-docs/src/utils/fileUtils.ts b/packages/bruno-api-docs/src/utils/fileUtils.ts index 5c80b7c6..e8d58e80 100644 --- a/packages/bruno-api-docs/src/utils/fileUtils.ts +++ b/packages/bruno-api-docs/src/utils/fileUtils.ts @@ -1,7 +1,9 @@ import type { OpenCollection } from '@opencollection/types'; import type { Item, Folder } from '@opencollection/types/collection/item'; +import type { HttpRequest } from '@opencollection/types/requests/http'; +import type { GrpcRequest } from '@opencollection/types/requests/grpc'; import type { Scripts } from '@opencollection/types/common/scripts'; -import { getItemName, getRequestScripts, scriptsArrayToObject, isFolder, type RequestItem } from './schemaHelpers'; +import { getItemName, getRequestScripts, scriptsArrayToObject, isFolder } from './schemaHelpers'; import { isYamlFile, parseYaml } from './yamlUtils'; import type { ScriptFlow } from './request'; @@ -305,7 +307,7 @@ interface TestSource { const forEachTestSource = ( collection: OpenCollection | null | undefined, ancestors: Item[], - item: Item, + item: HttpRequest | GrpcRequest, flow: ScriptFlow, visit: (level: TestRow['level'], code: string | undefined, sourceName?: string) => void ): void => { @@ -316,7 +318,7 @@ const forEachTestSource = ( code: testsCode(folderScripts(folder)), sourceName: getItemName(folder) })), - { level: 'request', code: testsCode(getRequestScripts(item as RequestItem)) } + { level: 'request', code: testsCode(getRequestScripts(item)) } ]; const ordered = flow === 'sequential' ? sources : [...sources].reverse(); @@ -326,7 +328,7 @@ const forEachTestSource = ( export const collectTests = ( collection: OpenCollection | null | undefined, ancestors: Item[], - item: Item, + item: HttpRequest | GrpcRequest, flow: ScriptFlow = 'sandwich' ): TestRow[] => { const rows: TestRow[] = []; @@ -355,7 +357,7 @@ export interface RawTestScript { export const collectRawTestScripts = ( collection: OpenCollection | null | undefined, ancestors: Item[], - item: Item, + item: HttpRequest | GrpcRequest, flow: ScriptFlow = 'sandwich' ): RawTestScript[] => { const scripts: RawTestScript[] = []; diff --git a/packages/bruno-api-docs/src/utils/grpcSnippets.ts b/packages/bruno-api-docs/src/utils/grpcSnippets.ts index 958049e5..f78a0d00 100644 --- a/packages/bruno-api-docs/src/utils/grpcSnippets.ts +++ b/packages/bruno-api-docs/src/utils/grpcSnippets.ts @@ -1,8 +1,8 @@ import type { GrpcMetadata, GrpcMethodType } from '@opencollection/types/requests/grpc'; import type { Auth } from '@opencollection/types/common/auth'; -import type { GrpcMessageEntry } from './schemaHelpers'; -import { templateVariableGlobalRegex } from './common'; -import { authToHeaders } from './codeSnippets'; +import type { GrpcMessageEntry } from '@/utils/schemaHelpers'; +import { templateVariableGlobalRegex } from '@/utils/common'; +import { authToHeaders } from '@/utils/codeSnippets'; export interface GrpcSnippetInput { url: string; diff --git a/packages/bruno-api-docs/src/utils/request.ts b/packages/bruno-api-docs/src/utils/request.ts index 00828b4c..c7b3108f 100644 --- a/packages/bruno-api-docs/src/utils/request.ts +++ b/packages/bruno-api-docs/src/utils/request.ts @@ -1,5 +1,6 @@ import type { OpenCollection } from '@opencollection/types'; import type { Item } from '@opencollection/types/collection/item'; +import type { GrpcRequest } from '@opencollection/types/requests/grpc'; import type { HttpRequest, HttpRequestBody, @@ -90,7 +91,7 @@ export const resolveInheritedAuth = ( ancestors: Item[], item: Item ): ResolvedAuth => { - const own = getRequestAuth(item as HttpRequest) as Auth | undefined; + const own = getRequestAuth(item as RequestItem) as Auth | undefined; if (own !== 'inherit') return { auth: own }; // Walk ancestors leaf->root. Only an `inherit` folder is transparent; the first folder that @@ -129,7 +130,7 @@ export const getInheritedAuthSummary = ( ancestors: Item[], item: Item ): InheritedAuthSummary | null => { - if (getRequestAuth(item as HttpRequest) !== 'inherit') return null; + if (getRequestAuth(item as RequestItem) !== 'inherit') return null; const resolved = resolveInheritedAuth(collection, ancestors, item); return { sourceName: resolved.source?.name || collection?.info?.name || 'Collection', @@ -276,7 +277,7 @@ const stepLabel = (level: ScriptLevel, phase: ScriptPhase): string => { export const buildScriptChain = ( collection: OpenCollection | null | undefined, ancestors: Item[], - item: Item + item: HttpRequest | GrpcRequest ): ScriptChainStep[] => { const collectionScripts = scriptsArrayToObject(collection?.request?.scripts); const sources: ScriptSource[] = [ @@ -286,7 +287,7 @@ export const buildScriptChain = ( const s = scriptsArrayToObject(folderScripts(folder)); sources.push({ level: 'folder', order: sources.length, sourceName: getItemName(folder), sourceUuid: getItemUuid(folder), pre: s.preRequest, post: s.postResponse }); }); - const requestScripts = scriptsArrayToObject(getRequestScripts(item as RequestItem)); + const requestScripts = scriptsArrayToObject(getRequestScripts(item)); sources.push({ level: 'request', order: sources.length, pre: requestScripts.preRequest, post: requestScripts.postResponse }); const steps: ScriptChainStep[] = []; @@ -343,10 +344,10 @@ const toPostResponseVarRow = (action: Action): PostResponseVarRow => ({ disabled: action.disabled }); -export const getPreRequestVars = (item: Item): PreRequestVarRow[] => - getRequestVariables(item as RequestItem).map(toPreRequestVarRow); +export const getPreRequestVars = (item: HttpRequest | GrpcRequest): PreRequestVarRow[] => + getRequestVariables(item).map(toPreRequestVarRow); -export const getPostResponseVars = (item: Item): PostResponseVarRow[] => +export const getPostResponseVars = (item: HttpRequest | GrpcRequest): PostResponseVarRow[] => ((item as { runtime?: { actions?: Action[] } }).runtime?.actions ?? []) .filter(isAfterResponseSetVariable) .map(toPostResponseVarRow); diff --git a/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts b/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts index 2532cfb1..06788d6a 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts @@ -8,11 +8,14 @@ import { getGrpcMethod, getGrpcMethodType, getGrpcMetadata, - getGrpcProtoFileName + getGrpcProtoFileName, + type RequestItem } from './schemaHelpers'; const item = (data: Record): OpenCollectionItem => data as unknown as OpenCollectionItem; +const requestItem = (data: Record): RequestItem => data as unknown as RequestItem; + describe('getItemDescription', () => { it('reads a plain string description from the info block', () => { expect(getItemDescription({ info: { description: 'Short summary.' } } as any)).toBe('Short summary.'); @@ -54,24 +57,24 @@ describe('getRequestBadgeLabel', () => { describe('getRequestAuth', () => { it('lets the protocol block win over a request-block auth', () => { expect( - getRequestAuth(item({ http: { auth: { type: 'bearer' } }, request: { auth: { type: 'apikey' } } })) + getRequestAuth(requestItem({ http: { auth: { type: 'bearer' } }, request: { auth: { type: 'apikey' } } })) ).toEqual({ type: 'bearer' }); }); it('reads auth nested under a request block (flat-shape requests)', () => { - expect(getRequestAuth(item({ method: 'POST', request: { auth: { type: 'apikey' } } }))).toEqual({ + expect(getRequestAuth(requestItem({ method: 'POST', request: { auth: { type: 'apikey' } } }))).toEqual({ type: 'apikey' }); }); it('falls back to request.auth when a protocol block exists without auth', () => { expect( - getRequestAuth(item({ http: { body: { type: 'json' } }, request: { auth: { type: 'apikey' } } })) + getRequestAuth(requestItem({ http: { body: { type: 'json' } }, request: { auth: { type: 'apikey' } } })) ).toEqual({ type: 'apikey' }); }); it('treats a cleared request-block auth as no auth', () => { - expect(getRequestAuth(item({ method: 'POST', request: { auth: undefined } }))).toBeUndefined(); + expect(getRequestAuth(requestItem({ method: 'POST', request: { auth: undefined } }))).toBeUndefined(); }); }); diff --git a/packages/bruno-api-docs/src/utils/schemaHelpers.ts b/packages/bruno-api-docs/src/utils/schemaHelpers.ts index e0ed0c9f..28066e4c 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.ts @@ -298,7 +298,7 @@ export const getHttpParams = (item: HttpRequest | null | undefined): HttpRequest // and writes (AuthTab) share this list so the two can't drift. export const REQUEST_PROTOCOL_KEYS = ['http', 'graphql', 'grpc', 'websocket'] as const; -export const getRequestAuth = (item: OpenCollectionItem | null | undefined): any => { +export const getRequestAuth = (item: RequestItem | null | undefined): any => { if (!item) return undefined; // Current schema: auth is part of the protocol-detail block. From 5ad93b1b572ffcaff241ef13bc46b237992cf608 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Mon, 10 Aug 2026 19:31:59 +0530 Subject: [PATCH 15/18] refactor(docs): review comments handled The expand toggle spec finds its button by test id rather than by a data attribute selector, and imports through the path alias. The field text declared font-weight 400, which is the value it would have inherited anyway. --- .../ExpandToggle/ExpandToggle.spec.tsx | 20 +++++++++---------- .../GrpcRequestContent/StyledWrapper.ts | 1 - 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/bruno-api-docs/src/components/ExpandToggle/ExpandToggle.spec.tsx b/packages/bruno-api-docs/src/components/ExpandToggle/ExpandToggle.spec.tsx index 2af51fb2..fa503c44 100644 --- a/packages/bruno-api-docs/src/components/ExpandToggle/ExpandToggle.spec.tsx +++ b/packages/bruno-api-docs/src/components/ExpandToggle/ExpandToggle.spec.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { describe, it, expect } from 'vitest'; -import { useRenderToDom } from '../../hooks/useRenderToDom'; -import { query } from '../../test-utils/dom'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { getByTestId, query } from '@/test-utils/dom'; import { ExpandToggle } from './ExpandToggle'; const noop = () => {}; @@ -12,7 +12,7 @@ describe('ExpandToggle', () => { ); - const button = query(root, '[data-testid="t"]'); + const button = getByTestId(root, 't'); expect(button.text).toContain('Show more'); expect(button.text).not.toContain('Show less'); expect(button.attributes['aria-expanded']).toBe('false'); @@ -23,7 +23,7 @@ describe('ExpandToggle', () => { ); - const button = query(root, '[data-testid="t"]'); + const button = getByTestId(root, 't'); expect(button.text).toContain('Show less'); expect(button.attributes['aria-expanded']).toBe('true'); }); @@ -33,7 +33,7 @@ describe('ExpandToggle', () => { ); - const button = query(root, '[data-testid="t"]'); + const button = getByTestId(root, 't'); expect(button.tagName.toLowerCase()).toBe('button'); expect(button.attributes.type).toBe('button'); }); @@ -43,7 +43,7 @@ describe('ExpandToggle', () => { ); - expect(query(root, '[data-testid="t"]').attributes['aria-controls']).toBe('panel-1'); + expect(getByTestId(root, 't').attributes['aria-controls']).toBe('panel-1'); }); it('omits aria-controls when there is no region to name', () => { @@ -51,7 +51,7 @@ describe('ExpandToggle', () => { ); - expect(query(root, '[data-testid="t"]').attributes['aria-controls']).toBeUndefined(); + expect(getByTestId(root, 't').attributes['aria-controls']).toBeUndefined(); }); it('hides the chevron from assistive tech and keeps the caller class', () => { @@ -61,14 +61,14 @@ describe('ExpandToggle', () => { moreLabel="More" lessLabel="Less" onToggle={noop} - className="grpc-messages-show-toggle" + className="mt-3" testId="t" /> ); - const button = query(root, '[data-testid="t"]'); + const button = getByTestId(root, 't'); expect(button.attributes.class).toContain('expand-toggle'); - expect(button.attributes.class).toContain('grpc-messages-show-toggle'); + expect(button.attributes.class).toContain('mt-3'); expect(query(root, '.expand-toggle-chevron').attributes['aria-hidden']).toBe('true'); }); }); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/StyledWrapper.ts b/packages/bruno-api-docs/src/components/GrpcRequestContent/StyledWrapper.ts index 1c86301d..c2372aed 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/StyledWrapper.ts @@ -58,7 +58,6 @@ export const StyledWrapper = styled.div` flex: 1; min-width: 0; font-family: var(--font-mono); - font-weight: 400; font-size: 0.75rem; line-height: 1.125rem; color: var(--text-primary); From cb56fefdfb30e155bb3c1838c2cabc043fdf1aaf Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Tue, 11 Aug 2026 12:11:20 +0530 Subject: [PATCH 16/18] refactor(docs): review comments addressed --- .../GrpcMessageCard/GrpcMessageCard.spec.tsx | 21 +- .../GrpcMessageCard/GrpcMessageCard.tsx | 10 +- .../GrpcMessages/GrpcMessages.spec.tsx | 26 +-- .../GrpcMetadataTable.spec.tsx | 42 ++-- .../GrpcMetadataTable/StyledWrapper.ts | 2 +- .../GrpcMethodTypeIcon.spec.tsx | 15 +- .../GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx | 12 +- .../GrpcRequestContent.spec.tsx | 189 ++++++++++-------- .../GrpcRequestContent/GrpcRequestContent.tsx | 8 +- .../SnippetTabs/SnippetTabs.spec.tsx | 61 +++--- .../components/SnippetTabs/SnippetTabs.tsx | 11 +- .../src/hooks/useRenderToDom.ts | 4 +- .../src/pages/Request/Request.spec.tsx | 6 +- .../src/ui/Table/Table.spec.tsx | 2 +- 14 files changed, 222 insertions(+), 187 deletions(-) diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.spec.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.spec.tsx index a24b89d8..e8e15abb 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.spec.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.spec.tsx @@ -1,26 +1,27 @@ import React from 'react'; import { describe, it, expect } from 'vitest'; import { useRenderToDom } from '@/hooks/useRenderToDom'; - -const useMarkup = (element: React.ReactElement): string => useRenderToDom(element).innerHTML; +import { getByTestId, queryByTestId } from '@/test-utils/dom'; import { GrpcMessageCard } from './GrpcMessageCard'; describe('GrpcMessageCard', () => { it('renders the title and the message when expanded', () => { - const html = useMarkup( + const root = useRenderToDom( {}} /> ); - expect(html).toContain('Message 1'); - expect(html).toContain('SKU-1001'); - expect(html).toContain('aria-expanded="true"'); + + expect(getByTestId(root, 'grpc-message-card-title').text).toBe('Message 1'); + expect(getByTestId(root, 'grpc-message-card-code').text).toContain('SKU-1001'); + expect(getByTestId(root, 'grpc-message-card-toggle').attributes['aria-expanded']).toBe('true'); }); it('renders the title but not the message when collapsed', () => { - const html = useMarkup( + const root = useRenderToDom( {}} /> ); - expect(html).toContain('Message 2'); - expect(html).not.toContain('SKU-1002'); - expect(html).toContain('aria-expanded="false"'); + + expect(getByTestId(root, 'grpc-message-card-title').text).toBe('Message 2'); + expect(queryByTestId(root, 'grpc-message-card-code')).toBeNull(); + expect(getByTestId(root, 'grpc-message-card-toggle').attributes['aria-expanded']).toBe('false'); }); }); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx index b703a147..734691e5 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useId, useRef, useState } from 'react'; +import React, { useId, useState } from 'react'; import cx from '@/utils/cx'; import { prefersReducedMotion } from '@/utils/motion'; import { ChevronArrow } from '../../../ChevronArrow/ChevronArrow'; @@ -13,8 +13,6 @@ interface GrpcMessageCardProps { testId?: string; } -const COLLAPSE_MS = 220; - export const GrpcMessageCard: React.FC = ({ title, message, @@ -23,22 +21,17 @@ export const GrpcMessageCard: React.FC = ({ testId = 'grpc-message-card' }) => { const [collapsing, setCollapsing] = useState(false); - const timerRef = useRef(0); const detailId = useId(); const isOpen = expanded && !collapsing; - useEffect(() => () => window.clearTimeout(timerRef.current), []); - const finishCollapse = () => { - window.clearTimeout(timerRef.current); setCollapsing(false); onToggle(); }; const handleToggle = () => { if (collapsing) { - window.clearTimeout(timerRef.current); setCollapsing(false); return; } @@ -47,7 +40,6 @@ export const GrpcMessageCard: React.FC = ({ return; } setCollapsing(true); - timerRef.current = window.setTimeout(finishCollapse, COLLAPSE_MS + 60); }; const handleTransitionEnd = (event: React.TransitionEvent) => { diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.spec.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.spec.tsx index 3dfabd12..83e887cd 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.spec.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.spec.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { describe, it, expect } from 'vitest'; import { useRenderToDom } from '@/hooks/useRenderToDom'; - -const useMarkup = (element: React.ReactElement): string => useRenderToDom(element).innerHTML; +import { getByTestId, queryByTestId } from '@/test-utils/dom'; import { GrpcMessages } from './GrpcMessages'; const entries = (count: number) => @@ -13,25 +12,26 @@ const entries = (count: number) => describe('GrpcMessages', () => { it('renders nothing when there are no messages', () => { - expect(useMarkup()).toBe(''); + const root = useRenderToDom(); + expect(queryByTestId(root, 'grpc-messages')).toBeNull(); }); it('opens the first message and leaves the rest closed', () => { - const html = useMarkup(); - expect(html).toContain('body-1'); - expect(html).not.toContain('body-2'); - expect(html).not.toContain('body-3'); + const root = useRenderToDom(); + expect(getByTestId(root, 'grpc-messages-card-0-code').text).toContain('body-1'); + expect(queryByTestId(root, 'grpc-messages-card-1-code')).toBeNull(); + expect(queryByTestId(root, 'grpc-messages-card-2-code')).toBeNull(); }); it('shows only the first three messages and offers to show more', () => { - const html = useMarkup(); - expect(html).toContain('Message 3'); - expect(html).not.toContain('Message 4'); - expect(html).toContain('Show more'); + const root = useRenderToDom(); + expect(getByTestId(root, 'grpc-messages-card-2-title').text).toBe('Message 3'); + expect(queryByTestId(root, 'grpc-messages-card-3')).toBeNull(); + expect(getByTestId(root, 'grpc-messages-show-toggle').text).toContain('Show more'); }); it('offers no show-more control when everything already fits', () => { - const html = useMarkup(); - expect(html).not.toContain('Show more'); + const root = useRenderToDom(); + expect(queryByTestId(root, 'grpc-messages-show-toggle')).toBeNull(); }); }); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.spec.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.spec.tsx index 3fb64c93..bccdde80 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.spec.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.spec.tsx @@ -1,20 +1,24 @@ import React from 'react'; import { describe, it, expect } from 'vitest'; +import type { HTMLElement } from 'node-html-parser'; import { useRenderToDom } from '@/hooks/useRenderToDom'; - -const useMarkup = (element: React.ReactElement): string => useRenderToDom(element).innerHTML; +import { getByTestId, queryByTestId, query } from '@/test-utils/dom'; import type { GrpcMetadata } from '@opencollection/types/requests/grpc'; import { GrpcMetadataTable } from './GrpcMetadataTable'; const rows = (entries: Record[]) => entries as unknown as GrpcMetadata[]; +const cellTexts = (table: HTMLElement, key: string): string[] => + table.querySelectorAll(`[data-testid="table-cell-${key}"]`).map((cell) => cell.text.trim()); + describe('GrpcMetadataTable', () => { it('renders nothing when there is no metadata', () => { - expect(useMarkup()).toBe(''); + const root = useRenderToDom(); + expect(queryByTestId(root, 'grpc-metadata-table')).toBeNull(); }); it('renders a name, value and description for every row', () => { - const html = useMarkup( + const root = useRenderToDom( { ])} /> ); - expect(html).toContain('authorization'); - expect(html).toContain('Bearer token'); - expect(html).toContain('Auth token'); - expect(html).toContain('x-request-id'); - expect(html).toContain('req-001'); + + const table = getByTestId(root, 'grpc-metadata-table'); + expect(cellTexts(table, 'name')).toEqual(['authorization', 'x-request-id']); + expect(cellTexts(table, 'value')).toEqual(['Bearer token', 'req-001']); + expect(cellTexts(table, 'description')).toEqual(['Auth token', '']); }); it('reads a description given as an object', () => { - const html = useMarkup( + const root = useRenderToDom( ); - expect(html).toContain('Client name'); + + const table = getByTestId(root, 'grpc-metadata-table'); + expect(cellTexts(table, 'description')).toEqual(['Client name']); }); it('marks a disabled row', () => { - const html = useMarkup( + const root = useRenderToDom( ); - expect(html).toContain('x-legacy-flag'); - expect(html).toContain('disabled-badge'); + + const table = getByTestId(root, 'grpc-metadata-table'); + expect(cellTexts(table, 'name')).toEqual(['x-legacy-flag']); + expect(getByTestId(table, 'disabled-badge').text).toBe('Disabled'); }); it('highlights a variable in a value', () => { - const html = useMarkup( + const root = useRenderToDom( ); - expect(html).toContain('var-text'); + + const table = getByTestId(root, 'grpc-metadata-table'); + expect(query(table, '.var-text').text).toContain('token'); }); }); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/StyledWrapper.ts b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/StyledWrapper.ts index 698947b9..4725f6f0 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/StyledWrapper.ts @@ -11,7 +11,7 @@ export const StyledWrapper = styled.div` align-items: center; gap: 0.5rem; min-width: 0; - font-family: 'Fira Code', var(--font-mono); + font-family: var(--font-mono); color: var(--oc-colors-text-subtext2); } .grpc-metadata-value .disabled-badge { diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx index 70982727..700b4eb5 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx @@ -1,13 +1,9 @@ import React from 'react'; import { describe, it, expect } from 'vitest'; import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { getByTestId, queryByTestId } from '@/test-utils/dom'; import { GrpcMethodTypeIcon } from './GrpcMethodTypeIcon'; -const useColourOf = (methodType: string): string => { - const root = useRenderToDom(); - return root.innerHTML; -}; - describe('GrpcMethodTypeIcon', () => { it.each([ ['unary', 'get'], @@ -15,19 +11,22 @@ describe('GrpcMethodTypeIcon', () => { ['client-streaming', 'head'], ['bidi-streaming', 'post'] ])('colours %s from the %s method variable', (methodType, token) => { - expect(useColourOf(methodType)).toContain(`var(--oc-request-methods-${token})`); + const root = useRenderToDom(); + const icon = getByTestId(root, 'grpc-method-type-icon'); + expect(icon.attributes.style).toContain(`var(--oc-request-methods-${token})`); + expect(icon.querySelector('svg')).not.toBeNull(); }); it('renders nothing when the method type is absent', () => { const root = useRenderToDom(); - expect(root.querySelector('svg')).toBeNull(); + expect(queryByTestId(root, 'grpc-method-type-icon')).toBeNull(); }); it.each(['toString', 'constructor', 'hasOwnProperty', '__proto__'])( 'renders nothing for a methodType named %s instead of crashing', (methodType) => { const root = useRenderToDom(); - expect(root.querySelector('svg')).toBeNull(); + expect(queryByTestId(root, 'grpc-method-type-icon')).toBeNull(); } ); }); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx index 9c623875..f84df037 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx @@ -5,7 +5,7 @@ import { ServerStreamingIcon, ClientStreamingIcon, BidiStreamingIcon -} from '../../../assets/icons'; +} from '@/assets/icons'; import { StyledWrapper } from './StyledWrapper'; // Method types borrow the HTTP method colour tokens rather than defining their own, so both @@ -22,9 +22,15 @@ interface GrpcMethodTypeIconProps { methodType?: GrpcMethodType; size?: number; className?: string; + testId?: string; } -export const GrpcMethodTypeIcon: React.FC = ({ methodType, size = 16, className }) => { +export const GrpcMethodTypeIcon: React.FC = ({ + methodType, + size = 16, + className, + testId = 'grpc-method-type-icon' +}) => { const entry = methodType && Object.prototype.hasOwnProperty.call(ICON_BY_METHOD_TYPE, methodType) ? ICON_BY_METHOD_TYPE[methodType] @@ -33,7 +39,7 @@ export const GrpcMethodTypeIcon: React.FC = ({ methodTy const Icon = entry.icon; return ( - + ); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx index 19b04a65..4219107a 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { describe, it, expect } from 'vitest'; import { useRenderToDom } from '@/hooks/useRenderToDom'; - -const useMarkup = (element: React.ReactElement): string => useRenderToDom(element).innerHTML; +import { getByTestId, queryByTestId, query } from '@/test-utils/dom'; import type { GrpcRequest } from '@opencollection/types/requests/grpc'; import { GrpcRequestContent } from './GrpcRequestContent'; @@ -10,32 +9,35 @@ const grpcItem = (data: Record): GrpcRequest => data as unknown describe('GrpcRequestContent', () => { it('renders the request name, the GRPC badge and the url', () => { - const html = useMarkup( + const root = useRenderToDom( ); - expect(html).toContain('Order Service'); - expect(html).toContain('gRPC'); - expect(html).toContain('grpc://localhost:50051'); + + expect(getByTestId(root, 'grpc-request-title').text).toBe('Order Service'); + expect(getByTestId(root, 'request-method').text).toBe('gRPC'); + expect(getByTestId(root, 'request-url').text).toContain('grpc://localhost:50051'); }); it('renders a request that has no grpc block at all', () => { - const html = useMarkup( + const root = useRenderToDom( ); - expect(html).toContain('Bare Method'); - expect(html).toContain('{{grpcUrl}}'); + + expect(getByTestId(root, 'grpc-request-title').text).toBe('Bare Method'); + expect(getByTestId(root, 'request-url').text).toContain('{{grpcUrl}}'); }); it('falls back to a placeholder name and never offers a Try button', () => { - const html = useMarkup(); - expect(html).toContain('Untitled Request'); - expect(html).not.toContain('Try'); + const root = useRenderToDom(); + + expect(getByTestId(root, 'grpc-request-title').text).toBe('Untitled Request'); + expect(queryByTestId(root, 'request-try-button')).toBeNull(); }); it('renders the docs markdown as html', () => { - const html = useMarkup( + const root = useRenderToDom( { })} /> ); - expect(html).toContain('markdown-documentation'); - expect(html).toContain('>Order Service'); - expect(html).toContain('

Fetches a single order.

'); + + const description = getByTestId(root, 'grpc-request-description'); + const markdown = query(description, '.markdown-documentation'); + expect(query(markdown, 'h1').text).toBe('Order Service'); + expect(query(markdown, 'p').text).toBe('Fetches a single order.'); }); it('omits the description block when there are no docs', () => { - const html = useMarkup( + const root = useRenderToDom( ); - expect(html).not.toContain('markdown-documentation'); + expect(queryByTestId(root, 'grpc-request-description')).toBeNull(); }); it('renders a request with a method', () => { - const html = useMarkup( + const root = useRenderToDom( ); - expect(html).toContain('GetOrder'); + expect(getByTestId(root, 'grpc-request-method').text).toContain('GetOrder'); }); it('renders the proto file name and the method with its type label', () => { - const html = useMarkup( + const root = useRenderToDom( { })} /> ); - expect(html).toContain('book.proto'); - expect(html).toContain('>com.bookstore.BookService/GetBook<'); - expect(html).toContain('Unary'); + + expect(getByTestId(root, 'grpc-request-proto-file').text).toContain('book.proto'); + expect(getByTestId(root, 'grpc-request-method').text).toContain('com.bookstore.BookService/GetBook'); + expect(getByTestId(root, 'grpc-request-method-type').text).toBe('Unary'); }); it('hides the proto file path when the request uses reflection', () => { - const html = useMarkup( + const root = useRenderToDom( { })} /> ); - expect(html).not.toContain('grpc-request-section-proto-file'); - expect(html).toContain('Bidirectional Streaming'); + + expect(queryByTestId(root, 'grpc-request-section-proto-file')).toBeNull(); + expect(getByTestId(root, 'grpc-request-method-type').text).toBe('Bidirectional Streaming'); }); it('hides the method section when no method is selected', () => { - const html = useMarkup( + const root = useRenderToDom( ); - expect(html).not.toContain('grpc-request-section-method'); - expect(html).toContain('Bare Method'); + + expect(queryByTestId(root, 'grpc-request-section-method')).toBeNull(); + expect(getByTestId(root, 'grpc-request-title').text).toBe('Bare Method'); }); it('renders metadata rows with their descriptions and counts only enabled ones', () => { - const html = useMarkup( + const root = useRenderToDom( { })} /> ); - expect(html).toContain('authorization'); - expect(html).toContain('Auth token'); - expect(html).toContain('x-legacy-flag'); - expect(html).toContain('2 fields'); + + const section = getByTestId(root, 'grpc-request-section-metadata'); + const names = section.querySelectorAll('[data-testid="table-cell-name"]').map((cell) => cell.text.trim()); + expect(names).toEqual(['authorization', 'x-request-id', 'x-legacy-flag']); + expect(section.text).toContain('Auth token'); + expect(section.text).toContain('2 fields'); }); it('reads a metadata description given as an object', () => { - const html = useMarkup( + const root = useRenderToDom( { })} /> ); - expect(html).toContain('Client name'); - expect(html).toContain('1 field'); + + const section = getByTestId(root, 'grpc-request-section-metadata'); + expect(section.text).toContain('Client name'); + expect(section.text).toContain('1 field'); }); it('hides the metadata section when there is none', () => { - const html = useMarkup( + const root = useRenderToDom( { })} /> ); - expect(html).not.toContain('grpc-request-section-metadata'); + expect(queryByTestId(root, 'grpc-request-section-metadata')).toBeNull(); }); it('shows concrete auth with no inherited badge', () => { - const html = useMarkup( + const root = useRenderToDom( { })} /> ); - expect(html).toContain('Basic Auth'); - expect(html).toContain('reader'); - expect(html).not.toContain('Inherited from'); + + const section = getByTestId(root, 'grpc-request-section-auth'); + expect(section.text).toContain('Basic Auth'); + expect(section.text).toContain('reader'); + expect(queryByTestId(root, 'grpc-request-auth-inherited')).toBeNull(); }); it('resolves inherited auth up to the collection and says where it came from', () => { - const html = useMarkup( + const root = useRenderToDom( { collection={{ info: { name: 'Testbench' }, request: { auth: { type: 'bearer', token: 'abc' } } } as never} /> ); - expect(html).toContain('Inherited from collection'); - expect(html).toContain('Bearer Token'); + + expect(getByTestId(root, 'grpc-request-auth-inherited').text).toContain('Inherited from collection'); + expect(getByTestId(root, 'grpc-request-section-auth').text).toContain('Bearer Token'); }); it('masks a secret rather than printing it', () => { - const html = useMarkup( + const root = useRenderToDom( { })} /> ); - expect(html).not.toContain('s3cret'); + expect(root.text).not.toContain('s3cret'); }); it('hides the auth section when the request has no auth', () => { - const html = useMarkup( + const root = useRenderToDom( { })} /> ); - expect(html).not.toContain('grpc-request-section-auth'); + expect(queryByTestId(root, 'grpc-request-section-auth')).toBeNull(); }); it('shows a single empty state when the request has no configuration', () => { - const html = useMarkup( + const root = useRenderToDom( ); - expect(html).toContain('grpc-request-config-empty'); - expect(html).toContain('No request configuration'); - expect(html).toContain('Bare Method'); + + expect(getByTestId(root, 'grpc-request-config-empty').text).toContain('No request configuration'); + expect(getByTestId(root, 'grpc-request-title').text).toBe('Bare Method'); }); it('builds a grpcurl snippet from the request', () => { - const html = useMarkup( + const root = useRenderToDom( { })} /> ); - expect(html).toContain('grpcURL'); - expect(html).toContain('grpcurl'); - expect(html).toContain('localhost:50051'); - expect(html).toContain('orders.OrderService/GetOrder'); + + expect(getByTestId(root, 'grpc-request-code-snippet-tab-grpcurl').text).toBe('grpcURL'); + const code = getByTestId(root, 'grpc-request-code-snippet-code'); + expect(code.text).toContain('grpcurl'); + expect(code.text).toContain('localhost:50051'); + expect(code.text).toContain('orders.OrderService/GetOrder'); }); it('omits the code snippet when the request has no method', () => { - const html = useMarkup( + const root = useRenderToDom( { })} /> ); - expect(html).not.toContain('grpc-request-section-code-snippet'); + expect(queryByTestId(root, 'grpc-request-section-code-snippet')).toBeNull(); }); it('shows sections instead of the empty state when there is any configuration', () => { - const html = useMarkup( + const root = useRenderToDom( { })} /> ); - expect(html).not.toContain('grpc-request-config-empty'); - expect(html).toContain('grpc-request-section-method'); + + expect(queryByTestId(root, 'grpc-request-config-empty')).toBeNull(); + expect(queryByTestId(root, 'grpc-request-section-method')).not.toBeNull(); }); it('offers a JavaScript snippet only when a proto file is attached', () => { - const withProto = useMarkup( + const withProto = useRenderToDom( { })} /> ); - expect(withProto).toContain('grpc-request-code-snippet-tab-javascript'); + expect(queryByTestId(withProto, 'grpc-request-code-snippet-tab-javascript')).not.toBeNull(); - const reflectionOnly = useMarkup( + const reflectionOnly = useRenderToDom( { })} /> ); - expect(reflectionOnly).toContain('grpc-request-code-snippet-tab-grpcurl'); - expect(reflectionOnly).not.toContain('grpc-request-code-snippet-tab-javascript'); + expect(queryByTestId(reflectionOnly, 'grpc-request-code-snippet-tab-grpcurl')).not.toBeNull(); + expect(queryByTestId(reflectionOnly, 'grpc-request-code-snippet-tab-javascript')).toBeNull(); }); }); describe('GrpcRequestContent — execution context', () => { const useWithRuntime = (runtime: Record) => - useMarkup( + useRenderToDom( { ); it('renders an empty state when the request carries no runtime', () => { - const html = useMarkup( + const root = useRenderToDom( { })} /> ); - expect(html).toContain('grpc-request-section-execution-context'); - expect(html).toContain('grpc-request-execution-context-empty'); - expect(html).toContain('No execution context'); + + const section = getByTestId(root, 'grpc-request-section-execution-context'); + expect(getByTestId(section, 'grpc-request-execution-context-empty').text).toContain('No execution context'); }); it('renders pre-request variables from the runtime block', () => { - const html = useWithRuntime({ variables: [{ name: 'orderId', value: '12345' }] }); - expect(html).not.toContain('grpc-request-execution-context-empty'); - expect(html).toContain('orderId'); + const root = useWithRuntime({ variables: [{ name: 'orderId', value: '12345' }] }); + expect(queryByTestId(root, 'grpc-request-execution-context-empty')).toBeNull(); + expect(getByTestId(root, 'grpc-request-section-execution-context').text).toContain('orderId'); }); it('renders post-response captures stored as actions', () => { - const html = useWithRuntime({ + const root = useWithRuntime({ actions: [ { type: 'set-variable', @@ -345,18 +362,18 @@ describe('GrpcRequestContent — execution context', () => { } ] }); - expect(html).not.toContain('grpc-request-execution-context-empty'); - expect(html).toContain('lastOrderStatus'); + expect(queryByTestId(root, 'grpc-request-execution-context-empty')).toBeNull(); + expect(getByTestId(root, 'grpc-request-section-execution-context').text).toContain('lastOrderStatus'); }); it('renders assertions from the runtime block', () => { - const html = useWithRuntime({ assertions: [{ expression: 'res.body.orderId', operator: 'eq', value: '12345' }] }); - expect(html).not.toContain('grpc-request-execution-context-empty'); - expect(html).toContain('res.body.orderId'); + const root = useWithRuntime({ assertions: [{ expression: 'res.body.orderId', operator: 'eq', value: '12345' }] }); + expect(queryByTestId(root, 'grpc-request-execution-context-empty')).toBeNull(); + expect(getByTestId(root, 'grpc-request-section-execution-context').text).toContain('res.body.orderId'); }); it('renders scripts from the runtime block', () => { - const html = useWithRuntime({ scripts: [{ type: 'before-request', code: 'bru.setVar(\'requestedAt\', Date.now());' }] }); - expect(html).not.toContain('grpc-request-execution-context-empty'); + const root = useWithRuntime({ scripts: [{ type: 'before-request', code: 'bru.setVar(\'requestedAt\', Date.now());' }] }); + expect(queryByTestId(root, 'grpc-request-execution-context-empty')).toBeNull(); }); }); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx index a0972546..80ee7940 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx @@ -170,7 +170,7 @@ export const GrpcRequestContent: React.FC = ({ {descHtml && ( - +
= ({
{grpcMethodPath(method)} - {methodTypeLabel && {methodTypeLabel}} + {methodTypeLabel && ( + + {methodTypeLabel} + + )}
)} diff --git a/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.spec.tsx b/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.spec.tsx index 3fafa213..0adc3473 100644 --- a/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.spec.tsx +++ b/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.spec.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { describe, it, expect } from 'vitest'; import { useRenderToDom } from '@/hooks/useRenderToDom'; - -const useMarkup = (element: React.ReactElement): string => useRenderToDom(element).innerHTML; +import { getByTestId, queryByTestId } from '@/test-utils/dom'; import { SnippetTabs, type Snippet } from './SnippetTabs'; const snippets: Snippet[] = [ @@ -12,63 +11,67 @@ const snippets: Snippet[] = [ describe('SnippetTabs', () => { it('renders a tab per snippet and shows the first one', () => { - const html = useMarkup(); + const root = useRenderToDom(); - expect(html).toContain('grpcURL'); - expect(html).toContain('JavaScript'); - expect(html).toContain('pkg.Svc/Do'); - expect(html).not.toContain('@grpc/grpc-js'); + expect(getByTestId(root, 'request-code-snippet-tab-grpcurl').text).toBe('grpcURL'); + expect(getByTestId(root, 'request-code-snippet-tab-javascript').text).toBe('JavaScript'); + const code = getByTestId(root, 'request-code-snippet-code'); + expect(code.text).toContain('pkg.Svc/Do'); + expect(code.text).not.toContain('@grpc/grpc-js'); }); it('renders nothing when there are no snippets', () => { - expect(useMarkup()).toBe(''); + const root = useRenderToDom(); + expect(queryByTestId(root, 'request-code-snippet')).toBeNull(); }); it('derives every child test id from the testId it is given', () => { - const html = useMarkup(); + const root = useRenderToDom(); - expect(html).toContain('data-testid="grpc-request-code-snippet"'); - expect(html).toContain('data-testid="grpc-request-code-snippet-tab-grpcurl"'); - expect(html).toContain('data-testid="grpc-request-code-snippet-tab-javascript"'); - expect(html).toContain('data-testid="grpc-request-code-snippet-expand"'); - expect(html).toContain('data-testid="grpc-request-code-snippet-code"'); + expect(queryByTestId(root, 'grpc-request-code-snippet')).not.toBeNull(); + expect(queryByTestId(root, 'grpc-request-code-snippet-tab-grpcurl')).not.toBeNull(); + expect(queryByTestId(root, 'grpc-request-code-snippet-tab-javascript')).not.toBeNull(); + expect(queryByTestId(root, 'grpc-request-code-snippet-expand')).not.toBeNull(); + expect(queryByTestId(root, 'grpc-request-code-snippet-code')).not.toBeNull(); }); it('falls back to the request base when no testId is given', () => { - const html = useMarkup(); - expect(html).toContain('data-testid="request-code-snippet-tab-grpcurl"'); + const root = useRenderToDom(); + expect(queryByTestId(root, 'request-code-snippet-tab-grpcurl')).not.toBeNull(); }); it('marks the active tab as selected', () => { - const html = useMarkup(); + const root = useRenderToDom(); - expect(html).toContain('aria-selected="true"'); - expect(html).toContain('aria-selected="false"'); + expect(getByTestId(root, 'request-code-snippet-tab-grpcurl').attributes['aria-selected']).toBe('true'); + expect(getByTestId(root, 'request-code-snippet-tab-javascript').attributes['aria-selected']).toBe('false'); }); it('collapses to a trigger instead of the code box when embedded', () => { - const html = useMarkup( + const root = useRenderToDom( ); - expect(html).toContain('data-testid="example-code-snippet-trigger"'); - expect(html).toContain('Code Snippet'); - expect(html).not.toContain('pkg.Svc/Do'); - expect(html).not.toContain('data-testid="example-code-snippet-expand"'); + expect(getByTestId(root, 'example-code-snippet-trigger').text).toContain('Code Snippet'); + expect(queryByTestId(root, 'example-code-snippet-code')).toBeNull(); + expect(queryByTestId(root, 'example-code-snippet-expand')).toBeNull(); }); it('renders variables in the code as hover tokens', () => { - const html = useMarkup(); + const root = useRenderToDom(); - expect(html).toContain('data-var-name="host"'); - expect(html).toContain('{{host}}'); + const code = getByTestId(root, 'request-code-snippet-code'); + const token = code.querySelector('[data-var-name="host"]'); + expect(token).not.toBeNull(); + expect(code.text).toContain('{{host}}'); }); it('passes the snippet language through to the highlighter', () => { - const html = useMarkup( + const root = useRenderToDom( ); - expect(html).toContain('language-json'); + const code = getByTestId(root, 'request-code-snippet-code'); + expect(code.querySelector('code.language-json')).not.toBeNull(); }); }); diff --git a/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx b/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx index 1ca5f0ab..9639ac44 100644 --- a/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx +++ b/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx @@ -1,5 +1,6 @@ import React, { useRef, useState } from 'react'; import { IconCode } from '@tabler/icons'; +import cx from '@/utils/cx'; import { Code } from '../Code/Code'; import { CopyButton } from '@/ui/CopyButton/CopyButton'; import { useResolvedVariables } from '@/hooks'; @@ -29,7 +30,7 @@ export const SnippetTabs: React.FC = ({ testId = 'request-code-snippet' }) => { const [active, setActive] = useState(snippets[0]?.id ?? ''); - const [modalActive, setModalActive] = useState(snippets[0]?.id ?? ''); + const [activeModalId, setActiveModalId] = useState(snippets[0]?.id ?? ''); const [expanded, setExpanded] = useState(false); const triggerRef = useRef(null); const { showVars, resolve } = useResolvedVariables(); @@ -37,7 +38,7 @@ export const SnippetTabs: React.FC = ({ if (snippets.length === 0) return null; const openModal = () => { - setModalActive(active); + setActiveModalId(active); setExpanded(true); }; @@ -61,7 +62,7 @@ export const SnippetTabs: React.FC = ({ role="tab" aria-selected={activeSnippet.id === snippet.id} data-testid={`${testId}-tab-${snippet.id}`} - className={['snippet-tab', activeSnippet.id === snippet.id ? 'is-active' : ''].filter(Boolean).join(' ')} + className={cx('snippet-tab', { 'is-active': activeSnippet.id === snippet.id })} onClick={() => setActiveId(snippet.id)} > {snippet.label} @@ -98,7 +99,7 @@ export const SnippetTabs: React.FC = ({ }; return ( - + {variant === 'inline' ? ( renderSnippetBox('inline', active, setActive) ) : ( @@ -122,7 +123,7 @@ export const SnippetTabs: React.FC = ({ > {expanded && ( - {renderSnippetBox('modal', modalActive, setModalActive)} + {renderSnippetBox('modal', activeModalId, setActiveModalId)} )} diff --git a/packages/bruno-api-docs/src/hooks/useRenderToDom.ts b/packages/bruno-api-docs/src/hooks/useRenderToDom.ts index 58068275..60c68ff1 100644 --- a/packages/bruno-api-docs/src/hooks/useRenderToDom.ts +++ b/packages/bruno-api-docs/src/hooks/useRenderToDom.ts @@ -3,7 +3,9 @@ import { parse } from 'node-html-parser'; import type { ReactElement } from 'react'; export const useRenderToDom = (ui: ReactElement) => { - const root = parse(renderToStaticMarkup(ui)); + const root = parse(renderToStaticMarkup(ui), { + blockTextElements: { script: true, noscript: true, style: true } + }); root.querySelectorAll('style').forEach((node) => node.remove()); return root; }; diff --git a/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx b/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx index 96aa0fd5..d6f9a9b0 100644 --- a/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx +++ b/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx @@ -4,10 +4,10 @@ import type { OpenCollection } from '@opencollection/types'; import type { HttpRequest } from '@opencollection/types/requests/http'; import type { Item } from '@opencollection/types/collection/item'; import { MemoryRouter } from 'react-router-dom'; -import type { RequestItem } from '../../utils/schemaHelpers'; +import type { RequestItem } from '@/utils/schemaHelpers'; import { Request } from './Request'; -import { useRenderToDom } from '../../hooks/useRenderToDom'; -import { getByTestId, queryByTestId } from '../../test-utils/dom'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { getByTestId, queryByTestId } from '@/test-utils/dom'; const collection: OpenCollection = { info: { name: 'Auth API', version: '1.0.0' }, diff --git a/packages/bruno-api-docs/src/ui/Table/Table.spec.tsx b/packages/bruno-api-docs/src/ui/Table/Table.spec.tsx index 36a9a2eb..bf535555 100644 --- a/packages/bruno-api-docs/src/ui/Table/Table.spec.tsx +++ b/packages/bruno-api-docs/src/ui/Table/Table.spec.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { describe, it, expect } from 'vitest'; -import { useRenderToDom } from '../../hooks/useRenderToDom'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; import { Table, type TableColumn } from './Table'; const columns: TableColumn[] = [ From 566d87bc4f3a1f68b492d08645984a668f7dfe1e Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Wed, 12 Aug 2026 13:54:50 +0530 Subject: [PATCH 17/18] fix(docs): label the gRPC script-chain marker as GRPC instead of HTTP --- .../components/ExecutionContext/ExecutionContext.tsx | 8 +++++--- .../ExecutionContext/ScriptChain/ScriptChain.tsx | 10 +++++----- .../GrpcRequestContent/GrpcRequestContent.spec.tsx | 5 +++++ .../GrpcRequestContent/GrpcRequestContent.tsx | 1 + packages/bruno-api-docs/src/pages/Request/Request.tsx | 1 - 5 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/bruno-api-docs/src/components/ExecutionContext/ExecutionContext.tsx b/packages/bruno-api-docs/src/components/ExecutionContext/ExecutionContext.tsx index 7d7670a8..92cfbdf4 100644 --- a/packages/bruno-api-docs/src/components/ExecutionContext/ExecutionContext.tsx +++ b/packages/bruno-api-docs/src/components/ExecutionContext/ExecutionContext.tsx @@ -29,7 +29,7 @@ interface ExecutionContextProps { tests: TestRow[]; testScripts?: RawTestScript[]; flow?: ScriptFlow; - method?: string; + requestLabel?: string; url?: string; variant?: 'tabs' | 'docs'; className?: string; @@ -65,7 +65,7 @@ export const ExecutionContext: React.FC = ({ tests, testScripts = [], flow = 'sandwich', - method, + requestLabel, url, variant = 'tabs', className, @@ -88,7 +88,9 @@ export const ExecutionContext: React.FC = ({ const inheritedVarCount = inheritedPreVars.length + inheritedPostVars.length; const inheritedVarsBadge = inheritedVarCount > 0 ? : undefined; - const scripts = ; + const scripts = ( + + ); const variables = ( void; } -const HttpMarker: React.FC<{ position: number; url?: string }> = ({ position, url }) => ( +const RequestMarker: React.FC<{ position: number; label: string; url?: string }> = ({ position, label, url }) => (
{position}
); -export const ScriptChain: React.FC = ({ steps, flow, url, onNavigate }) => { +export const ScriptChain: React.FC = ({ steps, flow, requestLabel = 'HTTP', url, onNavigate }) => { const { pre, post } = useMemo(() => { const byOrderAsc = (a: ScriptChainStep, b: ScriptChainStep) => a.order - b.order; const pre = steps.filter((s) => s.phase === 'before-request').sort(byOrderAsc); @@ -49,7 +49,7 @@ export const ScriptChain: React.FC = ({ steps, flow, url, onNa {pre.map((step, index) => ( ))} - + {post.map((step, index) => ( ))} diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx index 4219107a..c38344a2 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx @@ -376,4 +376,9 @@ describe('GrpcRequestContent — execution context', () => { const root = useWithRuntime({ scripts: [{ type: 'before-request', code: 'bru.setVar(\'requestedAt\', Date.now());' }] }); expect(queryByTestId(root, 'grpc-request-execution-context-empty')).toBeNull(); }); + + it('labels the script-chain request marker as GRPC', () => { + const root = useWithRuntime({ scripts: [{ type: 'before-request', code: 'bru.setVar(\'requestedAt\', Date.now());' }] }); + expect(getByTestId(root, 'script-chain-request-label').text).toBe('GRPC'); + }); }); diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx index 80ee7940..638558d4 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx +++ b/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.tsx @@ -302,6 +302,7 @@ export const GrpcRequestContent: React.FC = ({ tests={tests} testScripts={testScripts} flow={scriptFlow} + requestLabel="GRPC" url={url} onNavigate={onBreadcrumbClick} /> diff --git a/packages/bruno-api-docs/src/pages/Request/Request.tsx b/packages/bruno-api-docs/src/pages/Request/Request.tsx index 18da9397..56cadaa7 100644 --- a/packages/bruno-api-docs/src/pages/Request/Request.tsx +++ b/packages/bruno-api-docs/src/pages/Request/Request.tsx @@ -280,7 +280,6 @@ const RequestContent: React.FC = ({ tests={tests} testScripts={testScripts} flow={scriptFlow} - method={method} url={url} onNavigate={onBreadcrumbClick} /> From 47730263cf75f68f6f387bf8203e4a2d3bc5b0c7 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Wed, 12 Aug 2026 19:29:59 +0530 Subject: [PATCH 18/18] refactor(docs): move gRPC page to pages/GrpcRequest and rename component --- .../GrpcMessageCard/GrpcMessageCard.spec.tsx | 0 .../GrpcMessageCard/GrpcMessageCard.tsx | 4 +- .../GrpcMessageCard/StyledWrapper.ts | 0 .../GrpcMessages/GrpcMessages.spec.tsx | 0 .../GrpcMessages/GrpcMessages.tsx | 2 +- .../GrpcMetadataTable.spec.tsx | 0 .../GrpcMetadataTable/GrpcMetadataTable.tsx | 6 +- .../GrpcMetadataTable/StyledWrapper.ts | 0 .../GrpcMethodTypeIcon.spec.tsx | 0 .../GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx | 0 .../GrpcMethodTypeIcon/StyledWrapper.ts | 0 .../GrpcRequest/GrpcRequest.spec.tsx} | 58 +++++++++---------- .../GrpcRequest/GrpcRequest.tsx} | 30 +++++----- .../GrpcRequest}/StyledWrapper.ts | 0 .../src/pages/Request/Request.tsx | 4 +- 15 files changed, 52 insertions(+), 52 deletions(-) rename packages/bruno-api-docs/src/{components/GrpcRequestContent => pages/GrpcRequest}/GrpcMessages/GrpcMessageCard/GrpcMessageCard.spec.tsx (100%) rename packages/bruno-api-docs/src/{components/GrpcRequestContent => pages/GrpcRequest}/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx (94%) rename packages/bruno-api-docs/src/{components/GrpcRequestContent => pages/GrpcRequest}/GrpcMessages/GrpcMessageCard/StyledWrapper.ts (100%) rename packages/bruno-api-docs/src/{components/GrpcRequestContent => pages/GrpcRequest}/GrpcMessages/GrpcMessages.spec.tsx (100%) rename packages/bruno-api-docs/src/{components/GrpcRequestContent => pages/GrpcRequest}/GrpcMessages/GrpcMessages.tsx (95%) rename packages/bruno-api-docs/src/{components/GrpcRequestContent => pages/GrpcRequest}/GrpcMetadataTable/GrpcMetadataTable.spec.tsx (100%) rename packages/bruno-api-docs/src/{components/GrpcRequestContent => pages/GrpcRequest}/GrpcMetadataTable/GrpcMetadataTable.tsx (87%) rename packages/bruno-api-docs/src/{components/GrpcRequestContent => pages/GrpcRequest}/GrpcMetadataTable/StyledWrapper.ts (100%) rename packages/bruno-api-docs/src/{components/GrpcRequestContent => pages/GrpcRequest}/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx (100%) rename packages/bruno-api-docs/src/{components/GrpcRequestContent => pages/GrpcRequest}/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx (100%) rename packages/bruno-api-docs/src/{components/GrpcRequestContent => pages/GrpcRequest}/GrpcMethodTypeIcon/StyledWrapper.ts (100%) rename packages/bruno-api-docs/src/{components/GrpcRequestContent/GrpcRequestContent.spec.tsx => pages/GrpcRequest/GrpcRequest.spec.tsx} (90%) rename packages/bruno-api-docs/src/{components/GrpcRequestContent/GrpcRequestContent.tsx => pages/GrpcRequest/GrpcRequest.tsx} (92%) rename packages/bruno-api-docs/src/{components/GrpcRequestContent => pages/GrpcRequest}/StyledWrapper.ts (100%) diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.spec.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessageCard/GrpcMessageCard.spec.tsx similarity index 100% rename from packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.spec.tsx rename to packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessageCard/GrpcMessageCard.spec.tsx diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx similarity index 94% rename from packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx rename to packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx index 734691e5..584dae86 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx @@ -1,8 +1,8 @@ import React, { useId, useState } from 'react'; import cx from '@/utils/cx'; import { prefersReducedMotion } from '@/utils/motion'; -import { ChevronArrow } from '../../../ChevronArrow/ChevronArrow'; -import { Code } from '../../../Code/Code'; +import { ChevronArrow } from '@/components/ChevronArrow/ChevronArrow'; +import { Code } from '@/components/Code/Code'; import { StyledWrapper } from './StyledWrapper'; interface GrpcMessageCardProps { diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/StyledWrapper.ts b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessageCard/StyledWrapper.ts similarity index 100% rename from packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessageCard/StyledWrapper.ts rename to packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessageCard/StyledWrapper.ts diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.spec.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessages.spec.tsx similarity index 100% rename from packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.spec.tsx rename to packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessages.spec.tsx diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessages.tsx similarity index 95% rename from packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.tsx rename to packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessages.tsx index 1a677b28..b82a4079 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMessages/GrpcMessages.tsx +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessages.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import type { GrpcMessageEntry } from '@/utils/schemaHelpers'; import { GrpcMessageCard } from './GrpcMessageCard/GrpcMessageCard'; -import { ExpandToggle } from '../../ExpandToggle/ExpandToggle'; +import { ExpandToggle } from '@/components/ExpandToggle/ExpandToggle'; const COLLAPSED_COUNT = 3; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.spec.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMetadataTable/GrpcMetadataTable.spec.tsx similarity index 100% rename from packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.spec.tsx rename to packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMetadataTable/GrpcMetadataTable.spec.tsx diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMetadataTable/GrpcMetadataTable.tsx similarity index 87% rename from packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.tsx rename to packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMetadataTable/GrpcMetadataTable.tsx index 765330b1..6ad61286 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/GrpcMetadataTable.tsx +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMetadataTable/GrpcMetadataTable.tsx @@ -2,9 +2,9 @@ import React from 'react'; import type { GrpcMetadata } from '@opencollection/types/requests/grpc'; import { getDescription } from '@/utils/request'; import { Table, type TableColumn } from '@/ui/Table/Table'; -import { TruncatedText } from '../../TruncatedText/TruncatedText'; -import { VariableText } from '../../VariableText/VariableText'; -import { DisabledBadge } from '../../DisabledBadge/DisabledBadge'; +import { TruncatedText } from '@/components/TruncatedText/TruncatedText'; +import { VariableText } from '@/components/VariableText/VariableText'; +import { DisabledBadge } from '@/components/DisabledBadge/DisabledBadge'; import { StyledWrapper } from './StyledWrapper'; const COLUMNS: TableColumn[] = [ diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/StyledWrapper.ts b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMetadataTable/StyledWrapper.ts similarity index 100% rename from packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMetadataTable/StyledWrapper.ts rename to packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMetadataTable/StyledWrapper.ts diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx similarity index 100% rename from packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx rename to packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx similarity index 100% rename from packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx rename to packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/StyledWrapper.ts b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMethodTypeIcon/StyledWrapper.ts similarity index 100% rename from packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcMethodTypeIcon/StyledWrapper.ts rename to packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMethodTypeIcon/StyledWrapper.ts diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.spec.tsx similarity index 90% rename from packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx rename to packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.spec.tsx index c38344a2..1ae73b38 100644 --- a/packages/bruno-api-docs/src/components/GrpcRequestContent/GrpcRequestContent.spec.tsx +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.spec.tsx @@ -2,15 +2,15 @@ import React from 'react'; import { describe, it, expect } from 'vitest'; import { useRenderToDom } from '@/hooks/useRenderToDom'; import { getByTestId, queryByTestId, query } from '@/test-utils/dom'; -import type { GrpcRequest } from '@opencollection/types/requests/grpc'; -import { GrpcRequestContent } from './GrpcRequestContent'; +import type { GrpcRequest as GrpcRequestItem } from '@opencollection/types/requests/grpc'; +import { GrpcRequest } from './GrpcRequest'; -const grpcItem = (data: Record): GrpcRequest => data as unknown as GrpcRequest; +const grpcItem = (data: Record): GrpcRequestItem => data as unknown as GrpcRequestItem; -describe('GrpcRequestContent', () => { +describe('GrpcRequest', () => { it('renders the request name, the GRPC badge and the url', () => { const root = useRenderToDom( - ); @@ -22,7 +22,7 @@ describe('GrpcRequestContent', () => { it('renders a request that has no grpc block at all', () => { const root = useRenderToDom( - + ); expect(getByTestId(root, 'grpc-request-title').text).toBe('Bare Method'); @@ -30,7 +30,7 @@ describe('GrpcRequestContent', () => { }); it('falls back to a placeholder name and never offers a Try button', () => { - const root = useRenderToDom(); + const root = useRenderToDom(); expect(getByTestId(root, 'grpc-request-title').text).toBe('Untitled Request'); expect(queryByTestId(root, 'request-try-button')).toBeNull(); @@ -38,7 +38,7 @@ describe('GrpcRequestContent', () => { it('renders the docs markdown as html', () => { const root = useRenderToDom( - { it('omits the description block when there are no docs', () => { const root = useRenderToDom( - + ); expect(queryByTestId(root, 'grpc-request-description')).toBeNull(); }); it('renders a request with a method', () => { const root = useRenderToDom( - + ); expect(getByTestId(root, 'grpc-request-method').text).toContain('GetOrder'); }); it('renders the proto file name and the method with its type label', () => { const root = useRenderToDom( - { it('hides the proto file path when the request uses reflection', () => { const root = useRenderToDom( - { it('hides the method section when no method is selected', () => { const root = useRenderToDom( - + ); expect(queryByTestId(root, 'grpc-request-section-method')).toBeNull(); @@ -116,7 +116,7 @@ describe('GrpcRequestContent', () => { it('renders metadata rows with their descriptions and counts only enabled ones', () => { const root = useRenderToDom( - { it('reads a metadata description given as an object', () => { const root = useRenderToDom( - { it('hides the metadata section when there is none', () => { const root = useRenderToDom( - { it('shows concrete auth with no inherited badge', () => { const root = useRenderToDom( - { it('resolves inherited auth up to the collection and says where it came from', () => { const root = useRenderToDom( - { it('masks a secret rather than printing it', () => { const root = useRenderToDom( - { it('hides the auth section when the request has no auth', () => { const root = useRenderToDom( - { it('shows a single empty state when the request has no configuration', () => { const root = useRenderToDom( - + ); expect(getByTestId(root, 'grpc-request-config-empty').text).toContain('No request configuration'); @@ -244,7 +244,7 @@ describe('GrpcRequestContent', () => { it('builds a grpcurl snippet from the request', () => { const root = useRenderToDom( - { it('omits the code snippet when the request has no method', () => { const root = useRenderToDom( - { it('shows sections instead of the empty state when there is any configuration', () => { const root = useRenderToDom( - { it('offers a JavaScript snippet only when a proto file is attached', () => { const withProto = useRenderToDom( - { expect(queryByTestId(withProto, 'grpc-request-code-snippet-tab-javascript')).not.toBeNull(); const reflectionOnly = useRenderToDom( - { }); }); -describe('GrpcRequestContent — execution context', () => { +describe('GrpcRequest — execution context', () => { const useWithRuntime = (runtime: Record) => useRenderToDom( - { it('renders an empty state when the request carries no runtime', () => { const root = useRenderToDom( - void; testId?: string; } -export const GrpcRequestContent: React.FC = ({ +export const GrpcRequest: React.FC = ({ item, ancestry = NO_ANCESTRY, collection, @@ -320,4 +320,4 @@ export const GrpcRequestContent: React.FC = ({ ); }; -export default GrpcRequestContent; +export default GrpcRequest; diff --git a/packages/bruno-api-docs/src/components/GrpcRequestContent/StyledWrapper.ts b/packages/bruno-api-docs/src/pages/GrpcRequest/StyledWrapper.ts similarity index 100% rename from packages/bruno-api-docs/src/components/GrpcRequestContent/StyledWrapper.ts rename to packages/bruno-api-docs/src/pages/GrpcRequest/StyledWrapper.ts diff --git a/packages/bruno-api-docs/src/pages/Request/Request.tsx b/packages/bruno-api-docs/src/pages/Request/Request.tsx index 56cadaa7..cb2daf13 100644 --- a/packages/bruno-api-docs/src/pages/Request/Request.tsx +++ b/packages/bruno-api-docs/src/pages/Request/Request.tsx @@ -54,7 +54,7 @@ import { CodeSnippetTabs } from '../../components/CodeSnippetTabs/CodeSnippetTab import { Examples } from '../../components/Examples/Examples'; import { ExecutionContext } from '../../components/ExecutionContext/ExecutionContext'; import { UnsupportedRequest } from '../../components/UnsupportedRequest/UnsupportedRequest'; -import { GrpcRequestContent } from '../../components/GrpcRequestContent/GrpcRequestContent'; +import { GrpcRequest } from '../GrpcRequest/GrpcRequest'; import { StyledWrapper } from './StyledWrapper'; interface RequestProps { @@ -307,7 +307,7 @@ export const Request: React.FC = ({ }) => { if (isGrpcRequest(item)) { return ( -
- {column.header} -
+ {column.header} +