unit tests

This commit is contained in:
ismail simsek
2026-01-13 00:06:30 +01:00
parent d84652d8aa
commit c923b58ef7
4 changed files with 1282 additions and 375 deletions
@@ -0,0 +1,404 @@
import { Extension } from '@codemirror/state';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useEffect } from 'react';
import * as React from 'react';
import { createTheme, GrafanaTheme2 } from '@grafana/data';
import { CodeMirrorEditor } from './CodeMirrorEditor';
import { createGenericHighlighter } from './highlight';
import { createGenericTheme } from './styles';
import { HighlighterFactory, SyntaxHighlightConfig, ThemeFactory } from './types';
// Mock DOM elements required by CodeMirror
beforeAll(() => {
Range.prototype.getClientRects = jest.fn(() => ({
item: () => null,
length: 0,
[Symbol.iterator]: jest.fn(),
}));
Range.prototype.getBoundingClientRect = jest.fn(() => ({
x: 0,
y: 0,
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
toJSON: () => {},
}));
});
describe('CodeMirrorEditor', () => {
describe('basic rendering', () => {
it('renders with initial value', async () => {
const onChange = jest.fn();
render(<CodeMirrorEditor value="Hello World" onChange={onChange} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('renders with placeholder when value is empty', async () => {
const onChange = jest.fn();
const placeholder = 'Enter text here';
render(<CodeMirrorEditor value="" onChange={onChange} placeholder={placeholder} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toHaveAttribute('aria-placeholder', placeholder);
});
});
it('renders with aria-label', async () => {
const onChange = jest.fn();
const ariaLabel = 'Code editor';
render(<CodeMirrorEditor value="" onChange={onChange} ariaLabel={ariaLabel} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
// aria-label is set on the parent .cm-editor element
expect(editor.closest('.cm-editor')).toHaveAttribute('aria-label', ariaLabel);
});
});
});
describe('user interaction', () => {
it('calls onChange when user types', async () => {
const onChange = jest.fn();
const user = userEvent.setup();
render(<CodeMirrorEditor value="" onChange={onChange} />);
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
const editor = screen.getByRole('textbox');
await user.click(editor);
await user.keyboard('test');
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
});
it('updates when external value prop changes', async () => {
const onChange = jest.fn();
function TestWrapper({ initialValue }: { initialValue: string }) {
const [value, setValue] = React.useState(initialValue);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
return <CodeMirrorEditor value={value} onChange={onChange} />;
}
const { rerender } = render(<TestWrapper initialValue="first" />);
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
rerender(<TestWrapper initialValue="second" />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
});
describe('highlight functionality', () => {
it('renders with default highlighter using highlightConfig', async () => {
const onChange = jest.fn();
const highlightConfig: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'variable-highlight',
};
render(<CodeMirrorEditor value="${test}" onChange={onChange} highlightConfig={highlightConfig} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('renders with custom highlighter factory', async () => {
const onChange = jest.fn();
const customHighlighter: HighlighterFactory = (config) => {
return config ? createGenericHighlighter(config) : [];
};
const highlightConfig: SyntaxHighlightConfig = {
pattern: /\btest\b/g,
className: 'keyword',
};
render(
<CodeMirrorEditor
value="test keyword"
onChange={onChange}
highlighterFactory={customHighlighter}
highlightConfig={highlightConfig}
/>
);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('updates highlights when highlightConfig changes', async () => {
const onChange = jest.fn();
function TestWrapper({ pattern }: { pattern: RegExp }) {
const [config, setConfig] = React.useState<SyntaxHighlightConfig>({
pattern,
className: 'highlight',
});
useEffect(() => {
setConfig({ pattern, className: 'highlight' });
}, [pattern]);
return <CodeMirrorEditor value="${var}" onChange={onChange} highlightConfig={config} />;
}
const { rerender } = render(<TestWrapper pattern={/\$\{[^}]+\}/g} />);
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
rerender(<TestWrapper pattern={/\d+/g} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('renders without highlighting when highlightConfig is not provided', async () => {
const onChange = jest.fn();
render(<CodeMirrorEditor value="plain text" onChange={onChange} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
});
describe('theme functionality', () => {
it('renders with default theme', async () => {
const onChange = jest.fn();
render(<CodeMirrorEditor value="test" onChange={onChange} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('renders with custom theme factory', async () => {
const onChange = jest.fn();
const customTheme: ThemeFactory = (theme) => {
return createGenericTheme(theme);
};
render(<CodeMirrorEditor value="test" onChange={onChange} themeFactory={customTheme} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('updates theme when themeFactory changes', async () => {
const onChange = jest.fn();
const theme1: ThemeFactory = (theme) => createGenericTheme(theme);
const theme2: ThemeFactory = (theme) => createGenericTheme(theme);
function TestWrapper({ themeFactory }: { themeFactory: ThemeFactory }) {
return <CodeMirrorEditor value="test" onChange={onChange} themeFactory={themeFactory} />;
}
const { rerender } = render(<TestWrapper themeFactory={theme1} />);
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
rerender(<TestWrapper themeFactory={theme2} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
});
describe('combined highlight and theme', () => {
it('renders with both custom theme and highlighter', async () => {
const onChange = jest.fn();
const customTheme: ThemeFactory = (theme) => createGenericTheme(theme);
const highlightConfig: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'variable',
};
render(
<CodeMirrorEditor
value="${variable} test"
onChange={onChange}
themeFactory={customTheme}
highlightConfig={highlightConfig}
/>
);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('updates both theme and highlights together', async () => {
const onChange = jest.fn();
function TestWrapper({ pattern, mode }: { pattern: RegExp; mode: 'light' | 'dark' }) {
const [config, setConfig] = React.useState<SyntaxHighlightConfig>({
pattern,
className: 'highlight',
});
const [themeFactory, setThemeFactory] = React.useState<ThemeFactory>(
() => (theme: GrafanaTheme2) => createGenericTheme(theme)
);
useEffect(() => {
setConfig({ pattern, className: 'highlight' });
setThemeFactory(() => (theme: GrafanaTheme2) => {
const customTheme = createTheme({ colors: { mode } });
return createGenericTheme(customTheme);
});
}, [pattern, mode]);
return (
<CodeMirrorEditor
value="${var} 123"
onChange={onChange}
themeFactory={themeFactory}
highlightConfig={config}
/>
);
}
const { rerender } = render(<TestWrapper pattern={/\$\{[^}]+\}/g} mode="light" />);
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
rerender(<TestWrapper pattern={/\d+/g} mode="dark" />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
});
describe('additional features with highlight and theme', () => {
it('renders with showLineNumbers and highlighting', async () => {
const onChange = jest.fn();
const highlightConfig: SyntaxHighlightConfig = {
pattern: /\d+/g,
className: 'number',
};
render(
<CodeMirrorEditor
value="Line 1\nLine 2\nLine 3"
onChange={onChange}
showLineNumbers={true}
highlightConfig={highlightConfig}
/>
);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('renders with custom extensions alongside theme and highlighter', async () => {
const onChange = jest.fn();
const customExtension: Extension[] = [];
const highlightConfig: SyntaxHighlightConfig = {
pattern: /test/g,
className: 'keyword',
};
render(
<CodeMirrorEditor
value="test"
onChange={onChange}
extensions={customExtension}
highlightConfig={highlightConfig}
/>
);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('applies custom className with theme', async () => {
const onChange = jest.fn();
const customClassName = 'custom-editor';
render(<CodeMirrorEditor value="test" onChange={onChange} className={customClassName} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
});
describe('useInputStyles prop', () => {
it('renders with input styles enabled', async () => {
const onChange = jest.fn();
render(<CodeMirrorEditor value="test" onChange={onChange} useInputStyles={true} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('renders with input styles disabled', async () => {
const onChange = jest.fn();
render(<CodeMirrorEditor value="test" onChange={onChange} useInputStyles={false} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
});
});
@@ -0,0 +1,246 @@
import { EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { createGenericHighlighter } from './highlight';
import { SyntaxHighlightConfig } from './types';
// Mock DOM elements required by CodeMirror
beforeAll(() => {
Range.prototype.getClientRects = jest.fn(() => ({
item: () => null,
length: 0,
[Symbol.iterator]: jest.fn(),
}));
Range.prototype.getBoundingClientRect = jest.fn(() => ({
x: 0,
y: 0,
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
toJSON: () => {},
}));
});
describe('createGenericHighlighter', () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
});
/**
* Helper to create editor with highlighter
*/
function createEditorWithHighlighter(config: SyntaxHighlightConfig, text: string) {
const highlighter = createGenericHighlighter(config);
const state = EditorState.create({
doc: text,
extensions: [highlighter],
});
return new EditorView({ state, parent: container });
}
describe('basic highlighting', () => {
it('highlights text matching the pattern', () => {
const config: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'test-highlight',
};
const view = createEditorWithHighlighter(config, 'Hello ${world}!');
const content = view.dom.textContent;
expect(content).toBe('Hello ${world}!');
view.destroy();
});
it('highlights multiple matches', () => {
const config: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'variable',
};
const view = createEditorWithHighlighter(config, '${first} and ${second} and ${third}');
const content = view.dom.textContent;
expect(content).toBe('${first} and ${second} and ${third}');
view.destroy();
});
it('handles text with no matches', () => {
const config: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'variable',
};
const view = createEditorWithHighlighter(config, 'No variables here');
const content = view.dom.textContent;
expect(content).toBe('No variables here');
view.destroy();
});
it('handles empty text', () => {
const config: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'variable',
};
const view = createEditorWithHighlighter(config, '');
const content = view.dom.textContent;
expect(content).toBe('');
view.destroy();
});
});
describe('pattern variations', () => {
it('highlights with simple word pattern', () => {
const config: SyntaxHighlightConfig = {
pattern: /\btest\b/g,
className: 'keyword',
};
const view = createEditorWithHighlighter(config, 'This is a test of the test word');
const content = view.dom.textContent;
expect(content).toBe('This is a test of the test word');
view.destroy();
});
it('highlights with number pattern', () => {
const config: SyntaxHighlightConfig = {
pattern: /\d+/g,
className: 'number',
};
const view = createEditorWithHighlighter(config, 'Numbers: 123, 456, 789');
const content = view.dom.textContent;
expect(content).toBe('Numbers: 123, 456, 789');
view.destroy();
});
it('highlights with URL pattern', () => {
const config: SyntaxHighlightConfig = {
pattern: /https?:\/\/[^\s]+/g,
className: 'url',
};
const view = createEditorWithHighlighter(config, 'Visit https://grafana.com and http://example.com');
const content = view.dom.textContent;
expect(content).toBe('Visit https://grafana.com and http://example.com');
view.destroy();
});
});
describe('dynamic updates', () => {
it('updates highlights when document changes', () => {
const config: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'variable',
};
const view = createEditorWithHighlighter(config, 'Initial text');
// Update document
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: 'New ${variable} text' },
});
const content = view.dom.textContent;
expect(content).toBe('New ${variable} text');
view.destroy();
});
it('updates highlights when adding to document', () => {
const config: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'variable',
};
const view = createEditorWithHighlighter(config, 'Start ');
// Insert text
view.dispatch({
changes: { from: view.state.doc.length, insert: '${var}' },
});
const content = view.dom.textContent;
expect(content).toBe('Start ${var}');
view.destroy();
});
it('removes highlights when pattern no longer matches', () => {
const config: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'variable',
};
const view = createEditorWithHighlighter(config, '${variable}');
// Replace with non-matching text
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: 'plain text' },
});
const content = view.dom.textContent;
expect(content).toBe('plain text');
view.destroy();
});
});
describe('complex patterns', () => {
it('highlights nested brackets', () => {
const config: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'variable',
};
const view = createEditorWithHighlighter(config, 'Text with ${var1} and ${var2} variables');
const content = view.dom.textContent;
expect(content).toBe('Text with ${var1} and ${var2} variables');
view.destroy();
});
it('highlights overlapping patterns correctly', () => {
const config: SyntaxHighlightConfig = {
pattern: /test/g,
className: 'keyword',
};
const view = createEditorWithHighlighter(config, 'testtesttest');
const content = view.dom.textContent;
expect(content).toBe('testtesttest');
view.destroy();
});
});
describe('multiline text', () => {
it('highlights patterns across multiple lines', () => {
const config: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'variable',
};
const text = 'Line 1 ${var1}\nLine 2 ${var2}\nLine 3';
const view = createEditorWithHighlighter(config, text);
// Check the document state instead of textContent (which doesn't preserve newlines in DOM)
const docContent = view.state.doc.toString();
expect(docContent).toBe(text);
view.destroy();
});
});
});
@@ -0,0 +1,189 @@
import { Compartment, EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { createTheme } from '@grafana/data';
import { createGenericTheme } from './styles';
// Mock DOM elements required by CodeMirror
beforeAll(() => {
Range.prototype.getClientRects = jest.fn(() => ({
item: () => null,
length: 0,
[Symbol.iterator]: jest.fn(),
}));
Range.prototype.getBoundingClientRect = jest.fn(() => ({
x: 0,
y: 0,
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
toJSON: () => {},
}));
});
describe('createGenericTheme', () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
});
/**
* Helper to create editor with theme
*/
function createEditorWithTheme(themeMode: 'light' | 'dark', text = 'test') {
const theme = createTheme({ colors: { mode: themeMode } });
const themeExtension = createGenericTheme(theme);
const state = EditorState.create({
doc: text,
extensions: [themeExtension],
});
return new EditorView({ state, parent: container });
}
describe('theme creation', () => {
it('creates theme for light mode', () => {
const theme = createTheme({ colors: { mode: 'light' } });
const themeExtension = createGenericTheme(theme);
expect(themeExtension).toBeDefined();
});
it('creates theme for dark mode', () => {
const theme = createTheme({ colors: { mode: 'dark' } });
const themeExtension = createGenericTheme(theme);
expect(themeExtension).toBeDefined();
});
it('applies theme to editor in light mode', () => {
const view = createEditorWithTheme('light');
expect(view).toBeDefined();
expect(view.dom).toBeInstanceOf(HTMLElement);
view.destroy();
});
it('applies theme to editor in dark mode', () => {
const view = createEditorWithTheme('dark');
expect(view).toBeDefined();
expect(view.dom).toBeInstanceOf(HTMLElement);
view.destroy();
});
});
describe('theme properties', () => {
it('applies typography settings from theme', () => {
const theme = createTheme({ colors: { mode: 'light' } });
const themeExtension = createGenericTheme(theme);
const state = EditorState.create({
doc: 'test',
extensions: [themeExtension],
});
const view = new EditorView({ state, parent: container });
// Check that editor is created successfully
expect(view.dom).toBeInstanceOf(HTMLElement);
view.destroy();
});
it('applies color settings from theme', () => {
const theme = createTheme({ colors: { mode: 'dark' } });
const themeExtension = createGenericTheme(theme);
const state = EditorState.create({
doc: 'test',
extensions: [themeExtension],
});
const view = new EditorView({ state, parent: container });
expect(view.dom).toBeInstanceOf(HTMLElement);
view.destroy();
});
});
describe('theme updates', () => {
it('switches from light to dark theme', () => {
const themeCompartment = new Compartment();
const lightTheme = createTheme({ colors: { mode: 'light' } });
const lightThemeExtension = createGenericTheme(lightTheme);
const state = EditorState.create({
doc: 'test',
extensions: [themeCompartment.of(lightThemeExtension)],
});
const view = new EditorView({ state, parent: container });
// Update to dark theme
const darkTheme = createTheme({ colors: { mode: 'dark' } });
const darkThemeExtension = createGenericTheme(darkTheme);
view.dispatch({
effects: themeCompartment.reconfigure(darkThemeExtension),
});
expect(view.dom).toBeInstanceOf(HTMLElement);
view.destroy();
});
it('switches from dark to light theme', () => {
const themeCompartment = new Compartment();
const darkTheme = createTheme({ colors: { mode: 'dark' } });
const darkThemeExtension = createGenericTheme(darkTheme);
const state = EditorState.create({
doc: 'test',
extensions: [themeCompartment.of(darkThemeExtension)],
});
const view = new EditorView({ state, parent: container });
// Update to light theme
const lightTheme = createTheme({ colors: { mode: 'light' } });
const lightThemeExtension = createGenericTheme(lightTheme);
view.dispatch({
effects: themeCompartment.reconfigure(lightThemeExtension),
});
expect(view.dom).toBeInstanceOf(HTMLElement);
view.destroy();
});
});
describe('editor rendering', () => {
it('renders editor with light theme and content', () => {
const view = createEditorWithTheme('light', 'Hello world!');
expect(view.dom).toHaveTextContent('Hello world!');
view.destroy();
});
it('renders editor with dark theme and content', () => {
const view = createEditorWithTheme('dark', 'Hello world!');
expect(view.dom).toHaveTextContent('Hello world!');
view.destroy();
});
it('renders multiline content with theme', () => {
const text = 'Line 1\nLine 2\nLine 3';
const view = createEditorWithTheme('light', text);
// Check the document state instead of textContent (which doesn't preserve newlines in DOM)
const docContent = view.state.doc.toString();
expect(docContent).toBe(text);
view.destroy();
});
});
});
@@ -1,412 +1,480 @@
import { CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState, Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { DataLinkBuiltInVars, VariableOrigin, VariableSuggestion } from '@grafana/data';
import { createTheme, DataLinkBuiltInVars, VariableOrigin, VariableSuggestion } from '@grafana/data';
import { dataLinkAutocompletion } from './codemirrorUtils';
import {
createDataLinkAutocompletion,
createDataLinkHighlighter,
createDataLinkTheme,
dataLinkAutocompletion,
} from './codemirrorUtils';
describe('dataLinkAutocompletion', () => {
const mockSuggestions: VariableSuggestion[] = [
{
value: DataLinkBuiltInVars.seriesName,
label: '__series.name',
documentation: 'Series name',
origin: VariableOrigin.Series,
},
{
value: DataLinkBuiltInVars.fieldName,
label: '__field.name',
documentation: 'Field name',
origin: VariableOrigin.Field,
},
{
value: 'myVar',
label: 'myVar',
documentation: 'Custom variable',
origin: VariableOrigin.Template,
},
{
value: DataLinkBuiltInVars.includeVars,
label: '__all_variables',
documentation: 'Include all variables',
origin: VariableOrigin.BuiltIn,
},
];
// Mock DOM elements required by CodeMirror
beforeAll(() => {
Range.prototype.getClientRects = jest.fn(() => ({
item: () => null,
length: 0,
[Symbol.iterator]: jest.fn(),
}));
Range.prototype.getBoundingClientRect = jest.fn(() => ({
x: 0,
y: 0,
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
toJSON: () => {},
}));
});
// Helper function to create a mock CompletionContext
function createMockContext(text: string, pos: number, explicit = false): CompletionContext {
const state = EditorState.create({ doc: text });
const mockSuggestions: VariableSuggestion[] = [
{
value: DataLinkBuiltInVars.seriesName,
label: '__series.name',
documentation: 'Series name',
origin: VariableOrigin.Series,
},
{
value: DataLinkBuiltInVars.fieldName,
label: '__field.name',
documentation: 'Field name',
origin: VariableOrigin.Field,
},
{
value: 'myVar',
label: 'myVar',
documentation: 'Custom variable',
origin: VariableOrigin.Template,
},
{
value: DataLinkBuiltInVars.includeVars,
label: '__all_variables',
documentation: 'Include all variables',
origin: VariableOrigin.Template,
},
];
return {
state,
pos,
explicit,
matchBefore: (regex: RegExp) => {
const textBefore = text.slice(0, pos);
const match = textBefore.match(regex);
if (match) {
describe('codemirrorUtils', () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
});
/**
* Helper to create editor with extensions
*/
function createEditor(text: string, extensions: Extension | Extension[]) {
const state = EditorState.create({
doc: text,
extensions,
});
return new EditorView({ state, parent: container });
}
describe('createDataLinkTheme', () => {
it('creates theme for light mode', () => {
const theme = createTheme({ colors: { mode: 'light' } });
const themeExtension = createDataLinkTheme(theme);
expect(themeExtension).toBeDefined();
expect(Array.isArray(themeExtension)).toBe(true);
});
it('creates theme for dark mode', () => {
const theme = createTheme({ colors: { mode: 'dark' } });
const themeExtension = createDataLinkTheme(theme);
expect(themeExtension).toBeDefined();
expect(Array.isArray(themeExtension)).toBe(true);
});
it('applies theme to editor', () => {
const theme = createTheme({ colors: { mode: 'light' } });
const themeExtension = createDataLinkTheme(theme);
const view = createEditor('${test}', themeExtension);
expect(view.dom).toBeInstanceOf(HTMLElement);
view.destroy();
});
it('applies theme with variable highlighting', () => {
const theme = createTheme({ colors: { mode: 'dark' } });
const themeExtension = createDataLinkTheme(theme);
const highlighter = createDataLinkHighlighter();
const view = createEditor('${variable}', [themeExtension, highlighter]);
expect(view.dom).toBeInstanceOf(HTMLElement);
const content = view.dom.textContent;
expect(content).toBe('${variable}');
view.destroy();
});
});
describe('createDataLinkHighlighter', () => {
it('creates highlighter extension', () => {
const highlighter = createDataLinkHighlighter();
expect(highlighter).toBeDefined();
});
it('highlights single variable', () => {
const highlighter = createDataLinkHighlighter();
const view = createEditor('${variable}', [highlighter]);
const content = view.dom.textContent;
expect(content).toBe('${variable}');
view.destroy();
});
it('highlights multiple variables', () => {
const highlighter = createDataLinkHighlighter();
const view = createEditor('${var1} and ${var2}', [highlighter]);
const content = view.dom.textContent;
expect(content).toBe('${var1} and ${var2}');
view.destroy();
});
it('highlights variables in URLs', () => {
const highlighter = createDataLinkHighlighter();
const view = createEditor('https://example.com?id=${id}&name=${name}', [highlighter]);
const content = view.dom.textContent;
expect(content).toBe('https://example.com?id=${id}&name=${name}');
view.destroy();
});
it('does not highlight incomplete variables', () => {
const highlighter = createDataLinkHighlighter();
const view = createEditor('${incomplete', [highlighter]);
const content = view.dom.textContent;
expect(content).toBe('${incomplete');
view.destroy();
});
it('highlights variables with dots', () => {
const highlighter = createDataLinkHighlighter();
const view = createEditor('${__series.name}', [highlighter]);
const content = view.dom.textContent;
expect(content).toBe('${__series.name}');
view.destroy();
});
it('highlights variables with underscores', () => {
const highlighter = createDataLinkHighlighter();
const view = createEditor('${__field_name}', [highlighter]);
const content = view.dom.textContent;
expect(content).toBe('${__field_name}');
view.destroy();
});
it('updates highlights when document changes', () => {
const highlighter = createDataLinkHighlighter();
const view = createEditor('initial', [highlighter]);
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: '${newVar}' },
});
const content = view.dom.textContent;
expect(content).toBe('${newVar}');
view.destroy();
});
});
describe('dataLinkAutocompletion', () => {
/**
* Helper to create a mock completion context
*/
function createMockContext(
text: string,
pos: number,
explicit = false
): CompletionContext {
const state = EditorState.create({ doc: text });
return {
state,
pos,
explicit,
matchBefore: (regex: RegExp) => {
const before = text.slice(0, pos);
const match = before.match(regex);
if (!match) {
return null;
}
const from = pos - match[0].length;
return {
from: pos - match[0].length,
from,
to: pos,
text: match[0],
};
}
return null;
},
tokenBefore: jest.fn().mockReturnValue(null),
aborted: false,
addEventListener: jest.fn(),
};
}
describe('with no suggestions', () => {
it('should return null when suggestions array is empty', () => {
const autocompletion = dataLinkAutocompletion([]);
const context = createMockContext('$', 1);
const result = autocompletion(context);
expect(result).toBeNull();
});
});
describe('explicit completion (Ctrl+Space)', () => {
it('should show all suggestions at cursor position when triggered explicitly', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('https://grafana.com', 19, true);
const result = autocompletion(context) as CompletionResult;
expect(result).not.toBeNull();
expect(result.from).toBe(19);
expect(result.options).toHaveLength(4);
});
it('should include proper labels and details for all suggestions', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('test', 4, true);
const result = autocompletion(context) as CompletionResult;
expect(result.options[0]).toMatchObject({
label: '__series.name',
detail: VariableOrigin.Series,
info: 'Series name',
type: 'variable',
});
expect(result.options[1]).toMatchObject({
label: '__field.name',
detail: VariableOrigin.Field,
info: 'Field name',
type: 'variable',
});
});
it('should apply template variables with :queryparam suffix', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('test', 4, true);
const result = autocompletion(context) as CompletionResult;
const templateVar = result.options.find((opt) => opt.label === 'myVar');
expect(templateVar?.apply).toBe('${myVar:queryparam}');
});
it('should apply non-template variables without :queryparam suffix', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('test', 4, true);
const result = autocompletion(context) as CompletionResult;
const seriesVar = result.options.find((opt) => opt.label === '__series.name');
expect(seriesVar?.apply).toBe(`\${${DataLinkBuiltInVars.seriesName}}`);
});
it('should apply includeVars without :queryparam suffix', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('test', 4, true);
const result = autocompletion(context) as CompletionResult;
const includeVars = result.options.find((opt) => opt.label === '__all_variables');
expect(includeVars?.apply).toBe(`\${${DataLinkBuiltInVars.includeVars}}`);
});
});
describe('trigger character matching', () => {
it('should return null when no trigger character is present', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('https://grafana.com', 19);
const result = autocompletion(context);
expect(result).toBeNull();
});
it('should return null when text does not start with $ or =', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('test', 4);
const result = autocompletion(context);
expect(result).toBeNull();
});
});
describe('$ trigger character', () => {
it('should show completions when $ is typed', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('$', 1);
const result = autocompletion(context) as CompletionResult;
expect(result).not.toBeNull();
expect(result.options).toHaveLength(4);
});
it('should show completions when ${ is typed', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('${', 2);
const result = autocompletion(context) as CompletionResult;
expect(result).not.toBeNull();
expect(result.options).toHaveLength(4);
});
it('should show completions when typing partial variable name', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('${my', 4);
const result = autocompletion(context) as CompletionResult;
expect(result).not.toBeNull();
expect(result.options).toHaveLength(4);
});
it('should show completions with dots in variable name', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('${__series.name', 15);
const result = autocompletion(context) as CompletionResult;
expect(result).not.toBeNull();
expect(result.options).toHaveLength(4);
});
it('should position completion from current position for single $', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('$', 1);
const result = autocompletion(context) as CompletionResult;
expect(result.from).toBe(1);
});
it('should position completion from $ for partial match', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('${test', 6);
const result = autocompletion(context) as CompletionResult;
expect(result.from).toBe(0);
});
});
describe('= trigger character', () => {
it('should show completions when = is typed', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('=', 1);
const result = autocompletion(context) as CompletionResult;
expect(result).not.toBeNull();
expect(result.options).toHaveLength(4);
});
it('should show completions after = in URL query parameter', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('https://example.com?param=', 26);
const result = autocompletion(context) as CompletionResult;
expect(result).not.toBeNull();
expect(result.options).toHaveLength(4);
});
it('should position completion from current position for single =', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('=', 1);
const result = autocompletion(context) as CompletionResult;
expect(result.from).toBe(1);
});
});
describe('variable application', () => {
it('should use custom apply function for single character trigger', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('$', 1);
const result = autocompletion(context) as CompletionResult;
expect(typeof result.options[0].apply).toBe('function');
});
it('should use string apply for multi-character match', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('${test', 6);
const result = autocompletion(context) as CompletionResult;
expect(typeof result.options[0].apply).toBe('string');
});
it('should apply correct variable syntax for Series origin', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('${test', 6);
const result = autocompletion(context) as CompletionResult;
const seriesVar = result.options.find((opt) => opt.label === '__series.name');
expect(seriesVar?.apply).toBe(`\${${DataLinkBuiltInVars.seriesName}}`);
});
it('should apply correct variable syntax for Field origin', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('${test', 6);
const result = autocompletion(context) as CompletionResult;
const fieldVar = result.options.find((opt) => opt.label === '__field.name');
expect(fieldVar?.apply).toBe(`\${${DataLinkBuiltInVars.fieldName}}`);
});
it('should apply correct variable syntax for Template origin', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('${test', 6);
const result = autocompletion(context) as CompletionResult;
const templateVar = result.options.find((opt) => opt.label === 'myVar');
expect(templateVar?.apply).toBe('${myVar:queryparam}');
});
it('should apply correct variable syntax for includeVars built-in', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('${test', 6);
const result = autocompletion(context) as CompletionResult;
const includeVars = result.options.find((opt) => opt.label === '__all_variables');
expect(includeVars?.apply).toBe(`\${${DataLinkBuiltInVars.includeVars}}`);
});
});
describe('edge cases', () => {
it('should handle empty document', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('', 0);
const result = autocompletion(context);
expect(result).toBeNull();
});
it('should handle $ at the end of longer text', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('https://grafana.com?var=$', 25);
const result = autocompletion(context) as CompletionResult;
expect(result).not.toBeNull();
expect(result.from).toBe(25);
});
it('should handle = at the end of longer text', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('https://grafana.com?var=', 24);
const result = autocompletion(context) as CompletionResult;
expect(result).not.toBeNull();
expect(result.from).toBe(24);
});
it('should handle mixed content with ${ in the middle', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('https://grafana.com?var=${', 26);
const result = autocompletion(context) as CompletionResult;
expect(result).not.toBeNull();
expect(result.options).toHaveLength(4);
});
it('should handle single suggestion', () => {
const singleSuggestion: VariableSuggestion[] = [
{
value: 'test',
label: 'test',
documentation: 'Test variable',
origin: VariableOrigin.Template,
},
];
const autocompletion = dataLinkAutocompletion(singleSuggestion);
const context = createMockContext('$', 1);
const result = autocompletion(context) as CompletionResult;
aborted: false,
addEventListener: jest.fn(),
} as unknown as CompletionContext;
}
expect(result).not.toBeNull();
expect(result.options).toHaveLength(1);
expect(result.options[0].label).toBe('test');
describe('explicit completion', () => {
it('shows all suggestions on explicit trigger', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('', 0, true);
const result = autocomplete(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(4);
expect(result?.from).toBe(0);
});
it('formats series variable correctly', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('', 0, true);
const result = autocomplete(context);
const seriesOption = result?.options.find((opt) => opt.label === '__series.name');
expect(seriesOption).toBeDefined();
expect(seriesOption?.apply).toBe('${__series.name}');
});
it('formats field variable correctly', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('', 0, true);
const result = autocomplete(context);
const fieldOption = result?.options.find((opt) => opt.label === '__field.name');
expect(fieldOption).toBeDefined();
expect(fieldOption?.apply).toBe('${__field.name}');
});
it('formats template variable with queryparam', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('', 0, true);
const result = autocomplete(context);
const templateOption = result?.options.find((opt) => opt.label === 'myVar');
expect(templateOption).toBeDefined();
expect(templateOption?.apply).toBe('${myVar:queryparam}');
});
it('formats includeVars without queryparam', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('', 0, true);
const result = autocomplete(context);
const includeVarsOption = result?.options.find((opt) => opt.label === '__all_variables');
expect(includeVarsOption).toBeDefined();
expect(includeVarsOption?.apply).toBe('${__all_variables}');
});
it('returns null when no suggestions available', () => {
const autocomplete = dataLinkAutocompletion([]);
const context = createMockContext('', 0, true);
const result = autocomplete(context);
expect(result).toBeNull();
});
});
it('should include all metadata fields in completion options', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('$', 1);
const result = autocompletion(context) as CompletionResult;
describe('trigger on $ character', () => {
it('shows completions after typing $', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('$', 1, false);
result.options.forEach((option) => {
expect(option).toHaveProperty('label');
expect(option).toHaveProperty('detail');
expect(option).toHaveProperty('info');
expect(option).toHaveProperty('apply');
expect(option).toHaveProperty('type');
expect(option.type).toBe('variable');
const result = autocomplete(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(4);
});
it('shows completions after typing ${', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('${', 2, false);
const result = autocomplete(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(4);
});
it('shows completions while typing variable name', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('${ser', 5, false);
const result = autocomplete(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(4);
});
it('does not show completions without trigger', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('test', 4, false);
const result = autocomplete(context);
expect(result).toBeNull();
});
});
describe('trigger on = character', () => {
it('shows completions after typing =', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('url?param=', 10, false);
const result = autocomplete(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(4);
});
it('shows completions after typing =${', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('url?param=${', 12, false);
const result = autocomplete(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(4);
});
});
describe('option metadata', () => {
it('includes label for all options', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('$', 1, false);
const result = autocomplete(context);
result?.options.forEach((option) => {
expect(option.label).toBeDefined();
expect(typeof option.label).toBe('string');
});
});
it('includes detail (origin) for all options', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('$', 1, false);
const result = autocomplete(context);
result?.options.forEach((option) => {
expect(option.detail).toBeDefined();
});
});
it('includes documentation info for all options', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('$', 1, false);
const result = autocomplete(context);
result?.options.forEach((option) => {
expect(option.info).toBeDefined();
expect(typeof option.info).toBe('string');
});
});
it('sets type to variable for all options', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('$', 1, false);
const result = autocomplete(context);
result?.options.forEach((option) => {
expect(option.type).toBe('variable');
});
});
});
});
describe('completion context states', () => {
it('should handle explicit completion in middle of text', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('https://example.com', 10, true);
const result = autocompletion(context) as CompletionResult;
describe('createDataLinkAutocompletion', () => {
it('creates autocompletion extension', () => {
const extension = createDataLinkAutocompletion(mockSuggestions);
expect(result).not.toBeNull();
expect(result.from).toBe(10);
expect(result.options).toHaveLength(4);
expect(extension).toBeDefined();
});
it('should handle explicit completion at start of document', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('test', 0, true);
const result = autocompletion(context) as CompletionResult;
it('applies autocompletion to editor', () => {
const extension = createDataLinkAutocompletion(mockSuggestions);
const view = createEditor('', [extension]);
expect(result).not.toBeNull();
expect(result.from).toBe(0);
expect(result.options).toHaveLength(4);
expect(view.dom).toBeInstanceOf(HTMLElement);
view.destroy();
});
it('should not show completions for text without trigger when not explicit', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('test', 4);
const result = autocompletion(context);
it('works with empty suggestions', () => {
const extension = createDataLinkAutocompletion([]);
const view = createEditor('', [extension]);
expect(result).toBeNull();
expect(view.dom).toBeInstanceOf(HTMLElement);
view.destroy();
});
it('integrates with theme and highlighter', () => {
const theme = createTheme({ colors: { mode: 'light' } });
const themeExtension = createDataLinkTheme(theme);
const highlighter = createDataLinkHighlighter();
const autocompletion = createDataLinkAutocompletion(mockSuggestions);
const view = createEditor('${test}', [themeExtension, highlighter, autocompletion]);
expect(view.dom).toBeInstanceOf(HTMLElement);
const content = view.dom.textContent;
expect(content).toBe('${test}');
view.destroy();
});
});
describe('multiple variables in text', () => {
it('should handle completion after existing variable', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('${myVar}$', 9);
const result = autocompletion(context) as CompletionResult;
describe('integration tests', () => {
it('combines all utilities together', () => {
const theme = createTheme({ colors: { mode: 'dark' } });
const themeExtension = createDataLinkTheme(theme);
const highlighter = createDataLinkHighlighter();
const autocompletion = createDataLinkAutocompletion(mockSuggestions);
expect(result).not.toBeNull();
expect(result.from).toBe(9);
const view = createEditor(
'https://example.com?id=${id}&name=${name}',
[themeExtension, highlighter, autocompletion]
);
expect(view.dom).toBeInstanceOf(HTMLElement);
const content = view.dom.textContent;
expect(content).toBe('https://example.com?id=${id}&name=${name}');
view.destroy();
});
it('should handle completion between variables', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('${var1}$${var2}', 8);
const result = autocompletion(context) as CompletionResult;
it('handles dynamic content updates', () => {
const theme = createTheme({ colors: { mode: 'light' } });
const themeExtension = createDataLinkTheme(theme);
const highlighter = createDataLinkHighlighter();
expect(result).not.toBeNull();
expect(result.from).toBe(8);
});
const view = createEditor('initial', [themeExtension, highlighter]);
it('should handle completion in URL with multiple query params', () => {
const autocompletion = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('?a=${var1}&b=$', 14);
const result = autocompletion(context) as CompletionResult;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: '${variable} updated' },
});
expect(result).not.toBeNull();
expect(result.from).toBe(14);
const content = view.dom.textContent;
expect(content).toBe('${variable} updated');
view.destroy();
});
});
});