diff --git a/packages/bruno-app/src/components/Errors/IpcErrorModal/StyledWrapper.js b/packages/bruno-app/src/components/Errors/IpcErrorModal/StyledWrapper.js index 88fe7f30d..f774707b4 100644 --- a/packages/bruno-app/src/components/Errors/IpcErrorModal/StyledWrapper.js +++ b/packages/bruno-app/src/components/Errors/IpcErrorModal/StyledWrapper.js @@ -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; } `; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/CollectionVersionInfo/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/CollectionVersionInfo/index.js new file mode 100644 index 000000000..204d17ed8 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/CollectionVersionInfo/index.js @@ -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 ( +
+
+ Collection Version:{' '} + {formatVersion(version)} +
+

+ {`${folderCount} ${folderLabel} • ${requestCount} ${requestLabel}`} +

+
+ ); +}; + +export default memo(CollectionVersionInfo); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/EnvironmentSelectionList/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/EnvironmentSelectionList/index.js new file mode 100644 index 000000000..8cf1bbb6b --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/EnvironmentSelectionList/index.js @@ -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) => ( + + ), + [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 ( + <> +
+
+

{title}

+ + ({selectedCount}/{environments.length} selected) + +
+ +
+ + + ); +}; + +export default memo(EnvironmentSelectionList); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/StyledWrapper.js index f7ccd063b..dbe18a56e 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/StyledWrapper.js @@ -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; + } + } } } diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/index.js index 6a3d41838..ab038a433 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/index.js @@ -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) => ``; const CollectionNotFound = ({ onClose }) => ( - - -
- - Collection not found. It may have been deleted or is no longer available. -
-
-
+ + + +
+ + Collection not found. It may have been deleted or is no longer available. +
+
+
+
); 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 ; } return ( - - - {isLoading ? ( -
- - Loading collection... -
- ) : ( -
-

- - Interactive API Documentation -

-

- Generate a standalone HTML file that can be hosted anywhere or shared with your team. -

- -
- Sample Output - Documentation preview + + + + {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 })