Feature(size/L): BRU-2542 Choose environments to include and show versions in the Generate Documentation modal (#8268)

* BRU-1128 bug fix OpenAPI import error message

* Feat:BRU-2542 Choose environments to include and show UI

* Feat:BRU-2542 Added virtualization for env lists

* Feat:BRU-2542 Reverted IPCError modal changes

* BRU-2542 removed wait mount from playwrite script

* BRU-2542 Added select all checkbox

* BRU-2542 Added portals for generate doc modal and fixed overlapping issue

* BRU-2542 Comments addressed

---------

Co-authored-by: bruno-sachin <bruno-sachin@brunos-MacBook-Air.local>
This commit is contained in:
sachin-bruno
2026-06-20 01:30:16 +05:30
committed by GitHub
parent 6136d3ac62
commit 4fffef51ba
13 changed files with 691 additions and 78 deletions

View File

@@ -4,6 +4,8 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.danger};
pre {
color: ${(props) => props.theme.colors.danger};
max-height: 60vh;
overflow: auto;
}
`;

View File

@@ -0,0 +1,39 @@
import React, { memo } from 'react';
import semver from 'semver';
const DEFAULT_VERSION = 'v1.0.0';
/**
* Normalise a raw collection version for display: coerce partials to a full
* major.minor.patch ("1" -> "v1.0.0", "2.1" -> "v2.1.0"), keep a single "v"
* prefix, preserve pre-releases ("1.0.0-beta" -> "v1.0.0-beta"), and fall back to
* the default when the version is unset or unparseable.
*/
const formatVersion = (version) => {
const coerced = semver.coerce(version, { includePrerelease: true });
return coerced ? `v${coerced.version}` : DEFAULT_VERSION;
};
/**
* Read-only display of the collection's current version and a summary of its
* contents (folder + request counts). Presentational and prop-driven so it can be
* reused wherever the collection version needs to be shown.
*/
const CollectionVersionInfo = ({ version, folderCount = 0, requestCount = 0 }) => {
const folderLabel = folderCount === 1 ? 'Folder' : 'Folders';
const requestLabel = requestCount === 1 ? 'request' : 'requests';
return (
<div className="version-info" data-testid="version-info">
<div className="version-line">
<span className="version-label">Collection Version:</span>{' '}
<span className="version-value" data-testid="version-value">{formatVersion(version)}</span>
</div>
<p className="version-summary" data-testid="version-summary">
{`${folderCount} ${folderLabel}${requestCount} ${requestLabel}`}
</p>
</div>
);
};
export default memo(CollectionVersionInfo);

View File

@@ -0,0 +1,113 @@
import React, { useCallback, useEffect, useMemo, useRef, memo } from 'react';
import { Virtuoso } from 'react-virtuoso';
import ColorBadge from 'components/ColorBadge';
// Show at most 5 environments at a glance; the list virtualises and scrolls beyond
// that, so it stays performant even for collections with hundreds of environments
// (only the visible rows are ever in the DOM).
const MAX_VISIBLE_ROWS = 5;
// Fixed row height (px). MUST stay in sync with the `.env-row` height in StyledWrapper.js,
// since it is passed to Virtuoso as `fixedItemHeight`.
const ENV_ROW_HEIGHT = 34;
/**
* A selectable, virtualised list of collection environments (checkbox + color dot + name)
* with a header that carries the title and a tri-state "select all" checkbox.
*
* Selection is controlled by the parent via `selectedUids`; `onToggleAll(nextSelectAll)`
* fires with the desired state when the header checkbox is clicked. Presentational and
* prop-driven so it can be reused wherever an environment multi-select is needed.
*/
const EnvironmentSelectionList = ({
environments = [],
selectedUids = [],
onToggle,
onToggleAll,
title = 'Environments',
disabled = false
}) => {
// O(1) membership checks regardless of how many environments are rendered.
const selectedSet = useMemo(() => new Set(selectedUids), [selectedUids]);
const selectedCount = useMemo(
() => environments.reduce((count, env) => (selectedSet.has(env?.uid) ? count + 1 : count), 0),
[environments, selectedSet]
);
const allSelected = environments.length > 0 && selectedCount === environments.length;
const someSelected = selectedCount > 0 && !allSelected;
const selectAllRef = useRef(null);
useEffect(() => {
if (selectAllRef.current) {
selectAllRef.current.indeterminate = someSelected;
}
}, [someSelected]);
const handleToggleAll = useCallback((event) => onToggleAll?.(event.target.checked), [onToggleAll]);
const computeItemKey = useCallback((_index, env) => env?.uid, []);
const renderEnvironment = useCallback(
(_index, env) => (
<label className="env-row" data-testid="env-row">
<input
type="checkbox"
className="env-checkbox"
checked={selectedSet.has(env?.uid)}
disabled={disabled}
onChange={() => onToggle?.(env?.uid)}
data-testid={`env-select-${env?.uid}`}
/>
<ColorBadge color={env?.color} size={8} />
<span className="env-name truncate">{env?.name}</span>
</label>
),
[selectedSet, disabled, onToggle]
);
if (!environments.length) {
return null;
}
// Fit the list to its content, capped at MAX_VISIBLE_ROWS — beyond that it scrolls.
const visibleRows = Math.min(environments.length, MAX_VISIBLE_ROWS);
return (
<>
<div className="env-section-header">
<div className="env-section-heading">
<h4 className="env-section-title" data-testid="env-section-title">{title}</h4>
<span className="env-section-count" data-testid="env-selected-count">
({selectedCount}/{environments.length} selected)
</span>
</div>
<label className="env-select-all">
<input
ref={selectAllRef}
type="checkbox"
className="env-checkbox"
checked={allSelected}
disabled={disabled}
onChange={handleToggleAll}
data-testid="env-select-all"
/>
<span className="env-select-all-label" data-testid="env-select-all-label">Select All</span>
</label>
</div>
<Virtuoso
className="env-list"
role="group"
aria-label={title}
style={{ height: visibleRows * ENV_ROW_HEIGHT }}
data={environments}
computeItemKey={computeItemKey}
itemContent={renderEnvironment}
fixedItemHeight={ENV_ROW_HEIGHT}
increaseViewportBy={ENV_ROW_HEIGHT * 3}
/>
</>
);
};
export default memo(EnvironmentSelectionList);

View File

@@ -13,27 +13,108 @@ const StyledWrapper = styled.div`
line-height: 1.6;
}
.preview-container {
.config-card {
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: ${(props) => props.theme.border.radius.md};
overflow: hidden;
border: 1px solid ${(props) => props.theme.border.border1};
.preview-label {
top: 0.5rem;
right: 0.5rem;
padding: 0.125rem 0.5rem;
font-size: ${(props) => props.theme.font.size.xs};
font-weight: 500;
color: #3b82f6;
background-color: rgba(59, 130, 246, 0.1);
border: 1px dashed rgba(59, 130, 246, 0.4);
border-radius: ${(props) => props.theme.border.radius.sm};
.version-info {
padding: 0.75rem 1rem;
background-color: ${(props) => props.theme.background.mantle};
.version-line {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
}
.version-label {
font-weight: 500;
}
.version-summary {
margin: 0.25rem 0 0;
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
}
}
.preview-image {
width: 100%;
height: auto;
display: block;
.card-divider {
height: 1px;
background-color: ${(props) => props.theme.border.border1};
}
.env-section {
padding: 1rem;
.env-checkbox {
width: 1rem;
height: 1rem;
margin: 0;
flex-shrink: 0;
cursor: pointer;
}
.env-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.env-section-heading {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.env-section-count {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
white-space: nowrap;
}
.env-section-title {
margin: 0;
font-size: ${(props) => props.theme.font.size.xs};
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: ${(props) => props.theme.colors.text.muted};
}
.env-select-all {
display: flex;
align-items: center;
gap: 0.375rem;
margin: 0;
cursor: pointer;
user-select: none;
.env-select-all-label {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
white-space: nowrap;
}
}
.env-row {
display: flex;
align-items: center;
gap: 0.5rem;
/* Fixed row height — MUST match ENV_ROW_HEIGHT (Virtuoso fixedItemHeight)
in EnvironmentSelectionList. The inter-row spacing is baked in here. */
height: 34px;
cursor: pointer;
margin: 0;
.env-name {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
min-width: 0;
}
}
}
}

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useMemo, useState, Fragment } from 'react';
import { useSelector } from 'react-redux';
import { cloneDeep } from 'lodash';
import * as FileSaver from 'file-saver';
@@ -8,10 +8,12 @@ import toast from 'react-hot-toast';
import { IconBook, IconCheck, IconAlertTriangle, IconLoader2 } from '@tabler/icons';
import Modal from 'components/Modal';
import Portal from 'components/Portal';
import StyledWrapper from './StyledWrapper';
import demoImage from './demo.png';
import CollectionVersionInfo from './CollectionVersionInfo';
import EnvironmentSelectionList from './EnvironmentSelectionList';
import { useApp } from 'providers/App';
import { transformCollectionToSaveToExportAsFile, findCollectionByUid, areItemsLoading, sortItemsBySidebarOrder } from 'utils/collections/index';
import { transformCollectionToSaveToExportAsFile, findCollectionByUid, areItemsLoading, sortItemsBySidebarOrder, getCollectionItemCounts } from 'utils/collections/index';
import { brunoToOpenCollection } from '@usebruno/converters';
import { sanitizeName } from 'utils/common/regex';
import { escapeHtml } from 'utils/response';
@@ -51,14 +53,16 @@ const buildHtmlDocument = (collectionName, escapedYamlContent) => `<!DOCTYPE htm
</html>`;
const CollectionNotFound = ({ onClose }) => (
<Modal size="md" title="Generate Documentation" confirmText="Close" handleConfirm={onClose} hideCancel>
<StyledWrapper className="w-[500px]">
<div className="flex items-center gap-2 text-warning">
<IconAlertTriangle size={16} className="shrink-0" />
<span>Collection not found. It may have been deleted or is no longer available.</span>
</div>
</StyledWrapper>
</Modal>
<Portal>
<Modal size="md" title="Generate Documentation" confirmText="Close" handleConfirm={onClose} hideCancel>
<StyledWrapper className="w-[500px]">
<div className="flex items-center gap-2 text-warning">
<IconAlertTriangle size={16} className="shrink-0" />
<span>Collection not found. It may have been deleted or is no longer available.</span>
</div>
</StyledWrapper>
</Modal>
</Portal>
);
const GenerateDocumentation = ({ onClose, collectionUid }) => {
@@ -72,6 +76,43 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => {
[collection]
);
// The collection's current version (read-only here); formatted for display below.
const currentVersion = collection?.version;
// Folder + request counts, computed from the collection tree (recursively).
const { folderCount, requestCount } = useMemo(
() => getCollectionItemCounts(collection?.items),
[collection?.items]
);
const environments = useMemo(() => collection?.environments || [], [collection?.environments]);
// Track *deselected* environments so all environments — including any that load
// after mount — stay selected by default, matching the design.
const [deselectedEnvUids, setDeselectedEnvUids] = useState(() => new Set());
const selectedEnvUids = useMemo(
() => environments.filter((env) => !deselectedEnvUids.has(env.uid)).map((env) => env.uid),
[environments, deselectedEnvUids]
);
const toggleEnv = useCallback((uid) => {
setDeselectedEnvUids((prev) => {
const next = new Set(prev);
if (next.has(uid)) {
next.delete(uid);
} else {
next.add(uid);
}
return next;
});
}, []);
// Select all -> nothing deselected; deselect all -> every environment deselected.
const toggleAllEnvs = useCallback(
(selectAll) => setDeselectedEnvUids(selectAll ? new Set() : new Set(environments.map((env) => env.uid))),
[environments]
);
const handleGenerate = useCallback(() => {
try {
const collectionCopy = cloneDeep(collection);
@@ -80,9 +121,21 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => {
// ) at every depth, so the generated docs match the collection shown in the sidebar.
collectionCopy.items = sortItemsBySidebarOrder(collectionCopy.items);
// Only include the environments the user kept selected in the generated docs.
const selectedSet = new Set(selectedEnvUids);
collectionCopy.environments = (collectionCopy.environments || []).filter((env) => selectedSet.has(env.uid));
const transformedCollection = transformCollectionToSaveToExportAsFile(collectionCopy);
const openCollection = brunoToOpenCollection(transformedCollection);
// The docs are generated from the current collection version (when set).
if (currentVersion) {
openCollection.info = {
...openCollection.info,
version: currentVersion
};
}
openCollection.extensions = {
...openCollection.extensions,
bruno: {
@@ -119,59 +172,74 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => {
console.error('Error generating documentation:', error);
toast.error('Failed to generate documentation');
}
}, [collection, version, onClose]);
}, [collection, version, onClose, currentVersion, selectedEnvUids]);
if (!collection) {
return <CollectionNotFound onClose={onClose} />;
}
return (
<Modal
size="md"
title="Generate Documentation"
confirmText={isLoading ? 'Loading...' : 'Generate'}
cancelText="Cancel"
handleConfirm={isLoading ? undefined : handleGenerate}
handleCancel={onClose}
confirmDisabled={isLoading}
>
<StyledWrapper className="w-[500px]">
{isLoading ? (
<div className="flex items-center justify-center gap-3 py-8">
<IconLoader2 size={20} className="animate-spin" />
<span>Loading collection...</span>
</div>
) : (
<div className="content">
<h3 className="title flex items-center gap-2 mt-2 font-medium">
<IconBook size={18} />
<span>Interactive API Documentation</span>
</h3>
<p className="description mb-4">
Generate a standalone HTML file that can be hosted anywhere or shared with your team.
</p>
<div className="preview-container relative mb-4">
<span className="preview-label absolute">Sample Output</span>
<img src={demoImage} alt="Documentation preview" className="preview-image" />
<Portal>
<Modal
size="md"
title="Generate Documentation"
confirmText={isLoading ? 'Loading...' : 'Generate'}
cancelText="Cancel"
handleConfirm={isLoading ? undefined : handleGenerate}
handleCancel={onClose}
confirmDisabled={isLoading}
>
<StyledWrapper className="w-[500px]">
{isLoading ? (
<div className="flex items-center justify-center gap-3 py-8">
<IconLoader2 size={20} className="animate-spin" />
<span>Loading collection...</span>
</div>
) : (
<div className="content">
<h3 className="title flex items-center gap-2 mt-2 font-medium">
<IconBook size={18} />
<span>Interactive API Documentation</span>
</h3>
<p className="description mb-4">
Generate a standalone HTML file that can be hosted anywhere or shared with your team.
</p>
<ul className="features flex flex-col list-none gap-2 p-0 mb-4">
{FEATURES.map((feature) => (
<li key={feature} className="flex items-center gap-2.5">
<IconCheck size={16} className="check-icon flex-shrink-0" />
<span>{feature}</span>
</li>
))}
</ul>
<ul className="features flex flex-col list-none gap-2 p-0 mb-4">
{FEATURES.map((feature) => (
<li key={feature} className="flex items-center gap-2.5">
<IconCheck size={16} className="check-icon flex-shrink-0" />
<span>{feature}</span>
</li>
))}
</ul>
<p className="note m-0">
The generated file loads OpenCollection's JavaScript and CSS files from a CDN, which requires an internet connection.
</p>
</div>
)}
</StyledWrapper>
</Modal>
<div className="config-card mb-4">
<CollectionVersionInfo version={currentVersion} folderCount={folderCount} requestCount={requestCount} />
{environments.length > 0 && (
<Fragment>
<div className="card-divider" />
<div className="env-section">
<EnvironmentSelectionList
title="Environments to include"
environments={environments}
selectedUids={selectedEnvUids}
onToggle={toggleEnv}
onToggleAll={toggleAllEnvs}
/>
</div>
</Fragment>
)}
</div>
<p className="note m-0">
The generated file loads OpenCollection's JavaScript and CSS files from a CDN, which requires an internet connection.
</p>
</div>
)}
</StyledWrapper>
</Modal>
</Portal>
);
};

View File

@@ -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

View File

@@ -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 });
});
});

View File

@@ -0,0 +1,3 @@
vars {
baseUrl: https://dev.example.com
}

View File

@@ -0,0 +1,3 @@
vars {
baseUrl: https://api.example.com
}

View File

@@ -0,0 +1,3 @@
vars {
baseUrl: https://staging.example.com
}

View File

@@ -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<string, any> => {
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<string, any>;
};
/** 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<Record<string, any>>;
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([]);
});
});

View File

@@ -1869,7 +1869,8 @@ const openWorkspaceFromDialog = async (app: any, page: any, targetPath: string)
*/
const generateCollectionDocs = async (
page: Page,
collectionName: string
collectionName: string,
beforeGenerate?: () => Promise<void>
): 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 `<a download>` (via

View File

@@ -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 })