diff --git a/package-lock.json b/package-lock.json
index 07b2c4f3c3c..ded4c6c9e6f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -155,6 +155,24 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@apidevtools/json-schema-ref-parser": {
+ "version": "14.2.1",
+ "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.2.1.tgz",
+ "integrity": "sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==",
+ "license": "MIT",
+ "dependencies": {
+ "js-yaml": "^4.1.0"
+ },
+ "engines": {
+ "node": ">= 20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/philsturgeon"
+ },
+ "peerDependencies": {
+ "@types/json-schema": "^7.0.15"
+ }
+ },
"node_modules/@aws-crypto/sha256-browser": {
"version": "5.2.0",
"license": "Apache-2.0",
@@ -28817,6 +28835,7 @@
"dependencies": {
"@ai-sdk/anthropic": "3.0.15",
"@ai-sdk/openai": "3.0.12",
+ "@apidevtools/json-schema-ref-parser": "14.2.1",
"@aws-sdk/credential-providers": "3.1019.0",
"@grpc/grpc-js": "^1.14.4",
"@grpc/proto-loader": "^0.7.13",
diff --git a/packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js b/packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js
index cf4e1dc3330..4df3230e7e3 100644
--- a/packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js
+++ b/packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js
@@ -38,12 +38,15 @@ const MIN_RIGHT_PANE_WIDTH = 450;
*
* Props:
* - content (string) The spec content (YAML/JSON string)
+ * - resolvedSpec (object|null) The same spec with the files it references inlined, for
+ * multi-file specs. The preview renders this when present, since it cannot
+ * resolve `./sibling.yaml` itself; the editor always shows `content`.
* - readOnly (boolean) If true, editor is not editable and save icon is hidden
* - onSave (fn) Called with current editor content on save (editable mode only)
* - leftPaneWidth (number|null) Persisted left pane width in px; null = use 50/50 default
* - onLeftPaneWidthChange (fn) Persist the new width (called on mouseup / double-click / resize-clamp)
*/
-const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthChange }) => {
+const SpecViewer = ({ content, resolvedSpec, readOnly, onSave, leftPaneWidth, onLeftPaneWidthChange }) => {
const { displayedTheme, theme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
@@ -161,7 +164,7 @@ const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthC
) : (
<>
-
+
{!swaggerReady && (
{
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
let apiSpec = find(apiSpecs, (c) => c.uid === activeApiSpecUid);
- const { filename, pathname, raw, uid, leftPaneWidth } = apiSpec || {};
+ const { filename, pathname, raw, uid, leftPaneWidth, resolvedJson } = apiSpec || {};
const handleLeftPaneWidthChange = useCallback(
(w) => {
@@ -87,6 +87,7 @@ const ApiSpecPanel = () => {
dispatch(saveApiSpecToFile({ uid, content }))}
leftPaneWidth={leftPaneWidth ?? null}
onLeftPaneWidthChange={handleLeftPaneWidthChange}
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/apiSpec.js b/packages/bruno-app/src/providers/ReduxStore/slices/apiSpec.js
index 5a8dcea39ca..773fce58a91 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/apiSpec.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/apiSpec.js
@@ -12,7 +12,7 @@ export const apiSpecSlice = createSlice({
initialState,
reducers: {
apiSpecAddFileEvent: (state, action) => {
- const { name, raw, uid, filename, pathname, json } = action?.payload?.data || {};
+ const { name, raw, uid, filename, pathname, json, resolvedJson } = action?.payload?.data || {};
if (!uid) {
toast.error('Error adding API spec');
}
@@ -23,6 +23,7 @@ export const apiSpecSlice = createSlice({
apiSpec.filename = filename;
apiSpec.pathname = pathname;
apiSpec.json = json;
+ apiSpec.resolvedJson = resolvedJson;
} else {
const newApiSpec = {
name,
@@ -30,14 +31,15 @@ export const apiSpecSlice = createSlice({
uid,
filename,
pathname,
- json
+ json,
+ resolvedJson
};
state.apiSpecs.push(newApiSpec);
}
state.activeApiSpecUid = uid;
},
apiSpecChangeFileEvent: (state, action) => {
- const { name, raw, uid, filename, pathname, json } = action?.payload?.data || {};
+ const { name, raw, uid, filename, pathname, json, resolvedJson } = action?.payload?.data || {};
if (!uid) return;
const apiSpec = findApiSpecByUid(state.apiSpecs, uid);
@@ -47,6 +49,7 @@ export const apiSpecSlice = createSlice({
apiSpec.filename = filename;
apiSpec.pathname = pathname;
apiSpec.json = json;
+ apiSpec.resolvedJson = resolvedJson;
}
},
saveApiSpec: (state, action) => {
diff --git a/packages/bruno-electron/package.json b/packages/bruno-electron/package.json
index 5b6c4de156f..e5d73ae81b2 100644
--- a/packages/bruno-electron/package.json
+++ b/packages/bruno-electron/package.json
@@ -32,6 +32,7 @@
"dependencies": {
"@ai-sdk/anthropic": "3.0.15",
"@ai-sdk/openai": "3.0.12",
+ "@apidevtools/json-schema-ref-parser": "14.2.1",
"@aws-sdk/credential-providers": "3.1019.0",
"@grpc/grpc-js": "^1.14.4",
"@grpc/proto-loader": "^0.7.13",
diff --git a/packages/bruno-electron/src/app/apiSpecs.js b/packages/bruno-electron/src/app/apiSpecs.js
index 683ede06678..350376ed60d 100644
--- a/packages/bruno-electron/src/app/apiSpecs.js
+++ b/packages/bruno-electron/src/app/apiSpecs.js
@@ -3,7 +3,7 @@ const path = require('node:path');
const { dialog, ipcMain } = require('electron');
const { normalizeAndResolvePath } = require('../utils/filesystem');
const { generateUidBasedOnHash } = require('../utils/common');
-const { parseApiSpecContent } = require('../utils/apiSpecs');
+const { parseApiSpecContent, resolveExternalApiSpecRefs } = require('../utils/apiSpecs');
const {
addApiSpecToWorkspace,
readWorkspaceConfig,
@@ -94,6 +94,8 @@ const openApiSpec = async (win, watcher, apiSpecPath, options = {}) => {
} else {
const rawContent = fs.readFileSync(apiSpecPath, 'utf8');
const extension = path.extname(apiSpecPath);
+ const apiSpecContent = parseApiSpecContent(rawContent, extension);
+ const { resolvedJson } = await resolveExternalApiSpecRefs(apiSpecContent, apiSpecPath);
win.webContents.send('main:apispec-tree-updated', 'addFile', {
pathname: apiSpecPath,
@@ -101,7 +103,8 @@ const openApiSpec = async (win, watcher, apiSpecPath, options = {}) => {
raw: rawContent,
name: path.basename(apiSpecPath, path.extname(apiSpecPath)),
filename: path.basename(apiSpecPath),
- json: parseApiSpecContent(rawContent, extension)
+ json: apiSpecContent,
+ resolvedJson: resolvedJson
});
}
} catch (err) {
diff --git a/packages/bruno-electron/src/app/apiSpecsWatcher.js b/packages/bruno-electron/src/app/apiSpecsWatcher.js
index dc07d3f0f48..47c9db0734d 100644
--- a/packages/bruno-electron/src/app/apiSpecsWatcher.js
+++ b/packages/bruno-electron/src/app/apiSpecsWatcher.js
@@ -4,19 +4,29 @@ const path = require('node:path');
const chokidar = require('chokidar');
const { getApiSpecUid } = require('../cache/apiSpecUids');
const { isDirectory } = require('../utils/filesystem');
-const { parseApiSpecContent } = require('../utils/apiSpecs');
+const { parseApiSpecContent, resolveExternalApiSpecRefs } = require('../utils/apiSpecs');
const hasApiSpecExtension = (filename) => {
if (!filename || typeof filename !== 'string') return false;
return ['yaml', 'yml', 'json'].some((ext) => filename.toLowerCase().endsWith(`.${ext}`));
};
+const isSpecItself = (pathname, watchPath) => path.normalize(pathname) === path.normalize(watchPath);
+
const hydrateApiSpecWithUuid = (apiSpec, pathname) => {
apiSpec.uid = getApiSpecUid(pathname);
return apiSpec;
};
-const add = async (win, pathname) => {
+const syncRefFileWatchers = ({ watcher, watchedRefFilePaths }, refFilePaths) => {
+ const unwatched = refFilePaths.filter((filePath) => !watchedRefFilePaths.has(filePath));
+ if (!unwatched.length) return;
+
+ unwatched.forEach((filePath) => watchedRefFilePaths.add(filePath));
+ watcher.add(unwatched);
+};
+
+const add = async (win, pathname, refWatchState) => {
if (!hasApiSpecExtension(pathname)) return;
try {
const basename = path.basename(pathname);
@@ -24,20 +34,23 @@ const add = async (win, pathname) => {
const raw = fs.readFileSync(pathname, 'utf8');
const extension = path.extname(pathname);
const apiSpecContent = parseApiSpecContent(raw, extension);
+ const { resolvedJson, refFilePaths } = await resolveExternalApiSpecRefs(apiSpecContent, pathname);
file.raw = raw;
file.name = apiSpecContent?.info?.title || basename.split('.')[0];
file.filename = basename;
file.pathname = pathname;
file.json = apiSpecContent;
+ file.resolvedJson = resolvedJson;
hydrateApiSpecWithUuid(file, pathname);
win.webContents.send('main:apispec-tree-updated', 'addFile', file);
+ syncRefFileWatchers(refWatchState, refFilePaths);
} catch (err) {
console.error(err);
}
};
-const change = async (win, pathname) => {
+const change = async (win, pathname, refWatchState) => {
if (!hasApiSpecExtension(pathname)) return;
try {
const basename = path.basename(pathname);
@@ -45,14 +58,17 @@ const change = async (win, pathname) => {
const raw = fs.readFileSync(pathname, 'utf8');
const extension = path.extname(pathname);
const apiSpecContent = parseApiSpecContent(raw, extension);
+ const { resolvedJson, refFilePaths } = await resolveExternalApiSpecRefs(apiSpecContent, pathname);
file.raw = raw;
file.name = apiSpecContent?.info?.title || basename.split('.')[0];
file.filename = basename;
file.pathname = pathname;
file.json = apiSpecContent;
+ file.resolvedJson = resolvedJson;
hydrateApiSpecWithUuid(file, pathname);
win.webContents.send('main:apispec-tree-updated', 'changeFile', file);
+ syncRefFileWatchers(refWatchState, refFilePaths);
} catch (err) {
console.error(err);
}
@@ -111,9 +127,21 @@ class ApiSpecWatcher {
depth: 20
});
+ const refWatchState = { watcher, watchedRefFilePaths: new Set() };
+
watcher
- .on('add', (pathname) => add(win, pathname, apiSpecUid, watchPath, workspacePath))
- .on('change', (pathname) => change(win, pathname, apiSpecUid, watchPath, workspacePath));
+ .on('add', (pathname) => {
+ if (isSpecItself(pathname, watchPath)) return add(win, watchPath, refWatchState);
+ if (refWatchState.watchedRefFilePaths.has(path.resolve(pathname))) return;
+ change(win, watchPath, refWatchState);
+ })
+ .on('change', () => change(win, watchPath, refWatchState))
+ .on('unlink', (pathname) => {
+ if (isSpecItself(pathname, watchPath)) return;
+ refWatchState.watchedRefFilePaths.delete(path.resolve(pathname));
+ change(win, watchPath, refWatchState);
+ })
+ .on('error', (err) => console.error(`API spec watcher error for ${watchPath}:`, err));
self.watchers[watchPath] = watcher;
}, 100);
diff --git a/packages/bruno-electron/src/utils/apiSpecs.js b/packages/bruno-electron/src/utils/apiSpecs.js
index a57b2f1105b..5d2738ed9ac 100644
--- a/packages/bruno-electron/src/utils/apiSpecs.js
+++ b/packages/bruno-electron/src/utils/apiSpecs.js
@@ -1,4 +1,14 @@
+const fs = require('node:fs');
+const path = require('node:path');
const yaml = require('js-yaml');
+const { $RefParser } = require('@apidevtools/json-schema-ref-parser');
+
+const REF_PARSER_OPTIONS = {
+ resolve: { external: true, http: false },
+ continueOnError: true
+};
+
+const URI_SCHEME_REGEX = /^[a-z][a-z\d+\-.]+:/i;
const parseApiSpecContent = (content, extension) => {
const ext = (extension || '').toLowerCase();
@@ -16,4 +26,46 @@ const parseApiSpecContent = (content, extension) => {
return null;
};
-module.exports = { parseApiSpecContent };
+const externalRefTarget = (ref, specDir) => {
+ if (typeof ref !== 'string') return null;
+
+ const [filePath] = ref.split('#');
+ if (!filePath || URI_SCHEME_REGEX.test(filePath)) return null;
+
+ return path.resolve(specDir, filePath);
+};
+
+const containsExternalFileRef = (node, specDir, visited = new Set()) => {
+ if (!node || typeof node !== 'object' || visited.has(node)) return false;
+ visited.add(node);
+
+ const refTarget = externalRefTarget(node.$ref, specDir);
+ if (refTarget && fs.existsSync(refTarget)) return true;
+
+ return Object.values(node).some((value) => containsExternalFileRef(value, specDir, visited));
+};
+
+const refFilePathsOf = (parser, apiSpecPath) => {
+ const root = path.resolve(apiSpecPath);
+
+ return (parser?.$refs.paths('file') ?? [])
+ .map((refFilePath) => path.resolve(refFilePath))
+ .filter((refFilePath) => refFilePath !== root);
+};
+
+const resolveExternalApiSpecRefs = async (json, apiSpecPath) => {
+ let parser;
+ try {
+ if (!containsExternalFileRef(json, path.dirname(apiSpecPath))) {
+ return { resolvedJson: null, refFilePaths: [] };
+ }
+ parser = new $RefParser();
+ const resolvedJson = await parser.bundle(apiSpecPath, structuredClone(json), REF_PARSER_OPTIONS);
+
+ return { resolvedJson, refFilePaths: refFilePathsOf(parser, apiSpecPath) };
+ } catch {
+ return { resolvedJson: parser?.schema ?? null, refFilePaths: refFilePathsOf(parser, apiSpecPath) };
+ }
+};
+
+module.exports = { parseApiSpecContent, resolveExternalApiSpecRefs };
diff --git a/packages/bruno-electron/src/utils/tests/apiSpecs.spec.js b/packages/bruno-electron/src/utils/tests/apiSpecs.spec.js
new file mode 100644
index 00000000000..415210ebe2d
--- /dev/null
+++ b/packages/bruno-electron/src/utils/tests/apiSpecs.spec.js
@@ -0,0 +1,156 @@
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { parseApiSpecContent, resolveExternalApiSpecRefs } = require('../apiSpecs');
+
+describe('parseApiSpecContent', () => {
+ it('parses yaml and json by extension', () => {
+ expect(parseApiSpecContent('openapi: 3.1.0', '.yaml')).toEqual({ openapi: '3.1.0' });
+ expect(parseApiSpecContent('openapi: 3.1.0', '.YML')).toEqual({ openapi: '3.1.0' });
+ expect(parseApiSpecContent('{"openapi":"3.1.0"}', '.json')).toEqual({ openapi: '3.1.0' });
+ });
+
+ it('returns null for unknown extensions and unparseable content', () => {
+ expect(parseApiSpecContent('openapi: 3.1.0', '.txt')).toBeNull();
+ expect(parseApiSpecContent('{oops', '.json')).toBeNull();
+ });
+});
+
+describe('resolveExternalApiSpecRefs', () => {
+ let specDir;
+
+ const writeSpecFile = (filename, content) => {
+ const filePath = path.join(specDir, filename);
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, content);
+ return filePath;
+ };
+
+ beforeEach(() => {
+ specDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-api-spec-'));
+ });
+
+ afterEach(() => {
+ fs.rmSync(specDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
+ });
+
+ it('inlines a file referenced relative to the spec, not the process cwd', async () => {
+ writeSpecFile('endpoint.yaml', 'get:\n operationId: hello\n');
+ const specPath = writeSpecFile('openapi.yaml', '');
+
+ const { resolvedJson: resolved } = await resolveExternalApiSpecRefs(
+ { openapi: '3.1.0', paths: { '/hello': { $ref: './endpoint.yaml' } } },
+ specPath
+ );
+
+ expect(resolved.paths['/hello']).toEqual({ get: { operationId: 'hello' } });
+ });
+
+ it('inlines refs from a nested directory and rebases their internal pointers', async () => {
+ writeSpecFile('schemas/hello.yaml', 'Hello:\n type: object\nGreeting:\n $ref: "#/Hello"\n');
+ const specPath = writeSpecFile('openapi.yaml', '');
+
+ const { resolvedJson: resolved } = await resolveExternalApiSpecRefs(
+ {
+ openapi: '3.1.0',
+ components: {
+ schemas: {
+ Hello: { $ref: './schemas/hello.yaml#/Hello' },
+ Greeting: { $ref: './schemas/hello.yaml#/Greeting' }
+ }
+ }
+ },
+ specPath
+ );
+
+ expect(resolved.components.schemas.Hello).toEqual({ type: 'object' });
+ expect(resolved.components.schemas.Greeting).toEqual({ $ref: '#/components/schemas/Hello' });
+ });
+
+ it('leaves the document it is given untouched', async () => {
+ writeSpecFile('endpoint.yaml', 'get:\n operationId: hello\n');
+ const specPath = writeSpecFile('openapi.yaml', '');
+ const json = { openapi: '3.1.0', paths: { '/hello': { $ref: './endpoint.yaml' } } };
+
+ await resolveExternalApiSpecRefs(json, specPath);
+
+ expect(json.paths['/hello']).toEqual({ $ref: './endpoint.yaml' });
+ });
+
+ it('follows a ref chain through a third file', async () => {
+ writeSpecFile('shared.yaml', 'Ok:\n description: ok\n');
+ writeSpecFile('endpoint.yaml', 'get:\n responses:\n "200":\n $ref: "./shared.yaml#/Ok"\n');
+ const specPath = writeSpecFile('openapi.yaml', '');
+
+ const { resolvedJson: resolved } = await resolveExternalApiSpecRefs(
+ { openapi: '3.1.0', paths: { '/hello': { $ref: './endpoint.yaml' } } },
+ specPath
+ );
+
+ expect(resolved.paths['/hello'].get.responses['200']).toEqual({ description: 'ok' });
+ });
+
+ it('turns a ref cycle across files into an internal ref the renderer can receive', async () => {
+ writeSpecFile('endpoint.yaml', 'get:\n responses:\n "200":\n $ref: "./openapi.yaml#/paths/~1hello"\n');
+ const specPath = writeSpecFile('openapi.yaml', 'paths:\n /hello:\n $ref: "./endpoint.yaml"\n');
+
+ const { resolvedJson: resolved } = await resolveExternalApiSpecRefs(
+ { openapi: '3.1.0', paths: { '/hello': { $ref: './endpoint.yaml' } } },
+ specPath
+ );
+
+ expect(resolved.paths['/hello'].get.responses['200']).toEqual({ $ref: '#/paths/~1hello' });
+ expect(() => JSON.stringify(resolved)).not.toThrow();
+ });
+
+ it('returns null when there is nothing external to inline', async () => {
+ const specPath = writeSpecFile('openapi.yaml', '');
+ const internalOnly = {
+ openapi: '3.1.0',
+ paths: { '/hello': { get: { responses: { 200: { $ref: '#/components/responses/Ok' } } } } }
+ };
+
+ expect((await resolveExternalApiSpecRefs(internalOnly, specPath)).resolvedJson).toBeNull();
+ expect((await resolveExternalApiSpecRefs(null, specPath)).resolvedJson).toBeNull();
+ });
+
+ it('leaves refs the parser cannot follow alone', async () => {
+ const specPath = writeSpecFile('openapi.yaml', '');
+ const remoteRef = { openapi: '3.1.0', paths: { '/hello': { $ref: 'https://example.com/endpoint.yaml' } } };
+ // An OpenAPI 3.1 `$ref` to an in-document `$id` is indistinguishable from a relative file path.
+ const idRef = {
+ openapi: '3.1.0',
+ components: { schemas: { Hello: { $id: 'hello.json', type: 'object' }, Greeting: { $ref: 'hello.json' } } }
+ };
+
+ expect((await resolveExternalApiSpecRefs(remoteRef, specPath)).resolvedJson).toBeNull();
+ expect((await resolveExternalApiSpecRefs(idRef, specPath)).resolvedJson).toBeNull();
+ });
+
+ it('inlines what it can when one ref among several is unresolvable', async () => {
+ writeSpecFile('endpoint.yaml', 'get:\n operationId: hello\n');
+ const specPath = writeSpecFile('openapi.yaml', '');
+
+ const { resolvedJson: resolved } = await resolveExternalApiSpecRefs(
+ {
+ openapi: '3.1.0',
+ paths: {
+ '/hello': { $ref: './endpoint.yaml' },
+ '/missing': { $ref: './deleted.yaml' }
+ }
+ },
+ specPath
+ );
+
+ expect(resolved.paths['/hello']).toEqual({ get: { operationId: 'hello' } });
+ expect(resolved.paths['/missing']).toEqual({ $ref: './deleted.yaml' });
+ });
+
+ it('does not hang on a self-referencing document', async () => {
+ const specPath = writeSpecFile('openapi.yaml', '');
+ const json = { openapi: '3.1.0', components: {} };
+ json.components.self = json;
+
+ expect((await resolveExternalApiSpecRefs(json, specPath)).resolvedJson).toBeNull();
+ });
+});
diff --git a/packages/bruno-electron/tests/app/apiSpecs.spec.js b/packages/bruno-electron/tests/app/apiSpecs.spec.js
index 63a38b03f48..8aafe721826 100644
--- a/packages/bruno-electron/tests/app/apiSpecs.spec.js
+++ b/packages/bruno-electron/tests/app/apiSpecs.spec.js
@@ -91,6 +91,44 @@ describe('openApiSpec', () => {
expect(win.webContents.send).not.toHaveBeenCalledWith('main:display-error', expect.anything());
});
+ test('sends the referenced files inlined as resolvedJson for a multi-file spec', async () => {
+ writeSpecFile('endpoint.yaml', 'get:\n summary: Hello endpoint\n operationId: hello\n');
+ const specPath = writeSpecFile(
+ 'openapi.yaml',
+ 'openapi: 3.1.0\ninfo:\n title: Test API\n version: 1.0.0\npaths:\n /hello:\n $ref: "./endpoint.yaml"\n'
+ );
+ watcher.hasWatcher.mockReturnValue(true);
+
+ await openApiSpec(win, watcher, specPath);
+
+ expect(win.webContents.send).toHaveBeenCalledWith(
+ 'main:apispec-tree-updated',
+ 'addFile',
+ expect.objectContaining({
+ json: expect.objectContaining({ paths: { '/hello': { $ref: './endpoint.yaml' } } }),
+ resolvedJson: expect.objectContaining({
+ paths: { '/hello': { get: { summary: 'Hello endpoint', operationId: 'hello' } } }
+ })
+ })
+ );
+ });
+
+ test('sends resolvedJson as null for a single-file spec', async () => {
+ const specPath = writeSpecFile(
+ 'openapi.yaml',
+ 'openapi: 3.1.0\ninfo:\n title: Test API\n version: 1.0.0\npaths:\n /hello:\n get:\n responses:\n "200":\n description: ok\n'
+ );
+ watcher.hasWatcher.mockReturnValue(true);
+
+ await openApiSpec(win, watcher, specPath);
+
+ expect(win.webContents.send).toHaveBeenCalledWith(
+ 'main:apispec-tree-updated',
+ 'addFile',
+ expect.objectContaining({ resolvedJson: null })
+ );
+ });
+
test('opens a broken JSON file with a valid extension without throwing, resolving json to null', async () => {
const specPath = writeSpecFile('broken.json', '{\n "openapi": "3.0.0",\n "info": {\n "title": "Test"\n "version": "1.0.0"\n },\n "paths": {\n');
watcher.hasWatcher.mockReturnValue(true);
diff --git a/packages/bruno-electron/tests/app/apiSpecsWatcher.spec.js b/packages/bruno-electron/tests/app/apiSpecsWatcher.spec.js
new file mode 100644
index 00000000000..7ec0989b05d
--- /dev/null
+++ b/packages/bruno-electron/tests/app/apiSpecsWatcher.spec.js
@@ -0,0 +1,53 @@
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const ApiSpecWatcher = require('../../src/app/apiSpecsWatcher');
+
+describe('ApiSpecWatcher ref file watching', () => {
+ let specDir;
+ let win;
+ let apiSpecWatcher;
+
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+ const specUpdates = () => win.webContents.send.mock.calls.filter(([channel]) => channel === 'main:apispec-tree-updated');
+
+ const waitForUpdates = async (predicate, description) => {
+ const deadline = Date.now() + 10000;
+ while (Date.now() < deadline) {
+ if (predicate()) return;
+ await sleep(25);
+ }
+ throw new Error(`Timed out waiting for ${description}. Updates so far: ${specUpdates().length}`);
+ };
+
+ beforeEach(() => {
+ specDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-apispec-watcher-')));
+ win = { webContents: { send: jest.fn() } };
+ apiSpecWatcher = new ApiSpecWatcher();
+ });
+
+ afterEach(async () => {
+ await apiSpecWatcher.closeAllWatchers();
+ fs.rmSync(specDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
+ });
+
+ it('resolves the spec once on open and again when a referenced file changes', async () => {
+ const refPath = path.join(specDir, 'endpoint.yaml');
+ const specPath = path.join(specDir, 'openapi.yaml');
+ fs.writeFileSync(refPath, 'get:\n operationId: hello\n');
+ fs.writeFileSync(specPath, 'openapi: 3.1.0\npaths:\n /hello:\n $ref: \'./endpoint.yaml\'\n');
+
+ apiSpecWatcher.addWatcher(win, specPath, 'api-spec-uid', {});
+ await waitForUpdates(() => specUpdates().length >= 1, 'the spec to be picked up');
+ await sleep(600);
+ expect(specUpdates()).toHaveLength(1);
+ expect(specUpdates()[0][2].resolvedJson.paths['/hello'].get.operationId).toBe('hello');
+
+ fs.writeFileSync(refPath, 'get:\n operationId: helloEdited\n');
+
+ await waitForUpdates(() => specUpdates().length > 1, 'the ref file edit to refresh the spec');
+ const [, type, file] = specUpdates()[specUpdates().length - 1];
+ expect(type).toBe('changeFile');
+ expect(file.resolvedJson.paths['/hello'].get.operationId).toBe('helloEdited');
+ }, 20000);
+});
diff --git a/tests/import/openapi/api-spec-panel-validation.spec.ts b/tests/import/openapi/api-spec-panel-validation.spec.ts
index b956ae21a07..ade16805834 100644
--- a/tests/import/openapi/api-spec-panel-validation.spec.ts
+++ b/tests/import/openapi/api-spec-panel-validation.spec.ts
@@ -63,6 +63,16 @@ test.describe('API Spec Panel - open & preview validation', () => {
).toBeVisible();
});
+ test('Render a spec whose paths live in a referenced file', async ({ page, electronApp }) => {
+ const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-multifile.yaml');
+ await openApiSpecFromDialog(page, electronApp, openApiFile);
+ await openApiSpecSidebarItem(page, 'Multi File API');
+ // "Hello endpoint" lives only in openapi-multifile-endpoint.yaml, so a preview showing it
+ // proves the referenced file was resolved relative to the spec and not to the app's resources.
+ await expect(page.getByText('Hello endpoint').first()).toBeVisible();
+ await expect(page.getByText(/Could not resolve reference/i)).toHaveCount(0);
+ });
+
test('Render a valid spec without any preview error', async ({ page, electronApp }) => {
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-simple.json');
await openApiSpecFromDialog(page, electronApp, openApiFile);
diff --git a/tests/import/openapi/fixtures/openapi-multifile-endpoint.yaml b/tests/import/openapi/fixtures/openapi-multifile-endpoint.yaml
new file mode 100644
index 00000000000..11e0672b4dc
--- /dev/null
+++ b/tests/import/openapi/fixtures/openapi-multifile-endpoint.yaml
@@ -0,0 +1,6 @@
+get:
+ summary: Hello endpoint
+ operationId: hello
+ responses:
+ '200':
+ description: Successful response
diff --git a/tests/import/openapi/fixtures/openapi-multifile.yaml b/tests/import/openapi/fixtures/openapi-multifile.yaml
new file mode 100644
index 00000000000..82362114046
--- /dev/null
+++ b/tests/import/openapi/fixtures/openapi-multifile.yaml
@@ -0,0 +1,7 @@
+openapi: 3.1.0
+info:
+ title: Multi File API
+ version: 1.0.0
+paths:
+ /hello:
+ $ref: './openapi-multifile-endpoint.yaml'