From c68735dd9085f16608c75f70a310b541df410818 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Tue, 22 Sep 2026 19:13:12 +0530 Subject: [PATCH 1/5] feat(docs): execution context sections, post-response vars and env switcher Splits the folder and collection pages into a configuration section and a collapsible Execution Context section, shows both variable phases with "None." when one is empty, wires the playground folder Vars tab to post-response variables, and clamps long environment names with a tooltip. --- .../folder/folder-configuration.component.ts | 1 + .../collection-configuration.component.ts | 29 ++++---- .../e2e/components/playground.component.ts | 1 + .../bruno-api-docs/e2e/pages/folder.page.ts | 2 + .../bruno-api-docs/e2e/pages/overview.page.ts | 1 + .../environments/env-switcher-empty.spec.ts | 32 +++++++++ .../env-switcher-truncation.spec.ts | 52 ++++++++++++++ .../e2e/tests/folder/folder.spec.ts | 58 +++++++++++++++ .../e2e/tests/overview/overview.spec.ts | 72 +++++++++++++++---- .../playground/folder-settings-vars.spec.ts | 28 ++++++++ .../src/components/AppShell/AppShell.tsx | 2 +- .../EnvSwitcher/EnvSwitcher.spec.tsx | 59 +++++++++++---- .../components/EnvSwitcher/EnvSwitcher.tsx | 42 ++++++++--- .../components/EnvSwitcher/StyledWrapper.ts | 9 --- .../EnvironmentLabel.spec.tsx | 18 +++++ .../EnvironmentLabel/EnvironmentLabel.tsx | 14 +++- .../EnvironmentLabel/StyledWrapper.ts | 10 +++ .../VariablesPanel/VariablesPanel.spec.tsx | 12 ++-- .../VariablesPanel/VariablesPanel.tsx | 6 +- .../FolderConfiguration.spec.tsx | 34 ++++++++- .../FolderConfiguration.tsx | 38 +++++----- .../FolderConfiguration/StyledWrapper.ts | 11 +++ .../CollectionConfiguration.spec.tsx | 26 +++++++ .../CollectionConfiguration.tsx | 15 ++-- .../FolderSettingsView/FolderSettingsView.tsx | 13 +++- .../src/e2eFixtures/foldersCollection.ts | 4 +- .../src/pages/Folder/Folder.spec.tsx | 54 ++++++++++++++ .../src/pages/Folder/Folder.tsx | 57 +++++++++++---- .../src/pages/Overview/Overview.spec.tsx | 56 +++++++++++++++ .../src/pages/Overview/Overview.tsx | 65 +++++++++++------ .../src/pages/Overview/StyledWrapper.ts | 5 +- .../src/utils/collectionOverview.spec.ts | 39 +++++++++- .../src/utils/collectionOverview.ts | 13 ++-- .../bruno-api-docs/src/utils/folder.spec.ts | 58 +++++++++++++++ packages/bruno-api-docs/src/utils/folder.ts | 15 ++-- 35 files changed, 804 insertions(+), 147 deletions(-) create mode 100644 packages/bruno-api-docs/e2e/tests/environments/env-switcher-empty.spec.ts create mode 100644 packages/bruno-api-docs/e2e/tests/environments/env-switcher-truncation.spec.ts create mode 100644 packages/bruno-api-docs/e2e/tests/playground/folder-settings-vars.spec.ts diff --git a/packages/bruno-api-docs/e2e/components/folder/folder-configuration.component.ts b/packages/bruno-api-docs/e2e/components/folder/folder-configuration.component.ts index ed8c0199..6137579b 100644 --- a/packages/bruno-api-docs/e2e/components/folder/folder-configuration.component.ts +++ b/packages/bruno-api-docs/e2e/components/folder/folder-configuration.component.ts @@ -2,6 +2,7 @@ import { BaseComponent } from '../base.component'; export class FolderConfigurationComponent extends BaseComponent { readonly root = this.page.getByTestId('folder-config'); + readonly executionContext = this.page.getByTestId('folder-execution-context'); readonly headers = this.page.getByTestId('folder-config-headers'); readonly auth = this.page.getByTestId('folder-config-auth'); readonly script = this.page.getByTestId('folder-config-script'); diff --git a/packages/bruno-api-docs/e2e/components/overview/collection-configuration.component.ts b/packages/bruno-api-docs/e2e/components/overview/collection-configuration.component.ts index 1267ab31..44a90e6b 100644 --- a/packages/bruno-api-docs/e2e/components/overview/collection-configuration.component.ts +++ b/packages/bruno-api-docs/e2e/components/overview/collection-configuration.component.ts @@ -1,25 +1,26 @@ -import type { Locator } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; import { BaseComponent } from '../base.component'; import { SecretValueComponent } from '../secret-value.component'; export class ConfigurationSection extends BaseComponent { - readonly root = this.page.getByTestId('collection-config'); + readonly copyButton: Locator; + readonly secret: SecretValueComponent; + readonly disabledRows: Locator; - readonly copyButton = this.root.getByTestId('collection-config-tests-copy'); + private readonly testId: string; - readonly secret = new SecretValueComponent( - this.page, - 'collection-config-auth-token-secret' - ); - - readonly disabledRows = this.root - .getByTestId('property-value-line') - .filter({ has: this.page.getByTestId('disabled-badge') }); + constructor(page: Page, testId = 'collection-config') { + super(page, page.getByTestId(testId)); + this.testId = testId; + this.copyButton = this.root.getByTestId(`${testId}-tests-copy`); + this.secret = new SecretValueComponent(page, `${testId}-auth-token-secret`); + this.disabledRows = this.root + .getByTestId('property-value-line') + .filter({ has: page.getByTestId('disabled-badge') }); + } subHeading(name: string): Locator { - return this.root - .getByTestId('collection-config-subheading') - .filter({ hasText: name }); + return this.root.getByTestId(`${this.testId}-subheading`).filter({ hasText: name }); } async copyToClipboard(): Promise { diff --git a/packages/bruno-api-docs/e2e/components/playground.component.ts b/packages/bruno-api-docs/e2e/components/playground.component.ts index b8e5df11..969c06ea 100644 --- a/packages/bruno-api-docs/e2e/components/playground.component.ts +++ b/packages/bruno-api-docs/e2e/components/playground.component.ts @@ -12,6 +12,7 @@ import type { DockMode } from '../../src/utils/playgroundDock'; export class PlaygroundComponent extends BaseComponent { readonly keyValueTable = new KeyValueTableComponent(this.page); readonly preRequestVars = new KeyValueTableComponent(this.page, 'variables-pre-request'); + readonly postResponseVars = new KeyValueTableComponent(this.page, 'variables-post-response'); // The Auth tab lives inside the playground request pane; open it with selectTab('auth'). readonly auth = new RequestAuthComponent(this.page); readonly methodSelector = new MethodSelectorComponent(this.page); diff --git a/packages/bruno-api-docs/e2e/pages/folder.page.ts b/packages/bruno-api-docs/e2e/pages/folder.page.ts index ca31e8bf..228fc8d0 100644 --- a/packages/bruno-api-docs/e2e/pages/folder.page.ts +++ b/packages/bruno-api-docs/e2e/pages/folder.page.ts @@ -11,6 +11,8 @@ export class FolderPage extends BasePage { readonly folderMarkdownDocs = this.page.getByTestId('folder-docs'); readonly folderMarkdownDocsToggle = this.page.getByTestId('folder-docs-toggle'); readonly emptyState = this.page.getByTestId('folder-config-empty'); + readonly configurationSection = this.page.getByTestId('folder-section-configuration'); + readonly executionContextSection = this.page.getByTestId('folder-section-execution-context'); readonly sidebar = new SidebarComponent(this.page); readonly breadcrumb = new BreadcrumbComponent(this.page, 'folder-breadcrumb'); diff --git a/packages/bruno-api-docs/e2e/pages/overview.page.ts b/packages/bruno-api-docs/e2e/pages/overview.page.ts index a8919c86..ba3caeaa 100644 --- a/packages/bruno-api-docs/e2e/pages/overview.page.ts +++ b/packages/bruno-api-docs/e2e/pages/overview.page.ts @@ -14,6 +14,7 @@ export class OverviewPage extends BasePage { readonly stats = new StatsSection(this.page); readonly environments = new EnvironmentsSection(this.page); readonly configuration = new ConfigurationSection(this.page); + readonly executionContext = new ConfigurationSection(this.page, 'collection-execution-context'); readonly docMarkdown = new MarkdownComponent(this.page, this.page.getByTestId('overview-markdown-documentation')); readonly variableCard = new VariableCardComponent(this.page, this.root); diff --git a/packages/bruno-api-docs/e2e/tests/environments/env-switcher-empty.spec.ts b/packages/bruno-api-docs/e2e/tests/environments/env-switcher-empty.spec.ts new file mode 100644 index 00000000..d6406c4d --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/environments/env-switcher-empty.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '../../playwright'; + +const NO_ENVIRONMENTS = '/?fixture=descriptions'; + +test.describe('Environment switcher with no environments', () => { + test('is hidden in the docs header', async ({ page, envSwitcher }) => { + await page.goto(NO_ENVIRONMENTS); + await expect(envSwitcher.showVarsToggle).toBeVisible(); + await expect(envSwitcher.root).toHaveCount(0); + }); + + test('still shows its empty state in the playground sidebar', async ({ page, playground }) => { + await page.goto(`${NO_ENVIRONMENTS}#/?pg=1&dock=bottom`); + await playground.ensureSidebarOpen(); + await expect(playground.envSwitcher.trigger).toBeVisible(); + await expect(playground.envSwitcher.trigger).toContainText('No environments'); + }); + + test('opens no menu in the playground, and shows no caret', async ({ page, playground }) => { + await page.goto(`${NO_ENVIRONMENTS}#/?pg=1&dock=bottom`); + await playground.ensureSidebarOpen(); + const { trigger } = playground.envSwitcher; + + await expect(trigger).toHaveAttribute('aria-disabled', 'true'); + await expect(trigger.locator('.env-switcher-chevron')).toHaveCount(0); + + // Forced: Playwright sees it as disabled and would not click it otherwise. + await trigger.click({ force: true }); + await page.waitForTimeout(300); + await expect(playground.envSwitcher.menu).toHaveCount(0); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/environments/env-switcher-truncation.spec.ts b/packages/bruno-api-docs/e2e/tests/environments/env-switcher-truncation.spec.ts new file mode 100644 index 00000000..70532268 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/environments/env-switcher-truncation.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from '../../playwright'; + +const LONG_ENV = 'staging-regression-automation-eu-west-1'; +const FIXTURE = '/?fixture=folders'; + +const isClipped = (el: HTMLElement): boolean => el.scrollWidth > el.clientWidth + 1; + +test.describe('Environment switcher name truncation', () => { + test('clips a long name in the trigger and reveals it in a tooltip on hover', async ({ + page, + envSwitcher, + tooltip + }) => { + await page.goto(FIXTURE); + await envSwitcher.selectEnvironment(LONG_ENV); + + const name = envSwitcher.trigger.getByTestId('truncated-text'); + await expect(name).toHaveText(LONG_ENV); + expect(await name.evaluate(isClipped)).toBe(true); + + await name.hover(); + await expect(tooltip.popup).toBeVisible(); + await expect(tooltip.popup).toHaveText(LONG_ENV); + }); + + test('clips the same long name inside the open dropdown, revealing it via the native title', async ({ + page, + envSwitcher + }) => { + await page.goto(FIXTURE); + await envSwitcher.open(); + + const option = envSwitcher.option(LONG_ENV); + await expect(option).toHaveAttribute('title', LONG_ENV); + const name = option.locator('.environment-label-name--clamped'); + await expect(name).toHaveText(LONG_ENV); + expect(await name.evaluate(isClipped)).toBe(true); + }); + + test('shows no tooltip for a short name, which is not clipped', async ({ page, envSwitcher, tooltip }) => { + await page.goto(FIXTURE); + await envSwitcher.selectEnvironment('Dev'); + + const name = envSwitcher.trigger.getByTestId('truncated-text'); + await expect(name).toHaveText('Dev'); + expect(await name.evaluate(isClipped)).toBe(false); + + await name.hover(); + await page.waitForTimeout(400); + await expect(tooltip.popup).toHaveCount(0); + }); +}); 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 99c39221..70d8380a 100644 --- a/packages/bruno-api-docs/e2e/tests/folder/folder.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/folder/folder.spec.ts @@ -69,3 +69,61 @@ test.describe('Folder page', () => { }); }); }); + +test.describe('Folder page sections', () => { + test('separates headers and auth from the execution context', async ({ folderPage }) => { + await folderPage.open(['Realtime']); + + await expect(folderPage.configurationSection).toBeVisible(); + await expect(folderPage.configurationSection).toContainText('Folder Configuration'); + await expect(folderPage.executionContextSection).toBeVisible(); + await expect(folderPage.executionContextSection).toContainText('Execution Context'); + + await test.step('headers stay under Folder Configuration', async () => { + await expect(folderPage.configurationSection.getByTestId('folder-config-headers')).toBeVisible(); + await expect(folderPage.executionContextSection.getByTestId('folder-config-headers')).toHaveCount(0); + }); + + await test.step('variables move under Execution Context', async () => { + await expect(folderPage.executionContextSection.getByTestId('folder-config-vars')).toBeVisible(); + await expect(folderPage.configurationSection.getByTestId('folder-config-vars')).toHaveCount(0); + }); + }); +}); + +test.describe('Folder variable phases', () => { + test('shows both Pre-Request and Post-Response, marking the empty one "None."', async ({ folderPage }) => { + await folderPage.open(['Realtime']); + await expect(folderPage.configuration.vars).toContainText('Pre-Request'); + await expect(folderPage.configuration.vars).toContainText('Post-Response'); + await expect(folderPage.configuration.vars).toContainText('None.'); + }); +}); + +test.describe('Folder empty variable phase', () => { + test('frames the empty Post-Response table like the populated one beside it', async ({ folderPage }) => { + await folderPage.open(['Realtime']); + const empty = folderPage.configuration.vars.getByTestId('property-table-empty'); + await expect(empty).toBeVisible(); + await expect(empty).toHaveText('None.'); + const framing = await empty.evaluate((el) => getComputedStyle(el).boxShadow); + expect(framing).not.toBe('none'); + }); +}); + +test.describe('Folder execution context accordion', () => { + test('collapses the Execution Context section and remembers it across a reload', async ({ folderPage, page }) => { + await folderPage.open(['Realtime']); + const toggle = folderPage.executionContextSection.getByRole('button', { name: /Execution Context/i }); + + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await expect(folderPage.configuration.vars).toBeVisible(); + + await toggle.click(); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await expect(folderPage.configuration.vars).toBeHidden(); + + await page.reload(); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + }); +}); 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 aab31cee..f516d5a0 100644 --- a/packages/bruno-api-docs/e2e/tests/overview/overview.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/overview/overview.spec.ts @@ -36,7 +36,7 @@ test.describe('Collection Overview', () => { }); test.describe('Collection Configuration', () => { - test('shows the Headers, Auth, Variables, Script and Tests groups with their values', async ({ overviewPage }) => { + test('shows the Headers and Auth groups with their values', async ({ overviewPage }) => { const { configuration } = overviewPage; await expect(overviewPage.sectionLabel('Collection Configuration')).toBeVisible(); @@ -49,19 +49,65 @@ test.describe('Collection Overview', () => { await expect(configuration.subHeading('Auth')).toBeVisible(); await expect(configuration.root.getByText('Bearer Token')).toBeVisible(); }); + }); + + test('shows the Variables, Script and Tests groups under Execution Context', async ({ overviewPage }) => { + const { executionContext } = overviewPage; + await expect(overviewPage.sectionLabel('Execution Context')).toBeVisible(); await test.step('the Variables group lists the collection-level pre-request variables', async () => { - await expect(configuration.subHeading('Variables')).toBeVisible(); - await expect(configuration.root.getByText('collection_pre_var_value', { exact: true })).toBeVisible(); - await expect(configuration.root.getByText('collection-var-value', { exact: true })).toBeVisible(); + await expect(executionContext.subHeading('Variables')).toBeVisible(); + await expect(executionContext.root.getByText('collection_pre_var_value', { exact: true })).toBeVisible(); + await expect(executionContext.root.getByText('collection-var-value', { exact: true })).toBeVisible(); }); await test.step('the Script and Tests groups are present', async () => { - await expect(configuration.subHeading('Script')).toBeVisible(); - await expect(configuration.subHeading('Tests')).toBeVisible(); + await expect(executionContext.subHeading('Script')).toBeVisible(); + await expect(executionContext.subHeading('Tests')).toBeVisible(); }); }); + test('separates the two sections with a gap, so the headings do not run together', async ({ overviewPage }) => { + const configBox = (await overviewPage.sectionLabel('Collection Configuration').boundingBox())!; + const executionBox = (await overviewPage.sectionLabel('Execution Context').boundingBox())!; + expect(executionBox.y).toBeGreaterThan(configBox.y + configBox.height + 12); + }); + + test('collapses the Execution Context section and remembers it across a reload', async ({ + overviewPage, + page + }) => { + const toggle = () => overviewPage.sectionLabel('Execution Context').getByRole('button', { name: /Execution Context/i }); + await expect(toggle()).toHaveAttribute('aria-expanded', 'true'); + await expect(overviewPage.executionContext.root).toBeVisible(); + + await toggle().click(); + await expect(toggle()).toHaveAttribute('aria-expanded', 'false'); + await expect(overviewPage.executionContext.root).toBeHidden(); + + await page.reload(); + await expect(toggle()).toHaveAttribute('aria-expanded', 'false'); + }); + + test('mutes the Execution Context heading like the other overview headings', async ({ overviewPage }) => { + const colorOf = (el: HTMLElement) => getComputedStyle(el).color; + const configuration = await overviewPage + .sectionLabel('Collection Configuration') + .getByTestId('section-label') + .evaluate(colorOf); + const execution = await overviewPage + .sectionLabel('Execution Context') + .getByTestId('section-label') + .evaluate(colorOf); + expect(execution).toBe(configuration); + }); + + test('keeps headers and auth out of the Execution Context section', async ({ overviewPage }) => { + await expect(overviewPage.executionContext.subHeading('Headers')).toHaveCount(0); + await expect(overviewPage.executionContext.subHeading('Auth')).toHaveCount(0); + await expect(overviewPage.configuration.subHeading('Script')).toHaveCount(0); + }); + test('keeps the auth token masked until the reveal toggle is clicked', async ({ overviewPage }) => { const { secret } = overviewPage.configuration; @@ -78,11 +124,11 @@ test.describe('Collection Overview', () => { test('copies a config code snippet and confirms with a "Copied" label', async ({ overviewPage, context }) => { await context.grantPermissions(['clipboard-read', 'clipboard-write']); - const { configuration } = overviewPage; + const { executionContext } = overviewPage; await test.step('clicking the copy button switches its label to "Copied"', async () => { - await configuration.copyToClipboard(); - await expect(configuration.copyButton).toHaveAttribute('aria-label', 'Copied'); + await executionContext.copyToClipboard(); + await expect(executionContext.copyButton).toHaveAttribute('aria-label', 'Copied'); }); }); }); @@ -92,13 +138,13 @@ test.describe('Collection Overview', () => { page }) => { await page.setViewportSize({ width: 360, height: 800 }); - const { configuration } = overviewPage; + const { executionContext } = overviewPage; - const rows = configuration.disabledRows; + const rows = executionContext.disabledRows; const count = await rows.count(); expect(count).toBeGreaterThan(0); - const card = await boundingBoxOf(configuration.root); + const card = await boundingBoxOf(executionContext.root); for (let i = 0; i < count; i += 1) { const row = rows.nth(i); @@ -116,7 +162,7 @@ test.describe('Collection Overview', () => { } await test.step('the table does not scroll on mobile — the value truncates instead', async () => { - const info = await configuration.root + const info = await executionContext.root .locator('.property-table') .first() .evaluate((el) => ({ scrollWidth: el.scrollWidth, clientWidth: el.clientWidth })); diff --git a/packages/bruno-api-docs/e2e/tests/playground/folder-settings-vars.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/folder-settings-vars.spec.ts new file mode 100644 index 00000000..a8c53d18 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/playground/folder-settings-vars.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '../../playwright'; + +// The desktop app's folder Vars tab has both tables, and the export carries both. +test.describe('Playground folder settings, Vars tab', () => { + test.beforeEach(async ({ playground }) => { + await playground.open('bottom'); + await playground.openTreeItem(['billing']); + await playground.folderSettingsTab('variables').click(); + }); + + test('offers both a Pre Request and a Post Response table', async ({ playground }) => { + await expect(playground.preRequestVars.root).toBeVisible(); + await expect(playground.postResponseVars.root).toBeVisible(); + }); + + test('keeps an edited post-response variable across a tab switch', async ({ playground }) => { + const { postResponseVars } = playground; + const blankRowIndex = (await postResponseVars.nameInputs.count()) - 1; + await postResponseVars.nameInputs.nth(blankRowIndex).fill('sessionId'); + await postResponseVars.valueInputs.nth(blankRowIndex).fill('res.body.id'); + + await playground.folderSettingsTab('headers').click(); + await playground.folderSettingsTab('variables').click(); + + await expect(postResponseVars.nameInputs.nth(blankRowIndex)).toHaveValue('sessionId'); + await expect(postResponseVars.valueInputs.nth(blankRowIndex)).toHaveValue('res.body.id'); + }); +}); diff --git a/packages/bruno-api-docs/src/components/AppShell/AppShell.tsx b/packages/bruno-api-docs/src/components/AppShell/AppShell.tsx index 14609054..0b564c90 100644 --- a/packages/bruno-api-docs/src/components/AppShell/AppShell.tsx +++ b/packages/bruno-api-docs/src/components/AppShell/AppShell.tsx @@ -127,7 +127,7 @@ const AppShell: React.FC = ({ logo, testId = 'app-shell' }) => { envSwitcherSlot={( <> - + )} openInBrunoHref={openInBrunoHref} diff --git a/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.spec.tsx b/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.spec.tsx index 4a07b627..a7ec157b 100644 --- a/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.spec.tsx +++ b/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.spec.tsx @@ -8,8 +8,8 @@ import type { Environment } from '@opencollection/types/config/environments'; import { createOpenCollectionStore } from '@/store/store'; import { setDocsCollection } from '@/store/slices/docs'; import { setActiveEnv } from '@/store/slices/env'; -import { getByTestId } from '@/test-utils/dom'; -import EnvSwitcher from './EnvSwitcher'; +import { getByTestId, queryByTestId } from '@/test-utils/dom'; +import EnvSwitcher, { type EnvSwitcherProps } from './EnvSwitcher'; const collectionWith = (environments: Environment[]): OpenCollection => ({ config: { environments } }); @@ -18,7 +18,7 @@ const withEnvs = collectionWith([{ name: 'Dev' }, { name: 'Prod' }]); const render = ( collection: OpenCollection, configure?: (s: ReturnType) => void, - props?: { testId?: string } + props?: Partial ) => { const store = createOpenCollectionStore(); store.dispatch(setDocsCollection(collection)); @@ -63,11 +63,23 @@ describe('EnvSwitcher', () => { expect(trigger.text).not.toContain('Ghost'); }); - it('renders an empty state when there are no environments', () => { - const trigger = getByTestId(render(collectionWith([])), 'env-switcher'); + it('renders an inert empty state when there are no environments', () => { + const root = render(collectionWith([])); + const trigger = getByTestId(root, 'env-switcher'); expect(trigger.text).toContain('No environments'); expect(trigger.classList.contains('env-switcher-trigger--empty')).toBe(true); expect(trigger.getAttribute('title')).toBeFalsy(); + expect(trigger.getAttribute('aria-disabled')).toBe('true'); + expect(trigger.getAttribute('aria-haspopup')).toBeFalsy(); + expect(root.querySelector('.env-switcher-chevron')).toBeFalsy(); + }); + + it('keeps the caret and the menu affordance when there are environments', () => { + const root = render(withEnvs, (s) => s.dispatch(setActiveEnv('Dev'))); + const trigger = getByTestId(root, 'env-switcher'); + expect(trigger.getAttribute('aria-haspopup')).toBe('menu'); + expect(trigger.getAttribute('aria-disabled')).toBeFalsy(); + expect(root.querySelector('.env-switcher-chevron')).toBeTruthy(); }); it('carries the empty modifier only when there are no environments', () => { @@ -84,16 +96,12 @@ describe('EnvSwitcher', () => { expect(dot?.classList.contains('environment-label-dot--empty')).toBe(false); }); - it('exposes the full env name in the trigger title (truncation is CSS)', () => { + it('renders the full env name in the trigger, clamped for the tooltip to reveal', () => { const long = 'development-staging-area-testing-qa-automation-local-setup-1'; - const trigger = getByTestId( - render(collectionWith([{ name: long }]), (s) => s.dispatch(setActiveEnv(long))), - 'env-switcher' - ); - // The name renders in full in the DOM; the trigger button carries the full - // name as its title (CSS clamps the visible width with an ellipsis). + const root = render(collectionWith([{ name: long }]), (s) => s.dispatch(setActiveEnv(long))); + const trigger = getByTestId(root, 'env-switcher'); expect(trigger.text).toContain(long); - expect(trigger.getAttribute('title')).toBe(long); + expect(getByTestId(root, 'truncated-text').classList.contains('environment-label-name--clamped')).toBe(true); }); it('derives the root and trigger test ids from a custom testId', () => { @@ -102,3 +110,28 @@ describe('EnvSwitcher', () => { expect(getByTestId(root, 'playground-env-switcher')).toBeTruthy(); }); }); + +describe('EnvSwitcher hideWhenEmpty', () => { + it('renders nothing when the collection has no environments', () => { + const root = render(collectionWith([]), undefined, { hideWhenEmpty: true }); + expect(queryByTestId(root, 'env-switcher-root')).toBeNull(); + }); + + it('still renders the switcher when the collection has environments', () => { + const root = render(withEnvs, undefined, { hideWhenEmpty: true }); + expect(getByTestId(root, 'env-switcher').text).toContain('Dev'); + }); + + it('keeps the empty state by default, for the playground sidebar', () => { + const trigger = getByTestId(render(collectionWith([])), 'env-switcher'); + expect(trigger.text).toContain('No environments'); + }); +}); + +describe('EnvSwitcher name truncation', () => { + it('drops the native title in favour of the styled tooltip anchor', () => { + const root = render(withEnvs, (s) => s.dispatch(setActiveEnv('Dev'))); + expect(getByTestId(root, 'env-switcher').getAttribute('title')).toBeFalsy(); + expect(queryByTestId(root, 'truncated-text')).toBeTruthy(); + }); +}); diff --git a/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.tsx b/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.tsx index b783fe0d..7e2d8789 100644 --- a/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.tsx +++ b/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.tsx @@ -9,6 +9,7 @@ import MenuDropdown, { type MenuDropdownItem } from '@/ui/MenuDropdown'; import { StyledWrapper } from './StyledWrapper'; export interface EnvSwitcherProps { + hideWhenEmpty?: boolean; testId?: string; } @@ -22,7 +23,7 @@ export interface EnvSwitcherProps { * or dismissal logic of its own. Tippy's `--z-popover` surface keeps the menu * above every playground dock. */ -const EnvSwitcher: React.FC = ({ testId = 'env-switcher' }) => { +const EnvSwitcher: React.FC = ({ hideWhenEmpty = false, testId = 'env-switcher' }) => { const dispatch = useAppDispatch(); const collection = useAppSelector(selectDocsCollection); const activeEnvName = useAppSelector(selectActiveEnvName); @@ -47,21 +48,45 @@ const EnvSwitcher: React.FC = ({ testId = 'env-switcher' }) => } }, [activeEnv, activeEnvName, dispatch]); + if (hideWhenEmpty && !hasEnvironments) return null; + const items: MenuDropdownItem[] = hasEnvironments ? environments.map((environment) => ({ id: environment.name, - label: , + label: ( + + ), ariaLabel: environment.name, title: environment.name, onClick: () => dispatch(setActiveEnv(environment.name)) })) - : [{ id: 'no-environments', label: 'No environments', disabled: true }]; + : []; + + if (!hasEnvironments) { + return ( + + + + ); + } return ( = ({ testId = 'env-switcher' }) => > + ); } diff --git a/packages/bruno-api-docs/src/components/EnvSwitcher/StyledWrapper.ts b/packages/bruno-api-docs/src/components/EnvSwitcher/StyledWrapper.ts index 94745680..a09a992b 100644 --- a/packages/bruno-api-docs/src/components/EnvSwitcher/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/EnvSwitcher/StyledWrapper.ts @@ -28,9 +28,12 @@ export const StyledWrapper = styled.div` background: var(--oc-app-collection-toolbar-environment-selector-hover-bg); } - .env-switcher-trigger--empty { + .env-switcher-trigger--empty, + .env-switcher-trigger--empty:hover { color: var(--oc-app-collection-toolbar-environment-selector-no-environment-text); background: var(--oc-app-collection-toolbar-environment-selector-no-environment-bg); + border-color: var(--oc-border-border1); + cursor: default; } .env-switcher-chevron { diff --git a/packages/bruno-api-docs/src/components/FolderConfiguration/FolderConfiguration.spec.tsx b/packages/bruno-api-docs/src/components/FolderConfiguration/FolderConfiguration.spec.tsx index c799c621..75c53533 100644 --- a/packages/bruno-api-docs/src/components/FolderConfiguration/FolderConfiguration.spec.tsx +++ b/packages/bruno-api-docs/src/components/FolderConfiguration/FolderConfiguration.spec.tsx @@ -28,15 +28,14 @@ describe('FolderConfiguration', () => { headers: [{ name: 'Accept', value: 'application/json' }], preRequest: 'console.log(1)' }; - const root = useRenderToDom(); - - expect(queryByTestId(root, 'folder-config-headers')).not.toBeNull(); - expect(queryByTestId(root, 'folder-config-script')).not.toBeNull(); - expect(query(root, '[data-testid="folder-config-headers"] .property-key').text.trim()).toBe('Accept'); - - expect(queryByTestId(root, 'folder-config-auth')).toBeNull(); - expect(queryByTestId(root, 'folder-config-vars')).toBeNull(); - expect(queryByTestId(root, 'folder-config-tests')).toBeNull(); + const requestRoot = useRenderToDom(); + expect(query(requestRoot, '[data-testid="folder-config-headers"] .property-key').text.trim()).toBe('Accept'); + expect(queryByTestId(requestRoot, 'folder-config-auth')).toBeNull(); + + const executionRoot = useRenderToDom(); + expect(queryByTestId(executionRoot, 'folder-config-script')).not.toBeNull(); + expect(queryByTestId(executionRoot, 'folder-config-vars')).toBeNull(); + expect(queryByTestId(executionRoot, 'folder-config-tests')).toBeNull(); }); it('labels the Auth group with an "Inherited from collection" badge when auth is inherited', () => { @@ -45,7 +44,7 @@ describe('FolderConfiguration', () => { auth: { type: 'bearer', token: 't' } as any, authSource: { level: 'collection', name: 'API', uuid: '__collection_root__' } }; - const root = useRenderToDom(); + const root = useRenderToDom(); const authGroup = getByTestId(root, 'folder-config-auth'); expect(query(authGroup, '.config-group-head').text).toContain('Inherited from collection: API'); @@ -57,7 +56,7 @@ describe('FolderConfiguration', () => { auth: { type: 'bearer', token: 't' } as any, authSource: { level: 'folder', name: 'Parent', uuid: 'parent-uid' } }; - const root = useRenderToDom( {}} />); + const root = useRenderToDom( {}} />); const chip = getByTestId(root, 'folder-config-auth-inherited'); expect(chip.text).toContain('Inherited from folder: Parent'); expect(chip.getAttribute('role')).toBe('button'); @@ -73,7 +72,7 @@ describe('FolderConfiguration', () => { { name: 'Authorization', value: 'Bearer x', source: folderSource } ] }; - const root = useRenderToDom( {}} />); + const root = useRenderToDom( {}} />); const headers = getByTestId(root, 'folder-config-headers'); expect(headers.text).toContain('Accept'); // own expect(headers.text).toContain('X-Api-Version'); // inherited @@ -87,7 +86,7 @@ describe('FolderConfiguration', () => { ...baseConfig, inheritedHeaders: [{ name: 'X-Api-Version', value: 'v2', source: folderSource }] }; - const root = useRenderToDom( {}} />); + const root = useRenderToDom( {}} />); const headers = getByTestId(root, 'folder-config-headers'); expect(headers.text).toContain('X-Api-Version'); expect(headers.text).toContain('1 header inherited'); @@ -100,7 +99,7 @@ describe('FolderConfiguration', () => { inheritedPreVariables: [{ name: 'baseUrl', value: '{{host}}', source: folderSource }], inheritedPostVariables: [{ name: 'sessionId', expression: 'res.body.id', source: folderSource }] }; - const root = useRenderToDom( {}} />); + const root = useRenderToDom( {}} />); const vars = getByTestId(root, 'folder-config-vars'); expect(vars.text).toContain('ownPre'); expect(vars.text).toContain('baseUrl'); @@ -116,7 +115,7 @@ describe('FolderConfiguration', () => { postResponse: 'post()', tests: 'test()' }; - const root = useRenderToDom(); + const root = useRenderToDom(); const phases = getByTestId(root, 'folder-config-script') .querySelectorAll('.config-phase-label') @@ -130,7 +129,7 @@ describe('FolderConfiguration', () => { ...baseConfig, headers: [{ name: 'X-Debug', value: 'on', disabled: true, description: 'toggles debug logging' }] }; - const root = useRenderToDom(); + const root = useRenderToDom(); const headers = getByTestId(root, 'folder-config-headers'); expect(query(headers, '.property-key').text.trim()).toBe('X-Debug'); @@ -144,7 +143,7 @@ describe('FolderConfiguration', () => { variables: [{ name: 'baseUrl', value: 'https://api.example.com' }], postVariables: [{ name: 'token', expression: 'res.body.token', scope: 'runtime' }] }; - const root = useRenderToDom(); + const root = useRenderToDom(); const vars = getByTestId(root, 'folder-config-vars'); const phases = vars.querySelectorAll('.config-phase-label').map((el) => el.text.trim()); @@ -159,7 +158,7 @@ describe('FolderConfiguration', () => { ...baseConfig, postVariables: [{ name: 'sessionId', expression: 'res.body.id', scope: 'runtime' }] }; - const root = useRenderToDom(); + const root = useRenderToDom(); const vars = getByTestId(root, 'folder-config-vars'); const phases = vars.querySelectorAll('.config-phase-label').map((el) => el.text.trim()); diff --git a/packages/bruno-api-docs/src/components/FolderConfiguration/FolderConfiguration.tsx b/packages/bruno-api-docs/src/components/FolderConfiguration/FolderConfiguration.tsx index 7e9f1ed8..56f5877a 100644 --- a/packages/bruno-api-docs/src/components/FolderConfiguration/FolderConfiguration.tsx +++ b/packages/bruno-api-docs/src/components/FolderConfiguration/FolderConfiguration.tsx @@ -14,7 +14,7 @@ interface FolderConfigurationProps { config: FolderConfig; authModeLabels?: Record; onNavigate?: (uuid: string) => void; - sectionType?: 'request' | 'execution'; + sectionType: 'request' | 'execution'; testId?: string; } @@ -25,8 +25,8 @@ export const FolderConfiguration: React.FC = ({ sectionType, testId }) => { - const showRequestGroups = sectionType !== 'execution'; - const showExecutionGroups = sectionType !== 'request'; + const showRequestGroups = sectionType === 'request'; + const showExecutionGroups = sectionType === 'execution'; const hasInheritedHeaders = config.inheritedHeaders.length > 0; const hasHeaders = showRequestGroups && (config.headers.length > 0 || hasInheritedHeaders); const hasAuth = showRequestGroups && Boolean(config.auth); diff --git a/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.spec.tsx b/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.spec.tsx index 670d30db..9d40519f 100644 --- a/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.spec.tsx +++ b/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.spec.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; import { describe, it, expect } from 'vitest'; import type { HttpRequestHeader } from '@opencollection/types/requests/http'; import { CollectionConfiguration } from './CollectionConfiguration'; @@ -11,67 +10,84 @@ import type { PreRequestVarRow } from '@/utils/request'; describe('CollectionConfiguration', () => { it('renders nothing when there is no configuration', () => { - const html = renderToStaticMarkup(); - expect(html).toBe(''); + expect(useRenderToDom().toString()).toBe(''); + expect(useRenderToDom().toString()).toBe(''); }); - it('renders enabled headers, masks secret auth values and shows script/test code', () => { + it('renders enabled and disabled headers and masks secret auth values', () => { const headers: HttpRequestHeader[] = [ { name: 'Accept', value: 'application/json' }, { name: 'X-Disabled', value: 'nope', disabled: true } ]; - const html = renderToStaticMarkup( + const root = useRenderToDom( {})' }} authModeLabels={AUTH_MODE_LABELS} + sectionType="request" /> ); - expect(html).toContain('Accept'); - expect(html).toContain('application/json'); - expect(html).toContain('X-Disabled'); - expect(html).toContain('disabled-badge'); + const headersTable = getByTestId(root, 'collection-config-headers'); + expect(headersTable.text).toContain('Accept'); + expect(headersTable.text).toContain('application/json'); + expect(headersTable.text).toContain('X-Disabled'); + expect(queryByTestId(headersTable, 'disabled-badge')).not.toBeNull(); + + const auth = getByTestId(root, 'collection-config-auth'); + expect(auth.text).toContain('Basic Auth'); + expect(auth.text).toContain('user@example.com'); + expect(auth.text).not.toContain('s3cr3t'); + }); - // Auth mode resolved via the supplied labels; username shown, password masked - expect(html).toContain('Basic Auth'); - expect(html).toContain('user@example.com'); - expect(html).not.toContain('s3cr3t'); + it('renders the script and test code', () => { + const root = useRenderToDom( + {})' }} + sectionType="execution" + /> + ); - // Script and test sections render - expect(html).toContain('Pre-Request'); - expect(html).toContain('Tests'); + expect(getByTestId(root, 'collection-config-script-subheading').text.trim()).toBe('Script'); + expect(root.text).toContain('Pre-Request'); + expect(getByTestId(root, 'collection-config-tests').text).toContain('test("ok"'); }); - it('renders collection variables (pre-request + post-response) and header descriptions', () => { - const html = renderToStaticMarkup( + it('renders header descriptions', () => { + const root = useRenderToDom( + ); + expect(getByTestId(root, 'collection-config-headers').text).toContain('content negotiation'); + }); + + it('renders collection variables (pre-request + post-response)', () => { + const root = useRenderToDom( + ); - expect(html).toContain('Variables'); - expect(html).toContain('baseUrl'); - expect(html).toContain('https://api.example.com'); - expect(html).toContain('token'); - expect(html).toContain('res.body.token'); - expect(html).toContain('content negotiation'); + expect(getByTestId(root, 'collection-config-vars-subheading').text.trim()).toBe('Variables'); + expect(root.text).toContain('baseUrl'); + expect(root.text).toContain('https://api.example.com'); + expect(root.text).toContain('token'); + expect(root.text).toContain('res.body.token'); }); it('falls back to the raw auth type when no label is supplied', () => { - const html = renderToStaticMarkup(); - expect(html).toContain('bearer'); + const root = useRenderToDom(); + expect(getByTestId(root, 'collection-config-auth').text).toContain('bearer'); }); it('omits the subsections that have no content', () => { - const html = renderToStaticMarkup(); - expect(html).toContain('Auth'); - expect(html).not.toContain('Headers'); - expect(html).not.toContain('Script'); - expect(html).not.toContain('Tests'); + const root = useRenderToDom(); + expect(getByTestId(root, 'collection-config-auth-subheading').text.trim()).toBe('Auth'); + expect(queryByTestId(root, 'collection-config-headers-subheading')).toBeNull(); }); }); diff --git a/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx b/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx index 7866dc81..5ca03333 100644 --- a/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx +++ b/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx @@ -23,7 +23,7 @@ interface CollectionConfigurationProps { preVars?: PreRequestVarRow[]; postVars?: PostResponseVarRow[]; authModeLabels?: Record; - sectionType?: 'request' | 'execution'; + sectionType: 'request' | 'execution'; testId?: string; } @@ -37,8 +37,8 @@ export const CollectionConfiguration: React.FC = ( sectionType, testId = 'collection-config' }) => { - const showRequestGroups = sectionType !== 'execution'; - const showExecutionGroups = sectionType !== 'request'; + const showRequestGroups = sectionType === 'request'; + const showExecutionGroups = sectionType === 'execution'; const headerRows: PropertyRow[] = headers .filter((header) => header && header.name) .map((header) => ({ diff --git a/packages/bruno-api-docs/src/utils/collectionOverview.spec.ts b/packages/bruno-api-docs/src/utils/collectionOverview.spec.ts index 3f0dd688..b48b5f6a 100644 --- a/packages/bruno-api-docs/src/utils/collectionOverview.spec.ts +++ b/packages/bruno-api-docs/src/utils/collectionOverview.spec.ts @@ -1,14 +1,24 @@ import { describe, it, expect } from 'vitest'; import type { OpenCollection } from '@opencollection/types'; import type { Item as OpenCollectionItem } from '@opencollection/types/collection/item'; +import type { HttpRequestHeader } from '@opencollection/types/requests/http'; +import type { Auth } from '@opencollection/types/common/auth'; import { countItems, getCollectionStats, - hasCollectionConfiguration, hasCollectionExecutionContext, - hasCollectionRequestConfig + hasCollectionRequestConfig, + type CollectionScripts } from './collectionOverview'; +const hasCollectionConfiguration = ( + headers: HttpRequestHeader[] = [], + auth?: Auth, + scripts: CollectionScripts = {}, + hasVars = false +): boolean => + hasCollectionRequestConfig(headers, auth) || hasCollectionExecutionContext(scripts, hasVars); + describe('countItems', () => { it('counts requests and folders recursively at every depth', () => { const items = [ diff --git a/packages/bruno-api-docs/src/utils/collectionOverview.ts b/packages/bruno-api-docs/src/utils/collectionOverview.ts index 93207caf..fb1c089f 100644 --- a/packages/bruno-api-docs/src/utils/collectionOverview.ts +++ b/packages/bruno-api-docs/src/utils/collectionOverview.ts @@ -55,11 +55,3 @@ export const hasCollectionRequestConfig = (headers: HttpRequestHeader[] = [], au export const hasCollectionExecutionContext = (scripts: CollectionScripts = {}, hasVars = false): boolean => hasVars || Boolean(scripts.preRequest || scripts.postResponse || scripts.tests); - -export const hasCollectionConfiguration = ( - headers: HttpRequestHeader[] = [], - auth?: Auth, - scripts: CollectionScripts = {}, - hasVars = false -): boolean => - hasCollectionRequestConfig(headers, auth) || hasCollectionExecutionContext(scripts, hasVars); diff --git a/packages/bruno-api-docs/src/utils/folder.spec.ts b/packages/bruno-api-docs/src/utils/folder.spec.ts index 5c4de8b6..c9bd79d7 100644 --- a/packages/bruno-api-docs/src/utils/folder.spec.ts +++ b/packages/bruno-api-docs/src/utils/folder.spec.ts @@ -1,7 +1,8 @@ import { describe, it, expect } from 'vitest'; +import type { OpenCollection } from '@opencollection/types'; +import type { Folder } from '@opencollection/types/collection/item'; import { getFolderConfig, - hasFolderConfig, hasFolderExecutionContext, hasFolderRequestConfig, countFolderRequests, @@ -104,29 +105,6 @@ describe('getFolderConfig', () => { }); }); -describe('hasFolderConfig', () => { - it('is false for a folder with no configuration', () => { - const folder: any = { items: [{ info: { type: 'http' } }] }; - expect(hasFolderConfig(getFolderConfig(null, [], folder))).toBe(false); - }); - - it('is true when any configuration is present', () => { - const folder: any = { request: { scripts: [{ type: 'before-request', code: 'x' }] } }; - expect(hasFolderConfig(getFolderConfig(null, [], folder))).toBe(true); - }); - - it('is true when only post-response (actions) vars are present', () => { - const folder: any = { - request: { - actions: [ - { type: 'set-variable', phase: 'after-response', selector: { expression: 'x' }, variable: { name: 'v' } } - ] - } - }; - expect(hasFolderConfig(getFolderConfig(null, [], folder))).toBe(true); - }); -}); - describe('countFolderRequests', () => { it('counts request items recursively, excluding folders and scripts', () => { const folder: any = { @@ -159,56 +137,56 @@ describe('requestCountLabel', () => { describe('hasFolderRequestConfig', () => { it('is true for own headers', () => { - const folder: any = { request: { headers: [{ name: 'Accept', value: 'json' }] } }; + const folder = { request: { headers: [{ name: 'Accept', value: 'json' }] } } as unknown as Folder; expect(hasFolderRequestConfig(getFolderConfig(null, [], folder))).toBe(true); }); it('is true for headers inherited from the collection', () => { - const parent: any = { request: { headers: [{ name: 'X-Api', value: 'v2' }] } }; - const folder: any = {}; + const parent = { request: { headers: [{ name: 'X-Api', value: 'v2' }] } } as unknown as OpenCollection; + const folder = {} as unknown as Folder; expect(hasFolderRequestConfig(getFolderConfig(parent, [], folder))).toBe(true); }); it('is true for concrete auth', () => { - const folder: any = { request: { auth: { type: 'basic', username: 'u' } } }; + const folder = { request: { auth: { type: 'basic', username: 'u' } } } as unknown as Folder; expect(hasFolderRequestConfig(getFolderConfig(null, [], folder))).toBe(true); }); it('is false when the folder only has vars, scripts or tests', () => { - const folder: any = { request: { scripts: [{ type: 'before-request', code: 'x' }] } }; + const folder = { request: { scripts: [{ type: 'before-request', code: 'x' }] } } as unknown as Folder; expect(hasFolderRequestConfig(getFolderConfig(null, [], folder))).toBe(false); }); }); describe('hasFolderExecutionContext', () => { it('is true for pre-request variables', () => { - const folder: any = { request: { variables: [{ name: 'v', value: '1' }] } }; + const folder = { request: { variables: [{ name: 'v', value: '1' }] } } as unknown as Folder; expect(hasFolderExecutionContext(getFolderConfig(null, [], folder))).toBe(true); }); it('is true for post-response variables', () => { - const folder: any = { + const folder = { request: { actions: [ { type: 'set-variable', phase: 'after-response', selector: { expression: 'x' }, variable: { name: 'v' } } ] } - }; + } as unknown as Folder; expect(hasFolderExecutionContext(getFolderConfig(null, [], folder))).toBe(true); }); it('is true for a script', () => { - const folder: any = { request: { scripts: [{ type: 'before-request', code: 'x' }] } }; + const folder = { request: { scripts: [{ type: 'before-request', code: 'x' }] } } as unknown as Folder; expect(hasFolderExecutionContext(getFolderConfig(null, [], folder))).toBe(true); }); it('is true for tests', () => { - const folder: any = { request: { scripts: [{ type: 'tests', code: 'expect(1).to.equal(1)' }] } }; + const folder = { request: { scripts: [{ type: 'tests', code: 'expect(1).to.equal(1)' }] } } as unknown as Folder; expect(hasFolderExecutionContext(getFolderConfig(null, [], folder))).toBe(true); }); it('is false when the folder only has headers and auth', () => { - const folder: any = { request: { headers: [{ name: 'Accept', value: 'json' }], auth: { type: 'basic' } } }; + const folder = { request: { headers: [{ name: 'Accept', value: 'json' }], auth: { type: 'basic' } } } as unknown as Folder; expect(hasFolderExecutionContext(getFolderConfig(null, [], folder))).toBe(false); }); }); diff --git a/packages/bruno-api-docs/src/utils/folder.ts b/packages/bruno-api-docs/src/utils/folder.ts index 7bce8754..97678be1 100644 --- a/packages/bruno-api-docs/src/utils/folder.ts +++ b/packages/bruno-api-docs/src/utils/folder.ts @@ -128,9 +128,6 @@ export const hasFolderExecutionContext = (config: FolderConfig): boolean => || config.inheritedPreVariables.length > 0 || config.inheritedPostVariables.length > 0; -export const hasFolderConfig = (config: FolderConfig): boolean => - hasFolderRequestConfig(config) || hasFolderExecutionContext(config); - export const countFolderRequests = (folder: Folder): number => { let count = 0; const walk = (items: Item[] | undefined): void => { From fd9c07ce598f278a2bbcfc90a0b53f6a2d7bf976 Mon Sep 17 00:00:00 2001 From: Arpit Date: Thu, 24 Sep 2026 22:23:19 +0530 Subject: [PATCH 5/5] Enhance folder and collection pages with new features --- .changeset/lazy-tables-search.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/lazy-tables-search.md diff --git a/.changeset/lazy-tables-search.md b/.changeset/lazy-tables-search.md new file mode 100644 index 00000000..afdbab3c --- /dev/null +++ b/.changeset/lazy-tables-search.md @@ -0,0 +1,14 @@ +--- +"@usebruno/api-docs": patch +--- + +Bugfixes: +19. The folder and collection pages now have an Execution Context section, separate from the configuration section. Headers and auth stay under Folder Configuration and Collection Configuration; vars, script and tests move into Execution Context, which collapses and expands like the one on request pages. The environment switcher is hidden from the docs header when the collection has no environments. + +25. A long environment name is cut short with an ellipsis in the switcher and in its dropdown, and the full name shows in a tooltip when it is cut. + +32. Empty Post-Response variables now read "None." on the folder and collection pages, the same as on request pages, instead of the section disappearing. + +33. The playground folder settings Vars tab now has a Post Response table, like the Bruno app. Edits are saved with the folder. + +Also, when a collection has no environments, the playground switcher no longer opens a dropdown whose only entry repeats "No environments". It shows the text with no arrow.