diff --git a/web/app/components/develop/ApiServer.spec.tsx b/web/app/components/develop/ApiServer.spec.tsx new file mode 100644 index 0000000000..097eac578a --- /dev/null +++ b/web/app/components/develop/ApiServer.spec.tsx @@ -0,0 +1,220 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { act } from 'react' +import ApiServer from './ApiServer' + +// Mock the secret-key-modal since it involves complex API interactions +vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ + default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => ( + isShow ?
: null + ), +})) + +describe('ApiServer', () => { + const defaultProps = { + apiBaseUrl: 'https://api.example.com', + } + + describe('rendering', () => { + it('should render the API server label', () => { + render() + expect(screen.getByText('appApi.apiServer')).toBeInTheDocument() + }) + + it('should render the API base URL', () => { + render() + expect(screen.getByText('https://api.example.com')).toBeInTheDocument() + }) + + it('should render the OK status badge', () => { + render() + expect(screen.getByText('appApi.ok')).toBeInTheDocument() + }) + + it('should render the API key button', () => { + render() + expect(screen.getByText('appApi.apiKey')).toBeInTheDocument() + }) + + it('should render CopyFeedback component', () => { + render() + // CopyFeedback renders a button for copying + const copyButtons = screen.getAllByRole('button') + expect(copyButtons.length).toBeGreaterThan(0) + }) + }) + + describe('with different API URLs', () => { + it('should render localhost URL', () => { + render() + expect(screen.getByText('http://localhost:3000/api')).toBeInTheDocument() + }) + + it('should render production URL', () => { + render() + expect(screen.getByText('https://api.dify.ai/v1')).toBeInTheDocument() + }) + + it('should render URL with path', () => { + render() + expect(screen.getByText('https://api.example.com/v1/chat')).toBeInTheDocument() + }) + }) + + describe('with appId prop', () => { + it('should render without appId', () => { + render() + expect(screen.getByText('https://api.example.com')).toBeInTheDocument() + }) + + it('should render with appId', () => { + render() + expect(screen.getByText('https://api.example.com')).toBeInTheDocument() + }) + }) + + describe('SecretKeyButton interaction', () => { + it('should open modal when API key button is clicked', async () => { + const user = userEvent.setup() + render() + + const apiKeyButton = screen.getByText('appApi.apiKey') + await act(async () => { + await user.click(apiKeyButton) + }) + + expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() + }) + + it('should close modal when close button is clicked', async () => { + const user = userEvent.setup() + render() + + // Open modal + const apiKeyButton = screen.getByText('appApi.apiKey') + await act(async () => { + await user.click(apiKeyButton) + }) + + expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() + + // Close modal + const closeButton = screen.getByText('Close Modal') + await act(async () => { + await user.click(closeButton) + }) + + expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() + }) + }) + + describe('styling', () => { + it('should have flex layout with wrap', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('flex') + expect(wrapper.className).toContain('flex-wrap') + }) + + it('should have items-center alignment', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('items-center') + }) + + it('should have gap-y-2 for vertical spacing', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('gap-y-2') + }) + + it('should apply green styling to OK badge', () => { + render() + const okBadge = screen.getByText('appApi.ok') + expect(okBadge.className).toContain('bg-[#ECFDF3]') + expect(okBadge.className).toContain('text-[#039855]') + }) + + it('should have border styling on URL container', () => { + render() + const urlText = screen.getByText('https://api.example.com') + const urlContainer = urlText.closest('div[class*="rounded-lg"]') + expect(urlContainer).toBeInTheDocument() + }) + }) + + describe('API server label', () => { + it('should have correct styling for label', () => { + render() + const label = screen.getByText('appApi.apiServer') + expect(label.className).toContain('rounded-md') + expect(label.className).toContain('border') + }) + + it('should have tertiary text color on label', () => { + render() + const label = screen.getByText('appApi.apiServer') + expect(label.className).toContain('text-text-tertiary') + }) + }) + + describe('URL display', () => { + it('should have truncate class for long URLs', () => { + render() + const urlText = screen.getByText('https://api.example.com') + expect(urlText.className).toContain('truncate') + }) + + it('should have font-medium class on URL', () => { + render() + const urlText = screen.getByText('https://api.example.com') + expect(urlText.className).toContain('font-medium') + }) + + it('should have secondary text color on URL', () => { + render() + const urlText = screen.getByText('https://api.example.com') + expect(urlText.className).toContain('text-text-secondary') + }) + }) + + describe('divider', () => { + it('should render vertical divider between URL and copy button', () => { + const { container } = render() + const divider = container.querySelector('.bg-divider-regular') + expect(divider).toBeInTheDocument() + }) + + it('should have correct divider dimensions', () => { + const { container } = render() + const divider = container.querySelector('.bg-divider-regular') + expect(divider?.className).toContain('h-[14px]') + expect(divider?.className).toContain('w-[1px]') + }) + }) + + describe('SecretKeyButton styling', () => { + it('should have shrink-0 class to prevent shrinking', () => { + render() + // The SecretKeyButton wraps a Button component + const button = screen.getByRole('button', { name: /apiKey/i }) + // Check parent container has shrink-0 + const buttonContainer = button.closest('.shrink-0') + expect(buttonContainer).toBeInTheDocument() + }) + }) + + describe('accessibility', () => { + it('should have accessible button for API key', () => { + render() + const button = screen.getByRole('button', { name: /apiKey/i }) + expect(button).toBeInTheDocument() + }) + + it('should have multiple buttons (copy + API key)', () => { + render() + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(2) + }) + }) +}) diff --git a/web/app/components/develop/code.spec.tsx b/web/app/components/develop/code.spec.tsx new file mode 100644 index 0000000000..b279c41a66 --- /dev/null +++ b/web/app/components/develop/code.spec.tsx @@ -0,0 +1,590 @@ +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Code, CodeGroup, Embed, Pre } from './code' + +// Mock the clipboard utility +vi.mock('@/utils/clipboard', () => ({ + writeTextToClipboard: vi.fn().mockResolvedValue(undefined), +})) + +describe('code.tsx components', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers({ shouldAdvanceTime: true }) + }) + + afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() + }) + + describe('Code', () => { + it('should render children', () => { + render(const x = 1) + expect(screen.getByText('const x = 1')).toBeInTheDocument() + }) + + it('should render as code element', () => { + render(code snippet) + const codeElement = screen.getByText('code snippet') + expect(codeElement.tagName).toBe('CODE') + }) + + it('should pass through additional props', () => { + render(snippet) + const codeElement = screen.getByTestId('custom-code') + expect(codeElement).toHaveClass('custom-class') + }) + + it('should render with complex children', () => { + render( + + part1 + part2 + , + ) + expect(screen.getByText('part1')).toBeInTheDocument() + expect(screen.getByText('part2')).toBeInTheDocument() + }) + }) + + describe('Embed', () => { + it('should render value prop', () => { + render(ignored children) + expect(screen.getByText('embedded content')).toBeInTheDocument() + }) + + it('should render as span element', () => { + render(children) + const span = screen.getByText('test value') + expect(span.tagName).toBe('SPAN') + }) + + it('should pass through additional props', () => { + render(children) + const embed = screen.getByTestId('embed-test') + expect(embed).toHaveClass('embed-class') + }) + + it('should not render children, only value', () => { + render(hidden children) + expect(screen.getByText('shown')).toBeInTheDocument() + expect(screen.queryByText('hidden children')).not.toBeInTheDocument() + }) + }) + + describe('CodeGroup', () => { + describe('with string targetCode', () => { + it('should render code from targetCode string', () => { + render( + +
fallback
+
, + ) + expect(screen.getByText('const hello = \'world\'')).toBeInTheDocument() + }) + + it('should have shadow and rounded styles', () => { + const { container } = render( + +
fallback
+
, + ) + const codeGroup = container.querySelector('.shadow-md') + expect(codeGroup).toBeInTheDocument() + expect(codeGroup).toHaveClass('rounded-2xl') + }) + + it('should have bg-zinc-900 background', () => { + const { container } = render( + +
fallback
+
, + ) + const codeGroup = container.querySelector('.bg-zinc-900') + expect(codeGroup).toBeInTheDocument() + }) + }) + + describe('with array targetCode', () => { + it('should render single code example without tabs', () => { + const examples = [{ code: 'single example' }] + render( + +
fallback
+
, + ) + expect(screen.getByText('single example')).toBeInTheDocument() + }) + + it('should render multiple code examples with tabs', () => { + const examples = [ + { title: 'JavaScript', code: 'console.log("js")' }, + { title: 'Python', code: 'print("py")' }, + ] + render( + +
fallback
+
, + ) + expect(screen.getByRole('tab', { name: 'JavaScript' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Python' })).toBeInTheDocument() + }) + + it('should show first tab content by default', () => { + const examples = [ + { title: 'Tab1', code: 'first content' }, + { title: 'Tab2', code: 'second content' }, + ] + render( + +
fallback
+
, + ) + expect(screen.getByText('first content')).toBeInTheDocument() + }) + + it('should switch tabs on click', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + const examples = [ + { title: 'Tab1', code: 'first content' }, + { title: 'Tab2', code: 'second content' }, + ] + render( + +
fallback
+
, + ) + + const tab2 = screen.getByRole('tab', { name: 'Tab2' }) + await act(async () => { + await user.click(tab2) + }) + + await waitFor(() => { + expect(screen.getByText('second content')).toBeInTheDocument() + }) + }) + + it('should use "Code" as default title when title not provided', () => { + const examples = [ + { code: 'example 1' }, + { code: 'example 2' }, + ] + render( + +
fallback
+
, + ) + const codeTabs = screen.getAllByRole('tab', { name: 'Code' }) + expect(codeTabs).toHaveLength(2) + }) + }) + + describe('with title prop', () => { + it('should render title in header', () => { + render( + +
fallback
+
, + ) + expect(screen.getByText('API Example')).toBeInTheDocument() + }) + + it('should render title in h3 element', () => { + render( + +
fallback
+
, + ) + const h3 = screen.getByRole('heading', { level: 3 }) + expect(h3).toHaveTextContent('Example Title') + }) + }) + + describe('with tag and label props', () => { + it('should render tag in code panel header', () => { + render( + +
fallback
+
, + ) + expect(screen.getByText('GET')).toBeInTheDocument() + }) + + it('should render label in code panel header', () => { + render( + +
fallback
+
, + ) + expect(screen.getByText('/api/users')).toBeInTheDocument() + }) + + it('should render both tag and label with separator', () => { + const { container } = render( + +
fallback
+
, + ) + expect(screen.getByText('POST')).toBeInTheDocument() + expect(screen.getByText('/api/create')).toBeInTheDocument() + // Separator should be present + const separator = container.querySelector('.rounded-full.bg-zinc-500') + expect(separator).toBeInTheDocument() + }) + }) + + describe('CopyButton functionality', () => { + it('should render copy button', () => { + render( + +
fallback
+
, + ) + const copyButton = screen.getByRole('button') + expect(copyButton).toBeInTheDocument() + }) + + it('should show "Copy" text initially', () => { + render( + +
fallback
+
, + ) + expect(screen.getByText('Copy')).toBeInTheDocument() + }) + + it('should show "Copied!" after clicking copy button', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + const { writeTextToClipboard } = await import('@/utils/clipboard') + + render( + +
fallback
+
, + ) + + const copyButton = screen.getByRole('button') + await act(async () => { + await user.click(copyButton) + }) + + await waitFor(() => { + expect(writeTextToClipboard).toHaveBeenCalledWith('code to copy') + }) + + expect(screen.getByText('Copied!')).toBeInTheDocument() + }) + + it('should reset copy state after timeout', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + + render( + +
fallback
+
, + ) + + const copyButton = screen.getByRole('button') + await act(async () => { + await user.click(copyButton) + }) + + await waitFor(() => { + expect(screen.getByText('Copied!')).toBeInTheDocument() + }) + + // Advance time past the timeout + await act(async () => { + vi.advanceTimersByTime(1500) + }) + + await waitFor(() => { + expect(screen.getByText('Copy')).toBeInTheDocument() + }) + }) + }) + + describe('without targetCode (using children)', () => { + it('should render children when no targetCode provided', () => { + render( + +
child code content
+
, + ) + expect(screen.getByText('child code content')).toBeInTheDocument() + }) + }) + + describe('styling', () => { + it('should have not-prose class to prevent prose styling', () => { + const { container } = render( + +
fallback
+
, + ) + const codeGroup = container.querySelector('.not-prose') + expect(codeGroup).toBeInTheDocument() + }) + + it('should have my-6 margin', () => { + const { container } = render( + +
fallback
+
, + ) + const codeGroup = container.querySelector('.my-6') + expect(codeGroup).toBeInTheDocument() + }) + + it('should have overflow-hidden', () => { + const { container } = render( + +
fallback
+
, + ) + const codeGroup = container.querySelector('.overflow-hidden') + expect(codeGroup).toBeInTheDocument() + }) + }) + }) + + describe('Pre', () => { + describe('when outside CodeGroup context', () => { + it('should wrap children in CodeGroup', () => { + const { container } = render( +
+            
code content
+
, + ) + // Should render within a CodeGroup structure + const codeGroup = container.querySelector('.bg-zinc-900') + expect(codeGroup).toBeInTheDocument() + }) + + it('should pass props to CodeGroup', () => { + render( +
+            
code
+
, + ) + expect(screen.getByText('Pre Title')).toBeInTheDocument() + }) + }) + + describe('when inside CodeGroup context (isGrouped)', () => { + it('should return children directly without wrapping', () => { + render( + +
+              inner code
+            
+
, + ) + // The outer code should be rendered (from targetCode) + expect(screen.getByText('outer code')).toBeInTheDocument() + }) + }) + }) + + describe('CodePanelHeader (via CodeGroup)', () => { + it('should not render when neither tag nor label provided', () => { + const { container } = render( + +
fallback
+
, + ) + const headerDivider = container.querySelector('.border-b-white\\/7\\.5') + expect(headerDivider).not.toBeInTheDocument() + }) + + it('should render when only tag is provided', () => { + render( + +
fallback
+
, + ) + expect(screen.getByText('GET')).toBeInTheDocument() + }) + + it('should render when only label is provided', () => { + render( + +
fallback
+
, + ) + expect(screen.getByText('/api/endpoint')).toBeInTheDocument() + }) + + it('should render label with font-mono styling', () => { + render( + +
fallback
+
, + ) + const label = screen.getByText('/api/test') + expect(label.className).toContain('font-mono') + expect(label.className).toContain('text-xs') + }) + }) + + describe('CodeGroupHeader (via CodeGroup with multiple tabs)', () => { + it('should render tab list for multiple examples', () => { + const examples = [ + { title: 'cURL', code: 'curl example' }, + { title: 'Node.js', code: 'node example' }, + ] + render( + +
fallback
+
, + ) + expect(screen.getByRole('tablist')).toBeInTheDocument() + }) + + it('should style active tab differently', () => { + const examples = [ + { title: 'Active', code: 'active code' }, + { title: 'Inactive', code: 'inactive code' }, + ] + render( + +
fallback
+
, + ) + const activeTab = screen.getByRole('tab', { name: 'Active' }) + expect(activeTab.className).toContain('border-emerald-500') + expect(activeTab.className).toContain('text-emerald-400') + }) + + it('should have header background styling', () => { + const examples = [ + { title: 'Tab1', code: 'code1' }, + { title: 'Tab2', code: 'code2' }, + ] + const { container } = render( + +
fallback
+
, + ) + const header = container.querySelector('.bg-zinc-800') + expect(header).toBeInTheDocument() + }) + }) + + describe('CodePanel (via CodeGroup)', () => { + it('should render code in pre element', () => { + render( + +
fallback
+
, + ) + const preElement = screen.getByText('pre content').closest('pre') + expect(preElement).toBeInTheDocument() + }) + + it('should have text-white class on pre', () => { + render( + +
fallback
+
, + ) + const preElement = screen.getByText('white text').closest('pre') + expect(preElement?.className).toContain('text-white') + }) + + it('should have text-xs class on pre', () => { + render( + +
fallback
+
, + ) + const preElement = screen.getByText('small text').closest('pre') + expect(preElement?.className).toContain('text-xs') + }) + + it('should have overflow-x-auto on pre', () => { + render( + +
fallback
+
, + ) + const preElement = screen.getByText('scrollable').closest('pre') + expect(preElement?.className).toContain('overflow-x-auto') + }) + + it('should have p-4 padding on pre', () => { + render( + +
fallback
+
, + ) + const preElement = screen.getByText('padded').closest('pre') + expect(preElement?.className).toContain('p-4') + }) + }) + + describe('ClipboardIcon (via CopyButton in CodeGroup)', () => { + it('should render clipboard icon in copy button', () => { + render( + +
fallback
+
, + ) + const copyButton = screen.getByRole('button') + const svg = copyButton.querySelector('svg') + expect(svg).toBeInTheDocument() + expect(svg).toHaveAttribute('viewBox', '0 0 20 20') + }) + }) + + describe('edge cases', () => { + it('should handle empty string targetCode', () => { + render( + +
fallback
+
, + ) + // Should render copy button even with empty code + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should handle targetCode with special characters', () => { + const specialCode = '
&
' + render( + +
fallback
+
, + ) + expect(screen.getByText(specialCode)).toBeInTheDocument() + }) + + it('should handle multiline targetCode', () => { + const multilineCode = `line1 +line2 +line3` + render( + +
fallback
+
, + ) + // Multiline code should be rendered - use a partial match + expect(screen.getByText(/line1/)).toBeInTheDocument() + expect(screen.getByText(/line2/)).toBeInTheDocument() + expect(screen.getByText(/line3/)).toBeInTheDocument() + }) + + it('should handle examples with tag property', () => { + const examples = [ + { title: 'Example', tag: 'v1', code: 'versioned code' }, + ] + render( + +
fallback
+
, + ) + expect(screen.getByText('versioned code')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/develop/index.spec.tsx b/web/app/components/develop/index.spec.tsx new file mode 100644 index 0000000000..f90e33e691 --- /dev/null +++ b/web/app/components/develop/index.spec.tsx @@ -0,0 +1,339 @@ +import { render, screen } from '@testing-library/react' +import DevelopMain from './index' + +// Mock the app store with a factory function to control state +const mockAppDetailValue: { current: unknown } = { current: undefined } +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: unknown) => unknown) => { + const state = { appDetail: mockAppDetailValue.current } + return selector(state) + }, +})) + +// Mock the Doc component since it has complex dependencies +vi.mock('@/app/components/develop/doc', () => ({ + default: ({ appDetail }: { appDetail: { name?: string } | null }) => ( +
+ Doc Component - + {appDetail?.name} +
+ ), +})) + +// Mock the ApiServer component +vi.mock('@/app/components/develop/ApiServer', () => ({ + default: ({ apiBaseUrl, appId }: { apiBaseUrl: string, appId: string }) => ( +
+ API Server - + {apiBaseUrl} + {' '} + - + {appId} +
+ ), +})) + +describe('DevelopMain', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAppDetailValue.current = undefined + }) + + describe('loading state', () => { + it('should show loading when appDetail is undefined', () => { + mockAppDetailValue.current = undefined + render() + + // Loading component renders with role="status" + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('should show loading when appDetail is null', () => { + mockAppDetailValue.current = null + render() + + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('should have centered loading container', () => { + mockAppDetailValue.current = undefined + const { container } = render() + + const loadingContainer = container.querySelector('.flex.h-full.items-center.justify-center') + expect(loadingContainer).toBeInTheDocument() + }) + + it('should have correct background on loading state', () => { + mockAppDetailValue.current = undefined + const { container } = render() + + const loadingContainer = container.querySelector('.bg-background-default') + expect(loadingContainer).toBeInTheDocument() + }) + }) + + describe('with appDetail loaded', () => { + const mockAppDetail = { + id: 'app-123', + name: 'Test Application', + api_base_url: 'https://api.example.com/v1', + mode: 'chat', + } + + beforeEach(() => { + mockAppDetailValue.current = mockAppDetail + }) + + it('should render ApiServer component', () => { + render() + expect(screen.getByTestId('api-server')).toBeInTheDocument() + }) + + it('should pass api_base_url to ApiServer', () => { + render() + expect(screen.getByTestId('api-server')).toHaveTextContent('https://api.example.com/v1') + }) + + it('should pass appId to ApiServer', () => { + render() + expect(screen.getByTestId('api-server')).toHaveTextContent('app-123') + }) + + it('should render Doc component', () => { + render() + expect(screen.getByTestId('doc-component')).toBeInTheDocument() + }) + + it('should pass appDetail to Doc component', () => { + render() + expect(screen.getByTestId('doc-component')).toHaveTextContent('Test Application') + }) + + it('should not show loading when appDetail exists', () => { + render() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + }) + + describe('layout structure', () => { + const mockAppDetail = { + id: 'app-123', + name: 'Test Application', + api_base_url: 'https://api.example.com', + mode: 'chat', + } + + beforeEach(() => { + mockAppDetailValue.current = mockAppDetail + }) + + it('should have flex column layout', () => { + const { container } = render() + const mainContainer = container.firstChild as HTMLElement + expect(mainContainer.className).toContain('flex') + expect(mainContainer.className).toContain('flex-col') + }) + + it('should have relative positioning', () => { + const { container } = render() + const mainContainer = container.firstChild as HTMLElement + expect(mainContainer.className).toContain('relative') + }) + + it('should have full height', () => { + const { container } = render() + const mainContainer = container.firstChild as HTMLElement + expect(mainContainer.className).toContain('h-full') + }) + + it('should have overflow-hidden', () => { + const { container } = render() + const mainContainer = container.firstChild as HTMLElement + expect(mainContainer.className).toContain('overflow-hidden') + }) + }) + + describe('header section', () => { + const mockAppDetail = { + id: 'app-123', + name: 'Test Application', + api_base_url: 'https://api.example.com', + mode: 'chat', + } + + beforeEach(() => { + mockAppDetailValue.current = mockAppDetail + }) + + it('should have header with border', () => { + const { container } = render() + const header = container.querySelector('.border-b') + expect(header).toBeInTheDocument() + }) + + it('should have shrink-0 on header to prevent shrinking', () => { + const { container } = render() + const header = container.querySelector('.shrink-0') + expect(header).toBeInTheDocument() + }) + + it('should have horizontal padding on header', () => { + const { container } = render() + const header = container.querySelector('.px-6') + expect(header).toBeInTheDocument() + }) + + it('should have vertical padding on header', () => { + const { container } = render() + const header = container.querySelector('.py-2') + expect(header).toBeInTheDocument() + }) + + it('should have items centered in header', () => { + const { container } = render() + const header = container.querySelector('.items-center') + expect(header).toBeInTheDocument() + }) + + it('should have justify-between in header', () => { + const { container } = render() + const header = container.querySelector('.justify-between') + expect(header).toBeInTheDocument() + }) + }) + + describe('content section', () => { + const mockAppDetail = { + id: 'app-123', + name: 'Test Application', + api_base_url: 'https://api.example.com', + mode: 'chat', + } + + beforeEach(() => { + mockAppDetailValue.current = mockAppDetail + }) + + it('should have grow class for content area', () => { + const { container } = render() + const content = container.querySelector('.grow') + expect(content).toBeInTheDocument() + }) + + it('should have overflow-auto for content scrolling', () => { + const { container } = render() + const content = container.querySelector('.overflow-auto') + expect(content).toBeInTheDocument() + }) + + it('should have horizontal padding on content', () => { + const { container } = render() + const content = container.querySelector('.px-4') + expect(content).toBeInTheDocument() + }) + + it('should have vertical padding on content', () => { + const { container } = render() + const content = container.querySelector('.py-4') + expect(content).toBeInTheDocument() + }) + + it('should have responsive padding', () => { + const { container } = render() + const content = container.querySelector('[class*="sm:px-10"]') + expect(content).toBeInTheDocument() + }) + }) + + describe('with different appIds', () => { + const mockAppDetail = { + id: 'app-456', + name: 'Another App', + api_base_url: 'https://another-api.com', + mode: 'completion', + } + + beforeEach(() => { + mockAppDetailValue.current = mockAppDetail + }) + + it('should pass different appId to ApiServer', () => { + render() + expect(screen.getByTestId('api-server')).toHaveTextContent('app-456') + }) + + it('should handle app with different api_base_url', () => { + render() + expect(screen.getByTestId('api-server')).toHaveTextContent('https://another-api.com') + }) + }) + + describe('empty state handling', () => { + it('should handle appDetail with minimal properties', () => { + mockAppDetailValue.current = { + api_base_url: 'https://api.test.com', + } + render() + expect(screen.getByTestId('api-server')).toBeInTheDocument() + }) + + it('should handle appDetail with empty api_base_url', () => { + mockAppDetailValue.current = { + api_base_url: '', + name: 'Empty URL App', + } + render() + expect(screen.getByTestId('api-server')).toBeInTheDocument() + }) + }) + + describe('title element', () => { + const mockAppDetail = { + id: 'app-123', + name: 'Test Application', + api_base_url: 'https://api.example.com', + mode: 'chat', + } + + beforeEach(() => { + mockAppDetailValue.current = mockAppDetail + }) + + it('should have title div with correct styling', () => { + const { container } = render() + const title = container.querySelector('.text-lg.font-medium.text-text-primary') + expect(title).toBeInTheDocument() + }) + + it('should render empty title div', () => { + const { container } = render() + const title = container.querySelector('.text-lg.font-medium.text-text-primary') + expect(title?.textContent).toBe('') + }) + }) + + describe('border styling', () => { + const mockAppDetail = { + id: 'app-123', + name: 'Test Application', + api_base_url: 'https://api.example.com', + mode: 'chat', + } + + beforeEach(() => { + mockAppDetailValue.current = mockAppDetail + }) + + it('should have solid border style', () => { + const { container } = render() + const header = container.querySelector('.border-solid') + expect(header).toBeInTheDocument() + }) + + it('should have divider regular color on border', () => { + const { container } = render() + const header = container.querySelector('.border-b-divider-regular') + expect(header).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/develop/md.spec.tsx b/web/app/components/develop/md.spec.tsx new file mode 100644 index 0000000000..8eab1c0ac8 --- /dev/null +++ b/web/app/components/develop/md.spec.tsx @@ -0,0 +1,655 @@ +import { render, screen } from '@testing-library/react' +import { Col, Heading, Properties, Property, PropertyInstruction, Row, SubProperty } from './md' + +describe('md.tsx components', () => { + describe('Heading', () => { + const defaultProps = { + url: '/api/messages', + method: 'GET' as const, + title: 'Get Messages', + name: '#get-messages', + } + + describe('rendering', () => { + it('should render the method badge', () => { + render() + expect(screen.getByText('GET')).toBeInTheDocument() + }) + + it('should render the url', () => { + render() + expect(screen.getByText('/api/messages')).toBeInTheDocument() + }) + + it('should render the title as a link', () => { + render() + const link = screen.getByRole('link', { name: 'Get Messages' }) + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', '#get-messages') + }) + + it('should render an anchor span with correct id', () => { + const { container } = render() + const anchor = container.querySelector('#get-messages') + expect(anchor).toBeInTheDocument() + }) + + it('should strip # prefix from name for id', () => { + const { container } = render() + const anchor = container.querySelector('#with-hash') + expect(anchor).toBeInTheDocument() + }) + }) + + describe('method styling', () => { + it('should apply emerald styles for GET method', () => { + render() + const badge = screen.getByText('GET') + expect(badge.className).toContain('text-emerald') + expect(badge.className).toContain('bg-emerald-400/10') + expect(badge.className).toContain('ring-emerald-300') + }) + + it('should apply sky styles for POST method', () => { + render() + const badge = screen.getByText('POST') + expect(badge.className).toContain('text-sky') + expect(badge.className).toContain('bg-sky-400/10') + expect(badge.className).toContain('ring-sky-300') + }) + + it('should apply amber styles for PUT method', () => { + render() + const badge = screen.getByText('PUT') + expect(badge.className).toContain('text-amber') + expect(badge.className).toContain('bg-amber-400/10') + expect(badge.className).toContain('ring-amber-300') + }) + + it('should apply rose styles for DELETE method', () => { + render() + const badge = screen.getByText('DELETE') + expect(badge.className).toContain('text-red') + expect(badge.className).toContain('bg-rose') + expect(badge.className).toContain('ring-rose') + }) + + it('should apply violet styles for PATCH method', () => { + render() + const badge = screen.getByText('PATCH') + expect(badge.className).toContain('text-violet') + expect(badge.className).toContain('bg-violet-400/10') + expect(badge.className).toContain('ring-violet-300') + }) + }) + + describe('badge base styles', () => { + it('should have rounded-lg class', () => { + render() + const badge = screen.getByText('GET') + expect(badge.className).toContain('rounded-lg') + }) + + it('should have font-mono class', () => { + render() + const badge = screen.getByText('GET') + expect(badge.className).toContain('font-mono') + }) + + it('should have font-semibold class', () => { + render() + const badge = screen.getByText('GET') + expect(badge.className).toContain('font-semibold') + }) + + it('should have ring-1 and ring-inset classes', () => { + render() + const badge = screen.getByText('GET') + expect(badge.className).toContain('ring-1') + expect(badge.className).toContain('ring-inset') + }) + }) + + describe('url styles', () => { + it('should have font-mono class on url', () => { + render() + const url = screen.getByText('/api/messages') + expect(url.className).toContain('font-mono') + }) + + it('should have text-xs class on url', () => { + render() + const url = screen.getByText('/api/messages') + expect(url.className).toContain('text-xs') + }) + + it('should have zinc text color on url', () => { + render() + const url = screen.getByText('/api/messages') + expect(url.className).toContain('text-zinc-400') + }) + }) + + describe('h2 element', () => { + it('should render title inside h2', () => { + render() + const h2 = screen.getByRole('heading', { level: 2 }) + expect(h2).toBeInTheDocument() + expect(h2).toHaveTextContent('Get Messages') + }) + + it('should have scroll-mt-32 class on h2', () => { + render() + const h2 = screen.getByRole('heading', { level: 2 }) + expect(h2.className).toContain('scroll-mt-32') + }) + }) + }) + + describe('Row', () => { + it('should render children', () => { + render( + +
Child 1
+
Child 2
+
, + ) + expect(screen.getByText('Child 1')).toBeInTheDocument() + expect(screen.getByText('Child 2')).toBeInTheDocument() + }) + + it('should have grid layout', () => { + const { container } = render( + +
Content
+
, + ) + const row = container.firstChild as HTMLElement + expect(row.className).toContain('grid') + expect(row.className).toContain('grid-cols-1') + }) + + it('should have gap classes', () => { + const { container } = render( + +
Content
+
, + ) + const row = container.firstChild as HTMLElement + expect(row.className).toContain('gap-x-16') + expect(row.className).toContain('gap-y-10') + }) + + it('should have xl responsive classes', () => { + const { container } = render( + +
Content
+
, + ) + const row = container.firstChild as HTMLElement + expect(row.className).toContain('xl:grid-cols-2') + expect(row.className).toContain('xl:!max-w-none') + }) + + it('should have items-start class', () => { + const { container } = render( + +
Content
+
, + ) + const row = container.firstChild as HTMLElement + expect(row.className).toContain('items-start') + }) + }) + + describe('Col', () => { + it('should render children', () => { + render( + +
Column Content
+ , + ) + expect(screen.getByText('Column Content')).toBeInTheDocument() + }) + + it('should have first/last child margin classes', () => { + const { container } = render( + +
Content
+ , + ) + const col = container.firstChild as HTMLElement + expect(col.className).toContain('[&>:first-child]:mt-0') + expect(col.className).toContain('[&>:last-child]:mb-0') + }) + + it('should apply sticky classes when sticky is true', () => { + const { container } = render( + +
Sticky Content
+ , + ) + const col = container.firstChild as HTMLElement + expect(col.className).toContain('xl:sticky') + expect(col.className).toContain('xl:top-24') + }) + + it('should not apply sticky classes when sticky is false', () => { + const { container } = render( + +
Non-sticky Content
+ , + ) + const col = container.firstChild as HTMLElement + expect(col.className).not.toContain('xl:sticky') + expect(col.className).not.toContain('xl:top-24') + }) + }) + + describe('Properties', () => { + it('should render children', () => { + render( + +
  • Property 1
  • +
  • Property 2
  • +
    , + ) + expect(screen.getByText('Property 1')).toBeInTheDocument() + expect(screen.getByText('Property 2')).toBeInTheDocument() + }) + + it('should render as ul with role list', () => { + render( + +
  • Property
  • +
    , + ) + const list = screen.getByRole('list') + expect(list).toBeInTheDocument() + expect(list.tagName).toBe('UL') + }) + + it('should have my-6 margin class', () => { + const { container } = render( + +
  • Property
  • +
    , + ) + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('my-6') + }) + + it('should have list-none class on ul', () => { + render( + +
  • Property
  • +
    , + ) + const list = screen.getByRole('list') + expect(list.className).toContain('list-none') + }) + + it('should have m-0 and p-0 classes on ul', () => { + render( + +
  • Property
  • +
    , + ) + const list = screen.getByRole('list') + expect(list.className).toContain('m-0') + expect(list.className).toContain('p-0') + }) + + it('should have divide-y class on ul', () => { + render( + +
  • Property
  • +
    , + ) + const list = screen.getByRole('list') + expect(list.className).toContain('divide-y') + }) + + it('should have max-w constraint class', () => { + render( + +
  • Property
  • +
    , + ) + const list = screen.getByRole('list') + expect(list.className).toContain('max-w-[calc(theme(maxWidth.lg)-theme(spacing.8))]') + }) + }) + + describe('Property', () => { + const defaultProps = { + name: 'user_id', + type: 'string', + anchor: false, + } + + it('should render name in code element', () => { + render( + + User identifier + , + ) + const code = screen.getByText('user_id') + expect(code.tagName).toBe('CODE') + }) + + it('should render type', () => { + render( + + User identifier + , + ) + expect(screen.getByText('string')).toBeInTheDocument() + }) + + it('should render children as description', () => { + render( + + User identifier + , + ) + expect(screen.getByText('User identifier')).toBeInTheDocument() + }) + + it('should render as li element', () => { + const { container } = render( + + Description + , + ) + expect(container.querySelector('li')).toBeInTheDocument() + }) + + it('should have m-0 class on li', () => { + const { container } = render( + + Description + , + ) + const li = container.querySelector('li')! + expect(li.className).toContain('m-0') + }) + + it('should have padding classes on li', () => { + const { container } = render( + + Description + , + ) + const li = container.querySelector('li')! + expect(li.className).toContain('px-0') + expect(li.className).toContain('py-4') + }) + + it('should have first:pt-0 and last:pb-0 classes', () => { + const { container } = render( + + Description + , + ) + const li = container.querySelector('li')! + expect(li.className).toContain('first:pt-0') + expect(li.className).toContain('last:pb-0') + }) + + it('should render dl element with proper structure', () => { + const { container } = render( + + Description + , + ) + expect(container.querySelector('dl')).toBeInTheDocument() + }) + + it('should have sr-only dt elements for accessibility', () => { + const { container } = render( + + User identifier + , + ) + const dtElements = container.querySelectorAll('dt') + expect(dtElements.length).toBe(3) + dtElements.forEach((dt) => { + expect(dt.className).toContain('sr-only') + }) + }) + + it('should have font-mono class on type', () => { + render( + + Description + , + ) + const typeElement = screen.getByText('string') + expect(typeElement.className).toContain('font-mono') + expect(typeElement.className).toContain('text-xs') + }) + }) + + describe('SubProperty', () => { + const defaultProps = { + name: 'sub_field', + type: 'number', + anchor: false, + } + + it('should render name in code element', () => { + render( + + Sub field description + , + ) + const code = screen.getByText('sub_field') + expect(code.tagName).toBe('CODE') + }) + + it('should render type', () => { + render( + + Sub field description + , + ) + expect(screen.getByText('number')).toBeInTheDocument() + }) + + it('should render children as description', () => { + render( + + Sub field description + , + ) + expect(screen.getByText('Sub field description')).toBeInTheDocument() + }) + + it('should render as li element', () => { + const { container } = render( + + Description + , + ) + expect(container.querySelector('li')).toBeInTheDocument() + }) + + it('should have m-0 class on li', () => { + const { container } = render( + + Description + , + ) + const li = container.querySelector('li')! + expect(li.className).toContain('m-0') + }) + + it('should have different padding than Property (py-1 vs py-4)', () => { + const { container } = render( + + Description + , + ) + const li = container.querySelector('li')! + expect(li.className).toContain('px-0') + expect(li.className).toContain('py-1') + }) + + it('should have last:pb-0 class', () => { + const { container } = render( + + Description + , + ) + const li = container.querySelector('li')! + expect(li.className).toContain('last:pb-0') + }) + + it('should render dl element with proper structure', () => { + const { container } = render( + + Description + , + ) + expect(container.querySelector('dl')).toBeInTheDocument() + }) + + it('should have sr-only dt elements for accessibility', () => { + const { container } = render( + + Sub field description + , + ) + const dtElements = container.querySelectorAll('dt') + expect(dtElements.length).toBe(3) + dtElements.forEach((dt) => { + expect(dt.className).toContain('sr-only') + }) + }) + + it('should have font-mono and text-xs on type', () => { + render( + + Description + , + ) + const typeElement = screen.getByText('number') + expect(typeElement.className).toContain('font-mono') + expect(typeElement.className).toContain('text-xs') + }) + }) + + describe('PropertyInstruction', () => { + it('should render children', () => { + render( + + This is an instruction + , + ) + expect(screen.getByText('This is an instruction')).toBeInTheDocument() + }) + + it('should render as li element', () => { + const { container } = render( + + Instruction text + , + ) + expect(container.querySelector('li')).toBeInTheDocument() + }) + + it('should have m-0 class', () => { + const { container } = render( + + Instruction + , + ) + const li = container.querySelector('li')! + expect(li.className).toContain('m-0') + }) + + it('should have padding classes', () => { + const { container } = render( + + Instruction + , + ) + const li = container.querySelector('li')! + expect(li.className).toContain('px-0') + expect(li.className).toContain('py-4') + }) + + it('should have italic class', () => { + const { container } = render( + + Instruction + , + ) + const li = container.querySelector('li')! + expect(li.className).toContain('italic') + }) + + it('should have first:pt-0 class', () => { + const { container } = render( + + Instruction + , + ) + const li = container.querySelector('li')! + expect(li.className).toContain('first:pt-0') + }) + }) + + describe('integration tests', () => { + it('should render Property inside Properties', () => { + render( + + + Unique identifier + + + Display name + + , + ) + + expect(screen.getByText('id')).toBeInTheDocument() + expect(screen.getByText('name')).toBeInTheDocument() + expect(screen.getByText('Unique identifier')).toBeInTheDocument() + expect(screen.getByText('Display name')).toBeInTheDocument() + }) + + it('should render Col inside Row', () => { + render( + + +
    Left column
    + + +
    Right column
    + +
    , + ) + + expect(screen.getByText('Left column')).toBeInTheDocument() + expect(screen.getByText('Right column')).toBeInTheDocument() + }) + + it('should render PropertyInstruction inside Properties', () => { + render( + + + Note: All fields are required + + + A required field + + , + ) + + expect(screen.getByText('Note: All fields are required')).toBeInTheDocument() + expect(screen.getByText('required_field')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/develop/secret-key/input-copy.spec.tsx b/web/app/components/develop/secret-key/input-copy.spec.tsx new file mode 100644 index 0000000000..0216f2bfad --- /dev/null +++ b/web/app/components/develop/secret-key/input-copy.spec.tsx @@ -0,0 +1,314 @@ +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import copy from 'copy-to-clipboard' +import InputCopy from './input-copy' + +// Mock copy-to-clipboard +vi.mock('copy-to-clipboard', () => ({ + default: vi.fn().mockReturnValue(true), +})) + +describe('InputCopy', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers({ shouldAdvanceTime: true }) + }) + + afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() + }) + + describe('rendering', () => { + it('should render the value', () => { + render() + expect(screen.getByText('test-api-key-12345')).toBeInTheDocument() + }) + + it('should render with empty value by default', () => { + render() + // Empty string should be rendered + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should render children when provided', () => { + render( + + Custom Content + , + ) + expect(screen.getByTestId('custom-child')).toBeInTheDocument() + }) + + it('should render CopyFeedback component', () => { + render() + // CopyFeedback should render a button + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThan(0) + }) + }) + + describe('styling', () => { + it('should apply custom className', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('custom-class') + }) + + it('should have flex layout', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('flex') + }) + + it('should have items-center alignment', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('items-center') + }) + + it('should have rounded-lg class', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('rounded-lg') + }) + + it('should have background class', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('bg-components-input-bg-normal') + }) + + it('should have hover state', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('hover:bg-state-base-hover') + }) + + it('should have py-2 padding', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('py-2') + }) + }) + + describe('copy functionality', () => { + it('should copy value when clicked', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + render() + + const copyableArea = screen.getByText('copy-this-value') + await act(async () => { + await user.click(copyableArea) + }) + + expect(copy).toHaveBeenCalledWith('copy-this-value') + }) + + it('should update copied state after clicking', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + render() + + const copyableArea = screen.getByText('test-value') + await act(async () => { + await user.click(copyableArea) + }) + + // Copy function should have been called + expect(copy).toHaveBeenCalledWith('test-value') + }) + + it('should reset copied state after timeout', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + render() + + const copyableArea = screen.getByText('test-value') + await act(async () => { + await user.click(copyableArea) + }) + + expect(copy).toHaveBeenCalledWith('test-value') + + // Advance time to reset the copied state + await act(async () => { + vi.advanceTimersByTime(1500) + }) + + // Component should still be functional + expect(screen.getByText('test-value')).toBeInTheDocument() + }) + + it('should render tooltip on value', () => { + render() + // Value should be wrapped in tooltip (tooltip shows on hover, not as visible text) + const valueText = screen.getByText('test-value') + expect(valueText).toBeInTheDocument() + }) + }) + + describe('tooltip', () => { + it('should render tooltip wrapper', () => { + render() + const valueText = screen.getByText('test') + expect(valueText).toBeInTheDocument() + }) + + it('should have cursor-pointer on clickable area', () => { + render() + const valueText = screen.getByText('test') + const clickableArea = valueText.closest('div[class*="cursor-pointer"]') + expect(clickableArea).toBeInTheDocument() + }) + }) + + describe('divider', () => { + it('should render vertical divider', () => { + const { container } = render() + const divider = container.querySelector('.bg-divider-regular') + expect(divider).toBeInTheDocument() + }) + + it('should have correct divider dimensions', () => { + const { container } = render() + const divider = container.querySelector('.bg-divider-regular') + expect(divider?.className).toContain('h-4') + expect(divider?.className).toContain('w-px') + }) + + it('should have shrink-0 on divider', () => { + const { container } = render() + const divider = container.querySelector('.bg-divider-regular') + expect(divider?.className).toContain('shrink-0') + }) + }) + + describe('value display', () => { + it('should have truncate class for long values', () => { + render() + const valueText = screen.getByText('very-long-api-key-value-that-might-overflow') + const container = valueText.closest('div[class*="truncate"]') + expect(container).toBeInTheDocument() + }) + + it('should have text-secondary color on value', () => { + render() + const valueText = screen.getByText('test-value') + expect(valueText.className).toContain('text-text-secondary') + }) + + it('should have absolute positioning for overlay', () => { + render() + const valueText = screen.getByText('test') + const container = valueText.closest('div[class*="absolute"]') + expect(container).toBeInTheDocument() + }) + }) + + describe('inner container', () => { + it('should have grow class on inner container', () => { + const { container } = render() + const innerContainer = container.querySelector('.grow') + expect(innerContainer).toBeInTheDocument() + }) + + it('should have h-5 height on inner container', () => { + const { container } = render() + const innerContainer = container.querySelector('.h-5') + expect(innerContainer).toBeInTheDocument() + }) + }) + + describe('with children', () => { + it('should render children before value', () => { + const { container } = render( + + Prefix: + , + ) + const children = container.querySelector('[data-testid="prefix"]') + expect(children).toBeInTheDocument() + }) + + it('should render both children and value', () => { + render( + + Label: + , + ) + expect(screen.getByText('Label:')).toBeInTheDocument() + expect(screen.getByText('api-key')).toBeInTheDocument() + }) + }) + + describe('CopyFeedback section', () => { + it('should have margin on CopyFeedback container', () => { + const { container } = render() + const copyFeedbackContainer = container.querySelector('.mx-1') + expect(copyFeedbackContainer).toBeInTheDocument() + }) + }) + + describe('relative container', () => { + it('should have relative positioning on value container', () => { + const { container } = render() + const relativeContainer = container.querySelector('.relative') + expect(relativeContainer).toBeInTheDocument() + }) + + it('should have grow on value container', () => { + const { container } = render() + // Find the relative container that also has grow + const valueContainer = container.querySelector('.relative.grow') + expect(valueContainer).toBeInTheDocument() + }) + + it('should have full height on value container', () => { + const { container } = render() + const valueContainer = container.querySelector('.relative.h-full') + expect(valueContainer).toBeInTheDocument() + }) + }) + + describe('edge cases', () => { + it('should handle undefined value', () => { + render() + // Should not crash + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should handle empty string value', () => { + render() + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should handle very long values', () => { + const longValue = 'a'.repeat(500) + render() + expect(screen.getByText(longValue)).toBeInTheDocument() + }) + + it('should handle special characters in value', () => { + const specialValue = 'key-with-special-chars!@#$%^&*()' + render() + expect(screen.getByText(specialValue)).toBeInTheDocument() + }) + }) + + describe('multiple clicks', () => { + it('should handle multiple rapid clicks', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + render() + + const copyableArea = screen.getByText('test') + + // Click multiple times rapidly + await act(async () => { + await user.click(copyableArea) + await user.click(copyableArea) + await user.click(copyableArea) + }) + + expect(copy).toHaveBeenCalledTimes(3) + }) + }) +}) diff --git a/web/app/components/develop/secret-key/secret-key-button.spec.tsx b/web/app/components/develop/secret-key/secret-key-button.spec.tsx new file mode 100644 index 0000000000..595c6cc5bb --- /dev/null +++ b/web/app/components/develop/secret-key/secret-key-button.spec.tsx @@ -0,0 +1,300 @@ +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import SecretKeyButton from './secret-key-button' + +// Mock the SecretKeyModal since it has complex dependencies +vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ + default: ({ isShow, onClose, appId }: { isShow: boolean, onClose: () => void, appId?: string }) => ( + isShow + ? ( +
    + + Modal for + {appId || 'no-app'} + + +
    + ) + : null + ), +})) + +describe('SecretKeyButton', () => { + describe('rendering', () => { + it('should render the button', () => { + render() + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should render the API key text', () => { + render() + expect(screen.getByText('appApi.apiKey')).toBeInTheDocument() + }) + + it('should render the key icon', () => { + const { container } = render() + // RiKey2Line icon should be rendered as an svg + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + }) + + it('should not show modal initially', () => { + render() + expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() + }) + }) + + describe('button interaction', () => { + it('should open modal when button is clicked', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await act(async () => { + await user.click(button) + }) + + expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() + }) + + it('should close modal when onClose is called', async () => { + const user = userEvent.setup() + render() + + // Open modal + const button = screen.getByRole('button') + await act(async () => { + await user.click(button) + }) + + expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() + + // Close modal + const closeButton = screen.getByTestId('close-modal') + await act(async () => { + await user.click(closeButton) + }) + + expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() + }) + + it('should toggle modal visibility', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + + // Open + await act(async () => { + await user.click(button) + }) + expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() + + // Close + const closeButton = screen.getByTestId('close-modal') + await act(async () => { + await user.click(closeButton) + }) + expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() + + // Open again + await act(async () => { + await user.click(button) + }) + expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() + }) + }) + + describe('props', () => { + it('should apply custom className', () => { + render() + const button = screen.getByRole('button') + expect(button.className).toContain('custom-class') + }) + + it('should pass appId to modal', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await act(async () => { + await user.click(button) + }) + + expect(screen.getByText('Modal for app-123')).toBeInTheDocument() + }) + + it('should handle undefined appId', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await act(async () => { + await user.click(button) + }) + + expect(screen.getByText('Modal for no-app')).toBeInTheDocument() + }) + + it('should apply custom textCls', () => { + render() + const text = screen.getByText('appApi.apiKey') + expect(text.className).toContain('custom-text-class') + }) + }) + + describe('button styling', () => { + it('should have px-3 padding', () => { + render() + const button = screen.getByRole('button') + expect(button.className).toContain('px-3') + }) + + it('should have small size', () => { + render() + const button = screen.getByRole('button') + expect(button.className).toContain('btn-small') + }) + + it('should have ghost variant', () => { + render() + const button = screen.getByRole('button') + expect(button.className).toContain('btn-ghost') + }) + }) + + describe('icon styling', () => { + it('should have icon container with flex layout', () => { + const { container } = render() + const iconContainer = container.querySelector('.flex.items-center.justify-center') + expect(iconContainer).toBeInTheDocument() + }) + + it('should have correct icon dimensions', () => { + const { container } = render() + const iconContainer = container.querySelector('.h-3\\.5.w-3\\.5') + expect(iconContainer).toBeInTheDocument() + }) + + it('should have tertiary text color on icon', () => { + const { container } = render() + const icon = container.querySelector('.text-text-tertiary') + expect(icon).toBeInTheDocument() + }) + }) + + describe('text styling', () => { + it('should have system-xs-medium class', () => { + render() + const text = screen.getByText('appApi.apiKey') + expect(text.className).toContain('system-xs-medium') + }) + + it('should have horizontal padding', () => { + render() + const text = screen.getByText('appApi.apiKey') + expect(text.className).toContain('px-[3px]') + }) + + it('should have tertiary text color', () => { + render() + const text = screen.getByText('appApi.apiKey') + expect(text.className).toContain('text-text-tertiary') + }) + }) + + describe('modal props', () => { + it('should pass isShow prop to modal', async () => { + const user = userEvent.setup() + render() + + // Initially modal should not be visible + expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() + + const button = screen.getByRole('button') + await act(async () => { + await user.click(button) + }) + + // Now modal should be visible + expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() + }) + + it('should pass onClose callback to modal', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await act(async () => { + await user.click(button) + }) + + const closeButton = screen.getByTestId('close-modal') + await act(async () => { + await user.click(closeButton) + }) + + // Modal should be closed after clicking close + expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() + }) + }) + + describe('accessibility', () => { + it('should have accessible button', () => { + render() + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + }) + + it('should be keyboard accessible', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + button.focus() + expect(document.activeElement).toBe(button) + + // Press Enter to activate + await act(async () => { + await user.keyboard('{Enter}') + }) + + expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() + }) + }) + + describe('multiple instances', () => { + it('should work independently when multiple instances exist', async () => { + const user = userEvent.setup() + render( + <> + + + , + ) + + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(2) + + // Click first button + await act(async () => { + await user.click(buttons[0]) + }) + + expect(screen.getByText('Modal for app-1')).toBeInTheDocument() + + // Close first modal + const closeButton = screen.getByTestId('close-modal') + await act(async () => { + await user.click(closeButton) + }) + + // Click second button + await act(async () => { + await user.click(buttons[1]) + }) + + expect(screen.getByText('Modal for app-2')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/develop/secret-key/secret-key-generate.spec.tsx b/web/app/components/develop/secret-key/secret-key-generate.spec.tsx new file mode 100644 index 0000000000..2984a7b471 --- /dev/null +++ b/web/app/components/develop/secret-key/secret-key-generate.spec.tsx @@ -0,0 +1,301 @@ +import type { CreateApiKeyResponse } from '@/models/app' +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import SecretKeyGenerateModal from './secret-key-generate' + +// Helper to create a valid CreateApiKeyResponse +const createMockApiKey = (token: string): CreateApiKeyResponse => ({ + id: 'mock-id', + token, + created_at: '2024-01-01T00:00:00Z', +}) + +describe('SecretKeyGenerateModal', () => { + const defaultProps = { + isShow: true, + onClose: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering when shown', () => { + it('should render the modal when isShow is true', () => { + render() + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + }) + + it('should render the generate tips text', () => { + render() + expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument() + }) + + it('should render the OK button', () => { + render() + expect(screen.getByText('appApi.actionMsg.ok')).toBeInTheDocument() + }) + + it('should render the close icon', () => { + render() + // Modal renders via portal, so query from document.body + const closeIcon = document.body.querySelector('svg.cursor-pointer') + expect(closeIcon).toBeInTheDocument() + }) + + it('should render InputCopy component', () => { + render() + expect(screen.getByText('test-token-123')).toBeInTheDocument() + }) + }) + + describe('rendering when hidden', () => { + it('should not render content when isShow is false', () => { + render() + expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument() + }) + }) + + describe('newKey prop', () => { + it('should display the token when newKey is provided', () => { + render() + expect(screen.getByText('sk-abc123xyz')).toBeInTheDocument() + }) + + it('should handle undefined newKey', () => { + render() + // Should not crash and modal should still render + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + }) + + it('should handle newKey with empty token', () => { + render() + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + }) + + it('should display long tokens correctly', () => { + const longToken = `sk-${'a'.repeat(100)}` + render() + expect(screen.getByText(longToken)).toBeInTheDocument() + }) + }) + + describe('close functionality', () => { + it('should call onClose when X icon is clicked', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + render() + + // Modal renders via portal + const closeIcon = document.body.querySelector('svg.cursor-pointer') + expect(closeIcon).toBeInTheDocument() + + await act(async () => { + await user.click(closeIcon!) + }) + + // HeadlessUI Dialog may trigger onClose multiple times (icon click handler + dialog close) + expect(onClose).toHaveBeenCalled() + }) + + it('should call onClose when OK button is clicked', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + render() + + const okButton = screen.getByRole('button', { name: /ok/i }) + await act(async () => { + await user.click(okButton) + }) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) + + describe('className prop', () => { + it('should apply custom className', () => { + render( + , + ) + // Modal renders via portal + const modal = document.body.querySelector('.custom-modal-class') + expect(modal).toBeInTheDocument() + }) + + it('should apply shrink-0 class', () => { + render( + , + ) + // Modal renders via portal + const modal = document.body.querySelector('.shrink-0') + expect(modal).toBeInTheDocument() + }) + }) + + describe('modal styling', () => { + it('should have px-8 padding', () => { + render() + // Modal renders via portal + const modal = document.body.querySelector('.px-8') + expect(modal).toBeInTheDocument() + }) + }) + + describe('close icon styling', () => { + it('should have cursor-pointer class on close icon', () => { + render() + // Modal renders via portal + const closeIcon = document.body.querySelector('svg.cursor-pointer') + expect(closeIcon).toBeInTheDocument() + }) + + it('should have correct dimensions on close icon', () => { + render() + // Modal renders via portal + const closeIcon = document.body.querySelector('svg[class*="h-6"][class*="w-6"]') + expect(closeIcon).toBeInTheDocument() + }) + + it('should have tertiary text color on close icon', () => { + render() + // Modal renders via portal + const closeIcon = document.body.querySelector('svg[class*="text-text-tertiary"]') + expect(closeIcon).toBeInTheDocument() + }) + }) + + describe('header section', () => { + it('should have flex justify-end on close container', () => { + render() + // Modal renders via portal + const closeIcon = document.body.querySelector('svg.cursor-pointer') + const closeContainer = closeIcon?.parentElement + expect(closeContainer).toBeInTheDocument() + expect(closeContainer?.className).toContain('flex') + expect(closeContainer?.className).toContain('justify-end') + }) + + it('should have negative margin on close container', () => { + render() + // Modal renders via portal + const closeIcon = document.body.querySelector('svg.cursor-pointer') + const closeContainer = closeIcon?.parentElement + expect(closeContainer).toBeInTheDocument() + expect(closeContainer?.className).toContain('-mr-2') + expect(closeContainer?.className).toContain('-mt-6') + }) + + it('should have bottom margin on close container', () => { + render() + // Modal renders via portal + const closeIcon = document.body.querySelector('svg.cursor-pointer') + const closeContainer = closeIcon?.parentElement + expect(closeContainer).toBeInTheDocument() + expect(closeContainer?.className).toContain('mb-4') + }) + }) + + describe('tips text styling', () => { + it('should have mt-1 margin on tips', () => { + render() + const tips = screen.getByText('appApi.apiKeyModal.generateTips') + expect(tips.className).toContain('mt-1') + }) + + it('should have correct font size', () => { + render() + const tips = screen.getByText('appApi.apiKeyModal.generateTips') + expect(tips.className).toContain('text-[13px]') + }) + + it('should have normal font weight', () => { + render() + const tips = screen.getByText('appApi.apiKeyModal.generateTips') + expect(tips.className).toContain('font-normal') + }) + + it('should have leading-5 line height', () => { + render() + const tips = screen.getByText('appApi.apiKeyModal.generateTips') + expect(tips.className).toContain('leading-5') + }) + + it('should have tertiary text color', () => { + render() + const tips = screen.getByText('appApi.apiKeyModal.generateTips') + expect(tips.className).toContain('text-text-tertiary') + }) + }) + + describe('InputCopy section', () => { + it('should render InputCopy with token value', () => { + render() + expect(screen.getByText('test-token')).toBeInTheDocument() + }) + + it('should have w-full class on InputCopy', () => { + render() + // The InputCopy component should have w-full + const inputText = screen.getByText('test') + const inputContainer = inputText.closest('.w-full') + expect(inputContainer).toBeInTheDocument() + }) + }) + + describe('OK button section', () => { + it('should render OK button', () => { + render() + const button = screen.getByRole('button', { name: /ok/i }) + expect(button).toBeInTheDocument() + }) + + it('should have button container with flex layout', () => { + render() + const button = screen.getByRole('button', { name: /ok/i }) + const container = button.parentElement + expect(container).toBeInTheDocument() + expect(container?.className).toContain('flex') + }) + + it('should have shrink-0 on button', () => { + render() + const button = screen.getByRole('button', { name: /ok/i }) + expect(button.className).toContain('shrink-0') + }) + }) + + describe('button text styling', () => { + it('should have text-xs font size on button text', () => { + render() + const buttonText = screen.getByText('appApi.actionMsg.ok') + expect(buttonText.className).toContain('text-xs') + }) + + it('should have font-medium on button text', () => { + render() + const buttonText = screen.getByText('appApi.actionMsg.ok') + expect(buttonText.className).toContain('font-medium') + }) + + it('should have secondary text color on button text', () => { + render() + const buttonText = screen.getByText('appApi.actionMsg.ok') + expect(buttonText.className).toContain('text-text-secondary') + }) + }) + + describe('default prop values', () => { + it('should default isShow to false', () => { + // When isShow is explicitly set to false + render() + expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument() + }) + }) + + describe('modal title', () => { + it('should display the correct title', () => { + render() + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/develop/secret-key/secret-key-modal.spec.tsx b/web/app/components/develop/secret-key/secret-key-modal.spec.tsx new file mode 100644 index 0000000000..79c51759ea --- /dev/null +++ b/web/app/components/develop/secret-key/secret-key-modal.spec.tsx @@ -0,0 +1,614 @@ +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import SecretKeyModal from './secret-key-modal' + +// Mock the app context +const mockCurrentWorkspace = vi.fn().mockReturnValue({ + id: 'workspace-1', + name: 'Test Workspace', +}) +const mockIsCurrentWorkspaceManager = vi.fn().mockReturnValue(true) +const mockIsCurrentWorkspaceEditor = vi.fn().mockReturnValue(true) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + currentWorkspace: mockCurrentWorkspace(), + isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(), + isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(), + }), +})) + +// Mock the timestamp hook +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: vi.fn((value: number, _format: string) => `Formatted: ${value}`), + formatDate: vi.fn((value: string, _format: string) => `Formatted: ${value}`), + }), +})) + +// Mock API services +const mockCreateAppApikey = vi.fn().mockResolvedValue({ token: 'new-app-token-123' }) +const mockDelAppApikey = vi.fn().mockResolvedValue({}) +vi.mock('@/service/apps', () => ({ + createApikey: (...args: unknown[]) => mockCreateAppApikey(...args), + delApikey: (...args: unknown[]) => mockDelAppApikey(...args), +})) + +const mockCreateDatasetApikey = vi.fn().mockResolvedValue({ token: 'new-dataset-token-123' }) +const mockDelDatasetApikey = vi.fn().mockResolvedValue({}) +vi.mock('@/service/datasets', () => ({ + createApikey: (...args: unknown[]) => mockCreateDatasetApikey(...args), + delApikey: (...args: unknown[]) => mockDelDatasetApikey(...args), +})) + +// Mock React Query hooks for apps +const mockAppApiKeysData = vi.fn().mockReturnValue({ data: [] }) +const mockIsAppApiKeysLoading = vi.fn().mockReturnValue(false) +const mockInvalidateAppApiKeys = vi.fn() + +vi.mock('@/service/use-apps', () => ({ + useAppApiKeys: (_appId: string, _options: unknown) => ({ + data: mockAppApiKeysData(), + isLoading: mockIsAppApiKeysLoading(), + }), + useInvalidateAppApiKeys: () => mockInvalidateAppApiKeys, +})) + +// Mock React Query hooks for datasets +const mockDatasetApiKeysData = vi.fn().mockReturnValue({ data: [] }) +const mockIsDatasetApiKeysLoading = vi.fn().mockReturnValue(false) +const mockInvalidateDatasetApiKeys = vi.fn() + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useDatasetApiKeys: (_options: unknown) => ({ + data: mockDatasetApiKeysData(), + isLoading: mockIsDatasetApiKeysLoading(), + }), + useInvalidateDatasetApiKeys: () => mockInvalidateDatasetApiKeys, +})) + +describe('SecretKeyModal', () => { + const defaultProps = { + isShow: true, + onClose: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + mockCurrentWorkspace.mockReturnValue({ id: 'workspace-1', name: 'Test Workspace' }) + mockIsCurrentWorkspaceManager.mockReturnValue(true) + mockIsCurrentWorkspaceEditor.mockReturnValue(true) + mockAppApiKeysData.mockReturnValue({ data: [] }) + mockIsAppApiKeysLoading.mockReturnValue(false) + mockDatasetApiKeysData.mockReturnValue({ data: [] }) + mockIsDatasetApiKeysLoading.mockReturnValue(false) + }) + + describe('rendering when shown', () => { + it('should render the modal when isShow is true', () => { + render() + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + }) + + it('should render the tips text', () => { + render() + expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument() + }) + + it('should render the create new key button', () => { + render() + expect(screen.getByText('appApi.apiKeyModal.createNewSecretKey')).toBeInTheDocument() + }) + + it('should render the close icon', () => { + render() + // Modal renders via portal, so we need to query from document.body + const closeIcon = document.body.querySelector('svg.cursor-pointer') + expect(closeIcon).toBeInTheDocument() + }) + }) + + describe('rendering when hidden', () => { + it('should not render content when isShow is false', () => { + render() + expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument() + }) + }) + + describe('loading state', () => { + it('should show loading when app API keys are loading', () => { + mockIsAppApiKeysLoading.mockReturnValue(true) + render() + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('should show loading when dataset API keys are loading', () => { + mockIsDatasetApiKeysLoading.mockReturnValue(true) + render() + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('should not show loading when data is loaded', () => { + mockIsAppApiKeysLoading.mockReturnValue(false) + render() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + }) + + describe('API keys list for app', () => { + const apiKeys = [ + { id: 'key-1', token: 'sk-abc123def456ghi789', created_at: 1700000000, last_used_at: 1700100000 }, + { id: 'key-2', token: 'sk-xyz987wvu654tsr321', created_at: 1700050000, last_used_at: null }, + ] + + beforeEach(() => { + mockAppApiKeysData.mockReturnValue({ data: apiKeys }) + }) + + it('should render API keys when available', () => { + render() + // Token 'sk-abc123def456ghi789' (21 chars) -> first 3 'sk-' + '...' + last 20 'k-abc123def456ghi789' + expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument() + }) + + it('should render created time for keys', () => { + render() + expect(screen.getByText('Formatted: 1700000000')).toBeInTheDocument() + }) + + it('should render last used time for keys', () => { + render() + expect(screen.getByText('Formatted: 1700100000')).toBeInTheDocument() + }) + + it('should render "never" for keys without last_used_at', () => { + render() + expect(screen.getByText('appApi.never')).toBeInTheDocument() + }) + + it('should render delete button for managers', () => { + render() + // Delete button contains RiDeleteBinLine SVG - look for SVGs with h-4 w-4 class within buttons + const buttons = screen.getAllByRole('button') + // There should be at least 3 buttons: copy feedback, delete, and create + expect(buttons.length).toBeGreaterThanOrEqual(2) + // Check for delete icon SVG - Modal renders via portal + const deleteIcon = document.body.querySelector('svg[class*="h-4"][class*="w-4"]') + expect(deleteIcon).toBeInTheDocument() + }) + + it('should not render delete button for non-managers', () => { + mockIsCurrentWorkspaceManager.mockReturnValue(false) + render() + // The specific delete action button should not be present + const actionButtons = screen.getAllByRole('button') + // Should only have copy and create buttons, not delete + expect(actionButtons.length).toBeGreaterThan(0) + }) + + it('should render table headers', () => { + render() + expect(screen.getByText('appApi.apiKeyModal.secretKey')).toBeInTheDocument() + expect(screen.getByText('appApi.apiKeyModal.created')).toBeInTheDocument() + expect(screen.getByText('appApi.apiKeyModal.lastUsed')).toBeInTheDocument() + }) + }) + + describe('API keys list for dataset', () => { + const datasetKeys = [ + { id: 'dk-1', token: 'dk-abc123def456ghi789', created_at: 1700000000, last_used_at: 1700100000 }, + ] + + beforeEach(() => { + mockDatasetApiKeysData.mockReturnValue({ data: datasetKeys }) + }) + + it('should render dataset API keys when no appId', () => { + render() + // Token 'dk-abc123def456ghi789' (21 chars) -> first 3 'dk-' + '...' + last 20 'k-abc123def456ghi789' + expect(screen.getByText('dk-...k-abc123def456ghi789')).toBeInTheDocument() + }) + }) + + describe('close functionality', () => { + it('should call onClose when X icon is clicked', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + render() + + // Modal renders via portal, so we need to query from document.body + const closeIcon = document.body.querySelector('svg.cursor-pointer') + expect(closeIcon).toBeInTheDocument() + + await act(async () => { + await user.click(closeIcon!) + }) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) + + describe('create new key', () => { + it('should call create API for app when button is clicked', async () => { + const user = userEvent.setup() + render() + + const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey') + await act(async () => { + await user.click(createButton) + }) + + await waitFor(() => { + expect(mockCreateAppApikey).toHaveBeenCalledWith({ + url: '/apps/app-123/api-keys', + body: {}, + }) + }) + }) + + it('should call create API for dataset when no appId', async () => { + const user = userEvent.setup() + render() + + const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey') + await act(async () => { + await user.click(createButton) + }) + + await waitFor(() => { + expect(mockCreateDatasetApikey).toHaveBeenCalledWith({ + url: '/datasets/api-keys', + body: {}, + }) + }) + }) + + it('should show generate modal after creating key', async () => { + const user = userEvent.setup() + render() + + const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey') + await act(async () => { + await user.click(createButton) + }) + + await waitFor(() => { + // The SecretKeyGenerateModal should be shown with the new token + expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument() + }) + }) + + it('should invalidate app API keys after creating', async () => { + const user = userEvent.setup() + render() + + const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey') + await act(async () => { + await user.click(createButton) + }) + + await waitFor(() => { + expect(mockInvalidateAppApiKeys).toHaveBeenCalledWith('app-123') + }) + }) + + it('should invalidate dataset API keys after creating (no appId)', async () => { + const user = userEvent.setup() + render() + + const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey') + await act(async () => { + await user.click(createButton) + }) + + await waitFor(() => { + expect(mockInvalidateDatasetApiKeys).toHaveBeenCalled() + }) + }) + + it('should disable create button when no workspace', () => { + mockCurrentWorkspace.mockReturnValue(null) + render() + + const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey').closest('button') + expect(createButton).toBeDisabled() + }) + + it('should disable create button when not editor', () => { + mockIsCurrentWorkspaceEditor.mockReturnValue(false) + render() + + const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey').closest('button') + expect(createButton).toBeDisabled() + }) + }) + + describe('delete key', () => { + const apiKeys = [ + { id: 'key-1', token: 'sk-abc123def456ghi789', created_at: 1700000000, last_used_at: 1700100000 }, + ] + + beforeEach(() => { + mockAppApiKeysData.mockReturnValue({ data: apiKeys }) + }) + + it('should render delete button for managers', () => { + render() + + // Find buttons that contain SVG (delete/copy buttons) + const actionButtons = screen.getAllByRole('button') + // There should be at least copy, delete, and create buttons + expect(actionButtons.length).toBeGreaterThanOrEqual(3) + }) + + it('should render API key row with actions', () => { + render() + + // Verify the truncated token is rendered + expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument() + }) + + it('should have action buttons in the key row', () => { + render() + + // Check for action button containers - Modal renders via portal + const actionContainers = document.body.querySelectorAll('[class*="space-x-2"]') + expect(actionContainers.length).toBeGreaterThan(0) + }) + + it('should have delete button visible for managers', async () => { + render() + + // Find the delete button by looking for the button with the delete icon + const deleteIcon = document.body.querySelector('svg[class*="h-4"][class*="w-4"]') + const deleteButton = deleteIcon?.closest('button') + expect(deleteButton).toBeInTheDocument() + }) + + it('should show confirm dialog when delete button is clicked', async () => { + const user = userEvent.setup() + render() + + // Find delete button by action-btn class (second action button after copy) + const actionButtons = document.body.querySelectorAll('button.action-btn') + // The delete button is the second action button (first is copy) + const deleteButton = actionButtons[1] + expect(deleteButton).toBeInTheDocument() + + await act(async () => { + await user.click(deleteButton!) + }) + + // Confirm dialog should appear + await waitFor(() => { + expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument() + expect(screen.getByText('appApi.actionMsg.deleteConfirmTips')).toBeInTheDocument() + }) + }) + + it('should call delete API for app when confirmed', async () => { + const user = userEvent.setup() + render() + + // Find and click delete button + const actionButtons = document.body.querySelectorAll('button.action-btn') + const deleteButton = actionButtons[1] + await act(async () => { + await user.click(deleteButton!) + }) + + // Wait for confirm dialog and click confirm + await waitFor(() => { + expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument() + }) + + // Find and click the confirm button + const confirmButton = screen.getByText('common.operation.confirm') + await act(async () => { + await user.click(confirmButton) + }) + + await waitFor(() => { + expect(mockDelAppApikey).toHaveBeenCalledWith({ + url: '/apps/app-123/api-keys/key-1', + params: {}, + }) + }) + }) + + it('should invalidate app API keys after deleting', async () => { + const user = userEvent.setup() + render() + + // Find and click delete button + const actionButtons = document.body.querySelectorAll('button.action-btn') + const deleteButton = actionButtons[1] + await act(async () => { + await user.click(deleteButton!) + }) + + // Wait for confirm dialog and click confirm + await waitFor(() => { + expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument() + }) + + const confirmButton = screen.getByText('common.operation.confirm') + await act(async () => { + await user.click(confirmButton) + }) + + await waitFor(() => { + expect(mockInvalidateAppApiKeys).toHaveBeenCalledWith('app-123') + }) + }) + + it('should close confirm dialog and clear delKeyId when cancel is clicked', async () => { + const user = userEvent.setup() + render() + + // Find and click delete button + const actionButtons = document.body.querySelectorAll('button.action-btn') + const deleteButton = actionButtons[1] + await act(async () => { + await user.click(deleteButton!) + }) + + // Wait for confirm dialog + await waitFor(() => { + expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument() + }) + + // Click cancel button + const cancelButton = screen.getByText('common.operation.cancel') + await act(async () => { + await user.click(cancelButton) + }) + + // Confirm dialog should close + await waitFor(() => { + expect(screen.queryByText('appApi.actionMsg.deleteConfirmTitle')).not.toBeInTheDocument() + }) + + // Delete API should not be called + expect(mockDelAppApikey).not.toHaveBeenCalled() + }) + }) + + describe('delete key for dataset', () => { + const datasetKeys = [ + { id: 'dk-1', token: 'dk-abc123def456ghi789', created_at: 1700000000, last_used_at: 1700100000 }, + ] + + beforeEach(() => { + mockDatasetApiKeysData.mockReturnValue({ data: datasetKeys }) + }) + + it('should call delete API for dataset when no appId', async () => { + const user = userEvent.setup() + render() + + // Find and click delete button + const actionButtons = document.body.querySelectorAll('button.action-btn') + const deleteButton = actionButtons[1] + await act(async () => { + await user.click(deleteButton!) + }) + + // Wait for confirm dialog and click confirm + await waitFor(() => { + expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument() + }) + + const confirmButton = screen.getByText('common.operation.confirm') + await act(async () => { + await user.click(confirmButton) + }) + + await waitFor(() => { + expect(mockDelDatasetApikey).toHaveBeenCalledWith({ + url: '/datasets/api-keys/dk-1', + params: {}, + }) + }) + }) + + it('should invalidate dataset API keys after deleting', async () => { + const user = userEvent.setup() + render() + + // Find and click delete button + const actionButtons = document.body.querySelectorAll('button.action-btn') + const deleteButton = actionButtons[1] + await act(async () => { + await user.click(deleteButton!) + }) + + // Wait for confirm dialog and click confirm + await waitFor(() => { + expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument() + }) + + const confirmButton = screen.getByText('common.operation.confirm') + await act(async () => { + await user.click(confirmButton) + }) + + await waitFor(() => { + expect(mockInvalidateDatasetApiKeys).toHaveBeenCalled() + }) + }) + }) + + describe('token truncation', () => { + it('should truncate token correctly', () => { + const apiKeys = [ + { id: 'key-1', token: 'sk-abcdefghijklmnopqrstuvwxyz1234567890', created_at: 1700000000, last_used_at: null }, + ] + mockAppApiKeysData.mockReturnValue({ data: apiKeys }) + + render() + + // Token format: first 3 chars + ... + last 20 chars + // 'sk-abcdefghijklmnopqrstuvwxyz1234567890' -> 'sk-...qrstuvwxyz1234567890' + expect(screen.getByText('sk-...qrstuvwxyz1234567890')).toBeInTheDocument() + }) + }) + + describe('styling', () => { + it('should render modal with expected structure', () => { + render() + // Modal should render and contain the title + expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() + }) + + it('should render create button with flex styling', () => { + render() + // Modal renders via portal, so query from document.body + const flexContainers = document.body.querySelectorAll('[class*="flex"]') + expect(flexContainers.length).toBeGreaterThan(0) + }) + }) + + describe('empty state', () => { + it('should not render table when no keys', () => { + mockAppApiKeysData.mockReturnValue({ data: [] }) + render() + + expect(screen.queryByText('appApi.apiKeyModal.secretKey')).not.toBeInTheDocument() + }) + + it('should not render table when data is null', () => { + mockAppApiKeysData.mockReturnValue(null) + render() + + expect(screen.queryByText('appApi.apiKeyModal.secretKey')).not.toBeInTheDocument() + }) + }) + + describe('SecretKeyGenerateModal', () => { + it('should close generate modal on close', async () => { + const user = userEvent.setup() + render() + + // Create a new key to open generate modal + const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey') + await act(async () => { + await user.click(createButton) + }) + + await waitFor(() => { + expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument() + }) + + // Find and click the close/OK button in generate modal + const okButton = screen.getByText('appApi.actionMsg.ok') + await act(async () => { + await user.click(okButton) + }) + + await waitFor(() => { + expect(screen.queryByText('appApi.apiKeyModal.generateTips')).not.toBeInTheDocument() + }) + }) + }) +}) diff --git a/web/app/components/develop/tag.spec.tsx b/web/app/components/develop/tag.spec.tsx new file mode 100644 index 0000000000..60a12040fa --- /dev/null +++ b/web/app/components/develop/tag.spec.tsx @@ -0,0 +1,242 @@ +import { render, screen } from '@testing-library/react' +import { Tag } from './tag' + +describe('Tag', () => { + describe('rendering', () => { + it('should render children text', () => { + render(GET) + expect(screen.getByText('GET')).toBeInTheDocument() + }) + + it('should render as a span element', () => { + render(POST) + const tag = screen.getByText('POST') + expect(tag.tagName).toBe('SPAN') + }) + }) + + describe('default color mapping based on HTTP methods', () => { + it('should apply emerald color for GET method', () => { + render(GET) + const tag = screen.getByText('GET') + expect(tag.className).toContain('text-emerald') + }) + + it('should apply sky color for POST method', () => { + render(POST) + const tag = screen.getByText('POST') + expect(tag.className).toContain('text-sky') + }) + + it('should apply amber color for PUT method', () => { + render(PUT) + const tag = screen.getByText('PUT') + expect(tag.className).toContain('text-amber') + }) + + it('should apply rose color for DELETE method', () => { + render(DELETE) + const tag = screen.getByText('DELETE') + expect(tag.className).toContain('text-red') + }) + + it('should apply emerald color for unknown methods', () => { + render(UNKNOWN) + const tag = screen.getByText('UNKNOWN') + expect(tag.className).toContain('text-emerald') + }) + + it('should handle lowercase method names', () => { + render(get) + const tag = screen.getByText('get') + expect(tag.className).toContain('text-emerald') + }) + + it('should handle mixed case method names', () => { + render(Post) + const tag = screen.getByText('Post') + expect(tag.className).toContain('text-sky') + }) + }) + + describe('custom color prop', () => { + it('should override default color with custom emerald color', () => { + render(CUSTOM) + const tag = screen.getByText('CUSTOM') + expect(tag.className).toContain('text-emerald') + }) + + it('should override default color with custom sky color', () => { + render(CUSTOM) + const tag = screen.getByText('CUSTOM') + expect(tag.className).toContain('text-sky') + }) + + it('should override default color with custom amber color', () => { + render(CUSTOM) + const tag = screen.getByText('CUSTOM') + expect(tag.className).toContain('text-amber') + }) + + it('should override default color with custom rose color', () => { + render(CUSTOM) + const tag = screen.getByText('CUSTOM') + expect(tag.className).toContain('text-red') + }) + + it('should override default color with custom zinc color', () => { + render(CUSTOM) + const tag = screen.getByText('CUSTOM') + expect(tag.className).toContain('text-zinc') + }) + + it('should override automatic color mapping with explicit color', () => { + render(GET) + const tag = screen.getByText('GET') + expect(tag.className).toContain('text-sky') + }) + }) + + describe('variant styles', () => { + it('should apply medium variant styles by default', () => { + render(GET) + const tag = screen.getByText('GET') + expect(tag.className).toContain('rounded-lg') + expect(tag.className).toContain('px-1.5') + expect(tag.className).toContain('ring-1') + expect(tag.className).toContain('ring-inset') + }) + + it('should apply small variant styles', () => { + render(GET) + const tag = screen.getByText('GET') + // Small variant should not have ring styles + expect(tag.className).not.toContain('rounded-lg') + expect(tag.className).not.toContain('ring-1') + }) + }) + + describe('base styles', () => { + it('should always have font-mono class', () => { + render(GET) + const tag = screen.getByText('GET') + expect(tag.className).toContain('font-mono') + }) + + it('should always have correct font-size class', () => { + render(GET) + const tag = screen.getByText('GET') + expect(tag.className).toContain('text-[0.625rem]') + }) + + it('should always have font-semibold class', () => { + render(GET) + const tag = screen.getByText('GET') + expect(tag.className).toContain('font-semibold') + }) + + it('should always have leading-6 class', () => { + render(GET) + const tag = screen.getByText('GET') + expect(tag.className).toContain('leading-6') + }) + }) + + describe('color styles for medium variant', () => { + it('should apply full emerald medium styles', () => { + render(TEST) + const tag = screen.getByText('TEST') + expect(tag.className).toContain('ring-emerald-300') + expect(tag.className).toContain('bg-emerald-400/10') + expect(tag.className).toContain('text-emerald-500') + }) + + it('should apply full sky medium styles', () => { + render(TEST) + const tag = screen.getByText('TEST') + expect(tag.className).toContain('ring-sky-300') + expect(tag.className).toContain('bg-sky-400/10') + expect(tag.className).toContain('text-sky-500') + }) + + it('should apply full amber medium styles', () => { + render(TEST) + const tag = screen.getByText('TEST') + expect(tag.className).toContain('ring-amber-300') + expect(tag.className).toContain('bg-amber-400/10') + expect(tag.className).toContain('text-amber-500') + }) + + it('should apply full rose medium styles', () => { + render(TEST) + const tag = screen.getByText('TEST') + expect(tag.className).toContain('ring-rose-200') + expect(tag.className).toContain('bg-rose-50') + expect(tag.className).toContain('text-red-500') + }) + + it('should apply full zinc medium styles', () => { + render(TEST) + const tag = screen.getByText('TEST') + expect(tag.className).toContain('ring-zinc-200') + expect(tag.className).toContain('bg-zinc-50') + expect(tag.className).toContain('text-zinc-500') + }) + }) + + describe('color styles for small variant', () => { + it('should apply emerald small styles', () => { + render(TEST) + const tag = screen.getByText('TEST') + expect(tag.className).toContain('text-emerald-500') + // Small variant should not have background/ring styles + expect(tag.className).not.toContain('bg-emerald-400/10') + expect(tag.className).not.toContain('ring-emerald-300') + }) + + it('should apply sky small styles', () => { + render(TEST) + const tag = screen.getByText('TEST') + expect(tag.className).toContain('text-sky-500') + }) + + it('should apply amber small styles', () => { + render(TEST) + const tag = screen.getByText('TEST') + expect(tag.className).toContain('text-amber-500') + }) + + it('should apply rose small styles', () => { + render(TEST) + const tag = screen.getByText('TEST') + expect(tag.className).toContain('text-red-500') + }) + + it('should apply zinc small styles', () => { + render(TEST) + const tag = screen.getByText('TEST') + expect(tag.className).toContain('text-zinc-400') + }) + }) + + describe('HTTP method color combinations', () => { + it('should correctly map PATCH to emerald (default)', () => { + render(PATCH) + const tag = screen.getByText('PATCH') + // PATCH is not in the valueColorMap, so it defaults to emerald + expect(tag.className).toContain('text-emerald') + }) + + it('should correctly render all standard HTTP methods', () => { + const methods = ['GET', 'POST', 'PUT', 'DELETE'] + const expectedColors = ['emerald', 'sky', 'amber', 'red'] + + methods.forEach((method, index) => { + const { unmount } = render({method}) + const tag = screen.getByText(method) + expect(tag.className).toContain(`text-${expectedColors[index]}`) + unmount() + }) + }) + }) +})