fix: relative path getting stored as absolute on windows (#7895)

This commit is contained in:
prateek-bruno
2026-05-19 23:53:37 +05:30
committed by GitHub
parent e86a036fd6
commit e0de7d5557
8 changed files with 446 additions and 23 deletions

View File

@@ -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);

View File

@@ -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

View File

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

View File

@@ -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 };

View File

@@ -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);

View File

@@ -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', () => {

View File

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

View File

@@ -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';
@@ -1009,6 +1010,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
@@ -1472,7 +1517,11 @@ export {
getResponseBody,
expectResponseContains,
selectRequestPaneTab,
selectRequestBodyMode,
selectResponsePaneTab,
mockBrowseFiles,
addMultipartFileToLastRow,
removeFirstMultipartFile,
sendRequestAndWaitForResponse,
switchResponseFormat,
switchToPreviewTab,