diff --git a/package-lock.json b/package-lock.json index d2f90304f..91f47fd98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "@storybook/react-webpack5": "^10.1.10", "@stylistic/eslint-plugin": "^5.3.1", "@types/jest": "^29.5.11", + "@types/js-yaml": "^4.0.9", "@types/lodash-es": "^4.17.12", "@types/node": "^22.14.1", "@typescript-eslint/parser": "^8.39.0", @@ -12915,6 +12916,13 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jsdom": { "version": "20.0.1", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", diff --git a/package.json b/package.json index f33eb7177..e3ac38d06 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@storybook/react-webpack5": "^10.1.10", "@stylistic/eslint-plugin": "^5.3.1", "@types/jest": "^29.5.11", + "@types/js-yaml": "^4.0.9", "@types/lodash-es": "^4.17.12", "@types/node": "^22.14.1", "@typescript-eslint/parser": "^8.39.0", @@ -94,9 +95,9 @@ ] }, "overrides": { - "axios":"1.13.6", + "axios": "1.13.6", "rollup": "3.30.0", - "pbkdf2":"3.1.5", + "pbkdf2": "3.1.5", "electron-store": { "conf": { "json-schema-typed": "8.0.1" 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 864feb367..6a3d41838 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 @@ -11,7 +11,7 @@ import Modal from 'components/Modal'; import StyledWrapper from './StyledWrapper'; import demoImage from './demo.png'; import { useApp } from 'providers/App'; -import { transformCollectionToSaveToExportAsFile, findCollectionByUid, areItemsLoading } from 'utils/collections/index'; +import { transformCollectionToSaveToExportAsFile, findCollectionByUid, areItemsLoading, sortItemsBySidebarOrder } from 'utils/collections/index'; import { brunoToOpenCollection } from '@usebruno/converters'; import { sanitizeName } from 'utils/common/regex'; import { escapeHtml } from 'utils/response'; @@ -75,6 +75,11 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => { const handleGenerate = useCallback(() => { try { const collectionCopy = cloneDeep(collection); + + // Order items exactly like the Sidebar tree (folders by seq, then requests by seq + // ) at every depth, so the generated docs match the collection shown in the sidebar. + collectionCopy.items = sortItemsBySidebarOrder(collectionCopy.items); + const transformedCollection = transformCollectionToSaveToExportAsFile(collectionCopy); const openCollection = brunoToOpenCollection(transformedCollection); diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 0b551b53b..ca51ed03d 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -888,6 +888,25 @@ export const isItemAFolder = (item) => { return !item.hasOwnProperty('request') && item.type === 'folder'; }; +/** + * Orders a list of collection items exactly the way the Sidebar tree renders them: + * folders first (via `sortByNameThenSequence`), then requests ordered by `seq`. The + * same ordering is applied recursively to every nested folder so an exported/serialized + * tree matches the sidebar at all depths. + * + * Items that are neither folders nor requests (e.g. `js` script files) are excluded, + * mirroring the sidebar, which only renders folders and requests. Transient items are + * excluded too. + */ +export const sortItemsBySidebarOrder = (items = []) => { + const folderItems = sortByNameThenSequence(filter(items, (i) => isItemAFolder(i) && !i.isTransient)); + const requestItems = filter(items, (i) => isItemARequest(i) && !i.isTransient).sort((a, b) => a.seq - b.seq); + + return [...folderItems, ...requestItems].map((item) => + Array.isArray(item.items) ? { ...item, items: sortItemsBySidebarOrder(item.items) } : item + ); +}; + export const humanizeRequestBodyMode = (mode) => { let label = 'No Body'; switch (mode) { diff --git a/packages/bruno-app/src/utils/common/sort-items-by-sidebar-order.spec.js b/packages/bruno-app/src/utils/common/sort-items-by-sidebar-order.spec.js new file mode 100644 index 000000000..64b759fc8 --- /dev/null +++ b/packages/bruno-app/src/utils/common/sort-items-by-sidebar-order.spec.js @@ -0,0 +1,93 @@ +const { describe, it, expect } = require('@jest/globals'); +const { sortItemsBySidebarOrder } = require('../collections/index'); + +const folder = (name, seq, items = []) => ({ uid: name, type: 'folder', name, seq, items }); +const request = (name, seq) => ({ uid: name, type: 'http-request', name, seq, request: { method: 'GET', url: '' } }); + +const names = (items) => items.map((i) => i.name); + +describe('sortItemsBySidebarOrder', () => { + describe('Grouping order (folders → requests)', () => { + it('places folders first, then requests, then files regardless of input order', () => { + const result = sortItemsBySidebarOrder([ + request('req', 1), + folder('dir', 1) + ]); + expect(names(result)).toEqual(['dir', 'req']); + }); + + it('orders folders by seq (via sortByNameThenSequence)', () => { + const result = sortItemsBySidebarOrder([ + folder('third', 3), + folder('first', 1), + folder('second', 2) + ]); + expect(names(result)).toEqual(['first', 'second', 'third']); + }); + + it('orders requests by seq', () => { + const result = sortItemsBySidebarOrder([ + request('c', 3), + request('a', 1), + request('b', 2) + ]); + expect(names(result)).toEqual(['a', 'b', 'c']); + }); + + it('produces the full sidebar order for a mixed list', () => { + const result = sortItemsBySidebarOrder([ + request('reqB', 2), + folder('folderB', 2), + request('reqA', 1), + folder('folderA', 1) + ]); + expect(names(result)).toEqual(['folderA', 'folderB', 'reqA', 'reqB']); + }); + }); + + describe('Nested folders', () => { + it('applies the same ordering recursively at every depth', () => { + const result = sortItemsBySidebarOrder([ + folder('outer', 1, [ + request('innerReqB', 2), + folder('innerFolder', 1, [request('deepB', 2), request('deepA', 1)]), + request('innerReqA', 1) + ]) + ]); + + const outer = result[0]; + expect(names(outer.items)).toEqual(['innerFolder', 'innerReqA', 'innerReqB']); + + const innerFolder = outer.items.find((i) => i.name === 'innerFolder'); + expect(names(innerFolder.items)).toEqual(['deepA', 'deepB']); + }); + }); + + describe('Filtering', () => { + it('excludes transient folders and requests', () => { + const transientReq = { ...request('ghost', 5), isTransient: true }; + const transientFolder = { ...folder('ghostDir', 5), isTransient: true }; + const result = sortItemsBySidebarOrder([ + transientFolder, + folder('real', 1), + transientReq, + request('realReq', 1) + ]); + expect(names(result)).toEqual(['real', 'realReq']); + }); + }); + + describe('Purity & robustness', () => { + it('does not mutate the input array', () => { + const items = [request('b', 2), request('a', 1)]; + const snapshot = JSON.parse(JSON.stringify(items)); + sortItemsBySidebarOrder(items); + expect(items).toEqual(snapshot); + }); + + it('returns an empty array for empty/no input', () => { + expect(sortItemsBySidebarOrder([])).toEqual([]); + expect(sortItemsBySidebarOrder()).toEqual([]); + }); + }); +}); diff --git a/tests/collection/generate-docs/fixtures/collection/Aviary/Parrot.bru b/tests/collection/generate-docs/fixtures/collection/Aviary/Parrot.bru new file mode 100644 index 000000000..823ed06f5 --- /dev/null +++ b/tests/collection/generate-docs/fixtures/collection/Aviary/Parrot.bru @@ -0,0 +1,11 @@ +meta { + name: Parrot + type: http + seq: 1 +} + +get { + url: https://echo.usebruno.com + body: none + auth: none +} diff --git a/tests/collection/generate-docs/fixtures/collection/Aviary/folder.bru b/tests/collection/generate-docs/fixtures/collection/Aviary/folder.bru new file mode 100644 index 000000000..0e402ee3d --- /dev/null +++ b/tests/collection/generate-docs/fixtures/collection/Aviary/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Aviary + seq: 2 +} + +auth { + mode: inherit +} diff --git a/tests/collection/generate-docs/fixtures/collection/ReqAlpha.bru b/tests/collection/generate-docs/fixtures/collection/ReqAlpha.bru new file mode 100644 index 000000000..e0b62a4a5 --- /dev/null +++ b/tests/collection/generate-docs/fixtures/collection/ReqAlpha.bru @@ -0,0 +1,11 @@ +meta { + name: ReqAlpha + type: http + seq: 2 +} + +get { + url: https://echo.usebruno.com + body: none + auth: none +} diff --git a/tests/collection/generate-docs/fixtures/collection/ReqBeta.bru b/tests/collection/generate-docs/fixtures/collection/ReqBeta.bru new file mode 100644 index 000000000..3aec97eb3 --- /dev/null +++ b/tests/collection/generate-docs/fixtures/collection/ReqBeta.bru @@ -0,0 +1,11 @@ +meta { + name: ReqBeta + type: http + seq: 1 +} + +get { + url: https://echo.usebruno.com + body: none + auth: none +} diff --git a/tests/collection/generate-docs/fixtures/collection/Zoo/Bear.bru b/tests/collection/generate-docs/fixtures/collection/Zoo/Bear.bru new file mode 100644 index 000000000..7b7e8629d --- /dev/null +++ b/tests/collection/generate-docs/fixtures/collection/Zoo/Bear.bru @@ -0,0 +1,11 @@ +meta { + name: Bear + type: http + seq: 2 +} + +get { + url: https://echo.usebruno.com + body: none + auth: none +} diff --git a/tests/collection/generate-docs/fixtures/collection/Zoo/Lion.bru b/tests/collection/generate-docs/fixtures/collection/Zoo/Lion.bru new file mode 100644 index 000000000..d716a1c6e --- /dev/null +++ b/tests/collection/generate-docs/fixtures/collection/Zoo/Lion.bru @@ -0,0 +1,11 @@ +meta { + name: Lion + type: http + seq: 1 +} + +get { + url: https://echo.usebruno.com + body: none + auth: none +} diff --git a/tests/collection/generate-docs/fixtures/collection/Zoo/folder.bru b/tests/collection/generate-docs/fixtures/collection/Zoo/folder.bru new file mode 100644 index 000000000..a91e1b80f --- /dev/null +++ b/tests/collection/generate-docs/fixtures/collection/Zoo/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Zoo + seq: 1 +} + +auth { + mode: inherit +} diff --git a/tests/collection/generate-docs/fixtures/collection/bruno.json b/tests/collection/generate-docs/fixtures/collection/bruno.json new file mode 100644 index 000000000..6d5f1edfb --- /dev/null +++ b/tests/collection/generate-docs/fixtures/collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "GenerateDocsOrder", + "type": "collection" +} diff --git a/tests/collection/generate-docs/generate-docs.spec.ts b/tests/collection/generate-docs/generate-docs.spec.ts new file mode 100644 index 000000000..75de96a3a --- /dev/null +++ b/tests/collection/generate-docs/generate-docs.spec.ts @@ -0,0 +1,125 @@ +import jsyaml from 'js-yaml'; +import { test, expect } from '../../../playwright'; +import { generateCollectionDocs } from '../../utils/page'; +import { buildCommonLocators } from '../../utils/page/locators'; +import { + getCollectionTreeStructure, + waitForCollectionMount, + type CollectionTreeItem +} from '../../utils/page/mounting'; + +const COLLECTION_NAME = 'GenerateDocsOrder'; + +/** + * Name-only nested view of an ordered item tree. Both the sidebar structure and + * the generated-docs structure are reduced to this shape so they can be compared + * directly — the change under test only affects ordering, so names + nesting are + * all that matter. + */ +type NameTree = { name: string; items?: NameTree[] }; + +/** + * The order the sidebar renders (and therefore the order the generated docs must + * match): folders first by `seq`, then requests by `seq`, applied recursively. + * + * The on-disk fixture is intentionally laid out so this differs from both the + * alphabetical filename order and the requests-before-folders grouping, so a + * regression that drops the sort would produce a visibly different tree. + */ +const EXPECTED_ORDER: NameTree[] = [ + { + name: 'Zoo', + items: [{ name: 'Lion' }, { name: 'Bear' }] + }, + { name: 'Aviary', items: [{ name: 'Parrot' }] }, + { name: 'ReqBeta' }, + { name: 'ReqAlpha' } +]; + +/** + * Pull the OpenCollection payload out of the generated HTML and reduce it to a + * NameTree. The docs file embeds the collection as a `jsesc`-encoded YAML string + * literal (`const collectionData = "...";`); we decode that literal back to YAML + * and parse it. + */ +const parseGeneratedDocs = (html: string): NameTree[] => { + const match = html.match(/const collectionData = ("(?:\\.|[^"\\])*");/); + if (!match) { + throw new Error('Could not find the embedded collection data in the generated documentation'); + } + + // The literal is a double-quoted `jsesc` string of the YAML payload. For the + // ASCII content this fixture produces, jsesc only emits JSON-valid escapes + // (`\n`, `\"`, `\\`, `\/`), so the literal is also valid JSON and decodes back + // to the raw YAML with a plain JSON.parse. + const yamlContent = JSON.parse(match[1]) as string; + + const openCollection = jsyaml.load(yamlContent) as { items?: Array> }; + return openCollectionItemsToNameTree(openCollection.items); +}; + +/** Reduce OpenCollection items (name lives at `info.name`) to a NameTree. */ +const openCollectionItemsToNameTree = (items: Array> = []): NameTree[] => + items.map((item) => { + const node: NameTree = { name: item?.info?.name }; + if (Array.isArray(item?.items)) { + node.items = openCollectionItemsToNameTree(item.items); + } + return node; + }); + +/** Reduce the sidebar tree structure to a NameTree. */ +const sidebarItemsToNameTree = (items: CollectionTreeItem[] = []): NameTree[] => + items.map((item) => { + const node: NameTree = { name: item.name }; + if (item.type === 'folder') { + node.items = sidebarItemsToNameTree(item.items ?? []); + } + return node; + }); + +test.describe('Generate Documentation', () => { + test('orders generated docs to match the sidebar tree (folders by seq, then requests by seq, recursively)', async ({ + pageWithUserData: page + }) => { + let generatedTree: NameTree[] = []; + await test.step('Generate documentation and read the embedded collection order', async () => { + const { content, fileName } = await generateCollectionDocs(page, COLLECTION_NAME); + expect(fileName).toBe('GenerateDocsOrder-documentation.html'); + generatedTree = parseGeneratedDocs(content); + }); + + await test.step('Generated docs follow the sidebar order at every depth', async () => { + expect(generatedTree).toEqual(EXPECTED_ORDER); + }); + + await test.step('The order also matches what the sidebar actually renders', async () => { + const sidebarStructure = await getCollectionTreeStructure(page, COLLECTION_NAME); + const sidebarTree = sidebarItemsToNameTree(sidebarStructure.items); + + expect(sidebarTree).toEqual(EXPECTED_ORDER); + expect(generatedTree).toEqual(sidebarTree); + }); + }); + + test('shows the Generate Documentation modal from the collection context menu', async ({ + pageWithUserData: page + }) => { + 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(); + + const modal = locators.generateDocs.modal(); + await expect(modal).toBeVisible(); + await expect(locators.generateDocs.heading()).toBeVisible(); + await expect(locators.generateDocs.generateButton()).toBeEnabled(); + + // Cancel so the test leaves no download behind. + await locators.generateDocs.cancelButton().click(); + await expect(modal).toBeHidden(); + }); +}); diff --git a/tests/collection/generate-docs/init-user-data/preferences.json b/tests/collection/generate-docs/init-user-data/preferences.json new file mode 100644 index 000000000..8ea9320a6 --- /dev/null +++ b/tests/collection/generate-docs/init-user-data/preferences.json @@ -0,0 +1,11 @@ +{ + "lastOpenedCollections": [ + "{{collectionPath}}" + ], + "preferences": { + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + } + } +} diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index b01065d44..9a02b3667 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -2,6 +2,7 @@ import { test, expect, Page, ElectronApplication, waitForReadyPage as waitForRea import process from 'node:process'; import * as path from 'path'; import { buildCommonLocators, buildScriptErrorLocators, buildGrpcCommonLocators } from './locators'; +import { waitForCollectionMount } from './mounting'; type SandboxMode = 'safe' | 'developer'; @@ -1756,6 +1757,91 @@ const openWorkspaceFromDialog = async (app: any, page: any, targetPath: string) await page.locator('.dropdown-item').filter({ hasText: 'Open workspace' }).click(); }; +/** + * Trigger "Generate Docs" from a collection's sidebar context menu and capture + * the generated HTML documentation. + * + * The GenerateDocumentation modal hands the file to `FileSaver.saveAs`, which + * builds an in-memory Blob and saves it via an `` click rather than + * an Electron IPC write. Electron doesn't surface that as a Playwright + * `download` event, so instead we intercept it in the renderer: `URL.createObjectURL` + * gives us the Blob's content, and overriding the anchor click captures the + * suggested file name while suppressing the real save (no file leaks to disk). + * + * @param page - The page object + * @param collectionName - The name of the collection to generate docs for + * @returns The generated HTML content and the download's suggested file name + */ +const generateCollectionDocs = async ( + page: Page, + collectionName: string +): Promise<{ content: string; fileName: string }> => { + return await test.step(`Generate docs for collection "${collectionName}"`, async () => { + const locators = buildCommonLocators(page); + + // Make sure the collection has finished mounting before interacting — on a + // cold start the row (and its hover-revealed actions icon) isn't ready yet, + // so this keeps the helper self-sufficient for any caller. + await waitForCollectionMount(page, collectionName); + + // Open the collection's context menu and click "Generate Docs" + await locators.sidebar.collection(collectionName).hover(); + const collectionAction = locators.actions.collectionActions(collectionName); + await expect(collectionAction).toBeVisible({ timeout: 2000 }); + await collectionAction.click(); + await locators.generateDocs.menuItem().click(); + + // Wait for the Generate Documentation modal to reach its ready (non-loading) + // state — the confirm button reads "Loading..." while the collection's items + // are still mounting and only becomes "Generate" once they are ready. + const modal = locators.generateDocs.modal(); + await expect(modal).toBeVisible({ timeout: 5000 }); + const generateButton = locators.generateDocs.generateButton(); + await expect(generateButton).toBeEnabled({ timeout: 10000 }); + + // 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 + // `setTimeout(…, 0)`), so both points are intercepted. Each is exposed as a + // promise to absorb that deferred dispatch without a race. + await page.evaluate(() => { + const w = window as any; + const originalCreate = URL.createObjectURL.bind(URL); + const originalDispatch = HTMLAnchorElement.prototype.dispatchEvent; + + w.__docsContent = new Promise((resolve) => { + URL.createObjectURL = function (obj: Blob | MediaSource) { + if (obj instanceof Blob) { + obj.text().then(resolve); + } + return originalCreate(obj as Blob); + }; + }); + + w.__docsFileName = new Promise((resolve) => { + HTMLAnchorElement.prototype.dispatchEvent = function (this: HTMLAnchorElement, event: Event) { + if (this.download && event && event.type === 'click') { + resolve(this.download); + // Suppress the actual save — the Blob content is already captured. + return true; + } + return originalDispatch.call(this, event); + }; + }); + }); + + await generateButton.click(); + + const content = await page.evaluate(() => (window as any).__docsContent as Promise); + const fileName = await page.evaluate(() => (window as any).__docsFileName as Promise); + + // The modal closes itself on the success path. + await expect(modal).toBeHidden({ timeout: 5000 }); + + return { content, fileName }; + }); +}; + export { waitForReadyPage, dismissImportIssuesToasts, @@ -1825,7 +1911,8 @@ export { getGeneratedSnippet, closeGenerateCodeDialog, openRequestInFolder, - setUrlEncoding + setUrlEncoding, + generateCollectionDocs }; export type { SandboxMode, EnvironmentType, EnvironmentVariable, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions, CreateTransientRequestOptions, AssertionInput }; diff --git a/tests/utils/page/locators.ts b/tests/utils/page/locators.ts index 662c25af4..512a24b3b 100644 --- a/tests/utils/page/locators.ts +++ b/tests/utils/page/locators.ts @@ -113,6 +113,15 @@ export const buildCommonLocators = (page: Page) => ({ input: () => page.getByTestId('tag-input').getByRole('textbox'), item: (tagName: string) => page.locator('.tag-item', { hasText: tagName }) }, + generateDocs: { + menuItem: () => page.locator('.dropdown-item').filter({ hasText: 'Generate Docs' }), + modal: () => page.locator('.bruno-modal').filter({ + has: page.locator('.bruno-modal-header-title').filter({ hasText: 'Generate Documentation' }) + }), + 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 }) + }, runnerResults: { itemPath: (name: string) => page.getByTestId('runner-result-item').filter({ hasText: name }) },