// Copyright 2026 OpenObserve Inc. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mount } from "@vue/test-utils"; import CodeQueryEditor from "./CodeQueryEditor.vue"; import { createStore } from "vuex"; // Stable mock editor instance so tests can reference it directly const mockEditorObj = { onDidChangeModelContent: vi.fn(), createContextKey: vi.fn(), addCommand: vi.fn(), onDidFocusEditorWidget: vi.fn(), onDidBlurEditorWidget: vi.fn(), dispose: vi.fn(), getValue: vi.fn(() => ""), setValue: vi.fn(), layout: vi.fn(), getModel: vi.fn(() => ({ getValue: vi.fn(() => ""), setValue: vi.fn(), getLineCount: vi.fn(() => 1), getLineLength: vi.fn(() => 0), pushEditOperations: vi.fn(), getOffsetAt: vi.fn(() => 0), })), updateOptions: vi.fn(), hasWidgetFocus: vi.fn(() => false), getRawOptions: vi.fn(() => ({ readOnly: false })), deltaDecorations: vi.fn(() => []), getPosition: vi.fn(() => ({ lineNumber: 1, column: 1 })), trigger: vi.fn(), getAction: vi.fn(() => ({ run: vi.fn(() => Promise.resolve()) })), }; // Simple mock for monaco editor vi.mock("monaco-editor/esm/vs/editor/editor.all.js", () => ({})); vi.mock("monaco-editor/esm/vs/editor/editor.api", () => ({ default: {}, editor: { create: vi.fn(() => mockEditorObj), defineTheme: vi.fn(), setTheme: vi.fn(), setModelMarkers: vi.fn(), }, languages: { CompletionItemKind: {}, CompletionItemInsertTextRule: {}, register: vi.fn(), setMonarchTokensProvider: vi.fn(), registerCompletionItemProvider: vi.fn(() => ({ dispose: vi.fn() })), }, KeyMod: { CtrlCmd: 1 }, KeyCode: { Enter: 13 }, })); // Mock dynamic imports vi.mock( "monaco-editor/esm/vs/basic-languages/sql/sql.contribution.js", () => ({}), ); vi.mock( "monaco-editor/esm/vs/language/json/monaco.contribution.js", () => ({}), ); vi.mock( "monaco-editor/esm/vs/language/html/monaco.contribution.js", () => ({}), ); vi.mock( "monaco-editor/esm/vs/basic-languages/markdown/markdown.contribution.js", () => ({}), ); vi.mock( "monaco-editor/esm/vs/basic-languages/python/python.contribution.js", () => ({}), ); vi.mock( "monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution.js", () => ({}), ); // Fix: component imports default from "@/composables/useLogs/searchState" vi.mock("@/composables/useLogs/searchState", () => ({ default: () => ({ searchObj: { data: {}, meta: {} }, }), searchState: () => ({ searchObj: { data: {}, meta: {} }, }), })); vi.mock("@/composables/useNLQuery", () => ({ useNLQuery: () => ({ detectNaturalLanguage: vi.fn(() => false), generateSQL: vi.fn(() => Promise.resolve(null)), transformToSQL: vi.fn((_nl: string, sql: string) => sql), isGenerating: { value: false }, streamingResponse: { value: "" }, }), })); vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key, }), })); vi.mock("@/composables/useNotifications", () => ({ default: () => ({ showErrorNotification: vi.fn(), showPositiveNotification: vi.fn(), }), })); vi.mock("@/utils/zincutils", () => ({ getImageURL: vi.fn((path: string) => `/mocked/${path}`), })); vi.mock("@/utils/query/vrlLanguageDefinition", () => ({ vrlLanguageDefinition: {}, })); describe("CodeQueryEditor", () => { let store: any; beforeEach(() => { store = createStore({ state: { theme: "light", }, }); // Reset call histories on the stable editor instance so each test starts clean Object.values(mockEditorObj).forEach((fn) => { if (typeof fn === "function" && "mockClear" in fn) { (fn as ReturnType).mockClear(); } }); vi.clearAllMocks(); }); const createWrapper = (props: any = {}) => { return mount(CodeQueryEditor, { props: { editorId: "test-editor", query: "SELECT * FROM logs", ...props, }, global: { plugins: [store], }, }); }; describe("Component Rendering", () => { it("should render the component", () => { const wrapper = createWrapper(); expect(wrapper.exists()).toBe(true); }); it("should have query editor element", () => { const wrapper = createWrapper(); expect(wrapper.find('[data-test="query-editor"]').exists()).toBe(true); }); it("should apply correct editorId", () => { const wrapper = createWrapper({ editorId: "my-custom-editor" }); const editor = wrapper.find('[data-test="query-editor"]'); expect(editor.attributes("id")).toBe("my-custom-editor"); }); it("should have editorRef", () => { const wrapper = createWrapper(); expect(wrapper.vm.editorRef).toBeDefined(); }); it("should have logs-query-editor class", () => { const wrapper = createWrapper(); expect(wrapper.find(".logs-query-editor").exists()).toBe(true); }); }); describe("Props", () => { it("should accept editorId prop", () => { const wrapper = createWrapper({ editorId: "custom-id" }); expect(wrapper.props("editorId")).toBe("custom-id"); }); it("should have default editorId", () => { const wrapper = createWrapper({ editorId: undefined }); expect(wrapper.props("editorId")).toBe("editor"); }); it("should accept query prop", () => { const wrapper = createWrapper({ query: "SELECT * FROM users" }); expect(wrapper.props("query")).toBe("SELECT * FROM users"); }); it("should have default query as empty string", () => { const wrapper = createWrapper({ query: undefined }); expect(wrapper.props("query")).toBe(""); }); it("should accept showAutoComplete prop", () => { const wrapper = createWrapper({ showAutoComplete: false }); expect(wrapper.props("showAutoComplete")).toBe(false); }); it("should have showAutoComplete default as true", () => { const wrapper = createWrapper({ showAutoComplete: undefined }); expect(wrapper.props("showAutoComplete")).toBe(true); }); it("should accept keywords array prop", () => { const keywords = [ { label: "SELECT", kind: "Keyword", insertText: "SELECT" }, ]; const wrapper = createWrapper({ keywords }); expect(wrapper.props("keywords")).toEqual(keywords); }); it("should have empty keywords array as default", () => { const wrapper = createWrapper({ keywords: undefined }); expect(wrapper.props("keywords")).toEqual([]); }); it("should accept suggestions array prop", () => { const suggestions = [{ label: () => "test", kind: "Text" }]; const wrapper = createWrapper({ suggestions }); expect(wrapper.props("suggestions")).toEqual(suggestions); }); it("should have null suggestions as default", () => { const wrapper = createWrapper({ suggestions: undefined }); expect(wrapper.props("suggestions")).toBeNull(); }); it("should accept debounceTime prop", () => { const wrapper = createWrapper({ debounceTime: 1000 }); expect(wrapper.props("debounceTime")).toBe(1000); }); it("should have default debounceTime of 500", () => { const wrapper = createWrapper({ debounceTime: undefined }); expect(wrapper.props("debounceTime")).toBe(500); }); it("should accept readOnly prop", () => { const wrapper = createWrapper({ readOnly: true }); expect(wrapper.props("readOnly")).toBe(true); }); it("should have readOnly default as false", () => { const wrapper = createWrapper({ readOnly: undefined }); expect(wrapper.props("readOnly")).toBe(false); }); it("should accept language prop", () => { const wrapper = createWrapper({ language: "json" }); expect(wrapper.props("language")).toBe("json"); }); it("should have default language as sql", () => { const wrapper = createWrapper({ language: undefined }); expect(wrapper.props("language")).toBe("sql"); }); it("should accept functions array prop", () => { const functions = ["count", "sum", "avg"]; const wrapper = createWrapper({ functions }); expect(wrapper.props("functions")).toEqual(functions); }); it("should have empty functions array as default", () => { const wrapper = createWrapper({ functions: undefined }); expect(wrapper.props("functions")).toEqual([]); }); it("should accept fields array prop", () => { const fields = ["user_id", "timestamp", "message"]; const wrapper = createWrapper({ fields }); expect(wrapper.props("fields")).toEqual(fields); }); it("should have empty fields array as default", () => { const wrapper = createWrapper({ fields: undefined }); expect(wrapper.props("fields")).toEqual([]); }); }); describe("Exposed Methods", () => { it("should expose setValue method", () => { const wrapper = createWrapper(); expect(wrapper.vm.setValue).toBeDefined(); expect(typeof wrapper.vm.setValue).toBe("function"); }); it("should expose resetEditorLayout method", () => { const wrapper = createWrapper(); expect(wrapper.vm.resetEditorLayout).toBeDefined(); expect(typeof wrapper.vm.resetEditorLayout).toBe("function"); }); it("should expose disableSuggestionPopup method", () => { const wrapper = createWrapper(); expect(wrapper.vm.disableSuggestionPopup).toBeDefined(); expect(typeof wrapper.vm.disableSuggestionPopup).toBe("function"); }); it("should expose triggerAutoComplete method", () => { const wrapper = createWrapper(); expect(wrapper.vm.triggerAutoComplete).toBeDefined(); expect(typeof wrapper.vm.triggerAutoComplete).toBe("function"); }); it("should expose getCursorIndex method", () => { const wrapper = createWrapper(); expect(wrapper.vm.getCursorIndex).toBeDefined(); expect(typeof wrapper.vm.getCursorIndex).toBe("function"); }); it("should expose formatDocument method", () => { const wrapper = createWrapper(); expect(wrapper.vm.formatDocument).toBeDefined(); expect(typeof wrapper.vm.formatDocument).toBe("function"); }); it("should expose getModel method", () => { const wrapper = createWrapper(); expect(wrapper.vm.getModel).toBeDefined(); expect(typeof wrapper.vm.getModel).toBe("function"); }); it("should expose getValue method", () => { const wrapper = createWrapper(); expect(wrapper.vm.getValue).toBeDefined(); expect(typeof wrapper.vm.getValue).toBe("function"); }); it("should expose decorateRanges method", () => { const wrapper = createWrapper(); expect(wrapper.vm.decorateRanges).toBeDefined(); expect(typeof wrapper.vm.decorateRanges).toBe("function"); }); it("should expose addErrorDiagnostics method", () => { const wrapper = createWrapper(); expect(wrapper.vm.addErrorDiagnostics).toBeDefined(); expect(typeof wrapper.vm.addErrorDiagnostics).toBe("function"); }); it("should expose searchObj from useLogs", () => { const wrapper = createWrapper(); expect(wrapper.vm.searchObj).toBeDefined(); expect(typeof wrapper.vm.searchObj).toBe("object"); }); }); describe("Emits", () => { it("should define update-query emit", () => { const wrapper = createWrapper(); expect(wrapper.vm.$options.emits).toContain("update-query"); }); it("should define run-query emit", () => { const wrapper = createWrapper(); expect(wrapper.vm.$options.emits).toContain("run-query"); }); it("should define update:query emit", () => { const wrapper = createWrapper(); expect(wrapper.vm.$options.emits).toContain("update:query"); }); it("should define focus emit", () => { const wrapper = createWrapper(); expect(wrapper.vm.$options.emits).toContain("focus"); }); it("should define blur emit", () => { const wrapper = createWrapper(); expect(wrapper.vm.$options.emits).toContain("blur"); }); }); describe("Language Support", () => { it("should support sql language", () => { const wrapper = createWrapper({ language: "sql" }); expect(wrapper.props("language")).toBe("sql"); }); it("should support json language", () => { const wrapper = createWrapper({ language: "json" }); expect(wrapper.props("language")).toBe("json"); }); it("should support html language", () => { const wrapper = createWrapper({ language: "html" }); expect(wrapper.props("language")).toBe("html"); }); it("should support markdown language", () => { const wrapper = createWrapper({ language: "markdown" }); expect(wrapper.props("language")).toBe("markdown"); }); it("should support python language", () => { const wrapper = createWrapper({ language: "python" }); expect(wrapper.props("language")).toBe("python"); }); it("should support javascript language", () => { const wrapper = createWrapper({ language: "javascript" }); expect(wrapper.props("language")).toBe("javascript"); }); it("should support promql language", () => { const wrapper = createWrapper({ language: "promql" }); expect(wrapper.props("language")).toBe("promql"); }); it("should support vrl language", () => { const wrapper = createWrapper({ language: "vrl" }); expect(wrapper.props("language")).toBe("vrl"); }); }); describe("Configuration", () => { it("should accept different editor IDs", () => { const id1 = "editor-1"; const id2 = "editor-2"; const wrapper1 = createWrapper({ editorId: id1 }); const wrapper2 = createWrapper({ editorId: id2 }); expect(wrapper1.props("editorId")).toBe(id1); expect(wrapper2.props("editorId")).toBe(id2); }); it("should handle empty query", () => { const wrapper = createWrapper({ query: "" }); expect(wrapper.props("query")).toBe(""); }); it("should handle multi-line query", () => { const query = "SELECT *\nFROM users\nWHERE id = 1"; const wrapper = createWrapper({ query }); expect(wrapper.props("query")).toBe(query); }); it("should handle complex SQL query", () => { const query = "SELECT u.name, COUNT(*) as count FROM users u JOIN orders o ON u.id = o.user_id GROUP BY u.name"; const wrapper = createWrapper({ query }); expect(wrapper.props("query")).toBe(query); }); }); describe("Reactivity", () => { it("should update when query prop changes", async () => { const wrapper = createWrapper({ query: "SELECT 1" }); expect(wrapper.props("query")).toBe("SELECT 1"); await wrapper.setProps({ query: "SELECT 2" }); expect(wrapper.props("query")).toBe("SELECT 2"); }); it("should update when readOnly prop changes", async () => { const wrapper = createWrapper({ readOnly: false }); expect(wrapper.props("readOnly")).toBe(false); await wrapper.setProps({ readOnly: true }); expect(wrapper.props("readOnly")).toBe(true); }); it("should update when language prop changes", async () => { const wrapper = createWrapper({ language: "sql" }); expect(wrapper.props("language")).toBe("sql"); await wrapper.setProps({ language: "json" }); expect(wrapper.props("language")).toBe("json"); }); }); describe("Ctrl+Enter / Cmd+Enter keyboard shortcut", () => { let shortcutWrapper: ReturnType | null = null; let getElementByIdSpy: ReturnType; // Spy on document.getElementById so setupEditor finds the editor element // without needing the component attached to document. This bypasses the // 100ms retry-loop setTimeout. Then use vi.waitFor to poll until the // async setupEditor chain (dynamic imports + loadMonaco) fully completes. const mountAndSetup = async (props: any = {}) => { const fakeEditorEl = document.createElement("div"); getElementByIdSpy = vi .spyOn(document, "getElementById") .mockReturnValue(fakeEditorEl); shortcutWrapper = mount(CodeQueryEditor, { props: { editorId: "test-editor", query: "SELECT * FROM logs", ...props, }, global: { plugins: [store] }, }); // Wait until the async setupEditor completes and addCommand is recorded await vi.waitFor( () => { expect(mockEditorObj.addCommand).toHaveBeenCalled(); }, { timeout: 10000 }, ); return shortcutWrapper; }; afterEach(() => { getElementByIdSpy?.mockRestore(); shortcutWrapper?.unmount(); shortcutWrapper = null; }); it("should call addCommand with CtrlCmd+Enter keybinding", async () => { await mountAndSetup(); expect(mockEditorObj.addCommand).toHaveBeenCalled(); const firstCall = mockEditorObj.addCommand.mock.calls[0]; // KeyMod.CtrlCmd | KeyCode.Enter = 1 | 13 (bitwise OR of the mock values) expect(firstCall[0]).toBe(1 | 13); }); it("should emit run-query when CtrlCmd+Enter handler is invoked", async () => { const wrapper = await mountAndSetup(); expect(mockEditorObj.addCommand).toHaveBeenCalled(); const handler = mockEditorObj.addCommand.mock.calls[0][1]; expect(typeof handler).toBe("function"); handler(); expect(wrapper.emitted("run-query")).toBeTruthy(); }); it("should register addCommand with ctrlenter context", async () => { await mountAndSetup(); expect(mockEditorObj.addCommand).toHaveBeenCalled(); const firstCall = mockEditorObj.addCommand.mock.calls[0]; expect(firstCall[2]).toBe("ctrlenter"); }); }); // Tests for the bug fix: suggestions=[] must not fall back to defaultSuggestions. // When a parent passes an explicit empty array (e.g. effectiveSuggestions during // value context), the Monaco provider must return no function suggestions. describe("suggestions prop — function suggestions gated by null vs []", () => { let getElementByIdSpy: ReturnType; afterEach(() => { getElementByIdSpy?.mockRestore(); }); // Snapshot the registerCompletionItemProvider call count before mounting, // then wait for our mount to push a new call. Using a baseline (instead of // vi.clearAllMocks + waiting for addCommand) avoids a race under parallel // CI load where a previous test's still-running setupEditor would satisfy // vi.waitFor on addCommand BEFORE our component had registered its provider. const mountAndWait = async (props: any = {}) => { const monacoApi = await import("monaco-editor/esm/vs/editor/editor.api"); const registerFn = vi.mocked( monacoApi.languages.registerCompletionItemProvider, ); const baselineIndex = registerFn.mock.calls.length; const fakeEl = document.createElement("div"); getElementByIdSpy = vi .spyOn(document, "getElementById") .mockReturnValue(fakeEl); mount(CodeQueryEditor, { props: { editorId: "test-editor", query: "SELECT * FROM logs", ...props, }, global: { plugins: [store] }, }); await vi.waitFor( () => { expect(registerFn.mock.calls.length).toBeGreaterThan(baselineIndex); }, { timeout: 4000, interval: 50 }, ); return baselineIndex; }; const captureProvideCompletionItems = async (baselineIndex: number) => { const monacoApi = await import("monaco-editor/esm/vs/editor/editor.api"); const calls = vi.mocked( monacoApi.languages.registerCompletionItemProvider, ).mock.calls; // Use the first call AFTER the baseline — that is unambiguously our mount. return calls[baselineIndex][1].provideCompletionItems as Function; }; const callProvider = (fn: Function, text = "") => { const mockModel = { getValueInRange: vi.fn(() => text), getWordUntilPosition: vi.fn(() => ({ word: "", startColumn: 1, endColumn: 1, })), }; return fn(mockModel, { lineNumber: 1, column: 1 }); }; const hasFunctionSuggestion = (result: any) => result.suggestions.some( (s: any) => typeof s.label === "string" && s.label.startsWith("match_all"), ); it.skip( "includes provided suggestions when suggestions prop has items", { timeout: 20000 }, async () => { const customSuggestion = { label: (_kw: string) => `custom_fn('${_kw}')`, kind: "Text", insertText: (_kw: string) => `custom_fn('${_kw}')`, }; const baselineIndex = await mountAndWait({ suggestions: [customSuggestion], }); const fn = await captureProvideCompletionItems(baselineIndex); const result = callProvider(fn, "SELECT"); const found = result.suggestions.some( (s: any) => typeof s.label === "string" && s.label.startsWith("custom_fn"), ); expect(found).toBe(true); }, ); it.skip( "includes function suggestions when suggestions prop is null (default)", { timeout: 20000 }, async () => { const baselineIndex = await mountAndWait({ suggestions: null }); const fn = await captureProvideCompletionItems(baselineIndex); const result = callProvider(fn); expect(hasFunctionSuggestion(result)).toBe(true); }, ); }); // Tests for validateDoubleQuotes. // The detection regex is tested directly here — this avoids async Monaco mock // coordination while still covering every pattern the component will flag. describe("validateDoubleQuotes — detection regex", () => { // Mirror of the regex used in validateDoubleQuotes in CodeQueryEditor.vue. // Update here if the regex changes in the component. const makeRegex = () => /(?:NOT\s+LIKE|NOT\s+IN\s*\(|!=|<>|>=|<=|=|>|<|LIKE|IN\s*\()\s*("[^'"]*'|'[^'"]*"|"[^"]*")/gi; const findInvalidQuotes = (text: string): string[] => { const r = makeRegex(); const matches: string[] = []; let m; while ((m = r.exec(text)) !== null) matches.push(m[1]); return matches; }; // ── double-quoted values ──────────────────────────────────────────────── it('flags = "value"', () => { expect(findInvalidQuotes(`WHERE http_endpoint = "test"`)).toEqual([ `"test"`, ]); }); it('flags != "value"', () => { expect(findInvalidQuotes(`WHERE status != "error"`)).toEqual([`"error"`]); }); it('flags LIKE "%value%"', () => { expect(findInvalidQuotes(`WHERE msg LIKE "%error%"`)).toEqual([ `"%error%"`, ]); }); it('flags NOT LIKE "value"', () => { expect(findInvalidQuotes(`WHERE path NOT LIKE "%admin%"`)).toEqual([ `"%admin%"`, ]); }); it('flags IN ("value")', () => { expect(findInvalidQuotes(`WHERE status IN ("200")`)).toEqual([`"200"`]); }); it("flags every double-quoted value across multiple conditions", () => { expect( findInvalidQuotes(`WHERE status = "200" AND env = "prod"`), ).toEqual([`"200"`, `"prod"`]); }); it('flags double-quoted URL path: = "/api/v1/payments"', () => { expect( findInvalidQuotes(`WHERE http_endpoint = "/api/v1/payments"`), ).toEqual([`"/api/v1/payments"`]); }); // ── mismatched quotes ─────────────────────────────────────────────────── it("flags mismatched open-double close-single: = \"value'", () => { expect(findInvalidQuotes(`WHERE field = "test'`)).toEqual([`"test'`]); }); it("flags mismatched open-single close-double: = 'value\"", () => { expect(findInvalidQuotes(`WHERE field = 'test"`)).toEqual([`'test"`]); }); // ── valid cases — must NOT be flagged ──────────────────────────────────── it("does NOT flag single-quoted values", () => { expect(findInvalidQuotes(`WHERE status = 'ok' AND env = 'prod'`)).toEqual( [], ); }); it("does NOT flag numeric values", () => { expect(findInvalidQuotes(`WHERE status = 200 AND code >= 400`)).toEqual( [], ); }); it("does NOT flag double-quoted table names in FROM clause", () => { expect( findInvalidQuotes(`SELECT * FROM "test_table" WHERE status = 200`), ).toEqual([]); }); it("does NOT flag a valid URL path in single quotes", () => { expect( findInvalidQuotes(`WHERE http_endpoint = '/api/v1/payments'`), ).toEqual([]); }); }); });