Files
openobserve/web/src/components/CodeQueryEditor.completion.spec.ts
Bhargavandgithub-actions[bot] 303abafa31 fix(a11y): fix P0 accessibility blockers and shared control naming (#15513)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-20 15:38:58 +00:00

2886 lines
116 KiB
TypeScript

// Copyright 2026 OpenObserve Inc.
//
// Phase 1 completion-provider integration tests (tmp/code.md).
//
// Deliberately a SEPARATE file from CodeQueryEditor.spec.ts: that suite's
// async-mount tests (Ctrl+Enter, setValue) are load-flaky on main — an
// unmodified copy fails ~9 of them on roughly 3 runs in 4 — and the contention
// starved this suite's beforeAll, skipping every test here. Isolated, these
// three mounts run reliably.
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
import { mount } from "@vue/test-utils";
import CodeQueryEditor from "./CodeQueryEditor.vue";
import { createStore } from "vuex";
import { SQL_CLAUSE_KEYWORDS, SQL_KEYWORDS } from "@/utils/query/sqlCompletion";
import analytics from "@/services/product_analytics";
import { flushEditorSuggestSummary } from "@/utils/query/editorSuggestStats";
// 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.
let modelSeq = 0;
const makeModel = (initialValue = "") => ({
// Returns what the editor was created with, as a real model does. A stub that
// always answers "" makes anything reading model.getValue() — the
// double-quote scan, for one — untestable: the assertion passes because the
// model is empty, not because the code is right.
getValue: vi.fn(() => initialValue),
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 })),
getWordAtPosition: vi.fn(() => ({ word: "", startColumn: 1, endColumn: 1 })),
// Stable per model, as real monaco URIs are. A fresh value on every call
// would break any implementation keying per-editor config by uri.
uri: (() => {
const value = `inmemory://model/${modelSeq++}`;
return { toString: () => value };
})(),
});
// The default model, used by the tests that only ever mount one editor.
const mockModel = makeModel();
/** Every editor stub monaco.editor.create has handed out, newest last. */
const createdEditors: any[] = [];
const makeEditorStub = (model: any) => ({
onDidChangeModelContent: vi.fn(),
createContextKey: vi.fn(() => ({ set: vi.fn() })),
addAction: vi.fn(() => ({ dispose: vi.fn() })),
executeEdits: vi.fn(),
setPosition: vi.fn(),
pushUndoStop: vi.fn(),
getSelections: 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(() => model),
updateOptions: vi.fn(),
hasWidgetFocus: vi.fn(() => false),
getRawOptions: vi.fn(() => ({ readOnly: false })),
deltaDecorations: vi.fn(() => []),
getPosition: vi.fn(() => ({ lineNumber: 1, column: 1 })),
hasTextFocus: vi.fn(() => true),
trigger: vi.fn(),
getAction: vi.fn(() => ({ run: vi.fn(() => Promise.resolve()) })),
});
// Kept for the tests that assert on "the editor" without caring which one.
// Points at the most recently created stub.
const mockEditorObj: any = makeEditorStub(mockModel);
// 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((_el: any, options: any = {}) => {
// A fresh editor AND model per call, as real monaco does. Sharing one
// stub makes a per-language singleton provider impossible to test: it
// distinguishes editors by model.
const stub = makeEditorStub(makeModel(options?.value ?? ""));
createdEditors.push(stub);
Object.assign(mockEditorObj, stub);
return stub;
}),
defineTheme: vi.fn(),
setTheme: vi.fn(),
setModelMarkers: vi.fn(),
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,
Event: 10,
Text: 18,
Issue: 26,
Folder: 23,
Snippet: 27,
},
CompletionItemInsertTextRule: { None: 0, KeepWhitespace: 1, InsertAsSnippet: 4 },
CompletionItemTag: { Deprecated: 1 },
register: vi.fn(),
setMonarchTokensProvider: vi.fn(),
setLanguageConfiguration: vi.fn(),
registerCompletionItemProvider: vi.fn(() => ({ dispose: vi.fn() })),
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 });
}
},
Selection: class {
constructor(...args: number[]) {
Object.assign(this, { args });
}
},
// Real values from monaco-editor/esm/vs/editor/editor.api. Omitting this
// made validateDoubleQuotes throw on `monaco.MarkerSeverity.Warning` the
// moment it found anything to report, so it published NOTHING and looked
// like a component that simply never ran its validation.
MarkerSeverity: { Hint: 1, Info: 2, Warning: 4, Error: 8 },
}));
// Mock dynamic imports
vi.mock("monaco-editor/esm/vs/basic-languages/sql/sql.contribution.js", () => ({}));
vi.mock("monaco-editor/esm/vs/language/json/monaco.contribution.js", () => ({}));
vi.mock("monaco-editor/esm/vs/language/html/monaco.contribution.js", () => ({}));
vi.mock("monaco-editor/esm/vs/basic-languages/markdown/markdown.contribution.js", () => ({}));
vi.mock("monaco-editor/esm/vs/basic-languages/python/python.contribution.js", () => ({}));
vi.mock("monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution.js", () => ({}));
// Fix: component imports default from "@/composables/useLogs/searchState"
vi.mock("@/composables/useLogs/searchState", () => ({
default: () => ({
searchObj: { data: {}, meta: {} },
}),
searchState: () => ({
searchObj: { data: {}, meta: {} },
}),
}));
vi.mock("@/composables/useNLQuery", () => ({
useNLQuery: () => ({
detectNaturalLanguage: vi.fn(() => false),
generateSQL: vi.fn(() => Promise.resolve(null)),
transformToSQL: vi.fn((_nl: string, sql: string) => sql),
isGenerating: { value: false },
streamingResponse: { value: "" },
}),
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}));
vi.mock("@/composables/useNotifications", () => ({
default: () => ({
showErrorNotification: vi.fn(),
showPositiveNotification: vi.fn(),
}),
}));
vi.mock("@/utils/zincutils", () => ({
getImageURL: vi.fn((path: string) => `/mocked/${path}`),
}));
vi.mock("@/utils/query/vrlLanguageDefinition", () => ({
vrlLanguageDefinition: {},
}));
vi.mock("@/utils/query/promqlLanguageDefinition", () => ({
loadPromqlLanguage: async () => ({ language: {}, languageConfiguration: {} }),
}));
vi.mock("@/composables/usePromqlSuggestions", async (importOriginal) => ({
...(await importOriginal<typeof import("@/composables/usePromqlSuggestions")>()),
resolvePromqlLabelValues: vi.fn(async () => ["api-gateway", "chat-service"]),
}));
// ─── Phase 1 integration (tmp/code.md A2/A4/A5/N7/C1) ─────────────────────────
// The pure helper specs in src/utils/query/sqlCompletion.spec.ts can all pass
// while CodeQueryEditor keeps its own split(" ") tokenisation, String.includes
// pre-filter, singular `insertTextRule` lookup and four-field push. These tests
// drive the REGISTERED provider so the component itself is on the hook.
//
// Mounted ONCE in beforeAll. The suite's other async-mount tests (Ctrl+Enter,
// setValue) are already load-flaky on main — they time out on ~1 run in 2 with
// no source changes at all — so we do not add nine more waitFor mounts here.
// A function row carries its signature in an object label, so tests compare by name.
const nameOf = (s: any): string => (typeof s.label === "string" ? s.label : s.label.label);
const isHeader = (s: any): boolean => s.kind === 23;
const rowsOf = (result: any) => result.suggestions.filter((s: any) => !isHeader(s));
describe("Phase 1 — the registered completion provider", () => {
const providerStore = createStore({ state: { theme: "light" } });
// Two providers so a throw on the suggestions path cannot mask the
// keyword-path signals (A5/C1) — otherwise every assertion here fails for
// the same single reason and only one bug is observable at a time.
let provideKw: Function;
let provideSug: Function;
let provideFallback: Function;
let getElementByIdSpy: ReturnType<typeof vi.spyOn>;
const KEYWORDS = [
{
name: "like",
label: "like",
kind: "Keyword",
insertText: "like '%${1:params}%' ",
insertTextRules: "InsertAsSnippet",
},
{
name: "kubernetes_namespace_name",
label: "kubernetes_namespace_name",
kind: "Field",
insertText: "kubernetes_namespace_name",
sortText: "\u0000kubernetes_namespace_name",
},
{ name: "and", label: "and", kind: "Keyword", insertText: "and " },
];
const SUGGESTIONS = [
{
name: "approx_topk",
label: "approx_topk",
kind: "Function",
detail: "(field, k) → top-k values",
documentation: "Approximate top-k.",
insertText: "approx_topk(${1:field}, ${2:10})",
insertTextRules: "InsertAsSnippet",
sortText: "zz-approx_topk",
},
// Legacy callable shape — the `suggestions` prop is public API.
{
label: (kw: string) => `legacy('${kw}')`,
kind: "Text",
insertText: (kw: string) => `legacy('${kw}')`,
},
];
let kwModel: any;
let sugModel: any;
let fallbackModel: any;
beforeAll(async () => {
const monacoApi = await import("monaco-editor/esm/vs/editor/editor.api");
const registerFn = vi.mocked(monacoApi.languages.registerCompletionItemProvider);
const createFn = vi.mocked(monacoApi.editor.create);
getElementByIdSpy = vi
.spyOn(document, "getElementById")
.mockImplementation(() => document.createElement("div"));
// Waits for THIS editor to be created, then takes the current provider.
// Deliberately does NOT require a new registration per mount: C5 makes the
// provider a per-language singleton, at which point the second and third
// mounts register nothing and a "wait for another registration" helper
// would hang forever.
const mountAndCapture = async (editorId: string, props: any) => {
const createsBefore = createFn.mock.calls.length;
mount(CodeQueryEditor, {
props: { editorId, language: "sql", query: "SELECT * FROM logs", ...props },
global: { plugins: [providerStore] },
});
await vi.waitFor(() => expect(createFn.mock.calls.length).toBe(createsBefore + 1), {
timeout: 15000,
interval: 25,
});
const sqlCalls = registerFn.mock.calls.filter((c) => c[0] === "sql");
const provider = sqlCalls.at(-1)![1].provideCompletionItems as Function;
const model = createdEditors.at(-1)!.getModel();
return { provider, model };
};
({ provider: provideKw, model: kwModel } = await mountAndCapture("provider-kw", {
keywords: KEYWORDS,
suggestions: [],
}));
({ provider: provideSug, model: sugModel } = await mountAndCapture("provider-sug", {
keywords: [],
suggestions: SUGGESTIONS,
}));
// suggestions omitted => the shared catalog fallback (N2/D7)
({ provider: provideFallback, model: fallbackModel } = await mountAndCapture(
"provider-fallback",
{ keywords: [] },
));
}, 60000);
afterAll(() => getElementByIdSpy?.mockRestore());
/**
* Invoke a provider as monaco would.
*
* Awaited on purpose: C4 turns provideCompletionItems async so the field-value
* lookup can be resolved inline. A helper that read `.suggestions` off the
* returned value would silently read it off a Promise and every assertion
* here would start failing the moment that lands.
*/
const invokeWith = async (
provide: Function,
model: any,
textUntilCursor: string,
word: string,
) => {
model.getValueInRange.mockReturnValue(textUntilCursor);
model.getWordUntilPosition.mockReturnValue({
word,
startColumn: 1,
endColumn: word.length + 1,
});
return await provide(model, { lineNumber: 1, column: word.length + 1 });
};
const invokeKw = (t: string, w: string) => invokeWith(provideKw, kwModel, t, w);
const invokeSug = (t: string, w: string) => invokeWith(provideSug, sugModel, t, w);
const invokeFallback = (t: string, w: string) => invokeWith(provideFallback, fallbackModel, t, w);
const itemFor = (result: any, label: string) =>
result.suggestions.find((s: any) => nameOf(s) === label);
it("answers for its own model", async () => {
expect(provideKw).toBeTypeOf("function");
expect((await invokeKw("SELECT ", "")).suggestions.length).toBeGreaterThan(0);
});
it("A5 — snippet keywords carry the NUMERIC InsertAsSnippet flag", async () => {
const item = itemFor(await invokeKw("where ", ""), "like");
expect(item).toBeDefined();
expect(typeof item.insertTextRules).toBe("number");
expect(item.insertTextRules).toBe(4);
});
it("A5 — plain keywords carry no snippet flag", async () => {
expect(itemFor(await invokeKw("where ", ""), "and").insertTextRules).toBeUndefined();
});
it("N7 — detail/documentation/sortText survive the push", async () => {
const item = itemFor(await invokeSug("SELECT ", ""), "approx_topk");
expect(item).toBeDefined();
expect(item.detail).toBe("(field, k) → top-k values");
expect(item.documentation).toEqual({ value: "Approximate top-k." });
expect(item.sortText).toBe("zz-approx_topk");
});
it("N7/A5 — suggestion snippets reach monaco as a number too", async () => {
expect(itemFor(await invokeSug("SELECT ", ""), "approx_topk").insertTextRules).toBe(4);
});
it("A1 — a Function-kind suggestion maps to monaco kind 1", async () => {
expect(itemFor(await invokeSug("SELECT ", ""), "approx_topk").kind).toBe(1);
});
it("C1 — subsequence candidates are not dropped by a substring pre-filter", async () => {
// "knn" is a subsequence of kubernetes_namespace_name but NOT a substring,
// so String.includes() removes it before monaco can score it.
expect(itemFor(await invokeKw("SELECT knn", "knn"), "kubernetes_namespace_name")).toBeDefined();
});
it("A2 — items are identical no matter how much of the word is typed", async () => {
const a = itemFor(await invokeSug("SELECT a", "a"), "approx_topk");
const appr = itemFor(await invokeSug("SELECT appr", "appr"), "approx_topk");
expect(a.label).toEqual(appr.label);
expect(a.insertText).toBe(appr.insertText);
});
it("N2 — the SQL fallback serves the shared catalog, aggregates included", async () => {
const labels = (await invokeFallback("SELECT ", "")).suggestions.map(nameOf);
// The component's old 7-entry local list had none of these.
for (const agg of ["sum", "avg", "count", "max", "min", "histogram", "approx_topk"]) {
expect(labels, `fallback missing ${agg}`).toContain(agg);
}
});
it("N2 — the SQL fallback also serves the array family", async () => {
const labels = (await invokeFallback("SELECT ", "")).suggestions.map(nameOf);
for (const fn of ["arrcount", "arrsort", "arrjoin", "arrzip", "spath"]) {
expect(labels, `fallback missing ${fn}`).toContain(fn);
}
});
it("N2 — fallback items are Function-kinded and snippet-enabled", async () => {
const sum = (await invokeFallback("SELECT ", "")).suggestions.find(
(s: any) => nameOf(s) === "sum",
);
expect(sum).toBeDefined();
expect(sum.kind).toBe(1);
expect(sum.insertTextRules).toBe(4);
expect(sum.insertText).toBe("sum(${1:field})");
});
it("A2 — a list containing legacy callables is reported as incomplete", async () => {
// SUGGESTIONS includes one callable entry, so monaco must re-query as the
// user keeps typing rather than freezing the first-keystroke content.
expect((await invokeSug("SELECT a", "a")).incomplete).toBe(true);
});
it("A2/D10 — even a static list is reported as incomplete", async () => {
// This asserted the OPPOSITE, on the reasoning that re-querying content
// which cannot change is wasted work. The content cannot change; the
// CONTEXT can. `severity = ` turns the same static catalog into a value
// list, and monaco will not ask again unless the previous answer said
// incomplete — so the values waited for a trigger character, and any value
// fetched from the server after the first call could never arrive at all.
//
// The wasted work is real and small: a local catalog and a cached lookup,
// rebuilt per keystroke. See tmp/code.md D10.
expect((await invokeKw("SELECT a", "a")).incomplete).toBe(true);
expect((await invokeFallback("SELECT a", "a")).incomplete).toBe(true);
});
it("B3 — deprecated aliases carry monaco's Deprecated tag (strikethrough)", async () => {
const raw = (await invokeFallback("SELECT ", "")).suggestions.find(
(s: any) => nameOf(s) === "match_all_raw",
);
expect(raw).toBeDefined();
expect(raw.tags).toEqual([1]); // CompletionItemTag.Deprecated
});
it("B3 — match_all itself is not tagged deprecated", async () => {
const ok = (await invokeFallback("SELECT ", "")).suggestions.find(
(s: any) => nameOf(s) === "match_all",
);
expect(ok.tags).toBeUndefined();
});
it("D1 — catalog items carry a compact detail and prose documentation", async () => {
const topk = (await invokeFallback("SELECT ", "")).suggestions.find(
(s: any) => nameOf(s) === "approx_topk",
);
expect(topk.detail).toBe("(field, k)");
expect(String((topk.documentation as any).value).length).toBeGreaterThan(15);
});
it("A4 — legacy callables get monaco's word, not a space-split token", async () => {
// Old code did textUntilPosition.trim().split(" ").pop() => "*\nFROM".
const labels = (await invokeSug("SELECT *\nFROM", "FROM")).suggestions.map(nameOf);
expect(labels).toContain("legacy('FROM')");
expect(labels.join("|")).not.toContain("\n");
});
});
// ═══════════════════════════════════════════════════════════════════════════
// PHASE 3 — IntelliSense parity (tmp/code.md D2, D3, C3, D4, N3, N4, C5)
//
// Deliberately at the REGISTRATION level, not against helper modules. Every
// gap that escaped in this workstream — Alerts binding the base list, the SLO
// form never loading the catalog, Traces omitting a prop — was a helper that
// worked perfectly and a component that never called it.
// ═══════════════════════════════════════════════════════════════════════════
// D4 (resolveCompletionItem for lazy documentation) is deliberately NOT part of
// this phase. Measured: the whole catalog carries 37 KB of documentation (server
// 32.7 KB across 301 of 350 entries, median 66 chars; local 4 KB), and every byte
// is already resident — a module constant plus a per-org cached ref. Lazy
// resolution exists in VS Code because documentation usually means a
// language-server round trip; here it would save allocating a few hundred small
// wrapper objects per keystroke and would add a provider method, item identity
// for re-lookup, and a failure mode where docs silently vanish if the resolver
// is not wired. Revisit only if docs ever become expensive to produce.
describe("Phase 3 — providers are registered and configured", () => {
const providerStore2 = createStore({ state: { theme: "light" } });
let spy: ReturnType<typeof vi.spyOn>;
const mountEditor = async (props: any = {}) => {
const monacoApi = await import("monaco-editor/esm/vs/editor/editor.api");
const createFn = vi.mocked(monacoApi.editor.create);
// Wait for THIS mount, not for any past one. createContextKey is a shared
// spy that earlier describes have already tripped, so waiting on
// toHaveBeenCalled() returns instantly and the assertions below would read
// whatever the previous test left behind.
const createsBefore = createFn.mock.calls.length;
spy = vi
.spyOn(document, "getElementById")
.mockImplementation(() => document.createElement("div"));
mount(CodeQueryEditor, {
props: { editorId: `p3-${Math.random()}`, language: "sql", query: "", ...props },
global: { plugins: [providerStore2] },
});
await vi.waitFor(() => expect(createFn.mock.calls.length).toBe(createsBefore + 1), {
timeout: 15000,
interval: 25,
});
return monacoApi;
};
afterAll(() => spy?.mockRestore());
it("D2 — registers a signature help provider for SQL", { timeout: 30000 }, async () => {
const api = await mountEditor();
const reg = vi.mocked((api.languages as any).registerSignatureHelpProvider);
expect(reg, "no signature help provider is registered at all").toBeDefined();
expect(reg.mock.calls.length).toBeGreaterThan(0);
expect(reg.mock.calls.at(-1)![0]).toBe("sql");
});
it("D2 — signature help triggers on ( and ,", { timeout: 30000 }, async () => {
const api = await mountEditor();
const provider = vi
.mocked((api.languages as any).registerSignatureHelpProvider)
.mock.calls.at(-1)![1];
expect(provider.signatureHelpTriggerCharacters).toEqual(expect.arrayContaining(["(", ","]));
});
it("D3 — registers a hover provider for SQL", { timeout: 30000 }, async () => {
const api = await mountEditor();
const reg = vi.mocked((api.languages as any).registerHoverProvider);
expect(reg, "no hover provider is registered at all").toBeDefined();
expect(reg.mock.calls.length).toBeGreaterThan(0);
expect(reg.mock.calls.at(-1)![0]).toBe("sql");
});
it("C3 — completion declares trigger characters", { timeout: 30000 }, async () => {
const api = await mountEditor();
const provider = vi.mocked(api.languages.registerCompletionItemProvider).mock.calls.at(-1)![1];
// Without these nothing opens after a paren, a comma or an opening quote —
// exactly the positions where the user most needs help.
// The full set from the plan. Omitting the double quote leaves quoted
// identifiers (FROM "my stream") untriggered, and omitting the space leaves
// the position right after a keyword untriggered — both the moments a user
// most expects the list to appear.
expect(provider.triggerCharacters).toEqual(
expect.arrayContaining(["(", ",", "'", ".", '"', " "]),
);
});
it("N4 — word-based suggestions are off for SQL", { timeout: 30000 }, async () => {
const api = await mountEditor();
const opts = vi.mocked(api.editor.create).mock.calls.at(-1)![1] as any;
// Default is 'matchingDocuments', which pulls raw buffer text from every
// other SQL editor on the page into this one's dropdown.
expect(opts.wordBasedSuggestions).toBe("off");
});
it("N4 — but NOT for the non-query languages", { timeout: 30000 }, async () => {
// N4 was about SQL and PromQL, where every suggestion should come from the
// catalog. VRL, JS, JSON and the rest have no catalog at all, so turning
// word-based completion off there removes the only completion they have.
for (const language of ["vrl", "javascript", "json", "markdown"]) {
const api = await mountEditor({ language });
const opts = vi.mocked(api.editor.create).mock.calls.at(-1)![1] as any;
expect(opts.wordBasedSuggestions, `${language} lost its word completion`).not.toBe("off");
}
});
it("N3 — quick suggestions are enabled inside string literals", { timeout: 30000 }, async () => {
const api = await mountEditor();
const opts = vi.mocked(api.editor.create).mock.calls.at(-1)![1] as any;
// Monaco defaults strings to 'off', which is why field-VALUE completion
// needed the hide/re-trigger hack.
expect(opts.quickSuggestions).toMatchObject({ other: "on", strings: "on" });
});
});
describe("Phase 3 — providers return the shapes monaco requires", () => {
const store4 = createStore({ state: { theme: "light" } });
let spy: ReturnType<typeof vi.spyOn>;
afterAll(() => spy?.mockRestore());
const mountAndGet = async () => {
const api = await import("monaco-editor/esm/vs/editor/editor.api");
const createFn = vi.mocked(api.editor.create);
const before = createFn.mock.calls.length;
spy = vi
.spyOn(document, "getElementById")
.mockImplementation(() => document.createElement("div"));
mount(CodeQueryEditor, {
props: {
editorId: `shape-${Math.random()}`,
language: "sql",
query: "",
keywords: [
{
name: "host_name",
label: "host_name",
kind: "Field",
insertText: "host_name",
detail: "Utf8",
},
],
},
global: { plugins: [store4] },
});
await vi.waitFor(() => expect(createFn.mock.calls.length).toBe(before + 1), {
timeout: 15000,
interval: 25,
});
// Hand back THIS editor's model. Invoking a provider with an unrelated
// model is exactly what a correct per-model implementation should refuse.
return { api, model: createdEditors.at(-1)!.getModel() };
};
const position = { lineNumber: 1, column: 12 };
it(
"D2 — provideSignatureHelp returns { value, dispose }, not a bare SignatureHelp",
{ timeout: 30000 },
async () => {
const { api, model } = await mountAndGet();
const provider = vi
.mocked((api.languages as any).registerSignatureHelpProvider)
.mock.calls.at(-1)![1];
model.getValueInRange.mockReturnValue("SELECT sum(");
const result = await provider.provideSignatureHelp(model, position, {}, {});
// monaco reads result.value (SignatureHelpResult extends IDisposable).
// Returning the SignatureHelp directly yields undefined and NO hint, silently.
expect(result).toBeTruthy();
expect(result.value, "provider returned a bare SignatureHelp").toBeTruthy();
expect(result.value.signatures[0].label).toContain("sum");
expect(typeof result.dispose).toBe("function");
},
);
it("D2 — returns null when the cursor is not in a call", { timeout: 30000 }, async () => {
const { api, model } = await mountAndGet();
const provider = vi
.mocked((api.languages as any).registerSignatureHelpProvider)
.mock.calls.at(-1)![1];
model.getValueInRange.mockReturnValue("SELECT * FROM logs ");
expect(await provider.provideSignatureHelp(model, position, {}, {})).toBeNull();
});
it(
"D3 — provideHover returns { contents } as IMarkdownString[]",
{ timeout: 30000 },
async () => {
const { api, model } = await mountAndGet();
const provider = vi
.mocked((api.languages as any).registerHoverProvider)
.mock.calls.at(-1)![1];
model.getWordAtPosition.mockReturnValue({
word: "host_name",
startColumn: 1,
endColumn: 10,
});
const hover = await provider.provideHover(model, position, {});
expect(hover).toBeTruthy();
expect(Array.isArray(hover.contents)).toBe(true);
expect(hover.contents[0].value).toContain("Utf8");
},
);
it("D3 — returns null for a word it knows nothing about", { timeout: 30000 }, async () => {
const { api, model } = await mountAndGet();
const provider = vi.mocked((api.languages as any).registerHoverProvider).mock.calls.at(-1)![1];
model.getWordAtPosition.mockReturnValue({
word: "zzz_unknown",
startColumn: 1,
endColumn: 12,
});
expect(await provider.provideHover(model, position, {})).toBeNull();
});
});
describe("Phase 3 — C4: the value lookup is awaited inside the provider", () => {
const store5 = createStore({ state: { theme: "light" } });
let spy: ReturnType<typeof vi.spyOn>;
afterAll(() => spy?.mockRestore());
// Awaiting the provider in the tests above only PERMITS an async
// implementation; it does not require one. Nothing so far fails if the
// current arrangement survives untouched: parent debounces, calls
// getSuggestions, pushes contextKeywords down as a prop, then force-reopens
// the widget. This is the test that makes the difference observable — the
// values must be in the FIRST result the provider returns, with no second
// trigger and no prop round trip.
it(
"returns field values on the FIRST call, from a delayed resolver",
{ timeout: 30000 },
async () => {
const api = await import("monaco-editor/esm/vs/editor/editor.api");
const createFn = vi.mocked(api.editor.create);
const registerFn = vi.mocked(api.languages.registerCompletionItemProvider);
const before = createFn.mock.calls.length;
let resolverCalls = 0;
const fieldValueResolver = vi.fn(async (field: string) => {
resolverCalls += 1;
// Deliberately slower than a microtask: a provider that forgets to await
// returns before this settles.
await new Promise((r) => setTimeout(r, 60));
return field === "level" ? ["error", "warn"] : [];
});
spy = vi
.spyOn(document, "getElementById")
.mockImplementation(() => document.createElement("div"));
mount(CodeQueryEditor, {
props: {
editorId: "c4-editor",
language: "sql",
query: "",
keywords: [
{ name: "level", label: "level", kind: "Field", insertText: "level", detail: "Utf8" },
],
suggestions: [],
fieldValueResolver,
},
global: { plugins: [store5] },
});
await vi.waitFor(() => expect(createFn.mock.calls.length).toBe(before + 1), {
timeout: 15000,
interval: 25,
});
const model = createdEditors.at(-1)!.getModel();
const provider = registerFn.mock.calls.filter((c) => c[0] === "sql").at(-1)![1];
model.getValueInRange.mockReturnValue("SELECT * FROM logs WHERE level = '");
model.getWordUntilPosition.mockReturnValue({ word: "", startColumn: 34, endColumn: 34 });
const result = await provider.provideCompletionItems(
model,
{ lineNumber: 1, column: 34 },
{},
{},
);
expect(fieldValueResolver, "the provider never asked for values").toHaveBeenCalledWith(
"level",
expect.anything(),
);
const labels = result.suggestions.filter((s: any) => !isHeader(s)).map(nameOf);
expect(labels, "values did not make the first result — they were not awaited").toEqual(
expect.arrayContaining(["'error'", "'warn'"]),
);
expect(resolverCalls).toBe(1);
},
);
it(
"offers the normal list when the cursor is not after an operator",
{ timeout: 30000 },
async () => {
const api = await import("monaco-editor/esm/vs/editor/editor.api");
const createFn = vi.mocked(api.editor.create);
const registerFn = vi.mocked(api.languages.registerCompletionItemProvider);
const before = createFn.mock.calls.length;
const fieldValueResolver = vi.fn(async () => ["error"]);
spy = vi
.spyOn(document, "getElementById")
.mockImplementation(() => document.createElement("div"));
mount(CodeQueryEditor, {
props: {
editorId: "c4-editor-2",
language: "sql",
query: "",
keywords: [
{ name: "level", label: "level", kind: "Field", insertText: "level", detail: "Utf8" },
],
suggestions: [],
fieldValueResolver,
},
global: { plugins: [store5] },
});
await vi.waitFor(() => expect(createFn.mock.calls.length).toBe(before + 1), {
timeout: 15000,
interval: 25,
});
const model = createdEditors.at(-1)!.getModel();
const provider = registerFn.mock.calls.filter((c) => c[0] === "sql").at(-1)![1];
model.getValueInRange.mockReturnValue("SELECT le");
model.getWordUntilPosition.mockReturnValue({ word: "le", startColumn: 8, endColumn: 10 });
const result = await provider.provideCompletionItems(
model,
{ lineNumber: 1, column: 10 },
{},
{},
);
expect(result.suggestions.filter((s: any) => !isHeader(s)).map(nameOf)).toContain("level");
expect(
fieldValueResolver,
"resolver was called outside value context",
).not.toHaveBeenCalled();
},
);
});
describe("Phase 3 — C5: one provider per language, not per editor", () => {
const store3 = createStore({ state: { theme: "light" } });
let spy: ReturnType<typeof vi.spyOn>;
afterAll(() => spy?.mockRestore());
it(
"registers the completion provider once for three SQL editors",
{ timeout: 60000 },
async () => {
const api = await import("monaco-editor/esm/vs/editor/editor.api");
const reg = vi.mocked(api.languages.registerCompletionItemProvider);
const createFn = vi.mocked(api.editor.create);
const providersBefore = reg.mock.calls.filter((c) => c[0] === "sql").length;
spy = vi
.spyOn(document, "getElementById")
.mockImplementation(() => document.createElement("div"));
// Mounted SEQUENTIALLY, each awaited to completion. Mounting them in one
// synchronous burst is not equivalent: only the first editor finishes
// initialising, so the count would look like 1 whether or not the
// per-language fix exists — a green test for the wrong reason.
for (const id of ["c5-a", "c5-b", "c5-c"]) {
const createsBefore = createFn.mock.calls.length;
mount(CodeQueryEditor, {
props: { editorId: id, language: "sql", query: "" },
global: { plugins: [store3] },
});
await vi.waitFor(() => expect(createFn.mock.calls.length).toBe(createsBefore + 1), {
timeout: 15000,
interval: 25,
});
}
const added = reg.mock.calls.filter((c) => c[0] === "sql").length - providersBefore;
const total = reg.mock.calls.filter((c) => c[0] === "sql").length;
// Asserting `added === 1` would be order-dependent AND unsatisfiable once
// the fix lands: registration moves to module scope, so an earlier
// describe in this file has already done it and three more editors add
// ZERO. The order-independent invariant is that the module ever registers
// one SQL provider. Monaco aggregates every provider registered for a
// language, so today each editor adds another that answers every
// keystroke only to return an empty list for a model that did not ask.
expect(added, `three editors added ${added} SQL completion providers`).toBeLessThanOrEqual(1);
expect(total, `${total} SQL completion providers registered in this file`).toBe(1);
},
);
});
// Reported from the SLO form: inside approx_percentile_cont( on a metrics
// stream, twenty string labels sorted above `value` — the one column the
// function can take — and it fell below the visible list.
describe("numeric columns rank first inside a numeric aggregate", () => {
const store6 = createStore({ state: { theme: "light" } });
let spy: ReturnType<typeof vi.spyOn>;
afterAll(() => spy?.mockRestore());
const FIELDS = [
{
name: "availability_zone",
label: "availability_zone",
kind: "Field",
insertText: "availability_zone",
detail: "Utf8",
sortText: "\u0000availability_zone",
},
{
name: "value",
label: "value",
kind: "Field",
insertText: "value",
detail: "Float64",
sortText: "\u0000value",
},
];
/** Mount an editor and ask its provider for the list at `text`. */
const completionsAt = async (editorId: string, text: string) => {
const api = await import("monaco-editor/esm/vs/editor/editor.api");
const createFn = vi.mocked(api.editor.create);
const registerFn = vi.mocked(api.languages.registerCompletionItemProvider);
const before = createFn.mock.calls.length;
spy = vi
.spyOn(document, "getElementById")
.mockImplementation(() => document.createElement("div"));
mount(CodeQueryEditor, {
props: { editorId, language: "sql", query: "", keywords: FIELDS, suggestions: [] },
global: { plugins: [store6] },
});
await vi.waitFor(() => expect(createFn.mock.calls.length).toBe(before + 1), {
timeout: 15000,
interval: 25,
});
const model = createdEditors.at(-1)!.getModel();
const provider = registerFn.mock.calls.filter((c) => c[0] === "sql").at(-1)![1];
model.getValueInRange.mockReturnValue(text);
model.getWordUntilPosition.mockReturnValue({
word: "",
startColumn: text.length + 1,
endColumn: text.length + 1,
});
const result = await provider.provideCompletionItems(
model,
{ lineNumber: 1, column: text.length + 1 },
{},
{},
);
// The order monaco applies to equally-scoring items.
return result.suggestions
.slice()
.sort((a: any, b: any) => (String(a.sortText) < String(b.sortText) ? -1 : 1))
.map((s: any) => s.label);
};
it("puts the numeric column above the string ones", { timeout: 30000 }, async () => {
const labels = await completionsAt("numeric-rank-a", "SELECT approx_percentile_cont(");
expect(labels.indexOf("value")).toBeLessThan(labels.indexOf("availability_zone"));
});
it("leaves the order alone outside such a call", { timeout: 30000 }, async () => {
// Without this the first test passes for a trivial reason -- alphabetical
// order would put `availability_zone` first either way, so a ranking that
// never fires must be observably different here.
const labels = await completionsAt("numeric-rank-b", "SELECT ");
expect(labels.indexOf("availability_zone")).toBeLessThan(labels.indexOf("value"));
});
});
// ─── tmp/code.md section E — hiding the popup ────────────────────────────────
// disableSuggestionPopup synthesizes an Escape KeyboardEvent and dispatches it
// at the container div. Monaco has a command for this — the same one this file
// already calls four lines later to hide the widget — and a synthetic key event
// is a guess about monaco's internal key handling that nothing verifies. It
// also reaches anything else listening for Escape on the way up.
describe("hiding the suggestion popup uses monaco's own command", () => {
const store7 = createStore({ state: { theme: "light" } });
let spy: ReturnType<typeof vi.spyOn>;
afterAll(() => spy?.mockRestore());
it("triggers hideSuggestWidget and synthesizes no key event", { timeout: 30000 }, async () => {
const api = await import("monaco-editor/esm/vs/editor/editor.api");
const createFn = vi.mocked(api.editor.create);
const before = createFn.mock.calls.length;
spy = vi
.spyOn(document, "getElementById")
.mockImplementation(() => document.createElement("div"));
const wrapper = mount(CodeQueryEditor, {
props: { editorId: "hide-popup", language: "sql", query: "" },
global: { plugins: [store7] },
});
await vi.waitFor(() => expect(createFn.mock.calls.length).toBe(before + 1), {
timeout: 15000,
interval: 25,
});
const editor = createdEditors.at(-1)!;
editor.trigger.mockClear();
const dispatched: Event[] = [];
const dispatchSpy = vi
.spyOn(HTMLElement.prototype, "dispatchEvent")
.mockImplementation(function (this: HTMLElement, e: Event) {
dispatched.push(e);
return true;
});
try {
(wrapper.vm as any).disableSuggestionPopup();
} finally {
dispatchSpy.mockRestore();
}
expect(
editor.trigger.mock.calls.some((c: any[]) => c[1] === "hideSuggestWidget"),
"the widget was never asked to hide through monaco's command",
).toBe(true);
expect(
dispatched.filter((e) => e.type === "keydown"),
"a synthetic key event was still dispatched",
).toEqual([]);
});
});
// ─── tmp/code.md section E — the double-quote warning is WIRED ───────────────
// doubleQuoteWarnings.spec.ts proves the scan itself. This proves the editor
// calls it — the gap that shipped three times in this workstream (Alerts bound
// the wrong list, the SLO form never triggered the catalog load, Traces omitted
// a prop), every one of them a correct helper nobody had wired up.
describe("double-quote warnings reach the model", () => {
const store8 = createStore({ state: { theme: "light" } });
let spy: ReturnType<typeof vi.spyOn>;
afterAll(() => spy?.mockRestore());
const markersFor = async (query: string, editorId: string) => {
const api = await import("monaco-editor/esm/vs/editor/editor.api");
const createFn = vi.mocked(api.editor.create);
const setMarkers = vi.mocked((api.editor as any).setModelMarkers);
const before = createFn.mock.calls.length;
setMarkers.mockClear();
spy = vi
.spyOn(document, "getElementById")
.mockImplementation(() => document.createElement("div"));
mount(CodeQueryEditor, {
props: { editorId, language: "sql", query },
global: { plugins: [store8] },
});
await vi.waitFor(() => expect(createFn.mock.calls.length).toBe(before + 1), {
timeout: 15000,
interval: 25,
});
// Give the tail of setupEditor a chance to run after create resolves.
await vi.waitFor(() => expect(setMarkers.mock.calls.length).toBeGreaterThan(0), {
timeout: 15000,
interval: 25,
});
const calls = setMarkers.mock.calls.filter((c: any[]) => c[1] === "dq-validation");
return calls.at(-1)?.[2] ?? null;
};
it("publishes a marker for a double-quoted value", { timeout: 30000 }, async () => {
const markers = await markersFor(`SELECT * FROM t WHERE level = "error"`, "dq-real");
expect(markers, "the validation never ran at all").not.toBeNull();
expect(markers.length).toBe(1);
// `useI18n` is stubbed to an identity `t` above, so the marker carries the
// key rather than the resolved copy ("… use single quotes instead").
expect(markers[0].message).toBe("sqlEditor.diagnostics.doubleQuotedValue");
});
it("publishes NOTHING for the same text inside a comment", { timeout: 30000 }, async () => {
// The reported defect: a warning on SQL that is not SQL.
const markers = await markersFor(`-- level = "error"\nSELECT * FROM t`, "dq-comment");
expect(markers, "the validation never ran at all").not.toBeNull();
expect(markers).toEqual([]);
});
});
// ─── Phase 5 (tmp/code.md D10) — the list must be re-queried, not re-filtered ─
// Monaco only calls a provider again mid-word when the previous result said
// `incomplete` (suggestModel.js). Ours says complete, so while the widget is
// open monaco re-filters the list it already has — and the switch from fields
// to VALUES waits for a trigger character. Reproduced live: at `severity = `
// the widget still showed availability_zone/AWS/body; one quote later it showed
// INFO/ERROR/WARN, and a fresh provider call at either position returned
// values. The provider was right; the invalidation was wrong.
//
// It is also the mechanism D9 needs: values fetched from the server after the
// first call can only reach the list if there IS a next call.
describe("completion results invite monaco to ask again", () => {
const store9 = createStore({ state: { theme: "light" } });
let spy: ReturnType<typeof vi.spyOn>;
afterAll(() => spy?.mockRestore());
const providerFor = async (props: any) => {
const api = await import("monaco-editor/esm/vs/editor/editor.api");
const createFn = vi.mocked(api.editor.create);
const registerFn = vi.mocked(api.languages.registerCompletionItemProvider);
const before = createFn.mock.calls.length;
spy = vi
.spyOn(document, "getElementById")
.mockImplementation(() => document.createElement("div"));
mount(CodeQueryEditor, {
props: { language: "sql", query: "", ...props },
global: { plugins: [store9] },
});
await vi.waitFor(() => expect(createFn.mock.calls.length).toBe(before + 1), {
timeout: 15000,
interval: 25,
});
return {
provide: registerFn.mock.calls.filter((c) => c[0] === "sql").at(-1)![1]
.provideCompletionItems,
model: createdEditors.at(-1)!.getModel(),
};
};
const ask = async (provide: any, model: any, textUntilCursor: string) => {
model.getValueInRange.mockReturnValue(textUntilCursor);
model.getWordUntilPosition.mockReturnValue({ word: "", startColumn: 1, endColumn: 1 });
return await provide(model, { lineNumber: 1, column: 1 }, {}, {});
};
const FIELDS = [
{ name: "level", label: "level", kind: "Field", insertText: "level", detail: "Utf8" },
];
it("marks the ordinary list incomplete", { timeout: 30000 }, async () => {
const { provide, model } = await providerFor({
editorId: "incomplete-normal",
keywords: FIELDS,
fieldValueResolver: async () => [],
});
const result = await ask(provide, model, "SELECT * FROM logs WHERE ");
expect(result.incomplete, "monaco will re-filter this list instead of asking again").toBe(true);
});
it("marks the VALUE list incomplete too", { timeout: 30000 }, async () => {
// The values on screen may be a cold-cache partial answer, with the real
// ones still in flight; the next keystroke has to come back here.
const { provide, model } = await providerFor({
editorId: "incomplete-values",
keywords: FIELDS,
fieldValueResolver: async () => ["error"],
});
const result = await ask(provide, model, "SELECT * FROM logs WHERE level = ");
const rows = result.suggestions.filter((s: any) => !isHeader(s)).map(nameOf);
expect(rows.slice(0, 2)).toEqual(["'error'", "level"]);
expect(rows).toContain("spath");
expect(result.incomplete).toBe(true);
});
it("switches to values as soon as the operator is typed", { timeout: 30000 }, async () => {
// The user-visible half: no quote needed, no second trigger character.
const { provide, model } = await providerFor({
editorId: "incomplete-switch",
keywords: FIELDS,
fieldValueResolver: async (f: string) => (f === "level" ? ["error", "warn"] : []),
});
const before = await ask(provide, model, "SELECT * FROM logs WHERE lev");
expect(before.suggestions.filter((s: any) => !isHeader(s)).map(nameOf)).toContain("level");
const after = await ask(provide, model, "SELECT * FROM logs WHERE level = ");
expect(
after.suggestions
.filter((s: any) => !isHeader(s))
.map(nameOf)
.slice(0, 3),
).toEqual(["'error'", "'warn'", "level"]);
});
});
describe("P0-1 — the Enter fix follows ZO_EDITOR_SUGGEST_MODE", () => {
let spy: ReturnType<typeof vi.spyOn>;
afterAll(() => spy?.mockRestore());
const optionsFor = async (mode: string | undefined, language = "sql") => {
const api = await import("monaco-editor/esm/vs/editor/editor.api");
const createFn = vi.mocked(api.editor.create);
const before = createFn.mock.calls.length;
spy = vi
.spyOn(document, "getElementById")
.mockImplementation(() => document.createElement("div"));
const store = createStore({
state: { theme: "light", zoConfig: mode === undefined ? {} : { editor_suggest_mode: mode } },
});
mount(CodeQueryEditor, {
props: { editorId: `mode-${mode}-${language}`, language, query: "" },
global: { plugins: [store] },
});
await vi.waitFor(() => expect(createFn.mock.calls.length).toBe(before + 1), {
timeout: 15000,
interval: 25,
});
return createFn.mock.calls.at(-1)![1] as any;
};
it(
"automatic opens trigger-character menus unhighlighted and uses smart Enter",
{ timeout: 30000 },
async () => {
const opts = await optionsFor("automatic");
expect(opts.suggest).toEqual({ selectionMode: "whenQuickSuggestion" });
expect(opts.acceptSuggestionOnEnter).toBe("smart");
},
);
it("a missing flag is automatic", { timeout: 30000 }, async () => {
const opts = await optionsFor(undefined);
expect(opts.suggest).toEqual({ selectionMode: "whenQuickSuggestion" });
expect(opts.acceptSuggestionOnEnter).toBe("smart");
});
it("typing keeps the same Enter fix", { timeout: 30000 }, async () => {
const opts = await optionsFor("typing");
expect(opts.suggest).toEqual({ selectionMode: "whenQuickSuggestion" });
expect(opts.acceptSuggestionOnEnter).toBe("smart");
});
it("off is Monaco's own defaults, today's editor", { timeout: 30000 }, async () => {
const opts = await optionsFor("off");
expect(opts.suggest).toEqual({ selectionMode: "always" });
expect(opts.acceptSuggestionOnEnter).toBe("on");
expect(opts.quickSuggestions).toEqual({ other: "on", comments: "off", strings: "on" });
});
it(
"a mode that arrives after the editor mounted is applied to it",
{ timeout: 30000 },
async () => {
const api = await import("monaco-editor/esm/vs/editor/editor.api");
const createFn = vi.mocked(api.editor.create);
const before = createFn.mock.calls.length;
spy = vi
.spyOn(document, "getElementById")
.mockImplementation(() => document.createElement("div"));
const store = createStore({ state: { theme: "light", zoConfig: {} as any } });
mount(CodeQueryEditor, {
props: { editorId: "mode-late", language: "sql", query: "" },
global: { plugins: [store] },
});
await vi.waitFor(() => expect(createFn.mock.calls.length).toBe(before + 1), {
timeout: 15000,
interval: 25,
});
const editor = createdEditors.at(-1)!;
store.state.zoConfig = { editor_suggest_mode: "off" };
await vi.waitFor(() =>
expect(editor.updateOptions).toHaveBeenCalledWith({
suggest: { selectionMode: "always" },
acceptSuggestionOnEnter: "on",
}),
);
},
);
it("non-query editors are never touched", { timeout: 30000 }, async () => {
const opts = await optionsFor("automatic", "javascript");
expect(opts.suggest).toBeUndefined();
expect(opts.acceptSuggestionOnEnter).toBeUndefined();
});
});
describe("P0-4 — values awaited within a budget, guarded, never identifiers in a string", () => {
const storeV = createStore({ state: { theme: "light" } });
let spy: ReturnType<typeof vi.spyOn>;
afterAll(() => spy?.mockRestore());
const FIELDS = [
{ name: "level", label: "level", kind: "Field", insertText: "level", detail: "Utf8" },
{
name: "status_text",
label: "status_text",
kind: "Field",
insertText: "status_text",
detail: "Utf8",
},
{ name: "code", label: "code", kind: "Field", insertText: "code", detail: "Int64" },
];
const mountWith = async (editorId: string, fieldValueResolver: any, suggestions: any[] = []) => {
const api = await import("monaco-editor/esm/vs/editor/editor.api");
const createFn = vi.mocked(api.editor.create);
const registerFn = vi.mocked(api.languages.registerCompletionItemProvider);
const before = createFn.mock.calls.length;
spy = vi
.spyOn(document, "getElementById")
.mockImplementation(() => document.createElement("div"));
const wrapper = mount(CodeQueryEditor, {
props: {
editorId,
language: "sql",
query: "",
keywords: FIELDS,
suggestions,
fieldValueResolver,
},
global: { plugins: [storeV] },
});
await vi.waitFor(() => expect(createFn.mock.calls.length).toBe(before + 1), {
timeout: 15000,
interval: 25,
});
const editor = createdEditors.at(-1)!;
const registration = registerFn.mock.calls.filter((c) => c[0] === "sql").at(-1)![1];
return {
provide: registration.provideCompletionItems,
// Monaco resolves a row as it gains focus; a row whose resolve never settles is one the key rules step over.
steppedOver: async (item: any) =>
!(await Promise.race([
Promise.resolve(registration.resolveCompletionItem(item)).then(() => true),
new Promise((r) => setTimeout(() => r(false), 30)),
])),
model: editor.getModel(),
editor,
wrapper,
};
};
const TYPED_SPACE = { triggerKind: 1, triggerCharacter: " " };
const CTRL_SPACE = { triggerKind: 0 };
const ask = (
provide: any,
model: any,
text: string,
token: any = {},
context: any = TYPED_SPACE,
) => {
model.getValueInRange.mockReturnValue(text);
model.getWordUntilPosition.mockReturnValue({ word: "", startColumn: 1, endColumn: 1 });
return provide(model, { lineNumber: 1, column: text.length + 1 }, context, token);
};
// Group header rows are layout, not choices, so most assertions read the rows under them.
const labels = (r: any) => r.suggestions.filter((s: any) => !isHeader(s)).map(nameOf);
const noLookup = () =>
vi.fn(async () =>
Promise.reject(Object.assign(new Error("no lookup"), { name: "NoValueLookup" })),
);
it("never claims there are no values when no lookup ran", { timeout: 30000 }, async () => {
const { provide, model } = await mountWith("v-nolookup", noLookup());
const result = await ask(provide, model, "SELECT * FROM logs WHERE level = ");
expect(labels(result)).toEqual(["level", "status_text", "code"]);
});
it("does not look values up for a word that is not a field", { timeout: 30000 }, async () => {
const resolver = vi.fn(async () => ["x"]);
const { provide, model } = await mountWith("v-unknown", resolver);
await ask(provide, model, "SELECT * FROM logs WHERE nope = ");
expect(resolver).not.toHaveBeenCalled();
});
it("offers nothing for `key=` written inside a string", { timeout: 30000 }, async () => {
const resolver = vi.fn(async () => ["bob"]);
const { provide, model } = await mountWith("v-instring", resolver);
const result = await ask(provide, model, "SELECT * FROM logs WHERE match_all('level=");
expect(result.suggestions).toEqual([]);
expect(resolver).not.toHaveBeenCalled();
});
it("in a highlighted menu a status row is never row 1", { timeout: 30000 }, async () => {
const { provide, model, steppedOver } = await mountWith(
"v-hl-empty",
vi.fn(async () => []),
);
const result = await ask(provide, model, "SELECT * FROM logs WHERE level = ", {}, CTRL_SPACE);
const status = rowsOf(result).find((s: any) => nameOf(s).includes("valuesEmpty"));
expect(await steppedOver(status), "Tab and the arrows step over it").toBe(true);
expect(status.preselect).toBeUndefined();
const field = rowsOf(result).find((s: any) => nameOf(s) === "level");
expect(await steppedOver(field)).toBe(false);
expect(field.preselect, "the highlight starts on a real row").toBe(true);
});
it("in a highlighted menu a slow lookup shows no row at all", { timeout: 30000 }, async () => {
const { provide, model } = await mountWith("v-hl-slow", delayed(2500, ["error"]));
const result = await ask(provide, model, "SELECT * FROM logs WHERE level = ", {}, CTRL_SPACE);
expect(result.suggestions).toEqual([]);
});
const delayed = (ms: number, values: string[]) =>
vi.fn(() => new Promise<string[]>((r) => setTimeout(() => r(values), ms)));
it("a fast resolver puts values above fields", { timeout: 30000 }, async () => {
const { provide, model } = await mountWith("v-fast", delayed(60, ["error", "warn"]));
const result = await ask(provide, model, "SELECT * FROM logs WHERE level = ");
expect(labels(result)).toEqual(["'error'", "'warn'", "level", "status_text", "code"]);
expect(result.suggestions.every((s: any) => s.kind !== 1 && s.kind !== 17)).toBe(true);
});
it(
"a resolver slower than the budget gives one row that inserts nothing",
{ timeout: 30000 },
async () => {
const { provide, model, steppedOver } = await mountWith("v-slow", delayed(2500, ["error"]));
const started = Date.now();
const result = await ask(provide, model, "SELECT * FROM logs WHERE level = ");
expect(Date.now() - started).toBeLessThan(2000);
const [row] = rowsOf(result);
expect(rowsOf(result)).toHaveLength(1);
expect(row.label.label).toContain("valuesLoading");
expect(row.label.description).toContain("valuesLoadingHint");
expect(row.kind).toBe(10);
expect(row.insertText).toBe("");
expect(await steppedOver(row)).toBe(true);
},
);
it("an empty answer says so, and still offers the fields", { timeout: 30000 }, async () => {
const { provide, model } = await mountWith(
"v-empty",
vi.fn(async () => []),
);
const result = await ask(provide, model, "SELECT * FROM logs WHERE level = ");
expect(nameOf(rowsOf(result)[0])).toContain("valuesEmpty");
expect(rowsOf(result)[0].insertText).toBe("");
expect(labels(result)).toContain("level");
});
it("a failed lookup says it could not load", { timeout: 30000 }, async () => {
const { provide, model } = await mountWith(
"v-fail",
vi.fn(async () => Promise.reject(new Error("x"))),
);
const result = await ask(provide, model, "SELECT * FROM logs WHERE level = ");
expect(nameOf(rowsOf(result)[0])).toContain("valuesFailed");
});
it("drops a late answer after Esc", { timeout: 30000 }, async () => {
const { provide, model, editor } = await mountWith("v-esc", delayed(150, ["error"]));
const onKey = editor.onKeyDown.mock.calls.at(-1)![0];
const pending = ask(provide, model, "SELECT * FROM logs WHERE level = ");
onKey({ keyCode: 9 });
expect((await pending).suggestions).toEqual([]);
});
it("drops a late answer after blur", { timeout: 30000 }, async () => {
const { provide, model, editor } = await mountWith("v-blur", delayed(150, ["error"]));
const pending = ask(provide, model, "SELECT * FROM logs WHERE level = ");
editor.hasTextFocus.mockReturnValue(false);
expect((await pending).suggestions).toEqual([]);
});
it("drops it when Monaco cancelled the request", { timeout: 30000 }, async () => {
const { provide, model } = await mountWith("v-token", delayed(50, ["error"]));
const result = await ask(provide, model, "SELECT * FROM logs WHERE level = ", {
isCancellationRequested: true,
});
expect(result.suggestions).toEqual([]);
});
it("offers values only, never fields, inside an open string", { timeout: 30000 }, async () => {
const { provide, model } = await mountWith(
"v-quote",
vi.fn(async () => ["error"]),
);
expect(labels(await ask(provide, model, "SELECT * FROM logs WHERE level = 'e"))).toEqual([
"'error'",
]);
});
it("ILIKE ' offers values and no fields", { timeout: 30000 }, async () => {
const { provide, model } = await mountWith(
"v-ilike",
vi.fn(async () => ["warn"]),
);
const result = await ask(provide, model, "SELECT * FROM logs WHERE level ILIKE '");
expect(labels(result)).toEqual(["'%warn%'"]);
expect(rowsOf(result)[0].insertText).toBe("%warn%'");
});
it("a bare = 50 keeps value help", { timeout: 30000 }, async () => {
const resolver = vi.fn(async () => ["500", "404"]);
const { provide, model } = await mountWith("v-bare", resolver);
const result = await ask(provide, model, "SELECT * FROM logs WHERE code = 50");
expect(resolver).toHaveBeenCalledWith("code", expect.anything());
// The provider filters by the typed token, so only values that start with it remain.
expect(labels(result)).toEqual(["500"]);
expect(rowsOf(result)[0].insertText).toBe("500");
expect(rowsOf(result)[0].range.startColumn).toBe("SELECT * FROM logs WHERE code = ".length + 1);
});
it("quotes by column type: a Utf8 200 is quoted", { timeout: 30000 }, async () => {
const { provide, model } = await mountWith(
"v-type",
vi.fn(async () => ["200"]),
);
const result = await ask(provide, model, "SELECT * FROM logs WHERE status_text = ");
expect(rowsOf(result)[0].insertText).toBe("'200'");
});
it("skips the lookup when the typed text is a column", { timeout: 30000 }, async () => {
const resolver = vi.fn(async () => ["x"]);
const { provide, model } = await mountWith("v-col", resolver);
const result = await ask(provide, model, "SELECT * FROM a JOIN b ON a.level = code");
expect(resolver).not.toHaveBeenCalled();
expect(labels(result)).toContain("code");
});
it(
"a full list says it may be cut short, in a row that inserts nothing",
{ timeout: 30000 },
async () => {
const many = Array.from({ length: 10 }, (_, i) => `v${i}`);
const { provide, model } = await mountWith(
"v-cap",
vi.fn(async () => many),
);
const result = await ask(provide, model, "SELECT * FROM logs WHERE level = ");
const values = rowsOf(result).filter((s: any) => String(s.sortText).startsWith("0"));
const last = values.at(-1);
expect(nameOf(last)).toContain("valuesCap");
expect(last.insertText).toBe("");
expect(last.kind).toBe(26);
},
);
it("orders values the same way every time", { timeout: 30000 }, async () => {
const { provide, model } = await mountWith(
"v-order",
vi.fn(async () => ["warn", "error", "info"]),
);
const result = await ask(provide, model, "SELECT * FROM logs WHERE level = ");
expect(labels(result).slice(0, 3)).toEqual(["'error'", "'info'", "'warn'"]);
});
it(
"inside a quote with no values, says so after the caret and never as a row",
{
timeout: 30000,
},
async () => {
const { provide, model, editor } = await mountWith(
"v-hint",
vi.fn(async () => []),
);
const result = await ask(provide, model, "SELECT * FROM logs WHERE level = 'zz");
expect(result.suggestions).toEqual([]);
const set = editor.createDecorationsCollection.mock.results.at(-1).value.set;
expect(JSON.stringify(set.mock.calls.at(-1)[0])).toContain("valuesEmpty");
},
);
it(
"a value typed across a line break never gets a range before column 1",
{ timeout: 30000 },
async () => {
const { provide, model } = await mountWith(
"v-multiline",
vi.fn(async () => ["\nerror"]),
);
model.getValueInRange.mockReturnValue("SELECT * FROM t WHERE level = '\ner");
model.getWordUntilPosition.mockReturnValue({ word: "er", startColumn: 1, endColumn: 3 });
const result = await provide(model, { lineNumber: 2, column: 3 }, CTRL_SPACE, {});
for (const row of result.suggestions) {
if (row.range?.startColumn !== undefined)
expect(row.range.startColumn).toBeGreaterThanOrEqual(1);
if (row.range?.insert) expect(row.range.insert.startColumn).toBeGreaterThanOrEqual(1);
}
},
);
describe("functions after the columns in a value position", () => {
const CATALOG = [
{ name: "lower", label: "lower", kind: "Function", insertText: "lower(x)" },
{ name: "now", label: "now", kind: "Function", insertText: "now()" },
{ name: "count", label: "count", kind: "Function", insertText: "count(x)", aggregate: true },
{ name: "CAST", label: "CAST", kind: "Keyword", insertText: "CAST(x AS t)" },
{ name: "SELECT", label: "SELECT", kind: "Keyword", insertText: "SELECT" },
];
const headersOf = (r: any) => r.suggestions.filter(isHeader).map(nameOf);
const resolve = () => vi.fn(async () => ["error"]);
it(
"a comparison offers values, then columns, then functions, never an aggregate or a keyword",
{ timeout: 30000 },
async () => {
const { provide, model } = await mountWith("v-fn-order", resolve(), CATALOG);
const result = await ask(provide, model, "SELECT * FROM logs WHERE level = ");
expect(labels(result)).toEqual([
"'error'",
"level",
"status_text",
"code",
"lower",
"now",
"CAST",
]);
expect(headersOf(result)).toEqual([
"sqlEditor.completion.groupValues",
"sqlEditor.completion.groupFields",
"sqlEditor.completion.groupFunctions",
]);
},
);
it("code = CAS offers CAST alone, over the typed letters", { timeout: 30000 }, async () => {
const { provide, model } = await mountWith("v-fn-cast", resolve(), CATALOG);
const text = "SELECT * FROM logs WHERE code = CAS";
const result = await ask(provide, model, text, {}, CTRL_SPACE);
expect(labels(result)).toEqual(["CAST"]);
expect(headersOf(result)).toEqual(["sqlEditor.completion.groupFunctions"]);
const cast = result.suggestions.find((s: any) => nameOf(s) === "CAST");
expect(cast.range.startColumn).toBe(text.length + 1 - "CAS".length);
});
it("inside an open quote only values are offered", { timeout: 30000 }, async () => {
const { provide, model } = await mountWith("v-fn-quote", resolve(), CATALOG);
const result = await ask(provide, model, "SELECT * FROM logs WHERE level = '");
expect(labels(result)).toEqual(["'error'"]);
});
it(
"an IN list offers values, then columns, then functions after each comma",
{ timeout: 30000 },
async () => {
const { provide, model } = await mountWith(
"v-fn-in",
vi.fn(async () => ["error", "warn"]),
CATALOG,
);
const result = await ask(provide, model, "SELECT * FROM logs WHERE level IN ('error', ");
expect(labels(result)).toEqual([
"'warn'",
"level",
"status_text",
"code",
"lower",
"now",
"CAST",
]);
},
);
it("LIKE takes a pattern, so it offers no functions", { timeout: 30000 }, async () => {
const { provide, model } = await mountWith("v-fn-like", resolve(), CATALOG);
const result = await ask(provide, model, "SELECT * FROM logs WHERE level LIKE ");
expect(labels(result)).not.toContain("lower");
});
});
it(
"quotes a value by the column type current when the lookup returns",
{ timeout: 30000 },
async () => {
let finish!: (values: string[]) => void;
const resolver = vi.fn(() => new Promise<string[]>((resolve) => (finish = resolve)));
const { provide, model, wrapper } = await mountWith("v-type-switch", resolver);
const pending = ask(provide, model, "SELECT * FROM logs WHERE code = ");
await vi.waitFor(() => expect(resolver).toHaveBeenCalled());
await wrapper.setProps({
keywords: FIELDS.map((f) => (f.name === "code" ? { ...f, detail: "Utf8" } : f)),
});
finish(["service-unavailable"]);
const value = (await pending).suggestions.find(
(s: any) => nameOf(s) === "'service-unavailable'",
);
expect(value?.insertText).toBe("'service-unavailable'");
},
);
it(
"a qualified column after = is filtered by the name after the dot",
{ timeout: 30000 },
async () => {
const { provide, model } = await mountWith(
"v-qualified",
vi.fn(async () => ["x"]),
);
const text = "SELECT * FROM a JOIN b ON a.level = b.le";
model.getValueInRange.mockReturnValue(text);
model.getWordUntilPosition.mockReturnValue({
word: "le",
startColumn: text.length - 1,
endColumn: text.length + 1,
});
const result = await provide(
model,
{ lineNumber: 1, column: text.length + 1 },
CTRL_SPACE,
{},
);
expect(labels(result)).toEqual(["level"]);
},
);
it(
"a retry at the same caret that finds values clears the no-values hint",
{ timeout: 30000 },
async () => {
const resolver = vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce(["error"]);
const { provide, model, editor } = await mountWith("v-hint-retry", resolver);
const text = "SELECT * FROM logs WHERE level = '";
await ask(provide, model, text, {}, CTRL_SPACE);
const hint = editor.createDecorationsCollection.mock.results.at(-1).value;
expect(JSON.stringify(hint.set.mock.calls.at(-1)[0])).toContain("valuesEmpty");
hint.clear.mockClear();
const retry = await ask(provide, model, text, {}, CTRL_SPACE);
expect(labels(retry)).toContain("'error'");
expect(hint.clear).toHaveBeenCalled();
},
);
it(
"typing forward past the budget still says it is loading, after the caret",
{
timeout: 30000,
},
async () => {
const { provide, model, editor } = await mountWith("v-forward", delayed(2500, ["error"]));
const result = await ask(
provide,
model,
"SELECT * FROM logs WHERE level = 'e",
{},
CTRL_SPACE,
);
expect(result.suggestions).toEqual([]);
const set = editor.createDecorationsCollection.mock.results.at(-1).value.set;
expect(JSON.stringify(set.mock.calls.at(-1)[0])).toContain("valuesLoading");
},
);
// A typed quote reopens its menu as an auto Invoke, which highlights the preselected first value.
const typeQuote = async (editor: any, model: any, text: string) => {
model.getValueInRange.mockReturnValue(text);
editor.getPosition.mockReturnValue({ lineNumber: 1, column: text.length + 1 });
editor.trigger.mockClear();
editor.onDidChangeModelContent.mock.calls.at(-1)![0]({ changes: [{ text: "''" }] });
await new Promise((r) => setTimeout(r, 20));
return editor.trigger.mock.calls.map((c: any[]) => [c[1], c[2]]);
};
it(
"a typed quote in a value position reopens the menu so the first value is highlighted",
{ timeout: 30000 },
async () => {
const { model, editor } = await mountWith(
"v-quote-enter",
vi.fn(async () => ["error"]),
);
expect(await typeQuote(editor, model, "SELECT * FROM logs WHERE level = '")).toEqual([
["hideSuggestWidget", {}],
["editor.action.triggerSuggest", { auto: true }],
]);
},
);
it(
"a key typed after the quote voids the reopen, so a later menu never inherits its highlight",
{ timeout: 30000 },
async () => {
const { model, editor } = await mountWith(
"v-quote-late",
vi.fn(async () => ["error"]),
);
let version = 1;
model.getVersionId = vi.fn(() => version);
model.getValueInRange.mockReturnValue("SELECT * FROM logs WHERE level = '");
editor.getPosition.mockReturnValue({ lineNumber: 1, column: 35 });
editor.trigger.mockClear();
editor.onDidChangeModelContent.mock.calls.at(-1)![0]({ changes: [{ text: "''" }] });
version = 2;
await new Promise((r) => setTimeout(r, 20));
expect(editor.trigger.mock.calls.map((c: any[]) => c[1])).not.toContain(
"editor.action.triggerSuggest",
);
},
);
it(
"Esc or a blur before the queued reopen runs leaves the menu closed",
{ timeout: 30000 },
async () => {
const { model, editor } = await mountWith(
"v-quote-dismissed",
vi.fn(async () => ["error"]),
);
const text = "SELECT * FROM logs WHERE level = '";
model.getValueInRange.mockReturnValue(text);
editor.getPosition.mockReturnValue({ lineNumber: 1, column: text.length + 1 });
const typeThen = async (dismiss: () => void) => {
editor.trigger.mockClear();
editor.onDidChangeModelContent.mock.calls.at(-1)![0]({ changes: [{ text: "''" }] });
dismiss();
await new Promise((r) => setTimeout(r, 20));
return editor.trigger.mock.calls.map((c: any[]) => c[1]);
};
const escape = () => editor.onKeyDown.mock.calls.at(-1)![0]({ keyCode: 9 });
expect(await typeThen(escape)).not.toContain("editor.action.triggerSuggest");
editor.hasTextFocus.mockReturnValue(false);
expect(await typeThen(() => {})).not.toContain("editor.action.triggerSuggest");
editor.hasTextFocus.mockReturnValue(true);
},
);
it("a quote outside a value position reopens nothing", { timeout: 30000 }, async () => {
const { model, editor } = await mountWith(
"v-quote-none",
vi.fn(async () => ["error"]),
);
expect(await typeQuote(editor, model, "SELECT * FROM logs WHERE match_all('")).toEqual([]);
});
it(
"only real rows are preselected, and only in a menu no trigger character opened",
{ timeout: 30000 },
async () => {
const { provide, model } = await mountWith(
"v-preselect",
vi.fn(async () => ["error"]),
);
const invoked = await ask(
provide,
model,
"SELECT * FROM logs WHERE level = '",
{},
CTRL_SPACE,
);
expect(rowsOf(invoked).every((s: any) => s.preselect)).toBe(true);
expect(invoked.suggestions.filter(isHeader).every((s: any) => !s.preselect)).toBe(true);
const typed = await ask(
provide,
model,
"SELECT * FROM logs WHERE level = '",
{},
{
triggerKind: 1,
triggerCharacter: "'",
},
);
expect(typed.suggestions.some((s: any) => s.preselect)).toBe(false);
},
);
it(
"a menu opened by a space outside a quote stays unhighlighted",
{ timeout: 30000 },
async () => {
const { provide, model, editor } = await mountWith(
"v-space-enter",
vi.fn(async () => ["error"]),
);
editor.trigger.mockClear();
await ask(provide, model, "SELECT * FROM logs WHERE level = ");
await new Promise((r) => setTimeout(r, 200));
expect(editor.trigger.mock.calls.some((c: any[]) => c[1] === "focusSuggestion")).toBe(false);
},
);
it(
"never offers identifiers inside a string that is not a value",
{ timeout: 30000 },
async () => {
const { provide, model } = await mountWith(
"v-str",
vi.fn(async () => ["x"]),
);
expect(
(await ask(provide, model, "SELECT * FROM logs WHERE match_all('err")).suggestions,
).toEqual([]);
},
);
describe("an IN list continues after each comma", () => {
const resolve = () => vi.fn(async () => ["checkout", "cart", "search"]);
it(
"a bare letter after a comma offers the values, quoted, over the letter",
{ timeout: 30000 },
async () => {
const { provide, model } = await mountWith("v-in-letter", resolve());
const text = "SELECT * FROM logs WHERE level IN ('checkout',c";
const result = await ask(provide, model, text, {}, CTRL_SPACE);
const values = result.suggestions.filter((s: any) => nameOf(s).startsWith("'"));
expect(values.map(nameOf)).toEqual(["'cart'"]);
expect(values[0].range).toMatchObject({
startColumn: text.length,
endColumn: text.length + 1,
});
expect(labels(result)).toEqual(["'cart'", "code"]);
expect(labels(result)).not.toContain("count");
},
);
it("never offers a value the list already has", { timeout: 30000 }, async () => {
const { provide, model } = await mountWith("v-in-chosen", resolve());
const result = await ask(provide, model, "SELECT * FROM logs WHERE level IN ('checkout', ");
expect(labels(result)).toContain("'cart'");
expect(labels(result)).not.toContain("'checkout'");
});
it("a numeric list offers bare numbers", { timeout: 30000 }, async () => {
const { provide, model } = await mountWith(
"v-in-num",
vi.fn(async () => ["200", "404"]),
);
const result = await ask(provide, model, "SELECT * FROM logs WHERE code IN (200, ");
const values = result.suggestions.filter((s: any) => /^\d+$/.test(nameOf(s)));
expect(values.map((s: any) => s.insertText)).toEqual(["404"]);
});
it(
"falls back to the fields, with no status row, once every value is chosen",
{ timeout: 30000 },
async () => {
const { provide, model } = await mountWith("v-in-all", resolve());
const result = await ask(
provide,
model,
"SELECT * FROM logs WHERE level IN ('checkout', 'cart', 'search', ",
);
expect(labels(result)).toEqual(["level", "status_text", "code"]);
},
);
});
});
describe("P0-4 — PromQL label values come from the provider", () => {
let spy: ReturnType<typeof vi.spyOn>;
afterAll(() => spy?.mockRestore());
it(
"drops label values when the organization changes while they load",
{ timeout: 30000 },
async () => {
const api = await import("monaco-editor/esm/vs/editor/editor.api");
const { resolvePromqlLabelValues } = await import("@/composables/usePromqlSuggestions");
const createFn = vi.mocked(api.editor.create);
const registerFn = vi.mocked(api.languages.registerCompletionItemProvider);
const before = createFn.mock.calls.length;
spy = vi
.spyOn(document, "getElementById")
.mockImplementation(() => document.createElement("div"));
const store = createStore({
state: { theme: "light", selectedOrganization: { identifier: "old-org" } },
});
mount(CodeQueryEditor, {
props: { editorId: "promql-org-switch", language: "promql", query: "", keywords: [] },
global: { plugins: [store] },
});
await vi.waitFor(() => expect(createFn.mock.calls.length).toBe(before + 1), {
timeout: 15000,
interval: 25,
});
const model = createdEditors.at(-1)!.getModel();
const provide = registerFn.mock.calls.filter((c) => c[0] === "promql").at(-1)![1]
.provideCompletionItems;
let finish!: (values: string[]) => void;
vi.mocked(resolvePromqlLabelValues).mockImplementationOnce(
() => new Promise<string[]>((resolve) => (finish = resolve)),
);
const text = 'up{job="';
model.getValueInRange.mockReturnValue(text);
model.getLineContent.mockReturnValue(`${text}"}`);
model.getWordUntilPosition.mockReturnValue({ word: "", startColumn: 1, endColumn: 1 });
const pending = provide(model, { lineNumber: 1, column: text.length + 1 }, {}, {});
await vi.waitFor(() => expect(finish).toBeDefined());
store.state.selectedOrganization = { identifier: "new-org" };
finish(["old-org-only-service"]);
expect((await pending).suggestions.map(nameOf)).not.toContain('"old-org-only-service"');
},
);
it(
"awaits label values in a value position and closes the quote",
{ timeout: 30000 },
async () => {
const api = await import("monaco-editor/esm/vs/editor/editor.api");
const createFn = vi.mocked(api.editor.create);
const registerFn = vi.mocked(api.languages.registerCompletionItemProvider);
const before = createFn.mock.calls.length;
spy = vi
.spyOn(document, "getElementById")
.mockImplementation(() => document.createElement("div"));
const store = createStore({
state: { theme: "light", selectedOrganization: { identifier: "myorg" } },
});
mount(CodeQueryEditor, {
props: { editorId: "promql-values", language: "promql", query: "", keywords: [] },
global: { plugins: [store] },
});
await vi.waitFor(() => expect(createFn.mock.calls.length).toBe(before + 1), {
timeout: 15000,
interval: 25,
});
const model = createdEditors.at(-1)!.getModel();
const provide = registerFn.mock.calls.filter((c) => c[0] === "promql").at(-1)![1]
.provideCompletionItems;
const text = 'cpu_utilization_percent{service="';
model.getValueInRange.mockReturnValue(text);
model.getLineContent.mockReturnValue(`${text}"}`);
model.getWordUntilPosition.mockReturnValue({ word: "", startColumn: 1, endColumn: 1 });
const result = await provide(model, { lineNumber: 1, column: text.length + 1 }, {}, {});
expect(result.suggestions.filter((s: any) => !isHeader(s)).map(nameOf)).toEqual([
'"api-gateway"',
'"chat-service"',
]);
expect(result.suggestions[0].insertText).toBe('api-gateway"');
expect(result.suggestions[0].range.endColumn).toBe(text.length + 2);
const bare = "cpu_utilization_percent{service=api-g";
model.getValueInRange.mockReturnValue(bare);
model.getLineContent.mockReturnValue(`${bare}}`);
const typedBare = await provide(model, { lineNumber: 1, column: bare.length + 1 }, {}, {});
expect(typedBare.suggestions[0].insertText).toBe('"api-gateway"');
expect(typedBare.suggestions[0].range.startColumn).toBe(bare.length + 1 - "api-g".length);
const comparison = "rate(http_total[5m]) =";
model.getValueInRange.mockReturnValue(comparison);
model.getLineContent.mockReturnValue(comparison);
const noMenu = await provide(
model,
{ lineNumber: 1, column: comparison.length + 1 },
{ triggerKind: 1, triggerCharacter: "=" },
{},
);
expect(noMenu.suggestions).toEqual([]);
},
);
});
// Runs after the PromQL block: each language's provider keeps the store of the first editor that registered it.
describe("value quotes, late values, the empty-values copy and the typed prefix", () => {
let spy: ReturnType<typeof vi.spyOn>;
afterAll(() => spy?.mockRestore());
const FIELDS = [
{ name: "level", label: "level", kind: "Field", insertText: "level", detail: "Utf8" },
];
const mountWith = async (editorId: string, props: any = {}, zoConfig: any = {}) => {
const api: any = await import("monaco-editor/esm/vs/editor/editor.api");
const createFn = vi.mocked(api.editor.create);
const registerFn = vi.mocked(api.languages.registerCompletionItemProvider);
const before = createFn.mock.calls.length;
spy = vi
.spyOn(document, "getElementById")
.mockImplementation(() => document.createElement("div"));
const wrapper = mount(CodeQueryEditor, {
props: { editorId, language: "sql", query: "", keywords: FIELDS, ...props },
global: { plugins: [createStore({ state: { theme: "light", zoConfig } })] },
});
await vi.waitFor(() => expect(createFn.mock.calls.length).toBe(before + 1), {
timeout: 15000,
interval: 25,
});
const editor = createdEditors.at(-1)!;
const language = props.language ?? "sql";
const registration = registerFn.mock.calls.filter((c: any) => c[0] === language).at(-1)![1];
return {
api,
editor,
wrapper,
model: editor.getModel(),
provide: registration.provideCompletionItems,
};
};
// Puts the caret at the end of `before`, with `after` following it on the same line.
const placeCaret = (editor: any, model: any, before: string, after = "") => {
model.getValueInRange.mockReturnValue(before);
model.getLineContent.mockReturnValue(before + after);
model.getWordUntilPosition.mockReturnValue({ word: "", startColumn: 1, endColumn: 1 });
editor.getPosition.mockReturnValue({ lineNumber: 1, column: before.length + 1 });
};
const TYPED_SPACE = { triggerKind: 1, triggerCharacter: " " };
const ask = (provide: any, editor: any, model: any, text: string) => {
placeCaret(editor, model, text);
return provide(model, { lineNumber: 1, column: text.length + 1 }, TYPED_SPACE, {});
};
const delayed = (ms: number, values: string[]) =>
vi.fn(
(_field: string, _options?: any) =>
new Promise<string[]>((r) => setTimeout(() => r(values), ms)),
);
const triggers = (editor: any) =>
editor.trigger.mock.calls.filter((c: any[]) => c[1] === "editor.action.triggerSuggest");
describe("Escape as the keyboard exit", () => {
it("binds Escape only while no widget or selection owns it", { timeout: 30000 }, async () => {
const { api, editor } = await mountWith("q-escape");
const action = editor.addAction.mock.calls
.map((c: any[]) => c[0])
.find((a: any) => a.id === "o2.editor.escapeToNextControl");
expect(action.keybindings).toEqual([api.KeyCode.Escape]);
for (const guard of [
"!suggestWidgetVisible",
"!findWidgetVisible",
"!inSnippetMode",
"!inlineSuggestionVisible",
"!editorHasSelection",
]) {
expect(action.precondition).toContain(guard);
}
});
});
describe("Enter in an unclosed value quote", () => {
const enterAction = (editor: any) =>
editor.addAction.mock.calls
.map((c: any[]) => c[0])
.find((a: any) => a.id === "o2.suggest.closeValueQuote");
const quoteKey = (editor: any) => {
const index = editor.createContextKey.mock.calls.findIndex(
(c: any[]) => c[0] === "o2InValueQuote",
);
return editor.createContextKey.mock.results[index].value;
};
const moveCaret = (editor: any) => editor.onDidChangeCursorPosition.mock.calls.at(-1)![0]();
it("binds Enter only while no suggest row is focused", { timeout: 30000 }, async () => {
const { api, editor } = await mountWith("q-bind");
const action = enterAction(editor);
expect(action.keybindings).toEqual([api.KeyCode.Enter]);
expect(action.precondition).toContain("o2InValueQuote");
expect(action.precondition).toContain("!suggestWidgetHasFocusedSuggestion");
});
it(
"steps over an auto-closed quote instead of breaking the string",
{ timeout: 30000 },
async () => {
const { editor, model } = await mountWith("q-closer");
const before = "SELECT * FROM logs WHERE level = 'zz";
placeCaret(editor, model, before, "'");
enterAction(editor).run();
expect(editor.setPosition).toHaveBeenCalledWith({
lineNumber: 1,
column: before.length + 2,
});
expect(editor.executeEdits).not.toHaveBeenCalled();
expect(editor.trigger).toHaveBeenCalledWith("o2", "hideSuggestWidget", {});
},
);
it("inserts the closing quote when none follows", { timeout: 30000 }, async () => {
const { editor, model } = await mountWith("q-insert");
const before = "SELECT * FROM logs WHERE level = 'zz";
placeCaret(editor, model, before, " AND x = 1");
enterAction(editor).run();
const [, edits, selection] = editor.executeEdits.mock.calls.at(-1)!;
expect(edits[0].text).toBe("'");
expect(edits[0].range.args).toEqual([1, before.length + 1, 1, before.length + 1]);
expect(selection[0].args).toEqual([1, before.length + 2, 1, before.length + 2]);
});
it("closes a PromQL label value with a double quote", { timeout: 30000 }, async () => {
const { editor, model } = await mountWith("q-promql", { language: "promql" });
const before = 'up{job="zz';
placeCaret(editor, model, before);
enterAction(editor).run();
expect(editor.executeEdits.mock.calls.at(-1)![1][0].text).toBe('"');
});
it("claims Enter only inside a value quote", { timeout: 30000 }, async () => {
const { editor, model } = await mountWith("q-key");
const key = quoteKey(editor);
placeCaret(editor, model, "SELECT * FROM logs WHERE level = 'zz", "'");
moveCaret(editor);
expect(key.set).toHaveBeenLastCalledWith(true);
placeCaret(editor, model, "SELECT * FROM logs WHERE level = 'zz' ");
moveCaret(editor);
expect(key.set).toHaveBeenLastCalledWith(false);
placeCaret(editor, model, "SELECT * FROM logs WHERE match_all('zz");
moveCaret(editor);
expect(key.set).toHaveBeenLastCalledWith(false);
});
it("leaves Enter alone in off mode", { timeout: 30000 }, async () => {
const { editor, model } = await mountWith("q-off", {}, { editor_suggest_mode: "off" });
placeCaret(editor, model, "SELECT * FROM logs WHERE level = 'zz", "'");
moveCaret(editor);
expect(quoteKey(editor).set).toHaveBeenLastCalledWith(false);
});
});
describe("a menu left on Still loading", () => {
it("asks Monaco again once the lookup lands", { timeout: 30000 }, async () => {
const resolver = delayed(1300, ["error"]);
const { editor, model, provide } = await mountWith("r-once", {
fieldValueResolver: resolver,
});
const result = await ask(provide, editor, model, "SELECT * FROM logs WHERE level = ");
expect(nameOf(rowsOf(result)[0])).toContain("valuesLoading");
expect(triggers(editor)).toHaveLength(0);
await vi.waitFor(() => expect(triggers(editor)).toHaveLength(1), { timeout: 3000 });
expect(triggers(editor)[0]).toEqual(["o2", "editor.action.triggerSuggest", { auto: true }]);
const ids = editor.trigger.mock.calls.map((c: any[]) => c[1]);
expect(ids.indexOf("hideSuggestWidget"), "closed first, or Monaco ignores the trigger").toBe(
ids.indexOf("editor.action.triggerSuggest") - 1,
);
});
it("never refills twice for the same lookup", { timeout: 30000 }, async () => {
const resolver = delayed(1100, ["error"]);
const { editor, model, provide } = await mountWith("r-twice", {
fieldValueResolver: resolver,
});
await ask(provide, editor, model, "SELECT * FROM logs WHERE level = ");
await vi.waitFor(() => expect(triggers(editor)).toHaveLength(1), { timeout: 3000 });
await ask(provide, editor, model, "SELECT * FROM logs WHERE level = ");
await new Promise((r) => setTimeout(r, 400));
expect(triggers(editor)).toHaveLength(1);
});
it(
"refills a second slow lookup after the range changes at the same caret",
{ timeout: 30000 },
async () => {
const resolver = delayed(1300, ["error"]);
const { editor, model, provide, wrapper } = await mountWith("r-range", {
fieldValueResolver: resolver,
valuesTimeRange: { startTime: 1_000_000, endTime: 2_000_000 },
});
await ask(provide, editor, model, "SELECT * FROM logs WHERE level = ");
await vi.waitFor(() => expect(triggers(editor)).toHaveLength(1), { timeout: 3000 });
await wrapper.setProps({ valuesTimeRange: { startTime: 3_000_000, endTime: 4_000_000 } });
await ask(provide, editor, model, "SELECT * FROM logs WHERE level = ");
await vi.waitFor(() => expect(triggers(editor)).toHaveLength(2), { timeout: 3000 });
},
);
it(
"refills again once an answered lookup sits between two slow ones",
{ timeout: 30000 },
async () => {
const slow = () => new Promise<string[]>((r) => setTimeout(() => r(["error"]), 1300));
const resolver = vi
.fn()
.mockImplementationOnce(slow)
.mockImplementationOnce(async () => ["error"])
.mockImplementationOnce(slow);
const { editor, model, provide } = await mountWith("r-scope", {
fieldValueResolver: resolver,
});
await ask(provide, editor, model, "SELECT * FROM logs WHERE level = ");
await vi.waitFor(() => expect(triggers(editor)).toHaveLength(1), { timeout: 3000 });
await ask(provide, editor, model, "SELECT * FROM logs WHERE level = ");
await ask(provide, editor, model, "SELECT * FROM logs WHERE level = ");
await vi.waitFor(() => expect(triggers(editor)).toHaveLength(2), { timeout: 3000 });
},
);
it("does not refill after Esc", { timeout: 30000 }, async () => {
const resolver = delayed(1300, ["error"]);
const { editor, model, provide } = await mountWith("r-esc", { fieldValueResolver: resolver });
await ask(provide, editor, model, "SELECT * FROM logs WHERE level = ");
editor.onKeyDown.mock.calls.at(-1)![0]({ keyCode: 9 });
await new Promise((r) => setTimeout(r, 600));
expect(triggers(editor)).toHaveLength(0);
});
it("does not refill after typed whitespace", { timeout: 30000 }, async () => {
const resolver = delayed(1300, ["error"]);
const { editor, model, provide } = await mountWith("r-space", {
fieldValueResolver: resolver,
});
const text = "SELECT * FROM logs WHERE level = ";
await ask(provide, editor, model, text);
editor.getPosition.mockReturnValue({ lineNumber: 1, column: text.length + 3 });
model.getValueInRange.mockImplementation((range: any) =>
range.startColumn === 1 ? text : "x ",
);
await new Promise((r) => setTimeout(r, 600));
expect(triggers(editor)).toHaveLength(0);
});
});
describe("the values copy follows the lookup's range", () => {
const emptyRow = async (editorId: string, props: any) => {
const { editor, model, provide } = await mountWith(editorId, {
fieldValueResolver: vi.fn(async () => []),
...props,
});
const result = await ask(provide, editor, model, "SELECT * FROM logs WHERE level = ");
return nameOf(rowsOf(result)[0]);
};
it("names the default lookback when no range is set", { timeout: 30000 }, async () => {
expect(await emptyRow("e-default", {})).toContain("valuesEmpty");
expect(await emptyRow("e-default2", {})).not.toContain("valuesEmptyInRange");
});
const valuesHeader = async (editorId: string, props: any) => {
const { editor, model, provide } = await mountWith(editorId, {
fieldValueResolver: vi.fn(async () => ["error"]),
...props,
});
const result = await ask(provide, editor, model, "SELECT * FROM logs WHERE level = ");
return nameOf(result.suggestions.find(isHeader));
};
it("heads values as recent when no range is set", { timeout: 30000 }, async () => {
expect(await valuesHeader("h-default", {})).toBe("sqlEditor.completion.groupValues");
});
it("heads values plainly when they come from a set range", { timeout: 30000 }, async () => {
const range = { startTime: 1_000_000, endTime: 2_000_000 };
expect(await valuesHeader("h-range", { valuesTimeRange: range })).toBe(
"sqlEditor.completion.groupValuesName",
);
});
it("speaks of the selected range when one is set", { timeout: 30000 }, async () => {
const range = { startTime: 1_000_000, endTime: 2_000_000 };
expect(await emptyRow("e-range", { valuesTimeRange: range })).toContain("valuesEmptyInRange");
});
});
describe("the typed prefix reaches the resolver", () => {
it("passes what is typed inside an open quote", { timeout: 30000 }, async () => {
const resolver = vi.fn(async (_field: string, _options?: any) => ["openobserve"]);
const { editor, model, provide } = await mountWith("p-quote", {
fieldValueResolver: resolver,
});
await ask(provide, editor, model, "SELECT * FROM logs WHERE level = 'openobs");
expect(resolver.mock.calls.at(-1)![1]).toMatchObject({ prefix: "openobs" });
});
it("passes an empty prefix outside a quote", { timeout: 30000 }, async () => {
const resolver = vi.fn(async (_field: string, _options?: any) => ["openobserve"]);
const { editor, model, provide } = await mountWith("p-bare", {
fieldValueResolver: resolver,
});
await ask(provide, editor, model, "SELECT * FROM logs WHERE level = ");
expect(resolver.mock.calls.at(-1)![1]).toMatchObject({ prefix: "" });
});
});
});
describe("P0-2 / P0-3 / W0-B — the chain, the operator lane and comments", () => {
let spy: ReturnType<typeof vi.spyOn>;
afterAll(() => spy?.mockRestore());
const FIELDS = [
{ name: "level", label: "level", kind: "Field", insertText: "level", detail: "Utf8" },
{ name: "code", label: "code", kind: "Field", insertText: "code", detail: "Int64" },
];
const mountIn = async (
editorId: string,
mode = "automatic",
extra: { keywords?: any[]; filterMode?: boolean } = {},
) => {
const api = await import("monaco-editor/esm/vs/editor/editor.api");
const createFn = vi.mocked(api.editor.create);
const registerFn = vi.mocked(api.languages.registerCompletionItemProvider);
const before = createFn.mock.calls.length;
spy = vi
.spyOn(document, "getElementById")
.mockImplementation(() => document.createElement("div"));
const store = createStore({
state: { theme: "light", zoConfig: { editor_suggest_mode: mode } },
});
const wrapper = mount(CodeQueryEditor, {
props: {
editorId,
language: "sql",
query: "",
keywords: extra.keywords ?? FIELDS,
suggestions: [],
filterMode: extra.filterMode ?? false,
},
global: { plugins: [store] },
});
await vi.waitFor(() => expect(createFn.mock.calls.length).toBe(before + 1), {
timeout: 15000,
interval: 25,
});
const registration = registerFn.mock.calls.filter((c) => c[0] === "sql").at(-1)![1];
return { wrapper, registration, editor: createdEditors.at(-1)!, api };
};
const ask = async (
registration: any,
editor: any,
text: string,
word = "",
context: any = {},
) => {
const model = editor.getModel();
model.getValueInRange.mockReturnValue(text);
model.getLineContent.mockReturnValue(text);
model.getWordUntilPosition.mockReturnValue({
word,
startColumn: text.length + 1 - word.length,
endColumn: text.length + 1,
});
return await registration.provideCompletionItems(
model,
{ lineNumber: 1, column: text.length + 1 },
context,
{},
);
};
const item = (r: any, label: string) =>
r.suggestions.find((s: any) => !isHeader(s) && nameOf(s) === label);
// Monaco runs an accepted item's command; this runs it the same way and reports whether a space was typed.
const accept = (api: any, editor: any, suggestion: any): boolean => {
vi.mocked(api.editor.getEditors).mockReturnValue([editor] as any);
editor.hasTextFocus.mockReturnValue(true);
editor.trigger.mockClear();
const handler = (vi.mocked(api.editor.registerCommand).mock.calls as any[]).find(
(c) => c[0] === suggestion.command?.id,
)?.[1];
handler?.({}, ...(suggestion.command?.arguments ?? []));
vi.mocked(api.editor.getEditors).mockReturnValue([]);
return editor.trigger.mock.calls.some(
(c: any[]) => c[0] === "keyboard" && c[1] === "type" && c[2]?.text === " ",
);
};
const TYPED_SPACE = { triggerKind: 1, triggerCharacter: " " };
it(
"under off, typing = after a field opens nothing; otherwise it opens values",
{
timeout: 30000,
},
async () => {
const off = await mountIn("trigger-off", "off");
const eq = { triggerKind: 1, triggerCharacter: "=" };
expect(
(await ask(off.registration, off.editor, "SELECT * FROM t WHERE level=", "", eq))
.suggestions,
).toEqual([]);
const on = await mountIn("trigger-on", "automatic");
const values = await ask(on.registration, on.editor, "SELECT * FROM t WHERE level=", "", eq);
expect(item(values, "level"), "a value position offers columns").toBeDefined();
},
);
it(
"the grouped operator menu still counts its rows in the operator lane",
{ timeout: 30000 },
async () => {
const { registration, editor } = await mountIn("lane-grouped");
const track = vi.spyOn(analytics, "track");
flushEditorSuggestSummary();
track.mockClear();
const model = editor.getModel();
const text = "SELECT * FROM t WHERE level ";
model.getLineContent.mockReturnValue(`${text}AND code = 1`);
model.getValueInRange.mockReturnValue(text);
model.getWordUntilPosition.mockReturnValue({
word: "",
startColumn: text.length + 1,
endColumn: text.length + 1,
});
const result = await registration.provideCompletionItems(
model,
{ lineNumber: 1, column: text.length + 1 },
TYPED_SPACE,
{},
);
expect(item(result, "=")?.command?.arguments).toEqual(["operator"]);
flushEditorSuggestSummary();
const summary = track.mock.calls
.map((c: any[]) => c[1])
.find((p: any) => p?.lane_offered_operator !== undefined);
expect(summary?.lane_offered_operator).toBe(1);
track.mockRestore();
},
);
it(
"level NOT offers every NOT operator, replacing the typed words",
{ timeout: 30000 },
async () => {
const { registration, editor } = await mountIn("not-partial");
const text = "SELECT * FROM t WHERE level NOT ";
const result = await ask(registration, editor, text);
const rows = result.suggestions.filter((s: any) => !isHeader(s));
for (const label of ["NOT IN", "NOT LIKE", "NOT ILIKE", "NOT BETWEEN"]) {
expect(rows.map(nameOf)).toContain(label);
}
expect(rows.find((s: any) => nameOf(s) === "NOT IN").range.startColumn).toBe(
text.length + 1 - "NOT ".length,
);
},
);
it(
"a value position offers functions after the columns, except under off",
{ timeout: 30000 },
async () => {
const keywords = [
...FIELDS,
{ name: "lower", label: "lower", kind: "Function", insertText: "lower(x)" },
];
const rowsIn = async (mode: string) => {
const { registration, editor } = await mountIn(`fn-mode-${mode}`, mode, { keywords });
const result = await ask(registration, editor, "SELECT * FROM t WHERE level = ");
return result.suggestions.filter((s: any) => !isHeader(s)).map(nameOf);
};
expect(await rowsIn("automatic")).toEqual(["level", "code", "lower"]);
expect(await rowsIn("off")).toEqual(["level", "code"]);
},
);
it(
"accepting a field in WHERE types a space, and that menu leads with =",
{
timeout: 30000,
},
async () => {
const { registration, editor, api } = await mountIn("chain-where");
const field = item(
await ask(registration, editor, "SELECT * FROM t WHERE lev", "lev"),
"level",
);
expect(field.insertText).toBe("level");
expect(accept(api, editor, field)).toBe(true);
const next = await ask(registration, editor, "SELECT * FROM t WHERE level ", "", TYPED_SPACE);
const sorted = [...next.suggestions].sort((a: any, b: any) =>
String(a.sortText ?? a.label).localeCompare(String(b.sortText ?? b.label)),
);
expect(nameOf(sorted.find((s: any) => !isHeader(s)))).toBe("=");
expect(rowsOf(next).filter((s: any) => nameOf(s) === "=")).toHaveLength(1);
expect(item(next, "code"), "the catalog stays below the lane").toBeDefined();
},
);
it("accepting = types a space that opens the value position", { timeout: 30000 }, async () => {
const { registration, editor, api } = await mountIn("chain-op");
const result = await ask(registration, editor, "SELECT * FROM t WHERE level ", "", TYPED_SPACE);
const eq = item(result, "=");
expect(eq.insertText).toBe("=");
expect(accept(api, editor, eq)).toBe(true);
});
it(
"never chains in SELECT, GROUP BY, ORDER BY or function arguments",
{
timeout: 30000,
},
async () => {
const { registration, editor, api } = await mountIn("chain-nowhere");
for (const text of [
"SELECT lev",
"SELECT * FROM t GROUP BY lev",
"SELECT * FROM t ORDER BY lev",
"SELECT * FROM t WHERE str_match(lev",
"SELECT CASE WHEN a = 1 AND lev",
"SELECT NOT lev",
"SELECT * FROM t WHERE ts BETWEEN now() - 1 AND lev",
]) {
const field = item(await ask(registration, editor, text, "lev"), "level");
expect(accept(api, editor, field), text).toBe(false);
}
},
);
it("typing mode never chains, and operators keep today's space", { timeout: 30000 }, async () => {
const { registration, editor, api } = await mountIn("chain-typing", "typing");
const field = item(
await ask(registration, editor, "SELECT * FROM t WHERE lev", "lev"),
"level",
);
expect(accept(api, editor, field)).toBe(false);
const eq = item(await ask(registration, editor, "SELECT * FROM t WHERE level "), "=");
expect(eq.insertText).toBe("= ");
expect(accept(api, editor, eq)).toBe(false);
});
it(
"an operator accepted with text already after it on the line does not chain",
{
timeout: 30000,
},
async () => {
const { registration, editor, api } = await mountIn("chain-midline");
const model = editor.getModel();
const text = "SELECT * FROM t WHERE level ";
model.getValueInRange.mockReturnValue(text);
model.getLineContent.mockReturnValue(`${text} AND code = 1`);
model.getWordUntilPosition.mockReturnValue({ word: "", startColumn: 29, endColumn: 29 });
const result = await registration.provideCompletionItems(
model,
{ lineNumber: 1, column: text.length + 1 },
TYPED_SPACE,
{},
);
const eq = item(result, "=");
expect(eq.insertText).toBe("= ");
expect(accept(api, editor, eq)).toBe(false);
},
);
it("a bare filter chains its first field only in filter mode", { timeout: 30000 }, async () => {
const api = await import("monaco-editor/esm/vs/editor/editor.api");
const createFn = vi.mocked(api.editor.create);
const registerFn = vi.mocked(api.languages.registerCompletionItemProvider);
const before = createFn.mock.calls.length;
const store = createStore({ state: { theme: "light", zoConfig: {} } });
mount(CodeQueryEditor, {
props: {
editorId: "chain-filter",
language: "sql",
query: "",
keywords: FIELDS,
suggestions: [],
filterMode: true,
},
global: { plugins: [store] },
});
await vi.waitFor(() => expect(createFn.mock.calls.length).toBe(before + 1), {
timeout: 15000,
interval: 25,
});
const editor = createdEditors.at(-1)!;
const registration = registerFn.mock.calls.filter((c) => c[0] === "sql").at(-1)![1];
const inFilter = item(await ask(registration, editor, "lev", "lev"), "level");
expect(accept(api, editor, inFilter)).toBe(true);
const plain = await mountIn("chain-nofilter");
const inSql = item(await ask(plain.registration, plain.editor, "lev", "lev"), "level");
expect(accept(api, plain.editor, inSql)).toBe(false);
});
it(
"a non-query editor gets no counting command it could not run",
{ timeout: 30000 },
async () => {
const api = await import("monaco-editor/esm/vs/editor/editor.api");
const createFn = vi.mocked(api.editor.create);
const registerFn = vi.mocked(api.languages.registerCompletionItemProvider);
const before = createFn.mock.calls.length;
mount(CodeQueryEditor, {
props: { editorId: "vrl-commands", language: "vrl", query: "", keywords: FIELDS },
global: { plugins: [createStore({ state: { theme: "light" } })] },
});
await vi.waitFor(() => expect(createFn.mock.calls.length).toBe(before + 1), {
timeout: 15000,
interval: 25,
});
const editor = createdEditors.at(-1)!;
const registration = registerFn.mock.calls.filter((c) => c[0] === "vrl").at(-1)![1];
const result = await ask(registration, editor, ".lev", "lev");
expect(result.suggestions.length).toBeGreaterThan(0);
expect(result.suggestions.every((s: any) => s.command === undefined)).toBe(true);
},
);
describe("a space typed right after the chain's own space", () => {
const press = (editor: any, browserEvent: Record<string, unknown> = {}) => {
const event = {
keyCode: 10,
browserEvent,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
};
editor.onKeyDown.mock.calls.at(-1)![0](event);
return event.preventDefault.mock.calls.length > 0;
};
const chainThenSpace = async (editorId: string) => {
const { registration, editor, api } = await mountIn(editorId);
const text = "SELECT * FROM t WHERE level";
const field = item(
await ask(registration, editor, "SELECT * FROM t WHERE lev", "lev"),
"level",
);
expect(accept(api, editor, field)).toBe(true);
editor.onDidChangeModelContent.mock.calls.at(-1)![0]({
changes: [{ text: " ", range: { startLineNumber: 1, startColumn: text.length + 1 } }],
});
editor.getPosition.mockReturnValue({ lineNumber: 1, column: text.length + 2 });
return editor;
};
it("is swallowed once, and the next one types", { timeout: 30000 }, async () => {
const editor = await chainThenSpace("swallow-once");
expect(press(editor)).toBe(true);
expect(press(editor)).toBe(false);
});
it("is left alone during IME composition", { timeout: 30000 }, async () => {
const editor = await chainThenSpace("swallow-ime");
expect(press(editor, { isComposing: true })).toBe(false);
});
it("is left alone once the caret has moved", { timeout: 30000 }, async () => {
const editor = await chainThenSpace("swallow-moved");
editor.getPosition.mockReturnValue({ lineNumber: 1, column: 3 });
expect(press(editor)).toBe(false);
});
it("never touches a space typed without a chain", { timeout: 30000 }, async () => {
const { editor } = await mountIn("swallow-none");
editor.onDidChangeModelContent.mock.calls.at(-1)![0]({
changes: [{ text: " ", range: { startLineNumber: 1, startColumn: 5 } }],
});
editor.getPosition.mockReturnValue({ lineNumber: 1, column: 6 });
expect(press(editor)).toBe(false);
});
});
describe("after a complete predicate, AND and OR lead", () => {
const CATALOG = [
...FIELDS.map((f) => ({ ...f, sortText: `\u0000${f.name}` })),
...SQL_KEYWORDS,
...SQL_CLAUSE_KEYWORDS,
];
const leading = (result: any, count: number) =>
[...result.suggestions]
.sort((a: any, b: any) => (String(a.sortText) < String(b.sortText) ? -1 : 1))
.slice(0, count)
.map((s: any) => (typeof s.label === "string" ? s.label : s.label.label));
it("in filter mode, after every predicate shape", { timeout: 30000 }, async () => {
const { registration, editor } = await mountIn("lead-filter", "automatic", {
keywords: CATALOG,
filterMode: true,
});
for (const text of [
"level = 'x' ",
"level IS NULL ",
"level IN ('a','b') ",
"code BETWEEN 1 AND 5 ",
"(code = 1) ",
]) {
const result = await ask(registration, editor, text, "", TYPED_SPACE);
expect(leading(result, 2), text).toEqual(["AND", "OR"]);
expect(item(result, "level"), `${text}: fields stay`).toBeDefined();
}
const inGroup = await ask(registration, editor, "(code = 1 ", "", TYPED_SPACE);
expect(leading(inGroup, 3)).toEqual(["AND", "OR", ")"]);
});
it("in full SQL, the clauses that can follow come next", { timeout: 30000 }, async () => {
const { registration, editor } = await mountIn("lead-sql", "automatic", {
keywords: CATALOG,
});
const result = await ask(
registration,
editor,
"SELECT * FROM t WHERE level = 'x' ",
"",
TYPED_SPACE,
);
expect(leading(result, 5)).toEqual(["AND", "OR", "GROUP BY", "ORDER BY", "LIMIT"]);
const when = await ask(registration, editor, "SELECT CASE WHEN code = 1 ", "", TYPED_SPACE);
expect(leading(when, 3)).toEqual(["AND", "OR", "THEN"]);
});
it("AND chains into the field menu, which leads with fields", { timeout: 30000 }, async () => {
const { registration, editor, api } = await mountIn("lead-chain", "automatic", {
keywords: CATALOG,
filterMode: true,
});
const result = await ask(registration, editor, "level = 'x' ", "", TYPED_SPACE);
const and = item(result, "AND");
expect(and.insertText).toBe("AND");
expect(accept(api, editor, and)).toBe(true);
const next = await ask(registration, editor, "level = 'x' AND ", "", TYPED_SPACE);
expect(leading(next, 1)).toEqual(["code"]);
const field = item(await ask(registration, editor, "level = 'x' AND lev", "lev"), "level");
expect(accept(api, editor, field)).toBe(true);
});
it("typing mode ranks the same lead without chaining", { timeout: 30000 }, async () => {
const { registration, editor, api } = await mountIn("lead-typing", "typing", {
keywords: CATALOG,
filterMode: true,
});
const result = await ask(registration, editor, "level = 'x' ", "", TYPED_SPACE);
expect(leading(result, 2)).toEqual(["AND", "OR"]);
const and = item(result, "AND");
expect(and.insertText).toBe("AND ");
expect(accept(api, editor, and)).toBe(false);
});
});
it(
"the match functions sit under Functions and fields, described, and Operators holds the plain operators",
{ timeout: 30000 },
async () => {
const { registration, editor } = await mountIn("op-groups");
const result = await ask(
registration,
editor,
"SELECT * FROM t WHERE level ",
"",
TYPED_SPACE,
);
const sorted = [...result.suggestions].sort((a: any, b: any) =>
a.sortText < b.sortText ? -1 : 1,
);
const groups: Record<string, string[]> = {};
let current = "";
for (const row of sorted) {
if (isHeader(row)) current = nameOf(row);
else (groups[current] ??= []).push(nameOf(row));
}
expect(groups["sqlEditor.completion.groupOperators"]).toEqual([
"=",
"!=",
"IN",
"LIKE",
"ILIKE",
"IS NULL",
"IS NOT NULL",
"NOT LIKE",
]);
expect(groups["sqlEditor.completion.groupFunctionsAndFields"].slice(0, 3)).toEqual([
"str_match",
"re_match",
"re_not_match",
]);
const described = (name: string) => item(result, name).label.description;
expect(described("str_match")).toBe("sqlEditor.completion.wrapStrMatch");
expect(described("re_not_match")).toBe("sqlEditor.completion.wrapReNotMatch");
},
);
it(
"an underscored prefix keeps the grouped menu: re_n gives Functions and fields with re_not_match",
{ timeout: 30000 },
async () => {
const { registration, editor } = await mountIn("op-groups-underscore");
const result = await ask(registration, editor, "SELECT * FROM t WHERE level re_n", "re_n");
expect(result.suggestions.filter(isHeader).map(nameOf)).toEqual([
"sqlEditor.completion.groupFunctionsAndFields",
]);
expect(rowsOf(result).map(nameOf)).toEqual(["re_not_match"]);
},
);
it(
"IS N offers only IS NULL and IS NOT NULL, over the whole prefix",
{ timeout: 30000 },
async () => {
const { registration, editor } = await mountIn("op-groups-isn", "automatic", {
keywords: [...FIELDS, ...SQL_KEYWORDS, ...SQL_CLAUSE_KEYWORDS],
});
const text = "SELECT * FROM t WHERE level IS N";
const result = await ask(registration, editor, text, "N");
expect(rowsOf(result).map(nameOf)).toEqual(["IS NULL", "IS NOT NULL"]);
expect(rowsOf(result)[0].range.startColumn).toBe(text.length - "IS N".length + 1);
},
);
it(
"typed letters keep the header of every group that still has matches",
{ timeout: 30000 },
async () => {
const { registration, editor } = await mountIn("op-groups-typed");
const result = await ask(registration, editor, "SELECT * FROM t WHERE level I", "I");
expect(result.suggestions.filter(isHeader).map(nameOf)).toContain(
"sqlEditor.completion.groupOperators",
);
},
);
it("a numeric field leads with >=", { timeout: 30000 }, async () => {
const { registration, editor } = await mountIn("chain-num");
const result = await ask(registration, editor, "SELECT * FROM t WHERE code ");
expect(nameOf(rowsOf(result)[0])).toBe(">=");
});
it(
"returns nothing inside a -- comment, even for a trigger character",
{ timeout: 30000 },
async () => {
const { registration, editor } = await mountIn("chain-comment");
const result = await ask(registration, editor, "-- show me errors ", "", {
triggerKind: 1,
triggerCharacter: " ",
});
expect(result.suggestions).toEqual([]);
},
);
it("announces a chained menu to screen readers", { timeout: 30000 }, async () => {
const { wrapper, registration, editor, api } = await mountIn("chain-announce");
vi.mocked(api.editor.getEditors).mockReturnValue([editor] as any);
editor.hasTextFocus.mockReturnValue(true);
const chain = (vi.mocked(api.editor.registerCommand).mock.calls as any[]).find(
(c) => c[0] === "o2.suggest.chainSpace",
)[1];
chain({}, "field");
expect(editor.trigger).toHaveBeenCalledWith("keyboard", "type", { text: " " });
await ask(registration, editor, "SELECT * FROM t WHERE level ", "", {
triggerKind: 1,
triggerCharacter: " ",
});
await vi.waitFor(() =>
expect(wrapper.find('[data-test="query-editor-suggest-status"]').text()).toContain(
"sqlEditor.completion.chainedMenuAnnouncement",
),
);
vi.mocked(api.editor.getEditors).mockReturnValue([]);
});
});