fix(size/L): Preserve folder order from seq attribute (#8213)

This commit is contained in:
sachin-bruno
2026-06-10 19:41:26 +05:30
committed by GitHub
parent 79504ed729
commit 377cdb488c
17 changed files with 438 additions and 4 deletions

8
package-lock.json generated
View File

@@ -39,6 +39,7 @@
"@storybook/react-webpack5": "^10.1.10", "@storybook/react-webpack5": "^10.1.10",
"@stylistic/eslint-plugin": "^5.3.1", "@stylistic/eslint-plugin": "^5.3.1",
"@types/jest": "^29.5.11", "@types/jest": "^29.5.11",
"@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.1", "@types/node": "^22.14.1",
"@typescript-eslint/parser": "^8.39.0", "@typescript-eslint/parser": "^8.39.0",
@@ -12915,6 +12916,13 @@
"pretty-format": "^29.0.0" "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": { "node_modules/@types/jsdom": {
"version": "20.0.1", "version": "20.0.1",
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz",

View File

@@ -32,6 +32,7 @@
"@storybook/react-webpack5": "^10.1.10", "@storybook/react-webpack5": "^10.1.10",
"@stylistic/eslint-plugin": "^5.3.1", "@stylistic/eslint-plugin": "^5.3.1",
"@types/jest": "^29.5.11", "@types/jest": "^29.5.11",
"@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.1", "@types/node": "^22.14.1",
"@typescript-eslint/parser": "^8.39.0", "@typescript-eslint/parser": "^8.39.0",
@@ -94,9 +95,9 @@
] ]
}, },
"overrides": { "overrides": {
"axios":"1.13.6", "axios": "1.13.6",
"rollup": "3.30.0", "rollup": "3.30.0",
"pbkdf2":"3.1.5", "pbkdf2": "3.1.5",
"electron-store": { "electron-store": {
"conf": { "conf": {
"json-schema-typed": "8.0.1" "json-schema-typed": "8.0.1"

View File

@@ -11,7 +11,7 @@ import Modal from 'components/Modal';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import demoImage from './demo.png'; import demoImage from './demo.png';
import { useApp } from 'providers/App'; 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 { brunoToOpenCollection } from '@usebruno/converters';
import { sanitizeName } from 'utils/common/regex'; import { sanitizeName } from 'utils/common/regex';
import { escapeHtml } from 'utils/response'; import { escapeHtml } from 'utils/response';
@@ -75,6 +75,11 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => {
const handleGenerate = useCallback(() => { const handleGenerate = useCallback(() => {
try { try {
const collectionCopy = cloneDeep(collection); 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 transformedCollection = transformCollectionToSaveToExportAsFile(collectionCopy);
const openCollection = brunoToOpenCollection(transformedCollection); const openCollection = brunoToOpenCollection(transformedCollection);

View File

@@ -888,6 +888,25 @@ export const isItemAFolder = (item) => {
return !item.hasOwnProperty('request') && item.type === 'folder'; 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) => { export const humanizeRequestBodyMode = (mode) => {
let label = 'No Body'; let label = 'No Body';
switch (mode) { switch (mode) {

View File

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

View File

@@ -0,0 +1,11 @@
meta {
name: Parrot
type: http
seq: 1
}
get {
url: https://echo.usebruno.com
body: none
auth: none
}

View File

@@ -0,0 +1,8 @@
meta {
name: Aviary
seq: 2
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,11 @@
meta {
name: ReqAlpha
type: http
seq: 2
}
get {
url: https://echo.usebruno.com
body: none
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: ReqBeta
type: http
seq: 1
}
get {
url: https://echo.usebruno.com
body: none
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: Bear
type: http
seq: 2
}
get {
url: https://echo.usebruno.com
body: none
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: Lion
type: http
seq: 1
}
get {
url: https://echo.usebruno.com
body: none
auth: none
}

View File

@@ -0,0 +1,8 @@
meta {
name: Zoo
seq: 1
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,5 @@
{
"version": "1",
"name": "GenerateDocsOrder",
"type": "collection"
}

View File

@@ -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<Record<string, any>> };
return openCollectionItemsToNameTree(openCollection.items);
};
/** Reduce OpenCollection items (name lives at `info.name`) to a NameTree. */
const openCollectionItemsToNameTree = (items: Array<Record<string, any>> = []): 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();
});
});

View File

@@ -0,0 +1,11 @@
{
"lastOpenedCollections": [
"{{collectionPath}}"
],
"preferences": {
"onboarding": {
"hasLaunchedBefore": true,
"hasSeenWelcomeModal": true
}
}
}

View File

@@ -2,6 +2,7 @@ import { test, expect, Page, ElectronApplication, waitForReadyPage as waitForRea
import process from 'node:process'; import process from 'node:process';
import * as path from 'path'; import * as path from 'path';
import { buildCommonLocators, buildScriptErrorLocators, buildGrpcCommonLocators } from './locators'; import { buildCommonLocators, buildScriptErrorLocators, buildGrpcCommonLocators } from './locators';
import { waitForCollectionMount } from './mounting';
type SandboxMode = 'safe' | 'developer'; 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(); 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 `<a download>` 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 `<a download>` (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<string>((resolve) => {
URL.createObjectURL = function (obj: Blob | MediaSource) {
if (obj instanceof Blob) {
obj.text().then(resolve);
}
return originalCreate(obj as Blob);
};
});
w.__docsFileName = new Promise<string>((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<string>);
const fileName = await page.evaluate(() => (window as any).__docsFileName as Promise<string>);
// The modal closes itself on the success path.
await expect(modal).toBeHidden({ timeout: 5000 });
return { content, fileName };
});
};
export { export {
waitForReadyPage, waitForReadyPage,
dismissImportIssuesToasts, dismissImportIssuesToasts,
@@ -1825,7 +1911,8 @@ export {
getGeneratedSnippet, getGeneratedSnippet,
closeGenerateCodeDialog, closeGenerateCodeDialog,
openRequestInFolder, openRequestInFolder,
setUrlEncoding setUrlEncoding,
generateCollectionDocs
}; };
export type { SandboxMode, EnvironmentType, EnvironmentVariable, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions, CreateTransientRequestOptions, AssertionInput }; export type { SandboxMode, EnvironmentType, EnvironmentVariable, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions, CreateTransientRequestOptions, AssertionInput };

View File

@@ -113,6 +113,15 @@ export const buildCommonLocators = (page: Page) => ({
input: () => page.getByTestId('tag-input').getByRole('textbox'), input: () => page.getByTestId('tag-input').getByRole('textbox'),
item: (tagName: string) => page.locator('.tag-item', { hasText: tagName }) 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: { runnerResults: {
itemPath: (name: string) => page.getByTestId('runner-result-item').filter({ hasText: name }) itemPath: (name: string) => page.getByTestId('runner-result-item').filter({ hasText: name })
}, },