// 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 { flushPromises, mount } from "@vue/test-utils"; import CodeQueryEditor from "./CodeQueryEditor.vue"; import { createStore } from "vuex"; // Stable model instance. CodeQueryEditor's completion provider answers only for // its OWN model (`if (own && model !== own) return { suggestions: [] }`), so a // getModel() that returns a fresh object each call makes the provider // permanently unreachable from tests — which is why the provider specs below // were previously skipped. const mockModel = { getValue: vi.fn(() => ""), setValue: vi.fn(), getLineCount: vi.fn(() => 1), getLineLength: vi.fn(() => 0), pushEditOperations: vi.fn(), getOffsetAt: vi.fn(() => 0), getPositionAt: vi.fn(() => ({ lineNumber: 1, column: 1 })), getLineContent: vi.fn(() => ""), getValueInRange: vi.fn(() => ""), getWordUntilPosition: vi.fn(() => ({ word: "", startColumn: 1, endColumn: 1 })), getValueLength: vi.fn(() => 13), }; // Stable mock editor instance so tests can reference it directly const mockEditorObj = { onDidChangeModelContent: vi.fn(), createContextKey: vi.fn(), addCommand: vi.fn(), onKeyDown: vi.fn(), onDidFocusEditorWidget: vi.fn(), onDidBlurEditorWidget: vi.fn(), onDidBlurEditorText: vi.fn(), onDidChangeCursorPosition: vi.fn(), createDecorationsCollection: vi.fn(() => ({ set: vi.fn(), clear: vi.fn() })), dispose: vi.fn(), getValue: vi.fn(() => ""), setValue: vi.fn(), layout: vi.fn(), getModel: vi.fn(() => mockModel), 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(), setModelLanguage: vi.fn(), TrackedRangeStickiness: { NeverGrowsWhenTypingAtEdges: 1 }, addCommand: vi.fn(() => ({ dispose: vi.fn() })), addKeybindingRule: vi.fn(() => ({ dispose: vi.fn() })), addKeybindingRules: vi.fn(() => ({ dispose: vi.fn() })), registerCommand: vi.fn(() => ({ dispose: vi.fn() })), getEditors: vi.fn(() => []), }, languages: { // Real values, mirroring monaco-editor/esm/vs/editor/common/languages.js CompletionItemKind: { Method: 0, Function: 1, Constructor: 2, Field: 3, Variable: 4, Operator: 11, Value: 13, Keyword: 17, Text: 18, Snippet: 27, }, CompletionItemInsertTextRule: { None: 0, KeepWhitespace: 1, InsertAsSnippet: 4 }, register: vi.fn(), setMonarchTokensProvider: vi.fn(), // The promql branch of setupEditor calls this immediately after // setMonarchTokensProvider. Missing, it throws mid-setup — swallowed, // because the vitest config sets dangerouslyIgnoreUnhandledErrors. setLanguageConfiguration: vi.fn(), registerCompletionItemProvider: vi.fn(() => ({ dispose: vi.fn() })), // setupEditor registers all three providers in a row. The mock predates the // signature-help and hover ones, so it was a `.mock` short of the component // it stands in for, and `dangerouslyIgnoreUnhandledErrors` in the vitest // config means the resulting "not a function" is swallowed rather than // reported. registerSignatureHelpProvider: vi.fn(() => ({ dispose: vi.fn() })), registerHoverProvider: vi.fn(() => ({ dispose: vi.fn() })), }, KeyMod: { CtrlCmd: 2048, WinCtrl: 256 }, KeyCode: { Backspace: 1, Tab: 2, Enter: 13, Escape: 9, Space: 10, PageUp: 11, PageDown: 12, UpArrow: 16, DownArrow: 18, KeyI: 39, KeyN: 44, KeyP: 46, KeyZ: 56, }, Range: class { constructor(...args: number[]) { Object.assign(this, { args }); } }, })); // 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", () => ({})); vi.mock("monaco-editor/esm/vs/basic-languages/hcl/hcl.contribution.js", () => ({})); // Fix: component imports default from "@/composables/useLogs/searchState" vi.mock("@/composables/useLogs/searchState", () => ({ default: () => ({ searchObj: { data: {}, meta: {} }, }), searchState: () => ({ searchObj: { data: {}, meta: {} }, }), })); const nlq = vi.hoisted(() => ({ generateSQL: vi.fn(() => Promise.resolve(null as string | null)), refusalNotice: null as unknown as { value: string }, showErrorNotification: vi.fn(), })); vi.mock("@/composables/useNLQuery", async () => { // A real ref, so the exposed refusalNotice unwraps on the component like it does in the app. const { ref } = await import("vue"); nlq.refusalNotice = ref(""); return { useNLQuery: () => ({ detectNaturalLanguage: vi.fn(() => false), generateSQL: nlq.generateSQL, transformToSQL: vi.fn((_nl: string, sql: string) => sql), isGenerating: { value: false }, streamingResponse: { value: "" }, refusalNotice: nlq.refusalNotice, }), }; }); vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key, }), })); vi.mock("@/composables/useNotifications", () => ({ default: () => ({ showErrorNotification: nlq.showErrorNotification, 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(); }); /** * setupEditor looks its host up with `document.getElementById(props.editorId)` * and, on a miss, retries five times on a 100ms timer before giving up — * WITHOUT ever reaching addCommand. Three describes used to fake that lookup * with a `vi.spyOn(document, "getElementById")` installed per mount and * restored in afterEach, which made "does this mount find its element" depend * on mock lifecycle while ~50 mounts from earlier describes were still * polling on their own timers. The result was bimodal: the tests either * finished in ~52ms or hung past any timeout, which is exactly the shape the * nine flaky failures had (they also reproduce on a clean origin/main). * * A real element carrying the real id removes the question — every lookup, * from any mount, finds it. */ const attachEditorHost = (id = "test-editor") => { const existing = document.getElementById(id); if (existing) return existing; const host = document.createElement("div"); host.id = id; document.body.appendChild(host); return host; }; const createWrapper = (props: any = {}) => { return mount(CodeQueryEditor, { props: { editorId: "test-editor", query: "SELECT * FROM logs", ...props, }, global: { plugins: [store], }, }); }; // A surface that switches language, such as the export dialog's JSON and // Terraform tabs, used to force a remount to get new highlighting: that throws // away the DOM, the scroll position and the undo stack on every toggle. describe("language switching", () => { it("retokenizes the existing model instead of recreating the editor", async () => { attachEditorHost(); const monacoApi: any = await import("monaco-editor/esm/vs/editor/editor.api"); const wrapper = createWrapper({ language: "json", query: "{}" }); await vi.waitFor(() => expect(monacoApi.editor.create).toHaveBeenCalled()); const createdBefore = monacoApi.editor.create.mock.calls.length; monacoApi.editor.setModelLanguage.mockClear(); await wrapper.setProps({ language: "hcl" }); await vi.waitFor(() => expect(monacoApi.editor.setModelLanguage).toHaveBeenCalled()); expect(monacoApi.editor.setModelLanguage.mock.calls[0][1]).toBe("hcl"); // No second editor: the same instance was retokenized. expect(monacoApi.editor.create.mock.calls.length).toBe(createdBefore); wrapper.unmount(); }); }); describe("free-text decorations (item 1 AC1.4)", () => { it("decorates each searched range with o2-free-text-term and the hover, and clears them", async () => { attachEditorHost(); const monacoApi: any = await import("monaco-editor/esm/vs/editor/editor.api"); mockModel.getPositionAt.mockImplementation((offset: number) => ({ lineNumber: 1, column: offset + 1, })); mockEditorObj.deltaDecorations.mockImplementation(((_old: string[], next: any[]) => next.map((_d: any, i: number) => `d${i}`)) as any); const wrapper = createWrapper({ query: "timeout error" }); await vi.waitFor(() => expect(monacoApi.editor.create).toHaveBeenCalled()); await wrapper.setProps({ freeTextDecorations: { ranges: [ { start: 0, end: 7 }, { start: 8, end: 13 }, ], hover: "Full-text search in: body\n\nRuns as match_all('timeout')", }, }); await vi.waitFor(() => expect(mockEditorObj.deltaDecorations).toHaveBeenCalled()); const [previous, next] = mockEditorObj.deltaDecorations.mock.calls.at(-1) as any[]; expect(previous).toEqual([]); expect(next.map((d: any) => d.range.args)).toEqual([ [1, 1, 1, 8], [1, 9, 1, 14], ]); expect(next[0].options.inlineClassName).toBe("o2-free-text-term"); expect(next[0].options.hoverMessage.map((m: any) => m.value)).toEqual([ "Full\\-text search in: body", "Runs as match\\_all\\('timeout'\\)", ]); await wrapper.setProps({ freeTextDecorations: null }); await vi.waitFor(() => expect(mockEditorObj.deltaDecorations.mock.calls.at(-1)).toEqual([["d0", "d1"], []]), ); mockModel.getPositionAt.mockReset(); mockModel.getPositionAt.mockImplementation(() => ({ lineNumber: 1, column: 1 })); mockEditorObj.deltaDecorations.mockImplementation((() => []) as any); wrapper.unmount(); }); }); 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 default releaseWheelToPage to true (page scrolls once editor has nothing left)", () => { const wrapper = createWrapper({ releaseWheelToPage: undefined }); expect(wrapper.props("releaseWheelToPage")).toBe(true); }); it("should allow opting out via releaseWheelToPage=false (editor keeps the wheel)", () => { const wrapper = createWrapper({ releaseWheelToPage: false }); expect(wrapper.props("releaseWheelToPage")).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("query generation refused by the server", () => { afterEach(() => { nlq.refusalNotice.value = ""; }); it("shows the refusal notice a single time and does not throw", async () => { const notice = "Your organization has used all of its AI credits."; nlq.generateSQL.mockImplementationOnce(() => { nlq.refusalNotice.value = notice; return Promise.resolve(null); }); const wrapper = createWrapper(); await expect(wrapper.vm.handleGenerateSQL("show error rows")).resolves.toBeUndefined(); // Exposed so the AI bar records it instead of the unchanged query. expect(wrapper.vm.refusalNotice).toBe(notice); expect(nlq.showErrorNotification).toHaveBeenCalledTimes(1); expect(nlq.showErrorNotification).toHaveBeenCalledWith(notice); wrapper.unmount(); }); }); 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; // 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 = {}) => { attachEditorHost(); shortcutWrapper = mount(CodeQueryEditor, { props: { editorId: "test-editor", query: "SELECT * FROM logs", ...props, }, global: { plugins: [store] }, }); // Wait until the async setupEditor completes and the key handler is bound await vi.waitFor( () => { expect(mockEditorObj.onKeyDown).toHaveBeenCalled(); }, { timeout: 10000 }, ); return shortcutWrapper; }; afterEach(() => { shortcutWrapper?.unmount(); shortcutWrapper = null; }); // The shortcut is bound via onKeyDown rather than addCommand: monaco's // addCommand discards the disposable it gets back, so its CommandsRegistry // entry outlives the editor and retains this component. it("should bind the shortcut through onKeyDown, not addCommand", async () => { await mountAndSetup(); expect(mockEditorObj.onKeyDown).toHaveBeenCalled(); expect(mockEditorObj.addCommand).not.toHaveBeenCalled(); }); it("should emit run-query when CtrlCmd+Enter is pressed", async () => { const wrapper = await mountAndSetup(); const handler = mockEditorObj.onKeyDown.mock.calls[0][0]; expect(typeof handler).toBe("function"); handler({ keyCode: 13, ctrlKey: true, metaKey: false, preventDefault: vi.fn(), stopPropagation: vi.fn(), }); expect(wrapper.emitted("run-query")).toBeTruthy(); }); it("should ignore Enter without a modifier", async () => { const wrapper = await mountAndSetup(); const handler = mockEditorObj.onKeyDown.mock.calls[0][0]; handler({ keyCode: 13, ctrlKey: false, metaKey: false, preventDefault: vi.fn(), stopPropagation: vi.fn(), }); expect(wrapper.emitted("run-query")).toBeFalsy(); }); // Monaco reports a widget blur on a timer, after a fast click on Run has already read the query. it("commits a pending edit as soon as the text loses focus", async () => { const wrapper = await mountAndSetup(); const onChange = mockEditorObj.onDidChangeModelContent.mock.calls[0][0]; mockEditorObj.getValue.mockReturnValue("up"); onChange({}); expect(wrapper.emitted("update:query")).toBeFalsy(); mockEditorObj.onDidBlurEditorText.mock.calls[0][0](); expect(wrapper.emitted("update:query")?.at(-1)?.[0]).toBe("up"); mockEditorObj.getValue.mockReturnValue(""); }); describe("user-edit (AC4.4 dirty editor)", () => { const changeHandler = () => mockEditorObj.onDidChangeModelContent.mock.calls[0][0]; afterEach(() => { mockModel.pushEditOperations.mockReset(); mockModel.setValue.mockReset(); mockEditorObj.setValue.mockReset(); mockModel.getValue.mockReturnValue(""); }); it("emits user-edit at once for a keystroke, before the debounced update", async () => { const wrapper = await mountAndSetup(); changeHandler()({ isFlush: false }); expect(wrapper.emitted("user-edit")).toHaveLength(1); expect(wrapper.emitted("update:query")).toBeFalsy(); }); it("ignores a flush (a whole-model replace)", async () => { const wrapper = await mountAndSetup(); changeHandler()({ isFlush: true }); expect(wrapper.emitted("user-edit")).toBeFalsy(); }); it("never marks the editor dirty for its own setValue", async () => { const wrapper = await mountAndSetup(); mockEditorObj.setValue.mockImplementation(() => changeHandler()({ isFlush: false })); (wrapper.vm as any).setValue("level='error'"); expect(wrapper.emitted("user-edit")).toBeFalsy(); }); it("never marks the editor dirty for the blur trim of a URL query with trailing whitespace", async () => { const wrapper = await mountAndSetup(); mockModel.getValue.mockReturnValue("status=500 "); mockModel.getLineCount.mockReturnValue(1); mockModel.getLineLength.mockReturnValue(13); mockModel.pushEditOperations.mockImplementation(() => changeHandler()({ isFlush: false })); mockEditorObj.onDidBlurEditorWidget.mock.calls[0][0](); expect(mockModel.pushEditOperations).toHaveBeenCalled(); expect(wrapper.emitted("user-edit")).toBeFalsy(); }); it("never marks the editor dirty for the unfocused prop sync", async () => { const wrapper = await mountAndSetup(); mockEditorObj.hasWidgetFocus.mockReturnValue(false); mockModel.setValue.mockImplementation(() => changeHandler()({ isFlush: false })); await wrapper.setProps({ query: "SELECT * FROM other" }); await flushPromises(); expect(mockModel.setValue).toHaveBeenCalled(); expect(wrapper.emitted("user-edit")).toBeFalsy(); }); }); }); // Tests for the bug fix: setValue must coerce null/undefined to "" so Monaco's // "Illegal argument" error can't surface when switching query modes (PromQL → SQL). describe("when query becomes null/undefined (PromQL -> SQL switch)", () => { let shortcutWrapper: ReturnType | null = null; // Mount the component and wait for the async setupEditor to complete so that // editorObj is fully initialised and the exposed setValue is wired to mockEditorObj. const mountAndSetup = async (props: any = {}) => { attachEditorHost(); shortcutWrapper = mount(CodeQueryEditor, { props: { editorId: "test-editor", query: "SELECT * FROM logs", ...props, }, global: { plugins: [store] }, }); await vi.waitFor( () => { expect(mockEditorObj.onKeyDown).toHaveBeenCalled(); }, { timeout: 10000 }, ); return shortcutWrapper; }; afterEach(() => { shortcutWrapper?.unmount(); shortcutWrapper = null; }); it("calls the underlying editor setValue with empty string when exposed setValue receives null", async () => { // Arrange const wrapper = await mountAndSetup(); mockEditorObj.setValue.mockClear(); // Act — call the exposed public method with null (simulates PromQL → SQL switch) wrapper.vm.setValue(null as any); // Assert — Monaco must receive "" not null expect(mockEditorObj.setValue).toHaveBeenCalledOnce(); expect(mockEditorObj.setValue).toHaveBeenCalledWith(""); }); it("calls the underlying editor setValue with empty string when exposed setValue receives undefined", async () => { // Arrange const wrapper = await mountAndSetup(); mockEditorObj.setValue.mockClear(); // Act wrapper.vm.setValue(undefined as any); // Assert expect(mockEditorObj.setValue).toHaveBeenCalledOnce(); expect(mockEditorObj.setValue).toHaveBeenCalledWith(""); }); it("does not throw when exposed setValue is called with null", async () => { // Arrange const wrapper = await mountAndSetup(); mockEditorObj.setValue.mockClear(); // Act & Assert — must not throw at all expect(() => wrapper.vm.setValue(null as any)).not.toThrow(); }); it("does not throw when exposed setValue is called with undefined", async () => { // Arrange const wrapper = await mountAndSetup(); mockEditorObj.setValue.mockClear(); // Act & Assert expect(() => wrapper.vm.setValue(undefined as any)).not.toThrow(); }); it("passes a real string value through unchanged to the underlying editor", async () => { // Arrange const wrapper = await mountAndSetup(); mockEditorObj.setValue.mockClear(); // Act — normal usage: a valid SQL query after mode switch wrapper.vm.setValue("SELECT count(*) FROM logs"); // Assert — value is forwarded as-is expect(mockEditorObj.setValue).toHaveBeenCalledOnce(); expect(mockEditorObj.setValue).toHaveBeenCalledWith("SELECT count(*) FROM logs"); }); it("also calls layout after setValue so the editor repaints", async () => { // Arrange const wrapper = await mountAndSetup(); mockEditorObj.setValue.mockClear(); mockEditorObj.layout.mockClear(); // Act wrapper.vm.setValue("SELECT 1"); // Assert — layout must be called to repaint after content change expect(mockEditorObj.layout).toHaveBeenCalled(); }); }); // 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 []", () => { afterEach(() => {}); // 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; attachEditorHost(); 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([]); }); }); });