mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 00:09:35 +00:00
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1092 lines
40 KiB
TypeScript
1092 lines
40 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(),
|
|
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<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("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<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([]);
|
|
});
|
|
});
|
|
});
|