fix(apispec): prevent crash on non-array specs and fix Windows spec listing (BRU-3556) (#8255)

This commit is contained in:
Sundram
2026-06-12 23:43:27 +05:30
committed by GitHub
parent 1472f6b158
commit 2bc735ee00
9 changed files with 208 additions and 34 deletions

View File

@@ -5,6 +5,7 @@ import { useTheme } from 'providers/Theme';
import { openApiSpec } from 'providers/ReduxStore/slices/apiSpec';
import ApiSpecItem from './ApiSpecItem';
import StyledWrapper from './StyledWrapper';
import { matchLoadedApiSpecs } from './matchLoadedApiSpecs';
import toast from 'react-hot-toast';
const LinkStyle = styled.span`
@@ -22,13 +23,11 @@ const ApiSpecs = () => {
const apiSpecs = React.useMemo(() => {
if (!activeWorkspace) return [];
const workspaceApiSpecs = activeWorkspace.apiSpecs || [];
const workspaceApiSpecs = Array.isArray(activeWorkspace.apiSpecs) ? activeWorkspace.apiSpecs : [];
// Map workspace API specs to loaded API specs from Redux store
return workspaceApiSpecs.map((ws) => {
const loadedApiSpec = allApiSpecs.find((apiSpec) => apiSpec.pathname === ws.path);
return loadedApiSpec;
}).filter(Boolean);
// Pair workspace API specs to loaded specs in redux, matching by normalized
// path so Windows (backslash) and stored (forward-slash) paths line up.
return matchLoadedApiSpecs(workspaceApiSpecs, allApiSpecs);
}, [allApiSpecs, activeWorkspace, activeWorkspace?.apiSpecs]);
const handleOpenApiSpec = () => {

View File

@@ -0,0 +1,29 @@
import { normalizePath } from 'utils/common/path';
/**
* Pairs each workspace API spec entry (from workspace.yml) with its loaded
* counterpart in the redux store, matching by normalized (posixified) path.
*
* The two paths are derived independently: the workspace entry's path is stored
* posixified (forward slashes) in workspace.yml, while the loaded spec's pathname
* comes from the file watcher in native form (backslashes on Windows). A raw
* `===` compare therefore fails on Windows (`C:/ws/api.yaml` !== `C:\ws\api.yaml`),
* which hides the spec from the sidebar until a workspace switch. Normalizing both
* sides makes them match on Windows while being a no-op on macOS/Linux.
*
* @param {Array} workspaceApiSpecs - spec entries from the active workspace (each has `path`)
* @param {Array} allApiSpecs - loaded specs in redux (each has `pathname`)
* @returns {Array} loaded specs that correspond to the workspace entries
*/
export const matchLoadedApiSpecs = (workspaceApiSpecs, allApiSpecs) => {
if (!Array.isArray(workspaceApiSpecs)) return [];
const loadedApiSpecs = Array.isArray(allApiSpecs) ? allApiSpecs : [];
return workspaceApiSpecs
.map((ws) => {
const wsPath = normalizePath(ws?.path);
if (!wsPath) return undefined;
return loadedApiSpecs.find((apiSpec) => normalizePath(apiSpec?.pathname) === wsPath);
})
.filter(Boolean);
};

View File

@@ -0,0 +1,55 @@
import { matchLoadedApiSpecs } from './matchLoadedApiSpecs';
const loaded = (pathname, extra = {}) => ({ uid: pathname, pathname, ...extra });
describe('matchLoadedApiSpecs', () => {
it('matches workspace specs to loaded specs by identical path (macOS/Linux)', () => {
const ws = [{ name: 'a', path: '/Users/me/ws/a.yaml' }];
const all = [loaded('/Users/me/ws/a.yaml'), loaded('/Users/me/ws/other.yaml')];
expect(matchLoadedApiSpecs(ws, all)).toEqual([loaded('/Users/me/ws/a.yaml')]);
});
it('matches when paths differ only by separator (Windows: backslash vs forward-slash)', () => {
// workspace.yml stores forward-slash; the file watcher reports native backslash.
const ws = [{ name: 'a', path: 'C:/Users/qa/Downloads/test.yaml' }];
const all = [loaded('C:\\Users\\qa\\Downloads\\test.yaml')];
const result = matchLoadedApiSpecs(ws, all);
expect(result).toHaveLength(1);
expect(result[0].pathname).toBe('C:\\Users\\qa\\Downloads\\test.yaml');
});
it('matches mixed separators within a single path', () => {
const ws = [{ name: 'a', path: 'C:/ws/sub/a.yaml' }];
const all = [loaded('C:\\ws/sub\\a.yaml')];
expect(matchLoadedApiSpecs(ws, all)).toHaveLength(1);
});
it('preserves workspace order and drops entries with no loaded counterpart', () => {
const ws = [
{ name: 'a', path: 'C:/ws/a.yaml' },
{ name: 'missing', path: 'C:/ws/missing.yaml' },
{ name: 'b', path: 'C:/ws/b.yaml' }
];
const all = [loaded('C:\\ws\\b.yaml'), loaded('C:\\ws\\a.yaml')];
const result = matchLoadedApiSpecs(ws, all);
expect(result.map((s) => s.pathname)).toEqual(['C:\\ws\\a.yaml', 'C:\\ws\\b.yaml']);
});
it('does not match entries with a missing/empty path (no empty-string false positive)', () => {
const ws = [{ name: 'noPath' }, { name: 'emptyPath', path: '' }];
const all = [loaded(undefined), loaded('')];
expect(matchLoadedApiSpecs(ws, all)).toEqual([]);
});
it('returns [] when workspaceApiSpecs is not an array', () => {
expect(matchLoadedApiSpecs(undefined, [])).toEqual([]);
expect(matchLoadedApiSpecs({ broken: 'map' }, [])).toEqual([]);
expect(matchLoadedApiSpecs('string', [])).toEqual([]);
});
it('returns [] when there are no loaded specs', () => {
const ws = [{ name: 'a', path: 'C:/ws/a.yaml' }];
expect(matchLoadedApiSpecs(ws, [])).toEqual([]);
expect(matchLoadedApiSpecs(ws, undefined)).toEqual([]);
});
});

View File

@@ -550,10 +550,12 @@ export const loadWorkspaceApiSpecs = (workspaceUid) => {
}));
const allApiSpecs = getState().apiSpec.apiSpecs;
const alreadyOpenApiSpecs = allApiSpecs.map((a) => a.pathname);
// Compare by normalized path so a spec already loaded under a native (Windows)
// path isn't treated as "not open" and needlessly re-opened.
const alreadyOpenApiSpecs = allApiSpecs.map((a) => normalizePath(a.pathname));
for (const apiSpec of apiSpecs) {
if (apiSpec.path && !alreadyOpenApiSpecs.includes(apiSpec.path)) {
if (apiSpec.path && !alreadyOpenApiSpecs.includes(normalizePath(apiSpec.path))) {
try {
await ipcRenderer.invoke('renderer:open-api-spec-file', apiSpec.path, workspace.pathname);
} catch (error) {