1import Plain from 'slate-plain-serializer';
2import { Editor as SlateEditor } from 'slate';
3import LanguageProvider from './language_provider';
4import { PrometheusDatasource } from './datasource';
5import { HistoryItem } from '@grafana/data';
6import { PromQuery } from './types';
7import Mock = jest.Mock;
8import { SearchFunctionType } from '@grafana/ui';
9
10describe('Language completion provider', () => {
11  const datasource: PrometheusDatasource = ({
12    metadataRequest: () => ({ data: { data: [] as any[] } }),
13    getTimeRangeParams: () => ({ start: '0', end: '1' }),
14  } as any) as PrometheusDatasource;
15
16  describe('cleanText', () => {
17    const cleanText = new LanguageProvider(datasource).cleanText;
18    it('does not remove metric or label keys', () => {
19      expect(cleanText('foo')).toBe('foo');
20      expect(cleanText('foo_bar')).toBe('foo_bar');
21    });
22
23    it('keeps trailing space but removes leading', () => {
24      expect(cleanText('foo ')).toBe('foo ');
25      expect(cleanText(' foo')).toBe('foo');
26    });
27
28    it('removes label syntax', () => {
29      expect(cleanText('foo="bar')).toBe('bar');
30      expect(cleanText('foo!="bar')).toBe('bar');
31      expect(cleanText('foo=~"bar')).toBe('bar');
32      expect(cleanText('foo!~"bar')).toBe('bar');
33      expect(cleanText('{bar')).toBe('bar');
34    });
35
36    it('removes previous operators', () => {
37      expect(cleanText('foo + bar')).toBe('bar');
38      expect(cleanText('foo+bar')).toBe('bar');
39      expect(cleanText('foo - bar')).toBe('bar');
40      expect(cleanText('foo * bar')).toBe('bar');
41      expect(cleanText('foo / bar')).toBe('bar');
42      expect(cleanText('foo % bar')).toBe('bar');
43      expect(cleanText('foo ^ bar')).toBe('bar');
44      expect(cleanText('foo and bar')).toBe('bar');
45      expect(cleanText('foo or bar')).toBe('bar');
46      expect(cleanText('foo unless bar')).toBe('bar');
47      expect(cleanText('foo == bar')).toBe('bar');
48      expect(cleanText('foo != bar')).toBe('bar');
49      expect(cleanText('foo > bar')).toBe('bar');
50      expect(cleanText('foo < bar')).toBe('bar');
51      expect(cleanText('foo >= bar')).toBe('bar');
52      expect(cleanText('foo <= bar')).toBe('bar');
53      expect(cleanText('memory')).toBe('memory');
54    });
55
56    it('removes aggregation syntax', () => {
57      expect(cleanText('(bar')).toBe('bar');
58      expect(cleanText('(foo,bar')).toBe('bar');
59      expect(cleanText('(foo, bar')).toBe('bar');
60    });
61
62    it('removes range syntax', () => {
63      expect(cleanText('[1m')).toBe('1m');
64    });
65  });
66
67  describe('fetchSeries', () => {
68    it('should use match[] parameter', () => {
69      const languageProvider = new LanguageProvider(datasource);
70      const fetchSeries = languageProvider.fetchSeries;
71      const requestSpy = jest.spyOn(languageProvider, 'request');
72      fetchSeries('{job="grafana"}');
73      expect(requestSpy).toHaveBeenCalled();
74      expect(requestSpy).toHaveBeenCalledWith(
75        '/api/v1/series',
76        {},
77        { end: '1', 'match[]': '{job="grafana"}', start: '0' }
78      );
79    });
80  });
81
82  describe('empty query suggestions', () => {
83    it('returns no suggestions on empty context', async () => {
84      const instance = new LanguageProvider(datasource);
85      const value = Plain.deserialize('');
86      const result = await instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] });
87      expect(result.context).toBeUndefined();
88      expect(result.suggestions).toMatchObject([]);
89    });
90
91    it('returns no suggestions with metrics on empty context even when metrics were provided', async () => {
92      const instance = new LanguageProvider(datasource);
93      instance.metrics = ['foo', 'bar'];
94      const value = Plain.deserialize('');
95      const result = await instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] });
96      expect(result.context).toBeUndefined();
97      expect(result.suggestions).toMatchObject([]);
98    });
99
100    it('returns history on empty context when history was provided', async () => {
101      const instance = new LanguageProvider(datasource);
102      const value = Plain.deserialize('');
103      const history: Array<HistoryItem<PromQuery>> = [
104        {
105          ts: 0,
106          query: { refId: '1', expr: 'metric' },
107        },
108      ];
109      const result = await instance.provideCompletionItems(
110        { text: '', prefix: '', value, wrapperClasses: [] },
111        { history }
112      );
113      expect(result.context).toBeUndefined();
114
115      expect(result.suggestions).toMatchObject([
116        {
117          label: 'History',
118          items: [
119            {
120              label: 'metric',
121            },
122          ],
123        },
124      ]);
125    });
126  });
127
128  describe('range suggestions', () => {
129    it('returns range suggestions in range context', async () => {
130      const instance = new LanguageProvider(datasource);
131      const value = Plain.deserialize('1');
132      const result = await instance.provideCompletionItems({
133        text: '1',
134        prefix: '1',
135        value,
136        wrapperClasses: ['context-range'],
137      });
138      expect(result.context).toBe('context-range');
139      expect(result.suggestions).toMatchObject([
140        {
141          items: [
142            { label: '$__interval', sortValue: '$__interval' },
143            { label: '$__rate_interval', sortValue: '$__rate_interval' },
144            { label: '$__range', sortValue: '$__range' },
145            { label: '1m', sortValue: '00:01:00' },
146            { label: '5m', sortValue: '00:05:00' },
147            { label: '10m', sortValue: '00:10:00' },
148            { label: '30m', sortValue: '00:30:00' },
149            { label: '1h', sortValue: '01:00:00' },
150            { label: '1d', sortValue: '24:00:00' },
151          ],
152          label: 'Range vector',
153        },
154      ]);
155    });
156  });
157
158  describe('metric suggestions', () => {
159    it('returns history, metrics and function suggestions in an uknown context ', async () => {
160      const instance = new LanguageProvider(datasource);
161      instance.metrics = ['foo', 'bar'];
162      const history: Array<HistoryItem<PromQuery>> = [
163        {
164          ts: 0,
165          query: { refId: '1', expr: 'metric' },
166        },
167      ];
168      let value = Plain.deserialize('m');
169      value = value.setSelection({ anchor: { offset: 1 }, focus: { offset: 1 } });
170      // Even though no metric with `m` is present, we still get metric completion items, filtering is done by the consumer
171      const result = await instance.provideCompletionItems(
172        { text: 'm', prefix: 'm', value, wrapperClasses: [] },
173        { history }
174      );
175      expect(result.context).toBeUndefined();
176      expect(result.suggestions).toMatchObject([
177        {
178          label: 'History',
179          items: [
180            {
181              label: 'metric',
182            },
183          ],
184        },
185        {
186          label: 'Functions',
187        },
188        {
189          label: 'Metrics',
190        },
191      ]);
192    });
193
194    it('returns no suggestions directly after a binary operator', async () => {
195      const instance = new LanguageProvider(datasource);
196      instance.metrics = ['foo', 'bar'];
197      const value = Plain.deserialize('*');
198      const result = await instance.provideCompletionItems({ text: '*', prefix: '', value, wrapperClasses: [] });
199      expect(result.context).toBeUndefined();
200      expect(result.suggestions).toMatchObject([]);
201    });
202
203    it('returns metric suggestions with prefix after a binary operator', async () => {
204      const instance = new LanguageProvider(datasource);
205      instance.metrics = ['foo', 'bar'];
206      const value = Plain.deserialize('foo + b');
207      const ed = new SlateEditor({ value });
208      const valueWithSelection = ed.moveForward(7).value;
209      const result = await instance.provideCompletionItems({
210        text: 'foo + b',
211        prefix: 'b',
212        value: valueWithSelection,
213        wrapperClasses: [],
214      });
215      expect(result.context).toBeUndefined();
216      expect(result.suggestions).toMatchObject([
217        {
218          label: 'Functions',
219        },
220        {
221          label: 'Metrics',
222        },
223      ]);
224    });
225
226    it('returns no suggestions at the beginning of a non-empty function', async () => {
227      const instance = new LanguageProvider(datasource);
228      const value = Plain.deserialize('sum(up)');
229      const ed = new SlateEditor({ value });
230
231      const valueWithSelection = ed.moveForward(4).value;
232      const result = await instance.provideCompletionItems({
233        text: '',
234        prefix: '',
235        value: valueWithSelection,
236        wrapperClasses: [],
237      });
238      expect(result.context).toBeUndefined();
239      expect(result.suggestions.length).toEqual(0);
240    });
241  });
242
243  describe('label suggestions', () => {
244    it('returns default label suggestions on label context and no metric', async () => {
245      const instance = new LanguageProvider(datasource);
246      const value = Plain.deserialize('{}');
247      const ed = new SlateEditor({ value });
248      const valueWithSelection = ed.moveForward(1).value;
249      const result = await instance.provideCompletionItems({
250        text: '',
251        prefix: '',
252        wrapperClasses: ['context-labels'],
253        value: valueWithSelection,
254      });
255      expect(result.context).toBe('context-labels');
256      expect(result.suggestions).toEqual([
257        {
258          items: [{ label: 'job' }, { label: 'instance' }],
259          label: 'Labels',
260          searchFunctionType: SearchFunctionType.Fuzzy,
261        },
262      ]);
263    });
264
265    it('returns label suggestions on label context and metric', async () => {
266      const datasources: PrometheusDatasource = ({
267        metadataRequest: () => ({ data: { data: [{ __name__: 'metric', bar: 'bazinga' }] as any[] } }),
268        getTimeRangeParams: () => ({ start: '0', end: '1' }),
269      } as any) as PrometheusDatasource;
270      const instance = new LanguageProvider(datasources);
271      const value = Plain.deserialize('metric{}');
272      const ed = new SlateEditor({ value });
273      const valueWithSelection = ed.moveForward(7).value;
274      const result = await instance.provideCompletionItems({
275        text: '',
276        prefix: '',
277        wrapperClasses: ['context-labels'],
278        value: valueWithSelection,
279      });
280      expect(result.context).toBe('context-labels');
281      expect(result.suggestions).toEqual([
282        { items: [{ label: 'bar' }], label: 'Labels', searchFunctionType: SearchFunctionType.Fuzzy },
283      ]);
284    });
285
286    it('returns label suggestions on label context but leaves out labels that already exist', async () => {
287      const datasource: PrometheusDatasource = ({
288        metadataRequest: () => ({
289          data: {
290            data: [
291              {
292                __name__: 'metric',
293                bar: 'asdasd',
294                job1: 'dsadsads',
295                job2: 'fsfsdfds',
296                job3: 'dsadsad',
297              },
298            ],
299          },
300        }),
301        getTimeRangeParams: () => ({ start: '0', end: '1' }),
302      } as any) as PrometheusDatasource;
303      const instance = new LanguageProvider(datasource);
304      const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",__name__="metric",}');
305      const ed = new SlateEditor({ value });
306      const valueWithSelection = ed.moveForward(54).value;
307      const result = await instance.provideCompletionItems({
308        text: '',
309        prefix: '',
310        wrapperClasses: ['context-labels'],
311        value: valueWithSelection,
312      });
313      expect(result.context).toBe('context-labels');
314      expect(result.suggestions).toEqual([
315        { items: [{ label: 'bar' }], label: 'Labels', searchFunctionType: SearchFunctionType.Fuzzy },
316      ]);
317    });
318
319    it('returns label value suggestions inside a label value context after a negated matching operator', async () => {
320      const instance = new LanguageProvider(({
321        ...datasource,
322        metadataRequest: () => {
323          return { data: { data: ['value1', 'value2'] } };
324        },
325      } as any) as PrometheusDatasource);
326      const value = Plain.deserialize('{job!=}');
327      const ed = new SlateEditor({ value });
328      const valueWithSelection = ed.moveForward(6).value;
329      const result = await instance.provideCompletionItems({
330        text: '!=',
331        prefix: '',
332        wrapperClasses: ['context-labels'],
333        labelKey: 'job',
334        value: valueWithSelection,
335      });
336      expect(result.context).toBe('context-label-values');
337      expect(result.suggestions).toEqual([
338        {
339          items: [{ label: 'value1' }, { label: 'value2' }],
340          label: 'Label values for "job"',
341          searchFunctionType: SearchFunctionType.Fuzzy,
342        },
343      ]);
344    });
345
346    it('returns a refresher on label context and unavailable metric', async () => {
347      const instance = new LanguageProvider(datasource);
348      const value = Plain.deserialize('metric{}');
349      const ed = new SlateEditor({ value });
350      const valueWithSelection = ed.moveForward(7).value;
351      const result = await instance.provideCompletionItems({
352        text: '',
353        prefix: '',
354        wrapperClasses: ['context-labels'],
355        value: valueWithSelection,
356      });
357      expect(result.context).toBeUndefined();
358      expect(result.suggestions).toEqual([]);
359    });
360
361    it('returns label values on label context when given a metric and a label key', async () => {
362      const instance = new LanguageProvider(({
363        ...datasource,
364        metadataRequest: () => simpleMetricLabelsResponse,
365      } as any) as PrometheusDatasource);
366      const value = Plain.deserialize('metric{bar=ba}');
367      const ed = new SlateEditor({ value });
368      const valueWithSelection = ed.moveForward(13).value;
369      const result = await instance.provideCompletionItems({
370        text: '=ba',
371        prefix: 'ba',
372        wrapperClasses: ['context-labels'],
373        labelKey: 'bar',
374        value: valueWithSelection,
375      });
376      expect(result.context).toBe('context-label-values');
377      expect(result.suggestions).toEqual([
378        { items: [{ label: 'baz' }], label: 'Label values for "bar"', searchFunctionType: SearchFunctionType.Fuzzy },
379      ]);
380    });
381
382    it('returns label suggestions on aggregation context and metric w/ selector', async () => {
383      const instance = new LanguageProvider(({
384        ...datasource,
385        metadataRequest: () => simpleMetricLabelsResponse,
386      } as any) as PrometheusDatasource);
387      const value = Plain.deserialize('sum(metric{foo="xx"}) by ()');
388      const ed = new SlateEditor({ value });
389      const valueWithSelection = ed.moveForward(26).value;
390      const result = await instance.provideCompletionItems({
391        text: '',
392        prefix: '',
393        wrapperClasses: ['context-aggregation'],
394        value: valueWithSelection,
395      });
396      expect(result.context).toBe('context-aggregation');
397      expect(result.suggestions).toEqual([
398        { items: [{ label: 'bar' }], label: 'Labels', searchFunctionType: SearchFunctionType.Fuzzy },
399      ]);
400    });
401
402    it('returns label suggestions on aggregation context and metric w/o selector', async () => {
403      const instance = new LanguageProvider(({
404        ...datasource,
405        metadataRequest: () => simpleMetricLabelsResponse,
406      } as any) as PrometheusDatasource);
407      const value = Plain.deserialize('sum(metric) by ()');
408      const ed = new SlateEditor({ value });
409      const valueWithSelection = ed.moveForward(16).value;
410      const result = await instance.provideCompletionItems({
411        text: '',
412        prefix: '',
413        wrapperClasses: ['context-aggregation'],
414        value: valueWithSelection,
415      });
416      expect(result.context).toBe('context-aggregation');
417      expect(result.suggestions).toEqual([
418        { items: [{ label: 'bar' }], label: 'Labels', searchFunctionType: SearchFunctionType.Fuzzy },
419      ]);
420    });
421
422    it('returns label suggestions inside a multi-line aggregation context', async () => {
423      const instance = new LanguageProvider(({
424        ...datasource,
425        metadataRequest: () => simpleMetricLabelsResponse,
426      } as any) as PrometheusDatasource);
427      const value = Plain.deserialize('sum(\nmetric\n)\nby ()');
428      const aggregationTextBlock = value.document.getBlocks().get(3);
429      const ed = new SlateEditor({ value });
430      ed.moveToStartOfNode(aggregationTextBlock);
431      const valueWithSelection = ed.moveForward(4).value;
432      const result = await instance.provideCompletionItems({
433        text: '',
434        prefix: '',
435        wrapperClasses: ['context-aggregation'],
436        value: valueWithSelection,
437      });
438      expect(result.context).toBe('context-aggregation');
439      expect(result.suggestions).toEqual([
440        {
441          items: [{ label: 'bar' }],
442          label: 'Labels',
443          searchFunctionType: SearchFunctionType.Fuzzy,
444        },
445      ]);
446    });
447
448    it('returns label suggestions inside an aggregation context with a range vector', async () => {
449      const instance = new LanguageProvider(({
450        ...datasource,
451        metadataRequest: () => simpleMetricLabelsResponse,
452      } as any) as PrometheusDatasource);
453      const value = Plain.deserialize('sum(rate(metric[1h])) by ()');
454      const ed = new SlateEditor({ value });
455      const valueWithSelection = ed.moveForward(26).value;
456      const result = await instance.provideCompletionItems({
457        text: '',
458        prefix: '',
459        wrapperClasses: ['context-aggregation'],
460        value: valueWithSelection,
461      });
462      expect(result.context).toBe('context-aggregation');
463      expect(result.suggestions).toEqual([
464        {
465          items: [{ label: 'bar' }],
466          label: 'Labels',
467          searchFunctionType: SearchFunctionType.Fuzzy,
468        },
469      ]);
470    });
471
472    it('returns label suggestions inside an aggregation context with a range vector and label', async () => {
473      const instance = new LanguageProvider(({
474        ...datasource,
475        metadataRequest: () => simpleMetricLabelsResponse,
476      } as any) as PrometheusDatasource);
477      const value = Plain.deserialize('sum(rate(metric{label1="value"}[1h])) by ()');
478      const ed = new SlateEditor({ value });
479      const valueWithSelection = ed.moveForward(42).value;
480      const result = await instance.provideCompletionItems({
481        text: '',
482        prefix: '',
483        wrapperClasses: ['context-aggregation'],
484        value: valueWithSelection,
485      });
486      expect(result.context).toBe('context-aggregation');
487      expect(result.suggestions).toEqual([
488        {
489          items: [{ label: 'bar' }],
490          label: 'Labels',
491          searchFunctionType: SearchFunctionType.Fuzzy,
492        },
493      ]);
494    });
495
496    it('returns no suggestions inside an unclear aggregation context using alternate syntax', async () => {
497      const instance = new LanguageProvider(datasource);
498      const value = Plain.deserialize('sum by ()');
499      const ed = new SlateEditor({ value });
500      const valueWithSelection = ed.moveForward(8).value;
501      const result = await instance.provideCompletionItems({
502        text: '',
503        prefix: '',
504        wrapperClasses: ['context-aggregation'],
505        value: valueWithSelection,
506      });
507      expect(result.context).toBe('context-aggregation');
508      expect(result.suggestions).toEqual([]);
509    });
510
511    it('returns label suggestions inside an aggregation context using alternate syntax', async () => {
512      const instance = new LanguageProvider(({
513        ...datasource,
514        metadataRequest: () => simpleMetricLabelsResponse,
515      } as any) as PrometheusDatasource);
516      const value = Plain.deserialize('sum by () (metric)');
517      const ed = new SlateEditor({ value });
518      const valueWithSelection = ed.moveForward(8).value;
519      const result = await instance.provideCompletionItems({
520        text: '',
521        prefix: '',
522        wrapperClasses: ['context-aggregation'],
523        value: valueWithSelection,
524      });
525      expect(result.context).toBe('context-aggregation');
526      expect(result.suggestions).toEqual([
527        {
528          items: [{ label: 'bar' }],
529          label: 'Labels',
530          searchFunctionType: SearchFunctionType.Fuzzy,
531        },
532      ]);
533    });
534
535    it('does not re-fetch default labels', async () => {
536      const datasource: PrometheusDatasource = ({
537        metadataRequest: jest.fn(() => ({ data: { data: [] as any[] } })),
538        getTimeRangeParams: jest.fn(() => ({ start: '0', end: '1' })),
539      } as any) as PrometheusDatasource;
540
541      const instance = new LanguageProvider(datasource);
542      const value = Plain.deserialize('{}');
543      const ed = new SlateEditor({ value });
544      const valueWithSelection = ed.moveForward(1).value;
545      const args = {
546        text: '',
547        prefix: '',
548        wrapperClasses: ['context-labels'],
549        value: valueWithSelection,
550      };
551      const promise1 = instance.provideCompletionItems(args);
552      // one call for 2 default labels job, instance
553      expect((datasource.metadataRequest as Mock).mock.calls.length).toBe(2);
554      const promise2 = instance.provideCompletionItems(args);
555      expect((datasource.metadataRequest as Mock).mock.calls.length).toBe(2);
556      await Promise.all([promise1, promise2]);
557      expect((datasource.metadataRequest as Mock).mock.calls.length).toBe(2);
558    });
559  });
560  describe('disabled metrics lookup', () => {
561    it('does not issue any metadata requests when lookup is disabled', async () => {
562      const datasource: PrometheusDatasource = ({
563        metadataRequest: jest.fn(() => ({ data: { data: ['foo', 'bar'] as string[] } })),
564        getTimeRangeParams: jest.fn(() => ({ start: '0', end: '1' })),
565        lookupsDisabled: true,
566      } as any) as PrometheusDatasource;
567      const instance = new LanguageProvider(datasource);
568      const value = Plain.deserialize('{}');
569      const ed = new SlateEditor({ value });
570      const valueWithSelection = ed.moveForward(1).value;
571      const args = {
572        text: '',
573        prefix: '',
574        wrapperClasses: ['context-labels'],
575        value: valueWithSelection,
576      };
577
578      expect((datasource.metadataRequest as Mock).mock.calls.length).toBe(0);
579      await instance.start();
580      expect((datasource.metadataRequest as Mock).mock.calls.length).toBe(0);
581      await instance.provideCompletionItems(args);
582      expect((datasource.metadataRequest as Mock).mock.calls.length).toBe(0);
583    });
584    it('issues metadata requests when lookup is not disabled', async () => {
585      const datasource: PrometheusDatasource = ({
586        metadataRequest: jest.fn(() => ({ data: { data: ['foo', 'bar'] as string[] } })),
587        getTimeRangeParams: jest.fn(() => ({ start: '0', end: '1' })),
588        lookupsDisabled: false,
589      } as any) as PrometheusDatasource;
590      const instance = new LanguageProvider(datasource);
591
592      expect((datasource.metadataRequest as Mock).mock.calls.length).toBe(0);
593      await instance.start();
594      expect((datasource.metadataRequest as Mock).mock.calls.length).toBeGreaterThan(0);
595    });
596  });
597});
598
599const simpleMetricLabelsResponse = {
600  data: {
601    data: [
602      {
603        __name__: 'metric',
604        bar: 'baz',
605      },
606    ],
607  },
608};
609