fix(editor): give the three surfaces whose resolver could never resolve

C4 made the field-value resolver mandatory on all 13 editor hosts, and it is
bound on all 13 -- but a resolver only returns anything if it can build the key
its lookup is under: "org|streamType|streamName|field". Three surfaces never set
any of the three, so resolveFieldValues returned [] on every call, the provider
fell straight through to the ordinary function list, and value completion was
silently absent. Working editor, no error, nothing to notice.

  views/slos/AddSlo.vue                    -- set in loadStreamFields, so it is
                                              cleared with the field list too
  components/anomaly_detection/.../AnomalyDetectionConfig.vue
  components/dashboards/addPanel/DashboardQueryEditor.vue
                                           -- per QUERY, not per panel: each tab
                                              has its own stream and a stale
                                              context would offer the previous
                                              tab's values

Found by asking what the resolver actually does at runtime rather than whether
it is wired -- the same distinction that made the previous commit's "verified in
the app" claim false. The structural guard now checks it: a surface that owns a
useSqlSuggestions resolver must set the context it looks values up under. It
fails for exactly these three before the fix, and exempts the pass-through
wrappers (QueryEditor.vue, SloExpressionField.vue), which forward a prop and
have no stream of their own.

SEPARATE BUG, same file. AnomalyDetectionConfig.loadStreamFields cleared the
field keywords in both failure branches but never SET them on success -- so its
SQL editor offered keywords and functions but not one field of the selected
stream. One line, and it is why that surface got a behavioural test rather than
only the structural one.

Both new AnomalyDetectionConfig tests and both new DashboardQueryEditor tests
were confirmed to fail with the component change stashed, and to fail for the
stated reason -- 2 failures each, no collateral.

Two test doubles were lying about the real shape and are now honest: the
DashboardQueryEditor mock of useSuggestions omitted autoCompleteData entirely
(every mount threw once the component started setting it), and its
dashboardPanelData mock was a plain object, so no watcher on panel state could
ever fire in a test.

1239 passing across the touched specs; type-check and eslint clean.

Note for later: rtk's summarised `prettier --check` reported "All files
formatted correctly" for a file raw prettier rejects. Formatting here was
verified through `rtk proxy`.
This commit is contained in:
Prabhat Sharma 2026-08-02 16:17:43 -07:00
parent 28464ae383
commit e7f23cc342
7 changed files with 210 additions and 9 deletions

View File

@ -39,6 +39,7 @@ import OSelect from "@/lib/forms/Select/OSelect.vue";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import { firstFieldError } from "@/lib/forms/Form/fieldError";
import streamService from "@/services/stream";
// vi.mock must be hoisted — declared before component import
vi.mock("@/services/stream", () => ({
@ -47,6 +48,15 @@ vi.mock("@/services/stream", () => ({
},
}));
// The stored-value lookup the field-value resolver ends at. Stubbed so the
// resolver tests can assert the composite key it was asked for without an
// IndexedDB in jsdom. useSuggestions imports nothing else from this module.
// vi.hoisted, because vi.mock is lifted above ordinary declarations.
const { getFieldValuesForSuggestion } = vi.hoisted(() => ({
getFieldValuesForSuggestion: vi.fn(async () => ["ERROR", "INFO"]),
}));
vi.mock("@/composables/useFieldValueStore", () => ({ getFieldValuesForSuggestion }));
vi.mock("@/components/dashboards/PanelSchemaRenderer.vue", () => ({
default: { template: '<div data-test="panel-schema-renderer" />' },
}));
@ -896,4 +906,47 @@ describe("AnomalyDetectionConfig", () => {
expect(ok).toBe(true);
});
});
// =========================================================================
// Editor autocomplete wiring. Both halves shipped broken: loadStreamFields
// cleared the field keywords on failure but never SET them on success, and
// the stream context the value resolver keys on was never populated at all.
// Neither was visible from the outside — the editor still opened, just with
// nothing stream-specific in it.
// =========================================================================
describe("SQL editor autocomplete", () => {
it("feeds the selected stream's fields to the editor", async () => {
(streamService.schema as any).mockResolvedValueOnce({
data: {
schema: [
{ name: "level", type: "Utf8" },
{ name: "code", type: "Int64" },
],
},
});
wrapper = mountConfig();
await flushPromises();
const labels = ((wrapper.vm as any).effectiveKeywords ?? []).map((k: any) => k.label);
expect(labels).toContain("level");
expect(labels).toContain("code");
});
it("resolves field values under the selected stream's key", async () => {
wrapper = mountConfig({ stream_name: "my_stream", stream_type: "logs" });
await flushPromises();
const values = await (wrapper.vm as any).resolveFieldValues("level");
expect(getFieldValuesForSuggestion).toHaveBeenCalledWith(
{
org: store.state.selectedOrganization.identifier,
streamType: "logs",
streamName: "my_stream",
},
"level",
);
expect(values).toEqual(["ERROR", "INFO"]);
});
});
});

