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