2025-04-01 10:38:02 +09:00

458 lines
18 KiB
TypeScript

import { DataSourceInstanceSettings, PluginMetaInfo, PluginType } from '@grafana/data';
import { monacoTypes } from '@grafana/ui';
import { emptyTags, testIntrinsics, v1Tags, v2Tags } from '../SearchTraceQLEditor/utils.test';
import { TempoDatasource } from '../datasource';
import TempoLanguageProvider from '../language_provider';
import { Scope, TempoJsonData } from '../types';
import { CompletionProvider } from './autocomplete';
import { intrinsicsV1, scopes } from './traceql';
const emptyPosition = {} as monacoTypes.Position;
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
}));
describe('CompletionProvider', () => {
it('suggests tags, intrinsics and scopes (API v1)', async () => {
const { provider, model } = setup('{}', 1, v1Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...scopes.map((s) => expect.objectContaining({ label: s, insertText: s })),
...intrinsicsV1.map((s) => expect.objectContaining({ label: s, insertText: s })),
expect.objectContaining({ label: 'bar', insertText: '.bar' }),
expect.objectContaining({ label: 'foo', insertText: '.foo' }),
expect.objectContaining({ label: 'status', insertText: '.status' }),
]);
});
it('suggests tags, intrinsics and scopes (API v2)', async () => {
const { provider, model } = setup('{}', 1, undefined, v2Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...scopes.map((s) => expect.objectContaining({ label: s, insertText: s })),
...testIntrinsics.map((s) => expect.objectContaining({ label: s, insertText: s })),
expect.objectContaining({ label: 'cluster', insertText: '.cluster' }),
expect.objectContaining({ label: 'container', insertText: '.container' }),
expect.objectContaining({ label: 'db', insertText: '.db' }),
]);
});
it('does not wrap the tag value in quotes if the type in the response is something other than "string"', async () => {
const { provider, model } = setup('{.foo=}', 6, v1Tags);
jest.spyOn(provider.languageProvider, 'getOptionsV2').mockImplementation(
() =>
new Promise((resolve) => {
resolve([
{
type: 'int',
value: 'foobar',
label: 'foobar',
},
]);
})
);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
expect.objectContaining({ label: 'foobar', insertText: 'foobar' }),
]);
});
it('wraps the tag value in quotes if the type in the response is set to "string"', async () => {
const { provider, model } = setup('{.foo=}', 6, v1Tags);
jest.spyOn(provider.languageProvider, 'getOptionsV2').mockImplementation(
() =>
new Promise((resolve) => {
resolve([
{
type: 'string',
value: 'foobar',
label: 'foobar',
},
]);
})
);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
expect.objectContaining({ label: 'foobar', insertText: '"foobar"' }),
]);
});
it('inserts the tag value without quotes if the user has entered quotes', async () => {
const { provider, model } = setup('{.foo="}', 6, v1Tags);
jest.spyOn(provider.languageProvider, 'getOptionsV2').mockImplementation(
() =>
new Promise((resolve) => {
resolve([
{
value: 'foobar',
label: 'foobar',
},
]);
})
);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
expect.objectContaining({ label: 'foobar', insertText: 'foobar' }),
]);
});
it('suggests options when inside quotes', async () => {
const { provider, model } = setup('{.foo=""}', 7, undefined, v2Tags);
jest.spyOn(provider.languageProvider, 'getOptionsV2').mockImplementation(
() =>
new Promise((resolve) => {
resolve([
{
type: 'string',
value: 'foobar',
label: 'foobar',
},
]);
})
);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
expect.objectContaining({ label: 'foobar', insertText: 'foobar' }),
]);
});
it('suggests nothing without tags', async () => {
const { provider, model } = setup('{.foo="}', 8, emptyTags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([]);
});
it('suggests tags on empty input (API v1)', async () => {
const { provider, model } = setup('', 0, v1Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...scopes.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}$0 }` })),
...intrinsicsV1.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}$0 }` })),
expect.objectContaining({ label: 'bar', insertText: '{ .bar' }),
expect.objectContaining({ label: 'foo', insertText: '{ .foo' }),
expect.objectContaining({ label: 'status', insertText: '{ .status' }),
]);
});
it('suggests tags on empty input (API v2)', async () => {
const { provider, model } = setup('', 0, undefined, v2Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...scopes.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}$0 }` })),
...testIntrinsics.map((s) => expect.objectContaining({ label: s, insertText: `{ ${s}$0 }` })),
expect.objectContaining({ label: 'cluster', insertText: '{ .cluster' }),
expect.objectContaining({ label: 'container', insertText: '{ .container' }),
expect.objectContaining({ label: 'db', insertText: '{ .db' }),
]);
});
it('only suggests tags after typing the global attribute scope (API v1)', async () => {
const { provider, model } = setup('{.}', 2, v1Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
v1Tags.map((s) => expect.objectContaining({ label: s, insertText: s }))
);
});
it('only suggests tags after typing the global attribute scope (API v2)', async () => {
const { provider, model } = setup('{.}', 2, undefined, v2Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
['cluster', 'container', 'db'].map((s) => expect.objectContaining({ label: s, insertText: s }))
);
});
it('suggests tags after a scope (API v1)', async () => {
const { provider, model } = setup('{ resource. }', 11, v1Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
v1Tags.map((s) => expect.objectContaining({ label: s, insertText: s }))
);
});
it('suggests correct tags after the resource scope (API v2)', async () => {
const { provider, model } = setup('{ resource. }', 11, undefined, v2Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
['cluster', 'container'].map((s) => expect.objectContaining({ label: s, insertText: s }))
);
});
it('suggests correct tags after the span scope (API v2)', async () => {
const { provider, model } = setup('{ span. }', 7, undefined, v2Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
['db'].map((s) => expect.objectContaining({ label: s, insertText: s }))
);
});
it('suggests logical operators and close bracket after the value', async () => {
const { provider, model } = setup('{.foo=300 }', 10, v1Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
[...CompletionProvider.logicalOps, ...CompletionProvider.arithmeticOps, ...CompletionProvider.comparisonOps].map(
(s) => expect.objectContaining({ label: s.label, insertText: s.insertText })
)
);
});
it('suggests spanset combining operators after spanset selector', async () => {
const { provider, model } = setup('{.foo=300} ', 11);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
CompletionProvider.spansetOps.map((s) => expect.objectContaining({ label: s.label, insertText: s.insertText }))
);
});
it.each([
['{.foo=300} | ', 13],
['{.foo=300} && {.bar=200} | ', 27],
['{.foo=300} && {.bar=300} && {.foo=300} | ', 41],
])(
'suggests operators that go after `|` (aggregators, selectorts, ...) - %s, %i',
async (input: string, offset: number) => {
const { provider, model } = setup(input, offset, undefined, v2Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
...CompletionProvider.functions.map((s) =>
expect.objectContaining({ label: s.label, insertText: s.insertText, documentation: s.documentation })
),
...scopes.map((s) => expect.objectContaining({ label: s, insertText: s })),
...testIntrinsics.map((s) => expect.objectContaining({ label: s, insertText: s })),
expect.objectContaining({ label: 'cluster', insertText: '.cluster' }),
expect.objectContaining({ label: 'container', insertText: '.container' }),
expect.objectContaining({ label: 'db', insertText: '.db' }),
]);
}
);
it.each([
['{.foo=300} | avg(.value) ', 25],
['{.foo=300} && {.foo=300} | avg(.value) ', 39],
])(
'suggests comparison operators after aggregator (avg, max, ...) - %s, %i',
async (input: string, offset: number) => {
const { provider, model } = setup(input, offset);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
CompletionProvider.comparisonOps.map((s) =>
expect.objectContaining({ label: s.label, insertText: s.insertText })
)
);
}
);
it.each([
['{.foo=300} | avg(.value) = ', 27],
['{.foo=300} && {.foo=300} | avg(.value) = ', 41],
])('does not suggest after aggregator and comparison operator - %s, %i', async (input: string, offset: number) => {
const { provider, model } = setup(input, offset);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([]);
});
it('suggests when `}` missing', async () => {
const { provider, model } = setup('{ span.http.status_code ', 24);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
[...CompletionProvider.comparisonOps, ...CompletionProvider.logicalOps].map((s) =>
expect.objectContaining({ label: s.label, insertText: s.insertText })
)
);
});
it.each([
['{ .foo }', 7],
['{.foo 300}', 6],
['{.foo 300}', 7],
['{.foo 300}', 8],
['{.foo 300 && .bar = 200}', 6],
['{.foo 300 && .bar = 200}', 7],
['{.foo 300 && .bar 200}', 19],
['{.foo 300 && .bar 200}', 20],
['{ .foo = 1 && .bar }', 19],
['{ .foo = 1 && .bar }', 19],
])('suggests with incomplete spanset - %s, %i', async (input: string, offset: number) => {
const { provider, model } = setup(input, offset);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
[...CompletionProvider.comparisonOps, ...CompletionProvider.logicalOps, ...CompletionProvider.arithmeticOps].map(
(s) => expect.objectContaining({ label: s.label, insertText: s.insertText })
)
);
});
it.each([
['{ .foo }', 6],
['{.foo 300}', 5],
['{.foo 300 && .bar = 200}', 5],
['{ .foo = 1 && .bar }', 18],
])('suggests with incomplete spanset with no space before cursor - %s, %i', async (input: string, offset: number) => {
const { provider, model } = setup(input, offset);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([]);
});
it.each([
['{ span.d }', 8],
['{ span.db }', 9],
])('suggests to complete attribute - %s, %i', async (input: string, offset: number) => {
const { provider, model } = setup(input, offset, undefined, v2Tags);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
expect.objectContaining({ label: 'db', insertText: 'db' }),
]);
});
it.each([
['{.foo=1} {.bar=2}', 8],
['{.foo=1} {.bar=2}', 9],
['{.foo=1} {.bar=2}', 10],
])(
'suggests spanset combining operators in an incomplete, multi-spanset query - %s, %i',
async (input: string, offset: number) => {
const { provider, model } = setup(input, offset);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
CompletionProvider.spansetOps.map((completionItem) =>
expect.objectContaining({
detail: completionItem.detail,
documentation: completionItem.documentation,
insertText: completionItem.insertText,
label: completionItem.label,
})
)
);
}
);
it.each([
// After spanset
['{ span.http.status_code = 200 && }', 33],
['{ span.http.status_code = 200 || }', 33],
['{ span.http.status_code = 200 && }', 34],
['{ span.http.status_code = 200 || }', 34],
['{ span.http.status_code = 200 && }', 35],
['{ span.http.status_code = 200 || }', 35],
['{ .foo = 200 } && ', 18],
['{ .foo = 200 } && ', 19],
['{ .foo = 200 } || ', 18],
['{ .foo = 200 } >> ', 18],
// Between spansets
['{ .foo = 1 } && { .bar = 2 }', 16],
// Inside `()`
['{.foo=1} | avg()', 15],
['{.foo=1} | avg() < 1s', 15],
['{.foo=1} | max() = 3', 15],
['{.foo=1} | by()', 14],
['{.foo=1} | select()', 18],
])('suggests attributes - %s, %i', async (input: string, offset: number) => {
const { provider, model } = setup(input, offset);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual(
[...scopes, ...intrinsicsV1].map((s) => expect.objectContaining({ label: s }))
);
});
it.each([
['{span.ht', 8],
['{span.http', 10],
['{span.http.', 11],
['{span.http.status', 17],
])(
'suggests attributes when containing trigger characters and missing `}`- %s, %i',
async (input: string, offset: number) => {
const { provider, model } = setup(input, offset, undefined, [
{
name: 'span',
tags: ['http.status_code'],
},
]);
const result = await provider.provideCompletionItems(model, emptyPosition);
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
expect.objectContaining({ label: 'http.status_code', insertText: 'http.status_code' }),
]);
}
);
});
function setup(value: string, offset: number, tagsV1?: string[], tagsV2?: Scope[]) {
const ds = new TempoDatasource(defaultSettings);
const lp = new TempoLanguageProvider(ds);
if (tagsV1) {
lp.setV1Tags(tagsV1);
} else if (tagsV2) {
lp.setV2Tags(tagsV2);
}
const provider = new CompletionProvider({ languageProvider: lp, setAlertText: () => {} });
const model = makeModel(value, offset);
provider.monaco = {
Range: {
fromPositions() {
return null;
},
},
languages: {
CompletionItemKind: {
Enum: 1,
EnumMember: 2,
},
},
} as unknown as typeof monacoTypes;
provider.editor = {
getModel() {
return model;
},
} as unknown as monacoTypes.editor.IStandaloneCodeEditor;
return { provider, model } as unknown as { provider: CompletionProvider; model: monacoTypes.editor.ITextModel };
}
function makeModel(value: string, offset: number) {
return {
id: 'test_monaco',
getWordAtPosition() {
return null;
},
getOffsetAt() {
return offset;
},
getValue() {
return value;
},
};
}
const defaultSettings: DataSourceInstanceSettings<TempoJsonData> = {
id: 0,
uid: 'gdev-tempo',
type: 'tracing',
name: 'tempo',
access: 'proxy',
meta: {
id: 'tempo',
name: 'tempo',
type: PluginType.datasource,
info: {} as PluginMetaInfo,
module: '',
baseUrl: '',
},
readOnly: false,
jsonData: {
nodeGraph: {
enabled: true,
},
},
};