diff --git a/.changeset/feat-wspage.md b/.changeset/feat-wspage.md new file mode 100644 index 00000000..862fee1a --- /dev/null +++ b/.changeset/feat-wspage.md @@ -0,0 +1,5 @@ +--- +"@usebruno/api-docs": minor +--- + +WebSocket requests now render as a read-only docs page instead of "Preview not available". The page shows the connection URL, the request docs, every message with its name and format (JSON and XML are pretty-printed), headers including inherited ones, and auth including inherited auth. diff --git a/packages/bruno-api-docs/e2e/pages/unsupported-request.page.ts b/packages/bruno-api-docs/e2e/pages/unsupported-request.page.ts deleted file mode 100644 index 6b74705a..00000000 --- a/packages/bruno-api-docs/e2e/pages/unsupported-request.page.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 UnsupportedRequestPage extends BasePage { - readonly sidebar = new SidebarComponent(this.page); - readonly breadcrumb = new BreadcrumbComponent(this.page, 'unsupported-request-breadcrumb'); - readonly urlBar = new RequestUrlBarComponent(this.page); - - readonly root = this.page.getByTestId('unsupported-request'); - readonly title = this.page.getByTestId('unsupported-request-title'); - readonly message = this.page.getByTestId('unsupported-request-empty'); - readonly docs = this.root.getByTestId('overview-markdown-documentation'); - - 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/pages/websocket-request.page.ts b/packages/bruno-api-docs/e2e/pages/websocket-request.page.ts new file mode 100644 index 00000000..682aa416 --- /dev/null +++ b/packages/bruno-api-docs/e2e/pages/websocket-request.page.ts @@ -0,0 +1,28 @@ +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 { GrpcMessagesComponent } from '../components/request/grpc-messages.component'; + +export class WebsocketRequestPage extends BasePage { + readonly sidebar = new SidebarComponent(this.page); + readonly breadcrumb = new BreadcrumbComponent(this.page, 'request-breadcrumb'); + readonly urlBar = new RequestUrlBarComponent(this.page); + readonly messages = new GrpcMessagesComponent(this.page, 'websocket-request-messages'); + + readonly root: Locator = this.page.getByTestId('websocket-request-page'); + readonly title: Locator = this.page.getByTestId('request-title'); + readonly description: Locator = this.page.getByTestId('request-description'); + + readonly messagesSection: Locator = this.page.getByTestId('request-section-messages'); + readonly headersSection: Locator = this.page.getByTestId('request-section-headers'); + readonly authSection: Locator = this.page.getByTestId('request-section-auth'); + readonly authInheritedBadge: Locator = this.page.getByTestId('request-auth-inherited'); + + 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 e35fdd2e..37f45326 100644 --- a/packages/bruno-api-docs/e2e/playwright/pages.fixture.ts +++ b/packages/bruno-api-docs/e2e/playwright/pages.fixture.ts @@ -5,8 +5,8 @@ import { RequestPage } from '../pages/request.page'; import { GraphqlRequestPage } from '../pages/graphql-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 { WebsocketRequestPage } from '../pages/websocket-request.page'; import { SidebarComponent } from '../components/sidebar.component'; import { TooltipComponent } from '../components/tooltip.component'; import { PlaygroundComponent } from '../components/playground.component'; @@ -27,8 +27,8 @@ type Fixtures = { graphqlRequestPage: GraphqlRequestPage; scriptPage: ScriptPage; folderPage: FolderPage; - unsupportedRequestPage: UnsupportedRequestPage; grpcRequestPage: GrpcRequestPage; + websocketRequestPage: WebsocketRequestPage; sidebar: SidebarComponent; tooltip: TooltipComponent; playground: PlaygroundComponent; @@ -62,12 +62,12 @@ export const test = base.extend({ folderPage: async ({ page }, use) => { await use(new FolderPage(page)); }, - unsupportedRequestPage: async ({ page }, use) => { - await use(new UnsupportedRequestPage(page)); - }, grpcRequestPage: async ({ page }, use) => { await use(new GrpcRequestPage(page)); }, + websocketRequestPage: async ({ page }, use) => { + await use(new WebsocketRequestPage(page)); + }, sidebar: async ({ page }, use) => { await use(new SidebarComponent(page)); }, 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 deleted file mode 100644 index 8a354cbf..00000000 --- a/packages/bruno-api-docs/e2e/tests/request/unsupported-request.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { test, expect } from '../../playwright'; - -const UNSUPPORTED_REQUESTS = [ - { paths: ['Realtime', 'Live Updates'], name: 'Live Updates', typeLabel: 'Websocket', shortName: 'WS', url: '/ws/updates' } -]; - -test.describe('Request page — unsupported request types', () => { - for (const { paths, name, typeLabel, shortName, url } of UNSUPPORTED_REQUESTS) { - test(`shows a "preview not available" notice for a ${typeLabel} request`, async ({ unsupportedRequestPage }) => { - await unsupportedRequestPage.open(paths); - - await expect(unsupportedRequestPage.title).toHaveText(name); - await expect(unsupportedRequestPage.breadcrumb.segment('Realtime')).toBeVisible(); - await expect(unsupportedRequestPage.breadcrumb.current).toHaveText(name); - await expect(unsupportedRequestPage.message).toContainText('Preview not available'); - await expect(unsupportedRequestPage.message).toContainText(`${typeLabel} documentation`); - }); - - test(`renders the protocol method and url for a ${typeLabel} request`, async ({ unsupportedRequestPage }) => { - await unsupportedRequestPage.open(paths); - - await expect(unsupportedRequestPage.urlBar.root).toBeVisible(); - await expect(unsupportedRequestPage.urlBar.method).toHaveText(shortName); - await expect(unsupportedRequestPage.urlBar.url).toContainText(url); - }); - } - - test('renders request docs on the docs page when the request provides them', async ({ unsupportedRequestPage }) => { - // Only "Live Updates" carries a `docs` field in the sample collection. - await unsupportedRequestPage.open(['Realtime', 'Live Updates']); - - await expect(unsupportedRequestPage.docs).toBeVisible(); - await expect(unsupportedRequestPage.docs).toContainText('full-duplex communication channel'); - }); -}); diff --git a/packages/bruno-api-docs/e2e/tests/request/websocket-request.spec.ts b/packages/bruno-api-docs/e2e/tests/request/websocket-request.spec.ts new file mode 100644 index 00000000..f300ff22 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/request/websocket-request.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from '../../playwright'; + +const LIVE_UPDATES = ['Realtime', 'Live Updates']; + +test.describe('Request page — WebSocket requests', () => { + test('renders the request identity without offering to run it', async ({ websocketRequestPage }) => { + await websocketRequestPage.open(LIVE_UPDATES); + + await expect(websocketRequestPage.title).toHaveText('Live Updates'); + await expect(websocketRequestPage.breadcrumb.segment('Realtime')).toBeVisible(); + await expect(websocketRequestPage.breadcrumb.current).toHaveText('Live Updates'); + await expect(websocketRequestPage.urlBar.method).toHaveText('WS'); + await expect(websocketRequestPage.urlBar.url).toContainText('/ws/updates'); + await expect(websocketRequestPage.urlBar.tryButton).toHaveCount(0); + }); + + test('renders the request docs', async ({ websocketRequestPage }) => { + await websocketRequestPage.open(LIVE_UPDATES); + + await expect(websocketRequestPage.description).toContainText('full-duplex communication channel'); + }); + + test('lists every message with its name and format, the first one open', async ({ websocketRequestPage }) => { + await websocketRequestPage.open(LIVE_UPDATES); + const { messages } = websocketRequestPage; + + await expect(websocketRequestPage.messagesSection).toContainText('5 messages'); + await expect(messages.card(0)).toContainText('Subscribe'); + await expect(messages.card(0)).toContainText('JSON'); + await expect(messages.card(1)).toContainText('Ping'); + await expect(messages.card(1)).toContainText('Text'); + + await expect(messages.toggle(0)).toHaveAttribute('aria-expanded', 'true'); + await expect(messages.toggle(1)).toHaveAttribute('aria-expanded', 'false'); + await expect(messages.code(1)).toHaveCount(0); + }); + + test('pretty-prints a JSON message stored on one line', async ({ websocketRequestPage }) => { + await websocketRequestPage.open(LIVE_UPDATES); + + // The sample stores Subscribe as a single line; each key lands on its own line once formatted. + await expect(websocketRequestPage.messages.code(0).locator('.code-line-numbers span')).toHaveCount(4); + }); + + test('opens a collapsed message on click', async ({ websocketRequestPage }) => { + await websocketRequestPage.open(LIVE_UPDATES); + const { messages } = websocketRequestPage; + + await messages.expand(1); + + await expect(messages.toggle(1)).toHaveAttribute('aria-expanded', 'true'); + await expect(messages.code(1)).toContainText('ping'); + }); + + test('shows the headers and the auth it inherits', async ({ websocketRequestPage }) => { + await websocketRequestPage.open(LIVE_UPDATES); + + await expect(websocketRequestPage.headersSection).toContainText('Accept'); + await expect(websocketRequestPage.headersSection).toContainText('X-Trace-Id'); + await expect(websocketRequestPage.authSection).toBeVisible(); + await expect(websocketRequestPage.authInheritedBadge).toBeVisible(); + }); + + test('shows only the first three messages until show more is used', async ({ websocketRequestPage }) => { + await websocketRequestPage.open(LIVE_UPDATES); + const { messages } = websocketRequestPage; + + await expect(messages.card(2)).toBeVisible(); + await expect(messages.card(3)).toHaveCount(0); + await expect(messages.showToggle).toHaveText('Show more'); + + await messages.showToggle.click(); + + await expect(messages.card(4)).toBeVisible(); + await expect(messages.showToggle).toHaveText('Show less'); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/section-nav/section-nav.spec.ts b/packages/bruno-api-docs/e2e/tests/section-nav/section-nav.spec.ts index 72013453..c498ef16 100644 --- a/packages/bruno-api-docs/e2e/tests/section-nav/section-nav.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/section-nav/section-nav.spec.ts @@ -170,11 +170,11 @@ test.describe('Section navigator (the "on this page" outline)', () => { await expect(page.getByTestId('section-nav-rail')).toBeHidden(); }); - test('appears on an unsupported-request page that has documentation headings', async ({ - unsupportedRequestPage, + test('appears on a websocket request page that has documentation headings', async ({ + websocketRequestPage, page }) => { - await unsupportedRequestPage.open(['Realtime', 'Live Updates']); + await websocketRequestPage.open(['Realtime', 'Live Updates']); await expect(page.getByTestId('section-nav-rail')).toBeVisible(); }); diff --git a/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx b/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx index f3dc9d79..60f9bc79 100644 --- a/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx +++ b/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx @@ -32,11 +32,14 @@ interface RequestPageLayoutProps { hasConfigContent: boolean; emptyConfigSubheading: string; snippetBody?: HttpRequestBody | HttpRequestBodyVariant[]; + showCodeSnippet?: boolean; + showExecutionContext?: boolean; + beforeColumns?: React.ReactNode; afterColumns?: React.ReactNode; onTryClick?: () => void; onBreadcrumbClick?: (uuid: string) => void; testId?: string; - children: React.ReactNode; + children?: React.ReactNode; } export const RequestPageLayout: React.FC = ({ @@ -46,6 +49,9 @@ export const RequestPageLayout: React.FC = ({ hasConfigContent, emptyConfigSubheading, snippetBody, + showCodeSnippet = true, + showExecutionContext = true, + beforeColumns, afterColumns, onTryClick, onBreadcrumbClick, @@ -82,6 +88,7 @@ export const RequestPageLayout: React.FC = ({ scriptFlow, hasExecutionContext } = data; + const hasTags = tags.length > 0 || inheritedTags.length > 0; const authBadge = ownAuth === 'inherit' ? ( @@ -93,6 +100,17 @@ export const RequestPageLayout: React.FC = ({ ) : null; const hasLeftColumn = showAuth || hasParams || hasConfigContent || hasHeaders || hasInheritedHeaders; + const hasRightColumn = showCodeSnippet || hasTags; + const showConfigEmptyState = !hasLeftColumn && !beforeColumns && !(showExecutionContext && hasExecutionContext); + + const configEmptyState = ( + } + heading="No request configuration" + subheading={emptyConfigSubheading} + /> + ); return ( @@ -116,110 +134,117 @@ export const RequestPageLayout: React.FC = ({ )} -
-
- {hasLeftColumn ? ( - <> - {hasParams && ( -
- + {beforeColumns &&
{beforeColumns}
} + + {hasLeftColumn || hasRightColumn ? ( +
+
+ {hasLeftColumn ? ( + <> + {hasParams && ( +
+ +
+ )} + + {children} + + {(hasHeaders || hasInheritedHeaders) && ( +
+ ) : null + } + > + +
+ )} + + {showAuth && ( +
+ +
+ )} + + ) : ( + showConfigEmptyState && configEmptyState + )} +
+ + {hasRightColumn && ( +
+ {showCodeSnippet && ( +
+
)} - - {children} - - {(hasHeaders || hasInheritedHeaders) && ( + {hasTags && (
- ) : null + inheritedTags.length > 0 ? ( + + ) : undefined } > - -
- )} - - {showAuth && ( -
- +
)} - - ) : hasExecutionContext ? null : ( - } - heading="No request configuration" - subheading={emptyConfigSubheading} - /> +
)}
- -
-
- -
- {(tags.length > 0 || inheritedTags.length > 0) && ( -
0 ? ( - - ) : undefined - } - > - -
- )} -
-
+ ) : ( + showConfigEmptyState &&
{configEmptyState}
+ )} {afterColumns} -
- {hasExecutionContext ? ( - - ) : ( - } - heading="No execution context" - subheading="This request has no scripts, variables, asserts, or tests configured." - /> - )} -
+ {showExecutionContext && ( +
+ {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/RequestPageLayout/StyledWrapper.ts b/packages/bruno-api-docs/src/components/RequestPageLayout/StyledWrapper.ts index 64f96ec3..478c5223 100644 --- a/packages/bruno-api-docs/src/components/RequestPageLayout/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/RequestPageLayout/StyledWrapper.ts @@ -17,6 +17,14 @@ export const StyledWrapper = styled.div` box-shadow: 0 0 0 1rem var(--content-bg); } + .request-before-columns { + margin-top: 1.5rem; + } + + .request-empty-fullwidth { + margin-top: 1.5rem; + } + .request-columns { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); diff --git a/packages/bruno-api-docs/src/constants/index.ts b/packages/bruno-api-docs/src/constants/index.ts index b74f3850..6ee10c6e 100644 --- a/packages/bruno-api-docs/src/constants/index.ts +++ b/packages/bruno-api-docs/src/constants/index.ts @@ -16,7 +16,8 @@ export { REQUEST_TYPE_LABELS, BODY_LANGUAGE, BODY_CONTENT_TYPE, - GRPC_METHOD_TYPE_LABELS + GRPC_METHOD_TYPE_LABELS, + WEBSOCKET_MESSAGE_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 05d96089..53217392 100644 --- a/packages/bruno-api-docs/src/constants/request.ts +++ b/packages/bruno-api-docs/src/constants/request.ts @@ -44,6 +44,12 @@ export const GRPC_METHOD_TYPE_LABELS: Record = { 'bidi-streaming': 'Bidirectional Streaming' }; +export const WEBSOCKET_MESSAGE_TYPE_LABELS: Record = { + text: 'Text', + json: 'JSON', + xml: 'XML' +}; + export const PROTOCOL_BADGE_LABELS: Record = { GRAPHQL: 'GQL', GRPC: 'GRPC', diff --git a/packages/bruno-api-docs/src/hooks/useRequestPageData.ts b/packages/bruno-api-docs/src/hooks/useRequestPageData.ts index aaf48c11..7bf36a81 100644 --- a/packages/bruno-api-docs/src/hooks/useRequestPageData.ts +++ b/packages/bruno-api-docs/src/hooks/useRequestPageData.ts @@ -14,6 +14,7 @@ import { getItemDescription, getItemTags, getInheritedTags, + isWebSocketRequest, type SupportedRequestItem } from '@/utils/schemaHelpers'; import { @@ -45,10 +46,11 @@ export const useRequestPageData = ( const url = getRequestUrl(item); const headers = getRequestHeaders(item); const params = getRequestParams(item); + const hasParamsModel = !isWebSocketRequest(item); const { path: pathParams, query: queryParams } = useMemo( - () => resolvePathAndQueryParams(params, url), - [params, url] + () => (hasParamsModel ? resolvePathAndQueryParams(params, url) : { path: [], query: [] }), + [hasParamsModel, params, url] ); const descHtml = useMemo(() => { diff --git a/packages/bruno-api-docs/src/pages/Request/Request.tsx b/packages/bruno-api-docs/src/pages/Request/Request.tsx index d2b79099..07426233 100644 --- a/packages/bruno-api-docs/src/pages/Request/Request.tsx +++ b/packages/bruno-api-docs/src/pages/Request/Request.tsx @@ -11,16 +11,14 @@ import { type RequestItem } from '@/utils/schemaHelpers'; import { getBodyView } from '@/utils/request'; -import { EyeOffIcon } from '@/assets/icons'; import { Section } from '../../components/Section/Section'; import { RequestBody } from '../../components/Request/RequestBody/RequestBody'; import { ContentTypeBadge } from '../../components/ContentTypeBadge/ContentTypeBadge'; import { Examples } from '../../components/Examples/Examples'; -import { PageWrapper } from '@/components/PageWrapper/PageWrapper'; -import { UnsupportedRequest } from '../../components/UnsupportedRequest/UnsupportedRequest'; import { useRequestPageData } from '@/hooks/useRequestPageData'; import { RequestPageLayout, NAV_GROUP, NAV_LEVEL } from '@/components/RequestPageLayout/RequestPageLayout'; import { GrpcRequest } from '@/pages/GrpcRequest/GrpcRequest'; +import { WebsocketRequest } from '@/pages/WebsocketRequest/WebsocketRequest'; interface RequestProps { item: RequestItem; @@ -113,18 +111,12 @@ export const Request: React.FC = ({ if (isWebSocketRequest(item)) { return ( - - , - heading: 'Preview not available', - subheadingSuffix: 'documentation isn\'t supported in this viewer.' - }} - /> - + ); } diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/StyledWrapper.ts b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/StyledWrapper.ts new file mode 100644 index 00000000..c0d4ab95 --- /dev/null +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/StyledWrapper.ts @@ -0,0 +1,91 @@ +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; + } + + .websocket-message-summary { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + } + + .websocket-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; + } + .websocket-message-toggle:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + border-radius: 0.25rem; + } + + .websocket-message-chevron { + flex: 0 0 auto; + color: var(--text-muted); + } + + .websocket-message-title { + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1.125rem; + color: var(--text-primary); + } + + .websocket-message-detail { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.22s ease; + } + .websocket-message-detail.is-open { + grid-template-rows: 1fr; + } + .websocket-message-detail-clip { + overflow: hidden; + min-height: 0; + } + .websocket-message-detail-body { + border-top: 1px solid var(--border-color); + } + .websocket-message-detail-body .code-content-wrapper { + border: none; + border-radius: 0; + } + .websocket-message-empty { + margin: 0; + padding: 0.75rem; + font-family: var(--font-sans); + font-weight: 500; + font-style: italic; + font-size: 0.8125rem; + line-height: 1; + color: var(--text-secondary); + } + .websocket-message-detail-body .code-content-numbered { + min-height: 2.75rem; + } + + @media (prefers-reduced-motion: reduce) { + .websocket-message-detail { + transition: none; + } + } +`; diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.spec.tsx b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.spec.tsx new file mode 100644 index 00000000..caae5456 --- /dev/null +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.spec.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { getByTestId, queryByTestId } from '@/test-utils/dom'; +import { WebsocketMessageCard } from './WebsocketMessageCard'; + +const noop = () => {}; + +describe('WebsocketMessageCard', () => { + it('shows the title and format, with the body hidden while collapsed', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'websocket-message-card-title').text).toBe('Subscribe'); + expect(getByTestId(root, 'websocket-message-card-type').text).toBe('JSON'); + expect(getByTestId(root, 'websocket-message-card-toggle').getAttribute('aria-expanded')).toBe('false'); + expect(queryByTestId(root, 'websocket-message-card-code')).toBeNull(); + }); + + it('pretty-prints a JSON message and keeps its variables', () => { + const root = useRenderToDom( + + ); + + const code = getByTestId(root, 'websocket-message-card-code').text; + expect(code).toContain('"channel": "orders",\n "token": "{{token}}"'); + }); + + it('pretty-prints an XML message', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'websocket-message-card-code').text).toContain('\n 1\n'); + }); + + it('leaves a text message untouched', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'websocket-message-card-type').text).toBe('Text'); + expect(getByTestId(root, 'websocket-message-card-code').text).toContain(' ping '); + }); + + it('says the message is empty instead of showing a blank code block', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'websocket-message-card-title').text).toBe('Close'); + expect(getByTestId(root, 'websocket-message-card-type').text).toBe('Text'); + expect(getByTestId(root, 'websocket-message-card-empty').text).toBe('Empty message'); + expect(queryByTestId(root, 'websocket-message-card-code')).toBeNull(); + }); +}); diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.tsx b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.tsx new file mode 100644 index 00000000..29e18561 --- /dev/null +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessageCard/WebsocketMessageCard.tsx @@ -0,0 +1,106 @@ +import React, { useId, useMemo, useState } from 'react'; +import type { WebSocketMessageType } from '@opencollection/types/requests/websocket'; +import cx from '@/utils/cx'; +import { prefersReducedMotion } from '@/utils/motion'; +import { prettifyJsonString, prettifyXmlString } from '@/utils/dataFormatter'; +import { BODY_LANGUAGE, WEBSOCKET_MESSAGE_TYPE_LABELS } from '@/constants'; +import { ChevronArrow } from '@/components/ChevronArrow/ChevronArrow'; +import { ContentTypeBadge } from '@/components/ContentTypeBadge/ContentTypeBadge'; +import { Code } from '@/components/Code/Code'; +import { StyledWrapper } from './StyledWrapper'; + +const prettify = (type: WebSocketMessageType, data: string): string => { + if (type === 'json') return prettifyJsonString(data); + if (type === 'xml') return prettifyXmlString(data); + return data; +}; + +interface WebsocketMessageCardProps { + title: string; + type: WebSocketMessageType; + data: string; + expanded: boolean; + onToggle: () => void; + testId?: string; +} + +export const WebsocketMessageCard: React.FC = ({ + title, + type, + data, + expanded, + onToggle, + testId = 'websocket-message-card' +}) => { + const [collapsing, setCollapsing] = useState(false); + const detailId = useId(); + const isEmpty = data.trim().length === 0; + const code = useMemo(() => (isEmpty ? '' : prettify(type, data)), [isEmpty, type, data]); + + const isOpen = expanded && !collapsing; + + const finishCollapse = () => { + setCollapsing(false); + onToggle(); + }; + + const handleToggle = () => { + if (collapsing) { + setCollapsing(false); + return; + } + if (!expanded || prefersReducedMotion()) { + onToggle(); + return; + } + setCollapsing(true); + }; + + const handleTransitionEnd = (event: React.TransitionEvent) => { + if (!collapsing) return; + if (event.propertyName !== 'grid-template-rows') return; + if (event.target !== event.currentTarget) return; + finishCollapse(); + }; + + return ( + +
+ + +
+ +
+
+ {expanded && ( +
+ {isEmpty ? ( +

Empty message

+ ) : ( + + )} +
+ )} +
+
+
+ ); +}; + +export default WebsocketMessageCard; diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessages.spec.tsx b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessages.spec.tsx new file mode 100644 index 00000000..9b7147fa --- /dev/null +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessages.spec.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { getByTestId, queryByTestId } from '@/test-utils/dom'; +import type { WebSocketMessageEntry } from '@/utils/schemaHelpers'; +import { WebsocketMessages } from './WebsocketMessages'; + +const entries = (count: number): WebSocketMessageEntry[] => + Array.from({ length: count }, (_, index) => ({ + title: `Message ${index + 1}`, + type: 'json', + data: `{"payload":"body-${index + 1}"}` + })); + +describe('WebsocketMessages', () => { + it('renders nothing when there are no messages', () => { + const root = useRenderToDom(); + expect(queryByTestId(root, 'websocket-messages')).toBeNull(); + }); + + it('opens the first message and leaves the rest closed', () => { + const root = useRenderToDom(); + expect(getByTestId(root, 'websocket-messages-card-0-code').text).toContain('body-1'); + expect(queryByTestId(root, 'websocket-messages-card-1-code')).toBeNull(); + expect(queryByTestId(root, 'websocket-messages-card-2-code')).toBeNull(); + }); + + it('shows only the first three messages and offers to show more', () => { + const root = useRenderToDom(); + expect(getByTestId(root, 'websocket-messages-card-2-title').text).toBe('Message 3'); + expect(queryByTestId(root, 'websocket-messages-card-3')).toBeNull(); + expect(getByTestId(root, 'websocket-messages-show-toggle').text).toContain('Show more'); + }); + + it('offers no show-more control when everything already fits', () => { + const root = useRenderToDom(); + expect(queryByTestId(root, 'websocket-messages-show-toggle')).toBeNull(); + }); +}); diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessages.tsx b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessages.tsx new file mode 100644 index 00000000..3f2b1589 --- /dev/null +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketMessages/WebsocketMessages.tsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import type { WebSocketMessageEntry } from '@/utils/schemaHelpers'; +import { WebsocketMessageCard } from './WebsocketMessageCard/WebsocketMessageCard'; +import { ExpandToggle } from '@/components/ExpandToggle/ExpandToggle'; + +const COLLAPSED_COUNT = 3; + +interface WebsocketMessagesProps { + messages: WebSocketMessageEntry[]; + testId?: string; +} + +export const WebsocketMessages: React.FC = ({ messages, testId = 'websocket-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 && ( + setShowAll((value) => !value)} + className="mt-3" + testId={`${testId}-show-toggle`} + /> + )} +
+ ); +}; + +export default WebsocketMessages; diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.spec.tsx b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.spec.tsx new file mode 100644 index 00000000..be21d62a --- /dev/null +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.spec.tsx @@ -0,0 +1,197 @@ +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 { WebSocketRequest } from '@opencollection/types/requests/websocket'; +import { WebsocketRequest } from './WebsocketRequest'; + +const wsItem = (data: Record): WebSocketRequest => data as unknown as WebSocketRequest; + +describe('WebsocketRequest', () => { + it('renders the request name, the WS badge and the url', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'request-title').text).toBe('Live Updates'); + expect(getByTestId(root, 'request-method').text).toBe('WS'); + expect(getByTestId(root, 'request-url').text).toContain('wss://echo.websocket.org'); + }); + + it('renders a request that has no websocket block at all', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'request-title').text).toBe('Bare Socket'); + expect(getByTestId(root, 'request-url').text).toContain('{{host}}/ws'); + }); + + it('falls back to a placeholder name and never offers a Try button', () => { + const root = useRenderToDom(); + + expect(getByTestId(root, 'request-title').text).toBe('Untitled Request'); + expect(queryByTestId(root, 'request-try-button')).toBeNull(); + }); + + it('renders the docs markdown as html', () => { + const root = useRenderToDom( + + ); + + const markdown = query(getByTestId(root, 'request-description'), '.markdown-documentation'); + expect(query(markdown, 'h1').text).toBe('Live Updates'); + expect(query(markdown, 'p').text).toBe('Streams order updates.'); + }); + + it('falls back to the description when there are no docs', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'request-description').text).toContain('Streams order updates.'); + }); + + it('lists the messages in one section with a count', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'request-section-messages').text).toContain('2 messages'); + expect(getByTestId(root, 'websocket-request-messages-card-0-title').text).toBe('Subscribe'); + expect(getByTestId(root, 'websocket-request-messages-card-1-title').text).toBe('Ping'); + expect(queryByTestId(root, 'request-config-empty')).toBeNull(); + }); + + it('lists the request headers alongside the ones it inherits', () => { + const root = useRenderToDom( + + ); + + const section = getByTestId(root, 'request-section-headers'); + expect(section.text).toContain('Accept'); + expect(section.text).toContain('X-Tenant'); + expect(section.text).toContain('1 header inherited'); + }); + + it('shows concrete auth with no inherited badge and masks the secret', () => { + const root = useRenderToDom( + + ); + + const section = getByTestId(root, 'request-section-auth'); + expect(section.text).toContain('Basic Auth'); + expect(section.text).toContain('reader'); + expect(root.text).not.toContain('s3cret'); + expect(queryByTestId(root, 'request-auth-inherited')).toBeNull(); + }); + + it('resolves inherited auth up to the collection and says where it came from', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'request-auth-inherited').text).toContain('Inherited from collection'); + expect(getByTestId(root, 'request-section-auth').text).toContain('Bearer Token'); + }); + + it('shows the tags of a request that has no other configuration', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'request-section-tags').text).toContain('realtime'); + expect(query(root, '.request-col-left').querySelector('[data-testid="request-config-empty"]')).not.toBeNull(); + }); + + it('shows an empty state when nothing is configured', () => { + const root = useRenderToDom( + + ); + + expect(query(root, '.request-empty-fullwidth').text).toContain('No request configuration'); + expect(root.querySelector('.request-columns')).toBeNull(); + expect(queryByTestId(root, 'request-section-messages')).toBeNull(); + expect(queryByTestId(root, 'request-section-headers')).toBeNull(); + expect(queryByTestId(root, 'request-section-auth')).toBeNull(); + }); + + it('has no code snippet, execution context, or params from the url query', () => { + const root = useRenderToDom( + + ); + + expect(queryByTestId(root, 'request-section-code-snippet')).toBeNull(); + expect(queryByTestId(root, 'request-section-execution-context')).toBeNull(); + expect(queryByTestId(root, 'request-section-params')).toBeNull(); + }); + + it('still shows the empty state when the request only has scripts', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'request-config-empty').text).toContain('No request configuration'); + expect(queryByTestId(root, 'request-section-execution-context')).toBeNull(); + }); + + it('draws no empty column grid under the messages when nothing else is configured', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'request-section-messages').text).toContain('1 message'); + expect(root.querySelector('.request-columns')).toBeNull(); + expect(queryByTestId(root, 'request-config-empty')).toBeNull(); + }); +}); diff --git a/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.tsx b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.tsx new file mode 100644 index 00000000..b98c954e --- /dev/null +++ b/packages/bruno-api-docs/src/pages/WebsocketRequest/WebsocketRequest.tsx @@ -0,0 +1,62 @@ +import React, { useMemo } from 'react'; +import type { OpenCollection } from '@opencollection/types'; +import type { Item } from '@opencollection/types/collection/item'; +import type { WebSocketRequest } from '@opencollection/types/requests/websocket'; +import { getWebSocketMessages } from '@/utils/schemaHelpers'; +import { PROTOCOL_BADGE_LABELS } from '@/constants'; +import { Section } from '@/components/Section/Section'; +import { ContentTypeBadge } from '@/components/ContentTypeBadge/ContentTypeBadge'; +import { useRequestPageData } from '@/hooks/useRequestPageData'; +import { RequestPageLayout, NAV_GROUP, NAV_LEVEL } from '@/components/RequestPageLayout/RequestPageLayout'; +import { WebsocketMessages } from './WebsocketMessages/WebsocketMessages'; + +const NO_ANCESTRY: Item[] = []; + +interface WebsocketRequestProps { + item: WebSocketRequest; + collection?: OpenCollection | null; + ancestry?: Item[]; + onBreadcrumbClick?: (uuid: string) => void; + testId?: string; +} + +export const WebsocketRequest: React.FC = ({ + item, + ancestry = NO_ANCESTRY, + collection, + onBreadcrumbClick, + testId = 'websocket-request-page' +}) => { + const data = useRequestPageData(collection, ancestry, item); + const messages = useMemo(() => getWebSocketMessages(item), [item]); + const hasMessages = messages.length > 0; + + const messagesSection = hasMessages ? ( +
} + > + +
+ ) : null; + + return ( + + ); +}; + +export default WebsocketRequest; diff --git a/packages/bruno-api-docs/src/sampleCollection.ts b/packages/bruno-api-docs/src/sampleCollection.ts index b9ff065e..d58da240 100644 --- a/packages/bruno-api-docs/src/sampleCollection.ts +++ b/packages/bruno-api-docs/src/sampleCollection.ts @@ -144,11 +144,42 @@ items: - name: "Realtime" type: "folder" items: - - name: "Live Updates" - description: "Streams live order updates over a WebSocket connection." - type: "websocket" - tags: ["realtime"] - url: "{{host}}/ws/updates" + - info: + name: "Live Updates" + description: "Streams live order updates over a WebSocket connection." + type: "websocket" + tags: ["realtime"] + websocket: + url: "{{host}}/ws/updates" + headers: + - name: "Accept" + value: "application/json" + - name: "X-Trace-Id" + value: "{{$randomUUID}}" + description: "Correlates the stream with server logs" + message: + - title: "Subscribe" + selected: true + message: + type: "json" + data: '{"action":"subscribe","channel":"orders"}' + - title: "Ping" + message: + type: "text" + data: "ping" + - title: "Order Snapshot" + message: + type: "xml" + data: 'orderspacked' + - title: "Unsubscribe" + message: + type: "json" + data: '{"action":"unsubscribe","channel":"orders"}' + - title: "Heartbeat" + message: + type: "text" + data: "heartbeat" + auth: inherit docs: | # Websockets WebSockets provide a persistent, full-duplex communication channel over a single, long-lived TCP connection. Unlike standard HTTP request-response cycles, they enable real-time, low-latency data transfer. diff --git a/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts b/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts index 9845d95c..8dbfeacf 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts @@ -18,6 +18,7 @@ import { getGrpcMethodType, getGrpcMetadata, getGrpcProtoFileName, + getWebSocketMessages, getItemTags, getInheritedTags, type RequestItem @@ -284,6 +285,70 @@ describe('getGrpcMessages', () => { }); }); +describe('getWebSocketMessages', () => { + it('lists a single message as "Message 1" with its format', () => { + expect(getWebSocketMessages(item({ websocket: { message: { type: 'json', data: '{"a":1}' } } }))).toEqual([ + { title: 'Message 1', type: 'json', data: '{"a":1}' } + ]); + }); + + it('keeps the title, format and order of every message in a list', () => { + expect( + getWebSocketMessages( + item({ + websocket: { + message: [ + { title: 'Subscribe', message: { type: 'json', data: '{"channel":"orders"}' } }, + { title: 'Ping', selected: true, message: { type: 'text', data: 'ping' } } + ] + } + }) + ) + ).toEqual([ + { title: 'Subscribe', type: 'json', data: '{"channel":"orders"}' }, + { title: 'Ping', type: 'text', data: 'ping' } + ]); + }); + + it('numbers untitled messages by their position', () => { + const messages = getWebSocketMessages( + item({ + websocket: { + message: [ + { title: 'First', message: { type: 'text', data: 'a' } }, + { title: '', message: { type: 'xml', data: '' } } + ] + } + }) + ); + expect(messages[1].title).toBe('Message 2'); + }); + + it('keeps a message whose payload is blank', () => { + expect( + getWebSocketMessages( + item({ + websocket: { + message: [ + { title: 'Empty', message: { type: 'text', data: ' ' } }, + { title: 'Ping', message: { type: 'text', data: 'ping' } } + ] + } + }) + ) + ).toEqual([ + { title: 'Empty', type: 'text', data: ' ' }, + { title: 'Ping', type: 'text', data: 'ping' } + ]); + }); + + it('returns no messages when the request defines none', () => { + expect(getWebSocketMessages(item({ websocket: { url: 'wss://x' } }))).toEqual([]); + expect(getWebSocketMessages(item({ type: 'websocket', url: 'wss://x' }))).toEqual([]); + expect(getWebSocketMessages(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'); diff --git a/packages/bruno-api-docs/src/utils/schemaHelpers.ts b/packages/bruno-api-docs/src/utils/schemaHelpers.ts index ddc7d190..f49df3e7 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.ts @@ -16,13 +16,13 @@ import type { Item as OpenCollectionItem, Folder, ScriptFile } from '@opencollec import type { HttpRequest, HttpRequestHeader, HttpRequestParam, HttpRequestExample, HttpRequestBody, HttpRequestBodyVariant } from '@opencollection/types/requests/http'; import type { GraphQLRequest } from '@opencollection/types/requests/graphql'; import type { GrpcRequest, GrpcRequestDetails, GrpcMetadata, GrpcMethodType } from '@opencollection/types/requests/grpc'; -import type { WebSocketRequest } from '@opencollection/types/requests/websocket'; +import type { WebSocketRequest, WebSocketMessageType } from '@opencollection/types/requests/websocket'; import type { Script, Scripts, ScriptType } from '@opencollection/types/common/scripts'; import { PROTOCOL_BADGE_LABELS } from '@/constants'; -export type SupportedRequestItem = HttpRequest | GraphQLRequest | GrpcRequest; +export type SupportedRequestItem = HttpRequest | GraphQLRequest | GrpcRequest | WebSocketRequest; -export type RequestItem = SupportedRequestItem | WebSocketRequest; +export type RequestItem = SupportedRequestItem; /** A request body as stored on an item: a single body, a list of body variants, or none. */ export type RequestBody = HttpRequestBody | HttpRequestBodyVariant[] | undefined; @@ -265,6 +265,25 @@ export const getGrpcMessages = (item: OpenCollectionItem | null | undefined): Gr return []; }; +export interface WebSocketMessageEntry { + title: string; + type: WebSocketMessageType; + data: string; +} + +export const getWebSocketMessages = (item: OpenCollectionItem | null | undefined): WebSocketMessageEntry[] => { + const payload = item && 'websocket' in item ? (item as WebSocketRequest).websocket?.message : undefined; + if (!payload) return []; + + const variants = Array.isArray(payload) ? payload : [{ title: '', message: payload }]; + + return variants.map((variant, index) => ({ + title: variant.title || `Message ${index + 1}`, + type: variant.message?.type ?? 'text', + data: variant.message?.data ?? '' + })); +}; + export const getGrpcProtoFilePath = (item: OpenCollectionItem | null | undefined): string | undefined => getGrpcDetails(item).protoFilePath || undefined;