mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
fix(size/L): Preserve folder order from seq attribute (#8213)
This commit is contained in:
8
package-lock.json
generated
8
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: Parrot
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://echo.usebruno.com
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: Aviary
|
||||
seq: 2
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: ReqAlpha
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://echo.usebruno.com
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: ReqBeta
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://echo.usebruno.com
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: Bear
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://echo.usebruno.com
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: Lion
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://echo.usebruno.com
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: Zoo
|
||||
seq: 1
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "GenerateDocsOrder",
|
||||
"type": "collection"
|
||||
}
|
||||
125
tests/collection/generate-docs/generate-docs.spec.ts
Normal file
125
tests/collection/generate-docs/generate-docs.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"lastOpenedCollections": [
|
||||
"{{collectionPath}}"
|
||||
],
|
||||
"preferences": {
|
||||
"onboarding": {
|
||||
"hasLaunchedBefore": true,
|
||||
"hasSeenWelcomeModal": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 `<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 {
|
||||
waitForReadyPage,
|
||||
dismissImportIssuesToasts,
|
||||
@@ -1825,7 +1911,8 @@ export {
|
||||
getGeneratedSnippet,
|
||||
closeGenerateCodeDialog,
|
||||
openRequestInFolder,
|
||||
setUrlEncoding
|
||||
setUrlEncoding,
|
||||
generateCollectionDocs
|
||||
};
|
||||
|
||||
export type { SandboxMode, EnvironmentType, EnvironmentVariable, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions, CreateTransientRequestOptions, AssertionInput };
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user