test(editor): failing specs for Phase 3 IntelliSense parity (TDD red)

Covers tmp/code.md Phase 3 items 11-15.

editorProviders.spec.ts (new, blocked until the module exists) pins the two
providers the editor has never had:

  parseCallContext  — which call the cursor sits in and on which argument.
    Nested calls report the INNERMOST open one; commas and parens inside string
    literals do not count; a doubled quote is an escape, not a boundary; a bare
    parenthesised group is not a call. Each of those is a way the hint silently
    points at the wrong parameter.
  buildSignatureHelp — one parameter per argument, active parameter clamped so
    extra commas cannot point past the end, documentation carried through.
  findCatalogEntry / buildHoverContents — resolve the word under the cursor and
    render it: signature as code plus prose for a function, name and column type
    for a field, "deprecated" surfaced, and no invented type when none is known.

CodeQueryEditor.completion.spec.ts pins the wiring at REGISTRATION level rather
than against the helpers. Every gap that escaped in this workstream was a helper
that worked and a component that never called it, so:

  D2 signature help provider registered for sql, triggering on ( and ,
  D3 hover provider registered for sql
  C3 completion declares trigger characters ( , ' .
  D4 completion supplies resolveCompletionItem
  N4 wordBasedSuggestions 'off'   (default pulls buffer text between editors)
  N3 quickSuggestions.strings 'on' (default 'off' is why value completion needed
     the hide/re-trigger hack)
  C5 three SQL editors register ONE provider, not three

Note on C5: mounting the three editors in one synchronous burst produces a
single provider today regardless of the fix — only the first editor finishes
initialising — so that shape would pass for the wrong reason. The test mounts
sequentially and awaits each. It now fails honestly: "three editors registered
3 SQL completion providers".

Also worth a look separately: that concurrent-mount observation is not a test
artifact I can rule out. Three editors mounted in the same tick produced ONE
monaco.editor.create call; sequentially, three. If that reproduces in a browser
it would affect dashboards with several SQL panels.

8 failing, 1 suite blocked on the unwritten module. Everything else green.
This commit is contained in:
Prabhat Sharma 2026-08-02 14:27:34 -07:00
parent 7cf28ed124
commit b85fa78700
2 changed files with 387 additions and 0 deletions

View File

@ -359,3 +359,126 @@ describe("Phase 1 — the registered completion provider", () => {
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.
// ═══════════════════════════════════════════════════════════════════════════
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");
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(mockEditorObj.createContextKey).toHaveBeenCalled(), {
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.
expect(provider.triggerCharacters).toEqual(expect.arrayContaining(["(", ",", "'", "."]));
});
it("D4 — completion supplies resolveCompletionItem for lazy docs", { timeout: 30000 }, async () => {
const api = await mountEditor();
const provider = vi.mocked(api.languages.registerCompletionItemProvider).mock.calls.at(-1)![1];
expect(typeof provider.resolveCompletionItem).toBe("function");
});
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("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 — 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;
// Monaco aggregates every provider registered for a language, so N editors
// meant N providers answering on every keystroke — N-1 of them only to
// return an empty list for a model that did not ask.
expect(added, `three editors registered ${added} SQL completion providers`).toBe(1);
});
});

View File

