Files
openobserve/web/src/components/CodeQueryEditor.spec.ts
T
c99cb0d516 fix(logs): logs finesse phase 1 — severity inference, auto-run, free-text FTS search, J/K, line permalinks (#15234)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Neha P <neha@openobserve.ai>
2026-10-12 16:50:32 +00:00

1036 lines
38 KiB
TypeScript

// 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 <http://www.gnu.org/licenses/>.
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(),
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 },
},
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: 1 },
KeyCode: { Enter: 13 },
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: {} },
}),
}));
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<typeof vi.fn>).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("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<typeof mount> | 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<typeof mount> | 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([]);
});
});
});