mirror of
https://github.com/langgenius/dify.git
synced 2026-02-10 15:40:12 -05:00
Compare commits
1 Commits
test/tool-
...
test/billi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4a3be7fb6 |
@@ -5,7 +5,7 @@ from collections.abc import Generator
|
||||
from copy import deepcopy
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
if TYPE_CHECKING:
|
||||
from models.model import File
|
||||
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
@@ -171,7 +171,7 @@ class Tool(ABC):
|
||||
def create_file_message(self, file: File) -> ToolInvokeMessage:
|
||||
return ToolInvokeMessage(
|
||||
type=ToolInvokeMessage.MessageType.FILE,
|
||||
message=ToolInvokeMessage.FileMessage(file_marker="file_marker"),
|
||||
message=ToolInvokeMessage.FileMessage(),
|
||||
meta={"file": file},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.tools.__base.tool import Tool
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
from core.tools.entities.tool_entities import ToolEntity, ToolIdentity, ToolInvokeMessage, ToolProviderType
|
||||
|
||||
|
||||
class DummyCastType:
|
||||
def cast_value(self, value: Any) -> str:
|
||||
return f"cast:{value}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummyParameter:
|
||||
name: str
|
||||
type: DummyCastType
|
||||
form: str = "llm"
|
||||
required: bool = False
|
||||
default: Any = None
|
||||
options: list[Any] | None = None
|
||||
llm_description: str | None = None
|
||||
|
||||
|
||||
class DummyTool(Tool):
|
||||
def __init__(self, entity: ToolEntity, runtime: ToolRuntime):
|
||||
super().__init__(entity=entity, runtime=runtime)
|
||||
self.result: ToolInvokeMessage | list[ToolInvokeMessage] | Generator[ToolInvokeMessage, None, None] = (
|
||||
self.create_text_message("default")
|
||||
)
|
||||
self.runtime_parameter_overrides: list[Any] | None = None
|
||||
self.last_invocation: dict[str, Any] | None = None
|
||||
|
||||
def tool_provider_type(self) -> ToolProviderType:
|
||||
return ToolProviderType.BUILT_IN
|
||||
|
||||
def _invoke(
|
||||
self,
|
||||
user_id: str,
|
||||
tool_parameters: dict[str, Any],
|
||||
conversation_id: str | None = None,
|
||||
app_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> ToolInvokeMessage | list[ToolInvokeMessage] | Generator[ToolInvokeMessage, None, None]:
|
||||
self.last_invocation = {
|
||||
"user_id": user_id,
|
||||
"tool_parameters": tool_parameters,
|
||||
"conversation_id": conversation_id,
|
||||
"app_id": app_id,
|
||||
"message_id": message_id,
|
||||
}
|
||||
return self.result
|
||||
|
||||
def get_runtime_parameters(
|
||||
self,
|
||||
conversation_id: str | None = None,
|
||||
app_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
):
|
||||
if self.runtime_parameter_overrides is not None:
|
||||
return self.runtime_parameter_overrides
|
||||
return super().get_runtime_parameters(
|
||||
conversation_id=conversation_id,
|
||||
app_id=app_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
|
||||
def _build_tool(runtime: ToolRuntime | None = None) -> DummyTool:
|
||||
entity = ToolEntity(
|
||||
identity=ToolIdentity(author="test", name="dummy", label=I18nObject(en_US="dummy"), provider="test"),
|
||||
parameters=[],
|
||||
description=None,
|
||||
has_runtime_parameters=False,
|
||||
)
|
||||
runtime = runtime or ToolRuntime(tenant_id="tenant-1", invoke_from=InvokeFrom.DEBUGGER, runtime_parameters={})
|
||||
return DummyTool(entity=entity, runtime=runtime)
|
||||
|
||||
|
||||
def test_invoke_supports_single_message_and_parameter_casting():
|
||||
runtime = ToolRuntime(
|
||||
tenant_id="tenant-1",
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
runtime_parameters={"from_runtime": "runtime-value"},
|
||||
)
|
||||
tool = _build_tool(runtime)
|
||||
tool.entity.parameters = cast(
|
||||
Any,
|
||||
[
|
||||
DummyParameter(name="unused", type=DummyCastType()),
|
||||
DummyParameter(name="age", type=DummyCastType()),
|
||||
],
|
||||
)
|
||||
tool.result = tool.create_text_message("ok")
|
||||
|
||||
messages = list(
|
||||
tool.invoke(
|
||||
user_id="user-1",
|
||||
tool_parameters={"age": "18", "raw": "keep"},
|
||||
conversation_id="conv-1",
|
||||
app_id="app-1",
|
||||
message_id="msg-1",
|
||||
)
|
||||
)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert messages[0].message.text == "ok"
|
||||
assert tool.last_invocation == {
|
||||
"user_id": "user-1",
|
||||
"tool_parameters": {"age": "cast:18", "raw": "keep", "from_runtime": "runtime-value"},
|
||||
"conversation_id": "conv-1",
|
||||
"app_id": "app-1",
|
||||
"message_id": "msg-1",
|
||||
}
|
||||
|
||||
|
||||
def test_invoke_supports_list_and_generator_results():
|
||||
tool = _build_tool()
|
||||
tool.result = [tool.create_text_message("a"), tool.create_text_message("b")]
|
||||
list_messages = list(tool.invoke(user_id="user-1", tool_parameters={}))
|
||||
assert [msg.message.text for msg in list_messages] == ["a", "b"]
|
||||
|
||||
def _message_generator() -> Generator[ToolInvokeMessage, None, None]:
|
||||
yield tool.create_text_message("g1")
|
||||
yield tool.create_text_message("g2")
|
||||
|
||||
tool.result = _message_generator()
|
||||
generated_messages = list(tool.invoke(user_id="user-2", tool_parameters={}))
|
||||
assert [msg.message.text for msg in generated_messages] == ["g1", "g2"]
|
||||
|
||||
|
||||
def test_fork_tool_runtime_returns_new_tool_with_copied_entity():
|
||||
tool = _build_tool()
|
||||
new_runtime = ToolRuntime(tenant_id="tenant-2", invoke_from=InvokeFrom.EXPLORE, runtime_parameters={})
|
||||
|
||||
forked = tool.fork_tool_runtime(new_runtime)
|
||||
|
||||
assert isinstance(forked, DummyTool)
|
||||
assert forked is not tool
|
||||
assert forked.runtime == new_runtime
|
||||
assert forked.entity == tool.entity
|
||||
assert forked.entity is not tool.entity
|
||||
|
||||
|
||||
def test_get_runtime_parameters_and_merge_runtime_parameters():
|
||||
tool = _build_tool()
|
||||
original = DummyParameter(name="temperature", type=DummyCastType(), form="schema", required=True, default="0.7")
|
||||
tool.entity.parameters = cast(Any, [original])
|
||||
|
||||
default_runtime_parameters = tool.get_runtime_parameters()
|
||||
assert default_runtime_parameters == [original]
|
||||
|
||||
override = DummyParameter(name="temperature", type=DummyCastType(), form="llm", required=False, default="0.5")
|
||||
appended = DummyParameter(name="new_param", type=DummyCastType(), form="form", required=False, default="x")
|
||||
tool.runtime_parameter_overrides = [override, appended]
|
||||
|
||||
merged = tool.get_merged_runtime_parameters()
|
||||
assert len(merged) == 2
|
||||
assert merged[0].name == "temperature"
|
||||
assert merged[0].form == "llm"
|
||||
assert merged[0].required is False
|
||||
assert merged[0].default == "0.5"
|
||||
assert merged[1].name == "new_param"
|
||||
|
||||
|
||||
def test_message_factory_helpers():
|
||||
tool = _build_tool()
|
||||
|
||||
image_message = tool.create_image_message("https://example.com/image.png")
|
||||
assert image_message.type == ToolInvokeMessage.MessageType.IMAGE
|
||||
assert image_message.message.text == "https://example.com/image.png"
|
||||
|
||||
file_obj = object()
|
||||
file_message = tool.create_file_message(file_obj) # type: ignore[arg-type]
|
||||
assert file_message.type == ToolInvokeMessage.MessageType.FILE
|
||||
assert file_message.message.file_marker == "file_marker"
|
||||
assert file_message.meta == {"file": file_obj}
|
||||
|
||||
link_message = tool.create_link_message("https://example.com")
|
||||
assert link_message.type == ToolInvokeMessage.MessageType.LINK
|
||||
assert link_message.message.text == "https://example.com"
|
||||
|
||||
text_message = tool.create_text_message("hello")
|
||||
assert text_message.type == ToolInvokeMessage.MessageType.TEXT
|
||||
assert text_message.message.text == "hello"
|
||||
|
||||
blob_message = tool.create_blob_message(b"blob", meta={"source": "unit-test"})
|
||||
assert blob_message.type == ToolInvokeMessage.MessageType.BLOB
|
||||
assert blob_message.message.blob == b"blob"
|
||||
assert blob_message.meta == {"source": "unit-test"}
|
||||
|
||||
json_message = tool.create_json_message({"k": "v"}, suppress_output=True)
|
||||
assert json_message.type == ToolInvokeMessage.MessageType.JSON
|
||||
assert json_message.message.json_object == {"k": "v"}
|
||||
assert json_message.message.suppress_output is True
|
||||
|
||||
variable_message = tool.create_variable_message("answer", 42, stream=False)
|
||||
assert variable_message.type == ToolInvokeMessage.MessageType.VARIABLE
|
||||
assert variable_message.message.variable_name == "answer"
|
||||
assert variable_message.message.variable_value == 42
|
||||
assert variable_message.message.stream is False
|
||||
|
||||
|
||||
def test_base_abstract_invoke_placeholder_returns_none():
|
||||
tool = _build_tool()
|
||||
assert Tool._invoke(tool, user_id="u", tool_parameters={}) is None
|
||||
@@ -255,32 +255,6 @@ def test_create_variable_message():
|
||||
assert message.message.stream is False
|
||||
|
||||
|
||||
def test_create_file_message_should_include_file_marker():
|
||||
entity = ToolEntity(
|
||||
identity=ToolIdentity(author="test", name="test tool", label=I18nObject(en_US="test tool"), provider="test"),
|
||||
parameters=[],
|
||||
description=None,
|
||||
has_runtime_parameters=False,
|
||||
)
|
||||
runtime = ToolRuntime(tenant_id="test_tool", invoke_from=InvokeFrom.EXPLORE)
|
||||
tool = WorkflowTool(
|
||||
workflow_app_id="",
|
||||
workflow_as_tool_id="",
|
||||
version="1",
|
||||
workflow_entities={},
|
||||
workflow_call_depth=1,
|
||||
entity=entity,
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
file_obj = object()
|
||||
message = tool.create_file_message(file_obj) # type: ignore[arg-type]
|
||||
|
||||
assert message.type == ToolInvokeMessage.MessageType.FILE
|
||||
assert message.message.file_marker == "file_marker"
|
||||
assert message.meta == {"file": file_obj}
|
||||
|
||||
|
||||
def test_resolve_user_from_database_falls_back_to_end_user(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Ensure worker context can resolve EndUser when Account is missing."""
|
||||
|
||||
|
||||
996
web/__tests__/billing-integration.test.tsx
Normal file
996
web/__tests__/billing-integration.test.tsx
Normal file
@@ -0,0 +1,996 @@
|
||||
import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { defaultPlan, NUM_INFINITE } from '@/app/components/billing/config'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
|
||||
// ─── Module-level mock state ────────────────────────────────────────────────
|
||||
let mockProviderCtx: Record<string, unknown> = {}
|
||||
let mockAppCtx: Record<string, unknown> = {}
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
|
||||
// ─── Context mocks ──────────────────────────────────────────────────────────
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => mockAppCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
}),
|
||||
useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
useGetPricingPageLanguage: () => 'en',
|
||||
}))
|
||||
|
||||
// ─── Service mocks ──────────────────────────────────────────────────────────
|
||||
const mockRefetch = vi.fn().mockResolvedValue({ data: 'https://billing.example.com' })
|
||||
vi.mock('@/service/use-billing', () => ({
|
||||
useBillingUrl: () => ({
|
||||
data: 'https://billing.example.com',
|
||||
isFetching: false,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
useBindPartnerStackInfo: () => ({ mutateAsync: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-education', () => ({
|
||||
useEducationVerify: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ token: 'test-token' }),
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
// ─── Navigation mocks ───────────────────────────────────────────────────────
|
||||
const mockRouterPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockRouterPush }),
|
||||
usePathname: () => '/billing',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ─── External component mocks ───────────────────────────────────────────────
|
||||
vi.mock('@/app/education-apply/verify-state-modal', () => ({
|
||||
default: ({ isShow }: { isShow: boolean }) =>
|
||||
isShow ? <div data-testid="verify-state-modal" /> : null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/utils/util', () => ({
|
||||
mailToSupport: () => 'mailto:support@test.com',
|
||||
}))
|
||||
|
||||
// ─── Test data factories ────────────────────────────────────────────────────
|
||||
type PlanOverrides = {
|
||||
type?: string
|
||||
usage?: Partial<UsagePlanInfo>
|
||||
total?: Partial<UsagePlanInfo>
|
||||
reset?: Partial<UsageResetInfo>
|
||||
}
|
||||
|
||||
const createPlanData = (overrides: PlanOverrides = {}) => ({
|
||||
...defaultPlan,
|
||||
...overrides,
|
||||
type: overrides.type ?? defaultPlan.type,
|
||||
usage: { ...defaultPlan.usage, ...overrides.usage },
|
||||
total: { ...defaultPlan.total, ...overrides.total },
|
||||
reset: { ...defaultPlan.reset, ...overrides.reset },
|
||||
})
|
||||
|
||||
const setupProviderContext = (planOverrides: PlanOverrides = {}, extra: Record<string, unknown> = {}) => {
|
||||
mockProviderCtx = {
|
||||
plan: createPlanData(planOverrides),
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
enableEducationPlan: false,
|
||||
isEducationAccount: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
const setupAppContext = (overrides: Record<string, unknown> = {}) => {
|
||||
mockAppCtx = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
userProfile: { email: 'test@example.com' },
|
||||
langGeniusVersionInfo: { current_version: '1.0.0' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Imports (after mocks) ──────────────────────────────────────────────────
|
||||
// These must be imported after all vi.mock() calls
|
||||
/* eslint-disable import/first */
|
||||
import AnnotationFull from '@/app/components/billing/annotation-full'
|
||||
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import Billing from '@/app/components/billing/billing-page'
|
||||
import HeaderBillingBtn from '@/app/components/billing/header-billing-btn'
|
||||
import PlanComp from '@/app/components/billing/plan'
|
||||
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
|
||||
import PriorityLabel from '@/app/components/billing/priority-label'
|
||||
import TriggerEventsLimitModal from '@/app/components/billing/trigger-events-limit-modal'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
|
||||
/* eslint-enable import/first */
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 1. Billing Page + Plan Component Integration
|
||||
// Tests the full data flow: BillingPage → PlanComp → UsageInfo → ProgressBar
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Billing Page + Plan Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
// Verify that the billing page renders PlanComp with all 7 usage items
|
||||
describe('Rendering complete plan information', () => {
|
||||
it('should display all 7 usage metrics for sandbox plan', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: {
|
||||
buildApps: 3,
|
||||
teamMembers: 1,
|
||||
documentsUploadQuota: 10,
|
||||
vectorSpace: 20,
|
||||
annotatedResponse: 5,
|
||||
triggerEvents: 1000,
|
||||
apiRateLimit: 2000,
|
||||
},
|
||||
total: {
|
||||
buildApps: 5,
|
||||
teamMembers: 1,
|
||||
documentsUploadQuota: 50,
|
||||
vectorSpace: 50,
|
||||
annotatedResponse: 10,
|
||||
triggerEvents: 3000,
|
||||
apiRateLimit: 5000,
|
||||
},
|
||||
})
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
// Plan name
|
||||
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
|
||||
|
||||
// All 7 usage items should be visible
|
||||
expect(screen.getByText(/usagePage\.buildApps/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.teamMembers/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.documentsUploadQuota/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.vectorSpace/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.annotationQuota/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.triggerEvents/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plansCommon\.apiRateLimit/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display usage values as "usage / total" format', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 3, teamMembers: 1 },
|
||||
total: { buildApps: 5, teamMembers: 1 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Check that the buildApps usage fraction "3 / 5" is rendered
|
||||
const usageContainers = screen.getAllByText('3')
|
||||
expect(usageContainers.length).toBeGreaterThan(0)
|
||||
const totalContainers = screen.getAllByText('5')
|
||||
expect(totalContainers.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should show "unlimited" for infinite quotas (professional API rate limit)', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
total: { apiRateLimit: NUM_INFINITE },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.unlimited/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display reset days for trigger events when applicable', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
total: { triggerEvents: 20000 },
|
||||
reset: { triggerEvents: 7 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Reset text should be visible
|
||||
expect(screen.getByText(/usagePage\.resetsIn/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Verify billing URL button visibility and behavior
|
||||
describe('Billing URL button', () => {
|
||||
it('should show billing button when enableBilling and isCurrentWorkspaceManager', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
setupAppContext({ isCurrentWorkspaceManager: true })
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing button when user is not workspace manager', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
setupAppContext({ isCurrentWorkspaceManager: false })
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing button when billing is disabled', () => {
|
||||
setupProviderContext({ type: Plan.sandbox }, { enableBilling: false })
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 2. Plan Type Display Integration
|
||||
// Tests that different plan types render correct visual elements
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Plan Type Display Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
it('should render sandbox plan with upgrade button (premium badge)', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.sandbox\.for/i)).toBeInTheDocument()
|
||||
// Sandbox shows premium badge upgrade button (not plain)
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render professional plan with plain upgrade button', () => {
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument()
|
||||
// Professional shows plain button because it's not team
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render team plan with plain-style upgrade button', () => {
|
||||
setupProviderContext({ type: Plan.team })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/plans\.team\.name/i)).toBeInTheDocument()
|
||||
// Team plan has isPlain=true, so shows "upgradeBtn.plain" text
|
||||
expect(screen.getByText(/upgradeBtn\.plain/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render upgrade button for enterprise plan', () => {
|
||||
setupProviderContext({ type: Plan.enterprise })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn\.plain/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show education verify button when enableEducationPlan is true and not yet verified', () => {
|
||||
setupProviderContext({ type: Plan.sandbox }, {
|
||||
enableEducationPlan: true,
|
||||
isEducationAccount: false,
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 3. Upgrade Flow Integration
|
||||
// Tests the flow: UpgradeBtn click → setShowPricingModal
|
||||
// and PlanUpgradeModal → close + trigger pricing
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Upgrade Flow Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
})
|
||||
|
||||
// UpgradeBtn triggers pricing modal
|
||||
describe('UpgradeBtn triggers pricing modal', () => {
|
||||
it('should call setShowPricingModal when clicking premium badge upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UpgradeBtn />)
|
||||
|
||||
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
|
||||
await user.click(badgeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call setShowPricingModal when clicking plain upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UpgradeBtn isPlain />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should use custom onClick when provided instead of setShowPricingModal', async () => {
|
||||
const customOnClick = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UpgradeBtn onClick={customOnClick} />)
|
||||
|
||||
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
|
||||
await user.click(badgeText)
|
||||
|
||||
expect(customOnClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fire gtag event with loc parameter when clicked', async () => {
|
||||
const mockGtag = vi.fn()
|
||||
;(window as unknown as Record<string, unknown>).gtag = mockGtag
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UpgradeBtn loc="billing-page" />)
|
||||
|
||||
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
|
||||
await user.click(badgeText)
|
||||
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', { loc: 'billing-page' })
|
||||
delete (window as unknown as Record<string, unknown>).gtag
|
||||
})
|
||||
})
|
||||
|
||||
// PlanUpgradeModal integration: close modal and trigger pricing
|
||||
describe('PlanUpgradeModal upgrade flow', () => {
|
||||
it('should call onClose and setShowPricingModal when clicking upgrade button in modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<PlanUpgradeModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
title="Upgrade Required"
|
||||
description="You need a better plan"
|
||||
/>,
|
||||
)
|
||||
|
||||
// The modal should show title and description
|
||||
expect(screen.getByText('Upgrade Required')).toBeInTheDocument()
|
||||
expect(screen.getByText('You need a better plan')).toBeInTheDocument()
|
||||
|
||||
// Click the upgrade button inside the modal
|
||||
const upgradeText = screen.getByText(/triggerLimitModal\.upgrade/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
// Should close the current modal first
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
// Then open pricing modal
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose and custom onUpgrade when provided', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
const onUpgrade = vi.fn()
|
||||
|
||||
render(
|
||||
<PlanUpgradeModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
onUpgrade={onUpgrade}
|
||||
title="Test"
|
||||
description="Test"
|
||||
/>,
|
||||
)
|
||||
|
||||
const upgradeText = screen.getByText(/triggerLimitModal\.upgrade/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onUpgrade).toHaveBeenCalledTimes(1)
|
||||
// Custom onUpgrade replaces default setShowPricingModal
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClose when clicking dismiss button', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<PlanUpgradeModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
title="Test"
|
||||
description="Test"
|
||||
/>,
|
||||
)
|
||||
|
||||
const dismissBtn = screen.getByText(/triggerLimitModal\.dismiss/i)
|
||||
await user.click(dismissBtn)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Upgrade from PlanComp: clicking upgrade button in plan component triggers pricing
|
||||
describe('PlanComp upgrade button triggers pricing', () => {
|
||||
it('should open pricing modal when clicking upgrade in sandbox plan', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
|
||||
render(<PlanComp loc="test-loc" />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourageShort/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 4. Capacity Full Components Integration
|
||||
// Tests AppsFull, VectorSpaceFull, AnnotationFull, TriggerEventsLimitModal
|
||||
// with real child components (UsageInfo, ProgressBar, UpgradeBtn)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Capacity Full Components Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
// AppsFull renders with correct messaging and components
|
||||
describe('AppsFull integration', () => {
|
||||
it('should display upgrade tip and upgrade button for sandbox plan at capacity', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 5 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
// Should show "full" tip
|
||||
expect(screen.getByText(/apps\.fullTip1$/i)).toBeInTheDocument()
|
||||
// Should show upgrade button
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
// Should show usage/total fraction "5/5"
|
||||
expect(screen.getByText(/5\/5/)).toBeInTheDocument()
|
||||
// Should have a progress bar rendered
|
||||
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display upgrade tip and upgrade button for professional plan', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
usage: { buildApps: 48 },
|
||||
total: { buildApps: 50 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
expect(screen.getByText(/apps\.fullTip1$/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display contact tip and contact button for team plan', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.team,
|
||||
usage: { buildApps: 200 },
|
||||
total: { buildApps: 200 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
// Team plan shows different tip
|
||||
expect(screen.getByText(/apps\.fullTip2$/i)).toBeInTheDocument()
|
||||
// Team plan shows "Contact Us" instead of upgrade
|
||||
expect(screen.getByText(/apps\.contactUs/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render progress bar with correct color based on usage percentage', () => {
|
||||
// 100% usage should show error color
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 5 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
const progressBar = screen.getByTestId('billing-progress-bar')
|
||||
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
|
||||
})
|
||||
})
|
||||
|
||||
// VectorSpaceFull renders with VectorSpaceInfo and UpgradeBtn
|
||||
describe('VectorSpaceFull integration', () => {
|
||||
it('should display full tip, upgrade button, and vector space usage info', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { vectorSpace: 50 },
|
||||
total: { vectorSpace: 50 },
|
||||
})
|
||||
|
||||
render(<VectorSpaceFull />)
|
||||
|
||||
// Should show full tip
|
||||
expect(screen.getByText(/vectorSpace\.fullTip/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/vectorSpace\.fullSolution/i)).toBeInTheDocument()
|
||||
// Should show upgrade button
|
||||
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
|
||||
// Should show vector space usage info
|
||||
expect(screen.getByText(/usagePage\.vectorSpace/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// AnnotationFull renders with Usage component and UpgradeBtn
|
||||
describe('AnnotationFull integration', () => {
|
||||
it('should display annotation full tip, upgrade button, and usage info', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFull />)
|
||||
|
||||
expect(screen.getByText(/annotatedResponse\.fullTipLine1/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/annotatedResponse\.fullTipLine2/i)).toBeInTheDocument()
|
||||
// UpgradeBtn rendered
|
||||
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
|
||||
// Usage component should show annotation quota
|
||||
expect(screen.getByText(/annotatedResponse\.quotaTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// AnnotationFullModal shows modal with usage and upgrade button
|
||||
describe('AnnotationFullModal integration', () => {
|
||||
it('should render modal with annotation info and upgrade button when show is true', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFullModal show={true} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText(/annotatedResponse\.fullTipLine1/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/annotatedResponse\.quotaTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render content when show is false', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFullModal show={false} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByText(/annotatedResponse\.fullTipLine1/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// TriggerEventsLimitModal renders PlanUpgradeModal with embedded UsageInfo
|
||||
describe('TriggerEventsLimitModal integration', () => {
|
||||
it('should display trigger limit title, usage info, and upgrade button', () => {
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show={true}
|
||||
onClose={vi.fn()}
|
||||
onUpgrade={vi.fn()}
|
||||
usage={18000}
|
||||
total={20000}
|
||||
resetInDays={5}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Modal title and description
|
||||
expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/triggerLimitModal\.description/i)).toBeInTheDocument()
|
||||
// Embedded UsageInfo with trigger events data
|
||||
expect(screen.getByText(/triggerLimitModal\.usageTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('18000')).toBeInTheDocument()
|
||||
expect(screen.getByText('20000')).toBeInTheDocument()
|
||||
// Reset info
|
||||
expect(screen.getByText(/usagePage\.resetsIn/i)).toBeInTheDocument()
|
||||
// Upgrade and dismiss buttons
|
||||
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/triggerLimitModal\.dismiss/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onClose and onUpgrade when clicking upgrade', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
const onUpgrade = vi.fn()
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
onUpgrade={onUpgrade}
|
||||
usage={20000}
|
||||
total={20000}
|
||||
/>,
|
||||
)
|
||||
|
||||
const upgradeBtn = screen.getByText(/triggerLimitModal\.upgrade/i)
|
||||
await user.click(upgradeBtn)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onUpgrade).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 5. Header Billing Button Integration
|
||||
// Tests HeaderBillingBtn behavior for different plan states
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Header Billing Button Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
it('should render UpgradeBtn (premium badge) for sandbox plan', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
|
||||
render(<HeaderBillingBtn />)
|
||||
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "pro" badge for professional plan', () => {
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(<HeaderBillingBtn />)
|
||||
|
||||
expect(screen.getByText('pro')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "team" badge for team plan', () => {
|
||||
setupProviderContext({ type: Plan.team })
|
||||
|
||||
render(<HeaderBillingBtn />)
|
||||
|
||||
expect(screen.getByText('team')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return null when billing is disabled', () => {
|
||||
setupProviderContext({ type: Plan.sandbox }, { enableBilling: false })
|
||||
|
||||
const { container } = render(<HeaderBillingBtn />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should return null when plan is not fetched yet', () => {
|
||||
setupProviderContext({ type: Plan.sandbox }, { isFetchedPlan: false })
|
||||
|
||||
const { container } = render(<HeaderBillingBtn />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should call onClick when clicking pro/team badge in non-display-only mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(<HeaderBillingBtn onClick={onClick} />)
|
||||
|
||||
await user.click(screen.getByText('pro'))
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onClick when isDisplayOnly is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(<HeaderBillingBtn onClick={onClick} isDisplayOnly />)
|
||||
|
||||
await user.click(screen.getByText('pro'))
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 6. PriorityLabel Integration
|
||||
// Tests priority badge display for different plan types
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('PriorityLabel Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
it('should display "standard" priority for sandbox plan', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
|
||||
render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.priority\.standard/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "priority" for professional plan with icon', () => {
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.priority\.priority/i)).toBeInTheDocument()
|
||||
// Professional plan should show the priority icon
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "top-priority" for team plan with icon', () => {
|
||||
setupProviderContext({ type: Plan.team })
|
||||
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.priority\.top-priority/i)).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "top-priority" for enterprise plan', () => {
|
||||
setupProviderContext({ type: Plan.enterprise })
|
||||
|
||||
render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.priority\.top-priority/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 7. Usage Display Edge Cases
|
||||
// Tests storage mode, threshold logic, and progress bar color integration
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Usage Display Edge Cases', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
// Vector space storage mode behavior
|
||||
describe('VectorSpace storage mode in PlanComp', () => {
|
||||
it('should show "< 50" for sandbox plan with low vector space usage', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { vectorSpace: 10 },
|
||||
total: { vectorSpace: 50 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Storage mode: usage below threshold shows "< 50"
|
||||
expect(screen.getByText(/</)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show indeterminate progress bar for usage below threshold', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { vectorSpace: 10 },
|
||||
total: { vectorSpace: 50 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Should have an indeterminate progress bar
|
||||
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show actual usage for pro plan above threshold', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
usage: { vectorSpace: 1024 },
|
||||
total: { vectorSpace: 5120 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Pro plan above threshold shows actual value
|
||||
expect(screen.getByText('1024')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Progress bar color logic through real components
|
||||
describe('Progress bar color reflects usage severity', () => {
|
||||
it('should show normal color for low usage percentage', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 1 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// 20% usage - normal color
|
||||
const progressBars = screen.getAllByTestId('billing-progress-bar')
|
||||
// At least one should have the normal progress color
|
||||
const hasNormalColor = progressBars.some(bar =>
|
||||
bar.classList.contains('bg-components-progress-bar-progress-solid'),
|
||||
)
|
||||
expect(hasNormalColor).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Reset days calculation in PlanComp
|
||||
describe('Reset days integration', () => {
|
||||
it('should not show reset for sandbox trigger events (no reset_date)', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
total: { triggerEvents: 3000 },
|
||||
reset: { triggerEvents: null },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Find the trigger events section - should not have reset text
|
||||
const triggerSection = screen.getByText(/usagePage\.triggerEvents/i)
|
||||
const parent = triggerSection.closest('[class*="flex flex-col"]')
|
||||
// No reset text should appear (sandbox doesn't show reset for triggerEvents)
|
||||
expect(parent?.textContent).not.toContain('usagePage.resetsIn')
|
||||
})
|
||||
|
||||
it('should show reset for professional trigger events with reset date', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
total: { triggerEvents: 20000 },
|
||||
reset: { triggerEvents: 14 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Professional plan with finite triggerEvents should show reset
|
||||
const resetTexts = screen.getAllByText(/usagePage\.resetsIn/i)
|
||||
expect(resetTexts.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 8. Cross-Component Upgrade Flow (End-to-End)
|
||||
// Tests the complete chain: capacity alert → upgrade button → pricing
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Cross-Component Upgrade Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
it('should trigger pricing from AppsFull upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 5 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="app-create" />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourageShort/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger pricing from VectorSpaceFull upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { vectorSpace: 50 },
|
||||
total: { vectorSpace: 50 },
|
||||
})
|
||||
|
||||
render(<VectorSpaceFull />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger pricing from AnnotationFull upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFull />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger pricing from TriggerEventsLimitModal through PlanUpgradeModal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
onUpgrade={vi.fn()}
|
||||
usage={20000}
|
||||
total={20000}
|
||||
/>,
|
||||
)
|
||||
|
||||
// TriggerEventsLimitModal passes onUpgrade to PlanUpgradeModal
|
||||
// PlanUpgradeModal's upgrade button calls onClose then onUpgrade
|
||||
const upgradeBtn = screen.getByText(/triggerLimitModal\.upgrade/i)
|
||||
await user.click(upgradeBtn)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger pricing from AnnotationFullModal upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFullModal show={true} onHide={vi.fn()} />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,271 +0,0 @@
|
||||
/**
|
||||
* Integration Test: Plugin Authentication Flow
|
||||
*
|
||||
* Tests the integration between PluginAuth, usePluginAuth hook,
|
||||
* Authorize/Authorized components, and credential management.
|
||||
* Verifies the complete auth flow from checking authorization status
|
||||
* to rendering the correct UI state.
|
||||
*/
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AuthCategory, CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'plugin.auth.setUpTip': 'Set up your credentials',
|
||||
'plugin.auth.authorized': 'Authorized',
|
||||
'plugin.auth.apiKey': 'API Key',
|
||||
'plugin.auth.oauth': 'OAuth',
|
||||
}
|
||||
return map[key] ?? key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
const mockUsePluginAuth = vi.fn()
|
||||
vi.mock('@/app/components/plugins/plugin-auth/hooks/use-plugin-auth', () => ({
|
||||
usePluginAuth: (...args: unknown[]) => mockUsePluginAuth(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-auth/authorize', () => ({
|
||||
default: ({ pluginPayload, canOAuth, canApiKey }: {
|
||||
pluginPayload: { provider: string }
|
||||
canOAuth: boolean
|
||||
canApiKey: boolean
|
||||
}) => (
|
||||
<div data-testid="authorize-component">
|
||||
<span data-testid="auth-provider">{pluginPayload.provider}</span>
|
||||
{canOAuth && <span data-testid="auth-oauth">OAuth available</span>}
|
||||
{canApiKey && <span data-testid="auth-apikey">API Key available</span>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-auth/authorized', () => ({
|
||||
default: ({ pluginPayload, credentials }: {
|
||||
pluginPayload: { provider: string }
|
||||
credentials: Array<{ id: string, name: string }>
|
||||
}) => (
|
||||
<div data-testid="authorized-component">
|
||||
<span data-testid="auth-provider">{pluginPayload.provider}</span>
|
||||
<span data-testid="auth-credential-count">
|
||||
{credentials.length}
|
||||
{' '}
|
||||
credentials
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const { default: PluginAuth } = await import('@/app/components/plugins/plugin-auth/plugin-auth')
|
||||
|
||||
describe('Plugin Authentication Flow Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
const basePayload = {
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
}
|
||||
|
||||
describe('Unauthorized State', () => {
|
||||
it('renders Authorize component when not authorized', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: false,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
render(<PluginAuth pluginPayload={basePayload} />)
|
||||
|
||||
expect(screen.getByTestId('authorize-component')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('authorized-component')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('auth-apikey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows OAuth option when plugin supports it', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: false,
|
||||
canOAuth: true,
|
||||
canApiKey: true,
|
||||
credentials: [],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
render(<PluginAuth pluginPayload={basePayload} />)
|
||||
|
||||
expect(screen.getByTestId('auth-oauth')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('auth-apikey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies className to wrapper when not authorized', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: false,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<PluginAuth pluginPayload={basePayload} className="custom-class" />,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authorized State', () => {
|
||||
it('renders Authorized component when authorized and no children', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: true,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [
|
||||
{ id: 'cred-1', name: 'My API Key', is_default: true },
|
||||
],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
render(<PluginAuth pluginPayload={basePayload} />)
|
||||
|
||||
expect(screen.queryByTestId('authorize-component')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('authorized-component')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('auth-credential-count')).toHaveTextContent('1 credentials')
|
||||
})
|
||||
|
||||
it('renders children instead of Authorized when authorized and children provided', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: true,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [{ id: 'cred-1', name: 'Key', is_default: true }],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
render(
|
||||
<PluginAuth pluginPayload={basePayload}>
|
||||
<div data-testid="custom-children">Custom authorized view</div>
|
||||
</PluginAuth>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('authorize-component')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('authorized-component')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('custom-children')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not apply className when authorized', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: true,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [{ id: 'cred-1', name: 'Key', is_default: true }],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<PluginAuth pluginPayload={basePayload} className="custom-class" />,
|
||||
)
|
||||
|
||||
expect(container.firstChild).not.toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Auth Category Integration', () => {
|
||||
it('passes correct provider to usePluginAuth for tool category', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: false,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
const toolPayload = {
|
||||
category: AuthCategory.tool,
|
||||
provider: 'google-search-provider',
|
||||
}
|
||||
|
||||
render(<PluginAuth pluginPayload={toolPayload} />)
|
||||
|
||||
expect(mockUsePluginAuth).toHaveBeenCalledWith(toolPayload, true)
|
||||
expect(screen.getByTestId('auth-provider')).toHaveTextContent('google-search-provider')
|
||||
})
|
||||
|
||||
it('passes correct provider to usePluginAuth for datasource category', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: false,
|
||||
canOAuth: true,
|
||||
canApiKey: false,
|
||||
credentials: [],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
const dsPayload = {
|
||||
category: AuthCategory.datasource,
|
||||
provider: 'notion-datasource',
|
||||
}
|
||||
|
||||
render(<PluginAuth pluginPayload={dsPayload} />)
|
||||
|
||||
expect(mockUsePluginAuth).toHaveBeenCalledWith(dsPayload, true)
|
||||
expect(screen.getByTestId('auth-oauth')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('auth-apikey')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple Credentials', () => {
|
||||
it('shows credential count when multiple credentials exist', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: true,
|
||||
canOAuth: true,
|
||||
canApiKey: true,
|
||||
credentials: [
|
||||
{ id: 'cred-1', name: 'API Key 1', is_default: true },
|
||||
{ id: 'cred-2', name: 'API Key 2', is_default: false },
|
||||
{ id: 'cred-3', name: 'OAuth Token', is_default: false, credential_type: CredentialTypeEnum.OAUTH2 },
|
||||
],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
render(<PluginAuth pluginPayload={basePayload} />)
|
||||
|
||||
expect(screen.getByTestId('auth-credential-count')).toHaveTextContent('3 credentials')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,224 +0,0 @@
|
||||
/**
|
||||
* Integration Test: Plugin Card Rendering Pipeline
|
||||
*
|
||||
* Tests the integration between Card, Icon, Title, Description,
|
||||
* OrgInfo, CornerMark, and CardMoreInfo components. Verifies that
|
||||
* plugin data flows correctly through the card rendering pipeline.
|
||||
*/
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('#i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: 'light' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config', () => ({
|
||||
renderI18nObject: (obj: Record<string, string>, locale: string) => obj[locale] || obj.en_US || '',
|
||||
}))
|
||||
|
||||
vi.mock('@/types/app', () => ({
|
||||
Theme: { dark: 'dark', light: 'light' },
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(a => typeof a === 'string' && a).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/hooks', () => ({
|
||||
useCategories: () => ({
|
||||
categoriesMap: {
|
||||
tool: { label: 'Tool' },
|
||||
model: { label: 'Model' },
|
||||
extension: { label: 'Extension' },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/base/badges/partner', () => ({
|
||||
default: () => <span data-testid="partner-badge">Partner</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/base/badges/verified', () => ({
|
||||
default: () => <span data-testid="verified-badge">Verified</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
|
||||
default: ({ src, installed, installFailed }: { src: string | object, installed?: boolean, installFailed?: boolean }) => (
|
||||
<div data-testid="card-icon" data-installed={installed} data-install-failed={installFailed}>
|
||||
{typeof src === 'string' ? src : 'emoji-icon'}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/corner-mark', () => ({
|
||||
default: ({ text }: { text: string }) => (
|
||||
<div data-testid="corner-mark">{text}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/description', () => ({
|
||||
default: ({ text, descriptionLineRows }: { text: string, descriptionLineRows?: number }) => (
|
||||
<div data-testid="description" data-rows={descriptionLineRows}>{text}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
|
||||
default: ({ orgName, packageName }: { orgName: string, packageName: string }) => (
|
||||
<div data-testid="org-info">
|
||||
{orgName}
|
||||
/
|
||||
{packageName}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/placeholder', () => ({
|
||||
default: ({ text }: { text: string }) => (
|
||||
<div data-testid="placeholder">{text}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/title', () => ({
|
||||
default: ({ title }: { title: string }) => (
|
||||
<div data-testid="title">{title}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const { default: Card } = await import('@/app/components/plugins/card/index')
|
||||
type CardPayload = Parameters<typeof Card>[0]['payload']
|
||||
|
||||
describe('Plugin Card Rendering Integration', () => {
|
||||
beforeEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
const makePayload = (overrides = {}) => ({
|
||||
category: 'tool',
|
||||
type: 'plugin',
|
||||
name: 'google-search',
|
||||
org: 'langgenius',
|
||||
label: { en_US: 'Google Search', zh_Hans: 'Google搜索' },
|
||||
brief: { en_US: 'Search the web using Google', zh_Hans: '使用Google搜索网页' },
|
||||
icon: 'https://example.com/icon.png',
|
||||
verified: true,
|
||||
badges: [] as string[],
|
||||
...overrides,
|
||||
}) as CardPayload
|
||||
|
||||
it('renders a complete plugin card with all subcomponents', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} />)
|
||||
|
||||
expect(screen.getByTestId('card-icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Google Search')
|
||||
expect(screen.getByTestId('org-info')).toHaveTextContent('langgenius/google-search')
|
||||
expect(screen.getByTestId('description')).toHaveTextContent('Search the web using Google')
|
||||
})
|
||||
|
||||
it('shows corner mark with category label when not hidden', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} />)
|
||||
|
||||
expect(screen.getByTestId('corner-mark')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides corner mark when hideCornerMark is true', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} hideCornerMark />)
|
||||
|
||||
expect(screen.queryByTestId('corner-mark')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows installed status on icon', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} installed />)
|
||||
|
||||
const icon = screen.getByTestId('card-icon')
|
||||
expect(icon).toHaveAttribute('data-installed', 'true')
|
||||
})
|
||||
|
||||
it('shows install failed status on icon', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} installFailed />)
|
||||
|
||||
const icon = screen.getByTestId('card-icon')
|
||||
expect(icon).toHaveAttribute('data-install-failed', 'true')
|
||||
})
|
||||
|
||||
it('renders verified badge when plugin is verified', () => {
|
||||
const payload = makePayload({ verified: true })
|
||||
render(<Card payload={payload} />)
|
||||
|
||||
expect(screen.getByTestId('verified-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders partner badge when plugin has partner badge', () => {
|
||||
const payload = makePayload({ badges: ['partner'] })
|
||||
render(<Card payload={payload} />)
|
||||
|
||||
expect(screen.getByTestId('partner-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders footer content when provided', () => {
|
||||
const payload = makePayload()
|
||||
render(
|
||||
<Card
|
||||
payload={payload}
|
||||
footer={<div data-testid="custom-footer">Custom footer</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-footer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders titleLeft content when provided', () => {
|
||||
const payload = makePayload()
|
||||
render(
|
||||
<Card
|
||||
payload={payload}
|
||||
titleLeft={<span data-testid="title-left-content">New</span>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('title-left-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses dark icon when theme is dark and icon_dark is provided', () => {
|
||||
vi.doMock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: 'dark' }),
|
||||
}))
|
||||
|
||||
const payload = makePayload({
|
||||
icon: 'https://example.com/icon-light.png',
|
||||
icon_dark: 'https://example.com/icon-dark.png',
|
||||
})
|
||||
|
||||
render(<Card payload={payload} />)
|
||||
expect(screen.getByTestId('card-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows loading placeholder when isLoading is true', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} isLoading loadingFileName="uploading.difypkg" />)
|
||||
|
||||
expect(screen.getByTestId('placeholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders description with custom line rows', () => {
|
||||
const payload = makePayload()
|
||||
render(<Card payload={payload} descriptionLineRows={3} />)
|
||||
|
||||
const description = screen.getByTestId('description')
|
||||
expect(description).toHaveAttribute('data-rows', '3')
|
||||
})
|
||||
})
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* Integration Test: Plugin Data Utilities
|
||||
*
|
||||
* Tests the integration between plugin utility functions, including
|
||||
* tag/category validation, form schema transformation, and
|
||||
* credential data processing. Verifies that these utilities work
|
||||
* correctly together in processing plugin metadata.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { transformFormSchemasSecretInput } from '@/app/components/plugins/plugin-auth/utils'
|
||||
import { getValidCategoryKeys, getValidTagKeys } from '@/app/components/plugins/utils'
|
||||
|
||||
type TagInput = Parameters<typeof getValidTagKeys>[0]
|
||||
|
||||
describe('Plugin Data Utilities Integration', () => {
|
||||
describe('Tag and Category Validation Pipeline', () => {
|
||||
it('validates tags and categories in a metadata processing flow', () => {
|
||||
const pluginMetadata = {
|
||||
tags: ['search', 'productivity', 'invalid-tag', 'media-generate'],
|
||||
category: 'tool',
|
||||
}
|
||||
|
||||
const validTags = getValidTagKeys(pluginMetadata.tags as TagInput)
|
||||
expect(validTags.length).toBeGreaterThan(0)
|
||||
expect(validTags.length).toBeLessThanOrEqual(pluginMetadata.tags.length)
|
||||
|
||||
const validCategory = getValidCategoryKeys(pluginMetadata.category)
|
||||
expect(validCategory).toBeDefined()
|
||||
})
|
||||
|
||||
it('handles completely invalid metadata gracefully', () => {
|
||||
const invalidMetadata = {
|
||||
tags: ['nonexistent-1', 'nonexistent-2'],
|
||||
category: 'nonexistent-category',
|
||||
}
|
||||
|
||||
const validTags = getValidTagKeys(invalidMetadata.tags as TagInput)
|
||||
expect(validTags).toHaveLength(0)
|
||||
|
||||
const validCategory = getValidCategoryKeys(invalidMetadata.category)
|
||||
expect(validCategory).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles undefined and empty inputs', () => {
|
||||
expect(getValidTagKeys([] as TagInput)).toHaveLength(0)
|
||||
expect(getValidCategoryKeys(undefined)).toBeUndefined()
|
||||
expect(getValidCategoryKeys('')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Credential Secret Masking Pipeline', () => {
|
||||
it('masks secrets when displaying credential form data', () => {
|
||||
const credentialValues = {
|
||||
api_key: 'sk-abc123456789',
|
||||
api_endpoint: 'https://api.example.com',
|
||||
secret_token: 'secret-token-value',
|
||||
description: 'My credential set',
|
||||
}
|
||||
|
||||
const secretFields = ['api_key', 'secret_token']
|
||||
|
||||
const displayValues = transformFormSchemasSecretInput(secretFields, credentialValues)
|
||||
|
||||
expect(displayValues.api_key).toBe('[__HIDDEN__]')
|
||||
expect(displayValues.secret_token).toBe('[__HIDDEN__]')
|
||||
expect(displayValues.api_endpoint).toBe('https://api.example.com')
|
||||
expect(displayValues.description).toBe('My credential set')
|
||||
})
|
||||
|
||||
it('preserves original values when no secret fields', () => {
|
||||
const values = {
|
||||
name: 'test',
|
||||
endpoint: 'https://api.example.com',
|
||||
}
|
||||
|
||||
const result = transformFormSchemasSecretInput([], values)
|
||||
expect(result).toEqual(values)
|
||||
})
|
||||
|
||||
it('handles falsy secret values without masking', () => {
|
||||
const values = {
|
||||
api_key: '',
|
||||
secret: null as unknown as string,
|
||||
other: 'visible',
|
||||
}
|
||||
|
||||
const result = transformFormSchemasSecretInput(['api_key', 'secret'], values)
|
||||
expect(result.api_key).toBe('')
|
||||
expect(result.secret).toBeNull()
|
||||
expect(result.other).toBe('visible')
|
||||
})
|
||||
|
||||
it('does not mutate the original values object', () => {
|
||||
const original = {
|
||||
api_key: 'my-secret-key',
|
||||
name: 'test',
|
||||
}
|
||||
const originalCopy = { ...original }
|
||||
|
||||
transformFormSchemasSecretInput(['api_key'], original)
|
||||
|
||||
expect(original).toEqual(originalCopy)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Combined Plugin Metadata Validation', () => {
|
||||
it('processes a complete plugin entry with tags and credentials', () => {
|
||||
const pluginEntry = {
|
||||
name: 'test-plugin',
|
||||
category: 'tool',
|
||||
tags: ['search', 'invalid-tag'],
|
||||
credentials: {
|
||||
api_key: 'sk-test-key-123',
|
||||
base_url: 'https://api.test.com',
|
||||
},
|
||||
secretFields: ['api_key'],
|
||||
}
|
||||
|
||||
const validCategory = getValidCategoryKeys(pluginEntry.category)
|
||||
expect(validCategory).toBe('tool')
|
||||
|
||||
const validTags = getValidTagKeys(pluginEntry.tags as TagInput)
|
||||
expect(validTags).toContain('search')
|
||||
|
||||
const displayCredentials = transformFormSchemasSecretInput(
|
||||
pluginEntry.secretFields,
|
||||
pluginEntry.credentials,
|
||||
)
|
||||
expect(displayCredentials.api_key).toBe('[__HIDDEN__]')
|
||||
expect(displayCredentials.base_url).toBe('https://api.test.com')
|
||||
|
||||
expect(pluginEntry.credentials.api_key).toBe('sk-test-key-123')
|
||||
})
|
||||
|
||||
it('handles multiple plugins in batch processing', () => {
|
||||
const plugins = [
|
||||
{ tags: ['search', 'productivity'], category: 'tool' },
|
||||
{ tags: ['image', 'design'], category: 'model' },
|
||||
{ tags: ['invalid'], category: 'extension' },
|
||||
]
|
||||
|
||||
const results = plugins.map(p => ({
|
||||
validTags: getValidTagKeys(p.tags as TagInput),
|
||||
validCategory: getValidCategoryKeys(p.category),
|
||||
}))
|
||||
|
||||
expect(results[0].validTags.length).toBeGreaterThan(0)
|
||||
expect(results[0].validCategory).toBe('tool')
|
||||
|
||||
expect(results[1].validTags).toContain('image')
|
||||
expect(results[1].validTags).toContain('design')
|
||||
expect(results[1].validCategory).toBe('model')
|
||||
|
||||
expect(results[2].validTags).toHaveLength(0)
|
||||
expect(results[2].validCategory).toBe('extension')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* Integration Test: Plugin Installation Flow
|
||||
*
|
||||
* Tests the integration between GitHub release fetching, version comparison,
|
||||
* upload handling, and task status polling. Verifies the complete plugin
|
||||
* installation pipeline from source discovery to completion.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
GITHUB_ACCESS_TOKEN: '',
|
||||
}))
|
||||
|
||||
const mockToastNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: (...args: unknown[]) => mockToastNotify(...args) },
|
||||
}))
|
||||
|
||||
const mockUploadGitHub = vi.fn()
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
uploadGitHub: (...args: unknown[]) => mockUploadGitHub(...args),
|
||||
checkTaskStatus: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/semver', () => ({
|
||||
compareVersion: (a: string, b: string) => {
|
||||
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number)
|
||||
const [aMajor, aMinor = 0, aPatch = 0] = parse(a)
|
||||
const [bMajor, bMinor = 0, bPatch = 0] = parse(b)
|
||||
if (aMajor !== bMajor)
|
||||
return aMajor > bMajor ? 1 : -1
|
||||
if (aMinor !== bMinor)
|
||||
return aMinor > bMinor ? 1 : -1
|
||||
if (aPatch !== bPatch)
|
||||
return aPatch > bPatch ? 1 : -1
|
||||
return 0
|
||||
},
|
||||
getLatestVersion: (versions: string[]) => {
|
||||
return versions.sort((a, b) => {
|
||||
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number)
|
||||
const [aMaj, aMin = 0, aPat = 0] = parse(a)
|
||||
const [bMaj, bMin = 0, bPat = 0] = parse(b)
|
||||
if (aMaj !== bMaj)
|
||||
return bMaj - aMaj
|
||||
if (aMin !== bMin)
|
||||
return bMin - aMin
|
||||
return bPat - aPat
|
||||
})[0]
|
||||
},
|
||||
}))
|
||||
|
||||
const { useGitHubReleases, useGitHubUpload } = await import(
|
||||
'@/app/components/plugins/install-plugin/hooks',
|
||||
)
|
||||
|
||||
describe('Plugin Installation Flow Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
globalThis.fetch = vi.fn()
|
||||
})
|
||||
|
||||
describe('GitHub Release Discovery → Version Check → Upload Pipeline', () => {
|
||||
it('fetches releases, checks for updates, and uploads the new version', async () => {
|
||||
const mockReleases = [
|
||||
{
|
||||
tag_name: 'v2.0.0',
|
||||
assets: [{ browser_download_url: 'https://github.com/test/v2.difypkg', name: 'plugin-v2.difypkg' }],
|
||||
},
|
||||
{
|
||||
tag_name: 'v1.5.0',
|
||||
assets: [{ browser_download_url: 'https://github.com/test/v1.5.difypkg', name: 'plugin-v1.5.difypkg' }],
|
||||
},
|
||||
{
|
||||
tag_name: 'v1.0.0',
|
||||
assets: [{ browser_download_url: 'https://github.com/test/v1.difypkg', name: 'plugin-v1.difypkg' }],
|
||||
},
|
||||
]
|
||||
|
||||
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockReleases),
|
||||
})
|
||||
|
||||
mockUploadGitHub.mockResolvedValue({
|
||||
manifest: { name: 'test-plugin', version: '2.0.0' },
|
||||
unique_identifier: 'test-plugin:2.0.0',
|
||||
})
|
||||
|
||||
const { fetchReleases, checkForUpdates } = useGitHubReleases()
|
||||
|
||||
const releases = await fetchReleases('test-org', 'test-repo')
|
||||
expect(releases).toHaveLength(3)
|
||||
expect(releases[0].tag_name).toBe('v2.0.0')
|
||||
|
||||
const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0')
|
||||
expect(needUpdate).toBe(true)
|
||||
expect(toastProps.message).toContain('v2.0.0')
|
||||
|
||||
const { handleUpload } = useGitHubUpload()
|
||||
const onSuccess = vi.fn()
|
||||
const result = await handleUpload(
|
||||
'https://github.com/test-org/test-repo',
|
||||
'v2.0.0',
|
||||
'plugin-v2.difypkg',
|
||||
onSuccess,
|
||||
)
|
||||
|
||||
expect(mockUploadGitHub).toHaveBeenCalledWith(
|
||||
'https://github.com/test-org/test-repo',
|
||||
'v2.0.0',
|
||||
'plugin-v2.difypkg',
|
||||
)
|
||||
expect(onSuccess).toHaveBeenCalledWith({
|
||||
manifest: { name: 'test-plugin', version: '2.0.0' },
|
||||
unique_identifier: 'test-plugin:2.0.0',
|
||||
})
|
||||
expect(result).toEqual({
|
||||
manifest: { name: 'test-plugin', version: '2.0.0' },
|
||||
unique_identifier: 'test-plugin:2.0.0',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles no new version available', async () => {
|
||||
const mockReleases = [
|
||||
{
|
||||
tag_name: 'v1.0.0',
|
||||
assets: [{ browser_download_url: 'https://github.com/test/v1.difypkg', name: 'plugin-v1.difypkg' }],
|
||||
},
|
||||
]
|
||||
|
||||
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockReleases),
|
||||
})
|
||||
|
||||
const { fetchReleases, checkForUpdates } = useGitHubReleases()
|
||||
|
||||
const releases = await fetchReleases('test-org', 'test-repo')
|
||||
const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0')
|
||||
|
||||
expect(needUpdate).toBe(false)
|
||||
expect(toastProps.type).toBe('info')
|
||||
expect(toastProps.message).toBe('No new version available')
|
||||
})
|
||||
|
||||
it('handles empty releases', async () => {
|
||||
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
})
|
||||
|
||||
const { fetchReleases, checkForUpdates } = useGitHubReleases()
|
||||
|
||||
const releases = await fetchReleases('test-org', 'test-repo')
|
||||
expect(releases).toHaveLength(0)
|
||||
|
||||
const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0')
|
||||
expect(needUpdate).toBe(false)
|
||||
expect(toastProps.type).toBe('error')
|
||||
expect(toastProps.message).toBe('Input releases is empty')
|
||||
})
|
||||
|
||||
it('handles fetch failure gracefully', async () => {
|
||||
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
})
|
||||
|
||||
const { fetchReleases } = useGitHubReleases()
|
||||
const releases = await fetchReleases('nonexistent-org', 'nonexistent-repo')
|
||||
|
||||
expect(releases).toEqual([])
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('handles upload failure gracefully', async () => {
|
||||
mockUploadGitHub.mockRejectedValue(new Error('Upload failed'))
|
||||
|
||||
const { handleUpload } = useGitHubUpload()
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
await expect(
|
||||
handleUpload('https://github.com/test/repo', 'v1.0.0', 'plugin.difypkg', onSuccess),
|
||||
).rejects.toThrow('Upload failed')
|
||||
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error', message: 'Error uploading package' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Task Status Polling Integration', () => {
|
||||
it('polls until plugin installation succeeds', async () => {
|
||||
const mockCheckTaskStatus = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
task: {
|
||||
plugins: [{ plugin_unique_identifier: 'test:1.0.0', status: 'running' }],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
task: {
|
||||
plugins: [{ plugin_unique_identifier: 'test:1.0.0', status: 'success' }],
|
||||
},
|
||||
})
|
||||
|
||||
const { checkTaskStatus: fetchCheckTaskStatus } = await import('@/service/plugins')
|
||||
;(fetchCheckTaskStatus as ReturnType<typeof vi.fn>).mockImplementation(mockCheckTaskStatus)
|
||||
|
||||
vi.mock('@/utils', () => ({
|
||||
sleep: () => Promise.resolve(),
|
||||
}))
|
||||
|
||||
const { default: checkTaskStatus } = await import(
|
||||
'@/app/components/plugins/install-plugin/base/check-task-status',
|
||||
)
|
||||
|
||||
const checker = checkTaskStatus()
|
||||
const result = await checker.check({
|
||||
taskId: 'task-123',
|
||||
pluginUniqueIdentifier: 'test:1.0.0',
|
||||
})
|
||||
|
||||
expect(result.status).toBe('success')
|
||||
})
|
||||
|
||||
it('returns failure when plugin not found in task', async () => {
|
||||
const mockCheckTaskStatus = vi.fn().mockResolvedValue({
|
||||
task: {
|
||||
plugins: [{ plugin_unique_identifier: 'other:1.0.0', status: 'success' }],
|
||||
},
|
||||
})
|
||||
|
||||
const { checkTaskStatus: fetchCheckTaskStatus } = await import('@/service/plugins')
|
||||
;(fetchCheckTaskStatus as ReturnType<typeof vi.fn>).mockImplementation(mockCheckTaskStatus)
|
||||
|
||||
const { default: checkTaskStatus } = await import(
|
||||
'@/app/components/plugins/install-plugin/base/check-task-status',
|
||||
)
|
||||
|
||||
const checker = checkTaskStatus()
|
||||
const result = await checker.check({
|
||||
taskId: 'task-123',
|
||||
pluginUniqueIdentifier: 'test:1.0.0',
|
||||
})
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.error).toBe('Plugin package not found')
|
||||
})
|
||||
|
||||
it('stops polling when stop() is called', async () => {
|
||||
const { default: checkTaskStatus } = await import(
|
||||
'@/app/components/plugins/install-plugin/base/check-task-status',
|
||||
)
|
||||
|
||||
const checker = checkTaskStatus()
|
||||
checker.stop()
|
||||
|
||||
const result = await checker.check({
|
||||
taskId: 'task-123',
|
||||
pluginUniqueIdentifier: 'test:1.0.0',
|
||||
})
|
||||
|
||||
expect(result.status).toBe('success')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,97 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { pluginInstallLimit } from '@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit'
|
||||
import { InstallationScope } from '@/types/feature'
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Plugin Marketplace to Install Flow', () => {
|
||||
describe('install permission validation pipeline', () => {
|
||||
const systemFeaturesAll = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
|
||||
const systemFeaturesMarketplaceOnly = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: true,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
|
||||
const systemFeaturesOfficialOnly = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.OFFICIAL_ONLY,
|
||||
},
|
||||
}
|
||||
|
||||
it('should allow marketplace plugin when all sources allowed', () => {
|
||||
const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' } }
|
||||
const result = pluginInstallLimit(plugin as never, systemFeaturesAll as never)
|
||||
expect(result.canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow github plugin when all sources allowed', () => {
|
||||
const plugin = { from: 'github' as const, verification: { authorized_category: 'langgenius' } }
|
||||
const result = pluginInstallLimit(plugin as never, systemFeaturesAll as never)
|
||||
expect(result.canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should block github plugin when marketplace only', () => {
|
||||
const plugin = { from: 'github' as const, verification: { authorized_category: 'langgenius' } }
|
||||
const result = pluginInstallLimit(plugin as never, systemFeaturesMarketplaceOnly as never)
|
||||
expect(result.canInstall).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow marketplace plugin when marketplace only', () => {
|
||||
const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'partner' } }
|
||||
const result = pluginInstallLimit(plugin as never, systemFeaturesMarketplaceOnly as never)
|
||||
expect(result.canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow official plugin when official only', () => {
|
||||
const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' } }
|
||||
const result = pluginInstallLimit(plugin as never, systemFeaturesOfficialOnly as never)
|
||||
expect(result.canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should block community plugin when official only', () => {
|
||||
const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'community' } }
|
||||
const result = pluginInstallLimit(plugin as never, systemFeaturesOfficialOnly as never)
|
||||
expect(result.canInstall).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin source classification', () => {
|
||||
it('should correctly classify plugin install sources', () => {
|
||||
const sources = ['marketplace', 'github', 'package'] as const
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: true,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
|
||||
const results = sources.map(source => ({
|
||||
source,
|
||||
canInstall: pluginInstallLimit(
|
||||
{ from: source, verification: { authorized_category: 'langgenius' } } as never,
|
||||
features as never,
|
||||
).canInstall,
|
||||
}))
|
||||
|
||||
expect(results.find(r => r.source === 'marketplace')?.canInstall).toBe(true)
|
||||
expect(results.find(r => r.source === 'github')?.canInstall).toBe(false)
|
||||
expect(results.find(r => r.source === 'package')?.canInstall).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,120 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { useStore } from '@/app/components/plugins/plugin-page/filter-management/store'
|
||||
|
||||
describe('Plugin Page Filter Management Integration', () => {
|
||||
beforeEach(() => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
act(() => {
|
||||
result.current.setTagList([])
|
||||
result.current.setCategoryList([])
|
||||
result.current.setShowTagManagementModal(false)
|
||||
result.current.setShowCategoryManagementModal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tag and category filter lifecycle', () => {
|
||||
it('should manage full tag lifecycle: add -> update -> clear', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
|
||||
const initialTags = [
|
||||
{ name: 'search', label: { en_US: 'Search' } },
|
||||
{ name: 'productivity', label: { en_US: 'Productivity' } },
|
||||
]
|
||||
|
||||
act(() => {
|
||||
result.current.setTagList(initialTags as never[])
|
||||
})
|
||||
expect(result.current.tagList).toHaveLength(2)
|
||||
|
||||
const updatedTags = [
|
||||
...initialTags,
|
||||
{ name: 'image', label: { en_US: 'Image' } },
|
||||
]
|
||||
|
||||
act(() => {
|
||||
result.current.setTagList(updatedTags as never[])
|
||||
})
|
||||
expect(result.current.tagList).toHaveLength(3)
|
||||
|
||||
act(() => {
|
||||
result.current.setTagList([])
|
||||
})
|
||||
expect(result.current.tagList).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should manage full category lifecycle: add -> update -> clear', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
|
||||
const categories = [
|
||||
{ name: 'tool', label: { en_US: 'Tool' } },
|
||||
{ name: 'model', label: { en_US: 'Model' } },
|
||||
]
|
||||
|
||||
act(() => {
|
||||
result.current.setCategoryList(categories as never[])
|
||||
})
|
||||
expect(result.current.categoryList).toHaveLength(2)
|
||||
|
||||
act(() => {
|
||||
result.current.setCategoryList([])
|
||||
})
|
||||
expect(result.current.categoryList).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('modal state management', () => {
|
||||
it('should manage tag management modal independently', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowTagManagementModal(true)
|
||||
})
|
||||
expect(result.current.showTagManagementModal).toBe(true)
|
||||
expect(result.current.showCategoryManagementModal).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.setShowTagManagementModal(false)
|
||||
})
|
||||
expect(result.current.showTagManagementModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should manage category management modal independently', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowCategoryManagementModal(true)
|
||||
})
|
||||
expect(result.current.showCategoryManagementModal).toBe(true)
|
||||
expect(result.current.showTagManagementModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should support both modals open simultaneously', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowTagManagementModal(true)
|
||||
result.current.setShowCategoryManagementModal(true)
|
||||
})
|
||||
|
||||
expect(result.current.showTagManagementModal).toBe(true)
|
||||
expect(result.current.showCategoryManagementModal).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('state persistence across renders', () => {
|
||||
it('should maintain filter state when re-rendered', () => {
|
||||
const { result, rerender } = renderHook(() => useStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setTagList([{ name: 'search' }] as never[])
|
||||
result.current.setCategoryList([{ name: 'tool' }] as never[])
|
||||
})
|
||||
|
||||
rerender()
|
||||
|
||||
expect(result.current.tagList).toHaveLength(1)
|
||||
expect(result.current.categoryList).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,369 +0,0 @@
|
||||
import type { Collection } from '@/app/components/tools/types'
|
||||
/**
|
||||
* Integration Test: Tool Browsing & Filtering Flow
|
||||
*
|
||||
* Tests the integration between ProviderList, TabSliderNew, LabelFilter,
|
||||
* Input (search), and card rendering. Verifies that tab switching, keyword
|
||||
* filtering, and label filtering work together correctly.
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
|
||||
// ---- Mocks ----
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'type.builtIn': 'Built-in',
|
||||
'type.custom': 'Custom',
|
||||
'type.workflow': 'Workflow',
|
||||
'noTools': 'No tools found',
|
||||
}
|
||||
return map[key] ?? key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('nuqs', () => ({
|
||||
useQueryState: () => ['builtin', vi.fn()],
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({ enable_marketplace: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/hooks', () => ({
|
||||
useTags: () => ({
|
||||
getTagLabel: (key: string) => key,
|
||||
tags: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useCheckInstalled: () => ({ data: null }),
|
||||
useInvalidateInstalledPluginList: () => vi.fn(),
|
||||
}))
|
||||
|
||||
const mockCollections: Collection[] = [
|
||||
{
|
||||
id: 'google-search',
|
||||
name: 'google_search',
|
||||
author: 'Dify',
|
||||
description: { en_US: 'Google Search Tool', zh_Hans: 'Google搜索工具' },
|
||||
icon: 'https://example.com/google.png',
|
||||
label: { en_US: 'Google Search', zh_Hans: 'Google搜索' },
|
||||
type: CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: true,
|
||||
allow_delete: false,
|
||||
labels: ['search'],
|
||||
},
|
||||
{
|
||||
id: 'weather-api',
|
||||
name: 'weather_api',
|
||||
author: 'Dify',
|
||||
description: { en_US: 'Weather API Tool', zh_Hans: '天气API工具' },
|
||||
icon: 'https://example.com/weather.png',
|
||||
label: { en_US: 'Weather API', zh_Hans: '天气API' },
|
||||
type: CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: ['utility'],
|
||||
},
|
||||
{
|
||||
id: 'my-custom-tool',
|
||||
name: 'my_custom_tool',
|
||||
author: 'User',
|
||||
description: { en_US: 'My Custom Tool', zh_Hans: '我的自定义工具' },
|
||||
icon: 'https://example.com/custom.png',
|
||||
label: { en_US: 'My Custom Tool', zh_Hans: '我的自定义工具' },
|
||||
type: CollectionType.custom,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: true,
|
||||
labels: [],
|
||||
},
|
||||
{
|
||||
id: 'workflow-tool-1',
|
||||
name: 'workflow_tool_1',
|
||||
author: 'User',
|
||||
description: { en_US: 'Workflow Tool', zh_Hans: '工作流工具' },
|
||||
icon: 'https://example.com/workflow.png',
|
||||
label: { en_US: 'Workflow Tool', zh_Hans: '工作流工具' },
|
||||
type: CollectionType.workflow,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: true,
|
||||
labels: [],
|
||||
},
|
||||
]
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllToolProviders: () => ({
|
||||
data: mockCollections,
|
||||
refetch: mockRefetch,
|
||||
isSuccess: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tab-slider-new', () => ({
|
||||
default: ({ value, onChange, options }: { value: string, onChange: (v: string) => void, options: Array<{ value: string, text: string }> }) => (
|
||||
<div data-testid="tab-slider">
|
||||
{options.map((opt: { value: string, text: string }) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
data-testid={`tab-${opt.value}`}
|
||||
data-active={value === opt.value ? 'true' : 'false'}
|
||||
onClick={() => onChange(opt.value)}
|
||||
>
|
||||
{opt.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/input', () => ({
|
||||
default: ({ value, onChange, onClear, showLeftIcon, showClearIcon, wrapperClassName }: {
|
||||
value: string
|
||||
onChange: (e: { target: { value: string } }) => void
|
||||
onClear: () => void
|
||||
showLeftIcon?: boolean
|
||||
showClearIcon?: boolean
|
||||
wrapperClassName?: string
|
||||
}) => (
|
||||
<div data-testid="search-input-wrapper" className={wrapperClassName}>
|
||||
<input
|
||||
data-testid="search-input"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
data-left-icon={showLeftIcon ? 'true' : 'false'}
|
||||
data-clear-icon={showClearIcon ? 'true' : 'false'}
|
||||
/>
|
||||
{showClearIcon && value && (
|
||||
<button data-testid="clear-search" onClick={onClear}>Clear</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card', () => ({
|
||||
default: ({ payload, className }: { payload: { brief: Record<string, string> | string, name: string }, className?: string }) => {
|
||||
const briefText = typeof payload.brief === 'object' ? payload.brief?.en_US || '' : payload.brief
|
||||
return (
|
||||
<div data-testid={`card-${payload.name}`} className={className}>
|
||||
<span>{payload.name}</span>
|
||||
<span>{briefText}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/card-more-info', () => ({
|
||||
default: ({ tags }: { tags: string[] }) => (
|
||||
<div data-testid="card-more-info">{tags.join(', ')}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/labels/filter', () => ({
|
||||
default: ({ value: _value, onChange }: { value: string[], onChange: (v: string[]) => void }) => (
|
||||
<div data-testid="label-filter">
|
||||
<button data-testid="filter-search" onClick={() => onChange(['search'])}>Filter: search</button>
|
||||
<button data-testid="filter-utility" onClick={() => onChange(['utility'])}>Filter: utility</button>
|
||||
<button data-testid="filter-clear" onClick={() => onChange([])}>Clear filter</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/custom-create-card', () => ({
|
||||
default: () => <div data-testid="custom-create-card">Create Custom Tool</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/detail', () => ({
|
||||
default: ({ collection, onHide }: { collection: Collection, onHide: () => void }) => (
|
||||
<div data-testid="provider-detail">
|
||||
<span data-testid="detail-name">{collection.name}</span>
|
||||
<button data-testid="detail-close" onClick={onHide}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/empty', () => ({
|
||||
default: () => <div data-testid="workflow-empty">No workflow tools</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({
|
||||
default: ({ detail, onHide }: { detail: unknown, onHide: () => void }) => (
|
||||
detail ? <div data-testid="plugin-detail-panel"><button onClick={onHide}>Close</button></div> : null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace/empty', () => ({
|
||||
default: ({ text }: { text: string }) => <div data-testid="empty-state">{text}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/marketplace', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/mcp', () => ({
|
||||
default: () => <div data-testid="mcp-list">MCP List</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector/types', () => ({
|
||||
ToolTypeEnum: { BuiltIn: 'builtin', Custom: 'api', Workflow: 'workflow', MCP: 'mcp' },
|
||||
}))
|
||||
|
||||
const { default: ProviderList } = await import('@/app/components/tools/provider-list')
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Tool Browsing & Filtering Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders tab options and built-in tools by default', () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByTestId('tab-slider')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-builtin')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-api')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-workflow')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-mcp')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-weather_api')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-my_custom_tool')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-workflow_tool_1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters tools by keyword search', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
const searchInput = screen.getByTestId('search-input')
|
||||
fireEvent.change(searchInput, { target: { value: 'Google' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('clears search keyword and shows all tools again', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
const searchInput = screen.getByTestId('search-input')
|
||||
fireEvent.change(searchInput, { target: { value: 'Google' } })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: '' } })
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-weather_api')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('filters tools by label tags', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
fireEvent.click(screen.getByTestId('filter-search'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('clears label filter and shows all tools', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
fireEvent.click(screen.getByTestId('filter-utility'))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('card-google_search')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-weather_api')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('filter-clear'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-weather_api')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('combines keyword search and label filter', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
fireEvent.click(screen.getByTestId('filter-search'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-google_search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const searchInput = screen.getByTestId('search-input')
|
||||
fireEvent.change(searchInput, { target: { value: 'Weather' } })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('card-google_search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-weather_api')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens provider detail when clicking a non-plugin collection card', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
const card = screen.getByTestId('card-google_search')
|
||||
fireEvent.click(card.parentElement!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('provider-detail')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('detail-name')).toHaveTextContent('google_search')
|
||||
})
|
||||
})
|
||||
|
||||
it('closes provider detail and deselects current provider', async () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
const card = screen.getByTestId('card-google_search')
|
||||
fireEvent.click(card.parentElement!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('provider-detail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('detail-close'))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('provider-detail')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows label filter for non-MCP tabs', () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByTestId('label-filter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows search input on all tabs', () => {
|
||||
render(<ProviderList />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByTestId('search-input')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,239 +0,0 @@
|
||||
/**
|
||||
* Integration Test: Tool Data Processing Pipeline
|
||||
*
|
||||
* Tests the integration between tool utility functions and type conversions.
|
||||
* Verifies that data flows correctly through the processing pipeline:
|
||||
* raw API data → form schemas → form values → configured values.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils/index'
|
||||
import {
|
||||
addDefaultValue,
|
||||
generateFormValue,
|
||||
getConfiguredValue,
|
||||
getPlainValue,
|
||||
getStructureValue,
|
||||
toolCredentialToFormSchemas,
|
||||
toolParametersToFormSchemas,
|
||||
toType,
|
||||
triggerEventParametersToFormSchemas,
|
||||
} from '@/app/components/tools/utils/to-form-schema'
|
||||
|
||||
describe('Tool Data Processing Pipeline Integration', () => {
|
||||
describe('End-to-end: API schema → form schema → form value', () => {
|
||||
it('processes tool parameters through the full pipeline', () => {
|
||||
const rawParameters = [
|
||||
{
|
||||
name: 'query',
|
||||
label: { en_US: 'Search Query', zh_Hans: '搜索查询' },
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: 'hello',
|
||||
form: 'llm',
|
||||
human_description: { en_US: 'Enter your search query', zh_Hans: '输入搜索查询' },
|
||||
llm_description: 'The search query string',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
label: { en_US: 'Result Limit', zh_Hans: '结果限制' },
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: '10',
|
||||
form: 'form',
|
||||
human_description: { en_US: 'Maximum results', zh_Hans: '最大结果数' },
|
||||
llm_description: 'Limit for results',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const formSchemas = toolParametersToFormSchemas(rawParameters as unknown as Parameters<typeof toolParametersToFormSchemas>[0])
|
||||
expect(formSchemas).toHaveLength(2)
|
||||
expect(formSchemas[0].variable).toBe('query')
|
||||
expect(formSchemas[0].required).toBe(true)
|
||||
expect(formSchemas[0].type).toBe('text-input')
|
||||
expect(formSchemas[1].variable).toBe('limit')
|
||||
expect(formSchemas[1].type).toBe('number-input')
|
||||
|
||||
const withDefaults = addDefaultValue({}, formSchemas)
|
||||
expect(withDefaults.query).toBe('hello')
|
||||
expect(withDefaults.limit).toBe('10')
|
||||
|
||||
const formValues = generateFormValue({}, formSchemas, false)
|
||||
expect(formValues).toBeDefined()
|
||||
expect(formValues.query).toBeDefined()
|
||||
expect(formValues.limit).toBeDefined()
|
||||
})
|
||||
|
||||
it('processes tool credentials through the pipeline', () => {
|
||||
const rawCredentials = [
|
||||
{
|
||||
name: 'api_key',
|
||||
label: { en_US: 'API Key', zh_Hans: 'API 密钥' },
|
||||
type: 'secret-input',
|
||||
required: true,
|
||||
default: '',
|
||||
placeholder: { en_US: 'Enter API key', zh_Hans: '输入 API 密钥' },
|
||||
help: { en_US: 'Your API key', zh_Hans: '你的 API 密钥' },
|
||||
url: 'https://example.com/get-key',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const credentialSchemas = toolCredentialToFormSchemas(rawCredentials as Parameters<typeof toolCredentialToFormSchemas>[0])
|
||||
expect(credentialSchemas).toHaveLength(1)
|
||||
expect(credentialSchemas[0].variable).toBe('api_key')
|
||||
expect(credentialSchemas[0].required).toBe(true)
|
||||
expect(credentialSchemas[0].type).toBe('secret-input')
|
||||
})
|
||||
|
||||
it('processes trigger event parameters through the pipeline', () => {
|
||||
const rawParams = [
|
||||
{
|
||||
name: 'event_type',
|
||||
label: { en_US: 'Event Type', zh_Hans: '事件类型' },
|
||||
type: 'select',
|
||||
required: true,
|
||||
default: 'push',
|
||||
form: 'form',
|
||||
description: { en_US: 'Type of event', zh_Hans: '事件类型' },
|
||||
options: [
|
||||
{ value: 'push', label: { en_US: 'Push', zh_Hans: '推送' } },
|
||||
{ value: 'pull', label: { en_US: 'Pull', zh_Hans: '拉取' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const schemas = triggerEventParametersToFormSchemas(rawParams as unknown as Parameters<typeof triggerEventParametersToFormSchemas>[0])
|
||||
expect(schemas).toHaveLength(1)
|
||||
expect(schemas[0].name).toBe('event_type')
|
||||
expect(schemas[0].type).toBe('select')
|
||||
expect(schemas[0].options).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type conversion integration', () => {
|
||||
it('converts all supported types correctly', () => {
|
||||
const typeConversions = [
|
||||
{ input: 'string', expected: 'text-input' },
|
||||
{ input: 'number', expected: 'number-input' },
|
||||
{ input: 'boolean', expected: 'checkbox' },
|
||||
{ input: 'select', expected: 'select' },
|
||||
{ input: 'secret-input', expected: 'secret-input' },
|
||||
{ input: 'file', expected: 'file' },
|
||||
{ input: 'files', expected: 'files' },
|
||||
]
|
||||
|
||||
typeConversions.forEach(({ input, expected }) => {
|
||||
expect(toType(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the original type for unrecognized types', () => {
|
||||
expect(toType('unknown-type')).toBe('unknown-type')
|
||||
expect(toType('app-selector')).toBe('app-selector')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Value extraction integration', () => {
|
||||
it('wraps values with getStructureValue and extracts inner value with getPlainValue', () => {
|
||||
const plainInput = { query: 'test', limit: 10 }
|
||||
const structured = getStructureValue(plainInput)
|
||||
|
||||
expect(structured.query).toEqual({ value: 'test' })
|
||||
expect(structured.limit).toEqual({ value: 10 })
|
||||
|
||||
const objectStructured = {
|
||||
query: { value: { type: 'constant', content: 'test search' } },
|
||||
limit: { value: { type: 'constant', content: 10 } },
|
||||
}
|
||||
const extracted = getPlainValue(objectStructured)
|
||||
expect(extracted.query).toEqual({ type: 'constant', content: 'test search' })
|
||||
expect(extracted.limit).toEqual({ type: 'constant', content: 10 })
|
||||
})
|
||||
|
||||
it('handles getConfiguredValue for workflow tool configurations', () => {
|
||||
const formSchemas = [
|
||||
{ variable: 'query', type: 'text-input', default: 'default-query' },
|
||||
{ variable: 'format', type: 'select', default: 'json' },
|
||||
]
|
||||
|
||||
const configured = getConfiguredValue({}, formSchemas)
|
||||
expect(configured).toBeDefined()
|
||||
expect(configured.query).toBeDefined()
|
||||
expect(configured.format).toBeDefined()
|
||||
})
|
||||
|
||||
it('preserves existing values in getConfiguredValue', () => {
|
||||
const formSchemas = [
|
||||
{ variable: 'query', type: 'text-input', default: 'default-query' },
|
||||
]
|
||||
|
||||
const configured = getConfiguredValue({ query: 'my-existing-query' }, formSchemas)
|
||||
expect(configured.query).toBe('my-existing-query')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent utilities integration', () => {
|
||||
it('sorts agent thoughts and enriches with file infos end-to-end', () => {
|
||||
const thoughts = [
|
||||
{ id: 't3', position: 3, tool: 'search', files: ['f1'] },
|
||||
{ id: 't1', position: 1, tool: 'analyze', files: [] },
|
||||
{ id: 't2', position: 2, tool: 'summarize', files: ['f2'] },
|
||||
] as Parameters<typeof sortAgentSorts>[0]
|
||||
|
||||
const messageFiles = [
|
||||
{ id: 'f1', name: 'result.txt', type: 'document' },
|
||||
{ id: 'f2', name: 'summary.pdf', type: 'document' },
|
||||
] as Parameters<typeof addFileInfos>[1]
|
||||
|
||||
const sorted = sortAgentSorts(thoughts)
|
||||
expect(sorted[0].id).toBe('t1')
|
||||
expect(sorted[1].id).toBe('t2')
|
||||
expect(sorted[2].id).toBe('t3')
|
||||
|
||||
const enriched = addFileInfos(sorted, messageFiles)
|
||||
expect(enriched[0].message_files).toBeUndefined()
|
||||
expect(enriched[1].message_files).toHaveLength(1)
|
||||
expect(enriched[1].message_files![0].id).toBe('f2')
|
||||
expect(enriched[2].message_files).toHaveLength(1)
|
||||
expect(enriched[2].message_files![0].id).toBe('f1')
|
||||
})
|
||||
|
||||
it('handles null inputs gracefully in the pipeline', () => {
|
||||
const sortedNull = sortAgentSorts(null as never)
|
||||
expect(sortedNull).toBeNull()
|
||||
|
||||
const enrichedNull = addFileInfos(null as never, [])
|
||||
expect(enrichedNull).toBeNull()
|
||||
|
||||
// addFileInfos with empty list and null files returns the mapped (empty) list
|
||||
const enrichedEmptyList = addFileInfos([], null as never)
|
||||
expect(enrichedEmptyList).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default value application', () => {
|
||||
it('applies defaults only to empty fields, preserving user values', () => {
|
||||
const userValues = { api_key: 'user-provided-key' }
|
||||
const schemas = [
|
||||
{ variable: 'api_key', type: 'text-input', default: 'default-key', name: 'api_key' },
|
||||
{ variable: 'secret', type: 'secret-input', default: 'default-secret', name: 'secret' },
|
||||
]
|
||||
|
||||
const result = addDefaultValue(userValues, schemas)
|
||||
expect(result.api_key).toBe('user-provided-key')
|
||||
expect(result.secret).toBe('default-secret')
|
||||
})
|
||||
|
||||
it('handles boolean type conversion in defaults', () => {
|
||||
const schemas = [
|
||||
{ variable: 'enabled', type: 'boolean', default: 'true', name: 'enabled' },
|
||||
]
|
||||
|
||||
const result = addDefaultValue({ enabled: 'true' }, schemas)
|
||||
expect(result.enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,548 +0,0 @@
|
||||
import type { Collection } from '@/app/components/tools/types'
|
||||
/**
|
||||
* Integration Test: Tool Provider Detail Flow
|
||||
*
|
||||
* Tests the integration between ProviderDetail, ConfigCredential,
|
||||
* EditCustomToolModal, WorkflowToolModal, and service APIs.
|
||||
* Verifies that different provider types render correctly and
|
||||
* handle auth/edit/delete flows.
|
||||
*/
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) => {
|
||||
const map: Record<string, string> = {
|
||||
'auth.authorized': 'Authorized',
|
||||
'auth.unauthorized': 'Set up credentials',
|
||||
'auth.setup': 'NEEDS SETUP',
|
||||
'createTool.editAction': 'Edit',
|
||||
'createTool.deleteToolConfirmTitle': 'Delete Tool',
|
||||
'createTool.deleteToolConfirmContent': 'Are you sure?',
|
||||
'createTool.toolInput.title': 'Tool Input',
|
||||
'createTool.toolInput.required': 'Required',
|
||||
'openInStudio': 'Open in Studio',
|
||||
'api.actionSuccess': 'Action succeeded',
|
||||
}
|
||||
if (key === 'detailPanel.actionNum')
|
||||
return `${opts?.num ?? 0} actions`
|
||||
if (key === 'includeToolNum')
|
||||
return `${opts?.num ?? 0} actions`
|
||||
return map[key] ?? key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en',
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
getLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSetShowModelModal = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowModelModal: mockSetShowModelModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
modelProviders: [
|
||||
{ provider: 'model-provider-1', name: 'Model Provider 1' },
|
||||
],
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockFetchBuiltInToolList = vi.fn().mockResolvedValue([
|
||||
{ name: 'tool-1', description: { en_US: 'Tool 1' }, parameters: [] },
|
||||
{ name: 'tool-2', description: { en_US: 'Tool 2' }, parameters: [] },
|
||||
])
|
||||
const mockFetchModelToolList = vi.fn().mockResolvedValue([])
|
||||
const mockFetchCustomToolList = vi.fn().mockResolvedValue([])
|
||||
const mockFetchCustomCollection = vi.fn().mockResolvedValue({
|
||||
credentials: { auth_type: 'none' },
|
||||
schema: '',
|
||||
schema_type: 'openapi',
|
||||
})
|
||||
const mockFetchWorkflowToolDetail = vi.fn().mockResolvedValue({
|
||||
workflow_app_id: 'app-123',
|
||||
tool: {
|
||||
parameters: [
|
||||
{ name: 'query', llm_description: 'Search query', form: 'text', required: true, type: 'string' },
|
||||
],
|
||||
labels: ['search'],
|
||||
},
|
||||
})
|
||||
const mockUpdateBuiltInToolCredential = vi.fn().mockResolvedValue({})
|
||||
const mockRemoveBuiltInToolCredential = vi.fn().mockResolvedValue({})
|
||||
const mockUpdateCustomCollection = vi.fn().mockResolvedValue({})
|
||||
const mockRemoveCustomCollection = vi.fn().mockResolvedValue({})
|
||||
const mockDeleteWorkflowTool = vi.fn().mockResolvedValue({})
|
||||
const mockSaveWorkflowToolProvider = vi.fn().mockResolvedValue({})
|
||||
|
||||
vi.mock('@/service/tools', () => ({
|
||||
fetchBuiltInToolList: (...args: unknown[]) => mockFetchBuiltInToolList(...args),
|
||||
fetchModelToolList: (...args: unknown[]) => mockFetchModelToolList(...args),
|
||||
fetchCustomToolList: (...args: unknown[]) => mockFetchCustomToolList(...args),
|
||||
fetchCustomCollection: (...args: unknown[]) => mockFetchCustomCollection(...args),
|
||||
fetchWorkflowToolDetail: (...args: unknown[]) => mockFetchWorkflowToolDetail(...args),
|
||||
updateBuiltInToolCredential: (...args: unknown[]) => mockUpdateBuiltInToolCredential(...args),
|
||||
removeBuiltInToolCredential: (...args: unknown[]) => mockRemoveBuiltInToolCredential(...args),
|
||||
updateCustomCollection: (...args: unknown[]) => mockUpdateCustomCollection(...args),
|
||||
removeCustomCollection: (...args: unknown[]) => mockRemoveCustomCollection(...args),
|
||||
deleteWorkflowTool: (...args: unknown[]) => mockDeleteWorkflowTool(...args),
|
||||
saveWorkflowToolProvider: (...args: unknown[]) => mockSaveWorkflowToolProvider(...args),
|
||||
fetchBuiltInToolCredential: vi.fn().mockResolvedValue({}),
|
||||
fetchBuiltInToolCredentialSchema: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useInvalidateAllWorkflowTools: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
basePath: '',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/drawer', () => ({
|
||||
default: ({ isOpen, children, onClose }: { isOpen: boolean, children: React.ReactNode, onClose: () => void }) => (
|
||||
isOpen
|
||||
? (
|
||||
<div data-testid="drawer">
|
||||
{children}
|
||||
<button data-testid="drawer-close" onClick={onClose}>Close Drawer</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/confirm', () => ({
|
||||
default: ({ title, isShow, onConfirm, onCancel }: {
|
||||
title: string
|
||||
content: string
|
||||
isShow: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) => (
|
||||
isShow
|
||||
? (
|
||||
<div data-testid="confirm-dialog">
|
||||
<span>{title}</span>
|
||||
<button data-testid="confirm-ok" onClick={onConfirm}>Confirm</button>
|
||||
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({
|
||||
LinkExternal02: () => <span data-testid="link-icon" />,
|
||||
Settings01: () => <span data-testid="settings-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiCloseLine: () => <span data-testid="close-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
|
||||
ConfigurationMethodEnum: { predefinedModel: 'predefined-model' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/indicator', () => ({
|
||||
default: ({ color }: { color: string }) => <span data-testid={`indicator-${color}`} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
|
||||
default: ({ src }: { src: string }) => <div data-testid="card-icon" data-src={typeof src === 'string' ? src : 'emoji'} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/description', () => ({
|
||||
default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
|
||||
default: ({ orgName, packageName }: { orgName: string, packageName: string }) => (
|
||||
<div data-testid="org-info">
|
||||
{orgName}
|
||||
{' '}
|
||||
/
|
||||
{' '}
|
||||
{packageName}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/title', () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="title">{title}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({
|
||||
default: ({ onHide, onEdit, onRemove }: { onHide: () => void, onEdit: (data: unknown) => void, onRemove: () => void, payload: unknown }) => (
|
||||
<div data-testid="edit-custom-modal">
|
||||
<button data-testid="custom-modal-hide" onClick={onHide}>Hide</button>
|
||||
<button data-testid="custom-modal-save" onClick={() => onEdit({ name: 'updated', labels: [] })}>Save</button>
|
||||
<button data-testid="custom-modal-remove" onClick={onRemove}>Remove</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({
|
||||
default: ({ onCancel, onSaved, onRemove }: { collection: Collection, onCancel: () => void, onSaved: (v: Record<string, unknown>) => void, onRemove: () => void }) => (
|
||||
<div data-testid="config-credential">
|
||||
<button data-testid="cred-cancel" onClick={onCancel}>Cancel</button>
|
||||
<button data-testid="cred-save" onClick={() => onSaved({ api_key: 'test-key' })}>Save</button>
|
||||
<button data-testid="cred-remove" onClick={onRemove}>Remove</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/workflow-tool', () => ({
|
||||
default: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => (
|
||||
<div data-testid="workflow-tool-modal">
|
||||
<button data-testid="wf-modal-hide" onClick={onHide}>Hide</button>
|
||||
<button data-testid="wf-modal-save" onClick={() => onSave({ name: 'updated-wf' })}>Save</button>
|
||||
<button data-testid="wf-modal-remove" onClick={onRemove}>Remove</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/tool-item', () => ({
|
||||
default: ({ tool }: { tool: { name: string } }) => (
|
||||
<div data-testid={`tool-item-${tool.name}`}>{tool.name}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const { default: ProviderDetail } = await import('@/app/components/tools/provider/detail')
|
||||
|
||||
const makeCollection = (overrides: Partial<Collection> = {}): Collection => ({
|
||||
id: 'test-collection',
|
||||
name: 'test_collection',
|
||||
author: 'Dify',
|
||||
description: { en_US: 'Test collection description', zh_Hans: '测试集合描述' },
|
||||
icon: 'https://example.com/icon.png',
|
||||
label: { en_US: 'Test Collection', zh_Hans: '测试集合' },
|
||||
type: CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const mockOnHide = vi.fn()
|
||||
const mockOnRefreshData = vi.fn()
|
||||
|
||||
describe('Tool Provider Detail Flow Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('Built-in Provider', () => {
|
||||
it('renders provider detail with title, author, and description', async () => {
|
||||
const collection = makeCollection()
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Test Collection')
|
||||
expect(screen.getByTestId('org-info')).toHaveTextContent('Dify')
|
||||
expect(screen.getByTestId('description')).toHaveTextContent('Test collection description')
|
||||
})
|
||||
})
|
||||
|
||||
it('loads tool list from API on mount', async () => {
|
||||
const collection = makeCollection()
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchBuiltInToolList).toHaveBeenCalledWith('test_collection')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('tool-item-tool-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tool-item-tool-2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows "Set up credentials" button when not authorized and needs auth', async () => {
|
||||
const collection = makeCollection({
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows "Authorized" button when authorized', async () => {
|
||||
const collection = makeCollection({
|
||||
allow_delete: true,
|
||||
is_team_authorization: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Authorized')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('indicator-green')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens ConfigCredential when clicking auth button (built-in type)', async () => {
|
||||
const collection = makeCollection({
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Set up credentials'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('saves credential and refreshes data', async () => {
|
||||
const collection = makeCollection({
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Set up credentials'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('cred-save'))
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateBuiltInToolCredential).toHaveBeenCalledWith('test_collection', { api_key: 'test-key' })
|
||||
expect(mockOnRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('removes credential and refreshes data', async () => {
|
||||
const collection = makeCollection({
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByText('Set up credentials'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('config-credential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('cred-remove'))
|
||||
await waitFor(() => {
|
||||
expect(mockRemoveBuiltInToolCredential).toHaveBeenCalledWith('test_collection')
|
||||
expect(mockOnRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Model Provider', () => {
|
||||
it('opens model modal when clicking auth button for model type', async () => {
|
||||
const collection = makeCollection({
|
||||
id: 'model-provider-1',
|
||||
type: CollectionType.model,
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Set up credentials'))
|
||||
await waitFor(() => {
|
||||
expect(mockSetShowModelModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
currentProvider: expect.objectContaining({ provider: 'model-provider-1' }),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom Provider', () => {
|
||||
it('fetches custom collection details and shows edit button', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.custom,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchCustomCollection).toHaveBeenCalledWith('test_collection')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens edit modal and saves changes', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.custom,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Edit'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('edit-custom-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom-modal-save'))
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateCustomCollection).toHaveBeenCalled()
|
||||
expect(mockOnRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows delete confirmation and removes collection', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.custom,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Edit'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('edit-custom-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom-modal-remove'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Delete Tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
await waitFor(() => {
|
||||
expect(mockRemoveCustomCollection).toHaveBeenCalledWith('test_collection')
|
||||
expect(mockOnRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workflow Provider', () => {
|
||||
it('fetches workflow tool detail and shows "Open in Studio" and "Edit" buttons', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.workflow,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowToolDetail).toHaveBeenCalledWith('test-collection')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Open in Studio')).toBeInTheDocument()
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows workflow tool parameters', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.workflow,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('query')).toBeInTheDocument()
|
||||
expect(screen.getByText('string')).toBeInTheDocument()
|
||||
expect(screen.getByText('Search query')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes workflow tool through confirmation dialog', async () => {
|
||||
const collection = makeCollection({
|
||||
type: CollectionType.workflow,
|
||||
allow_delete: true,
|
||||
})
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Edit'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('workflow-tool-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('wf-modal-remove'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'))
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteWorkflowTool).toHaveBeenCalledWith('test-collection')
|
||||
expect(mockOnRefreshData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Drawer Interaction', () => {
|
||||
it('calls onHide when closing the drawer', async () => {
|
||||
const collection = makeCollection()
|
||||
render(<ProviderDetail collection={collection} onHide={mockOnHide} onRefreshData={mockOnRefreshData} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('drawer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('drawer-close'))
|
||||
expect(mockOnHide).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import type { CSSProperties, ReactNode } from 'react'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import './index.css'
|
||||
|
||||
enum BadgeState {
|
||||
Warning = 'warning',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
import { Highlight } from '@/app/components/base/icons/src/public/common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import './index.css'
|
||||
|
||||
const PremiumBadgeVariants = cva(
|
||||
'premium-badge',
|
||||
|
||||
@@ -193,4 +193,107 @@ describe('usePSInfo', () => {
|
||||
domain: '.dify.ai',
|
||||
})
|
||||
})
|
||||
|
||||
// Cookie parse failure: covers catch block (L14-16)
|
||||
it('should fall back to empty object when cookie contains invalid JSON', () => {
|
||||
const { get } = ensureCookieMocks()
|
||||
get.mockReturnValue('not-valid-json{{{')
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
setSearchParams({
|
||||
ps_partner_key: 'from-url',
|
||||
ps_xid: 'click-url',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to parse partner stack info from cookie:',
|
||||
expect.any(SyntaxError),
|
||||
)
|
||||
// Should still pick up values from search params
|
||||
expect(result.current.psPartnerKey).toBe('from-url')
|
||||
expect(result.current.psClickId).toBe('click-url')
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
// No keys at all: covers saveOrUpdate early return (L30) and bind no-op (L45 false branch)
|
||||
it('should not save or bind when neither search params nor cookie have keys', () => {
|
||||
const { get, set } = ensureCookieMocks()
|
||||
get.mockReturnValue('{}')
|
||||
setSearchParams({})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBeUndefined()
|
||||
expect(result.current.psClickId).toBeUndefined()
|
||||
|
||||
act(() => {
|
||||
result.current.saveOrUpdate()
|
||||
})
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call mutateAsync when keys are missing during bind', async () => {
|
||||
const { get } = ensureCookieMocks()
|
||||
get.mockReturnValue('{}')
|
||||
setSearchParams({})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
const mutate = ensureMutateAsync()
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Non-400 error: covers L55 false branch (shouldRemoveCookie stays false)
|
||||
it('should not remove cookie when bind fails with non-400 error', async () => {
|
||||
const mutate = ensureMutateAsync()
|
||||
mutate.mockRejectedValueOnce({ status: 500 })
|
||||
setSearchParams({
|
||||
ps_partner_key: 'bind-partner',
|
||||
ps_xid: 'bind-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
const { remove } = ensureCookieMocks()
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Fallback to cookie values: covers L19-20 right side of || operator
|
||||
it('should use cookie values when search params are absent', () => {
|
||||
const { get } = ensureCookieMocks()
|
||||
get.mockReturnValue(JSON.stringify({
|
||||
partnerKey: 'cookie-partner',
|
||||
clickId: 'cookie-click',
|
||||
}))
|
||||
setSearchParams({})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBe('cookie-partner')
|
||||
expect(result.current.psClickId).toBe('cookie-click')
|
||||
})
|
||||
|
||||
// Partial key missing: only partnerKey present, no clickId
|
||||
it('should not save when only one key is available', () => {
|
||||
const { get, set } = ensureCookieMocks()
|
||||
get.mockReturnValue('{}')
|
||||
setSearchParams({ ps_partner_key: 'partial-key' })
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
act(() => {
|
||||
result.current.saveOrUpdate()
|
||||
})
|
||||
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,13 +66,6 @@ beforeAll(() => {
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
|
||||
@@ -82,6 +75,13 @@ beforeEach(() => {
|
||||
assignedHref = ''
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
})
|
||||
})
|
||||
|
||||
describe('CloudPlanItem', () => {
|
||||
// Static content for each plan
|
||||
describe('Rendering', () => {
|
||||
@@ -192,5 +192,128 @@ describe('CloudPlanItem', () => {
|
||||
expect(assignedHref).toBe('https://subscription.example')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers L92-93: isFreePlan guard inside handleGetPayUrl
|
||||
it('should do nothing when clicking sandbox plan CTA that is not the current plan', async () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.sandbox}
|
||||
currentPlan={Plan.professional}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
// Sandbox viewed from a higher plan is disabled, but let's verify no API calls
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
|
||||
expect(mockBillingInvoices).not.toHaveBeenCalled()
|
||||
expect(assignedHref).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers L95: yearly subscription URL ('year' parameter)
|
||||
it('should fetch yearly subscription url when planRange is yearly', async () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.team}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.yearly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.team, 'year')
|
||||
expect(assignedHref).toBe('https://subscription.example')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers L62-63: loading guard prevents double click
|
||||
it('should ignore second click while loading', async () => {
|
||||
// Make the first fetch hang until we resolve it
|
||||
let resolveFirst!: (v: { url: string }) => void
|
||||
mockFetchSubscriptionUrls.mockImplementationOnce(
|
||||
() => new Promise((resolve) => { resolveFirst = resolve }),
|
||||
)
|
||||
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.professional}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' })
|
||||
|
||||
// First click starts loading
|
||||
fireEvent.click(button)
|
||||
// Second click while loading should be ignored
|
||||
fireEvent.click(button)
|
||||
|
||||
// Resolve first request
|
||||
resolveFirst({ url: 'https://first.example' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Covers L82-83, L85-87: openAsyncWindow error path when invoices returns no url
|
||||
it('should invoke onError when billing invoices returns empty url', async () => {
|
||||
mockBillingInvoices.mockResolvedValue({ url: '' })
|
||||
const openWindow = vi.fn(async (cb: () => Promise<string>, opts: { onError?: (e: Error) => void }) => {
|
||||
try {
|
||||
await cb()
|
||||
}
|
||||
catch (e) {
|
||||
opts.onError?.(e as Error)
|
||||
}
|
||||
})
|
||||
mockUseAsyncWindowOpen.mockReturnValue(openWindow)
|
||||
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.professional}
|
||||
currentPlan={Plan.professional}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openWindow).toHaveBeenCalledTimes(1)
|
||||
// The onError callback should have been passed to openAsyncWindow
|
||||
const callArgs = openWindow.mock.calls[0]
|
||||
expect(callArgs[1]).toHaveProperty('onError')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers monthly price display (L139 !isYear branch for price)
|
||||
it('should display monthly pricing without discount', () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.team}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
const teamPlan = ALL_PLANS[Plan.team]
|
||||
expect(screen.getByText(`$${teamPlan.price}`)).toBeInTheDocument()
|
||||
expect(screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.month/)).toBeInTheDocument()
|
||||
// Should NOT show crossed-out yearly price
|
||||
expect(screen.queryByText(`$${teamPlan.price * 12}`)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/public/plugins/VerifiedDark', () => ({
|
||||
default: () => <span data-testid="verified-dark" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/public/plugins/VerifiedLight', () => ({
|
||||
default: () => <span data-testid="verified-light" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: 'light' }),
|
||||
}))
|
||||
|
||||
vi.mock('./icon-with-tooltip', () => ({
|
||||
default: ({ popupContent, BadgeIconLight, BadgeIconDark, theme }: {
|
||||
popupContent: string
|
||||
BadgeIconLight: React.FC
|
||||
BadgeIconDark: React.FC
|
||||
theme: string
|
||||
[key: string]: unknown
|
||||
}) => (
|
||||
<div data-testid="icon-with-tooltip" data-popup={popupContent}>
|
||||
{theme === 'light' ? <BadgeIconLight /> : <BadgeIconDark />}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('Verified', () => {
|
||||
let Verified: (typeof import('./verified'))['default']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./verified')
|
||||
Verified = mod.default
|
||||
})
|
||||
|
||||
it('should render with tooltip text', () => {
|
||||
render(<Verified text="Verified Plugin" />)
|
||||
|
||||
const tooltip = screen.getByTestId('icon-with-tooltip')
|
||||
expect(tooltip).toHaveAttribute('data-popup', 'Verified Plugin')
|
||||
})
|
||||
|
||||
it('should render light theme icon by default', () => {
|
||||
render(<Verified text="Verified" />)
|
||||
|
||||
expect(screen.getByTestId('verified-light')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,119 +0,0 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DeprecationNotice from './deprecation-notice'
|
||||
|
||||
vi.mock('#i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) => {
|
||||
const map: Record<string, string> = {
|
||||
'detailPanel.deprecation.noReason': 'This plugin has been deprecated.',
|
||||
'detailPanel.deprecation.reason.businessAdjustments': 'business adjustments',
|
||||
'detailPanel.deprecation.reason.ownershipTransferred': 'ownership transferred',
|
||||
'detailPanel.deprecation.reason.noMaintainer': 'no maintainer',
|
||||
}
|
||||
if (key === 'detailPanel.deprecation.onlyReason')
|
||||
return `Deprecated due to ${opts?.deprecatedReason}`
|
||||
return map[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
Trans: ({ values }: { values: Record<string, string> }) => (
|
||||
<span data-testid="trans">{`Deprecated: ${values?.deprecatedReason} → ${values?.alternativePluginId}`}</span>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiAlertFill: () => <span data-testid="alert-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode, href: string }) => (
|
||||
<a data-testid="link" href={href}>{children}</a>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('DeprecationNotice', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('returns null when status is not "deleted"', () => {
|
||||
const { container } = render(
|
||||
<DeprecationNotice
|
||||
status="active"
|
||||
deprecatedReason="business_adjustments"
|
||||
alternativePluginId="alt-plugin"
|
||||
alternativePluginURL="/plugins/alt-plugin"
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders deprecation notice when status is "deleted"', () => {
|
||||
render(
|
||||
<DeprecationNotice
|
||||
status="deleted"
|
||||
deprecatedReason=""
|
||||
alternativePluginId=""
|
||||
alternativePluginURL=""
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('alert-icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('This plugin has been deprecated.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with valid reason and alternative plugin', () => {
|
||||
render(
|
||||
<DeprecationNotice
|
||||
status="deleted"
|
||||
deprecatedReason="business_adjustments"
|
||||
alternativePluginId="better-plugin"
|
||||
alternativePluginURL="/plugins/better-plugin"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('trans')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders only reason without alternative plugin', () => {
|
||||
render(
|
||||
<DeprecationNotice
|
||||
status="deleted"
|
||||
deprecatedReason="no_maintainer"
|
||||
alternativePluginId=""
|
||||
alternativePluginURL=""
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Deprecated due to no maintainer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders no-reason message for invalid reason', () => {
|
||||
render(
|
||||
<DeprecationNotice
|
||||
status="deleted"
|
||||
deprecatedReason="unknown_reason"
|
||||
alternativePluginId=""
|
||||
alternativePluginURL=""
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('This plugin has been deprecated.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(
|
||||
<DeprecationNotice
|
||||
status="deleted"
|
||||
deprecatedReason=""
|
||||
alternativePluginId=""
|
||||
alternativePluginURL=""
|
||||
className="my-custom-class"
|
||||
/>,
|
||||
)
|
||||
expect((container.firstChild as HTMLElement).className).toContain('my-custom-class')
|
||||
})
|
||||
})
|
||||
@@ -1,81 +0,0 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import KeyValueItem from './key-value-item'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'operation.copy': 'Copy',
|
||||
'operation.copied': 'Copied',
|
||||
}
|
||||
return map[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiClipboardLine: () => <span data-testid="clipboard-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../base/icons/src/vender/line/files', () => ({
|
||||
CopyCheck: () => <span data-testid="copy-check-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../base/tooltip', () => ({
|
||||
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
|
||||
<div data-testid="tooltip" data-content={popupContent}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/action-button', () => ({
|
||||
default: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
|
||||
<button data-testid="action-button" onClick={onClick}>{children}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock copy-to-clipboard
|
||||
const mockCopy = vi.fn()
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: (...args: unknown[]) => mockCopy(...args),
|
||||
}))
|
||||
|
||||
describe('KeyValueItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders label and value', () => {
|
||||
render(<KeyValueItem label="ID" value="abc-123" />)
|
||||
expect(screen.getByText('ID')).toBeInTheDocument()
|
||||
expect(screen.getByText('abc-123')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders maskedValue instead of value when provided', () => {
|
||||
render(<KeyValueItem label="Key" value="sk-secret" maskedValue="sk-***" />)
|
||||
expect(screen.getByText('sk-***')).toBeInTheDocument()
|
||||
expect(screen.queryByText('sk-secret')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('copies actual value (not masked) when copy button is clicked', () => {
|
||||
render(<KeyValueItem label="Key" value="sk-secret" maskedValue="sk-***" />)
|
||||
fireEvent.click(screen.getByTestId('action-button'))
|
||||
expect(mockCopy).toHaveBeenCalledWith('sk-secret')
|
||||
})
|
||||
|
||||
it('shows clipboard icon initially', () => {
|
||||
render(<KeyValueItem label="ID" value="123" />)
|
||||
expect(screen.getByTestId('clipboard-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders copy tooltip', () => {
|
||||
render(<KeyValueItem label="ID" value="123" />)
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'Copy')
|
||||
})
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Icon from './card-icon'
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiCheckLine: () => <span data-testid="check-icon" />,
|
||||
RiCloseLine: () => <span data-testid="close-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: ({ icon, background }: { icon: string, background: string }) => (
|
||||
<div data-testid="app-icon" data-icon={icon} data-bg={background} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/other', () => ({
|
||||
Mcp: () => <span data-testid="mcp-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/mcp', () => ({
|
||||
shouldUseMcpIcon: () => false,
|
||||
}))
|
||||
|
||||
describe('Icon', () => {
|
||||
it('renders string src as background image', () => {
|
||||
const { container } = render(<Icon src="https://example.com/icon.png" />)
|
||||
const el = container.firstChild as HTMLElement
|
||||
expect(el.style.backgroundImage).toContain('https://example.com/icon.png')
|
||||
})
|
||||
|
||||
it('renders emoji src using AppIcon', () => {
|
||||
render(<Icon src={{ content: '🔍', background: '#fff' }} />)
|
||||
expect(screen.getByTestId('app-icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-icon')).toHaveAttribute('data-icon', '🔍')
|
||||
expect(screen.getByTestId('app-icon')).toHaveAttribute('data-bg', '#fff')
|
||||
})
|
||||
|
||||
it('shows check icon when installed', () => {
|
||||
render(<Icon src="icon.png" installed />)
|
||||
expect(screen.getByTestId('check-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows close icon when installFailed', () => {
|
||||
render(<Icon src="icon.png" installFailed />)
|
||||
expect(screen.getByTestId('close-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show status icons by default', () => {
|
||||
render(<Icon src="icon.png" />)
|
||||
expect(screen.queryByTestId('check-icon')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('close-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<Icon src="icon.png" className="my-class" />)
|
||||
const el = container.firstChild as HTMLElement
|
||||
expect(el.className).toContain('my-class')
|
||||
})
|
||||
|
||||
it('applies correct size class', () => {
|
||||
const { container } = render(<Icon src="icon.png" size="small" />)
|
||||
const el = container.firstChild as HTMLElement
|
||||
expect(el.className).toContain('w-8')
|
||||
expect(el.className).toContain('h-8')
|
||||
})
|
||||
})
|
||||
@@ -1,27 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import CornerMark from './corner-mark'
|
||||
|
||||
vi.mock('../../../base/icons/src/vender/plugin', () => ({
|
||||
LeftCorner: ({ className }: { className: string }) => <svg data-testid="left-corner" className={className} />,
|
||||
}))
|
||||
|
||||
describe('CornerMark', () => {
|
||||
it('renders the text content', () => {
|
||||
render(<CornerMark text="NEW" />)
|
||||
expect(screen.getByText('NEW')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the LeftCorner icon', () => {
|
||||
render(<CornerMark text="BETA" />)
|
||||
expect(screen.getByTestId('left-corner')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with absolute positioning', () => {
|
||||
const { container } = render(<CornerMark text="TAG" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('absolute')
|
||||
expect(wrapper.className).toContain('right-0')
|
||||
expect(wrapper.className).toContain('top-0')
|
||||
})
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Description from './description'
|
||||
|
||||
describe('Description', () => {
|
||||
it('renders description text', () => {
|
||||
render(<Description text="A great plugin" descriptionLineRows={1} />)
|
||||
expect(screen.getByText('A great plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies truncate class for 1 line', () => {
|
||||
render(<Description text="Single line" descriptionLineRows={1} />)
|
||||
const el = screen.getByText('Single line')
|
||||
expect(el.className).toContain('truncate')
|
||||
expect(el.className).toContain('h-4')
|
||||
})
|
||||
|
||||
it('applies line-clamp-2 class for 2 lines', () => {
|
||||
render(<Description text="Two lines" descriptionLineRows={2} />)
|
||||
const el = screen.getByText('Two lines')
|
||||
expect(el.className).toContain('line-clamp-2')
|
||||
expect(el.className).toContain('h-8')
|
||||
})
|
||||
|
||||
it('applies line-clamp-3 class for 3 lines', () => {
|
||||
render(<Description text="Three lines" descriptionLineRows={3} />)
|
||||
const el = screen.getByText('Three lines')
|
||||
expect(el.className).toContain('line-clamp-3')
|
||||
expect(el.className).toContain('h-12')
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Description text="test" descriptionLineRows={1} className="mt-2" />)
|
||||
const el = screen.getByText('test')
|
||||
expect(el.className).toContain('mt-2')
|
||||
})
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import DownloadCount from './download-count'
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiInstallLine: () => <span data-testid="install-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/format', () => ({
|
||||
formatNumber: (n: number) => {
|
||||
if (n >= 1000)
|
||||
return `${(n / 1000).toFixed(1)}k`
|
||||
return String(n)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('DownloadCount', () => {
|
||||
it('renders formatted download count', () => {
|
||||
render(<DownloadCount downloadCount={1500} />)
|
||||
expect(screen.getByText('1.5k')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders small numbers directly', () => {
|
||||
render(<DownloadCount downloadCount={42} />)
|
||||
expect(screen.getByText('42')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders install icon', () => {
|
||||
render(<DownloadCount downloadCount={100} />)
|
||||
expect(screen.getByTestId('install-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders zero download count', () => {
|
||||
render(<DownloadCount downloadCount={0} />)
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import OrgInfo from './org-info'
|
||||
|
||||
describe('OrgInfo', () => {
|
||||
it('renders package name', () => {
|
||||
render(<OrgInfo packageName="my-plugin" />)
|
||||
expect(screen.getByText('my-plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders org name with separator when provided', () => {
|
||||
render(<OrgInfo orgName="dify" packageName="search-tool" />)
|
||||
expect(screen.getByText('dify')).toBeInTheDocument()
|
||||
expect(screen.getByText('/')).toBeInTheDocument()
|
||||
expect(screen.getByText('search-tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render org name or separator when orgName is not provided', () => {
|
||||
render(<OrgInfo packageName="standalone" />)
|
||||
expect(screen.queryByText('/')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('standalone')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<OrgInfo packageName="pkg" className="custom-class" />)
|
||||
expect((container.firstChild as HTMLElement).className).toContain('custom-class')
|
||||
})
|
||||
|
||||
it('applies packageNameClassName to package name element', () => {
|
||||
render(<OrgInfo packageName="pkg" packageNameClassName="w-auto" />)
|
||||
const pkgEl = screen.getByText('pkg')
|
||||
expect(pkgEl.className).toContain('w-auto')
|
||||
})
|
||||
})
|
||||
@@ -1,71 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./title', () => ({
|
||||
default: ({ title }: { title: string }) => <span data-testid="title">{title}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('../../../base/icons/src/vender/other', () => ({
|
||||
Group: ({ className }: { className: string }) => <span data-testid="group-icon" className={className} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
describe('Placeholder', () => {
|
||||
let Placeholder: (typeof import('./placeholder'))['default']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./placeholder')
|
||||
Placeholder = mod.default
|
||||
})
|
||||
|
||||
it('should render skeleton rows', () => {
|
||||
const { container } = render(<Placeholder wrapClassName="w-full" />)
|
||||
|
||||
expect(container.querySelectorAll('.gap-2').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should render group icon placeholder', () => {
|
||||
render(<Placeholder wrapClassName="w-full" />)
|
||||
|
||||
expect(screen.getByTestId('group-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading filename when provided', () => {
|
||||
render(<Placeholder wrapClassName="w-full" loadingFileName="test-plugin.zip" />)
|
||||
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('test-plugin.zip')
|
||||
})
|
||||
|
||||
it('should render skeleton rectangles when no filename', () => {
|
||||
const { container } = render(<Placeholder wrapClassName="w-full" />)
|
||||
|
||||
expect(container.querySelectorAll('.bg-text-quaternary').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LoadingPlaceholder', () => {
|
||||
let LoadingPlaceholder: (typeof import('./placeholder'))['LoadingPlaceholder']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./placeholder')
|
||||
LoadingPlaceholder = mod.LoadingPlaceholder
|
||||
})
|
||||
|
||||
it('should render as a simple div with background', () => {
|
||||
const { container } = render(<LoadingPlaceholder />)
|
||||
|
||||
expect(container.firstChild).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should accept className prop', () => {
|
||||
const { container } = render(<LoadingPlaceholder className="mt-3 w-[420px]" />)
|
||||
|
||||
expect(container.firstChild).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Title from './title'
|
||||
|
||||
describe('Title', () => {
|
||||
it('renders the title text', () => {
|
||||
render(<Title title="Test Plugin" />)
|
||||
expect(screen.getByText('Test Plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with truncate class for long text', () => {
|
||||
render(<Title title="A very long title that should be truncated" />)
|
||||
const el = screen.getByText('A very long title that should be truncated')
|
||||
expect(el.className).toContain('truncate')
|
||||
})
|
||||
|
||||
it('renders empty string without error', () => {
|
||||
const { container } = render(<Title title="" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import CardMoreInfo from './card-more-info'
|
||||
|
||||
vi.mock('./base/download-count', () => ({
|
||||
default: ({ downloadCount }: { downloadCount: number }) => (
|
||||
<span data-testid="download-count">{downloadCount}</span>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('CardMoreInfo', () => {
|
||||
it('renders tags with # prefix', () => {
|
||||
render(<CardMoreInfo tags={['search', 'agent']} />)
|
||||
expect(screen.getByText('search')).toBeInTheDocument()
|
||||
expect(screen.getByText('agent')).toBeInTheDocument()
|
||||
// # prefixes
|
||||
const hashmarks = screen.getAllByText('#')
|
||||
expect(hashmarks).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders download count when provided', () => {
|
||||
render(<CardMoreInfo downloadCount={1000} tags={[]} />)
|
||||
expect(screen.getByTestId('download-count')).toHaveTextContent('1000')
|
||||
})
|
||||
|
||||
it('does not render download count when undefined', () => {
|
||||
render(<CardMoreInfo tags={['tag1']} />)
|
||||
expect(screen.queryByTestId('download-count')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders separator between download count and tags', () => {
|
||||
render(<CardMoreInfo downloadCount={500} tags={['test']} />)
|
||||
expect(screen.getByText('·')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render separator when no tags', () => {
|
||||
render(<CardMoreInfo downloadCount={500} tags={[]} />)
|
||||
expect(screen.queryByText('·')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render separator when no download count', () => {
|
||||
render(<CardMoreInfo tags={['tag1']} />)
|
||||
expect(screen.queryByText('·')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles empty tags array', () => {
|
||||
const { container } = render(<CardMoreInfo tags={[]} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,125 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TaskStatus } from '../../types'
|
||||
import checkTaskStatus from './check-task-status'
|
||||
|
||||
const mockCheckTaskStatus = vi.fn()
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
checkTaskStatus: (...args: unknown[]) => mockCheckTaskStatus(...args),
|
||||
}))
|
||||
|
||||
// Mock sleep to avoid actual waiting in tests
|
||||
vi.mock('@/utils', () => ({
|
||||
sleep: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
describe('checkTaskStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns success when plugin status is success', async () => {
|
||||
mockCheckTaskStatus.mockResolvedValue({
|
||||
task: {
|
||||
plugins: [
|
||||
{ plugin_unique_identifier: 'test-plugin', status: TaskStatus.success, message: '' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const { check } = checkTaskStatus()
|
||||
const result = await check({ taskId: 'task-1', pluginUniqueIdentifier: 'test-plugin' })
|
||||
expect(result.status).toBe(TaskStatus.success)
|
||||
})
|
||||
|
||||
it('returns failed when plugin status is failed', async () => {
|
||||
mockCheckTaskStatus.mockResolvedValue({
|
||||
task: {
|
||||
plugins: [
|
||||
{ plugin_unique_identifier: 'test-plugin', status: TaskStatus.failed, message: 'Install failed' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const { check } = checkTaskStatus()
|
||||
const result = await check({ taskId: 'task-1', pluginUniqueIdentifier: 'test-plugin' })
|
||||
expect(result.status).toBe(TaskStatus.failed)
|
||||
expect(result.error).toBe('Install failed')
|
||||
})
|
||||
|
||||
it('returns failed when plugin is not found in task', async () => {
|
||||
mockCheckTaskStatus.mockResolvedValue({
|
||||
task: {
|
||||
plugins: [
|
||||
{ plugin_unique_identifier: 'other-plugin', status: TaskStatus.success, message: '' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const { check } = checkTaskStatus()
|
||||
const result = await check({ taskId: 'task-1', pluginUniqueIdentifier: 'test-plugin' })
|
||||
expect(result.status).toBe(TaskStatus.failed)
|
||||
expect(result.error).toBe('Plugin package not found')
|
||||
})
|
||||
|
||||
it('polls recursively when status is running, then resolves on success', async () => {
|
||||
let callCount = 0
|
||||
mockCheckTaskStatus.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount < 3) {
|
||||
return Promise.resolve({
|
||||
task: {
|
||||
plugins: [
|
||||
{ plugin_unique_identifier: 'test-plugin', status: TaskStatus.running, message: '' },
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
return Promise.resolve({
|
||||
task: {
|
||||
plugins: [
|
||||
{ plugin_unique_identifier: 'test-plugin', status: TaskStatus.success, message: '' },
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const { check } = checkTaskStatus()
|
||||
const result = await check({ taskId: 'task-1', pluginUniqueIdentifier: 'test-plugin' })
|
||||
expect(result.status).toBe(TaskStatus.success)
|
||||
expect(mockCheckTaskStatus).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('stop() causes early return with success', async () => {
|
||||
const { check, stop } = checkTaskStatus()
|
||||
stop()
|
||||
const result = await check({ taskId: 'task-1', pluginUniqueIdentifier: 'test-plugin' })
|
||||
expect(result.status).toBe(TaskStatus.success)
|
||||
expect(mockCheckTaskStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns different instances with independent state', async () => {
|
||||
const checker1 = checkTaskStatus()
|
||||
const checker2 = checkTaskStatus()
|
||||
|
||||
checker1.stop()
|
||||
|
||||
mockCheckTaskStatus.mockResolvedValue({
|
||||
task: {
|
||||
plugins: [
|
||||
{ plugin_unique_identifier: 'test-plugin', status: TaskStatus.success, message: '' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const result1 = await checker1.check({ taskId: 'task-1', pluginUniqueIdentifier: 'test-plugin' })
|
||||
const result2 = await checker2.check({ taskId: 'task-2', pluginUniqueIdentifier: 'test-plugin' })
|
||||
|
||||
expect(result1.status).toBe(TaskStatus.success)
|
||||
expect(result2.status).toBe(TaskStatus.success)
|
||||
expect(mockCheckTaskStatus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,87 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../card', () => ({
|
||||
default: ({ installed, installFailed, titleLeft }: { installed: boolean, installFailed: boolean, titleLeft?: React.ReactNode }) => (
|
||||
<div data-testid="card" data-installed={installed} data-failed={installFailed}>{titleLeft}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../utils', () => ({
|
||||
pluginManifestInMarketToPluginProps: (p: unknown) => p,
|
||||
pluginManifestToCardPluginProps: (p: unknown) => p,
|
||||
}))
|
||||
|
||||
describe('Installed', () => {
|
||||
let Installed: (typeof import('./installed'))['default']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./installed')
|
||||
Installed = mod.default
|
||||
})
|
||||
|
||||
it('should render success message when not failed', () => {
|
||||
render(<Installed isFailed={false} onCancel={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('installModal.installedSuccessfullyDesc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render failure message when failed', () => {
|
||||
render(<Installed isFailed={true} onCancel={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('installModal.installFailedDesc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom error message when provided', () => {
|
||||
render(<Installed isFailed={true} errMsg="Custom error" onCancel={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Custom error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render card with payload', () => {
|
||||
const payload = { version: '1.0.0', name: 'test-plugin' } as never
|
||||
render(<Installed payload={payload} isFailed={false} onCancel={vi.fn()} />)
|
||||
|
||||
const card = screen.getByTestId('card')
|
||||
expect(card).toHaveAttribute('data-installed', 'true')
|
||||
expect(card).toHaveAttribute('data-failed', 'false')
|
||||
})
|
||||
|
||||
it('should render card as failed when isFailed', () => {
|
||||
const payload = { version: '1.0.0', name: 'test-plugin' } as never
|
||||
render(<Installed payload={payload} isFailed={true} onCancel={vi.fn()} />)
|
||||
|
||||
const card = screen.getByTestId('card')
|
||||
expect(card).toHaveAttribute('data-installed', 'false')
|
||||
expect(card).toHaveAttribute('data-failed', 'true')
|
||||
})
|
||||
|
||||
it('should call onCancel when close button clicked', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
render(<Installed isFailed={false} onCancel={mockOnCancel} />)
|
||||
|
||||
fireEvent.click(screen.getByText('operation.close'))
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show version badge in card', () => {
|
||||
const payload = { version: '1.0.0', name: 'test-plugin' } as never
|
||||
render(<Installed payload={payload} isFailed={false} onCancel={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('1.0.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render card when no payload', () => {
|
||||
render(<Installed isFailed={false} onCancel={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByTestId('card')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,57 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiCloseLine: () => <span data-testid="icon-close" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/placeholder', () => ({
|
||||
LoadingPlaceholder: () => <div data-testid="loading-placeholder" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../../base/icons/src/vender/other', () => ({
|
||||
Group: ({ className }: { className: string }) => <span data-testid="group-icon" className={className} />,
|
||||
}))
|
||||
|
||||
describe('LoadingError', () => {
|
||||
let LoadingError: React.FC
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./loading-error')
|
||||
LoadingError = mod.default
|
||||
})
|
||||
|
||||
it('should render error message', () => {
|
||||
render(<LoadingError />)
|
||||
|
||||
expect(screen.getByText('installModal.pluginLoadError')).toBeInTheDocument()
|
||||
expect(screen.getByText('installModal.pluginLoadErrorDesc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render disabled checkbox', () => {
|
||||
render(<LoadingError />)
|
||||
|
||||
expect(screen.getByTestId('checkbox-undefined')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render error icon with close indicator', () => {
|
||||
render(<LoadingError />)
|
||||
|
||||
expect(screen.getByTestId('icon-close')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('group-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading placeholder', () => {
|
||||
render(<LoadingError />)
|
||||
|
||||
expect(screen.getByTestId('loading-placeholder')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../card/base/placeholder', () => ({
|
||||
default: () => <div data-testid="placeholder" />,
|
||||
}))
|
||||
|
||||
describe('Loading', () => {
|
||||
let Loading: React.FC
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./loading')
|
||||
Loading = mod.default
|
||||
})
|
||||
|
||||
it('should render disabled unchecked checkbox', () => {
|
||||
render(<Loading />)
|
||||
|
||||
expect(screen.getByTestId('checkbox-undefined')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render placeholder', () => {
|
||||
render(<Loading />)
|
||||
|
||||
expect(screen.getByTestId('placeholder')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('Version', () => {
|
||||
let Version: (typeof import('./version'))['default']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./version')
|
||||
Version = mod.default
|
||||
})
|
||||
|
||||
it('should show simple version badge for new install', () => {
|
||||
render(<Version hasInstalled={false} toInstallVersion="1.0.0" />)
|
||||
|
||||
expect(screen.getByText('1.0.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show upgrade version badge for existing install', () => {
|
||||
render(
|
||||
<Version
|
||||
hasInstalled={true}
|
||||
installedVersion="1.0.0"
|
||||
toInstallVersion="2.0.0"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('1.0.0 -> 2.0.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle downgrade version display', () => {
|
||||
render(
|
||||
<Version
|
||||
hasInstalled={true}
|
||||
installedVersion="2.0.0"
|
||||
toInstallVersion="1.0.0"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('2.0.0 -> 1.0.0')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,166 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useGitHubReleases, useGitHubUpload } from './hooks'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: (...args: unknown[]) => mockNotify(...args) },
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
GITHUB_ACCESS_TOKEN: '',
|
||||
}))
|
||||
|
||||
const mockUploadGitHub = vi.fn()
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
uploadGitHub: (...args: unknown[]) => mockUploadGitHub(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/semver', () => ({
|
||||
compareVersion: (a: string, b: string) => {
|
||||
const parseVersion = (v: string) => v.replace(/^v/, '').split('.').map(Number)
|
||||
const va = parseVersion(a)
|
||||
const vb = parseVersion(b)
|
||||
for (let i = 0; i < Math.max(va.length, vb.length); i++) {
|
||||
const diff = (va[i] || 0) - (vb[i] || 0)
|
||||
if (diff > 0)
|
||||
return 1
|
||||
if (diff < 0)
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
},
|
||||
getLatestVersion: (versions: string[]) => {
|
||||
return versions.sort((a, b) => {
|
||||
const pa = a.replace(/^v/, '').split('.').map(Number)
|
||||
const pb = b.replace(/^v/, '').split('.').map(Number)
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const diff = (pa[i] || 0) - (pb[i] || 0)
|
||||
if (diff !== 0)
|
||||
return diff
|
||||
}
|
||||
return 0
|
||||
}).pop()!
|
||||
},
|
||||
}))
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
globalThis.fetch = mockFetch
|
||||
|
||||
describe('install-plugin/hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('useGitHubReleases', () => {
|
||||
describe('fetchReleases', () => {
|
||||
it('fetches releases from GitHub API and formats them', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([
|
||||
{
|
||||
tag_name: 'v1.0.0',
|
||||
assets: [{ browser_download_url: 'https://example.com/v1.zip', name: 'plugin.zip' }],
|
||||
body: 'Release notes',
|
||||
},
|
||||
]),
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useGitHubReleases())
|
||||
const releases = await result.current.fetchReleases('owner', 'repo')
|
||||
|
||||
expect(releases).toHaveLength(1)
|
||||
expect(releases[0].tag_name).toBe('v1.0.0')
|
||||
expect(releases[0].assets[0].name).toBe('plugin.zip')
|
||||
expect(releases[0]).not.toHaveProperty('body')
|
||||
})
|
||||
|
||||
it('returns empty array and shows toast on fetch error', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useGitHubReleases())
|
||||
const releases = await result.current.fetchReleases('owner', 'repo')
|
||||
|
||||
expect(releases).toEqual([])
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkForUpdates', () => {
|
||||
it('detects newer version available', () => {
|
||||
const { result } = renderHook(() => useGitHubReleases())
|
||||
const releases = [
|
||||
{ tag_name: 'v1.0.0', assets: [] },
|
||||
{ tag_name: 'v2.0.0', assets: [] },
|
||||
]
|
||||
const { needUpdate, toastProps } = result.current.checkForUpdates(releases, 'v1.0.0')
|
||||
expect(needUpdate).toBe(true)
|
||||
expect(toastProps.message).toContain('v2.0.0')
|
||||
})
|
||||
|
||||
it('returns no update when current is latest', () => {
|
||||
const { result } = renderHook(() => useGitHubReleases())
|
||||
const releases = [
|
||||
{ tag_name: 'v1.0.0', assets: [] },
|
||||
]
|
||||
const { needUpdate, toastProps } = result.current.checkForUpdates(releases, 'v1.0.0')
|
||||
expect(needUpdate).toBe(false)
|
||||
expect(toastProps.type).toBe('info')
|
||||
})
|
||||
|
||||
it('returns error for empty releases', () => {
|
||||
const { result } = renderHook(() => useGitHubReleases())
|
||||
const { needUpdate, toastProps } = result.current.checkForUpdates([], 'v1.0.0')
|
||||
expect(needUpdate).toBe(false)
|
||||
expect(toastProps.type).toBe('error')
|
||||
expect(toastProps.message).toContain('empty')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useGitHubUpload', () => {
|
||||
it('uploads successfully and calls onSuccess', async () => {
|
||||
const mockManifest = { name: 'test-plugin' }
|
||||
mockUploadGitHub.mockResolvedValue({
|
||||
manifest: mockManifest,
|
||||
unique_identifier: 'uid-123',
|
||||
})
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useGitHubUpload())
|
||||
const pkg = await result.current.handleUpload(
|
||||
'https://github.com/owner/repo',
|
||||
'v1.0.0',
|
||||
'plugin.difypkg',
|
||||
onSuccess,
|
||||
)
|
||||
|
||||
expect(mockUploadGitHub).toHaveBeenCalledWith(
|
||||
'https://github.com/owner/repo',
|
||||
'v1.0.0',
|
||||
'plugin.difypkg',
|
||||
)
|
||||
expect(onSuccess).toHaveBeenCalledWith({
|
||||
manifest: mockManifest,
|
||||
unique_identifier: 'uid-123',
|
||||
})
|
||||
expect(pkg.unique_identifier).toBe('uid-123')
|
||||
})
|
||||
|
||||
it('shows toast on upload error', async () => {
|
||||
mockUploadGitHub.mockRejectedValue(new Error('Upload failed'))
|
||||
|
||||
const { result } = renderHook(() => useGitHubUpload())
|
||||
await expect(
|
||||
result.current.handleUpload('url', 'v1', 'pkg'),
|
||||
).rejects.toThrow('Upload failed')
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error', message: 'Error uploading package' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import useCheckInstalled from './use-check-installed'
|
||||
|
||||
const mockPlugins = [
|
||||
{
|
||||
plugin_id: 'plugin-1',
|
||||
id: 'installed-1',
|
||||
declaration: { version: '1.0.0' },
|
||||
plugin_unique_identifier: 'org/plugin-1',
|
||||
},
|
||||
{
|
||||
plugin_id: 'plugin-2',
|
||||
id: 'installed-2',
|
||||
declaration: { version: '2.0.0' },
|
||||
plugin_unique_identifier: 'org/plugin-2',
|
||||
},
|
||||
]
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useCheckInstalled: ({ pluginIds, enabled }: { pluginIds: string[], enabled: boolean }) => ({
|
||||
data: enabled && pluginIds.length > 0 ? { plugins: mockPlugins } : undefined,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useCheckInstalled', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return installed info when enabled and has plugin IDs', () => {
|
||||
const { result } = renderHook(() => useCheckInstalled({
|
||||
pluginIds: ['plugin-1', 'plugin-2'],
|
||||
enabled: true,
|
||||
}))
|
||||
|
||||
expect(result.current.installedInfo).toBeDefined()
|
||||
expect(result.current.installedInfo?.['plugin-1']).toEqual({
|
||||
installedId: 'installed-1',
|
||||
installedVersion: '1.0.0',
|
||||
uniqueIdentifier: 'org/plugin-1',
|
||||
})
|
||||
expect(result.current.installedInfo?.['plugin-2']).toEqual({
|
||||
installedId: 'installed-2',
|
||||
installedVersion: '2.0.0',
|
||||
uniqueIdentifier: 'org/plugin-2',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return undefined installedInfo when disabled', () => {
|
||||
const { result } = renderHook(() => useCheckInstalled({
|
||||
pluginIds: ['plugin-1'],
|
||||
enabled: false,
|
||||
}))
|
||||
|
||||
expect(result.current.installedInfo).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined installedInfo with empty plugin IDs', () => {
|
||||
const { result } = renderHook(() => useCheckInstalled({
|
||||
pluginIds: [],
|
||||
enabled: true,
|
||||
}))
|
||||
|
||||
expect(result.current.installedInfo).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return isLoading and error states', () => {
|
||||
const { result } = renderHook(() => useCheckInstalled({
|
||||
pluginIds: ['plugin-1'],
|
||||
enabled: true,
|
||||
}))
|
||||
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,76 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import useHideLogic from './use-hide-logic'
|
||||
|
||||
const mockFoldAnimInto = vi.fn()
|
||||
const mockClearCountDown = vi.fn()
|
||||
const mockCountDownFoldIntoAnim = vi.fn()
|
||||
|
||||
vi.mock('./use-fold-anim-into', () => ({
|
||||
default: () => ({
|
||||
modalClassName: 'test-modal-class',
|
||||
foldIntoAnim: mockFoldAnimInto,
|
||||
clearCountDown: mockClearCountDown,
|
||||
countDownFoldIntoAnim: mockCountDownFoldIntoAnim,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useHideLogic', () => {
|
||||
const mockOnClose = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return initial state with modalClassName', () => {
|
||||
const { result } = renderHook(() => useHideLogic(mockOnClose))
|
||||
|
||||
expect(result.current.modalClassName).toBe('test-modal-class')
|
||||
})
|
||||
|
||||
it('should call onClose directly when not installing', () => {
|
||||
const { result } = renderHook(() => useHideLogic(mockOnClose))
|
||||
|
||||
act(() => {
|
||||
result.current.foldAnimInto()
|
||||
})
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
expect(mockFoldAnimInto).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call doFoldAnimInto when installing', () => {
|
||||
const { result } = renderHook(() => useHideLogic(mockOnClose))
|
||||
|
||||
act(() => {
|
||||
result.current.handleStartToInstall()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.foldAnimInto()
|
||||
})
|
||||
|
||||
expect(mockFoldAnimInto).toHaveBeenCalled()
|
||||
expect(mockOnClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set installing and start countdown on handleStartToInstall', () => {
|
||||
const { result } = renderHook(() => useHideLogic(mockOnClose))
|
||||
|
||||
act(() => {
|
||||
result.current.handleStartToInstall()
|
||||
})
|
||||
|
||||
expect(mockCountDownFoldIntoAnim).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear countdown when setIsInstalling to false', () => {
|
||||
const { result } = renderHook(() => useHideLogic(mockOnClose))
|
||||
|
||||
act(() => {
|
||||
result.current.setIsInstalling(false)
|
||||
})
|
||||
|
||||
expect(mockClearCountDown).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,149 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { InstallationScope } from '@/types/feature'
|
||||
import { pluginInstallLimit } from './use-install-plugin-limit'
|
||||
|
||||
const mockSystemFeatures = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: typeof mockSystemFeatures }) => unknown) =>
|
||||
selector({ systemFeatures: mockSystemFeatures }),
|
||||
}))
|
||||
|
||||
const basePlugin = {
|
||||
from: 'marketplace' as const,
|
||||
verification: { authorized_category: 'langgenius' },
|
||||
}
|
||||
|
||||
describe('pluginInstallLimit', () => {
|
||||
it('should allow all plugins when scope is ALL', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
|
||||
expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should deny all plugins when scope is NONE', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.NONE,
|
||||
},
|
||||
}
|
||||
|
||||
expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow langgenius plugins when scope is OFFICIAL_ONLY', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.OFFICIAL_ONLY,
|
||||
},
|
||||
}
|
||||
|
||||
expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should deny non-official plugins when scope is OFFICIAL_ONLY', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.OFFICIAL_ONLY,
|
||||
},
|
||||
}
|
||||
const plugin = { ...basePlugin, verification: { authorized_category: 'community' } }
|
||||
|
||||
expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow partner plugins when scope is OFFICIAL_AND_PARTNER', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.OFFICIAL_AND_PARTNER,
|
||||
},
|
||||
}
|
||||
const plugin = { ...basePlugin, verification: { authorized_category: 'partner' } }
|
||||
|
||||
expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should deny github plugins when restrict_to_marketplace_only is true', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: true,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
const plugin = { ...basePlugin, from: 'github' as const }
|
||||
|
||||
expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(false)
|
||||
})
|
||||
|
||||
it('should deny package plugins when restrict_to_marketplace_only is true', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: true,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
const plugin = { ...basePlugin, from: 'package' as const }
|
||||
|
||||
expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow marketplace plugins even when restrict_to_marketplace_only is true', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: true,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}
|
||||
|
||||
expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should default to langgenius when no verification info', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.OFFICIAL_ONLY,
|
||||
},
|
||||
}
|
||||
const plugin = { from: 'marketplace' as const }
|
||||
|
||||
expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(true)
|
||||
})
|
||||
|
||||
it('should fallback to canInstall true for unrecognized scope', () => {
|
||||
const features = {
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: 'unknown-scope' as InstallationScope,
|
||||
},
|
||||
}
|
||||
|
||||
expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePluginInstallLimit', () => {
|
||||
it('should return canInstall from pluginInstallLimit using global store', async () => {
|
||||
const { default: usePluginInstallLimit } = await import('./use-install-plugin-limit')
|
||||
const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' } }
|
||||
|
||||
const { result } = renderHook(() => usePluginInstallLimit(plugin as never))
|
||||
|
||||
expect(result.current.canInstall).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,168 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '../../types'
|
||||
|
||||
// Mock invalidation / refresh functions
|
||||
const mockInvalidateInstalledPluginList = vi.fn()
|
||||
const mockRefetchLLMModelList = vi.fn()
|
||||
const mockRefetchEmbeddingModelList = vi.fn()
|
||||
const mockRefetchRerankModelList = vi.fn()
|
||||
const mockRefreshModelProviders = vi.fn()
|
||||
const mockInvalidateAllToolProviders = vi.fn()
|
||||
const mockInvalidateAllBuiltInTools = vi.fn()
|
||||
const mockInvalidateAllDataSources = vi.fn()
|
||||
const mockInvalidateDataSourceListAuth = vi.fn()
|
||||
const mockInvalidateStrategyProviders = vi.fn()
|
||||
const mockInvalidateAllTriggerPlugins = vi.fn()
|
||||
const mockInvalidateRAGRecommendedPlugins = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
|
||||
ModelTypeEnum: { textGeneration: 'text-generation', textEmbedding: 'text-embedding', rerank: 'rerank' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelList: (type: string) => {
|
||||
const map: Record<string, { mutate: ReturnType<typeof vi.fn> }> = {
|
||||
'text-generation': { mutate: mockRefetchLLMModelList },
|
||||
'text-embedding': { mutate: mockRefetchEmbeddingModelList },
|
||||
'rerank': { mutate: mockRefetchRerankModelList },
|
||||
}
|
||||
return map[type] ?? { mutate: vi.fn() }
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({ refreshModelProviders: mockRefreshModelProviders }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders,
|
||||
useInvalidateAllBuiltInTools: () => mockInvalidateAllBuiltInTools,
|
||||
useInvalidateRAGRecommendedPlugins: () => mockInvalidateRAGRecommendedPlugins,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useInvalidDataSourceList: () => mockInvalidateAllDataSources,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-datasource', () => ({
|
||||
useInvalidDataSourceListAuth: () => mockInvalidateDataSourceListAuth,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-strategy', () => ({
|
||||
useInvalidateStrategyProviders: () => mockInvalidateStrategyProviders,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useInvalidateAllTriggerPlugins: () => mockInvalidateAllTriggerPlugins,
|
||||
}))
|
||||
|
||||
const { default: useRefreshPluginList } = await import('./use-refresh-plugin-list')
|
||||
|
||||
describe('useRefreshPluginList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should always invalidate installed plugin list', () => {
|
||||
const { result } = renderHook(() => useRefreshPluginList())
|
||||
|
||||
result.current.refreshPluginList()
|
||||
|
||||
expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should refresh tool providers for tool category manifest', () => {
|
||||
const { result } = renderHook(() => useRefreshPluginList())
|
||||
|
||||
result.current.refreshPluginList({ category: PluginCategoryEnum.tool } as never)
|
||||
|
||||
expect(mockInvalidateAllToolProviders).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateAllBuiltInTools).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateRAGRecommendedPlugins).toHaveBeenCalledWith('tool')
|
||||
})
|
||||
|
||||
it('should refresh model lists for model category manifest', () => {
|
||||
const { result } = renderHook(() => useRefreshPluginList())
|
||||
|
||||
result.current.refreshPluginList({ category: PluginCategoryEnum.model } as never)
|
||||
|
||||
expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefetchLLMModelList).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefetchEmbeddingModelList).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefetchRerankModelList).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should refresh datasource lists for datasource category manifest', () => {
|
||||
const { result } = renderHook(() => useRefreshPluginList())
|
||||
|
||||
result.current.refreshPluginList({ category: PluginCategoryEnum.datasource } as never)
|
||||
|
||||
expect(mockInvalidateAllDataSources).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateDataSourceListAuth).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should refresh trigger plugins for trigger category manifest', () => {
|
||||
const { result } = renderHook(() => useRefreshPluginList())
|
||||
|
||||
result.current.refreshPluginList({ category: PluginCategoryEnum.trigger } as never)
|
||||
|
||||
expect(mockInvalidateAllTriggerPlugins).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should refresh strategy providers for agent category manifest', () => {
|
||||
const { result } = renderHook(() => useRefreshPluginList())
|
||||
|
||||
result.current.refreshPluginList({ category: PluginCategoryEnum.agent } as never)
|
||||
|
||||
expect(mockInvalidateStrategyProviders).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should refresh all types when refreshAllType is true', () => {
|
||||
const { result } = renderHook(() => useRefreshPluginList())
|
||||
|
||||
result.current.refreshPluginList(undefined, true)
|
||||
|
||||
expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateAllToolProviders).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateAllBuiltInTools).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateRAGRecommendedPlugins).toHaveBeenCalledWith('tool')
|
||||
expect(mockInvalidateAllTriggerPlugins).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateAllDataSources).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateDataSourceListAuth).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefetchLLMModelList).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefetchEmbeddingModelList).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefetchRerankModelList).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateStrategyProviders).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not refresh category-specific lists when manifest is null', () => {
|
||||
const { result } = renderHook(() => useRefreshPluginList())
|
||||
|
||||
result.current.refreshPluginList(null)
|
||||
|
||||
expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateAllToolProviders).not.toHaveBeenCalled()
|
||||
expect(mockRefreshModelProviders).not.toHaveBeenCalled()
|
||||
expect(mockInvalidateAllDataSources).not.toHaveBeenCalled()
|
||||
expect(mockInvalidateAllTriggerPlugins).not.toHaveBeenCalled()
|
||||
expect(mockInvalidateStrategyProviders).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not refresh unrelated categories for a specific manifest', () => {
|
||||
const { result } = renderHook(() => useRefreshPluginList())
|
||||
|
||||
result.current.refreshPluginList({ category: PluginCategoryEnum.tool } as never)
|
||||
|
||||
expect(mockInvalidateAllToolProviders).toHaveBeenCalledTimes(1)
|
||||
expect(mockRefreshModelProviders).not.toHaveBeenCalled()
|
||||
expect(mockInvalidateAllDataSources).not.toHaveBeenCalled()
|
||||
expect(mockInvalidateAllTriggerPlugins).not.toHaveBeenCalled()
|
||||
expect(mockInvalidateStrategyProviders).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants'
|
||||
|
||||
describe('DEFAULT_SORT', () => {
|
||||
it('should have correct default sort values', () => {
|
||||
expect(DEFAULT_SORT).toEqual({
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should be immutable at runtime', () => {
|
||||
const originalSortBy = DEFAULT_SORT.sortBy
|
||||
const originalSortOrder = DEFAULT_SORT.sortOrder
|
||||
|
||||
expect(DEFAULT_SORT.sortBy).toBe(originalSortBy)
|
||||
expect(DEFAULT_SORT.sortOrder).toBe(originalSortOrder)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SCROLL_BOTTOM_THRESHOLD', () => {
|
||||
it('should be 100 pixels', () => {
|
||||
expect(SCROLL_BOTTOM_THRESHOLD).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PLUGIN_TYPE_SEARCH_MAP', () => {
|
||||
it('should contain all expected keys', () => {
|
||||
expect(PLUGIN_TYPE_SEARCH_MAP).toHaveProperty('all')
|
||||
expect(PLUGIN_TYPE_SEARCH_MAP).toHaveProperty('model')
|
||||
expect(PLUGIN_TYPE_SEARCH_MAP).toHaveProperty('tool')
|
||||
expect(PLUGIN_TYPE_SEARCH_MAP).toHaveProperty('agent')
|
||||
expect(PLUGIN_TYPE_SEARCH_MAP).toHaveProperty('extension')
|
||||
expect(PLUGIN_TYPE_SEARCH_MAP).toHaveProperty('datasource')
|
||||
expect(PLUGIN_TYPE_SEARCH_MAP).toHaveProperty('trigger')
|
||||
expect(PLUGIN_TYPE_SEARCH_MAP).toHaveProperty('bundle')
|
||||
})
|
||||
|
||||
it('should map to correct category enum values', () => {
|
||||
expect(PLUGIN_TYPE_SEARCH_MAP.all).toBe('all')
|
||||
expect(PLUGIN_TYPE_SEARCH_MAP.model).toBe(PluginCategoryEnum.model)
|
||||
expect(PLUGIN_TYPE_SEARCH_MAP.tool).toBe(PluginCategoryEnum.tool)
|
||||
expect(PLUGIN_TYPE_SEARCH_MAP.agent).toBe(PluginCategoryEnum.agent)
|
||||
expect(PLUGIN_TYPE_SEARCH_MAP.extension).toBe(PluginCategoryEnum.extension)
|
||||
expect(PLUGIN_TYPE_SEARCH_MAP.datasource).toBe(PluginCategoryEnum.datasource)
|
||||
expect(PLUGIN_TYPE_SEARCH_MAP.trigger).toBe(PluginCategoryEnum.trigger)
|
||||
expect(PLUGIN_TYPE_SEARCH_MAP.bundle).toBe('bundle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PLUGIN_CATEGORY_WITH_COLLECTIONS', () => {
|
||||
it('should include all and tool categories', () => {
|
||||
expect(PLUGIN_CATEGORY_WITH_COLLECTIONS.has(PLUGIN_TYPE_SEARCH_MAP.all)).toBe(true)
|
||||
expect(PLUGIN_CATEGORY_WITH_COLLECTIONS.has(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe(true)
|
||||
})
|
||||
|
||||
it('should not include other categories', () => {
|
||||
expect(PLUGIN_CATEGORY_WITH_COLLECTIONS.has(PLUGIN_TYPE_SEARCH_MAP.model)).toBe(false)
|
||||
expect(PLUGIN_CATEGORY_WITH_COLLECTIONS.has(PLUGIN_TYPE_SEARCH_MAP.bundle)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,601 +0,0 @@
|
||||
import { render, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ================================
|
||||
// Mock External Dependencies
|
||||
// ================================
|
||||
|
||||
vi.mock('@/i18n-config/i18next-config', () => ({
|
||||
default: {
|
||||
getFixedT: () => (key: string) => key,
|
||||
},
|
||||
}))
|
||||
|
||||
const mockSetUrlFilters = vi.fn()
|
||||
vi.mock('@/hooks/use-query-params', () => ({
|
||||
useMarketplaceFilters: () => [
|
||||
{ q: '', tags: [], category: '' },
|
||||
mockSetUrlFilters,
|
||||
],
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstalledPluginList: () => ({
|
||||
data: { plugins: [] },
|
||||
isSuccess: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockFetchNextPage = vi.fn()
|
||||
const mockHasNextPage = false
|
||||
let mockInfiniteQueryData: { pages: Array<{ plugins: unknown[], total: number, page: number, page_size: number }> } | undefined
|
||||
let capturedInfiniteQueryFn: ((ctx: { pageParam: number, signal: AbortSignal }) => Promise<unknown>) | null = null
|
||||
let capturedQueryFn: ((ctx: { signal: AbortSignal }) => Promise<unknown>) | null = null
|
||||
let capturedGetNextPageParam: ((lastPage: { page: number, page_size: number, total: number }) => number | undefined) | null = null
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: vi.fn(({ queryFn, enabled }: { queryFn: (ctx: { signal: AbortSignal }) => Promise<unknown>, enabled: boolean }) => {
|
||||
capturedQueryFn = queryFn
|
||||
if (queryFn) {
|
||||
const controller = new AbortController()
|
||||
queryFn({ signal: controller.signal }).catch(() => {})
|
||||
}
|
||||
return {
|
||||
data: enabled ? { marketplaceCollections: [], marketplaceCollectionPluginsMap: {} } : undefined,
|
||||
isFetching: false,
|
||||
isPending: false,
|
||||
isSuccess: enabled,
|
||||
}
|
||||
}),
|
||||
useInfiniteQuery: vi.fn(({ queryFn, getNextPageParam }: {
|
||||
queryFn: (ctx: { pageParam: number, signal: AbortSignal }) => Promise<unknown>
|
||||
getNextPageParam: (lastPage: { page: number, page_size: number, total: number }) => number | undefined
|
||||
enabled: boolean
|
||||
}) => {
|
||||
capturedInfiniteQueryFn = queryFn
|
||||
capturedGetNextPageParam = getNextPageParam
|
||||
if (queryFn) {
|
||||
const controller = new AbortController()
|
||||
queryFn({ pageParam: 1, signal: controller.signal }).catch(() => {})
|
||||
}
|
||||
if (getNextPageParam) {
|
||||
getNextPageParam({ page: 1, page_size: 40, total: 100 })
|
||||
getNextPageParam({ page: 3, page_size: 40, total: 100 })
|
||||
}
|
||||
return {
|
||||
data: mockInfiniteQueryData,
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
hasNextPage: mockHasNextPage,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
}
|
||||
}),
|
||||
useQueryClient: vi.fn(() => ({
|
||||
removeQueries: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounceFn: (fn: (...args: unknown[]) => void) => ({
|
||||
run: fn,
|
||||
cancel: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
let mockPostMarketplaceShouldFail = false
|
||||
const mockPostMarketplaceResponse = {
|
||||
data: {
|
||||
plugins: [
|
||||
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
|
||||
{ type: 'plugin', org: 'test', name: 'plugin2', tags: [] },
|
||||
],
|
||||
bundles: [] as Array<{ type: string, org: string, name: string, tags: unknown[] }>,
|
||||
total: 2,
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
postMarketplace: vi.fn(() => {
|
||||
if (mockPostMarketplaceShouldFail)
|
||||
return Promise.reject(new Error('Mock API error'))
|
||||
return Promise.resolve(mockPostMarketplaceResponse)
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
API_PREFIX: '/api',
|
||||
APP_VERSION: '1.0.0',
|
||||
IS_MARKETPLACE: false,
|
||||
MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1',
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}`,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
marketplaceClient: {
|
||||
collections: vi.fn(async () => ({
|
||||
data: {
|
||||
collections: [
|
||||
{
|
||||
name: 'collection-1',
|
||||
label: { 'en-US': 'Collection 1' },
|
||||
description: { 'en-US': 'Desc' },
|
||||
rule: '',
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
searchable: true,
|
||||
search_params: { query: '', sort_by: 'install_count', sort_order: 'DESC' },
|
||||
},
|
||||
],
|
||||
},
|
||||
})),
|
||||
collectionPlugins: vi.fn(async () => ({
|
||||
data: {
|
||||
plugins: [
|
||||
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
|
||||
],
|
||||
},
|
||||
})),
|
||||
searchAdvanced: vi.fn(async () => ({
|
||||
data: {
|
||||
plugins: [
|
||||
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
// ================================
|
||||
// useMarketplaceCollectionsAndPlugins Tests
|
||||
// ================================
|
||||
describe('useMarketplaceCollectionsAndPlugins', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return initial state correctly', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.isSuccess).toBe(false)
|
||||
expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined()
|
||||
expect(result.current.setMarketplaceCollections).toBeDefined()
|
||||
expect(result.current.setMarketplaceCollectionPluginsMap).toBeDefined()
|
||||
})
|
||||
|
||||
it('should provide queryMarketplaceCollectionsAndPlugins function', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
expect(typeof result.current.queryMarketplaceCollectionsAndPlugins).toBe('function')
|
||||
})
|
||||
|
||||
it('should provide setMarketplaceCollections function', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
expect(typeof result.current.setMarketplaceCollections).toBe('function')
|
||||
})
|
||||
|
||||
it('should provide setMarketplaceCollectionPluginsMap function', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
expect(typeof result.current.setMarketplaceCollectionPluginsMap).toBe('function')
|
||||
})
|
||||
|
||||
it('should return marketplaceCollections from data or override', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
expect(result.current.marketplaceCollections).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return marketplaceCollectionPluginsMap from data or override', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
expect(result.current.marketplaceCollectionPluginsMap).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// useMarketplacePluginsByCollectionId Tests
|
||||
// ================================
|
||||
describe('useMarketplacePluginsByCollectionId', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return initial state when collectionId is undefined', async () => {
|
||||
const { useMarketplacePluginsByCollectionId } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePluginsByCollectionId(undefined))
|
||||
expect(result.current.plugins).toEqual([])
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.isSuccess).toBe(false)
|
||||
})
|
||||
|
||||
it('should return isLoading false when collectionId is provided and query completes', async () => {
|
||||
const { useMarketplacePluginsByCollectionId } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePluginsByCollectionId('test-collection'))
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept query parameter', async () => {
|
||||
const { useMarketplacePluginsByCollectionId } = await import('./hooks')
|
||||
const { result } = renderHook(() =>
|
||||
useMarketplacePluginsByCollectionId('test-collection', {
|
||||
category: 'tool',
|
||||
type: 'plugin',
|
||||
}))
|
||||
expect(result.current.plugins).toBeDefined()
|
||||
})
|
||||
|
||||
it('should return plugins property from hook', async () => {
|
||||
const { useMarketplacePluginsByCollectionId } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePluginsByCollectionId('collection-1'))
|
||||
expect(result.current.plugins).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// useMarketplacePlugins Tests
|
||||
// ================================
|
||||
describe('useMarketplacePlugins', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInfiniteQueryData = undefined
|
||||
})
|
||||
|
||||
it('should return initial state correctly', async () => {
|
||||
const { useMarketplacePlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(result.current.plugins).toBeUndefined()
|
||||
expect(result.current.total).toBeUndefined()
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.isFetchingNextPage).toBe(false)
|
||||
expect(result.current.hasNextPage).toBe(false)
|
||||
expect(result.current.page).toBe(0)
|
||||
})
|
||||
|
||||
it('should provide queryPlugins function', async () => {
|
||||
const { useMarketplacePlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(typeof result.current.queryPlugins).toBe('function')
|
||||
})
|
||||
|
||||
it('should provide queryPluginsWithDebounced function', async () => {
|
||||
const { useMarketplacePlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(typeof result.current.queryPluginsWithDebounced).toBe('function')
|
||||
})
|
||||
|
||||
it('should provide cancelQueryPluginsWithDebounced function', async () => {
|
||||
const { useMarketplacePlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(typeof result.current.cancelQueryPluginsWithDebounced).toBe('function')
|
||||
})
|
||||
|
||||
it('should provide resetPlugins function', async () => {
|
||||
const { useMarketplacePlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(typeof result.current.resetPlugins).toBe('function')
|
||||
})
|
||||
|
||||
it('should provide fetchNextPage function', async () => {
|
||||
const { useMarketplacePlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(typeof result.current.fetchNextPage).toBe('function')
|
||||
})
|
||||
|
||||
it('should handle queryPlugins call without errors', async () => {
|
||||
const { useMarketplacePlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(() => {
|
||||
result.current.queryPlugins({
|
||||
query: 'test',
|
||||
sort_by: 'install_count',
|
||||
sort_order: 'DESC',
|
||||
category: 'tool',
|
||||
page_size: 20,
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle queryPlugins with bundle type', async () => {
|
||||
const { useMarketplacePlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(() => {
|
||||
result.current.queryPlugins({
|
||||
query: 'test',
|
||||
type: 'bundle',
|
||||
page_size: 40,
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle resetPlugins call', async () => {
|
||||
const { useMarketplacePlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(() => {
|
||||
result.current.resetPlugins()
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle queryPluginsWithDebounced call', async () => {
|
||||
const { useMarketplacePlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(() => {
|
||||
result.current.queryPluginsWithDebounced({
|
||||
query: 'debounced search',
|
||||
category: 'all',
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle cancelQueryPluginsWithDebounced call', async () => {
|
||||
const { useMarketplacePlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(() => {
|
||||
result.current.cancelQueryPluginsWithDebounced()
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should return correct page number', async () => {
|
||||
const { useMarketplacePlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(result.current.page).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle queryPlugins with tags', async () => {
|
||||
const { useMarketplacePlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(() => {
|
||||
result.current.queryPlugins({
|
||||
query: 'test',
|
||||
tags: ['search', 'image'],
|
||||
exclude: ['excluded-plugin'],
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Hooks queryFn Coverage Tests
|
||||
// ================================
|
||||
describe('Hooks queryFn Coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInfiniteQueryData = undefined
|
||||
mockPostMarketplaceShouldFail = false
|
||||
capturedInfiniteQueryFn = null
|
||||
capturedQueryFn = null
|
||||
})
|
||||
|
||||
it('should cover queryFn with pages data', async () => {
|
||||
mockInfiniteQueryData = {
|
||||
pages: [
|
||||
{ plugins: [{ name: 'plugin1' }], total: 10, page: 1, page_size: 40 },
|
||||
],
|
||||
}
|
||||
|
||||
const { useMarketplacePlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
|
||||
result.current.queryPlugins({
|
||||
query: 'test',
|
||||
category: 'tool',
|
||||
})
|
||||
|
||||
expect(result.current).toBeDefined()
|
||||
})
|
||||
|
||||
it('should expose page and total from infinite query data', async () => {
|
||||
mockInfiniteQueryData = {
|
||||
pages: [
|
||||
{ plugins: [{ name: 'plugin1' }, { name: 'plugin2' }], total: 20, page: 1, page_size: 40 },
|
||||
{ plugins: [{ name: 'plugin3' }], total: 20, page: 2, page_size: 40 },
|
||||
],
|
||||
}
|
||||
|
||||
const { useMarketplacePlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
|
||||
result.current.queryPlugins({ query: 'search' })
|
||||
expect(result.current.page).toBe(2)
|
||||
})
|
||||
|
||||
it('should return undefined total when no query is set', async () => {
|
||||
const { useMarketplacePlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
expect(result.current.total).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should directly test queryFn execution', async () => {
|
||||
const { useMarketplacePlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
|
||||
result.current.queryPlugins({
|
||||
query: 'direct test',
|
||||
category: 'tool',
|
||||
sort_by: 'install_count',
|
||||
sort_order: 'DESC',
|
||||
page_size: 40,
|
||||
})
|
||||
|
||||
if (capturedInfiniteQueryFn) {
|
||||
const controller = new AbortController()
|
||||
const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal })
|
||||
expect(response).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('should test queryFn with bundle type', async () => {
|
||||
const { useMarketplacePlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
|
||||
result.current.queryPlugins({
|
||||
type: 'bundle',
|
||||
query: 'bundle test',
|
||||
})
|
||||
|
||||
if (capturedInfiniteQueryFn) {
|
||||
const controller = new AbortController()
|
||||
const response = await capturedInfiniteQueryFn({ pageParam: 2, signal: controller.signal })
|
||||
expect(response).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('should test queryFn error handling', async () => {
|
||||
mockPostMarketplaceShouldFail = true
|
||||
|
||||
const { useMarketplacePlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplacePlugins())
|
||||
|
||||
result.current.queryPlugins({ query: 'test that will fail' })
|
||||
|
||||
if (capturedInfiniteQueryFn) {
|
||||
const controller = new AbortController()
|
||||
const response = await capturedInfiniteQueryFn({ pageParam: 1, signal: controller.signal })
|
||||
expect(response).toBeDefined()
|
||||
expect(response).toHaveProperty('plugins')
|
||||
}
|
||||
|
||||
mockPostMarketplaceShouldFail = false
|
||||
})
|
||||
|
||||
it('should test useMarketplaceCollectionsAndPlugins queryFn', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
|
||||
result.current.queryMarketplaceCollectionsAndPlugins({
|
||||
condition: 'category=tool',
|
||||
})
|
||||
|
||||
if (capturedQueryFn) {
|
||||
const controller = new AbortController()
|
||||
const response = await capturedQueryFn({ signal: controller.signal })
|
||||
expect(response).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('should test getNextPageParam directly', async () => {
|
||||
const { useMarketplacePlugins } = await import('./hooks')
|
||||
renderHook(() => useMarketplacePlugins())
|
||||
|
||||
if (capturedGetNextPageParam) {
|
||||
const nextPage = capturedGetNextPageParam({ page: 1, page_size: 40, total: 100 })
|
||||
expect(nextPage).toBe(2)
|
||||
|
||||
const noMorePages = capturedGetNextPageParam({ page: 3, page_size: 40, total: 100 })
|
||||
expect(noMorePages).toBeUndefined()
|
||||
|
||||
const atBoundary = capturedGetNextPageParam({ page: 2, page_size: 50, total: 100 })
|
||||
expect(atBoundary).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// useMarketplaceContainerScroll Tests
|
||||
// ================================
|
||||
describe('useMarketplaceContainerScroll', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should attach scroll event listener to container', async () => {
|
||||
const mockCallback = vi.fn()
|
||||
const mockContainer = document.createElement('div')
|
||||
mockContainer.id = 'marketplace-container'
|
||||
document.body.appendChild(mockContainer)
|
||||
|
||||
const addEventListenerSpy = vi.spyOn(mockContainer, 'addEventListener')
|
||||
const { useMarketplaceContainerScroll } = await import('./hooks')
|
||||
|
||||
const TestComponent = () => {
|
||||
useMarketplaceContainerScroll(mockCallback)
|
||||
return null
|
||||
}
|
||||
|
||||
render(<TestComponent />)
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function))
|
||||
document.body.removeChild(mockContainer)
|
||||
})
|
||||
|
||||
it('should call callback when scrolled to bottom', async () => {
|
||||
const mockCallback = vi.fn()
|
||||
const mockContainer = document.createElement('div')
|
||||
mockContainer.id = 'scroll-test-container-hooks'
|
||||
document.body.appendChild(mockContainer)
|
||||
|
||||
Object.defineProperty(mockContainer, 'scrollTop', { value: 900, writable: true })
|
||||
Object.defineProperty(mockContainer, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(mockContainer, 'clientHeight', { value: 100, writable: true })
|
||||
|
||||
const { useMarketplaceContainerScroll } = await import('./hooks')
|
||||
|
||||
const TestComponent = () => {
|
||||
useMarketplaceContainerScroll(mockCallback, 'scroll-test-container-hooks')
|
||||
return null
|
||||
}
|
||||
|
||||
render(<TestComponent />)
|
||||
|
||||
const scrollEvent = new Event('scroll')
|
||||
Object.defineProperty(scrollEvent, 'target', { value: mockContainer })
|
||||
mockContainer.dispatchEvent(scrollEvent)
|
||||
|
||||
expect(mockCallback).toHaveBeenCalled()
|
||||
document.body.removeChild(mockContainer)
|
||||
})
|
||||
|
||||
it('should not call callback when scrollTop is 0', async () => {
|
||||
const mockCallback = vi.fn()
|
||||
const mockContainer = document.createElement('div')
|
||||
mockContainer.id = 'scroll-test-container-hooks-2'
|
||||
document.body.appendChild(mockContainer)
|
||||
|
||||
Object.defineProperty(mockContainer, 'scrollTop', { value: 0, writable: true })
|
||||
Object.defineProperty(mockContainer, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(mockContainer, 'clientHeight', { value: 100, writable: true })
|
||||
|
||||
const { useMarketplaceContainerScroll } = await import('./hooks')
|
||||
|
||||
const TestComponent = () => {
|
||||
useMarketplaceContainerScroll(mockCallback, 'scroll-test-container-hooks-2')
|
||||
return null
|
||||
}
|
||||
|
||||
render(<TestComponent />)
|
||||
|
||||
const scrollEvent = new Event('scroll')
|
||||
Object.defineProperty(scrollEvent, 'target', { value: mockContainer })
|
||||
mockContainer.dispatchEvent(scrollEvent)
|
||||
|
||||
expect(mockCallback).not.toHaveBeenCalled()
|
||||
document.body.removeChild(mockContainer)
|
||||
})
|
||||
|
||||
it('should remove event listener on unmount', async () => {
|
||||
const mockCallback = vi.fn()
|
||||
const mockContainer = document.createElement('div')
|
||||
mockContainer.id = 'scroll-unmount-container-hooks'
|
||||
document.body.appendChild(mockContainer)
|
||||
|
||||
const removeEventListenerSpy = vi.spyOn(mockContainer, 'removeEventListener')
|
||||
const { useMarketplaceContainerScroll } = await import('./hooks')
|
||||
|
||||
const TestComponent = () => {
|
||||
useMarketplaceContainerScroll(mockCallback, 'scroll-unmount-container-hooks')
|
||||
return null
|
||||
}
|
||||
|
||||
const { unmount } = render(<TestComponent />)
|
||||
unmount()
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function))
|
||||
document.body.removeChild(mockContainer)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,317 +0,0 @@
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
|
||||
|
||||
// Mock config
|
||||
vi.mock('@/config', () => ({
|
||||
API_PREFIX: '/api',
|
||||
APP_VERSION: '1.0.0',
|
||||
IS_MARKETPLACE: false,
|
||||
MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1',
|
||||
}))
|
||||
|
||||
// Mock var utils
|
||||
vi.mock('@/utils/var', () => ({
|
||||
getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}`,
|
||||
}))
|
||||
|
||||
// Mock marketplace client
|
||||
const mockCollectionPlugins = vi.fn()
|
||||
const mockCollections = vi.fn()
|
||||
const mockSearchAdvanced = vi.fn()
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
marketplaceClient: {
|
||||
collections: (...args: unknown[]) => mockCollections(...args),
|
||||
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
|
||||
searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
// Factory for creating mock plugins
|
||||
const createMockPlugin = (overrides?: Partial<Plugin>): Plugin => ({
|
||||
type: 'plugin',
|
||||
org: 'test-org',
|
||||
name: 'test-plugin',
|
||||
plugin_id: 'plugin-1',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'test-org/test-plugin:1.0.0',
|
||||
icon: '/icon.png',
|
||||
verified: true,
|
||||
label: { 'en-US': 'Test Plugin' },
|
||||
brief: { 'en-US': 'Test plugin brief' },
|
||||
description: { 'en-US': 'Test plugin description' },
|
||||
introduction: 'Test plugin introduction',
|
||||
repository: 'https://github.com/test/plugin',
|
||||
category: PluginCategoryEnum.tool,
|
||||
install_count: 1000,
|
||||
endpoint: { settings: [] },
|
||||
tags: [{ name: 'search' }],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'community' },
|
||||
from: 'marketplace',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('getPluginIconInMarketplace', () => {
|
||||
it('should return correct icon URL for regular plugin', async () => {
|
||||
const { getPluginIconInMarketplace } = await import('./utils')
|
||||
const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' })
|
||||
const iconUrl = getPluginIconInMarketplace(plugin)
|
||||
expect(iconUrl).toBe('https://marketplace.dify.ai/api/v1/plugins/test-org/test-plugin/icon')
|
||||
})
|
||||
|
||||
it('should return correct icon URL for bundle', async () => {
|
||||
const { getPluginIconInMarketplace } = await import('./utils')
|
||||
const bundle = createMockPlugin({ org: 'test-org', name: 'test-bundle', type: 'bundle' })
|
||||
const iconUrl = getPluginIconInMarketplace(bundle)
|
||||
expect(iconUrl).toBe('https://marketplace.dify.ai/api/v1/bundles/test-org/test-bundle/icon')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFormattedPlugin', () => {
|
||||
it('should format plugin with icon URL', async () => {
|
||||
const { getFormattedPlugin } = await import('./utils')
|
||||
const rawPlugin = {
|
||||
type: 'plugin',
|
||||
org: 'test-org',
|
||||
name: 'test-plugin',
|
||||
tags: [{ name: 'search' }],
|
||||
} as unknown as Plugin
|
||||
|
||||
const formatted = getFormattedPlugin(rawPlugin)
|
||||
expect(formatted.icon).toBe('https://marketplace.dify.ai/api/v1/plugins/test-org/test-plugin/icon')
|
||||
})
|
||||
|
||||
it('should format bundle with additional properties', async () => {
|
||||
const { getFormattedPlugin } = await import('./utils')
|
||||
const rawBundle = {
|
||||
type: 'bundle',
|
||||
org: 'test-org',
|
||||
name: 'test-bundle',
|
||||
description: 'Bundle description',
|
||||
labels: { 'en-US': 'Test Bundle' },
|
||||
} as unknown as Plugin
|
||||
|
||||
const formatted = getFormattedPlugin(rawBundle)
|
||||
expect(formatted.icon).toBe('https://marketplace.dify.ai/api/v1/bundles/test-org/test-bundle/icon')
|
||||
expect(formatted.brief).toBe('Bundle description')
|
||||
expect(formatted.label).toEqual({ 'en-US': 'Test Bundle' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPluginLinkInMarketplace', () => {
|
||||
it('should return correct link for regular plugin', async () => {
|
||||
const { getPluginLinkInMarketplace } = await import('./utils')
|
||||
const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' })
|
||||
const link = getPluginLinkInMarketplace(plugin)
|
||||
expect(link).toBe('https://marketplace.dify.ai/plugins/test-org/test-plugin')
|
||||
})
|
||||
|
||||
it('should return correct link for bundle', async () => {
|
||||
const { getPluginLinkInMarketplace } = await import('./utils')
|
||||
const bundle = createMockPlugin({ org: 'test-org', name: 'test-bundle', type: 'bundle' })
|
||||
const link = getPluginLinkInMarketplace(bundle)
|
||||
expect(link).toBe('https://marketplace.dify.ai/bundles/test-org/test-bundle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPluginDetailLinkInMarketplace', () => {
|
||||
it('should return correct detail link for regular plugin', async () => {
|
||||
const { getPluginDetailLinkInMarketplace } = await import('./utils')
|
||||
const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' })
|
||||
const link = getPluginDetailLinkInMarketplace(plugin)
|
||||
expect(link).toBe('/plugins/test-org/test-plugin')
|
||||
})
|
||||
|
||||
it('should return correct detail link for bundle', async () => {
|
||||
const { getPluginDetailLinkInMarketplace } = await import('./utils')
|
||||
const bundle = createMockPlugin({ org: 'test-org', name: 'test-bundle', type: 'bundle' })
|
||||
const link = getPluginDetailLinkInMarketplace(bundle)
|
||||
expect(link).toBe('/bundles/test-org/test-bundle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMarketplaceListCondition', () => {
|
||||
it('should return category condition for tool', async () => {
|
||||
const { getMarketplaceListCondition } = await import('./utils')
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.tool)).toBe('category=tool')
|
||||
})
|
||||
|
||||
it('should return category condition for model', async () => {
|
||||
const { getMarketplaceListCondition } = await import('./utils')
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.model)).toBe('category=model')
|
||||
})
|
||||
|
||||
it('should return category condition for agent', async () => {
|
||||
const { getMarketplaceListCondition } = await import('./utils')
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.agent)).toBe('category=agent-strategy')
|
||||
})
|
||||
|
||||
it('should return category condition for datasource', async () => {
|
||||
const { getMarketplaceListCondition } = await import('./utils')
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.datasource)).toBe('category=datasource')
|
||||
})
|
||||
|
||||
it('should return category condition for trigger', async () => {
|
||||
const { getMarketplaceListCondition } = await import('./utils')
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.trigger)).toBe('category=trigger')
|
||||
})
|
||||
|
||||
it('should return endpoint category for extension', async () => {
|
||||
const { getMarketplaceListCondition } = await import('./utils')
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.extension)).toBe('category=endpoint')
|
||||
})
|
||||
|
||||
it('should return type condition for bundle', async () => {
|
||||
const { getMarketplaceListCondition } = await import('./utils')
|
||||
expect(getMarketplaceListCondition('bundle')).toBe('type=bundle')
|
||||
})
|
||||
|
||||
it('should return empty string for all', async () => {
|
||||
const { getMarketplaceListCondition } = await import('./utils')
|
||||
expect(getMarketplaceListCondition('all')).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for unknown type', async () => {
|
||||
const { getMarketplaceListCondition } = await import('./utils')
|
||||
expect(getMarketplaceListCondition('unknown')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMarketplaceListFilterType', () => {
|
||||
it('should return undefined for all', async () => {
|
||||
const { getMarketplaceListFilterType } = await import('./utils')
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.all)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return bundle for bundle', async () => {
|
||||
const { getMarketplaceListFilterType } = await import('./utils')
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.bundle)).toBe('bundle')
|
||||
})
|
||||
|
||||
it('should return plugin for other categories', async () => {
|
||||
const { getMarketplaceListFilterType } = await import('./utils')
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe('plugin')
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.model)).toBe('plugin')
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.agent)).toBe('plugin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMarketplacePluginsByCollectionId', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should fetch plugins by collection id successfully', async () => {
|
||||
const mockPlugins = [
|
||||
{ type: 'plugin', org: 'test', name: 'plugin1', tags: [] },
|
||||
{ type: 'plugin', org: 'test', name: 'plugin2', tags: [] },
|
||||
]
|
||||
mockCollectionPlugins.mockResolvedValueOnce({
|
||||
data: { plugins: mockPlugins },
|
||||
})
|
||||
|
||||
const { getMarketplacePluginsByCollectionId } = await import('./utils')
|
||||
const result = await getMarketplacePluginsByCollectionId('test-collection', {
|
||||
category: 'tool',
|
||||
exclude: ['excluded-plugin'],
|
||||
type: 'plugin',
|
||||
})
|
||||
|
||||
expect(mockCollectionPlugins).toHaveBeenCalled()
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle fetch error and return empty array', async () => {
|
||||
mockCollectionPlugins.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const { getMarketplacePluginsByCollectionId } = await import('./utils')
|
||||
const result = await getMarketplacePluginsByCollectionId('test-collection')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should pass abort signal when provided', async () => {
|
||||
const mockPlugins = [{ type: 'plugin', org: 'test', name: 'plugin1' }]
|
||||
mockCollectionPlugins.mockResolvedValueOnce({
|
||||
data: { plugins: mockPlugins },
|
||||
})
|
||||
|
||||
const controller = new AbortController()
|
||||
const { getMarketplacePluginsByCollectionId } = await import('./utils')
|
||||
await getMarketplacePluginsByCollectionId('test-collection', {}, { signal: controller.signal })
|
||||
|
||||
expect(mockCollectionPlugins).toHaveBeenCalled()
|
||||
const call = mockCollectionPlugins.mock.calls[0]
|
||||
expect(call[1]).toMatchObject({ signal: controller.signal })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMarketplaceCollectionsAndPlugins', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should fetch collections and plugins successfully', async () => {
|
||||
const mockCollectionData = [
|
||||
{ name: 'collection1', label: {}, description: {}, rule: '', created_at: '', updated_at: '' },
|
||||
]
|
||||
const mockPluginData = [{ type: 'plugin', org: 'test', name: 'plugin1' }]
|
||||
|
||||
mockCollections.mockResolvedValueOnce({ data: { collections: mockCollectionData } })
|
||||
mockCollectionPlugins.mockResolvedValue({ data: { plugins: mockPluginData } })
|
||||
|
||||
const { getMarketplaceCollectionsAndPlugins } = await import('./utils')
|
||||
const result = await getMarketplaceCollectionsAndPlugins({
|
||||
condition: 'category=tool',
|
||||
type: 'plugin',
|
||||
})
|
||||
|
||||
expect(result.marketplaceCollections).toBeDefined()
|
||||
expect(result.marketplaceCollectionPluginsMap).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle fetch error and return empty data', async () => {
|
||||
mockCollections.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const { getMarketplaceCollectionsAndPlugins } = await import('./utils')
|
||||
const result = await getMarketplaceCollectionsAndPlugins()
|
||||
|
||||
expect(result.marketplaceCollections).toEqual([])
|
||||
expect(result.marketplaceCollectionPluginsMap).toEqual({})
|
||||
})
|
||||
|
||||
it('should append condition and type to URL when provided', async () => {
|
||||
mockCollections.mockResolvedValueOnce({ data: { collections: [] } })
|
||||
|
||||
const { getMarketplaceCollectionsAndPlugins } = await import('./utils')
|
||||
await getMarketplaceCollectionsAndPlugins({
|
||||
condition: 'category=tool',
|
||||
type: 'bundle',
|
||||
})
|
||||
|
||||
expect(mockCollections).toHaveBeenCalled()
|
||||
const call = mockCollections.mock.calls[0]
|
||||
expect(call[0]).toMatchObject({ query: expect.objectContaining({ condition: 'category=tool', type: 'bundle' }) })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCollectionsParams', () => {
|
||||
it('should return empty object for all category', async () => {
|
||||
const { getCollectionsParams } = await import('./utils')
|
||||
expect(getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.all)).toEqual({})
|
||||
})
|
||||
|
||||
it('should return category, condition, and type for tool category', async () => {
|
||||
const { getCollectionsParams } = await import('./utils')
|
||||
const result = getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.tool)
|
||||
expect(result).toEqual({
|
||||
category: PluginCategoryEnum.tool,
|
||||
condition: 'category=tool',
|
||||
type: 'plugin',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,67 +0,0 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthCategory } from '../types'
|
||||
import AddApiKeyButton from './add-api-key-button'
|
||||
|
||||
let _mockModalOpen = false
|
||||
vi.mock('./api-key-modal', () => ({
|
||||
default: ({ onClose, onUpdate }: { onClose: () => void, onUpdate?: () => void }) => {
|
||||
_mockModalOpen = true
|
||||
return (
|
||||
<div data-testid="api-key-modal">
|
||||
<button data-testid="modal-close" onClick={onClose}>Close</button>
|
||||
<button data-testid="modal-update" onClick={onUpdate}>Update</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const defaultPayload = {
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
}
|
||||
|
||||
describe('AddApiKeyButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
_mockModalOpen = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders button with default text', () => {
|
||||
render(<AddApiKeyButton pluginPayload={defaultPayload} />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders button with custom text', () => {
|
||||
render(<AddApiKeyButton pluginPayload={defaultPayload} buttonText="Add Key" />)
|
||||
expect(screen.getByText('Add Key')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens modal when button is clicked', () => {
|
||||
render(<AddApiKeyButton pluginPayload={defaultPayload} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(screen.getByTestId('api-key-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('respects disabled prop', () => {
|
||||
render(<AddApiKeyButton pluginPayload={defaultPayload} disabled />)
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('closes modal when onClose is called', () => {
|
||||
render(<AddApiKeyButton pluginPayload={defaultPayload} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(screen.getByTestId('api-key-modal')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('modal-close'))
|
||||
expect(screen.queryByTestId('api-key-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies custom button variant', () => {
|
||||
render(<AddApiKeyButton pluginPayload={defaultPayload} buttonVariant="primary" />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,114 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthCategory } from '../types'
|
||||
|
||||
const mockGetPluginOAuthUrl = vi.fn().mockResolvedValue({ authorization_url: 'https://auth.example.com' })
|
||||
const mockOpenOAuthPopup = vi.fn()
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-i18n', () => ({
|
||||
useRenderI18nObject: () => (obj: Record<string, string> | string) => typeof obj === 'string' ? obj : obj.en_US || '',
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-oauth', () => ({
|
||||
openOAuthPopup: (...args: unknown[]) => mockOpenOAuthPopup(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-credential', () => ({
|
||||
useGetPluginOAuthUrlHook: () => ({
|
||||
mutateAsync: mockGetPluginOAuthUrl,
|
||||
}),
|
||||
useGetPluginOAuthClientSchemaHook: () => ({
|
||||
data: {
|
||||
schema: [],
|
||||
is_oauth_custom_client_enabled: false,
|
||||
is_system_oauth_params_exists: true,
|
||||
client_params: {},
|
||||
redirect_uri: 'https://redirect.example.com',
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./oauth-client-settings', () => ({
|
||||
default: ({ onClose }: { onClose: () => void }) => (
|
||||
<div data-testid="oauth-settings-modal">
|
||||
<button data-testid="oauth-settings-close" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form/types', () => ({
|
||||
FormTypeEnum: { radio: 'radio' },
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiClipboardLine: () => <span data-testid="icon-clipboard" />,
|
||||
RiEqualizer2Line: () => <span data-testid="icon-equalizer" />,
|
||||
RiInformation2Fill: () => <span data-testid="icon-info" />,
|
||||
}))
|
||||
|
||||
const basePayload = {
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
}
|
||||
|
||||
describe('AddOAuthButton', () => {
|
||||
let AddOAuthButton: (typeof import('./add-oauth-button'))['default']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./add-oauth-button')
|
||||
AddOAuthButton = mod.default
|
||||
})
|
||||
|
||||
it('should render OAuth button when configured (system params exist)', () => {
|
||||
render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" />)
|
||||
|
||||
expect(screen.getByText('Use OAuth')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open OAuth settings modal when settings icon clicked', () => {
|
||||
render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('oauth-settings-button'))
|
||||
|
||||
expect(screen.getByTestId('oauth-settings-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close OAuth settings modal', () => {
|
||||
render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('oauth-settings-button'))
|
||||
fireEvent.click(screen.getByTestId('oauth-settings-close'))
|
||||
|
||||
expect(screen.queryByTestId('oauth-settings-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should trigger OAuth flow on main button click', async () => {
|
||||
render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" />)
|
||||
|
||||
const button = screen.getByText('Use OAuth').closest('button')
|
||||
if (button)
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(mockGetPluginOAuthUrl).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should be disabled when disabled prop is true', () => {
|
||||
render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" disabled />)
|
||||
|
||||
const button = screen.getByText('Use OAuth').closest('button')
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -1,171 +0,0 @@
|
||||
import type { ApiKeyModalProps } from './api-key-modal'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthCategory } from '../types'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
const mockAddPluginCredential = vi.fn().mockResolvedValue({})
|
||||
const mockUpdatePluginCredential = vi.fn().mockResolvedValue({})
|
||||
const mockFormValues = { isCheckValidated: true, values: { __name__: 'My Key', api_key: 'sk-123' } }
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-credential', () => ({
|
||||
useAddPluginCredentialHook: () => ({
|
||||
mutateAsync: mockAddPluginCredential,
|
||||
}),
|
||||
useGetPluginCredentialSchemaHook: () => ({
|
||||
data: [
|
||||
{ name: 'api_key', label: 'API Key', type: 'secret-input', required: true },
|
||||
],
|
||||
isLoading: false,
|
||||
}),
|
||||
useUpdatePluginCredentialHook: () => ({
|
||||
mutateAsync: mockUpdatePluginCredential,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../readme-panel/entrance', () => ({
|
||||
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../readme-panel/store', () => ({
|
||||
ReadmeShowType: { modal: 'modal' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/encrypted-bottom', () => ({
|
||||
EncryptedBottom: () => <div data-testid="encrypted-bottom" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/modal/modal', () => ({
|
||||
default: ({ children, title, onClose, onConfirm, onExtraButtonClick, showExtraButton, disabled }: {
|
||||
children: React.ReactNode
|
||||
title: string
|
||||
onClose?: () => void
|
||||
onCancel?: () => void
|
||||
onConfirm?: () => void
|
||||
onExtraButtonClick?: () => void
|
||||
showExtraButton?: boolean
|
||||
disabled?: boolean
|
||||
[key: string]: unknown
|
||||
}) => (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
{children}
|
||||
<button data-testid="modal-confirm" onClick={onConfirm} disabled={disabled}>Confirm</button>
|
||||
<button data-testid="modal-close" onClick={onClose}>Close</button>
|
||||
{showExtraButton && <button data-testid="modal-extra" onClick={onExtraButtonClick}>Remove</button>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({
|
||||
default: React.forwardRef((_props: Record<string, unknown>, ref: React.Ref<unknown>) => {
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
getFormValues: () => mockFormValues,
|
||||
}))
|
||||
return <div data-testid="auth-form" />
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form/types', () => ({
|
||||
FormTypeEnum: { textInput: 'text-input' },
|
||||
}))
|
||||
|
||||
const basePayload = {
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
}
|
||||
|
||||
describe('ApiKeyModal', () => {
|
||||
let ApiKeyModal: React.FC<ApiKeyModalProps>
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./api-key-modal')
|
||||
ApiKeyModal = mod.default
|
||||
})
|
||||
|
||||
it('should render modal with correct title', () => {
|
||||
render(<ApiKeyModal pluginPayload={basePayload} />)
|
||||
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent('auth.useApiAuth')
|
||||
})
|
||||
|
||||
it('should render auth form when data is loaded', () => {
|
||||
render(<ApiKeyModal pluginPayload={basePayload} />)
|
||||
|
||||
expect(screen.getByTestId('auth-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show remove button when editValues is provided', () => {
|
||||
render(<ApiKeyModal pluginPayload={basePayload} editValues={{ api_key: 'existing' }} />)
|
||||
|
||||
expect(screen.getByTestId('modal-extra')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show remove button in add mode', () => {
|
||||
render(<ApiKeyModal pluginPayload={basePayload} />)
|
||||
|
||||
expect(screen.queryByTestId('modal-extra')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onClose when close button clicked', () => {
|
||||
const mockOnClose = vi.fn()
|
||||
render(<ApiKeyModal pluginPayload={basePayload} onClose={mockOnClose} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-close'))
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call addPluginCredential on confirm in add mode', async () => {
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnUpdate = vi.fn()
|
||||
render(<ApiKeyModal pluginPayload={basePayload} onClose={mockOnClose} onUpdate={mockOnUpdate} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-confirm'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddPluginCredential).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'api-key',
|
||||
name: 'My Key',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should call updatePluginCredential on confirm in edit mode', async () => {
|
||||
render(<ApiKeyModal pluginPayload={basePayload} editValues={{ api_key: 'existing', __credential_id__: 'cred-1' }} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-confirm'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePluginCredential).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onRemove when remove button clicked', () => {
|
||||
const mockOnRemove = vi.fn()
|
||||
render(<ApiKeyModal pluginPayload={basePayload} editValues={{ api_key: 'existing' }} onRemove={mockOnRemove} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-extra'))
|
||||
expect(mockOnRemove).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render readme entrance when detail is provided', () => {
|
||||
const payload = { ...basePayload, detail: { name: 'Test' } as never }
|
||||
render(<ApiKeyModal pluginPayload={payload} />)
|
||||
|
||||
expect(screen.getByTestId('readme-entrance')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,185 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthCategory } from '../types'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
const mockSetPluginOAuthCustomClient = vi.fn().mockResolvedValue({})
|
||||
const mockDeletePluginOAuthCustomClient = vi.fn().mockResolvedValue({})
|
||||
const mockInvalidPluginOAuthClientSchema = vi.fn()
|
||||
const mockFormValues = { isCheckValidated: true, values: { __oauth_client__: 'custom', client_id: 'test-id' } }
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-credential', () => ({
|
||||
useSetPluginOAuthCustomClientHook: () => ({
|
||||
mutateAsync: mockSetPluginOAuthCustomClient,
|
||||
}),
|
||||
useDeletePluginOAuthCustomClientHook: () => ({
|
||||
mutateAsync: mockDeletePluginOAuthCustomClient,
|
||||
}),
|
||||
useInvalidPluginOAuthClientSchemaHook: () => mockInvalidPluginOAuthClientSchema,
|
||||
}))
|
||||
|
||||
vi.mock('../../readme-panel/entrance', () => ({
|
||||
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../readme-panel/store', () => ({
|
||||
ReadmeShowType: { modal: 'modal' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/modal/modal', () => ({
|
||||
default: ({ children, title, onClose: _onClose, onConfirm, onCancel, onExtraButtonClick, footerSlot }: {
|
||||
children: React.ReactNode
|
||||
title: string
|
||||
onClose?: () => void
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
onExtraButtonClick?: () => void
|
||||
footerSlot?: React.ReactNode
|
||||
[key: string]: unknown
|
||||
}) => (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
{children}
|
||||
<button data-testid="modal-confirm" onClick={onConfirm}>Save And Auth</button>
|
||||
<button data-testid="modal-cancel" onClick={onCancel}>Save Only</button>
|
||||
<button data-testid="modal-close" onClick={onExtraButtonClick}>Cancel</button>
|
||||
{footerSlot && <div data-testid="footer-slot">{footerSlot}</div>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({
|
||||
default: React.forwardRef((_props: Record<string, unknown>, ref: React.Ref<unknown>) => {
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
getFormValues: () => mockFormValues,
|
||||
}))
|
||||
return <div data-testid="auth-form" />
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-form', () => ({
|
||||
useForm: (config: Record<string, unknown>) => ({
|
||||
store: { subscribe: vi.fn(), getState: () => ({ values: config.defaultValues || {} }) },
|
||||
}),
|
||||
useStore: (_store: unknown, selector: (state: Record<string, unknown>) => unknown) => {
|
||||
return selector({ values: { __oauth_client__: 'custom' } })
|
||||
},
|
||||
}))
|
||||
|
||||
const basePayload = {
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
}
|
||||
|
||||
const defaultSchemas = [
|
||||
{ name: 'client_id', label: 'Client ID', type: 'text-input', required: true },
|
||||
] as never
|
||||
|
||||
describe('OAuthClientSettings', () => {
|
||||
let OAuthClientSettings: (typeof import('./oauth-client-settings'))['default']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./oauth-client-settings')
|
||||
OAuthClientSettings = mod.default
|
||||
})
|
||||
|
||||
it('should render modal with correct title', () => {
|
||||
render(
|
||||
<OAuthClientSettings
|
||||
pluginPayload={basePayload}
|
||||
schemas={defaultSchemas}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent('auth.oauthClientSettings')
|
||||
})
|
||||
|
||||
it('should render auth form', () => {
|
||||
render(
|
||||
<OAuthClientSettings
|
||||
pluginPayload={basePayload}
|
||||
schemas={defaultSchemas}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('auth-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onClose when cancel clicked', () => {
|
||||
const mockOnClose = vi.fn()
|
||||
render(
|
||||
<OAuthClientSettings
|
||||
pluginPayload={basePayload}
|
||||
schemas={defaultSchemas}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-close'))
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should save settings on save only button click', async () => {
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnUpdate = vi.fn()
|
||||
render(
|
||||
<OAuthClientSettings
|
||||
pluginPayload={basePayload}
|
||||
schemas={defaultSchemas}
|
||||
onClose={mockOnClose}
|
||||
onUpdate={mockOnUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledWith(expect.objectContaining({
|
||||
enable_oauth_custom_client: true,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should save and authorize on confirm button click', async () => {
|
||||
const mockOnAuth = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
<OAuthClientSettings
|
||||
pluginPayload={basePayload}
|
||||
schemas={defaultSchemas}
|
||||
onAuth={mockOnAuth}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-confirm'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetPluginOAuthCustomClient).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render readme entrance when detail is provided', () => {
|
||||
const payload = { ...basePayload, detail: { name: 'Test' } as never }
|
||||
render(
|
||||
<OAuthClientSettings
|
||||
pluginPayload={payload}
|
||||
schemas={defaultSchemas}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('readme-entrance')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,61 +0,0 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AuthorizedInDataSourceNode from './authorized-in-data-source-node'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'auth.authorization': '1 Authorization',
|
||||
'auth.authorizations': 'Multiple Authorizations',
|
||||
}
|
||||
return map[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiEqualizer2Line: () => <span data-testid="equalizer-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/indicator', () => ({
|
||||
default: ({ color }: { color: string }) => <span data-testid="indicator" data-color={color} />,
|
||||
}))
|
||||
|
||||
describe('AuthorizedInDataSourceNode', () => {
|
||||
const mockOnJump = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders with green indicator', () => {
|
||||
render(<AuthorizedInDataSourceNode authorizationsNum={1} onJumpToDataSourcePage={mockOnJump} />)
|
||||
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green')
|
||||
})
|
||||
|
||||
it('renders singular text for 1 authorization', () => {
|
||||
render(<AuthorizedInDataSourceNode authorizationsNum={1} onJumpToDataSourcePage={mockOnJump} />)
|
||||
expect(screen.getByText('1 Authorization')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders plural text for multiple authorizations', () => {
|
||||
render(<AuthorizedInDataSourceNode authorizationsNum={3} onJumpToDataSourcePage={mockOnJump} />)
|
||||
expect(screen.getByText('Multiple Authorizations')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onJumpToDataSourcePage when button is clicked', () => {
|
||||
render(<AuthorizedInDataSourceNode authorizationsNum={1} onJumpToDataSourcePage={mockOnJump} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(mockOnJump).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders equalizer icon', () => {
|
||||
render(<AuthorizedInDataSourceNode authorizationsNum={1} onJumpToDataSourcePage={mockOnJump} />)
|
||||
expect(screen.getByTestId('equalizer-icon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,210 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Credential, PluginPayload } from './types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthCategory, CredentialTypeEnum } from './types'
|
||||
|
||||
// ==================== Mock Setup ====================
|
||||
|
||||
const mockGetPluginCredentialInfo = vi.fn()
|
||||
const mockGetPluginOAuthClientSchema = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-plugins-auth', () => ({
|
||||
useGetPluginCredentialInfo: (url: string) => ({
|
||||
data: url ? mockGetPluginCredentialInfo() : undefined,
|
||||
isLoading: false,
|
||||
}),
|
||||
useDeletePluginCredential: () => ({ mutateAsync: vi.fn() }),
|
||||
useSetPluginDefaultCredential: () => ({ mutateAsync: vi.fn() }),
|
||||
useUpdatePluginCredential: () => ({ mutateAsync: vi.fn() }),
|
||||
useInvalidPluginCredentialInfo: () => vi.fn(),
|
||||
useGetPluginOAuthUrl: () => ({ mutateAsync: vi.fn() }),
|
||||
useGetPluginOAuthClientSchema: () => ({
|
||||
data: mockGetPluginOAuthClientSchema(),
|
||||
isLoading: false,
|
||||
}),
|
||||
useSetPluginOAuthCustomClient: () => ({ mutateAsync: vi.fn() }),
|
||||
useDeletePluginOAuthCustomClient: () => ({ mutateAsync: vi.fn() }),
|
||||
useInvalidPluginOAuthClientSchema: () => vi.fn(),
|
||||
useAddPluginCredential: () => ({ mutateAsync: vi.fn() }),
|
||||
useGetPluginCredentialSchema: () => ({ data: undefined, isLoading: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useInvalidToolsByType: () => vi.fn(),
|
||||
}))
|
||||
|
||||
const mockIsCurrentWorkspaceManager = vi.fn()
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({ notify: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-oauth', () => ({
|
||||
openOAuthPopup: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useTriggerPluginDynamicOptions: () => ({ data: { options: [] }, isLoading: false }),
|
||||
useTriggerPluginDynamicOptionsInfo: () => ({ data: null, isLoading: false }),
|
||||
useInvalidTriggerDynamicOptions: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ==================== Test Utilities ====================
|
||||
|
||||
const createTestQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
},
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
const testQueryClient = createTestQueryClient()
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const createPluginPayload = (overrides: Partial<PluginPayload> = {}): PluginPayload => ({
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createCredential = (overrides: Partial<Credential> = {}): Credential => ({
|
||||
id: 'test-credential-id',
|
||||
name: 'Test Credential',
|
||||
provider: 'test-provider',
|
||||
credential_type: CredentialTypeEnum.API_KEY,
|
||||
is_default: false,
|
||||
credentials: { api_key: 'test-key' },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ==================== Tests ====================
|
||||
|
||||
describe('AuthorizedInNode Component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCurrentWorkspaceManager.mockReturnValue(true)
|
||||
mockGetPluginCredentialInfo.mockReturnValue({
|
||||
credentials: [createCredential({ is_default: true })],
|
||||
supported_credential_types: [CredentialTypeEnum.API_KEY],
|
||||
allow_custom_token: true,
|
||||
})
|
||||
mockGetPluginOAuthClientSchema.mockReturnValue({
|
||||
schema: [],
|
||||
is_oauth_custom_client_enabled: false,
|
||||
is_system_oauth_params_exists: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render with workspace default when no credentialId', async () => {
|
||||
const AuthorizedInNode = (await import('./authorized-in-node')).default
|
||||
const pluginPayload = createPluginPayload()
|
||||
render(
|
||||
<AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={vi.fn()} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('plugin.auth.workspaceDefault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render credential name when credentialId matches', async () => {
|
||||
const AuthorizedInNode = (await import('./authorized-in-node')).default
|
||||
const credential = createCredential({ id: 'selected-id', name: 'My Credential' })
|
||||
mockGetPluginCredentialInfo.mockReturnValue({
|
||||
credentials: [credential],
|
||||
supported_credential_types: [CredentialTypeEnum.API_KEY],
|
||||
allow_custom_token: true,
|
||||
})
|
||||
const pluginPayload = createPluginPayload()
|
||||
render(
|
||||
<AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={vi.fn()} credentialId="selected-id" />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('My Credential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show auth removed when credentialId not found', async () => {
|
||||
const AuthorizedInNode = (await import('./authorized-in-node')).default
|
||||
mockGetPluginCredentialInfo.mockReturnValue({
|
||||
credentials: [createCredential()],
|
||||
supported_credential_types: [CredentialTypeEnum.API_KEY],
|
||||
allow_custom_token: true,
|
||||
})
|
||||
const pluginPayload = createPluginPayload()
|
||||
render(
|
||||
<AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={vi.fn()} credentialId="non-existent" />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('plugin.auth.authRemoved')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show unavailable when credential is not allowed', async () => {
|
||||
const AuthorizedInNode = (await import('./authorized-in-node')).default
|
||||
const credential = createCredential({
|
||||
id: 'unavailable-id',
|
||||
not_allowed_to_use: true,
|
||||
from_enterprise: false,
|
||||
})
|
||||
mockGetPluginCredentialInfo.mockReturnValue({
|
||||
credentials: [credential],
|
||||
supported_credential_types: [CredentialTypeEnum.API_KEY],
|
||||
allow_custom_token: true,
|
||||
})
|
||||
const pluginPayload = createPluginPayload()
|
||||
render(
|
||||
<AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={vi.fn()} credentialId="unavailable-id" />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button.textContent).toContain('plugin.auth.unavailable')
|
||||
})
|
||||
|
||||
it('should show unavailable when default credential is not allowed', async () => {
|
||||
const AuthorizedInNode = (await import('./authorized-in-node')).default
|
||||
const credential = createCredential({
|
||||
is_default: true,
|
||||
not_allowed_to_use: true,
|
||||
})
|
||||
mockGetPluginCredentialInfo.mockReturnValue({
|
||||
credentials: [credential],
|
||||
supported_credential_types: [CredentialTypeEnum.API_KEY],
|
||||
allow_custom_token: true,
|
||||
})
|
||||
const pluginPayload = createPluginPayload()
|
||||
render(
|
||||
<AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={vi.fn()} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button.textContent).toContain('plugin.auth.unavailable')
|
||||
})
|
||||
|
||||
it('should call onAuthorizationItemClick when clicking', async () => {
|
||||
const AuthorizedInNode = (await import('./authorized-in-node')).default
|
||||
const onAuthorizationItemClick = vi.fn()
|
||||
const pluginPayload = createPluginPayload()
|
||||
render(
|
||||
<AuthorizedInNode pluginPayload={pluginPayload} onAuthorizationItemClick={onAuthorizationItemClick} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[0])
|
||||
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should be memoized', async () => {
|
||||
const AuthorizedInNodeModule = await import('./authorized-in-node')
|
||||
expect(typeof AuthorizedInNodeModule.default).toBe('object')
|
||||
})
|
||||
})
|
||||
@@ -1,186 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthCategory, CredentialTypeEnum } from '../types'
|
||||
import {
|
||||
useAddPluginCredentialHook,
|
||||
useDeletePluginCredentialHook,
|
||||
useDeletePluginOAuthCustomClientHook,
|
||||
useGetPluginCredentialInfoHook,
|
||||
useGetPluginCredentialSchemaHook,
|
||||
useGetPluginOAuthClientSchemaHook,
|
||||
useGetPluginOAuthUrlHook,
|
||||
useInvalidPluginCredentialInfoHook,
|
||||
useInvalidPluginOAuthClientSchemaHook,
|
||||
useSetPluginDefaultCredentialHook,
|
||||
useSetPluginOAuthCustomClientHook,
|
||||
useUpdatePluginCredentialHook,
|
||||
} from './use-credential'
|
||||
|
||||
// Mock service hooks
|
||||
const mockUseGetPluginCredentialInfo = vi.fn().mockReturnValue({ data: null, isLoading: false })
|
||||
const mockUseDeletePluginCredential = vi.fn().mockReturnValue({ mutateAsync: vi.fn() })
|
||||
const mockUseInvalidPluginCredentialInfo = vi.fn().mockReturnValue(vi.fn())
|
||||
const mockUseSetPluginDefaultCredential = vi.fn().mockReturnValue({ mutateAsync: vi.fn() })
|
||||
const mockUseGetPluginCredentialSchema = vi.fn().mockReturnValue({ data: [], isLoading: false })
|
||||
const mockUseAddPluginCredential = vi.fn().mockReturnValue({ mutateAsync: vi.fn() })
|
||||
const mockUseUpdatePluginCredential = vi.fn().mockReturnValue({ mutateAsync: vi.fn() })
|
||||
const mockUseGetPluginOAuthUrl = vi.fn().mockReturnValue({ mutateAsync: vi.fn() })
|
||||
const mockUseGetPluginOAuthClientSchema = vi.fn().mockReturnValue({ data: null, isLoading: false })
|
||||
const mockUseInvalidPluginOAuthClientSchema = vi.fn().mockReturnValue(vi.fn())
|
||||
const mockUseSetPluginOAuthCustomClient = vi.fn().mockReturnValue({ mutateAsync: vi.fn() })
|
||||
const mockUseDeletePluginOAuthCustomClient = vi.fn().mockReturnValue({ mutateAsync: vi.fn() })
|
||||
const mockInvalidToolsByType = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-plugins-auth', () => ({
|
||||
useGetPluginCredentialInfo: (...args: unknown[]) => mockUseGetPluginCredentialInfo(...args),
|
||||
useDeletePluginCredential: (...args: unknown[]) => mockUseDeletePluginCredential(...args),
|
||||
useInvalidPluginCredentialInfo: (...args: unknown[]) => mockUseInvalidPluginCredentialInfo(...args),
|
||||
useSetPluginDefaultCredential: (...args: unknown[]) => mockUseSetPluginDefaultCredential(...args),
|
||||
useGetPluginCredentialSchema: (...args: unknown[]) => mockUseGetPluginCredentialSchema(...args),
|
||||
useAddPluginCredential: (...args: unknown[]) => mockUseAddPluginCredential(...args),
|
||||
useUpdatePluginCredential: (...args: unknown[]) => mockUseUpdatePluginCredential(...args),
|
||||
useGetPluginOAuthUrl: (...args: unknown[]) => mockUseGetPluginOAuthUrl(...args),
|
||||
useGetPluginOAuthClientSchema: (...args: unknown[]) => mockUseGetPluginOAuthClientSchema(...args),
|
||||
useInvalidPluginOAuthClientSchema: (...args: unknown[]) => mockUseInvalidPluginOAuthClientSchema(...args),
|
||||
useSetPluginOAuthCustomClient: (...args: unknown[]) => mockUseSetPluginOAuthCustomClient(...args),
|
||||
useDeletePluginOAuthCustomClient: (...args: unknown[]) => mockUseDeletePluginOAuthCustomClient(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useInvalidToolsByType: () => mockInvalidToolsByType,
|
||||
}))
|
||||
|
||||
const toolPayload = {
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
providerType: 'builtin',
|
||||
}
|
||||
|
||||
describe('use-credential hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('useGetPluginCredentialInfoHook', () => {
|
||||
it('should call service with correct URL when enabled', () => {
|
||||
renderHook(() => useGetPluginCredentialInfoHook(toolPayload, true))
|
||||
expect(mockUseGetPluginCredentialInfo).toHaveBeenCalledWith(
|
||||
`/workspaces/current/tool-provider/builtin/${toolPayload.provider}/credential/info`,
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass empty string when disabled', () => {
|
||||
renderHook(() => useGetPluginCredentialInfoHook(toolPayload, false))
|
||||
expect(mockUseGetPluginCredentialInfo).toHaveBeenCalledWith('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDeletePluginCredentialHook', () => {
|
||||
it('should call service with correct URL', () => {
|
||||
renderHook(() => useDeletePluginCredentialHook(toolPayload))
|
||||
expect(mockUseDeletePluginCredential).toHaveBeenCalledWith(
|
||||
`/workspaces/current/tool-provider/builtin/${toolPayload.provider}/delete`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useInvalidPluginCredentialInfoHook', () => {
|
||||
it('should return a function that invalidates both credential info and tools', () => {
|
||||
const { result } = renderHook(() => useInvalidPluginCredentialInfoHook(toolPayload))
|
||||
|
||||
result.current()
|
||||
|
||||
const invalidFn = mockUseInvalidPluginCredentialInfo.mock.results[0].value
|
||||
expect(invalidFn).toHaveBeenCalled()
|
||||
expect(mockInvalidToolsByType).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSetPluginDefaultCredentialHook', () => {
|
||||
it('should call service with correct URL', () => {
|
||||
renderHook(() => useSetPluginDefaultCredentialHook(toolPayload))
|
||||
expect(mockUseSetPluginDefaultCredential).toHaveBeenCalledWith(
|
||||
`/workspaces/current/tool-provider/builtin/${toolPayload.provider}/default-credential`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useGetPluginCredentialSchemaHook', () => {
|
||||
it('should call service with correct schema URL for API_KEY', () => {
|
||||
renderHook(() => useGetPluginCredentialSchemaHook(toolPayload, CredentialTypeEnum.API_KEY))
|
||||
expect(mockUseGetPluginCredentialSchema).toHaveBeenCalledWith(
|
||||
`/workspaces/current/tool-provider/builtin/${toolPayload.provider}/credential/schema/${CredentialTypeEnum.API_KEY}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('should call service with correct schema URL for OAUTH2', () => {
|
||||
renderHook(() => useGetPluginCredentialSchemaHook(toolPayload, CredentialTypeEnum.OAUTH2))
|
||||
expect(mockUseGetPluginCredentialSchema).toHaveBeenCalledWith(
|
||||
`/workspaces/current/tool-provider/builtin/${toolPayload.provider}/credential/schema/${CredentialTypeEnum.OAUTH2}`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAddPluginCredentialHook', () => {
|
||||
it('should call service with correct URL', () => {
|
||||
renderHook(() => useAddPluginCredentialHook(toolPayload))
|
||||
expect(mockUseAddPluginCredential).toHaveBeenCalledWith(
|
||||
`/workspaces/current/tool-provider/builtin/${toolPayload.provider}/add`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useUpdatePluginCredentialHook', () => {
|
||||
it('should call service with correct URL', () => {
|
||||
renderHook(() => useUpdatePluginCredentialHook(toolPayload))
|
||||
expect(mockUseUpdatePluginCredential).toHaveBeenCalledWith(
|
||||
`/workspaces/current/tool-provider/builtin/${toolPayload.provider}/update`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useGetPluginOAuthUrlHook', () => {
|
||||
it('should call service with correct URL', () => {
|
||||
renderHook(() => useGetPluginOAuthUrlHook(toolPayload))
|
||||
expect(mockUseGetPluginOAuthUrl).toHaveBeenCalledWith(
|
||||
`/oauth/plugin/${toolPayload.provider}/tool/authorization-url`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useGetPluginOAuthClientSchemaHook', () => {
|
||||
it('should call service with correct URL', () => {
|
||||
renderHook(() => useGetPluginOAuthClientSchemaHook(toolPayload))
|
||||
expect(mockUseGetPluginOAuthClientSchema).toHaveBeenCalledWith(
|
||||
`/workspaces/current/tool-provider/builtin/${toolPayload.provider}/oauth/client-schema`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useInvalidPluginOAuthClientSchemaHook', () => {
|
||||
it('should call service with correct URL', () => {
|
||||
renderHook(() => useInvalidPluginOAuthClientSchemaHook(toolPayload))
|
||||
expect(mockUseInvalidPluginOAuthClientSchema).toHaveBeenCalledWith(
|
||||
`/workspaces/current/tool-provider/builtin/${toolPayload.provider}/oauth/client-schema`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSetPluginOAuthCustomClientHook', () => {
|
||||
it('should call service with correct URL', () => {
|
||||
renderHook(() => useSetPluginOAuthCustomClientHook(toolPayload))
|
||||
expect(mockUseSetPluginOAuthCustomClient).toHaveBeenCalledWith(
|
||||
`/workspaces/current/tool-provider/builtin/${toolPayload.provider}/oauth/custom-client`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDeletePluginOAuthCustomClientHook', () => {
|
||||
it('should call service with correct URL', () => {
|
||||
renderHook(() => useDeletePluginOAuthCustomClientHook(toolPayload))
|
||||
expect(mockUseDeletePluginOAuthCustomClient).toHaveBeenCalledWith(
|
||||
`/workspaces/current/tool-provider/builtin/${toolPayload.provider}/oauth/custom-client`,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AuthCategory, CredentialTypeEnum } from '../types'
|
||||
import { useGetApi } from './use-get-api'
|
||||
|
||||
describe('useGetApi', () => {
|
||||
const provider = 'test-provider'
|
||||
|
||||
describe('tool category', () => {
|
||||
it('returns correct API paths for tool category', () => {
|
||||
const api = useGetApi({ category: AuthCategory.tool, provider })
|
||||
expect(api.getCredentialInfo).toBe(`/workspaces/current/tool-provider/builtin/${provider}/credential/info`)
|
||||
expect(api.setDefaultCredential).toBe(`/workspaces/current/tool-provider/builtin/${provider}/default-credential`)
|
||||
expect(api.getCredentials).toBe(`/workspaces/current/tool-provider/builtin/${provider}/credentials`)
|
||||
expect(api.addCredential).toBe(`/workspaces/current/tool-provider/builtin/${provider}/add`)
|
||||
expect(api.updateCredential).toBe(`/workspaces/current/tool-provider/builtin/${provider}/update`)
|
||||
expect(api.deleteCredential).toBe(`/workspaces/current/tool-provider/builtin/${provider}/delete`)
|
||||
expect(api.getOauthUrl).toBe(`/oauth/plugin/${provider}/tool/authorization-url`)
|
||||
})
|
||||
|
||||
it('returns a function for getCredentialSchema', () => {
|
||||
const api = useGetApi({ category: AuthCategory.tool, provider })
|
||||
expect(typeof api.getCredentialSchema).toBe('function')
|
||||
const schemaUrl = api.getCredentialSchema('api-key' as never)
|
||||
expect(schemaUrl).toBe(`/workspaces/current/tool-provider/builtin/${provider}/credential/schema/api-key`)
|
||||
})
|
||||
|
||||
it('includes OAuth client endpoints', () => {
|
||||
const api = useGetApi({ category: AuthCategory.tool, provider })
|
||||
expect(api.getOauthClientSchema).toBe(`/workspaces/current/tool-provider/builtin/${provider}/oauth/client-schema`)
|
||||
expect(api.setCustomOauthClient).toBe(`/workspaces/current/tool-provider/builtin/${provider}/oauth/custom-client`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('datasource category', () => {
|
||||
it('returns correct API paths for datasource category', () => {
|
||||
const api = useGetApi({ category: AuthCategory.datasource, provider })
|
||||
expect(api.getCredentials).toBe(`/auth/plugin/datasource/${provider}`)
|
||||
expect(api.addCredential).toBe(`/auth/plugin/datasource/${provider}`)
|
||||
expect(api.updateCredential).toBe(`/auth/plugin/datasource/${provider}/update`)
|
||||
expect(api.deleteCredential).toBe(`/auth/plugin/datasource/${provider}/delete`)
|
||||
expect(api.setDefaultCredential).toBe(`/auth/plugin/datasource/${provider}/default`)
|
||||
expect(api.getOauthUrl).toBe(`/oauth/plugin/${provider}/datasource/get-authorization-url`)
|
||||
})
|
||||
|
||||
it('returns empty string for getCredentialInfo', () => {
|
||||
const api = useGetApi({ category: AuthCategory.datasource, provider })
|
||||
expect(api.getCredentialInfo).toBe('')
|
||||
})
|
||||
|
||||
it('returns a function for getCredentialSchema that returns empty string', () => {
|
||||
const api = useGetApi({ category: AuthCategory.datasource, provider })
|
||||
expect(api.getCredentialSchema(CredentialTypeEnum.API_KEY)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('other categories', () => {
|
||||
it('returns empty strings as fallback for unsupported category', () => {
|
||||
const api = useGetApi({ category: AuthCategory.model, provider })
|
||||
expect(api.getCredentialInfo).toBe('')
|
||||
expect(api.setDefaultCredential).toBe('')
|
||||
expect(api.getCredentials).toBe('')
|
||||
expect(api.addCredential).toBe('')
|
||||
expect(api.updateCredential).toBe('')
|
||||
expect(api.deleteCredential).toBe('')
|
||||
expect(api.getOauthUrl).toBe('')
|
||||
})
|
||||
|
||||
it('returns a function for getCredentialSchema that returns empty string', () => {
|
||||
const api = useGetApi({ category: AuthCategory.model, provider })
|
||||
expect(api.getCredentialSchema(CredentialTypeEnum.API_KEY)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('default category', () => {
|
||||
it('defaults to tool category when category is not specified', () => {
|
||||
const api = useGetApi({ provider } as { category: AuthCategory, provider: string })
|
||||
expect(api.getCredentialInfo).toContain('tool-provider')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,197 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { usePluginAuthAction } from '../hooks/use-plugin-auth-action'
|
||||
import { AuthCategory } from '../types'
|
||||
|
||||
const mockDeletePluginCredential = vi.fn().mockResolvedValue({})
|
||||
const mockSetPluginDefaultCredential = vi.fn().mockResolvedValue({})
|
||||
const mockUpdatePluginCredential = vi.fn().mockResolvedValue({})
|
||||
const mockNotify = vi.fn()
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-credential', () => ({
|
||||
useDeletePluginCredentialHook: () => ({
|
||||
mutateAsync: mockDeletePluginCredential,
|
||||
}),
|
||||
useSetPluginDefaultCredentialHook: () => ({
|
||||
mutateAsync: mockSetPluginDefaultCredential,
|
||||
}),
|
||||
useUpdatePluginCredentialHook: () => ({
|
||||
mutateAsync: mockUpdatePluginCredential,
|
||||
}),
|
||||
}))
|
||||
|
||||
const pluginPayload = {
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
}
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return React.createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
}
|
||||
}
|
||||
|
||||
describe('usePluginAuthAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
expect(result.current.doingAction).toBe(false)
|
||||
expect(result.current.deleteCredentialId).toBeNull()
|
||||
expect(result.current.editValues).toBeNull()
|
||||
})
|
||||
|
||||
it('should open and close confirm dialog', () => {
|
||||
const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.openConfirm('cred-1')
|
||||
})
|
||||
expect(result.current.deleteCredentialId).toBe('cred-1')
|
||||
|
||||
act(() => {
|
||||
result.current.closeConfirm()
|
||||
})
|
||||
expect(result.current.deleteCredentialId).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle edit action', () => {
|
||||
const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
const editVals = { key: 'value' }
|
||||
act(() => {
|
||||
result.current.handleEdit('cred-1', editVals)
|
||||
})
|
||||
expect(result.current.editValues).toEqual(editVals)
|
||||
})
|
||||
|
||||
it('should handle remove action by setting deleteCredentialId', () => {
|
||||
const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleEdit('cred-1', { key: 'value' })
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleRemove()
|
||||
})
|
||||
expect(result.current.deleteCredentialId).toBe('cred-1')
|
||||
})
|
||||
|
||||
it('should handle confirm delete', async () => {
|
||||
const mockOnUpdate = vi.fn()
|
||||
const { result } = renderHook(() => usePluginAuthAction(pluginPayload, mockOnUpdate), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.openConfirm('cred-1')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleConfirm()
|
||||
})
|
||||
|
||||
expect(mockDeletePluginCredential).toHaveBeenCalledWith({ credential_id: 'cred-1' })
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }))
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
expect(result.current.deleteCredentialId).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle set default credential', async () => {
|
||||
const mockOnUpdate = vi.fn()
|
||||
const { result } = renderHook(() => usePluginAuthAction(pluginPayload, mockOnUpdate), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSetDefault('cred-1')
|
||||
})
|
||||
|
||||
expect(mockSetPluginDefaultCredential).toHaveBeenCalledWith('cred-1')
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }))
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle rename credential', async () => {
|
||||
const mockOnUpdate = vi.fn()
|
||||
const { result } = renderHook(() => usePluginAuthAction(pluginPayload, mockOnUpdate), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRename({
|
||||
credential_id: 'cred-1',
|
||||
name: 'New Name',
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockUpdatePluginCredential).toHaveBeenCalledWith({
|
||||
credential_id: 'cred-1',
|
||||
name: 'New Name',
|
||||
})
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }))
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should prevent concurrent actions during doingAction', async () => {
|
||||
const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleSetDoingAction(true)
|
||||
})
|
||||
expect(result.current.doingAction).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.openConfirm('cred-1')
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleConfirm()
|
||||
})
|
||||
expect(mockDeletePluginCredential).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle confirm without pending credential ID', async () => {
|
||||
const { result } = renderHook(() => usePluginAuthAction(pluginPayload), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleConfirm()
|
||||
})
|
||||
|
||||
expect(mockDeletePluginCredential).not.toHaveBeenCalled()
|
||||
expect(result.current.deleteCredentialId).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,110 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthCategory, CredentialTypeEnum } from '../types'
|
||||
import { usePluginAuth } from './use-plugin-auth'
|
||||
|
||||
// Mock dependencies
|
||||
const mockCredentials = [
|
||||
{ id: '1', credential_type: CredentialTypeEnum.API_KEY, is_default: false },
|
||||
{ id: '2', credential_type: CredentialTypeEnum.OAUTH2, is_default: true },
|
||||
]
|
||||
|
||||
const mockCredentialInfo = vi.fn().mockReturnValue({
|
||||
credentials: mockCredentials,
|
||||
supported_credential_types: [CredentialTypeEnum.API_KEY, CredentialTypeEnum.OAUTH2],
|
||||
allow_custom_token: true,
|
||||
})
|
||||
|
||||
const mockInvalidate = vi.fn()
|
||||
|
||||
vi.mock('./use-credential', () => ({
|
||||
useGetPluginCredentialInfoHook: (_payload: unknown, enable?: boolean) => ({
|
||||
data: enable ? mockCredentialInfo() : undefined,
|
||||
isLoading: false,
|
||||
}),
|
||||
useInvalidPluginCredentialInfoHook: () => mockInvalidate,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const basePayload = {
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
}
|
||||
|
||||
describe('usePluginAuth', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return authorized state when credentials exist', () => {
|
||||
const { result } = renderHook(() => usePluginAuth(basePayload, true))
|
||||
|
||||
expect(result.current.isAuthorized).toBe(true)
|
||||
expect(result.current.credentials).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should detect OAuth and API Key support', () => {
|
||||
const { result } = renderHook(() => usePluginAuth(basePayload, true))
|
||||
|
||||
expect(result.current.canOAuth).toBe(true)
|
||||
expect(result.current.canApiKey).toBe(true)
|
||||
})
|
||||
|
||||
it('should return disabled=false for workspace managers', () => {
|
||||
const { result } = renderHook(() => usePluginAuth(basePayload, true))
|
||||
|
||||
expect(result.current.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should return notAllowCustomCredential=false when allowed', () => {
|
||||
const { result } = renderHook(() => usePluginAuth(basePayload, true))
|
||||
|
||||
expect(result.current.notAllowCustomCredential).toBe(false)
|
||||
})
|
||||
|
||||
it('should return unauthorized when enable is false', () => {
|
||||
const { result } = renderHook(() => usePluginAuth(basePayload, false))
|
||||
|
||||
expect(result.current.isAuthorized).toBe(false)
|
||||
expect(result.current.credentials).toEqual([])
|
||||
})
|
||||
|
||||
it('should provide invalidate function', () => {
|
||||
const { result } = renderHook(() => usePluginAuth(basePayload, true))
|
||||
|
||||
expect(result.current.invalidPluginCredentialInfo).toBe(mockInvalidate)
|
||||
})
|
||||
|
||||
it('should handle empty credentials', () => {
|
||||
mockCredentialInfo.mockReturnValueOnce({
|
||||
credentials: [],
|
||||
supported_credential_types: [],
|
||||
allow_custom_token: false,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePluginAuth(basePayload, true))
|
||||
|
||||
expect(result.current.isAuthorized).toBe(false)
|
||||
expect(result.current.canOAuth).toBe(false)
|
||||
expect(result.current.canApiKey).toBe(false)
|
||||
expect(result.current.notAllowCustomCredential).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle only API Key support', () => {
|
||||
mockCredentialInfo.mockReturnValueOnce({
|
||||
credentials: [{ id: '1' }],
|
||||
supported_credential_types: [CredentialTypeEnum.API_KEY],
|
||||
allow_custom_token: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePluginAuth(basePayload, true))
|
||||
|
||||
expect(result.current.canApiKey).toBe(true)
|
||||
expect(result.current.canOAuth).toBe(false)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,255 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Credential, PluginPayload } from './types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthCategory, CredentialTypeEnum } from './types'
|
||||
|
||||
// ==================== Mock Setup ====================
|
||||
|
||||
const mockGetPluginCredentialInfo = vi.fn()
|
||||
const mockGetPluginOAuthClientSchema = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-plugins-auth', () => ({
|
||||
useGetPluginCredentialInfo: (url: string) => ({
|
||||
data: url ? mockGetPluginCredentialInfo() : undefined,
|
||||
isLoading: false,
|
||||
}),
|
||||
useDeletePluginCredential: () => ({ mutateAsync: vi.fn() }),
|
||||
useSetPluginDefaultCredential: () => ({ mutateAsync: vi.fn() }),
|
||||
useUpdatePluginCredential: () => ({ mutateAsync: vi.fn() }),
|
||||
useInvalidPluginCredentialInfo: () => vi.fn(),
|
||||
useGetPluginOAuthUrl: () => ({ mutateAsync: vi.fn() }),
|
||||
useGetPluginOAuthClientSchema: () => ({
|
||||
data: mockGetPluginOAuthClientSchema(),
|
||||
isLoading: false,
|
||||
}),
|
||||
useSetPluginOAuthCustomClient: () => ({ mutateAsync: vi.fn() }),
|
||||
useDeletePluginOAuthCustomClient: () => ({ mutateAsync: vi.fn() }),
|
||||
useInvalidPluginOAuthClientSchema: () => vi.fn(),
|
||||
useAddPluginCredential: () => ({ mutateAsync: vi.fn() }),
|
||||
useGetPluginCredentialSchema: () => ({ data: undefined, isLoading: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useInvalidToolsByType: () => vi.fn(),
|
||||
}))
|
||||
|
||||
const mockIsCurrentWorkspaceManager = vi.fn()
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({ notify: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-oauth', () => ({
|
||||
openOAuthPopup: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useTriggerPluginDynamicOptions: () => ({ data: { options: [] }, isLoading: false }),
|
||||
useTriggerPluginDynamicOptionsInfo: () => ({ data: null, isLoading: false }),
|
||||
useInvalidTriggerDynamicOptions: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ==================== Test Utilities ====================
|
||||
|
||||
const createTestQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
},
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
const testQueryClient = createTestQueryClient()
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const createPluginPayload = (overrides: Partial<PluginPayload> = {}): PluginPayload => ({
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createCredential = (overrides: Partial<Credential> = {}): Credential => ({
|
||||
id: 'test-credential-id',
|
||||
name: 'Test Credential',
|
||||
provider: 'test-provider',
|
||||
credential_type: CredentialTypeEnum.API_KEY,
|
||||
is_default: false,
|
||||
credentials: { api_key: 'test-key' },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ==================== Tests ====================
|
||||
|
||||
describe('PluginAuthInAgent Component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCurrentWorkspaceManager.mockReturnValue(true)
|
||||
mockGetPluginCredentialInfo.mockReturnValue({
|
||||
credentials: [createCredential()],
|
||||
supported_credential_types: [CredentialTypeEnum.API_KEY],
|
||||
allow_custom_token: true,
|
||||
})
|
||||
mockGetPluginOAuthClientSchema.mockReturnValue({
|
||||
schema: [],
|
||||
is_oauth_custom_client_enabled: false,
|
||||
is_system_oauth_params_exists: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render Authorize when not authorized', async () => {
|
||||
const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
|
||||
mockGetPluginCredentialInfo.mockReturnValue({
|
||||
credentials: [],
|
||||
supported_credential_types: [CredentialTypeEnum.API_KEY],
|
||||
allow_custom_token: true,
|
||||
})
|
||||
const pluginPayload = createPluginPayload()
|
||||
render(
|
||||
<PluginAuthInAgent pluginPayload={pluginPayload} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Authorized with workspace default when authorized', async () => {
|
||||
const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
|
||||
const pluginPayload = createPluginPayload()
|
||||
render(
|
||||
<PluginAuthInAgent pluginPayload={pluginPayload} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.auth.workspaceDefault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show credential name when credentialId is provided', async () => {
|
||||
const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
|
||||
const credential = createCredential({ id: 'selected-id', name: 'Selected Credential' })
|
||||
mockGetPluginCredentialInfo.mockReturnValue({
|
||||
credentials: [credential],
|
||||
supported_credential_types: [CredentialTypeEnum.API_KEY],
|
||||
allow_custom_token: true,
|
||||
})
|
||||
const pluginPayload = createPluginPayload()
|
||||
render(
|
||||
<PluginAuthInAgent pluginPayload={pluginPayload} credentialId="selected-id" />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('Selected Credential')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show auth removed when credential not found', async () => {
|
||||
const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
|
||||
mockGetPluginCredentialInfo.mockReturnValue({
|
||||
credentials: [createCredential()],
|
||||
supported_credential_types: [CredentialTypeEnum.API_KEY],
|
||||
allow_custom_token: true,
|
||||
})
|
||||
const pluginPayload = createPluginPayload()
|
||||
render(
|
||||
<PluginAuthInAgent pluginPayload={pluginPayload} credentialId="non-existent-id" />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText('plugin.auth.authRemoved')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show unavailable when credential is not allowed to use', async () => {
|
||||
const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
|
||||
const credential = createCredential({
|
||||
id: 'unavailable-id',
|
||||
name: 'Unavailable Credential',
|
||||
not_allowed_to_use: true,
|
||||
from_enterprise: false,
|
||||
})
|
||||
mockGetPluginCredentialInfo.mockReturnValue({
|
||||
credentials: [credential],
|
||||
supported_credential_types: [CredentialTypeEnum.API_KEY],
|
||||
allow_custom_token: true,
|
||||
})
|
||||
const pluginPayload = createPluginPayload()
|
||||
render(
|
||||
<PluginAuthInAgent pluginPayload={pluginPayload} credentialId="unavailable-id" />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button.textContent).toContain('plugin.auth.unavailable')
|
||||
})
|
||||
|
||||
it('should call onAuthorizationItemClick when item is clicked', async () => {
|
||||
const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
|
||||
const onAuthorizationItemClick = vi.fn()
|
||||
const pluginPayload = createPluginPayload()
|
||||
render(
|
||||
<PluginAuthInAgent pluginPayload={pluginPayload} onAuthorizationItemClick={onAuthorizationItemClick} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[0])
|
||||
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should trigger handleAuthorizationItemClick and close popup when item is clicked', async () => {
|
||||
const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
|
||||
const onAuthorizationItemClick = vi.fn()
|
||||
const credential = createCredential({ id: 'test-cred-id', name: 'Test Credential' })
|
||||
mockGetPluginCredentialInfo.mockReturnValue({
|
||||
credentials: [credential],
|
||||
supported_credential_types: [CredentialTypeEnum.API_KEY],
|
||||
allow_custom_token: true,
|
||||
})
|
||||
const pluginPayload = createPluginPayload()
|
||||
render(
|
||||
<PluginAuthInAgent pluginPayload={pluginPayload} onAuthorizationItemClick={onAuthorizationItemClick} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
const triggerButton = screen.getByRole('button')
|
||||
fireEvent.click(triggerButton)
|
||||
const workspaceDefaultItems = screen.getAllByText('plugin.auth.workspaceDefault')
|
||||
const popupItem = workspaceDefaultItems.length > 1 ? workspaceDefaultItems[1] : workspaceDefaultItems[0]
|
||||
fireEvent.click(popupItem)
|
||||
expect(onAuthorizationItemClick).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('should call onAuthorizationItemClick with credential id when specific credential is clicked', async () => {
|
||||
const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default
|
||||
const onAuthorizationItemClick = vi.fn()
|
||||
const credential = createCredential({
|
||||
id: 'specific-cred-id',
|
||||
name: 'Specific Credential',
|
||||
credential_type: CredentialTypeEnum.API_KEY,
|
||||
})
|
||||
mockGetPluginCredentialInfo.mockReturnValue({
|
||||
credentials: [credential],
|
||||
supported_credential_types: [CredentialTypeEnum.API_KEY],
|
||||
allow_custom_token: true,
|
||||
})
|
||||
const pluginPayload = createPluginPayload()
|
||||
render(
|
||||
<PluginAuthInAgent pluginPayload={pluginPayload} onAuthorizationItemClick={onAuthorizationItemClick} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
const triggerButton = screen.getByRole('button')
|
||||
fireEvent.click(triggerButton)
|
||||
const credentialItems = screen.getAllByText('Specific Credential')
|
||||
const popupItem = credentialItems[credentialItems.length - 1]
|
||||
fireEvent.click(popupItem)
|
||||
expect(onAuthorizationItemClick).toHaveBeenCalledWith('specific-cred-id')
|
||||
})
|
||||
|
||||
it('should be memoized', async () => {
|
||||
const PluginAuthInAgentModule = await import('./plugin-auth-in-agent')
|
||||
expect(typeof PluginAuthInAgentModule.default).toBe('object')
|
||||
})
|
||||
})
|
||||
@@ -1,67 +0,0 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import PluginAuthInDataSourceNode from './plugin-auth-in-datasource-node'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'integrations.connect': 'Connect',
|
||||
}
|
||||
return map[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiAddLine: () => <span data-testid="add-icon" />,
|
||||
}))
|
||||
|
||||
describe('PluginAuthInDataSourceNode', () => {
|
||||
const mockOnJump = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders connect button when not authorized', () => {
|
||||
render(<PluginAuthInDataSourceNode onJumpToDataSourcePage={mockOnJump} />)
|
||||
expect(screen.getByText('Connect')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('add-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders connect button', () => {
|
||||
render(<PluginAuthInDataSourceNode onJumpToDataSourcePage={mockOnJump} />)
|
||||
expect(screen.getByRole('button', { name: /Connect/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onJumpToDataSourcePage when connect button is clicked', () => {
|
||||
render(<PluginAuthInDataSourceNode onJumpToDataSourcePage={mockOnJump} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Connect/ }))
|
||||
expect(mockOnJump).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hides connect button and shows children when authorized', () => {
|
||||
render(
|
||||
<PluginAuthInDataSourceNode isAuthorized onJumpToDataSourcePage={mockOnJump}>
|
||||
<div data-testid="child-content">Data Source Connected</div>
|
||||
</PluginAuthInDataSourceNode>,
|
||||
)
|
||||
expect(screen.queryByText('Connect')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('child-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows connect button when isAuthorized is false', () => {
|
||||
render(
|
||||
<PluginAuthInDataSourceNode isAuthorized={false} onJumpToDataSourcePage={mockOnJump}>
|
||||
<div data-testid="child-content">Data Source Connected</div>
|
||||
</PluginAuthInDataSourceNode>,
|
||||
)
|
||||
expect(screen.getByText('Connect')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('child-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,139 +0,0 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import PluginAuth from './plugin-auth'
|
||||
import { AuthCategory } from './types'
|
||||
|
||||
const mockUsePluginAuth = vi.fn()
|
||||
vi.mock('./hooks/use-plugin-auth', () => ({
|
||||
usePluginAuth: (...args: unknown[]) => mockUsePluginAuth(...args),
|
||||
}))
|
||||
|
||||
vi.mock('./authorize', () => ({
|
||||
default: ({ pluginPayload }: { pluginPayload: { provider: string } }) => (
|
||||
<div data-testid="authorize">
|
||||
Authorize:
|
||||
{pluginPayload.provider}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./authorized', () => ({
|
||||
default: ({ pluginPayload }: { pluginPayload: { provider: string } }) => (
|
||||
<div data-testid="authorized">
|
||||
Authorized:
|
||||
{pluginPayload.provider}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const defaultPayload = {
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
}
|
||||
|
||||
describe('PluginAuth', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders Authorize component when not authorized', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: false,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
render(<PluginAuth pluginPayload={defaultPayload} />)
|
||||
expect(screen.getByTestId('authorize')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('authorized')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Authorized component when authorized and no children', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: true,
|
||||
canOAuth: true,
|
||||
canApiKey: true,
|
||||
credentials: [{ id: '1', name: 'key', is_default: true, provider: 'test' }],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
render(<PluginAuth pluginPayload={defaultPayload} />)
|
||||
expect(screen.getByTestId('authorized')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('authorize')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders children when authorized and children provided', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: true,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [{ id: '1', name: 'key', is_default: true, provider: 'test' }],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
render(
|
||||
<PluginAuth pluginPayload={defaultPayload}>
|
||||
<div data-testid="custom-children">Custom Content</div>
|
||||
</PluginAuth>,
|
||||
)
|
||||
expect(screen.getByTestId('custom-children')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('authorized')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies className when not authorized', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: false,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
const { container } = render(<PluginAuth pluginPayload={defaultPayload} className="custom-class" />)
|
||||
expect((container.firstChild as HTMLElement).className).toContain('custom-class')
|
||||
})
|
||||
|
||||
it('does not apply className when authorized', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: true,
|
||||
canOAuth: false,
|
||||
canApiKey: true,
|
||||
credentials: [],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
const { container } = render(<PluginAuth pluginPayload={defaultPayload} className="custom-class" />)
|
||||
expect((container.firstChild as HTMLElement).className).not.toContain('custom-class')
|
||||
})
|
||||
|
||||
it('passes pluginPayload.provider to usePluginAuth', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: false,
|
||||
canOAuth: false,
|
||||
canApiKey: false,
|
||||
credentials: [],
|
||||
disabled: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
notAllowCustomCredential: false,
|
||||
})
|
||||
|
||||
render(<PluginAuth pluginPayload={defaultPayload} />)
|
||||
expect(mockUsePluginAuth).toHaveBeenCalledWith(defaultPayload, true)
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { transformFormSchemasSecretInput } from './utils'
|
||||
|
||||
describe('plugin-auth/utils', () => {
|
||||
describe('transformFormSchemasSecretInput', () => {
|
||||
it('replaces secret input values with [__HIDDEN__]', () => {
|
||||
const values = { api_key: 'sk-12345', username: 'admin' }
|
||||
const result = transformFormSchemasSecretInput(['api_key'], values)
|
||||
expect(result.api_key).toBe('[__HIDDEN__]')
|
||||
expect(result.username).toBe('admin')
|
||||
})
|
||||
|
||||
it('does not replace falsy values (empty string)', () => {
|
||||
const values = { api_key: '', username: 'admin' }
|
||||
const result = transformFormSchemasSecretInput(['api_key'], values)
|
||||
expect(result.api_key).toBe('')
|
||||
})
|
||||
|
||||
it('does not replace undefined values', () => {
|
||||
const values = { username: 'admin' }
|
||||
const result = transformFormSchemasSecretInput(['api_key'], values)
|
||||
expect(result.api_key).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles multiple secret fields', () => {
|
||||
const values = { key1: 'secret1', key2: 'secret2', normal: 'value' }
|
||||
const result = transformFormSchemasSecretInput(['key1', 'key2'], values)
|
||||
expect(result.key1).toBe('[__HIDDEN__]')
|
||||
expect(result.key2).toBe('[__HIDDEN__]')
|
||||
expect(result.normal).toBe('value')
|
||||
})
|
||||
|
||||
it('does not mutate the original values', () => {
|
||||
const values = { api_key: 'sk-12345' }
|
||||
const result = transformFormSchemasSecretInput(['api_key'], values)
|
||||
expect(result).not.toBe(values)
|
||||
expect(values.api_key).toBe('sk-12345')
|
||||
})
|
||||
|
||||
it('returns same values when no secret names provided', () => {
|
||||
const values = { api_key: 'sk-12345', username: 'admin' }
|
||||
const result = transformFormSchemasSecretInput([], values)
|
||||
expect(result).toEqual(values)
|
||||
})
|
||||
|
||||
it('handles null-like values correctly', () => {
|
||||
const values = { key: null, key2: 0, key3: false }
|
||||
const result = transformFormSchemasSecretInput(['key', 'key2', 'key3'], values)
|
||||
// null, 0, false are falsy — should not be replaced
|
||||
expect(result.key).toBeNull()
|
||||
expect(result.key2).toBe(0)
|
||||
expect(result.key3).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiArrowDownSLine: () => <span data-testid="chevron-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: ({ size }: { size: string }) => <div data-testid="app-icon" data-size={size} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
describe('AppTrigger', () => {
|
||||
let AppTrigger: (typeof import('./app-trigger'))['default']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./app-trigger')
|
||||
AppTrigger = mod.default
|
||||
})
|
||||
|
||||
it('should render placeholder when no app is selected', () => {
|
||||
render(<AppTrigger open={false} />)
|
||||
|
||||
expect(screen.queryByTestId('app-icon')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('chevron-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app details when appDetail is provided', () => {
|
||||
const appDetail = {
|
||||
name: 'My App',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#fff',
|
||||
}
|
||||
render(<AppTrigger open={false} appDetail={appDetail as never} />)
|
||||
|
||||
expect(screen.getByTestId('app-icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('My App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show chevron icon', () => {
|
||||
render(<AppTrigger open={true} />)
|
||||
|
||||
expect(screen.getByTestId('chevron-icon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,113 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/textarea', () => ({
|
||||
default: ({ value, onChange, disabled, placeholder }: {
|
||||
value?: string
|
||||
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}) => (
|
||||
<textarea
|
||||
data-testid="description-textarea"
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../readme-panel/entrance', () => ({
|
||||
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector/tool-picker', () => ({
|
||||
default: ({ trigger }: { trigger: React.ReactNode }) => (
|
||||
<div data-testid="tool-picker">{trigger}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./tool-trigger', () => ({
|
||||
default: ({ value, provider }: { open?: boolean, value?: unknown, provider?: unknown }) => (
|
||||
<div data-testid="tool-trigger" data-has-value={!!value} data-has-provider={!!provider} />
|
||||
),
|
||||
}))
|
||||
|
||||
const mockOnDescriptionChange = vi.fn()
|
||||
const mockOnShowChange = vi.fn()
|
||||
const mockOnSelectTool = vi.fn()
|
||||
const mockOnSelectMultipleTool = vi.fn()
|
||||
|
||||
const defaultProps = {
|
||||
isShowChooseTool: false,
|
||||
hasTrigger: true,
|
||||
onShowChange: mockOnShowChange,
|
||||
onSelectTool: mockOnSelectTool,
|
||||
onSelectMultipleTool: mockOnSelectMultipleTool,
|
||||
onDescriptionChange: mockOnDescriptionChange,
|
||||
}
|
||||
|
||||
describe('ToolBaseForm', () => {
|
||||
let ToolBaseForm: (typeof import('./tool-base-form'))['default']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./tool-base-form')
|
||||
ToolBaseForm = mod.default
|
||||
})
|
||||
|
||||
it('should render tool trigger within tool picker', () => {
|
||||
render(<ToolBaseForm {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('tool-trigger')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tool-picker')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description textarea', () => {
|
||||
render(<ToolBaseForm {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('description-textarea')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable textarea when no provider_name in value', () => {
|
||||
render(<ToolBaseForm {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('description-textarea')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable textarea when value has provider_name', () => {
|
||||
const value = { provider_name: 'test-provider', tool_name: 'test', extra: { description: 'Hello' } } as never
|
||||
render(<ToolBaseForm {...defaultProps} value={value} />)
|
||||
|
||||
expect(screen.getByTestId('description-textarea')).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should call onDescriptionChange when textarea content changes', () => {
|
||||
const value = { provider_name: 'test-provider', tool_name: 'test', extra: { description: 'Hello' } } as never
|
||||
render(<ToolBaseForm {...defaultProps} value={value} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('description-textarea'), { target: { value: 'Updated' } })
|
||||
expect(mockOnDescriptionChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show ReadmeEntrance when provider has plugin_unique_identifier', () => {
|
||||
const provider = { plugin_unique_identifier: 'test/plugin' } as never
|
||||
render(<ToolBaseForm {...defaultProps} currentProvider={provider} />)
|
||||
|
||||
expect(screen.getByTestId('readme-entrance')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show ReadmeEntrance without plugin_unique_identifier', () => {
|
||||
render(<ToolBaseForm {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByTestId('readme-entrance')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,124 +0,0 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-i18n', () => ({
|
||||
useRenderI18nObject: () => (obj: Record<string, string> | string) => typeof obj === 'string' ? obj : obj?.en_US || '',
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiArrowRightUpLine: () => <span data-testid="icon-arrow" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
useToastContext: () => ({ notify: vi.fn() }),
|
||||
}))
|
||||
|
||||
const mockFormSchemas = [
|
||||
{ name: 'api_key', label: { en_US: 'API Key' }, type: 'secret-input', required: true },
|
||||
]
|
||||
|
||||
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
|
||||
addDefaultValue: (values: Record<string, unknown>) => values,
|
||||
toolCredentialToFormSchemas: () => mockFormSchemas,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/tools', () => ({
|
||||
fetchBuiltInToolCredential: vi.fn().mockResolvedValue({ api_key: 'sk-existing-key' }),
|
||||
fetchBuiltInToolCredentialSchema: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({
|
||||
default: ({ value: _value, onChange }: { formSchemas: unknown[], value: Record<string, unknown>, onChange: (v: Record<string, unknown>) => void }) => (
|
||||
<div data-testid="credential-form">
|
||||
<input
|
||||
data-testid="form-input"
|
||||
onChange={e => onChange({ api_key: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ToolCredentialForm', () => {
|
||||
let ToolCredentialForm: (typeof import('./tool-credentials-form'))['default']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./tool-credentials-form')
|
||||
ToolCredentialForm = mod.default
|
||||
})
|
||||
|
||||
it('should render loading state initially', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<ToolCredentialForm
|
||||
collection={{ id: 'test', name: 'Test', labels: [] } as never}
|
||||
onCancel={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
})
|
||||
|
||||
// After act resolves async effects, form should be loaded
|
||||
expect(screen.getByTestId('credential-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render form after loading', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<ToolCredentialForm
|
||||
collection={{ id: 'test', name: 'Test', labels: [] } as never}
|
||||
onCancel={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('credential-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onCancel when cancel button clicked', async () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
await act(async () => {
|
||||
render(
|
||||
<ToolCredentialForm
|
||||
collection={{ id: 'test', name: 'Test', labels: [] } as never}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
})
|
||||
|
||||
const cancelBtn = screen.getByText('operation.cancel')
|
||||
fireEvent.click(cancelBtn)
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onSaved when save button clicked', async () => {
|
||||
const mockOnSaved = vi.fn()
|
||||
await act(async () => {
|
||||
render(
|
||||
<ToolCredentialForm
|
||||
collection={{ id: 'test', name: 'Test', labels: [] } as never}
|
||||
onCancel={vi.fn()}
|
||||
onSaved={mockOnSaved}
|
||||
/>,
|
||||
)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('operation.save'))
|
||||
expect(mockOnSaved).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { usePluginInstalledCheck } from './use-plugin-installed-check'
|
||||
|
||||
const mockManifest = {
|
||||
data: {
|
||||
plugin: {
|
||||
name: 'test-plugin',
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
usePluginManifestInfo: (pluginID: string) => ({
|
||||
data: pluginID ? mockManifest : undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('usePluginInstalledCheck', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should extract pluginID from provider name', () => {
|
||||
const { result } = renderHook(() => usePluginInstalledCheck('org/plugin/tool'))
|
||||
|
||||
expect(result.current.pluginID).toBe('org/plugin')
|
||||
})
|
||||
|
||||
it('should detect plugin in marketplace when manifest exists', () => {
|
||||
const { result } = renderHook(() => usePluginInstalledCheck('org/plugin/tool'))
|
||||
|
||||
expect(result.current.inMarketPlace).toBe(true)
|
||||
expect(result.current.manifest).toEqual(mockManifest.data.plugin)
|
||||
})
|
||||
|
||||
it('should handle empty provider name', () => {
|
||||
const { result } = renderHook(() => usePluginInstalledCheck(''))
|
||||
|
||||
expect(result.current.pluginID).toBe('')
|
||||
expect(result.current.inMarketPlace).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle undefined provider name', () => {
|
||||
const { result } = renderHook(() => usePluginInstalledCheck())
|
||||
|
||||
expect(result.current.pluginID).toBe('')
|
||||
expect(result.current.inMarketPlace).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle provider name with only one segment', () => {
|
||||
const { result } = renderHook(() => usePluginInstalledCheck('single'))
|
||||
|
||||
expect(result.current.pluginID).toBe('single')
|
||||
})
|
||||
|
||||
it('should handle provider name with two segments', () => {
|
||||
const { result } = renderHook(() => usePluginInstalledCheck('org/plugin'))
|
||||
|
||||
expect(result.current.pluginID).toBe('org/plugin')
|
||||
})
|
||||
})
|
||||
@@ -1,226 +0,0 @@
|
||||
import type * as React from 'react'
|
||||
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useToolSelectorState } from './use-tool-selector-state'
|
||||
|
||||
const mockToolParams = [
|
||||
{ name: 'param1', form: 'llm', type: 'string', required: true, label: { en_US: 'Param 1' } },
|
||||
{ name: 'param2', form: 'form', type: 'number', required: false, label: { en_US: 'Param 2' } },
|
||||
]
|
||||
|
||||
const mockTools = [
|
||||
{
|
||||
id: 'test-provider',
|
||||
name: 'Test Provider',
|
||||
tools: [
|
||||
{
|
||||
name: 'test-tool',
|
||||
label: { en_US: 'Test Tool' },
|
||||
description: { en_US: 'A test tool' },
|
||||
parameters: mockToolParams,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllBuiltInTools: () => ({ data: mockTools }),
|
||||
useAllCustomTools: () => ({ data: [] }),
|
||||
useAllWorkflowTools: () => ({ data: [] }),
|
||||
useAllMCPTools: () => ({ data: [] }),
|
||||
useInvalidateAllBuiltInTools: () => vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInvalidateInstalledPluginList: () => vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('./use-plugin-installed-check', () => ({
|
||||
usePluginInstalledCheck: () => ({
|
||||
inMarketPlace: false,
|
||||
manifest: null,
|
||||
pluginID: '',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/get-icon', () => ({
|
||||
getIconFromMarketPlace: () => '',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
|
||||
toolParametersToFormSchemas: (params: unknown[]) => (params as Record<string, unknown>[]).map(p => ({
|
||||
...p,
|
||||
variable: p.name,
|
||||
})),
|
||||
generateFormValue: (value: Record<string, unknown>) => value || {},
|
||||
getPlainValue: (value: Record<string, unknown>) => value || {},
|
||||
getStructureValue: (value: Record<string, unknown>) => value || {},
|
||||
}))
|
||||
|
||||
describe('useToolSelectorState', () => {
|
||||
const mockOnSelect = vi.fn()
|
||||
const _mockOnSelectMultiple = vi.fn()
|
||||
|
||||
const toolValue: ToolValue = {
|
||||
provider_name: 'test-provider',
|
||||
provider_show_name: 'Test Provider',
|
||||
tool_name: 'test-tool',
|
||||
tool_label: 'Test Tool',
|
||||
tool_description: 'A test tool',
|
||||
settings: {},
|
||||
parameters: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should initialize with default panel states', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useToolSelectorState({ onSelect: mockOnSelect }),
|
||||
)
|
||||
|
||||
expect(result.current.isShow).toBe(false)
|
||||
expect(result.current.isShowChooseTool).toBe(false)
|
||||
expect(result.current.currType).toBe('settings')
|
||||
})
|
||||
|
||||
it('should find current provider from tool value', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
|
||||
)
|
||||
|
||||
expect(result.current.currentProvider).toBeDefined()
|
||||
expect(result.current.currentProvider?.id).toBe('test-provider')
|
||||
})
|
||||
|
||||
it('should find current tool from provider', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
|
||||
)
|
||||
|
||||
expect(result.current.currentTool).toBeDefined()
|
||||
expect(result.current.currentTool?.name).toBe('test-tool')
|
||||
})
|
||||
|
||||
it('should compute tool settings and params correctly', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
|
||||
)
|
||||
|
||||
// param2 has form='form' (not 'llm'), so it goes to settings
|
||||
expect(result.current.currentToolSettings).toHaveLength(1)
|
||||
expect(result.current.currentToolSettings[0].name).toBe('param2')
|
||||
|
||||
// param1 has form='llm', so it goes to params
|
||||
expect(result.current.currentToolParams).toHaveLength(1)
|
||||
expect(result.current.currentToolParams[0].name).toBe('param1')
|
||||
})
|
||||
|
||||
it('should show tab slider when both settings and params exist', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
|
||||
)
|
||||
|
||||
expect(result.current.showTabSlider).toBe(true)
|
||||
expect(result.current.userSettingsOnly).toBe(false)
|
||||
expect(result.current.reasoningConfigOnly).toBe(false)
|
||||
})
|
||||
|
||||
it('should toggle panel visibility', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useToolSelectorState({ onSelect: mockOnSelect }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setIsShow(true)
|
||||
})
|
||||
expect(result.current.isShow).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.setIsShowChooseTool(true)
|
||||
})
|
||||
expect(result.current.isShowChooseTool).toBe(true)
|
||||
})
|
||||
|
||||
it('should switch tab type', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useToolSelectorState({ onSelect: mockOnSelect }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrType('params')
|
||||
})
|
||||
expect(result.current.currType).toBe('params')
|
||||
})
|
||||
|
||||
it('should handle description change', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
|
||||
)
|
||||
|
||||
const event = { target: { value: 'New description' } } as React.ChangeEvent<HTMLTextAreaElement>
|
||||
act(() => {
|
||||
result.current.handleDescriptionChange(event)
|
||||
})
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({
|
||||
extra: expect.objectContaining({ description: 'New description' }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should handle enabled change', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleEnabledChange(false)
|
||||
})
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({
|
||||
enabled: false,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should handle authorization item click', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleAuthorizationItemClick('cred-123')
|
||||
})
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({
|
||||
credential_id: 'cred-123',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should not call onSelect if value is undefined', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useToolSelectorState({ onSelect: mockOnSelect }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleEnabledChange(true)
|
||||
})
|
||||
expect(mockOnSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return empty arrays when no provider matches', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useToolSelectorState({
|
||||
value: { ...toolValue, provider_name: 'nonexistent' },
|
||||
onSelect: mockOnSelect,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.currentProvider).toBeUndefined()
|
||||
expect(result.current.currentTool).toBeUndefined()
|
||||
expect(result.current.currentToolSettings).toEqual([])
|
||||
expect(result.current.currentToolParams).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,127 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiArrowDownSLine: () => <span data-testid="icon-arrow-down" />,
|
||||
RiCloseCircleFill: ({ onClick }: { onClick?: (e: React.MouseEvent) => void }) => (
|
||||
<span data-testid="icon-clear" onClick={onClick} />
|
||||
),
|
||||
RiSearchLine: () => <span data-testid="icon-search" />,
|
||||
RiCheckLine: () => <span data-testid="icon-check" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
|
||||
<div data-testid="portal" data-open={open}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="portal-content">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
const mockCategories = [
|
||||
{ name: 'tool', label: 'Tool' },
|
||||
{ name: 'model', label: 'Model' },
|
||||
{ name: 'extension', label: 'Extension' },
|
||||
]
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useCategories: () => ({
|
||||
categories: mockCategories,
|
||||
categoriesMap: {
|
||||
tool: { label: 'Tool' },
|
||||
model: { label: 'Model' },
|
||||
extension: { label: 'Extension' },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('CategoriesFilter', () => {
|
||||
let CategoriesFilter: (typeof import('./category-filter'))['default']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./category-filter')
|
||||
CategoriesFilter = mod.default
|
||||
})
|
||||
|
||||
it('should show "allCategories" when no categories selected', () => {
|
||||
render(<CategoriesFilter value={[]} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('allCategories')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show selected category labels', () => {
|
||||
render(<CategoriesFilter value={['tool']} onChange={vi.fn()} />)
|
||||
|
||||
// "Tool" appears both in trigger and dropdown list
|
||||
const toolElements = screen.getAllByText('Tool')
|
||||
expect(toolElements.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should show +N when more than 2 selected', () => {
|
||||
render(<CategoriesFilter value={['tool', 'model', 'extension']} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('+1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show clear button when categories are selected', () => {
|
||||
render(<CategoriesFilter value={['tool']} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('icon-clear')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should clear all selections when clear button clicked', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<CategoriesFilter value={['tool']} onChange={mockOnChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('icon-clear'))
|
||||
expect(mockOnChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('should show arrow down when no selection', () => {
|
||||
render(<CategoriesFilter value={[]} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('icon-arrow-down')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render category options in dropdown', () => {
|
||||
render(<CategoriesFilter value={[]} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Tool')).toBeInTheDocument()
|
||||
expect(screen.getByText('Model')).toBeInTheDocument()
|
||||
expect(screen.getByText('Extension')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle category on option click', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<CategoriesFilter value={[]} onChange={mockOnChange} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Tool'))
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['tool'])
|
||||
})
|
||||
|
||||
it('should remove category when clicking already selected', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<CategoriesFilter value={['tool']} onChange={mockOnChange} />)
|
||||
|
||||
// Click on the option in dropdown (last "Tool" element)
|
||||
const toolElements = screen.getAllByText('Tool')
|
||||
fireEvent.click(toolElements[toolElements.length - 1])
|
||||
expect(mockOnChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('SearchBox', () => {
|
||||
let SearchBox: (typeof import('./search-box'))['default']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./search-box')
|
||||
SearchBox = mod.default
|
||||
})
|
||||
|
||||
it('should render input with placeholder', () => {
|
||||
render(<SearchBox searchQuery="" onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', 'search')
|
||||
})
|
||||
|
||||
it('should display current search query', () => {
|
||||
render(<SearchBox searchQuery="test query" onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('test query')
|
||||
})
|
||||
|
||||
it('should call onChange when input changes', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<SearchBox searchQuery="" onChange={mockOnChange} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new query' } })
|
||||
expect(mockOnChange).toHaveBeenCalledWith('new query')
|
||||
})
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { useStore } from './store'
|
||||
|
||||
describe('filter-management store', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store to default state
|
||||
const { result } = renderHook(() => useStore())
|
||||
act(() => {
|
||||
result.current.setTagList([])
|
||||
result.current.setCategoryList([])
|
||||
result.current.setShowTagManagementModal(false)
|
||||
result.current.setShowCategoryManagementModal(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should initialize with default values', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
|
||||
expect(result.current.tagList).toEqual([])
|
||||
expect(result.current.categoryList).toEqual([])
|
||||
expect(result.current.showTagManagementModal).toBe(false)
|
||||
expect(result.current.showCategoryManagementModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should set tag list', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
const tags = [{ name: 'tag1', label: { en_US: 'Tag 1' } }]
|
||||
|
||||
act(() => {
|
||||
result.current.setTagList(tags as never[])
|
||||
})
|
||||
|
||||
expect(result.current.tagList).toEqual(tags)
|
||||
})
|
||||
|
||||
it('should set category list', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
const categories = [{ name: 'cat1', label: { en_US: 'Cat 1' } }]
|
||||
|
||||
act(() => {
|
||||
result.current.setCategoryList(categories as never[])
|
||||
})
|
||||
|
||||
expect(result.current.categoryList).toEqual(categories)
|
||||
})
|
||||
|
||||
it('should toggle tag management modal', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowTagManagementModal(true)
|
||||
})
|
||||
expect(result.current.showTagManagementModal).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.setShowTagManagementModal(false)
|
||||
})
|
||||
expect(result.current.showTagManagementModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should toggle category management modal', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setShowCategoryManagementModal(true)
|
||||
})
|
||||
expect(result.current.showCategoryManagementModal).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.setShowCategoryManagementModal(false)
|
||||
})
|
||||
expect(result.current.showCategoryManagementModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle undefined tag list', () => {
|
||||
const { result } = renderHook(() => useStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setTagList(undefined)
|
||||
})
|
||||
|
||||
expect(result.current.tagList).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,81 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../base/modal', () => ({
|
||||
default: ({ children, title, isShow }: { children: React.ReactNode, title: string, isShow: boolean }) => (
|
||||
isShow
|
||||
? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../base/key-value-item', () => ({
|
||||
default: ({ label, value }: { label: string, value: string }) => (
|
||||
<div data-testid="key-value-item">
|
||||
<span data-testid="kv-label">{label}</span>
|
||||
<span data-testid="kv-value">{value}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../install-plugin/utils', () => ({
|
||||
convertRepoToUrl: (repo: string) => `https://github.com/${repo}`,
|
||||
}))
|
||||
|
||||
describe('PlugInfo', () => {
|
||||
let PlugInfo: (typeof import('./plugin-info'))['default']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./plugin-info')
|
||||
PlugInfo = mod.default
|
||||
})
|
||||
|
||||
it('should render modal with title', () => {
|
||||
render(<PlugInfo onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent('pluginInfoModal.title')
|
||||
})
|
||||
|
||||
it('should display repository info', () => {
|
||||
render(<PlugInfo repository="org/plugin" onHide={vi.fn()} />)
|
||||
|
||||
const kvItems = screen.getAllByTestId('key-value-item')
|
||||
expect(kvItems.length).toBeGreaterThanOrEqual(1)
|
||||
const values = screen.getAllByTestId('kv-value')
|
||||
expect(values.some(v => v.textContent?.includes('https://github.com/org/plugin'))).toBe(true)
|
||||
})
|
||||
|
||||
it('should display release info', () => {
|
||||
render(<PlugInfo release="v1.0.0" onHide={vi.fn()} />)
|
||||
|
||||
const values = screen.getAllByTestId('kv-value')
|
||||
expect(values.some(v => v.textContent === 'v1.0.0')).toBe(true)
|
||||
})
|
||||
|
||||
it('should display package name', () => {
|
||||
render(<PlugInfo packageName="my-plugin.difypkg" onHide={vi.fn()} />)
|
||||
|
||||
const values = screen.getAllByTestId('kv-value')
|
||||
expect(values.some(v => v.textContent === 'my-plugin.difypkg')).toBe(true)
|
||||
})
|
||||
|
||||
it('should not show items for undefined props', () => {
|
||||
render(<PlugInfo onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.queryAllByTestId('key-value-item')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,77 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TaskStatus } from '@/app/components/plugins/types'
|
||||
import { usePluginTaskStatus } from './hooks'
|
||||
|
||||
const mockClearTask = vi.fn().mockResolvedValue({})
|
||||
const mockRefetch = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
usePluginTaskList: () => ({
|
||||
pluginTasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
plugins: [
|
||||
{ id: 'plugin-1', status: TaskStatus.success, taskId: 'task-1' },
|
||||
{ id: 'plugin-2', status: TaskStatus.running, taskId: 'task-1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'task-2',
|
||||
plugins: [
|
||||
{ id: 'plugin-3', status: TaskStatus.failed, taskId: 'task-2' },
|
||||
],
|
||||
},
|
||||
],
|
||||
handleRefetch: mockRefetch,
|
||||
}),
|
||||
useMutationClearTaskPlugin: () => ({
|
||||
mutateAsync: mockClearTask,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('usePluginTaskStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should categorize plugins by status', () => {
|
||||
const { result } = renderHook(() => usePluginTaskStatus())
|
||||
|
||||
expect(result.current.successPlugins).toHaveLength(1)
|
||||
expect(result.current.runningPlugins).toHaveLength(1)
|
||||
expect(result.current.errorPlugins).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should compute correct length values', () => {
|
||||
const { result } = renderHook(() => usePluginTaskStatus())
|
||||
|
||||
expect(result.current.totalPluginsLength).toBe(3)
|
||||
expect(result.current.runningPluginsLength).toBe(1)
|
||||
expect(result.current.errorPluginsLength).toBe(1)
|
||||
expect(result.current.successPluginsLength).toBe(1)
|
||||
})
|
||||
|
||||
it('should detect isInstallingWithError state', () => {
|
||||
const { result } = renderHook(() => usePluginTaskStatus())
|
||||
|
||||
// running > 0 && error > 0
|
||||
expect(result.current.isInstallingWithError).toBe(true)
|
||||
expect(result.current.isInstalling).toBe(false)
|
||||
expect(result.current.isInstallingWithSuccess).toBe(false)
|
||||
expect(result.current.isSuccess).toBe(false)
|
||||
expect(result.current.isFailed).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle clear error plugin', async () => {
|
||||
const { result } = renderHook(() => usePluginTaskStatus())
|
||||
|
||||
await result.current.handleClearErrorPlugin('task-2', 'plugin-3')
|
||||
|
||||
expect(mockClearTask).toHaveBeenCalledWith({
|
||||
taskId: 'task-2',
|
||||
pluginId: 'plugin-3',
|
||||
})
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BUILTIN_TOOLS_ARRAY } from './constants'
|
||||
|
||||
describe('BUILTIN_TOOLS_ARRAY', () => {
|
||||
it('should contain expected builtin tools', () => {
|
||||
expect(BUILTIN_TOOLS_ARRAY).toContain('code')
|
||||
expect(BUILTIN_TOOLS_ARRAY).toContain('audio')
|
||||
expect(BUILTIN_TOOLS_ARRAY).toContain('time')
|
||||
expect(BUILTIN_TOOLS_ARRAY).toContain('webscraper')
|
||||
})
|
||||
|
||||
it('should have exactly 4 builtin tools', () => {
|
||||
expect(BUILTIN_TOOLS_ARRAY).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('should be an array of strings', () => {
|
||||
for (const tool of BUILTIN_TOOLS_ARRAY)
|
||||
expect(typeof tool).toBe('string')
|
||||
})
|
||||
})
|
||||
@@ -1,77 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiBookReadLine: () => <span data-testid="icon-book" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
const mockSetCurrentPluginDetail = vi.fn()
|
||||
|
||||
vi.mock('./store', () => ({
|
||||
ReadmeShowType: { drawer: 'drawer', side: 'side', modal: 'modal' },
|
||||
useReadmePanelStore: () => ({
|
||||
setCurrentPluginDetail: mockSetCurrentPluginDetail,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./constants', () => ({
|
||||
BUILTIN_TOOLS_ARRAY: ['google_search', 'bing_search'],
|
||||
}))
|
||||
|
||||
describe('ReadmeEntrance', () => {
|
||||
let ReadmeEntrance: (typeof import('./entrance'))['ReadmeEntrance']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./entrance')
|
||||
ReadmeEntrance = mod.ReadmeEntrance
|
||||
})
|
||||
|
||||
it('should render readme button for non-builtin plugin with unique identifier', () => {
|
||||
const pluginDetail = { id: 'custom-plugin', name: 'custom-plugin', plugin_unique_identifier: 'org/custom-plugin' } as never
|
||||
render(<ReadmeEntrance pluginDetail={pluginDetail} />)
|
||||
|
||||
expect(screen.getByTestId('icon-book')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setCurrentPluginDetail on button click', () => {
|
||||
const pluginDetail = { id: 'custom-plugin', name: 'custom-plugin', plugin_unique_identifier: 'org/custom-plugin' } as never
|
||||
render(<ReadmeEntrance pluginDetail={pluginDetail} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(mockSetCurrentPluginDetail).toHaveBeenCalledWith(pluginDetail, 'drawer')
|
||||
})
|
||||
|
||||
it('should return null for builtin tools', () => {
|
||||
const pluginDetail = { id: 'google_search', name: 'Google Search', plugin_unique_identifier: 'org/google' } as never
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={pluginDetail} />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should return null when plugin_unique_identifier is missing', () => {
|
||||
const pluginDetail = { id: 'some-plugin', name: 'Some Plugin' } as never
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={pluginDetail} />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should return null when pluginDetail is null', () => {
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={null as never} />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum, PluginSource } from '../types'
|
||||
import { BUILTIN_TOOLS_ARRAY } from './constants'
|
||||
import { ReadmeEntrance } from './entrance'
|
||||
import ReadmePanel from './index'
|
||||
import { ReadmeShowType, useReadmePanelStore } from './store'
|
||||
@@ -114,9 +115,289 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
)
|
||||
}
|
||||
|
||||
// Constants (BUILTIN_TOOLS_ARRAY) tests moved to constants.spec.ts
|
||||
// Store (useReadmePanelStore) tests moved to store.spec.ts
|
||||
// Entrance (ReadmeEntrance) tests moved to entrance.spec.tsx
|
||||
// ================================
|
||||
// Constants Tests
|
||||
// ================================
|
||||
describe('BUILTIN_TOOLS_ARRAY', () => {
|
||||
it('should contain expected builtin tools', () => {
|
||||
expect(BUILTIN_TOOLS_ARRAY).toContain('code')
|
||||
expect(BUILTIN_TOOLS_ARRAY).toContain('audio')
|
||||
expect(BUILTIN_TOOLS_ARRAY).toContain('time')
|
||||
expect(BUILTIN_TOOLS_ARRAY).toContain('webscraper')
|
||||
})
|
||||
|
||||
it('should have exactly 4 builtin tools', () => {
|
||||
expect(BUILTIN_TOOLS_ARRAY).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Store Tests
|
||||
// ================================
|
||||
describe('useReadmePanelStore', () => {
|
||||
describe('Initial State', () => {
|
||||
it('should have undefined currentPluginDetail initially', () => {
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setCurrentPluginDetail', () => {
|
||||
it('should set currentPluginDetail with detail and default showType', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
|
||||
act(() => {
|
||||
setCurrentPluginDetail(mockDetail)
|
||||
})
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toEqual({
|
||||
detail: mockDetail,
|
||||
showType: ReadmeShowType.drawer,
|
||||
})
|
||||
})
|
||||
|
||||
it('should set currentPluginDetail with custom showType', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
|
||||
act(() => {
|
||||
setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
|
||||
})
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toEqual({
|
||||
detail: mockDetail,
|
||||
showType: ReadmeShowType.modal,
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear currentPluginDetail when called without arguments', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
|
||||
// First set a detail
|
||||
act(() => {
|
||||
setCurrentPluginDetail(mockDetail)
|
||||
})
|
||||
|
||||
// Then clear it
|
||||
act(() => {
|
||||
setCurrentPluginDetail()
|
||||
})
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should clear currentPluginDetail when called with undefined', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
const { setCurrentPluginDetail } = useReadmePanelStore.getState()
|
||||
|
||||
// First set a detail
|
||||
act(() => {
|
||||
setCurrentPluginDetail(mockDetail)
|
||||
})
|
||||
|
||||
// Then clear it with explicit undefined
|
||||
act(() => {
|
||||
setCurrentPluginDetail(undefined)
|
||||
})
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReadmeShowType enum', () => {
|
||||
it('should have drawer and modal types', () => {
|
||||
expect(ReadmeShowType.drawer).toBe('drawer')
|
||||
expect(ReadmeShowType.modal).toBe('modal')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// ReadmeEntrance Component Tests
|
||||
// ================================
|
||||
describe('ReadmeEntrance', () => {
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render the entrance button with full tip text', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
render(<ReadmeEntrance pluginDetail={mockDetail} />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.readmeInfo.needHelpCheckReadme')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with short tip text when showShortTip is true', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
render(<ReadmeEntrance pluginDetail={mockDetail} showShortTip />)
|
||||
|
||||
expect(screen.getByText('plugin.readmeInfo.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render divider when showShortTip is false', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} showShortTip={false} />)
|
||||
|
||||
expect(container.querySelector('.bg-divider-regular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render divider when showShortTip is true', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} showShortTip />)
|
||||
|
||||
expect(container.querySelector('.bg-divider-regular')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply drawer mode padding class', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
const { container } = render(
|
||||
<ReadmeEntrance pluginDetail={mockDetail} showType={ReadmeShowType.drawer} />,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.px-4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
const { container } = render(
|
||||
<ReadmeEntrance pluginDetail={mockDetail} className="custom-class" />,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.custom-class')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Conditional Rendering / Edge Cases
|
||||
// ================================
|
||||
describe('Conditional Rendering', () => {
|
||||
it('should return null when pluginDetail is null/undefined', () => {
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={null as unknown as PluginDetail} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when plugin_unique_identifier is missing', () => {
|
||||
const mockDetail = createMockPluginDetail({ plugin_unique_identifier: '' })
|
||||
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for builtin tool: code', () => {
|
||||
const mockDetail = createMockPluginDetail({ id: 'code' })
|
||||
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for builtin tool: audio', () => {
|
||||
const mockDetail = createMockPluginDetail({ id: 'audio' })
|
||||
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for builtin tool: time', () => {
|
||||
const mockDetail = createMockPluginDetail({ id: 'time' })
|
||||
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for builtin tool: webscraper', () => {
|
||||
const mockDetail = createMockPluginDetail({ id: 'webscraper' })
|
||||
|
||||
const { container } = render(<ReadmeEntrance pluginDetail={mockDetail} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render for non-builtin plugins', () => {
|
||||
const mockDetail = createMockPluginDetail({ id: 'custom-plugin' })
|
||||
|
||||
render(<ReadmeEntrance pluginDetail={mockDetail} />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// User Interactions / Event Handlers
|
||||
// ================================
|
||||
describe('User Interactions', () => {
|
||||
it('should call setCurrentPluginDetail with drawer type when clicked', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
render(<ReadmeEntrance pluginDetail={mockDetail} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toEqual({
|
||||
detail: mockDetail,
|
||||
showType: ReadmeShowType.drawer,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call setCurrentPluginDetail with modal type when clicked', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
render(<ReadmeEntrance pluginDetail={mockDetail} showType={ReadmeShowType.modal} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail).toEqual({
|
||||
detail: mockDetail,
|
||||
showType: ReadmeShowType.modal,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Prop Variations
|
||||
// ================================
|
||||
describe('Prop Variations', () => {
|
||||
it('should use default showType when not provided', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
render(<ReadmeEntrance pluginDetail={mockDetail} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
const { currentPluginDetail } = useReadmePanelStore.getState()
|
||||
expect(currentPluginDetail?.showType).toBe(ReadmeShowType.drawer)
|
||||
})
|
||||
|
||||
it('should handle modal showType correctly', () => {
|
||||
const mockDetail = createMockPluginDetail()
|
||||
|
||||
render(<ReadmeEntrance pluginDetail={mockDetail} showType={ReadmeShowType.modal} />)
|
||||
|
||||
// Modal mode should not have px-4 class
|
||||
const container = screen.getByRole('button').parentElement
|
||||
expect(container).not.toHaveClass('px-4')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// ReadmePanel Component Tests
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { ReadmeShowType, useReadmePanelStore } from './store'
|
||||
|
||||
describe('readme-panel/store', () => {
|
||||
beforeEach(() => {
|
||||
useReadmePanelStore.setState({ currentPluginDetail: undefined })
|
||||
})
|
||||
|
||||
it('initializes with undefined currentPluginDetail', () => {
|
||||
const state = useReadmePanelStore.getState()
|
||||
expect(state.currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sets current plugin detail with drawer showType by default', () => {
|
||||
const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail)
|
||||
|
||||
const state = useReadmePanelStore.getState()
|
||||
expect(state.currentPluginDetail).toEqual({
|
||||
detail: mockDetail,
|
||||
showType: ReadmeShowType.drawer,
|
||||
})
|
||||
})
|
||||
|
||||
it('sets current plugin detail with modal showType', () => {
|
||||
const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail, ReadmeShowType.modal)
|
||||
|
||||
const state = useReadmePanelStore.getState()
|
||||
expect(state.currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
|
||||
})
|
||||
|
||||
it('clears current plugin detail when called with undefined', () => {
|
||||
const mockDetail = { id: 'test', plugin_unique_identifier: 'uid' } as PluginDetail
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(mockDetail)
|
||||
expect(useReadmePanelStore.getState().currentPluginDetail).toBeDefined()
|
||||
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(undefined)
|
||||
expect(useReadmePanelStore.getState().currentPluginDetail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('replaces previous detail with new one', () => {
|
||||
const detail1 = { id: 'plugin-1', plugin_unique_identifier: 'uid-1' } as PluginDetail
|
||||
const detail2 = { id: 'plugin-2', plugin_unique_identifier: 'uid-2' } as PluginDetail
|
||||
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(detail1)
|
||||
expect(useReadmePanelStore.getState().currentPluginDetail?.detail.id).toBe('plugin-1')
|
||||
|
||||
useReadmePanelStore.getState().setCurrentPluginDetail(detail2, ReadmeShowType.modal)
|
||||
expect(useReadmePanelStore.getState().currentPluginDetail?.detail.id).toBe('plugin-2')
|
||||
expect(useReadmePanelStore.getState().currentPluginDetail?.showType).toBe(ReadmeShowType.modal)
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PermissionType } from '@/app/components/plugins/types'
|
||||
import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from './auto-update-setting/types'
|
||||
import ReferenceSettingModal from './index'
|
||||
import Label from './label'
|
||||
|
||||
// ================================
|
||||
// Mock External Dependencies Only
|
||||
@@ -155,7 +156,153 @@ describe('reference-setting-modal', () => {
|
||||
mockSystemFeatures.enable_marketplace = true
|
||||
})
|
||||
|
||||
// Label component tests moved to label.spec.tsx
|
||||
// ============================================================
|
||||
// Label Component Tests
|
||||
// ============================================================
|
||||
describe('Label (label.tsx)', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render label text', () => {
|
||||
// Arrange & Act
|
||||
render(<Label label="Test Label" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Test Label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with label only when no description provided', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Label label="Simple Label" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Simple Label')).toBeInTheDocument()
|
||||
// Should have h-6 class when no description
|
||||
expect(container.querySelector('.h-6')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render label and description when both provided', () => {
|
||||
// Arrange & Act
|
||||
render(<Label label="Label Text" description="Description Text" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Label Text')).toBeInTheDocument()
|
||||
expect(screen.getByText('Description Text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply h-4 class to label container when description is provided', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Label label="Label" description="Has description" />)
|
||||
|
||||
// Assert
|
||||
expect(container.querySelector('.h-4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render description element when description is undefined', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Label label="Only Label" />)
|
||||
|
||||
// Assert
|
||||
expect(container.querySelectorAll('.body-xs-regular')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should render description with correct styling', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Label label="Label" description="Styled Description" />)
|
||||
|
||||
// Assert
|
||||
const descriptionElement = container.querySelector('.body-xs-regular')
|
||||
expect(descriptionElement).toBeInTheDocument()
|
||||
expect(descriptionElement).toHaveClass('mt-1', 'text-text-tertiary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props Variations', () => {
|
||||
it('should handle empty label string', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Label label="" />)
|
||||
|
||||
// Assert - should render without crashing
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty description string', () => {
|
||||
// Arrange & Act
|
||||
render(<Label label="Label" description="" />)
|
||||
|
||||
// Assert - empty description still renders the description container
|
||||
expect(screen.getByText('Label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long label text', () => {
|
||||
// Arrange
|
||||
const longLabel = 'A'.repeat(200)
|
||||
|
||||
// Act
|
||||
render(<Label label={longLabel} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(longLabel)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long description text', () => {
|
||||
// Arrange
|
||||
const longDescription = 'B'.repeat(500)
|
||||
|
||||
// Act
|
||||
render(<Label label="Label" description={longDescription} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(longDescription)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in label', () => {
|
||||
// Arrange
|
||||
const specialLabel = '<script>alert("xss")</script>'
|
||||
|
||||
// Act
|
||||
render(<Label label={specialLabel} />)
|
||||
|
||||
// Assert - should be escaped
|
||||
expect(screen.getByText(specialLabel)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in description', () => {
|
||||
// Arrange
|
||||
const specialDescription = '!@#$%^&*()_+-=[]{}|;:,.<>?'
|
||||
|
||||
// Act
|
||||
render(<Label label="Label" description={specialDescription} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(specialDescription)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
// Assert
|
||||
expect(Label).toBeDefined()
|
||||
expect((Label as any).$$typeof?.toString()).toContain('Symbol')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should apply system-sm-semibold class to label', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Label label="Styled Label" />)
|
||||
|
||||
// Assert
|
||||
expect(container.querySelector('.system-sm-semibold')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply text-text-secondary class to label', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Label label="Styled Label" />)
|
||||
|
||||
// Assert
|
||||
expect(container.querySelector('.text-text-secondary')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// ReferenceSettingModal (PluginSettingModal) Component Tests
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Label from './label'
|
||||
|
||||
describe('Label', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render label text', () => {
|
||||
render(<Label label="Test Label" />)
|
||||
expect(screen.getByText('Test Label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with label only when no description provided', () => {
|
||||
const { container } = render(<Label label="Simple Label" />)
|
||||
expect(screen.getByText('Simple Label')).toBeInTheDocument()
|
||||
expect(container.querySelector('.h-6')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render label and description when both provided', () => {
|
||||
render(<Label label="Label Text" description="Description Text" />)
|
||||
expect(screen.getByText('Label Text')).toBeInTheDocument()
|
||||
expect(screen.getByText('Description Text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply h-4 class to label container when description is provided', () => {
|
||||
const { container } = render(<Label label="Label" description="Has description" />)
|
||||
expect(container.querySelector('.h-4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render description element when description is undefined', () => {
|
||||
const { container } = render(<Label label="Only Label" />)
|
||||
expect(container.querySelectorAll('.body-xs-regular')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should render description with correct styling', () => {
|
||||
const { container } = render(<Label label="Label" description="Styled Description" />)
|
||||
const descriptionElement = container.querySelector('.body-xs-regular')
|
||||
expect(descriptionElement).toBeInTheDocument()
|
||||
expect(descriptionElement).toHaveClass('mt-1', 'text-text-tertiary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props Variations', () => {
|
||||
it('should handle empty label string', () => {
|
||||
const { container } = render(<Label label="" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty description string', () => {
|
||||
render(<Label label="Label" description="" />)
|
||||
expect(screen.getByText('Label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long label text', () => {
|
||||
const longLabel = 'A'.repeat(200)
|
||||
render(<Label label={longLabel} />)
|
||||
expect(screen.getByText(longLabel)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long description text', () => {
|
||||
const longDescription = 'B'.repeat(500)
|
||||
render(<Label label="Label" description={longDescription} />)
|
||||
expect(screen.getByText(longDescription)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in label', () => {
|
||||
const specialLabel = '<script>alert("xss")</script>'
|
||||
render(<Label label={specialLabel} />)
|
||||
expect(screen.getByText(specialLabel)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in description', () => {
|
||||
const specialDescription = '!@#$%^&*()_+-=[]{}|;:,.<>?'
|
||||
render(<Label label="Label" description={specialDescription} />)
|
||||
expect(screen.getByText(specialDescription)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
expect(Label).toBeDefined()
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
expect((Label as any).$$typeof?.toString()).toContain('Symbol')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should apply system-sm-semibold class to label', () => {
|
||||
const { container } = render(<Label label="Styled Label" />)
|
||||
expect(container.querySelector('.system-sm-semibold')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply text-text-secondary class to label', () => {
|
||||
const { container } = render(<Label label="Styled Label" />)
|
||||
expect(container.querySelector('.text-text-secondary')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,93 +0,0 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DowngradeWarningModal from './downgrade-warning'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'autoUpdate.pluginDowngradeWarning.title': 'Downgrade Warning',
|
||||
'autoUpdate.pluginDowngradeWarning.description': 'This will downgrade the plugin.',
|
||||
'newApp.Cancel': 'Cancel',
|
||||
'autoUpdate.pluginDowngradeWarning.downgrade': 'Just Downgrade',
|
||||
'autoUpdate.pluginDowngradeWarning.exclude': 'Exclude & Downgrade',
|
||||
}
|
||||
return map[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('DowngradeWarningModal', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
const mockOnJustDowngrade = vi.fn()
|
||||
const mockOnExcludeAndDowngrade = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders title and description', () => {
|
||||
render(
|
||||
<DowngradeWarningModal
|
||||
onCancel={mockOnCancel}
|
||||
onJustDowngrade={mockOnJustDowngrade}
|
||||
onExcludeAndDowngrade={mockOnExcludeAndDowngrade}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Downgrade Warning')).toBeInTheDocument()
|
||||
expect(screen.getByText('This will downgrade the plugin.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders three action buttons', () => {
|
||||
render(
|
||||
<DowngradeWarningModal
|
||||
onCancel={mockOnCancel}
|
||||
onJustDowngrade={mockOnJustDowngrade}
|
||||
onExcludeAndDowngrade={mockOnExcludeAndDowngrade}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument()
|
||||
expect(screen.getByText('Just Downgrade')).toBeInTheDocument()
|
||||
expect(screen.getByText('Exclude & Downgrade')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCancel when Cancel is clicked', () => {
|
||||
render(
|
||||
<DowngradeWarningModal
|
||||
onCancel={mockOnCancel}
|
||||
onJustDowngrade={mockOnJustDowngrade}
|
||||
onExcludeAndDowngrade={mockOnExcludeAndDowngrade}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onJustDowngrade when downgrade button is clicked', () => {
|
||||
render(
|
||||
<DowngradeWarningModal
|
||||
onCancel={mockOnCancel}
|
||||
onJustDowngrade={mockOnJustDowngrade}
|
||||
onExcludeAndDowngrade={mockOnExcludeAndDowngrade}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('Just Downgrade'))
|
||||
expect(mockOnJustDowngrade).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onExcludeAndDowngrade when exclude button is clicked', () => {
|
||||
render(
|
||||
<DowngradeWarningModal
|
||||
onCancel={mockOnCancel}
|
||||
onJustDowngrade={mockOnJustDowngrade}
|
||||
onExcludeAndDowngrade={mockOnExcludeAndDowngrade}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('Exclude & Downgrade'))
|
||||
expect(mockOnExcludeAndDowngrade).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/install-from-github', () => ({
|
||||
default: ({ updatePayload, onClose, onSuccess }: {
|
||||
updatePayload?: Record<string, unknown>
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) => (
|
||||
<div data-testid="install-from-github">
|
||||
<span data-testid="update-payload">{JSON.stringify(updatePayload)}</span>
|
||||
<button data-testid="close-btn" onClick={onClose}>Close</button>
|
||||
<button data-testid="success-btn" onClick={onSuccess}>Success</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('FromGitHub', () => {
|
||||
let FromGitHub: (typeof import('./from-github'))['default']
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('./from-github')
|
||||
FromGitHub = mod.default
|
||||
})
|
||||
|
||||
it('should render InstallFromGitHub with update payload', () => {
|
||||
const payload = { id: '1', owner: 'test', repo: 'plugin' } as never
|
||||
render(<FromGitHub payload={payload} onSave={vi.fn()} onCancel={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('install-from-github')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('update-payload')).toHaveTextContent(JSON.stringify(payload))
|
||||
})
|
||||
|
||||
it('should call onCancel when close is triggered', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
render(<FromGitHub payload={{} as never} onSave={vi.fn()} onCancel={mockOnCancel} />)
|
||||
|
||||
screen.getByTestId('close-btn').click()
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onSave on success', () => {
|
||||
const mockOnSave = vi.fn()
|
||||
render(<FromGitHub payload={{} as never} onSave={mockOnSave} onCancel={vi.fn()} />)
|
||||
|
||||
screen.getByTestId('success-btn').click()
|
||||
expect(mockOnSave).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { TagKey } from './constants'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PluginCategoryEnum } from './types'
|
||||
import { getValidCategoryKeys, getValidTagKeys } from './utils'
|
||||
|
||||
describe('plugins/utils', () => {
|
||||
describe('getValidTagKeys', () => {
|
||||
it('returns only valid tag keys from the predefined set', () => {
|
||||
const input = ['agent', 'rag', 'invalid-tag', 'search'] as TagKey[]
|
||||
const result = getValidTagKeys(input)
|
||||
expect(result).toEqual(['agent', 'rag', 'search'])
|
||||
})
|
||||
|
||||
it('returns empty array when no valid tags', () => {
|
||||
const result = getValidTagKeys(['foo', 'bar'] as unknown as TagKey[])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(getValidTagKeys([])).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves all valid tags when all are valid', () => {
|
||||
const input: TagKey[] = ['agent', 'rag', 'search', 'image']
|
||||
const result = getValidTagKeys(input)
|
||||
expect(result).toEqual(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getValidCategoryKeys', () => {
|
||||
it('returns matching category for valid key', () => {
|
||||
expect(getValidCategoryKeys(PluginCategoryEnum.model)).toBe(PluginCategoryEnum.model)
|
||||
expect(getValidCategoryKeys(PluginCategoryEnum.tool)).toBe(PluginCategoryEnum.tool)
|
||||
expect(getValidCategoryKeys(PluginCategoryEnum.agent)).toBe(PluginCategoryEnum.agent)
|
||||
expect(getValidCategoryKeys('bundle')).toBe('bundle')
|
||||
})
|
||||
|
||||
it('returns undefined for invalid category', () => {
|
||||
expect(getValidCategoryKeys('nonexistent')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for undefined input', () => {
|
||||
expect(getValidCategoryKeys(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for empty string', () => {
|
||||
expect(getValidCategoryKeys('')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Label } from './constant'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { useStore } from './store'
|
||||
|
||||
describe('labels/store', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store to initial state before each test
|
||||
useStore.setState({ labelList: [] })
|
||||
})
|
||||
|
||||
it('initializes with empty labelList', () => {
|
||||
const state = useStore.getState()
|
||||
expect(state.labelList).toEqual([])
|
||||
})
|
||||
|
||||
it('sets labelList via setLabelList', () => {
|
||||
const labels: Label[] = [
|
||||
{ name: 'search', label: 'Search' },
|
||||
{ name: 'agent', label: { en_US: 'Agent', zh_Hans: '代理' } },
|
||||
]
|
||||
useStore.getState().setLabelList(labels)
|
||||
expect(useStore.getState().labelList).toEqual(labels)
|
||||
})
|
||||
|
||||
it('replaces existing labels with new list', () => {
|
||||
const initial: Label[] = [{ name: 'old', label: 'Old' }]
|
||||
useStore.getState().setLabelList(initial)
|
||||
expect(useStore.getState().labelList).toEqual(initial)
|
||||
|
||||
const updated: Label[] = [{ name: 'new', label: 'New' }]
|
||||
useStore.getState().setLabelList(updated)
|
||||
expect(useStore.getState().labelList).toEqual(updated)
|
||||
})
|
||||
|
||||
it('handles undefined argument (sets labelList to undefined)', () => {
|
||||
const labels: Label[] = [{ name: 'test', label: 'Test' }]
|
||||
useStore.getState().setLabelList(labels)
|
||||
useStore.getState().setLabelList(undefined)
|
||||
expect(useStore.getState().labelList).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,205 +0,0 @@
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import type { Collection } from '@/app/components/tools/types'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SCROLL_BOTTOM_THRESHOLD } from '@/app/components/plugins/marketplace/constants'
|
||||
import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { useMarketplace } from './hooks'
|
||||
|
||||
// ==================== Mock Setup ====================
|
||||
|
||||
const mockQueryMarketplaceCollectionsAndPlugins = vi.fn()
|
||||
const mockQueryPlugins = vi.fn()
|
||||
const mockQueryPluginsWithDebounced = vi.fn()
|
||||
const mockResetPlugins = vi.fn()
|
||||
const mockFetchNextPage = vi.fn()
|
||||
|
||||
const mockUseMarketplaceCollectionsAndPlugins = vi.fn()
|
||||
const mockUseMarketplacePlugins = vi.fn()
|
||||
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
|
||||
useMarketplaceCollectionsAndPlugins: (...args: unknown[]) => mockUseMarketplaceCollectionsAndPlugins(...args),
|
||||
useMarketplacePlugins: (...args: unknown[]) => mockUseMarketplacePlugins(...args),
|
||||
}))
|
||||
|
||||
const mockUseAllToolProviders = vi.fn()
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllToolProviders: (...args: unknown[]) => mockUseAllToolProviders(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
getMarketplaceUrl: vi.fn(() => 'https://marketplace.test/market'),
|
||||
}))
|
||||
|
||||
vi.mock('next-themes', () => ({
|
||||
useTheme: () => ({ theme: 'light' }),
|
||||
}))
|
||||
|
||||
// ==================== Test Utilities ====================
|
||||
|
||||
const createToolProvider = (overrides: Partial<Collection> = {}): Collection => ({
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
author: 'Author',
|
||||
description: { en_US: 'desc', zh_Hans: '描述' },
|
||||
icon: 'icon',
|
||||
label: { en_US: 'label', zh_Hans: '标签' },
|
||||
type: CollectionType.custom,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const setupHookMocks = (overrides?: {
|
||||
isLoading?: boolean
|
||||
isPluginsLoading?: boolean
|
||||
pluginsPage?: number
|
||||
hasNextPage?: boolean
|
||||
plugins?: Plugin[] | undefined
|
||||
}) => {
|
||||
mockUseMarketplaceCollectionsAndPlugins.mockReturnValue({
|
||||
isLoading: overrides?.isLoading ?? false,
|
||||
marketplaceCollections: [],
|
||||
marketplaceCollectionPluginsMap: {},
|
||||
queryMarketplaceCollectionsAndPlugins: mockQueryMarketplaceCollectionsAndPlugins,
|
||||
})
|
||||
mockUseMarketplacePlugins.mockReturnValue({
|
||||
plugins: overrides?.plugins,
|
||||
resetPlugins: mockResetPlugins,
|
||||
queryPlugins: mockQueryPlugins,
|
||||
queryPluginsWithDebounced: mockQueryPluginsWithDebounced,
|
||||
isLoading: overrides?.isPluginsLoading ?? false,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: overrides?.hasNextPage ?? false,
|
||||
page: overrides?.pluginsPage,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Tests ====================
|
||||
|
||||
describe('useMarketplace', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAllToolProviders.mockReturnValue({
|
||||
data: [],
|
||||
isSuccess: true,
|
||||
})
|
||||
setupHookMocks()
|
||||
})
|
||||
|
||||
describe('Queries', () => {
|
||||
it('should query plugins with debounce when search text is provided', async () => {
|
||||
mockUseAllToolProviders.mockReturnValue({
|
||||
data: [
|
||||
createToolProvider({ plugin_id: 'plugin-a' }),
|
||||
createToolProvider({ plugin_id: undefined }),
|
||||
],
|
||||
isSuccess: true,
|
||||
})
|
||||
|
||||
renderHook(() => useMarketplace('alpha', []))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockQueryPluginsWithDebounced).toHaveBeenCalledWith({
|
||||
category: PluginCategoryEnum.tool,
|
||||
query: 'alpha',
|
||||
tags: [],
|
||||
exclude: ['plugin-a'],
|
||||
type: 'plugin',
|
||||
})
|
||||
})
|
||||
expect(mockQueryMarketplaceCollectionsAndPlugins).not.toHaveBeenCalled()
|
||||
expect(mockResetPlugins).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should query plugins immediately when only tags are provided', async () => {
|
||||
mockUseAllToolProviders.mockReturnValue({
|
||||
data: [createToolProvider({ plugin_id: 'plugin-b' })],
|
||||
isSuccess: true,
|
||||
})
|
||||
|
||||
renderHook(() => useMarketplace('', ['tag-1']))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockQueryPlugins).toHaveBeenCalledWith({
|
||||
category: PluginCategoryEnum.tool,
|
||||
query: '',
|
||||
tags: ['tag-1'],
|
||||
exclude: ['plugin-b'],
|
||||
type: 'plugin',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should query collections and reset plugins when no filters are provided', async () => {
|
||||
mockUseAllToolProviders.mockReturnValue({
|
||||
data: [createToolProvider({ plugin_id: 'plugin-c' })],
|
||||
isSuccess: true,
|
||||
})
|
||||
|
||||
renderHook(() => useMarketplace('', []))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockQueryMarketplaceCollectionsAndPlugins).toHaveBeenCalledWith({
|
||||
category: PluginCategoryEnum.tool,
|
||||
condition: getMarketplaceListCondition(PluginCategoryEnum.tool),
|
||||
exclude: ['plugin-c'],
|
||||
type: 'plugin',
|
||||
})
|
||||
})
|
||||
expect(mockResetPlugins).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('State', () => {
|
||||
it('should expose combined loading state and fallback page value', () => {
|
||||
setupHookMocks({ isLoading: true, isPluginsLoading: false, pluginsPage: undefined })
|
||||
|
||||
const { result } = renderHook(() => useMarketplace('', []))
|
||||
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
expect(result.current.page).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Scroll', () => {
|
||||
it('should fetch next page when scrolling near bottom with filters', () => {
|
||||
setupHookMocks({ hasNextPage: true })
|
||||
const { result } = renderHook(() => useMarketplace('search', []))
|
||||
const event = {
|
||||
target: {
|
||||
scrollTop: 100,
|
||||
scrollHeight: 200,
|
||||
clientHeight: 100 + SCROLL_BOTTOM_THRESHOLD,
|
||||
},
|
||||
} as unknown as Event
|
||||
|
||||
act(() => {
|
||||
result.current.handleScroll(event)
|
||||
})
|
||||
|
||||
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not fetch next page when no filters are applied', () => {
|
||||
setupHookMocks({ hasNextPage: true })
|
||||
const { result } = renderHook(() => useMarketplace('', []))
|
||||
const event = {
|
||||
target: {
|
||||
scrollTop: 100,
|
||||
scrollHeight: 200,
|
||||
clientHeight: 100 + SCROLL_BOTTOM_THRESHOLD,
|
||||
},
|
||||
} as unknown as Event
|
||||
|
||||
act(() => {
|
||||
result.current.handleScroll(event)
|
||||
})
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { useMarketplace } from './hooks'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import type { Collection } from '@/app/components/tools/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { act, render, renderHook, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { SCROLL_BOTTOM_THRESHOLD } from '@/app/components/plugins/marketplace/constants'
|
||||
import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import { useMarketplace } from './hooks'
|
||||
|
||||
import Marketplace from './index'
|
||||
|
||||
@@ -45,7 +47,7 @@ vi.mock('next-themes', () => ({
|
||||
|
||||
const mockGetMarketplaceUrl = vi.mocked(getMarketplaceUrl)
|
||||
|
||||
const _createToolProvider = (overrides: Partial<Collection> = {}): Collection => ({
|
||||
const createToolProvider = (overrides: Partial<Collection> = {}): Collection => ({
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
author: 'Author',
|
||||
@@ -181,4 +183,178 @@ describe('Marketplace', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// useMarketplace hook tests moved to hooks.spec.ts
|
||||
describe('useMarketplace', () => {
|
||||
const mockQueryMarketplaceCollectionsAndPlugins = vi.fn()
|
||||
const mockQueryPlugins = vi.fn()
|
||||
const mockQueryPluginsWithDebounced = vi.fn()
|
||||
const mockResetPlugins = vi.fn()
|
||||
const mockFetchNextPage = vi.fn()
|
||||
|
||||
const setupHookMocks = (overrides?: {
|
||||
isLoading?: boolean
|
||||
isPluginsLoading?: boolean
|
||||
pluginsPage?: number
|
||||
hasNextPage?: boolean
|
||||
plugins?: Plugin[] | undefined
|
||||
}) => {
|
||||
mockUseMarketplaceCollectionsAndPlugins.mockReturnValue({
|
||||
isLoading: overrides?.isLoading ?? false,
|
||||
marketplaceCollections: [],
|
||||
marketplaceCollectionPluginsMap: {},
|
||||
queryMarketplaceCollectionsAndPlugins: mockQueryMarketplaceCollectionsAndPlugins,
|
||||
})
|
||||
mockUseMarketplacePlugins.mockReturnValue({
|
||||
plugins: overrides?.plugins,
|
||||
resetPlugins: mockResetPlugins,
|
||||
queryPlugins: mockQueryPlugins,
|
||||
queryPluginsWithDebounced: mockQueryPluginsWithDebounced,
|
||||
isLoading: overrides?.isPluginsLoading ?? false,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: overrides?.hasNextPage ?? false,
|
||||
page: overrides?.pluginsPage,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAllToolProviders.mockReturnValue({
|
||||
data: [],
|
||||
isSuccess: true,
|
||||
})
|
||||
setupHookMocks()
|
||||
})
|
||||
|
||||
// Query behavior driven by search filters and provider exclusions.
|
||||
describe('Queries', () => {
|
||||
it('should query plugins with debounce when search text is provided', async () => {
|
||||
// Arrange
|
||||
mockUseAllToolProviders.mockReturnValue({
|
||||
data: [
|
||||
createToolProvider({ plugin_id: 'plugin-a' }),
|
||||
createToolProvider({ plugin_id: undefined }),
|
||||
],
|
||||
isSuccess: true,
|
||||
})
|
||||
|
||||
// Act
|
||||
renderHook(() => useMarketplace('alpha', []))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockQueryPluginsWithDebounced).toHaveBeenCalledWith({
|
||||
category: PluginCategoryEnum.tool,
|
||||
query: 'alpha',
|
||||
tags: [],
|
||||
exclude: ['plugin-a'],
|
||||
type: 'plugin',
|
||||
})
|
||||
})
|
||||
expect(mockQueryMarketplaceCollectionsAndPlugins).not.toHaveBeenCalled()
|
||||
expect(mockResetPlugins).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should query plugins immediately when only tags are provided', async () => {
|
||||
// Arrange
|
||||
mockUseAllToolProviders.mockReturnValue({
|
||||
data: [createToolProvider({ plugin_id: 'plugin-b' })],
|
||||
isSuccess: true,
|
||||
})
|
||||
|
||||
// Act
|
||||
renderHook(() => useMarketplace('', ['tag-1']))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockQueryPlugins).toHaveBeenCalledWith({
|
||||
category: PluginCategoryEnum.tool,
|
||||
query: '',
|
||||
tags: ['tag-1'],
|
||||
exclude: ['plugin-b'],
|
||||
type: 'plugin',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should query collections and reset plugins when no filters are provided', async () => {
|
||||
// Arrange
|
||||
mockUseAllToolProviders.mockReturnValue({
|
||||
data: [createToolProvider({ plugin_id: 'plugin-c' })],
|
||||
isSuccess: true,
|
||||
})
|
||||
|
||||
// Act
|
||||
renderHook(() => useMarketplace('', []))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockQueryMarketplaceCollectionsAndPlugins).toHaveBeenCalledWith({
|
||||
category: PluginCategoryEnum.tool,
|
||||
condition: getMarketplaceListCondition(PluginCategoryEnum.tool),
|
||||
exclude: ['plugin-c'],
|
||||
type: 'plugin',
|
||||
})
|
||||
})
|
||||
expect(mockResetPlugins).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// State derived from hook inputs and loading signals.
|
||||
describe('State', () => {
|
||||
it('should expose combined loading state and fallback page value', () => {
|
||||
// Arrange
|
||||
setupHookMocks({ isLoading: true, isPluginsLoading: false, pluginsPage: undefined })
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() => useMarketplace('', []))
|
||||
|
||||
// Assert
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
expect(result.current.page).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Scroll handling that triggers pagination when appropriate.
|
||||
describe('Scroll', () => {
|
||||
it('should fetch next page when scrolling near bottom with filters', () => {
|
||||
// Arrange
|
||||
setupHookMocks({ hasNextPage: true })
|
||||
const { result } = renderHook(() => useMarketplace('search', []))
|
||||
const event = {
|
||||
target: {
|
||||
scrollTop: 100,
|
||||
scrollHeight: 200,
|
||||
clientHeight: 100 + SCROLL_BOTTOM_THRESHOLD,
|
||||
},
|
||||
} as unknown as Event
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current.handleScroll(event)
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not fetch next page when no filters are applied', () => {
|
||||
// Arrange
|
||||
setupHookMocks({ hasNextPage: true })
|
||||
const { result } = renderHook(() => useMarketplace('', []))
|
||||
const event = {
|
||||
target: {
|
||||
scrollTop: 100,
|
||||
scrollHeight: 200,
|
||||
clientHeight: 100 + SCROLL_BOTTOM_THRESHOLD,
|
||||
},
|
||||
} as unknown as Event
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current.handleScroll(event)
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ProviderList from './provider-list'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'type.builtIn': 'Built-in',
|
||||
'type.custom': 'Custom',
|
||||
'type.workflow': 'Workflow',
|
||||
'noTools': 'No tools found',
|
||||
}
|
||||
return map[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
let mockActiveTab = 'builtin'
|
||||
const mockSetActiveTab = vi.fn((val: string) => {
|
||||
mockActiveTab = val
|
||||
})
|
||||
vi.mock('nuqs', () => ({
|
||||
useQueryState: () => [mockActiveTab, mockSetActiveTab],
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/hooks', () => ({
|
||||
useTags: () => ({
|
||||
tags: [],
|
||||
tagsMap: {},
|
||||
getTagLabel: (name: string) => name,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({ enable_marketplace: false }),
|
||||
}))
|
||||
|
||||
const mockCollections = [
|
||||
{
|
||||
id: 'builtin-1',
|
||||
name: 'google-search',
|
||||
author: 'Dify',
|
||||
description: { en_US: 'Google Search', zh_Hans: '谷歌搜索' },
|
||||
icon: 'icon-google',
|
||||
label: { en_US: 'Google Search', zh_Hans: '谷歌搜索' },
|
||||
type: 'builtin',
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: ['search'],
|
||||
},
|
||||
{
|
||||
id: 'api-1',
|
||||
name: 'my-api',
|
||||
author: 'User',
|
||||
description: { en_US: 'My API tool', zh_Hans: '我的 API 工具' },
|
||||
icon: { background: '#fff', content: '🔧' },
|
||||
label: { en_US: 'My API Tool', zh_Hans: '我的 API 工具' },
|
||||
type: 'api',
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: true,
|
||||
labels: [],
|
||||
},
|
||||
{
|
||||
id: 'workflow-1',
|
||||
name: 'wf-tool',
|
||||
author: 'User',
|
||||
description: { en_US: 'Workflow Tool', zh_Hans: '工作流工具' },
|
||||
icon: { background: '#fff', content: '⚡' },
|
||||
label: { en_US: 'Workflow Tool', zh_Hans: '工作流工具' },
|
||||
type: 'workflow',
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: true,
|
||||
labels: [],
|
||||
},
|
||||
]
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllToolProviders: () => ({
|
||||
data: mockCollections,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useCheckInstalled: () => ({ data: null }),
|
||||
useInvalidateInstalledPluginList: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tab-slider-new', () => ({
|
||||
default: ({ value, onChange, options }: {
|
||||
value: string
|
||||
onChange: (val: string) => void
|
||||
options: { value: string, text: string }[]
|
||||
}) => (
|
||||
<div data-testid="tab-slider">
|
||||
{options.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
data-testid={`tab-${opt.value}`}
|
||||
data-active={value === opt.value}
|
||||
onClick={() => onChange(opt.value)}
|
||||
>
|
||||
{opt.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card', () => ({
|
||||
default: ({ payload, className }: { payload: { name: string }, className?: string }) => (
|
||||
<div data-testid={`card-${payload.name}`} className={className}>{payload.name}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/card-more-info', () => ({
|
||||
default: ({ tags }: { tags: string[] }) => <div data-testid="card-more-info">{tags.join(', ')}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/labels/filter', () => ({
|
||||
default: ({ value, onChange }: { value: string[], onChange: (v: string[]) => void }) => (
|
||||
<div data-testid="label-filter">
|
||||
<button data-testid="add-filter" onClick={() => onChange(['search'])}>Add filter</button>
|
||||
<button data-testid="clear-filter" onClick={() => onChange([])}>Clear filter</button>
|
||||
<span>{value.join(', ')}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/custom-create-card', () => ({
|
||||
default: () => <div data-testid="custom-create-card">Create Custom Tool</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/detail', () => ({
|
||||
default: ({ collection, onHide }: { collection: { name: string }, onHide: () => void }) => (
|
||||
<div data-testid="provider-detail">
|
||||
<span>{collection.name}</span>
|
||||
<button data-testid="detail-close" onClick={onHide}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/provider/empty', () => ({
|
||||
default: () => <div data-testid="workflow-empty">No workflow tools</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({
|
||||
default: ({ detail }: { detail: unknown }) =>
|
||||
detail ? <div data-testid="plugin-detail-panel" /> : null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace/empty', () => ({
|
||||
default: ({ text }: { text: string }) => <div data-testid="empty">{text}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./marketplace', () => ({
|
||||
default: () => <div data-testid="marketplace">Marketplace</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./marketplace/hooks', () => ({
|
||||
useMarketplace: () => ({
|
||||
isLoading: false,
|
||||
marketplaceCollections: [],
|
||||
marketplaceCollectionPluginsMap: {},
|
||||
plugins: [],
|
||||
handleScroll: vi.fn(),
|
||||
page: 1,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./mcp', () => ({
|
||||
default: ({ searchText }: { searchText: string }) => (
|
||||
<div data-testid="mcp-list">
|
||||
MCP List:
|
||||
{searchText}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ProviderList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockActiveTab = 'builtin'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('Tab Navigation', () => {
|
||||
it('renders all four tabs', () => {
|
||||
render(<ProviderList />)
|
||||
expect(screen.getByTestId('tab-builtin')).toHaveTextContent('Built-in')
|
||||
expect(screen.getByTestId('tab-api')).toHaveTextContent('Custom')
|
||||
expect(screen.getByTestId('tab-workflow')).toHaveTextContent('Workflow')
|
||||
expect(screen.getByTestId('tab-mcp')).toHaveTextContent('MCP')
|
||||
})
|
||||
|
||||
it('switches tab when clicked', () => {
|
||||
render(<ProviderList />)
|
||||
fireEvent.click(screen.getByTestId('tab-api'))
|
||||
expect(mockSetActiveTab).toHaveBeenCalledWith('api')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Filtering', () => {
|
||||
it('shows only builtin collections by default', () => {
|
||||
render(<ProviderList />)
|
||||
expect(screen.getByTestId('card-google-search')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('card-my-api')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by search keyword', () => {
|
||||
render(<ProviderList />)
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'nonexistent' } })
|
||||
expect(screen.queryByTestId('card-google-search')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows label filter for non-MCP tabs', () => {
|
||||
render(<ProviderList />)
|
||||
expect(screen.getByTestId('label-filter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders search input', () => {
|
||||
render(<ProviderList />)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom Tab', () => {
|
||||
it('shows custom create card when on api tab', () => {
|
||||
mockActiveTab = 'api'
|
||||
render(<ProviderList />)
|
||||
expect(screen.getByTestId('custom-create-card')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workflow Tab', () => {
|
||||
it('shows empty state when no workflow collections', () => {
|
||||
mockActiveTab = 'workflow'
|
||||
render(<ProviderList />)
|
||||
// Only one workflow collection exists, so it should show
|
||||
expect(screen.getByTestId('card-wf-tool')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MCP Tab', () => {
|
||||
it('renders MCPList component', () => {
|
||||
mockActiveTab = 'mcp'
|
||||
render(<ProviderList />)
|
||||
expect(screen.getByTestId('mcp-list')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Provider Detail', () => {
|
||||
it('opens provider detail when a non-plugin collection is clicked', () => {
|
||||
render(<ProviderList />)
|
||||
fireEvent.click(screen.getByTestId('card-google-search'))
|
||||
expect(screen.getByTestId('provider-detail')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('provider-detail')).toHaveTextContent('google-search')
|
||||
})
|
||||
|
||||
it('closes provider detail when close button is clicked', () => {
|
||||
render(<ProviderList />)
|
||||
fireEvent.click(screen.getByTestId('card-google-search'))
|
||||
expect(screen.getByTestId('provider-detail')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('detail-close'))
|
||||
expect(screen.queryByTestId('provider-detail')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,408 +0,0 @@
|
||||
import type { Collection } from '../types'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CollectionType } from '../types'
|
||||
import ProviderDetail from './detail'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) => {
|
||||
const map: Record<string, string> = {
|
||||
'createTool.editAction': 'Edit',
|
||||
'openInStudio': 'Open in Studio',
|
||||
'auth.authorized': 'Authorized',
|
||||
'auth.unauthorized': 'Set up credentials',
|
||||
'auth.setup': 'SETUP REQUIRED',
|
||||
'createTool.deleteToolConfirmTitle': 'Delete Tool',
|
||||
'createTool.deleteToolConfirmContent': 'Are you sure?',
|
||||
'createTool.toolInput.title': 'Tool Input',
|
||||
'api.actionSuccess': 'Action succeeded',
|
||||
}
|
||||
if (key === 'detailPanel.actionNum')
|
||||
return `${opts?.num} ${opts?.action}`
|
||||
if (key === 'includeToolNum')
|
||||
return `${opts?.num} ${opts?.action}`
|
||||
return map[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en-US',
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
getLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
const mockIsCurrentWorkspaceManager = vi.fn(() => true)
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSetShowModelModal = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowModelModal: mockSetShowModelModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
modelProviders: [
|
||||
{ provider: 'model-collection-id', name: 'TestModel' },
|
||||
],
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockFetchBuiltInToolList = vi.fn().mockResolvedValue([])
|
||||
const mockFetchCustomToolList = vi.fn().mockResolvedValue([])
|
||||
const mockFetchModelToolList = vi.fn().mockResolvedValue([])
|
||||
const mockFetchCustomCollection = vi.fn().mockResolvedValue({
|
||||
credentials: { auth_type: 'none' },
|
||||
})
|
||||
const mockFetchWorkflowToolDetail = vi.fn().mockResolvedValue({
|
||||
workflow_app_id: 'wf-123',
|
||||
workflow_tool_id: 'wt-456',
|
||||
tool: { parameters: [], labels: [] },
|
||||
})
|
||||
const mockUpdateBuiltInToolCredential = vi.fn().mockResolvedValue({})
|
||||
const mockRemoveBuiltInToolCredential = vi.fn().mockResolvedValue({})
|
||||
const mockUpdateCustomCollection = vi.fn().mockResolvedValue({})
|
||||
const mockRemoveCustomCollection = vi.fn().mockResolvedValue({})
|
||||
const mockDeleteWorkflowTool = vi.fn().mockResolvedValue({})
|
||||
const mockSaveWorkflowToolProvider = vi.fn().mockResolvedValue({})
|
||||
|
||||
vi.mock('@/service/tools', () => ({
|
||||
fetchBuiltInToolList: (...args: unknown[]) => mockFetchBuiltInToolList(...args),
|
||||
fetchCustomToolList: (...args: unknown[]) => mockFetchCustomToolList(...args),
|
||||
fetchModelToolList: (...args: unknown[]) => mockFetchModelToolList(...args),
|
||||
fetchCustomCollection: (...args: unknown[]) => mockFetchCustomCollection(...args),
|
||||
fetchWorkflowToolDetail: (...args: unknown[]) => mockFetchWorkflowToolDetail(...args),
|
||||
updateBuiltInToolCredential: (...args: unknown[]) => mockUpdateBuiltInToolCredential(...args),
|
||||
removeBuiltInToolCredential: (...args: unknown[]) => mockRemoveBuiltInToolCredential(...args),
|
||||
updateCustomCollection: (...args: unknown[]) => mockUpdateCustomCollection(...args),
|
||||
removeCustomCollection: (...args: unknown[]) => mockRemoveCustomCollection(...args),
|
||||
deleteWorkflowTool: (...args: unknown[]) => mockDeleteWorkflowTool(...args),
|
||||
saveWorkflowToolProvider: (...args: unknown[]) => mockSaveWorkflowToolProvider(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useInvalidateAllWorkflowTools: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
basePath: '',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/drawer', () => ({
|
||||
default: ({ children, isOpen }: { children: React.ReactNode, isOpen: boolean }) =>
|
||||
isOpen ? <div data-testid="drawer">{children}</div> : null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/confirm', () => ({
|
||||
default: ({ isShow, onConfirm, onCancel, title }: { isShow: boolean, onConfirm: () => void, onCancel: () => void, title: string }) =>
|
||||
isShow
|
||||
? (
|
||||
<div data-testid="confirm-dialog">
|
||||
<span>{title}</span>
|
||||
<button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button>
|
||||
<button data-testid="cancel-btn" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/indicator', () => ({
|
||||
default: () => <span data-testid="indicator" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
|
||||
default: () => <span data-testid="card-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/description', () => ({
|
||||
default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
|
||||
default: ({ orgName }: { orgName: string }) => <span data-testid="org-info">{orgName}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/title', () => ({
|
||||
default: ({ title }: { title: string }) => <span data-testid="title">{title}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('./tool-item', () => ({
|
||||
default: ({ tool }: { tool: { name: string } }) => <div data-testid={`tool-${tool.name}`}>{tool.name}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({
|
||||
default: ({ onHide, onEdit, onRemove }: { onHide: () => void, onEdit: (data: unknown) => void, onRemove: () => void }) => (
|
||||
<div data-testid="edit-custom-modal">
|
||||
<button data-testid="edit-save" onClick={() => onEdit({ labels: ['test'] })}>Save</button>
|
||||
<button data-testid="edit-remove" onClick={onRemove}>Remove</button>
|
||||
<button data-testid="edit-close" onClick={onHide}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({
|
||||
default: ({ onCancel, onSaved, onRemove }: { onCancel: () => void, onSaved: (val: Record<string, string>) => Promise<void>, onRemove: () => Promise<void> }) => (
|
||||
<div data-testid="config-credential">
|
||||
<button data-testid="credential-save" onClick={() => onSaved({ key: 'val' })}>Save</button>
|
||||
<button data-testid="credential-remove" onClick={onRemove}>Remove</button>
|
||||
<button data-testid="credential-cancel" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/workflow-tool', () => ({
|
||||
default: ({ onHide, onSave, onRemove }: { onHide: () => void, onSave: (data: unknown) => void, onRemove: () => void }) => (
|
||||
<div data-testid="workflow-tool-modal">
|
||||
<button data-testid="wf-save" onClick={() => onSave({ name: 'test' })}>Save</button>
|
||||
<button data-testid="wf-remove" onClick={onRemove}>Remove</button>
|
||||
<button data-testid="wf-close" onClick={onHide}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createMockCollection = (overrides?: Partial<Collection>): Collection => ({
|
||||
id: 'test-id',
|
||||
name: 'test-collection',
|
||||
author: 'Test Author',
|
||||
description: { en_US: 'A test collection', zh_Hans: '测试集合' },
|
||||
icon: 'icon-url',
|
||||
label: { en_US: 'Test Collection', zh_Hans: '测试集合' },
|
||||
type: CollectionType.builtIn,
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: ['search'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ProviderDetail', () => {
|
||||
const mockOnHide = vi.fn()
|
||||
const mockOnRefreshData = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchBuiltInToolList.mockResolvedValue([
|
||||
{ name: 'tool-1', label: { en_US: 'Tool 1' }, description: { en_US: 'desc' }, parameters: [], labels: [], author: '', output_schema: {} },
|
||||
{ name: 'tool-2', label: { en_US: 'Tool 2' }, description: { en_US: 'desc' }, parameters: [], labels: [], author: '', output_schema: {} },
|
||||
])
|
||||
mockFetchCustomToolList.mockResolvedValue([])
|
||||
mockFetchModelToolList.mockResolvedValue([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders title, org info and description for a builtIn collection', async () => {
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={createMockCollection()}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Test Collection')
|
||||
expect(screen.getByTestId('org-info')).toHaveTextContent('Test Author')
|
||||
expect(screen.getByTestId('description')).toHaveTextContent('A test collection')
|
||||
})
|
||||
|
||||
it('shows loading state initially', () => {
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={createMockCollection()}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders tool list after loading for builtIn type', async () => {
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={createMockCollection()}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('tool-tool-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tool-tool-2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('hides description when description is empty', () => {
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={createMockCollection({ description: { en_US: '', zh_Hans: '' } })}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByTestId('description')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BuiltIn Collection Auth', () => {
|
||||
it('shows "Set up credentials" button when not authorized and allow_delete', async () => {
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={createMockCollection({ allow_delete: true, is_team_authorization: false })}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows "Authorized" button when authorized and allow_delete', async () => {
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={createMockCollection({ allow_delete: true, is_team_authorization: true })}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Authorized')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom Collection', () => {
|
||||
it('fetches custom collection and shows edit button', async () => {
|
||||
mockFetchCustomCollection.mockResolvedValue({
|
||||
credentials: { auth_type: 'none' },
|
||||
})
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={createMockCollection({ type: CollectionType.custom })}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(mockFetchCustomCollection).toHaveBeenCalledWith('test-collection')
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workflow Collection', () => {
|
||||
it('fetches workflow tool detail and shows workflow buttons', async () => {
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={createMockCollection({ type: CollectionType.workflow })}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowToolDetail).toHaveBeenCalledWith('test-id')
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Open in Studio')).toBeInTheDocument()
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Model Collection', () => {
|
||||
it('opens model modal when clicking auth button for model type', async () => {
|
||||
mockFetchModelToolList.mockResolvedValue([
|
||||
{ name: 'model-tool-1', label: { en_US: 'MT1' }, description: { en_US: '' }, parameters: [], labels: [], author: '', output_schema: {} },
|
||||
])
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={createMockCollection({
|
||||
id: 'model-collection-id',
|
||||
type: CollectionType.model,
|
||||
is_team_authorization: false,
|
||||
allow_delete: true,
|
||||
})}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up credentials')).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByText('Set up credentials'))
|
||||
expect(mockSetShowModelModal).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Close Action', () => {
|
||||
it('calls onHide when close button is clicked', () => {
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={createMockCollection()}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[0])
|
||||
expect(mockOnHide).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('API calls by collection type', () => {
|
||||
it('calls fetchBuiltInToolList for builtIn type', async () => {
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={createMockCollection({ type: CollectionType.builtIn })}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(mockFetchBuiltInToolList).toHaveBeenCalledWith('test-collection')
|
||||
})
|
||||
})
|
||||
|
||||
it('calls fetchModelToolList for model type', async () => {
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={createMockCollection({ type: CollectionType.model })}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(mockFetchModelToolList).toHaveBeenCalledWith('test-collection')
|
||||
})
|
||||
})
|
||||
|
||||
it('calls fetchCustomToolList for custom type', async () => {
|
||||
render(
|
||||
<ProviderDetail
|
||||
collection={createMockCollection({ type: CollectionType.custom })}
|
||||
onHide={mockOnHide}
|
||||
onRefreshData={mockOnRefreshData}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(mockFetchCustomToolList).toHaveBeenCalledWith('test-collection')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,206 +0,0 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ConfigCredential from './config-credentials'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) => {
|
||||
const map: Record<string, string> = {
|
||||
'auth.setupModalTitle': 'Set up credentials',
|
||||
'auth.setupModalTitleDescription': 'Configure your credentials',
|
||||
'operation.cancel': 'Cancel',
|
||||
'operation.save': 'Save',
|
||||
'operation.remove': 'Remove',
|
||||
'howToGet': 'How to get',
|
||||
}
|
||||
if (key === 'errorMsg.fieldRequired')
|
||||
return `${opts?.field} is required`
|
||||
return map[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
const mockFetchCredentialSchema = vi.fn()
|
||||
const mockFetchCredentialValue = vi.fn()
|
||||
|
||||
vi.mock('@/service/tools', () => ({
|
||||
fetchBuiltInToolCredentialSchema: (...args: unknown[]) => mockFetchCredentialSchema(...args),
|
||||
fetchBuiltInToolCredential: (...args: unknown[]) => mockFetchCredentialValue(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/to-form-schema', () => ({
|
||||
toolCredentialToFormSchemas: (schemas: unknown[]) => (schemas as Record<string, unknown>[]).map(s => ({
|
||||
...s,
|
||||
variable: s.name,
|
||||
show_on: [],
|
||||
})),
|
||||
addDefaultValue: (value: Record<string, unknown>, _schemas: unknown[]) => ({ ...value }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/drawer-plus', () => ({
|
||||
default: ({ body, title, onHide }: { body: React.ReactNode, title: string, onHide: () => void }) => (
|
||||
<div data-testid="drawer">
|
||||
<span data-testid="drawer-title">{title}</span>
|
||||
<button data-testid="drawer-close" onClick={onHide}>Close</button>
|
||||
{body}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({
|
||||
default: ({ value, onChange }: { value: Record<string, string>, onChange: (v: Record<string, string>) => void }) => (
|
||||
<div data-testid="form">
|
||||
<input
|
||||
data-testid="form-input"
|
||||
value={value.api_key || ''}
|
||||
onChange={e => onChange({ ...value, api_key: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createMockCollection = (overrides?: Record<string, unknown>) => ({
|
||||
id: 'test-collection',
|
||||
name: 'test-tool',
|
||||
author: 'Test',
|
||||
description: { en_US: 'Test', zh_Hans: '测试' },
|
||||
icon: '',
|
||||
label: { en_US: 'Test', zh_Hans: '测试' },
|
||||
type: 'builtin',
|
||||
team_credentials: {},
|
||||
is_team_authorization: false,
|
||||
allow_delete: false,
|
||||
labels: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ConfigCredential', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
const mockOnSaved = vi.fn().mockResolvedValue(undefined)
|
||||
const mockOnRemove = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchCredentialSchema.mockResolvedValue([
|
||||
{ name: 'api_key', label: { en_US: 'API Key' }, type: 'secret-input', required: true },
|
||||
])
|
||||
mockFetchCredentialValue.mockResolvedValue({ api_key: 'sk-existing' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('shows loading state initially then renders form', async () => {
|
||||
render(
|
||||
<ConfigCredential
|
||||
collection={createMockCollection() as never}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('form')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders drawer with correct title', async () => {
|
||||
render(
|
||||
<ConfigCredential
|
||||
collection={createMockCollection() as never}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('drawer-title')).toHaveTextContent('Set up credentials')
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button is clicked', async () => {
|
||||
render(
|
||||
<ConfigCredential
|
||||
collection={createMockCollection() as never}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('form')).toBeInTheDocument()
|
||||
})
|
||||
const cancelBtn = screen.getByText('Cancel')
|
||||
fireEvent.click(cancelBtn)
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onSaved with credential values when save is clicked', async () => {
|
||||
render(
|
||||
<ConfigCredential
|
||||
collection={createMockCollection() as never}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('form')).toBeInTheDocument()
|
||||
})
|
||||
const saveBtn = screen.getByText('Save')
|
||||
fireEvent.click(saveBtn)
|
||||
await waitFor(() => {
|
||||
expect(mockOnSaved).toHaveBeenCalledWith(expect.objectContaining({ api_key: 'sk-existing' }))
|
||||
})
|
||||
})
|
||||
|
||||
it('shows remove button when team is authorized and isHideRemoveBtn is false', async () => {
|
||||
render(
|
||||
<ConfigCredential
|
||||
collection={createMockCollection({ is_team_authorization: true }) as never}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
onRemove={mockOnRemove}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('form')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('Remove')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides remove button when isHideRemoveBtn is true', async () => {
|
||||
render(
|
||||
<ConfigCredential
|
||||
collection={createMockCollection({ is_team_authorization: true }) as never}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
onRemove={mockOnRemove}
|
||||
isHideRemoveBtn
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('form')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('Remove')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('fetches credential schema for the collection name', async () => {
|
||||
render(
|
||||
<ConfigCredential
|
||||
collection={createMockCollection() as never}
|
||||
onCancel={mockOnCancel}
|
||||
onSaved={mockOnSaved}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(mockFetchCredentialSchema).toHaveBeenCalledWith('test-tool')
|
||||
expect(mockFetchCredentialValue).toHaveBeenCalledWith('test-tool')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { ThoughtItem } from '@/app/components/base/chat/chat/type'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addFileInfos, sortAgentSorts } from './index'
|
||||
|
||||
describe('tools/utils', () => {
|
||||
describe('sortAgentSorts', () => {
|
||||
it('returns null/undefined input as-is', () => {
|
||||
expect(sortAgentSorts(null as unknown as ThoughtItem[])).toBeNull()
|
||||
expect(sortAgentSorts(undefined as unknown as ThoughtItem[])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns unsorted when some items lack position', () => {
|
||||
const items = [
|
||||
{ id: '1', position: 2 },
|
||||
{ id: '2' },
|
||||
] as unknown as ThoughtItem[]
|
||||
const result = sortAgentSorts(items)
|
||||
expect(result[0]).toEqual(expect.objectContaining({ id: '1' }))
|
||||
expect(result[1]).toEqual(expect.objectContaining({ id: '2' }))
|
||||
})
|
||||
|
||||
it('sorts items by position ascending', () => {
|
||||
const items = [
|
||||
{ id: 'c', position: 3 },
|
||||
{ id: 'a', position: 1 },
|
||||
{ id: 'b', position: 2 },
|
||||
] as unknown as ThoughtItem[]
|
||||
const result = sortAgentSorts(items)
|
||||
expect(result.map((item: ThoughtItem & { id: string }) => item.id)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('does not mutate the original array', () => {
|
||||
const items = [
|
||||
{ id: 'b', position: 2 },
|
||||
{ id: 'a', position: 1 },
|
||||
] as unknown as ThoughtItem[]
|
||||
const result = sortAgentSorts(items)
|
||||
expect(result).not.toBe(items)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addFileInfos', () => {
|
||||
it('returns null/undefined input as-is', () => {
|
||||
expect(addFileInfos(null as unknown as ThoughtItem[], [])).toBeNull()
|
||||
expect(addFileInfos(undefined as unknown as ThoughtItem[], [])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns items when messageFiles is null', () => {
|
||||
const items = [{ id: '1' }] as unknown as ThoughtItem[]
|
||||
expect(addFileInfos(items, null as unknown as FileEntity[])).toEqual(items)
|
||||
})
|
||||
|
||||
it('adds message_files by matching file IDs', () => {
|
||||
const file1 = { id: 'file-1', name: 'doc.pdf' } as FileEntity
|
||||
const file2 = { id: 'file-2', name: 'img.png' } as FileEntity
|
||||
const items = [
|
||||
{ id: '1', files: ['file-1', 'file-2'] },
|
||||
{ id: '2', files: [] },
|
||||
] as unknown as ThoughtItem[]
|
||||
|
||||
const result = addFileInfos(items, [file1, file2])
|
||||
expect((result[0] as ThoughtItem & { message_files: FileEntity[] }).message_files).toEqual([file1, file2])
|
||||
})
|
||||
|
||||
it('returns items without files unchanged', () => {
|
||||
const items = [
|
||||
{ id: '1' },
|
||||
{ id: '2', files: null },
|
||||
] as unknown as ThoughtItem[]
|
||||
const result = addFileInfos(items, [])
|
||||
expect(result[0]).toEqual(expect.objectContaining({ id: '1' }))
|
||||
})
|
||||
|
||||
it('does not mutate original items', () => {
|
||||
const file1 = { id: 'file-1', name: 'doc.pdf' } as FileEntity
|
||||
const items = [{ id: '1', files: ['file-1'] }] as unknown as ThoughtItem[]
|
||||
const result = addFileInfos(items, [file1])
|
||||
expect(result[0]).not.toBe(items[0])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,408 +0,0 @@
|
||||
import type { TriggerEventParameter } from '../../plugins/types'
|
||||
import type { ToolCredential, ToolParameter } from '../types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
addDefaultValue,
|
||||
generateAgentToolValue,
|
||||
generateFormValue,
|
||||
getConfiguredValue,
|
||||
getPlainValue,
|
||||
getStructureValue,
|
||||
toolCredentialToFormSchemas,
|
||||
toolParametersToFormSchemas,
|
||||
toType,
|
||||
triggerEventParametersToFormSchemas,
|
||||
} from './to-form-schema'
|
||||
|
||||
describe('to-form-schema utilities', () => {
|
||||
describe('toType', () => {
|
||||
it('converts "string" to "text-input"', () => {
|
||||
expect(toType('string')).toBe('text-input')
|
||||
})
|
||||
|
||||
it('converts "number" to "number-input"', () => {
|
||||
expect(toType('number')).toBe('number-input')
|
||||
})
|
||||
|
||||
it('converts "boolean" to "checkbox"', () => {
|
||||
expect(toType('boolean')).toBe('checkbox')
|
||||
})
|
||||
|
||||
it('returns the original type for unknown types', () => {
|
||||
expect(toType('select')).toBe('select')
|
||||
expect(toType('secret-input')).toBe('secret-input')
|
||||
expect(toType('file')).toBe('file')
|
||||
})
|
||||
})
|
||||
|
||||
describe('triggerEventParametersToFormSchemas', () => {
|
||||
it('returns empty array for null/undefined parameters', () => {
|
||||
expect(triggerEventParametersToFormSchemas(null as unknown as TriggerEventParameter[])).toEqual([])
|
||||
expect(triggerEventParametersToFormSchemas([])).toEqual([])
|
||||
})
|
||||
|
||||
it('maps parameters with type conversion and tooltip from description', () => {
|
||||
const params = [
|
||||
{
|
||||
name: 'query',
|
||||
type: 'string',
|
||||
description: { en_US: 'Search query', zh_Hans: '搜索查询' },
|
||||
label: { en_US: 'Query', zh_Hans: '查询' },
|
||||
required: true,
|
||||
form: 'llm',
|
||||
},
|
||||
] as unknown as TriggerEventParameter[]
|
||||
const result = triggerEventParametersToFormSchemas(params)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].type).toBe('text-input')
|
||||
expect(result[0]._type).toBe('string')
|
||||
expect(result[0].tooltip).toEqual({ en_US: 'Search query', zh_Hans: '搜索查询' })
|
||||
})
|
||||
|
||||
it('preserves all original fields via spread', () => {
|
||||
const params = [
|
||||
{
|
||||
name: 'count',
|
||||
type: 'number',
|
||||
description: { en_US: 'Count', zh_Hans: '数量' },
|
||||
label: { en_US: 'Count', zh_Hans: '数量' },
|
||||
required: false,
|
||||
form: 'form',
|
||||
},
|
||||
] as unknown as TriggerEventParameter[]
|
||||
const result = triggerEventParametersToFormSchemas(params)
|
||||
expect(result[0].name).toBe('count')
|
||||
expect(result[0].label).toEqual({ en_US: 'Count', zh_Hans: '数量' })
|
||||
expect(result[0].required).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toolParametersToFormSchemas', () => {
|
||||
it('returns empty array for null parameters', () => {
|
||||
expect(toolParametersToFormSchemas(null as unknown as ToolParameter[])).toEqual([])
|
||||
})
|
||||
|
||||
it('converts parameters with variable = name and type conversion', () => {
|
||||
const params: ToolParameter[] = [
|
||||
{
|
||||
name: 'input_text',
|
||||
label: { en_US: 'Input', zh_Hans: '输入' },
|
||||
human_description: { en_US: 'Enter text', zh_Hans: '输入文本' },
|
||||
type: 'string',
|
||||
form: 'llm',
|
||||
llm_description: 'The input text',
|
||||
required: true,
|
||||
multiple: false,
|
||||
default: 'hello',
|
||||
},
|
||||
]
|
||||
const result = toolParametersToFormSchemas(params)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].variable).toBe('input_text')
|
||||
expect(result[0].type).toBe('text-input')
|
||||
expect(result[0]._type).toBe('string')
|
||||
expect(result[0].show_on).toEqual([])
|
||||
expect(result[0].tooltip).toEqual({ en_US: 'Enter text', zh_Hans: '输入文本' })
|
||||
})
|
||||
|
||||
it('maps options with show_on = []', () => {
|
||||
const params: ToolParameter[] = [
|
||||
{
|
||||
name: 'mode',
|
||||
label: { en_US: 'Mode', zh_Hans: '模式' },
|
||||
human_description: { en_US: 'Select mode', zh_Hans: '选择模式' },
|
||||
type: 'select',
|
||||
form: 'form',
|
||||
llm_description: '',
|
||||
required: false,
|
||||
multiple: false,
|
||||
default: 'fast',
|
||||
options: [
|
||||
{ label: { en_US: 'Fast', zh_Hans: '快速' }, value: 'fast' },
|
||||
{ label: { en_US: 'Accurate', zh_Hans: '精确' }, value: 'accurate' },
|
||||
],
|
||||
},
|
||||
]
|
||||
const result = toolParametersToFormSchemas(params)
|
||||
expect(result[0].options).toHaveLength(2)
|
||||
expect(result[0].options![0].show_on).toEqual([])
|
||||
expect(result[0].options![1].show_on).toEqual([])
|
||||
})
|
||||
|
||||
it('handles parameters without options', () => {
|
||||
const params: ToolParameter[] = [
|
||||
{
|
||||
name: 'flag',
|
||||
label: { en_US: 'Flag', zh_Hans: '标记' },
|
||||
human_description: { en_US: 'Enable', zh_Hans: '启用' },
|
||||
type: 'boolean',
|
||||
form: 'form',
|
||||
llm_description: '',
|
||||
required: false,
|
||||
multiple: false,
|
||||
default: 'false',
|
||||
},
|
||||
]
|
||||
const result = toolParametersToFormSchemas(params)
|
||||
expect(result[0].options).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('toolCredentialToFormSchemas', () => {
|
||||
it('returns empty array for null parameters', () => {
|
||||
expect(toolCredentialToFormSchemas(null as unknown as ToolCredential[])).toEqual([])
|
||||
})
|
||||
|
||||
it('converts credentials with variable = name and tooltip from help', () => {
|
||||
const creds: ToolCredential[] = [
|
||||
{
|
||||
name: 'api_key',
|
||||
label: { en_US: 'API Key', zh_Hans: 'API 密钥' },
|
||||
help: { en_US: 'Enter your API key', zh_Hans: '输入你的 API 密钥' },
|
||||
placeholder: { en_US: 'sk-xxx', zh_Hans: 'sk-xxx' },
|
||||
type: 'secret-input',
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
]
|
||||
const result = toolCredentialToFormSchemas(creds)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].variable).toBe('api_key')
|
||||
expect(result[0].type).toBe('secret-input')
|
||||
expect(result[0].tooltip).toEqual({ en_US: 'Enter your API key', zh_Hans: '输入你的 API 密钥' })
|
||||
expect(result[0].show_on).toEqual([])
|
||||
})
|
||||
|
||||
it('handles null help field → tooltip becomes undefined', () => {
|
||||
const creds: ToolCredential[] = [
|
||||
{
|
||||
name: 'token',
|
||||
label: { en_US: 'Token', zh_Hans: '令牌' },
|
||||
help: null,
|
||||
placeholder: { en_US: '', zh_Hans: '' },
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
]
|
||||
const result = toolCredentialToFormSchemas(creds)
|
||||
expect(result[0].tooltip).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps credential options with show_on = []', () => {
|
||||
const creds: ToolCredential[] = [
|
||||
{
|
||||
name: 'auth_method',
|
||||
label: { en_US: 'Auth', zh_Hans: '认证' },
|
||||
help: null,
|
||||
placeholder: { en_US: '', zh_Hans: '' },
|
||||
type: 'select',
|
||||
required: true,
|
||||
default: 'bearer',
|
||||
options: [
|
||||
{ label: { en_US: 'Bearer', zh_Hans: 'Bearer' }, value: 'bearer' },
|
||||
{ label: { en_US: 'Basic', zh_Hans: 'Basic' }, value: 'basic' },
|
||||
],
|
||||
},
|
||||
]
|
||||
const result = toolCredentialToFormSchemas(creds)
|
||||
expect(result[0].options).toHaveLength(2)
|
||||
result[0].options!.forEach(opt => expect(opt.show_on).toEqual([]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('addDefaultValue', () => {
|
||||
it('fills in default when value is empty/null/undefined', () => {
|
||||
const schemas = [
|
||||
{ variable: 'name', type: 'text-input', default: 'default-name' },
|
||||
{ variable: 'count', type: 'number-input', default: 10 },
|
||||
]
|
||||
const result = addDefaultValue({}, schemas)
|
||||
expect(result.name).toBe('default-name')
|
||||
expect(result.count).toBe(10)
|
||||
})
|
||||
|
||||
it('does not override existing values', () => {
|
||||
const schemas = [{ variable: 'name', type: 'text-input', default: 'default' }]
|
||||
const result = addDefaultValue({ name: 'existing' }, schemas)
|
||||
expect(result.name).toBe('existing')
|
||||
})
|
||||
|
||||
it('fills default for empty string value', () => {
|
||||
const schemas = [{ variable: 'name', type: 'text-input', default: 'default' }]
|
||||
const result = addDefaultValue({ name: '' }, schemas)
|
||||
expect(result.name).toBe('default')
|
||||
})
|
||||
|
||||
it('converts string boolean values to proper boolean type', () => {
|
||||
const schemas = [{ variable: 'flag', type: 'boolean' }]
|
||||
expect(addDefaultValue({ flag: 'true' }, schemas).flag).toBe(true)
|
||||
expect(addDefaultValue({ flag: 'false' }, schemas).flag).toBe(false)
|
||||
expect(addDefaultValue({ flag: '1' }, schemas).flag).toBe(true)
|
||||
expect(addDefaultValue({ flag: 'True' }, schemas).flag).toBe(true)
|
||||
expect(addDefaultValue({ flag: '0' }, schemas).flag).toBe(false)
|
||||
})
|
||||
|
||||
it('converts number boolean values to proper boolean type', () => {
|
||||
const schemas = [{ variable: 'flag', type: 'boolean' }]
|
||||
expect(addDefaultValue({ flag: 1 }, schemas).flag).toBe(true)
|
||||
expect(addDefaultValue({ flag: 0 }, schemas).flag).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves actual boolean values', () => {
|
||||
const schemas = [{ variable: 'flag', type: 'boolean' }]
|
||||
expect(addDefaultValue({ flag: true }, schemas).flag).toBe(true)
|
||||
expect(addDefaultValue({ flag: false }, schemas).flag).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateFormValue', () => {
|
||||
it('generates constant-type value wrapper for defaults', () => {
|
||||
const schemas = [{ variable: 'name', type: 'text-input', default: 'hello' }]
|
||||
const result = generateFormValue({}, schemas)
|
||||
expect(result.name).toBeDefined()
|
||||
const wrapper = result.name as { value: { type: string, value: unknown } }
|
||||
// correctInitialData sets type to 'mixed' for text-input but preserves default value
|
||||
expect(wrapper.value.type).toBe('mixed')
|
||||
expect(wrapper.value.value).toBe('hello')
|
||||
})
|
||||
|
||||
it('skips values that already exist', () => {
|
||||
const schemas = [{ variable: 'name', type: 'text-input', default: 'hello' }]
|
||||
const result = generateFormValue({ name: 'existing' }, schemas)
|
||||
expect(result.name).toBeUndefined()
|
||||
})
|
||||
|
||||
it('generates auto:1 for reasoning mode', () => {
|
||||
const schemas = [{ variable: 'name', type: 'text-input', default: 'hello' }]
|
||||
const result = generateFormValue({}, schemas, true)
|
||||
expect(result.name).toEqual({ auto: 1, value: null })
|
||||
})
|
||||
|
||||
it('handles boolean default conversion in non-reasoning mode', () => {
|
||||
const schemas = [{ variable: 'flag', type: 'boolean', default: 'true' }]
|
||||
const result = generateFormValue({}, schemas)
|
||||
const wrapper = result.flag as { value: { type: string, value: unknown } }
|
||||
expect(wrapper.value.value).toBe(true)
|
||||
})
|
||||
|
||||
it('handles number-input default conversion', () => {
|
||||
const schemas = [{ variable: 'count', type: 'number-input', default: '42' }]
|
||||
const result = generateFormValue({}, schemas)
|
||||
const wrapper = result.count as { value: { type: string, value: unknown } }
|
||||
expect(wrapper.value.value).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPlainValue', () => {
|
||||
it('unwraps { value: ... } structure to plain values', () => {
|
||||
const input = {
|
||||
a: { value: { type: 'constant', val: 1 } },
|
||||
b: { value: { type: 'mixed', val: 'text' } },
|
||||
}
|
||||
const result = getPlainValue(input)
|
||||
expect(result.a).toEqual({ type: 'constant', val: 1 })
|
||||
expect(result.b).toEqual({ type: 'mixed', val: 'text' })
|
||||
})
|
||||
|
||||
it('returns empty object for empty input', () => {
|
||||
expect(getPlainValue({})).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStructureValue', () => {
|
||||
it('wraps plain values into { value: ... } structure', () => {
|
||||
const input = { a: 'hello', b: 42 }
|
||||
const result = getStructureValue(input)
|
||||
expect(result).toEqual({ a: { value: 'hello' }, b: { value: 42 } })
|
||||
})
|
||||
|
||||
it('returns empty object for empty input', () => {
|
||||
expect(getStructureValue({})).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getConfiguredValue', () => {
|
||||
it('fills defaults with correctInitialData for missing values', () => {
|
||||
const schemas = [{ variable: 'name', type: 'text-input', default: 'hello' }]
|
||||
const result = getConfiguredValue({}, schemas)
|
||||
const val = result.name as { type: string, value: unknown }
|
||||
expect(val.type).toBe('mixed')
|
||||
})
|
||||
|
||||
it('does not override existing values', () => {
|
||||
const schemas = [{ variable: 'name', type: 'text-input', default: 'hello' }]
|
||||
const result = getConfiguredValue({ name: 'existing' }, schemas)
|
||||
expect(result.name).toBe('existing')
|
||||
})
|
||||
|
||||
it('escapes newlines in string defaults', () => {
|
||||
const schemas = [{ variable: 'prompt', type: 'text-input', default: 'line1\nline2' }]
|
||||
const result = getConfiguredValue({}, schemas)
|
||||
const val = result.prompt as { type: string, value: unknown }
|
||||
expect(val.type).toBe('mixed')
|
||||
expect(val.value).toBe('line1\\nline2')
|
||||
})
|
||||
|
||||
it('handles boolean default conversion', () => {
|
||||
const schemas = [{ variable: 'flag', type: 'boolean', default: 'true' }]
|
||||
const result = getConfiguredValue({}, schemas)
|
||||
const val = result.flag as { type: string, value: unknown }
|
||||
expect(val.value).toBe(true)
|
||||
})
|
||||
|
||||
it('handles app-selector type', () => {
|
||||
const schemas = [{ variable: 'app', type: 'app-selector', default: 'app-id-123' }]
|
||||
const result = getConfiguredValue({}, schemas)
|
||||
const val = result.app as { type: string, value: unknown }
|
||||
expect(val.value).toBe('app-id-123')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateAgentToolValue', () => {
|
||||
it('generates constant-type values in non-reasoning mode', () => {
|
||||
const schemas = [{ variable: 'name', type: 'text-input', default: 'hello' }]
|
||||
const value = { name: { value: 'world' } }
|
||||
const result = generateAgentToolValue(value, schemas)
|
||||
expect(result.name.value).toBeDefined()
|
||||
expect(result.name.value!.type).toBe('mixed')
|
||||
})
|
||||
|
||||
it('generates auto:1 for auto-mode parameters in reasoning mode', () => {
|
||||
const schemas = [{ variable: 'name', type: 'text-input' }]
|
||||
const value = { name: { auto: 1 as const, value: undefined } }
|
||||
const result = generateAgentToolValue(value, schemas, true)
|
||||
expect(result.name).toEqual({ auto: 1, value: null })
|
||||
})
|
||||
|
||||
it('generates auto:0 with value for manual parameters in reasoning mode', () => {
|
||||
const schemas = [{ variable: 'name', type: 'text-input' }]
|
||||
const value = { name: { auto: 0 as const, value: { type: 'constant', value: 'manual' } } }
|
||||
const result = generateAgentToolValue(value, schemas, true)
|
||||
expect(result.name.auto).toBe(0)
|
||||
expect(result.name.value).toEqual({ type: 'constant', value: 'manual' })
|
||||
})
|
||||
|
||||
it('handles undefined value in reasoning mode with fallback', () => {
|
||||
const schemas = [{ variable: 'name', type: 'select' }]
|
||||
const value = { name: { auto: 0 as const, value: undefined } }
|
||||
const result = generateAgentToolValue(value, schemas, true)
|
||||
expect(result.name.auto).toBe(0)
|
||||
expect(result.name.value).toEqual({ type: 'constant', value: null })
|
||||
})
|
||||
|
||||
it('applies correctInitialData for text-input type', () => {
|
||||
const schemas = [{ variable: 'query', type: 'text-input' }]
|
||||
const value = { query: { value: 'search term' } }
|
||||
const result = generateAgentToolValue(value, schemas)
|
||||
expect(result.query.value!.type).toBe('mixed')
|
||||
})
|
||||
|
||||
it('applies correctInitialData for boolean type conversion', () => {
|
||||
const schemas = [{ variable: 'flag', type: 'boolean' }]
|
||||
const value = { flag: { value: 'true' } }
|
||||
const result = generateAgentToolValue(value, schemas)
|
||||
expect(result.flag.value!.value).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -13,54 +13,6 @@ describe('buildWorkflowOutputParameters', () => {
|
||||
expect(result).toBe(params)
|
||||
})
|
||||
|
||||
it('fills missing output description and type from schema when array input exists', () => {
|
||||
const params: WorkflowToolProviderOutputParameter[] = [
|
||||
{ name: 'answer', description: '', type: undefined },
|
||||
{ name: 'files', description: 'keep this description', type: VarType.arrayFile },
|
||||
]
|
||||
const schema: WorkflowToolProviderOutputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
answer: {
|
||||
type: VarType.string,
|
||||
description: 'Generated answer',
|
||||
},
|
||||
files: {
|
||||
type: VarType.arrayFile,
|
||||
description: 'Schema files description',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = buildWorkflowOutputParameters(params, schema)
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'answer', description: 'Generated answer', type: VarType.string },
|
||||
{ name: 'files', description: 'keep this description', type: VarType.arrayFile },
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to empty description when both payload and schema descriptions are missing', () => {
|
||||
const params: WorkflowToolProviderOutputParameter[] = [
|
||||
{ name: 'missing_desc', description: '', type: undefined },
|
||||
]
|
||||
const schema: WorkflowToolProviderOutputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
other_field: {
|
||||
type: VarType.string,
|
||||
description: 'Other',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = buildWorkflowOutputParameters(params, schema)
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'missing_desc', description: '', type: undefined },
|
||||
])
|
||||
})
|
||||
|
||||
it('derives parameters from schema when explicit array missing', () => {
|
||||
const schema: WorkflowToolProviderOutputSchema = {
|
||||
type: 'object',
|
||||
@@ -92,56 +44,4 @@ describe('buildWorkflowOutputParameters', () => {
|
||||
it('returns empty array when no source information is provided', () => {
|
||||
expect(buildWorkflowOutputParameters(null, null)).toEqual([])
|
||||
})
|
||||
|
||||
it('derives parameters from schema when explicit array is empty', () => {
|
||||
const schema: WorkflowToolProviderOutputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
output_text: {
|
||||
type: VarType.string,
|
||||
description: 'Output text',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = buildWorkflowOutputParameters([], schema)
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'output_text', description: 'Output text', type: VarType.string },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns undefined type when schema output type is missing', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
answer: {
|
||||
description: 'Answer without type',
|
||||
},
|
||||
},
|
||||
} as unknown as WorkflowToolProviderOutputSchema
|
||||
|
||||
const result = buildWorkflowOutputParameters(undefined, schema)
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'answer', description: 'Answer without type', type: undefined },
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to empty description when schema-derived description is missing', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
answer: {
|
||||
type: VarType.string,
|
||||
},
|
||||
},
|
||||
} as unknown as WorkflowToolProviderOutputSchema
|
||||
|
||||
const result = buildWorkflowOutputParameters(undefined, schema)
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'answer', description: '', type: VarType.string },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,28 +14,15 @@ export const buildWorkflowOutputParameters = (
|
||||
outputParameters: WorkflowToolProviderOutputParameter[] | null | undefined,
|
||||
outputSchema?: WorkflowToolProviderOutputSchema | null,
|
||||
): WorkflowToolProviderOutputParameter[] => {
|
||||
const schemaProperties = outputSchema?.properties
|
||||
if (Array.isArray(outputParameters))
|
||||
return outputParameters
|
||||
|
||||
if (Array.isArray(outputParameters) && outputParameters.length > 0) {
|
||||
if (!schemaProperties)
|
||||
return outputParameters
|
||||
|
||||
return outputParameters.map((item) => {
|
||||
const schema = schemaProperties[item.name]
|
||||
return {
|
||||
...item,
|
||||
description: item.description || schema?.description || '',
|
||||
type: normalizeVarType(item.type || schema?.type),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!schemaProperties)
|
||||
if (!outputSchema?.properties)
|
||||
return []
|
||||
|
||||
return Object.entries(schemaProperties).map(([name, schema]) => ({
|
||||
return Object.entries(outputSchema.properties).map(([name, schema]) => ({
|
||||
name,
|
||||
description: schema.description || '',
|
||||
description: schema.description,
|
||||
type: normalizeVarType(schema.type),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
@import "preflight.css";
|
||||
|
||||
|
||||
@import '../../themes/light.css';
|
||||
@import '../../themes/dark.css';
|
||||
@import "../../themes/manual-light.css";
|
||||
@import "../../themes/manual-dark.css";
|
||||
@import "./monaco-sticky-fix.css";
|
||||
|
||||
@import "../components/base/action-button/index.css";
|
||||
@import "../components/base/badge/index.css";
|
||||
@import "../components/base/button/index.css";
|
||||
@import "../components/base/action-button/index.css";
|
||||
@import "../components/base/modal/index.css";
|
||||
@import "../components/base/premium-badge/index.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
|
||||
@@ -3002,11 +3002,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx": {
|
||||
"test/prefer-hooks-in-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/billing/pricing/plans/cloud-plan-item/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 6
|
||||
@@ -5447,7 +5442,7 @@
|
||||
},
|
||||
"app/components/plugins/reference-setting-modal/index.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 6
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"app/components/plugins/reference-setting-modal/index.tsx": {
|
||||
|
||||
@@ -7,10 +7,6 @@ import sonar from 'eslint-plugin-sonarjs'
|
||||
import storybook from 'eslint-plugin-storybook'
|
||||
import dify from './eslint-rules/index.js'
|
||||
|
||||
// Enable Tailwind CSS IntelliSense mode for ESLint runs
|
||||
// See: tailwind-css-plugin.ts
|
||||
process.env.TAILWIND_MODE ??= 'ESLINT'
|
||||
|
||||
export default antfu(
|
||||
{
|
||||
react: {
|
||||
|
||||
@@ -182,11 +182,6 @@ const config = {
|
||||
}),
|
||||
cssAsPlugin([
|
||||
path.resolve(_dirname, './app/styles/globals.css'),
|
||||
path.resolve(_dirname, './app/components/base/action-button/index.css'),
|
||||
path.resolve(_dirname, './app/components/base/badge/index.css'),
|
||||
path.resolve(_dirname, './app/components/base/button/index.css'),
|
||||
path.resolve(_dirname, './app/components/base/modal/index.css'),
|
||||
path.resolve(_dirname, './app/components/base/premium-badge/index.css'),
|
||||
]),
|
||||
],
|
||||
// https://github.com/tailwindlabs/tailwindcss/discussions/5969
|
||||
|
||||
@@ -7,11 +7,9 @@ import { parse } from 'postcss'
|
||||
import { objectify } from 'postcss-js'
|
||||
|
||||
export const cssAsPlugin: (cssPath: string[]) => PluginCreator = (cssPath: string[]) => {
|
||||
const isTailwindCSSIntelliSenseMode = 'TAILWIND_MODE' in process.env
|
||||
if (!isTailwindCSSIntelliSenseMode) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
return ({ addUtilities, addComponents, addBase }) => {
|
||||
const jssList = cssPath.map(p => objectify(parse(readFileSync(p, 'utf8'))))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user