mirror of
https://github.com/pgadmin-org/pgadmin4.git
synced 2026-08-19 01:15:04 -05:00
Core LLM integration infrastructure to allow pgAdmin to connect to AI providers. #9641
* Core infrastructure for LLM integration. * Add support for a number of different AI generated reports on security, performance, and schema design on servers, databases, and schemas, as appropriate. * Add a Natural Language AI assistant to the Query Tool. * Add an AI Insights panel to the EXPLAIN tool in the Query Tool, to analyse and report on issues in query plans.
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
/////////////////////////////////////////////////////////////
|
||||
//
|
||||
// pgAdmin 4 - PostgreSQL Tools
|
||||
//
|
||||
// Copyright (C) 2013 - 2025, The pgAdmin Development Team
|
||||
// This software is released under the PostgreSQL Licence
|
||||
//
|
||||
//////////////////////////////////////////////////////////////
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { withTheme } from '../fake_theme';
|
||||
import AIInsights from '../../../pgadmin/static/js/Explain/AIInsights';
|
||||
|
||||
// Mock url_for
|
||||
jest.mock('sources/url_for', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn((endpoint) => `/mock/${endpoint}`),
|
||||
}));
|
||||
|
||||
// Mock gettext
|
||||
jest.mock('sources/gettext', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn((str) => str),
|
||||
}));
|
||||
|
||||
// Mock the Loader component
|
||||
jest.mock('../../../pgadmin/static/js/components/Loader', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="loader">Loading...</div>,
|
||||
}));
|
||||
|
||||
// Mock EmptyPanelMessage
|
||||
jest.mock('../../../pgadmin/static/js/components/EmptyPanelMessage', () => ({
|
||||
__esModule: true,
|
||||
default: ({ text }) => <div data-testid="empty-message">{text}</div>,
|
||||
}));
|
||||
|
||||
describe('AIInsights Component', () => {
|
||||
let ThemedAIInsights;
|
||||
|
||||
const mockPlans = [{
|
||||
Plan: {
|
||||
'Node Type': 'Seq Scan',
|
||||
'Relation Name': 'users',
|
||||
'Total Cost': 100.0,
|
||||
'Plan Rows': 1000,
|
||||
},
|
||||
}];
|
||||
|
||||
beforeAll(() => {
|
||||
ThemedAIInsights = withTheme(AIInsights);
|
||||
|
||||
// Mock fetch for SSE
|
||||
global.fetch = jest.fn();
|
||||
|
||||
// Mock window.getComputedStyle
|
||||
window.getComputedStyle = jest.fn().mockReturnValue({
|
||||
color: 'rgb(0, 0, 0)',
|
||||
});
|
||||
|
||||
// Mock clipboard API
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: jest.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should show empty message when no plans provided', () => {
|
||||
render(<ThemedAIInsights plans={null} isActive={true} />);
|
||||
expect(screen.getByTestId('empty-message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show idle state with analyze button when plans provided but not active', () => {
|
||||
render(
|
||||
<ThemedAIInsights
|
||||
plans={mockPlans}
|
||||
sql="SELECT * FROM users"
|
||||
transId={12345}
|
||||
isActive={false}
|
||||
/>
|
||||
);
|
||||
// Component should be in idle state when not active
|
||||
expect(screen.getByText('Analyze')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Click Analyze to get AI-powered insights/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should start analysis when tab becomes active', async () => {
|
||||
const mockReader = {
|
||||
read: jest.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: new TextEncoder().encode('data: {"type":"thinking","message":"Analyzing..."}\n\n'),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: new TextEncoder().encode('data: {"type":"complete","bottlenecks":[],"recommendations":[],"summary":"Plan looks good"}\n\n'),
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
};
|
||||
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => mockReader,
|
||||
},
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<ThemedAIInsights
|
||||
plans={mockPlans}
|
||||
sql="SELECT * FROM users"
|
||||
transId={12345}
|
||||
isActive={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Rerender with isActive=true to trigger analysis
|
||||
rerender(
|
||||
<ThemedAIInsights
|
||||
plans={mockPlans}
|
||||
sql="SELECT * FROM users"
|
||||
transId={12345}
|
||||
isActive={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for the analysis to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Plan looks good')).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('should display bottlenecks when present', async () => {
|
||||
const mockReader = {
|
||||
read: jest.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: new TextEncoder().encode('data: {"type":"complete","bottlenecks":[{"severity":"high","node":"Seq Scan on users","issue":"Sequential scan","details":"Consider index"}],"recommendations":[],"summary":"Found issues"}\n\n'),
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
};
|
||||
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => mockReader,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<ThemedAIInsights
|
||||
plans={mockPlans}
|
||||
sql="SELECT * FROM users"
|
||||
transId={12345}
|
||||
isActive={true}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Performance Bottlenecks')).toBeInTheDocument();
|
||||
expect(screen.getByText('Seq Scan on users')).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('should display recommendations with SQL when present', async () => {
|
||||
const mockReader = {
|
||||
read: jest.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: new TextEncoder().encode('data: {"type":"complete","bottlenecks":[],"recommendations":[{"priority":1,"title":"Create index on users","explanation":"Will help performance","sql":"CREATE INDEX idx ON users(id);"}],"summary":"Consider adding an index"}\n\n'),
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
};
|
||||
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => mockReader,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<ThemedAIInsights
|
||||
plans={mockPlans}
|
||||
sql="SELECT * FROM users"
|
||||
transId={12345}
|
||||
isActive={true}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Recommendations')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create index on users')).toBeInTheDocument();
|
||||
expect(screen.getByText('CREATE INDEX idx ON users(id);')).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('should show error state on failure', async () => {
|
||||
global.fetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
render(
|
||||
<ThemedAIInsights
|
||||
plans={mockPlans}
|
||||
sql="SELECT * FROM users"
|
||||
transId={12345}
|
||||
isActive={true}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
/////////////////////////////////////////////////////////////
|
||||
//
|
||||
// pgAdmin 4 - PostgreSQL Tools
|
||||
//
|
||||
// Copyright (C) 2013 - 2025, The pgAdmin Development Team
|
||||
// This software is released under the PostgreSQL Licence
|
||||
//
|
||||
//////////////////////////////////////////////////////////////
|
||||
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { withTheme } from '../fake_theme';
|
||||
import AIReport from '../../../pgadmin/llm/static/js/AIReport.jsx';
|
||||
|
||||
describe('AIReport Component', () => {
|
||||
let ThemedAIReport;
|
||||
|
||||
beforeAll(() => {
|
||||
ThemedAIReport = withTheme(AIReport);
|
||||
|
||||
// Mock window.getComputedStyle for dark mode detection
|
||||
window.getComputedStyle = jest.fn().mockReturnValue({
|
||||
color: 'rgb(212, 212, 212)',
|
||||
backgroundColor: 'rgb(30, 30, 30)'
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(
|
||||
<ThemedAIReport
|
||||
sid={1}
|
||||
reportCategory="security"
|
||||
reportType="server"
|
||||
serverName="TestServer"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show regenerate and download buttons', () => {
|
||||
render(
|
||||
<ThemedAIReport
|
||||
sid={1}
|
||||
reportCategory="security"
|
||||
reportType="server"
|
||||
serverName="TestServer"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Regenerate')).toBeInTheDocument();
|
||||
expect(screen.getByText('Download')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable download button when no report exists', () => {
|
||||
render(
|
||||
<ThemedAIReport
|
||||
sid={1}
|
||||
reportCategory="security"
|
||||
reportType="server"
|
||||
serverName="TestServer"
|
||||
/>
|
||||
);
|
||||
|
||||
const downloadButton = screen.getByText('Download').closest('button');
|
||||
expect(downloadButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should detect dark mode from body styles', async () => {
|
||||
render(
|
||||
<ThemedAIReport
|
||||
sid={1}
|
||||
reportCategory="security"
|
||||
reportType="server"
|
||||
serverName="TestServer"
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for dark mode detection to run
|
||||
await waitFor(() => {
|
||||
// The component should apply light colors in dark mode
|
||||
// This would be verified by checking computed styles
|
||||
}, { timeout: 1500 });
|
||||
});
|
||||
|
||||
it('should handle light mode correctly', async () => {
|
||||
// Mock light mode
|
||||
window.getComputedStyle = jest.fn().mockReturnValue({
|
||||
color: 'rgb(0, 0, 0)',
|
||||
backgroundColor: 'rgb(255, 255, 255)'
|
||||
});
|
||||
|
||||
render(
|
||||
<ThemedAIReport
|
||||
sid={1}
|
||||
reportCategory="security"
|
||||
reportType="server"
|
||||
serverName="TestServer"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Component should apply dark colors in light mode
|
||||
}, { timeout: 1500 });
|
||||
});
|
||||
|
||||
it('should handle report generation error gracefully', async () => {
|
||||
// Mock fetch to return error
|
||||
global.fetch = jest.fn().mockRejectedValue(new Error('API Error'));
|
||||
|
||||
render(
|
||||
<ThemedAIReport
|
||||
sid={1}
|
||||
reportCategory="security"
|
||||
reportType="server"
|
||||
serverName="TestServer"
|
||||
/>
|
||||
);
|
||||
|
||||
const regenerateButton = screen.getByText('Regenerate');
|
||||
fireEvent.click(regenerateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show error message
|
||||
// expect(screen.getByText(/error/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display progress during report generation', async () => {
|
||||
// Mock SSE EventSource
|
||||
const mockEventSource = {
|
||||
addEventListener: jest.fn(),
|
||||
close: jest.fn(),
|
||||
onerror: null
|
||||
};
|
||||
|
||||
global.EventSource = jest.fn(() => mockEventSource);
|
||||
|
||||
render(
|
||||
<ThemedAIReport
|
||||
sid={1}
|
||||
reportCategory="security"
|
||||
reportType="server"
|
||||
serverName="TestServer"
|
||||
/>
|
||||
);
|
||||
|
||||
const regenerateButton = screen.getByText('Regenerate');
|
||||
fireEvent.click(regenerateButton);
|
||||
|
||||
// Simulate SSE progress event
|
||||
const onMessage = mockEventSource.addEventListener.mock.calls.find(
|
||||
call => call[0] === 'message'
|
||||
)?.[1];
|
||||
|
||||
if (onMessage) {
|
||||
onMessage({
|
||||
data: JSON.stringify({
|
||||
type: 'progress',
|
||||
stage: 'analyzing',
|
||||
message: 'Analyzing database structure...',
|
||||
completed: 1,
|
||||
total: 5
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
// Progress should be visible
|
||||
// expect(screen.getByText(/analyzing/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should support all report categories', () => {
|
||||
const categories = ['security', 'performance', 'design'];
|
||||
|
||||
categories.forEach(category => {
|
||||
const { unmount } = render(
|
||||
<ThemedAIReport
|
||||
sid={1}
|
||||
reportCategory={category}
|
||||
reportType="server"
|
||||
serverName="TestServer"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Regenerate')).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('should support all report types', () => {
|
||||
const types = [
|
||||
{ type: 'server', props: { sid: 1, serverName: 'Test' } },
|
||||
{ type: 'database', props: { sid: 1, did: 5, serverName: 'Test', databaseName: 'TestDB' } },
|
||||
{ type: 'schema', props: { sid: 1, did: 5, scid: 10, serverName: 'Test', databaseName: 'TestDB', schemaName: 'public' } }
|
||||
];
|
||||
|
||||
types.forEach(({ type, props }) => {
|
||||
const { unmount } = render(
|
||||
<ThemedAIReport
|
||||
reportCategory="security"
|
||||
reportType={type}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Regenerate')).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render markdown content correctly', () => {
|
||||
render(
|
||||
<ThemedAIReport
|
||||
sid={1}
|
||||
reportCategory="security"
|
||||
reportType="server"
|
||||
serverName="TestServer"
|
||||
/>
|
||||
);
|
||||
|
||||
// Would need to simulate report completion and verify markdown rendering
|
||||
});
|
||||
|
||||
it('should handle download functionality', () => {
|
||||
// Mock URL.createObjectURL
|
||||
global.URL.createObjectURL = jest.fn(() => 'blob:mock-url');
|
||||
global.URL.revokeObjectURL = jest.fn();
|
||||
|
||||
// Mock document.createElement for download link
|
||||
const mockLink = {
|
||||
click: jest.fn(),
|
||||
setAttribute: jest.fn()
|
||||
};
|
||||
const createElementSpy = jest.spyOn(document, 'createElement').mockReturnValue(mockLink);
|
||||
const appendChildSpy = jest.spyOn(document.body, 'appendChild').mockImplementation(() => {});
|
||||
const removeChildSpy = jest.spyOn(document.body, 'removeChild').mockImplementation(() => {});
|
||||
|
||||
// Test would simulate having a report and clicking download
|
||||
|
||||
// Restore document mocks
|
||||
createElementSpy.mockRestore();
|
||||
appendChildSpy.mockRestore();
|
||||
removeChildSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should close EventSource on component unmount', () => {
|
||||
const mockEventSource = {
|
||||
addEventListener: jest.fn(),
|
||||
close: jest.fn(),
|
||||
onerror: null
|
||||
};
|
||||
|
||||
global.EventSource = jest.fn(() => mockEventSource);
|
||||
|
||||
const { unmount } = render(
|
||||
<ThemedAIReport
|
||||
sid={1}
|
||||
reportCategory="security"
|
||||
reportType="server"
|
||||
serverName="TestServer"
|
||||
/>
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
// EventSource should be closed on unmount
|
||||
// Would verify mockEventSource.close was called
|
||||
});
|
||||
|
||||
it('should update text colors when theme changes', async () => {
|
||||
render(
|
||||
<ThemedAIReport
|
||||
sid={1}
|
||||
reportCategory="security"
|
||||
reportType="server"
|
||||
serverName="TestServer"
|
||||
/>
|
||||
);
|
||||
|
||||
// Change theme
|
||||
window.getComputedStyle = jest.fn().mockReturnValue({
|
||||
color: 'rgb(255, 255, 255)',
|
||||
backgroundColor: 'rgb(0, 0, 0)'
|
||||
});
|
||||
|
||||
// Wait for theme detection interval
|
||||
await waitFor(() => {
|
||||
// Colors should update
|
||||
}, { timeout: 1500 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/////////////////////////////////////////////////////////////
|
||||
//
|
||||
// pgAdmin 4 - PostgreSQL Tools
|
||||
//
|
||||
// Copyright (C) 2013 - 2025, The pgAdmin Development Team
|
||||
// This software is released under the PostgreSQL Licence
|
||||
//
|
||||
//////////////////////////////////////////////////////////////
|
||||
|
||||
// Mock url_for
|
||||
jest.mock('sources/url_for', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn((endpoint) => `/mock/${endpoint}`),
|
||||
}));
|
||||
|
||||
// Mock preferences store
|
||||
jest.mock('../../../pgadmin/preferences/static/js/store', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({
|
||||
getPreferencesForModule: jest.fn(() => ({})),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the QueryToolComponent to avoid importing all its dependencies
|
||||
jest.mock('../../../pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx', () => {
|
||||
const React = require('react');
|
||||
return {
|
||||
QueryToolContext: React.createContext(null),
|
||||
QueryToolEventsContext: React.createContext(null),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock CodeMirror
|
||||
jest.mock('../../../pgadmin/static/js/components/ReactCodeMirror', () => ({
|
||||
__esModule: true,
|
||||
default: ({ value }) => <pre data-testid="codemirror">{value}</pre>,
|
||||
}));
|
||||
|
||||
// Mock EmptyPanelMessage
|
||||
jest.mock('../../../pgadmin/static/js/components/EmptyPanelMessage', () => ({
|
||||
__esModule: true,
|
||||
default: ({ text }) => <div data-testid="empty-message">{text}</div>,
|
||||
}));
|
||||
|
||||
// Mock Loader
|
||||
jest.mock('sources/components/Loader', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="loader">Loading...</div>,
|
||||
}));
|
||||
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { withTheme } from '../fake_theme';
|
||||
import { NLQChatPanel } from '../../../pgadmin/tools/sqleditor/static/js/components/sections/NLQChatPanel.jsx';
|
||||
import {
|
||||
QueryToolContext,
|
||||
QueryToolEventsContext,
|
||||
} from '../../../pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx';
|
||||
|
||||
// Mock the EventBus
|
||||
const createMockEventBus = () => ({
|
||||
fireEvent: jest.fn(),
|
||||
registerListener: jest.fn(),
|
||||
});
|
||||
|
||||
// Mock the QueryToolContext
|
||||
const createMockQueryToolCtx = (isQueryTool = true) => ({
|
||||
params: {
|
||||
trans_id: 12345,
|
||||
is_query_tool: isQueryTool,
|
||||
},
|
||||
api: {
|
||||
post: jest.fn(),
|
||||
get: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to render with contexts
|
||||
const renderWithContexts = (component, { queryToolCtx, eventBus } = {}) => {
|
||||
const mockEventBus = eventBus || createMockEventBus();
|
||||
const mockQueryToolCtx = queryToolCtx || createMockQueryToolCtx();
|
||||
|
||||
return render(
|
||||
<QueryToolContext.Provider value={mockQueryToolCtx}>
|
||||
<QueryToolEventsContext.Provider value={mockEventBus}>
|
||||
{component}
|
||||
</QueryToolEventsContext.Provider>
|
||||
</QueryToolContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('NLQChatPanel Component', () => {
|
||||
let ThemedNLQChatPanel;
|
||||
|
||||
beforeAll(() => {
|
||||
ThemedNLQChatPanel = withTheme(NLQChatPanel);
|
||||
|
||||
// Mock fetch for SSE
|
||||
global.fetch = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render without crashing', () => {
|
||||
const { container } = renderWithContexts(<ThemedNLQChatPanel />);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show AI Assistant header', () => {
|
||||
renderWithContexts(<ThemedNLQChatPanel />);
|
||||
expect(screen.getByText('AI Assistant')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show empty state message when no messages', () => {
|
||||
renderWithContexts(<ThemedNLQChatPanel />);
|
||||
expect(
|
||||
screen.getByText(/Describe what SQL you need/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have input field for typing queries', () => {
|
||||
renderWithContexts(<ThemedNLQChatPanel />);
|
||||
const input = screen.getByPlaceholderText(/Describe the SQL you need/i);
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have send button', () => {
|
||||
renderWithContexts(<ThemedNLQChatPanel />);
|
||||
const sendButton = screen.getByLabelText('Send');
|
||||
expect(sendButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have clear conversation button', () => {
|
||||
renderWithContexts(<ThemedNLQChatPanel />);
|
||||
const clearButton = screen.getByText('Clear');
|
||||
expect(clearButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable send button when input is empty', () => {
|
||||
const { container } = renderWithContexts(<ThemedNLQChatPanel />);
|
||||
const sendButton = container.querySelector('button[data-label="Send"]');
|
||||
expect(sendButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should enable send button when input has text', () => {
|
||||
const { container } = renderWithContexts(<ThemedNLQChatPanel />);
|
||||
const input = screen.getByPlaceholderText(/Describe the SQL you need/i);
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Find all users' } });
|
||||
|
||||
const sendButton = container.querySelector('button[data-label="Send"]');
|
||||
expect(sendButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should show message when not in query tool mode', () => {
|
||||
const mockQueryToolCtx = createMockQueryToolCtx(false);
|
||||
renderWithContexts(<ThemedNLQChatPanel />, {
|
||||
queryToolCtx: mockQueryToolCtx,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(/AI Assistant is only available in Query Tool mode/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should clear input after typing and clicking clear', () => {
|
||||
renderWithContexts(<ThemedNLQChatPanel />);
|
||||
const input = screen.getByPlaceholderText(/Describe the SQL you need/i);
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Find all users' } });
|
||||
expect(input.value).toBe('Find all users');
|
||||
|
||||
const clearButton = screen.getByText('Clear');
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
// Input should still have text (clear only clears messages)
|
||||
expect(input.value).toBe('Find all users');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user