@ -0,0 +1,264 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// TDD spec for tmp/code.md Phase 3 — the two providers the editor has never
// had. Completion tells you a function EXISTS; signature help tells you what to
// type next, and hover tells you what something is without typing at all.
//
// Both derive from the same catalog entries completion already uses, so the
// only new logic is: work out which call the cursor sits in, and render an
// entry for each surface.
import { describe, it, expect } from "vitest";
import { SQL_FUNCTIONS } from "./sqlCompletion";
import {
parseCallContext,
buildSignatureHelp,
buildHoverContents,
findCatalogEntry,
} from "./editorProviders";
const fn = (name: string) => SQL_FUNCTIONS.find((f) => f.name === name)!;
// ───────────────────────────────────────────────────────────────────────────
// parseCallContext — which call is the cursor inside, and on which argument
// ───────────────────────────────────────────────────────────────────────────
describe("parseCallContext — locating the enclosing call", () => {
it("returns null when the cursor is not inside a call", () => {
expect(parseCallContext("")).toBeNull();
expect(parseCallContext("SELECT ")).toBeNull();
expect(parseCallContext("SELECT * FROM logs WHERE ")).toBeNull();
});
it("finds the function as soon as the paren opens", () => {
expect(parseCallContext("SELECT histogram(")).toEqual({
name: "histogram",
activeParameter: 0,
});
});
it("stays on argument 0 while the first argument is being typed", () => {
expect(parseCallContext("SELECT histogram(_timestamp")).toEqual({
name: "histogram",
activeParameter: 0,
});
});
it("advances to the next argument after a comma", () => {
expect(parseCallContext("SELECT histogram(_timestamp, ")).toEqual({
name: "histogram",
activeParameter: 1,
});
});
it("stays on that argument while it is being typed", () => {
expect(parseCallContext("SELECT histogram(_timestamp, '30 sec")).toEqual({
name: "histogram",
activeParameter: 1,
});
});
it("returns null once the call is closed", () => {
expect(parseCallContext("SELECT histogram(_timestamp, '30 second')")).toBeNull();
});
it("reports the INNERMOST open call when calls are nested", () => {
expect(parseCallContext("SELECT sum(abs(")).toEqual({ name: "abs", activeParameter: 0 });
});
it("returns to the outer call once the inner one closes", () => {
expect(parseCallContext("SELECT sum(abs(x), ")).toEqual({ name: "sum", activeParameter: 1 });
});
it("ignores commas inside a string literal", () => {
// Otherwise typing a value containing a comma silently advances the hint
// to the wrong parameter.
expect(parseCallContext("str_match(body, 'a,b'")).toEqual({
name: "str_match",
activeParameter: 1,
});
});
it("ignores parens inside a string literal", () => {
expect(parseCallContext("str_match(body, 'f(x'")).toEqual({
name: "str_match",
activeParameter: 1,
});
});
it("handles a doubled quote as an escaped quote, not a string boundary", () => {
expect(parseCallContext("str_match(body, 'it''s, fine', ")).toEqual({
name: "str_match",
activeParameter: 2,
});
});
it("works across newlines", () => {
expect(parseCallContext("SELECT sum(\n amount")).toEqual({
name: "sum",
activeParameter: 0,
});
});
it("ignores a bare parenthesised group with no function name", () => {
expect(parseCallContext("WHERE (a > 1 AND ")).toBeNull();
});
it("tolerates whitespace between the name and the paren", () => {
expect(parseCallContext("SELECT sum (")).toEqual({ name: "sum", activeParameter: 0 });
});
});
// ───────────────────────────────────────────────────────────────────────────
// buildSignatureHelp — render one catalog entry as monaco's SignatureHelp
// ───────────────────────────────────────────────────────────────────────────
describe("buildSignatureHelp", () => {
it("labels the signature with the name and its arguments", () => {
const help = buildSignatureHelp(fn("histogram"), 0)!;
expect(help.signatures[0].label).toBe("histogram(field, interval)");
});
it("splits the detail into one parameter per argument", () => {
const help = buildSignatureHelp(fn("histogram"), 0)!;
expect(help.signatures[0].parameters.map((p: any) => p.label)).toEqual(["field", "interval"]);
});
it("reports the active parameter so monaco can bold it", () => {
expect(buildSignatureHelp(fn("histogram"), 1)!.activeParameter).toBe(1);
});
it("clamps a runaway active parameter to the last one", () => {
// Typing extra commas must not leave monaco pointing past the end.
expect(buildSignatureHelp(fn("histogram"), 9)!.activeParameter).toBe(1);
});
it("carries the documentation so the hint explains the function", () => {
const help = buildSignatureHelp(fn("approx_topk"), 0)!;
expect(help.signatures[0].documentation).toEqual({ value: fn("approx_topk").documentation });
});
it("always reports a single active signature", () => {
expect(buildSignatureHelp(fn("sum"), 0)!.activeSignature).toBe(0);
});
it("handles a zero-argument entry without inventing parameters", () => {
const help = buildSignatureHelp(
{ name: "now", label: "now", kind: "Function", insertText: "now()", detail: "()" },
0,
)!;
expect(help.signatures[0].parameters).toEqual([]);
expect(help.activeParameter).toBe(0);
});
it("degrades gracefully when the signature is opaque", () => {
const help = buildSignatureHelp(
{ name: "x", label: "x", kind: "Function", insertText: "x()", detail: "(...)" },
0,
)!;
expect(help.signatures[0].label).toBe("x(...)");
expect(help.signatures[0].parameters).toEqual([]);
});
it("returns null for an entry it cannot describe", () => {
expect(buildSignatureHelp(null, 0)).toBeNull();
});
});
// ───────────────────────────────────────────────────────────────────────────
// findCatalogEntry — resolve the word under the cursor
// ───────────────────────────────────────────────────────────────────────────
describe("findCatalogEntry", () => {
const fields = [
{ name: "host_name", label: "host_name", kind: "Field" as const, insertText: "host_name", detail: "Utf8" },
];
it("finds a function from the suggestion catalog", () => {
expect(findCatalogEntry("histogram", fields, SQL_FUNCTIONS)?.name).toBe("histogram");
});
it("finds a field from the keyword list", () => {
expect(findCatalogEntry("host_name", fields, SQL_FUNCTIONS)?.kind).toBe("Field");
});
it("matches case-insensitively, as SQL is written both ways", () => {
expect(findCatalogEntry("HISTOGRAM", fields, SQL_FUNCTIONS)?.name).toBe("histogram");
});
it("returns null for an unknown word rather than guessing", () => {
expect(findCatalogEntry("not_a_function", fields, SQL_FUNCTIONS)).toBeNull();
});
it("returns null for an empty word", () => {
expect(findCatalogEntry("", fields, SQL_FUNCTIONS)).toBeNull();
});
it("prefers the field when a field and a function share a name", () => {
// A column named `count` is the thing under the cursor in that buffer.
const shadow = [{ name: "count", label: "count", kind: "Field" as const, insertText: "count", detail: "Int64" }];
expect(findCatalogEntry("count", shadow, SQL_FUNCTIONS)?.kind).toBe("Field");
});
});
// ───────────────────────────────────────────────────────────────────────────
// buildHoverContents
// ───────────────────────────────────────────────────────────────────────────
describe("buildHoverContents", () => {
it("shows a function's signature as code", () => {
const [head] = buildHoverContents(fn("histogram"))!;
expect(head.value).toContain("histogram(field, interval)");
expect(head.value).toContain("```");
});
it("shows the function's prose underneath", () => {
const contents = buildHoverContents(fn("histogram"))!;
expect(contents.map((c) => c.value).join("\n")).toContain(fn("histogram").documentation);
});
it("shows a field's column type", () => {
const contents = buildHoverContents({
name: "host_name",
label: "host_name",
kind: "Field",
insertText: "host_name",
detail: "Utf8",
})!;
expect(contents[0].value).toContain("host_name");
expect(contents[0].value).toContain("Utf8");
});
it("does not claim a type for a field that has none", () => {
const contents = buildHoverContents({
name: "mystery",
label: "mystery",
kind: "Field",
insertText: "mystery",
})!;
expect(contents[0].value).toContain("mystery");
expect(contents[0].value).not.toContain("undefined");
});
it("marks a deprecated entry as deprecated", () => {
const contents = buildHoverContents(fn("match_all_raw"))!;
expect(contents.map((c) => c.value).join(" ").toLowerCase()).toContain("deprecated");
});
it("returns null when there is nothing to say", () => {
expect(buildHoverContents(null)).toBeNull();
});
});