View File

@ -788,8 +788,13 @@ export default defineComponent({
// Same completion machinery every other SQL editor in the app uses, so this
// one also gets SQL keywords, the O2 functions and the server function
// catalog rather than bare field names.
const { effectiveKeywords, effectiveSuggestions, updateFieldKeywords, resolveFieldValues } =
useSqlSuggestions();
const {
autoCompleteData,
effectiveKeywords,
effectiveSuggestions,
updateFieldKeywords,
resolveFieldValues,
} = useSqlSuggestions();
const numericStreamFields = ref<string[]>([]); // only numeric types for avg/sum/min/max/pXX
const filteredStreamFields = ref<string[]>([]);
const filteredDetectionFields = ref<string[]>([]);
@ -815,6 +820,13 @@ export default defineComponent({
const loadStreamFields = async () => {
const streamName = props.config.stream_name;
const streamType = props.config.stream_type;
// Field VALUES are looked up under "org|streamType|streamName|field", so
// the resolver returns nothing at all until this is set.
autoCompleteData.value.org = store.state.selectedOrganization?.identifier ?? "";
autoCompleteData.value.streamType = String(streamType ?? "");
autoCompleteData.value.streamName = String(streamName ?? "");
if (!streamName || !streamType) {
allStreamFields.value = [];
updateFieldKeywords([]);
@ -836,6 +848,10 @@ export default defineComponent({
? schema.uds_schema
: schema.schema || schema.fields || [];
allStreamFields.value = fieldsArray.map((f: any) => f.name).sort();
// The two failure branches below already cleared the keywords; without
// this the success branch never set them, so the SQL editor offered
// functions and keywords but not one field of the selected stream.
updateFieldKeywords(fieldsArray);
numericStreamFields.value = fieldsArray
.filter((f: any) => {
const t: string = f.field_type || f.data_type || f.type || "";

View File

@ -15,6 +15,7 @@
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
import { mount } from "@vue/test-utils";
import { reactive } from "vue";
// Mock the zincutils utilities completely
vi.mock("@/utils/zincutils", async (importOriginal) => {
const actual = (await importOriginal()) as any;
@ -67,6 +68,7 @@ vi.mock("@/components/CodeQueryEditor.vue", () => ({
}));
import DashboardQueryEditor from "@/components/dashboards/addPanel/DashboardQueryEditor.vue";
import useSqlSuggestions from "@/composables/useSuggestions";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import router from "@/test/unit/helpers/router";
@ -114,7 +116,11 @@ const createMockDashboardPanelData = () => {
};
return {
dashboardPanelData: mockData,
// reactive() because the real composable's state is: the component watches
// the active query's stream, and a plain object would never fire the
// watcher — the test would pass or fail for reasons unrelated to the
// component.
dashboardPanelData: reactive(mockData),
promqlMode: false, // Make this a direct boolean instead of ref
addQuery: vi.fn(() => {
mockData.data.queries.push({
@ -155,6 +161,20 @@ vi.mock("@/composables/usePromqlSuggestions", () => ({
vi.mock("@/composables/useSuggestions", () => ({
default: vi.fn(() => ({
// Mirrors the real composable's shape. autoCompleteData carries the stream
// context the field-value resolver looks values up under; omitting it here
// made every mount throw once the component started setting it.
autoCompleteData: {
value: {
org: "",
streamType: "",
streamName: "",
query: "",
cursorIndex: 0,
popup: { open: vi.fn(), close: vi.fn() },
},
},
resolveFieldValues: vi.fn(async () => []),
autoCompleteKeywords: { value: [] },
autoCompleteSuggestions: { value: [] },
effectiveKeywords: { value: [] },
@ -410,6 +430,52 @@ describe("DashboardQueryEditor", () => {
expect(wrapper.vm.dashboardPanelData.layout.currentQueryIndex).toBe(1);
});
// The field-value resolver looks values up under "org|streamType|
// streamName|field". This panel never set any of the three, so its
// resolver could only ever return [] — a working editor with value
// completion silently absent. Per QUERY, not per panel: each tab has its
// own stream and a stale context would offer the previous tab's values.
const sqlAutoCompleteData = () =>
(useSqlSuggestions as any).mock.results.at(-1).value.autoCompleteData.value;
it("publishes the active query's stream as the field-value lookup context", async () => {
wrapper = createWrapper();
wrapper.vm.dashboardPanelData.data.queries[0].fields = {
stream: "app_logs",
stream_type: "logs",
};
await wrapper.vm.$nextTick();
expect(sqlAutoCompleteData()).toMatchObject({
org: "test-org",
streamType: "logs",
streamName: "app_logs",
});
});
it("follows the stream when the user switches query tabs", async () => {
wrapper = createWrapper();
wrapper.vm.dashboardPanelData.data.queries[0].fields = {
stream: "app_logs",
stream_type: "logs",
};
wrapper.vm.dashboardPanelData.data.queries.push({
query: "",
queryType: "sql",
customQuery: true,
fields: { stream: "app_metrics", stream_type: "metrics" },
});
await wrapper.vm.$nextTick();
wrapper.vm.dashboardPanelData.layout.currentQueryIndex = 1;
await wrapper.vm.$nextTick();
expect(sqlAutoCompleteData()).toMatchObject({
streamType: "metrics",
streamName: "app_metrics",
});
});
it("should handle query editor configuration", () => {
wrapper = createWrapper();

View File

@ -646,6 +646,27 @@ export default defineComponent({
{ immediate: true },
);
// Field VALUES are looked up under "org|streamType|streamName|field", so the
// resolver returns nothing at all until this is set. Tracked per QUERY, not
// per panel: each tab has its own stream, and switching tabs must not leave
// the previous tab's values on offer. A multi-stream query is keyed on its
// primary stream the same one the Fields panel is built from.
watch(
[
() => dashboardPanelData.layout.currentQueryIndex,
() =>
dashboardPanelData.data.queries?.[dashboardPanelData.layout.currentQueryIndex]?.fields,
],
() => {
const fields =
dashboardPanelData.data.queries?.[dashboardPanelData.layout.currentQueryIndex]?.fields;
sqlAutoCompleteData.value.org = store.state.selectedOrganization?.identifier ?? "";
sqlAutoCompleteData.value.streamType = String(fields?.stream_type ?? "");
sqlAutoCompleteData.value.streamName = String(fields?.stream ?? "");
},
{ immediate: true, deep: true },
);
const removeTab = async (rawIndex: string | number) => {
const index = Number(rawIndex);
if (dashboardPanelData.layout.currentQueryIndex >= dashboardPanelData.data.queries.length - 1)

View File

@ -168,10 +168,13 @@ describe("Phase 2 — the server catalog is actually fetched (B4 wiring)", () =>
// ─── Surfaces that never call getSuggestions ─────────────────────────────────
// The SLO form (AddSlo.vue) wires the editor with updateFieldKeywords ONLY: it
// never calls getSuggestions and never sets autoCompleteData.org. Hanging the
// catalog fetch off getSuggestions therefore left that page with the ~26 local
// functions and none of the ~330 from the registry — reported as "many
// functions are not available in typeahead".
// never calls getSuggestions. Hanging the catalog fetch off getSuggestions
// therefore left that page with the ~26 local functions and none of the ~330
// from the registry — reported as "many functions are not available in
// typeahead". It does now set autoCompleteData.org (for the field-value
// resolver's lookup key), but only once a stream is chosen — so the fallback
// to the store's org still carries the fetch for every surface, including this
// one before a stream is picked.
describe("Phase 2 — the catalog loads for surfaces that only set up fields", () => {
const SERVER_LIST = [

View File

@ -88,6 +88,36 @@ describe("editor wiring — every surface supplies both completion sources", ()
).toBe(true);
});
// A resolver that can never resolve anything is the same class of silent gap
// as a missing prop: the wiring test passes, the editor works, and value
// completion just quietly does nothing. resolveFieldValues reads the stored
// values under the composite key "org|streamType|streamName|field", so a
// surface that never sets those three gets [] on every lookup and the
// provider falls straight through to the ordinary function list.
const composableHosts = editorHosts.filter((f) => /useSqlSuggestions\s*\(/.test(f.source));
it("finds the surfaces that own a composable (not the pass-through wrappers)", () => {
expect(composableHosts.length).toBeGreaterThan(8);
// QueryEditor.vue and SloExpressionField.vue forward a resolver prop rather
// than owning one; they have no stream context to set and must not be
// required to have any.
expect(composableHosts.map((f) => f.path)).not.toContain("components/QueryEditor.vue");
});
it.each(composableHosts.map((f) => f.path))(
"%s sets the stream context its resolver looks values up under",
(path) => {
const { source } = composableHosts.find((f) => f.path === path)!;
for (const key of ["org", "streamType", "streamName"]) {
expect(
new RegExp(`[Aa]utoCompleteData(\\.value)?\\.${key}\\s*=`).test(source),
`${path} owns a useSqlSuggestions resolver but never sets ` +
`autoCompleteData.${key}, so every field-value lookup returns []`,
).toBe(true);
}
},
);
it.each(editorHosts.map((f) => f.path))("%s does not bind the base keyword list", (path) => {
const { source } = editorHosts.find((f) => f.path === path)!;
// autoCompleteKeywords is the pre-context list; binding it means field

View File

@ -561,10 +561,22 @@ const streamFieldNames = computed(() => streamFields.value.map((f) => f.value));
// builds the field list (dropping the timestamp column) and merges it with
// the SQL keyword and function sets. Nothing about autocomplete is
// reimplemented here.
const { effectiveKeywords, effectiveSuggestions, updateFieldKeywords, resolveFieldValues } =
useSqlSuggestions();
const {
autoCompleteData,
effectiveKeywords,
effectiveSuggestions,
updateFieldKeywords,
resolveFieldValues,
} = useSqlSuggestions();
async function loadStreamFields(streamName: string) {
// Field VALUES are looked up under "org|streamType|streamName|field", so the
// resolver returns nothing at all until this is set. Cleared alongside the
// field list so a de-selected stream cannot keep offering its old values.
autoCompleteData.value.org = org.value ?? "";
autoCompleteData.value.streamType = String(form.config.stream_type ?? "");
autoCompleteData.value.streamName = streamName;
if (!streamName || !form.config.stream_type) {
streamFields.value = [];
updateFieldKeywords([]);