From b047feb2dd3c7254f6ba96fd93cff4e094fd1b9e Mon Sep 17 00:00:00 2001 From: Adwait Aayush Date: Wed, 12 Aug 2026 15:57:59 +0530 Subject: [PATCH 1/6] fix(openAPI):multifile openAPI specs can be imported --- package-lock.json | 19 +++ .../src/components/ApiSpecPanel/SpecViewer.js | 7 +- .../src/components/ApiSpecPanel/index.js | 3 +- .../providers/ReduxStore/slices/apiSpec.js | 9 +- packages/bruno-electron/package.json | 1 + packages/bruno-electron/src/app/apiSpecs.js | 6 +- .../bruno-electron/src/app/apiSpecsWatcher.js | 4 +- packages/bruno-electron/src/utils/apiSpecs.js | 42 ++++- .../src/utils/tests/apiSpecs.spec.js | 156 ++++++++++++++++++ .../bruno-electron/tests/app/apiSpecs.spec.js | 38 +++++ .../openapi/api-spec-panel-validation.spec.ts | 10 ++ .../fixtures/openapi-multifile-endpoint.yaml | 6 + .../openapi/fixtures/openapi-multifile.yaml | 7 + 13 files changed, 298 insertions(+), 10 deletions(-) create mode 100644 packages/bruno-electron/src/utils/tests/apiSpecs.spec.js create mode 100644 tests/import/openapi/fixtures/openapi-multifile-endpoint.yaml create mode 100644 tests/import/openapi/fixtures/openapi-multifile.yaml diff --git a/package-lock.json b/package-lock.json index 07b2c4f3c3c..e05696f8416 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..4c2fbb32e09 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..0402bac4cce 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,7 @@ const openApiSpec = async (win, watcher, apiSpecPath, options = {}) => { } else { const rawContent = fs.readFileSync(apiSpecPath, 'utf8'); const extension = path.extname(apiSpecPath); + const apiSpecContent = parseApiSpecContent(rawContent, extension); win.webContents.send('main:apispec-tree-updated', 'addFile', { pathname: apiSpecPath, @@ -101,7 +102,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: await resolveExternalApiSpecRefs(apiSpecContent, apiSpecPath) }); } } catch (err) { diff --git a/packages/bruno-electron/src/app/apiSpecsWatcher.js b/packages/bruno-electron/src/app/apiSpecsWatcher.js index dc07d3f0f48..a7e5820d72a 100644 --- a/packages/bruno-electron/src/app/apiSpecsWatcher.js +++ b/packages/bruno-electron/src/app/apiSpecsWatcher.js @@ -4,7 +4,7 @@ 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; @@ -30,6 +30,7 @@ const add = async (win, pathname) => { file.filename = basename; file.pathname = pathname; file.json = apiSpecContent; + file.resolvedJson = await resolveExternalApiSpecRefs(apiSpecContent, pathname); hydrateApiSpecWithUuid(file, pathname); win.webContents.send('main:apispec-tree-updated', 'addFile', file); } catch (err) { @@ -51,6 +52,7 @@ const change = async (win, pathname) => { file.filename = basename; file.pathname = pathname; file.json = apiSpecContent; + file.resolvedJson = await resolveExternalApiSpecRefs(apiSpecContent, pathname); hydrateApiSpecWithUuid(file, pathname); win.webContents.send('main:apispec-tree-updated', 'changeFile', file); } catch (err) { diff --git a/packages/bruno-electron/src/utils/apiSpecs.js b/packages/bruno-electron/src/utils/apiSpecs.js index a57b2f1105b..c198393889c 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,34 @@ 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 resolveExternalApiSpecRefs = async (json, apiSpecPath) => { + if (!containsExternalFileRef(json, path.dirname(apiSpecPath))) return null; + + const parser = new $RefParser(); + try { + return await parser.bundle(apiSpecPath, structuredClone(json), REF_PARSER_OPTIONS); + } catch { + return parser.schema ?? null; + } +}; + +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..f53b09b4cf2 --- /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 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 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 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 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)).toBeNull(); + expect(await resolveExternalApiSpecRefs(null, specPath)).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)).toBeNull(); + expect(await resolveExternalApiSpecRefs(idRef, specPath)).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 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)).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/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' From 63afc6f5c36d427e7138ca7069336fddb3bdf2d5 Mon Sep 17 00:00:00 2001 From: Adwait Aayush Date: Thu, 13 Aug 2026 18:43:16 +0530 Subject: [PATCH 2/6] fix(openAPI):ref resolution failures no longer stop a spec from opening --- packages/bruno-electron/src/utils/apiSpecs.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/bruno-electron/src/utils/apiSpecs.js b/packages/bruno-electron/src/utils/apiSpecs.js index c198393889c..2a13d6a34f5 100644 --- a/packages/bruno-electron/src/utils/apiSpecs.js +++ b/packages/bruno-electron/src/utils/apiSpecs.js @@ -46,13 +46,13 @@ const containsExternalFileRef = (node, specDir, visited = new Set()) => { }; const resolveExternalApiSpecRefs = async (json, apiSpecPath) => { - if (!containsExternalFileRef(json, path.dirname(apiSpecPath))) return null; - - const parser = new $RefParser(); + let parser; try { + if (!containsExternalFileRef(json, path.dirname(apiSpecPath))) return null; + parser = new $RefParser(); return await parser.bundle(apiSpecPath, structuredClone(json), REF_PARSER_OPTIONS); } catch { - return parser.schema ?? null; + return parser?.schema ?? null; } }; From 56d30121d98fc1a7095e69dbbc1d8246590e47e3 Mon Sep 17 00:00:00 2001 From: Adwait Aayush Date: Wed, 26 Aug 2026 00:03:33 +0530 Subject: [PATCH 3/6] fix:ref files are now also watched --- packages/bruno-electron/src/app/apiSpecs.js | 3 +- .../bruno-electron/src/app/apiSpecsWatcher.js | 40 ++++++++++++++++--- packages/bruno-electron/src/utils/apiSpecs.js | 18 +++++++-- .../src/utils/tests/apiSpecs.spec.js | 20 +++++----- 4 files changed, 62 insertions(+), 19 deletions(-) diff --git a/packages/bruno-electron/src/app/apiSpecs.js b/packages/bruno-electron/src/app/apiSpecs.js index 0402bac4cce..350376ed60d 100644 --- a/packages/bruno-electron/src/app/apiSpecs.js +++ b/packages/bruno-electron/src/app/apiSpecs.js @@ -95,6 +95,7 @@ const openApiSpec = async (win, watcher, apiSpecPath, options = {}) => { 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, @@ -103,7 +104,7 @@ const openApiSpec = async (win, watcher, apiSpecPath, options = {}) => { name: path.basename(apiSpecPath, path.extname(apiSpecPath)), filename: path.basename(apiSpecPath), json: apiSpecContent, - resolvedJson: await resolveExternalApiSpecRefs(apiSpecContent, apiSpecPath) + resolvedJson: resolvedJson }); } } catch (err) { diff --git a/packages/bruno-electron/src/app/apiSpecsWatcher.js b/packages/bruno-electron/src/app/apiSpecsWatcher.js index a7e5820d72a..12c27d9b66f 100644 --- a/packages/bruno-electron/src/app/apiSpecsWatcher.js +++ b/packages/bruno-electron/src/app/apiSpecsWatcher.js @@ -16,6 +16,19 @@ const hydrateApiSpecWithUuid = (apiSpec, pathname) => { return apiSpec; }; +const refFileWatchState = new Map(); + +const syncRefFileWatchers = (rootPath, refFilePaths) => { + const state = refFileWatchState.get(rootPath); + if (!state) return; + + const unwatched = refFilePaths.filter((filePath) => !state.watchedRefFilePaths.has(filePath)); + if (!unwatched.length) return; + + unwatched.forEach((filePath) => state.watchedRefFilePaths.add(filePath)); + state.watcher.add(unwatched); +}; + const add = async (win, pathname) => { if (!hasApiSpecExtension(pathname)) return; try { @@ -24,15 +37,17 @@ 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 = await resolveExternalApiSpecRefs(apiSpecContent, pathname); + file.resolvedJson = resolvedJson; hydrateApiSpecWithUuid(file, pathname); win.webContents.send('main:apispec-tree-updated', 'addFile', file); + syncRefFileWatchers(pathname, refFilePaths); } catch (err) { console.error(err); } @@ -46,15 +61,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 = await resolveExternalApiSpecRefs(apiSpecContent, pathname); + file.resolvedJson = resolvedJson; hydrateApiSpecWithUuid(file, pathname); win.webContents.send('main:apispec-tree-updated', 'changeFile', file); + syncRefFileWatchers(pathname, refFilePaths); } catch (err) { console.error(err); } @@ -72,6 +89,7 @@ class ApiSpecWatcher { if (this.watchers[watchPath]) { this.watchers[watchPath].close(); + refFileWatchState.delete(watchPath); } if (workspacePath) { @@ -113,10 +131,20 @@ class ApiSpecWatcher { depth: 20 }); - watcher - .on('add', (pathname) => add(win, pathname, apiSpecUid, watchPath, workspacePath)) - .on('change', (pathname) => change(win, pathname, apiSpecUid, watchPath, workspacePath)); + const isSpecItself = (pathname) => path.resolve(pathname) === path.resolve(watchPath); + watcher + .on('add', (pathname) => { + if (isSpecItself(pathname)) add(win, watchPath); + else change(win, watchPath); + }) + .on('change', () => change(win, watchPath)) + .on('unlink', (pathname) => { + if (!isSpecItself(pathname)) change(win, watchPath); + }) + .on('error', (err) => console.error(`API spec watcher error for ${watchPath}:`, err)); + + refFileWatchState.set(watchPath, { watcher, watchedRefFilePaths: new Set() }); self.watchers[watchPath] = watcher; }, 100); } @@ -126,6 +154,7 @@ class ApiSpecWatcher { } removeWatcher(watchPath, win) { + refFileWatchState.delete(watchPath); if (this.watchers[watchPath]) { this.watchers[watchPath].close(); this.watchers[watchPath] = null; @@ -145,6 +174,7 @@ class ApiSpecWatcher { } this.watchers = {}; this.watcherWorkspaces = {}; + refFileWatchState.clear(); return Promise.allSettled(pending); } } diff --git a/packages/bruno-electron/src/utils/apiSpecs.js b/packages/bruno-electron/src/utils/apiSpecs.js index 2a13d6a34f5..5d2738ed9ac 100644 --- a/packages/bruno-electron/src/utils/apiSpecs.js +++ b/packages/bruno-electron/src/utils/apiSpecs.js @@ -45,14 +45,26 @@ const containsExternalFileRef = (node, specDir, visited = new Set()) => { 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 null; + if (!containsExternalFileRef(json, path.dirname(apiSpecPath))) { + return { resolvedJson: null, refFilePaths: [] }; + } parser = new $RefParser(); - return await parser.bundle(apiSpecPath, structuredClone(json), REF_PARSER_OPTIONS); + const resolvedJson = await parser.bundle(apiSpecPath, structuredClone(json), REF_PARSER_OPTIONS); + + return { resolvedJson, refFilePaths: refFilePathsOf(parser, apiSpecPath) }; } catch { - return parser?.schema ?? null; + return { resolvedJson: parser?.schema ?? null, refFilePaths: refFilePathsOf(parser, apiSpecPath) }; } }; diff --git a/packages/bruno-electron/src/utils/tests/apiSpecs.spec.js b/packages/bruno-electron/src/utils/tests/apiSpecs.spec.js index f53b09b4cf2..415210ebe2d 100644 --- a/packages/bruno-electron/src/utils/tests/apiSpecs.spec.js +++ b/packages/bruno-electron/src/utils/tests/apiSpecs.spec.js @@ -38,7 +38,7 @@ describe('resolveExternalApiSpecRefs', () => { writeSpecFile('endpoint.yaml', 'get:\n operationId: hello\n'); const specPath = writeSpecFile('openapi.yaml', ''); - const resolved = await resolveExternalApiSpecRefs( + const { resolvedJson: resolved } = await resolveExternalApiSpecRefs( { openapi: '3.1.0', paths: { '/hello': { $ref: './endpoint.yaml' } } }, specPath ); @@ -50,7 +50,7 @@ describe('resolveExternalApiSpecRefs', () => { writeSpecFile('schemas/hello.yaml', 'Hello:\n type: object\nGreeting:\n $ref: "#/Hello"\n'); const specPath = writeSpecFile('openapi.yaml', ''); - const resolved = await resolveExternalApiSpecRefs( + const { resolvedJson: resolved } = await resolveExternalApiSpecRefs( { openapi: '3.1.0', components: { @@ -82,7 +82,7 @@ describe('resolveExternalApiSpecRefs', () => { writeSpecFile('endpoint.yaml', 'get:\n responses:\n "200":\n $ref: "./shared.yaml#/Ok"\n'); const specPath = writeSpecFile('openapi.yaml', ''); - const resolved = await resolveExternalApiSpecRefs( + const { resolvedJson: resolved } = await resolveExternalApiSpecRefs( { openapi: '3.1.0', paths: { '/hello': { $ref: './endpoint.yaml' } } }, specPath ); @@ -94,7 +94,7 @@ describe('resolveExternalApiSpecRefs', () => { 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 resolved = await resolveExternalApiSpecRefs( + const { resolvedJson: resolved } = await resolveExternalApiSpecRefs( { openapi: '3.1.0', paths: { '/hello': { $ref: './endpoint.yaml' } } }, specPath ); @@ -110,8 +110,8 @@ describe('resolveExternalApiSpecRefs', () => { paths: { '/hello': { get: { responses: { 200: { $ref: '#/components/responses/Ok' } } } } } }; - expect(await resolveExternalApiSpecRefs(internalOnly, specPath)).toBeNull(); - expect(await resolveExternalApiSpecRefs(null, specPath)).toBeNull(); + expect((await resolveExternalApiSpecRefs(internalOnly, specPath)).resolvedJson).toBeNull(); + expect((await resolveExternalApiSpecRefs(null, specPath)).resolvedJson).toBeNull(); }); it('leaves refs the parser cannot follow alone', async () => { @@ -123,15 +123,15 @@ describe('resolveExternalApiSpecRefs', () => { components: { schemas: { Hello: { $id: 'hello.json', type: 'object' }, Greeting: { $ref: 'hello.json' } } } }; - expect(await resolveExternalApiSpecRefs(remoteRef, specPath)).toBeNull(); - expect(await resolveExternalApiSpecRefs(idRef, specPath)).toBeNull(); + 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 resolved = await resolveExternalApiSpecRefs( + const { resolvedJson: resolved } = await resolveExternalApiSpecRefs( { openapi: '3.1.0', paths: { @@ -151,6 +151,6 @@ describe('resolveExternalApiSpecRefs', () => { const json = { openapi: '3.1.0', components: {} }; json.components.self = json; - expect(await resolveExternalApiSpecRefs(json, specPath)).toBeNull(); + expect((await resolveExternalApiSpecRefs(json, specPath)).resolvedJson).toBeNull(); }); }); From 90eaee68a69cce2977b27fd31e4f0037159bc938 Mon Sep 17 00:00:00 2001 From: Adwait Aayush Date: Wed, 26 Aug 2026 13:17:57 +0530 Subject: [PATCH 4/6] chore:added comments --- packages/bruno-electron/src/app/apiSpecsWatcher.js | 8 ++++++-- packages/bruno-electron/src/utils/apiSpecs.js | 6 ++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/bruno-electron/src/app/apiSpecsWatcher.js b/packages/bruno-electron/src/app/apiSpecsWatcher.js index 12c27d9b66f..d7546ad7e82 100644 --- a/packages/bruno-electron/src/app/apiSpecsWatcher.js +++ b/packages/bruno-electron/src/app/apiSpecsWatcher.js @@ -17,11 +17,11 @@ const hydrateApiSpecWithUuid = (apiSpec, pathname) => { }; const refFileWatchState = new Map(); - +// Maps the root path of the spec to an object containing the watcher object and set of referenced files const syncRefFileWatchers = (rootPath, refFilePaths) => { const state = refFileWatchState.get(rootPath); if (!state) return; - + // If any referenced file is not being watched,add it to watcher and update the state. const unwatched = refFilePaths.filter((filePath) => !state.watchedRefFilePaths.has(filePath)); if (!unwatched.length) return; @@ -133,11 +133,15 @@ class ApiSpecWatcher { const isSpecItself = (pathname) => path.resolve(pathname) === path.resolve(watchPath); + // watch the changes in the spec file and referenced file. watcher .on('add', (pathname) => { + // if the added file is spec file then sends an addFile message to renderer. if (isSpecItself(pathname)) add(win, watchPath); + // else if the added file is referenced file then refresh the spec file else change(win, watchPath); }) + // if there are any changes in the spec or referenced file just refresh the spec file. .on('change', () => change(win, watchPath)) .on('unlink', (pathname) => { if (!isSpecItself(pathname)) change(win, watchPath); diff --git a/packages/bruno-electron/src/utils/apiSpecs.js b/packages/bruno-electron/src/utils/apiSpecs.js index 5d2738ed9ac..dbba39b8e59 100644 --- a/packages/bruno-electron/src/utils/apiSpecs.js +++ b/packages/bruno-electron/src/utils/apiSpecs.js @@ -8,6 +8,7 @@ const REF_PARSER_OPTIONS = { continueOnError: true }; +// regex to check whether the file path is a valid one rejects(http://,https:// ,file:// etc) const URI_SCHEME_REGEX = /^[a-z][a-z\d+\-.]+:/i; const parseApiSpecContent = (content, extension) => { @@ -34,7 +35,7 @@ const externalRefTarget = (ref, specDir) => { return path.resolve(specDir, filePath); }; - +// recursively chceks if any node contains an external reference . const containsExternalFileRef = (node, specDir, visited = new Set()) => { if (!node || typeof node !== 'object' || visited.has(node)) return false; visited.add(node); @@ -44,7 +45,7 @@ const containsExternalFileRef = (node, specDir, visited = new Set()) => { return Object.values(node).some((value) => containsExternalFileRef(value, specDir, visited)); }; - +// returns the list of referenced files except the root spec file. const refFilePathsOf = (parser, apiSpecPath) => { const root = path.resolve(apiSpecPath); @@ -53,6 +54,7 @@ const refFilePathsOf = (parser, apiSpecPath) => { .filter((refFilePath) => refFilePath !== root); }; +// This function resolves the external referencs inline and returns the resoved json and the list of referenced file paths. const resolveExternalApiSpecRefs = async (json, apiSpecPath) => { let parser; try { From 725c53b986382ad15dabc9dda15796775d4fce35 Mon Sep 17 00:00:00 2001 From: Adwait Aayush Date: Thu, 27 Aug 2026 18:21:25 +0530 Subject: [PATCH 5/6] fix(openAPI): resolve multifile specs once per change --- .../bruno-electron/src/app/apiSpecsWatcher.js | 42 ++++++--------- packages/bruno-electron/src/utils/apiSpecs.js | 6 +-- .../tests/app/apiSpecsWatcher.spec.js | 53 +++++++++++++++++++ 3 files changed, 72 insertions(+), 29 deletions(-) create mode 100644 packages/bruno-electron/tests/app/apiSpecsWatcher.spec.js diff --git a/packages/bruno-electron/src/app/apiSpecsWatcher.js b/packages/bruno-electron/src/app/apiSpecsWatcher.js index d7546ad7e82..dec4f6f6133 100644 --- a/packages/bruno-electron/src/app/apiSpecsWatcher.js +++ b/packages/bruno-electron/src/app/apiSpecsWatcher.js @@ -16,20 +16,15 @@ const hydrateApiSpecWithUuid = (apiSpec, pathname) => { return apiSpec; }; -const refFileWatchState = new Map(); -// Maps the root path of the spec to an object containing the watcher object and set of referenced files -const syncRefFileWatchers = (rootPath, refFilePaths) => { - const state = refFileWatchState.get(rootPath); - if (!state) return; - // If any referenced file is not being watched,add it to watcher and update the state. - const unwatched = refFilePaths.filter((filePath) => !state.watchedRefFilePaths.has(filePath)); +const syncRefFileWatchers = ({ watcher, watchedRefFilePaths }, refFilePaths) => { + const unwatched = refFilePaths.filter((filePath) => !watchedRefFilePaths.has(filePath)); if (!unwatched.length) return; - unwatched.forEach((filePath) => state.watchedRefFilePaths.add(filePath)); - state.watcher.add(unwatched); + unwatched.forEach((filePath) => watchedRefFilePaths.add(filePath)); + watcher.add(unwatched); }; -const add = async (win, pathname) => { +const add = async (win, pathname, refWatchState) => { if (!hasApiSpecExtension(pathname)) return; try { const basename = path.basename(pathname); @@ -47,13 +42,13 @@ const add = async (win, pathname) => { file.resolvedJson = resolvedJson; hydrateApiSpecWithUuid(file, pathname); win.webContents.send('main:apispec-tree-updated', 'addFile', file); - syncRefFileWatchers(pathname, refFilePaths); + 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); @@ -71,7 +66,7 @@ const change = async (win, pathname) => { file.resolvedJson = resolvedJson; hydrateApiSpecWithUuid(file, pathname); win.webContents.send('main:apispec-tree-updated', 'changeFile', file); - syncRefFileWatchers(pathname, refFilePaths); + syncRefFileWatchers(refWatchState, refFilePaths); } catch (err) { console.error(err); } @@ -89,7 +84,6 @@ class ApiSpecWatcher { if (this.watchers[watchPath]) { this.watchers[watchPath].close(); - refFileWatchState.delete(watchPath); } if (workspacePath) { @@ -133,22 +127,22 @@ class ApiSpecWatcher { const isSpecItself = (pathname) => path.resolve(pathname) === path.resolve(watchPath); - // watch the changes in the spec file and referenced file. + const refWatchState = { watcher, watchedRefFilePaths: new Set() }; + watcher .on('add', (pathname) => { - // if the added file is spec file then sends an addFile message to renderer. - if (isSpecItself(pathname)) add(win, watchPath); - // else if the added file is referenced file then refresh the spec file - else change(win, watchPath); + if (isSpecItself(pathname)) return add(win, watchPath, refWatchState); + if (refWatchState.watchedRefFilePaths.has(path.resolve(pathname))) return; + change(win, watchPath, refWatchState); }) - // if there are any changes in the spec or referenced file just refresh the spec file. - .on('change', () => change(win, watchPath)) + .on('change', () => change(win, watchPath, refWatchState)) .on('unlink', (pathname) => { - if (!isSpecItself(pathname)) change(win, watchPath); + if (isSpecItself(pathname)) return; + refWatchState.watchedRefFilePaths.delete(path.resolve(pathname)); + change(win, watchPath, refWatchState); }) .on('error', (err) => console.error(`API spec watcher error for ${watchPath}:`, err)); - refFileWatchState.set(watchPath, { watcher, watchedRefFilePaths: new Set() }); self.watchers[watchPath] = watcher; }, 100); } @@ -158,7 +152,6 @@ class ApiSpecWatcher { } removeWatcher(watchPath, win) { - refFileWatchState.delete(watchPath); if (this.watchers[watchPath]) { this.watchers[watchPath].close(); this.watchers[watchPath] = null; @@ -178,7 +171,6 @@ class ApiSpecWatcher { } this.watchers = {}; this.watcherWorkspaces = {}; - refFileWatchState.clear(); return Promise.allSettled(pending); } } diff --git a/packages/bruno-electron/src/utils/apiSpecs.js b/packages/bruno-electron/src/utils/apiSpecs.js index dbba39b8e59..5d2738ed9ac 100644 --- a/packages/bruno-electron/src/utils/apiSpecs.js +++ b/packages/bruno-electron/src/utils/apiSpecs.js @@ -8,7 +8,6 @@ const REF_PARSER_OPTIONS = { continueOnError: true }; -// regex to check whether the file path is a valid one rejects(http://,https:// ,file:// etc) const URI_SCHEME_REGEX = /^[a-z][a-z\d+\-.]+:/i; const parseApiSpecContent = (content, extension) => { @@ -35,7 +34,7 @@ const externalRefTarget = (ref, specDir) => { return path.resolve(specDir, filePath); }; -// recursively chceks if any node contains an external reference . + const containsExternalFileRef = (node, specDir, visited = new Set()) => { if (!node || typeof node !== 'object' || visited.has(node)) return false; visited.add(node); @@ -45,7 +44,7 @@ const containsExternalFileRef = (node, specDir, visited = new Set()) => { return Object.values(node).some((value) => containsExternalFileRef(value, specDir, visited)); }; -// returns the list of referenced files except the root spec file. + const refFilePathsOf = (parser, apiSpecPath) => { const root = path.resolve(apiSpecPath); @@ -54,7 +53,6 @@ const refFilePathsOf = (parser, apiSpecPath) => { .filter((refFilePath) => refFilePath !== root); }; -// This function resolves the external referencs inline and returns the resoved json and the list of referenced file paths. const resolveExternalApiSpecRefs = async (json, apiSpecPath) => { let parser; try { 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); +}); From 76e14576df6c682c245c6abe37742391306b2978 Mon Sep 17 00:00:00 2001 From: Adwait Aayush Date: Tue, 1 Sep 2026 17:33:26 +0530 Subject: [PATCH 6/6] chore(deps):fixed version to 14.2.1 --- package-lock.json | 2 +- packages/bruno-electron/package.json | 2 +- packages/bruno-electron/src/app/apiSpecsWatcher.js | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index e05696f8416..ded4c6c9e6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28835,7 +28835,7 @@ "dependencies": { "@ai-sdk/anthropic": "3.0.15", "@ai-sdk/openai": "3.0.12", - "@apidevtools/json-schema-ref-parser": "^14.2.1", + "@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/package.json b/packages/bruno-electron/package.json index 4c2fbb32e09..e5d73ae81b2 100644 --- a/packages/bruno-electron/package.json +++ b/packages/bruno-electron/package.json @@ -32,7 +32,7 @@ "dependencies": { "@ai-sdk/anthropic": "3.0.15", "@ai-sdk/openai": "3.0.12", - "@apidevtools/json-schema-ref-parser": "^14.2.1", + "@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/apiSpecsWatcher.js b/packages/bruno-electron/src/app/apiSpecsWatcher.js index dec4f6f6133..47c9db0734d 100644 --- a/packages/bruno-electron/src/app/apiSpecsWatcher.js +++ b/packages/bruno-electron/src/app/apiSpecsWatcher.js @@ -11,6 +11,8 @@ const hasApiSpecExtension = (filename) => { 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; @@ -125,19 +127,17 @@ class ApiSpecWatcher { depth: 20 }); - const isSpecItself = (pathname) => path.resolve(pathname) === path.resolve(watchPath); - const refWatchState = { watcher, watchedRefFilePaths: new Set() }; watcher .on('add', (pathname) => { - if (isSpecItself(pathname)) return add(win, watchPath, refWatchState); + 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)) return; + if (isSpecItself(pathname, watchPath)) return; refWatchState.watchedRefFilePaths.delete(path.resolve(pathname)); change(win, watchPath, refWatchState); })