import * as monaco from 'monaco-editor'; import { buildCompletions } from '../completion/completions'; import type { Situation } from '../parser/types'; const RANGE: monaco.IRange = { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1, }; function makeSituation(partial: Partial): Situation { return { kind: 'NONE', prefix: '', replaceRange: { start: 0, end: 0 }, ...partial, }; } const MOCK_LABEL_NAMES = ['job', 'app', 'namespace']; const MOCK_LABEL_VALUES: Record = { job: ['nginx', 'mysql', 'api'], }; describe('Loki buildCompletions', () => { // ── NONE ── it('returns nothing for NONE situation', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'NONE' }), range: RANGE, variables: ['host'], currentMatchers: [], }); expect(items).toEqual([]); }); // ── IN_SELECTOR_LABEL ── it('returns label names for IN_SELECTOR_LABEL', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_SELECTOR_LABEL' }), range: RANGE, variables: ['host'], currentMatchers: [], fetchLabelNames: async () => MOCK_LABEL_NAMES, }); const labels = items.filter((i) => typeof i.label === 'string' && MOCK_LABEL_NAMES.includes(i.label as string)); expect(labels).toHaveLength(3); }); it('does NOT return variables for IN_SELECTOR_LABEL', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_SELECTOR_LABEL' }), range: RANGE, variables: ['host'], currentMatchers: [], }); const vars = items.filter((i) => String(i.label).startsWith('${')); expect(vars).toHaveLength(0); }); it('handles fetchLabelNames error gracefully', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_SELECTOR_LABEL' }), range: RANGE, variables: [], currentMatchers: [], fetchLabelNames: async () => { throw new Error('fail'); }, }); expect(items).toEqual([]); }); // ── IN_SELECTOR_VALUE ── it('returns variables for IN_SELECTOR_VALUE', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_SELECTOR_VALUE', labelName: 'job' }), range: RANGE, variables: ['host'], currentMatchers: [], }); const vars = items.filter((i) => String(i.label).startsWith('${')); expect(vars.length).toBeGreaterThan(0); }); it('returns label values for IN_SELECTOR_VALUE', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_SELECTOR_VALUE', labelName: 'job' }), range: RANGE, variables: [], currentMatchers: [], fetchLabelValues: async (name) => MOCK_LABEL_VALUES[name] ?? [], }); const vals = items.filter((i) => MOCK_LABEL_VALUES['job'].includes(String(i.label))); expect(vals).toHaveLength(3); }); it('wraps values in quotes when not inside string', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_SELECTOR_VALUE', labelName: 'job' }), range: RANGE, variables: [], currentMatchers: [], fetchLabelValues: async () => ['nginx'], }); const val = items.find((i) => i.label === 'nginx'); expect(val?.insertText).toBe('"nginx"'); }); it('does NOT wrap values in quotes when inside string', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_SELECTOR_VALUE', labelName: 'job', insideString: true }), range: RANGE, variables: [], currentMatchers: [], fetchLabelValues: async () => ['nginx'], }); const val = items.find((i) => i.label === 'nginx'); expect(val?.insertText).toBe('nginx'); }); it('wraps variables in quotes when not inside string', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_SELECTOR_VALUE', labelName: 'job' }), range: RANGE, variables: ['host'], currentMatchers: [], }); const v = items.find((i) => String(i.label) === '${host}'); expect(v?.insertText).toBe('"${host}"'); }); it('does NOT wrap variables in quotes when inside string', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_SELECTOR_VALUE', labelName: 'job', insideString: true }), range: RANGE, variables: ['host'], currentMatchers: [], }); const v = items.find((i) => String(i.label) === '${host}'); expect(v?.insertText).toBe('${host}'); }); // ── AFTER_PIPE ── it('returns line filter operators for AFTER_PIPE', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'AFTER_PIPE' }), range: RANGE, variables: ['host'], currentMatchers: [], }); const ops = items.filter((i) => ['|=', '!=', '|~', '!~'].includes(String(i.label))); expect(ops).toHaveLength(4); }); it('returns pipeline stages for AFTER_PIPE', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'AFTER_PIPE' }), range: RANGE, variables: [], currentMatchers: [], }); const stages = items.filter((i) => ['json', 'logfmt', 'regexp', 'line_format', 'drop', 'keep', 'unpack'].includes(String(i.label))); expect(stages.length).toBeGreaterThan(0); }); it('does NOT return variables for AFTER_PIPE', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'AFTER_PIPE' }), range: RANGE, variables: ['host'], currentMatchers: [], }); const vars = items.filter((i) => String(i.label).startsWith('${')); expect(vars).toHaveLength(0); }); // ── IN_FUNCTION_PARENS ── it('returns metric functions for IN_FUNCTION_PARENS', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_FUNCTION_PARENS' }), range: RANGE, variables: [], currentMatchers: [], }); const funcs = items.filter((i) => ['rate', 'count_over_time', 'sum_over_time', 'rate_counter'].includes(String(i.label))); expect(funcs.length).toBeGreaterThan(0); }); it('returns aggregation functions for IN_FUNCTION_PARENS', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_FUNCTION_PARENS' }), range: RANGE, variables: [], currentMatchers: [], }); const aggr = items.filter((i) => ['sum', 'avg', 'min', 'max', 'count', 'topk'].includes(String(i.label))); expect(aggr.length).toBeGreaterThan(0); }); it('does NOT return variables for IN_FUNCTION_PARENS', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_FUNCTION_PARENS' }), range: RANGE, variables: ['host'], currentMatchers: [], }); const vars = items.filter((i) => String(i.label).startsWith('${')); expect(vars).toHaveLength(0); }); it('does NOT return stream selector snippet for IN_FUNCTION_PARENS', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_FUNCTION_PARENS' }), range: RANGE, variables: [], currentMatchers: [], }); const snippetItems = items.filter((i) => i.label === '{ }'); expect(snippetItems).toHaveLength(0); }); // ── IN_GROUPING ── it('returns label names for IN_GROUPING', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_GROUPING' }), range: RANGE, variables: [], currentMatchers: [], fetchLabelNames: async () => MOCK_LABEL_NAMES, }); const labels = items.filter((i) => MOCK_LABEL_NAMES.includes(String(i.label))); expect(labels).toHaveLength(3); }); // ── IN_LABEL_FILTER ── it('returns operators for IN_LABEL_FILTER', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_LABEL_FILTER' }), range: RANGE, variables: [], currentMatchers: [], }); const ops = items.filter((i) => ['>=', '<=', '>', '<'].includes(String(i.label))); expect(ops.length).toBeGreaterThan(0); }); // ── Completions use plain text (no snippet templates) ── it('returns plain text insertText for functions (no snippet)', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'IN_FUNCTION_PARENS' }), range: RANGE, variables: [], currentMatchers: [], }); const rate = items.find((i) => i.label === 'rate'); expect(rate?.insertText).toBe('rate'); // No placeholder template markers expect(rate?.insertText).not.toContain('${'); }); it('returns plain text insertText for pipeline keywords (no snippet)', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'AFTER_PIPE' }), range: RANGE, variables: [], currentMatchers: [], }); const regexpItem = items.find((i) => i.label === 'regexp'); expect(regexpItem?.insertText).toBe('regexp '); expect(regexpItem?.insertText).not.toContain('${'); }); it('returns plain text for line filter operators (no snippet)', async () => { const items = await buildCompletions({ situation: makeSituation({ kind: 'AFTER_PIPE' }), range: RANGE, variables: [], currentMatchers: [], }); const pipeEq = items.find((i) => i.label === '|='); expect(pipeEq?.insertText).toBe('|= '); expect(pipeEq?.insertText).not.toContain('${'); }); });