Compare commits

...

6 Commits

Author SHA1 Message Date
Tyson Cung
7c60ad01d3 fix: add return type annotation to Moderation.validate_config abstract method (#32491)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
2026-02-24 02:11:43 +09:00
Stella Miyako
57890eed25 refactor: fix opentelemetry histogram type assignment error (#32490)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-24 01:32:16 +09:00
木之本澪
737575d637 test: migrate Dataset/Document property tests to testcontainers (#32487)
Co-authored-by: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-24 01:23:48 +09:00
木之本澪
f76ee7cfa4 fix: add return type annotation to BaseVector.create (#32475)
Co-authored-by: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com>
2026-02-23 22:28:40 +09:00
akashseth-ifp
a0244d1390 test(web): add tests for model-provider-page files in header account-… (#32360) 2026-02-23 20:07:19 +08:00
akashseth-ifp
42af9d5438 test(web): add members-page account-setting specs and improve coverage (#32311) 2026-02-23 20:06:35 +08:00
33 changed files with 3314 additions and 177 deletions

View File

@@ -39,7 +39,7 @@ class Moderation(Extensible, ABC):
@classmethod
@abstractmethod
def validate_config(cls, tenant_id: str, config: dict):
def validate_config(cls, tenant_id: str, config: dict) -> None:
"""
Validate the incoming form config data.

View File

@@ -18,8 +18,7 @@ except ImportError:
from importlib_metadata import version # type: ignore[import-not-found]
if TYPE_CHECKING:
from opentelemetry.metrics import Meter
from opentelemetry.metrics._internal.instrument import Histogram
from opentelemetry.metrics import Histogram, Meter
from opentelemetry.sdk.metrics.export import MetricReader
from opentelemetry import trace as trace_api

View File

@@ -15,7 +15,7 @@ class BaseVector(ABC):
raise NotImplementedError
@abstractmethod
def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs):
def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs) -> list[str] | None:
raise NotImplementedError
@abstractmethod

View File

@@ -0,0 +1,271 @@
"""
Integration tests for Dataset and Document model properties using testcontainers.
These tests validate database-backed model properties (total_documents, word_count, etc.)
without mocking SQLAlchemy queries, ensuring real query behavior against PostgreSQL.
"""
from collections.abc import Generator
from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
from models.dataset import Dataset, Document, DocumentSegment
class TestDatasetDocumentProperties:
"""Integration tests for Dataset and Document model properties."""
@pytest.fixture(autouse=True)
def _auto_rollback(self, db_session_with_containers: Session) -> Generator[None, None, None]:
"""Automatically rollback session changes after each test."""
yield
db_session_with_containers.rollback()
def test_dataset_with_documents_relationship(self, db_session_with_containers: Session) -> None:
"""Test dataset can track its documents."""
tenant_id = str(uuid4())
created_by = str(uuid4())
dataset = Dataset(
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
)
db_session_with_containers.add(dataset)
db_session_with_containers.flush()
for i in range(3):
doc = Document(
tenant_id=tenant_id,
dataset_id=dataset.id,
position=i + 1,
data_source_type="upload_file",
batch="batch_001",
name=f"doc_{i}.pdf",
created_from="web",
created_by=created_by,
)
db_session_with_containers.add(doc)
db_session_with_containers.flush()
assert dataset.total_documents == 3
def test_dataset_available_documents_count(self, db_session_with_containers: Session) -> None:
"""Test dataset can count available documents."""
tenant_id = str(uuid4())
created_by = str(uuid4())
dataset = Dataset(
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
)
db_session_with_containers.add(dataset)
db_session_with_containers.flush()
doc_available = Document(
tenant_id=tenant_id,
dataset_id=dataset.id,
position=1,
data_source_type="upload_file",
batch="batch_001",
name="available.pdf",
created_from="web",
created_by=created_by,
indexing_status="completed",
enabled=True,
archived=False,
)
doc_pending = Document(
tenant_id=tenant_id,
dataset_id=dataset.id,
position=2,
data_source_type="upload_file",
batch="batch_001",
name="pending.pdf",
created_from="web",
created_by=created_by,
indexing_status="waiting",
enabled=True,
archived=False,
)
doc_disabled = Document(
tenant_id=tenant_id,
dataset_id=dataset.id,
position=3,
data_source_type="upload_file",
batch="batch_001",
name="disabled.pdf",
created_from="web",
created_by=created_by,
indexing_status="completed",
enabled=False,
archived=False,
)
db_session_with_containers.add_all([doc_available, doc_pending, doc_disabled])
db_session_with_containers.flush()
assert dataset.total_available_documents == 1
def test_dataset_word_count_aggregation(self, db_session_with_containers: Session) -> None:
"""Test dataset can aggregate word count from documents."""
tenant_id = str(uuid4())
created_by = str(uuid4())
dataset = Dataset(
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
)
db_session_with_containers.add(dataset)
db_session_with_containers.flush()
for i, wc in enumerate([2000, 3000]):
doc = Document(
tenant_id=tenant_id,
dataset_id=dataset.id,
position=i + 1,
data_source_type="upload_file",
batch="batch_001",
name=f"doc_{i}.pdf",
created_from="web",
created_by=created_by,
word_count=wc,
)
db_session_with_containers.add(doc)
db_session_with_containers.flush()
assert dataset.word_count == 5000
def test_dataset_available_segment_count(self, db_session_with_containers: Session) -> None:
"""Test Dataset.available_segment_count counts completed and enabled segments."""
tenant_id = str(uuid4())
created_by = str(uuid4())
dataset = Dataset(
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
)
db_session_with_containers.add(dataset)
db_session_with_containers.flush()
doc = Document(
tenant_id=tenant_id,
dataset_id=dataset.id,
position=1,
data_source_type="upload_file",
batch="batch_001",
name="doc.pdf",
created_from="web",
created_by=created_by,
)
db_session_with_containers.add(doc)
db_session_with_containers.flush()
for i in range(2):
seg = DocumentSegment(
tenant_id=tenant_id,
dataset_id=dataset.id,
document_id=doc.id,
position=i + 1,
content=f"segment {i}",
word_count=100,
tokens=50,
status="completed",
enabled=True,
created_by=created_by,
)
db_session_with_containers.add(seg)
seg_waiting = DocumentSegment(
tenant_id=tenant_id,
dataset_id=dataset.id,
document_id=doc.id,
position=3,
content="waiting segment",
word_count=100,
tokens=50,
status="waiting",
enabled=True,
created_by=created_by,
)
db_session_with_containers.add(seg_waiting)
db_session_with_containers.flush()
assert dataset.available_segment_count == 2
def test_document_segment_count_property(self, db_session_with_containers: Session) -> None:
"""Test document can count its segments."""
tenant_id = str(uuid4())
created_by = str(uuid4())
dataset = Dataset(
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
)
db_session_with_containers.add(dataset)
db_session_with_containers.flush()
doc = Document(
tenant_id=tenant_id,
dataset_id=dataset.id,
position=1,
data_source_type="upload_file",
batch="batch_001",
name="doc.pdf",
created_from="web",
created_by=created_by,
)
db_session_with_containers.add(doc)
db_session_with_containers.flush()
for i in range(3):
seg = DocumentSegment(
tenant_id=tenant_id,
dataset_id=dataset.id,
document_id=doc.id,
position=i + 1,
content=f"segment {i}",
word_count=100,
tokens=50,
created_by=created_by,
)
db_session_with_containers.add(seg)
db_session_with_containers.flush()
assert doc.segment_count == 3
def test_document_hit_count_aggregation(self, db_session_with_containers: Session) -> None:
"""Test document can aggregate hit count from segments."""
tenant_id = str(uuid4())
created_by = str(uuid4())
dataset = Dataset(
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
)
db_session_with_containers.add(dataset)
db_session_with_containers.flush()
doc = Document(
tenant_id=tenant_id,
dataset_id=dataset.id,
position=1,
data_source_type="upload_file",
batch="batch_001",
name="doc.pdf",
created_from="web",
created_by=created_by,
)
db_session_with_containers.add(doc)
db_session_with_containers.flush()
for i, hits in enumerate([10, 15]):
seg = DocumentSegment(
tenant_id=tenant_id,
dataset_id=dataset.id,
document_id=doc.id,
position=i + 1,
content=f"segment {i}",
word_count=100,
tokens=50,
hit_count=hits,
created_by=created_by,
)
db_session_with_containers.add(seg)
db_session_with_containers.flush()
assert doc.hit_count == 25

View File

@@ -12,7 +12,7 @@ This test suite covers:
import json
import pickle
from datetime import UTC, datetime
from unittest.mock import MagicMock, patch
from unittest.mock import patch
from uuid import uuid4
from models.dataset import (
@@ -954,156 +954,6 @@ class TestChildChunk:
assert child_chunk.index_node_hash == index_node_hash
class TestDatasetDocumentCascadeDeletes:
"""Test suite for Dataset-Document cascade delete operations."""
def test_dataset_with_documents_relationship(self):
"""Test dataset can track its documents."""
# Arrange
dataset_id = str(uuid4())
dataset = Dataset(
tenant_id=str(uuid4()),
name="Test Dataset",
data_source_type="upload_file",
created_by=str(uuid4()),
)
dataset.id = dataset_id
# Mock the database session query
mock_query = MagicMock()
mock_query.where.return_value.scalar.return_value = 3
with patch("models.dataset.db.session.query", return_value=mock_query):
# Act
total_docs = dataset.total_documents
# Assert
assert total_docs == 3
def test_dataset_available_documents_count(self):
"""Test dataset can count available documents."""
# Arrange
dataset_id = str(uuid4())
dataset = Dataset(
tenant_id=str(uuid4()),
name="Test Dataset",
data_source_type="upload_file",
created_by=str(uuid4()),
)
dataset.id = dataset_id
# Mock the database session query
mock_query = MagicMock()
mock_query.where.return_value.scalar.return_value = 2
with patch("models.dataset.db.session.query", return_value=mock_query):
# Act
available_docs = dataset.total_available_documents
# Assert
assert available_docs == 2
def test_dataset_word_count_aggregation(self):
"""Test dataset can aggregate word count from documents."""
# Arrange
dataset_id = str(uuid4())
dataset = Dataset(
tenant_id=str(uuid4()),
name="Test Dataset",
data_source_type="upload_file",
created_by=str(uuid4()),
)
dataset.id = dataset_id
# Mock the database session query
mock_query = MagicMock()
mock_query.with_entities.return_value.where.return_value.scalar.return_value = 5000
with patch("models.dataset.db.session.query", return_value=mock_query):
# Act
total_words = dataset.word_count
# Assert
assert total_words == 5000
def test_dataset_available_segment_count(self):
"""Test dataset can count available segments."""
# Arrange
dataset_id = str(uuid4())
dataset = Dataset(
tenant_id=str(uuid4()),
name="Test Dataset",
data_source_type="upload_file",
created_by=str(uuid4()),
)
dataset.id = dataset_id
# Mock the database session query
mock_query = MagicMock()
mock_query.where.return_value.scalar.return_value = 15
with patch("models.dataset.db.session.query", return_value=mock_query):
# Act
segment_count = dataset.available_segment_count
# Assert
assert segment_count == 15
def test_document_segment_count_property(self):
"""Test document can count its segments."""
# Arrange
document_id = str(uuid4())
document = Document(
tenant_id=str(uuid4()),
dataset_id=str(uuid4()),
position=1,
data_source_type="upload_file",
batch="batch_001",
name="test.pdf",
created_from="web",
created_by=str(uuid4()),
)
document.id = document_id
# Mock the database session query
mock_query = MagicMock()
mock_query.where.return_value.count.return_value = 10
with patch("models.dataset.db.session.query", return_value=mock_query):
# Act
segment_count = document.segment_count
# Assert
assert segment_count == 10
def test_document_hit_count_aggregation(self):
"""Test document can aggregate hit count from segments."""
# Arrange
document_id = str(uuid4())
document = Document(
tenant_id=str(uuid4()),
dataset_id=str(uuid4()),
position=1,
data_source_type="upload_file",
batch="batch_001",
name="test.pdf",
created_from="web",
created_by=str(uuid4()),
)
document.id = document_id
# Mock the database session query
mock_query = MagicMock()
mock_query.with_entities.return_value.where.return_value.scalar.return_value = 25
with patch("models.dataset.db.session.query", return_value=mock_query):
# Act
hit_count = document.hit_count
# Assert
assert hit_count == 25
class TestDocumentSegmentNavigation:
"""Test suite for DocumentSegment navigation properties."""

View File

@@ -32,10 +32,10 @@ describe('YearAndMonthPicker Options', () => {
it('should render year options', () => {
const props = createOptionsProps()
render(<Options {...props} />)
const { container } = render(<Options {...props} />)
const allItems = screen.getAllByRole('listitem')
expect(allItems).toHaveLength(212)
const yearList = container.querySelectorAll('ul')[1]
expect(yearList?.children).toHaveLength(200)
})
})

View File

@@ -0,0 +1,103 @@
import type { AppContextValue } from '@/context/app-context'
import type { ICurrentWorkspace } from '@/models/common'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast'
import { useAppContext } from '@/context/app-context'
import { updateWorkspaceInfo } from '@/service/common'
import EditWorkspaceModal from './index'
vi.mock('@/context/app-context')
vi.mock('@/service/common')
describe('EditWorkspaceModal', () => {
const mockOnCancel = vi.fn()
const mockNotify = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: true,
} as unknown as AppContextValue)
})
afterEach(() => {
vi.unstubAllGlobals()
})
const renderModal = () => render(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
<EditWorkspaceModal onCancel={mockOnCancel} />
</ToastContext.Provider>,
)
it('should show current workspace name in the input', async () => {
renderModal()
expect(await screen.findByDisplayValue('Test Workspace')).toBeInTheDocument()
})
it('should let user edit workspace name', async () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i)
await user.clear(input)
await user.type(input, 'New Workspace Name')
expect(input).toHaveValue('New Workspace Name')
})
it('should submit update when confirming as owner', async () => {
const user = userEvent.setup()
const mockAssign = vi.fn()
vi.stubGlobal('location', { ...window.location, assign: mockAssign })
vi.mocked(updateWorkspaceInfo).mockResolvedValue({} as ICurrentWorkspace)
renderModal()
const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i)
await user.clear(input)
await user.type(input, 'Renamed Workspace')
await user.click(screen.getByRole('button', { name: /operation\.confirm/i }))
await waitFor(() => {
expect(updateWorkspaceInfo).toHaveBeenCalledWith({
url: '/workspaces/info',
body: { name: 'Renamed Workspace' },
})
expect(mockAssign).toHaveBeenCalled()
})
})
it('should show error toast when update fails', async () => {
const user = userEvent.setup()
vi.mocked(updateWorkspaceInfo).mockRejectedValue(new Error('update failed'))
renderModal()
await user.click(screen.getByRole('button', { name: /operation\.confirm/i }))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
it('should disable confirm button for non-owners', async () => {
vi.mocked(useAppContext).mockReturnValue({
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: false,
} as unknown as AppContextValue)
renderModal()
expect(await screen.findByRole('button', { name: /operation\.confirm/i })).toBeDisabled()
})
})

View File

@@ -0,0 +1,194 @@
import type { AppContextValue } from '@/context/app-context'
import type { ICurrentWorkspace, Member } from '@/models/common'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useMembers } from '@/service/use-common'
import MembersPage from './index'
vi.mock('@/context/app-context')
vi.mock('@/context/global-public-context')
vi.mock('@/context/provider-context')
vi.mock('@/hooks/use-format-time-from-now')
vi.mock('@/service/use-common')
vi.mock('./edit-workspace-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div>
<div>Edit Workspace Modal</div>
<button onClick={onCancel}>Close Edit Workspace</button>
</div>
),
}))
vi.mock('./invite-button', () => ({
default: ({ onClick, disabled }: { onClick: () => void, disabled: boolean }) => (
<button onClick={onClick} disabled={disabled}>Invite</button>
),
}))
vi.mock('./invite-modal', () => ({
default: ({ onCancel, onSend }: { onCancel: () => void, onSend: (results: Array<{ email: string, status: 'success', url: string }>) => void }) => (
<div>
<div>Invite Modal</div>
<button onClick={onCancel}>Close Invite Modal</button>
<button onClick={() => onSend([{ email: 'sent@example.com', status: 'success', url: 'http://invite/link' }])}>Send Invite Results</button>
</div>
),
}))
vi.mock('./invited-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div>
<div>Invited Modal</div>
<button onClick={onCancel}>Close Invited Modal</button>
</div>
),
}))
vi.mock('./operation', () => ({
default: () => <div>Member Operation</div>,
}))
vi.mock('./operation/transfer-ownership', () => ({
default: ({ onOperate }: { onOperate: () => void }) => <button onClick={onOperate}>Transfer ownership</button>,
}))
vi.mock('./transfer-ownership-modal', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div>
<div>Transfer Ownership Modal</div>
<button onClick={onClose}>Close Transfer Modal</button>
</div>
),
}))
describe('MembersPage', () => {
const mockRefetch = vi.fn()
const mockFormatTimeFromNow = vi.fn(() => 'just now')
const mockAccounts: Member[] = [
{
id: '1',
name: 'Owner User',
email: 'owner@example.com',
avatar: '',
avatar_url: '',
role: 'owner',
last_active_at: '1731000000',
last_login_at: '1731000000',
created_at: '1731000000',
status: 'active',
},
{
id: '2',
name: 'Admin User',
email: 'admin@example.com',
avatar: '',
avatar_url: '',
role: 'admin',
last_active_at: '1731000000',
last_login_at: '1731000000',
created_at: '1731000000',
status: 'active',
},
]
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({
userProfile: { email: 'owner@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'owner' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: true,
isCurrentWorkspaceManager: true,
} as unknown as AppContextValue)
vi.mocked(useMembers).mockReturnValue({
data: { accounts: mockAccounts },
refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>)
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
systemFeatures: { is_email_setup: true },
} as unknown as Parameters<typeof selector>[0]))
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: false,
isAllowTransferWorkspace: true,
}))
vi.mocked(useFormatTimeFromNow).mockReturnValue({
formatTimeFromNow: mockFormatTimeFromNow,
})
})
it('should render workspace and member information', () => {
render(<MembersPage />)
expect(screen.getByText('Test Workspace')).toBeInTheDocument()
expect(screen.getByText('Owner User')).toBeInTheDocument()
expect(screen.getByText('Admin User')).toBeInTheDocument()
})
it('should open and close invite modal', async () => {
const user = userEvent.setup()
render(<MembersPage />)
await user.click(screen.getByRole('button', { name: /invite/i }))
expect(screen.getByText('Invite Modal')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Close Invite Modal' }))
expect(screen.queryByText('Invite Modal')).not.toBeInTheDocument()
})
it('should open invited modal after invite results are sent', async () => {
const user = userEvent.setup()
render(<MembersPage />)
await user.click(screen.getByRole('button', { name: /invite/i }))
await user.click(screen.getByRole('button', { name: 'Send Invite Results' }))
expect(screen.getByText('Invited Modal')).toBeInTheDocument()
expect(mockRefetch).toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Close Invited Modal' }))
expect(screen.queryByText('Invited Modal')).not.toBeInTheDocument()
})
it('should open transfer ownership modal when transfer action is used', async () => {
const user = userEvent.setup()
render(<MembersPage />)
await user.click(screen.getByRole('button', { name: /transfer ownership/i }))
expect(screen.getByText('Transfer Ownership Modal')).toBeInTheDocument()
})
it('should show non-interactive owner role when transfer ownership is not allowed', () => {
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: false,
isAllowTransferWorkspace: false,
}))
render(<MembersPage />)
expect(screen.getByText('common.members.owner')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument()
})
it('should hide manager controls for non-owner non-manager users', () => {
vi.mocked(useAppContext).mockReturnValue({
userProfile: { email: 'admin@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceManager: false,
} as unknown as AppContextValue)
render(<MembersPage />)
expect(screen.queryByRole('button', { name: /invite/i })).not.toBeInTheDocument()
expect(screen.queryByText('Transfer ownership')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,71 @@
import type { AppContextValue } from '@/context/app-context'
import type { ICurrentWorkspace } from '@/models/common'
import { render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWorkspacePermissions } from '@/service/use-workspace'
import InviteButton from './invite-button'
vi.mock('@/context/app-context')
vi.mock('@/context/global-public-context')
vi.mock('@/service/use-workspace')
describe('InviteButton', () => {
const setupMocks = ({
brandingEnabled,
isFetching,
allowInvite,
}: {
brandingEnabled: boolean
isFetching: boolean
allowInvite?: boolean
}) => {
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
systemFeatures: { branding: { enabled: brandingEnabled } },
} as unknown as Parameters<typeof selector>[0]))
vi.mocked(useWorkspacePermissions).mockReturnValue({
data: allowInvite === undefined ? null : { allow_member_invite: allowInvite },
isFetching,
} as unknown as ReturnType<typeof useWorkspacePermissions>)
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({
currentWorkspace: { id: 'workspace-id' } as ICurrentWorkspace,
} as unknown as AppContextValue)
})
it('should show invite button when branding is disabled', () => {
setupMocks({ brandingEnabled: false, isFetching: false })
render(<InviteButton />)
expect(screen.getByRole('button', { name: /members\.invite/i })).toBeInTheDocument()
})
it('should show loading status while permissions are loading', () => {
setupMocks({ brandingEnabled: true, isFetching: true })
render(<InviteButton />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should hide invite button when permission is denied', () => {
setupMocks({ brandingEnabled: true, isFetching: false, allowInvite: false })
render(<InviteButton />)
expect(screen.queryByRole('button', { name: /members\.invite/i })).not.toBeInTheDocument()
})
it('should show invite button when permission is granted', () => {
setupMocks({ brandingEnabled: true, isFetching: false, allowInvite: true })
render(<InviteButton />)
expect(screen.getByRole('button', { name: /members\.invite/i })).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,118 @@
import type { InvitationResponse } from '@/models/common'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast'
import { useProviderContextSelector } from '@/context/provider-context'
import { inviteMember } from '@/service/common'
import InviteModal from './index'
vi.mock('@/context/provider-context', () => ({
useProviderContextSelector: vi.fn(),
useProviderContext: vi.fn(() => ({
datasetOperatorEnabled: true,
})),
}))
vi.mock('@/service/common')
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
describe('InviteModal', () => {
const mockOnCancel = vi.fn()
const mockOnSend = vi.fn()
const mockRefreshLicenseLimit = vi.fn()
const mockNotify = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 5, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
})
const renderModal = (isEmailSetup = true) => render(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
<InviteModal isEmailSetup={isEmailSetup} onCancel={mockOnCancel} onSend={mockOnSend} />
</ToastContext.Provider>,
)
it('should render invite modal content', async () => {
renderModal()
expect(await screen.findByText(/members\.inviteTeamMember$/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled()
})
it('should show warning when email service is not configured', async () => {
renderModal(false)
expect(await screen.findByText(/members\.emailNotSetup$/i)).toBeInTheDocument()
})
it('should enable send button after entering an email', async () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByRole('textbox')
await user.type(input, 'user@example.com{enter}')
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeEnabled()
})
it('should not close modal when invite request fails', async () => {
const user = userEvent.setup()
vi.mocked(inviteMember).mockRejectedValue(new Error('request failed'))
renderModal()
await user.type(screen.getByRole('textbox'), 'user@example.com{enter}')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
expect(inviteMember).toHaveBeenCalled()
expect(mockOnCancel).not.toHaveBeenCalled()
expect(mockOnSend).not.toHaveBeenCalled()
})
})
it('should send invites and close modal on successful submission', async () => {
const user = userEvent.setup()
vi.mocked(inviteMember).mockResolvedValue({
result: 'success',
invitation_results: [],
} as InvitationResponse)
renderModal()
const input = screen.getByRole('textbox')
await user.type(input, 'user@example.com{enter}')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
expect(inviteMember).toHaveBeenCalled()
expect(mockRefreshLicenseLimit).toHaveBeenCalled()
expect(mockOnCancel).toHaveBeenCalled()
expect(mockOnSend).toHaveBeenCalled()
})
})
it('should keep send button disabled when license limit is exceeded', async () => {
const user = userEvent.setup()
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
renderModal()
const input = screen.getByRole('textbox')
await user.type(input, 'user@example.com{enter}')
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled()
})
})

View File

@@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { vi } from 'vitest'
import { useProviderContext } from '@/context/provider-context'
import RoleSelector from './role-selector'
vi.mock('@/context/provider-context')
type WrapperProps = {
initialRole?: 'normal' | 'editor' | 'admin' | 'dataset_operator'
}
const RoleSelectorWrapper = ({ initialRole = 'normal' }: WrapperProps) => {
const [role, setRole] = useState<'normal' | 'editor' | 'admin' | 'dataset_operator'>(initialRole)
return <RoleSelector value={role} onChange={setRole} />
}
describe('RoleSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useProviderContext).mockReturnValue({
datasetOperatorEnabled: true,
} as unknown as ReturnType<typeof useProviderContext>)
})
it('should show current role in trigger text', () => {
render(<RoleSelectorWrapper initialRole="admin" />)
expect(screen.getByText(/members\.invitedAsRole/i)).toBeInTheDocument()
})
it.each([
'common.members.admin',
'common.members.editor',
'common.members.datasetOperator',
])('should update selected role after user chooses %s', async (nextRoleLabel) => {
const user = userEvent.setup()
render(<RoleSelectorWrapper initialRole="normal" />)
await user.click(screen.getByText(/members\.invitedAsRole/i))
await user.click(screen.getByText(nextRoleLabel))
expect(screen.getByText(new RegExp(nextRoleLabel.replace('.', '\\.'), 'i'))).toBeInTheDocument()
})
it('should hide dataset operator option when feature is disabled', async () => {
const user = userEvent.setup()
vi.mocked(useProviderContext).mockReturnValue({
datasetOperatorEnabled: false,
} as unknown as ReturnType<typeof useProviderContext>)
render(<RoleSelectorWrapper />)
await user.click(screen.getByText(/members\.invitedAsRole/i))
expect(screen.queryByText('common.members.datasetOperator')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,24 @@
import type { InvitationResult } from '@/models/common'
import { render, screen } from '@testing-library/react'
import InvitedModal from './index'
vi.mock('@/config', () => ({
IS_CE_EDITION: true,
}))
describe('InvitedModal', () => {
const mockOnCancel = vi.fn()
const results: InvitationResult[] = [
{ email: 'success@example.com', status: 'success', url: 'http://invite.com/1' },
{ email: 'failed@example.com', status: 'failed', message: 'Error msg' },
]
it('should show success and failed invitation sections', async () => {
render(<InvitedModal invitationResults={results} onCancel={mockOnCancel} />)
expect(await screen.findByText(/members\.invitationSent$/i)).toBeInTheDocument()
expect(await screen.findByText(/members\.invitationLink/i)).toBeInTheDocument()
expect(screen.getByText('http://invite.com/1')).toBeInTheDocument()
expect(screen.getByText('failed@example.com')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,30 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import InvitationLink from './invitation-link'
describe('InvitationLink', () => {
const value = { email: 'test@example.com', status: 'success' as const, url: '/invite/123' }
it('should render invitation url and keep it visible after click', async () => {
const user = userEvent.setup()
render(<InvitationLink value={value} />)
const url = screen.getByText('/invite/123')
await user.click(url)
expect(url).toBeInTheDocument()
})
it('should keep link visible after copy feedback timeout passes', async () => {
const user = userEvent.setup()
render(<InvitationLink value={value} />)
await user.click(screen.getByText('/invite/123'))
await waitFor(() => {
expect(screen.getByText('/invite/123')).toBeInTheDocument()
}, { timeout: 1500 })
})
})

View File

@@ -1,5 +1,6 @@
import type { Member } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast'
import Operation from './index'
@@ -55,20 +56,45 @@ describe('Operation', () => {
})
it('shows dataset operator option when the feature flag is enabled', async () => {
const user = userEvent.setup()
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true })
renderOperation()
fireEvent.click(screen.getByText('common.members.editor'))
await user.click(screen.getByText('common.members.editor'))
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
})
it('shows owner-allowed role options for admin operators', async () => {
const user = userEvent.setup()
renderOperation({}, 'admin')
await user.click(screen.getByText('common.members.editor'))
expect(screen.queryByText('common.members.admin')).not.toBeInTheDocument()
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
})
it('does not show role options for unsupported operators', async () => {
const user = userEvent.setup()
renderOperation({}, 'normal')
await user.click(screen.getByText('common.members.editor'))
expect(screen.queryByText('common.members.normal')).not.toBeInTheDocument()
expect(screen.getByText('common.members.removeFromTeam')).toBeInTheDocument()
})
it('calls updateMemberRole and onOperate when selecting another role', async () => {
const user = userEvent.setup()
const onOperate = vi.fn()
renderOperation({}, 'owner', onOperate)
fireEvent.click(screen.getByText('common.members.editor'))
fireEvent.click(await screen.findByText('common.members.normal'))
await user.click(screen.getByText('common.members.editor'))
await user.click(await screen.findByText('common.members.normal'))
await waitFor(() => {
expect(mockUpdateMemberRole).toHaveBeenCalled()
@@ -77,11 +103,12 @@ describe('Operation', () => {
})
it('calls deleteMemberOrCancelInvitation when removing the member', async () => {
const user = userEvent.setup()
const onOperate = vi.fn()
renderOperation({}, 'owner', onOperate)
fireEvent.click(screen.getByText('common.members.editor'))
fireEvent.click(await screen.findByText('common.members.removeFromTeam'))
await user.click(screen.getByText('common.members.editor'))
await user.click(await screen.findByText('common.members.removeFromTeam'))
await waitFor(() => {
expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalled()

View File

@@ -0,0 +1,89 @@
import type { AppContextValue } from '@/context/app-context'
import type { ICurrentWorkspace } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWorkspacePermissions } from '@/service/use-workspace'
import TransferOwnership from './transfer-ownership'
vi.mock('@/context/app-context')
vi.mock('@/context/global-public-context')
vi.mock('@/service/use-workspace')
describe('TransferOwnership', () => {
const setupMocks = ({
brandingEnabled,
isFetching,
allowOwnerTransfer,
}: {
brandingEnabled: boolean
isFetching: boolean
allowOwnerTransfer?: boolean
}) => {
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
systemFeatures: { branding: { enabled: brandingEnabled } },
} as unknown as Parameters<typeof selector>[0]))
vi.mocked(useWorkspacePermissions).mockReturnValue({
data: allowOwnerTransfer === undefined ? null : { allow_owner_transfer: allowOwnerTransfer },
isFetching,
} as unknown as ReturnType<typeof useWorkspacePermissions>)
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({
currentWorkspace: { id: 'workspace-id' } as ICurrentWorkspace,
} as unknown as AppContextValue)
})
it('should show loading status while permissions are loading', () => {
setupMocks({ brandingEnabled: true, isFetching: true })
render(<TransferOwnership onOperate={vi.fn()} />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should show owner text without transfer menu when transfer is forbidden', () => {
setupMocks({ brandingEnabled: true, isFetching: false, allowOwnerTransfer: false })
render(<TransferOwnership onOperate={vi.fn()} />)
expect(screen.getByText(/members\.owner/i)).toBeInTheDocument()
expect(screen.queryByText(/members\.transferOwnership/i)).toBeNull()
})
it('should open transfer dialog when transfer option is selected', async () => {
const user = userEvent.setup()
const onOperate = vi.fn()
setupMocks({ brandingEnabled: true, isFetching: false, allowOwnerTransfer: true })
render(<TransferOwnership onOperate={onOperate} />)
await user.click(screen.getByRole('button', { name: /members\.owner/i }))
const transferOptionText = await screen.findByText(/members\.transferOwnership/i)
const transferOption = transferOptionText.closest('div.cursor-pointer')
if (!transferOption)
throw new Error('Transfer option container not found')
fireEvent.click(transferOption)
await waitFor(() => {
expect(onOperate).toHaveBeenCalledTimes(1)
})
})
it('should allow transfer menu when branding is disabled', async () => {
const user = userEvent.setup()
setupMocks({ brandingEnabled: false, isFetching: false })
render(<TransferOwnership onOperate={vi.fn()} />)
await user.click(screen.getByRole('button', { name: /members\.owner/i }))
expect(screen.getByText(/members\.transferOwnership/i)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,149 @@
import type { AppContextValue } from '@/context/app-context'
import type { ICurrentWorkspace } from '@/models/common'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast'
import { useAppContext } from '@/context/app-context'
import { ownershipTransfer, sendOwnerEmail, verifyOwnerEmail } from '@/service/common'
import TransferOwnershipModal from './index'
vi.mock('@/context/app-context')
vi.mock('@/service/common')
vi.mock('./member-selector', () => ({
default: ({ onSelect }: { onSelect: (id: string) => void }) => (
<button onClick={() => onSelect('new-owner-id')}>Select member</button>
),
}))
describe('TransferOwnershipModal', () => {
const mockOnClose = vi.fn()
const mockNotify = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(globalThis, 'setInterval').mockImplementation(() => 0 as unknown as ReturnType<typeof setInterval>)
vi.spyOn(globalThis, 'clearInterval').mockImplementation(() => {})
vi.mocked(useAppContext).mockReturnValue({
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,
userProfile: { email: 'owner@example.com', id: 'owner-id' },
} as unknown as AppContextValue)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
const renderModal = () => render(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
<TransferOwnershipModal show onClose={mockOnClose} />
</ToastContext.Provider>,
)
const mockEmailVerification = ({
isValid = true,
token = 'final-token',
}: {
isValid?: boolean
token?: string
} = {}) => {
vi.mocked(sendOwnerEmail).mockResolvedValue({
data: 'step-token',
result: 'success',
} as Awaited<ReturnType<typeof sendOwnerEmail>>)
vi.mocked(verifyOwnerEmail).mockResolvedValue({
is_valid: isValid,
token,
result: 'success',
} as Awaited<ReturnType<typeof verifyOwnerEmail>>)
}
const goToTransferStep = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i }))
await user.type(screen.getByPlaceholderText(/members\.transferModal\.codePlaceholder/i), '123456')
await user.click(screen.getByRole('button', { name: /members\.transferModal\.continue/i }))
}
const selectNewOwnerAndSubmit = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole('button', { name: /select member/i }))
await user.click(screen.getByRole('button', { name: /members\.transferModal\.transfer$/i }))
}
it('should complete ownership transfer flow through all steps', async () => {
const user = userEvent.setup()
mockEmailVerification()
vi.mocked(ownershipTransfer).mockResolvedValue({
result: 'success',
} as Awaited<ReturnType<typeof ownershipTransfer>>)
const mockReload = vi.fn()
vi.stubGlobal('location', { ...window.location, reload: mockReload })
renderModal()
await goToTransferStep(user)
expect(await screen.findByText(/members\.transferModal\.transferLabel/i)).toBeInTheDocument()
await selectNewOwnerAndSubmit(user)
await waitFor(() => {
expect(ownershipTransfer).toHaveBeenCalledWith('new-owner-id', { token: 'final-token' })
expect(mockReload).toHaveBeenCalled()
})
}, 15000)
it('should show error when email verification returns invalid code', async () => {
const user = userEvent.setup()
mockEmailVerification({ isValid: false, token: 'step-token' })
renderModal()
await goToTransferStep(user)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
it('should show error when sending verification email fails', async () => {
const user = userEvent.setup()
vi.mocked(sendOwnerEmail).mockRejectedValue(new Error('network error'))
renderModal()
await user.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i }))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
it('should show error when ownership transfer fails', async () => {
const user = userEvent.setup()
mockEmailVerification()
vi.mocked(ownershipTransfer).mockRejectedValue(new Error('transfer failed'))
renderModal()
await goToTransferStep(user)
await selectNewOwnerAndSubmit(user)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
})

View File

@@ -0,0 +1,107 @@
import type { Member } from '@/models/common'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { vi } from 'vitest'
import { useMembers } from '@/service/use-common'
import MemberSelector from './member-selector'
vi.mock('@/service/use-common')
const MemberSelectorHarness = ({ initialValue = '', exclude = [] as string[] }: { initialValue?: string, exclude?: string[] }) => {
const [selected, setSelected] = useState<string>(initialValue)
return (
<>
<MemberSelector value={selected} onSelect={setSelected} exclude={exclude} />
{selected && (
<div>
Selected:
{' '}
{selected}
</div>
)}
</>
)
}
describe('MemberSelector', () => {
const mockMembers = [
{ id: '1', name: 'User 1', email: 'user1@example.com', role: 'admin' },
{ id: '2', name: 'User 2', email: 'user2@example.com', role: 'normal' },
] as Member[]
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useMembers).mockReturnValue({
data: { accounts: mockMembers },
} as unknown as ReturnType<typeof useMembers>)
})
it('should show member options when selector is opened', async () => {
const user = userEvent.setup()
render(<MemberSelectorHarness />)
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
expect(screen.getByPlaceholderText(/common\.operation\.search/i)).toBeInTheDocument()
expect(screen.getByText('User 1')).toBeInTheDocument()
expect(screen.getByText('User 2')).toBeInTheDocument()
})
it('should filter displayed members by search term', async () => {
const user = userEvent.setup()
render(<MemberSelectorHarness />)
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
await user.type(screen.getByPlaceholderText(/common\.operation\.search/i), 'User 2')
expect(screen.queryByText('User 1')).not.toBeInTheDocument()
expect(screen.getByText('User 2')).toBeInTheDocument()
})
it('should show selected member after clicking an option', async () => {
const user = userEvent.setup()
render(<MemberSelectorHarness />)
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
await user.click(screen.getByText('User 1'))
expect(screen.getByText('Selected: 1')).toBeInTheDocument()
})
it('should show selected value details when an initial value is provided', () => {
render(<MemberSelectorHarness initialValue="2" />)
expect(screen.getByText('User 2')).toBeInTheDocument()
expect(screen.getByText('user2@example.com')).toBeInTheDocument()
})
it('should hide excluded members from options', async () => {
const user = userEvent.setup()
render(<MemberSelectorHarness exclude={['1']} />)
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
expect(screen.queryByText('User 1')).not.toBeInTheDocument()
expect(screen.getByText('User 2')).toBeInTheDocument()
})
it('should render empty options when member data is unavailable', async () => {
const user = userEvent.setup()
vi.mocked(useMembers).mockReturnValue({
data: undefined,
} as unknown as ReturnType<typeof useMembers>)
render(<MemberSelectorHarness />)
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
expect(screen.queryByText('User 1')).not.toBeInTheDocument()
expect(screen.queryByText('User 2')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react'
import ModelBadge from './index'
describe('ModelBadge', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering behavior for user-visible content.
describe('Rendering', () => {
it('should render provided text', () => {
render(<ModelBadge>Provider</ModelBadge>)
expect(screen.getByText(/provider/i)).toBeInTheDocument()
})
it('should render without text when children is null', () => {
const { container } = render(<ModelBadge>{null}</ModelBadge>)
expect(container.textContent).toBe('')
})
it('should render nested content', () => {
render(
<ModelBadge>
<span>Badge Label</span>
</ModelBadge>,
)
expect(screen.getByText(/badge label/i)).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,108 @@
import type { Model } from '../declarations'
import { render, screen } from '@testing-library/react'
import { Theme } from '@/types/app'
import {
ConfigurationMethodEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '../declarations'
import ModelIcon from './index'
type I18nText = {
en_US: string
zh_Hans: string
}
let mockTheme: Theme = Theme.light
let mockLanguage = 'en_US'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: mockTheme }),
}))
vi.mock('../hooks', () => ({
useLanguage: () => mockLanguage,
}))
vi.mock('@/app/components/base/icons/src/public/llm', () => ({
OpenaiYellow: () => <svg data-testid="openai-yellow-icon" />,
}))
const createI18nText = (value: string): I18nText => ({
en_US: value,
zh_Hans: value,
})
const createModel = (overrides?: Partial<Model>): Model => ({
provider: 'test-provider',
icon_small: createI18nText('light.png'),
icon_small_dark: createI18nText('dark.png'),
label: createI18nText('Test Provider'),
models: [
{
model: 'test-model',
label: createI18nText('Test Model'),
model_type: ModelTypeEnum.textGeneration,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
},
],
status: ModelStatusEnum.active,
...overrides,
})
describe('ModelIcon', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTheme = Theme.light
mockLanguage = 'en_US'
})
// Rendering
it('should render the light icon when icon_small is provided', () => {
const provider = createModel({
icon_small: createI18nText('light-only.png'),
icon_small_dark: undefined,
})
render(<ModelIcon provider={provider} />)
expect(screen.getByRole('img', { name: /model-icon/i })).toHaveAttribute('src', 'light-only.png')
})
// Theme selection
it('should render the dark icon when theme is dark and icon_small_dark exists', () => {
mockTheme = Theme.dark
const provider = createModel({
icon_small: createI18nText('light.png'),
icon_small_dark: createI18nText('dark.png'),
})
render(<ModelIcon provider={provider} />)
expect(screen.getByRole('img', { name: /model-icon/i })).toHaveAttribute('src', 'dark.png')
})
// Provider override
it('should ignore icon_small for OpenAI models starting with "o"', () => {
const provider = createModel({
provider: 'openai',
icon_small: createI18nText('openai.png'),
})
render(<ModelIcon provider={provider} modelName="o1" />)
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
expect(screen.getByTestId('openai-yellow-icon')).toBeInTheDocument()
})
// Edge case
it('should render without an icon when provider is undefined', () => {
const { container } = render(<ModelIcon />)
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
expect(container.firstChild).not.toBeNull()
})
})

View File

@@ -0,0 +1,447 @@
import type {
CredentialFormSchema,
CredentialFormSchemaBase,
CredentialFormSchemaNumberInput,
CredentialFormSchemaRadio,
CredentialFormSchemaSelect,
CredentialFormSchemaTextInput,
FormValue,
} from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { FormTypeEnum } from '../declarations'
import Form from './Form'
type CustomSchema = Omit<CredentialFormSchemaBase, 'type'> & { type: 'custom-type' }
type MockVarPayload = { type: string }
type AnyFormSchema = CredentialFormSchema | (CredentialFormSchemaBase & { type: FormTypeEnum })
vi.mock('../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({
default: ({ onSelect }: { onSelect: (item: { id: string }) => void }) => (
<button type="button" onClick={() => onSelect({ id: 'app-1' })}>Select App</button>
),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({
default: ({ setModel }: { setModel: (model: { model: string, model_type: string }) => void }) => (
<button type="button" onClick={() => setModel({ model: 'gpt-1', model_type: 'llm' })}>Select Model</button>
),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/multiple-tool-selector', () => ({
default: ({ onChange }: { onChange: (items: Array<{ id: string }>) => void }) => (
<button type="button" onClick={() => onChange([{ id: 'tool-1' }])}>Select Tools</button>
),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({
default: ({ onSelect, onDelete }: { onSelect: (item: { id: string }) => void, onDelete: () => void }) => (
<div>
<button type="button" onClick={() => onSelect({ id: 'tool-1' })}>Select Tool</button>
<button type="button" onClick={onDelete}>Remove Tool</button>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ filterVar, onChange }: { filterVar?: (payload: MockVarPayload) => boolean, onChange: (items: Array<{ name: string }>) => void }) => {
const allowed = filterVar ? filterVar({ type: 'text' }) : true
const blocked = filterVar ? filterVar({ type: 'image' }) : false
return (
<div>
<div>{allowed ? 'allowed' : 'blocked'}</div>
<div>{blocked ? 'allowed' : 'blocked'}</div>
<button type="button" onClick={() => onChange([{ name: 'var-1' }])}>Pick Variable</button>
</div>
)
},
}))
vi.mock('../../key-validator/ValidateStatus', () => ({
ValidatingTip: () => <div>Validating...</div>,
}))
const createI18n = (text: string) => ({ en_US: text, zh_Hans: text })
const createBaseSchema = (
type: FormTypeEnum,
overrides: Partial<CredentialFormSchemaBase> = {},
): CredentialFormSchemaBase => ({
name: overrides.variable ?? 'field',
variable: overrides.variable ?? 'field',
label: createI18n('Field'),
type,
required: false,
show_on: [],
...overrides,
})
const createTextSchema = (overrides: Partial<CredentialFormSchemaTextInput> & { type?: FormTypeEnum }) => ({
...createBaseSchema(overrides.type ?? FormTypeEnum.textInput, { variable: overrides.variable ?? 'text' }),
placeholder: createI18n('Input'),
...overrides,
})
const createNumberSchema = (overrides: Partial<CredentialFormSchemaNumberInput>) => ({
...createBaseSchema(FormTypeEnum.textNumber, { variable: overrides.variable ?? 'number' }),
placeholder: createI18n('Number'),
min: 1,
max: 9,
...overrides,
})
const createRadioSchema = (overrides: Partial<CredentialFormSchemaRadio>) => ({
...createBaseSchema(FormTypeEnum.radio, { variable: overrides.variable ?? 'radio' }),
options: [
{ label: createI18n('Option A'), value: 'a', show_on: [] },
{ label: createI18n('Option B'), value: 'b', show_on: [] },
],
...overrides,
})
const createSelectSchema = (overrides: Partial<CredentialFormSchemaSelect>) => ({
...createBaseSchema(FormTypeEnum.select, { variable: overrides.variable ?? 'select' }),
placeholder: createI18n('Select one'),
options: [
{ label: createI18n('Select A'), value: 'a', show_on: [] },
{ label: createI18n('Select B'), value: 'b', show_on: [] },
],
...overrides,
})
describe('Form', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering basics
describe('Rendering', () => {
it('should render visible fields and apply default values', () => {
const formSchemas: AnyFormSchema[] = [
createTextSchema({
variable: 'api_key',
label: createI18n('API Key'),
placeholder: createI18n('API Key'),
required: true,
default: 'default-key',
}),
createTextSchema({
variable: 'secret',
type: FormTypeEnum.secretInput,
label: createI18n('Secret'),
placeholder: createI18n('Secret'),
}),
createNumberSchema({
variable: 'limit',
label: createI18n('Limit'),
placeholder: createI18n('Limit'),
default: '5',
}),
createTextSchema({
variable: 'hidden',
label: createI18n('Hidden'),
show_on: [{ variable: 'toggle', value: 'on' }],
}),
]
const value: FormValue = {
api_key: '',
secret: 'top-secret',
limit: '',
toggle: 'off',
}
render(
<Form
value={value}
onChange={vi.fn()}
formSchemas={formSchemas}
validating={false}
validatedSuccess={false}
showOnVariableMap={{}}
isEditMode={false}
isShowDefaultValue
/>,
)
expect(screen.getByPlaceholderText('API Key')).toHaveValue('default-key')
expect(screen.getByPlaceholderText('Secret')).toHaveValue('top-secret')
expect(screen.getByPlaceholderText('Limit')).toHaveValue(5)
expect(screen.queryByText('Hidden')).not.toBeInTheDocument()
expect(screen.getAllByText('*')).toHaveLength(1)
})
})
// Interaction updates
describe('Interactions', () => {
it('should update values and clear dependent fields when a field changes', () => {
const formSchemas: AnyFormSchema[] = [
createTextSchema({
variable: 'api_key',
label: createI18n('API Key'),
placeholder: createI18n('API Key'),
}),
createTextSchema({
variable: 'dependent',
label: createI18n('Dependent'),
default: 'reset',
}),
]
const value: FormValue = { api_key: 'old', dependent: 'keep' }
const onChange = vi.fn()
render(
<Form
value={value}
onChange={onChange}
formSchemas={formSchemas}
validating
validatedSuccess={false}
showOnVariableMap={{ api_key: ['dependent'] }}
isEditMode={false}
/>,
)
fireEvent.change(screen.getByPlaceholderText('API Key'), { target: { value: 'new-key' } })
expect(onChange).toHaveBeenCalledWith({ api_key: 'new-key', dependent: 'reset' })
expect(screen.getByText('Validating...')).toBeInTheDocument()
})
it('should render radio options based on show conditions and ignore edit-locked changes', () => {
const formSchemas: AnyFormSchema[] = [
createRadioSchema({
variable: 'region',
label: createI18n('Region'),
options: [
{ label: createI18n('US'), value: 'us', show_on: [] },
{ label: createI18n('EU'), value: 'eu', show_on: [{ variable: 'toggle', value: 'on' }] },
],
}),
createRadioSchema({
variable: 'hidden_region',
label: createI18n('Hidden Region'),
show_on: [{ variable: 'toggle', value: 'hidden' }],
options: [
{ label: createI18n('Hidden A'), value: 'a', show_on: [] },
],
}),
createRadioSchema({
variable: '__model_name',
label: createI18n('Locked'),
options: [
{ label: createI18n('Locked A'), value: 'a', show_on: [] },
],
}),
]
const value: FormValue = { region: 'us', toggle: 'on', __model_name: 'a' }
const onChange = vi.fn()
render(
<Form
value={value}
onChange={onChange}
formSchemas={formSchemas}
validating={false}
validatedSuccess={false}
showOnVariableMap={{}}
isEditMode
/>,
)
expect(screen.getByText('EU')).toBeInTheDocument()
expect(screen.queryByText('Hidden Region')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('EU'))
fireEvent.click(screen.getByText('Locked A'))
expect(onChange).toHaveBeenCalledWith({ region: 'eu', toggle: 'on', __model_name: 'a' })
expect(onChange).toHaveBeenCalledTimes(1)
})
it('should render select and checkbox fields and update checkbox value', () => {
const formSchemas: AnyFormSchema[] = [
createSelectSchema({
variable: 'model',
label: createI18n('Model'),
placeholder: createI18n('Pick model'),
show_on: [{ variable: 'toggle', value: 'on' }],
options: [
{ label: createI18n('Select A'), value: 'a', show_on: [] },
{ label: createI18n('Select B'), value: 'b', show_on: [{ variable: 'toggle', value: 'on' }] },
],
}),
createRadioSchema({
variable: 'agree',
type: FormTypeEnum.checkbox,
label: createI18n('Agree'),
options: [],
show_on: [{ variable: 'toggle', value: 'on' }],
}),
]
const value: FormValue = { model: 'a', agree: false, toggle: 'off' }
const onChange = vi.fn()
const { rerender } = render(
<Form
value={value}
onChange={onChange}
formSchemas={formSchemas}
validating={false}
validatedSuccess={false}
showOnVariableMap={{}}
isEditMode={false}
/>,
)
expect(screen.queryByText('Pick model')).not.toBeInTheDocument()
expect(screen.queryByText('Agree')).not.toBeInTheDocument()
rerender(
<Form
value={{ model: 'a', agree: false, toggle: 'on' }}
onChange={onChange}
formSchemas={formSchemas}
validating={false}
validatedSuccess={false}
showOnVariableMap={{}}
isEditMode={false}
/>,
)
expect(screen.getByText('Select A')).toBeInTheDocument()
fireEvent.click(screen.getByText('Select A'))
fireEvent.click(screen.getByText('Select B'))
fireEvent.click(screen.getByText('True'))
expect(onChange).toHaveBeenCalledWith({ model: 'b', agree: false, toggle: 'on' })
expect(onChange).toHaveBeenCalledWith({ model: 'a', agree: true, toggle: 'on' })
})
it('should pass selected items from model and tool selectors to the form value', () => {
const formSchemas: AnyFormSchema[] = [
createTextSchema({
variable: 'model_selector',
type: FormTypeEnum.modelSelector,
label: createI18n('Model Selector'),
}),
createTextSchema({
variable: 'tool_selector',
type: FormTypeEnum.toolSelector,
label: createI18n('Tool Selector'),
}),
createTextSchema({
variable: 'multi_tool',
type: FormTypeEnum.multiToolSelector,
label: createI18n('Multi Tool'),
tooltip: createI18n('Tips'),
}),
createTextSchema({
variable: 'app_selector',
type: FormTypeEnum.appSelector,
label: createI18n('App Selector'),
}),
]
const value: FormValue = { model_selector: {}, tool_selector: null, multi_tool: [], app_selector: null }
const onChange = vi.fn()
render(
<Form
value={value}
onChange={onChange}
formSchemas={formSchemas}
validating={false}
validatedSuccess={false}
showOnVariableMap={{}}
isEditMode={false}
/>,
)
fireEvent.click(screen.getByText('Select Model'))
fireEvent.click(screen.getByText('Select Tool'))
fireEvent.click(screen.getByText('Remove Tool'))
fireEvent.click(screen.getByText('Select Tools'))
fireEvent.click(screen.getByText('Select App'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
model_selector: { model: 'gpt-1', model_type: 'llm', type: FormTypeEnum.modelSelector },
}))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
tool_selector: { id: 'tool-1' },
}))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
tool_selector: null,
}))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
multi_tool: [{ id: 'tool-1' }],
}))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
app_selector: { id: 'app-1', type: FormTypeEnum.appSelector },
}))
})
it('should render variable picker and custom render overrides', () => {
const formSchemas: Array<AnyFormSchema | CustomSchema> = [
createTextSchema({
variable: 'override',
label: createI18n('Override'),
type: FormTypeEnum.textInput,
}),
createTextSchema({
variable: 'any_var',
type: FormTypeEnum.any,
label: createI18n('Any Var'),
scope: 'text&audio',
}),
createTextSchema({
variable: 'any_without_scope',
type: FormTypeEnum.any,
label: createI18n('Any Without Scope'),
}),
{
...createTextSchema({
variable: 'custom_field',
label: createI18n('Custom Field'),
}),
type: 'custom-type',
},
]
const value: FormValue = { override: '', any_var: [], any_without_scope: [], custom_field: '' }
const onChange = vi.fn()
render(
<Form<CustomSchema>
value={value}
onChange={onChange}
formSchemas={formSchemas}
validating={false}
validatedSuccess={false}
showOnVariableMap={{}}
isEditMode={false}
fieldMoreInfo={() => <div>Extra Info</div>}
override={[[FormTypeEnum.textInput], () => <div>Override Field</div>]}
customRenderField={schema => (
<div>
Custom Render:
{schema.variable}
</div>
)}
/>,
)
expect(screen.getByText('Override Field')).toBeInTheDocument()
expect(screen.getByText(/Custom Render:.*custom_field/)).toBeInTheDocument()
expect(screen.getAllByText('allowed')).toHaveLength(3)
expect(screen.getAllByText('blocked')).toHaveLength(1)
fireEvent.click(screen.getAllByText('Pick Variable')[0])
expect(onChange).toHaveBeenCalledWith({ override: '', any_var: [{ name: 'var-1' }], any_without_scope: [], custom_field: '' })
expect(screen.getAllByText('Extra Info')).toHaveLength(2)
})
})
})

View File

@@ -0,0 +1,96 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Input from './Input'
describe('Input', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering basics
it('should render with the provided placeholder and value', () => {
render(
<Input
value="hello"
placeholder="API Key"
onChange={vi.fn()}
/>,
)
expect(screen.getByPlaceholderText('API Key')).toHaveValue('hello')
})
// User interaction
it('should call onChange when the user types', () => {
const onChange = vi.fn()
render(
<Input
placeholder="API Key"
onChange={onChange}
/>,
)
fireEvent.change(screen.getByPlaceholderText('API Key'), { target: { value: 'next' } })
expect(onChange).toHaveBeenCalledWith('next')
})
// Edge cases: min/max enforcement
it('should clamp to the min value when the input is below min on blur', () => {
const onChange = vi.fn()
render(
<Input
placeholder="Limit"
onChange={onChange}
min={2}
max={6}
/>,
)
const input = screen.getByPlaceholderText('Limit')
fireEvent.change(input, { target: { value: '1' } })
fireEvent.blur(input)
expect(onChange).toHaveBeenLastCalledWith('2')
})
it('should clamp to the max value when the input is above max on blur', () => {
const onChange = vi.fn()
render(
<Input
placeholder="Limit"
onChange={onChange}
min={2}
max={6}
/>,
)
const input = screen.getByPlaceholderText('Limit')
fireEvent.change(input, { target: { value: '8' } })
fireEvent.blur(input)
expect(onChange).toHaveBeenLastCalledWith('6')
})
it('should keep the value when it is within the min/max range on blur', () => {
const onChange = vi.fn()
render(
<Input
placeholder="Limit"
onChange={onChange}
min={2}
max={6}
/>,
)
const input = screen.getByPlaceholderText('Limit')
fireEvent.change(input, { target: { value: '4' } })
fireEvent.blur(input)
expect(onChange).not.toHaveBeenCalledWith('2')
expect(onChange).not.toHaveBeenCalledWith('6')
})
})

View File

@@ -0,0 +1,353 @@
import type { Credential, CredentialFormSchema, ModelProvider } from '../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
ModelModalModeEnum,
ModelTypeEnum,
PreferredProviderTypeEnum,
QuotaUnitEnum,
} from '../declarations'
import ModelModal from './index'
type CredentialData = {
credentials: Record<string, unknown>
available_credentials: Credential[]
}
type ModelFormSchemas = {
formSchemas: CredentialFormSchema[]
formValues: Record<string, unknown>
modelNameAndTypeFormSchemas: CredentialFormSchema[]
modelNameAndTypeFormValues: Record<string, unknown>
}
const mockState = vi.hoisted(() => ({
isLoading: false,
credentialData: { credentials: {}, available_credentials: [] } as CredentialData,
doingAction: false,
deleteCredentialId: null as string | null,
isCurrentWorkspaceManager: true,
formSchemas: [] as CredentialFormSchema[],
formValues: {} as Record<string, unknown>,
modelNameAndTypeFormSchemas: [] as CredentialFormSchema[],
modelNameAndTypeFormValues: {} as Record<string, unknown>,
}))
const mockHandlers = vi.hoisted(() => ({
handleSaveCredential: vi.fn(),
handleConfirmDelete: vi.fn(),
closeConfirmDelete: vi.fn(),
openConfirmDelete: vi.fn(),
handleActiveCredential: vi.fn(),
}))
type FormResponse = {
isCheckValidated: boolean
values: Record<string, unknown>
}
const mockFormState = vi.hoisted(() => ({
responses: [] as FormResponse[],
setFieldValue: vi.fn(),
}))
vi.mock('../model-auth/hooks', () => ({
useCredentialData: () => ({
isLoading: mockState.isLoading,
credentialData: mockState.credentialData,
}),
useAuth: () => ({
handleSaveCredential: mockHandlers.handleSaveCredential,
handleConfirmDelete: mockHandlers.handleConfirmDelete,
deleteCredentialId: mockState.deleteCredentialId,
closeConfirmDelete: mockHandlers.closeConfirmDelete,
openConfirmDelete: mockHandlers.openConfirmDelete,
doingAction: mockState.doingAction,
handleActiveCredential: mockHandlers.handleActiveCredential,
}),
useModelFormSchemas: (): ModelFormSchemas => ({
formSchemas: mockState.formSchemas,
formValues: mockState.formValues,
modelNameAndTypeFormSchemas: mockState.modelNameAndTypeFormSchemas,
modelNameAndTypeFormValues: mockState.modelNameAndTypeFormValues,
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({ isCurrentWorkspaceManager: mockState.isCurrentWorkspaceManager }),
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (value: { en_US: string }) => value.en_US,
}))
vi.mock('../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/base/form/form-scenarios/auth', async () => {
const React = await import('react')
const AuthForm = React.forwardRef(({
onChange,
}: {
onChange?: (field: string, value: string) => void
}, ref: React.ForwardedRef<{ getFormValues: () => FormResponse, getForm: () => { setFieldValue: (field: string, value: string) => void } }>) => {
React.useImperativeHandle(ref, () => ({
getFormValues: () => mockFormState.responses.shift() || { isCheckValidated: false, values: {} },
getForm: () => ({ setFieldValue: mockFormState.setFieldValue }),
}))
return (
<div>
<button type="button" onClick={() => onChange?.('__model_name', 'updated-model')}>Model Name Change</button>
</div>
)
})
return { default: AuthForm }
})
vi.mock('../model-auth', () => ({
CredentialSelector: ({ onSelect }: { onSelect: (credential: Credential & { addNewCredential?: boolean }) => void }) => (
<div>
<button type="button" onClick={() => onSelect({ credential_id: 'existing' })}>Choose Existing</button>
<button type="button" onClick={() => onSelect({ credential_id: 'new', addNewCredential: true })}>Add New</button>
</div>
),
}))
const createI18n = (text: string) => ({ en_US: text, zh_Hans: text })
const createProvider = (overrides?: Partial<ModelProvider>): ModelProvider => ({
provider: 'openai',
label: createI18n('OpenAI'),
help: {
title: createI18n('Help'),
url: createI18n('https://example.com'),
},
icon_small: createI18n('icon'),
supported_model_types: [ModelTypeEnum.textGeneration],
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
provider_credential_schema: { credential_form_schemas: [] },
model_credential_schema: {
model: { label: createI18n('Model'), placeholder: createI18n('Model') },
credential_form_schemas: [],
},
preferred_provider_type: PreferredProviderTypeEnum.system,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
available_credentials: [],
custom_models: [],
can_added_models: [],
},
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.trial,
quota_configurations: [
{
quota_type: CurrentSystemQuotaTypeEnum.trial,
quota_unit: QuotaUnitEnum.times,
quota_limit: 0,
quota_used: 0,
last_used: 0,
is_valid: true,
},
],
},
allow_custom_token: true,
...overrides,
})
const renderModal = (overrides?: Partial<React.ComponentProps<typeof ModelModal>>) => {
const provider = createProvider()
const props = {
provider,
configurateMethod: ConfigurationMethodEnum.predefinedModel,
onCancel: vi.fn(),
onSave: vi.fn(),
onRemove: vi.fn(),
...overrides,
}
const view = render(<ModelModal {...props} />)
return {
...props,
unmount: view.unmount,
}
}
describe('ModelModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockState.isLoading = false
mockState.credentialData = { credentials: {}, available_credentials: [] }
mockState.doingAction = false
mockState.deleteCredentialId = null
mockState.isCurrentWorkspaceManager = true
mockState.formSchemas = []
mockState.formValues = {}
mockState.modelNameAndTypeFormSchemas = []
mockState.modelNameAndTypeFormValues = {}
mockFormState.responses = []
})
it('should show title, description, and loading state for predefined models', () => {
mockState.isLoading = true
const predefined = renderModal()
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.title')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.desc')).toBeInTheDocument()
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
predefined.unmount()
const customizable = renderModal({ configurateMethod: ConfigurationMethodEnum.customizableModel })
expect(screen.queryByText('common.modelProvider.auth.apiKeyModal.desc')).not.toBeInTheDocument()
customizable.unmount()
mockState.credentialData = { credentials: {}, available_credentials: [] }
renderModal({ mode: ModelModalModeEnum.configModelCredential, model: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration } })
expect(screen.getByText('common.modelProvider.auth.addModelCredential')).toBeInTheDocument()
})
it('should reveal the credential label when adding a new credential', () => {
renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList })
expect(screen.queryByText('common.modelProvider.auth.modelCredential')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Add New'))
expect(screen.getByText('common.modelProvider.auth.modelCredential')).toBeInTheDocument()
})
it('should call onCancel when the cancel button is clicked', () => {
const { onCancel } = renderModal()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should call onCancel when the escape key is pressed', () => {
const { onCancel } = renderModal()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should confirm deletion when a delete dialog is shown', () => {
mockState.credentialData = { credentials: { api_key: 'secret' }, available_credentials: [] }
mockState.deleteCredentialId = 'delete-id'
const credential: Credential = { credential_id: 'cred-1' }
const { onCancel } = renderModal({ credential })
expect(screen.getByText('common.modelProvider.confirmDelete')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(mockHandlers.handleConfirmDelete).toHaveBeenCalledTimes(1)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should handle save flows for different modal modes', async () => {
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text-input' } as unknown as CredentialFormSchema]
mockState.formSchemas = [{ variable: 'api_key', type: 'secret-input' } as unknown as CredentialFormSchema]
mockFormState.responses = [
{ isCheckValidated: true, values: { __model_name: 'custom-model', __model_type: ModelTypeEnum.textGeneration } },
{ isCheckValidated: true, values: { __authorization_name__: 'Auth Name', api_key: 'secret' } },
]
const configCustomModel = renderModal({ mode: ModelModalModeEnum.configCustomModel })
fireEvent.click(screen.getAllByText('Model Name Change')[0])
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockFormState.setFieldValue).toHaveBeenCalledWith('__model_name', 'updated-model')
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'secret' },
name: 'Auth Name',
model: 'custom-model',
model_type: ModelTypeEnum.textGeneration,
})
})
expect(configCustomModel.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Auth Name', api_key: 'secret' })
configCustomModel.unmount()
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Model Auth', api_key: 'abc' } }]
const model = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration }
const configModelCredential = renderModal({
mode: ModelModalModeEnum.configModelCredential,
model,
credential: { credential_id: 'cred-123' },
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: 'cred-123',
credentials: { api_key: 'abc' },
name: 'Model Auth',
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
})
expect(configModelCredential.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Model Auth', api_key: 'abc' })
configModelCredential.unmount()
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Provider Auth', api_key: 'provider-key' } }]
const configProviderCredential = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'provider-key' },
name: 'Provider Auth',
})
})
configProviderCredential.unmount()
const addToModelList = renderModal({
mode: ModelModalModeEnum.addCustomModelToModelList,
model,
})
fireEvent.click(screen.getByText('Choose Existing'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockHandlers.handleActiveCredential).toHaveBeenCalledWith({ credential_id: 'existing' }, model)
expect(addToModelList.onCancel).toHaveBeenCalled()
addToModelList.unmount()
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'New Auth', api_key: 'new-key' } }]
const addToModelListWithNew = renderModal({
mode: ModelModalModeEnum.addCustomModelToModelList,
model,
})
fireEvent.click(screen.getByText('Add New'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'new-key' },
name: 'New Auth',
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
})
addToModelListWithNew.unmount()
mockFormState.responses = [{ isCheckValidated: false, values: {} }]
const invalidSave = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledTimes(4)
})
invalidSave.unmount()
mockState.credentialData = { credentials: { api_key: 'value' }, available_credentials: [] }
mockState.formValues = { api_key: 'value' }
const removable = renderModal({ credential: { credential_id: 'remove-1' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' }))
expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith({ credential_id: 'remove-1' }, undefined)
removable.unmount()
})
})

View File

@@ -0,0 +1,116 @@
import type { ModelItem } from '../declarations'
import { render, screen } from '@testing-library/react'
import {
ConfigurationMethodEnum,
ModelFeatureEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '../declarations'
import ModelName from './index'
let mockLocale = 'en-US'
vi.mock('#i18n', () => ({
useTranslation: () => ({
i18n: {
language: mockLocale,
},
}),
}))
const createModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'gpt-4o',
label: {
en_US: 'English Model',
zh_Hans: 'Chinese Model',
},
model_type: ModelTypeEnum.textGeneration,
features: [],
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
...overrides,
})
describe('ModelName', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLocale = 'en-US'
})
// Rendering scenarios for the model name label.
describe('rendering', () => {
it('should render the localized model label when translation exists', () => {
mockLocale = 'zh-Hans'
const modelItem = createModelItem()
render(<ModelName modelItem={modelItem} />)
expect(screen.getByText('Chinese Model')).toBeInTheDocument()
})
it('should fall back to en_US label when localized label is missing', () => {
mockLocale = 'fr-FR'
const modelItem = createModelItem({
label: {
en_US: 'English Only',
zh_Hans: 'Chinese Model',
},
})
render(<ModelName modelItem={modelItem} />)
expect(screen.getByText('English Only')).toBeInTheDocument()
})
it('should render nothing when modelItem is null', () => {
const { container } = render(<ModelName modelItem={null as unknown as ModelItem} />)
expect(container).toBeEmptyDOMElement()
})
})
// Badges that surface model metadata to the user.
describe('badges', () => {
it('should show model type, mode, and context size when enabled', () => {
const modelItem = createModelItem({
model_type: ModelTypeEnum.textEmbedding,
model_properties: {
mode: 'chat',
context_size: 2000,
},
})
render(
<ModelName
modelItem={modelItem}
showModelType
showMode
showContextSize
/>,
)
expect(screen.getByText('TEXT EMBEDDING')).toBeInTheDocument()
expect(screen.getByText('CHAT')).toBeInTheDocument()
expect(screen.getByText('2K')).toBeInTheDocument()
})
it('should render feature labels when showFeaturesLabel is enabled', () => {
const modelItem = createModelItem({
features: [ModelFeatureEnum.vision, ModelFeatureEnum.audio],
})
render(
<ModelName
modelItem={modelItem}
showFeatures
showFeaturesLabel
/>,
)
expect(screen.getByText('Vision')).toBeInTheDocument()
expect(screen.getByText('Audio')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,154 @@
import type { MouseEvent } from 'react'
import type { ModelProvider } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import {
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
ModelTypeEnum,
QuotaUnitEnum,
} from '../declarations'
import AgentModelTrigger from './agent-model-trigger'
let modelProviders: ModelProvider[] = []
let pluginInfo: { latest_package_identifier: string } | null = null
let pluginLoading = false
let inModelList = true
const invalidateInstalledPluginList = vi.fn()
const handleOpenModal = vi.fn()
const updateModelProviders = vi.fn()
const updateModelList = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders,
}),
}))
vi.mock('@/service/use-plugins', () => ({
useInvalidateInstalledPluginList: () => invalidateInstalledPluginList,
useModelInList: () => ({ data: inModelList }),
usePluginInfo: () => ({ data: pluginInfo, isLoading: pluginLoading }),
}))
vi.mock('../hooks', () => ({
useModelModalHandler: () => handleOpenModal,
useUpdateModelList: () => updateModelList,
useUpdateModelProviders: () => updateModelProviders,
}))
vi.mock('../model-icon', () => ({
default: () => <div>Icon</div>,
}))
vi.mock('./model-display', () => ({
default: () => <div>ModelDisplay</div>,
}))
vi.mock('./status-indicators', () => ({
default: () => <div>StatusIndicators</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({
InstallPluginButton: ({ onClick, onSuccess }: { onClick: (event: MouseEvent<HTMLButtonElement>) => void, onSuccess: () => void }) => (
<button
onClick={(event) => {
onClick(event)
onSuccess()
}}
>
Install Plugin
</button>
),
}))
describe('AgentModelTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
modelProviders = []
pluginInfo = null
pluginLoading = false
inModelList = true
})
it('should render loading state when plugin info is still fetching', () => {
pluginLoading = true
render(
<AgentModelTrigger
modelId="gpt-4"
providerName="openai"
/>,
)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should render model actions for configured provider', () => {
modelProviders = [{
provider: 'openai',
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.paid,
quota_configurations: [{
quota_type: CurrentSystemQuotaTypeEnum.paid,
quota_unit: QuotaUnitEnum.times,
quota_limit: 10,
quota_used: 1,
last_used: 1,
is_valid: true,
}],
},
}] as unknown as ModelProvider[]
render(
<AgentModelTrigger
modelId="gpt-4"
providerName="openai"
/>,
)
expect(screen.getByText('ModelDisplay')).toBeInTheDocument()
expect(screen.getByText('StatusIndicators')).toBeInTheDocument()
})
it('should support plugin installation flow when provider is missing', () => {
pluginInfo = { latest_package_identifier: 'plugin/demo@1.0.0' }
render(
<AgentModelTrigger
modelId="gpt-4"
providerName="openai"
scope={`${ModelTypeEnum.textGeneration},${ModelTypeEnum.tts}`}
/>,
)
fireEvent.click(screen.getByText('Install Plugin'))
expect(updateModelList).toHaveBeenCalledWith(ModelTypeEnum.textGeneration)
expect(updateModelList).toHaveBeenCalledWith(ModelTypeEnum.tts)
expect(updateModelProviders).toHaveBeenCalledTimes(1)
expect(invalidateInstalledPluginList).toHaveBeenCalledTimes(1)
})
it('should show configuration action when provider requires setup', () => {
modelProviders = [{
provider: 'openai',
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.paid,
quota_configurations: [],
},
}] as unknown as ModelProvider[]
render(
<AgentModelTrigger
modelId="gpt-4"
providerName="openai"
/>,
)
expect(screen.getByText('workflow.nodes.agent.notAuthorized')).toBeInTheDocument()
})
it('should render unconfigured state when model is not selected', () => {
render(<AgentModelTrigger />)
expect(screen.getByText('workflow.nodes.agent.configureModel')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,28 @@
import type { ComponentProps } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import { ConfigurationMethodEnum } from '../declarations'
import ConfigurationButton from './configuration-button'
describe('ConfigurationButton', () => {
it('should render and handle click', () => {
const handleOpenModal = vi.fn()
const modelProvider = { id: 1 }
render(
<ConfigurationButton
modelProvider={modelProvider as unknown as ComponentProps<typeof ConfigurationButton>['modelProvider']}
handleOpenModal={handleOpenModal}
/>,
)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(handleOpenModal).toHaveBeenCalledWith(
modelProvider,
ConfigurationMethodEnum.predefinedModel,
undefined,
)
})
})

View File

@@ -0,0 +1,273 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import ModelParameterModal from './index'
let isAPIKeySet = true
let parameterRules = [
{
name: 'temperature',
label: { en_US: 'Temperature' },
type: 'float',
default: 0.7,
min: 0,
max: 1,
help: { en_US: 'Control randomness' },
},
]
let isRulesLoading = false
let currentProvider: Record<string, unknown> | undefined = { provider: 'openai', label: { en_US: 'OpenAI' } }
let currentModel: Record<string, unknown> | undefined = {
model: 'gpt-3.5-turbo',
status: 'active',
model_properties: { mode: 'chat' },
}
let activeTextGenerationModelList: Array<Record<string, unknown>> = [
{
provider: 'openai',
models: [
{
model: 'gpt-3.5-turbo',
model_properties: { mode: 'chat' },
features: ['vision'],
},
{
model: 'gpt-4.1',
model_properties: { mode: 'chat' },
features: ['vision', 'tool-call'],
},
],
},
]
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
isAPIKeySet,
}),
}))
vi.mock('@/service/use-common', () => ({
useModelParameterRules: () => ({
data: {
data: parameterRules,
},
isPending: isRulesLoading,
}),
}))
vi.mock('../hooks', () => ({
useTextGenerationCurrentProviderAndModelAndModelList: () => ({
currentProvider,
currentModel,
activeTextGenerationModelList,
}),
}))
// Mock PortalToFollowElem components to control visibility and simplify testing
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
return {
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => {
return (
<div>
<div data-testid="portal-wrapper">
{children}
</div>
</div>
)
},
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>
{children}
</div>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className: string }) => (
<div data-testid="portal-content" className={className}>
{children}
</div>
),
}
})
vi.mock('./parameter-item', () => ({
default: ({ parameterRule, value, onChange, onSwitch }: { parameterRule: { name: string, label: { en_US: string } }, value: string | number, onChange: (v: number) => void, onSwitch: (checked: boolean, val: unknown) => void }) => (
<div data-testid={`param-${parameterRule.name}`}>
{parameterRule.label.en_US}
<input
aria-label={parameterRule.name}
value={value || ''}
onChange={e => onChange(Number(e.target.value))}
/>
<button onClick={() => onSwitch?.(false, undefined)}>Remove</button>
<button onClick={() => onSwitch?.(true, 'assigned')}>Add</button>
</div>
),
}))
vi.mock('../model-selector', () => ({
default: ({ onSelect }: { onSelect: (value: { provider: string, model: string }) => void }) => (
<div data-testid="model-selector">
Model Selector
<button onClick={() => onSelect({ provider: 'openai', model: 'gpt-4.1' })}>Select GPT-4.1</button>
</div>
),
}))
vi.mock('./presets-parameter', () => ({
default: ({ onSelect }: { onSelect: (id: number) => void }) => (
<button onClick={() => onSelect(1)}>Preset 1</button>
),
}))
vi.mock('./trigger', () => ({
default: () => <button>Open Settings</button>,
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | null | false)[]) => args.filter(Boolean).join(' '),
}))
// Mock config
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
PROVIDER_WITH_PRESET_TONE: ['openai'], // ensure presets mock renders
}
})
describe('ModelParameterModal', () => {
const defaultProps = {
isAdvancedMode: false,
modelId: 'gpt-3.5-turbo',
provider: 'openai',
setModel: vi.fn(),
completionParams: { temperature: 0.7 },
onCompletionParamsChange: vi.fn(),
hideDebugWithMultipleModel: false,
debugWithMultipleModel: false,
onDebugWithMultipleModelChange: vi.fn(),
readonly: false,
}
beforeEach(() => {
vi.clearAllMocks()
isAPIKeySet = true
isRulesLoading = false
parameterRules = [
{
name: 'temperature',
label: { en_US: 'Temperature' },
type: 'float',
default: 0.7,
min: 0,
max: 1,
help: { en_US: 'Control randomness' },
},
]
currentProvider = { provider: 'openai', label: { en_US: 'OpenAI' } }
currentModel = {
model: 'gpt-3.5-turbo',
status: 'active',
model_properties: { mode: 'chat' },
}
activeTextGenerationModelList = [
{
provider: 'openai',
models: [
{
model: 'gpt-3.5-turbo',
model_properties: { mode: 'chat' },
features: ['vision'],
},
{
model: 'gpt-4.1',
model_properties: { mode: 'chat' },
features: ['vision', 'tool-call'],
},
],
},
]
})
it('should render trigger and content', () => {
render(<ModelParameterModal {...defaultProps} />)
expect(screen.getByText('Open Settings')).toBeInTheDocument()
expect(screen.getByText('Temperature')).toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('portal-trigger'))
})
it('should update params when changed and handle switch add/remove', () => {
render(<ModelParameterModal {...defaultProps} />)
const input = screen.getByLabelText('temperature')
fireEvent.change(input, { target: { value: '0.9' } })
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
...defaultProps.completionParams,
temperature: 0.9,
})
fireEvent.click(screen.getByText('Remove'))
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({})
fireEvent.click(screen.getByText('Add'))
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
...defaultProps.completionParams,
temperature: 'assigned',
})
})
it('should handle preset selection', () => {
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Preset 1'))
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalled()
})
it('should handle debug mode toggle', () => {
const { rerender } = render(<ModelParameterModal {...defaultProps} />)
const toggle = screen.getByText(/debugAsMultipleModel/i)
fireEvent.click(toggle)
expect(defaultProps.onDebugWithMultipleModelChange).toHaveBeenCalled()
rerender(<ModelParameterModal {...defaultProps} debugWithMultipleModel />)
expect(screen.getByText(/debugAsSingleModel/i)).toBeInTheDocument()
})
it('should handle custom renderTrigger', () => {
const renderTrigger = vi.fn().mockReturnValue(<div>Custom Trigger</div>)
render(<ModelParameterModal {...defaultProps} renderTrigger={renderTrigger} readonly />)
expect(screen.getByText('Custom Trigger')).toBeInTheDocument()
expect(renderTrigger).toHaveBeenCalled()
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(renderTrigger).toHaveBeenCalledTimes(1)
})
it('should handle model selection and advanced mode parameters', () => {
parameterRules = [
{
name: 'temperature',
label: { en_US: 'Temperature' },
type: 'float',
default: 0.7,
min: 0,
max: 1,
help: { en_US: 'Control randomness' },
},
]
const { rerender } = render(<ModelParameterModal {...defaultProps} />)
expect(screen.getByTestId('param-temperature')).toBeInTheDocument()
rerender(<ModelParameterModal {...defaultProps} isAdvancedMode />)
expect(screen.getByTestId('param-stop')).toBeInTheDocument()
fireEvent.click(screen.getByText('Select GPT-4.1'))
expect(defaultProps.setModel).toHaveBeenCalledWith({
modelId: 'gpt-4.1',
provider: 'openai',
mode: 'chat',
features: ['vision', 'tool-call'],
})
})
})

View File

@@ -0,0 +1,20 @@
import { render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import ModelDisplay from './model-display'
vi.mock('../model-name', () => ({
default: ({ modelItem }: { modelItem: { model: string } }) => <div>{modelItem.model}</div>,
}))
describe('ModelDisplay', () => {
it('should render model name when model is present', () => {
const currentModel = { model: 'gpt-4' }
render(<ModelDisplay currentModel={currentModel} modelId="gpt-4" />)
expect(screen.getByText('gpt-4')).toBeInTheDocument()
})
it('should render modelID when currentModel is missing', () => {
render(<ModelDisplay currentModel={null} modelId="unknown-model" />)
expect(screen.getByText('unknown-model')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,239 @@
import type { ModelParameterRule } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import ParameterItem from './parameter-item'
vi.mock('../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/base/radio', () => {
const Radio = ({ children, value }: { children: React.ReactNode, value: boolean }) => <button data-testid={`radio-${value}`}>{children}</button>
Radio.Group = ({ children, onChange }: { children: React.ReactNode, onChange: (value: boolean) => void }) => (
<div>
{children}
<button onClick={() => onChange(true)}>Select True</button>
<button onClick={() => onChange(false)}>Select False</button>
</div>
)
return { default: Radio }
})
vi.mock('@/app/components/base/select', () => ({
SimpleSelect: ({ onSelect, items }: { onSelect: (item: { value: string }) => void, items: { value: string, name: string }[] }) => (
<select onChange={e => onSelect({ value: e.target.value })}>
{items.map(item => (
<option key={item.value} value={item.value}>{item.name}</option>
))}
</select>
),
}))
vi.mock('@/app/components/base/slider', () => ({
default: ({ value, onChange }: { value: number, onChange: (val: number) => void }) => (
<input type="range" value={value} onChange={e => onChange(Number(e.target.value))} />
),
}))
vi.mock('@/app/components/base/switch', () => ({
default: ({ onChange, value }: { onChange: (val: boolean) => void, value: boolean }) => (
<button onClick={() => onChange(!value)}>Switch</button>
),
}))
vi.mock('@/app/components/base/tag-input', () => ({
default: ({ onChange }: { onChange: (val: string[]) => void }) => (
<input onChange={e => onChange(e.target.value.split(','))} />
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,
}))
describe('ParameterItem', () => {
const createRule = (overrides: Partial<ModelParameterRule> = {}): ModelParameterRule => ({
name: 'temp',
label: { en_US: 'Temperature', zh_Hans: 'Temperature' },
type: 'float',
min: 0,
max: 1,
help: { en_US: 'Help text', zh_Hans: 'Help text' },
required: false,
...overrides,
})
const createProps = (overrides: {
parameterRule?: ModelParameterRule
value?: number | string | boolean | string[]
} = {}) => {
const onChange = vi.fn()
const onSwitch = vi.fn()
return {
parameterRule: createRule(),
value: 0.7,
onChange,
onSwitch,
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render float input with slider', () => {
const props = createProps()
const { rerender } = render(<ParameterItem {...props} />)
expect(screen.getByText('Temperature')).toBeInTheDocument()
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '0.8' } })
expect(props.onChange).toHaveBeenCalledWith(0.8)
fireEvent.change(input, { target: { value: '1.4' } })
expect(props.onChange).toHaveBeenCalledWith(1)
fireEvent.change(input, { target: { value: '-0.2' } })
expect(props.onChange).toHaveBeenCalledWith(0)
const slider = screen.getByRole('slider')
fireEvent.change(slider, { target: { value: '2' } })
expect(props.onChange).toHaveBeenCalledWith(1)
fireEvent.change(slider, { target: { value: '-1' } })
expect(props.onChange).toHaveBeenCalledWith(0)
fireEvent.change(slider, { target: { value: '0.4' } })
expect(props.onChange).toHaveBeenCalledWith(0.4)
fireEvent.blur(input)
expect(input).toHaveValue(0.7)
const minBoundedProps = createProps({
parameterRule: createRule({ type: 'float', min: 1, max: 2 }),
value: 1.5,
})
rerender(<ParameterItem {...minBoundedProps} />)
fireEvent.change(screen.getByRole('slider'), { target: { value: '0' } })
expect(minBoundedProps.onChange).toHaveBeenCalledWith(1)
})
it('should render boolean radio', () => {
const props = createProps({ parameterRule: createRule({ type: 'boolean', default: false }), value: true })
render(<ParameterItem {...props} />)
expect(screen.getByText('True')).toBeInTheDocument()
fireEvent.click(screen.getByText('Select False'))
expect(props.onChange).toHaveBeenCalledWith(false)
})
it('should render string input and select options', () => {
const props = createProps({ parameterRule: createRule({ type: 'string' }), value: 'test' })
const { rerender } = render(<ParameterItem {...props} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new' } })
expect(props.onChange).toHaveBeenCalledWith('new')
const selectProps = createProps({
parameterRule: createRule({ type: 'string', options: ['opt1', 'opt2'] }),
value: 'opt1',
})
rerender(<ParameterItem {...selectProps} />)
const select = screen.getByRole('combobox')
fireEvent.change(select, { target: { value: 'opt2' } })
expect(selectProps.onChange).toHaveBeenCalledWith('opt2')
})
it('should handle switch toggle', () => {
const props = createProps()
let view = render(<ParameterItem {...props} />)
fireEvent.click(screen.getByText('Switch'))
expect(props.onSwitch).toHaveBeenCalledWith(false, 0.7)
const intDefaultProps = createProps({
parameterRule: createRule({ type: 'int', min: 0, default: undefined }),
value: undefined,
})
view.unmount()
view = render(<ParameterItem {...intDefaultProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(intDefaultProps.onSwitch).toHaveBeenCalledWith(true, 0)
const stringDefaultProps = createProps({
parameterRule: createRule({ type: 'string', default: 'preset-value' }),
value: undefined,
})
view.unmount()
view = render(<ParameterItem {...stringDefaultProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(stringDefaultProps.onSwitch).toHaveBeenCalledWith(true, 'preset-value')
const booleanDefaultProps = createProps({
parameterRule: createRule({ type: 'boolean', default: true }),
value: undefined,
})
view.unmount()
view = render(<ParameterItem {...booleanDefaultProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(booleanDefaultProps.onSwitch).toHaveBeenCalledWith(true, true)
const tagDefaultProps = createProps({
parameterRule: createRule({ type: 'tag', default: ['one'] }),
value: undefined,
})
view.unmount()
const tagView = render(<ParameterItem {...tagDefaultProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(tagDefaultProps.onSwitch).toHaveBeenCalledWith(true, ['one'])
const zeroValueProps = createProps({
parameterRule: createRule({ type: 'float', default: 0.5 }),
value: 0,
})
tagView.unmount()
render(<ParameterItem {...zeroValueProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(zeroValueProps.onSwitch).toHaveBeenCalledWith(false, 0)
})
it('should support text and tag parameter interactions', () => {
const textProps = createProps({
parameterRule: createRule({ type: 'text', name: 'prompt' }),
value: 'initial prompt',
})
const { rerender } = render(<ParameterItem {...textProps} />)
const textarea = screen.getByRole('textbox')
fireEvent.change(textarea, { target: { value: 'rewritten prompt' } })
expect(textProps.onChange).toHaveBeenCalledWith('rewritten prompt')
const tagProps = createProps({
parameterRule: createRule({
type: 'tag',
name: 'tags',
tagPlaceholder: { en_US: 'Tag hint', zh_Hans: 'Tag hint' },
}),
value: ['alpha'],
})
rerender(<ParameterItem {...tagProps} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'one,two' } })
expect(tagProps.onChange).toHaveBeenCalledWith(['one', 'two'])
})
it('should support int parameters and unknown type fallback', () => {
const intProps = createProps({
parameterRule: createRule({ type: 'int', min: 0, max: 500, default: 100 }),
value: 100,
})
const { rerender } = render(<ParameterItem {...intProps} />)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '350' } })
expect(intProps.onChange).toHaveBeenCalledWith(350)
const unknownTypeProps = createProps({
parameterRule: createRule({ type: 'unsupported' }),
value: 0.7,
})
rerender(<ParameterItem {...unknownTypeProps} />)
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
})
})

View File

@@ -109,7 +109,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
const handleSwitch = (checked: boolean) => {
if (onSwitch) {
const assignValue: ParameterValue = localValue || getDefaultValue()
const assignValue: ParameterValue = localValue ?? getDefaultValue()
onSwitch(checked, assignValue)
}
@@ -118,7 +118,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
useEffect(() => {
if ((parameterRule.type === 'int' || parameterRule.type === 'float') && numberInputRef.current)
numberInputRef.current.value = `${renderValue}`
}, [value])
}, [value, parameterRule.type, renderValue])
const renderInput = () => {
const numberInputWithSlide = (parameterRule.type === 'int' || parameterRule.type === 'float')
@@ -148,7 +148,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
)}
<input
ref={numberInputRef}
className="system-sm-regular ml-4 block h-8 w-16 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 text-components-input-text-filled outline-none"
className="ml-4 block h-8 w-16 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 text-components-input-text-filled outline-none system-sm-regular"
type="number"
max={parameterRule.max}
min={parameterRule.min}
@@ -175,7 +175,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
)}
<input
ref={numberInputRef}
className="system-sm-regular ml-4 block h-8 w-16 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 text-components-input-text-filled outline-none"
className="ml-4 block h-8 w-16 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 text-components-input-text-filled outline-none system-sm-regular"
type="number"
max={parameterRule.max}
min={parameterRule.min}
@@ -203,7 +203,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
if (parameterRule.type === 'string' && !parameterRule.options?.length) {
return (
<input
className={cn(isInWorkflow ? 'w-[150px]' : 'w-full', 'system-sm-regular ml-4 flex h-8 appearance-none items-center rounded-lg bg-components-input-bg-normal px-3 text-components-input-text-filled outline-none')}
className={cn(isInWorkflow ? 'w-[150px]' : 'w-full', 'ml-4 flex h-8 appearance-none items-center rounded-lg bg-components-input-bg-normal px-3 text-components-input-text-filled outline-none system-sm-regular')}
value={renderValue as string}
onChange={handleStringInputChange}
/>
@@ -213,7 +213,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
if (parameterRule.type === 'text') {
return (
<textarea
className="system-sm-regular ml-4 h-20 w-full rounded-lg bg-components-input-bg-normal px-1 text-components-input-text-filled"
className="ml-4 h-20 w-full rounded-lg bg-components-input-bg-normal px-1 text-components-input-text-filled system-sm-regular"
value={renderValue as string}
onChange={handleStringInputChange}
/>
@@ -265,7 +265,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
)
}
<div
className="system-xs-regular mr-0.5 truncate text-text-secondary"
className="mr-0.5 truncate text-text-secondary system-xs-regular"
title={parameterRule.label[language] || parameterRule.label.en_US}
>
{parameterRule.label[language] || parameterRule.label.en_US}
@@ -284,7 +284,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
</div>
{
parameterRule.type === 'tag' && (
<div className={cn(!isInWorkflow && 'w-[150px]', 'system-xs-regular text-text-tertiary')}>
<div className={cn(!isInWorkflow && 'w-[150px]', 'text-text-tertiary system-xs-regular')}>
{parameterRule?.tagPlaceholder?.[language]}
</div>
)

View File

@@ -0,0 +1,32 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import PresetsParameter from './presets-parameter'
vi.mock('@/app/components/base/dropdown', () => ({
default: ({ renderTrigger, items, onSelect }: { renderTrigger: (open: boolean) => React.ReactNode, items: { value: number, text: string }[], onSelect: (item: { value: number }) => void }) => (
<div>
{renderTrigger(false)}
{items.map(item => (
<button key={item.value} onClick={() => onSelect(item)}>
{item.text}
</button>
))}
</div>
),
}))
describe('PresetsParameter', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render presets and handle selection', () => {
const onSelect = vi.fn()
render(<PresetsParameter onSelect={onSelect} />)
expect(screen.getByText('common.modelProvider.loadPresets')).toBeInTheDocument()
fireEvent.click(screen.getByText('common.model.tone.Creative'))
expect(onSelect).toHaveBeenCalledWith(1)
})
})

View File

@@ -0,0 +1,103 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import StatusIndicators from './status-indicators'
let installedPlugins = [{ name: 'demo-plugin', plugin_unique_identifier: 'demo@1.0.0' }]
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({ data: { plugins: installedPlugins } }),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({
SwitchPluginVersion: ({ uniqueIdentifier }: { uniqueIdentifier: string }) => <div>{`SwitchVersion:${uniqueIdentifier}`}</div>,
}))
const t = (key: string) => key
describe('StatusIndicators', () => {
beforeEach(() => {
vi.clearAllMocks()
installedPlugins = [{ name: 'demo-plugin', plugin_unique_identifier: 'demo@1.0.0' }]
})
it('should render nothing when model is available and enabled', () => {
const { container } = render(
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={true}
disabled={false}
pluginInfo={null}
t={t}
/>,
)
expect(container).toBeEmptyDOMElement()
})
it('should render warning states when provider model is disabled', () => {
const parentClick = vi.fn()
const { rerender } = render(
<div onClick={parentClick}>
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={true}
disabled={true}
pluginInfo={null}
t={t}
/>
</div>,
)
expect(screen.getByText('nodes.agent.modelSelectorTooltips.deprecated')).toBeInTheDocument()
rerender(
<div onClick={parentClick}>
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={false}
disabled={true}
pluginInfo={null}
t={t}
/>
</div>,
)
expect(screen.getByText('nodes.agent.modelNotSupport.title')).toBeInTheDocument()
expect(screen.getByText('nodes.agent.linkToPlugin').closest('a')).toHaveAttribute('href', '/plugins')
fireEvent.click(screen.getByText('nodes.agent.modelNotSupport.title'))
fireEvent.click(screen.getByText('nodes.agent.linkToPlugin'))
expect(parentClick).not.toHaveBeenCalled()
rerender(
<div onClick={parentClick}>
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={false}
disabled={true}
pluginInfo={{ name: 'demo-plugin' }}
t={t}
/>
</div>,
)
expect(screen.getByText('SwitchVersion:demo@1.0.0')).toBeInTheDocument()
})
it('should render marketplace warning when provider is unavailable', () => {
render(
<StatusIndicators
needsConfiguration={false}
modelProvider={false}
inModelList={false}
disabled={false}
pluginInfo={null}
t={t}
/>,
)
expect(screen.getByText('nodes.agent.modelNotInMarketplace.title')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,47 @@
import type { ComponentProps } from 'react'
import { render, screen } from '@testing-library/react'
import Trigger from './trigger'
vi.mock('../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: [{ provider: 'openai', label: { en_US: 'OpenAI' } }],
}),
}))
vi.mock('../model-icon', () => ({
default: () => <div data-testid="model-icon">Icon</div>,
}))
vi.mock('../model-name', () => ({
default: ({ modelItem }: { modelItem: { model: string } }) => <div>{modelItem.model}</div>,
}))
describe('Trigger', () => {
const currentProvider = { provider: 'openai', label: { en_US: 'OpenAI' } } as unknown as ComponentProps<typeof Trigger>['currentProvider']
const currentModel = { model: 'gpt-4' } as unknown as ComponentProps<typeof Trigger>['currentModel']
it('should render initialized state', () => {
render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
/>,
)
expect(screen.getByText('gpt-4')).toBeInTheDocument()
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
})
it('should render fallback model id when current model is missing', () => {
render(
<Trigger
modelId="gpt-4"
providerName="openai"
/>,
)
expect(screen.getByText('gpt-4')).toBeInTheDocument()
})
})

View File

@@ -4387,11 +4387,6 @@
"count": 1
}
},
"app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 6
}
},
"app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1