diff --git a/web/app/components/develop/secret-key/input-copy.spec.tsx b/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx
similarity index 60%
rename from web/app/components/develop/secret-key/input-copy.spec.tsx
rename to web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx
index 0216f2bfad..e022faffc1 100644
--- a/web/app/components/develop/secret-key/input-copy.spec.tsx
+++ b/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx
@@ -1,13 +1,20 @@
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'
+import InputCopy from '../input-copy'
-// Mock copy-to-clipboard
vi.mock('copy-to-clipboard', () => ({
default: vi.fn().mockReturnValue(true),
}))
+async function renderAndFlush(ui: React.ReactElement) {
+ const result = render(ui)
+ await act(async () => {
+ vi.runAllTimers()
+ })
+ return result
+}
+
describe('InputCopy', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -20,19 +27,18 @@ describe('InputCopy', () => {
})
describe('rendering', () => {
- it('should render the value', () => {
- render(
)
+ it('should render the value', async () => {
+ await renderAndFlush(
)
expect(screen.getByText('test-api-key-12345')).toBeInTheDocument()
})
- it('should render with empty value by default', () => {
- render(
)
- // Empty string should be rendered
+ it('should render with empty value by default', async () => {
+ await renderAndFlush(
)
expect(screen.getByRole('button')).toBeInTheDocument()
})
- it('should render children when provided', () => {
- render(
+ it('should render children when provided', async () => {
+ await renderAndFlush(
Custom Content
,
@@ -40,53 +46,52 @@ describe('InputCopy', () => {
expect(screen.getByTestId('custom-child')).toBeInTheDocument()
})
- it('should render CopyFeedback component', () => {
- render(
)
- // CopyFeedback should render a button
+ it('should render CopyFeedback component', async () => {
+ await renderAndFlush(
)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
})
describe('styling', () => {
- it('should apply custom className', () => {
- const { container } = render(
)
+ it('should apply custom className', async () => {
+ const { container } = await renderAndFlush(
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('custom-class')
})
- it('should have flex layout', () => {
- const { container } = render(
)
+ it('should have flex layout', async () => {
+ const { container } = await renderAndFlush(
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('flex')
})
- it('should have items-center alignment', () => {
- const { container } = render(
)
+ it('should have items-center alignment', async () => {
+ const { container } = await renderAndFlush(
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('items-center')
})
- it('should have rounded-lg class', () => {
- const { container } = render(
)
+ it('should have rounded-lg class', async () => {
+ const { container } = await renderAndFlush(
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('rounded-lg')
})
- it('should have background class', () => {
- const { container } = render(
)
+ it('should have background class', async () => {
+ const { container } = await renderAndFlush(
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('bg-components-input-bg-normal')
})
- it('should have hover state', () => {
- const { container } = render(
)
+ it('should have hover state', async () => {
+ const { container } = await renderAndFlush(
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('hover:bg-state-base-hover')
})
- it('should have py-2 padding', () => {
- const { container } = render(
)
+ it('should have py-2 padding', async () => {
+ const { container } = await renderAndFlush(
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('py-2')
})
@@ -95,7 +100,7 @@ describe('InputCopy', () => {
describe('copy functionality', () => {
it('should copy value when clicked', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
- render(
)
+ await renderAndFlush(
)
const copyableArea = screen.getByText('copy-this-value')
await act(async () => {
@@ -107,20 +112,19 @@ describe('InputCopy', () => {
it('should update copied state after clicking', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
- render(
)
+ await renderAndFlush(
)
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(
)
+ await renderAndFlush(
)
const copyableArea = screen.getByText('test-value')
await act(async () => {
@@ -129,32 +133,29 @@ describe('InputCopy', () => {
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)
+ it('should render tooltip on value', async () => {
+ await renderAndFlush(
)
const valueText = screen.getByText('test-value')
expect(valueText).toBeInTheDocument()
})
})
describe('tooltip', () => {
- it('should render tooltip wrapper', () => {
- render(
)
+ it('should render tooltip wrapper', async () => {
+ await renderAndFlush(
)
const valueText = screen.getByText('test')
expect(valueText).toBeInTheDocument()
})
- it('should have cursor-pointer on clickable area', () => {
- render(
)
+ it('should have cursor-pointer on clickable area', async () => {
+ await renderAndFlush(
)
const valueText = screen.getByText('test')
const clickableArea = valueText.closest('div[class*="cursor-pointer"]')
expect(clickableArea).toBeInTheDocument()
@@ -162,42 +163,42 @@ describe('InputCopy', () => {
})
describe('divider', () => {
- it('should render vertical divider', () => {
- const { container } = render(
)
+ it('should render vertical divider', async () => {
+ const { container } = await renderAndFlush(
)
const divider = container.querySelector('.bg-divider-regular')
expect(divider).toBeInTheDocument()
})
- it('should have correct divider dimensions', () => {
- const { container } = render(
)
+ it('should have correct divider dimensions', async () => {
+ const { container } = await renderAndFlush(
)
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(
)
+ it('should have shrink-0 on divider', async () => {
+ const { container } = await renderAndFlush(
)
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(
)
+ it('should have truncate class for long values', async () => {
+ await renderAndFlush(
)
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(
)
+ it('should have text-secondary color on value', async () => {
+ await renderAndFlush(
)
const valueText = screen.getByText('test-value')
expect(valueText.className).toContain('text-text-secondary')
})
- it('should have absolute positioning for overlay', () => {
- render(
)
+ it('should have absolute positioning for overlay', async () => {
+ await renderAndFlush(
)
const valueText = screen.getByText('test')
const container = valueText.closest('div[class*="absolute"]')
expect(container).toBeInTheDocument()
@@ -205,22 +206,22 @@ describe('InputCopy', () => {
})
describe('inner container', () => {
- it('should have grow class on inner container', () => {
- const { container } = render(
)
+ it('should have grow class on inner container', async () => {
+ const { container } = await renderAndFlush(
)
const innerContainer = container.querySelector('.grow')
expect(innerContainer).toBeInTheDocument()
})
- it('should have h-5 height on inner container', () => {
- const { container } = render(
)
+ it('should have h-5 height on inner container', async () => {
+ const { container } = await renderAndFlush(
)
const innerContainer = container.querySelector('.h-5')
expect(innerContainer).toBeInTheDocument()
})
})
describe('with children', () => {
- it('should render children before value', () => {
- const { container } = render(
+ it('should render children before value', async () => {
+ const { container } = await renderAndFlush(
Prefix:
,
@@ -229,8 +230,8 @@ describe('InputCopy', () => {
expect(children).toBeInTheDocument()
})
- it('should render both children and value', () => {
- render(
+ it('should render both children and value', async () => {
+ await renderAndFlush(
Label:
,
@@ -241,55 +242,53 @@ describe('InputCopy', () => {
})
describe('CopyFeedback section', () => {
- it('should have margin on CopyFeedback container', () => {
- const { container } = render(
)
+ it('should have margin on CopyFeedback container', async () => {
+ const { container } = await renderAndFlush(
)
const copyFeedbackContainer = container.querySelector('.mx-1')
expect(copyFeedbackContainer).toBeInTheDocument()
})
})
describe('relative container', () => {
- it('should have relative positioning on value container', () => {
- const { container } = render(
)
+ it('should have relative positioning on value container', async () => {
+ const { container } = await renderAndFlush(
)
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
+ it('should have grow on value container', async () => {
+ const { container } = await renderAndFlush(
)
const valueContainer = container.querySelector('.relative.grow')
expect(valueContainer).toBeInTheDocument()
})
- it('should have full height on value container', () => {
- const { container } = render(
)
+ it('should have full height on value container', async () => {
+ const { container } = await renderAndFlush(
)
const valueContainer = container.querySelector('.relative.h-full')
expect(valueContainer).toBeInTheDocument()
})
})
describe('edge cases', () => {
- it('should handle undefined value', () => {
- render(
)
- // Should not crash
+ it('should handle undefined value', async () => {
+ await renderAndFlush(
)
expect(screen.getByRole('button')).toBeInTheDocument()
})
- it('should handle empty string value', () => {
- render(
)
+ it('should handle empty string value', async () => {
+ await renderAndFlush(
)
expect(screen.getByRole('button')).toBeInTheDocument()
})
- it('should handle very long values', () => {
+ it('should handle very long values', async () => {
const longValue = 'a'.repeat(500)
- render(
)
+ await renderAndFlush(
)
expect(screen.getByText(longValue)).toBeInTheDocument()
})
- it('should handle special characters in value', () => {
+ it('should handle special characters in value', async () => {
const specialValue = 'key-with-special-chars!@#$%^&*()'
- render(
)
+ await renderAndFlush(
)
expect(screen.getByText(specialValue)).toBeInTheDocument()
})
})
@@ -297,11 +296,10 @@ describe('InputCopy', () => {
describe('multiple clicks', () => {
it('should handle multiple rapid clicks', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
- render(
)
+ await renderAndFlush(
)
const copyableArea = screen.getByText('test')
- // Click multiple times rapidly
await act(async () => {
await user.click(copyableArea)
await user.click(copyableArea)
diff --git a/web/app/components/develop/secret-key/secret-key-button.spec.tsx b/web/app/components/develop/secret-key/__tests__/secret-key-button.spec.tsx
similarity index 94%
rename from web/app/components/develop/secret-key/secret-key-button.spec.tsx
rename to web/app/components/develop/secret-key/__tests__/secret-key-button.spec.tsx
index 4b4fbaab29..798d0dd16f 100644
--- a/web/app/components/develop/secret-key/secret-key-button.spec.tsx
+++ b/web/app/components/develop/secret-key/__tests__/secret-key-button.spec.tsx
@@ -1,8 +1,7 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import SecretKeyButton from './secret-key-button'
+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
@@ -30,7 +29,6 @@ describe('SecretKeyButton', () => {
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()
})
@@ -58,7 +56,6 @@ describe('SecretKeyButton', () => {
const user = userEvent.setup()
render(
)
- // Open modal
const button = screen.getByRole('button')
await act(async () => {
await user.click(button)
@@ -66,7 +63,6 @@ describe('SecretKeyButton', () => {
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
- // Close modal
const closeButton = screen.getByTestId('close-modal')
await act(async () => {
await user.click(closeButton)
@@ -81,20 +77,17 @@ describe('SecretKeyButton', () => {
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)
})
@@ -205,7 +198,6 @@ describe('SecretKeyButton', () => {
const user = userEvent.setup()
render(
)
- // Initially modal should not be visible
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
const button = screen.getByRole('button')
@@ -213,7 +205,6 @@ describe('SecretKeyButton', () => {
await user.click(button)
})
- // Now modal should be visible
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
})
@@ -231,7 +222,6 @@ describe('SecretKeyButton', () => {
await user.click(closeButton)
})
- // Modal should be closed after clicking close
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
})
})
@@ -251,7 +241,6 @@ describe('SecretKeyButton', () => {
button.focus()
expect(document.activeElement).toBe(button)
- // Press Enter to activate
await act(async () => {
await user.keyboard('{Enter}')
})
@@ -273,20 +262,17 @@ describe('SecretKeyButton', () => {
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])
})
diff --git a/web/app/components/develop/secret-key/secret-key-generate.spec.tsx b/web/app/components/develop/secret-key/__tests__/secret-key-generate.spec.tsx
similarity index 53%
rename from web/app/components/develop/secret-key/secret-key-generate.spec.tsx
rename to web/app/components/develop/secret-key/__tests__/secret-key-generate.spec.tsx
index 5988d6b7f3..7df86917ed 100644
--- a/web/app/components/develop/secret-key/secret-key-generate.spec.tsx
+++ b/web/app/components/develop/secret-key/__tests__/secret-key-generate.spec.tsx
@@ -1,15 +1,22 @@
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'
+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',
})
+async function renderModal(ui: React.ReactElement) {
+ const result = render(ui)
+ await act(async () => {
+ vi.runAllTimers()
+ })
+ return result
+}
+
describe('SecretKeyGenerateModal', () => {
const defaultProps = {
isShow: true,
@@ -18,75 +25,78 @@ describe('SecretKeyGenerateModal', () => {
beforeEach(() => {
vi.clearAllMocks()
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ })
+
+ afterEach(() => {
+ vi.runOnlyPendingTimers()
+ vi.useRealTimers()
})
describe('rendering when shown', () => {
- it('should render the modal when isShow is true', () => {
- render(
)
+ it('should render the modal when isShow is true', async () => {
+ await renderModal(
)
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
- it('should render the generate tips text', () => {
- render(
)
+ it('should render the generate tips text', async () => {
+ await renderModal(
)
expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument()
})
- it('should render the OK button', () => {
- render(
)
+ it('should render the OK button', async () => {
+ await renderModal(
)
expect(screen.getByText('appApi.actionMsg.ok')).toBeInTheDocument()
})
- it('should render the close icon', () => {
- render(
)
- // Modal renders via portal, so query from document.body
+ it('should render the close icon', async () => {
+ await renderModal(
)
const closeIcon = document.body.querySelector('svg.cursor-pointer')
expect(closeIcon).toBeInTheDocument()
})
- it('should render InputCopy component', () => {
- render(
)
+ it('should render InputCopy component', async () => {
+ await renderModal(
)
expect(screen.getByText('test-token-123')).toBeInTheDocument()
})
})
describe('rendering when hidden', () => {
- it('should not render content when isShow is false', () => {
- render(
)
+ it('should not render content when isShow is false', async () => {
+ await renderModal(
)
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument()
})
})
describe('newKey prop', () => {
- it('should display the token when newKey is provided', () => {
- render(
)
+ it('should display the token when newKey is provided', async () => {
+ await renderModal(
)
expect(screen.getByText('sk-abc123xyz')).toBeInTheDocument()
})
- it('should handle undefined newKey', () => {
- render(
)
- // Should not crash and modal should still render
+ it('should handle undefined newKey', async () => {
+ await renderModal(
)
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
- it('should handle newKey with empty token', () => {
- render(
)
+ it('should handle newKey with empty token', async () => {
+ await renderModal(
)
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
- it('should display long tokens correctly', () => {
+ it('should display long tokens correctly', async () => {
const longToken = `sk-${'a'.repeat(100)}`
- render(
)
+ await renderModal(
)
expect(screen.getByText(longToken)).toBeInTheDocument()
})
})
describe('close functionality', () => {
it('should call onClose when X icon is clicked', async () => {
- const user = userEvent.setup()
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const onClose = vi.fn()
- render(
)
+ await renderModal(
)
- // Modal renders via portal
const closeIcon = document.body.querySelector('svg.cursor-pointer')
expect(closeIcon).toBeInTheDocument()
@@ -94,81 +104,60 @@ describe('SecretKeyGenerateModal', () => {
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 user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const onClose = vi.fn()
- render(
)
+ await renderModal(
)
const okButton = screen.getByRole('button', { name: /ok/i })
await act(async () => {
await user.click(okButton)
})
- // HeadlessUI Dialog calls onClose both from button click and modal close
expect(onClose).toHaveBeenCalled()
})
})
describe('className prop', () => {
- it('should apply custom className', () => {
- render(
+ it('should apply custom className', async () => {
+ await renderModal(
,
)
- // Modal renders via portal
const modal = document.body.querySelector('.custom-modal-class')
expect(modal).toBeInTheDocument()
})
- it('should apply shrink-0 class', () => {
- render(
+ it('should apply shrink-0 class', async () => {
+ await renderModal(
,
)
- // 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
+ it('should have px-8 padding', async () => {
+ await renderModal(
)
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
+ it('should have cursor-pointer class on close icon', async () => {
+ await renderModal(
)
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
+ it('should have flex justify-end on close container', async () => {
+ await renderModal(
)
const closeIcon = document.body.querySelector('svg.cursor-pointer')
const closeContainer = closeIcon?.parentElement
expect(closeContainer).toBeInTheDocument()
@@ -176,9 +165,8 @@ describe('SecretKeyGenerateModal', () => {
expect(closeContainer?.className).toContain('justify-end')
})
- it('should have negative margin on close container', () => {
- render(
)
- // Modal renders via portal
+ it('should have negative margin on close container', async () => {
+ await renderModal(
)
const closeIcon = document.body.querySelector('svg.cursor-pointer')
const closeContainer = closeIcon?.parentElement
expect(closeContainer).toBeInTheDocument()
@@ -186,9 +174,8 @@ describe('SecretKeyGenerateModal', () => {
expect(closeContainer?.className).toContain('-mt-6')
})
- it('should have bottom margin on close container', () => {
- render(
)
- // Modal renders via portal
+ it('should have bottom margin on close container', async () => {
+ await renderModal(
)
const closeIcon = document.body.querySelector('svg.cursor-pointer')
const closeContainer = closeIcon?.parentElement
expect(closeContainer).toBeInTheDocument()
@@ -197,46 +184,45 @@ describe('SecretKeyGenerateModal', () => {
})
describe('tips text styling', () => {
- it('should have mt-1 margin on tips', () => {
- render(
)
+ it('should have mt-1 margin on tips', async () => {
+ await renderModal(
)
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
expect(tips.className).toContain('mt-1')
})
- it('should have correct font size', () => {
- render(
)
+ it('should have correct font size', async () => {
+ await renderModal(
)
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
expect(tips.className).toContain('text-[13px]')
})
- it('should have normal font weight', () => {
- render(
)
+ it('should have normal font weight', async () => {
+ await renderModal(
)
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
expect(tips.className).toContain('font-normal')
})
- it('should have leading-5 line height', () => {
- render(
)
+ it('should have leading-5 line height', async () => {
+ await renderModal(
)
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
expect(tips.className).toContain('leading-5')
})
- it('should have tertiary text color', () => {
- render(
)
+ it('should have tertiary text color', async () => {
+ await renderModal(
)
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(
)
+ it('should render InputCopy with token value', async () => {
+ await renderModal(
)
expect(screen.getByText('test-token')).toBeInTheDocument()
})
- it('should have w-full class on InputCopy', () => {
- render(
)
- // The InputCopy component should have w-full
+ it('should have w-full class on InputCopy', async () => {
+ await renderModal(
)
const inputText = screen.getByText('test')
const inputContainer = inputText.closest('.w-full')
expect(inputContainer).toBeInTheDocument()
@@ -244,58 +230,57 @@ describe('SecretKeyGenerateModal', () => {
})
describe('OK button section', () => {
- it('should render OK button', () => {
- render(
)
+ it('should render OK button', async () => {
+ await renderModal(
)
const button = screen.getByRole('button', { name: /ok/i })
expect(button).toBeInTheDocument()
})
- it('should have button container with flex layout', () => {
- render(
)
+ it('should have button container with flex layout', async () => {
+ await renderModal(
)
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(
)
+ it('should have shrink-0 on button', async () => {
+ await renderModal(
)
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(
)
+ it('should have text-xs font size on button text', async () => {
+ await renderModal(
)
const buttonText = screen.getByText('appApi.actionMsg.ok')
expect(buttonText.className).toContain('text-xs')
})
- it('should have font-medium on button text', () => {
- render(
)
+ it('should have font-medium on button text', async () => {
+ await renderModal(
)
const buttonText = screen.getByText('appApi.actionMsg.ok')
expect(buttonText.className).toContain('font-medium')
})
- it('should have secondary text color on button text', () => {
- render(
)
+ it('should have secondary text color on button text', async () => {
+ await renderModal(
)
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(
)
+ it('should default isShow to false', async () => {
+ await renderModal(
)
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument()
})
})
describe('modal title', () => {
- it('should display the correct title', () => {
- render(
)
+ it('should display the correct title', async () => {
+ await renderModal(
)
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/__tests__/secret-key-modal.spec.tsx
similarity index 69%
rename from web/app/components/develop/secret-key/secret-key-modal.spec.tsx
rename to web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx
index 79c51759ea..8cfd976a95 100644
--- a/web/app/components/develop/secret-key/secret-key-modal.spec.tsx
+++ b/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx
@@ -1,8 +1,25 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import SecretKeyModal from './secret-key-modal'
+import { afterEach } from 'vitest'
+import SecretKeyModal from '../secret-key-modal'
+
+async function renderModal(ui: React.ReactElement) {
+ const result = render(ui)
+ await act(async () => {
+ vi.runAllTimers()
+ })
+ return result
+}
+
+async function flushTransitions() {
+ await act(async () => {
+ vi.runAllTimers()
+ })
+ await act(async () => {
+ vi.runAllTimers()
+ })
+}
-// Mock the app context
const mockCurrentWorkspace = vi.fn().mockReturnValue({
id: 'workspace-1',
name: 'Test Workspace',
@@ -18,7 +35,6 @@ vi.mock('@/context/app-context', () => ({
}),
}))
-// Mock the timestamp hook
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: vi.fn((value: number, _format: string) => `Formatted: ${value}`),
@@ -26,7 +42,6 @@ vi.mock('@/hooks/use-timestamp', () => ({
}),
}))
-// Mock API services
const mockCreateAppApikey = vi.fn().mockResolvedValue({ token: 'new-app-token-123' })
const mockDelAppApikey = vi.fn().mockResolvedValue({})
vi.mock('@/service/apps', () => ({
@@ -41,7 +56,6 @@ vi.mock('@/service/datasets', () => ({
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()
@@ -54,7 +68,6 @@ vi.mock('@/service/use-apps', () => ({
useInvalidateAppApiKeys: () => mockInvalidateAppApiKeys,
}))
-// Mock React Query hooks for datasets
const mockDatasetApiKeysData = vi.fn().mockReturnValue({ data: [] })
const mockIsDatasetApiKeysLoading = vi.fn().mockReturnValue(false)
const mockInvalidateDatasetApiKeys = vi.fn()
@@ -75,6 +88,7 @@ describe('SecretKeyModal', () => {
beforeEach(() => {
vi.clearAllMocks()
+ vi.useFakeTimers({ shouldAdvanceTime: true })
mockCurrentWorkspace.mockReturnValue({ id: 'workspace-1', name: 'Test Workspace' })
mockIsCurrentWorkspaceManager.mockReturnValue(true)
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
@@ -84,53 +98,57 @@ describe('SecretKeyModal', () => {
mockIsDatasetApiKeysLoading.mockReturnValue(false)
})
+ afterEach(() => {
+ vi.runOnlyPendingTimers()
+ vi.useRealTimers()
+ })
+
describe('rendering when shown', () => {
- it('should render the modal when isShow is true', () => {
- render(
)
+ it('should render the modal when isShow is true', async () => {
+ await renderModal(
)
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
})
- it('should render the tips text', () => {
- render(
)
+ it('should render the tips text', async () => {
+ await renderModal(
)
expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument()
})
- it('should render the create new key button', () => {
- render(
)
+ it('should render the create new key button', async () => {
+ await renderModal(
)
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
+ it('should render the close icon', async () => {
+ await renderModal(
)
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(
)
+ it('should not render content when isShow is false', async () => {
+ await renderModal(
)
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument()
})
})
describe('loading state', () => {
- it('should show loading when app API keys are loading', () => {
+ it('should show loading when app API keys are loading', async () => {
mockIsAppApiKeysLoading.mockReturnValue(true)
- render(
)
+ await renderModal(
)
expect(screen.getByRole('status')).toBeInTheDocument()
})
- it('should show loading when dataset API keys are loading', () => {
+ it('should show loading when dataset API keys are loading', async () => {
mockIsDatasetApiKeysLoading.mockReturnValue(true)
- render(
)
+ await renderModal(
)
expect(screen.getByRole('status')).toBeInTheDocument()
})
- it('should not show loading when data is loaded', () => {
+ it('should not show loading when data is loaded', async () => {
mockIsAppApiKeysLoading.mockReturnValue(false)
- render(
)
+ await renderModal(
)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
})
@@ -145,49 +163,43 @@ describe('SecretKeyModal', () => {
mockAppApiKeysData.mockReturnValue({ data: apiKeys })
})
- it('should render API keys when available', () => {
- render(
)
- // Token 'sk-abc123def456ghi789' (21 chars) -> first 3 'sk-' + '...' + last 20 'k-abc123def456ghi789'
+ it('should render API keys when available', async () => {
+ await renderModal(
)
expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument()
})
- it('should render created time for keys', () => {
- render(
)
+ it('should render created time for keys', async () => {
+ await renderModal(
)
expect(screen.getByText('Formatted: 1700000000')).toBeInTheDocument()
})
- it('should render last used time for keys', () => {
- render(
)
+ it('should render last used time for keys', async () => {
+ await renderModal(
)
expect(screen.getByText('Formatted: 1700100000')).toBeInTheDocument()
})
- it('should render "never" for keys without last_used_at', () => {
- render(
)
+ it('should render "never" for keys without last_used_at', async () => {
+ await renderModal(
)
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
+ it('should render delete button for managers', async () => {
+ await renderModal(
)
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', () => {
+ it('should not render delete button for non-managers', async () => {
mockIsCurrentWorkspaceManager.mockReturnValue(false)
- render(
)
- // The specific delete action button should not be present
+ await renderModal(
)
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(
)
+ it('should render table headers', async () => {
+ await renderModal(
)
expect(screen.getByText('appApi.apiKeyModal.secretKey')).toBeInTheDocument()
expect(screen.getByText('appApi.apiKeyModal.created')).toBeInTheDocument()
expect(screen.getByText('appApi.apiKeyModal.lastUsed')).toBeInTheDocument()
@@ -203,20 +215,18 @@ describe('SecretKeyModal', () => {
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'
+ it('should render dataset API keys when no appId', async () => {
+ await renderModal(
)
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 user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const onClose = vi.fn()
- render(
)
+ await renderModal(
)
- // Modal renders via portal, so we need to query from document.body
const closeIcon = document.body.querySelector('svg.cursor-pointer')
expect(closeIcon).toBeInTheDocument()
@@ -224,14 +234,14 @@ describe('SecretKeyModal', () => {
await user.click(closeIcon!)
})
- expect(onClose).toHaveBeenCalledTimes(1)
+ expect(onClose).toHaveBeenCalled()
})
})
describe('create new key', () => {
it('should call create API for app when button is clicked', async () => {
- const user = userEvent.setup()
- render(
)
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
+ await renderModal(
)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
await act(async () => {
@@ -247,8 +257,8 @@ describe('SecretKeyModal', () => {
})
it('should call create API for dataset when no appId', async () => {
- const user = userEvent.setup()
- render(
)
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
+ await renderModal(
)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
await act(async () => {
@@ -264,8 +274,8 @@ describe('SecretKeyModal', () => {
})
it('should show generate modal after creating key', async () => {
- const user = userEvent.setup()
- render(
)
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
+ await renderModal(
)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
await act(async () => {
@@ -273,14 +283,13 @@ describe('SecretKeyModal', () => {
})
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 user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
+ await renderModal(
)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
await act(async () => {
@@ -293,8 +302,8 @@ describe('SecretKeyModal', () => {
})
it('should invalidate dataset API keys after creating (no appId)', async () => {
- const user = userEvent.setup()
- render(
)
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
+ await renderModal(
)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
await act(async () => {
@@ -306,17 +315,17 @@ describe('SecretKeyModal', () => {
})
})
- it('should disable create button when no workspace', () => {
+ it('should disable create button when no workspace', async () => {
mockCurrentWorkspace.mockReturnValue(null)
- render(
)
+ await renderModal(
)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey').closest('button')
expect(createButton).toBeDisabled()
})
- it('should disable create button when not editor', () => {
+ it('should disable create button when not editor', async () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
- render(
)
+ await renderModal(
)
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey').closest('button')
expect(createButton).toBeDisabled()
@@ -332,80 +341,74 @@ describe('SecretKeyModal', () => {
mockAppApiKeysData.mockReturnValue({ data: apiKeys })
})
- it('should render delete button for managers', () => {
- render(
)
+ it('should render delete button for managers', async () => {
+ await renderModal(
)
- // 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(
)
+ it('should render API key row with actions', async () => {
+ await renderModal(
)
- // Verify the truncated token is rendered
expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument()
})
- it('should have action buttons in the key row', () => {
- render(
)
+ it('should have action buttons in the key row', async () => {
+ await renderModal(
)
- // 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(
)
+ await renderModal(
)
- // 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(
)
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
+ await renderModal(
)
- // 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!)
+ vi.runAllTimers()
})
- // Confirm dialog should appear
await waitFor(() => {
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
expect(screen.getByText('appApi.actionMsg.deleteConfirmTips')).toBeInTheDocument()
})
+ await flushTransitions()
})
it('should call delete API for app when confirmed', async () => {
- const user = userEvent.setup()
- render(
)
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
+ await renderModal(
)
- // Find and click delete button
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
await act(async () => {
await user.click(deleteButton!)
+ vi.runAllTimers()
})
- // Wait for confirm dialog and click confirm
await waitFor(() => {
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
})
+ await flushTransitions()
- // Find and click the confirm button
const confirmButton = screen.getByText('common.operation.confirm')
await act(async () => {
await user.click(confirmButton)
+ vi.runAllTimers()
})
await waitFor(() => {
@@ -417,24 +420,25 @@ describe('SecretKeyModal', () => {
})
it('should invalidate app API keys after deleting', async () => {
- const user = userEvent.setup()
- render(
)
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
+ await renderModal(
)
- // Find and click delete button
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
await act(async () => {
await user.click(deleteButton!)
+ vi.runAllTimers()
})
- // Wait for confirm dialog and click confirm
await waitFor(() => {
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
})
+ await flushTransitions()
const confirmButton = screen.getByText('common.operation.confirm')
await act(async () => {
await user.click(confirmButton)
+ vi.runAllTimers()
})
await waitFor(() => {
@@ -443,33 +447,31 @@ describe('SecretKeyModal', () => {
})
it('should close confirm dialog and clear delKeyId when cancel is clicked', async () => {
- const user = userEvent.setup()
- render(
)
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
+ await renderModal(
)
- // Find and click delete button
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
await act(async () => {
await user.click(deleteButton!)
+ vi.runAllTimers()
})
- // Wait for confirm dialog
await waitFor(() => {
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
})
+ await flushTransitions()
- // Click cancel button
const cancelButton = screen.getByText('common.operation.cancel')
await act(async () => {
await user.click(cancelButton)
+ vi.runAllTimers()
})
- // Confirm dialog should close
await waitFor(() => {
expect(screen.queryByText('appApi.actionMsg.deleteConfirmTitle')).not.toBeInTheDocument()
})
- // Delete API should not be called
expect(mockDelAppApikey).not.toHaveBeenCalled()
})
})
@@ -484,24 +486,25 @@ describe('SecretKeyModal', () => {
})
it('should call delete API for dataset when no appId', async () => {
- const user = userEvent.setup()
- render(
)
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
+ await renderModal(
)
- // Find and click delete button
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
await act(async () => {
await user.click(deleteButton!)
+ vi.runAllTimers()
})
- // Wait for confirm dialog and click confirm
await waitFor(() => {
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
})
+ await flushTransitions()
const confirmButton = screen.getByText('common.operation.confirm')
await act(async () => {
await user.click(confirmButton)
+ vi.runAllTimers()
})
await waitFor(() => {
@@ -513,24 +516,25 @@ describe('SecretKeyModal', () => {
})
it('should invalidate dataset API keys after deleting', async () => {
- const user = userEvent.setup()
- render(
)
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
+ await renderModal(
)
- // Find and click delete button
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
await act(async () => {
await user.click(deleteButton!)
+ vi.runAllTimers()
})
- // Wait for confirm dialog and click confirm
await waitFor(() => {
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
})
+ await flushTransitions()
const confirmButton = screen.getByText('common.operation.confirm')
await act(async () => {
await user.click(confirmButton)
+ vi.runAllTimers()
})
await waitFor(() => {
@@ -540,46 +544,42 @@ describe('SecretKeyModal', () => {
})
describe('token truncation', () => {
- it('should truncate token correctly', () => {
+ it('should truncate token correctly', async () => {
const apiKeys = [
{ id: 'key-1', token: 'sk-abcdefghijklmnopqrstuvwxyz1234567890', created_at: 1700000000, last_used_at: null },
]
mockAppApiKeysData.mockReturnValue({ data: apiKeys })
- render(
)
+ await renderModal(
)
- // 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
+ it('should render modal with expected structure', async () => {
+ await renderModal(
)
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
+ it('should render create button with flex styling', async () => {
+ await renderModal(
)
const flexContainers = document.body.querySelectorAll('[class*="flex"]')
expect(flexContainers.length).toBeGreaterThan(0)
})
})
describe('empty state', () => {
- it('should not render table when no keys', () => {
+ it('should not render table when no keys', async () => {
mockAppApiKeysData.mockReturnValue({ data: [] })
- render(
)
+ await renderModal(
)
expect(screen.queryByText('appApi.apiKeyModal.secretKey')).not.toBeInTheDocument()
})
- it('should not render table when data is null', () => {
+ it('should not render table when data is null', async () => {
mockAppApiKeysData.mockReturnValue(null)
- render(
)
+ await renderModal(
)
expect(screen.queryByText('appApi.apiKeyModal.secretKey')).not.toBeInTheDocument()
})
@@ -587,23 +587,23 @@ describe('SecretKeyModal', () => {
describe('SecretKeyGenerateModal', () => {
it('should close generate modal on close', async () => {
- const user = userEvent.setup()
- render(
)
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
+ await renderModal(
)
- // Create a new key to open generate modal
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
await act(async () => {
await user.click(createButton)
+ vi.runAllTimers()
})
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)
+ vi.runAllTimers()
})
await waitFor(() => {
diff --git a/web/app/components/goto-anything/command-selector.spec.tsx b/web/app/components/goto-anything/__tests__/command-selector.spec.tsx
similarity index 96%
rename from web/app/components/goto-anything/command-selector.spec.tsx
rename to web/app/components/goto-anything/__tests__/command-selector.spec.tsx
index 0712a1afd6..56e40a71f0 100644
--- a/web/app/components/goto-anything/command-selector.spec.tsx
+++ b/web/app/components/goto-anything/__tests__/command-selector.spec.tsx
@@ -1,9 +1,9 @@
-import type { ActionItem } from './actions/types'
+import type { ActionItem } from '../actions/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Command } from 'cmdk'
import * as React from 'react'
-import CommandSelector from './command-selector'
+import CommandSelector from '../command-selector'
vi.mock('next/navigation', () => ({
usePathname: () => '/app',
@@ -16,7 +16,7 @@ const slashCommandsMock = [{
isAvailable: () => true,
}]
-vi.mock('./actions/commands/registry', () => ({
+vi.mock('../actions/commands/registry', () => ({
slashCommandRegistry: {
getAvailableCommands: () => slashCommandsMock,
},
@@ -97,7 +97,6 @@ describe('CommandSelector', () => {
,
)
- // Should show the zen command from mock
expect(screen.getByText('/zen')).toBeInTheDocument()
})
@@ -125,7 +124,6 @@ describe('CommandSelector', () => {
,
)
- // Should show @ commands but not /
expect(screen.getByText('@app')).toBeInTheDocument()
expect(screen.queryByText('/')).not.toBeInTheDocument()
})
diff --git a/web/app/components/goto-anything/context.spec.tsx b/web/app/components/goto-anything/__tests__/context.spec.tsx
similarity index 96%
rename from web/app/components/goto-anything/context.spec.tsx
rename to web/app/components/goto-anything/__tests__/context.spec.tsx
index 2be2cbc730..c427f76c61 100644
--- a/web/app/components/goto-anything/context.spec.tsx
+++ b/web/app/components/goto-anything/__tests__/context.spec.tsx
@@ -1,6 +1,6 @@
import { render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
-import { GotoAnythingProvider, useGotoAnythingContext } from './context'
+import { GotoAnythingProvider, useGotoAnythingContext } from '../context'
let pathnameMock: string | null | undefined = '/'
vi.mock('next/navigation', () => ({
@@ -8,7 +8,7 @@ vi.mock('next/navigation', () => ({
}))
let isWorkflowPageMock = false
-vi.mock('../workflow/constants', () => ({
+vi.mock('../../workflow/constants', () => ({
isInWorkflowPage: () => isWorkflowPageMock,
}))
diff --git a/web/app/components/goto-anything/index.spec.tsx b/web/app/components/goto-anything/__tests__/index.spec.tsx
similarity index 95%
rename from web/app/components/goto-anything/index.spec.tsx
rename to web/app/components/goto-anything/__tests__/index.spec.tsx
index 6a6143a6e2..eb5fa8ccdd 100644
--- a/web/app/components/goto-anything/index.spec.tsx
+++ b/web/app/components/goto-anything/__tests__/index.spec.tsx
@@ -1,27 +1,15 @@
import type { ReactNode } from 'react'
-import type { ActionItem, SearchResult } from './actions/types'
+import type { ActionItem, SearchResult } from '../actions/types'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
-import GotoAnything from './index'
+import GotoAnything from '../index'
-// Test helper type that matches SearchResult but allows ReactNode for icon and flexible data
type TestSearchResult = Omit
& {
icon?: ReactNode
data?: Record
}
-// Mock react-i18next to return namespace.key format
-vi.mock('react-i18next', () => ({
- useTranslation: () => ({
- t: (key: string, options?: { ns?: string }) => {
- const ns = options?.ns || 'common'
- return `${ns}.${key}`
- },
- i18n: { language: 'en' },
- }),
-}))
-
const routerPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
@@ -65,7 +53,7 @@ vi.mock('@/context/i18n', () => ({
}))
const contextValue = { isWorkflowPage: false, isRagPipelinePage: false }
-vi.mock('./context', () => ({
+vi.mock('../context', () => ({
useGotoAnythingContext: () => contextValue,
GotoAnythingProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
}))
@@ -93,13 +81,13 @@ const createActionsMock = vi.fn(() => actionsMock)
const matchActionMock = vi.fn(() => undefined)
const searchAnythingMock = vi.fn(async () => mockQueryResult.data)
-vi.mock('./actions', () => ({
+vi.mock('../actions', () => ({
createActions: () => createActionsMock(),
matchAction: () => matchActionMock(),
searchAnything: () => searchAnythingMock(),
}))
-vi.mock('./actions/commands', () => ({
+vi.mock('../actions/commands', () => ({
SlashCommandProvider: () => null,
}))
@@ -110,7 +98,7 @@ type MockSlashCommand = {
} | null
let mockFindCommand: MockSlashCommand = null
-vi.mock('./actions/commands/registry', () => ({
+vi.mock('../actions/commands/registry', () => ({
slashCommandRegistry: {
findCommand: () => mockFindCommand,
getAvailableCommands: () => [],
@@ -129,7 +117,7 @@ vi.mock('@/app/components/workflow/utils/node-navigation', () => ({
selectWorkflowNode: vi.fn(),
}))
-vi.mock('../plugins/install-plugin/install-from-marketplace', () => ({
+vi.mock('../../plugins/install-plugin/install-from-marketplace', () => ({
default: (props: { manifest?: { name?: string }, onClose: () => void, onSuccess: () => void }) => (
{props.manifest?.name}
@@ -207,23 +195,19 @@ describe('GotoAnything', () => {
const user = userEvent.setup()
render(
)
- // Open modal first time
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
- // Type something
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'test')
- // Close modal
triggerKeyPress('esc')
await waitFor(() => {
expect(screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder')).not.toBeInTheDocument()
})
- // Open modal again - should be empty
triggerKeyPress('ctrl.k')
await waitFor(() => {
const newInput = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
@@ -278,7 +262,6 @@ describe('GotoAnything', () => {
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'test query')
- // Should not throw and input should have value
expect(input).toHaveValue('test query')
})
})
@@ -303,7 +286,6 @@ describe('GotoAnything', () => {
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'search')
- // Loading state shows in both EmptyState (spinner) and Footer
const searchingTexts = screen.getAllByText('app.gotoAnything.searching')
expect(searchingTexts.length).toBeGreaterThanOrEqual(1)
})
diff --git a/web/app/components/goto-anything/actions/__tests__/app.spec.ts b/web/app/components/goto-anything/actions/__tests__/app.spec.ts
new file mode 100644
index 0000000000..2a09b8be1d
--- /dev/null
+++ b/web/app/components/goto-anything/actions/__tests__/app.spec.ts
@@ -0,0 +1,71 @@
+import type { App } from '@/types/app'
+import { appAction } from '../app'
+
+vi.mock('@/service/apps', () => ({
+ fetchAppList: vi.fn(),
+}))
+
+vi.mock('@/utils/app-redirection', () => ({
+ getRedirectionPath: vi.fn((_isAdmin: boolean, app: { id: string }) => `/app/${app.id}`),
+}))
+
+vi.mock('../../../app/type-selector', () => ({
+ AppTypeIcon: () => null,
+}))
+
+vi.mock('../../../base/app-icon', () => ({
+ default: () => null,
+}))
+
+describe('appAction', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('has correct metadata', () => {
+ expect(appAction.key).toBe('@app')
+ expect(appAction.shortcut).toBe('@app')
+ })
+
+ it('returns parsed app results on success', async () => {
+ const { fetchAppList } = await import('@/service/apps')
+ vi.mocked(fetchAppList).mockResolvedValue({
+ data: [
+ { id: 'app-1', name: 'My App', description: 'A great app', mode: 'chat', icon: '', icon_type: 'emoji', icon_background: '', icon_url: '' } as unknown as App,
+ ],
+ has_more: false,
+ limit: 10,
+ page: 1,
+ total: 1,
+ })
+
+ const results = await appAction.search('@app test', 'test', 'en')
+
+ expect(fetchAppList).toHaveBeenCalledWith({
+ url: 'apps',
+ params: { page: 1, name: 'test' },
+ })
+ expect(results).toHaveLength(1)
+ expect(results[0]).toMatchObject({
+ id: 'app-1',
+ title: 'My App',
+ type: 'app',
+ })
+ })
+
+ it('returns empty array when response has no data', async () => {
+ const { fetchAppList } = await import('@/service/apps')
+ vi.mocked(fetchAppList).mockResolvedValue({ data: [], has_more: false, limit: 10, page: 1, total: 0 })
+
+ const results = await appAction.search('@app', '', 'en')
+ expect(results).toEqual([])
+ })
+
+ it('returns empty array on API failure', async () => {
+ const { fetchAppList } = await import('@/service/apps')
+ vi.mocked(fetchAppList).mockRejectedValue(new Error('network error'))
+
+ const results = await appAction.search('@app fail', 'fail', 'en')
+ expect(results).toEqual([])
+ })
+})
diff --git a/web/app/components/goto-anything/actions/__tests__/index.spec.ts b/web/app/components/goto-anything/actions/__tests__/index.spec.ts
new file mode 100644
index 0000000000..8b92297a57
--- /dev/null
+++ b/web/app/components/goto-anything/actions/__tests__/index.spec.ts
@@ -0,0 +1,276 @@
+import type { ActionItem, SearchResult } from '../types'
+import type { DataSet } from '@/models/datasets'
+import type { App } from '@/types/app'
+import { slashCommandRegistry } from '../commands/registry'
+import { createActions, matchAction, searchAnything } from '../index'
+
+vi.mock('../app', () => ({
+ appAction: {
+ key: '@app',
+ shortcut: '@app',
+ title: 'Apps',
+ description: 'Search apps',
+ search: vi.fn().mockResolvedValue([]),
+ } satisfies ActionItem,
+}))
+
+vi.mock('../knowledge', () => ({
+ knowledgeAction: {
+ key: '@knowledge',
+ shortcut: '@kb',
+ title: 'Knowledge',
+ description: 'Search knowledge',
+ search: vi.fn().mockResolvedValue([]),
+ } satisfies ActionItem,
+}))
+
+vi.mock('../plugin', () => ({
+ pluginAction: {
+ key: '@plugin',
+ shortcut: '@plugin',
+ title: 'Plugins',
+ description: 'Search plugins',
+ search: vi.fn().mockResolvedValue([]),
+ } satisfies ActionItem,
+}))
+
+vi.mock('../commands', () => ({
+ slashAction: {
+ key: '/',
+ shortcut: '/',
+ title: 'Commands',
+ description: 'Slash commands',
+ search: vi.fn().mockResolvedValue([]),
+ } satisfies ActionItem,
+}))
+
+vi.mock('../workflow-nodes', () => ({
+ workflowNodesAction: {
+ key: '@node',
+ shortcut: '@node',
+ title: 'Workflow Nodes',
+ description: 'Search workflow nodes',
+ search: vi.fn().mockResolvedValue([]),
+ } satisfies ActionItem,
+}))
+
+vi.mock('../rag-pipeline-nodes', () => ({
+ ragPipelineNodesAction: {
+ key: '@node',
+ shortcut: '@node',
+ title: 'RAG Pipeline Nodes',
+ description: 'Search RAG nodes',
+ search: vi.fn().mockResolvedValue([]),
+ } satisfies ActionItem,
+}))
+
+vi.mock('../commands/registry')
+
+describe('createActions', () => {
+ it('returns base actions when neither workflow nor rag-pipeline page', () => {
+ const actions = createActions(false, false)
+
+ expect(actions).toHaveProperty('slash')
+ expect(actions).toHaveProperty('app')
+ expect(actions).toHaveProperty('knowledge')
+ expect(actions).toHaveProperty('plugin')
+ expect(actions).not.toHaveProperty('node')
+ })
+
+ it('includes workflow nodes action on workflow pages', () => {
+ const actions = createActions(true, false) as Record
+
+ expect(actions).toHaveProperty('node')
+ expect(actions.node.title).toBe('Workflow Nodes')
+ })
+
+ it('includes rag-pipeline nodes action on rag-pipeline pages', () => {
+ const actions = createActions(false, true) as Record
+
+ expect(actions).toHaveProperty('node')
+ expect(actions.node.title).toBe('RAG Pipeline Nodes')
+ })
+
+ it('rag-pipeline page takes priority over workflow page', () => {
+ const actions = createActions(true, true) as Record
+
+ expect(actions.node.title).toBe('RAG Pipeline Nodes')
+ })
+})
+
+describe('searchAnything', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('delegates to specific action when actionItem is provided', async () => {
+ const mockResults: SearchResult[] = [
+ { id: '1', title: 'App1', type: 'app', data: {} as unknown as App },
+ ]
+ const action: ActionItem = {
+ key: '@app',
+ shortcut: '@app',
+ title: 'Apps',
+ description: 'Search apps',
+ search: vi.fn().mockResolvedValue(mockResults),
+ }
+
+ const results = await searchAnything('en', '@app myquery', action)
+
+ expect(action.search).toHaveBeenCalledWith('@app myquery', 'myquery', 'en')
+ expect(results).toEqual(mockResults)
+ })
+
+ it('strips action prefix from search term', async () => {
+ const action: ActionItem = {
+ key: '@knowledge',
+ shortcut: '@kb',
+ title: 'KB',
+ description: 'Search KB',
+ search: vi.fn().mockResolvedValue([]),
+ }
+
+ await searchAnything('en', '@kb hello', action)
+
+ expect(action.search).toHaveBeenCalledWith('@kb hello', 'hello', 'en')
+ })
+
+ it('returns empty for queries starting with @ without actionItem', async () => {
+ const results = await searchAnything('en', '@unknown')
+ expect(results).toEqual([])
+ })
+
+ it('returns empty for queries starting with / without actionItem', async () => {
+ const results = await searchAnything('en', '/theme')
+ expect(results).toEqual([])
+ })
+
+ it('handles action search failure gracefully', async () => {
+ const action: ActionItem = {
+ key: '@app',
+ shortcut: '@app',
+ title: 'Apps',
+ description: 'Search apps',
+ search: vi.fn().mockRejectedValue(new Error('network error')),
+ }
+
+ const results = await searchAnything('en', '@app test', action)
+ expect(results).toEqual([])
+ })
+
+ it('runs global search across all non-slash actions for plain queries', async () => {
+ const appResults: SearchResult[] = [
+ { id: 'a1', title: 'My App', type: 'app', data: {} as unknown as App },
+ ]
+ const kbResults: SearchResult[] = [
+ { id: 'k1', title: 'My KB', type: 'knowledge', data: {} as unknown as DataSet },
+ ]
+
+ const dynamicActions: Record = {
+ slash: { key: '/', shortcut: '/', title: 'Slash', description: '', search: vi.fn().mockResolvedValue([]) },
+ app: { key: '@app', shortcut: '@app', title: 'App', description: '', search: vi.fn().mockResolvedValue(appResults) },
+ knowledge: { key: '@knowledge', shortcut: '@kb', title: 'KB', description: '', search: vi.fn().mockResolvedValue(kbResults) },
+ }
+
+ const results = await searchAnything('en', 'my query', undefined, dynamicActions)
+
+ expect(dynamicActions.slash.search).not.toHaveBeenCalled()
+ expect(results).toHaveLength(2)
+ expect(results).toEqual(expect.arrayContaining([
+ expect.objectContaining({ id: 'a1' }),
+ expect.objectContaining({ id: 'k1' }),
+ ]))
+ })
+
+ it('handles partial search failures in global search gracefully', async () => {
+ const dynamicActions: Record = {
+ app: { key: '@app', shortcut: '@app', title: 'App', description: '', search: vi.fn().mockRejectedValue(new Error('fail')) },
+ knowledge: {
+ key: '@knowledge',
+ shortcut: '@kb',
+ title: 'KB',
+ description: '',
+ search: vi.fn().mockResolvedValue([
+ { id: 'k1', title: 'KB1', type: 'knowledge', data: {} as unknown as DataSet },
+ ]),
+ },
+ }
+
+ const results = await searchAnything('en', 'query', undefined, dynamicActions)
+
+ expect(results).toHaveLength(1)
+ expect(results[0].id).toBe('k1')
+ })
+})
+
+describe('matchAction', () => {
+ const actions: Record = {
+ app: { key: '@app', shortcut: '@app', title: 'App', description: '', search: vi.fn() },
+ knowledge: { key: '@knowledge', shortcut: '@kb', title: 'KB', description: '', search: vi.fn() },
+ plugin: { key: '@plugin', shortcut: '@plugin', title: 'Plugin', description: '', search: vi.fn() },
+ slash: { key: '/', shortcut: '/', title: 'Slash', description: '', search: vi.fn() },
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('matches @app query', () => {
+ const result = matchAction('@app test', actions)
+ expect(result?.key).toBe('@app')
+ })
+
+ it('matches @kb shortcut', () => {
+ const result = matchAction('@kb test', actions)
+ expect(result?.key).toBe('@knowledge')
+ })
+
+ it('matches @plugin query', () => {
+ const result = matchAction('@plugin test', actions)
+ expect(result?.key).toBe('@plugin')
+ })
+
+ it('returns undefined for unmatched query', () => {
+ vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([])
+ const result = matchAction('random query', actions)
+ expect(result).toBeUndefined()
+ })
+
+ describe('slash command matching', () => {
+ it('matches submenu command with full name', () => {
+ vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
+ { name: 'theme', mode: 'submenu', description: '', search: vi.fn() },
+ ])
+
+ const result = matchAction('/theme', actions)
+ expect(result?.key).toBe('/')
+ })
+
+ it('matches submenu command with args', () => {
+ vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
+ { name: 'theme', mode: 'submenu', description: '', search: vi.fn() },
+ ])
+
+ const result = matchAction('/theme dark', actions)
+ expect(result?.key).toBe('/')
+ })
+
+ it('does not match direct-mode commands', () => {
+ vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
+ { name: 'docs', mode: 'direct', description: '', search: vi.fn() },
+ ])
+
+ const result = matchAction('/docs', actions)
+ expect(result).toBeUndefined()
+ })
+
+ it('does not match partial slash command name', () => {
+ vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([
+ { name: 'theme', mode: 'submenu', description: '', search: vi.fn() },
+ ])
+
+ const result = matchAction('/the', actions)
+ expect(result).toBeUndefined()
+ })
+ })
+})
diff --git a/web/app/components/goto-anything/actions/__tests__/knowledge.spec.ts b/web/app/components/goto-anything/actions/__tests__/knowledge.spec.ts
new file mode 100644
index 0000000000..cb39bea0e5
--- /dev/null
+++ b/web/app/components/goto-anything/actions/__tests__/knowledge.spec.ts
@@ -0,0 +1,93 @@
+import type { DataSet } from '@/models/datasets'
+import { knowledgeAction } from '../knowledge'
+
+vi.mock('@/service/datasets', () => ({
+ fetchDatasets: vi.fn(),
+}))
+
+vi.mock('@/utils/classnames', () => ({
+ cn: (...args: string[]) => args.filter(Boolean).join(' '),
+}))
+
+vi.mock('../../../base/icons/src/vender/solid/files', () => ({
+ Folder: () => null,
+}))
+
+describe('knowledgeAction', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('has correct metadata', () => {
+ expect(knowledgeAction.key).toBe('@knowledge')
+ expect(knowledgeAction.shortcut).toBe('@kb')
+ })
+
+ it('returns parsed dataset results on success', async () => {
+ const { fetchDatasets } = await import('@/service/datasets')
+ vi.mocked(fetchDatasets).mockResolvedValue({
+ data: [
+ { id: 'ds-1', name: 'My Knowledge', description: 'A KB', provider: 'vendor', embedding_available: true } as unknown as DataSet,
+ ],
+ has_more: false,
+ limit: 10,
+ page: 1,
+ total: 1,
+ })
+
+ const results = await knowledgeAction.search('@knowledge query', 'query', 'en')
+
+ expect(fetchDatasets).toHaveBeenCalledWith({
+ url: '/datasets',
+ params: { page: 1, limit: 10, keyword: 'query' },
+ })
+ expect(results).toHaveLength(1)
+ expect(results[0]).toMatchObject({
+ id: 'ds-1',
+ title: 'My Knowledge',
+ type: 'knowledge',
+ })
+ })
+
+ it('generates correct path for external provider', async () => {
+ const { fetchDatasets } = await import('@/service/datasets')
+ vi.mocked(fetchDatasets).mockResolvedValue({
+ data: [
+ { id: 'ds-ext', name: 'External', description: '', provider: 'external', embedding_available: true } as unknown as DataSet,
+ ],
+ has_more: false,
+ limit: 10,
+ page: 1,
+ total: 1,
+ })
+
+ const results = await knowledgeAction.search('@knowledge', '', 'en')
+
+ expect(results[0].path).toBe('/datasets/ds-ext/hitTesting')
+ })
+
+ it('generates correct path for non-external provider', async () => {
+ const { fetchDatasets } = await import('@/service/datasets')
+ vi.mocked(fetchDatasets).mockResolvedValue({
+ data: [
+ { id: 'ds-2', name: 'Internal', description: '', provider: 'vendor', embedding_available: true } as unknown as DataSet,
+ ],
+ has_more: false,
+ limit: 10,
+ page: 1,
+ total: 1,
+ })
+
+ const results = await knowledgeAction.search('@knowledge', '', 'en')
+
+ expect(results[0].path).toBe('/datasets/ds-2/documents')
+ })
+
+ it('returns empty array on API failure', async () => {
+ const { fetchDatasets } = await import('@/service/datasets')
+ vi.mocked(fetchDatasets).mockRejectedValue(new Error('fail'))
+
+ const results = await knowledgeAction.search('@knowledge', 'fail', 'en')
+ expect(results).toEqual([])
+ })
+})
diff --git a/web/app/components/goto-anything/actions/__tests__/plugin.spec.ts b/web/app/components/goto-anything/actions/__tests__/plugin.spec.ts
new file mode 100644
index 0000000000..a5d8fe444c
--- /dev/null
+++ b/web/app/components/goto-anything/actions/__tests__/plugin.spec.ts
@@ -0,0 +1,72 @@
+import { pluginAction } from '../plugin'
+
+vi.mock('@/service/base', () => ({
+ postMarketplace: vi.fn(),
+}))
+
+vi.mock('@/i18n-config', () => ({
+ renderI18nObject: vi.fn((obj: Record | string, locale: string) => {
+ if (typeof obj === 'string')
+ return obj
+ return obj[locale] || obj.en_US || ''
+ }),
+}))
+
+vi.mock('../../../plugins/card/base/card-icon', () => ({
+ default: () => null,
+}))
+
+vi.mock('../../../plugins/marketplace/utils', () => ({
+ getPluginIconInMarketplace: vi.fn(() => 'icon-url'),
+}))
+
+describe('pluginAction', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('has correct metadata', () => {
+ expect(pluginAction.key).toBe('@plugin')
+ expect(pluginAction.shortcut).toBe('@plugin')
+ })
+
+ it('returns parsed plugin results on success', async () => {
+ const { postMarketplace } = await import('@/service/base')
+ vi.mocked(postMarketplace).mockResolvedValue({
+ data: {
+ plugins: [
+ { name: 'plugin-1', label: { en_US: 'My Plugin' }, brief: { en_US: 'A plugin' }, icon: 'icon.png' },
+ ],
+ total: 1,
+ },
+ })
+
+ const results = await pluginAction.search('@plugin', 'test', 'en_US')
+
+ expect(postMarketplace).toHaveBeenCalledWith('/plugins/search/advanced', {
+ body: { page: 1, page_size: 10, query: 'test', type: 'plugin' },
+ })
+ expect(results).toHaveLength(1)
+ expect(results[0]).toMatchObject({
+ id: 'plugin-1',
+ title: 'My Plugin',
+ type: 'plugin',
+ })
+ })
+
+ it('returns empty array when response has unexpected structure', async () => {
+ const { postMarketplace } = await import('@/service/base')
+ vi.mocked(postMarketplace).mockResolvedValue({ data: {} })
+
+ const results = await pluginAction.search('@plugin', 'test', 'en')
+ expect(results).toEqual([])
+ })
+
+ it('returns empty array on API failure', async () => {
+ const { postMarketplace } = await import('@/service/base')
+ vi.mocked(postMarketplace).mockRejectedValue(new Error('fail'))
+
+ const results = await pluginAction.search('@plugin', 'fail', 'en')
+ expect(results).toEqual([])
+ })
+})
diff --git a/web/app/components/goto-anything/actions/commands/__tests__/command-bus.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/command-bus.spec.ts
new file mode 100644
index 0000000000..559e7e1821
--- /dev/null
+++ b/web/app/components/goto-anything/actions/commands/__tests__/command-bus.spec.ts
@@ -0,0 +1,68 @@
+import { executeCommand, registerCommands, unregisterCommands } from '../command-bus'
+
+describe('command-bus', () => {
+ afterEach(() => {
+ unregisterCommands(['test.a', 'test.b', 'test.c', 'async.cmd', 'noop'])
+ })
+
+ describe('registerCommands / executeCommand', () => {
+ it('registers and executes a sync command', async () => {
+ const handler = vi.fn()
+ registerCommands({ 'test.a': handler })
+
+ await executeCommand('test.a', { value: 42 })
+
+ expect(handler).toHaveBeenCalledWith({ value: 42 })
+ })
+
+ it('registers and executes an async command', async () => {
+ const handler = vi.fn().mockResolvedValue(undefined)
+ registerCommands({ 'async.cmd': handler })
+
+ await executeCommand('async.cmd')
+
+ expect(handler).toHaveBeenCalled()
+ })
+
+ it('registers multiple commands at once', async () => {
+ const handlerA = vi.fn()
+ const handlerB = vi.fn()
+ registerCommands({ 'test.a': handlerA, 'test.b': handlerB })
+
+ await executeCommand('test.a')
+ await executeCommand('test.b')
+
+ expect(handlerA).toHaveBeenCalled()
+ expect(handlerB).toHaveBeenCalled()
+ })
+
+ it('silently ignores unregistered command names', async () => {
+ await expect(executeCommand('nonexistent')).resolves.toBeUndefined()
+ })
+
+ it('passes undefined args when not provided', async () => {
+ const handler = vi.fn()
+ registerCommands({ 'test.c': handler })
+
+ await executeCommand('test.c')
+
+ expect(handler).toHaveBeenCalledWith(undefined)
+ })
+ })
+
+ describe('unregisterCommands', () => {
+ it('removes commands so they can no longer execute', async () => {
+ const handler = vi.fn()
+ registerCommands({ 'test.a': handler })
+
+ unregisterCommands(['test.a'])
+ await executeCommand('test.a')
+
+ expect(handler).not.toHaveBeenCalled()
+ })
+
+ it('handles unregistering non-existent commands gracefully', () => {
+ expect(() => unregisterCommands(['nope'])).not.toThrow()
+ })
+ })
+})
diff --git a/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts
new file mode 100644
index 0000000000..1366c27245
--- /dev/null
+++ b/web/app/components/goto-anything/actions/commands/__tests__/direct-commands.spec.ts
@@ -0,0 +1,212 @@
+/**
+ * Tests for direct-mode commands that share similar patterns:
+ * docs, account, community, forum
+ *
+ * Each command: opens a URL or navigates, has direct mode, and registers a navigation command.
+ */
+import { accountCommand } from '../account'
+import { registerCommands, unregisterCommands } from '../command-bus'
+import { communityCommand } from '../community'
+import { docsCommand } from '../docs'
+import { forumCommand } from '../forum'
+
+vi.mock('../command-bus')
+
+vi.mock('react-i18next', () => ({
+ getI18n: () => ({
+ t: (key: string) => key,
+ language: 'en',
+ }),
+}))
+
+vi.mock('@/context/i18n', () => ({
+ defaultDocBaseUrl: 'https://docs.dify.ai',
+}))
+
+vi.mock('@/i18n-config/language', () => ({
+ getDocLanguage: (locale: string) => locale === 'en' ? 'en' : locale,
+}))
+
+describe('docsCommand', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('has correct metadata', () => {
+ expect(docsCommand.name).toBe('docs')
+ expect(docsCommand.mode).toBe('direct')
+ expect(docsCommand.execute).toBeDefined()
+ })
+
+ it('execute opens documentation in new tab', () => {
+ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
+
+ docsCommand.execute?.()
+
+ expect(openSpy).toHaveBeenCalledWith(
+ expect.stringContaining('https://docs.dify.ai'),
+ '_blank',
+ 'noopener,noreferrer',
+ )
+ openSpy.mockRestore()
+ })
+
+ it('search returns a single doc result', async () => {
+ const results = await docsCommand.search('', 'en')
+
+ expect(results).toHaveLength(1)
+ expect(results[0]).toMatchObject({
+ id: 'doc',
+ type: 'command',
+ data: { command: 'navigation.doc', args: {} },
+ })
+ })
+
+ it('registers navigation.doc command', () => {
+ docsCommand.register?.({} as Record)
+ expect(registerCommands).toHaveBeenCalledWith({ 'navigation.doc': expect.any(Function) })
+ })
+
+ it('unregisters navigation.doc command', () => {
+ docsCommand.unregister?.()
+ expect(unregisterCommands).toHaveBeenCalledWith(['navigation.doc'])
+ })
+})
+
+describe('accountCommand', () => {
+ let originalHref: string
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ originalHref = window.location.href
+ })
+
+ afterEach(() => {
+ Object.defineProperty(window, 'location', { value: { href: originalHref }, writable: true })
+ })
+
+ it('has correct metadata', () => {
+ expect(accountCommand.name).toBe('account')
+ expect(accountCommand.mode).toBe('direct')
+ expect(accountCommand.execute).toBeDefined()
+ })
+
+ it('execute navigates to /account', () => {
+ Object.defineProperty(window, 'location', { value: { href: '' }, writable: true })
+ accountCommand.execute?.()
+ expect(window.location.href).toBe('/account')
+ })
+
+ it('search returns account result', async () => {
+ const results = await accountCommand.search('', 'en')
+
+ expect(results).toHaveLength(1)
+ expect(results[0]).toMatchObject({
+ id: 'account',
+ type: 'command',
+ data: { command: 'navigation.account', args: {} },
+ })
+ })
+
+ it('registers navigation.account command', () => {
+ accountCommand.register?.({} as Record)
+ expect(registerCommands).toHaveBeenCalledWith({ 'navigation.account': expect.any(Function) })
+ })
+
+ it('unregisters navigation.account command', () => {
+ accountCommand.unregister?.()
+ expect(unregisterCommands).toHaveBeenCalledWith(['navigation.account'])
+ })
+})
+
+describe('communityCommand', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('has correct metadata', () => {
+ expect(communityCommand.name).toBe('community')
+ expect(communityCommand.mode).toBe('direct')
+ expect(communityCommand.execute).toBeDefined()
+ })
+
+ it('execute opens Discord URL', () => {
+ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
+
+ communityCommand.execute?.()
+
+ expect(openSpy).toHaveBeenCalledWith(
+ 'https://discord.gg/5AEfbxcd9k',
+ '_blank',
+ 'noopener,noreferrer',
+ )
+ openSpy.mockRestore()
+ })
+
+ it('search returns community result', async () => {
+ const results = await communityCommand.search('', 'en')
+
+ expect(results).toHaveLength(1)
+ expect(results[0]).toMatchObject({
+ id: 'community',
+ type: 'command',
+ data: { command: 'navigation.community' },
+ })
+ })
+
+ it('registers navigation.community command', () => {
+ communityCommand.register?.({} as Record)
+ expect(registerCommands).toHaveBeenCalledWith({ 'navigation.community': expect.any(Function) })
+ })
+
+ it('unregisters navigation.community command', () => {
+ communityCommand.unregister?.()
+ expect(unregisterCommands).toHaveBeenCalledWith(['navigation.community'])
+ })
+})
+
+describe('forumCommand', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('has correct metadata', () => {
+ expect(forumCommand.name).toBe('forum')
+ expect(forumCommand.mode).toBe('direct')
+ expect(forumCommand.execute).toBeDefined()
+ })
+
+ it('execute opens forum URL', () => {
+ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
+
+ forumCommand.execute?.()
+
+ expect(openSpy).toHaveBeenCalledWith(
+ 'https://forum.dify.ai',
+ '_blank',
+ 'noopener,noreferrer',
+ )
+ openSpy.mockRestore()
+ })
+
+ it('search returns forum result', async () => {
+ const results = await forumCommand.search('', 'en')
+
+ expect(results).toHaveLength(1)
+ expect(results[0]).toMatchObject({
+ id: 'forum',
+ type: 'command',
+ data: { command: 'navigation.forum' },
+ })
+ })
+
+ it('registers navigation.forum command', () => {
+ forumCommand.register?.({} as Record)
+ expect(registerCommands).toHaveBeenCalledWith({ 'navigation.forum': expect.any(Function) })
+ })
+
+ it('unregisters navigation.forum command', () => {
+ forumCommand.unregister?.()
+ expect(unregisterCommands).toHaveBeenCalledWith(['navigation.forum'])
+ })
+})
diff --git a/web/app/components/goto-anything/actions/commands/__tests__/language.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/language.spec.ts
new file mode 100644
index 0000000000..54aa28d24a
--- /dev/null
+++ b/web/app/components/goto-anything/actions/commands/__tests__/language.spec.ts
@@ -0,0 +1,89 @@
+import { registerCommands, unregisterCommands } from '../command-bus'
+import { languageCommand } from '../language'
+
+vi.mock('../command-bus')
+
+vi.mock('react-i18next', () => ({
+ getI18n: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('@/i18n-config/language', () => ({
+ languages: [
+ { value: 'en-US', name: 'English', supported: true },
+ { value: 'zh-Hans', name: '简体中文', supported: true },
+ { value: 'ja-JP', name: '日本語', supported: true },
+ { value: 'unsupported', name: 'Unsupported', supported: false },
+ ],
+}))
+
+describe('languageCommand', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('has correct metadata', () => {
+ expect(languageCommand.name).toBe('language')
+ expect(languageCommand.aliases).toEqual(['lang'])
+ expect(languageCommand.mode).toBe('submenu')
+ expect(languageCommand.execute).toBeUndefined()
+ })
+
+ describe('search', () => {
+ it('returns all supported languages when query is empty', async () => {
+ const results = await languageCommand.search('', 'en')
+
+ expect(results).toHaveLength(3) // 3 supported languages
+ expect(results.every(r => r.type === 'command')).toBe(true)
+ })
+
+ it('filters languages by name query', async () => {
+ const results = await languageCommand.search('english', 'en')
+
+ expect(results).toHaveLength(1)
+ expect(results[0].id).toBe('lang-en-US')
+ })
+
+ it('filters languages by value query', async () => {
+ const results = await languageCommand.search('zh', 'en')
+
+ expect(results).toHaveLength(1)
+ expect(results[0].id).toBe('lang-zh-Hans')
+ })
+
+ it('returns command data with i18n.set command', async () => {
+ const results = await languageCommand.search('', 'en')
+
+ results.forEach((r) => {
+ expect(r.data.command).toBe('i18n.set')
+ expect(r.data.args).toHaveProperty('locale')
+ })
+ })
+ })
+
+ describe('register / unregister', () => {
+ it('registers i18n.set command', () => {
+ languageCommand.register?.({ setLocale: vi.fn() })
+
+ expect(registerCommands).toHaveBeenCalledWith({ 'i18n.set': expect.any(Function) })
+ })
+
+ it('unregisters i18n.set command', () => {
+ languageCommand.unregister?.()
+
+ expect(unregisterCommands).toHaveBeenCalledWith(['i18n.set'])
+ })
+
+ it('registered handler calls setLocale with correct locale', async () => {
+ const setLocale = vi.fn().mockResolvedValue(undefined)
+ vi.mocked(registerCommands).mockImplementation((map) => {
+ map['i18n.set']?.({ locale: 'zh-Hans' })
+ })
+
+ languageCommand.register?.({ setLocale })
+
+ expect(setLocale).toHaveBeenCalledWith('zh-Hans')
+ })
+ })
+})
diff --git a/web/app/components/goto-anything/actions/commands/__tests__/registry.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/registry.spec.ts
new file mode 100644
index 0000000000..2488ffed28
--- /dev/null
+++ b/web/app/components/goto-anything/actions/commands/__tests__/registry.spec.ts
@@ -0,0 +1,267 @@
+import type { SlashCommandHandler } from '../types'
+import { SlashCommandRegistry } from '../registry'
+
+function createHandler(overrides: Partial = {}): SlashCommandHandler {
+ return {
+ name: 'test',
+ description: 'Test command',
+ search: vi.fn().mockResolvedValue([]),
+ register: vi.fn(),
+ unregister: vi.fn(),
+ ...overrides,
+ }
+}
+
+describe('SlashCommandRegistry', () => {
+ let registry: SlashCommandRegistry
+
+ beforeEach(() => {
+ registry = new SlashCommandRegistry()
+ })
+
+ describe('register & findCommand', () => {
+ it('registers a handler and retrieves it by name', () => {
+ const handler = createHandler({ name: 'docs' })
+ registry.register(handler)
+
+ expect(registry.findCommand('docs')).toBe(handler)
+ })
+
+ it('registers aliases so handler is found by any alias', () => {
+ const handler = createHandler({ name: 'language', aliases: ['lang', 'l'] })
+ registry.register(handler)
+
+ expect(registry.findCommand('language')).toBe(handler)
+ expect(registry.findCommand('lang')).toBe(handler)
+ expect(registry.findCommand('l')).toBe(handler)
+ })
+
+ it('calls handler.register with provided deps', () => {
+ const handler = createHandler({ name: 'theme' })
+ const deps = { setTheme: vi.fn() }
+ registry.register(handler, deps)
+
+ expect(handler.register).toHaveBeenCalledWith(deps)
+ })
+
+ it('does not call handler.register when no deps provided', () => {
+ const handler = createHandler({ name: 'docs' })
+ registry.register(handler)
+
+ expect(handler.register).not.toHaveBeenCalled()
+ })
+
+ it('returns undefined for unknown command name', () => {
+ expect(registry.findCommand('nonexistent')).toBeUndefined()
+ })
+ })
+
+ describe('unregister', () => {
+ it('removes handler by name', () => {
+ const handler = createHandler({ name: 'docs' })
+ registry.register(handler)
+ registry.unregister('docs')
+
+ expect(registry.findCommand('docs')).toBeUndefined()
+ })
+
+ it('removes all aliases', () => {
+ const handler = createHandler({ name: 'language', aliases: ['lang'] })
+ registry.register(handler)
+ registry.unregister('language')
+
+ expect(registry.findCommand('language')).toBeUndefined()
+ expect(registry.findCommand('lang')).toBeUndefined()
+ })
+
+ it('calls handler.unregister', () => {
+ const handler = createHandler({ name: 'docs' })
+ registry.register(handler)
+ registry.unregister('docs')
+
+ expect(handler.unregister).toHaveBeenCalled()
+ })
+
+ it('is a no-op for unknown command', () => {
+ expect(() => registry.unregister('unknown')).not.toThrow()
+ })
+ })
+
+ describe('getAllCommands', () => {
+ it('returns deduplicated handlers', () => {
+ const h1 = createHandler({ name: 'theme', aliases: ['t'] })
+ const h2 = createHandler({ name: 'docs' })
+ registry.register(h1)
+ registry.register(h2)
+
+ const commands = registry.getAllCommands()
+ expect(commands).toHaveLength(2)
+ expect(commands).toContainEqual(expect.objectContaining({ name: 'theme' }))
+ expect(commands).toContainEqual(expect.objectContaining({ name: 'docs' }))
+ })
+
+ it('returns empty array when nothing registered', () => {
+ expect(registry.getAllCommands()).toEqual([])
+ })
+ })
+
+ describe('getAvailableCommands', () => {
+ it('includes commands without isAvailable guard', () => {
+ registry.register(createHandler({ name: 'docs' }))
+
+ expect(registry.getAvailableCommands()).toHaveLength(1)
+ })
+
+ it('includes commands where isAvailable returns true', () => {
+ registry.register(createHandler({ name: 'zen', isAvailable: () => true }))
+
+ expect(registry.getAvailableCommands()).toHaveLength(1)
+ })
+
+ it('excludes commands where isAvailable returns false', () => {
+ registry.register(createHandler({ name: 'zen', isAvailable: () => false }))
+
+ expect(registry.getAvailableCommands()).toHaveLength(0)
+ })
+ })
+
+ describe('search', () => {
+ it('returns root commands for "/"', async () => {
+ registry.register(createHandler({ name: 'theme', description: 'Change theme' }))
+ registry.register(createHandler({ name: 'docs', description: 'Open docs' }))
+
+ const results = await registry.search('/')
+
+ expect(results).toHaveLength(2)
+ expect(results[0]).toMatchObject({
+ id: expect.stringContaining('root-'),
+ type: 'command',
+ })
+ })
+
+ it('returns root commands for "/ "', async () => {
+ registry.register(createHandler({ name: 'theme' }))
+
+ const results = await registry.search('/ ')
+ expect(results).toHaveLength(1)
+ })
+
+ it('delegates to exact-match handler for "/theme dark"', async () => {
+ const mockResults = [{ id: 'dark', title: 'Dark', description: '', type: 'command' as const, data: {} }]
+ const handler = createHandler({
+ name: 'theme',
+ search: vi.fn().mockResolvedValue(mockResults),
+ })
+ registry.register(handler)
+
+ const results = await registry.search('/theme dark')
+
+ expect(handler.search).toHaveBeenCalledWith('dark', 'en')
+ expect(results).toEqual(mockResults)
+ })
+
+ it('delegates to exact-match handler for command without args', async () => {
+ const handler = createHandler({ name: 'docs', search: vi.fn().mockResolvedValue([]) })
+ registry.register(handler)
+
+ await registry.search('/docs')
+
+ expect(handler.search).toHaveBeenCalledWith('', 'en')
+ })
+
+ it('uses partial match when no exact match found', async () => {
+ const mockResults = [{ id: '1', title: 'T', description: '', type: 'command' as const, data: {} }]
+ const handler = createHandler({
+ name: 'theme',
+ search: vi.fn().mockResolvedValue(mockResults),
+ })
+ registry.register(handler)
+
+ const results = await registry.search('/the')
+
+ expect(results).toEqual(mockResults)
+ })
+
+ it('uses alias partial match', async () => {
+ const mockResults = [{ id: '1', title: 'L', description: '', type: 'command' as const, data: {} }]
+ const handler = createHandler({
+ name: 'language',
+ aliases: ['lang'],
+ search: vi.fn().mockResolvedValue(mockResults),
+ })
+ registry.register(handler)
+
+ const results = await registry.search('/lan')
+
+ expect(results).toEqual(mockResults)
+ })
+
+ it('falls back to fuzzy search when nothing matches', async () => {
+ registry.register(createHandler({ name: 'theme', description: 'Set theme' }))
+
+ const results = await registry.search('/hem')
+
+ expect(results).toHaveLength(1)
+ expect(results[0].title).toBe('/theme')
+ })
+
+ it('fuzzy search also matches aliases', async () => {
+ registry.register(createHandler({ name: 'language', aliases: ['lang'], description: 'Set language' }))
+
+ const handler = registry.findCommand('language')
+ await registry.search('/lan')
+ expect(handler?.search).toHaveBeenCalled()
+ })
+
+ it('returns empty when handler.search throws', async () => {
+ const handler = createHandler({
+ name: 'broken',
+ search: vi.fn().mockRejectedValue(new Error('fail')),
+ })
+ registry.register(handler)
+
+ const results = await registry.search('/broken')
+ expect(results).toEqual([])
+ })
+
+ it('excludes unavailable commands from root listing', async () => {
+ registry.register(createHandler({ name: 'zen', isAvailable: () => false }))
+ registry.register(createHandler({ name: 'docs' }))
+
+ const results = await registry.search('/')
+ expect(results).toHaveLength(1)
+ expect(results[0].title).toBe('/docs')
+ })
+
+ it('skips unavailable handler in exact match', async () => {
+ registry.register(createHandler({ name: 'zen', isAvailable: () => false }))
+
+ const results = await registry.search('/zen')
+ expect(results).toEqual([])
+ })
+
+ it('passes locale to handler search', async () => {
+ const handler = createHandler({ name: 'theme', search: vi.fn().mockResolvedValue([]) })
+ registry.register(handler)
+
+ await registry.search('/theme light', 'zh')
+
+ expect(handler.search).toHaveBeenCalledWith('light', 'zh')
+ })
+ })
+
+ describe('getCommandDependencies', () => {
+ it('returns stored deps', () => {
+ const deps = { setTheme: vi.fn() }
+ registry.register(createHandler({ name: 'theme' }), deps)
+
+ expect(registry.getCommandDependencies('theme')).toBe(deps)
+ })
+
+ it('returns undefined when no deps stored', () => {
+ registry.register(createHandler({ name: 'docs' }))
+
+ expect(registry.getCommandDependencies('docs')).toBeUndefined()
+ })
+ })
+})
diff --git a/web/app/components/goto-anything/actions/commands/__tests__/theme.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/theme.spec.ts
new file mode 100644
index 0000000000..3dd45aad11
--- /dev/null
+++ b/web/app/components/goto-anything/actions/commands/__tests__/theme.spec.ts
@@ -0,0 +1,73 @@
+import { registerCommands, unregisterCommands } from '../command-bus'
+import { themeCommand } from '../theme'
+
+vi.mock('../command-bus')
+
+vi.mock('react-i18next', () => ({
+ getI18n: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+describe('themeCommand', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('has correct metadata', () => {
+ expect(themeCommand.name).toBe('theme')
+ expect(themeCommand.mode).toBe('submenu')
+ expect(themeCommand.execute).toBeUndefined()
+ })
+
+ describe('search', () => {
+ it('returns all theme options when query is empty', async () => {
+ const results = await themeCommand.search('', 'en')
+
+ expect(results).toHaveLength(3)
+ expect(results.map(r => r.id)).toEqual(['system', 'light', 'dark'])
+ })
+
+ it('returns all theme options with correct type', async () => {
+ const results = await themeCommand.search('', 'en')
+
+ results.forEach((r) => {
+ expect(r.type).toBe('command')
+ expect(r.data).toEqual({ command: 'theme.set', args: expect.objectContaining({ value: expect.any(String) }) })
+ })
+ })
+
+ it('filters results by query matching id', async () => {
+ const results = await themeCommand.search('dark', 'en')
+
+ expect(results).toHaveLength(1)
+ expect(results[0].id).toBe('dark')
+ })
+ })
+
+ describe('register / unregister', () => {
+ it('registers theme.set command with deps', () => {
+ const deps = { setTheme: vi.fn() }
+ themeCommand.register?.(deps)
+
+ expect(registerCommands).toHaveBeenCalledWith({ 'theme.set': expect.any(Function) })
+ })
+
+ it('unregisters theme.set command', () => {
+ themeCommand.unregister?.()
+
+ expect(unregisterCommands).toHaveBeenCalledWith(['theme.set'])
+ })
+
+ it('registered handler calls setTheme', async () => {
+ const setTheme = vi.fn()
+ vi.mocked(registerCommands).mockImplementation((map) => {
+ map['theme.set']?.({ value: 'dark' })
+ })
+
+ themeCommand.register?.({ setTheme })
+
+ expect(setTheme).toHaveBeenCalledWith('dark')
+ })
+ })
+})
diff --git a/web/app/components/goto-anything/actions/commands/__tests__/zen.spec.ts b/web/app/components/goto-anything/actions/commands/__tests__/zen.spec.ts
new file mode 100644
index 0000000000..623cbda140
--- /dev/null
+++ b/web/app/components/goto-anything/actions/commands/__tests__/zen.spec.ts
@@ -0,0 +1,84 @@
+import { registerCommands, unregisterCommands } from '../command-bus'
+import { ZEN_TOGGLE_EVENT, zenCommand } from '../zen'
+
+vi.mock('../command-bus')
+
+vi.mock('react-i18next', () => ({
+ getI18n: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('@/app/components/workflow/constants', () => ({
+ isInWorkflowPage: vi.fn(() => true),
+}))
+
+describe('zenCommand', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('has correct metadata', () => {
+ expect(zenCommand.name).toBe('zen')
+ expect(zenCommand.mode).toBe('direct')
+ expect(zenCommand.execute).toBeDefined()
+ })
+
+ it('exports ZEN_TOGGLE_EVENT constant', () => {
+ expect(ZEN_TOGGLE_EVENT).toBe('zen-toggle-maximize')
+ })
+
+ describe('isAvailable', () => {
+ it('delegates to isInWorkflowPage', async () => {
+ const { isInWorkflowPage } = vi.mocked(
+ await import('@/app/components/workflow/constants'),
+ )
+
+ isInWorkflowPage.mockReturnValue(true)
+ expect(zenCommand.isAvailable?.()).toBe(true)
+
+ isInWorkflowPage.mockReturnValue(false)
+ expect(zenCommand.isAvailable?.()).toBe(false)
+ })
+ })
+
+ describe('execute', () => {
+ it('dispatches custom zen-toggle event', () => {
+ const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
+
+ zenCommand.execute?.()
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ type: ZEN_TOGGLE_EVENT }),
+ )
+ dispatchSpy.mockRestore()
+ })
+ })
+
+ describe('search', () => {
+ it('returns single zen mode result', async () => {
+ const results = await zenCommand.search('', 'en')
+
+ expect(results).toHaveLength(1)
+ expect(results[0]).toMatchObject({
+ id: 'zen',
+ type: 'command',
+ data: { command: 'workflow.zen', args: {} },
+ })
+ })
+ })
+
+ describe('register / unregister', () => {
+ it('registers workflow.zen command', () => {
+ zenCommand.register?.({} as Record)
+
+ expect(registerCommands).toHaveBeenCalledWith({ 'workflow.zen': expect.any(Function) })
+ })
+
+ it('unregisters workflow.zen command', () => {
+ zenCommand.unregister?.()
+
+ expect(unregisterCommands).toHaveBeenCalledWith(['workflow.zen'])
+ })
+ })
+})
diff --git a/web/app/components/goto-anything/components/empty-state.spec.tsx b/web/app/components/goto-anything/components/__tests__/empty-state.spec.tsx
similarity index 88%
rename from web/app/components/goto-anything/components/empty-state.spec.tsx
rename to web/app/components/goto-anything/components/__tests__/empty-state.spec.tsx
index e1e5e0dc89..8921f5b897 100644
--- a/web/app/components/goto-anything/components/empty-state.spec.tsx
+++ b/web/app/components/goto-anything/components/__tests__/empty-state.spec.tsx
@@ -1,15 +1,5 @@
import { render, screen } from '@testing-library/react'
-import EmptyState from './empty-state'
-
-vi.mock('react-i18next', () => ({
- useTranslation: () => ({
- t: (key: string, options?: { ns?: string, shortcuts?: string }) => {
- if (options?.shortcuts !== undefined)
- return `${key}:${options.shortcuts}`
- return `${options?.ns || 'common'}.${key}`
- },
- }),
-}))
+import EmptyState from '../empty-state'
describe('EmptyState', () => {
describe('loading variant', () => {
@@ -86,10 +76,10 @@ describe('EmptyState', () => {
const Actions = {
app: { key: '@app', shortcut: '@app' },
plugin: { key: '@plugin', shortcut: '@plugin' },
- } as unknown as Record
+ } as unknown as Record
render()
- expect(screen.getByText('gotoAnything.emptyState.trySpecificSearch:@app, @plugin')).toBeInTheDocument()
+ expect(screen.getByText('app.gotoAnything.emptyState.trySpecificSearch:{"shortcuts":"@app, @plugin"}')).toBeInTheDocument()
})
})
@@ -150,8 +140,7 @@ describe('EmptyState', () => {
it('should use empty object as default Actions', () => {
render()
- // Should show empty shortcuts
- expect(screen.getByText('gotoAnything.emptyState.trySpecificSearch:')).toBeInTheDocument()
+ expect(screen.getByText('app.gotoAnything.emptyState.trySpecificSearch:{"shortcuts":""}')).toBeInTheDocument()
})
})
})
diff --git a/web/app/components/goto-anything/components/footer.spec.tsx b/web/app/components/goto-anything/components/__tests__/footer.spec.tsx
similarity index 92%
rename from web/app/components/goto-anything/components/footer.spec.tsx
rename to web/app/components/goto-anything/components/__tests__/footer.spec.tsx
index 3dfac5f71c..93239079de 100644
--- a/web/app/components/goto-anything/components/footer.spec.tsx
+++ b/web/app/components/goto-anything/components/__tests__/footer.spec.tsx
@@ -1,17 +1,5 @@
import { render, screen } from '@testing-library/react'
-import Footer from './footer'
-
-vi.mock('react-i18next', () => ({
- useTranslation: () => ({
- t: (key: string, options?: { ns?: string, count?: number, scope?: string }) => {
- if (options?.count !== undefined)
- return `${key}:${options.count}`
- if (options?.scope)
- return `${key}:${options.scope}`
- return `${options?.ns || 'common'}.${key}`
- },
- }),
-}))
+import Footer from '../footer'
describe('Footer', () => {
describe('left content', () => {
@@ -27,7 +15,7 @@ describe('Footer', () => {
/>,
)
- expect(screen.getByText('gotoAnything.resultCount:5')).toBeInTheDocument()
+ expect(screen.getByText('app.gotoAnything.resultCount:{"count":5}')).toBeInTheDocument()
})
it('should show scope when not in general mode', () => {
@@ -41,7 +29,7 @@ describe('Footer', () => {
/>,
)
- expect(screen.getByText('gotoAnything.inScope:app')).toBeInTheDocument()
+ expect(screen.getByText('app.gotoAnything.inScope:{"scope":"app"}')).toBeInTheDocument()
})
it('should NOT show scope when in general mode', () => {
diff --git a/web/app/components/goto-anything/components/__tests__/result-item.spec.tsx b/web/app/components/goto-anything/components/__tests__/result-item.spec.tsx
new file mode 100644
index 0000000000..068e5db3e7
--- /dev/null
+++ b/web/app/components/goto-anything/components/__tests__/result-item.spec.tsx
@@ -0,0 +1,82 @@
+import type { SearchResult } from '../../actions/types'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { Command } from 'cmdk'
+import ResultItem from '../result-item'
+
+function renderInCommandRoot(ui: React.ReactElement) {
+ return render({ui})
+}
+
+function createResult(overrides: Partial = {}): SearchResult {
+ return {
+ id: 'test-1',
+ title: 'Test Result',
+ type: 'app',
+ data: {},
+ ...overrides,
+ } as SearchResult
+}
+
+describe('ResultItem', () => {
+ it('renders title', () => {
+ renderInCommandRoot(
+ ,
+ )
+
+ expect(screen.getByText('My App')).toBeInTheDocument()
+ })
+
+ it('renders description when provided', () => {
+ renderInCommandRoot(
+ ,
+ )
+
+ expect(screen.getByText('A great app')).toBeInTheDocument()
+ })
+
+ it('does not render description when absent', () => {
+ const result = createResult()
+ delete (result as Record).description
+
+ renderInCommandRoot(
+ ,
+ )
+
+ expect(screen.getByText('Test Result')).toBeInTheDocument()
+ expect(screen.getByText('app')).toBeInTheDocument()
+ })
+
+ it('renders result type label', () => {
+ renderInCommandRoot(
+ ,
+ )
+
+ expect(screen.getByText('plugin')).toBeInTheDocument()
+ })
+
+ it('renders icon when provided', () => {
+ const icon = icon
+ renderInCommandRoot(
+ ,
+ )
+
+ expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
+ })
+
+ it('calls onSelect when clicked', async () => {
+ const user = userEvent.setup()
+ const onSelect = vi.fn()
+
+ renderInCommandRoot(
+ ,
+ )
+
+ await user.click(screen.getByText('Test Result'))
+
+ expect(onSelect).toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/goto-anything/components/__tests__/result-list.spec.tsx b/web/app/components/goto-anything/components/__tests__/result-list.spec.tsx
new file mode 100644
index 0000000000..746e6110b8
--- /dev/null
+++ b/web/app/components/goto-anything/components/__tests__/result-list.spec.tsx
@@ -0,0 +1,86 @@
+import type { SearchResult } from '../../actions/types'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { Command } from 'cmdk'
+import ResultList from '../result-list'
+
+function renderInCommandRoot(ui: React.ReactElement) {
+ return render({ui})
+}
+
+function createResult(overrides: Partial = {}): SearchResult {
+ return {
+ id: 'test-1',
+ title: 'Result 1',
+ type: 'app',
+ data: {},
+ ...overrides,
+ } as SearchResult
+}
+
+describe('ResultList', () => {
+ it('renders grouped results with headings', () => {
+ const grouped: Record = {
+ app: [createResult({ id: 'a1', title: 'App One', type: 'app' })],
+ plugin: [createResult({ id: 'p1', title: 'Plugin One', type: 'plugin' })],
+ }
+
+ renderInCommandRoot(
+ ,
+ )
+
+ expect(screen.getByText('App One')).toBeInTheDocument()
+ expect(screen.getByText('Plugin One')).toBeInTheDocument()
+ })
+
+ it('renders multiple results in the same group', () => {
+ const grouped: Record = {
+ app: [
+ createResult({ id: 'a1', title: 'App One', type: 'app' }),
+ createResult({ id: 'a2', title: 'App Two', type: 'app' }),
+ ],
+ }
+
+ renderInCommandRoot(
+ ,
+ )
+
+ expect(screen.getByText('App One')).toBeInTheDocument()
+ expect(screen.getByText('App Two')).toBeInTheDocument()
+ })
+
+ it('calls onSelect with the correct result when clicked', async () => {
+ const user = userEvent.setup()
+ const onSelect = vi.fn()
+ const result = createResult({ id: 'a1', title: 'Click Me', type: 'app' })
+
+ renderInCommandRoot(
+ ,
+ )
+
+ await user.click(screen.getByText('Click Me'))
+
+ expect(onSelect).toHaveBeenCalledWith(result)
+ })
+
+ it('renders empty when no grouped results provided', () => {
+ const { container } = renderInCommandRoot(
+ ,
+ )
+
+ const groups = container.querySelectorAll('[cmdk-group]')
+ expect(groups).toHaveLength(0)
+ })
+
+ it('uses i18n keys for known group types', () => {
+ const grouped: Record = {
+ command: [createResult({ id: 'c1', title: 'Cmd', type: 'command' })],
+ }
+
+ renderInCommandRoot(
+ ,
+ )
+
+ expect(screen.getByText('app.gotoAnything.groups.commands')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/goto-anything/components/search-input.spec.tsx b/web/app/components/goto-anything/components/__tests__/search-input.spec.tsx
similarity index 96%
rename from web/app/components/goto-anything/components/search-input.spec.tsx
rename to web/app/components/goto-anything/components/__tests__/search-input.spec.tsx
index 99c0f56d56..781531a341 100644
--- a/web/app/components/goto-anything/components/search-input.spec.tsx
+++ b/web/app/components/goto-anything/components/__tests__/search-input.spec.tsx
@@ -1,12 +1,6 @@
import type { ChangeEvent, KeyboardEvent, RefObject } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
-import SearchInput from './search-input'
-
-vi.mock('react-i18next', () => ({
- useTranslation: () => ({
- t: (key: string, options?: { ns?: string }) => `${options?.ns || 'common'}.${key}`,
- }),
-}))
+import SearchInput from '../search-input'
vi.mock('@remixicon/react', () => ({
RiSearchLine: ({ className }: { className?: string }) => (
diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-modal.spec.ts b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts
similarity index 91%
rename from web/app/components/goto-anything/hooks/use-goto-anything-modal.spec.ts
rename to web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts
index 89d05be25e..45bbfb7447 100644
--- a/web/app/components/goto-anything/hooks/use-goto-anything-modal.spec.ts
+++ b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts
@@ -1,5 +1,5 @@
import { act, renderHook } from '@testing-library/react'
-import { useGotoAnythingModal } from './use-goto-anything-modal'
+import { useGotoAnythingModal } from '../use-goto-anything-modal'
type KeyPressEvent = {
preventDefault: () => void
@@ -94,20 +94,17 @@ describe('useGotoAnythingModal', () => {
keyPressHandlers['ctrl.k']?.({ preventDefault: vi.fn(), target: document.body })
})
- // Should remain closed because focus is in input area
expect(result.current.show).toBe(false)
})
it('should close modal when escape is pressed and modal is open', () => {
const { result } = renderHook(() => useGotoAnythingModal())
- // Open modal first
act(() => {
result.current.setShow(true)
})
expect(result.current.show).toBe(true)
- // Press escape
act(() => {
keyPressHandlers.esc?.({ preventDefault: vi.fn() })
})
@@ -125,7 +122,6 @@ describe('useGotoAnythingModal', () => {
keyPressHandlers.esc?.({ preventDefault: preventDefaultMock })
})
- // Should remain closed, and preventDefault should not be called
expect(result.current.show).toBe(false)
expect(preventDefaultMock).not.toHaveBeenCalled()
})
@@ -146,13 +142,11 @@ describe('useGotoAnythingModal', () => {
it('should close modal when handleClose is called', () => {
const { result } = renderHook(() => useGotoAnythingModal())
- // Open modal first
act(() => {
result.current.setShow(true)
})
expect(result.current.show).toBe(true)
- // Close via handleClose
act(() => {
result.current.handleClose()
})
@@ -219,14 +213,12 @@ describe('useGotoAnythingModal', () => {
it('should not call requestAnimationFrame when modal closes', () => {
const { result } = renderHook(() => useGotoAnythingModal())
- // First open
act(() => {
result.current.setShow(true)
})
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
- // Then close
act(() => {
result.current.setShow(false)
})
@@ -236,7 +228,6 @@ describe('useGotoAnythingModal', () => {
})
it('should focus input when modal opens and inputRef.current exists', () => {
- // Mock requestAnimationFrame to execute callback immediately
const originalRAF = window.requestAnimationFrame
window.requestAnimationFrame = (callback: FrameRequestCallback) => {
callback(0)
@@ -245,11 +236,9 @@ describe('useGotoAnythingModal', () => {
const { result } = renderHook(() => useGotoAnythingModal())
- // Create a mock input element with focus method
const mockFocus = vi.fn()
const mockInput = { focus: mockFocus } as unknown as HTMLInputElement
- // Manually set the inputRef
Object.defineProperty(result.current.inputRef, 'current', {
value: mockInput,
writable: true,
@@ -261,12 +250,10 @@ describe('useGotoAnythingModal', () => {
expect(mockFocus).toHaveBeenCalled()
- // Restore original requestAnimationFrame
window.requestAnimationFrame = originalRAF
})
it('should not throw when inputRef.current is null when modal opens', () => {
- // Mock requestAnimationFrame to execute callback immediately
const originalRAF = window.requestAnimationFrame
window.requestAnimationFrame = (callback: FrameRequestCallback) => {
callback(0)
@@ -275,16 +262,12 @@ describe('useGotoAnythingModal', () => {
const { result } = renderHook(() => useGotoAnythingModal())
- // inputRef.current is already null by default
-
- // Should not throw
act(() => {
result.current.setShow(true)
})
expect(result.current.show).toBe(true)
- // Restore original requestAnimationFrame
window.requestAnimationFrame = originalRAF
})
})
diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-navigation.spec.ts b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts
similarity index 95%
rename from web/app/components/goto-anything/hooks/use-goto-anything-navigation.spec.ts
rename to web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts
index efb15f41b3..1ac3bbc17c 100644
--- a/web/app/components/goto-anything/hooks/use-goto-anything-navigation.spec.ts
+++ b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts
@@ -1,10 +1,10 @@
import type * as React from 'react'
-import type { Plugin } from '../../plugins/types'
-import type { CommonNodeType } from '../../workflow/types'
+import type { Plugin } from '../../../plugins/types'
+import type { CommonNodeType } from '../../../workflow/types'
import type { DataSet } from '@/models/datasets'
import type { App } from '@/types/app'
import { act, renderHook } from '@testing-library/react'
-import { useGotoAnythingNavigation } from './use-goto-anything-navigation'
+import { useGotoAnythingNavigation } from '../use-goto-anything-navigation'
const mockRouterPush = vi.fn()
const mockSelectWorkflowNode = vi.fn()
@@ -26,7 +26,7 @@ vi.mock('@/app/components/workflow/utils/node-navigation', () => ({
selectWorkflowNode: (...args: unknown[]) => mockSelectWorkflowNode(...args),
}))
-vi.mock('../actions/commands/registry', () => ({
+vi.mock('../../actions/commands/registry', () => ({
slashCommandRegistry: {
findCommand: () => mockFindCommandResult,
},
@@ -117,7 +117,6 @@ describe('useGotoAnythingNavigation', () => {
})
expect(options.onClose).not.toHaveBeenCalled()
- // Should proceed with submenu mode
expect(options.setSearchQuery).toHaveBeenCalledWith('/theme ')
})
@@ -177,7 +176,6 @@ describe('useGotoAnythingNavigation', () => {
result.current.handleCommandSelect('/unknown')
})
- // Should proceed with submenu mode
expect(options.setSearchQuery).toHaveBeenCalledWith('/unknown ')
})
})
@@ -333,13 +331,11 @@ describe('useGotoAnythingNavigation', () => {
it('should clear activePlugin when set to undefined', () => {
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
- // First set a plugin
act(() => {
result.current.setActivePlugin({ name: 'Plugin', latest_package_identifier: 'pkg' } as unknown as Plugin)
})
expect(result.current.activePlugin).toBeDefined()
- // Then clear it
act(() => {
result.current.setActivePlugin(undefined)
})
@@ -356,7 +352,6 @@ describe('useGotoAnythingNavigation', () => {
const { result } = renderHook(() => useGotoAnythingNavigation(options))
- // Should not throw
act(() => {
result.current.handleCommandSelect('@app')
})
@@ -364,8 +359,6 @@ describe('useGotoAnythingNavigation', () => {
act(() => {
vi.runAllTimers()
})
-
- // No error should occur
})
it('should handle missing slash action', () => {
@@ -375,7 +368,6 @@ describe('useGotoAnythingNavigation', () => {
const { result } = renderHook(() => useGotoAnythingNavigation(options))
- // Should not throw
act(() => {
result.current.handleNavigate({
id: 'cmd-1',
@@ -384,8 +376,6 @@ describe('useGotoAnythingNavigation', () => {
data: { command: 'test-command' },
})
})
-
- // No error should occur
})
})
})
diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-results.spec.ts b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-results.spec.ts
similarity index 97%
rename from web/app/components/goto-anything/hooks/use-goto-anything-results.spec.ts
rename to web/app/components/goto-anything/hooks/__tests__/use-goto-anything-results.spec.ts
index ca95abeacd..faaf0bbd1e 100644
--- a/web/app/components/goto-anything/hooks/use-goto-anything-results.spec.ts
+++ b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-results.spec.ts
@@ -1,6 +1,6 @@
-import type { SearchResult } from '../actions/types'
+import type { SearchResult } from '../../actions/types'
import { renderHook } from '@testing-library/react'
-import { useGotoAnythingResults } from './use-goto-anything-results'
+import { useGotoAnythingResults } from '../use-goto-anything-results'
type MockQueryResult = {
data: Array<{ id: string, type: string, title: string }> | undefined
@@ -30,7 +30,7 @@ vi.mock('@/context/i18n', () => ({
const mockMatchAction = vi.fn()
const mockSearchAnything = vi.fn()
-vi.mock('../actions', () => ({
+vi.mock('../../actions', () => ({
matchAction: (...args: unknown[]) => mockMatchAction(...args),
searchAnything: (...args: unknown[]) => mockSearchAnything(...args),
}))
@@ -139,7 +139,6 @@ describe('useGotoAnythingResults', () => {
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
- // Different types, same id = different keys, so both should remain
expect(result.current.dedupedResults).toHaveLength(2)
})
})
diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-search.spec.ts b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-search.spec.ts
similarity index 96%
rename from web/app/components/goto-anything/hooks/use-goto-anything-search.spec.ts
rename to web/app/components/goto-anything/hooks/__tests__/use-goto-anything-search.spec.ts
index d8987c2d9c..f13fb21704 100644
--- a/web/app/components/goto-anything/hooks/use-goto-anything-search.spec.ts
+++ b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-search.spec.ts
@@ -1,6 +1,6 @@
-import type { ActionItem } from '../actions/types'
+import type { ActionItem } from '../../actions/types'
import { act, renderHook } from '@testing-library/react'
-import { useGotoAnythingSearch } from './use-goto-anything-search'
+import { useGotoAnythingSearch } from '../use-goto-anything-search'
let mockContextValue = { isWorkflowPage: false, isRagPipelinePage: false }
let mockMatchActionResult: Partial | undefined
@@ -9,11 +9,11 @@ vi.mock('ahooks', () => ({
useDebounce: (value: T) => value,
}))
-vi.mock('../context', () => ({
+vi.mock('../../context', () => ({
useGotoAnythingContext: () => mockContextValue,
}))
-vi.mock('../actions', () => ({
+vi.mock('../../actions', () => ({
createActions: (isWorkflowPage: boolean, isRagPipelinePage: boolean) => {
const base = {
slash: { key: '/', shortcut: '/' },
@@ -233,13 +233,11 @@ describe('useGotoAnythingSearch', () => {
it('should reset cmdVal to "_"', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
- // First change cmdVal
act(() => {
result.current.setCmdVal('app-1')
})
expect(result.current.cmdVal).toBe('app-1')
- // Then clear
act(() => {
result.current.clearSelection()
})
@@ -294,7 +292,6 @@ describe('useGotoAnythingSearch', () => {
result.current.setSearchQuery(' test ')
})
- // Since we mock useDebounce to return value directly
expect(result.current.searchQueryDebouncedValue).toBe('test')
})
})
diff --git a/web/app/components/share/utils.spec.ts b/web/app/components/share/__tests__/utils.spec.ts
similarity index 97%
rename from web/app/components/share/utils.spec.ts
rename to web/app/components/share/__tests__/utils.spec.ts
index ee2aab58eb..1cf12f7508 100644
--- a/web/app/components/share/utils.spec.ts
+++ b/web/app/components/share/__tests__/utils.spec.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { getInitialTokenV2, isTokenV1 } from './utils'
+import { getInitialTokenV2, isTokenV1 } from '../utils'
describe('utils', () => {
describe('isTokenV1', () => {
diff --git a/web/app/components/share/text-generation/info-modal.spec.tsx b/web/app/components/share/text-generation/__tests__/info-modal.spec.tsx
similarity index 73%
rename from web/app/components/share/text-generation/info-modal.spec.tsx
rename to web/app/components/share/text-generation/__tests__/info-modal.spec.tsx
index 025c5edde1..972c22dfce 100644
--- a/web/app/components/share/text-generation/info-modal.spec.tsx
+++ b/web/app/components/share/text-generation/__tests__/info-modal.spec.tsx
@@ -1,19 +1,26 @@
import type { SiteInfo } from '@/models/share'
-import { cleanup, fireEvent, render, screen } from '@testing-library/react'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import InfoModal from './info-modal'
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import InfoModal from '../info-modal'
-// Only mock react-i18next for translations
-vi.mock('react-i18next', () => ({
- useTranslation: () => ({
- t: (key: string) => key,
- }),
-}))
+beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+})
afterEach(() => {
+ vi.runOnlyPendingTimers()
+ vi.useRealTimers()
cleanup()
})
+async function renderModal(ui: React.ReactElement) {
+ const result = render(ui)
+ await act(async () => {
+ vi.runAllTimers()
+ })
+ return result
+}
+
describe('InfoModal', () => {
const mockOnClose = vi.fn()
@@ -29,8 +36,8 @@ describe('InfoModal', () => {
})
describe('rendering', () => {
- it('should not render when isShow is false', () => {
- render(
+ it('should not render when isShow is false', async () => {
+ await renderModal(
{
expect(screen.queryByText('Test App')).not.toBeInTheDocument()
})
- it('should render when isShow is true', () => {
- render(
+ it('should render when isShow is true', async () => {
+ await renderModal(
{
expect(screen.getByText('Test App')).toBeInTheDocument()
})
- it('should render app title', () => {
- render(
+ it('should render app title', async () => {
+ await renderModal(
{
expect(screen.getByText('Test App')).toBeInTheDocument()
})
- it('should render copyright when provided', () => {
+ it('should render copyright when provided', async () => {
const siteInfoWithCopyright: SiteInfo = {
...baseSiteInfo,
copyright: 'Dify Inc.',
}
- render(
+ await renderModal(
{
expect(screen.getByText(/Dify Inc./)).toBeInTheDocument()
})
- it('should render current year in copyright', () => {
+ it('should render current year in copyright', async () => {
const siteInfoWithCopyright: SiteInfo = {
...baseSiteInfo,
copyright: 'Test Company',
}
- render(
+ await renderModal(
{
expect(screen.getByText(new RegExp(currentYear))).toBeInTheDocument()
})
- it('should render custom disclaimer when provided', () => {
+ it('should render custom disclaimer when provided', async () => {
const siteInfoWithDisclaimer: SiteInfo = {
...baseSiteInfo,
custom_disclaimer: 'This is a custom disclaimer',
}
- render(
+ await renderModal(
{
expect(screen.getByText('This is a custom disclaimer')).toBeInTheDocument()
})
- it('should not render copyright section when not provided', () => {
- render(
+ it('should not render copyright section when not provided', async () => {
+ await renderModal(
{
expect(screen.queryByText(new RegExp(`©.*${year}`))).not.toBeInTheDocument()
})
- it('should render with undefined data', () => {
- render(
+ it('should render with undefined data', async () => {
+ await renderModal(
{
/>,
)
- // Modal should still render but without content
expect(screen.queryByText('Test App')).not.toBeInTheDocument()
})
- it('should render with image icon type', () => {
+ it('should render with image icon type', async () => {
const siteInfoWithImage: SiteInfo = {
...baseSiteInfo,
icon_type: 'image',
icon_url: 'https://example.com/icon.png',
}
- render(
+ await renderModal(
{
})
describe('close functionality', () => {
- it('should call onClose when close button is clicked', () => {
- render(
+ it('should call onClose when close button is clicked', async () => {
+ await renderModal(
{
/>,
)
- // Find the close icon (RiCloseLine) which has text-text-tertiary class
const closeIcon = document.querySelector('[class*="text-text-tertiary"]')
expect(closeIcon).toBeInTheDocument()
if (closeIcon) {
@@ -183,14 +188,14 @@ describe('InfoModal', () => {
})
describe('both copyright and disclaimer', () => {
- it('should render both when both are provided', () => {
+ it('should render both when both are provided', async () => {
const siteInfoWithBoth: SiteInfo = {
...baseSiteInfo,
copyright: 'My Company',
custom_disclaimer: 'Disclaimer text here',
}
- render(
+ await renderModal(
({
- useTranslation: () => ({
- t: (key: string) => key,
- }),
-}))
-
-// Mock next/navigation
const mockReplace = vi.fn()
const mockPathname = '/test-path'
vi.mock('next/navigation', () => ({
@@ -20,7 +12,6 @@ vi.mock('next/navigation', () => ({
usePathname: () => mockPathname,
}))
-// Mock web-app-context
const mockShareCode = 'test-share-code'
vi.mock('@/context/web-app-context', () => ({
useWebAppStore: (selector: (state: Record) => unknown) => {
@@ -32,7 +23,6 @@ vi.mock('@/context/web-app-context', () => ({
},
}))
-// Mock webapp-auth service
const mockWebAppLogout = vi.fn().mockResolvedValue(undefined)
vi.mock('@/service/webapp-auth', () => ({
webAppLogout: (...args: unknown[]) => mockWebAppLogout(...args),
@@ -57,7 +47,6 @@ describe('MenuDropdown', () => {
it('should render the trigger button', () => {
render()
- // The trigger button contains a settings icon (RiEqualizer2Line)
const triggerButton = screen.getByRole('button')
expect(triggerButton).toBeInTheDocument()
})
@@ -65,8 +54,7 @@ describe('MenuDropdown', () => {
it('should not show dropdown content initially', () => {
render()
- // Dropdown content should not be visible initially
- expect(screen.queryByText('theme.theme')).not.toBeInTheDocument()
+ expect(screen.queryByText('common.theme.theme')).not.toBeInTheDocument()
})
it('should show dropdown content when clicked', async () => {
@@ -76,7 +64,7 @@ describe('MenuDropdown', () => {
fireEvent.click(triggerButton)
await waitFor(() => {
- expect(screen.getByText('theme.theme')).toBeInTheDocument()
+ expect(screen.getByText('common.theme.theme')).toBeInTheDocument()
})
})
@@ -87,7 +75,7 @@ describe('MenuDropdown', () => {
fireEvent.click(triggerButton)
await waitFor(() => {
- expect(screen.getByText('userProfile.about')).toBeInTheDocument()
+ expect(screen.getByText('common.userProfile.about')).toBeInTheDocument()
})
})
})
@@ -105,7 +93,7 @@ describe('MenuDropdown', () => {
fireEvent.click(triggerButton)
await waitFor(() => {
- expect(screen.getByText('chat.privacyPolicyMiddle')).toBeInTheDocument()
+ expect(screen.getByText('share.chat.privacyPolicyMiddle')).toBeInTheDocument()
})
})
@@ -116,7 +104,7 @@ describe('MenuDropdown', () => {
fireEvent.click(triggerButton)
await waitFor(() => {
- expect(screen.queryByText('chat.privacyPolicyMiddle')).not.toBeInTheDocument()
+ expect(screen.queryByText('share.chat.privacyPolicyMiddle')).not.toBeInTheDocument()
})
})
@@ -133,7 +121,7 @@ describe('MenuDropdown', () => {
fireEvent.click(triggerButton)
await waitFor(() => {
- const link = screen.getByText('chat.privacyPolicyMiddle').closest('a')
+ const link = screen.getByText('share.chat.privacyPolicyMiddle').closest('a')
expect(link).toHaveAttribute('href', privacyUrl)
expect(link).toHaveAttribute('target', '_blank')
})
@@ -148,7 +136,7 @@ describe('MenuDropdown', () => {
fireEvent.click(triggerButton)
await waitFor(() => {
- expect(screen.getByText('userProfile.logout')).toBeInTheDocument()
+ expect(screen.getByText('common.userProfile.logout')).toBeInTheDocument()
})
})
@@ -159,7 +147,7 @@ describe('MenuDropdown', () => {
fireEvent.click(triggerButton)
await waitFor(() => {
- expect(screen.queryByText('userProfile.logout')).not.toBeInTheDocument()
+ expect(screen.queryByText('common.userProfile.logout')).not.toBeInTheDocument()
})
})
@@ -170,10 +158,10 @@ describe('MenuDropdown', () => {
fireEvent.click(triggerButton)
await waitFor(() => {
- expect(screen.getByText('userProfile.logout')).toBeInTheDocument()
+ expect(screen.getByText('common.userProfile.logout')).toBeInTheDocument()
})
- const logoutButton = screen.getByText('userProfile.logout')
+ const logoutButton = screen.getByText('common.userProfile.logout')
await act(async () => {
fireEvent.click(logoutButton)
})
@@ -193,10 +181,10 @@ describe('MenuDropdown', () => {
fireEvent.click(triggerButton)
await waitFor(() => {
- expect(screen.getByText('userProfile.about')).toBeInTheDocument()
+ expect(screen.getByText('common.userProfile.about')).toBeInTheDocument()
})
- const aboutButton = screen.getByText('userProfile.about')
+ const aboutButton = screen.getByText('common.userProfile.about')
fireEvent.click(aboutButton)
await waitFor(() => {
@@ -213,13 +201,13 @@ describe('MenuDropdown', () => {
fireEvent.click(triggerButton)
await waitFor(() => {
- expect(screen.getByText('theme.theme')).toBeInTheDocument()
+ expect(screen.getByText('common.theme.theme')).toBeInTheDocument()
})
rerender()
await waitFor(() => {
- expect(screen.queryByText('theme.theme')).not.toBeInTheDocument()
+ expect(screen.queryByText('common.theme.theme')).not.toBeInTheDocument()
})
})
})
@@ -239,16 +227,14 @@ describe('MenuDropdown', () => {
const triggerButton = screen.getByRole('button')
- // Open
fireEvent.click(triggerButton)
await waitFor(() => {
- expect(screen.getByText('theme.theme')).toBeInTheDocument()
+ expect(screen.getByText('common.theme.theme')).toBeInTheDocument()
})
- // Close
fireEvent.click(triggerButton)
await waitFor(() => {
- expect(screen.queryByText('theme.theme')).not.toBeInTheDocument()
+ expect(screen.queryByText('common.theme.theme')).not.toBeInTheDocument()
})
})
})
diff --git a/web/app/components/share/text-generation/no-data/index.spec.tsx b/web/app/components/share/text-generation/no-data/__tests__/index.spec.tsx
similarity index 93%
rename from web/app/components/share/text-generation/no-data/index.spec.tsx
rename to web/app/components/share/text-generation/no-data/__tests__/index.spec.tsx
index 41de9907fd..68e161c9b3 100644
--- a/web/app/components/share/text-generation/no-data/index.spec.tsx
+++ b/web/app/components/share/text-generation/no-data/__tests__/index.spec.tsx
@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
-import NoData from './index'
+import NoData from '../index'
describe('NoData', () => {
beforeEach(() => {
diff --git a/web/app/components/share/text-generation/run-batch/index.spec.tsx b/web/app/components/share/text-generation/run-batch/__tests__/index.spec.tsx
similarity index 96%
rename from web/app/components/share/text-generation/run-batch/index.spec.tsx
rename to web/app/components/share/text-generation/run-batch/__tests__/index.spec.tsx
index 4344ea2156..63aa04e29a 100644
--- a/web/app/components/share/text-generation/run-batch/index.spec.tsx
+++ b/web/app/components/share/text-generation/run-batch/__tests__/index.spec.tsx
@@ -2,7 +2,7 @@ import type { Mock } from 'vitest'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
-import RunBatch from './index'
+import RunBatch from '../index'
vi.mock('@/hooks/use-breakpoints', async (importOriginal) => {
const actual = await importOriginal()
@@ -15,14 +15,14 @@ vi.mock('@/hooks/use-breakpoints', async (importOriginal) => {
let latestOnParsed: ((data: string[][]) => void) | undefined
let receivedCSVDownloadProps: Record | undefined
-vi.mock('./csv-reader', () => ({
+vi.mock('../csv-reader', () => ({
default: (props: { onParsed: (data: string[][]) => void }) => {
latestOnParsed = props.onParsed
return
},
}))
-vi.mock('./csv-download', () => ({
+vi.mock('../csv-download', () => ({
default: (props: { vars: { name: string }[] }) => {
receivedCSVDownloadProps = props
return
diff --git a/web/app/components/share/text-generation/run-batch/csv-download/index.spec.tsx b/web/app/components/share/text-generation/run-batch/csv-download/__tests__/index.spec.tsx
similarity index 97%
rename from web/app/components/share/text-generation/run-batch/csv-download/index.spec.tsx
rename to web/app/components/share/text-generation/run-batch/csv-download/__tests__/index.spec.tsx
index 120e3ed0c2..6a9bb21797 100644
--- a/web/app/components/share/text-generation/run-batch/csv-download/index.spec.tsx
+++ b/web/app/components/share/text-generation/run-batch/csv-download/__tests__/index.spec.tsx
@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
-import CSVDownload from './index'
+import CSVDownload from '../index'
const mockType = { Link: 'mock-link' }
let capturedProps: Record | undefined
diff --git a/web/app/components/share/text-generation/run-batch/csv-reader/index.spec.tsx b/web/app/components/share/text-generation/run-batch/csv-reader/__tests__/index.spec.tsx
similarity index 81%
rename from web/app/components/share/text-generation/run-batch/csv-reader/index.spec.tsx
rename to web/app/components/share/text-generation/run-batch/csv-reader/__tests__/index.spec.tsx
index 83e89a0a04..f1361965a5 100644
--- a/web/app/components/share/text-generation/run-batch/csv-reader/index.spec.tsx
+++ b/web/app/components/share/text-generation/run-batch/csv-reader/__tests__/index.spec.tsx
@@ -1,13 +1,20 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
-import CSVReader from './index'
+import CSVReader from '../index'
let mockAcceptedFile: { name: string } | null = null
-let capturedHandlers: Record void> = {}
+
+type CSVReaderHandlers = {
+ onUploadAccepted?: (payload: { data: string[][] }) => void
+ onDragOver?: (event: DragEvent) => void
+ onDragLeave?: (event: DragEvent) => void
+}
+
+let capturedHandlers: CSVReaderHandlers = {}
vi.mock('react-papaparse', () => ({
useCSVReader: () => ({
- CSVReader: ({ children, ...handlers }: any) => {
+ CSVReader: ({ children, ...handlers }: { children: (ctx: { getRootProps: () => Record, acceptedFile: { name: string } | null }) => React.ReactNode } & CSVReaderHandlers) => {
capturedHandlers = handlers
return (
diff --git a/web/app/components/share/text-generation/run-batch/res-download/index.spec.tsx b/web/app/components/share/text-generation/run-batch/res-download/__tests__/index.spec.tsx
similarity index 97%
rename from web/app/components/share/text-generation/run-batch/res-download/index.spec.tsx
rename to web/app/components/share/text-generation/run-batch/res-download/__tests__/index.spec.tsx
index b71b252345..2419a570f1 100644
--- a/web/app/components/share/text-generation/run-batch/res-download/index.spec.tsx
+++ b/web/app/components/share/text-generation/run-batch/res-download/__tests__/index.spec.tsx
@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
-import ResDownload from './index'
+import ResDownload from '../index'
const mockType = { Link: 'mock-link' }
let capturedProps: Record
| undefined
diff --git a/web/app/components/share/text-generation/run-once/index.spec.tsx b/web/app/components/share/text-generation/run-once/__tests__/index.spec.tsx
similarity index 98%
rename from web/app/components/share/text-generation/run-once/index.spec.tsx
rename to web/app/components/share/text-generation/run-once/__tests__/index.spec.tsx
index af3d723d20..65043ce0c2 100644
--- a/web/app/components/share/text-generation/run-once/index.spec.tsx
+++ b/web/app/components/share/text-generation/run-once/__tests__/index.spec.tsx
@@ -1,4 +1,4 @@
-import type { InputValueTypes } from '../types'
+import type { InputValueTypes } from '../../types'
import type { PromptConfig, PromptVariable } from '@/models/debug'
import type { SiteInfo } from '@/models/share'
import type { VisionFile, VisionSettings } from '@/types/app'
@@ -6,7 +6,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { Resolution, TransferMethod } from '@/types/app'
-import RunOnce from './index'
+import RunOnce from '../index'
vi.mock('@/hooks/use-breakpoints', () => {
const MediaType = {
@@ -39,7 +39,6 @@ vi.mock('@/app/components/base/image-uploader/text-generation-image-uploader', (
}
})
-// Mock FileUploaderInAttachmentWrapper as it requires context providers not available in tests
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({ value, onChange }: { value: object[], onChange: (files: object[]) => void }) => (
@@ -272,7 +271,6 @@ describe('RunOnce', () => {
selectInput: 'Option A',
})
})
- // The Select component should be rendered
expect(screen.getByText('Select Input')).toBeInTheDocument()
})
})
@@ -463,7 +461,6 @@ describe('RunOnce', () => {
key: 'textInput',
name: 'Text Input',
type: 'string',
- // max_length is not set
}),
],
}
diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json
index 3a518544e8..eff3e27589 100644
--- a/web/eslint-suppressions.json
+++ b/web/eslint-suppressions.json
@@ -32,11 +32,6 @@
"count": 2
}
},
- "__tests__/goto-anything/slash-command-modes.test.tsx": {
- "ts/no-explicit-any": {
- "count": 3
- }
- },
"__tests__/i18n-upload-features.test.ts": {
"no-console": {
"count": 3
@@ -5588,11 +5583,6 @@
"count": 2
}
},
- "app/components/share/text-generation/run-batch/csv-reader/index.spec.tsx": {
- "ts/no-explicit-any": {
- "count": 2
- }
- },
"app/components/share/text-generation/run-batch/csv-reader/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
diff --git a/web/vitest.config.ts b/web/vitest.config.ts
index 79486b6b4b..419b662b71 100644
--- a/web/vitest.config.ts
+++ b/web/vitest.config.ts
@@ -4,6 +4,17 @@ import viteConfig from './vite.config'
const isCI = !!process.env.CI
export default mergeConfig(viteConfig, defineConfig({
+ plugins: [
+ {
+ // Stub .mdx files so components importing them can be unit-tested
+ name: 'mdx-stub',
+ enforce: 'pre',
+ transform(_, id) {
+ if (id.endsWith('.mdx'))
+ return { code: 'export default () => null', map: null }
+ },
+ },
+ ],
test: {
environment: 'jsdom',
globals: true,