-
-
- Interactive API Documentation
-
-
- Generate a standalone HTML file that can be hosted anywhere or shared with your team.
-
-
-
-
Sample Output
-

+
+
+
+ {isLoading ? (
+
+
+ Loading collection...
+ ) : (
+
+
+
+ Interactive API Documentation
+
+
+ Generate a standalone HTML file that can be hosted anywhere or shared with your team.
+
-
- {FEATURES.map((feature) => (
- -
-
- {feature}
-
- ))}
-
+
+ {FEATURES.map((feature) => (
+ -
+
+ {feature}
+
+ ))}
+
-
- The generated file loads OpenCollection's JavaScript and CSS files from a CDN, which requires an internet connection.
-
-
- )}
-
-
+
+
+ {environments.length > 0 && (
+
+
+
+
+
+
+ )}
+
+
+
+ The generated file loads OpenCollection's JavaScript and CSS files from a CDN, which requires an internet connection.
+
+
+ )}
+
+
+
);
};
diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js
index 8107923d4..8599b3ba1 100644
--- a/packages/bruno-app/src/utils/collections/index.js
+++ b/packages/bruno-app/src/utils/collections/index.js
@@ -888,6 +888,21 @@ export const isItemAFolder = (item) => {
return !item.hasOwnProperty('request') && item.type === 'folder';
};
+/**
+ * Counts the folders and requests in a collection's item tree, recursively at every
+ * depth. Used to summarise a collection (e.g. in the Generate Documentation modal).
+ *
+ * @param {Array} items - The collection's `items` tree.
+ * @returns {{ folderCount: number, requestCount: number }}
+ */
+export const getCollectionItemCounts = (items = []) => {
+ const flattened = flattenItems(items);
+ return {
+ folderCount: flattened.filter(isItemAFolder).length,
+ requestCount: flattened.filter(isItemARequest).length
+ };
+};
+
/**
* Orders a list of collection items exactly the way the Sidebar tree renders them:
* folders first (via `sortByNameThenSequence`), then requests ordered by `seq`. The
diff --git a/packages/bruno-app/src/utils/collections/index.spec.js b/packages/bruno-app/src/utils/collections/index.spec.js
index 7297a43b5..e9c7f9acf 100644
--- a/packages/bruno-app/src/utils/collections/index.spec.js
+++ b/packages/bruno-app/src/utils/collections/index.spec.js
@@ -1,5 +1,5 @@
const { describe, it, expect } = require('@jest/globals');
-import { mergeHeaders, transformRequestToSaveToFilesystem } from './index';
+import { mergeHeaders, transformRequestToSaveToFilesystem, getCollectionItemCounts } from './index';
describe('mergeHeaders', () => {
it('should include headers from collection, folder and request (with correct precedence)', () => {
@@ -86,3 +86,49 @@ describe('transformRequestToSaveToFilesystem', () => {
expect(transformed.request.headers[0].annotations).toEqual([{ name: 'header-note', value: 'keep me' }]);
});
});
+
+describe('getCollectionItemCounts', () => {
+ it('counts folders and requests recursively at every depth', () => {
+ const items = [
+ {
+ type: 'folder',
+ name: 'Zoo',
+ items: [
+ { type: 'http-request', name: 'Lion', request: {} },
+ { type: 'graphql-request', name: 'Bear', request: {} }
+ ]
+ },
+ {
+ type: 'folder',
+ name: 'Aviary',
+ items: [
+ {
+ type: 'folder',
+ name: 'Nest',
+ items: [{ type: 'http-request', name: 'Egg', request: {} }]
+ }
+ ]
+ },
+ { type: 'http-request', name: 'RootReq', request: {} }
+ ];
+
+ // Folders: Zoo, Aviary, Nest -> 3. Requests: Lion, Bear, Egg, RootReq -> 4.
+ expect(getCollectionItemCounts(items)).toEqual({ folderCount: 3, requestCount: 4 });
+ });
+
+ it('counts every request transport type', () => {
+ const items = [
+ { type: 'http-request', request: {} },
+ { type: 'graphql-request', request: {} },
+ { type: 'grpc-request', request: {} },
+ { type: 'ws-request', request: {} }
+ ];
+
+ expect(getCollectionItemCounts(items)).toEqual({ folderCount: 0, requestCount: 4 });
+ });
+
+ it('returns zero counts for empty or missing items', () => {
+ expect(getCollectionItemCounts([])).toEqual({ folderCount: 0, requestCount: 0 });
+ expect(getCollectionItemCounts(undefined)).toEqual({ folderCount: 0, requestCount: 0 });
+ });
+});
diff --git a/tests/collection/generate-docs/fixtures/collection/environments/Development.bru b/tests/collection/generate-docs/fixtures/collection/environments/Development.bru
new file mode 100644
index 000000000..0bbe84595
--- /dev/null
+++ b/tests/collection/generate-docs/fixtures/collection/environments/Development.bru
@@ -0,0 +1,3 @@
+vars {
+ baseUrl: https://dev.example.com
+}
diff --git a/tests/collection/generate-docs/fixtures/collection/environments/Production.bru b/tests/collection/generate-docs/fixtures/collection/environments/Production.bru
new file mode 100644
index 000000000..bf8f3a1b8
--- /dev/null
+++ b/tests/collection/generate-docs/fixtures/collection/environments/Production.bru
@@ -0,0 +1,3 @@
+vars {
+ baseUrl: https://api.example.com
+}
diff --git a/tests/collection/generate-docs/fixtures/collection/environments/Staging.bru b/tests/collection/generate-docs/fixtures/collection/environments/Staging.bru
new file mode 100644
index 000000000..b84af6332
--- /dev/null
+++ b/tests/collection/generate-docs/fixtures/collection/environments/Staging.bru
@@ -0,0 +1,3 @@
+vars {
+ baseUrl: https://staging.example.com
+}
diff --git a/tests/collection/generate-docs/generate-docs.spec.ts b/tests/collection/generate-docs/generate-docs.spec.ts
index 75de96a3a..dbc2c4820 100644
--- a/tests/collection/generate-docs/generate-docs.spec.ts
+++ b/tests/collection/generate-docs/generate-docs.spec.ts
@@ -1,10 +1,9 @@
import jsyaml from 'js-yaml';
-import { test, expect } from '../../../playwright';
+import { test, expect, Page } from '../../../playwright';
import { generateCollectionDocs } from '../../utils/page';
import { buildCommonLocators } from '../../utils/page/locators';
import {
getCollectionTreeStructure,
- waitForCollectionMount,
type CollectionTreeItem
} from '../../utils/page/mounting';
@@ -78,6 +77,50 @@ const sidebarItemsToNameTree = (items: CollectionTreeItem[] = []): NameTree[] =>
return node;
});
+/**
+ * Environments defined in the fixture collection (one `.bru` file each under
+ * `environments/`). All of them should be selected by default in the modal.
+ */
+const EXPECTED_ENVIRONMENTS = ['Production', 'Development', 'Staging'];
+
+/** Extract the full embedded OpenCollection payload from the generated docs HTML. */
+const parseGeneratedOpenCollection = (html: string): Record
=> {
+ const match = html.match(/const collectionData = ("(?:\\.|[^"\\])*");/);
+ if (!match) {
+ throw new Error('Could not find the embedded collection data in the generated documentation');
+ }
+ const yamlContent = JSON.parse(match[1]) as string;
+ return jsyaml.load(yamlContent) as Record;
+};
+
+/** Names of the environments embedded in the generated docs (under config.environments). */
+const generatedEnvironmentNames = (html: string): string[] => {
+ const oc = parseGeneratedOpenCollection(html);
+ const environments = (oc?.config?.environments ?? []) as Array>;
+ return environments.map((env) => env?.name);
+};
+
+/** Text rendered by the header count, e.g. `(2/3 selected)`. */
+const selectedCountText = (selected: number): string => `(${selected}/${EXPECTED_ENVIRONMENTS.length} selected)`;
+
+/**
+ * Open the Generate Documentation modal from the collection context menu and wait until
+ * every fixture environment row has rendered, so selection/count assertions are stable.
+ */
+const openDocsModalWithEnvironments = async (page: Page) => {
+ const locators = buildCommonLocators(page);
+
+ await locators.sidebar.collection(COLLECTION_NAME).hover();
+ await locators.actions.collectionActions(COLLECTION_NAME).click();
+ await locators.generateDocs.menuItem().click();
+
+ const modal = locators.generateDocs.modal();
+ await expect(modal).toBeVisible();
+ await expect(locators.generateDocs.environmentRows()).toHaveCount(EXPECTED_ENVIRONMENTS.length);
+
+ return { locators, modal };
+};
+
test.describe('Generate Documentation', () => {
test('orders generated docs to match the sidebar tree (folders by seq, then requests by seq, recursively)', async ({
pageWithUserData: page
@@ -107,8 +150,6 @@ test.describe('Generate Documentation', () => {
}) => {
const locators = buildCommonLocators(page);
- await waitForCollectionMount(page, COLLECTION_NAME);
-
await locators.sidebar.collection(COLLECTION_NAME).hover();
await locators.actions.collectionActions(COLLECTION_NAME).click();
await locators.generateDocs.menuItem().click();
@@ -122,4 +163,176 @@ test.describe('Generate Documentation', () => {
await locators.generateDocs.cancelButton().click();
await expect(modal).toBeHidden();
});
+
+ test('shows the current collection version formatted as a v-prefixed semver', async ({
+ pageWithUserData: page
+ }) => {
+ const locators = buildCommonLocators(page);
+
+ await locators.sidebar.collection(COLLECTION_NAME).hover();
+ await locators.actions.collectionActions(COLLECTION_NAME).click();
+ await locators.generateDocs.menuItem().click();
+
+ const modal = locators.generateDocs.modal();
+ await expect(modal).toBeVisible();
+
+ // The fixture's bruno.json version ("1") is normalised for display to "v1.0.0".
+ await expect(locators.generateDocs.versionInfo()).toContainText('Collection Version:');
+ await expect(locators.generateDocs.versionValue()).toHaveText('v1.0.0');
+
+ // The fixture has 2 folders (Zoo, Aviary) and 5 requests (Lion, Bear, Parrot,
+ // ReqAlpha, ReqBeta), counted recursively across the whole tree.
+ await expect(locators.generateDocs.versionCounts()).toHaveText('2 Folders • 5 requests');
+
+ await locators.generateDocs.cancelButton().click();
+ await expect(modal).toBeHidden();
+ });
+
+ test('lists every environment under "Environments to include", all selected by default', async ({
+ pageWithUserData: page
+ }) => {
+ const locators = buildCommonLocators(page);
+
+ await locators.sidebar.collection(COLLECTION_NAME).hover();
+ await locators.actions.collectionActions(COLLECTION_NAME).click();
+ await locators.generateDocs.menuItem().click();
+
+ const modal = locators.generateDocs.modal();
+ await expect(modal).toBeVisible();
+ await expect(locators.generateDocs.environmentsTitle()).toBeVisible();
+
+ for (const name of EXPECTED_ENVIRONMENTS) {
+ await expect(locators.generateDocs.environmentRow(name)).toBeVisible();
+ await expect(locators.generateDocs.environmentCheckbox(name)).toBeChecked();
+ }
+
+ // Exactly the fixture's environments are listed — nothing more.
+ await expect(locators.generateDocs.environmentRows()).toHaveCount(EXPECTED_ENVIRONMENTS.length);
+
+ await locators.generateDocs.cancelButton().click();
+ await expect(modal).toBeHidden();
+ });
+
+ test('includes every environment in the generated docs by default', async ({
+ pageWithUserData: page
+ }) => {
+ const locators = buildCommonLocators(page);
+
+ // Ensure all environments have loaded (and stay checked) before generating.
+ const { content } = await generateCollectionDocs(page, COLLECTION_NAME, async () => {
+ for (const name of EXPECTED_ENVIRONMENTS) {
+ await expect(locators.generateDocs.environmentCheckbox(name)).toBeChecked();
+ }
+ });
+
+ expect(generatedEnvironmentNames(content).sort()).toEqual([...EXPECTED_ENVIRONMENTS].sort());
+ });
+
+ test('excludes a deselected environment from the generated docs', async ({
+ pageWithUserData: page
+ }) => {
+ const locators = buildCommonLocators(page);
+
+ const { content } = await generateCollectionDocs(page, COLLECTION_NAME, async () => {
+ // Wait for all environments to load, then deselect a single one.
+ for (const name of EXPECTED_ENVIRONMENTS) {
+ await expect(locators.generateDocs.environmentCheckbox(name)).toBeChecked();
+ }
+ await locators.generateDocs.environmentCheckbox('Development').uncheck();
+ await expect(locators.generateDocs.environmentCheckbox('Development')).not.toBeChecked();
+ });
+
+ const envNames = generatedEnvironmentNames(content);
+ expect(envNames).toContain('Production');
+ expect(envNames).toContain('Staging');
+ expect(envNames).not.toContain('Development');
+ });
+
+ test('checks "Select All" and shows a full count when every environment is selected by default', async ({
+ pageWithUserData: page
+ }) => {
+ const { locators, modal } = await openDocsModalWithEnvironments(page);
+
+ await expect(locators.generateDocs.selectAllLabel()).toContainText('Select All');
+ await expect(locators.generateDocs.selectAllCheckbox()).toBeChecked();
+ await expect(locators.generateDocs.selectedCount()).toHaveText(
+ selectedCountText(EXPECTED_ENVIRONMENTS.length)
+ );
+
+ await locators.generateDocs.cancelButton().click();
+ await expect(modal).toBeHidden();
+ });
+
+ test('shows "Select All" as indeterminate with a partial count when one environment is deselected', async ({
+ pageWithUserData: page
+ }) => {
+ const { locators, modal } = await openDocsModalWithEnvironments(page);
+
+ await locators.generateDocs.environmentCheckbox('Development').uncheck();
+
+ // Some-but-not-all selected -> tri-state checkbox shows the indeterminate state.
+ await expect(locators.generateDocs.selectAllCheckbox()).toBeChecked({ indeterminate: true });
+ await expect(locators.generateDocs.selectedCount()).toHaveText(
+ selectedCountText(EXPECTED_ENVIRONMENTS.length - 1)
+ );
+
+ await locators.generateDocs.cancelButton().click();
+ await expect(modal).toBeHidden();
+ });
+
+ test('clicking "Select All" deselects every environment, emptying the checkbox and count', async ({
+ pageWithUserData: page
+ }) => {
+ const { locators, modal } = await openDocsModalWithEnvironments(page);
+ await expect(locators.generateDocs.selectAllCheckbox()).toBeChecked();
+
+ await locators.generateDocs.selectAllCheckbox().click();
+
+ await expect(locators.generateDocs.selectAllCheckbox()).not.toBeChecked();
+ await expect(locators.generateDocs.selectedCount()).toHaveText(selectedCountText(0));
+ for (const name of EXPECTED_ENVIRONMENTS) {
+ await expect(locators.generateDocs.environmentCheckbox(name)).not.toBeChecked();
+ }
+
+ await locators.generateDocs.cancelButton().click();
+ await expect(modal).toBeHidden();
+ });
+
+ test('clicking "Select All" from a partial selection re-selects every environment', async ({
+ pageWithUserData: page
+ }) => {
+ const { locators, modal } = await openDocsModalWithEnvironments(page);
+
+ // Drop into the partial (indeterminate) state first.
+ await locators.generateDocs.environmentCheckbox('Development').uncheck();
+ await expect(locators.generateDocs.selectAllCheckbox()).toBeChecked({ indeterminate: true });
+
+ // Clicking the tri-state checkbox while partial selects everything.
+ await locators.generateDocs.selectAllCheckbox().click();
+
+ await expect(locators.generateDocs.selectAllCheckbox()).toBeChecked();
+ await expect(locators.generateDocs.selectedCount()).toHaveText(
+ selectedCountText(EXPECTED_ENVIRONMENTS.length)
+ );
+ for (const name of EXPECTED_ENVIRONMENTS) {
+ await expect(locators.generateDocs.environmentCheckbox(name)).toBeChecked();
+ }
+
+ await locators.generateDocs.cancelButton().click();
+ await expect(modal).toBeHidden();
+ });
+
+ test('deselecting everything via "Select All" excludes all environments from the generated docs', async ({
+ pageWithUserData: page
+ }) => {
+ const locators = buildCommonLocators(page);
+
+ const { content } = await generateCollectionDocs(page, COLLECTION_NAME, async () => {
+ await expect(locators.generateDocs.environmentRows()).toHaveCount(EXPECTED_ENVIRONMENTS.length);
+ await locators.generateDocs.selectAllCheckbox().click();
+ await expect(locators.generateDocs.selectedCount()).toHaveText(selectedCountText(0));
+ });
+
+ expect(generatedEnvironmentNames(content)).toEqual([]);
+ });
});
diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts
index 8b896236a..d19f00172 100644
--- a/tests/utils/page/actions.ts
+++ b/tests/utils/page/actions.ts
@@ -1869,7 +1869,8 @@ const openWorkspaceFromDialog = async (app: any, page: any, targetPath: string)
*/
const generateCollectionDocs = async (
page: Page,
- collectionName: string
+ collectionName: string,
+ beforeGenerate?: () => Promise
): Promise<{ content: string; fileName: string }> => {
return await test.step(`Generate docs for collection "${collectionName}"`, async () => {
const locators = buildCommonLocators(page);
@@ -1894,6 +1895,12 @@ const generateCollectionDocs = async (
const generateButton = locators.generateDocs.generateButton();
await expect(generateButton).toBeEnabled({ timeout: 10000 });
+ // Let the caller interact with the modal (e.g. toggle environment selection)
+ // after it is ready and before the docs are generated.
+ if (beforeGenerate) {
+ await beforeGenerate();
+ }
+
// Arm the renderer-side interception before the save fires. `file-saver`
// (v2) reads the Blob through `URL.createObjectURL` and then triggers the
// save by dispatching a synthetic click on a detached `` (via
diff --git a/tests/utils/page/locators.ts b/tests/utils/page/locators.ts
index 6d7b422e8..04f682caa 100644
--- a/tests/utils/page/locators.ts
+++ b/tests/utils/page/locators.ts
@@ -157,7 +157,27 @@ export const buildCommonLocators = (page: Page) => ({
}),
heading: () => page.locator('.bruno-modal').getByText('Interactive API Documentation'),
generateButton: () => page.locator('.bruno-modal').getByRole('button', { name: 'Generate', exact: true }),
- cancelButton: () => page.locator('.bruno-modal').getByRole('button', { name: 'Cancel', exact: true })
+ cancelButton: () => page.locator('.bruno-modal').getByRole('button', { name: 'Cancel', exact: true }),
+ // Collection version (read-only) display
+ versionInfo: () => page.locator('.bruno-modal').getByTestId('version-info'),
+ versionValue: () => page.locator('.bruno-modal').getByTestId('version-value'),
+ versionCounts: () => page.locator('.bruno-modal').getByTestId('version-summary'),
+ // Environment selection list
+ environmentsTitle: () => page.locator('.bruno-modal').getByTestId('env-section-title'),
+ // Header controls: tri-state "select all" checkbox + "X/Y selected" count
+ selectAllCheckbox: () => page.locator('.bruno-modal').getByTestId('env-select-all'),
+ selectAllLabel: () => page.locator('.bruno-modal').getByTestId('env-select-all-label'),
+ selectedCount: () => page.locator('.bruno-modal').getByTestId('env-selected-count'),
+ environmentRows: () => page.locator('.bruno-modal').getByTestId('env-row'),
+ environmentRow: (name: string) =>
+ page.locator('.bruno-modal').getByTestId('env-row').filter({ has: page.getByText(name, { exact: true }) }),
+ // A row has exactly one checkbox; its data-testid is uid-keyed, so select it by role within the named row.
+ environmentCheckbox: (name: string) =>
+ page
+ .locator('.bruno-modal')
+ .getByTestId('env-row')
+ .filter({ has: page.getByText(name, { exact: true }) })
+ .getByRole('checkbox')
},
runnerResults: {
itemPath: (name: string) => page.getByTestId('runner-result-item').filter({ hasText: name })