diff --git a/packages/bruno-app/src/components/FilePickerEditor/index.js b/packages/bruno-app/src/components/FilePickerEditor/index.js index 65f9046b5fc..907b98eac68 100644 --- a/packages/bruno-app/src/components/FilePickerEditor/index.js +++ b/packages/bruno-app/src/components/FilePickerEditor/index.js @@ -1,5 +1,5 @@ import React from 'react'; -import path from 'utils/common/path'; +import { getRelativePathWithinBasePath } from 'utils/common/path'; import { useDispatch } from 'react-redux'; import { browseFiles } from 'providers/ReduxStore/slices/collections/actions'; import { IconX, IconUpload, IconFile } from '@tabler/icons'; @@ -48,13 +48,7 @@ const FilePickerEditor = ({ // If file is in the collection's directory, then we use relative path // Otherwise, we use the absolute path filePaths = filePaths.map((filePath) => { - const collectionDir = collection.pathname; - - if (filePath.startsWith(collectionDir)) { - return path.relative(collectionDir, filePath); - } - - return filePath; + return getRelativePathWithinBasePath(collection.pathname, filePath); }); onChange(isSingleFilePicker ? filePaths[0] : filePaths); diff --git a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js index 530468445ae..33cd651aaf3 100644 --- a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js @@ -14,7 +14,7 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs'; import EditableTable from 'components/EditableTable'; import StyledWrapper from './StyledWrapper'; -import path from 'utils/common/path'; +import { getRelativePathWithinBasePath } from 'utils/common/path'; import { usePersistedState } from 'hooks/usePersistedState'; import { useTrackScroll } from 'hooks/useTrackScroll'; import { isWindowsOS } from 'utils/common/platform'; @@ -60,11 +60,7 @@ const MultipartFormParams = ({ item, collection }) => { dispatch(browseFiles()) .then((filePaths) => { const processedPaths = filePaths.map((filePath) => { - const collectionDir = collection.pathname; - if (filePath.startsWith(collectionDir)) { - return path.relative(collectionDir, filePath); - } - return filePath; + return getRelativePathWithinBasePath(collection.pathname, filePath); }); const currentParams = item.draft diff --git a/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/index.js b/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/index.js index ef327f9a2b9..03faf08cbbd 100644 --- a/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/index.js +++ b/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/index.js @@ -7,7 +7,7 @@ import { updateResponseExampleMultipartFormParams } from 'providers/ReduxStore/s import { browseFiles } from 'providers/ReduxStore/slices/collections/actions'; import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs'; import mime from 'mime-types'; -import path from 'utils/common/path'; +import path, { getRelativePathWithinBasePath } from 'utils/common/path'; import EditableTable from 'components/EditableTable'; import MultiLineEditor from 'components/MultiLineEditor'; import SingleLineEditor from 'components/SingleLineEditor'; @@ -51,11 +51,7 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit dispatch(browseFiles()) .then((filePaths) => { const processedPaths = filePaths.map((filePath) => { - const collectionDir = collection.pathname; - if (filePath.startsWith(collectionDir)) { - return path.relative(collectionDir, filePath); - } - return filePath; + return getRelativePathWithinBasePath(collection.pathname, filePath); }); const currentParams = params || []; diff --git a/packages/bruno-app/src/utils/common/path.js b/packages/bruno-app/src/utils/common/path.js index e1b8e826f13..338a5067819 100644 --- a/packages/bruno-app/src/utils/common/path.js +++ b/packages/bruno-app/src/utils/common/path.js @@ -163,10 +163,60 @@ const getAbsoluteFilePath = (basePath, relativePath, shouldPosixify = false) => return shouldPosixify ? posixify(result) : result; }; +/** + * Returns a relative path when filePath is contained within basePath. + * For paths outside basePath (or same path), returns the original filePath unchanged. + * + * @param {string} basePath - The base path to check containment against (e.g., collection pathname). + * @param {string} filePath - The absolute file path to compute a relative path for. + * @param {boolean} [shouldPosixify=false] - When true, output uses '/' separators for + * cross-platform safety. Callers storing to version-controlled config files should opt in + * by passing true. Default false preserves legacy platform-native separators for + * backwards compatibility. + * @returns {string} Relative path if filePath is inside basePath, otherwise filePath itself. + * + * @example + * getRelativePathWithinBasePath('/users/john/collections/api', '/users/john/collections/api/files/payload.txt'); + * → "files/payload.txt" + * + * @example + * getRelativePathWithinBasePath('/users/john/collections/api', '/users/john/downloads/payload.txt'); + * → "/users/john/downloads/payload.txt" + * + * @example + * On Windows with posixify enabled + * getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:\\Users\\John\\Collections\\Api\\files\\payload.txt', true); + * → "files/payload.txt" + */ +const getRelativePathWithinBasePath = (basePath, filePath, shouldPosixify = false) => { + if (!basePath || !filePath) { + return filePath; + } + + try { + const relativePath = getRelativePath(basePath, filePath, shouldPosixify); + const sep = shouldPosixify ? '/' : brunoPath.sep; + + if ( + !relativePath + || relativePath === '.' + || relativePath === '..' + || relativePath.startsWith(`..${sep}`) + || brunoPath.isAbsolute(relativePath) + ) { + return shouldPosixify ? posixify(filePath) : filePath; + } + + return relativePath; + } catch (error) { + return shouldPosixify ? posixify(filePath) : filePath; + } +}; + const normalizePath = (p) => { if (!p) return ''; return p.replace(/\\/g, '/').replace(/\/+$/, ''); }; export default brunoPath; -export { getRelativePath, getBasename, getAbsoluteFilePath, normalizePath }; +export { getRelativePath, getBasename, getAbsoluteFilePath, getRelativePathWithinBasePath, normalizePath }; diff --git a/packages/bruno-app/src/utils/common/path.spec.js b/packages/bruno-app/src/utils/common/path.spec.js index 4df2d6606db..e283ff4751f 100644 --- a/packages/bruno-app/src/utils/common/path.spec.js +++ b/packages/bruno-app/src/utils/common/path.spec.js @@ -5,7 +5,8 @@ jest.mock('platform', () => ({ } })); -import { getRelativePath, getBasename, getAbsoluteFilePath } from './path'; +import path from 'path'; +import { getRelativePath, getBasename, getAbsoluteFilePath, getRelativePathWithinBasePath } from './path'; describe('Path Utilities - Unix Platform', () => { describe('getRelativePath', () => { @@ -25,6 +26,10 @@ describe('Path Utilities - Unix Platform', () => { expect(getRelativePath('/users/john/projects', '/users/john/projects/src/components')).toBe('src/components'); }); + it('should return ".." for direct parent directory', () => { + expect(getRelativePath('/users/john/projects', '/users/john')).toBe('..'); + }); + it('should handle null/undefined inputs', () => { expect(getRelativePath(null, '/users/john/projects')).toBe('/users/john/projects'); expect(getRelativePath(undefined, '/users/john/projects')).toBe('/users/john/projects'); @@ -113,6 +118,78 @@ describe('Path Utilities - Unix Platform', () => { }); }); + describe('getRelativePathWithinBasePath', () => { + it('should store in-collection files as relative paths', () => { + const result = getRelativePathWithinBasePath('/users/john/collections/api', '/users/john/collections/api/files/payload.txt'); + expect(result).toBe('files/payload.txt'); + }); + + it('should handle collection paths with trailing separators', () => { + const result = getRelativePathWithinBasePath('/users/john/collections/api/', '/users/john/collections/api/files/payload.txt'); + expect(result).toBe('files/payload.txt'); + }); + + it('should resolve dot segments before deciding whether a file is inside the collection', () => { + const result = getRelativePathWithinBasePath('/users/john/collections/api', '/users/john/collections/api/files/../payload.txt'); + expect(result).toBe('payload.txt'); + }); + + it('should keep paths that resolve outside the collection absolute', () => { + const filePath = '/users/john/collections/api/../payload.txt'; + const result = getRelativePathWithinBasePath('/users/john/collections/api', filePath); + expect(result).toBe(filePath); + }); + + it('should keep outside collection paths absolute', () => { + const filePath = '/users/john/downloads/payload.txt'; + const result = getRelativePathWithinBasePath('/users/john/collections/api', filePath); + expect(result).toBe(filePath); + }); + + it('should keep sibling prefix paths absolute', () => { + const filePath = '/users/john/collections/api-other/payload.txt'; + const result = getRelativePathWithinBasePath('/users/john/collections/api', filePath); + expect(result).toBe(filePath); + }); + + it('should keep same-path values unchanged', () => { + const filePath = '/users/john/collections/api'; + const result = getRelativePathWithinBasePath('/users/john/collections/api', filePath); + expect(result).toBe(filePath); + }); + + it('should store in-collection paths whose names begin with two dots as relative paths', () => { + const result = getRelativePathWithinBasePath('/users/john/collections/api', '/users/john/collections/api/..payload.txt'); + expect(result).toBe('..payload.txt'); + }); + + it('should keep the original file path when inputs are missing', () => { + expect(getRelativePathWithinBasePath('', '/users/john/downloads/payload.txt')).toBe('/users/john/downloads/payload.txt'); + expect(getRelativePathWithinBasePath('/users/john/collections/api', '')).toBe(''); + }); + + it('should treat relative collection path as cwd-relative when file path is absolute', () => { + const collectionPath = 'collections/api'; + const filePath = path.resolve(collectionPath, 'files/payload.txt'); + const result = getRelativePathWithinBasePath(collectionPath, filePath); + expect(result).toBe('files/payload.txt'); + }); + + it('should treat relative file path as cwd-relative when collection path is absolute', () => { + const collectionPath = path.resolve('collections/api'); + const filePath = 'collections/api/files/payload.txt'; + const result = getRelativePathWithinBasePath(collectionPath, filePath); + expect(result).toBe('files/payload.txt'); + }); + + it('should treat both relative paths as cwd-relative for containment checks', () => { + const collectionPath = 'collections/api'; + const filePath = 'collections/api/files/payload.txt'; + const result = getRelativePathWithinBasePath(collectionPath, filePath); + expect(result).toBe('files/payload.txt'); + }); + }); + describe('Edge cases', () => { it('should handle very long paths', () => { const longPath = '/users/john/projects/' + 'a'.repeat(100); diff --git a/packages/bruno-app/src/utils/common/path.windows.spec.js b/packages/bruno-app/src/utils/common/path.windows.spec.js index b94e5a49992..c11f921d6aa 100644 --- a/packages/bruno-app/src/utils/common/path.windows.spec.js +++ b/packages/bruno-app/src/utils/common/path.windows.spec.js @@ -5,7 +5,7 @@ jest.mock('platform', () => ({ } })); -import { getRelativePath, getBasename, getAbsoluteFilePath } from './path'; +import { getRelativePath, getBasename, getAbsoluteFilePath, getRelativePathWithinBasePath } from './path'; describe('Path Utilities - Windows Platform', () => { describe('getRelativePath', () => { @@ -25,6 +25,14 @@ describe('Path Utilities - Windows Platform', () => { expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Projects\\src\\components', false)).toBe('src\\components'); }); + it('should return ".." for direct parent directory', () => { + expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John', false)).toBe('..'); + }); + + it('should return an absolute path for cross-drive targets', () => { + expect(getRelativePath('C:\\Users\\John\\Projects', 'D:\\payload.txt', false)).toBe('D:\\payload.txt'); + }); + describe('with posixify enabled', () => { it('should convert backslashes to forward slashes', () => { expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Projects\\App')).toBe('App'); @@ -181,6 +189,113 @@ describe('Path Utilities - Windows Platform', () => { }); }); + describe('getRelativePathWithinBasePath', () => { + it('should store in-collection files as Windows relative paths with mixed separators', () => { + const result = getRelativePathWithinBasePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Collections\\Api\\files\\payload.txt'); + expect(result).toBe('files\\payload.txt'); + }); + + it('should store nested in-collection files as Windows relative paths', () => { + const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt'); + expect(result).toBe('folder\\payload.txt'); + }); + + it('should handle collection paths with trailing separators', () => { + const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api\\', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt'); + expect(result).toBe('folder\\payload.txt'); + }); + + it('should handle case differences in Windows drive paths', () => { + const result = getRelativePathWithinBasePath('c:\\users\\john\\collections\\api', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt'); + expect(result).toBe('folder\\payload.txt'); + }); + + it('should resolve dot segments before deciding whether a file is inside the collection', () => { + const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:\\Users\\John\\Collections\\Api\\folder\\..\\payload.txt'); + expect(result).toBe('payload.txt'); + }); + + it('should keep paths that resolve outside the collection absolute', () => { + const filePath = 'C:\\Users\\John\\Collections\\Api\\..\\payload.txt'; + const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', filePath); + expect(result).toBe(filePath); + }); + + it('should keep sibling prefix paths absolute', () => { + const filePath = 'C:\\Users\\John\\Collections\\ApiOther\\payload.txt'; + const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', filePath); + expect(result).toBe(filePath); + }); + + it('should keep outside collection paths absolute', () => { + const filePath = 'C:\\Users\\John\\Downloads\\payload.txt'; + const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', filePath); + expect(result).toBe(filePath); + }); + + it('should keep cross-drive paths absolute', () => { + const filePath = 'D:\\payload.txt'; + const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', filePath); + expect(result).toBe(filePath); + }); + + it('should keep same-path values unchanged', () => { + const filePath = 'C:\\Users\\John\\Collections\\Api'; + const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', filePath); + expect(result).toBe(filePath); + }); + + it('should keep the original file path when inputs are missing', () => { + expect(getRelativePathWithinBasePath('', 'C:\\Users\\John\\Downloads\\payload.txt')).toBe('C:\\Users\\John\\Downloads\\payload.txt'); + expect(getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', '')).toBe(''); + }); + + describe('mixed separators (posix base / win file)', () => { + it('inside → relative path with native separators (default)', () => { + const r = getRelativePathWithinBasePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Collections\\Api\\files\\payload.txt'); + expect(r).toBe('files\\payload.txt'); + }); + + it('outside → returns original filePath unchanged (default)', () => { + const r = getRelativePathWithinBasePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Downloads\\payload.txt'); + expect(r).toBe('C:\\Users\\John\\Downloads\\payload.txt'); + }); + + it('outside → posixified absolute fallback when posixify=true', () => { + const r = getRelativePathWithinBasePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Downloads\\payload.txt', true); + expect(r).toBe('C:/Users/John/Downloads/payload.txt'); + }); + + it('inside → posixified relative path when posixify=true', () => { + const r = getRelativePathWithinBasePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Collections\\Api\\files\\payload.txt', true); + expect(r).toBe('files/payload.txt'); + }); + }); + + describe('mixed separators (win base / posix file)', () => { + it('inside → relative path with native separators (default)', () => { + const r = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:/Users/John/Collections/Api/files/payload.txt'); + expect(r).toBe('files\\payload.txt'); + }); + + it('outside → returns original filePath as-is (default)', () => { + const r = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:/Users/John/Downloads/payload.txt'); + // filePath uses '/', returned as-is since shouldPosixify=false + expect(r).toBe('C:/Users/John/Downloads/payload.txt'); + }); + + it('outside → posixified fallback when posixify=true', () => { + const r = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:/Users/John/Downloads/payload.txt', true); + expect(r).toBe('C:/Users/John/Downloads/payload.txt'); + }); + + it('inside → posixified relative path when posixify=true', () => { + const r = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:/Users/John/Collections/Api/files/payload.txt', true); + expect(r).toBe('files/payload.txt'); + }); + }); + }); + describe('Cross-platform path handling', () => { describe('Windows fromPath with POSIX toPath', () => { it('should handle Windows fromPath with POSIX toPath in getAbsoluteFilePath', () => { diff --git a/tests/collection/opencollection/multipart-file-path.spec.ts b/tests/collection/opencollection/multipart-file-path.spec.ts new file mode 100644 index 00000000000..9e940aa9f60 --- /dev/null +++ b/tests/collection/opencollection/multipart-file-path.spec.ts @@ -0,0 +1,146 @@ +import { test, expect, closeElectronApp } from '../../../playwright'; +import { + addMultipartFileToLastRow, + openRequest, + removeFirstMultipartFile, + saveRequest, + selectRequestBodyMode, + selectRequestPaneTab +} from '../../utils/page'; +import * as fs from 'fs'; +import * as path from 'path'; + +const collectionName = 'RelativePathBug'; +const requestName = 'upload-payload'; +const relativePayloadPath = path.join('files', 'payload.json'); + +const writeJson = async (filePath: string, value: unknown) => { + await fs.promises.writeFile(filePath, JSON.stringify(value, null, 2), 'utf-8'); +}; + +const setupOpenCollection = async (collectionDir: string, userDataDir: string) => { + await fs.promises.mkdir(path.join(collectionDir, 'files'), { recursive: true }); + await fs.promises.mkdir(userDataDir, { recursive: true }); + + await fs.promises.writeFile( + path.join(collectionDir, 'opencollection.yml'), + [ + 'opencollection: "1.0.0"', + 'info:', + ` name: ${collectionName}`, + ' type: collection', + '' + ].join('\n'), + 'utf-8' + ); + + await fs.promises.writeFile( + path.join(collectionDir, relativePayloadPath), + '{"ok":true}\n', + 'utf-8' + ); + + await fs.promises.writeFile( + path.join(collectionDir, `${requestName}.yml`), + [ + 'info:', + ` name: ${requestName}`, + ' type: http', + ' seq: 1', + '', + 'http:', + ' method: POST', + ' url: https://example.com/upload', + '', + 'settings:', + ' encodeUrl: true', + ' timeout: 0', + ' followRedirects: true', + ' maxRedirects: 5', + '' + ].join('\n'), + 'utf-8' + ); + + await writeJson(path.join(userDataDir, 'preferences.json'), { + lastOpenedCollections: [collectionDir], + preferences: { + onboarding: { + hasLaunchedBefore: true, + hasSeenWelcomeModal: true + } + } + }); + + await writeJson(path.join(userDataDir, 'collection-security.json'), { + collections: [ + { + path: collectionDir, + securityConfig: { + jsSandboxMode: 'safe' + } + } + ] + }); +}; + +const expectRequestFileToContainRelativePayload = async (requestFilePath: string, payloadPath: string) => { + await expect.poll(async () => fs.existsSync(requestFilePath)).toBe(true); + await expect.poll(async () => fs.promises.readFile(requestFilePath, 'utf-8')).toContain(` ${relativePayloadPath}\n`); + await expect.poll(async () => fs.promises.readFile(requestFilePath, 'utf-8')).not.toContain(payloadPath); +}; + +const expectRequestFileNotToContainPayload = async (requestFilePath: string, payloadPath: string) => { + await expect.poll(async () => fs.promises.readFile(requestFilePath, 'utf-8')).not.toContain(` ${relativePayloadPath}\n`); + await expect.poll(async () => fs.promises.readFile(requestFilePath, 'utf-8')).not.toContain(payloadPath); +}; + +test.describe('OpenCollection multipart file paths', () => { + test('keeps an in-collection multipart file relative after restart, OpenCollection edit, remove, and re-add', async ({ + launchElectronApp, + createTmpDir + }) => { + const collectionDir = path.join(await createTmpDir('opencollection-multipart'), collectionName); + const userDataDir = await createTmpDir('opencollection-multipart-userdata'); + const payloadPath = path.join(collectionDir, relativePayloadPath); + const requestFilePath = path.join(collectionDir, `${requestName}.yml`); + + await setupOpenCollection(collectionDir, userDataDir); + + let electronApp = await launchElectronApp({ userDataPath: userDataDir }); + let page = await electronApp.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await expect(page.locator('#sidebar-collection-name').filter({ hasText: collectionName })).toBeVisible(); + await expect.poll(async () => fs.existsSync(requestFilePath), { + timeout: 15000 + }).toBe(true); + + await openRequest(page, collectionName, requestName, { persist: true }); + await selectRequestBodyMode(page, 'Multipart Form'); + + await addMultipartFileToLastRow(page, electronApp, payloadPath); + await saveRequest(page); + await expectRequestFileToContainRelativePayload(requestFilePath, payloadPath); + + await closeElectronApp(electronApp); + await fs.promises.appendFile(path.join(collectionDir, 'opencollection.yml'), '\n\n', 'utf-8'); + + electronApp = await launchElectronApp({ userDataPath: userDataDir }); + page = await electronApp.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + await expect(page.locator('#sidebar-collection-name').filter({ hasText: collectionName })).toBeVisible(); + + await openRequest(page, collectionName, requestName, { persist: true }); + await selectRequestPaneTab(page, 'Body'); + await removeFirstMultipartFile(page); + await saveRequest(page); + await expectRequestFileNotToContainPayload(requestFilePath, payloadPath); + + await addMultipartFileToLastRow(page, electronApp, payloadPath); + await saveRequest(page); + + await expectRequestFileToContainRelativePayload(requestFilePath, payloadPath); + await closeElectronApp(electronApp); + }); +}); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 873bd09b371..52fe7e6d66a 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -1,5 +1,6 @@ import { test, expect, Page, ElectronApplication, waitForReadyPage as waitForReadyPageImpl } from '../../../playwright'; import process from 'node:process'; +import * as path from 'path'; import { buildCommonLocators, buildScriptErrorLocators } from './locators'; type SandboxMode = 'safe' | 'developer'; @@ -995,6 +996,50 @@ const selectRequestPaneTab = async (page: Page, tabName: string) => { await selectPaneTab(page, '[data-testid="request-pane"] > .px-4', tabName); }; +const selectRequestBodyMode = async (page: Page, mode: string) => { + await test.step(`Select request body mode "${mode}"`, async () => { + await selectRequestPaneTab(page, 'Body'); + const locators = buildCommonLocators(page); + await locators.request.bodyModeSelector().click(); + await locators.dropdown.item(mode).click(); + }); +}; + +const mockBrowseFiles = async (electronApp: ElectronApplication, filePaths: string[]) => { + await electronApp.evaluate(({ dialog }, selectedPaths: string[]) => { + const originalShowOpenDialog = dialog.showOpenDialog; + dialog.showOpenDialog = async (...args) => { + dialog.showOpenDialog = originalShowOpenDialog; + return { + canceled: false, + filePaths: selectedPaths + }; + }; + }, filePaths); +}; + +const addMultipartFileToLastRow = async (page: Page, electronApp: ElectronApplication, filePath: string) => { + await test.step(`Add multipart file "${path.basename(filePath)}"`, async () => { + await mockBrowseFiles(electronApp, [filePath]); + + const table = buildCommonLocators(page).table('editable-table'); + const lastRow = table.allRows().last(); + + await expect(lastRow.locator('.upload-btn')).toBeVisible(); + await lastRow.locator('.upload-btn').click(); + await expect(lastRow.locator('.file-value-cell')).toContainText(path.basename(filePath)); + }); +}; + +const removeFirstMultipartFile = async (page: Page) => { + await test.step('Remove first multipart file', async () => { + const table = buildCommonLocators(page).table('editable-table'); + await expect(table.allRows().locator('.file-value-cell').first()).toBeVisible(); + await table.allRows().first().locator('.clear-file-btn').click(); + await expect(table.allRows().first().locator('.upload-btn')).toBeVisible(); + }); +}; + /** * Verify response contains specific text * @param page - The page object @@ -1402,7 +1447,11 @@ export { getResponseBody, expectResponseContains, selectRequestPaneTab, + selectRequestBodyMode, selectResponsePaneTab, + mockBrowseFiles, + addMultipartFileToLastRow, + removeFirstMultipartFile, sendRequestAndWaitForResponse, switchResponseFormat, switchToPreviewTab,