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()
+ expect(screen.getByText('embedded content')).toBeInTheDocument()
+ })
+
+ it('should render as span element', () => {
+ render()
+ const span = screen.getByText('test value')
+ expect(span.tagName).toBe('SPAN')
+ })
+
+ it('should pass through additional props', () => {
+ render()
+ const embed = screen.getByTestId('embed-test')
+ expect(embed).toHaveClass('embed-class')
+ })
+
+ it('should not render children, only value', () => {
+ render()
+ 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()
+ })
+ })
+ })
+})