diff --git a/packages/grafana-ui/src/components/CodeMirror/CodeMirrorEditor.test.tsx b/packages/grafana-ui/src/components/CodeMirror/CodeMirrorEditor.test.tsx
new file mode 100644
index 00000000000..30562740707
--- /dev/null
+++ b/packages/grafana-ui/src/components/CodeMirror/CodeMirrorEditor.test.tsx
@@ -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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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 ;
+ }
+
+ const { rerender } = render();
+
+ await waitFor(() => {
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+ });
+
+ rerender();
+
+ 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();
+
+ 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(
+
+ );
+
+ 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({
+ pattern,
+ className: 'highlight',
+ });
+
+ useEffect(() => {
+ setConfig({ pattern, className: 'highlight' });
+ }, [pattern]);
+
+ return ;
+ }
+
+ const { rerender } = render();
+
+ await waitFor(() => {
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+ });
+
+ rerender();
+
+ 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();
+
+ await waitFor(() => {
+ const editor = screen.getByRole('textbox');
+ expect(editor).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('theme functionality', () => {
+ it('renders with default theme', async () => {
+ const onChange = jest.fn();
+
+ render();
+
+ 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();
+
+ 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 ;
+ }
+
+ const { rerender } = render();
+
+ await waitFor(() => {
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+ });
+
+ rerender();
+
+ 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(
+
+ );
+
+ 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({
+ pattern,
+ className: 'highlight',
+ });
+ const [themeFactory, setThemeFactory] = React.useState(
+ () => (theme: GrafanaTheme2) => createGenericTheme(theme)
+ );
+
+ useEffect(() => {
+ setConfig({ pattern, className: 'highlight' });
+ setThemeFactory(() => (theme: GrafanaTheme2) => {
+ const customTheme = createTheme({ colors: { mode } });
+ return createGenericTheme(customTheme);
+ });
+ }, [pattern, mode]);
+
+ return (
+
+ );
+ }
+
+ const { rerender } = render();
+
+ await waitFor(() => {
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+ });
+
+ rerender();
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ const editor = screen.getByRole('textbox');
+ expect(editor).toBeInTheDocument();
+ });
+ });
+
+ it('renders with input styles disabled', async () => {
+ const onChange = jest.fn();
+
+ render();
+
+ await waitFor(() => {
+ const editor = screen.getByRole('textbox');
+ expect(editor).toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/packages/grafana-ui/src/components/CodeMirror/highlight.test.ts b/packages/grafana-ui/src/components/CodeMirror/highlight.test.ts
new file mode 100644
index 00000000000..2c6e506b063
--- /dev/null
+++ b/packages/grafana-ui/src/components/CodeMirror/highlight.test.ts
@@ -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();
+ });
+ });
+});
diff --git a/packages/grafana-ui/src/components/CodeMirror/styles.test.ts b/packages/grafana-ui/src/components/CodeMirror/styles.test.ts
new file mode 100644
index 00000000000..ec9fa1f7bc4
--- /dev/null
+++ b/packages/grafana-ui/src/components/CodeMirror/styles.test.ts
@@ -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();
+ });
+ });
+});
diff --git a/packages/grafana-ui/src/components/DataLinks/codemirrorUtils.test.ts b/packages/grafana-ui/src/components/DataLinks/codemirrorUtils.test.ts
index d63a052c804..889b4bb3ece 100644
--- a/packages/grafana-ui/src/components/DataLinks/codemirrorUtils.test.ts
+++ b/packages/grafana-ui/src/components/DataLinks/codemirrorUtils.test.ts
@@ -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();
});
});
});