From f78b76e95fa6c540145eaaf76df45893a9c145a8 Mon Sep 17 00:00:00 2001 From: e576075 Date: Thu, 18 Jun 2026 15:05:51 +0200 Subject: [PATCH 1/7] Implement selective folder runs --- .../RunConfigurationPanel/index.jsx | 36 +-- .../src/components/RunnerResults/index.jsx | 19 +- .../CollectionItem/RunCollectionItem/index.js | 216 ++++++++++++------ 3 files changed, 188 insertions(+), 83 deletions(-) diff --git a/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/index.jsx b/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/index.jsx index 436fb2e1787..dac831e1432 100644 --- a/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/index.jsx +++ b/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/index.jsx @@ -6,7 +6,7 @@ import { useDispatch } from 'react-redux'; import { updateRunnerConfiguration } from 'providers/ReduxStore/slices/collections/actions'; import StyledWrapper from './StyledWrapper'; import { isItemARequest, isItemAFolder } from 'utils/collections'; -import { sortByNameThenSequence } from 'utils/common/index'; +import { sortByNameThenSequence } from 'utils/common'; import path from 'utils/common/path'; import { cloneDeep, get } from 'lodash'; import Button from 'ui/Button/index'; @@ -172,7 +172,7 @@ const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop, isDi ); }; -const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems, tags }) => { +const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems, tags, persistConfiguration = true }) => { const dispatch = useDispatch(); const [flattenedRequests, setFlattenedRequests] = useState([]); const [originalRequests, setOriginalRequests] = useState([]); @@ -182,6 +182,14 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems, ta // Track items that were auto-deselected due to tag filters, so we can re-select them when tags change back const pendingReselectRef = useRef(new Set()); + const persistRunnerConfiguration = useCallback((selectedRequestUids, requestItemsOrder) => { + if (!persistConfiguration) { + return; + } + + dispatch(updateRunnerConfiguration(collection.uid, selectedRequestUids, requestItemsOrder)); + }, [collection.uid, dispatch, persistConfiguration]); + const flattenRequests = useCallback((collection) => { const result = []; @@ -292,9 +300,9 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems, ta .map((r) => r.uid); setSelectedItems(ordered); const allRequestUidsOrder = flattenedRequests.map((item) => item.uid); - dispatch(updateRunnerConfiguration(collection.uid, ordered, allRequestUidsOrder)); + persistRunnerConfiguration(ordered, allRequestUidsOrder); } - }, [tags, flattenedRequests]); + }, [tags, flattenedRequests, selectedItems, setSelectedItems, persistRunnerConfiguration]); const enabledRequests = flattenedRequests.filter((item) => !isRequestDisabled(item, tags)); const enabledCount = enabledRequests.length; @@ -326,11 +334,11 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems, ta const allRequestUidsOrder = currentRequests.map((item) => item.uid); setSelectedItems(newOrderedSelectedUids); - dispatch(updateRunnerConfiguration(collection.uid, newOrderedSelectedUids, allRequestUidsOrder)); + persistRunnerConfiguration(newOrderedSelectedUids, allRequestUidsOrder); return currentRequests; }); - }, [selectedItems, collection.uid, dispatch, setSelectedItems]); + }, [selectedItems, setSelectedItems, persistRunnerConfiguration]); const handleRequestSelect = useCallback((item) => { if (isRequestDisabled(item, tags)) return; @@ -341,7 +349,7 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems, ta setSelectedItems(newSelectedUids); const allRequestUidsOrder = flattenedRequests.map((item) => item.uid); - dispatch(updateRunnerConfiguration(collection.uid, newSelectedUids, allRequestUidsOrder)); + persistRunnerConfiguration(newSelectedUids, allRequestUidsOrder); } else { const newSelectedUids = [...selectedItems, item.uid]; @@ -352,12 +360,12 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems, ta setSelectedItems(orderedSelectedUids); const allRequestUidsOrder = flattenedRequests.map((item) => item.uid); - dispatch(updateRunnerConfiguration(collection.uid, orderedSelectedUids, allRequestUidsOrder)); + persistRunnerConfiguration(orderedSelectedUids, allRequestUidsOrder); } } catch (error) { console.error('Error selecting item:', error); } - }, [selectedItems, setSelectedItems, flattenedRequests, dispatch, collection.uid, tags]); + }, [selectedItems, setSelectedItems, flattenedRequests, persistRunnerConfiguration, tags]); const handleSelectAll = useCallback(() => { try { @@ -367,15 +375,15 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems, ta if (selectedItems.length === enabledCount) { pendingReselectRef.current.clear(); setSelectedItems([]); - dispatch(updateRunnerConfiguration(collection.uid, [], allRequestUidsOrder)); + persistRunnerConfiguration([], allRequestUidsOrder); } else { setSelectedItems(enabledUids); - dispatch(updateRunnerConfiguration(collection.uid, enabledUids, allRequestUidsOrder)); + persistRunnerConfiguration(enabledUids, allRequestUidsOrder); } } catch (error) { console.error('Error selecting/deselecting all items:', error); } - }, [flattenedRequests, enabledRequests, enabledCount, selectedItems, setSelectedItems, dispatch, collection.uid]); + }, [flattenedRequests, enabledRequests, enabledCount, selectedItems, setSelectedItems, persistRunnerConfiguration]); const handleReset = useCallback(() => { try { @@ -387,11 +395,11 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems, ta .map((item) => item.uid); setSelectedItems(enabledUids); const allUidsOrder = resetRequests.map((item) => item.uid); - dispatch(updateRunnerConfiguration(collection.uid, enabledUids, allUidsOrder)); + persistRunnerConfiguration(enabledUids, allUidsOrder); } catch (error) { console.error('Error resetting configuration:', error); } - }, [originalRequests, setSelectedItems, collection.uid, dispatch, tags]); + }, [originalRequests, setSelectedItems, tags, persistRunnerConfiguration]); return ( diff --git a/packages/bruno-app/src/components/RunnerResults/index.jsx b/packages/bruno-app/src/components/RunnerResults/index.jsx index 03cff64582c..1b28ae88da8 100644 --- a/packages/bruno-app/src/components/RunnerResults/index.jsx +++ b/packages/bruno-app/src/components/RunnerResults/index.jsx @@ -24,6 +24,14 @@ const getTestStatus = (results) => { return failed.length ? 'fail' : 'pass'; }; +export const getSelectedRequestItemsForRunAgain = ({ runnerInfo, items, savedConfiguration }) => { + if (runnerInfo?.folderUid) { + return items.map((item) => item.uid); + } + + return savedConfiguration?.selectedRequestItems || []; +}; + const allTestsPassed = (item) => { return item.status !== 'error' && item.testStatus === 'pass' @@ -193,10 +201,15 @@ export default function RunnerResults({ collection }) { const runAgain = () => { ensureCollectionIsMounted(); isReRunningRef.current = true; - // Get the saved configuration to determine what to run + const savedConfiguration = get(collection, 'runnerConfiguration', null); - const savedSelectedItems = savedConfiguration?.selectedRequestItems || []; const savedDelay = savedConfiguration?.delay !== undefined ? savedConfiguration.delay : delay; + const selectedItemsForRunAgain = getSelectedRequestItemsForRunAgain({ + runnerInfo, + items, + savedConfiguration + }); + dispatch( runCollectionFolder( collection.uid, @@ -204,7 +217,7 @@ export default function RunnerResults({ collection }) { true, Number(savedDelay), tags, - savedSelectedItems + selectedItemsForRunAgain ) ); }; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js index a96afcaae91..d30568a4103 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js @@ -1,20 +1,36 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import get from 'lodash/get'; import { uuid } from 'utils/common'; import Modal from 'components/Modal'; import { useDispatch, useSelector } from 'react-redux'; import { addTab } from 'providers/ReduxStore/slices/tabs'; import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/actions'; -import { flattenItems } from 'utils/collections'; +import { areItemsLoading, getRequestItemsForCollectionRun } from 'utils/collections'; import StyledWrapper from './StyledWrapper'; -import { areItemsLoading } from 'utils/collections'; import RunnerTags from 'components/RunnerResults/RunnerTags/index'; -import { getRequestItemsForCollectionRun } from 'utils/collections/index'; +import RunConfigurationPanel from 'components/RunnerResults/RunConfigurationPanel'; import Button from 'ui/Button'; -const RunCollectionItem = ({ collectionUid, item, onClose }) => { +const buildRunConfigurationCollection = (collection, runTargetFolder) => { + if (!collection) { + return null; + } + + if (!runTargetFolder) { + return collection; + } + + return { + ...collection, + items: runTargetFolder.items || [], + pathname: runTargetFolder.pathname + }; +}; + +const RunCollectionItem = ({ collectionUid, item: runTargetFolder, onClose }) => { const dispatch = useDispatch(); const [delay, setDelay] = useState(''); + const [selectedRequestItems, setSelectedRequestItems] = useState([]); const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid)); const isCollectionRunInProgress = collection?.runnerResult?.info?.status && (collection?.runnerResult?.info?.status !== 'ended'); @@ -22,7 +38,7 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => { // tags for the collection run const tags = get(collection, 'runnerTags', { include: [], exclude: [] }); - const onSubmit = (recursive) => { + const onSubmit = ({ recursive, selectedRequestUids }) => { dispatch( addTab({ uid: uuid(), @@ -31,7 +47,14 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => { }) ); if (!isCollectionRunInProgress) { - dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive, delay ? Number(delay) : null, tags)); + dispatch(runCollectionFolder( + collection.uid, + runTargetFolder ? runTargetFolder.uid : null, + recursive, + delay ? Number(delay) : null, + tags, + selectedRequestUids + )); } onClose(); }; @@ -48,77 +71,138 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => { onClose(); }; - const isFolderLoading = areItemsLoading(item); + const isFolderLoading = areItemsLoading(runTargetFolder); - const requestItemsForRecursiveFolderRun = getRequestItemsForCollectionRun({ recursive: true, tags, items: item ? item.items : collection.items }); + const requestItemsForRecursiveFolderRun = getRequestItemsForCollectionRun({ + recursive: true, + tags, + items: runTargetFolder ? runTargetFolder.items : collection.items + }); const totalRequestItemsCountForRecursiveFolderRun = requestItemsForRecursiveFolderRun.length; const shouldDisableRecursiveFolderRun = totalRequestItemsCountForRecursiveFolderRun <= 0; - const requestItemsForFolderRun = getRequestItemsForCollectionRun({ recursive: false, tags, items: item ? item.items : collection.items }); + const requestItemsForFolderRun = getRequestItemsForCollectionRun({ + recursive: false, + tags, + items: runTargetFolder ? runTargetFolder.items : collection.items + }); const totalRequestItemsCountForFolderRun = requestItemsForFolderRun.length; const shouldDisableFolderRun = totalRequestItemsCountForFolderRun <= 0; + const selectedCount = selectedRequestItems.length; + + // Using memoization to avoid unwanted resets of users' request de-/selection and/or rearrangement + const runConfigCollection = useMemo( + () => buildRunConfigurationCollection(collection, runTargetFolder), + [collection, runTargetFolder] + ); + return ( - -
-
- Run - ({totalRequestItemsCountForFolderRun} requests) -
-
This will only run the requests in this folder.
-
- Recursive Run - ({totalRequestItemsCountForRecursiveFolderRun} requests) -
-
This will run all the requests in this folder and all its subfolders.
- {isFolderLoading ?
Requests in this folder are still loading.
: null} - {isCollectionRunInProgress ?
A Collection Run is already in progress.
: null} - -
- - {/* Timings */} -
- - setDelay(e.target.value)} - /> + +
+ {/* Left panel: options */} +
+
+ Run + ({totalRequestItemsCountForFolderRun} requests) +
+
This will only run the requests in this folder.
+
+ Recursive Run + ({totalRequestItemsCountForRecursiveFolderRun} requests) +
+
This will run all the requests in this folder and all its subfolders.
+ {isFolderLoading ?
Requests in this folder are still loading.
: null} + {isCollectionRunInProgress ?
A Collection Run is already in progress.
: null} + +
+ + {/* Timings */} +
+ + setDelay(e.target.value)} + /> +
+ + {/* Tags for the collection run */} + + +
+ + { + isCollectionRunInProgress + ? ( + + ) + : ( + <> + + + + ) + } +
- {/* Tags for the collection run */} - - -
- - { - isCollectionRunInProgress - ? ( - - ) - : ( - <> - - - - ) - } +
+ )} + + )}
From 3c9674e571512b672695cc7833ea7915f411a3c5 Mon Sep 17 00:00:00 2001 From: e576075 Date: Tue, 23 Jun 2026 12:17:14 +0200 Subject: [PATCH 2/7] Add Playwright tests for selective folder runs --- tests/runner/selective-folder-run.spec.ts | 131 ++++++++++++++++++ tests/utils/page/runner.ts | 161 ++++++++++++++++------ 2 files changed, 253 insertions(+), 39 deletions(-) create mode 100644 tests/runner/selective-folder-run.spec.ts diff --git a/tests/runner/selective-folder-run.spec.ts b/tests/runner/selective-folder-run.spec.ts new file mode 100644 index 00000000000..715e02f46cf --- /dev/null +++ b/tests/runner/selective-folder-run.spec.ts @@ -0,0 +1,131 @@ +import { test, expect } from '../../playwright'; +import { + getRunnerResultCounts, + getRunnerSelectionCounters, + setSandboxMode, + selectEnvironment, + openFolderRunModal, + getRequestItemsInFolderModal, + runSelectedRequestsInFolder +} from '../utils/page'; + +const TEST_COLLECTION_NAME = 'bruno-testbench'; +const TEST_FOLDER_PATH = ['scripting', 'api', 'bru', 'cookies']; + +test.describe.serial('Selective Folder Run', () => { + const getSelectiveRunButton = (page) => page.locator('button').filter({ hasText: /Run \d+ Request/ }).first(); + const getRunButton = (page) => page.locator('button').filter({ hasText: /^Run \(/ }); + const getRecursiveRunButton = (page) => page.locator('button').filter({ hasText: /^Recursive Run \(/ }); + const openCookiesFolderRunModal = async (page) => { + await setSandboxMode(page, TEST_COLLECTION_NAME, 'developer'); + await selectEnvironment(page, 'Local'); + await openFolderRunModal(page, TEST_COLLECTION_NAME, TEST_FOLDER_PATH); + }; + const readButtonCount = async (locator) => { + const text = await locator.innerText(); + const match = text.match(/\d+/); + return match ? parseInt(match[0]) : 0; + }; + const closeRunnerModal = async (page) => { + await page.keyboard.press('Escape').catch(() => {}); + await page.locator('.bruno-modal-backdrop').first().waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {}); + }; + + test.beforeEach(async ({ pageWithUserData: page }) => { + await openCookiesFolderRunModal(page); + }); + + test('shows request picker in folder run modal with all requests selected by default', async ({ pageWithUserData: page }) => { + const requestItems = getRequestItemsInFolderModal(page); + const itemCount = await requestItems.count(); + expect(itemCount).toBeGreaterThan(0); + + const selectedCount = await readButtonCount(getSelectiveRunButton(page)); + + await expect(getRunButton(page)).toBeVisible(); + await expect(getRecursiveRunButton(page)).toBeVisible(); + await expect(getSelectiveRunButton(page)).toBeVisible(); + expect(selectedCount).toBe(itemCount); + + // Assert the "N of M selected" indicator: N should equal M (all selected) + const selectionCounters = await getRunnerSelectionCounters(page); + expect(selectionCounters.selected).toBe(itemCount); + + await closeRunnerModal(page); + }); + + test('disables selective run when no requests are selected', async ({ pageWithUserData: page }) => { + await expect(async () => { + const currentSelected = await readButtonCount(getSelectiveRunButton(page)); + if (currentSelected !== 0) { + await page.getByTestId('runner-select-all').click(); + } + expect(await readButtonCount(getSelectiveRunButton(page))).toBe(0); + }).toPass({ timeout: 5000 }); + + await expect(getSelectiveRunButton(page)).toBeDisabled(); + + // Assert the "N of M selected" indicator: N should be 0 + const selectionCounters = await getRunnerSelectionCounters(page); + expect(selectionCounters.selected).toBe(0); + expect(selectionCounters.total).toBeGreaterThan(0); + + await closeRunnerModal(page); + }); + + test('runs only selected subset from folder request picker', async ({ pageWithUserData: page }) => { + const requestItems = getRequestItemsInFolderModal(page); + const initialCount = await requestItems.count(); + expect(initialCount).toBeGreaterThan(1); + + // Assert initial counter: all should be selected (N == M) + let selectionCounters = await getRunnerSelectionCounters(page); + expect(selectionCounters.selected).toBe(selectionCounters.total); + expect(selectionCounters.total).toBe(initialCount); + + const deselectedRequestIndex = 0; + const deselectedRequestName = (await requestItems + .nth(deselectedRequestIndex) + .locator('.request-name > span:first-child') + .innerText()).trim(); + await requestItems.nth(deselectedRequestIndex).locator('.checkbox-container').click(); + await expect(async () => { + expect(await readButtonCount(getSelectiveRunButton(page))).toBe(initialCount - 1); + }).toPass({ timeout: 5000 }); + + // Assert counter after deselecting one: N should be M-1 + selectionCounters = await getRunnerSelectionCounters(page); + expect(selectionCounters.selected).toBe(initialCount - 1); + expect(selectionCounters.total).toBe(initialCount); + + await runSelectedRequestsInFolder(page); + + const { totalRequests, failed } = await getRunnerResultCounts(page); + expect(totalRequests).toBe(initialCount - 1); + expect(failed).toBe(0); + + const resultDisplayNames = (await page.getByTestId('runner-result-item').locator('span.mr-1.ml-2').allInnerTexts()) + .map((name) => name.trim()); + expect(resultDisplayNames).not.toContain(deselectedRequestName); + }); + + test('non-recursive run count is less than or equal to recursive run count', async ({ pageWithUserData: page }) => { + await expect(getRunButton(page)).toBeVisible(); + await expect(getRecursiveRunButton(page)).toBeVisible(); + + const nonRecursiveCount = await readButtonCount(getRunButton(page)); + const recursiveCount = await readButtonCount(getRecursiveRunButton(page)); + + // Non-recursive only runs direct requests in the folder, not nested subfolders, + // so it must always be ≤ the recursive count. + expect(nonRecursiveCount).toBeGreaterThan(0); + expect(recursiveCount).toBeGreaterThanOrEqual(nonRecursiveCount); + + // Assert the "N of M selected" indicator: N should equal M (all selected, all enabled/recursive) + const selectionCounters = await getRunnerSelectionCounters(page); + expect(selectionCounters.total).toBe(recursiveCount); + expect(selectionCounters.selected).toBe(selectionCounters.total); + + await closeRunnerModal(page); + }); +}); diff --git a/tests/utils/page/runner.ts b/tests/utils/page/runner.ts index 7e69504aa9a..b5148dbf9db 100644 --- a/tests/utils/page/runner.ts +++ b/tests/utils/page/runner.ts @@ -38,6 +38,27 @@ export const getRunnerResultCounts = async (page: Page) => { return { totalRequests, passed, failed, skipped }; }; +/** + * Reads the "N of M selected" indicator from the runner configuration counter + * @param page - The Playwright page object + * @returns An object with selected count (N) and total count (M) + */ +export const getRunnerSelectionCounters = async (page: Page) => { + const locators = buildRunnerLocators(page); + const counterText = await locators.configCounter().innerText(); + + // Parse "N of M selected" format + const match = counterText.match(/(\d+)\s+of\s+(\d+)\s+selected/); + if (!match) { + throw new Error(`Unable to parse counter text: "${counterText}"`); + } + + return { + selected: parseInt(match[1]), + total: parseInt(match[2]) + }; +}; + /** * Opens the runner tab for a collection without starting a run * @param page - The Playwright page object @@ -114,6 +135,54 @@ export const runCollection = async (page: Page, collectionName: string) => { }); }; +/** + * Navigates to a folder in the sidebar and clicks "Run" from its context menu + * @param page - The Playwright page object + * @param collectionName - The name of the collection containing the folder + * @param folderPath - Array of folder names forming the path, (e.g. ['scripting', 'api', 'bru', 'cookies']) + */ +const openFolderRunMenu = async (page: Page, collectionName: string, folderPath: string[]) => { + // Scope to the specific collection by its DOM id (collection-) + const collectionId = `collection-${collectionName.replace(/\s+/g, '-').toLowerCase()}`; + const collectionContainer = page.locator(`#${collectionId}`); + await collectionContainer.waitFor({ state: 'visible', timeout: 5000 }); + + // Walk down the folder path, scoping each step to the previous folder's container. + // Each CollectionItem renders as a StyledWrapper div containing: + // - div.collection-item-name (the row with chevron, name, menu) + // - div (children container when expanded) + // We scope to the parent wrapper so the next folder lookup is unambiguous. + let scope = collectionContainer; + for (const folderName of folderPath) { + const row = scope.locator('.collection-item-name').filter({ hasText: folderName }).first(); + await row.waitFor({ state: 'visible', timeout: 5000 }); + + // Click the chevron to expand (skip if already expanded) + const chevron = row.getByTestId('folder-chevron'); + const isExpanded = await chevron.evaluate((el: HTMLElement) => el.classList.contains('rotate-90')); + if (!isExpanded) { + await chevron.click(); + } + + // Scope to this folder's wrapper (parent of the row) for the next iteration + scope = row.locator('..'); + } + + // The target folder row is the last one we found — hover to reveal menu + const targetRow = scope.locator('.collection-item-name').filter({ hasText: folderPath[folderPath.length - 1] }).first(); + await targetRow.hover(); + + // Click the menu icon + const menuIcon = targetRow.locator('.menu-icon'); + await menuIcon.waitFor({ state: 'visible', timeout: 5000 }); + await menuIcon.click(); + + // Click "Run" in the dropdown + const runMenuItem = page.locator('.dropdown-item').filter({ hasText: 'Run' }); + await runMenuItem.waitFor({ state: 'visible' }); + await runMenuItem.click(); +}; + /** * Runs a specific folder within a collection by navigating to it in the sidebar, * opening its context menu, and clicking "Run" followed by "Recursive Run". @@ -123,45 +192,7 @@ export const runCollection = async (page: Page, collectionName: string) => { */ export const runFolder = async (page: Page, collectionName: string, folderPath: string[]) => { await test.step(`Run folder "${folderPath.join('/')}" in "${collectionName}"`, async () => { - // Scope to the specific collection by its DOM id (collection-) - const collectionId = `collection-${collectionName.replace(/\s+/g, '-').toLowerCase()}`; - const collectionContainer = page.locator(`#${collectionId}`); - await collectionContainer.waitFor({ state: 'visible', timeout: 5000 }); - - // Walk down the folder path, scoping each step to the previous folder's container. - // Each CollectionItem renders as a StyledWrapper div containing: - // - div.collection-item-name (the row with chevron, name, menu) - // - div (children container when expanded) - // We scope to the parent wrapper so the next folder lookup is unambiguous. - let scope = collectionContainer; - for (const folderName of folderPath) { - const row = scope.locator('.collection-item-name').filter({ hasText: folderName }).first(); - await row.waitFor({ state: 'visible', timeout: 5000 }); - - // Click the chevron to expand (skip if already expanded) - const chevron = row.getByTestId('folder-chevron'); - const isExpanded = await chevron.evaluate((el: HTMLElement) => el.classList.contains('rotate-90')); - if (!isExpanded) { - await chevron.click(); - } - - // Scope to this folder's wrapper (parent of the row) for the next iteration - scope = row.locator('..'); - } - - // The target folder row is the last one we found — hover to reveal menu - const targetRow = scope.locator('.collection-item-name').filter({ hasText: folderPath[folderPath.length - 1] }).first(); - await targetRow.hover(); - - // Click the menu icon - const menuIcon = targetRow.locator('.menu-icon'); - await menuIcon.waitFor({ state: 'visible', timeout: 5000 }); - await menuIcon.click(); - - // Click "Run" in the dropdown - const runMenuItem = page.locator('.dropdown-item').filter({ hasText: 'Run' }); - await runMenuItem.waitFor({ state: 'visible' }); - await runMenuItem.click(); + await openFolderRunMenu(page, collectionName, folderPath); // In the RunCollectionItem modal, click "Recursive Run" const recursiveRunButton = page.getByRole('button', { name: 'Recursive Run' }); @@ -250,3 +281,55 @@ export const validateRunnerResults = async (page: Page, // Validate that passed + failed + skipped = totalRequests await expect(passed).toBe(totalRequests - skipped - failed); }; + +/** + * Opens the folder run modal without executing a run + * Navigates to a folder in the sidebar and clicks "Run" from its context menu + * @param page - The Playwright page object + * @param collectionName - The name of the collection containing the folder + * @param folderPath - Array of folder names forming the path + */ +export const openFolderRunModal = async (page: Page, collectionName: string, folderPath: string[]) => { + await test.step(`Open folder run modal for "${folderPath.join('/')}"`, async () => { + await openFolderRunMenu(page, collectionName, folderPath); + + // Wait for modal to appear + const modal = page.locator('.bruno-modal-card').filter({ hasText: /Collection Runner/i }); + await modal.waitFor({ state: 'visible', timeout: 10000 }); + + // Wait for request items to load + await expect.poll( + async () => { + const items = await page.locator('[data-testid="runner-request-item"]').count(); + return items > 0; + }, + { timeout: 10000 } + ).toBeTruthy(); + }); +}; + +/** + * Gets all request item locators visible in the folder run modal + * @param page - The Playwright page object + * @returns Locator for all request items + */ +export const getRequestItemsInFolderModal = (page: Page) => { + return page.locator('[data-testid="runner-request-item"]'); +}; + +/** + * Clicks the "Run X Request(s)" button to execute selected requests in folder modal + * @param page - The Playwright page object + */ +export const runSelectedRequestsInFolder = async (page: Page) => { + await test.step('Run selected requests from folder modal', async () => { + const runButton = page.locator('button').filter({ hasText: /Run \d+ Request/ }).first(); + await runButton.waitFor({ state: 'visible', timeout: 5000 }); + await expect(runButton).toBeEnabled(); + await runButton.click(); + + // Wait for the runner results to display + const runnerLocators = buildRunnerLocators(page); + await runnerLocators.runAgainButton().waitFor({ timeout: 2 * 60 * 1000 }); + }); +}; From 68af51aa1cdcb8e0887f97b917a8733956768712 Mon Sep 17 00:00:00 2001 From: timolex Date: Tue, 23 Jun 2026 16:55:06 +0200 Subject: [PATCH 3/7] Fix JSDoc for getRunnerSelectionCounters in tests/utils/page/runner.ts As suggested by GitHub Copilot during AI PR review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/utils/page/runner.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/page/runner.ts b/tests/utils/page/runner.ts index b5148dbf9db..b50ca9ef4c9 100644 --- a/tests/utils/page/runner.ts +++ b/tests/utils/page/runner.ts @@ -41,7 +41,7 @@ export const getRunnerResultCounts = async (page: Page) => { /** * Reads the "N of M selected" indicator from the runner configuration counter * @param page - The Playwright page object - * @returns An object with selected count (N) and total count (M) + * @returns An object with selected count (N) and enabled (selectable) count (M) */ export const getRunnerSelectionCounters = async (page: Page) => { const locators = buildRunnerLocators(page); From 4e69c3ffe470eb5cfe0dd556bbe393271c942fe4 Mon Sep 17 00:00:00 2001 From: e576075 Date: Tue, 23 Jun 2026 17:17:48 +0200 Subject: [PATCH 4/7] Fix as suggested in https://github.com/usebruno/bruno/pull/8346#discussion_r3460624515 --- tests/runner/selective-folder-run.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/runner/selective-folder-run.spec.ts b/tests/runner/selective-folder-run.spec.ts index 715e02f46cf..4efdd6adcff 100644 --- a/tests/runner/selective-folder-run.spec.ts +++ b/tests/runner/selective-folder-run.spec.ts @@ -12,7 +12,7 @@ import { const TEST_COLLECTION_NAME = 'bruno-testbench'; const TEST_FOLDER_PATH = ['scripting', 'api', 'bru', 'cookies']; -test.describe.serial('Selective Folder Run', () => { +test.describe('Selective Folder Run', () => { const getSelectiveRunButton = (page) => page.locator('button').filter({ hasText: /Run \d+ Request/ }).first(); const getRunButton = (page) => page.locator('button').filter({ hasText: /^Run \(/ }); const getRecursiveRunButton = (page) => page.locator('button').filter({ hasText: /^Recursive Run \(/ }); From f808ee7e79fc9f30df87bd7c44bbe2d7cd05b46d Mon Sep 17 00:00:00 2001 From: e576075 Date: Tue, 23 Jun 2026 17:21:19 +0200 Subject: [PATCH 5/7] Fix as suggested in https://github.com/usebruno/bruno/pull/8346#discussion_r3460624523, but using waitFor instead of toBeHidden, which had the tests fail --- tests/runner/selective-folder-run.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/runner/selective-folder-run.spec.ts b/tests/runner/selective-folder-run.spec.ts index 4efdd6adcff..443def946c4 100644 --- a/tests/runner/selective-folder-run.spec.ts +++ b/tests/runner/selective-folder-run.spec.ts @@ -27,8 +27,8 @@ test.describe('Selective Folder Run', () => { return match ? parseInt(match[0]) : 0; }; const closeRunnerModal = async (page) => { - await page.keyboard.press('Escape').catch(() => {}); - await page.locator('.bruno-modal-backdrop').first().waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {}); + await page.keyboard.press('Escape'); + await page.locator('.bruno-modal-backdrop').first().waitFor({ state: 'hidden', timeout: 3000 }); }; test.beforeEach(async ({ pageWithUserData: page }) => { From 8e9fbab40ee3a71894b8c7d57034daa3322eea7b Mon Sep 17 00:00:00 2001 From: e576075 Date: Tue, 23 Jun 2026 17:25:55 +0200 Subject: [PATCH 6/7] Fix as suggested in https://github.com/usebruno/bruno/pull/8346#discussion_r3460624527 --- .../RunnerResults/RunConfigurationPanel/index.jsx | 8 ++++++-- packages/bruno-app/src/components/RunnerResults/index.jsx | 5 +++-- tests/runner/selective-folder-run.spec.ts | 6 +++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/index.jsx b/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/index.jsx index dac831e1432..c85ee7a5d5b 100644 --- a/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/index.jsx +++ b/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/index.jsx @@ -152,7 +152,11 @@ const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop, isDi
-
!isDisabled && onSelect(item)}> +
!isDisabled && onSelect(item)} + >
{isSelected && !isDisabled && }
@@ -163,7 +167,7 @@ const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop, isDi
- {item.name} + {item.name} {item.folderPath && ( {item.folderPath} )} diff --git a/packages/bruno-app/src/components/RunnerResults/index.jsx b/packages/bruno-app/src/components/RunnerResults/index.jsx index 1b28ae88da8..da68827f467 100644 --- a/packages/bruno-app/src/components/RunnerResults/index.jsx +++ b/packages/bruno-app/src/components/RunnerResults/index.jsx @@ -419,7 +419,8 @@ export default function RunnerResults({ collection }) { : null} {item.displayName} @@ -442,7 +443,7 @@ export default function RunnerResults({ collection }) { Tags: {item.tags.filter((t) => tags.include.includes(t)).join(', ')}
)} - {item.status == 'error' ?
{item.error}
: null} + {item.status === 'error' ?
{item.error}
: null}
    {item.preRequestTestResults diff --git a/tests/runner/selective-folder-run.spec.ts b/tests/runner/selective-folder-run.spec.ts index 443def946c4..b39d03f9753 100644 --- a/tests/runner/selective-folder-run.spec.ts +++ b/tests/runner/selective-folder-run.spec.ts @@ -86,9 +86,9 @@ test.describe('Selective Folder Run', () => { const deselectedRequestIndex = 0; const deselectedRequestName = (await requestItems .nth(deselectedRequestIndex) - .locator('.request-name > span:first-child') + .getByTestId('runner-request-item-name') .innerText()).trim(); - await requestItems.nth(deselectedRequestIndex).locator('.checkbox-container').click(); + await requestItems.nth(deselectedRequestIndex).getByTestId('runner-request-item-checkbox').click(); await expect(async () => { expect(await readButtonCount(getSelectiveRunButton(page))).toBe(initialCount - 1); }).toPass({ timeout: 5000 }); @@ -104,7 +104,7 @@ test.describe('Selective Folder Run', () => { expect(totalRequests).toBe(initialCount - 1); expect(failed).toBe(0); - const resultDisplayNames = (await page.getByTestId('runner-result-item').locator('span.mr-1.ml-2').allInnerTexts()) + const resultDisplayNames = (await page.getByTestId('runner-result-item-name').allInnerTexts()) .map((name) => name.trim()); expect(resultDisplayNames).not.toContain(deselectedRequestName); }); From 7e908aa81dffb34bf6937b1993c2efd90246fdcb Mon Sep 17 00:00:00 2001 From: e576075 Date: Tue, 23 Jun 2026 17:52:10 +0200 Subject: [PATCH 7/7] Fix as suggested in https://github.com/usebruno/bruno/pull/8346#discussion_r3460624530 --- tests/utils/page/runner.ts | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/tests/utils/page/runner.ts b/tests/utils/page/runner.ts index b50ca9ef4c9..c2ff0964132 100644 --- a/tests/utils/page/runner.ts +++ b/tests/utils/page/runner.ts @@ -1,4 +1,4 @@ -import { Page, expect, test } from '../../../playwright'; +import { Page, expect, test, Locator } from '../../../playwright'; import { buildSandboxLocators } from './locators'; /** @@ -147,15 +147,23 @@ const openFolderRunMenu = async (page: Page, collectionName: string, folderPath: const collectionContainer = page.locator(`#${collectionId}`); await collectionContainer.waitFor({ state: 'visible', timeout: 5000 }); - // Walk down the folder path, scoping each step to the previous folder's container. - // Each CollectionItem renders as a StyledWrapper div containing: - // - div.collection-item-name (the row with chevron, name, menu) - // - div (children container when expanded) - // We scope to the parent wrapper so the next folder lookup is unambiguous. - let scope = collectionContainer; + // Escape regex metacharacters so folder names are treated as plain text in exact-match patterns. + const REGEXP_SPECIAL_CHARACTERS = /[\\^$.*+?()[\]{}|]/g; + const escapeRegExp = (value: string) => value.replace(REGEXP_SPECIAL_CHARACTERS, '\\$&'); + + // Top-level collection items are rendered in the collection content container. + let scope = collectionContainer.locator(':scope > div').last().locator(':scope > div').first(); + await scope.waitFor({ state: 'visible', timeout: 5000 }); + + // Walk down one hierarchy level at a time using only immediate child rows. + let targetRow: Locator; for (const folderName of folderPath) { - const row = scope.locator('.collection-item-name').filter({ hasText: folderName }).first(); + const levelRows = scope.locator(':scope > div > .collection-item-name'); + const row = levelRows + .filter({ has: page.locator('.item-name', { hasText: new RegExp(`^${escapeRegExp(folderName)}$`) }) }) + .first(); await row.waitFor({ state: 'visible', timeout: 5000 }); + targetRow = row; // Click the chevron to expand (skip if already expanded) const chevron = row.getByTestId('folder-chevron'); @@ -164,12 +172,15 @@ const openFolderRunMenu = async (page: Page, collectionName: string, folderPath: await chevron.click(); } - // Scope to this folder's wrapper (parent of the row) for the next iteration - scope = row.locator('..'); + // Scope to the next-level children container for this folder. + const folderWrapper = row.locator('..'); + scope = folderWrapper.locator(':scope > div').last(); } - // The target folder row is the last one we found — hover to reveal menu - const targetRow = scope.locator('.collection-item-name').filter({ hasText: folderPath[folderPath.length - 1] }).first(); + // The target folder row is the last exact row matched in the loop. + if (!targetRow) { + throw new Error('Folder path is empty; cannot open folder run menu'); + } await targetRow.hover(); // Click the menu icon