unit tests
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user