diff --git a/eslint.config.js b/eslint.config.js index 0e742fcdf..30930c550 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -25,6 +25,19 @@ module.exports = defineConfig([ "no-undef": "error", }, }, + { + // It prevents lint errors when using CommonJS exports (module.exports) in Jest mocks. + files: ["packages/bruno-app/src/test-utils/mocks/codemirror.js"], + languageOptions: { + globals: { + ...globals.node, + ...globals.jest, + }, + }, + rules: { + "no-undef": "error", + }, + }, { files: ["packages/bruno-electron/**/*.{js}"], ignores: ["**/*.config.js"], diff --git a/package-lock.json b/package-lock.json index 2e4a5e517..896e23a70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8209,78 +8209,6 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@testing-library/dom": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", - "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@testing-library/dom/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@testing-library/dom/node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/@testing-library/dom/node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@testing-library/dom/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT" - }, "node_modules/@testing-library/jest-dom": { "version": "6.6.3", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", @@ -8309,25 +8237,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@testing-library/react": { - "version": "14.3.1", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", - "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^9.0.0", - "@types/react-dom": "^18.0.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, "node_modules/@tippyjs/react": { "version": "4.2.6", "resolved": "https://registry.npmjs.org/@tippyjs/react/-/react-4.2.6.tgz", @@ -8700,16 +8609,6 @@ "csstype": "^3.0.2" } }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, "node_modules/@types/react-redux": { "version": "7.1.34", "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.34.tgz", @@ -28148,9 +28047,9 @@ "@rsbuild/plugin-react": "^1.0.7", "@rsbuild/plugin-sass": "^1.1.0", "@rsbuild/plugin-styled-components": "1.1.0", - "@testing-library/dom": "^9.3.3", - "@testing-library/jest-dom": "^6.1.5", - "@testing-library/react": "^14.1.2", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", "autoprefixer": "10.4.20", "babel-jest": "^29.7.0", "babel-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110", @@ -29478,6 +29377,64 @@ "semver": "bin/semver.js" } }, + "packages/bruno-app/node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "packages/bruno-app/node_modules/@testing-library/react": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "packages/bruno-app/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "packages/bruno-app/node_modules/babel-plugin-polyfill-corejs3": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", @@ -29546,6 +29503,23 @@ ], "license": "CC-BY-4.0" }, + "packages/bruno-app/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "packages/bruno-app/node_modules/cookie": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", @@ -29647,6 +29621,41 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "packages/bruno-app/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "packages/bruno-app/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "packages/bruno-app/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "packages/bruno-app/node_modules/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", @@ -31461,7 +31470,6 @@ "version": "2.0.0", "dependencies": { "@aws-sdk/credential-providers": "3.750.0", - "@faker-js/faker": "^9.5.1", "@usebruno/common": "0.1.0", "@usebruno/converters": "^0.1.0", "@usebruno/js": "0.12.0", @@ -31971,23 +31979,6 @@ } } }, - "packages/bruno-electron/node_modules/@faker-js/faker": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.5.1.tgz", - "integrity": "sha512-0fzMEDxkExR2cn731kpDaCCnBGBUOIXEi2S1N5l8Hltp6aPf4soTMJ+g4k8r2sI5oB+rpwIW8Uy/6jkwGpnWPg==", - "deprecated": "Please update to a newer version", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/fakerjs" - } - ], - "license": "MIT", - "engines": { - "node": ">=18.0.0", - "npm": ">=9.0.0" - } - }, "packages/bruno-electron/node_modules/@smithy/abort-controller": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.1.tgz", diff --git a/packages/bruno-app/.babelrc b/packages/bruno-app/.babelrc index c5ddc56c8..3d8b68884 100644 --- a/packages/bruno-app/.babelrc +++ b/packages/bruno-app/.babelrc @@ -1,4 +1,4 @@ { - "presets": ["@babel/preset-env"], + "presets": ["@babel/preset-env", "@babel/preset-react"], "plugins": [["styled-components", { "ssr": true }]] } \ No newline at end of file diff --git a/packages/bruno-app/jest.config.js b/packages/bruno-app/jest.config.js index 6a59e2d4c..fdab3f936 100644 --- a/packages/bruno-app/jest.config.js +++ b/packages/bruno-app/jest.config.js @@ -1,5 +1,11 @@ module.exports = { rootDir: '.', + transform: { + '^.+\\.[jt]sx?$': 'babel-jest', + }, + transformIgnorePatterns: [ + "/node_modules/(?!strip-json-comments|nanoid|xml-formatter)/", + ], moduleNameMapper: { '^assets/(.*)$': '/src/assets/$1', '^components/(.*)$': '/src/components/$1', @@ -8,18 +14,17 @@ module.exports = { '^api/(.*)$': '/src/api/$1', '^pageComponents/(.*)$': '/src/pageComponents/$1', '^providers/(.*)$': '/src/providers/$1', - '^utils/(.*)$': '/src/utils/$1' + '^utils/(.*)$': '/src/utils/$1', + '^test-utils/(.*)$': '/src/test-utils/$1' }, clearMocks: true, moduleDirectories: ['node_modules', 'src'], testEnvironment: 'jsdom', - transform: { - '^.+\\.[jt]sx?$': 'babel-jest' - }, - transformIgnorePatterns: [ - '/node_modules/(?!(nanoid|xml-formatter)/)' + setupFilesAfterEnv: ['@testing-library/jest-dom'], + setupFiles: [ + '/jest.setup.js', ], testMatch: [ '/src/**/*.spec.[jt]s?(x)' ] -}; +}; \ No newline at end of file diff --git a/packages/bruno-app/jest.setup.js b/packages/bruno-app/jest.setup.js new file mode 100644 index 000000000..1dbb39d85 --- /dev/null +++ b/packages/bruno-app/jest.setup.js @@ -0,0 +1,11 @@ +jest.mock('nanoid', () => { + return { + nanoid: () => {} + }; +}); + +jest.mock('strip-json-comments', () => { + return { + stripJsonComments: (str) => str + }; +}); diff --git a/packages/bruno-app/package.json b/packages/bruno-app/package.json index 51db3c032..913e4b400 100644 --- a/packages/bruno-app/package.json +++ b/packages/bruno-app/package.json @@ -91,9 +91,9 @@ "@rsbuild/plugin-react": "^1.0.7", "@rsbuild/plugin-sass": "^1.1.0", "@rsbuild/plugin-styled-components": "1.1.0", - "@testing-library/dom": "^9.3.3", - "@testing-library/jest-dom": "^6.1.5", - "@testing-library/react": "^14.1.2", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/dom": "^10.4.0", "autoprefixer": "10.4.20", "babel-jest": "^29.7.0", "babel-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110", diff --git a/packages/bruno-app/rsbuild.config.mjs b/packages/bruno-app/rsbuild.config.mjs index 0a2e9081f..f21f80666 100644 --- a/packages/bruno-app/rsbuild.config.mjs +++ b/packages/bruno-app/rsbuild.config.mjs @@ -20,6 +20,11 @@ export default defineConfig({ ], source: { tsconfigPath: './jsconfig.json', // Specifies the path to the JavaScript/TypeScript configuration file, + exclude: [ + '**/test-utils/**', + '**/*.test.*', + '**/*.spec.*' + ] }, html: { title: 'Bruno' diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js index 160891542..9be42e29c 100644 --- a/packages/bruno-app/src/components/CodeEditor/index.js +++ b/packages/bruno-app/src/components/CodeEditor/index.js @@ -8,6 +8,7 @@ import React from 'react'; import { isEqual, escapeRegExp } from 'lodash'; import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror'; +import { getMockDataHints } from 'utils/codemirror/mock-data-hints'; import StyledWrapper from './StyledWrapper'; import * as jsonlint from '@prantlf/jsonlint'; import { JSHINT } from 'jshint'; @@ -90,6 +91,7 @@ if (!SERVER_RENDERED) { 'bru.runner.stopExecution()', 'bru.interpolate(str)' ]; + CodeMirror.registerHelper('hint', 'brunoJS', (editor, options) => { const cursor = editor.getCursor(); const currentLine = editor.getLine(cursor.line); @@ -117,6 +119,7 @@ if (!SERVER_RENDERED) { } return result; }); + CodeMirror.commands.autocomplete = (cm, hint, options) => { cm.showHint({ hint, ...options }); }; @@ -278,11 +281,14 @@ export default class CodeEditor extends React.Component { } return found; }); + if (editor) { editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false); editor.on('change', this._onEdit); + editor.on('keyup', this._onKeyUpMockDataHints); this.addOverlay(); } + if (this.props.mode == 'javascript') { editor.on('keyup', function (cm, event) { const cursor = editor.getCursor(); @@ -305,6 +311,28 @@ export default class CodeEditor extends React.Component { } } + _onKeyUpMockDataHints(cm, event) { + // This prevents triggering hints for non-character keys (e.g., Arrow keys, Meta). + if ( + !/^(?!Shift|Tab|Enter|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Meta|Alt|Home|End\s)\w*/.test(event?.key) + ) { + return; + } + + const hints = getMockDataHints(cm); + if (!hints) { + if (cm.state.completionActive) { + cm.state.completionActive.close(); + } + return; + } + + cm.showHint({ + hint: () => hints, + completeSingle: false + }); + } + componentDidUpdate(prevProps) { // Ensure the changes caused by this update are not interpreted as // user-input changes which could otherwise result in an infinite @@ -338,6 +366,7 @@ export default class CodeEditor extends React.Component { componentWillUnmount() { if (this.editor) { this.editor.off('change', this._onEdit); + this.editor.off('keyup', this._onKeyUpMockDataHints); this.editor = null; } diff --git a/packages/bruno-app/src/components/CodeEditor/index.spec.js b/packages/bruno-app/src/components/CodeEditor/index.spec.js new file mode 100644 index 000000000..087a5a797 --- /dev/null +++ b/packages/bruno-app/src/components/CodeEditor/index.spec.js @@ -0,0 +1,105 @@ +import React from 'react'; +import { render, act } from '@testing-library/react'; +import CodeEditor from './index'; +import { ThemeProvider } from 'styled-components'; + +jest.mock('codemirror', () => { + const codemirror = require('test-utils/mocks/codemirror'); + return codemirror; +}); + +const MOCK_THEME = { + codemirror: { + bg: "#1e1e1e", + border: "#333", + }, + textLink: "#007acc", +}; + +const setupEditorState = (editor, { value, cursorPosition }) => { + editor._currentValue = value; + editor.getCursor.mockReturnValue({ line: 0, ch: cursorPosition }); + editor.getRange.mockImplementation((from, to) => { + if (from.line === 0 && from.ch === 0 && to.line === 0 && to.ch === cursorPosition) { + return value; + } + return editor._currentValue.slice(from.ch, to.ch); + }); + + editor.state = { + completionActive: null, + } +}; + +const setupEditorWithRef = () => { + const ref = React.createRef(); + const { rerender } = render( + + + + ); + return { ref, rerender }; +}; + +describe('CodeEditor Autocomplete', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + }); + + it('shows autocomplete suggestions when typing {{$ra', () => { + // Setup + const { ref } = setupEditorWithRef(); + const editorInstance = ref.current; + expect(editorInstance).toBeTruthy(); + + const editor = editorInstance.editor; + expect(editor).toBeTruthy(); + + // Configure editor state + setupEditorState(editor, { + value: '{{$r', + cursorPosition: 4 + }); + + // Trigger autocomplete + const _onKeyUpMockDataHints = editor._onKeyUpMockDataHints; + expect(typeof _onKeyUpMockDataHints).toBe('function'); + + act(() => { + _onKeyUpMockDataHints(editor, { text: ['a'], origin: '+input' }); + }); + + // Assertions + expect(editor.showHint).toHaveBeenCalled(); + const call = editor.showHint.mock.calls[0][0]; + expect(typeof call.hint).toBe('function'); + + const hints = call.hint(); + expect(Array.isArray(hints.list)).toBe(true); + expect(hints.list.some((s) => s.startsWith('$'))).toBe(true); + expect(hints.list.every((match) => match.startsWith('$ra'))).toBe(true); + }); + + it('does not show hints for regular text input', () => { + // Setup + const { ref } = setupEditorWithRef(); + const editor = ref.current.editor; + + // Configure editor state + setupEditorState(editor, { + value: 'regular text', + cursorPosition: 11 + }); + + // Trigger input + const _onKeyUpMockDataHints = editor._onKeyUpMockDataHints; + + act(() => { + _onKeyUpMockDataHints(editor, { text: ['x'], origin: '+input' }); + }); + + // Assert no hints shown for regular text + expect(editor.showHint).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/packages/bruno-app/src/components/SingleLineEditor/index.js b/packages/bruno-app/src/components/SingleLineEditor/index.js index e34085657..a82bfe94a 100644 --- a/packages/bruno-app/src/components/SingleLineEditor/index.js +++ b/packages/bruno-app/src/components/SingleLineEditor/index.js @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import isEqual from 'lodash/isEqual'; import { getAllVariables } from 'utils/collections'; import { defineCodeMirrorBrunoVariablesMode, MaskedEditor } from 'utils/common/codemirror'; +import { getMockDataHints } from 'utils/codemirror/mock-data-hints'; import StyledWrapper from './StyledWrapper'; import { IconEye, IconEyeOff } from '@tabler/icons'; @@ -26,6 +27,7 @@ class SingleLineEditor extends Component { maskInput: props.isSecret || false // Always mask the input by default (if it's a secret) }; } + componentDidMount() { // Initialize CodeMirror as a single line editor /** @type {import("codemirror").Editor} */ @@ -75,6 +77,7 @@ class SingleLineEditor extends Component { 'Shift-Tab': false } }); + if (this.props.autocomplete) { this.editor.on('keyup', (cm, event) => { if (!cm.state.completionActive /*Enables keyboard navigation in autocomplete list*/ && event.key !== 'Enter') { @@ -83,6 +86,8 @@ class SingleLineEditor extends Component { } }); } + this.editor.on('keyup', this._onKeyUpMockDataHints); + this.editor.setValue(String(this.props.value ?? '')); this.editor.on('change', this._onEdit); this.addOverlay(variables); @@ -94,7 +99,6 @@ class SingleLineEditor extends Component { _enableMaskedEditor = (enabled) => { if (typeof enabled !== 'boolean') return; - console.log('Enabling masked editor: ' + enabled); if (enabled == true) { if (!this.maskedEditor) this.maskedEditor = new MaskedEditor(this.editor, '*'); this.maskedEditor.enable(); @@ -113,6 +117,26 @@ class SingleLineEditor extends Component { } }; + _onKeyUpMockDataHints(cm, event) { + // This prevents triggering hints for non-character keys (e.g., Arrow keys, Meta). + if (!/^(?!Shift|Tab|Enter|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Meta|Alt|Home|End\s)\w*/.test(event?.key)) { + return; + } + + const hints = getMockDataHints(cm); + if (!hints) { + if (cm.state.completionActive) { + cm.state.completionActive.close(); + } + return; + } + + cm.showHint({ + hint: () => hints, + completeSingle: false + }); + } + componentDidUpdate(prevProps) { // Ensure the changes caused by this update are not interpreted as // user-input changes which could otherwise result in an infinite @@ -141,7 +165,12 @@ class SingleLineEditor extends Component { } componentWillUnmount() { - this.editor.getWrapperElement().remove(); + if (this.editor) { + this.editor.off('change', this._onEdit); + this.editor.off('keyup', this._onKeyUpMockDataHints); + this.editor.getWrapperElement().remove(); + this.editor = null; + } } addOverlay = (variables) => { diff --git a/packages/bruno-app/src/test-utils/mocks/codemirror.js b/packages/bruno-app/src/test-utils/mocks/codemirror.js new file mode 100644 index 000000000..03ee94c3b --- /dev/null +++ b/packages/bruno-app/src/test-utils/mocks/codemirror.js @@ -0,0 +1,45 @@ +const CodeMirror = jest.fn((node, options) => { + const editor = { + options, + _currentValue: '', + _onKeyUpMockDataHints: null, + getCursor: jest.fn(() => ({ line: 0, ch: editor._currentValue?.length || 0 })), + getRange: jest.fn((from, to) => editor._currentValue?.slice(0, to.ch) || ''), + getValue: jest.fn(() => editor._currentValue), + setValue: jest.fn(function (val) { + editor._currentValue = val; + }), + getLine: jest.fn(() => editor._currentValue || ''), + setOption: jest.fn(), + refresh: jest.fn(), + off: jest.fn(), + showHint: jest.fn(), + on: jest.fn(function (event, handler) { + if (event === 'keyup') { + if (handler && handler.name === '_onKeyUpMockDataHints') { + this._onKeyUpMockDataHints = handler; + } + } + }) + }; + return editor; +}); + +CodeMirror.commands = { + autocomplete: jest.fn() +}; + +CodeMirror.hint = {}; + +CodeMirror.registerHelper = jest.fn((type, name, value) => { + if (!CodeMirror[type]) { + CodeMirror[type] = {}; + } + + CodeMirror[type][name] = value; +}); + +CodeMirror.fromTextArea = jest.fn(); +CodeMirror.defineMode = jest.fn(); + +module.exports = CodeMirror; diff --git a/packages/bruno-app/src/utils/codemirror/mock-data-hints.js b/packages/bruno-app/src/utils/codemirror/mock-data-hints.js new file mode 100644 index 000000000..fb03e9f11 --- /dev/null +++ b/packages/bruno-app/src/utils/codemirror/mock-data-hints.js @@ -0,0 +1,25 @@ +import { mockDataFunctions } from '@usebruno/common'; + +const MOCK_FUNCTION_SUGGESTIONS = Object.keys(mockDataFunctions).map(key => `$${key}`); + +export const getMockDataHints = (cm) => { + const cursor = cm.getCursor(); + const currentString = cm.getRange({ line: cursor.line, ch: 0 }, cursor); + + const match = currentString.match(/\{\{\$(\w*)$/); + if (!match) return null; + + const wordMatch = match[1]; + if (!wordMatch) return null; + + const suggestions = MOCK_FUNCTION_SUGGESTIONS.filter(name => name.startsWith(`$${wordMatch}`)); + if (!suggestions.length) return null; + + const startPos = { line: cursor.line, ch: currentString.lastIndexOf('{{$') + 2 }; // +2 accounts for `{{` + + return { + list: suggestions, + from: startPos, + to: cm.getCursor(), + }; +}; \ No newline at end of file diff --git a/packages/bruno-app/src/utils/common/codemirror.js b/packages/bruno-app/src/utils/common/codemirror.js index b1a3d5a8a..64c98989c 100644 --- a/packages/bruno-app/src/utils/common/codemirror.js +++ b/packages/bruno-app/src/utils/common/codemirror.js @@ -1,4 +1,5 @@ import get from 'lodash/get'; +import { mockDataFunctions } from '@usebruno/common'; let CodeMirror; const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true; @@ -108,7 +109,9 @@ export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPa while ((ch = stream.next()) != null) { if (ch === '}' && stream.peek() === '}') { stream.eat('}'); - const found = pathFoundInVariables(word, variables); + // Check if it's a mock variable (starts with $) and exists in mockDataFunctions + const isMockVariable = word.startsWith('$') && mockDataFunctions.hasOwnProperty(word.substring(1)); + const found = isMockVariable || pathFoundInVariables(word, variables); const status = found ? 'valid' : 'invalid'; const randomClass = `random-${(Math.random() + 1).toString(36).substring(9)}`; return `variable-${status} ${randomClass}`; diff --git a/packages/bruno-common/src/index.ts b/packages/bruno-common/src/index.ts index 7d3b6e72d..c49e79d58 100644 --- a/packages/bruno-common/src/index.ts +++ b/packages/bruno-common/src/index.ts @@ -1 +1,2 @@ +export { mockDataFunctions } from './utils/faker-functions'; export { default as interpolate } from './interpolate'; diff --git a/packages/bruno-electron/package.json b/packages/bruno-electron/package.json index 7a6d51382..d7303e2fb 100644 --- a/packages/bruno-electron/package.json +++ b/packages/bruno-electron/package.json @@ -30,7 +30,6 @@ }, "dependencies": { "@aws-sdk/credential-providers": "3.750.0", - "@faker-js/faker": "^9.5.1", "@usebruno/common": "0.1.0", "@usebruno/converters": "^0.1.0", "@usebruno/js": "0.12.0",